← Blog

Why this architecture — deliberate choices behind Visiban's stack

Every stack is a set of bets. Here are the bets Visiban made and why.

The guiding principle throughout was: the simplest thing that could work correctly and securely. No microservices, no distributed systems complexity, no event sourcing. A well-built monolith is easier to operate, easier to debug, and easier to contribute to than a distributed system that happens to run on one server.

Django + Django REST Framework

The ORM eliminates raw SQL by default. That isn’t just a convenience — it’s a security boundary. SQL injection is the most common and most damaging class of web vulnerability. An ORM that parameterizes queries automatically means that class of bug requires deliberate effort to introduce.

DRF’s serializer layer puts validation at the right boundary: between the network and the application. Serializer-level validation means views stay thin and business logic doesn’t leak into request parsing. The field-level validation is declarative and testable in isolation.

Practically: Django has a large talent pool. If Visiban grows a team or a contributor community, the framework knowledge is widely available.

Django Channels + ASGI

Real-time board sync (every card drag, every column update, every comment) needs WebSockets. The alternative to Channels was a separate WebSocket service — a separate process to deploy, monitor, and debug. Channels runs in-process under Daphne (an ASGI server) and replaces Gunicorn entirely. One less service in the production stack.

The subtlety that matters: every board mutation that broadcasts to connected clients uses transaction.on_commit(). Without that, a WebSocket event can reach a connected client before the database transaction that caused it has committed — resulting in a client seeing a state that then disappears on next fetch. on_commit() ensures the broadcast fires only after the write is durable.

PostgreSQL 17

The card movement audit trail is the core feature. Every drag logs a CardMovement row — from column, to column, from swimlane, to swimlane, who moved it, when. That write happens atomically with the card update in the same transaction. PostgreSQL’s ACID guarantees make “the audit trail is accurate” a property of the database, not a hope expressed in application code.

JSONB for activity payloads (comments, mentions, system events) gives schema flexibility without a separate document store. A functional index on LOWER(username) enforces case-insensitive uniqueness at the database layer — not just in the application — which means it holds across migrations, admin actions, and direct SQL.

React 19 + TypeScript + Vite

TypeScript catches API/frontend contract drift at compile time. When a backend serializer adds a field, the TypeScript interface enforces that the frontend consumes it correctly. When a field is removed, the build fails rather than producing a silent runtime error. For a project where the API surface and the frontend evolve together, this feedback loop is worth the overhead.

Vite’s dev server starts in under a second and hot-reloads component changes without a full refresh. That keeps the iteration cycle tight during frontend development.

React 19’s concurrent rendering keeps the board responsive during large drag operations. A board with 50 cards across 8 columns and 10 swimlanes is 400 draggable elements. Blocking the main thread during a reorder produces visible jank. Concurrent rendering defers lower-priority updates so the drag animation stays smooth.

@dnd-kit

react-beautiful-dnd was deprecated. The successor landscape had a few options; @dnd-kit won on three criteria.

First, it’s accessible: drag-and-drop with keyboard navigation is a first-class concern, not an afterthought. Second, it’s headless — no imposed styles, which matters when you have a design system. Third, it was designed for multi-container grid layouts. Visiban’s board is a 2D grid (columns × swimlane rows) and every cell is a droppable zone. Libraries that assume a 1D list make that model awkward. dnd-kit handles it naturally.

The pattern on drag-end is optimistic update then API call with rollback on failure. The user sees the card in its new position immediately; if the backend rejects the move (invalid column, WIP limit exceeded) the card animates back. This keeps the UI feeling fast without lying about the final state.

Tiptap (ProseMirror)

Card descriptions and comments need rich text. Tiptap is a headless ProseMirror wrapper that makes extensions — including @mention — first-class primitives rather than special cases bolted on.

Storing Markdown server-side (not HTML) keeps the content portable. If the rendering engine changes, the stored content doesn’t need migration. If a future API client wants to display content in a non-browser environment, Markdown is universally parseable.

Headless means Tiptap applies no styles of its own. The prose typography comes from Tailwind Typography, which means it matches the rest of the design system.

django-allauth headless

Authentication covers five providers: email/password, Google, GitHub, GitLab, and generic OIDC (Keycloak, Okta, Authentik, Dex, or any compliant IdP). django-allauth supports all of them from a single dependency using the same underlying session/token model. The headless mode exposes a JSON API that the React SPA consumes directly — no server-rendered login pages.

The alternative was separate libraries for OAuth and OIDC, with custom session handling to bridge them. That’s a larger attack surface and more code to audit. allauth’s security track record is well-established and its OAuth flow handling (PKCE, state parameter validation, token storage) is not something worth reimplementing.

Generic OIDC is OSS core — not enterprise. If your team uses Keycloak internally, you should be able to connect it without paying for an enterprise tier. SAML 2.0 and SCIM directory sync are enterprise features; OIDC is not.

Docker Compose + Helm

Self-hosting is a first-class deployment target, not an afterthought. The docker-compose.prod.yml enforces credential configuration — it will not start with placeholder secrets. The init-prod.sh script handles first-boot TLS setup (Let’s Encrypt, self-signed, or none for air-gapped networks) and generates the one-time admin password.

The Helm chart for Kubernetes uses existingSecret passthrough for every credential, which means it integrates cleanly with Vault, Sealed Secrets, and External Secrets Operator without requiring changes to the chart. PodDisruptionBudgets, pod anti-affinity, and NetworkPolicy templates are included — not because a small self-hosted instance needs them, but because the chart should be correct out of the box when someone deploys it into a production Kubernetes cluster.

The CI pipeline

The pipeline is part of the architecture. It runs on every merge request and enforces:

  • 90% backend test coverage — not a target, a gate

  • Ruff for Python linting, ESLint with a 15-warning maximum for TypeScript

  • GPL license blocking — no GPL dependencies can enter the dependency graph; checked automatically via pip-licenses and license-checker

  • Bandit + Semgrep + SonarQube SAST — static analysis for security issues before merge

  • migration-check — verifies that every model change has a corresponding migration and flags destructive operations (NOT NULL without default, column drops in the same migration as ORM removal)

  • Claude Code reviewer stage — a dedicated CI job that runs the Claude Code agent as a code reviewer on every MR, applying the same security, RBAC, performance, and broadcast-wiring checks that were applied during development

The last item deserves its own post. Running an AI code reviewer in CI — one that understands the project’s specific patterns and constraints — has been one of the more interesting structural decisions in the project.