00:00

Domain-Driven Design (DDD) in Microservices

Microservices have become a popular way to build scalable and flexible systems. But one question decides whether they succeed: where do you draw the lines between services? Draw them wrong, and you get a tangle of services that cannot change without each other. Domain-Driven Design (DDD) gives you a reliable way to draw those lines around the business itself.

This guide explains DDD in simple terms, from strategic ideas such as bounded contexts and context mapping to tactical building blocks such as entities, value objects, aggregates and domain events, with a complete e-commerce example and formatted Java code.

1. Why Microservices Need Domain-Driven Design

A common pitfall is to split a large application into small, technical services, such as a “User Service”, “Product Service” and “Order Service”, without thinking about how the business actually works. The result is often a tangled web of dependencies:

  • One business change requires changes in several services at once.
  • Services constantly call each other to get the data they need.
  • Teams step on each other’s toes because responsibilities overlap.

This is where Domain-Driven Design comes in. It is not a technology or a framework, but an approach to software design that aligns your code with the business reality it represents. Combined with microservices, DDD provides the blueprint for services that are not just small, but also meaningful, autonomous and resilient.

2. What Is Domain-Driven Design?

At its heart, DDD is a philosophy:

To build software for a complex domain, you must deeply understand the business domain itself, and let that understanding drive the design of your system.

The domain is the area of business your software serves, such as online retail, banking or logistics. DDD provides a set of patterns and a shared vocabulary that bridge the gap between developers and domain experts, the people who know how the business works.

The approach was introduced by Eric Evans in his 2003 book on the subject and has since become one of the most widely used ways to design complex business software.

3. Strategic and Tactical Design

Strategic designTactical design
QuestionHow do we split the system?How do we model the code inside one part?
Main conceptsUbiquitous language, subdomains, bounded contexts, context mapsEntities, value objects, aggregates, domain events, repositories
LevelWhole system and teamsClasses and objects inside one service
Who takes partArchitects, developers and business experts togetherMainly developers, using the agreed language
Effect on microservicesDecides the service boundariesDecides how each service’s business logic is built

For microservices, strategic design matters most: good boundaries make everything else easier. Tactical patterns then keep the code inside each service clean.

4. Ubiquitous Language

The ubiquitous language is a common, precise language shared by the development team and the domain experts. The same words, such as Order, Account or Shipment, are used in conversations, documents, diagrams and code.

Without a ubiquitous languageWith a ubiquitous language
The business says “cancel the order”; the code calls setStatus(4)The code says order.cancel()
“Customer”, “client”, “user” and “account holder” mean the same thing, or maybe notOne agreed word per concept, written down in a glossary
Developers translate every requirement in their headsRequirements map directly to classes and methods

When code reads like the business talks, misunderstandings drop sharply, and domain experts can even review the model with you.

5. Domains and Subdomains

A large domain is split into subdomains: smaller areas of the business. DDD sorts them into three kinds, which helps decide where to invest effort:

Kind of subdomainMeaningE-commerce exampleStrategy
CoreWhat makes the business special and gives it an edgePricing and promotions, recommendationsBuild in-house with your best people and deepest modeling
SupportingNeeded and specific to you, but not a differentiatorOrder management, returnsBuild simply, or customize existing tools
GenericCommon problems every company hasLogin, payments, email sendingBuy or use an existing product or service

6. Bounded Contexts

A bounded context is the cornerstone of DDD. It is a clear boundary within which a particular model, and its part of the ubiquitous language, applies.

The same word can mean different things in different parts of a business. A “Customer” in the Shipping context has a delivery address and delivery preferences. A “Customer” in the Loyalty context has points and a membership level. Instead of forcing one giant Customer class that tries to serve everyone, each context keeps its own model.

A bounded context explicitly says: “Inside this fence, this word means this.”

Same word, different models
+------------------------+     +------------------------+
|   Shipping Context     |     |    Loyalty Context     |
|                        |     |                        |
|  Customer              |     |  Customer              |
|   - deliveryAddress    |     |   - points             |
|   - deliveryWindow     |     |   - membershipLevel    |
+------------------------+     +------------------------+

7. The Perfect Match: DDD and Microservices

The magic happens when we map one bounded context to one microservice. This alignment ensures:

  • High cohesion: everything related to one business capability is packaged together in one service.
  • Loose coupling: each service has a clear boundary and owns its data. Services talk through clean APIs and events, never by reading each other’s databases.
  • Team ownership: one team can own one context end to end.

This prevents the distributed monolith anti-pattern, where services are technically separate but so tightly coupled that a change in one forces changes in many others.

One context per service is a strong starting point, not an absolute rule. A small context can share a deployment with a neighbor, and a very large context with different scaling needs can be split further. The context boundary should never be crossed by shared code or a shared database.

8. A Detailed Example: The E-Commerce Platform

Let us design an online store using DDD principles. Without DDD, we might create a giant User service that handles everything from login to orders to invoices, which quickly becomes a bottleneck. Instead, we talk to the business experts, identify the subdomains, and define our bounded contexts.

8.1 Step 1: identify bounded contexts

Bounded contextResponsibility
Identity & AccessUser registration, login and basic profile: username, password and email
OrderCreating and managing orders: adding items, calculating totals and tracking status
ShippingLogistics: creating shipments, assigning carriers and providing tracking
PaymentProcessing payments, handling transactions and managing invoices

8.2 Step 2: define the ubiquitous language in each context

Notice how similar words have different meanings in different contexts:

  • User in the Identity context: { username, passwordHash, email, verified }. Its main job is authentication.
  • Customer in the Order context: { customerId, shippingAddress, billingAddress }. The password does not matter here, only what is needed to fulfill an order.
  • Order in the Order context: a rich object with a life cycle (Created, Paid, Shipped). It contains order lines and enforces business rules, such as “items cannot be added after payment”.

8.3 Step 3: design the microservices

ServiceExample endpoints
identity-servicePOST /register, POST /login, GET /users/{id}/profile
order-servicePOST /orders, GET /orders/{id}
shipping-servicePOST /shipments, GET /shipments/{id}/tracking
payment-servicePOST /payments

8.4 Step 4: establish context mapping (how they communicate)

Here is how the services work together in a “Place Order” workflow:

  1. The front end calls the order-service to create an order, sending the customerId and the list of items.
  2. The order service needs the customer’s shipping address, which it does not store. It calls the identity service’s profile API and translates the result into its own Customer model, using an anti-corruption layer (section 14).
  3. It calculates the total and saves the order in its own database with the status PENDING_PAYMENT.
  4. It publishes an OrderPlaced event to a message broker such as Kafka or RabbitMQ, containing the order ID, customer ID and total amount.
  5. The payment-service receives the event, processes the payment and publishes a PaymentConfirmed event.
  6. The order-service receives PaymentConfirmed and changes the order status to PAID.
  7. The shipping-service also receives PaymentConfirmed and creates a Shipment in its own database, starting delivery.
Place Order flow
Front end --> order-service --(API + translation)--> identity-service
                  |
                  +-- OrderPlaced --> [ Broker ] --> payment-service
                                                         |
                  +-------- PaymentConfirmed <-----------+
                  |                 |
            order-service     shipping-service
           (status = PAID)   (create Shipment)

9. Context Mapping Patterns

Contexts must work together, and the relationships between them have names. Choosing the right one makes the dependency between teams explicit:

PatternMeaningExample
PartnershipTwo teams plan and release together because they depend on each otherOrder and Payment teams agree on changes jointly
Shared kernelTwo contexts share a small, carefully agreed piece of model or codeA shared Money type; use sparingly
Customer–supplierThe upstream team (supplier) considers the downstream team’s (customer’s) needsIdentity provides the profile API that Order needs
ConformistThe downstream team simply adopts the upstream model as it isUsing a payment provider’s data model directly
Anti-corruption layer (ACL)The downstream team translates the upstream model into its ownOrder translates Identity’s User into its own Customer
Open host service / published languageA context offers a stable, documented API or event format for everyonePayment publishes a versioned PaymentConfirmed event schema
Separate waysTwo contexts do not integrate at allMarketing and Shipping duplicate a little data instead

Drawing a context map, a simple diagram of all contexts and these relationships, is one of the most useful documents a microservices team can have.

10. Tactical Building Blocks

Inside a bounded context, tactical DDD gives you building blocks for modeling the business logic:

Building blockWhat it isExample
EntityAn object with an identity that stays the same while its data changesOrder with ID 1001 stays the same order even when its status changes
Value objectAn immutable object defined only by its values; no identityMoney(49.99, "USD"), Address
AggregateA cluster of objects treated as one unit for changes, with one root entity as the entry pointOrder (root) with its OrderLines
Domain eventSomething important that happened in the domainOrderPlaced, PaymentConfirmed
RepositoryLoads and saves whole aggregates, hiding the databaseOrderRepository
Domain serviceBusiness logic that does not naturally belong to one entityCalculating shipping cost across several rules
FactoryCreates complex aggregates in a valid stateOrder.create(customerId)

Aggregate rules of thumb:

  • Change an aggregate only through its root; never edit an OrderLine directly from outside.
  • One transaction changes one aggregate. Other aggregates are updated afterwards, usually through domain events.
  • Refer to other aggregates by ID, not by holding the object itself.
  • Keep aggregates small: only what must be consistent at the same moment.

11. Example: The Order Aggregate in Java

Let us build the Order aggregate from the Order context. First, two value objects. Records make them short and immutable:

Money.java
package com.example.order.domain;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Objects;

public record Money(BigDecimal amount, String currency) {

    public Money {
        Objects.requireNonNull(amount, "amount is required");
        Objects.requireNonNull(currency, "currency is required");
        if (amount.signum() < 0) {
            throw new IllegalArgumentException("Money cannot be negative");
        }
        amount = amount.setScale(2, RoundingMode.HALF_UP);
    }

    public static Money zero(String currency) {
        return new Money(BigDecimal.ZERO, currency);
    }

    public Money add(Money other) {
        if (!currency.equals(other.currency)) {
            throw new IllegalArgumentException("Cannot add " + other.currency + " to " + currency);
        }
        return new Money(amount.add(other.amount), currency);
    }

    public Money times(int quantity) {
        return new Money(amount.multiply(BigDecimal.valueOf(quantity)), currency);
    }
}
OrderId.java, CustomerId.java, ProductId.java
package com.example.order.domain;

import java.util.UUID;

// Typed IDs stop you from passing a customer ID where an order ID is expected
public record OrderId(UUID value) {
    public static OrderId newId() {
        return new OrderId(UUID.randomUUID());
    }
}

public record CustomerId(String value) {
}

public record ProductId(String value) {
}

An order line is a small value object inside the aggregate, and the order status is an enum:

OrderLine.java, OrderStatus.java
package com.example.order.domain;

public record OrderLine(ProductId productId, int quantity, Money unitPrice) {

    public OrderLine {
        if (quantity <= 0) {
            throw new IllegalArgumentException("Quantity must be at least 1");
        }
    }

    public Money subtotal() {
        return unitPrice.times(quantity);
    }
}

public enum OrderStatus {
    CREATED, PENDING_PAYMENT, PAID, SHIPPED, CANCELLED
}

Now the aggregate root. All business rules live here, written in the ubiquitous language:

Order.java
package com.example.order.domain;

import java.time.Instant;
import java.util.ArrayList;
import java.util.List;

public class Order {

    private final OrderId id;
    private final CustomerId customerId;
    private final String currency;
    private final List<OrderLine> lines = new ArrayList<>();
    private OrderStatus status = OrderStatus.CREATED;
    private final List<Object> domainEvents = new ArrayList<>();

    private Order(OrderId id, CustomerId customerId, String currency) {
        this.id = id;
        this.customerId = customerId;
        this.currency = currency;
    }

    // Factory method: a new order always starts in a valid state
    public static Order create(CustomerId customerId, String currency) {
        return new Order(OrderId.newId(), customerId, currency);
    }

    public void addItem(ProductId productId, int quantity, Money unitPrice) {
        if (status != OrderStatus.CREATED) {
            throw new IllegalStateException("Items cannot be added after the order is placed");
        }
        lines.add(new OrderLine(productId, quantity, unitPrice));
    }

    public void place() {
        if (lines.isEmpty()) {
            throw new IllegalStateException("An order needs at least one item");
        }
        if (status != OrderStatus.CREATED) {
            throw new IllegalStateException("Order " + id.value() + " is already placed");
        }
        status = OrderStatus.PENDING_PAYMENT;
        domainEvents.add(new OrderPlaced(id, customerId, total(), Instant.now()));
    }

    public void markPaid() {
        if (status != OrderStatus.PENDING_PAYMENT) {
            throw new IllegalStateException("Only orders waiting for payment can be paid");
        }
        status = OrderStatus.PAID;
    }

    public void cancel() {
        if (status == OrderStatus.SHIPPED) {
            throw new IllegalStateException("Shipped orders cannot be cancelled");
        }
        status = OrderStatus.CANCELLED;
    }

    public Money total() {
        return lines.stream()
                .map(OrderLine::subtotal)
                .reduce(Money.zero(currency), Money::add);
    }

    // Returns the events raised so far and clears the list
    public List<Object> pullDomainEvents() {
        List<Object> events = List.copyOf(domainEvents);
        domainEvents.clear();
        return events;
    }

    public OrderId id() { return id; }
    public CustomerId customerId() { return customerId; }
    public OrderStatus status() { return status; }
    public List<OrderLine> lines() { return List.copyOf(lines); }
}

Notice what is not there: no public setters, no way to change a line from outside, and no way to put the order into an invalid state. The rule “items cannot be added after payment” from the ubiquitous language is enforced in exactly one place.

12. Repositories and Application Services

The repository is defined in the domain as a simple interface. How it is stored, with JPA, MongoDB or something else, is an infrastructure detail:

OrderRepository.java
package com.example.order.domain;

import java.util.Optional;

public interface OrderRepository {

    Optional<Order> findById(OrderId id);

    void save(Order order);
}

An application service coordinates one use case: it loads or creates aggregates, calls their methods, saves them and publishes their events. It contains no business rules of its own:

PlaceOrderService.java
package com.example.order.application;

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

import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.example.order.domain.*;

@Service
public class PlaceOrderService {

    private final OrderRepository orders;
    private final ApplicationEventPublisher eventPublisher;

    public PlaceOrderService(OrderRepository orders, ApplicationEventPublisher eventPublisher) {
        this.orders = orders;
        this.eventPublisher = eventPublisher;
    }

    @Transactional
    public OrderId placeOrder(String customerId, List<ItemRequest> items) {
        Order order = Order.create(new CustomerId(customerId), "USD");

        for (ItemRequest item : items) {
            order.addItem(new ProductId(item.productId()), item.quantity(),
                    new Money(item.unitPrice(), "USD"));
        }
        order.place();

        orders.save(order);
        order.pullDomainEvents().forEach(eventPublisher::publishEvent);
        return order.id();
    }

    public record ItemRequest(String productId, int quantity, BigDecimal unitPrice) {
    }
}

A typical service is organized in layers: domain (aggregates, value objects, repository interfaces), application (use cases), and infrastructure (database, messaging, REST controllers). The domain depends on nothing else, which keeps it easy to test and change.

13. Domain Events

A domain event records something important that happened, in past tense and in the ubiquitous language:

OrderPlaced.java
package com.example.order.domain;

import java.time.Instant;

public record OrderPlaced(OrderId orderId, CustomerId customerId, Money total, Instant placedAt) {
}

Inside the service, a listener can forward the event to other bounded contexts once the transaction has been committed:

OrderEventsForwarder.java
package com.example.order.infrastructure;

import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

import com.example.order.domain.OrderPlaced;

@Component
public class OrderEventsForwarder {

    private final KafkaTemplate<String, OrderPlacedMessage> kafkaTemplate;

    public OrderEventsForwarder(KafkaTemplate<String, OrderPlacedMessage> kafkaTemplate) {
        this.kafkaTemplate = kafkaTemplate;
    }

    // Runs only if the order was saved successfully
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void on(OrderPlaced event) {
        // Publish a stable, public message format, not the internal domain object
        OrderPlacedMessage message = OrderPlacedMessage.from(event);
        kafkaTemplate.send("order-placed", message.orderId(), message);
    }
}

The published message is part of the context’s published language, so it should be versioned and kept backward compatible. If the application crashes between the commit and sending, the message could be lost; the transactional outbox pattern solves this for critical events. Domain events are the foundation of event-driven architecture and the Saga pattern.

14. Anti-Corruption Layer in Code

The Order context needs a shipping address, which the Identity context provides in its own model. An anti-corruption layer (ACL) translates the foreign model into the Order context’s language, so Identity’s naming and structure never leak into the Order domain:

CustomerTranslator.java
package com.example.order.infrastructure.identity;

import org.springframework.stereotype.Component;

import com.example.order.domain.Address;
import com.example.order.domain.Customer;
import com.example.order.domain.CustomerId;

@Component
public class CustomerTranslator {

    private final IdentityClient identityClient;

    public CustomerTranslator(IdentityClient identityClient) {
        this.identityClient = identityClient;
    }

    public Customer loadCustomer(CustomerId customerId) {
        // Identity's model: names and fields chosen by another team
        IdentityProfileDto profile = identityClient.getProfile(customerId.value());

        // Our model: only what the Order context needs, in our own words
        Address shippingAddress = new Address(
                profile.addressLine1(),
                profile.city(),
                profile.postCode(),
                profile.countryCode());

        return new Customer(customerId, profile.displayName(), shippingAddress);
    }
}

If the Identity team renames a field, only this one class changes. The rest of the Order context, including the Order aggregate, is protected.

15. Finding Boundaries with Event Storming

How do you discover bounded contexts in practice? A popular workshop technique is Event Storming:

  1. Bring developers and domain experts together in front of a long wall or online whiteboard.
  2. Everyone writes domain events on orange sticky notes, in past tense: “Order placed”, “Payment confirmed”, “Parcel delivered”.
  3. Arrange the events on a timeline and discuss gaps and disagreements.
  4. Add the commands that cause events (“Place order”), the actors who trigger them, and the policies that react (“When payment is confirmed, create a shipment”).
  5. Look for clusters of events and changes in language. These clusters are strong candidates for bounded contexts.

A few hours of Event Storming often reveals more about service boundaries than weeks of technical design.

16. Why This DDD-Driven Approach Is Powerful

BenefitWhat it means in practice
AutonomyThe shipping team works independently of the payment team and can choose its own technology and database, as long as it respects the published event formats
ResilienceIf the shipping service is down, its events wait in the broker and are processed when it returns; the rest of the system keeps working
ClarityBoundaries are clear; everyone knows which service owns which part of the business logic
ScalabilityEach service scales on its own; payments can get more resources during a sale while identity stays the same
MaintainabilityCode reads like the business, so new developers and domain experts understand it faster
EvolvabilityA change in one context rarely ripples into others, so the system can grow with the business

17. When DDD Is Overkill

DDD shines in complex domains with rich business rules. It is often overkill when:

  • The application is mostly simple create, read, update and delete screens with little logic.
  • The team is very small and the product is still searching for its purpose.
  • No domain experts are available to build the shared language with.

Even then, the strategic ideas, especially clear boundaries and a shared language, are cheap and helpful. A good path for many teams is a modular monolith organized by bounded contexts, splitting into microservices later when there is a clear reason. See Monolithic vs Microservices Architecture.

18. Common Mistakes to Avoid

MistakeWhat goes wrongBetter approach
Splitting services by technical layer or by entityEvery feature needs changes in several servicesSplit by business capability and bounded context
One shared model for the whole companyA giant Customer class that nobody can change safelyLet each context own its own model
Sharing a database between contextsHidden coupling; services cannot change independentlyEach context owns its data; share through APIs and events
Anemic domain modelEntities are just getters and setters; rules are scattered across servicesPut business rules in aggregates and value objects
Huge aggregatesSlow loading and frequent locking conflictsKeep aggregates small; reference others by ID
Designing without domain expertsThe model reflects developers’ guesses, not the businessModel together, for example with Event Storming
Applying every tactical pattern everywhereLots of ceremony for simple featuresUse rich modeling where the business is complex; keep simple parts simple

19. Summary

Domain-Driven Design is the strategic compass that guides the design of microservices. By first understanding the business, agreeing on a ubiquitous language, and dividing the domain into well-defined bounded contexts, you create services that are truly decoupled, business-aligned and built to evolve. Context maps make the relationships between teams explicit, while tactical patterns such as entities, value objects, aggregates and domain events keep the code inside each service clean and expressive. DDD moves you from simply “splitting a monolith” to intentionally designing a system that mirrors your business, which is the key to microservices that are not just small, but also smart and sustainable. Continue with the Saga pattern to see how bounded contexts cooperate on long-running business processes.