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.

architecture spring-boot ddd archunit

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:

PackagePurpose
commonShared kernel: security, config, exceptions, utilities
householdHousehold & Auth: users, roles, invite codes, OAuth2
inventoryItems, HouseholdProducts, locations, expiry, lifecycle
scanningPhoto scan, voice scan, product resolution, LLM vision
category2-level hierarchy (Category → SubCategory), LLM matching
shoppingShopping lists, saved templates, PDF export
assistantAI chat assistant, context building
adminAdmin panel, audit logs, AI usage stats, rate limiting
feedbackApp ratings, feature ideas, votes
barcodeBarcode 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:

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:

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

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:

  1. Publishing events to a message broker instead of in-process
  2. Moving the package to its own deployable
  3. 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.