Skip to main content

03 - Bounded Contexts and Domain Driven Design: Logical Modularity

Introduction: DDD as the Foundation of Modularity Domain Driven Design (DDD) is not just an approach to software modeling: it is the tool...

What do you think about this article?

At a Glance

Introduction: DDD as the Foundation of Modularity Domain Driven Design (DDD) is not just an approach to software modeling: it is the tool...

Introduction: DDD as the Foundation for Modularity

Domain Driven Design (DDD) is not just an approach to software modeling: it is the most powerful tool for identifying the correct module boundaries in a modular monolith. Without DDD, module boundaries become arbitrary, based on technical criteria rather than real domain boundaries. The result is a fragile architecture that does not reflect the business.

In this article we will explore the fundamental concepts of DDD applied to modularization: Bounded Contexts, Ubiquitous Language, Context Mapping, and Aggregate Design. We will see how to translate these concepts into concrete module boundaries and how to implement them in Java/Spring Boot code.

What You Will Learn in This Article

  • DDD fundamentals: entities, value objects, aggregates, and bounded contexts
  • How to identify your application's bounded contexts
  • Ubiquitous Language: why each module has its own vocabulary
  • Context Mapping: Anti-Corruption Layer, Shared Kernel, Customer/Supplier
  • Aggregate Design: the key to consistent transactions
  • How to translate bounded contexts into Java package structure
  • Conway's Law: aligning teams and architecture

DDD Fundamentals for the Modular Monolith

Domain Driven Design introduces a precise vocabulary for modeling software around the business domain. Let us look at the fundamental building blocks:

Entities

An entity is a domain object with a unique identity that persists over time. Two entities are equal if they have the same ID, regardless of their attribute values. Examples: Order, User, Product.

Value Objects

A value object is an immutable object defined by its attributes, with no identity of its own. Two value objects with the same attributes are identical. Examples: Money, Address, EmailAddress.

Aggregates

An aggregate is a cluster of entities and value objects treated as a single unit for modification operations. The aggregate has a root entity serving as the access point, guaranteeing the integrity of internal invariants. Transactions must never cross aggregate boundaries.


// Aggregate Example: Order
// Order is the aggregate root entity
// OrderItem and Money are part of the aggregate

public class Order {
    private final UUID id;               // entity identity
    private UUID userId;
    private List<OrderItem> items;       // child entities
    private Money total;                 // value object
    private OrderStatus status;
    private Instant createdAt;

    // Factory method - only way to create an Order
    public static Order create(UUID userId, List<ProductDto> products) {
        Order order = new Order();
        order.id = UUID.randomUUID();
        order.userId = userId;
        order.items = products.stream()
            .map(p -> OrderItem.fromProduct(p))
            .toList();
        order.total = Money.sum(
            order.items.stream().map(OrderItem::getPrice).toList()
        );
        order.status = OrderStatus.CREATED;
        order.createdAt = Instant.now();
        return order;
    }

    // Invariants are protected within the aggregate
    public void cancel() {
        if (this.status == OrderStatus.SHIPPED) {
            throw new IllegalStateException(
                "Cannot cancel a shipped order"
            );
        }
        this.status = OrderStatus.CANCELLED;
    }
}

// Immutable Value Object
public record Money(BigDecimal amount, Currency currency) {
    public static Money sum(List<Money> values) {
        return new Money(
            values.stream()
                .map(Money::amount)
                .reduce(BigDecimal.ZERO, BigDecimal::add),
            values.get(0).currency()
        );
    }
}
    

Bounded Contexts: Domain Boundaries

A Bounded Context is a linguistic and functional boundary within which a domain model is valid and consistent. Within a bounded context, every term has a precise and unambiguous meaning. Across context boundaries, the same concept can have different meanings.

Concrete example: the concept of "Product" has different meanings in different contexts:

  • In the Catalog: a product has name, description, images, categories
  • In the Warehouse: a product has available quantity, location, reorder threshold
  • In the Order: a product is a line item with unit price, ordered quantity, discount
  • In Shipping: a product has weight, dimensions, fragility

Forcing a single Product model satisfying all these contexts creates a God Class with dozens of attributes, most of which are irrelevant for each specific context.


// WRONG: A single Product model for all contexts
// "God Class" violating the bounded context principle
public class Product {
    // Catalog data
    private String name;
    private String description;
    private List<String> images;
    private Category category;

    // Warehouse data
    private int stockQuantity;
    private String warehouseLocation;
    private int reorderThreshold;

    // Order data
    private BigDecimal unitPrice;
    private BigDecimal discount;

    // Shipping data
    private double weight;
    private Dimensions dimensions;
    private boolean fragile;
    // ... 30+ attributes
}

// CORRECT: One model per bounded context
// Catalog
public class CatalogProduct {
    private UUID id;
    private String name;
    private String description;
    private List<String> images;
    private Category category;
}

// Warehouse
public class InventoryItem {
    private UUID productId;  // reference, not the full object
    private int stockQuantity;
    private String location;
    private int reorderThreshold;
}

// Order
public class OrderLineItem {
    private UUID productId;  // reference
    private Money unitPrice;
    private int quantity;
    private Money discount;
}
    

Fundamental Rule

Each bounded context must have its own model for shared concepts. Contexts communicate through reference IDs, not shared objects. This is the key principle making modules independent and extractable.

Ubiquitous Language: One Vocabulary per Context

The Ubiquitous Language is the shared vocabulary between developers and domain experts within a bounded context. Each context has its own language, and the same term can have different meanings in different contexts.

This concept has practical implications for naming in code:

  • In the Order module: Customer is the person placing the order, with billing address and payment method
  • In the Shipping module: Customer is the recipient, with delivery address and shipping preferences
  • In the Support module: Customer is the ticket opener, with interaction history and priority level

Forcing a single Customer model for all contexts creates coupling and prevents teams from evolving their models independently.

Context Mapping: How Contexts Communicate

Context Mapping defines how bounded contexts interact with each other. Several relationship patterns exist, each with specific trade-offs:

Anti-Corruption Layer (ACL)

The Anti-Corruption Layer is a translation layer isolating a bounded context from another context's model. When the Order module needs data from the Catalog module, it does not directly use Catalog DTOs: it translates them into its own internal model through an ACL.


// Anti-Corruption Layer in the Order module
// Translates Catalog concepts into Order language
package com.ecommerce.order.internal.acl;

@Component
class CatalogAntiCorruptionLayer {

    private final CatalogModuleApi catalogModule;

    // Translates ProductDto (catalog) to OrderProduct (order)
    public OrderProduct resolveProduct(UUID productId) {
        ProductDto catalogProduct = catalogModule.findById(productId)
            .orElseThrow(() -> new ProductNotAvailableException(productId));

        // Translation: takes only what the Order context needs
        return new OrderProduct(
            catalogProduct.id(),
            catalogProduct.name(),
            Money.of(catalogProduct.price(), Currency.EUR),
            catalogProduct.isAvailable()
        );
    }
}

// OrderProduct: internal model of the Order context
// Not the Catalog's ProductDto
record OrderProduct(
    UUID productId,
    String displayName,
    Money price,
    boolean available
) {}
    

Shared Kernel

The Shared Kernel is a small set of code shared between two or more bounded contexts. It typically includes common value objects (like Money, Address), shared domain events, and constants. The shared kernel must be minimal and changed only with consent from all involved teams.


// Shared Kernel: code shared between modules
package com.ecommerce.shared;

// Shared value objects
public record Money(BigDecimal amount, Currency currency) {
    public Money add(Money other) {
        if (!this.currency.equals(other.currency)) {
            throw new CurrencyMismatchException();
        }
        return new Money(this.amount.add(other.amount), this.currency);
    }
}

public record Address(
    String street, String city,
    String zipCode, String country
) {}

// Shared domain events
public record OrderCreatedEvent(
    UUID orderId, UUID userId, Money total, Instant timestamp
) {}
    

Customer/Supplier

In the Customer/Supplier pattern, one context (supplier) provides data or services to another context (customer). The supplier defines the interface but considers the customer's needs. This pattern applies when one module clearly depends on another in a unidirectional manner.

Aggregate Design: Transactions and Consistency

Aggregate design is crucial for a modular monolith because it defines transactional boundaries. An ACID transaction should operate within a single aggregate. Interactions between different aggregates (even within the same module) should be managed with eventual consistency through domain events.

Aggregate Design Rules

  • Small: an aggregate should be as small as possible. Only aggregate what must be consistent in a single transaction
  • Reference by ID: aggregates do not contain direct references to other aggregates, only their IDs
  • One transaction per aggregate: never modify multiple aggregates in the same transaction
  • Eventual consistency between aggregates: use domain events to coordinate changes between different aggregates

// Correct Aggregate Design
// Order Aggregate - transactional boundary
public class Order {
    private UUID id;
    private UUID userId;      // reference by ID, not User object
    private UUID paymentId;   // reference by ID, not Payment object
    private List<OrderItem> items; // part of the aggregate
    private OrderStatus status;

    // Transactional operation: happens within the same aggregate
    @Transactional
    public void addItem(UUID productId, int quantity, Money price) {
        // Verify internal invariants
        if (this.status != OrderStatus.DRAFT) {
            throw new IllegalStateException("Cannot modify confirmed order");
        }
        this.items.add(new OrderItem(productId, quantity, price));
        this.recalculateTotal();
    }
}

// WRONG: transaction spanning multiple aggregates
@Transactional
public void createOrderAndReserveStock(CreateOrderCmd cmd) {
    Order order = orderRepo.save(Order.create(cmd));
    // VIOLATION: modifies another aggregate in the same transaction
    inventoryService.reserveStock(order.getItems());
    // If reserveStock fails, the entire rollback is complex
}

// CORRECT: eventual consistency between aggregates
@Transactional
public void createOrder(CreateOrderCmd cmd) {
    Order order = orderRepo.save(Order.create(cmd));
    // Publish event: the Inventory module will react asynchronously
    events.publish(new OrderCreatedEvent(order.getId(), order.getItems()));
}
    

Identifying Bounded Contexts: A Practical Method

Identifying correct bounded contexts is as much art as science. Here is a practical four-step method:

  1. Event Storming: gather developers and domain experts. Identify all domain events on orange sticky notes. Group events into logical clusters: each cluster is a potential bounded context
  2. Language analysis: identify where the same term has different meanings. Each linguistic divergence indicates a context boundary
  3. Dependency analysis: map dependencies between clusters. Clusters with few external dependencies are good candidates for independent modules
  4. Team validation: verify that identified boundaries reflect organizational structure and team competencies

Conway's Law

Conway's Law states that organizations design systems mirroring their own communication structure. In practice: module boundaries should align with team boundaries. A team responsible for the Order module should not need daily coordination with the Shipping module team to complete their work.

If code structure does not mirror team structure, friction arises: frequent merge conflicts, constant coordination meetings, mutual blocking. Aligning architecture and organization is a prerequisite for modular monolith success.

Next Article

In the next article we will tackle database design for modular monoliths: shared schema vs per-module schema, data ownership, distributed transaction management, and patterns like Saga and Event Sourcing for eventual consistency. We will translate the bounded contexts identified here into concrete data structures.

Frequently asked questions

What is the main difference between an entity and a value object in Domain Driven Design?

An entity has a unique identity that persists over time, while a value object is an immutable object defined by its attributes with no identity of its own.

How do bounded contexts interact with each other according to Context Mapping?

Bounded contexts can interact through several relationship patterns, including the Anti-Corruption Layer, Shared Kernel, and Customer/Supplier pattern.

What is the purpose of an Aggregate in Domain Driven Design?

An aggregate is a cluster of entities and value objects treated as a single unit for modification operations, with a root entity serving as the access point to guarantee the integrity of internal invariants.

Why is it important to align architecture and organization according to Conway's Law?

Aligning architecture and organization helps to reduce friction between teams, such as frequent merge conflicts and constant coordination meetings.

Sources and further reading

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects