Mastering DDD: How Aggregates and Aggregate Roots Simplify Complex Systems
This article explains core Domain‑Driven Design concepts—entities, value objects, bounded contexts, aggregates, and aggregate roots—illustrates design principles with a WeChat transfer example, and provides TypeScript code snippets to show how to model and implement aggregates for robust backend systems.
1. DDD Domain Partitioning
Before discussing the aggregate root concept, we need to understand basic DDD concepts: entity, value object, bounded context, aggregate, and aggregate root.
1. Entity
An entity has a unique identifier that remains constant throughout its lifecycle, even if its attributes change. In DDD, entities usually follow a rich model, with business logic inside the entity class; cross‑entity domain logic resides in domain services.
2. Value Object
A value object has no unique identifier and is defined by its attribute values, e.g., an address composed of street, city, state, and zip code.
Sometimes a value object may need to act as an entity within a specific boundary, such as a street name that must remain unique.
Defining something as a value object rather than an entity can greatly reduce system complexity . Note that domain modeling is different from data modeling; IDs may be added for storage efficiency.
3. Bounded Context
Bounded Context defines a domain boundary with a ubiquitous language, ensuring consistent terminology among developers and business stakeholders within that boundary.
Different domains use different terms; for example, “product” in an order domain versus “goods” in a logistics domain.
4. Domain Partitioning
When a system is simple, a single entity can serve as a domain, but as complexity grows, many entities and domains emerge, making management difficult.
Closely related entities can be grouped into an aggregate, with a single aggregate root providing the entry point.
2. Aggregates and Aggregate Roots
1. Aggregate
An aggregate is a cluster of entities and value objects that are tightly related; it is the basic unit for data modification and persistence. The aggregate root is the only external entry point, and other entities interact with the outside world through it.
Only one aggregate’s state can be modified within a transaction, ensuring strong consistency inside the aggregate.
For example, deleting a game community also deletes all posts within that community.
2. Aggregate Root
The aggregate root is a special entity that serves as the entry point of the aggregate. It maintains consistency by enforcing business rules and exposing a single interface for external objects.
It possesses the entity’s attributes and business behavior and coordinates internal entities and value objects.
It is the only outward‑facing identifier, typically referenced by other domains via its ID.
3. Design Principles for Aggregates
Based on Vaughn Vernon’s book, the principles are:
1. Model Invariant Conditions Within the Consistency Boundary
An “invariant” is a set of business rules that must never be violated inside the aggregate. For example, an order total cannot be negative.
Consistency requires atomicity and immediacy; only one aggregate should be modified per transaction.
2. Design Small Aggregates
Small aggregates improve performance, scalability, and reduce transaction conflicts.
In many projects, 70 % of aggregates consist of a single root entity; the remaining 30 % usually contain 2–3 entities.
3. Reference Other Aggregates by Identifier Only
Aggregates should not hold direct references to other aggregates; they use unique identifiers, which reduces coupling and improves performance.
4. Use Eventual Consistency Outside the Boundary
Cross‑aggregate business rules need not be instantly consistent; they can be handled asynchronously via domain events, batch jobs, etc.
4. WeChat Transfer Example
1. Analysis
We model a WeChat account system with two aggregates: Account Aggregate (account + wallet) and Bill Aggregate (transfer receipt).
When an account is deleted, its wallet is also deleted, but the bill must be retained for reconciliation.
2. Core Domain Classes
VO : Value Object
DP : Domain Primitive (treated as VO)
Entity : Entity
Identifier : Unique identifier for an entity
AggregateRoot : Special entity
Repository : Persistence interface per aggregate
export abstract class VO {}
export abstract class DP extends VO {}
export abstract class Identifier extends DP {}
export abstract class Entity<T extends Identifier> {
protected id: T;
constructor(id: T) { this.id = id; }
}
export abstract class AggregateRoot<T extends Identifier> extends Entity<T> {}
export abstract class Repository<T extends AggregateRoot<I>, I extends Identifier> {
public abstract save(t: T): Promise<void>;
}3. Account Aggregate
WechatAccount : Root entity exposing withdraw and deposit methods.
Wallet : Contains a Balance value object; balance cannot be negative.
PhoneNumber : DP serving as the account’s unique identifier.
Balance : Immutable value object; operations return new instances.
export class WechatAccount extends AggregateRoot<PhoneNumber> {
private nickname: string;
private wallet: Wallet;
public withdraw(asset: Asset) { this.wallet.pay(asset); }
public deposit(asset: Asset) { this.wallet.receive(asset); }
}
export class Wallet extends Entity<WalletNumber> {
constructor(private balance: Balance) { super(new WalletNumber(String(Math.random()))); }
public pay(asset: Asset) { this.balance = this.balance.decrease(asset); }
public receive(asset: Asset) {
try { this.balance = this.balance.increase(asset); }
catch { throw new Error('Insufficient balance'); }
}
}
export class PhoneNumber extends Identifier {
private static pattern = /^0?[1-9]{2,3}-?\d{8}$/;
public number: string;
constructor(number: string) {
super();
if (!this.isValid(number)) { throw new Error('Invalid phone number'); }
this.number = number;
}
private isValid(number: string) { return PhoneNumber.pattern.test(number); }
public getAreaCode(): string { return this.number.substring(0, 7); }
}
export class Balance extends VO {
private amount: number;
constructor(amount: number) {
super();
if (!this.isValid(amount)) { throw new Error('Invalid balance'); }
this.amount = amount;
}
private isValid(amount: number) { return amount >= 0; }
public decrease(asset: Asset): Balance { return new Balance(this.amount - asset.amount); }
public increase(asset: Asset): Balance { return new Balance(this.amount + asset.amount); }
}4. Bill Aggregate
TransferReceipt : Contains payer, payee, and asset; references accounts by identifier.
Account : Value object storing redundant name and phone for historical records.
export class TransferReceipt extends AggregateRoot<ReceiveNumber> {
public from: Account;
public to: Account;
public asset: Asset;
constructor(from: Account, to: Account, asset: Asset) {
this.from = from; this.to = to; this.asset = asset;
}
}
export class Account extends VO {
private id: string;
private name: string;
private phone: string;
}5. Application and Repository Layers
TransformService : Orchestrates the transfer, updates the account aggregate, persists changes, and creates a transfer receipt. The receipt can be persisted asynchronously using eventual consistency.
export class TransformService {
private accountRepository: AccountRepository;
private transferReceiptRepository: TransferReceiptRepository;
public transfer(payer: WechatAccount, payee: WechatAccount, asset: Asset) {
payer.withdraw(asset);
payee.deposit(asset);
this.accountRepository.save(payer);
this.accountRepository.save(payee);
const receipt = new TransferReceipt(
accountFactory.create(payer.accountId),
accountFactory.create(payee.accountId),
asset
);
this.transferReceiptRepository.save(receipt);
}
}
export class AccountRepository extends Repository<WechatAccount, PhoneNumber> {
public async save(t: WechatAccount) {
database.account.save(convert(t));
database.wallet.save(convertWallet(t));
}
}
export class TransferReceiptRepository extends Repository<TransferReceipt, ReceiveNumber> {
public async save(t: TransferReceipt) { database.receipt.save(convert(t)); }
}5. Conclusion
Aggregate roots are entities with a global unique identifier and independent lifecycle. An aggregate has exactly one root, which coordinates internal entities and value objects; aggregates communicate via IDs.
DDD is a business‑centric architectural approach; the article presents a personal perspective on some key points.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
MoonWebTeam
Official account of MoonWebTeam. All members are former front‑end engineers from Tencent, and the account shares valuable team tech insights, reflections, and other information.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
