Skip to main content
Ahmed Salama

Architecture Lab

Modular monolith vs microservices

Where the boundaries go decides it; the deployable count does not.

The shape of the decision

System diagram: Modular monolith vs microservices: Where the boundaries go decides it; the deployable count does not.Owns its tables. Other modules ask it rather than querying its data.SERVICEModule: OrdersA published interface instead of a shared schema, so it can be extracted later without archaeology.SERVICEModule: BillingThe first candidate for extraction if it needs to scale on its own.SERVICEModule: CatalogueOne engine, but schemas kept separate per module: the boundary that makes a later split possible.DATADatabaseexplicit interfaceexplicit interfaceown schemaown schemaown schema
Modular monolith vs microservices: Where the boundaries go decides it; the deployable count does not.
Read this diagram as text
Module: OrdersService
Owns its tables. Other modules ask it rather than querying its data.
Module: BillingService
A published interface instead of a shared schema, so it can be extracted later without archaeology.
Module: CatalogueService
The first candidate for extraction if it needs to scale on its own.
DatabaseData
One engine, but schemas kept separate per module: the boundary that makes a later split possible.

Connections

  • Module: Orders → Module: Billing (explicit interface)
  • Module: Orders → Module: Catalogue (explicit interface)
  • Module: Orders → Database (own schema)
  • Module: Billing → Database (own schema)
  • Module: Catalogue → Database (own schema)
The problem it solves
Teams block each other in one codebase, deployments become high-risk events, and one component needs to scale differently from the rest.
How it works
A modular monolith enforces boundaries inside one deployable: modules own their data, talk through explicit interfaces, and cannot reach into each other. Microservices take the same boundaries and put a network between them; you gain independent deployment and scaling, and you take on distributed-systems problems in exchange.
What it costs
Microservices convert every in-process call into a network call that can be slow, fail, or arrive twice. You inherit service discovery, distributed tracing, versioned contracts, partial failure and data that no longer joins. Most of the cost is operational, and it lands on the team rather than on the architecture diagram.
When not to reach for it
The boundaries are not yet clear, and in a young product they usually are not. Splitting on the wrong seam produces a distributed monolith: you pay the network cost without getting the independence. Get the modules right in one deployable first; that work is not wasted, because it is exactly the work a split requires.

Boundaries before deployment units

The deployable count is a decision that follows from where the boundaries sit, instead of one that precedes it. A module boundary and a service boundary are the same idea at different distances: one enforced by the compiler and the team's own discipline, the other enforced by a network and everyone who calls across it. Getting that boundary right is the actual design work, and it can be tested and corrected far more cheaply inside one process than it ever can once a network sits between the two sides.

Enforcement inside a monolith is a code-level constraint. A module that reaches into another module's tables, or imports a type it has no business depending on, shows up as an import that should not exist, a lint rule that fails, a code review comment noting that the logic belongs somewhere else. Fixing it is a rename and a move, tested by the same suite that already runs on every change, and reversible within the same afternoon if the new shape turns out to be wrong.

None of that holds once the boundary is a wire protocol. A module split into a service is depended on by clients that deploy on their own schedule, some of them still running last month's version of the contract. Moving a field, splitting a table, or discovering that two concerns once assumed separate in fact share a transaction now means a migration that has to preserve compatibility for everyone still on the old shape while the new one rolls out underneath them, rather than a local refactor tested by the same suite that already covers it.

Proving the boundary first, inside a single deployable, does the same work a split would eventually demand anyway, at a moment when a mistake still costs an afternoon instead of a cross-team migration. I would rather spend that afternoon undoing a wrong guess inside one process than discover the same mistake wearing a network protocol, coordinated across two teams and a deprecation window. A team that jumps straight to services because the boundaries feel obvious is betting that guess against a cost structure where the wrong guess grows more expensive in exact proportion to how confident everyone felt when they made it.

The operational bill of microservices

Each service earns its own deploy pipeline, its own versioning scheme, its own release calendar, because that independence is the entire point of splitting in the first place. What used to be one pipeline shipping the whole system together becomes as many pipelines as there are services, each with its own build, its own test suite, its own rollout strategy, and its own person who remembers what changed the last time it broke. The operational cost sits less in any single pipeline than in maintaining enough of them, well enough, over enough time, that none of them quietly rots into the pipeline nobody trusts to deploy cleanly.

A single request that used to live inside one process, visible in one stack trace, now crosses several services that each keep their own logs. Reconstructing what happened to a failed request means correlating a request id across every service it touched, in the right order, which is exactly what distributed tracing exists to automate. Without it, debugging a cross-service failure becomes an exercise in comparing timestamps across systems that were never designed to agree with each other precisely.

A schema or interface change can no longer assume every caller is ready for it, because callers now deploy on their own timeline rather than the one release that used to bundle everyone together. The realistic pattern is running two versions of a contract at once, deprecating the old one only once nothing still calls it, and treating consumer discovery, working out who still depends on the field being removed, as its own piece of work, not something a compiler catches for you across a network boundary.

A network call can fail in ways an in-process call never could: it can time out with the request half-processed on the other end, or arrive twice because a retry fired after the original response was simply delayed rather than lost. Handling that honestly means retries with backoff, timeouts set deliberately rather than left at whatever the client library defaults to, and circuit breakers that stop calling a dependency that is already struggling instead of adding load to it. When something does fail, the question I ask first is whether the fault started in the service that paged me or arrived from an upstream dependency that is failing too. On-call for one service now means understanding enough of the dependency graph around it to answer that, a wider brief than knowing the internals of the service the rota assigns.

Signals that justify a split

A signal that justifies a split has to be true today, measured against the system as it currently runs, rather than a scaling story about where the product might be in a year. Three signals hold up under that test: a component whose load profile genuinely diverges from the rest, a component that needs to release on a different cadence than the rest, and a team boundary that already exists whether the architecture acknowledges it or not.

A scaling-profile signal is a component whose resource shape differs in kind, not just in volume, from the rest of the system: heavy compute against light memory, or a bursty workload sitting next to a steady one. Splitting it lets that component scale on its own signal instead of dragging the rest of the deployable along for a resource pattern the rest of the system does not share. A component that is merely busier than its neighbours, without a genuinely different shape, gets the same benefit from running more instances of the whole monolith.

A cadence signal is a component that wants to ship several times in the time the rest of the system ships once, typically because it sits closest to a fast-moving external dependency or a rapidly iterating part of the product. Bundled inside one deployable, its releases wait on the release train of everything else, and everything else inherits its risk on every deploy whether or not that deploy touched that code. Splitting it turns one shared release train into two trains running at the speed each genuinely needs.

A team boundary is the signal least visible on an architecture diagram and the hardest to argue against. Conway's law states plainly why: a system's structure ends up mirroring how the teams building it communicate, whether anyone designed it that way or not. Two teams who already own separate parts of the domain, who already coordinate through a defined interface rather than by sitting near each other, are describing a boundary that exists whether or not the code reflects it yet. Splitting along that line removes friction that was already there, while splitting along a line invented for the architecture, one no team boundary supports, creates friction that was not there before. I trust one strong signal on its own, a scaling profile that genuinely diverges, a cadence that genuinely needs independence, a team boundary that already exists, over three signals that are each present but weak, because a weak signal in every dimension usually means the boundary has not been tested hard enough yet to know where it truly sits.

The migration path

The extraction that survives contact with production is the strangler pattern: put a seam in front of the module being extracted, route a slice of traffic through the new service while the old code path keeps running behind it, and grow that slice only as the new path proves itself against real load. Nothing about the interface facing the rest of the system needs to change during this, because the seam was already there before the extraction started. Only what answers on the other side of it changes.

That seam has to have already done real work as an interface inside the monolith, called across enough times, by enough different callers, that its shape has already survived contact with the rest of the system. Extracting a boundary that has never been exercised as an interface, one that was drawn on a diagram but never enforced in code, surfaces every hidden coupling it was quietly permitting at the worst possible time: during a live migration, in production, rather than during a code review where the fix costs nothing but an afternoon.

The database is the last thing to split, never the first, because splitting it commits the system to two sources of truth before anything has proven the split boundary is correct. Splitting the code first, and running it against a single shared database with each half accessing only the tables its own module already owned, tests the boundary under real traffic while a rollback still means reverting a deploy rather than reconciling data that has already diverged across two stores that both believe they are authoritative.

Picking which module to extract first is easiest when one of the earlier signals is already unambiguous: the module with the genuinely different scaling profile, or the one whose team already owns it as a separate unit in every way but the deployment boundary. I pick the clearest case over the most tempting one, so the team leaves the extraction with a working migration path, proven once under real conditions, that the next extraction can reuse instead of relearning under pressure.

Let’s talk

Have a product, platform or delivery challenge? Let’s talk about turning it into a structured, scalable solution.

Open to technical leadership, product delivery and senior engineering roles, and available for architecture consulting, technical reviews and mentorship. Engagements run as project-based work, contracts, consulting, freelance engagements, remote collaboration and long-term partnerships.

Based in Cairo, Egypt, working remotely with clients across the MENA region and internationally.

Also on LinkedIn (opens in a new tab)