Skip to main content
PProfileMatch

Microservices Boundaries: Bounded Contexts and Service Design

Backend DevelopmentLesson 2 of 45 min read

What you'll learn

  • Explain a bounded context and why the same word can mean different things in each one
  • Run a lightweight event-storming session to find seams in a domain
  • Choose a relationship (customer/supplier, anti-corruption layer, published language) between two contexts
  • Use co-change evidence from version control to test whether a boundary is in the right place

On this page

  1. One word, several models
  2. Finding the seams: a lightweight event storm
  3. How two contexts relate
  4. Test the boundary with evidence, not opinion
  5. Common mistakes and the symptoms they cause
  6. How this shows up in real projects and interviews

On an event-ticketing platform, a meeting stalls on one innocent sentence: "a ticket can be refunded until the day before." The sales team means a purchased entitlement with a price. The gate team means a scannable code that can be used once. The support team means a conversation thread with a customer. All three say "ticket", all three are right, and a single Ticket class trying to satisfy them grows forty fields and three owners. That ambiguity is the best clue to where a service boundary belongs.

One word, several models

A bounded context is a boundary inside which every term has one precise meaning and one model. Outside it, the same word may legitimately mean something else. The team's shared vocabulary inside a context is called its ubiquitous language: the words in conversations, in the code and in the API are the same words. Compare how the three contexts model the thing everyone calls a ticket.

ThreeModelsOfTicket.java
import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;

public class ThreeModelsOfTicket {

    // Sales context: a purchase with a price and refund rules.
    record Order(UUID orderId, String eventId, int seats, BigDecimal totalPaid, Instant refundableUntil) {}

    // Entry context: a scannable pass. It does not care what was paid.
    record Pass(String barcode, String eventId, String gate, boolean alreadyScanned) {}

    // Support context: a customer conversation that refers to the purchase by id only.
    record SupportCase(long caseNumber, UUID orderId, String status) {}

    public static void main(String[] args) {
        UUID id = UUID.randomUUID();
        Order sale = new Order(id, "city-derby-2031", 2, new BigDecimal("84.00"), Instant.parse("2031-05-09T00:00:00Z"));
        Pass pass = new Pass("PX-77310", "city-derby-2031", "North-3", false);
        SupportCase help = new SupportCase(1042, sale.orderId(), "OPEN");
        System.out.println(sale.eventId().equals(pass.eventId()) && help.orderId().equals(sale.orderId()));
    }
}

Output

true

Each record is small because each context only models what it needs. They link through identifiers (eventId, orderId), not through shared objects. That is the shape of a healthy boundary: services refer to each other's things by id and ask for details when they need them.

Finding the seams: a lightweight event storm

Event storming is a workshop technique, and you can run a small version with a whiteboard and sticky notes in an hour. You do not need a methodology certificate; you need the right people in the room (product, support, someone who knows the code).

  1. Write domain events in the past tense on notes, in rough time order: OrderPlaced, PaymentCaptured, PassIssued, PassScanned, RefundGranted.
  2. Under each event, add who or what triggers it (a command, a person, a timer) and what rule must hold.
  3. Look for places where the language shifts. When people start using a different noun for the same thing, or argue about a definition, draw a line there.
  4. Look for events that need strong consistency among themselves (a payment and an order total) and keep those inside one context. Events that only need to be informed eventually can cross a boundary.
  5. Name each region by the business capability it provides (Sales, Entry, Support), not by a technical role.

Here is the resulting picture as a context map. Arrows point from the upstream context (whose decisions the other depends on) to the downstream one.

context-map.txt
  +-----------+   OrderPaid     +------------+
  |   SALES   | --------------> |   ENTRY    |
  | (upstream)|   (event)       | issues the |
  +-----------+                 | passes     |
        |                       +------------+
        | orderId lookups
        v
  +-----------+
  |  SUPPORT  |   reads Sales via a thin
  | cases and |   anti-corruption adapter,
  | refunds   |   never its tables
  +-----------+

How two contexts relate

RelationshipPlain meaningUse when
Customer / supplierUpstream plans work with downstream's needs in mindSame company, teams can negotiate
ConformistDownstream accepts upstream's model as isUpstream will not change (a vendor)
Anti-corruption layerDownstream translates upstream's model into its ownUpstream's model is messy and you refuse to import it
Published languageUpstream publishes a documented, versioned contractMany consumers depend on one producer
Shared kernelTwo teams co-own a small piece of modelRarely; it couples release schedules

Test the boundary with evidence, not opinion

Once services or modules exist, version control tells you whether the seams are right. If two services change together in most pull requests, they are one design with an unnecessary network in the middle. The script below counts how often pairs of services appear in the same merged change. The data is a toy list; in practice you would build it from git log --name-only mapped to service directories.

cochange.py
from collections import Counter
from itertools import combinations

# Each entry is the set of services touched by one merged pull request.
pull_requests = [
    {"sales", "entry"},
    {"sales"},
    {"sales", "entry"},
    {"support"},
    {"sales", "entry", "support"},
    {"entry"},
    {"sales", "entry"},
    {"support", "sales"},
]

pairs = Counter()
for touched in pull_requests:
    for a, b in combinations(sorted(touched), 2):
        pairs[(a, b)] += 1

total = len(pull_requests)
for (a, b), n in pairs.most_common():
    print(f"{a} + {b}: changed together in {n} of {total} PRs")

Output

entry + sales: changed together in 4 of 8 PRs
sales + support: changed together in 2 of 8 PRs
entry + support: changed together in 1 of 8 PRs

Half of all changes touching both Entry and Sales is a red flag. Either the contract between them is leaking detail (fix the API), or part of Sales logic really belongs inside Entry (move it), or they should be merged. The number does not decide for you; it tells you where to look.

Common mistakes and the symptoms they cause

  • Entity-per-service design. Symptom: placing one order requires calls to five services in sequence, and a slow Customer service slows every checkout.
  • Sharing a database or a model library between contexts. Symptom: a field added for Support forces a coordinated release of Sales and Entry.
  • Chatty boundaries. Symptom: rendering one screen needs twenty calls between two services. Either the data belongs together or the consumer should keep its own read copy.
  • Boundaries drawn once and never reviewed. Symptom: the co-change table above keeps showing the same pair, and engineers say "we always have to touch both".
  • Ignoring team structure. Symptom: one service is owned by three teams and nobody feels responsible. Boundaries tend to settle where teams and their languages already separate.

How this shows up in real projects and interviews

Interviewers often hand you a vague domain (ride hailing, food delivery) and ask how you would split it. A strong answer names capabilities, says which data each owns, shows how they reference each other by id, and admits where the line is uncertain. On a real team, boundaries are rarely perfect on the first attempt, so the practical skill is making them cheap to move: keep contracts small, avoid shared tables, and review the co-change data periodically. The patterns here build directly on object-oriented design thinking about cohesion and coupling, scaled up from classes to services.

Try it yourself

Pick a domain you know (a gym, an online exam platform, a car-rental firm). List 10 domain events in time order, mark where vocabulary changes, and propose three bounded contexts with the one entity each calls by a different name. Draw a context map like the one above and label the relationships.

Show a hint

If two people give a different definition of the same word, you have found a candidate boundary. Start from events, not database tables.

Key takeaways

  • A bounded context is a boundary where each term has exactly one meaning; the same word can mean different things in different contexts.
  • Find seams by listing domain events and looking for shifts in language and for sets of events that need strong consistency together.
  • Contexts should refer to each other by identifier and use an anti-corruption layer or published language rather than sharing models or tables.
  • Validate boundaries with data: pairs that change together in many pull requests are probably one design.

Test yourself

A trick from this lesson's ride: When one word means two things to two teams, you have just found a boundary. Four more tricks like it wait along the route, each followed by a question.

Ride this lesson

Frequently asked questions

What is a bounded context in microservices?

A bounded context is a boundary within which a domain model and its vocabulary are consistent. A word such as "ticket" or "customer" can mean different things in different contexts, and each context owns its own model and data. Services are commonly aligned to bounded contexts.

How do you decide the size of a microservice?

Size by capability and ownership, not by lines of code. A good service is something one team can own and change without coordinating with others, with a clear language and its own data. If it constantly changes together with another service, the boundary is probably wrong.

What is an anti-corruption layer?

It is a translation layer that converts another system's model into your own so that its terms and quirks do not leak into your domain. It is useful when integrating with a legacy system, a vendor API or an upstream service whose model you do not want to adopt.