Modular Monolith: Why Not Microservices
How Vivolar uses a package-by-domain structure with ArchUnit enforcement to get the benefits of modularity without the operational overhead of microservices.
The Decision
When starting Vivolar, the temptation to build microservices was real. Microservices are the default conversation in most architecture discussions. But for a household management app built by a small team, the operational overhead of distributed systems would have been a net negative.
Instead, I chose a modular monolith — a single deployable unit with strict internal module boundaries enforced at the code level.
Package-by-Domain Structure
The backend is organized into domain-aligned packages following Strategic DDD principles:
| Package | Purpose |
|---|---|
common | Shared kernel: security, config, exceptions, utilities |
household | Household & Auth: users, roles, invite codes, OAuth2 |
inventory | Items, HouseholdProducts, locations, expiry, lifecycle |
scanning | Photo scan, voice scan, product resolution, LLM vision |
category | 2-level hierarchy (Category → SubCategory), LLM matching |
shopping | Shopping lists, saved templates, PDF export |
assistant | AI chat assistant, context building |
admin | Admin panel, audit logs, AI usage stats, rate limiting |
feedback | App ratings, feature ideas, votes |
barcode | Barcode lookup (Open Food Facts, Cosmos) |
Each package owns its models, repositories, services, controllers, and DTOs. Cross-package communication happens through events, not direct service calls.
ArchUnit Enforcement
Module boundaries aren’t just a convention — they’re enforced by ModuleBoundaryTest, an ArchUnit test that runs on every build. The rules are:
- Cross-module communication via events — never direct service-to-service calls across bounded contexts
- Read-only repository/model access across modules is acceptable — same database, no internal API overhead needed
common/must not depend on domain services — only cross-cutting infrastructure- Event classes are data carriers — they must not depend on services
If someone adds a direct dependency between scanning and shopping services, the build breaks. This keeps coupling honest.
Why This Works
The modular monolith gives us:
- Simple deployment — one JAR, one container, one Railway service
- Simple debugging — stack traces stay in one process, no distributed tracing needed
- Module isolation — bounded contexts are enforced, so extracting a microservice later is straightforward
- Fast iteration — no service mesh, no API gateway, no contract testing between services
The key insight: modularity is an architectural property, not a deployment topology. You can have well-modularized code in a monolith and a tangled mess across microservices.
Trade-offs
- Scaling is all-or-nothing — can’t scale the scanning module independently. For Vivolar’s traffic, this is fine.
- Shared database — all modules share one PostgreSQL instance. Cross-module read access is allowed, but write operations go through events.
- Single failure domain — a bug in any module can take down the whole app. Mitigated by thorough testing.
When to Extract
The modular monolith is designed for eventual extraction if needed. Clear event boundaries mean a module can become its own service by:
- Publishing events to a message broker instead of in-process
- Moving the package to its own deployable
- Adding an API layer for cross-service reads
But until there’s a concrete reason (independent scaling, different deployment cadence, team ownership), the monolith stays monolithic.