top of page

Service Boundaries: Decomposing Without Creating a Distributed Monolith

Shawn West
Apr 21
7 min read

Updated: 2 days ago

Splitting a system into services is a mechanical exercise. Splitting it along boundaries that actually hold is a different problem, and there is one test that tells you which one you did.


A freight logistics company's platform was decomposed into fourteen services over about a year. The architecture diagram was clean — neat boxes, clear arrows, one team per box.


(Composite example: a mid-sized freight logistics company, drawn from patterns across several organisations. Not a real company.)


Eighteen months later, deploys still went out together, on Thursdays, in a fixed order. Orders had to ship before Pricing. Pricing before Invoicing. If any one of the three failed its checks, all three were held.


Nobody had planned that. It emerged, one small coupling at a time, and by the time it was obvious it was the release process. Fourteen services, one deployment unit — every cost of distribution, none of the independence it was supposed to buy.


A boundary is only real if the two sides can ship on different days


That's the whole test, and it is refreshingly hard to argue with.


If deploying Service A requires deploying Service B at the same time, the boundary between them is decorative. It exists in the repository layout and the diagram; it does not exist in the system. You've paid for a network hop, a serialisation format, a second deployment pipeline and a new class of partial failure, and in exchange you got a drawing.


The reason this test works is that it's observable. "Are these services loosely coupled?" is a conversation. "Did these two services last ship on different days?" is a query against your deploy log, and it does not care what anyone believes about the architecture.


The company ran it and found three genuinely independent services out of fourteen. The other eleven formed two clumps that had to move together.


The test you can run: for each pair of services, find the last five deploys of each. If the timestamps interleave in lockstep, they're one service wearing two names.


The failure mode has a shape: coupling that survives the split


A monolith's dependencies are invisible but harmless — everything deploys together, so nothing can skew. Splitting makes each dependency visible and expensive, but splitting alone doesn't remove any of them. They come across intact unless you do something about each one.


Three kinds survive the split, and they behave differently:


Coupling

What it looks like after the split

The tell

Cost of removing

Shared database

Two services on one schema

A migration needs both teams in the room

High — but it's the one that matters

Synchronous call chain

A calls B calls C, all blocking

One slow service makes three slow

Medium — usually an async boundary

Shared library with domain logic

Both import common.pricing

A version bump forces a joint release

Low — duplicate the code, honestly


The third one is the one teams defend hardest and should give up first. A shared library of technical utilities is fine. A shared library containing domain rules is a monolith you've agreed to compile twice, and every change to it is a coordinated release.


The test: look at what your shared libraries contain. Anything encoding a business rule that could change for one service and not the other is a coupling, not a convenience.


The boundary goes around a model, not around a noun


The most common way to draw the lines wrong is to draw them around nouns from the database schema. Customer service, Order service, Product service. It feels principled and it produces exactly the coupling above, because a real "customer" is not one thing.


The useful idea here is the bounded context: a boundary inside which one specific model of a word applies. The same noun legitimately means different things in different places, and pretending otherwise is what forces every service to share the customer table.


At the company, "shipment" meant four different things:


Context

What a shipment is

What it needs

What it doesn't care about

Booking

A quote, a route, a promised date

Capacity, pricing

Where the truck is now

Operations

A physical load with a position

GPS, driver, exceptions

What it cost

Billing

A chargeable event with a weight

Rate card, tax, account

Route detail

Support

A case with a customer attached

History, contact, status

Rate cards


Four models, four services, and — critically — four different shipment records, each holding only the fields its context needs. That looks like duplication in a schema review. It's what makes the boundary hold, and it's a design pattern rather than a modelling error once data is distributed.


The test: take a core noun in your domain and ask three teams what fields it has. If you get three different lists, you have three contexts, and one shared table is fighting all of them.


Start coarse, because splitting is reversible and merging isn't


The other reliable failure is starting too fine. A team reads about microservices, draws twenty boxes on day one, and spends a year operating twenty deployment pipelines for a system that had three real seams.


Two asymmetries make coarse the right default:


  • Splitting a service later is a refactor. You know the access patterns by then, because you've watched them for a year. The seam is usually obvious once there's traffic.

  • Merging two services is a project. It means unwinding a network protocol, two datastores, two pipelines and, often, two teams' ownership.


So the boundaries you can't yet justify should stay inside one service, as modules. A modular monolith with clean internal seams is one refactor away from services. Twenty premature services are not one refactor away from anything.


The test: for each service, name the specific thing that would have been worse if it were a module in a larger service instead. If the answer is "it's cleaner", that's not a reason — that's the module boundary doing its job, and it didn't need a network.


Where this is the wrong instrument


Boundary design is worth real effort in a narrow band of situations, and outside it the effort goes somewhere better:


  • One team owns the whole system. Boundaries exist mostly to let teams move independently. With one team there's nobody to decouple from, and module boundaries give you the design benefit for none of the operational cost.

  • The domain isn't understood yet. Drawing boundaries before you know the domain locks in guesses at the most expensive layer. Build it as a monolith, watch what actually changes together, then cut.

  • The problem is scaling one component. If a single hot path needs to scale independently, extract that — one service, deliberately. That's not decomposition, it's an optimisation, and it doesn't imply anything about the rest.

  • The pain is deploy speed, not coupling. Slow deploys are usually a pipeline problem. Splitting the system to make deploys faster gives you many slow pipelines.


Where it always earns its cost: multiple teams that need to ship on their own cadence, genuinely different scaling profiles, and regulatory separation of data.


The test: name the team that will own each proposed service. If two boxes have the same owner, ask what the boundary between them is buying today.


The quality move: test the boundary, not the box


A boundary is a claim: these two sides can change independently. Like any claim, it can be tested, and most test suites never do. They test inside each box, and nothing tests the lines between the boxes, which is exactly where the coupling hides.


Two checks make a boundary observable. A consumer-driven contract test pins down what one context actually needs from another, so a change that would break the other side fails a build instead of a Thursday release; contract testing covers the mechanics. And the deploy-log query above, run monthly, turns "are we still decoupled?" into a number that can regress.


This matters more now that services get generated. A coding assistant asked for "an order service" builds around the noun in the prompt. It has no way to know that "shipment" means four things in your business. That is a discovery question, and it has to be answered before generation rather than caught in review: AI-generated code builds what the ticket states, so the context map belongs in the ticket.


The test: for each boundary, name the test that would fail if someone coupled across it. If there isn't one, the boundary is protected only by everyone remembering it exists.


What to change this week


Don't redraw the diagram. Query the deploy log.


For every pair of services, pull the last ten deploy timestamps and look for lockstep. That list — the pairs that always ship together — is your real architecture, and it's usually shorter and clumpier than the picture on the wall.


Then take the worst pair and find which of the three couplings binds them: shared schema, synchronous chain, or a shared library with domain logic in it. Fix one. The library is nearly always the cheapest and it's nearly always present.


The company is at nine services now, down from fourteen. Three pairs were merged back because the boundary was never real, and the two clumps were broken apart by moving pricing rules out of a shared package and into the service that owns them. Deploys stopped being a Thursday event about four months later.


Final takeaway


A service boundary is real when the two sides ship on different days, and nothing else proves it. Draw boundaries around models rather than nouns, start coarser than feels clever, and give every boundary a test that fails when someone crosses it. This week, query the deploy log for the pairs that always ship together; that list is your actual architecture.


Sources


  • Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software (Addison-Wesley, 2003). The origin of the bounded context.

  • Martin Fowler, "BoundedContext" (bliki, 2014): https://martinfowler.com/bliki/BoundedContext.html

  • Martin Fowler, "MonolithFirst" (bliki, 2015): https://martinfowler.com/bliki/MonolithFirst.html

  • James Lewis and Martin Fowler, "Microservices" (2014): https://martinfowler.com/articles/microservices.html

bottom of page