MSA To Monolith Retrospective
On April 4, 2026, Lumie decided to reverse an 18-service backend split and consolidate the backend into a modular monolith. This was not an outage response. It was an architecture rollback after the team concluded that the system was paying microservice costs without receiving microservice benefits.
The current repository is the result of that decision: a single Spring Boot backend with explicit module boundaries under modules/*, shared contracts under libs/internal-api, and one deployable runtime.
Context
The original backend was split across services such as tenant, auth, academy, exam, billing, and report. Most service-to-service calls used gRPC, while only a small subset of work needed asynchronous messaging.
The split did not create the usual microservice independence:
| Expected microservice benefit | Lumie's actual state |
|---|---|
| Independent deployment | protocol and route changes still required coordinated releases |
| Independent scaling | no domain had traffic skew large enough to justify separate scale units |
| Data ownership | services shared the same Postgres instance and did not have a real database boundary |
| Team autonomy | the team size did not map to 18 independently owned services |
At the same time, the system paid the normal distributed-system tax:
| Cost | Concrete shape |
|---|---|
| Network calls | in-process domain calls became gRPC calls and proto maintenance |
| Operations | per-service ArgoCD apps, Helm values, namespaces, services, and routes |
| Duplication | repeated application classes, JPA configuration, security configuration, exception handlers, and Docker/actuator setup |
| Local development | running realistic flows required many services or a cluster-style local environment |
| Testing | integration tests had to mock or start other services, and contract changes crossed repo/process boundaries |
Decision Trigger
The trigger was a routine architecture question: whether one more domain should become a separate service. Writing down the cost/benefit table made the larger problem visible. If a new service could not justify one more ArgoCD app, one more route, one more build, and one more cross-process contract, the existing 18 services had to justify those costs too.
They did not. The deciding evidence was that all four benefits were effectively off while all major costs were active.
Why The Previous Approach Failed
The core mistake was treating service count as architecture progress. The code was distributed, but the data and ownership boundaries were not.
The most important signal was the shared database. Once 18 services write to the same database without a hard data boundary, the runtime split becomes a distributed monolith. gRPC and deployment separation then add failure modes without removing coupling.
The second signal was repeated boilerplate. Multiple copies of persistence configuration, security configuration, exception handling, application entrypoints, and deployment templates did not represent useful abstraction. They were evidence that the boundary itself was wrong.
Resolution
The recovery path was a modular monolith, not an unstructured monolith.
The backend converged on these rules:
- one application process and one deployable artifact;
- module-local
domain,application, andadapterlayers; - cross-module calls through
libs/internal-apicontracts rather than HTTP or gRPC; - asynchronous infrastructure retained only for use cases that actually need queue semantics;
- data consistency handled with ordinary local transactions when the use case does not require distribution.
The current repo also encodes the lesson in rules: new code must not call another monolith module through HTTP or gRPC, and direct imports of another module's domain entities are blocked in favor of internal API contracts.
Current State
The present codebase no longer contains the 18-service topology. This page is therefore a retrospective decision record, not a live topology guide.
For live behavior, use:
- backend module reference pages for current module responsibilities;
lumie-backend/README.mdfor the monolith shape;libs/internal-apifor allowed cross-module contracts.
What Went Well
- The decision was made from explicit cost/benefit evidence rather than preference.
- The team separated "microservices are sometimes valid" from "this system has the conditions that justify them."
- The rollback preserved modular boundaries, which keeps future extraction possible if evidence changes.
- RabbitMQ was not removed simply because microservices were removed; asynchronous processing stayed where the use case justified it.
What Went Poorly
- The original split happened before team, data, and scaling boundaries justified it.
- Shared Postgres access meant the service split never had a strong data-ownership foundation.
- The local developer experience became heavy enough that the architecture cost was visible every day.
- "Independent deployment" existed more as a mental model than an operational fact.
Revisit Triggers
Microservices should only be reconsidered when at least one of these becomes true:
| Trigger | Evidence required |
|---|---|
| Independent scaling | a domain has sustained, asymmetric load that cannot be handled cleanly inside the monolith |
| Independent ownership | a team owns a domain end to end and needs separate release cadence |
| Data autonomy | the domain can own its data boundary without shared-table coupling |
| External contract stability | extraction would not force lockstep changes across internal callers |
Until then, the safer default is to improve module boundaries inside the monolith.
Lessons
- A microservice boundary without a data boundary is usually a distributed monolith boundary.
- Boilerplate copied across services is a warning sign, not proof of modularity.
- Local development cost is architecture feedback; if the normal path needs cluster-level machinery too early, the split is suspect.
- Rollbacks can be architecture progress when they remove accidental complexity and preserve the right internal boundaries.