00:00

Bounded Context in Microservices: Drawing Boundaries That Last

Last updated:

A bounded context in microservices is a clear boundary inside which one model and one vocabulary apply, so every term has exactly one meaning. Outside that boundary, the same word, such as "product" or "customer", may mean something else and is modelled differently. Bounded contexts are the most reliable guide for deciding where one microservice ends and the next begins.

Getting this boundary right decides whether your services can change independently, or whether every release turns into a meeting between three teams.

1. Why One Big Model Fails

Picture an online store that sells office equipment. The catalog team thinks of a product as a title, photos and a price. The warehouse team thinks of it as a weight, a shelf location and a stock count. The finance team thinks of it as a tax category and a VAT rate.

If you build one shared Product class for everyone, it slowly collects every field from every team. Nobody fully understands it, and a change for one team can break another. A rename for the catalog breaks a warehouse report. A new tax rule forces a redeployment of the product search.

The bigger problem is not the extra fields; it is meaning. Ask three teams "is this customer active?" and you may get three different answers, all correct within their own team. One shared model can only hold one of those meanings, so the others end up hidden in comments, flags and special cases.

A bounded context solves this by accepting that one universal model does not exist. Instead, each part of the business gets its own model, sized for its own job, inside a clear boundary.

2. What a Bounded Context Contains

The idea comes from Domain-Driven Design (DDD), an approach that shapes software around the business domain and the language of the people who work in it. A bounded context bundles three things:

  • A model: the classes, rules and data that represent the business inside this boundary.
  • A ubiquitous language: the shared vocabulary that developers and business experts use inside the boundary. "Ubiquitous" means it is used everywhere within the context: in conversations, in documents and in class and method names.
  • An explicit boundary: usually one team's code, one deployable unit and one database schema. Inside, terms are precise. At the edge, data is translated.

The usual way to find contexts is to listen for language changes. When the warehouse people say "item" and mean a physical unit with a barcode, while the catalog people say "item" and mean a listing on the website, you have found a boundary.

What happens internally: the boundary is mostly a rule about ownership and translation, not a technology. A context owns its data, and other contexts never read its tables or use its classes directly. Anything that crosses the boundary is translated into the receiver's own terms.

3. How Bounded Contexts Talk to Each Other

Contexts still need each other. An order in Sales must eventually become a parcel in Shipping. A context map shows which contexts exist and how they relate.

Context map of an online storeThe Catalog context provides a REST API, an open host service, that the Sales context uses. Sales publishes an OrderPlaced event that both the Shipping context and the Billing context consume. The external Courier API is upstream of Shipping, and Shipping translates it with an anti-corruption layer. Arrows point from the upstream context to the downstream context. Catalog context Sales context Shipping context Billing context Courier API external REST API open host service OrderPlaced event OrderPlaced event translated by Shipping's ACL
Arrows point from the upstream context, which provides data, to the downstream context, which uses it. Shipping protects its own model with an anti-corruption layer (ACL) when it uses the external courier API.

Three relationship terms appear on almost every context map:

  • Upstream and downstream. The upstream context provides data or decisions; the downstream context depends on them. Upstream changes can hurt downstream, never the reverse.
  • Open host service and published language. The upstream offers a stable, documented API or event format for everyone, instead of custom integrations for each consumer.
  • Anti-corruption layer (ACL). A translation layer on the downstream side that converts the other model into its own, so foreign concepts don't leak in.

In microservices, contexts usually communicate through REST or gRPC calls for questions that need an immediate answer, and through events, such as OrderPlaced, for facts that others react to later.

4. Code Examples

All three programs use only the JDK and Java 21.

4.1 Example 1: The Same Product in Three Contexts

Each context keeps its own Product record with only the fields it needs. They share nothing except the SKU, the stock-keeping unit code that identifies the item everywhere.

ProductInThreeContexts.java
import java.math.BigDecimal;
import java.util.List;

public class ProductInThreeContexts {

    // Catalog context: a product is something customers browse
    static final class Catalog {
        record Product(String sku, String title, List<String> imageUrls, BigDecimal listPrice) {}
    }

    // Warehouse context: the same SKU is a physical item on a shelf
    static final class Warehouse {
        record Product(String sku, int weightGrams, String binLocation, int unitsOnHand) {}
    }

    // Billing context: the same SKU is a taxable line item
    static final class Billing {
        record Product(String sku, String taxCategory, BigDecimal vatRate) {}
    }

    public static void main(String[] args) {
        String sku = "KB-ERGO-02"; // the only thing all three contexts share

        var shown = new Catalog.Product(sku, "Split ergonomic keyboard",
                List.of("/img/kb-ergo-02-front.webp"), new BigDecimal("129.00"));
        var stored = new Warehouse.Product(sku, 1140, "B-07-3", 42);
        var taxed = new Billing.Product(sku, "ELECTRONICS", new BigDecimal("0.19"));

        System.out.println("Catalog   sees: " + shown.title() + " for " + shown.listPrice());
        System.out.println("Warehouse sees: " + stored.unitsOnHand() + " units at " + stored.binLocation()
                + ", " + stored.weightGrams() + " g each");
        System.out.println("Billing   sees: " + taxed.taxCategory() + " taxed at " + taxed.vatRate());
    }
}
Output
Catalog   sees: Split ergonomic keyboard for 129.00
Warehouse sees: 42 units at B-07-3, 1140 g each
Billing   sees: ELECTRONICS taxed at 0.19

In a real system, these three records live in three different services, each with its own database. Notice that none of them is "the real product". Each is correct for its own context.

4.2 Example 2: One Word, Two Meanings

This example shows the problem a shared model would hide. Both contexts look at the same customer facts, but "active" means something different in each.

ActiveCustomerDemo.java
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;

public class ActiveCustomerDemo {

    record CustomerFacts(String id, LocalDate lastPurchase, int unpaidInvoices) {}

    // Marketing context: "active" means the customer bought something recently
    static boolean isActiveForMarketing(CustomerFacts c, LocalDate today) {
        return ChronoUnit.DAYS.between(c.lastPurchase(), today) <= 90;
    }

    // Billing context: "active" means the account is in good standing
    static boolean isActiveForBilling(CustomerFacts c) {
        return c.unpaidInvoices() == 0;
    }

    public static void main(String[] args) {
        LocalDate today = LocalDate.of(2026, 10, 28); // fixed date keeps the output repeatable
        var customers = new CustomerFacts[] {
                new CustomerFacts("C-1", LocalDate.of(2026, 10, 2), 0),
                new CustomerFacts("C-2", LocalDate.of(2026, 3, 15), 0),
                new CustomerFacts("C-3", LocalDate.of(2026, 9, 30), 2)
        };

        System.out.println("Customer | Marketing active | Billing active");
        for (CustomerFacts c : customers) {
            System.out.printf("%-8s | %-16s | %s%n",
                    c.id(), isActiveForMarketing(c, today), isActiveForBilling(c));
        }
    }
}
Output
Customer | Marketing active | Billing active
C-1      | true             | true
C-2      | false            | true
C-3      | true             | false

Customer C-2 has not bought anything for months but owes nothing: inactive for marketing, active for billing. C-3 is the reverse. A single Customer.isActive() method in a shared library would be wrong for one of the teams in both cases. This is the kind of bug that tests rarely catch, because each team's tests use its own meaning.

4.3 Example 3: Translating at the Boundary

When Sales publishes an OrderPlaced event, Shipping must not adopt the Sales model. A small translator, the anti-corruption layer, turns the event into Shipping's own Consignment.

ShippingTranslatorDemo.java
import java.util.List;
import java.util.Map;

public class ShippingTranslatorDemo {

    // Published language: the event Sales promises to keep stable for other contexts
    record OrderPlaced(String orderId, String customerName, String street, String city,
                       String postcode, List<String> skus) {}

    // Shipping's own model: it only cares about parcels and where they go
    record Address(String recipient, String street, String city, String postcode) {}
    record Consignment(String reference, Address deliverTo, int parcels, int totalWeightGrams) {}

    // Anti-corruption layer: the one place that knows the Sales vocabulary
    static final class OrderPlacedTranslator {
        private final Map<String, Integer> weightBySku;

        OrderPlacedTranslator(Map<String, Integer> weightBySku) {
            this.weightBySku = weightBySku;
        }

        Consignment toConsignment(OrderPlaced event) {
            int weight = event.skus().stream()
                    .mapToInt(sku -> weightBySku.getOrDefault(sku, 500)) // unknown SKU: assume 500 g
                    .sum();
            int parcels = (weight + 9_999) / 10_000; // courier limit: 10 kg per parcel, rounded up
            return new Consignment("SHP-" + event.orderId(),
                    new Address(event.customerName(), event.street(), event.city(), event.postcode()), parcels, weight);
        }
    }

    public static void main(String[] args) {
        var translator = new OrderPlacedTranslator(Map.of("KB-ERGO-02", 1_140, "DESK-OAK-160", 31_000));

        var small = new OrderPlaced("A-501", "Lena Ortiz", "12 Harbour Road", "Bristol", "BS1 5TY",
                List.of("KB-ERGO-02"));
        var heavy = new OrderPlaced("A-502", "Omar Haddad", "4 Mill Lane", "Leeds", "LS2 7HZ",
                List.of("DESK-OAK-160", "KB-ERGO-02", "MOUSE-V3"));

        for (OrderPlaced event : List.of(small, heavy)) {
            System.out.println(translator.toConsignment(event));
        }
    }
}
Output
Consignment[reference=SHP-A-501, deliverTo=Address[recipient=Lena Ortiz, street=12 Harbour Road, city=Bristol, postcode=BS1 5TY], parcels=1, totalWeightGrams=1140]
Consignment[reference=SHP-A-502, deliverTo=Address[recipient=Omar Haddad, street=4 Mill Lane, city=Leeds, postcode=LS2 7HZ], parcels=4, totalWeightGrams=32640]

The translator is the only class in Shipping that knows the Sales vocabulary. If Sales changes its internal order model, only the translator may need to change. Shipping's rules, such as the 10 kg limit per parcel, stay safely inside Shipping. The unknown MOUSE-V3 got a default weight of 500 g; in production, you would log or alert on such gaps instead of guessing silently.

5. Bounded Context vs Microservice vs Subdomain

These terms are often mixed up in interviews.

ConceptWhat it isBelongs toTypical size
SubdomainA part of the business, such as shipping or billingThe problem: how the business worksExists whether or not software does
Bounded contextA boundary where one model and language applyThe solution: how we design softwareOften one team's area
MicroserviceA separately deployable processThe deployment: how we run softwareOne context, or part of one
ModuleA package or Maven module inside one applicationThe code structureCan represent a context in a monolith

A common rule of thumb is one bounded context per microservice. It is a starting point, not a law. A large context may be deployed as a few services owned by the same team, and in a modular monolith several contexts live in one deployable application as separate modules.

6. Common Mistakes and Pitfalls

1. Splitting by nouns instead of language. A CustomerService, ProductService and OrderService that each own "the one true" entity usually create chatty services and shared meanings. Split by business capability and language instead.

2. The shared "common-model" library. Putting domain classes in a shared JAR quietly brings back one big model. Every change forces all services to upgrade together. Share technical code, not business models.

3. Leaking the upstream model. Using another context's DTOs directly in your domain code ties your rules to their naming and structure. Translate at the edge, as in Example 3.

4. Contexts that are too small. If one business change always touches three contexts together, the boundaries are wrong. A context should keep most decisions for a feature inside itself.

5. Treating the boundary as a diagram only. If services still read each other's tables, the boundary exists only on paper. A context must own its data.

7. Best Practices

  • Listen to the language. Hold workshops, such as Event Storming, with business experts and note where the meaning of words changes.
  • Give each context one owning team, its own code repository or module, and its own database schema.
  • Name things in the context's own language, even if it differs from other teams' language.
  • Translate at every boundary with small, well-tested translator classes.
  • Publish stable contracts: versioned APIs and events that hide internal models.
  • Refer to other contexts by ID, such as a SKU or customer ID, never by sharing objects.
  • Revisit boundaries as the business changes; moving a responsibility is normal.

8. Summary

  • A bounded context in microservices is a boundary where one model and one vocabulary apply.
  • The same word can mean different things in different contexts, and that is expected.
  • Each context owns its model and data, and is often implemented as one microservice.
  • A context map shows upstream and downstream relationships, APIs and events between contexts.
  • Anti-corruption layers translate foreign models so they don't leak into yours.
  • Avoid shared domain libraries, entity-based splits and contexts that only exist on diagrams.

When you design your next service, start by asking where the language changes. Drawing each bounded context in microservices along those lines gives you boundaries that hold up as the system and the teams grow.