A monolith that doesn't rot
Architected a multi-tenant SaaS as ~80 bounded-context modules with per-domain async queue isolation, running across ~45 pods on Kubernetes — without the operational tax of premature microservices.
- ~80 bounded contexts · ~370 REST routes
- 30 per-domain Celery queues · ~45 pods
- Carried ~20× revenue growth
Context
Dobare had to grow on two axes at once: more product surface, and more engineers shipping it. The default failure mode at that stage is to reach for microservices — and inherit distributed transactions, network failure between every domain, and a deployment pipeline per service — long before the team is sized to operate them. I wanted the modularity benefits of services without paying the distributed-systems tax prematurely.
Constraints
- One small team. A handful of engineers can’t operate a sprawl of independently deployed services without drowning in ops overhead.
- Multi-tenant from day one. Tenant isolation had to be a property of the architecture, not bolted on later.
- Brownfield, not greenfield. This grew out of the same codebase that had just been decomposed during the database migration — the boundaries had to honor that work.
Approach
I architected the platform as a modular monolith: roughly 80 bounded-context modules, each owning its data and domain logic behind an explicit interface, deployed as a single artifact but internally partitioned. Concretely that’s around 370 REST routes and 360+ migrations organized along domain lines rather than sprawled across one flat app.
The part that makes a monolith actually scale is the asynchronous boundary. Each domain got its own isolated Celery queue — 30 of them — so a slow or backed-up consumer in one domain (say, a bulk campaign send) could never starve unrelated work like points accrual. On Kubernetes that maps to ~45 pods with per-domain worker scaling, so the domains that need throughput get it independently.
┌──────── one deployable ─────────┐
│ [loyalty▸q] [payments▸q] │
│ [messaging▸q] [crm▸q] … ~80 │
│ bounded contexts · isolated q │
└─────────────────────────────────┘
Decision — modular monolith over microservices. One deployable keeps local function calls cheap and transactions simple, while module boundaries still enforce ownership and prevent the big ball of mud. The tradeoff I accepted: discipline has to be enforced in code review, not by the network. That’s a cheaper tax than distributed tracing and saga orchestration for a team this size.
Decision — queue-per-domain, not a shared worker pool. A single pool is simpler until one domain’s burst blocks everyone. Dedicated queues cost more configuration but turn cross-domain interference from a recurring incident into a non-event.
Outcome
The architecture kept deploys simple and the codebase navigable while the platform carried roughly 20× revenue growth and a growing team. Bounded contexts gave new engineers a small, comprehensible surface to own, and the queue isolation meant load in one domain stayed in that domain.
What I’d revisit
A modular monolith lives or dies by its boundaries, and boundaries erode under deadline pressure. I’d introduce automated boundary checks — import-linting that fails the build when one module reaches into another’s internals — earlier. Relying on review alone works until the team grows faster than the reviewers can keep up.