- Published on
- · Updated
Domain-Driven Design with Spring Boot, JPA, and Kafka: Boundaries, Aggregates, and Events
- Authors

- Name
- Maria
Domain-Driven Design is a way to organize software around business meaning and business change. It is most useful when the difficult part of the system is not HTTP, SQL, or Kafka itself, but deciding what the business concepts mean and which rules must always hold.
DDD does not require microservices. It does not require Kafka. It does not require every class to be an entity, value object, factory, specification, and domain service.
A sound design begins with two questions:
- Which part of the business is this model responsible for?
- Which rules must be consistent inside one transaction?
This guide uses Spring Boot 4.1, Java 25, JPA, PostgreSQL, and Kafka. It separates:
- strategic design, which defines business boundaries and relationships;
- tactical design, which models behavior inside one boundary;
- integration, which moves facts between boundaries without sharing internal models.
TL;DR A bounded context is a language and model boundary, not automatically a microservice. An aggregate is a transaction and consistency boundary, not an object graph that should contain everything related to a concept. Keep domain events separate from durable Kafka integration events. Save the aggregate and its outbox message in one PostgreSQL transaction, then publish to Kafka at least once.
Use DDD Where the Domain Is Actually Difficult
DDD is valuable when:
- business terms have several meanings;
- rules interact and change frequently;
- different teams own different parts of the business;
- workflows cannot be represented as simple CRUD;
- correctness depends on invariants, state transitions, or policy;
- integration with other domains must not contaminate the local model.
DDD may add unnecessary ceremony when:
- the service is mostly reference-data CRUD;
- the domain rules are stable and simple;
- the application is a thin adapter over another system;
- the team cannot collaborate with domain experts;
- naming patterns are being applied without a real model.
A system can use rich domain modeling in its core domain and ordinary transaction scripts in supporting areas. Consistency matters more than architectural purity.
Strategic Design Comes Before JPA
Strategic design decides where one model stops and another begins.
Subdomains
A business domain can be divided into:
- Core subdomain: creates meaningful competitive advantage;
- Supporting subdomain: necessary for the business but not differentiating;
- Generic subdomain: common capability that can often use an existing product or standard solution.
Example:
Commerce domain
|- Pricing -> core
|- Order fulfillment -> core
|- Identity -> generic
|- Email delivery -> generic
`- Internal reporting -> supporting
This classification influences where the organization invests modeling effort.
Do not build a highly elaborate aggregate model for every supporting table while leaving the genuinely complex pricing rules in controller code.
Ubiquitous language
The model should use the language of the people who make business decisions.
Weak names:
processData()
updateStatus()
type = 3
flag = true
Domain names:
reserveInventory()
approveRefund()
expireQuote()
customerHasExceededCreditLimit()
The language should appear consistently in:
- conversations;
- diagrams;
- class and method names;
- commands and events;
- tests;
- API and integration contracts.
A ubiquitous language is local to a bounded context. The same word can legitimately mean something different elsewhere.
Bounded context
A bounded context defines where a specific model and vocabulary are valid.
For example, Product may mean:
Catalog context:
description, images, search attributes
Pricing context:
price list, currency, discount eligibility
Inventory context:
stock keeping unit, quantity, reservation
Shipping context:
weight, dimensions, handling class
These should not be forced into one shared Product class.
A bounded context can be implemented as:
- a module in a modular monolith;
- one deployable service;
- several cooperating processes;
- one service that temporarily contains several contexts during migration.
The boundary is conceptual and contractual. Deployment topology is a separate decision.
Do Not Map Every Bounded Context to One Microservice Automatically
“One bounded context equals one microservice” is a useful heuristic only when organizational and operational conditions support it.
Splitting too early can create:
- distributed transactions where one local transaction was enough;
- synchronous call chains;
- duplicated infrastructure;
- unclear event ownership;
- deployment overhead;
- slow refactoring while the domain is still being learned.
A practical starting point is often a modular monolith:
application
|- orders
|- inventory
|- pricing
`- shipping
Each module owns:
- its model;
- its tables or schema conventions;
- its application services;
- its internal events;
- its public API.
A context can become a separate service after its boundary, ownership, load profile, and deployment needs are understood.
Context Maps Describe Relationships
A context map records how bounded contexts depend on each other.
Common relationships include:
Customer–supplier
One context supplies a contract that another consumes. The customer should make its needs visible, but the supplier owns the contract.
Conformist
The downstream context accepts the upstream model without translation. This is inexpensive but increases coupling.
Anti-corruption layer
The downstream context translates the upstream contract into its own concepts.
@Component
public class ShippingOrderTranslator {
public ShipmentRequest translate(
OrderPlacedIntegrationEvent event
) {
return new ShipmentRequest(
ShipmentOrderId.from(
event.orderId()
),
DeliveryAddress.from(
event.shippingAddress()
),
event.lines().stream()
.map(this::toPackageItem)
.toList()
);
}
}
The translator prevents the shipping domain from importing order-domain entities or enums.
Shared kernel
Two contexts share a deliberately small model or library. This requires tight coordination and should remain rare.
A shared Money type can be reasonable. Sharing every entity and repository across services is not a shared kernel; it is one model with unclear ownership.
Published language
Contexts integrate through a documented, stable language such as versioned Kafka events or an API schema.
A Kafka topic is an integration mechanism. It does not replace a context map or clarify ownership by itself.
Context Ownership and Data Ownership
Each bounded context should own its write model.
Avoid:
order-service updates inventory tables directly
inventory-service imports Order JPA entities
shipping-service reads the order database because it is convenient
Prefer:
Order context owns order state
Inventory context owns stock and reservations
Shipping context owns shipments
Integration happens through commands, APIs, or events
A read-only analytics platform may replicate data from several contexts, but it should not become an unofficial write path.
Tactical Design Starts with Invariants
Inside a bounded context, identify rules that must never be temporarily false.
For an order:
Only a draft order can receive new lines.
A confirmed order cannot be edited.
Quantity must be positive.
The total is derived from line prices and quantities.
The same command must not confirm the order twice.
These rules guide the aggregate boundary.
The aggregate should be the smallest cluster that can enforce the invariant in one transaction.
Do not create one aggregate containing:
Order
Customer
Inventory
Payment
Shipment
Product catalog
because those concepts are related. A large aggregate increases lock contention, load size, and coupling.
Project Setup
Use Spring Boot's dependency management rather than pinning Hibernate and Spring Kafka independently.
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.0</version>
<relativePath />
</parent>
<properties>
<java.version>25</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-kafka</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Use Flyway or Liquibase for production schema changes.
spring:
jpa:
hibernate:
ddl-auto: validate
open-in-view: false
open-in-view=false keeps lazy database access inside explicit application transactions instead of allowing controllers or serializers to navigate an aggregate after its transaction has ended.
Model Value Objects by Equality
A value object is defined by its attributes, not by an independent lifecycle.
@Embeddable
public class Money {
@Column(
name = "amount",
nullable = false,
precision = 19,
scale = 2
)
private BigDecimal amount;
@Column(
name = "currency",
nullable = false,
length = 3
)
private String currency;
protected Money() {}
public Money(
BigDecimal amount,
Currency currency
) {
Objects.requireNonNull(amount);
Objects.requireNonNull(currency);
if (amount.signum() < 0) {
throw new IllegalArgumentException(
"Money cannot be negative"
);
}
this.amount = amount
.setScale(
2,
RoundingMode.UNNECESSARY
);
this.currency =
currency.getCurrencyCode();
}
public Money add(Money other) {
requireSameCurrency(other);
return new Money(
amount.add(other.amount),
Currency.getInstance(currency)
);
}
public Money multiply(int quantity) {
if (quantity <= 0) {
throw new IllegalArgumentException(
"Quantity must be positive"
);
}
return new Money(
amount.multiply(
BigDecimal.valueOf(quantity)
),
Currency.getInstance(currency)
);
}
public BigDecimal amount() {
return amount;
}
public String currency() {
return currency;
}
private void requireSameCurrency(
Money other
) {
if (!currency.equals(other.currency)) {
throw new IllegalArgumentException(
"Currency mismatch"
);
}
}
@Override
public boolean equals(Object candidate) {
if (this == candidate) {
return true;
}
if (!(candidate instanceof Money other)) {
return false;
}
return amount.equals(other.amount)
&& currency.equals(other.currency);
}
@Override
public int hashCode() {
return Objects.hash(
amount,
currency
);
}
}
Important characteristics:
- construction validates the value;
- no setter permits partial mutation;
- equality compares attributes;
- money uses
BigDecimal, notdouble; - the domain type contains business operations such as
add()andmultiply().
JPA requires a no-argument constructor, but it can be protected.
Model an Aggregate as a Consistency Boundary
An order line has no lifecycle outside its order in this model, so it is an embeddable value object.
@Embeddable
public class OrderLine {
@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 = "unit_price_amount",
nullable = false,
precision = 19,
scale = 2
)
),
@AttributeOverride(
name = "currency",
column = @Column(
name = "unit_price_currency",
nullable = false,
length = 3
)
)
})
private Money unitPrice;
protected OrderLine() {}
public OrderLine(
UUID productId,
int quantity,
Money unitPrice
) {
if (quantity <= 0) {
throw new IllegalArgumentException(
"Quantity must be positive"
);
}
this.productId =
Objects.requireNonNull(productId);
this.quantity = quantity;
this.unitPrice =
Objects.requireNonNull(unitPrice);
}
public Money subtotal() {
return unitPrice.multiply(quantity);
}
public UUID productId() {
return productId;
}
public int quantity() {
return quantity;
}
}
The aggregate root controls all changes.
@Entity
@Table(name = "sales_orders")
public class Order {
@Id
private UUID id;
@Column(
name = "customer_id",
nullable = false,
updatable = false
)
private UUID customerId;
@Enumerated(EnumType.STRING)
@Column(
name = "status",
nullable = false,
length = 30
)
private OrderStatus status;
@Column(name = "confirmed_at")
private Instant confirmedAt;
@ElementCollection(fetch = FetchType.LAZY)
@CollectionTable(
name = "order_lines",
joinColumns = @JoinColumn(
name = "order_id"
)
)
@OrderColumn(name = "line_position")
private List<OrderLine> lines =
new ArrayList<>();
@Version
private long version;
protected Order() {}
private Order(
UUID id,
UUID customerId
) {
this.id =
Objects.requireNonNull(id);
this.customerId =
Objects.requireNonNull(customerId);
this.status = OrderStatus.DRAFT;
}
public static Order draft(
UUID orderId,
UUID customerId
) {
return new Order(
orderId,
customerId
);
}
public void addLine(
UUID productId,
int quantity,
Money unitPrice
) {
requireStatus(OrderStatus.DRAFT);
lines.add(
new OrderLine(
productId,
quantity,
unitPrice
)
);
}
public OrderConfirmed confirm(
Instant occurredAt
) {
requireStatus(OrderStatus.DRAFT);
if (lines.isEmpty()) {
throw new EmptyOrderException(id);
}
Money total = total();
status = OrderStatus.CONFIRMED;
confirmedAt = occurredAt;
return new OrderConfirmed(
UUID.randomUUID(),
id,
customerId,
total.amount(),
total.currency(),
occurredAt
);
}
public Money total() {
if (lines.isEmpty()) {
throw new EmptyOrderException(id);
}
Money first = lines.getFirst()
.subtotal();
return lines.stream()
.skip(1)
.map(OrderLine::subtotal)
.reduce(
first,
Money::add
);
}
private void requireStatus(
OrderStatus expected
) {
if (status != expected) {
throw new InvalidOrderStateException(
id,
status,
expected
);
}
}
public UUID id() {
return id;
}
public OrderStatus status() {
return status;
}
public long version() {
return version;
}
}
The root exposes behavior, not a mutable getLines() collection. External code cannot add a line without executing the order's rules.
The example assumes one currency per order. A real model should make that rule explicit instead of silently converting or mixing currencies.
Do Not Initialize @Version Manually
Hibernate owns the optimistic-lock version.
Do not write:
this.version = 0L;
as a business action, and do not put expected versions into random setters.
When two transactions modify the same order, the later commit receives an optimistic-lock failure. The application must decide whether to:
- return a conflict;
- reload and ask the user to retry;
- re-execute an idempotent command;
- reject the stale command.
Blindly retrying every aggregate method can apply a business decision to newer state that the original caller never saw.
Reference Other Aggregates by Identity
An order can store:
private UUID customerId;
It should not normally hold a JPA association to the entire customer aggregate owned elsewhere.
Benefits:
- aggregate loading remains bounded;
- transaction boundaries stay visible;
- one context does not navigate and mutate another aggregate accidentally;
- context separation survives a later service split.
The application layer can load another aggregate or call another port when a use case requires coordination.
Repositories Operate on Aggregate Roots
public interface OrderRepository
extends JpaRepository<Order, UUID> {
Optional<Order> findByIdAndCustomerId(
UUID orderId,
UUID customerId
);
}
Do not create a public OrderLineRepository when OrderLine is inside the order aggregate. Doing so lets callers bypass the root's invariants.
Repository interfaces are not automatically domain-pure merely because they are named repositories. Avoid exposing generic persistence operations that the use case should not perform.
For example, a domain-oriented interface can be narrower:
public interface Orders {
Optional<Order> find(OrderId orderId);
void add(Order order);
}
A Spring Data adapter can implement it behind the application boundary.
Separate Domain Services from Application Services
These two names are often confused.
Application service
An application service coordinates one use case:
- loads aggregates;
- starts the transaction;
- invokes domain behavior;
- calls ports;
- stores aggregates;
- appends outbox messages;
- returns a DTO.
It should not contain the core business rule.
@Service
public class ConfirmOrderService {
private final OrderRepository orders;
private final OrderOutboxWriter outbox;
private final EntityManager entityManager;
private final Clock clock;
public ConfirmOrderService(
OrderRepository orders,
OrderOutboxWriter outbox,
EntityManager entityManager,
Clock clock
) {
this.orders = orders;
this.outbox = outbox;
this.entityManager = entityManager;
this.clock = clock;
}
@Transactional
public ConfirmOrderResult confirm(
UUID orderId
) {
Order order = orders.findById(orderId)
.orElseThrow(
() -> new OrderNotFoundException(
orderId
)
);
OrderConfirmed domainEvent =
order.confirm(
clock.instant()
);
entityManager.flush();
outbox.append(
OrderConfirmedIntegrationEvent.from(
domainEvent,
order.version()
)
);
return new ConfirmOrderResult(
order.id(),
order.status(),
order.version()
);
}
}
The aggregate decides whether confirmation is valid. The application service decides how the use case is executed and persisted.
Domain service
A domain service contains a business rule that does not naturally belong to one entity or value object.
public class CreditPolicy {
public CreditDecision evaluate(
CustomerCreditProfile customer,
Money pendingOrderTotal,
Money existingExposure
) {
Money projected =
existingExposure.add(
pendingOrderTotal
);
if (projected.amount()
.compareTo(
customer.creditLimit()
.amount()
) > 0) {
return CreditDecision.rejected(
CreditRejectionReason
.LIMIT_EXCEEDED
);
}
return CreditDecision.approved();
}
}
This service is stateless and expressed in domain terms.
An external payment client is usually not a domain service implementation hidden inside the aggregate transaction. It is an infrastructure adapter behind an application port.
public interface PaymentAuthorizationPort {
PaymentAuthorization authorize(
PaymentAuthorizationRequest request
);
}
Remote I/O introduces timeouts and ambiguous outcomes. Keep it visible in the application workflow rather than pretending it is an ordinary in-memory domain method.
Domain Events Are Business Facts
A domain event describes something meaningful that already happened.
public record OrderConfirmed(
UUID eventId,
UUID orderId,
UUID customerId,
BigDecimal totalAmount,
String currency,
Instant occurredAt
) {}
Good event names are past tense:
OrderConfirmed
InventoryReserved
QuoteExpired
RefundApproved
Commands express intent:
ConfirmOrder
ReserveInventory
ExpireQuote
ApproveRefund
Do not use one generic OrderUpdated event for every change. Consumers cannot understand which business fact occurred without reinterpreting a mutable snapshot.
Domain Event and Integration Event Are Not the Same Contract
A domain event belongs to the local model. An integration event is a published contract for other bounded contexts.
public record OrderConfirmedIntegrationEvent(
UUID eventId,
int schemaVersion,
UUID orderId,
UUID customerId,
BigDecimal totalAmount,
String currency,
long aggregateVersion,
Instant occurredAt
) {
public static OrderConfirmedIntegrationEvent from(
OrderConfirmed event,
long aggregateVersion
) {
return new OrderConfirmedIntegrationEvent(
event.eventId(),
1,
event.orderId(),
event.customerId(),
event.totalAmount(),
event.currency(),
aggregateVersion,
event.occurredAt()
);
}
}
The integration event should contain enough stable information for its consumers without serializing the JPA entity.
Do not publish:
- lazy proxies;
- bidirectional object graphs;
- internal enum ordinals;
- repository-generated implementation details;
- fields that another context must not know;
- an internal class name as the public event type.
A local domain event can change with the model. A published integration event needs compatibility and lifecycle management.
Spring Data Domain Events Are In-Process Events
Spring Data supports aggregate-root events through @DomainEvents, @AfterDomainEventPublication, and AbstractAggregateRoot.
That support is useful for in-process reactions:
update an internal projection
run a local policy
record an audit detail
notify another module in the same process
It is not by itself a durable Kafka publication mechanism.
Repository event publication depends on repository method invocation. A process can still crash after the database commit and before an external Kafka send completes.
For cross-context delivery, use a transactional outbox or another durable change-data-capture boundary.
Persist Integration Intent with the Aggregate
Outbox table:
CREATE TABLE integration_outbox (
id UUID PRIMARY KEY,
aggregate_type VARCHAR(100) NOT NULL,
aggregate_id UUID NOT NULL,
aggregate_version BIGINT NOT NULL,
event_type VARCHAR(150) NOT NULL,
schema_version INTEGER NOT NULL,
payload JSONB NOT NULL,
status VARCHAR(30) NOT NULL DEFAULT 'READY',
attempts INTEGER NOT NULL DEFAULT 0,
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
published_at TIMESTAMPTZ,
UNIQUE (
aggregate_type,
aggregate_id,
aggregate_version,
event_type
)
);
CREATE INDEX idx_integration_outbox_ready
ON integration_outbox (
next_attempt_at,
created_at
)
WHERE status = 'READY';
Writer:
@Repository
public class OrderOutboxWriter {
private final JdbcClient jdbc;
private final ObjectMapper objectMapper;
public OrderOutboxWriter(
JdbcClient jdbc,
ObjectMapper objectMapper
) {
this.jdbc = jdbc;
this.objectMapper = objectMapper;
}
public void append(
OrderConfirmedIntegrationEvent event
) {
JsonNode payload =
objectMapper.valueToTree(event);
jdbc.sql("""
INSERT INTO integration_outbox (
id,
aggregate_type,
aggregate_id,
aggregate_version,
event_type,
schema_version,
payload
)
VALUES (
:id,
'ORDER',
:aggregateId,
:aggregateVersion,
'OrderConfirmed',
:schemaVersion,
CAST(:payload AS jsonb)
)
""")
.param("id", event.eventId())
.param(
"aggregateId",
event.orderId()
)
.param(
"aggregateVersion",
event.aggregateVersion()
)
.param(
"schemaVersion",
event.schemaVersion()
)
.param(
"payload",
payload.toString()
)
.update();
}
}
The order mutation and outbox insert use the same PostgreSQL transaction.
The relay publishes the outbox row to Kafka after commit. It may publish the same event more than once if it crashes after Kafka accepts the record but before the outbox row is marked published.
That is an at-least-once boundary. Consumers must be idempotent.
Key Kafka Records by Aggregate Identity
ProducerRecord<String, String> record =
new ProducerRecord<>(
"orders.events.v1",
event.orderId().toString(),
payload
);
record.headers().add(
"event-id",
event.eventId()
.toString()
.getBytes(StandardCharsets.UTF_8)
);
kafkaTemplate.send(record)
.get(10, TimeUnit.SECONDS);
Using the aggregate ID as the key keeps events for one order in one partition when all producers use the same topic and partitioning rule.
Kafka guarantees order within a partition, not across the entire topic.
The aggregateVersion remains useful for:
- duplicate detection;
- stale event rejection;
- missing-version monitoring;
- rebuilding projections;
- operator replay.
Do not use the JPA version as a universal ordering token across different aggregates or bounded contexts.
Consume Through an Anti-Corruption Layer
The inventory context should not import the order aggregate.
@Component
public class OrderEventTranslator {
public InventoryReservationRequest translate(
OrderConfirmedIntegrationEvent event
) {
return new InventoryReservationRequest(
ReservationReference.from(
event.orderId()
),
event.eventId(),
event.occurredAt()
);
}
}
Consumer:
@Component
public class OrderConfirmedListener {
private final ProcessedEventRepository processed;
private final InventoryApplicationService inventory;
private final OrderEventTranslator translator;
@KafkaListener(
topics = "orders.events.v1",
groupId = "inventory-order-events-v1"
)
@Transactional
public void onOrderConfirmed(
OrderConfirmedIntegrationEvent event
) {
boolean firstDelivery =
processed.tryInsert(
event.eventId()
);
if (!firstDelivery) {
return;
}
inventory.reserve(
translator.translate(event)
);
}
}
The processed-event insert and inventory mutation must share one local transaction.
A consumer should not catch every exception and return normally. Let the listener container retry according to policy, then route unrecoverable failures to a dead-letter path with operational ownership.
Eventual Consistency Must Be Visible
After an order is confirmed, inventory may not be reserved immediately.
The product behavior should define what users and operators observe:
Order status: CONFIRMED_PENDING_RESERVATION
Inventory result: pending
Timeout action: cancel, compensate, or escalate
Retry policy: durable and bounded
Do not hide asynchronous work behind a final-looking status such as COMPLETED.
A domain model should express intermediate states when they matter to the business.
Aggregates Do Not Coordinate Distributed Transactions
One aggregate transaction can guarantee local invariants.
It cannot guarantee:
Order confirmed
Inventory reserved
Payment captured
Shipment created
as one atomic commit across services.
Use:
- an orchestrated workflow;
- event-driven choreography;
- compensating actions;
- idempotent participants;
- durable timeout handling;
- reconciliation.
DDD helps define who owns each decision. It does not remove distributed-systems failure modes.
Keep the Module Boundary Visible in Code
A package structure can reflect the bounded context without forcing every layer into a global technical package.
com.example.commerce.orders
|- domain
| |- Order
| |- OrderLine
| |- Money
| |- OrderConfirmed
| `- Orders
|
|- application
| |- ConfirmOrderService
| |- ConfirmOrderCommand
| `- ConfirmOrderResult
|
|- infrastructure
| |- JpaOrderRepository
| |- OrderOutboxWriter
| `- KafkaOrderEventRelay
|
`- web
|- OrderController
`- OrderResponse
Avoid a project-wide structure such as:
controller/
service/
repository/
entity/
dto/
when it scatters one business capability across the entire codebase.
The domain package should not import controllers, HTTP DTOs, Kafka serializers, or database-specific adapters.
JPA annotations inside the domain model are a trade-off, not an automatic DDD violation. The important question is whether persistence concerns distort business behavior.
Avoid Persistence-Driven Aggregate Design
Common JPA-driven mistakes include:
Exposing every relationship as an association
@ManyToOne
private Customer customer;
@ManyToOne
private Product product;
@OneToOne
private Payment payment;
This encourages accidental cross-aggregate navigation and large persistence graphs.
Use identities when the related object is not inside the aggregate.
Using entity equals() across mutable fields
Lombok's class-wide @Data or generated equals() can include lazy collections, mutable fields, and proxies.
For entities:
- base equality on a stable identity with care;
- do not include lazy associations;
- do not use mutable business fields as identity;
- test behavior with Hibernate proxies.
For value objects, compare all defining attributes.
Public setters
A setter such as:
order.setStatus(CONFIRMED);
bypasses the rule that only a nonempty draft order can be confirmed.
Expose intention:
order.confirm(now);
Very large collections
Loading thousands of lines into one aggregate on every change is a warning that the boundary may be too large or that a different write model is needed.
Cascading everything
CascadeType.ALL is not a substitute for aggregate ownership. Cascade only when the child lifecycle truly belongs to the root.
Factories and Specifications Are Optional Tools
Use a factory when valid creation is too complex for one readable constructor.
public class OrderFactory {
private final PricingPolicy pricingPolicy;
public Order create(
UUID orderId,
UUID customerId,
List<CreateOrderLine> requestedLines
) {
Order order = Order.draft(
orderId,
customerId
);
requestedLines.forEach(line ->
order.addLine(
line.productId(),
line.quantity(),
pricingPolicy.priceFor(
line.productId(),
line.quantity()
)
)
);
return order;
}
}
Use a specification when a reusable domain predicate has a meaningful name.
public interface Specification<T> {
boolean isSatisfiedBy(T candidate);
}
public class RefundEligibility
implements Specification<Order> {
@Override
public boolean isSatisfiedBy(Order order) {
return order.isPaid()
&& !order.isShipped()
&& order.confirmedWithin(
Duration.ofDays(14)
);
}
}
Do not introduce these patterns merely to satisfy a checklist. A private method with a clear domain name can be better.
Testing Should Speak the Domain Language
Aggregate tests should not need Spring, PostgreSQL, or Kafka.
class OrderTest {
private static final Currency USD =
Currency.getInstance("USD");
@Test
void confirmsNonEmptyDraftOrder() {
UUID orderId = UUID.randomUUID();
Order order = Order.draft(
orderId,
UUID.randomUUID()
);
order.addLine(
UUID.randomUUID(),
2,
new Money(
new BigDecimal("15.00"),
USD
)
);
Instant confirmedAt =
Instant.parse(
"2026-06-23T09:00:00Z"
);
OrderConfirmed event =
order.confirm(confirmedAt);
assertThat(order.status())
.isEqualTo(
OrderStatus.CONFIRMED
);
assertThat(event.orderId())
.isEqualTo(orderId);
assertThat(event.totalAmount())
.isEqualByComparingTo(
"30.00"
);
}
@Test
void rejectsEmptyOrderConfirmation() {
Order order = Order.draft(
UUID.randomUUID(),
UUID.randomUUID()
);
assertThatThrownBy(
() -> order.confirm(
Instant.now()
)
)
.isInstanceOf(
EmptyOrderException.class
);
}
@Test
void rejectsChangesAfterConfirmation() {
Order order = Order.draft(
UUID.randomUUID(),
UUID.randomUUID()
);
order.addLine(
UUID.randomUUID(),
1,
new Money(
new BigDecimal("15.00"),
USD
)
);
order.confirm(
Instant.parse(
"2026-06-23T09:00:00Z"
)
);
assertThatThrownBy(
() -> order.addLine(
UUID.randomUUID(),
1,
new Money(
new BigDecimal("5.00"),
USD
)
)
)
.isInstanceOf(
InvalidOrderStateException.class
);
}
}
Integration tests should use PostgreSQL to verify:
- JPA mappings;
- optimistic locking;
- unique and foreign-key constraints;
- aggregate and outbox atomicity;
- production migrations.
Kafka tests should verify:
- topic;
- key;
- event ID;
- headers;
- payload;
- schema version;
- duplicate delivery;
- consumer idempotency.
Do not make every aggregate test a @SpringBootTest. Fast domain tests enable frequent model refinement.
Test the Context Boundary
A boundary test should prove that one context does not depend on another context's internals.
Possible checks:
- architecture tests reject imports from another context's domain package;
- integration events live in a published-contract module;
- internal JPA entities are not serializer inputs;
- repositories are not called across module boundaries;
- one module cannot update another module's tables.
For a modular monolith, tools such as Spring Modulith or architecture tests can validate module dependencies. The value comes from making the intended boundary executable, not from adding another diagram.
Common DDD Failure Modes
One shared enterprise model
A universal Customer, Product, and Order library seems reusable but forces unrelated contexts to agree on every change.
Prefer context-specific models and explicit translation.
Aggregate equals database schema
A table is not automatically an aggregate, and an aggregate is not necessarily one table.
The boundary comes from invariants and transactional consistency.
Every context becomes a microservice
This often distributes the system before the model stabilizes. Start with modules when independent deployment is not yet justified.
Anemic domain model
All fields have setters while every business rule lives in a large service.
Move state-transition rules into the aggregate or value object. Keep I/O orchestration in the application service.
God aggregate
One aggregate loads and updates every related concept.
Split according to consistency needs and coordinate through IDs and events.
Domain service as a dumping ground
A class named OrderDomainService with repositories, HTTP clients, Kafka producers, and dozens of unrelated methods is usually an application service or an unstructured workflow.
A domain service should express a focused domain rule.
Kafka event equals JPA entity
Publishing the entity leaks persistence shape and creates a fragile public contract.
Publish a dedicated integration event.
Spring event equals durable integration
An in-process application event does not close the database-to-Kafka crash window.
Use an outbox or CDC.
Event sourcing equals event-driven architecture
Publishing domain events does not mean the aggregate is reconstructed from an event log.
Event sourcing is a separate persistence model with its own versioning, replay, snapshot, and migration requirements.
CQRS everywhere
Separate read and write models only when their needs differ enough to justify the cost. A normal repository query is often sufficient.
Observability in a Domain-Oriented System
Infrastructure metrics alone cannot explain domain behavior.
Useful business observations include:
orders confirmed
confirmations rejected by rule
inventory reservations pending
quotes expired
refunds approved
workflow compensation started
Use bounded labels:
outcome
reason category
channel
region
bounded context
Do not use order IDs or customer IDs as metric labels.
Structured logs and traces may include a resource ID when policy permits:
bounded_context=orders
operation=confirm_order
order_id=...
event_id=...
outcome=success
trace_id=...
The domain language should remain recognizable during incident response.
Review Checklist
Before calling a design domain-driven, ask:
- Is the difficult business problem identified?
- Are core, supporting, and generic subdomains distinguished?
- Does each bounded context have a clear language and owner?
- Are context relationships documented?
- Is a service split justified independently from the context boundary?
- Are aggregates defined by invariants rather than table relationships?
- Can each aggregate commit in one local transaction?
- Do other aggregates appear as identities instead of mutable object graphs?
- Are value objects immutable and validated?
- Does the root expose intention-revealing behavior?
- Do repositories operate on roots?
- Are application and domain services distinguished?
- Are domain events past-tense business facts?
- Are integration events separate, versioned contracts?
- Is database-to-Kafka publication durable?
- Are consumers idempotent and protected by an anti-corruption layer?
- Are intermediate eventual-consistency states visible?
- Are domain tests fast and framework-free?
- Are persistence and messaging boundaries integration-tested?
- Is DDD being avoided where simple CRUD is enough?
Conclusion
Domain-Driven Design is not a collection of annotations or a synonym for microservices. It is a disciplined way to decide where a model is valid, which rules belong together, and how separate parts of the business communicate.
With Spring Boot, JPA, PostgreSQL, and Kafka:
- begin with subdomains and ubiquitous language;
- define bounded contexts before deployment boundaries;
- keep aggregates small and transactionally consistent;
- use value objects to make invalid states difficult to represent;
- separate application orchestration from domain behavior;
- persist only aggregate roots through their repositories;
- treat Spring application events as local unless durability is added;
- publish stable integration events through a transactional outbox;
- translate foreign contracts through anti-corruption layers;
- make eventual consistency and failure states explicit;
- test domain behavior without infrastructure and integration guarantees with real infrastructure.
A successful model is not the one with the most DDD terminology. It is the one that lets the code, business language, data ownership, and operational behavior change together without losing meaning.