Skip to main content

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 benefitLumie's actual state
Independent deploymentprotocol and route changes still required coordinated releases
Independent scalingno domain had traffic skew large enough to justify separate scale units
Data ownershipservices shared the same Postgres instance and did not have a real database boundary
Team autonomythe team size did not map to 18 independently owned services

At the same time, the system paid the normal distributed-system tax:

CostConcrete shape
Network callsin-process domain calls became gRPC calls and proto maintenance
Operationsper-service ArgoCD apps, Helm values, namespaces, services, and routes
Duplicationrepeated application classes, JPA configuration, security configuration, exception handlers, and Docker/actuator setup
Local developmentrunning realistic flows required many services or a cluster-style local environment
Testingintegration 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, and adapter layers;
  • cross-module calls through libs/internal-api contracts 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.md for the monolith shape;
  • libs/internal-api for 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:

TriggerEvidence required
Independent scalinga domain has sustained, asymmetric load that cannot be handled cleanly inside the monolith
Independent ownershipa team owns a domain end to end and needs separate release cadence
Data autonomythe domain can own its data boundary without shared-table coupling
External contract stabilityextraction 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.