Cruxly

Cruxly › System design › Microservices and Events

Decomposing a System into Services

the hard part of microservices is not the network, it is deciding where the lines go and who owns the data behind each one

Open the interactive lesson

Splitting a system into services is easy to do and hard to do well. The cut decides which changes stay inside one team, which calls cross a network, and which data has one owner. This lesson covers the seams (business capabilities and bounded contexts), data ownership, the strangler-fig migration, the symptoms of a wrong cut, and a worked decomposition of an e-commerce monolith.

It builds on Scaling Up, Scaling Out and Splitting (whose scale cube introduces splitting by function), State Gravity, Partitioning and Sharding, Boundaries Are Where Guarantees Die, API Gateway and Queues and Async Work, and does not repeat them. Further reading: Building Microservices (2nd edition) by Sam Newman, chapters "How to Model Microservices" and "Splitting the Monolith".

Why split at all: problems services solve and create

A service boundary is a bet that the cost of a network and a separate deployment is smaller than the cost of coordination inside one codebase. Lewis and Fowler's 2014 article defines a component as a unit of software that is independently replaceable and upgradeable, and describes services as components that run out of process, in contrast to libraries linked into the program. That one property, independent deployment, is what you are buying. Everything else is a consequence.

What the split solves:

What it creates, and this is the half people skip:

The same article reports that some people suggest starting with a monolith, keeping it modular, and splitting once the monolith becomes a problem, and then adds that the advice is not ideal, because a good in-process interface is usually not a good service interface. So treat it as a position, not a law. A sensible default in an interview is a modular monolith unless the prompt gives you a reason to split (many teams, very different scaling profiles, regulatory isolation), and if you split, design the service interface on purpose and do not just lift module boundaries.

What experts say in the room. "What problem is this split solving that a module boundary would not?" The honest answers are a team that cannot release independently, a capability with a different scaling or availability profile, or a security boundary. "It is more modern" is not an answer. What a senior checks: how many teams touch the code today, how often releases are blocked on each other, and whether a module boundary inside the monolith, with the same data rules, would give 80% of the benefit.

Finding the seams: business capabilities and bounded contexts

Cut along what the business does, not along technical layers, because business capabilities usually change more slowly than the code that implements them. Richardson's microservices.io pattern "Decompose by business capability" defines a capability as something a business does in order to generate value, and gives an online store as an example: product catalog management, inventory management, order management and delivery management, each mapping to a service. Its stated forces are cohesion, loose coupling, things that change together packaged together, and a service small enough for a two-pizza team of 6 to 10 people. It also admits the hard part: identifying capabilities needs a real understanding of the business (the page recommends an iterative process for finding bounded contexts). Useful starting points it names are the organisation structure and the high-level domain model.

Lewis and Fowler make the matching organisational point. Teams split by technology layer (UI team, server team, database team) can turn even a simple change into a cross-team project. Teams split by capability own the UI, storage and integrations for their slice, so a typical change stays inside one team.

The second tool is the bounded context from domain-driven design. Fowler's page describes it as a way to divide a large system into parts, each with an internally consistent model, because total unification of the domain model for a large system is not feasible or cost-effective. His example is an electricity utility where "meter" means a grid connection to a location in one part of the business, a connection to a customer in another, and the physical device in a third. The same word, different models. For e-commerce the pattern shows up with Product: in a catalog it is a description with images and attributes, in inventory it is a count in a warehouse bin, in billing it is a price line with a tax class. Forcing one Product table to serve all three is how a shared model becomes a shared bottleneck.

Fowler also says the dominant factor in drawing context boundaries is usually human culture, since a different language calls for a different model. So the seam is often found by listening: where do two groups use the same word to mean different things, or where does one group's vocabulary stop?

From context to service. Microsoft's Azure Architecture Center guidance on identifying microservice boundaries gives an order of work:

  1. Start with a bounded context. A service should not span more than one, and if it mixes domain models you should go back and refine the analysis.
  2. Look at aggregates, which are good candidates because they come from business requirements, have high cohesion, are a boundary of persistence and are loosely coupled.
  3. Treat stateless operations across several aggregates (domain services, such as a workflow) as candidates too.
  4. Then apply non-functional requirements: team size, data type, technology, scalability, availability and security. These can split one service or merge several. In its drone delivery example, a separate ingestion service appears because throughput needs call for a buffer in front of the scheduler, and a delivery history service appears because long-term analytical storage has different needs from in-flight operations.

If you worked through the LLD track, this is Single Responsibility at a larger scale: a service should have one reason to change, and the reason is a business capability.

What experts say in the room. "Show me the nouns that mean different things to different teams." The failure is the layered cut, a "user service" plus an "order service" plus a "validation service" that every feature must touch. What a senior checks: for the last twenty features, how many services each one changed. If the answer is usually one, the cut follows the business. If it is usually three, it follows the technology.

Data goes with the service: one owner per table

A service that shares its database with another service has not been separated, it has been given a second, hidden interface. Richardson's "Database per service" pattern states the rule: each service keeps its persistent data private and exposes it only through its API, and the service's transactions involve only its own database. The database is effectively part of the service's implementation. Without that rule, any team can read or write another team's tables, schema changes become negotiations, and the services must be deployed in a coordinated way. This is State Gravity applied to a cut: the state decides what can really move independently.

Richardson lists three ways to keep relational data private, which the page ranks by overhead (private tables and schema per service lowest) and which this lesson orders by rising isolation:

OptionWhat it isCost
Private tablesEach service owns a set of tables only it touchesLowest overhead, but the boundary is a convention
Schema per serviceEach service has its own schemaLow overhead, ownership is visible
Database server per serviceEach service runs its own serverHighest cost, suits services with high throughput

The page also recommends enforcing the boundary with a separate database user and grants per service, because otherwise developers will always be tempted to bypass a service's API. A convention that is not enforced decays.

What you give up, and the standard replacements. The same page names the drawbacks: transactions that span services become hard, queries that join data across services become hard, and many databases add operational work. Its mitigations:

The Azure guidance adds a boundary check: choose boundaries that avoid data-consistency problems. Sometimes keeping related functionality in one service is the right answer, because strong consistency is needed there. It also notes that strong consistency is not always required and that the benefits of decomposing often outweigh the complexity of eventual consistency.

Worked arithmetic: why the shared database hurts. Take four services reading one orders table. Any column change now has four consumers to coordinate, so a rename needs a multi-release dance (add new column, dual-write, migrate readers, drop old). With private data the order service changes the table alone and keeps its API stable, which is one release. The number of parties that must agree on a change is the coupling, and a shared table sets it to the number of readers.

What experts say in the room. "If two services need the same table, either they are one service or one of them is calling the other's API." The failure is the reporting shortcut: a team gets read access "just for a dashboard" and the table's columns become a public contract nobody agreed to. What a senior checks: database grants per service, and a list of every table with exactly one writing service.

Splitting by team versus splitting by load

There are two honest reasons to cut, people and load, and they give different answers. Mixing them up is how services get chosen for the wrong reason.

Splitting by team follows Conway. Melvin Conway's 1968 Datamation paper, "How Do Committees Invent?", states that design organisations are constrained to produce designs that are copies of their own communication structures. His own later restatement is that an organisation will inevitably produce a design whose structure is a copy of its communication structure. Lewis and Fowler quote the same law in support of cross-functional teams organised around capabilities. Fowler's strangler-fig page adds the warning that unchanged design and process habits recreate the same problems, so a migration has to include organisational change.

The useful reading for design is the inverse: if you want the system to have a certain shape, give the teams that shape, because the interfaces between services will be shaped like the conversations between teams. Worked arithmetic: pairwise communication channels among n people number n(n-1)/2, which is 15 for a team of 6 and 66 for a team of 12. Lewis and Fowler mention Amazon's two-pizza idea, no more than about a dozen people per team, and Richardson's pattern gives 6 to 10. A service owned by 30 people is a monolith with an API.

Splitting by load follows a measured bottleneck. The case is a capability whose shape differs from the rest: video transcoding next to a CRUD catalog, a search tier that needs a different datastore, a payments component that needs stricter isolation. The Azure guidance lists the non-functional factors (scalability, availability, security, data type, technology) that can split a service or merge several. The split is justified by a number: the capability's resource profile, scaling curve or availability target is different enough that a shared deployment wastes money or risks the rest.

Split by teamSplit by load
TriggerTeams block each other on releases or reviewsOne capability saturates a different resource or has a different availability need
EvidenceRelease calendar, review queues, who edits which filesUtilisation per capability, p99, cost per request
Failure if misusedServices shaped like a reorg, not like the domainServices split for scale that never arrives, paid for in network hops
Reversible byMoving ownership, merging servicesRe-merging the deployment

Both can point to the same cut, which is the best case. When they disagree, remember that a load split can often be satisfied by deploying the same module in a second fleet (scale-cube clone with routing), without a new service boundary or a new data owner.

Uber's July 2020 engineering post shows the cost of splitting by team with no domain structure. It reports around 2,200 critical microservices, and says engineers sometimes had to work through about 50 services across 12 teams to find a root cause. The company's response, the Domain-Oriented Microservice Architecture, grouped services into domains (about 70 at the time, with roughly half of them implemented) behind a gateway per domain, and used five layers where each layer may depend only on layers below it. Its Maps organisation put 80 microservices into three domains behind three gateways. Uber labels its reported gains as early data: one early consumer of its extensions architecture cut a feature integration from about three days to about three hours, and platform support costs often dropped by an order of magnitude. That is the company's own claim, not an independent measurement.

What experts say in the room. "Is this a people boundary or a load boundary?" followed by "What is the evidence?" What a senior checks: the team that would own the service, its on-call rota, and the utilisation data that justifies a load split.

Migrating from a monolith: the strangler pattern

Replace a system by growing the new one around the old one, route by route, and keep the old one running until nothing uses it. Fowler's page, dated August 2024, describes the idea: he saw strangler figs, vines that grow around a host tree and eventually replace it, and used that as an analogy for gradually modernising legacy software. He renamed the original "Strangler Application" to "Strangler Fig Application". The alternative, a full rewrite, he says takes a long time, users cannot wait for new features, and existing behaviour is hard to specify.

The mechanism, in the shape that matters for a service split:

  1. Put a routing layer in front. A proxy, API gateway (see API Gateway) or the edge of the monolith sends each request either to the old code or to the new service.
  2. Find a seam. Fowler's page says to look for seams that let you split the legacy system into parts that can be replaced independently. A bounded context with few inbound references and its own data is the best first pick.
  3. Build the new service and its data. Copy or migrate the data it will own. During the transition both sides may need it.
  4. Shift traffic gradually. Route a small percentage or one customer segment to the new path, compare, then raise it.
  5. Delete the old code. The step teams skip. Until the old path is gone you carry both.

The page describes four activities (from Cartwright, Horn and Lewis): understand the outcomes you want, decide how to break the problem into smaller parts, deliver the parts successfully, and change the organisation so this can continue. It also says to build temporary transitional architecture so old and new coexist, and argues the reduced risk and earlier value outweigh the cost of code that gets thrown away.

The hard part is data. A new service that still reads the monolith's tables is not independent. The usual path is stepwise: the service first calls the monolith's data through an adapter, then takes over the writes for its tables, with a sync or dual-write during the overlap, then the monolith reads that data through the new service's API. Each step has a rollback. Boundaries Are Where Guarantees Die explains what is lost in the overlap: during dual-writes there is no atomic commit across the two stores, so the design needs a source of truth for each table at each step and a reconciliation check.

Worked arithmetic: with 12 routes to move and one ready every two weeks, the migration takes about 24 weeks if nothing else changes. The point is that the system works at every one of those weeks, and that you can stop after route 5 if the benefit is gone. A big-bang rewrite offers no such off-ramp.

What experts say in the room. "We migrate by route, with a rollback at each step, and the first one is the context with the fewest inbound dependencies." The failure is the permanent middle: two systems forever because deleting the old path never gets scheduled. What a senior checks: the share of traffic on each side, the owner of each table at this step, and a ticket to delete the old code.

The distributed monolith: signs you cut in the wrong place

A distributed monolith has the deployment of services and the coupling of a monolith, so it pays both costs and collects neither benefit. Uber's 2020 post names it directly: teams modified each other's services and data models, producing "networked monoliths" that had to be deployed together, with slower development and instability for service owners.

Symptoms, each with a check you can run:

SymptomWhat it meansHow to measure
Lockstep deploysService A cannot ship without BFraction of releases that need another service released in the same window
Chatty callsThe boundary splits something that is used togetherCalls from A to B per user request; Lewis and Fowler warn naive RPC leads to chatty communication, and the Azure guidance says over-chatty services may belong together
Shared tables or a shared schema libraryHidden coupling through dataCount of tables with more than one writing service
Cross-service featuresThe cut follows layers, not capabilitiesServices changed per feature
Deep synchronous chainsLatency and availability multiplyCall depth in traces; the Guardian rule that Lewis and Fowler cite is one synchronous call per user request
Cascading failureOne slow service stalls callersRetries and thread pool saturation on callers
Debugging needs many teamsNo clear owner of an outcomeUber's example: about 50 services across 12 teams to find one root cause

Worked arithmetic on chattiness: a page that needs 12 sequential calls at an assumed 5 ms each spends 60 ms in the network before any work, and the p99 of the chain is worse than the sum of medians because each call can hit its own slow tail. The same 12 calls inside one process cost microseconds. The numbers are assumptions, but the shape holds: each hop adds latency, a failure mode and a serialisation step.

Azure's validation list is the checklist to apply before accepting any cut: a single responsibility per service, no chatty calls, a size a small team can build independently, no interdependencies requiring two services to deploy together, no tight coupling, and boundaries that avoid data consistency problems.

Repairs, in order of cost.

  1. Merge services that deploy together or call each other constantly. This is cheap and underused.
  2. Move the data so one owner exists, then replace direct reads with an API or events.
  3. Change the call style: replace a synchronous chain with an event or a precomputed read model (see Queues and Async Work).
  4. Add a domain gateway so many callers see one interface, as Uber did, so platform teams can rewrite internals without forcing callers to migrate.

What experts say in the room. "If you cannot deploy it on its own, it is not a service, it is a module with latency." What a senior checks: the lockstep-release rate, the median and p99 call depth in traces, and the count of tables with more than one writer.

Granularity: too big, too small, how to tell

There is no correct service size, only a size at which the symptoms stop. Lewis and Fowler say they see a range of sizes in practice. The Azure guidance admits that advice to avoid too large and too small is vague, and offers an operational answer: start from the domain model, then validate against the checks above. Its decision rule for doubt is worth memorising: start with more coarse-grained services, because splitting one service into two is easier than refactoring functionality across several existing ones.

Too big looks like:

Too small looks like:

SignalPoints toFirst move
Several teams edit one service weeklyToo bigSplit along the context boundary, with the data
Services always deployed togetherToo smallMerge
One service dominates fleet costLoad boundaryScale separately, or split that capability
Calls per request above single digitsToo small or wrong cutTrace, then merge or batch
Team cannot name what the service is for in one sentenceWrong cutRe-do the domain analysis

Granularity moves with the organisation and over time. Uber's post reports a microservice half-life of about 1.5 years, meaning about half the services churn in that period, which is a reminder that the first cut is never final. Their answer to over-fine granularity was not merging everything, it was putting domains and a gateway around groups of services so that outsiders see one surface. That is a legitimate third option between "big" and "small": keep fine internal services behind a coarse external interface.

Worked arithmetic for a team: if the guidance is that a service be buildable by a small team and a team is 6 to 10 people (Richardson), a company of 60 engineers supports roughly 6 to 10 teams and so roughly that many to a few dozen services, not 200. The ratio is a sanity check, not a rule. A count far above it means services have no real owners.

What experts say in the room. "I would start coarse and split on evidence, because merging is the expensive direction." What a senior checks: services per team, the lockstep-release rate, and the change-per-feature spread from the earlier sections.

Worked example: carving an e-commerce monolith

Take a monolith with 40 engineers, one Postgres database, and a weekly release that is blocked every second week. The task is to choose cuts and defend each with a reason. This is a worked illustration of the method, not a description of any company.

Step 1: find the contexts by vocabulary. Product means a listing with images and attributes to merchandisers, a countable stock unit to the warehouse, and a priced line with tax to finance. Order means a cart being edited to the storefront, a payable commitment to payments, and a parcel to fulfilment. Different words, different models, so these are different contexts.

Step 2: list candidate capabilities (the pattern's own online-store list is the starting point): catalog, inventory, cart and ordering, payment, shipping and delivery, notifications, search.

Step 3: decide each cut and its reason.

ServiceOwns (tables)Reason for the cutNot split because
Catalogproducts, images, attributesMerchandising team releases daily and reads dominate; read model can be cached widelyPricing is part of it until a pricing team exists
Searchits own index, built from catalog eventsDifferent datastore and scaling profile (a load split)Not authoritative for anything; can be rebuilt
Inventorystock levels, reservationsStrong consistency inside it (never oversell); a different owner teamNot merged with catalog, since write patterns differ
Ordering (cart plus order)carts, orders, order linesOne aggregate boundary: an order and its lines change togetherCart and order stay together; splitting them would make every checkout a cross-service call
Paymentpayment attempts, ledger entriesSecurity and audit isolation, an external provider, strict idempotencyNot inside Ordering, because its access rules differ
Fulfilmentshipments, carrier labelsExternal carriers, own team, async by natureNot synchronous in the checkout path
Notificationstemplates, send logLoosely coupled, consumes events only, ideal first strangler pickNot worth a team yet; shares one

Notice two refusals: cart and order stay together because they change together and a split would turn checkout into a chatty chain; pricing stays inside catalog until it has its own owners. A cut you decline is part of the answer.

Step 4: define the interaction style at each seam. Checkout is a business transaction across Ordering, Inventory and Payment, so it is a saga of local transactions with compensation (release the reservation if payment fails), not a distributed transaction. Search and Notifications consume events. Browsing reads go through an API gateway that composes catalog and inventory for the product page.

Step 5: sequence the migration. Strangle Notifications first (few inbound dependencies, own data), then Search (a read-only copy built from events, easy to rebuild), then Catalog, then Payment, with Ordering and Inventory last because they hold the strongest consistency needs. Each step ends with deleting the old code.

Step 6: verify against the checks. For the last twenty features, how many services does each touch? Do any two services deploy together? Does the product page need more than a few calls? Does any table have two writers? If a check fails, merge or move data before adding more services.

Worked arithmetic for the team fit: 40 engineers in teams of about six to eight gives five to seven teams, and the table has seven services, a few of which share a team at first (Notifications and Fulfilment, for example). That is consistent with the sanity check from the granularity section. A plan of 25 services for 40 engineers would not be.

What experts say in the room. "Walk me through why Cart and Order are one service." The strong answer names the aggregate, the change pattern and the chatty chain a split would create. What a senior checks: the services-per-feature spread and the lockstep rate after six months, and re-cuts without ceremony if they are bad.

How to say it in the interview

"I split along business capabilities, which are the bounded contexts of the domain, and I give each service the tables it writes, so no other service reads them. The test of a cut is not how it looks on the diagram, it is whether a typical change stays in one service and one team, and whether a typical request needs few synchronous hops. If every release needs two services deployed together, I have built a distributed monolith and I merge them. I would start from a modular monolith and strangle pieces out only when a team, a scaling profile or a release cadence demands it, and I would start coarse, because splitting a service later is cheaper than merging or moving logic across several."

Checklist

Practice this interactively
Share this lessonWhatsAppLinkedInX

Related lessons