Bounded Context Pattern for Microservices

Bounded Context Pattern for Microservices

Introduction

The Bounded Context pattern, derived from Domain-Driven Design (DDD), provides a systematic approach to decomposing complex systems into well-defined service boundaries. Unlike purely technical decomposition, bounded contexts align services with business domain boundaries, creating natural cohesion and reducing coupling.

For principal engineers architecting microservices, understanding and applying bounded contexts is critical for avoiding the distributed monolith anti-pattern and creating truly autonomous, evolvable services.

What is a Bounded Context?

A bounded context is an explicit boundary within which a domain model is defined and applicable. Within this boundary, all terms, definitions, and rules have specific, unambiguous meaning. Outside the boundary, the same terms may have different meanings.

Key characteristics:

When to Use Bounded Contexts

Ideal Scenarios

Warning Signs You Need Better Boundaries

Implementation: Go Example

Here’s a practical example showing two bounded contexts (Order Management and Inventory) in Go:

// Order Context - domain model
package order

import (
    "time"
    "github.com/google/uuid"
)

// Order represents the order aggregate in this bounded context
// "Product" here means "item the customer ordered"
type Order struct {
    ID          uuid.UUID
    CustomerID  uuid.UUID
    Items       []OrderItem
    TotalAmount Money
    Status      OrderStatus
    CreatedAt   time.Time
}

type OrderItem struct {
    ProductID   uuid.UUID  // Reference to Inventory context's concept
    ProductName string     // Denormalized for autonomy
    Quantity    int
    Price       Money
}

type OrderStatus string

const (
    OrderPending   OrderStatus = "pending"
    OrderConfirmed OrderStatus = "confirmed"
    OrderShipped   OrderStatus = "shipped"
)

// OrderService encapsulates business logic within this context
type OrderService struct {
    repo            OrderRepository
    inventoryClient InventoryServiceClient // Anti-corruption layer
    eventPublisher  EventPublisher
}

func (s *OrderService) PlaceOrder(customerID uuid.UUID, items []OrderItem) (*Order, error) {
    // Validate inventory availability by calling Inventory context
    for _, item := range items {
        available, err := s.inventoryClient.CheckAvailability(item.ProductID, item.Quantity)
        if err != nil {
            return nil, err
        }
        if !available {
            return nil, ErrInsufficientInventory
        }
    }

    order := &Order{
        ID:         uuid.New(),
        CustomerID: customerID,
        Items:      items,
        Status:     OrderPending,
        CreatedAt:  time.Now(),
    }

    if err := s.repo.Save(order); err != nil {
        return nil, err
    }

    // Publish domain event for other contexts
    s.eventPublisher.Publish(OrderPlacedEvent{
        OrderID: order.ID,
        Items:   items,
    })

    return order, nil
}
// Inventory Context - separate bounded context
package inventory

// Product in THIS context means "physical item in warehouse"
// Different from Order context's usage
type Product struct {
    ID              uuid.UUID
    SKU             string
    WarehouseID     uuid.UUID
    QuantityOnHand  int
    ReorderLevel    int
    Supplier        Supplier
}

type InventoryService struct {
    repo      ProductRepository
    eventBus  EventBus
}

func (s *InventoryService) CheckAvailability(productID uuid.UUID, quantity int) (bool, error) {
    product, err := s.repo.FindByID(productID)
    if err != nil {
        return false, err
    }
    
    return product.QuantityOnHand >= quantity, nil
}

func (s *InventoryService) ReserveInventory(productID uuid.UUID, quantity int) error {
    product, err := s.repo.FindByID(productID)
    if err != nil {
        return err
    }

    if product.QuantityOnHand < quantity {
        return ErrInsufficientStock
    }

    product.QuantityOnHand -= quantity
    
    if err := s.repo.Update(product); err != nil {
        return err
    }

    s.eventBus.Publish(InventoryReservedEvent{
        ProductID: productID,
        Quantity:  quantity,
    })

    return nil
}

// Event handler for OrderPlacedEvent from Order context
func (s *InventoryService) OnOrderPlaced(event OrderPlacedEvent) {
    for _, item := range event.Items {
        s.ReserveInventory(item.ProductID, item.Quantity)
    }
}

Python/FastAPI Example

# Order Context
from dataclasses import dataclass
from typing import List
from uuid import UUID, uuid4
from datetime import datetime
from enum import Enum

class OrderStatus(Enum):
    PENDING = "pending"
    CONFIRMED = "confirmed"
    SHIPPED = "shipped"

@dataclass
class OrderItem:
    product_id: UUID
    product_name: str  # Denormalized
    quantity: int
    price: float

@dataclass
class Order:
    id: UUID
    customer_id: UUID
    items: List[OrderItem]
    status: OrderStatus
    created_at: datetime

class OrderService:
    def __init__(
        self,
        repository: OrderRepository,
        inventory_client: InventoryServiceClient,
        event_publisher: EventPublisher
    ):
        self.repository = repository
        self.inventory_client = inventory_client
        self.event_publisher = event_publisher
    
    async def place_order(
        self,
        customer_id: UUID,
        items: List[OrderItem]
    ) -> Order:
        # Check inventory across context boundary
        for item in items:
            available = await self.inventory_client.check_availability(
                item.product_id,
                item.quantity
            )
            if not available:
                raise InsufficientInventoryError()
        
        order = Order(
            id=uuid4(),
            customer_id=customer_id,
            items=items,
            status=OrderStatus.PENDING,
            created_at=datetime.utcnow()
        )
        
        await self.repository.save(order)
        
        await self.event_publisher.publish(
            OrderPlacedEvent(order_id=order.id, items=items)
        )
        
        return order

# FastAPI endpoint in Order context
from fastapi import APIRouter, Depends

router = APIRouter(prefix="/orders", tags=["orders"])

@router.post("/", response_model=OrderResponse)
async def create_order(
    request: CreateOrderRequest,
    service: OrderService = Depends(get_order_service)
):
    order = await service.place_order(
        customer_id=request.customer_id,
        items=request.items
    )
    return OrderResponse.from_domain(order)

Context Mapping Patterns

Different contexts communicate through specific patterns:

1. Customer-Supplier

One context (supplier) provides services to another (customer). The supplier must meet customer needs.

2. Conformist

The downstream context conforms to the upstream context’s model without translation.

3. Anti-Corruption Layer (ACL)

A translation layer prevents the upstream context’s model from polluting the downstream context.

// Anti-Corruption Layer example
package order

// InventoryServiceClient translates between contexts
type InventoryServiceClient struct {
    httpClient *http.Client
    baseURL    string
}

// This adapter translates Inventory context's response to Order context's needs
func (c *InventoryServiceClient) CheckAvailability(productID uuid.UUID, quantity int) (bool, error) {
    // Call external Inventory API
    resp, err := c.httpClient.Get(
        fmt.Sprintf("%s/inventory/products/%s/availability?quantity=%d",
            c.baseURL, productID, quantity))
    if err != nil {
        return false, err
    }
    defer resp.Body.Close()

    var inventoryResponse struct {
        InStock       bool   `json:"in_stock"`
        AvailableQty  int    `json:"available_qty"`
        WarehouseCode string `json:"warehouse_code"`
    }

    if err := json.NewDecoder(resp.Body).Decode(&inventoryResponse); err != nil {
        return false, err
    }

    // Translate to what Order context understands (simple boolean)
    return inventoryResponse.InStock && inventoryResponse.AvailableQty >= quantity, nil
}

Trade-offs and Considerations

Benefits

Challenges

Finding the Right Boundaries

  1. Start with domain events: What significant things happen in the business?
  2. Listen to domain experts: Where do they naturally separate concerns?
  3. Follow the terminology: Where does the meaning of terms change?
  4. Identify ownership boundaries: Which teams naturally own which areas?
  5. Look for change patterns: What changes together vs independently?

Anti-Patterns to Avoid

1. Shared Database Across Contexts

Bad: Multiple bounded contexts accessing the same database tables directly. Why: Violates encapsulation, creates hidden coupling, prevents independent deployment. Fix: Each context owns its data; communicate through APIs or events.

2. Anemic Bounded Contexts

Bad: Contexts with no business logic, just CRUD operations. Why: Missing the point of DDD; contexts should encapsulate business rules. Fix: Ensure each context has meaningful business logic, not just data access.

3. Too Many Contexts Too Soon

Bad: Creating dozens of microservices before understanding the domain. Why: Premature optimization; high operational cost without clear benefits. Fix: Start with larger contexts (modular monolith), split when autonomy needed.

Conclusion

The Bounded Context pattern is foundational for successful microservices architecture. It shifts focus from technical decomposition to domain-driven boundaries, resulting in services that truly align with business capabilities.

For principal engineers, mastering bounded contexts means:

Well-defined bounded contexts are the difference between microservices that accelerate delivery and a distributed monolith that multiplies complexity without benefits.