The problem: many services, one release train

A team splits a Spring Boot application into twelve services by technical layer: user-api, order-api, database-service, validation-service. To add a field to checkout they change five services, deploy them in a fixed order, and a failure in validation-service stops everything. They have all the costs of distribution and none of the independence. This is the distributed monolith.

Microservices are a way to let teams change and deploy parts of a system independently. The boundaries decide whether that promise holds. A wrong boundary is far more expensive than a wrong class name, because it is baked into network contracts, databases and team habits.

Boundaries come from business capabilities

A business capability is something the organisation does: pricing, ordering, payment, fulfilment, customer support. Capabilities are more stable than screens or technical layers. A good service owns one capability, including its rules, its data and its user-facing behaviour for that capability.

Domain-driven design gives a sharper tool: the bounded context, an area where one model and one vocabulary are consistent. "Customer" means a billing account in invoicing, a delivery address in shipping and a set of preferences in marketing. Trying to share one Customer class across all three couples them. Each context holds the version of the concept it needs. See the article on aggregates for modelling inside a context.

Signals that a boundary is in the right place:

  • most changes for a feature stay inside one service;
  • the service can be explained in one sentence without "and";
  • the vocabulary inside is consistent;
  • the service has one dominant reason to change.

Data ownership is the real boundary

The test with the most teeth is the database. Each service owns its data and is the only writer. Others get it through the service's API or through events it publishes.

 Good                              Bad
 +--------+     API/events        +--------+   +--------+
 | Orders |----------------->     | Orders |   | Billing|
 |  (DB)  |   +---------+         +---+----+   +----+---+
 +--------+   | Billing |             \            /
              |  (DB)   |              +-- shared DB --+
              +---------+

A shared database is a hidden API without versioning: any schema change can break another team's service, and nobody sees the dependency in code. Foreign keys across services, joins across service tables and "just read their table" shortcuts all erase the boundary.

Owning data means accepting consequences. A report that joined orders and customers in one SQL query now needs either an API composition, a replicated read model fed by events, or a separate analytics store. Cross-service business rules can no longer rely on one ACID transaction; they become sagas with compensation.

Smells of a distributed monolith

  • Services must be deployed together or in a fixed order.
  • A request chain of many synchronous calls where one slow service slows all.
  • Two services share a database or a "common" library full of domain classes.
  • One change regularly requires pull requests in several repositories.
  • Chatty calls: a service makes dozens of calls to another to build a screen.
  • Services named after layers or nouns (order-database) rather than capabilities.
  • Integration tests of the whole system are the only tests that give confidence.

Start with a modular monolith

For most teams the pragmatic path is not microservices first. Build a modular monolith: one deployable, but inside it modules with explicit public interfaces, private internals and separate schemas or tables per module. Modules call each other only through those interfaces or internal events.

 app (one deployable)
 ├── ordering/   public: OrderFacade    private: domain, persistence
 ├── payment/    public: PaymentFacade  private: ...
 └── shipping/   public: ShippingFacade private: ...
 rule: ordering never imports payment's internals

You get fast refactoring, one transaction where it truly helps, simple debugging and a single deployment, while learning where the real seams are. When one module needs a different scaling profile, release cadence or ownership, you extract it, and because boundaries were already enforced (an architecture test can check imports) the extraction is mechanical rather than archaeological. Splitting early, before the domain is understood, usually produces wrong boundaries that are expensive to move.

Team structure shapes architecture

The shape of the organisation tends to show up in the system's design. If three teams must coordinate to ship one feature, the architecture has a coupling problem or the team boundaries do. Aim for services that a single team can own end to end, including operation: code, data, on-call. Aligning services with team responsibility reduces hand-offs, while a service shared by many teams tends to become a bottleneck. Treat team boundaries and service boundaries as one design problem and review both when either hurts.

Granularity: not too small

Nanoservices, one per function or entity, multiply network calls, deployments, pipelines and failure modes. Each service adds operational cost: monitoring, versioning, security, on-call, contract testing. A service should be big enough to justify that cost, typically a bounded context or a significant part of one. If two services always change and deploy together, merge them.

Common mistakes

  • Splitting by technical layer or by entity table instead of capability.
  • Sharing a database "temporarily".
  • Sharing a domain library that forces lock-step upgrades.
  • Treating synchronous HTTP between many services as free.
  • Extracting services before there is a deployment or scaling reason.
  • Ignoring data consistency until production exposes it.
  • No owner per service.

Trade-offs

Microservices can give independent deployment, targeted scaling, technology choice per service and fault isolation. They cost operational complexity, network failure modes, distributed data, harder end-to-end testing and a need for platform skills. A modular monolith gives much of the structure with much less cost. Choose microservices when organisational scale or divergent requirements justify the price, not by default.

Extracting a service step by step

When a module really needs to leave the monolith, move it in small, reversible steps. First make the module's interface the only way in, and move its tables into their own schema with no foreign keys to others. Then replace in-process calls with a network API behind the same interface, and replace shared reads with events or API calls. Only then move the code and data to a separate deployable. Run both paths side by side for a while and keep the ability to roll back. This avoids the common failure of splitting code first and discovering the shared data afterwards.

Checklist

  1. Each service maps to a capability or bounded context.
  2. Each service owns its data; no shared tables.
  3. Cross-service flows have an explicit failure and consistency story.
  4. A feature can usually ship by one team in one service.
  5. Services can be deployed independently, and this is tested.
  6. Dependencies between modules are enforced by tests, not good intentions.
  7. A modular monolith was considered first, with a reason if rejected.

Where to go next