In TypeScript 5+, OOP is an architectural contract, not an inheritance tree: enforce domain invariants at compile time, encapsulate mutation strictly within aggregates, and invert dependencies so high-level business policy never couples to execution details. The phrase "encapsulate mutation strictly within aggregates" is the central subject of this article.
An aggregate is a cluster of objects treated as a single unit with a clearly designated root. The root guards every entry point — external code can only interact with internal objects through the root's public API. This is not a bureaucratic pattern from DDD textbooks; it is the only reliable mechanism for enforcing domain invariants when multiple internal objects participate in the same business operation. Understanding aggregates requires first understanding the three relationship types that determine which objects belong inside a cluster and which should exist independently.
Here is the pattern responsible for countless order integrity bugs in production ledger services:
TYPESCRIPT
// ❌ Anti-Pattern: Exposing internal array — invariants bypassed silentlyclassOrder {
publicitems: OrderItem[] = []; // Public mutable array — fatal flawpublictotal: number = 0;
addItem(product: string, price: number, quantity: number): void {
constitem: OrderItem = { product, price, quantity };
this.items.push(item);
this.total += price * quantity; // Kept in sync manually
}
}
typeOrderItem = { product: string; price: number; quantity: number };
// Usage — callers can bypass all validation:const order = newOrder();
order.addItem('Widget', 10, 2); // total = 20 ✅// Direct mutation — addItem() never called, total never updated
order.items.push({ product: 'Gadget', price: 50, quantity: 1 });
console.log(order.total); // 20 — but actual item value is 70 ❌// Order is now internally inconsistent — invariant violated silently// Even more dangerous: external code removing items without updating total
order.items.splice(0, 1);
console.log(order.total); // Still 20 — item was removed but total not updated ❌
The root problem is that order.items is a reference to the internal array. Any caller anywhere in the codebase can invoke .push(), .splice(), or .sort() directly, bypassing all invariant logic. The total becomes a dangling value with no connection to reality.
The fix is an aggregate root pattern with private state and a ReadonlyArray public projection.
Three distinct relationship types describe how objects relate to each other in terms of lifecycle, ownership, and coupling. Using the wrong one is an architectural decision that cannot be easily refactored later.
Two objects know about each other but neither owns the other. Destroying one does not affect the other.
TYPESCRIPT
// ✅ Association — Customer and Address are independent entitiesclassCustomer {
constructor(readonlycustomerId: string,
privatebillingAddressId: string, // Holds a reference, not the object) {}
updateBillingAddress(addressId: string): void {
this.billingAddressId = addressId; // Can change which address we point to
}
}
classAddress {
constructor(readonlyaddressId: string,
readonlystreet: string,
readonlycity: string,
) {}
}
// Address exists independently — deleting a Customer does not delete the Address// One Address can be shared across multiple Customers
The owner creates its children and takes full responsibility for their lifecycle. When the owner is destroyed, all children are destroyed. This is the relationship inside a DDD aggregate.
TYPESCRIPT
// ✅ Composition — OrderAggregate owns LineItems; LineItems have no meaning without the OrderclassOrderAggregate {
#lineItems: LineItem[] = []; // Private — callers never see the raw array
#status: OrderStatus = 'DRAFT';
constructor(readonlyorderId: string) {}
// LineItems are created by the Order, not passed in from outsideaddLineItem(productId: string, unitPriceCents: number, quantity: number): void {
if (this.#status !== 'DRAFT') {
thrownewError('Cannot modify a non-draft order');
}
if (quantity <= 0) thrownewRangeError('Quantity must be positive');
if (unitPriceCents <= 0) thrownewRangeError('Price must be positive');
const existing = this.#lineItems.find(li => li.productId === productId);
if (existing) {
existing.increaseQuantity(quantity); // Delegate to child entity's own methods
} else {
this.#lineItems.push(newLineItem(productId, unitPriceCents, quantity));
}
}
// Public projection — ReadonlyArray prevents external mutationgetlineItems(): ReadonlyArray<LineItem> {
returnthis.#lineItems;
}
gettotalCents(): number {
returnthis.#lineItems.reduce((sum, li) => sum + li.subtotalCents, 0);
}
confirm(): void {
if (this.#lineItems.length === 0) thrownewError('Cannot confirm empty order');
this.#status = 'CONFIRMED';
}
}
typeOrderStatus = 'DRAFT' | 'CONFIRMED' | 'SHIPPED' | 'CANCELLED';
classLineItem {
#quantity: number;
constructor(readonlyproductId: string,
readonlyunitPriceCents: number,
quantity: number,
) {
this.#quantity = quantity;
}
increaseQuantity(delta: number): void {
if (delta <= 0) thrownewRangeError('Delta must be positive');
this.#quantity += delta;
}
getquantity(): number { returnthis.#quantity; }
getsubtotalCents(): number { returnthis.#quantity * this.unitPriceCents; }
}
Expand
Three-section diagram showing lifecycle ownership. LEFT section labeled 'Association' in dim: two equal-weight boxes 'Customer' and 'Address' connected by a…
The canonical argument for composition over inheritance is that deep extends chains bind derived classes to parent implementation details. Here is a practical example in the fintech domain:
// ❌ Fragile hierarchy — changing BaseProcessor breaks all subclassesabstractclassBaseProcessor {
protectedvalidate(amount: number): void {
if (amount <= 0) thrownewError('Amount must be positive');
}
protectedabstractprocessPayment(amount: number): Promise<string>;
asyncexecute(amount: number): Promise<string> {
this.validate(amount); // Called herereturnthis.processPayment(amount);
}
}
classStripeProcessorextendsBaseProcessor {
protectedasyncprocessPayment(amount: number): Promise<string> {
return`stripe_txn_${amount}`;
}
}
// If BaseProcessor.validate() is changed to validateAmount() or moved,// ALL subclasses that depend on it fail, even if they didn't call validate() directly
TypeScript's type system provides no protection against runtime memory leaks. These are JavaScript engine-level phenomena that require explicit defensive patterns.
The most common source of memory leaks in Node.js services: subscribing to events without cleanup handles.
TYPESCRIPT
// ❌ Anti-Pattern: EventEmitter subscription without cleanupclassPaymentNotificationService {
privatereadonlyemitter: EventEmitter;
constructor(emitter: EventEmitter) {
this.emitter = emitter;
// This listener is registered but never removedthis.emitter.on('payment.completed', this.onPaymentCompleted.bind(this));
}
privateonPaymentCompleted(event: unknown): void {
console.log('Payment completed', event);
}
// When PaymentNotificationService is garbage collected,// the bound method keeps the instance alive through the emitter reference.// MaxListenersExceededWarning will fire after 11 subscriptions.
}
// Each request handler creates a new PaymentNotificationService:
app.post('/payments', async (req, res) => {
const service = newPaymentNotificationService(globalEmitter);
// service goes out of scope, but the listener keeps it alive// After 1000 requests: 1000 retained instances — heap growth without bound
});
Circular imports between modules are a common source of undefined values at initialization time in Node.js. The symptom: import { X } from './module-a' gives undefined because module A and module B form a dependency cycle.
TYPESCRIPT
// ❌ Problem: circular dependency// module-a.ts imports from module-b.ts// module-b.ts imports from module-a.ts// One of them will see 'undefined' at import resolution time// ✅ Fix 1: import type — breaks runtime circular dependency while preserving compile-time typesimporttype { IOrderRepository } from'./order-repository'; // Type-only import — erased at runtime// ✅ Fix 2: Intermediate interface module// Extract the shared interface to a third module that neither imports:// interfaces/IOrderRepository.ts ← both modules import from here, not from each other// ✅ Fix 3: Mediator / Event Bus// Replace direct imports with an event bus: modules emit events instead of importing each other
TypeScript 5.2 implemented the TC39 Stage 3 proposal for Explicit Resource Management. The using keyword works like const but calls [Symbol.dispose]() automatically when the variable goes out of scope — analogous to defer in Go and with in Python.
CoderPad and HackerRank runners may not support using (requires TypeScript 5.2+ and esnext.disposable lib). The portable fallback is explicit try...finally:
TYPESCRIPT
// Interview Sandbox Fallback — works in any TypeScript/Node.js environmentfunctionprocessPaymentPortable(transactionId: string): string[] {
const conn = newDatabaseConnection('conn_001');
try {
return conn.query(`SELECT * FROM payments WHERE txn_id = '${transactionId}'`);
} finally {
conn[Symbol.dispose](); // Or conn.dispose() if you use a conventional method name
}
}
In Part 5, we shift from code to communication. The most powerful thing a senior engineer can do in a design review or LLD interview is produce a crisp, accurate diagram in under five minutes. Part 5: Pragmatic UML & Visual Architecture as Code teaches the 20% of Mermaid.js notation that delivers 80% of clarity — and a 30-minute LLD sketching framework timed for interview conditions.
Research & Synthesis Note
This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.