Published: 2026-06-01 โ€ข Updated: 2026-08-06

Designing Microservices: Domain-Driven Design (DDD) Principles

In enterprise software engineering, transitioning from a monolithic architecture to distributed microservices is rarely a challenge of pure coding. Instead, the primary bottleneck is boundary definition. How do you split a massive, interconnected system into independent, deployable services without creating a distributed monolith? The answer lies in Domain-Driven Design (DDD).

What is Domain-Driven Design (DDD) in Microservices?

Domain-Driven Design (DDD) is an architectural methodology that aligns software development with complex business domains. In a microservices architecture, DDD provides the blueprint for service boundaries through Strategic Design (Subdomains and Bounded Contexts) and Tactical Design (Aggregates, Entities, Value Objects, and Domain Events). By mapping each Bounded Context to a single microservice, organizations ensure high cohesion, loose coupling, and clear transactional boundaries across distributed environments.

This comprehensive guide will take you from the conceptual foundations of Strategic DDD to the hands-on tactical implementation of a production-grade, event-driven microservice using Spring Boot 3.x, Spring Data JPA, and Apache Kafka. We will design and build a highly resilient, scalable Order Fulfillment System that handles concurrency, maintains transactional integrity via the Outbox Pattern, and adheres to strict domain invariants.


Table of Contents


1. What You Will Learn

By the end of this deep-dive guide, you will master:

  • How to partition a complex business domain into Core, Supporting, and Generic Subdomains.
  • How to establish clean, decoupled boundaries using Bounded Contexts and Context Mapping.
  • The architectural mapping of a Bounded Context to a Spring Boot microservice.
  • How to design robust, encapsulated Aggregates that protect business invariants and prevent state corruption.
  • How to write clean, DDD-compliant code in Java 21 and Spring Boot 3.x using records, JPA, and custom domain-event publishers.
  • How to guarantee "at-least-once" message delivery using the Transactional Outbox Pattern with Apache Kafka.
  • How to monitor domain events, track distributed transactions, and handle high-concurrency write operations.

2. Prerequisites

To get the most out of this guide, you should be familiar with:

  • Java 17 or Java 21 (we will use Java 21 features such as records and pattern matching).
  • Spring Boot & Spring Data JPA basics (repositories, entity mapping, transaction management).
  • Microservices Architecture concepts (REST APIs, distributed data, asynchronous communication).
  • Basic messaging concepts with Apache Kafka (topics, producers, consumers).

If you need a refresher on the fundamentals of microservices, please review our introductory lesson: Introduction to Microservices Architecture.


3. Strategic DDD: Defining Microservice Boundaries

Strategic Design is the phase of Domain-Driven Design that focuses on large-scale architecture, business alignment, and system boundaries. It answers the question: "Where do we draw the lines between our services, and how do they talk to each other?"

Ubiquitous Language: The Core of Communication

Before writing a single line of code, developers, domain experts (product owners, business analysts), and QA engineers must establish a Ubiquitous Language. This is a single, rigorous, shared vocabulary used consistently across code, documentation, UI designs, and business discussions.

For example, in a logistics system, does the word "Order" mean the same thing to the sales team as it does to the warehouse staff? To sales, an "Order" is a financial transaction. To the warehouse, it is a "Shipment" or a "Consignment" containing physical items. Attempting to build a single, unified Order class that satisfies both departments leads to a bloated, unmaintainable codebase. Ubiquitous Language forces us to define terms strictly within specific contexts.

Subdomains: Core, Supporting, and Generic

Not all parts of a software system are created equal. DDD categorizes your business domain into three types of subdomains:

Subdomain Type Description Strategic Value Example (E-Commerce & Logistics)
Core Subdomain The primary competitive advantage of the business. Custom-built, highly optimized, and proprietary. High (Differentiates the business) Dynamic Pricing Engine, Automated Route Optimization.
Supporting Subdomain Necessary operations for the business, but not its primary differentiator. Custom-built but simpler. Medium (Standard business logic) Inventory Tracking, Order Management.
Generic Subdomain Standard industry problems with no competitive advantage. Best solved by buying off-the-shelf software or SaaS. Low (Commodity service) User Authentication (OAuth2/Auth0), Billing/Payment Gateway (Stripe).

Bounded Contexts: The Microservice Boundary

A Bounded Context is a conceptual boundary within which a domain model applies. Inside the boundary, all terms in the Ubiquitous Language have a singular, unambiguous meaning. Outside this boundary, the same terms may have different meanings.

In microservices, the gold standard is: One Bounded Context = One Microservice. If you map a single Bounded Context to multiple microservices, you risk creating a distributed monolith due to high coupling. If you map multiple Bounded Contexts into a single microservice, you build a monolithic codebase that is difficult to scale and deploy independently.

Context Mapping: Managing Inter-Service Relationships

When multiple Bounded Contexts interact, we define their relationship using a Context Map. Here are the primary patterns used in enterprise architectures:

  • Shared Kernel: Two contexts share a subset of the domain model and database directly. Avoid this in microservices, as it breaks database encapsulation.
  • Customer-Supplier (Upstream-Downstream): The supplier (upstream) must deliver data to the customer (downstream). The downstream's success depends on the upstream's delivery.
  • Conformist: The downstream context conforms completely to the domain model of the upstream context, accepting its schema directly.
  • Anti-Corruption Layer (ACL): A translation layer implemented in the downstream service. It translates incoming upstream data models into the downstream's clean, native domain model. This is critical when integrating with legacy systems or third-party APIs.
  • Open Host Service (OHS) / Published Language (PL): The upstream service provides a stable, public API (OHS) using a standard format like JSON or Protobuf (PL) that downstream consumers can easily integrate with.

4. Tactical DDD: Modeling the Domain Inside a Microservice

While Strategic DDD defines the boundaries of your microservices, Tactical DDD provides the structural building blocks to model the business logic inside those boundaries.

Entities

An Entity is an object defined not by its attributes, but by a unique, continuous thread of identity. Even if all of its properties change, it remains the same object.

  • Entities have a lifecycle (they are created, updated, and eventually archived or deleted).
  • Entities are mutable, but their state transitions must be strictly controlled through business methods.
  • Examples: A Customer (identified by a unique Customer ID), an Order (identified by an Order ID).

Value Objects

A Value Object is an immutable object that measures, quantifies, or describes a characteristic of a domain concept. It has no identity of its own.

  • Two Value Objects are considered equal if all of their attributes are identical (structural equality).
  • They are completely immutable. To change a Value Object, you replace the entire instance.
  • They enforce self-validation upon creation (they cannot exist in an invalid state).
  • Examples: Money (amount and currency), Address (street, city, zip code), EmailAddress.

Aggregates and Aggregate Roots

An Aggregate is a cluster of associated Entities and Value Objects that are treated as a single transactional unit for data changes. Every Aggregate has a single, designated boundary Entity called the Aggregate Root.

The Golden Rules of Aggregates:
  1. Refer by ID Only: External objects must only hold references to the Aggregate Root's ID, never to internal entities within the Aggregate.
  2. Boundary Control: Only the Aggregate Root can modify the state of its internal entities. No external service can bypass the Root to modify internal state.
  3. Transactional Consistency: A single transaction should only update one Aggregate Root. If other Aggregates need to change as a result, use eventual consistency (via Domain Events).
  4. Protect Invariants: An invariant is a business rule that must always remain true. The Aggregate Root is responsible for enforcing these invariants on every state transition.

Domain Events

A Domain Event is a record of something significant that has occurred in the domain. It is written in the past tense (e.g., OrderCreated, PaymentReceived, ShipmentDispatched). Domain Events are primary drivers of decoupling in event-driven microservices, allowing other Bounded Contexts to react asynchronously to state changes.

Domain Services vs. Application Services

It is crucial to distinguish between these two layers to avoid architectural leakage:

  • Domain Services: Contain pure business logic that spans multiple Aggregates or does not naturally fit inside a single Aggregate Root. They are stateless and do not handle infrastructure concerns (like databases or security).
  • Application Services: Act as orchestrators. They receive requests from the outer world (controllers, message listeners), load the Aggregate from a repository, invoke the Aggregate's business methods, save the Aggregate back to the database, and trigger external communication (like sending emails or publishing events). They do not contain business rules.

5. Enterprise Architecture Workflow & Diagrams

Let's visualize the architecture of our Order Fulfillment System. This system consists of three Bounded Contexts: Order Context, Inventory Context, and Shipping Context. They communicate asynchronously via an Apache Kafka message broker.

Strategic Context Map

+---------------------------------------------------------------------------------+
|                                 CONTEXT MAP                                     |
|                                                                                 |
|  +------------------------+                  +-------------------------------+  |
|  |     Order Context      |                  |       Inventory Context       |  |
|  |    (Core Subdomain)    |                  |    (Supporting Subdomain)     |  |
|  |                        |                  |                               |  |
|  |   [Order Aggregate Root]                  |    [Inventory Aggregate Root] |  |
|  +-----------+------------+                  +---------------+---------------+  |
|              |                                               ^                  |
|              | Publishes                                     | Consumes         |
|              | OrderCreatedEvent                             | OrderCreatedEvent|
|              v                                               |                  |
|    =============================================================                |
|    |                      KAFKA EVENT BUS                      |                |
|    =============================================================                |
|              |                                                                  |
|              | Consumes                                                         |
|              | OrderValidatedEvent                                              |
|              v                                                                  |
|  +-----------+------------+                                                     |
|  |    Shipping Context    |                                                     |
|  |  (Supporting Subdomain)|                                                     |
|  |                        |                                                     |
|  |  [Shipment Aggregate]  |                                                     |
|  +------------------------+                                                     |
+---------------------------------------------------------------------------------+
    

Tactical Aggregate Design: The Order Aggregate

The Order class serves as our Aggregate Root. It encapsulates internal entities (OrderItem) and Value Objects (Address, Money). No external service can access OrderItem directly; all modifications must go through Order.

+-----------------------------------------------------------------------+
|                       ORDER AGGREGATE BOUNDARY                        |
|                                                                       |
|   +---------------------------------------------------------------+   |
|   |                      Order (Aggregate Root)                   |   |
|   |   - orderId: OrderId (UUID)                                   |   |
|   |   - customerId: CustomerId (UUID)                             |   |
|   |   - status: OrderStatus (Enum)                                |   |
|   |   - orderTotal: Money (Value Object)                          |   |
|   |   - shippingAddress: Address (Value Object)                   |   |
|   +-------------------------------+-------------------------------+   |
|                                   |                                   |
|                                   | 1..* (One-to-Many)                |
|                                   v                                   |
|   +---------------------------------------------------------------+   |
|   |                         OrderItem (Entity)                    |   |
|   |   - orderItemId: UUID                                         |   |
|   |   - productId: ProductId (UUID)                               |   |
|   |   - quantity: int                                             |   |
|   |   - price: Money (Value Object)                               |   |
|   +---------------------------------------------------------------+   |
+-----------------------------------------------------------------------+
    

The End-to-End Event-Driven Workflow

Below is the sequence of operations when a customer places an order. Notice how the Outbox Table is used to bridge the database transaction with Kafka messaging securely.

[Customer]        [Order API]       [Order Database]     [Outbox Table]       [Kafka Broker]
    |                  |                    |                  |                    |
    |-- Place Order -->|                    |                  |                    |
    |                  |-- Begin Trans ---->|                  |                    |
    |                  |-- Save Order ----->|                  |                    |
    |                  |-- Save Outbox ----------------------->|                    |
    |                  |-- Commit Trans --->|                  |                    |
    |                  |<-- Success --------|                  |                    |
    |<-- Order ID -----|                                       |                    |
    |                  |                                       |                    |
    |                  | [Outbox Poller Run]                   |                    |
    |                  |-------------------------------------->|                    |
    |                  |<-- Read Unpublished Outbox Events ----|                    |
    |                  |-- Publish OrderCreated Event ----------------------------->|
    |                  |-- Mark Outbox Event as Published ---->|                    |
    

6. Practical Implementation: Spring Boot 3.x & JPA Domain Model

Let's build the Order Bounded Context. We will implement the Aggregate Root, internal Entities, Value Objects, and custom domain exceptions. We use Java 21 features like record for clean, immutable Value Objects.

The Value Objects: Address & Money

Value Objects must be immutable and self-validating. If invalid data is passed to their constructor, they must throw a domain-specific validation exception immediately.

package com.enterprise.order.domain.valueobjects;

import java.math.BigDecimal;

public record Money(BigDecimal amount, String currency) {
    public Money {
        if (amount == null) {
            throw new IllegalArgumentException("Amount cannot be null");
        }
        if (amount.compareTo(BigDecimal.ZERO) < 0) {
            throw new IllegalArgumentException("Amount cannot be negative");
        }
        if (currency == null || currency.isBlank()) {
            throw new IllegalArgumentException("Currency cannot be empty");
        }
    }

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

    public Money multiply(int factor) {
        return new Money(this.amount.multiply(BigDecimal.valueOf(factor)), this.currency);
    }

    public static Money zero(String currency) {
        return new Money(BigDecimal.ZERO, currency);
    }
}
package com.enterprise.order.domain.valueobjects;

public record Address(String street, String city, String state, String zipCode, String country) {
    public Address {
        if (street == null || street.isBlank()) throw new IllegalArgumentException("Street is required");
        if (city == null || city.isBlank()) throw new IllegalArgumentException("City is required");
        if (state == null || state.isBlank()) throw new IllegalArgumentException("State is required");
        if (zipCode == null || zipCode.isBlank()) throw new IllegalArgumentException("Zip code is required");
        if (country == null || country.isBlank()) throw new IllegalArgumentException("Country is required");
    }
}

The OrderItem Entity

The OrderItem has its own identity inside the Aggregate, but it cannot exist or be modified outside the lifecycle of the Order Aggregate Root.

package com.enterprise.order.domain.entities;

import com.enterprise.order.domain.valueobjects.Money;
import jakarta.persistence.*;
import java.util.UUID;

@Entity
@Table(name = "order_items")
public class OrderItem {

    @Id
    @Column(name = "id")
    private UUID id;

    @Column(name = "product_id", nullable = false)
    private UUID productId;

    @Column(name = "quantity", nullable = false)
    private int quantity;

    @Embedded
    @AttributeOverrides({
        @AttributeOverride(name = "amount", column = @Column(name = "price_amount", nullable = false)),
        @AttributeOverride(name = "currency", column = @Column(name = "price_currency", nullable = false))
    })
    private Money price;

    // JPA compliance constructor
    protected OrderItem() {}

    public OrderItem(UUID productId, int quantity, Money price) {
        if (quantity <= 0) {
            throw new IllegalArgumentException("Quantity must be greater than zero");
        }
        this.id = UUID.randomUUID();
        this.productId = productId;
        this.quantity = quantity;
        this.price = price;
    }

    public UUID getId() { return id; }
    public UUID getProductId() { return productId; }
    public int getQuantity() { return quantity; }
    public Money getPrice() { return price; }

    public Money calculateSubtotal() {
        return price.multiply(quantity);
    }
}

The Order Aggregate Root

This class is the gatekeeper of our domain logic. Notice that we use the @Version annotation for Optimistic Locking to prevent concurrent updates from overwriting state changes.

package com.enterprise.order.domain.aggregates;

import com.enterprise.order.domain.entities.OrderItem;
import com.enterprise.order.domain.events.OrderCreatedEvent;
import com.enterprise.order.domain.exceptions.DomainException;
import com.enterprise.order.domain.valueobjects.Address;
import com.enterprise.order.domain.valueobjects.Money;
import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.UUID;

@Entity
@Table(name = "orders")
public class Order {

    public enum OrderStatus {
        PENDING, VALIDATED, PAID, SHIPPED, CANCELLED
    }

    @Id
    @Column(name = "id")
    private UUID id;

    @Column(name = "customer_id", nullable = false)
    private UUID customerId;

    @Enumerated(EnumType.STRING)
    @Column(name = "status", nullable = false)
    private OrderStatus status;

    @Embedded
    @AttributeOverrides({
        @AttributeOverride(name = "amount", column = @Column(name = "total_amount", nullable = false)),
        @AttributeOverride(name = "currency", column = @Column(name = "total_currency", nullable = false))
    })
    private Money orderTotal;

    @Embedded
    @AttributeOverrides({
        @AttributeOverride(name = "street", column = @Column(name = "shipping_street", nullable = false)),
        @AttributeOverride(name = "city", column = @Column(name = "shipping_city", nullable = false)),
        @AttributeOverride(name = "state", column = @Column(name = "shipping_state", nullable = false)),
        @AttributeOverride(name = "zipCode", column = @Column(name = "shipping_zip_code", nullable = false)),
        @AttributeOverride(name = "country", column = @Column(name = "shipping_country", nullable = false))
    })
    private Address shippingAddress;

    @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
    @JoinColumn(name = "order_id")
    private List<OrderItem> items = new ArrayList<>();

    @Version
    private Long version;

    @Transient
    private final List<Object> domainEvents = new ArrayList<>();

    // JPA compliance constructor
    protected Order() {}

    public Order(UUID customerId, Address shippingAddress, String currency) {
        if (customerId == null) {
            throw new DomainException("Customer ID cannot be null");
        }
        this.id = UUID.randomUUID();
        this.customerId = customerId;
        this.shippingAddress = shippingAddress;
        this.status = OrderStatus.PENDING;
        this.orderTotal = Money.zero(currency);
    }

    // Business Method: Add Item & Enforce Invariant
    public void addItem(UUID productId, int quantity, Money price) {
        if (this.status != OrderStatus.PENDING) {
            throw new DomainException("Cannot add items to an order that is not in PENDING status");
        }
        
        // Invariant check: Ensure we do not add items with duplicate product IDs
        boolean itemExists = items.stream().anyMatch(item -> item.getProductId().equals(productId));
        if (itemExists) {
            throw new DomainException("Product already exists in the order. Update quantity instead.");
        }

        OrderItem newItem = new OrderItem(productId, quantity, price);
        this.items.add(newItem);
        recalculateTotal();
    }

    // Business Method: Complete Placement
    public void place() {
        if (items.isEmpty()) {
            throw new DomainException("An order must contain at least one item to be placed");
        }
        this.status = OrderStatus.PENDING;
        
        // Register Domain Event
        this.domainEvents.add(new OrderCreatedEvent(this.id, this.customerId, this.orderTotal));
    }

    // Business Method: Cancel Order
    public void cancel() {
        if (this.status == OrderStatus.SHIPPED || this.status == OrderStatus.PAID) {
            throw new DomainException("Cannot cancel an order that has already been paid or shipped");
        }
        this.status = OrderStatus.CANCELLED;
    }

    private void recalculateTotal() {
        String currency = this.orderTotal.currency();
        this.orderTotal = items.stream()
                .map(OrderItem::calculateSubtotal)
                .reduce(Money.zero(currency), Money::add);
    }

    // Accessors
    public UUID getId() { return id; }
    public UUID getCustomerId() { return customerId; }
    public OrderStatus getStatus() { return status; }
    public Money getOrderTotal() { return orderTotal; }
    public Address getShippingAddress() { return shippingAddress; }
    public List<OrderItem> getItems() { return Collections.unmodifiableList(items); }
    public List<Object> getDomainEvents() { return Collections.unmodifiableList(domainEvents); }
    public void clearDomainEvents() { this.domainEvents.clear(); }
}

The Domain Exception

package com.enterprise.order.domain.exceptions;

public class DomainException extends RuntimeException {
    public DomainException(String message) {
        super(message);
    }
}

7. Reliable Event Publishing: The Transactional Outbox Pattern

In a distributed microservice architecture, saving state to a database and publishing an event to a message broker (like Kafka) must be atomic. If the database save succeeds but the Kafka publisher crashes, your system becomes eventually inconsistent.

To solve this, we implement the Transactional Outbox Pattern. Instead of sending messages directly to Kafka during the transaction, we write the event payload to an outbox_events table within the same database transaction. A background poller then reads this table and publishes the events reliably.

The Outbox Entity

package com.enterprise.order.infrastructure.outbox;

import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.util.UUID;

@Entity
@Table(name = "outbox_events")
public class OutboxEvent {

    @Id
    private UUID id;

    @Column(name = "aggregate_type", nullable = false)
    private String aggregateType;

    @Column(name = "aggregate_id", nullable = false)
    private String aggregateId;

    @Column(name = "event_type", nullable = false)
    private String eventType;

    @Column(name = "payload", nullable = false, columnDefinition = "TEXT")
    private String payload;

    @Column(name = "created_at", nullable = false)
    private LocalDateTime createdAt;

    @Column(name = "processed", nullable = false)
    private boolean processed;

    @Column(name = "processed_at")
    private LocalDateTime processedAt;

    protected OutboxEvent() {}

    public OutboxEvent(UUID id, String aggregateType, String aggregateId, String eventType, String payload) {
        this.id = id;
        this.aggregateType = aggregateType;
        this.aggregateId = aggregateId;
        this.eventType = eventType;
        this.payload = payload;
        this.createdAt = LocalDateTime.now();
        this.processed = false;
    }

    public void markAsProcessed() {
        this.processed = true;
        this.processedAt = LocalDateTime.now();
    }

    // Getters
    public UUID getId() { return id; }
    public String getAggregateType() { return aggregateType; }
    public String getAggregateId() { return aggregateId; }
    public String getEventType() { return eventType; }
    public String getPayload() { return payload; }
    public boolean isProcessed() { return processed; }
}

Outbox Jpa Repository

package com.enterprise.order.infrastructure.outbox;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.List;
import java.util.UUID;

public interface OutboxRepository extends JpaRepository<OutboxEvent, UUID> {
    
    @Query("SELECT o FROM OutboxEvent o WHERE o.processed = false ORDER BY o.createdAt ASC")
    List<OutboxEvent> findUnprocessedEvents();
}

8. Integrating Kafka as the Domain Event Bus

Now, let's write the background scheduler that polls the outbox table, serializes events, dispatches them to Apache Kafka, and marks them as processed upon success. This guarantees at-least-once delivery.

The Kafka Configuration

package com.enterprise.order.infrastructure.config;

import org.apache.kafka.clients.admin.NewTopic;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.common.serialization.StringSerializer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.config.TopicBuilder;
import org.springframework.kafka.core.DefaultKafkaProducerFactory;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.core.ProducerFactory;
import java.util.HashMap;
import java.util.Map;

@Configuration
public class KafkaConfig {

    @Bean
    public NewTopic orderEventsTopic() {
        return TopicBuilder.name("order-events-topic")
                .partitions(3)
                .replicas(1) // Increase replicas in production
                .build();
    }

    @Bean
    public ProducerFactory<String, String> producerFactory() {
        Map<String, Object> configProps = new HashMap<>();
        configProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
        configProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
        configProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
        configProps.put(ProducerConfig.ACKS_CONFIG, "all"); // Max guarantees
        configProps.put(ProducerConfig.RETRIES_CONFIG, 3);
        configProps.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, "true");
        return new DefaultKafkaProducerFactory<>(configProps);
    }

    @Bean
    public KafkaTemplate<String, String> kafkaTemplate() {
        return new KafkaTemplate<>(producerFactory());
    }
}

The Outbox Publisher Scheduler

This scheduler processes unprocessed outbox entries. We wrap the state updates in a Spring transaction to ensure that if Kafka publishing fails, the database update is rolled back, allowing retry attempts on the next execution.

package com.enterprise.order.infrastructure.outbox;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;

@Component
public class OutboxPublisher {

    private static final Logger log = LoggerFactory.getLogger(OutboxPublisher.class);
    private final OutboxRepository outboxRepository;
    private final KafkaTemplate<String, String> kafkaTemplate;

    public OutboxPublisher(OutboxRepository outboxRepository, KafkaTemplate<String, String> kafkaTemplate) {
        this.outboxRepository = outboxRepository;
        this.kafkaTemplate = kafkaTemplate;
    }

    @Scheduled(fixedDelay = 1000) // Polls database every second
    @Transactional
    public void publishPendingEvents() {
        List<OutboxEvent> pendingEvents = outboxRepository.findUnprocessedEvents();

        if (pendingEvents.isEmpty()) {
            return;
        }

        log.info("Found {} pending outbox events to publish", pendingEvents.size());

        for (OutboxEvent event : pendingEvents) {
            try {
                // Publish to Kafka. AggregateId serves as partition key to preserve message ordering.
                kafkaTemplate.send("order-events-topic", event.getAggregateId(), event.getPayload())
                        .get(); // Blocking get guarantees transaction commit only after broker acknowledgment.

                event.markAsProcessed();
                outboxRepository.save(event);
                log.info("Successfully published event ID: {} to Kafka", event.getId());
            } catch (Exception e) {
                log.error("Failed to publish event ID: {}. Retrying on next cycle.", event.getId(), e);
                // We break the loop to prevent out-of-order message processing.
                break;
            }
        }
    }
}

The Application Service: Orchestrating the Flow

This service receives the request, loads or instantiates the Aggregate, executes the domain business logic, maps domain events to outbox events, and persists everything within a single atomic database transaction.

package com.enterprise.order.application;

import com.enterprise.order.application.dto.PlaceOrderRequest;
import com.enterprise.order.domain.aggregates.Order;
import com.enterprise.order.domain.valueobjects.Address;
import com.enterprise.order.domain.valueobjects.Money;
import com.enterprise.order.infrastructure.outbox.OutboxEvent;
import com.enterprise.order.infrastructure.outbox.OutboxRepository;
import com.enterprise.order.infrastructure.repositories.OrderRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.UUID;

@Service
public class OrderApplicationService {

    private final OrderRepository orderRepository;
    private final OutboxRepository outboxRepository;
    private final ObjectMapper objectMapper;

    public OrderApplicationService(OrderRepository orderRepository, 
                                   OutboxRepository outboxRepository, 
                                   ObjectMapper objectMapper) {
        this.orderRepository = orderRepository;
        this.outboxRepository = outboxRepository;
        this.objectMapper = objectMapper;
    }

    @Transactional
    public UUID placeOrder(PlaceOrderRequest request) {
        // 1. Create Address Value Object
        Address shippingAddress = new Address(
                request.street(),
                request.city(),
                request.state(),
                request.zipCode(),
                request.country()
        );

        // 2. Instantiate Aggregate Root
        Order order = new Order(request.customerId(), shippingAddress, request.currency());

        // 3. Add items and enforce domain invariants
        request.items().forEach(item -> {
            order.addItem(item.productId(), item.quantity(), new Money(item.price(), request.currency()));
        });

        // 4. Trigger state transition
        order.place();

        // 5. Persist Aggregate
        orderRepository.save(order);

        // 6. Map Domain Events to Outbox Table
        order.getDomainEvents().forEach(event -> {
            try {
                String payload = objectMapper.writeValueAsString(event);
                OutboxEvent outboxEvent = new OutboxEvent(
                        UUID.randomUUID(),
                        "Order",
                        order.getId().toString(),
                        event.getClass().getSimpleName(),
                        payload
                );
                outboxRepository.save(outboxEvent);
            } catch (Exception e) {
                throw new RuntimeException("Failed to serialize domain event", e);
            }
        });

        // Clear transient events from aggregate to prevent duplicate processing
        order.clearDomainEvents();

        return order.getId();
    }
}

9. Common Architectural Anti-Patterns & Mistakes

When implementing DDD, developers often fall into common traps that compromise the architecture's benefits. Here is how to identify and avoid them:

1. The Anemic Domain Model

This occurs when your Aggregates are simple data bags with nothing but public getters and setters, while the real business logic is implemented in procedural Application Services. This violates encapsulation and makes protecting domain invariants difficult.

Solution: Remove public setters. Force all state changes to occur through explicit business methods on the Aggregate Root (e.g., use order.cancel

About the Author

Naresh Kumar

Naresh Kumar

Senior Java Backend Engineer experienced in Banking, Payments, ISO 20022, Spring Boot, Microservices, Kafka, Docker, Kubernetes, AWS and Cloud Native Systems.

Built enterprise payment solutions, transaction processing systems, API platforms and scalable microservices used in production.

LinkedIn Profile