Service boundaries
Bounded contexts, the modular monolith as the starting point, data ownership, and the distributed monolith that shares a database.
Microservices are a way of paying for organisational scale with operational complexity. The payment is only worth making when the boundaries are right, because a wrong boundary in a monolith is a refactor and a wrong boundary between services is a distributed transaction, a chatty network hop, and two teams who have to deploy together. This lesson is why to split at all, where to cut, why the answer for most teams is a modular monolith first, and the smells that say a system is a monolith in pieces.
Why split at all
The honest reasons to run separate services are few:
- Independent deployment. A team ships its service without coordinating a release with five others. This is the big one, and it only holds if the boundary is clean enough that a deploy does not need the others to change.
- Team autonomy. Conway's law runs both ways: a service per team lets each own its code, its data and its on-call. Below about three teams there is nobody to be autonomous from.
- Different scaling or runtime needs. The image-processing part needs GPUs and the checkout part needs ten replicas; the search part is better in another language.
- Fault isolation. A memory leak in reporting should not take checkout down. This is real, and a monolith with a bulkhead gets most of it.
The costs are paid on every request forever: a network call where a method call was, partial failure (one service up, another down), data that lives in two places and disagrees for a while, a trace that spans eight processes, and an operations surface of deploy pipelines, dashboards and alerts per service. A team that cannot yet run one service well will not run twelve.
Bounded contexts
The boundary comes from the domain, not from the technical layers. Domain-driven design's bounded context is the tool: a part of the business inside which words have one meaning and one model. "Customer" in Sales is a lead with a pipeline stage; in Billing it is an account with a payment method; in Support it is a person with a history of tickets. Trying to make one Customer class serve all three produces the class with ninety fields that every team is afraid to change. Three contexts, three models, and a translation at the edge where Sales hands a won deal to Billing.
Finding contexts is a conversation with the people who do the work, listening for where the vocabulary changes. Signs of a context boundary: a word that means different things to two groups; a process that hands off ("once it is approved, Finance takes over"); data that one group creates and another only reads. A context becomes a service candidate when it also has a team, a rate of change, and a reason from the list above.
Modular monolith first
The safest way to find the right boundaries is to draw them inside one deployable and see whether they hold:
com.acme.shop
├── ShopApplication.java
├── orders/ ← public: OrderApi (the interface), OrderPlaced (event); everything else package-private
├── billing/ ← public: BillingApi, InvoiceIssued
├── inventory/
└── notifications/ ← listens for OrderPlaced and InvoiceIssued; calls nobodyEach module owns its tables and exposes an interface and events; other modules call the interface or subscribe to the events, and Java's package-private visibility makes reaching inside a compile error. Spring Modulith formalises this: it verifies at test time that no module touches another's internals, publishes events through an outbox so they survive a crash, and documents the module graph. A modular monolith deploys as one unit, runs as one process, and has method-call latency and one database transaction across modules, which is exactly what a set of services lacks.
When a module's team, scale or failure profile diverges, it is extracted: its package becomes a service, its interface becomes an API, its events go over Kafka, and the callers change one adapter. A boundary that has survived a year inside a monolith is a boundary worth a network hop. One drawn on a whiteboard on day one, before the domain is understood, is a guess, and extracting a guess is how you get the next section.
Data ownership
The rule with no exceptions: a service owns its data, and nobody else reads its tables. Other services get the data through the owner's API or its events. The moment two services share a table, the schema is an interface nobody declared: a column rename by one team breaks the other at 3 am, an index added for one workload slows the other, and the two cannot be deployed independently because their schemas move together. That is a monolith with a network in it and none of the monolith's advantages.
Ownership has consequences the monolith did not have:
- Joins across services do not exist. "Orders with customer names" is either the order service holding a copy of the name (updated by the customer service's events), an API composition in the caller, or a read model built from both services' events. All three are more work than
JOIN. - Transactions across services do not exist. Reserve stock and charge a card is two services and two commits, and the Distributed data course covers sagas and the outbox for exactly this.
- Reference data is copied, with the copy's staleness understood and accepted.
These are the costs paid for independent deployment. If a team is not willing to pay them, it should not split the data, and if it does not split the data, it has not split the service.
Distributed monolith smells
A distributed monolith has the costs of microservices and the coupling of a monolith. The signs:
- A shared database, or one service reading another's tables "just for reports".
- Lockstep deploys. A release of service A always needs B and C to go out at the same time; there is a release train and a spreadsheet.
- Synchronous call chains. A request enters the gateway and fans through six services in series; the latency is the sum, the availability is the product, and one slow service slows everything.
- Shared domain libraries. A
common-modeljar with entity classes that every service imports, so a change to one field is a coordinated upgrade of twelve services. - Services split by layer. A "database service", a "validation service", a "business rules service": technical layers as processes, every request touching all of them.
- Services so small that a feature touches five. The boundary was drawn around a noun, not a capability.
Each is fixable, and the fix is usually to merge, not to split further. Two services that always deploy together and share data are one service; making them one removes a network hop and a distributed transaction and loses nothing.
Under the hood: what a boundary is made of, and what extraction actually moves
A module boundary inside a monolith is enforced by three mechanisms, and a service boundary replaces each with something weaker. Visibility: package-private types and a small public API mean the compiler rejects a cross-module reach; after extraction the API is an HTTP or gRPC contract, and nothing rejects a caller who parses a field they were not meant to depend on, which is why contracts get versioned and schema-checked. Transactions: two modules writing in one @Transactional share a connection and commit together; after extraction each has its own database, and the same operation is two commits with a gap between them that a crash can land in. Calls: a method call costs nanoseconds, cannot partially fail, and propagates exceptions with a stack trace; a network call costs a millisecond or more, can time out with the work half-done, and returns a status code. Spring Modulith makes the first mechanism explicit (ApplicationModules.verify() fails the build on a reach-in), and it makes the third rehearsable: an @ApplicationModuleListener receives an event after the publisher's transaction commits, on another thread, persisted in an event publication registry table so a crash between commit and delivery replays it. That is the in-process form of the outbox, and a module that has been consuming events that way for a year is a module that will not notice when the event arrives from Kafka instead.
Extraction is therefore a sequence of substitutions, not a rewrite. The module's OrderApi interface gets a second implementation, an HTTP client, and the callers are switched to it by a flag; the module's tables are moved to a new database with the module's code as the only writer (a database-per-service is a rule, and the physical split can follow once the logical one holds); the events the module published in-process are published to a topic by the same outbox, and the consumers subscribe there; then the module's package is copied into a new deployable and the old one deleted. The strangler shape, a facade that routes some calls to the old code and some to the new, is the same idea for a boundary that was never drawn: put the facade in first, move behaviour behind it one capability at a time, and measure what still crosses. Every step is reversible until the last, which is the property a big-bang split never has.
Walkthrough: the forty-service platform that merged back to nine
A retailer had split a monolith into forty services in a year, one per team's whiteboard box, and by the following year a feature that added a field to the order touched nine of them.
orders → customers, catalogue, pricing, promotions, inventory, tax, shipping-rates, address-validation (all synchronous, all on the checkout path)
common-model-2.14.jar: imported by 31 services; a change = 31 upgrades- The team measured before deciding: for every service pair, calls per checkout and whether the call happened inside a database transaction.
ordersmade eight synchronous calls per checkout, three of them inside its transaction (pricing, promotions, tax), so a slow tax service held anordersdatabase connection and, when it timed out, left the order in a state the code had never planned for. - Availability was the product: eight dependencies at 99.9% each gave checkout 99.2% at best, and the observed figure was worse because the calls were serial and the timeouts added up. A dependency graph coloured by "deploys together" showed four clusters that always shipped as a unit, which is the definition of one service each.
pricing,promotionsandtaxwere merged back intoorders: same team, same release cadence, called only byorders, and the merged code ran in one transaction with method-call latency. The three databases became three schemas in one database for a quarter and then one schema.address-validationandshipping-ratesbecame a library, since neither had state.common-modelwas deleted in favour of each service owning its DTOs and reading only the fields it used, with consumer-driven contract tests (Pact) replacing the shared jar as the thing that caught breakage. Thirty-one coordinated upgrades became zero.- Nine services remained, each with a team, a database and a reason from the list at the top of this lesson. The "add a field" feature took one engineer two days, and checkout availability rose above 99.9% without anyone touching a timeout.
The count of services is not a measure of anything. The measure is whether a change lands in one deploy, and whether a request's availability is one service's or the product of eight.
Try it yourself
One context or two?
Sales records a Customer with leadScore, pipelineStage and accountManager; Billing records a Customer with paymentMethod, taxId and creditLimit; both share name and email. A proposal is one customers service with one table holding all nine fields. What is the argument against, and what would you build instead?
Answer
Two contexts with the same word: Sales' customer is a prospect in a pipeline, Billing's is an account that owes money, and they change for different reasons under different teams. One table means every field is nullable (a prospect has no taxId), every rule is conditional, and both teams block on each other's migrations. Build two models, each owning its fields, sharing only an identity (customerId) and the small overlap (name, email) that one of them owns and the other copies from its events, CustomerRenamed. The translation happens once, when a deal is won and Sales hands off to Billing.
Extract or not?
A monolith's reporting module runs nightly aggregations that take four hours and spike memory; it reads every other module's tables directly through JPA and has no API of its own. The proposal is to extract it "because it needs different resources". Is the reason valid, and what must happen first?
Answer
The reason is valid (divergent runtime needs) and the extraction is impossible as-is: reporting reads seven modules' tables, so a service version would need seven APIs or a shared database, which is the distributed-monolith smell. First, make it a real module: it consumes events from the others into its own read model (its own schema), and its queries hit only that. Once it has run that way for a while, extraction is moving one schema and one consumer, and the memory spike leaves the monolith with it.
Read the smell
Two services, orders and fulfilment, are owned by the same team, deploy in the same pipeline stage, share a Postgres database with separate schemas, and fulfilment reads orders.orders directly for the address. Name the smells, say what has actually been gained by the split, and recommend.
Answer
Shared database (cross-schema reads are a shared table), lockstep deploys, and a synchronous data dependency on internals. Gained: two deploy pipelines, two sets of dashboards, a network hop, and a schema change in orders that breaks fulfilment at runtime. Nothing from the "why split" list applies: same team, same cadence, no divergent scaling. Merge them into one service with two modules; if fulfilment later needs its own scaling, extract it then, with the address arriving via an OrderPlaced event rather than a table read.
Misconceptions
- "Microservices are the modern default." They are a trade of coupling for operational cost that pays off above a certain team count. Below it, the modular monolith ships faster with the same boundaries.
- "A separate repository or deploy makes it a service." A service owns its data and can deploy alone. Shared tables or lockstep releases mean the split did not happen.
- "Small services are better services." A boundary drawn around a noun produces features that touch five services; a boundary around a capability produces features that touch one.
- "Extraction is a rewrite." It is a sequence of transport substitutions on a boundary that already exists; the rewrite is what happens when the boundary did not.
- "Merging services is going backwards." Two services that always deploy together are one service paying a network tax; merging removes the tax and loses nothing.
Going deeper
- Eric Evans, Domain-Driven Design, part IV on bounded contexts and context maps; Vaughn Vernon's Implementing DDD for the practical version.
- Spring Modulith reference: "Fundamentals", "Verifying Application Module Structure", "Working with Application Events".
- Sam Newman, Monolith to Microservices, for the strangler fig and the data-decomposition patterns.
- Martin Fowler, "MonolithFirst" and "Microservice Premium"; Simon Brown, "Modular Monoliths" (talk).
- Pact documentation, consumer-driven contracts as the replacement for shared model jars.