In most projects, business logic gradually smears across services, controllers, and helper classes. After a year, it becomes unclear where the rules live, who is responsible for what, and why a change in one place breaks another.
DDD tactical patterns give you a structure for organizing this logic. Each pattern answers a specific question: what is an object with a unique identity, what is just a value, where the consistency boundary sits, and how parts of the system communicate through events.
Entity — an object with identity
Picture a user in the system. They have a name and an email — those can change. But the user stays the same user even after changing everything. Their identity is an identifier, not a set of fields.
An Entity is an object that has a unique identifier. Two objects with the same fields but different IDs are different entities.
Key properties:
- It has a stable ID (it does not change during the object's lifetime).
- Its state can change — but only through methods that check business rules.
- Equality is by ID, not by fields.
- It holds behavior, not just data.
Common examples: User, Order, Invoice, Shipment, Contract.
A frequent mistake is the "anemic" Entity: a class with getters and setters but no rules. Then business logic leaks into services, and the object becomes a data structure.
public class User {
private final UUID id;
private Email email;
private String name;
private boolean active;
public User(UUID id, Email email, String name) {
if (id == null) throw new IllegalArgumentException("id is required");
if (email == null) throw new IllegalArgumentException("email is required");
if (name == null || name.isBlank()) throw new IllegalArgumentException("name is required");
this.id = id;
this.email = email;
this.name = name;
this.active = true;
}
public void changeEmail(Email newEmail) {
if (!active) throw new IllegalStateException("Inactive user cannot change email");
this.email = Objects.requireNonNull(newEmail);
}
public void deactivate() { this.active = false; }
@Override
public boolean equals(Object o) {
if (!(o instanceof User user)) return false;
return Objects.equals(id, user.id);
}
@Override
public int hashCode() { return Objects.hash(id); }
}
The changeEmail method is not just a setter. It checks a business rule: an inactive user cannot change their email. That is the key difference from a plain record.
Value Object — an object without identity
Money is a good example. Two 100-ruble banknotes are the same — we do not care whether it is "the same" banknote or a different one. What matters is the amount and the currency.
A Value Object is an object that has no ID of its own. Equality is defined by the values of its fields. Once created, it does not change (it is immutable).
Key properties:
- No ID.
- Immutable: instead of changing it, you create a new object.
- Equality by all significant fields.
- It checks its own invariants at creation time.
Common examples: Money, Email, PhoneNumber, Address, DateRange.
Why Value Objects matter: they remove "primitive obsession" — where a plain String travels around everywhere instead of an Email type. Format rules, validation, and operations live inside the type instead of being scattered across the code.
public final class Money {
private final BigDecimal amount;
private final Currency currency;
public Money(BigDecimal amount, Currency currency) {
if (amount == null) throw new IllegalArgumentException("amount is required");
if (currency == null) throw new IllegalArgumentException("currency is required");
this.amount = amount.setScale(2, RoundingMode.HALF_UP);
this.currency = currency;
}
public Money add(Money other) {
if (!this.currency.equals(other.currency))
throw new IllegalArgumentException("Currency mismatch");
return new Money(this.amount.add(other.amount), this.currency);
}
@Override
public boolean equals(Object o) {
if (!(o instanceof Money m)) return false;
return amount.compareTo(m.amount) == 0 && currency.equals(m.currency);
}
@Override
public int hashCode() { return Objects.hash(amount.stripTrailingZeros(), currency); }
}
The add method returns a new object — the original does not change. The currency-compatibility check is built into the operation instead of sitting somewhere outside.
Aggregate — the consistency boundary
Here is a real problem: an order has lines. Can you modify a line directly, bypassing the order? Formally — yes. But then the order ends up in an invalid state: for example, the total is not recalculated, or a line was added to an already confirmed order.
An Aggregate is a cluster of Entity and Value Object with a single consistency boundary. From the outside, only the root (the Aggregate Root) is visible — you can change the aggregate's state only through it.
Rules:
- All changes inside the aggregate go through the root.
- You cannot hold direct references to another aggregate's internal objects — only IDs.
- An aggregate should be small. A "god aggregate" with dozens of objects inside is an anti-pattern.
public class Order {
private final OrderId id;
private final CustomerId customerId;
private final List<OrderLine> lines = new ArrayList<>();
private OrderStatus status;
public void addLine(ProductId productId, int qty, Money price) {
ensureDraft();
if (qty <= 0) throw new IllegalArgumentException("qty must be > 0");
lines.add(new OrderLine(productId, qty, price));
}
public void confirm() {
ensureDraft();
if (lines.isEmpty()) throw new IllegalStateException("Order has no lines");
status = OrderStatus.CONFIRMED;
}
public Money total() {
return lines.stream()
.map(OrderLine::subtotal)
.reduce(Money.zero(Currency.RUB), Money::add);
}
private void ensureDraft() {
if (status != OrderStatus.DRAFT)
throw new IllegalStateException("Order is not editable");
}
}
OrderLine is an internal object of the aggregate. From the outside, nobody touches it directly — only through Order methods.
Domain Event — what happened in the domain
When an order is paid, you need to: send an email, notify the warehouse, credit bonus points. One way is to call all of that right inside the pay() method. But then Order knows about email, the warehouse, and bonus points — which breaks the boundaries of responsibility.
A Domain Event is an object that records a fact: something significant happened in the domain. The aggregate publishes the event, and other parts of the system react on their own.
Characteristics:
- Immutable — a fact in the past cannot be changed.
- Named in the past tense:
OrderPaid,UserRegistered, notPayOrder. - Carries context:
orderId,amount,paidAt— not just a marker. - The aggregate accumulates events and hands them over after being saved.
public abstract class DomainEvent {
private final UUID eventId = UUID.randomUUID();
private final Instant occurredAt = Instant.now();
public UUID getEventId() { return eventId; }
public Instant getOccurredAt() { return occurredAt; }
}
public class OrderPaid extends DomainEvent {
private final UUID orderId;
private final BigDecimal amount;
public OrderPaid(UUID orderId, BigDecimal amount) {
this.orderId = orderId;
this.amount = amount;
}
}
public abstract class AggregateRoot {
private final List<DomainEvent> domainEvents = new ArrayList<>();
protected void registerEvent(DomainEvent event) { domainEvents.add(event); }
public List<DomainEvent> getDomainEvents() { return Collections.unmodifiableList(domainEvents); }
public void clearEvents() { domainEvents.clear(); }
}
The aggregate calls registerEvent(new OrderPaid(...)) when its state changes. After the aggregate is saved, the repository picks up the accumulated events and publishes them — this guarantees that events go out only after a successful write.
Internal events live inside a single bounded context, often in one transaction. External ones cross boundaries through a message broker (Kafka, RabbitMQ) and require serialization.
Repository — access to aggregates
As a rule, when you need to save an order, people write SQL right inside the service. Queries spill beyond the business logic, get tied to a specific database, and swapping storage becomes expensive.
A Repository is an abstraction over the storage of aggregates. It creates the illusion of an in-memory collection of objects: find, save. The interface lives in the domain layer, the implementation in the infrastructure.
Key rules:
- One repository — one aggregate.
- It loads and saves the aggregate as a whole.
- Methods in domain terms:
findById,save— not SQL. - Interface in the domain, implementation in the infrastructure (dependency inversion).
// Interface — in the domain
interface OrderRepository {
Optional<Order> findById(OrderId id);
void save(Order order);
}
// Implementation — in the infrastructure, publishes events after saving
public class OrderRepositoryImpl implements OrderRepository {
private final OrderDao dao;
private final EventPublisher eventPublisher;
@Override
public void save(Order order) {
dao.save(order);
order.getDomainEvents().forEach(eventPublisher::publish);
order.clearEvents();
}
}
The difference from a DAO: a Repository works with the aggregate as a whole and thinks in the language of the domain. A DAO works with a table and thinks in the language of storage.
Domain Service — logic between aggregates
Sometimes an operation does not belong to any single aggregate. Transferring money between accounts: you need to withdraw from one and deposit into another. Neither the sender's Account nor the receiver's Account should know about each other.
A Domain Service is the place for business logic that coordinates several aggregates or domain objects.
The rule: first try to place the logic in an Entity or Aggregate Root. A Domain Service is for when the logic truly does not belong to any of them.
class TransferService {
void transfer(Account from, Account to, Money amount) {
from.withdraw(amount);
to.deposit(amount);
}
}
A Domain Service lives in the domain layer and knows only about domain objects. Do not confuse it with an Application Service — that one lives in the application layer and orchestrates: load the aggregate, invoke the domain logic, save.
Factory — creating aggregates
Sometimes creating an aggregate is not just a new. You need to check business rules, generate an ID, assemble the object from several parts.
A Factory encapsulates the logic of creating an aggregate. If the constructor can handle it — you do not need a factory.
class OrderFactory {
Order createNew(Customer customer) {
if (customer.isBlocked())
throw new IllegalStateException("Blocked customer cannot place orders");
return new Order(OrderId.generate(), customer.id());
}
}
Here a business rule ("a blocked customer cannot place orders") is checked before the aggregate is created. The Order constructor itself knows nothing about Customer — that is a separation of responsibilities.
In short
- Entity — an object with a unique ID; equality by ID; holds behavior and checks invariants.
- Value Object — no ID, immutable, equality by values; removes "primitive obsession".
- Aggregate — a cluster of objects with a single consistency boundary; only the root is visible from the outside.
- Domain Event — an immutable fact in the past tense; the aggregate accumulates events, the repository publishes them after saving.
- Repository — an abstraction over the storage of aggregates; interface in the domain, implementation in the infrastructure.
- Domain Service — business logic that does not belong to any aggregate; a last resort, not a first choice.
- Factory — creating an aggregate with complex logic; if the constructor can handle it, you do not need one.
- You do not need to apply all the patterns at once. Start with Entity and Value Object, add the rest as needed.
What to read next
- What DDD is and why you need it — the strategic level: bounded context, ubiquitous language.
- DDD Strategic Patterns — how to split a system into contexts and shape the relationships between them.
- DDD Integration Patterns — how bounded contexts communicate with each other.