Archiboard

Set B · Services & Boundaries

Bounded contexts before microservices: drawing the seams

Find the seams with language, rate of change and team shape. Draw the context map first, and only then decide what deploys separately.

The meeting that taught me this was in the winter of 2015, lasted forty minutes and produced nothing, because two people were arguing about whether a subscription could be cancelled mid-month. One of them meant the plan a tenant is signed up to, which of course can be changed whenever you like. The other meant the recurring charge line on an invoice, which cannot, because it has already gone out to a customer who has paid it. They agreed on every fact and disagreed on every conclusion, and neither noticed for half an hour.

That is what a seam sounds like before you draw it. Not a technical argument, a vocabulary argument, conducted by people who think they are having a technical argument. When you find one, you have found a place where two models want to live separately, and the cheapest thing you can do is let them.

A bounded context is a piece of the system inside which a word means one thing. That is the whole definition worth carrying around. It is not a service, it does not imply a process boundary, and it certainly does not imply a repository. Microsoft's own guidance walks the same route in order: analyse the domain, define bounded contexts, model inside one context, and only then work out what a service is. Most of the failed splits I have been called in to look at skipped the first three steps and started at the fourth.

Listen for the same word meaning different things

Sit in on support calls and read the internal chat. Write down every noun that appears in more than one team's vocabulary, and for each one ask two people from different teams to define it without looking at each other. At a regulatory news publisher we ran that exercise in 2015 and got four meanings for "article" (a piece of copy an editor writes, a versioned row in the content store, a licensable thing a subscription grants access to, a block in an email alert) and three for "publication".

Those are not misunderstandings to be fixed by a glossary. They are correct, local, useful definitions, and a single shared Article class that satisfies all four is a class with thirty nullable properties and no invariants. The seam runs between the meanings. Put a boundary there and let each side keep its own small, sharp version.

Two more signals: rate of change and team

Language finds most seams. Two others catch what it misses.

Rate of change is the one I trust most now and did not write about at all in 2015. Pull twelve months of commit history and count changes per file per month, grouped by folder. On one product the entitlement rules changed roughly nine times a year, always at renewal season and always under commercial time pressure. The tenant directory next to them changed four times in three years. Anything that forces you to redeploy the entitlement rules in order to add a field to a tenant record is a boundary you drew in the wrong place, and the commit log will tell you before the architecture does.

Team is the third. If two groups of people are constantly editing the same files and constantly needing to talk before they merge, they are one context that has been split across two teams. If one group edits a set of files that nobody else touches for months, that is a context with an owner, whether or not anybody drew it.

A context map with four contexts and one bought system tenant admin subscription = a contract catalogue subscription = a plan billing subscription = a charge notifications subscription = an opt-in U D U D U D published language: catalogue.v1 anti-corruption layer legacy accounting bought, not built one word, four correct meanings
Fig. 1. A context map for a small multi-tenant web application. U is upstream, D is downstream; the arrow points the way influence travels. Only the hatched system, which we did not write, sits behind an anti-corruption layer.

The context map is a document about people

The map above says almost nothing about technology and quite a lot about who has to phone whom. Tenant admin is upstream of both catalogue and billing, so when tenant admin changes its contract, two teams have work to do and tenant admin owes them notice. Catalogue publishes a versioned language that billing consumes, which is the relationship you want between two teams that ship on different weeks: a written contract, versioned, with more than one version alive at a time. Sheet B-06 on this site is about keeping that promise.

Draw the arrows with direction, and label each one with the relationship rather than the protocol. Customer and supplier, where the downstream team gets a say in the upstream team's roadmap. Published language, where the upstream team commits to a stable shape and everybody codes against it. Separate ways, where two contexts share a word and nothing else and you should stop trying to integrate them. The protocol, HTTP or a queue or a method call in the same process, is a detail you can change later. The relationship is not.

The anti-corruption layer, and where it does not belong

The one place I insist on a translation layer is the edge of something I did not write and cannot change. In that product it was the accounting package the finance team had used since before I arrived, whose API spoke in six-character account codes, field names left over from the franc era, and a date format I still do not understand. Left alone, that vocabulary spreads. Within a year you have AccCd on a domain entity and nobody remembers why.

The rule is that exactly one class knows both shapes, and it lives on the downstream side.

// Billing's own idea of an account. It is not the tenant-admin Tenant,
// and it is not the accounting package's Ledger record either.
internal sealed record BillingAccount(Guid Id, string LegalName, VatNumber Vat, PaymentTerms Terms);

// The only type in the solution that knows both vocabularies (.NET 9).
internal sealed class LedgerTranslator(ILedgerClient ledger) : IBillingAccountSource
{
    public async Task<BillingAccount?> FindAsync(Guid tenantId, CancellationToken ct)
    {
        var row = await ledger.GetAccountAsync(LedgerCode.From(tenantId), ct);
        if (row is null) return null;

        return new BillingAccount(
            tenantId,
            row.NAAM.Trim(),
            VatNumber.Parse(row.BTWNR),
            PaymentTerms.FromDays(row.BETTERM ?? 30));
    }
}

Where it does not belong is between two contexts you both own and both maintain. I have seen a team put a translation layer on each side of every internal boundary because the pattern was in the architecture document, and the result was two mappings to keep in step for every field, plus a class of bug where the two disagree. Between contexts you control, agree a published language and share the contract types. Save the translation layer for the things that will never change to suit you.

What I would draw first today

The map, on paper, with four or five boxes and directed arrows, before anybody opens an IDE. Then the seams get implemented as module boundaries inside one deployable, which is sheet B-01, and stay there until one of them earns a process of its own. The order matters more than the outcome: a wrong boundary drawn on paper costs an afternoon, and the same boundary shipped as two applications with two databases cost a quarter in 2015, which is roughly how long it took to admit that "article" had been four things all along.

Drawn from