Microservices Architecture & Design

Bounded Contexts & Service Boundaries

18 min Lesson 3 of 12

Bounded Contexts & Service Boundaries

The hardest question in microservices is not "how do I deploy this?" — it is "where do I cut?" Get the service boundaries wrong and you end up with a distributed monolith: all the operational cost of microservices with none of the independence. The tool that guides that cut is Bounded Context, a concept from Eric Evans' Domain-Driven Design (DDD).

What Is a Bounded Context?

A bounded context is a region of your business domain inside which a particular model is defined and consistent. The word "bounded" is key: the same business term can mean something subtly different in two parts of the organisation. In an e-commerce platform, the word Customer means a person with payment credentials to the Billing team, an address to the Shipping team, and a browsing history to the Recommendations team. Each team maintains its own model — its own Customer class with different fields and rules.

DDD insight: One model trying to satisfy all three meanings produces a bloated, ambiguous class full of nullable fields and conditional logic. Bounded contexts let each team own a sharp, minimal model that precisely fits their responsibilities.

A microservice should map to — or be a subdivision of — a single bounded context. This gives you a reliable heuristic: if a change to one business concept requires editing code in two services, your boundary is probably wrong.

Finding Boundaries: Event Storming in Practice

Event Storming is a collaborative workshop technique where you walk through the system as a series of domain events — things that happen in past tense: OrderPlaced, PaymentAuthorized, ItemShipped. Natural clusters of events, the commands that trigger them, and the aggregates that own the state reveal your bounded contexts.

For a simplified e-commerce system, event storming often surfaces these clusters:

  • Catalog — ProductCreated, PriceUpdated, ProductDeactivated
  • Order — CartCreated, OrderPlaced, OrderCancelled
  • Payment — PaymentAuthorized, PaymentFailed, RefundIssued
  • Fulfillment — WarehousePickStarted, ItemShipped, DeliveryConfirmed
  • Identity — UserRegistered, PasswordChanged, AccountLocked

Each cluster is a candidate bounded context and, therefore, a candidate service boundary.

Translating a Bounded Context to a Spring Boot Service

Once you have identified a boundary, the service owns its data model independently. Here is the Order service's aggregate root — notice that it does not hold a full Product object or a full User object. It only stores the IDs it needs from neighbouring contexts, plus a local snapshot of the product name at order time so it is insulated from Catalog changes.

// order-service/src/main/java/com/example/order/domain/Order.java @Entity @Table(name = "orders") public class Order { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; // Reference to the Identity context — ID only, no join across services private Long customerId; @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) private List<OrderLine> lines = new ArrayList<>(); @Enumerated(EnumType.STRING) private OrderStatus status; private Instant placedAt; // ... constructors, domain methods, getters } @Embeddable public class OrderLine { private Long productId; // Catalog context ID only private String productNameSnapshot; // local snapshot — avoids cross-context joins private int quantity; private BigDecimal unitPriceSnapshot; }
Store IDs, not objects. Holding a foreign key into another service's domain is fine. Holding a JPA @ManyToOne that maps to another service's database table is not — it couples your schemas and defeats the purpose of separate deployments.

The Context Map: Relationships Between Services

Bounded contexts do not live in isolation; they interact. DDD names the relationship patterns between them. The two you will encounter most often in microservices are:

  • Customer/Supplier: one context (upstream) publishes data; the other (downstream) consumes it. The upstream sets the contract; the downstream adapts. Example: Catalog (upstream) publishes product events; Order (downstream) subscribes and maintains its own product snapshot.
  • Anti-Corruption Layer (ACL): the downstream wraps the upstream's model in a translation layer so its own model stays clean. In code, this is usually a mapper class or a dedicated port interface.
// order-service: Anti-Corruption Layer for Catalog events @Component public class CatalogEventAdapter { private final ProductSnapshotRepository snapshots; public CatalogEventAdapter(ProductSnapshotRepository snapshots) { this.snapshots = snapshots; } // Translates the Catalog's ProductUpdatedEvent into Order's internal model @KafkaListener(topics = "catalog.product-updated") public void onProductUpdated(ProductUpdatedEvent event) { ProductSnapshot snap = snapshots .findById(event.getProductId()) .orElse(new ProductSnapshot(event.getProductId())); snap.setName(event.getName()); snap.setCurrentPrice(event.getPrice()); snapshots.save(snap); } }

Cohesion and Coupling Heuristics

A useful rule of thumb when evaluating a boundary: code that changes together belongs together (high cohesion); code that does not need to know about each other should not be connected (low coupling). Apply it to service candidates:

  • If a feature request almost always touches only one service, the boundary is good.
  • If every sprint involves a coordinated deploy of three services for a single story, merge them.
  • If a service has grown to thousands of lines and its team debates ownership, split it.
Avoid entity-per-service decomposition. Splitting by technical entity ("UserService", "ProductService", "OrderService" each owning one JPA entity) sounds tidy but often mirrors the database schema rather than the business. Services sliced this way end up as thin wrappers that must be orchestrated together for every operation — the distributed monolith anti-pattern. Always slice by business capability, not by database table.

Security Implications of Boundaries

Service boundaries are also security boundaries. Each service should validate and authorise requests independently — never trust data coming from another service implicitly. In Spring Security 6, a common pattern is to propagate a JWT issued by the Identity service and let each downstream service verify it independently:

// order-service/src/main/java/com/example/order/config/SecurityConfig.java @Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(AbstractHttpConfigurer::disable) .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/actuator/health").permitAll() .anyRequest().authenticated()) .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())); return http.build(); } }

Each service verifies the JWT signature against the Identity service's public key (retrieved from its JWKS endpoint). The Order service extracts the customerId claim — it never calls the Identity service's database directly. This is the security expression of the bounded context principle: each service trusts the token, not a shared session or a cross-service database call.

Summary

A bounded context is a region of your domain model where terminology and rules are consistent. Map each candidate service to a single bounded context, have it own its data independently (store foreign IDs, not shared objects), model inter-context relationships with context maps and anti-corruption layers, and let JWT propagation be your security bridge. Get these boundaries right up front — refactoring them later is one of the costliest operations in a microservices architecture.