Morteza Taghdisi

Writing10 min read
Abstract technical illustration comparing monolith, modular monolith, and service architecture shapes
Architecture & Platform ThinkingMay 28, 2026

Monoliths, Modular Monoliths, And Services Without Hype

Series

System Architecture Field Guide

3 of 12 in the series

Article 3 of 12

Choosing between a monolith, modular monolith, and services is not a maturity contest. It is a decision about boundaries, ownership, deployment, data, and operational cost.

architecturemonolithmodular-monolithmicroservicesservice-boundaries

Few architecture conversations become religious faster than monoliths vs microservices.

That is already a bad sign.

A monolith is not automatically immature. A service architecture is not automatically advanced. A modular monolith is not a compromise for teams that could not do "real" architecture.

These are different ways to draw boundaries.

The architecture question is not:

"Should we use microservices?"

The better question is:

"Which boundary should exist now, and which costs are we ready to own?"

That question is less exciting. It is also the one that keeps teams out of trouble.

Three Shapes, Three Cost Models

The choice is not only about code structure.

It changes deployment, debugging, testing, data ownership, incident response, local development, and team coordination.

ShapeWhat It BuysWhat It Spends
MonolithSimple deployment, simple local development, easy transactions, fewer moving parts.Harder independent ownership as the product and team grow.
Modular monolithClear internal boundaries without network and deployment overhead.Requires discipline because boundaries are enforced mostly by code structure and review.
ServicesIndependent deployment, clearer runtime ownership, isolated scaling, stronger team autonomy.Distributed failure, contract management, observability, data consistency, and operational overhead.

None of these is always right.

The mistake is choosing the shape for prestige instead of constraints.

A Monolith Can Be The Right Architecture

A monolith is often the fastest honest architecture for an early product or a small team.

It keeps the system understandable. One deployment contains the behavior. One database transaction can update related data. Local development is simpler. Debugging usually starts in one process instead of five dashboards.

That simplicity has real value.

A monolith is a good fit when:

  • the domain is still changing quickly
  • the team is small enough to coordinate directly
  • independent deployment is not yet a real bottleneck
  • most workflows need simple transactional consistency
  • operational capacity is limited
  • the product needs learning speed more than organizational autonomy

This does not mean the monolith should be messy.

A healthy monolith still needs boundaries. It needs modules, ownership, tests, and rules about who can touch what. Otherwise it becomes a shared drawer where every feature leaves a little tangle behind.

The problem is not the monolith.

The problem is the unbounded monolith.

The Modular Monolith Is Often The Missing Step

Many teams jump from "our monolith is painful" to "we need services."

Often the better next step is a modular monolith.

A modular monolith keeps one deployable system but creates intentional internal boundaries. Billing, identity, catalog, checkout, notification, and reporting may live in the same application while having separate modules, interfaces, and ownership rules.

For example:

modular monolith - module layout
src/
billing/
BillingService.ts
BillingRepository.ts
BillingEvents.ts
identity/
IdentityService.ts
IdentityRepository.ts
checkout/
CheckoutService.ts
CheckoutRepository.ts
directory.kt.md / .txt.yaml / .gradle.xml.ts / .jsother

The exact folder names do not matter.

The important part is the rule:

"Other modules talk to billing through billing's public interface, not through billing's tables and internals."

That rule gives the team a boundary before it creates a network boundary.

This is useful because many service problems are not distribution problems first. They are ownership problems. If a team cannot keep a clean boundary inside one codebase, splitting the code into services may only move the mess onto the network.

Services Are Useful When Independence Is Worth The Cost

Services are not bad. They are powerful when the system actually needs what they buy.

A service boundary can be right when:

  • one capability needs independent deployment
  • one team needs clear runtime ownership
  • one part of the system has very different scaling needs
  • one domain has separate compliance or security constraints
  • failures should be isolated from the rest of the product
  • release coordination has become a real delivery bottleneck
  • the domain boundary is stable enough to become a contract

That last point matters.

A service boundary hardens a decision. Once another team calls the service, subscribes to its events, depends on its data, or builds alerts around its behavior, the boundary becomes expensive to move.

Services are useful when the boundary is worth making more formal.

They are painful when the boundary is still a guess.

Distribution Adds Failure Modes

Moving code into another service does not remove complexity.

It changes the type of complexity.

Inside one process, a function call either returns, throws, or times out according to local rules. Across services, the call can fail in more ways:

  • the network is slow
  • the service is down
  • DNS is wrong
  • authentication fails
  • the request succeeds but the response is lost
  • the dependency times out after doing work
  • retries duplicate side effects
  • one service deploys a contract change before another is ready

This is why "just split it into a service" is rarely a small change.

The team now needs timeouts, retries, idempotency, contract tests, dashboards, alerts, ownership, rollout strategy, and a way to debug across boundaries.

Those are not reasons to avoid services forever.

They are reasons to avoid services before the benefit is real.

Data Ownership Can Block The Shape Change

Code is usually easier to split than data.

A service split is not real if every caller still reads and writes the same tables. The team has added network calls, but the source of truth is still shared.

That does not mean every service needs a separate database immediately. It means the migration path has to name who owns the meaning of the data before the runtime split becomes permanent.

The next article treats data ownership as a boundary-finding tool. In this article, the shape-level warning is simpler:

"Do not call it a service extraction if the old shared data model still controls every change."

Transactions Change Shape

In a monolith, a checkout workflow might update an order, reserve inventory, and create a payment attempt inside one database transaction.

That is simple to reason about.

After splitting services, that same workflow may cross multiple owners:

plaintext
Checkout -> Orders
Checkout -> Inventory
Checkout -> Payments
Checkout -> Notifications

Now the team has to decide what happens when payments succeeds but notification fails, or inventory reservation times out after the order is created.

The system may need:

  • idempotency keys
  • outbox patterns
  • compensating actions
  • workflow state
  • reconciliation jobs
  • user-visible pending states
  • operational tools for stuck work

That is not "worse" by default.

It is a different architecture. It trades simple local consistency for independent ownership and resilience patterns.

If the product does not need that tradeoff yet, a modular monolith may be the cleaner answer.

Team Shape Matters

Service architecture is also an organizational decision.

A separate service needs an owner. Not a name in a diagram. A real owner.

Someone must decide:

  • what the service contract means
  • which changes are backward-compatible
  • how incidents are handled
  • how usage is monitored
  • how deprecations are communicated
  • when the service can reject a request
  • what operational quality the service promises

If five teams share a service and nobody owns the contract, the service becomes a distributed shared module.

That is usually worse than a shared module inside a monolith because now the coupling is slower, harder to test, and harder to debug.

Before extracting a service, ask:

"Who will wake up for this boundary?"

If the answer is unclear, the boundary may not be ready.

A Practical Decision Table

Use the smallest shape that honestly supports the system's current pressure.

PressureUsually PreferWhy
Small team, fast-changing productMonolithLearning speed matters more than deployment independence.
Growing codebase with unclear boundariesModular monolithBoundary discipline is needed before distribution.
Stable domain owned by one teamModular monolith or serviceChoose based on deployment and operational needs.
Independent deployment is a real bottleneckServiceSeparate release cadence may justify the overhead.
One capability has very different scaleServiceIsolation can protect cost and reliability.
Multiple teams modify the same area constantlyModular boundary firstDistribution will not fix unclear ownership by itself.
Data model is shared by many workflowsUsually not a service yetSplit ownership before splitting runtime.
Compliance or security boundary differsService may fitRuntime and data isolation may be worth the cost.

The table is not a formula.

It is a way to slow the conversation down before the team chooses the fashionable option.

Migration Should Be A Path, Not A Rewrite

The safest move is often:

  1. Name the boundary.
  2. Create a module around it.
  3. Stop other modules from reaching into its internals.
  4. Move reads and writes behind an internal interface.
  5. Make ownership explicit.
  6. Observe the boundary.
  7. Extract only when independent deployment or runtime isolation is worth it.

Suppose billing is tangled inside checkout.

The first move does not have to be a billing service. It can be a billing module:

ts
const authorization = await billing.authorizePayment({
  orderId,
  customerId,
  amount,
  idempotencyKey,
})

Checkout no longer knows gateway states, retry tables, or processor-specific fields. It asks billing for a billing decision.

Later, if billing truly needs independent deployment, the module boundary can become a service boundary:

plaintext
Step 1: checkout calls billing module
Step 2: billing module owns billing rules
Step 3: checkout stops reading billing internals
Step 4: billing exposes a stable contract
Step 5: billing gains its own deployable service

That migration is much easier than extracting a service from a pile of shared tables and hidden calls.

The shape decision should be revisited when the module boundary has stayed stable, independent deployment would remove real friction, and the team is ready to own the operational cost. The next article gives the deeper boundary scorecard.

Where To Go Deeper

The service boundaries article goes deeper into finding the boundary and deciding who owns data, contracts, and failure behavior.

The API design article goes deeper into what happens after a boundary becomes a public contract.

The database migrations article goes deeper into changing data safely when old code and new code overlap.

The Kafka Mastery branch is the right deeper path when the boundary becomes event-driven and the implementation details start to matter.

Summary

Choosing between a monolith, modular monolith, and services is not a maturity contest.

It is a cost decision.

A monolith buys simplicity. A modular monolith buys boundary discipline without distribution. Services buy independence, isolation, and ownership at the price of distributed operations.

The architect's job is not to pick the most impressive shape.

It is to choose the smallest boundary that solves the real problem, and to make sure the team can own the costs that come with it.