In the first four parts of this series, we worked from the outside in. We started by looking at the broader business domain, then explored ubiquitous language, subdomains, bounded contexts, and how those contexts interact.
Now, we’re going inside one of those boundaries.
For the rest of this article, we will focus primarily on the Recordkeeping context of our fictional 401(k) platform. At first glance, its domain structure might seem straightforward:
┌─────────────────────────┐
│ Participant │
└────────────┬────────────┘
│
▼
┌─────────────────────────┐
│ ParticipantAccount │
└────────────┬────────────┘
│
├──► Contributions
├──► Investments
├──► Transactions
└──► Balance
There is an important structural question hiding inside that diagram: What should each of these concepts actually be in our domain model?
- Should
Moneybe a class? - Is
ParticipantAccountan entity? - Is
Contributionan entity or a value object? - What about an investment position?
- Should we really be passing standard
BigDecimalandStringtypes around for everything?
These decisions form the core of tactical Domain-Driven Design (DDD).
Entities vs. Value Objects
The conceptual distinction between entities and value objects is defined by identity versus attributes:
- Entity: Defined primarily by its thread of continuity and unique identity.
- Value Object: Defined entirely by its attributes or structural values.
Consider a participant account:
Account ID: A-10045
Participant ID: P-2048
Balance: $75,420.50
Status: ACTIVE
If the balance changes tomorrow, it remains the exact same account. Its internal state evolved, but its unique identifier (A-10045) did not. That is an Entity.
Now consider money: $500 USD
There is no meaningful identity associated with that specific $500. If we instantiate another instance representing $500 USD, both instances are structurally equivalent and interchangeable. That is a Value Object.
Money(500, USD) == Money(500, USD)
Domain Modeling vs. Database Schema
A common architectural trap is designing domain classes to directly reflect database tables:
DATABASE TABLE: ACCOUNT
-----------------------
ACCOUNT_ID (VARCHAR)
BALANCE (DECIMAL)
STATUS (VARCHAR)
Mapping this directly into anemic data structures works for basic CRUD applications. However, DDD focuses on business concepts and invariant behaviors rather than database layout:
┌──────────────────────────────────────────────────────────────┐
│ ParticipantAccount │
├──────────────────────────────────────────────────────────────┤
│ - Must enforce non-negative balances on specific sub-accounts│
│ - Accepts contributions via explicit lifecycle methods │
│ - Tracks historical account transactions for auditing │
│ - State changes transition through formal lifecycle states │
└──────────────────────────────────────────────────────────────┘
An expressive domain model captures these business concepts and rules directly in code.
Encapsulating Concepts with Value Objects
Consider a standard financial representation using primitive types:
Java
public class AccountDto {
private BigDecimal balance;
private BigDecimal contributionAmount;
private BigDecimal distributionAmount;
// Standard getters and setters...
}
Using primitive types like BigDecimal leaves critical domain questions unanswered:
- What currency does this amount represent?
- Are negative values valid in this specific context?
- What rounding mode and scale rules apply?
- Can two instances safely be added together without explicit currency validation?
Replacing primitives with custom Value Objects encapsulates these business rules.
Implementing a Money Value Object in Java
Using modern Java features like record types makes implementing immutable Value Objects clean and concise:
Java
package com.acme.retirement.recordkeeping.domain;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Objects;
public record Money(BigDecimal amount, String currency) {
public Money {
Objects.requireNonNull(amount, "Amount cannot be null");
Objects.requireNonNull(currency, "Currency cannot be null");
if (currency.isBlank()) {
throw new IllegalArgumentException("Currency must not be blank");
}
// Standardize scale for standard financial calculations
amount = amount.setScale(2, RoundingMode.HALF_EVEN);
}
public static Money usd(BigDecimal amount) {
return new Money(amount, "USD");
}
public Money add(Money other) {
Objects.requireNonNull(other, "Cannot add null Money");
if (!this.currency.equals(other.currency())) {
throw new IllegalArgumentException(
"Cannot add different currencies: " + this.currency + " and " + other.currency()
);
}
return new Money(this.amount.add(other.amount()), this.currency);
}
}
Now operations enforce domain safety automatically:
Java
Money currentBalance = Money.usd(new BigDecimal("10000.00"));
Money contribution = Money.usd(new BigDecimal("500.00"));
// Clean, domain-safe addition
Money newBalance = currentBalance.add(contribution);
Because Java records automatically generate equals(), hashCode(), and toString() based on state, value equality is guaranteed without boilerplate.
Immutability and Identity-Free Equality
Value Objects should be immutable. Operations on a Value Object return a brand new instance rather than modifying existing state:
Java
Money original = Money.usd(new BigDecimal("500.00"));
Money added = original.add(Money.usd(new BigDecimal("100.00")));
// original remains 500.00 USD
// added is a new instance representing 600.00 USD
Strongly Typed Identifiers
Using raw UUID or String primitives for identifiers introduces subtle bug risks:
Java
// Danger: Easy to accidentally transpose parameters of the same type
public void processContribution(String planId, String participantId) { ... }
By wrapping identifiers in lightweight Value Objects, the Java compiler enforces domain correctness:
Java
public record ParticipantId(String value) {
public ParticipantId {
Objects.requireNonNull(value, "ParticipantId cannot be null");
if (value.isBlank()) {
throw new IllegalArgumentException("ParticipantId cannot be blank");
}
}
}
public record PlanId(String value) {
public PlanId {
Objects.requireNonNull(value, "PlanId cannot be null");
if (value.isBlank()) {
throw new IllegalArgumentException("PlanId cannot be blank");
}
}
}
Passing a PlanId where a ParticipantId is expected now results in a compile-time error.
Modeling the Participant Account Entity
Unlike Money, a ParticipantAccount maintains an identity that persists over time as its internal state changes.
Java
package com.acme.retirement.recordkeeping.domain;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
public class ParticipantAccount {
private final AccountId id;
private final ParticipantId participantId;
private final List<AccountTransaction> transactions;
private Money balance;
public ParticipantAccount(AccountId id, ParticipantId participantId, Money openingBalance) {
this.id = Objects.requireNonNull(id, "AccountId required");
this.participantId = Objects.requireNonNull(participantId, "ParticipantId required");
this.balance = Objects.requireNonNull(openingBalance, "Opening balance required");
this.transactions = new ArrayList<>();
}
public void applyContribution(Contribution contribution) {
Objects.requireNonNull(contribution, "Contribution cannot be null");
if (contribution.amount().amount().signum() <= 0) {
throw new IllegalArgumentException("Contribution amount must be positive");
}
AccountTransaction transaction = AccountTransaction.fromContribution(contribution);
this.transactions.add(transaction);
// Update internal state safely
this.balance = this.balance.add(contribution.amount());
}
public AccountId getId() {
return id;
}
public ParticipantId getParticipantId() {
return participantId;
}
public Money getBalance() {
return balance;
}
public List<AccountTransaction> getTransactions() {
return Collections.unmodifiableList(transactions);
}
}
Notice how the ParticipantAccount encapsulates state modifications. The balance cannot be arbitrarily overwritten via a raw setter; changes must occur through explicit domain methods like applyContribution().
Transactions: Audit Trails and State Evolution
In a financial system, tracking current balance alone is insufficient. The business must be able to verify how that balance was reached over time.
Opening Balance: $10,000.00
+ Contribution: $500.00
+ Employer Match: $250.00
- Investment Fee: -$15.00
------------------------------
Current Balance: $10,735.00
We can model individual ledger transactions with explicit domain context:
Java
public record TransactionId(String value) {}
public enum TransactionType {
EMPLOYEE_CONTRIBUTION,
EMPLOYER_MATCH,
FEE_DEDUCTION,
INVESTMENT_GAIN
}
public class AccountTransaction {
private final TransactionId id;
private final TransactionType type;
private final Money amount;
private final java.time.Instant effectiveTimestamp;
public AccountTransaction(TransactionId id, TransactionType type, Money amount, java.time.Instant effectiveTimestamp) {
this.id = Objects.requireNonNull(id);
this.type = Objects.requireNonNull(type);
this.amount = Objects.requireNonNull(amount);
this.effectiveTimestamp = Objects.requireNonNull(effectiveTimestamp);
}
public static AccountTransaction fromContribution(Contribution contribution) {
return new AccountTransaction(
new TransactionId(java.util.UUID.randomUUID().toString()),
TransactionType.EMPLOYEE_CONTRIBUTION,
contribution.amount(),
java.time.Instant.now()
);
}
public TransactionId getId() { return id; }
public TransactionType getType() { return type; }
public Money getAmount() { return amount; }
public java.time.Instant getEffectiveTimestamp() { return effectiveTimestamp; }
}
Avoiding Primitive Obsession
Using core primitives (String, BigDecimal, int) for complex domain concepts is known as Primitive Obsession.
| Primitive Representation | Expressive Domain Representation |
BigDecimal amount = new BigDecimal("500.00"); | Money amount = Money.usd(new BigDecimal("500.00")); |
String contributionType = "MATCH"; | ContributionType type = ContributionType.EMPLOYER_MATCH; |
BigDecimal rate = new BigDecimal("0.05"); | Percentage rate = Percentage.of(5); |
String participantId = "P-100"; | ParticipantId id = new ParticipantId("P-100"); |
Self-Validating Domain Types
Creating specific domain types provides a central location to enforce structural invariants:
Java
public record Percentage(BigDecimal value) {
public Percentage {
Objects.requireNonNull(value, "Percentage value required");
if (value.compareTo(BigDecimal.ZERO) < 0 || value.compareTo(new BigDecimal("100")) > 0) {
throw new IllegalArgumentException("Percentage must be between 0 and 100");
}
}
public static Percentage of(double val) {
return new Percentage(BigDecimal.valueOf(val));
}
}
Attempting to instantiate an invalid value like Percentage.of(150) throws an exception immediately at the point of creation, preserving model safety.
Summary of the Tactical Domain Architecture
Our Recordkeeping context combines custom Value Objects and Entities into an expressive model:
┌──────────────────────────────────┐
│ ParticipantAccount │ (Entity)
└────────────────┬─────────────────┘
│
┌───────────────────────┼───────────────────────┐
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ AccountId │ │ ParticipantId │ │ Money │ (Value Object)
│ (Value Object) │ │ (Value Object) │ └─────────────────┘
└─────────────────┘ └─────────────────┘
│
▼
┌─────────────────┐
│AccountTransaction│ (Entity)
└────────┬────────┘
│
▼
┌─────────────────┐
│ TransactionType │ (Enum / Value Object)
└─────────────────┘
What’s Next: Aggregates and Aggregate Roots
We now have expressive entities (ParticipantAccount, AccountTransaction) and self-validating value objects (Money, Percentage, ParticipantId).
However, this brings us to an important consistency question:
When a contribution is applied, how do we guarantee that the balance update, the creation of the transaction record, and investment allocations remain perfectly consistent without locking the entire database?
This introduces the concept of Aggregates and Aggregate Roots.
Related Reading: How Morning Routines Shape the Productivity of Top Engineers
Discover more from TACETRA
Subscribe to get the latest posts sent to your email.