Skip to content
Starone

Architecture

Microservices vs modular monoliths

Microservices solve organisational and scaling problems at the price of a distributed system. A modular monolith keeps most of the design benefit without that price, and is our default starting point until a specific pressure justifies extraction.

· 4 min read · Starone Engineering

The argument is often presented as old against new. It is better understood as a question about where boundaries are enforced. In a modular monolith, boundaries between business capabilities are enforced by the compiler and the build. In microservices, they are enforced by the network. The network is a stronger wall, and a far more expensive one.

Our default for a new system is a modular monolith: one deployable unit with strict internal modules, built so that a module can be extracted later. We move a module into its own service when a named pressure requires it, and not before.

What microservices actually buy

The benefits are real and specific. Each service can be deployed on its own schedule, which removes release coordination between teams. Each can be scaled according to its own load profile. A fault can be contained if the callers are built to tolerate it. A service can use a different runtime or datastore where the workload calls for it.

Note that most of these are organisational. Independent deployment matters when many teams would otherwise queue behind one release train. Conway's law describes the mechanism: systems come to mirror the communication structure of the organisation that builds them. Microservices are in large part a tool for letting many teams work without blocking each other.

What they cost

Every call that was a function invocation becomes a network request that can be slow, fail or succeed without the caller finding out. That single change introduces a list of obligations.

  • Timeouts, retries, circuit breakers and idempotency on every inter-service call.
  • No cross-service transactions, so consistency is rebuilt with sagas, outboxes and compensation.
  • Versioned contracts and backward compatibility for every interface, tested between services.
  • Distributed tracing, centralised logging and per-service dashboards just to answer what happened to one request.
  • A deployment pipeline, runtime configuration, secrets and on-call ownership for each service.

The distributed monolith

The worst outcome is a system that pays these costs and receives none of the benefits. The signs are familiar: services that must be deployed together, several services reading and writing the same database tables, a shared library whose upgrade forces every team to release at once, and request chains so deep that the availability of one endpoint depends on a dozen others all being up.

This usually happens when the split is made before the domain is understood. Boundaries drawn early are drawn in the wrong places, and moving a boundary between services is far harder than moving one between modules, because it involves data migration and contract changes across teams. A monolith lets the boundaries be corrected with a refactoring while they are still cheap to change.

What makes a monolith modular

A modular monolith is not a large codebase with tidy folders. The modules have to be enforced or they erode under delivery pressure.

  • Each module exposes a small public interface and hides everything else, using package visibility, separate build modules or the language's module system.
  • Dependency rules are checked in the build, with tools such as ArchUnit or Spring Modulith in the Java ecosystem, so an illegal import fails the pipeline.
  • Each module owns its tables, ideally in its own schema, and no module queries another's data directly.
  • Modules communicate through explicit interfaces or in-process events, which become network calls or broker messages if the module is extracted.

Limits of the single deployable

The monolith has real constraints and it is better to state them. All modules share a release, so one bad change can roll back everyone's work unless feature flags and good test coverage are in place. All modules share a process, so a memory leak or a runaway query in one affects the rest. Build and test times grow with the codebase and need active attention. The whole unit scales together, which wastes resources when one module carries most of the load.

Horizontal scaling is still available. A stateless monolith behind a load balancer handles substantial traffic, and the database is typically the limit long before the application tier is, which is equally true of microservices sharing that database.

Pressures that justify extraction

We extract a module into a service when one of the following is demonstrably true, and the benefit outweighs the operational overhead.

  • Its load profile differs sharply from the rest, such as a bursty ingestion path or a compute-heavy inference workload.
  • It needs a different runtime, hardware or datastore.
  • It has a different availability target or must be isolated for compliance, as with a component that handles card data.
  • Enough teams work on the system that release coordination has become the bottleneck.
  • It changes at a very different rate from the rest and its releases carry disproportionate risk.

When to start with services

Starting with microservices is the right call for an organisation that already has many teams, a mature platform with paved deployment and observability, and a domain it understands well, often because it is rebuilding a system it has run for years. In that setting the costs are already paid for and the boundaries are known.

For everyone else, the modular monolith keeps the option open. Extraction along a clean module boundary follows the strangler pattern: route one capability to the new service, move its data, retire the old code. The discipline that makes that possible, strict modules with owned data, is worth having whether or not the split ever happens.

Working on something similar

We are happy to compare notes. Talk to the engineers who wrote this.