Tactical DDD Building Blocks — Deep Dive
Level: Intermediate
Pre-reading: 02 · Domain-Driven Design · 02.01 · Strategic DDD
Tactical DDD Overview
Tactical DDD provides building blocks for implementing a rich domain model within a bounded context. These patterns help express business logic in code that mirrors how domain experts think.
graph TD
subgraph Tactical Building Blocks
E[Entity]
VO[Value Object]
AG[Aggregate]
DE[Domain Event]
DS[Domain Service]
R[Repository]
F[Factory]
end
Entity
An entity has a unique identity that persists over time. Two entities with the same attributes but different IDs are different entities.
| Characteristic | Description |
|---|---|
| Identity | Has a unique identifier (ID) |
| Mutability | Can change state over time |
| Lifecycle | Created, modified, possibly deleted |
| Equality | Two entities are equal if IDs match |
Entity Examples
| Entity | Identity | Why Entity? |
|---|---|---|
Order |
orderId |
Tracks through lifecycle |
Customer |
customerId |
Same person over time |
Product |
SKU |
Inventory tracked per SKU |
BankAccount |
accountNumber |
Balance changes; identity persists |
Entity Implementation
public class Order {
private final OrderId id; // Identity
private OrderStatus status;
private List<OrderLine> lines;
private Money totalAmount;
// Business methods, not just getters/setters
public void addLine(Product product, int quantity) {
// Invariant check
if (status != OrderStatus.DRAFT) {
throw new IllegalStateException("Cannot modify confirmed order");
}
lines.add(new OrderLine(product.getId(), quantity, product.getPrice()));
recalculateTotal();
}
public void confirm() {
if (lines.isEmpty()) {
throw new IllegalStateException("Cannot confirm empty order");
}
this.status = OrderStatus.CONFIRMED;
// Could raise OrderConfirmed event here
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Order)) return false;
Order order = (Order) o;
return id.equals(order.id); // Identity-based equality
}
}
Anemic Entity anti-pattern
Entities with only getters/setters and no behavior are called anemic. Business logic ends up scattered across services. Put behavior in the entity where the data lives.
Value Object
A value object has no identity. It is defined entirely by its attributes. Value objects are immutable — to change, create a new one.
| Characteristic | Description |
|---|---|
| No identity | Defined by attributes, not ID |
| Immutable | Cannot be modified after creation |
| Equality | Two VOs are equal if all attributes match |
| Replaceable | Swap one for another freely |
Value Object Examples
| Value Object | Attributes | Why Value Object? |
|---|---|---|
Money |
amount, currency |
$100 USD is $100 USD everywhere |
Address |
street, city, zip, country |
Interchangeable if attributes match |
DateRange |
start, end |
No need to track "which date range" |
EmailAddress |
value |
Validates format; interchangeable |
Coordinates |
latitude, longitude |
A point is just a point |
Value Object Implementation
public final class Money {
private final BigDecimal amount;
private final Currency currency;
public Money(BigDecimal amount, Currency currency) {
if (amount == null || currency == null) {
throw new IllegalArgumentException("Amount and currency required");
}
if (amount.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException("Amount cannot be negative");
}
this.amount = amount;
this.currency = currency;
}
// Operations return new instances (immutability)
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 quantity) {
return new Money(this.amount.multiply(BigDecimal.valueOf(quantity)), this.currency);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Money)) return false;
Money money = (Money) o;
return amount.compareTo(money.amount) == 0 && currency.equals(money.currency);
}
@Override
public int hashCode() {
return Objects.hash(amount, currency);
}
}
Entity vs Value Object Decision
| Question | Entity | Value Object |
|---|---|---|
| Does it need to be tracked over time? | Yes | No |
| Is identity important? | Yes | No |
| Are two instances with same attributes the same thing? | No | Yes |
| Should it be immutable? | Usually no | Always yes |
Domain Event
A domain event represents something that happened in the domain. Events are immutable facts — past tense, already occurred.
| Characteristic | Description |
|---|---|
| Past tense naming | OrderPlaced, PaymentReceived |
| Immutable | Cannot be changed after creation |
| Contains context | Includes data needed by consumers |
| Timestamp | When the event occurred |
Domain Event Examples
| Event | Payload | Triggered By |
|---|---|---|
OrderPlaced |
orderId, customerId, totalAmount, lines[] |
order.place() |
PaymentFailed |
orderId, reason, failedAt |
Payment gateway callback |
InventoryReserved |
orderId, items[], warehouseId |
Inventory service |
CustomerRegistered |
customerId, email, registeredAt |
Registration flow |
Domain Event Implementation
public record OrderPlaced(
OrderId orderId,
CustomerId customerId,
Money totalAmount,
List<OrderLineSnapshot> lines,
Instant occurredAt
) {
public OrderPlaced {
Objects.requireNonNull(orderId);
Objects.requireNonNull(customerId);
Objects.requireNonNull(totalAmount);
Objects.requireNonNull(lines);
if (occurredAt == null) {
occurredAt = Instant.now();
}
}
}
// Raising events from an entity
public class Order {
private final List<DomainEvent> domainEvents = new ArrayList<>();
public void place() {
// Business logic...
this.status = OrderStatus.PLACED;
// Raise event
domainEvents.add(new OrderPlaced(
this.id, this.customerId, this.totalAmount,
snapshotLines(), Instant.now()
));
}
public List<DomainEvent> getDomainEvents() {
return List.copyOf(domainEvents);
}
public void clearDomainEvents() {
domainEvents.clear();
}
}
Domain Service
A domain service contains business logic that doesn't naturally belong to any single entity or value object.
| Use When | Example |
|---|---|
| Logic spans multiple aggregates | TransferService.transfer(from, to, amount) |
| Logic requires external information | PricingService.calculatePrice(order, customerTier) |
| Operation is a domain concept | TaxCalculator.calculate(order, jurisdiction) |
Domain Service vs Application Service
| Domain Service | Application Service | |
|---|---|---|
| Contains | Business logic | Orchestration |
| Knows about | Domain model | Use cases, transactions |
| Calls | Entities, other domain services | Repositories, domain services |
| Depends on | Domain only | Infrastructure (indirectly via ports) |
Domain Service Implementation
// Domain Service (pure domain logic)
public class PricingService {
public Money calculateOrderTotal(Order order, CustomerTier tier) {
Money subtotal = order.getSubtotal();
Money discount = calculateDiscount(subtotal, tier);
return subtotal.subtract(discount);
}
private Money calculateDiscount(Money amount, CustomerTier tier) {
BigDecimal discountRate = switch (tier) {
case BRONZE -> BigDecimal.ZERO;
case SILVER -> new BigDecimal("0.05");
case GOLD -> new BigDecimal("0.10");
case PLATINUM -> new BigDecimal("0.15");
};
return amount.multiply(discountRate);
}
}
// Application Service (orchestration)
public class OrderApplicationService {
private final OrderRepository orderRepository;
private final PricingService pricingService;
private final CustomerRepository customerRepository;
public void placeOrder(PlaceOrderCommand command) {
Customer customer = customerRepository.findById(command.customerId())
.orElseThrow(() -> new CustomerNotFoundException(command.customerId()));
Order order = Order.create(command.items());
Money total = pricingService.calculateOrderTotal(order, customer.getTier());
order.setTotal(total);
order.place();
orderRepository.save(order);
// Publish domain events...
}
}
Repository
A repository provides an abstraction for loading and saving aggregates. It acts as a collection of aggregates.
| Characteristic | Description |
|---|---|
| Collection semantics | add(), remove(), findById() |
| Aggregate-level | One repository per aggregate root |
| Persistence ignorance | Domain doesn't know about DB |
| Interface in domain | Implementation in infrastructure |
Repository Implementation
// Port (interface in domain layer)
public interface OrderRepository {
Optional<Order> findById(OrderId id);
List<Order> findByCustomer(CustomerId customerId);
void save(Order order);
void delete(Order order);
}
// Adapter (implementation in infrastructure layer)
@Repository
public class JpaOrderRepository implements OrderRepository {
private final OrderJpaRepository jpaRepository;
private final OrderMapper mapper;
@Override
public Optional<Order> findById(OrderId id) {
return jpaRepository.findById(id.getValue())
.map(mapper::toDomain);
}
@Override
public void save(Order order) {
OrderEntity entity = mapper.toEntity(order);
jpaRepository.save(entity);
}
}
Factory
A factory encapsulates complex object creation logic. Use when constructing an aggregate requires significant logic.
| Use When |
|---|
| Creation logic is complex |
| Creation involves multiple steps |
| Different creation paths exist |
| Invariants must be enforced at creation |
Factory Implementation
public class OrderFactory {
private final ProductRepository productRepository;
private final PricingService pricingService;
public Order createFromCart(Cart cart, Customer customer) {
// Validate products exist and are available
List<OrderLine> lines = cart.getItems().stream()
.map(item -> {
Product product = productRepository.findById(item.productId())
.orElseThrow(() -> new ProductNotFoundException(item.productId()));
if (!product.isAvailable()) {
throw new ProductUnavailableException(product.getId());
}
return new OrderLine(product.getId(), item.quantity(), product.getPrice());
})
.toList();
Order order = new Order(OrderId.generate(), customer.getId(), lines);
Money total = pricingService.calculateOrderTotal(order, customer.getTier());
order.setTotal(total);
return order;
}
}
Building Blocks Summary
| Building Block | Identity | Mutable | Contains Logic | Persisted |
|---|---|---|---|---|
| Entity | Yes | Yes | Yes | Yes (via Repository) |
| Value Object | No | No | Yes | Embedded in Entity |
| Aggregate | Yes (root) | Yes | Yes | Yes (via Repository) |
| Domain Event | No | No | No | Yes (Event Store) |
| Domain Service | N/A | N/A | Yes | No |
| Repository | N/A | N/A | No (interface) | N/A |
| Factory | N/A | N/A | Yes (creation) | No |
When should logic go in an Entity vs a Domain Service?
If the logic operates on the data owned by a single entity and represents a natural behavior of that entity, it belongs in the entity. If the logic spans multiple aggregates, requires external information, or represents a standalone domain concept (like pricing or tax calculation), use a domain service.
Why are Value Objects immutable?
(1) Thread safety: no synchronization needed. (2) Simpler reasoning: no hidden state changes. (3) Safe sharing: pass references freely. (4) Cacheable: same inputs always produce same output. (5) Equality stability: if attributes define equality, changing them would break collections.
What's the difference between a Repository and a DAO?
A Repository operates on aggregates and uses collection semantics (add, remove). It's a domain concept. A DAO (Data Access Object) operates on database tables and uses CRUD semantics. DAOs are persistence-focused; Repositories are domain-focused. Repositories often use DAOs internally.