Drawing Service Boundaries: Business Capabilities, Bounded Contexts and Types of Coupling
A service boundary is drawn not around tables or entities but around business rules that change together: a service holds at most one bounded context and is owned by a single team. The business capability and subdomain approaches, Microsoft's boundary guide, Newman's domain, pass-through, common and content coupling, the entity service anti-pattern, and Customer as three separate models in three contexts.
Version note: The code in this post was built and run with C# 14 and .NET 10.
Guess First
At Bookstore's first service-splitting meeting, this list goes up on the whiteboard: BookService, CustomerService, OrderService, StockService. Each maps to a table and exposes CRUD (Create, Read, Update, Delete) endpoints. It sounds tidy: everything has a place.
Now the first real business request arrives: "During the back-to-school campaign, a customer may buy at most 3 copies of a campaign book." How many of these four services does this change touch? How many network calls happen during checkout? And who owns this rule, in which service? Write down your guess; at the end of the post we'll ask the same question again with boundaries drawn by capability.
Business Capability or Subdomain?
Chris Richardson's pattern catalog on microservices.io gives two main routes to a service boundary. Decompose by business capability: find what the business does to create value (catalog management, order management, inventory management, delivery) and make each one a service candidate. The capability concept comes from business architecture; it often corresponds to a business object and can form nested hierarchies. Decompose by subdomain: build on DDD's (Domain-Driven Design) subdomains and bounded contexts. Richardson presents the two as alternatives; in practice a well-done capability analysis and a well-done subdomain analysis usually arrive at the same map.
What they share is drawing the boundary around the business itself, not around technical roles. In Bookstore, "show books and make them searchable", "take orders and manage their lifecycle", "track and reserve stock", "collect the money", "ship the parcel" and "keep the customer informed" are six capabilities. They match the six candidates from the first post and the map in the DDD series post "Subdomains: Investing in the Right Place with Core, Supporting, and Generic". The difference is the point of view: capability starts from "what does the business do?", subdomain from "which part of this business makes us different, and which part can be bought?". In Bookstore, Ordering's campaign pricing is core and Payment is generic; that split also tells you how much engineering investment each service deserves.
One Service, at Most One Bounded Context
Microsoft's "Identify microservice boundaries" guide in the Azure Architecture Center describes the path from domain model to services in four steps. Start with a bounded context: in general, a microservice's functionality shouldn't span more than one bounded context; if it does, different domain models are getting mixed and you need to go back to the domain analysis. Then look at aggregates: a well-designed aggregate has many characteristics of a well-designed service; it comes from business requirements rather than technical ones, has high functional cohesion, is a persistence boundary and is loosely coupled to the others. Third, domain services can also be candidates, such as a workflow that coordinates several aggregates. Finally, non-functional requirements (team size, scaling, availability, security) may lead you to split a service or merge several.
Note the direction of the rule: a bounded context can be split into several services, but a service should not contain more than one bounded context. In the DDD series post "Bounded Context: Same Word, Different Meanings, and Model Boundaries" we saw the word "book" turn into four separate models in Bookstore's Catalog, Inventory, Ordering and Shipping. Merging Catalog and Inventory into one "BookService" is the service-scale version of squeezing those two models back into a single class. The idea that "an aggregate is a service candidate" needs careful reading too: being a candidate doesn't mean every aggregate should be a separate service. Inside the Ordering bounded context, Order, Cart and Promotion are separate aggregates, but they change together all the time and belong to the same team; making three services out of them invites every coupling problem in the rest of this post.
The guide's most practical sentence comes at the end: when in doubt, start with coarser-grained services. Splitting one service into two is much easier than redistributing functionality across several existing services.
The Three Faces of "Customer"
The CustomerService proposal from the opening is the best illustration of why bounded context separation matters. The word "customer" appears all over Bookstore, but each context wants something different from it. Ordering wants to know whom to send the order to and where. Payment wants to know whom to charge, under which country's rules, and the customer's counterpart at the payment provider. Notification wants to know whom to write to, in which language and over which channel. If you build a single Customer table and a single service managing it as the sum of these three needs, each carries the others' fields and a change by one affects them all.
Instead, when an account is opened an integration event is published and each context takes only what it needs from it to build its own model:
namespace Bookstore.Accounts.Contracts;
// Integration event published when an account is opened: everyone takes only the part they need
public sealed record CustomerRegistered(
Guid CustomerId,
string FullName,
string Email,
string PreferredLanguage,
string Street,
string City,
string Country);
namespace Bookstore.Ordering
{
// The customer for Ordering: whom do I send the order to, and where?
public sealed record Customer(Guid Id, string Name, Address DefaultShippingAddress);
public sealed record Address(string Street, string City, string Country);
}
namespace Bookstore.Payments
{
// The customer for Payments: whom do I charge, under which country's rules?
public sealed record Payer(Guid CustomerId, string BillingCountry, string? ProviderCustomerRef);
}
namespace Bookstore.Notifications
{
// The customer for Notification: whom do I write to, in which language, over which channel?
public sealed record Recipient(Guid CustomerId, string Email, string Language, bool SmsOptIn);
}
using System.Text.Json;
using Bookstore.Accounts.Contracts;
using Bookstore.Notifications;
using Bookstore.Ordering;
using Bookstore.Payments;
CustomerRegistered registered = new(
Guid.Parse("0199f0c4-5c1e-7000-8000-0000000000ad"), "Ada Lovelace", "ada@example.com", "en",
"1 Queen Street", "London", "GB");
// Each context builds its own model from the same event and writes it to its own database
Customer customer = new(registered.CustomerId, registered.FullName,
new Address(registered.Street, registered.City, registered.Country));
Payer payer = new(registered.CustomerId, registered.Country, ProviderCustomerRef: null);
Recipient recipient = new(registered.CustomerId, registered.Email, registered.PreferredLanguage, SmsOptIn: false);
JsonSerializerOptions options = new(JsonSerializerDefaults.Web);
Console.WriteLine($"Ordering : {JsonSerializer.Serialize(customer, options)}");
Console.WriteLine($"Payments : {JsonSerializer.Serialize(payer, options)}");
Console.WriteLine($"Notifications : {JsonSerializer.Serialize(recipient, options)}");
Ordering : {"id":"0199f0c4-5c1e-7000-8000-0000000000ad","name":"Ada Lovelace","defaultShippingAddress":{"street":"1 Queen Street","city":"London","country":"GB"}}
Payments : {"customerId":"0199f0c4-5c1e-7000-8000-0000000000ad","billingCountry":"GB","providerCustomerRef":null}
Notifications : {"customerId":"0199f0c4-5c1e-7000-8000-0000000000ad","email":"ada@example.com","language":"en","smsOptIn":false}
The output shows three separate models, and none carries the others' properties. Notification doesn't know the address, Ordering doesn't know the email, Payment keeps only the billing country and the reference at the provider. If Notification wants to add a "quiet hours" setting to the SMS (Short Message Service) preference tomorrow, the change happens only in Notification's code and database. Ordering doesn't need to be deployed. This is also a win for data minimization: each service holds only the personal data it needs, and the blast radius of a leak shrinks.
There is a price too: the same customer's name lives in three places, and when the customer changes it, all three copies must be updated. This is the first appearance of eventual consistency; the account service publishes a CustomerDetailsChanged event and each context updates its own copy. How copies are kept, and how long they may stay inconsistent, is the subject of the next post.
Newman's Types of Coupling
In the second edition of Building Microservices (O'Reilly, 2021), Sam Newman sorts coupling between services into four types, from loose to tight. The classification gives us a shared vocabulary for discussing whether a boundary is good.
Domain coupling: a service calls another because it needs the functionality the other provides. Ordering going to Inventory to reserve stock is an example. It is entirely unavoidable and acceptable; what you should do is limit the shared information to what is needed.
Pass-through coupling: a service takes data it doesn't use itself only to hand it on to another service. Imagine Ordering giving the shipping address to Inventory, and Inventory carrying that address to Shipping after reserving the stock:
namespace Bookstore.Inventory.Before
{
// Pass-through coupling: Inventory takes shipping data it doesn't use, only to carry it to Shipping
public sealed record ShippingManifest(string RecipientName, string Street, string City, string Country, string Carrier);
public interface IInventoryApi
{
Task ReserveAndForwardAsync(Guid orderId, IReadOnlyList<(string Isbn, int Quantity)> items, ShippingManifest manifest);
}
}
namespace Bookstore.Inventory.After
{
// Inventory only reserves stock; Shipping gets the address from Ordering's OrderPlaced event
public interface IInventoryApi
{
Task<Guid> ReserveAsync(Guid orderId, IReadOnlyList<(string Isbn, int Quantity)> items);
}
}
In the first interface, Inventory has nothing to do with ShippingManifest, yet when Shipping needs a new field for the carrier (a delivery note, say), Inventory's contract must change too. In the second interface Inventory only does its own job; Shipping takes the address itself from the OrderPlaced event Ordering publishes. The change now stays between Ordering and Shipping, with no third party in between.
Common coupling: several services share the same data; a shared database, a shared file system or a shared cache. Shipping and Notification reading order status from the same Orders table is an example. When the table's structure changes, all of them are affected. Newman's suggestion is that one service owns the shared state and changes go only through it; we cover the data side of this as database per service in the next post.
Content coupling: a service reaches into another service and changes its internal state directly. Shipping setting the Status column in the Orders table straight to Delivered when a parcel arrives is the most typical form. Ordering no longer controls what state its own order is in; a cancelled order can turn into "delivered", because the code that enforces that rule has been bypassed. This means the boundary has effectively disappeared. The right way is for Shipping to publish an OrderDelivered event and for Ordering to change its own state according to its own rules.
Beside these four there is also temporal coupling: if two services must be up at the same time, they are bound to each other through time. We cover that in the sixth post, on communication styles.
The Entity Service Anti-Pattern
The list from the opening is exactly the structure Michael Nygard described in "The Entity Service Antipattern", written in December 2017: services that each offer CRUD operations on one business object. Nygard's objection has two sides. The first is semantic coupling: a business rule almost never belongs to a single entity; the "at most 3 copies of a campaign book" rule involves the book, the customer, the order and the campaign together, and whichever entity service it goes into has to call the other three. The second is operational coupling: because entity services sit in the path of almost every request, both load and outages spread to everything; if CustomerService is slow, ordering, payment and notifications slow down at once.
Back to the opening question. In the entity service design it's hard to find an owner for the rule: OrderService enforces it, but to do so it asks BookService whether the book is on campaign, CustomerService for the customer and its own table for past orders; checkout gains at least two new network calls, and where to keep the campaign data turns into a debate. With boundaries drawn by capability the answer is one sentence: campaign and pricing rules are the core subdomain of the Ordering bounded context, the rule lives in Ordering, and the change touches one service. Information that comes from Catalog, such as the book's title and price, Ordering keeps in its own copy.
Validating a Boundary
Once you've drawn a boundary, the most practical way to test it is to ask a few questions. Microsoft's guide gives a list; the three most useful for Bookstore are these.
Does it have a single responsibility? If you can say what the service does in one sentence without "and", that's a good sign. "Inventory tracks and reserves stock" is one business capability; "the customer service manages customers, validates addresses and sends emails" is three.
Can it change independently? Take the change requests of the last few months and mark which services each would touch. If most requests stay in one service, the boundary is in the right place. If two services often change together, either the contract between them is too wide or they should really be one service. In the Introduction to Architecture series post "Conway's Law: How Team Structure Shapes Architecture" we showed how to find files that change together from commit history; the same analysis applies to service candidates.
Are there chatty calls? If one user request needs many round trips between two services, that may be, in the guide's words, a sign that the two functions belong in the same service. An Ordering that makes a separate call to Catalog for every book on the cart page needs either a copy or a batch endpoint.
The guide's list has three more items: a service should be small enough for a small team working independently to build, there should be no dependency that requires two services to be deployed together, and boundaries should be drawn so as to avoid data consistency problems. The last one means keeping rules that need consistency in a single service; it's the subject of the fifth post.
Service Ownership: You Build It, You Run It
The strongest way to protect a boundary isn't technical but organizational: every service should have a single owning team. "You build it, you run it", from Amazon CTO (Chief Technology Officer) Werner Vogels' 2006 conversation with Jim Gray for the ACM (Association for Computing Machinery) magazine Queue, became the summary of this idea: the team that writes a service also runs it in production, wakes up when the alert fires and is in direct contact with the customer. The "Products not Projects" item in Lewis and Fowler's article arrives at the same place.
Ownership directly affects the boundary decision. If you can't give two services to two separate teams, the main reason for them to be separate services weakens. Conversely, if a service has more than one owner, nobody protects its contract; "shared" services turn over time into structures everyone adds to and nobody can remove anything from. The stream-aligned team concept from Matthew Skelton and Manuel Pais's Team Topologies (IT Revolution, 2019) applies directly: a team owns a value stream, and the services it owns line up with that stream's boundaries. We covered the concept itself in the Introduction to Architecture series Conway's Law post; here let's look at its effect.
Bookstore's 12–18 month team goal is four stream-aligned teams and a platform team. Mapped to services: the Catalog & Search team owns Catalog, the Ordering & Payment team owns Ordering and Payment, the Fulfillment team owns Inventory and Shipping, and the Customer Experience team owns Notification and the account side. A team can have several services, but each service has exactly one team. The mapping also raises a question: if Ordering and Payment are in the same team and the context map in the DDD series shows a Partnership between them, is it really necessary to make them separate services from the start? The answer depends on whether Payment needs to deploy separately; if the payment provider integration changes often or needs to scale separately, yes; otherwise the two bounded contexts can start as two separate modules inside the same service. This doesn't bend the "a service holds at most one bounded context" rule; it is a deliberate form of starting coarse-grained, and a decision that keeps splitting easy later.
Common Pitfalls
Turning tables into services. One service per table in the database schema is the entity service anti-pattern itself. The boundary should follow the rules that change together, not the shape of the data.
Forcing one word into one model. Words like "Customer", "Book" and "Order" appear everywhere, but that's no reason to gather them in a single service; each context has its own model, and the mapping between them is done through an explicit contract.
Splitting too early and too fine. Fine boundaries drawn before you know the domain almost always go through the wrong places. Start coarse-grained, and split as real change data accumulates.
The ownerless service. Services where "the platform team looks after it" or "everyone contributes" are the ones whose contracts decay fastest. Every service should have a single, named owner.
Ignoring team structure. Drawing a boundary and leaving the teams as they were pits the boundary against the organization's communication structure. Conway's Law usually wins that fight.
A Small Experiment
★☆☆ Add a fourth customer model to this post's example for Bookstore.Shipping: Consignee. What does shipping need from the customer, and what does it not need? Does the CustomerRegistered event carry that information, and if not, what would you do?
★★☆ Pick one service in a system you work on and list its outgoing dependencies by Newman's four types of coupling. For each common or content coupling you find, propose a fix that moves ownership into a single service.
★★★ Write ten change requests Bookstore might have received in the last six months (coupons, partial returns, a new carrier, SMS notifications…). Mark which services each request touches under two designs: the entity services from the opening and the six capability-based services. In each design, how many requests stayed in a single service?
We've drawn the boundaries; the next question is who owns the data. In the next post we cover database per service, the hidden coupling a shared database creates, and three ways a service can get at another service's data, through how Ordering knows a book's title without asking Catalog.