Siddhant Deval
Siddhant Deval
system design20 min read

Object Lifecycles, Coupling & Domain Aggregates

Unclear object lifecycles turn modular architectures into leaking, circularly dependent graphs. This article covers DDD aggregate roots, composition vs. aggregation, circular dependency mitigation, and TypeScript 5.2+ explicit resource management (`using` / `Symbol.dispose`) for deterministic cleanup in production Node.js services.

Series·Part 4 of 13

TypeScript Low-Level Design & Object-Oriented Architecture

Object Lifecycles, Coupling & Domain Aggregates

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.


1. The Anti-Pattern Graveyard: The Array Mutation Leak

Here is the pattern responsible for countless order integrity bugs in production ledger services:

TYPESCRIPT
// ❌ Anti-Pattern: Exposing internal array — invariants bypassed silently

class Order {
  public items: OrderItem[] = [];   // Public mutable array — fatal flaw
  public total: number = 0;

  addItem(product: string, price: number, quantity: number): void {
    const item: OrderItem = { product, price, quantity };
    this.items.push(item);
    this.total += price * quantity; // Kept in sync manually
  }
}

type OrderItem = { product: string; price: number; quantity: number };

// Usage — callers can bypass all validation:
const order = new Order();
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.


2. Association, Aggregation & Composition

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.

2.1 Association: Independent Lifecycles

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 entities
class Customer {
  constructor(
    readonly customerId: string,
    private billingAddressId: string, // Holds a reference, not the object
  ) {}

  updateBillingAddress(addressId: string): void {
    this.billingAddressId = addressId; // Can change which address we point to
  }
}

class Address {
  constructor(
    readonly addressId: string,
    readonly street: string,
    readonly city: string,
  ) {}
}

// Address exists independently — deleting a Customer does not delete the Address
// One Address can be shared across multiple Customers

2.2 Aggregation: Shared Ownership

One object aggregates others, but the child objects can outlive the parent. The parent does not own the child's lifecycle.

TYPESCRIPT
// ✅ Aggregation — Warehouse aggregates Products, but Products can exist without the Warehouse
class Warehouse {
  private products: Product[] = [];

  addProduct(product: Product): void {
    this.products.push(product); // Warehouse holds a reference, does not own lifecycle
  }

  removeProduct(productId: string): Product | undefined {
    const index = this.products.findIndex(p => p.productId === productId);
    if (index === -1) return undefined;
    const [removed] = this.products.splice(index, 1);
    return removed; // Product returned — still alive outside the Warehouse
  }
}

class Product {
  constructor(
    readonly productId: string,
    readonly name: string,
    public stockLevel: number,
  ) {}
}

// A Product removed from Warehouse continues to exist
const product = new Product('prd_001', 'Widget', 100);
const warehouse = new Warehouse();
warehouse.addProduct(product);
const removed = warehouse.removeProduct('prd_001');
console.log(removed); // Product still exists — Warehouse deletion does not destroy Product

2.3 Composition: Strict Lifecycle Ownership

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 Order
class OrderAggregate {
  #lineItems: LineItem[] = [];       // Private — callers never see the raw array
  #status: OrderStatus = 'DRAFT';

  constructor(readonly orderId: string) {}

  // LineItems are created by the Order, not passed in from outside
  addLineItem(productId: string, unitPriceCents: number, quantity: number): void {
    if (this.#status !== 'DRAFT') {
      throw new Error('Cannot modify a non-draft order');
    }
    if (quantity <= 0) throw new RangeError('Quantity must be positive');
    if (unitPriceCents <= 0) throw new RangeError('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(new LineItem(productId, unitPriceCents, quantity));
    }
  }

  // Public projection — ReadonlyArray prevents external mutation
  get lineItems(): ReadonlyArray<LineItem> {
    return this.#lineItems;
  }

  get totalCents(): number {
    return this.#lineItems.reduce((sum, li) => sum + li.subtotalCents, 0);
  }

  confirm(): void {
    if (this.#lineItems.length === 0) throw new Error('Cannot confirm empty order');
    this.#status = 'CONFIRMED';
  }
}

type OrderStatus = 'DRAFT' | 'CONFIRMED' | 'SHIPPED' | 'CANCELLED';

class LineItem {
  #quantity: number;

  constructor(
    readonly productId: string,
    readonly unitPriceCents: number,
    quantity: number,
  ) {
    this.#quantity = quantity;
  }

  increaseQuantity(delta: number): void {
    if (delta <= 0) throw new RangeError('Delta must be positive');
    this.#quantity += delta;
  }

  get quantity(): number { return this.#quantity; }
  get subtotalCents(): number { return this.#quantity * this.unitPriceCents; }
}
Three-section diagram showing lifecycle ownership. LEFT section labeled 'Association' in dim: two equal-weight boxes 'Customer' and 'Address' connected by a plain line with no arrowhead. Lifecycle bars beneath each box show independent timelines that start and end at different points. Label: 'Either can outlive the other'. CENTER section labeled 'Aggregation' in amber: 'Warehouse' box with a hollow diamond on the line connecting to 'Product' box. Lifecycle bars show Product's bar continuing after Warehouse's bar ends. Label: 'Product outlives Warehouse — shared ownership'. RIGHT section labeled 'Composition (DDD Aggregate)' in cyan: 'OrderAggregate' box with a solid filled diamond connecting to 'LineItem' boxes (multiple). Lifecycle bars show all LineItem bars ending when OrderAggregate's bar ends. Label: 'LineItems destroyed with Order — strict ownership'.
Three-section diagram showing lifecycle ownership. LEFT section labeled 'Association' in dim: two equal-weight boxes 'Customer' and 'Address' connected by a…

3. Composition Over Inheritance: Practical Refactoring

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:

3.1 The Fragile Inheritance Version

TYPESCRIPT
// ❌ Fragile hierarchy — changing BaseProcessor breaks all subclasses
abstract class BaseProcessor {
  protected validate(amount: number): void {
    if (amount <= 0) throw new Error('Amount must be positive');
  }

  protected abstract processPayment(amount: number): Promise<string>;

  async execute(amount: number): Promise<string> {
    this.validate(amount); // Called here
    return this.processPayment(amount);
  }
}

class StripeProcessor extends BaseProcessor {
  protected async processPayment(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

3.2 The Composable Version

TYPESCRIPT
// ✅ Composition — PaymentValidator is a separate, injectable collaborator
class PaymentValidator {
  validate(amount: number): void {
    if (amount <= 0) throw new Error('Amount must be positive');
    if (!Number.isFinite(amount)) throw new TypeError('Amount must be a finite number');
  }
}

interface IPaymentGateway {
  charge(amount: number): Promise<string>;
}

// PaymentOrchestrator composes its collaborators — no inheritance
class PaymentOrchestrator {
  constructor(
    private readonly validator: PaymentValidator,
    private readonly gateway: IPaymentGateway,
  ) {}

  async execute(amount: number): Promise<string> {
    this.validator.validate(amount);
    return this.gateway.charge(amount);
  }
}

class StripeGateway implements IPaymentGateway {
  async charge(amount: number): Promise<string> {
    return `stripe_txn_${amount}`;
  }
}

// Easy to test: inject mocks without complex class hierarchy setup
const orchestrator = new PaymentOrchestrator(
  new PaymentValidator(),
  new StripeGateway(),
);

4. Memory Leaks & Stale References in Node.js

TypeScript's type system provides no protection against runtime memory leaks. These are JavaScript engine-level phenomena that require explicit defensive patterns.

4.1 Zombie Event Listeners

The most common source of memory leaks in Node.js services: subscribing to events without cleanup handles.

TYPESCRIPT
// ❌ Anti-Pattern: EventEmitter subscription without cleanup
class PaymentNotificationService {
  private readonly emitter: EventEmitter;

  constructor(emitter: EventEmitter) {
    this.emitter = emitter;
    // This listener is registered but never removed
    this.emitter.on('payment.completed', this.onPaymentCompleted.bind(this));
  }

  private onPaymentCompleted(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 = new PaymentNotificationService(globalEmitter);
  // service goes out of scope, but the listener keeps it alive
  // After 1000 requests: 1000 retained instances — heap growth without bound
});

4.2 Manual Cleanup Pattern

TYPESCRIPT
// ✅ Explicit cleanup — always return a disposal handle
class PaymentNotificationService {
  private readonly handler: (event: unknown) => void;

  constructor(private readonly emitter: EventEmitter) {
    this.handler = this.onPaymentCompleted.bind(this);
    this.emitter.on('payment.completed', this.handler);
  }

  private onPaymentCompleted(event: unknown): void {
    console.log('Payment completed', event);
  }

  // Explicit teardown — caller is responsible for calling this
  dispose(): void {
    this.emitter.off('payment.completed', this.handler);
  }
}

// In a request handler with try...finally:
app.post('/payments', async (req, res) => {
  const service = new PaymentNotificationService(globalEmitter);
  try {
    // Handle request
    res.json({ ok: true });
  } finally {
    service.dispose(); // Listener removed regardless of success or error
  }
});

4.3 Circular Module Dependency Mitigation

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 types
import type { 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

5. Explicit Resource Management: TypeScript 5.2+ using

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.

5.1 Implementing Symbol.dispose

TYPESCRIPT
// Compiler requirement: tsconfig.json must include "esnext.disposable" in lib
// "lib": ["es2022", "esnext.disposable"]

class DatabaseConnection {
  readonly connectionId: string;
  #isOpen = true;

  constructor(connectionId: string) {
    this.connectionId = connectionId;
    console.log(`[DB] Connection ${this.connectionId} opened`);
  }

  query(sql: string): string[] {
    if (!this.#isOpen) throw new Error('Connection is closed');
    return [`result_of:${sql}`];
  }

  // Required for using keyword
  [Symbol.dispose](): void {
    if (this.#isOpen) {
      this.#isOpen = false;
      console.log(`[DB] Connection ${this.connectionId} closed`);
    }
  }
}

// ✅ using — Symbol.dispose called automatically at scope exit
function processPayment(transactionId: string): string[] {
  using conn = new DatabaseConnection('conn_001');
  // conn is open here
  const results = conn.query(`SELECT * FROM payments WHERE txn_id = '${transactionId}'`);
  return results;
  // Scope ends → [Symbol.dispose]() called automatically → connection closed
}

const results = processPayment('txn_abc123');
// Log: [DB] Connection conn_001 opened
// Log: [DB] Connection conn_001 closed

5.2 Async Disposal: await using + Symbol.asyncDispose

TYPESCRIPT
class LedgerEventSubscription {
  #subscriptionId: string;
  #isActive = true;

  constructor(subscriptionId: string) {
    this.#subscriptionId = subscriptionId;
  }

  async [Symbol.asyncDispose](): Promise<void> {
    if (this.#isActive) {
      await fetch(`/api/subscriptions/${this.#subscriptionId}`, { method: 'DELETE' });
      this.#isActive = false;
      console.log(`[Subscription] ${this.#subscriptionId} cancelled`);
    }
  }
}

async function monitorOrderEvents(orderId: string): Promise<void> {
  await using sub = new LedgerEventSubscription(`sub_order_${orderId}`);
  // sub is active — event monitoring happens here
  await new Promise(resolve => setTimeout(resolve, 5000));
  // Scope ends → Symbol.asyncDispose awaited → subscription cancelled remotely
}
Two-column diagram. LEFT column labeled 'Manual try...finally (Fallback)' in dim: code flow showing 'const conn = new DatabaseConnection()' then try block then catch error path then finally block with 'conn.dispose()' highlighted in amber. Arrow shows disposal happens on both success and error paths. Label: 'Works in any TypeScript environment'. RIGHT column labeled 'TS 5.2+ using (Native)' in cyan: code flow showing 'using conn = new DatabaseConnection()' inside a scope block. Arrow shows automatic disposal at scope exit marked with cyan 'AUTO Symbol.dispose'. Both success and error paths merge into automatic disposal. Label: 'Scope exit triggers cleanup — no finally needed'.
Two-column diagram. LEFT column labeled 'Manual try...finally (Fallback)' in dim: code flow showing 'const conn = new DatabaseConnection()' then try block th…

5.3 Interview Sandbox Fallback

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 environment
function processPaymentPortable(transactionId: string): string[] {
  const conn = new DatabaseConnection('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
  }
}
Architectural Note

Cross-Language Rosetta — Explicit Resource Management:

  • Go: defer conn.Close() — runs at function return, including after panic.
  • Python: with DatabaseConnection() as conn: — calls conn.__exit__() at block exit.
  • TypeScript 5.2+: using conn = new DatabaseConnection() — calls [Symbol.dispose]() at scope exit.

6. DDD Building Blocks: Entities, Value Objects & Aggregate Roots

6.1 Entity vs. Value Object

Dimension Entity Value Object
Identity Unique ID that persists through mutation No ID — defined entirely by attribute values
Equality entity1.id === entity2.id All attributes equal
Mutability Mutable (controlled via aggregate root) Immutable — create new instance to change
Examples Order, Customer, LedgerEntry Money, Address, DateRange, Currency
TYPESCRIPT
// ✅ Value Object — immutable, equality by structure
class Money {
  constructor(
    readonly cents: number,
    readonly currency: 'USD' | 'EUR' | 'GBP',
  ) {
    if (!Number.isInteger(cents) || cents < 0) {
      throw new RangeError('Money must be a non-negative integer number of cents');
    }
    Object.freeze(this); // Enforce immutability at runtime
  }

  add(other: Money): Money {
    if (this.currency !== other.currency) {
      throw new Error(`Cannot add ${this.currency} and ${other.currency}`);
    }
    return new Money(this.cents + other.cents, this.currency); // New instance — original unchanged
  }

  equals(other: Money): boolean {
    return this.cents === other.cents && this.currency === other.currency;
  }

  toString(): string {
    return `${(this.cents / 100).toFixed(2)} ${this.currency}`;
  }
}

// Usage
const price = new Money(1999, 'USD'); // $19.99
const tax   = new Money(200,  'USD'); // $2.00
const total = price.add(tax);        // New Money — $21.99

6.2 The Full Aggregate Root Pattern

Bringing all concepts together — private state, ReadonlyArray projection, invariant guards, and Value Objects:

TYPESCRIPT
class OrderAggregate {
  readonly #orderId: string;
  #lineItems: LineItem[] = [];
  #status: OrderStatus = 'DRAFT';

  constructor(orderId: string) {
    if (!/^ord_[a-z0-9]{8,}$/.test(orderId)) {
      throw new Error(`Invalid order ID format: ${orderId}`);
    }
    this.#orderId = orderId;
  }

  get orderId(): string { return this.#orderId; }
  get status(): OrderStatus { return this.#status; }

  // ReadonlyArray — external callers can iterate but cannot mutate
  get lineItems(): ReadonlyArray<LineItem> { return this.#lineItems; }

  get totalMoney(): Money {
    return this.#lineItems.reduce(
      (sum, li) => sum.add(li.subtotal),
      new Money(0, 'USD'),
    );
  }

  addLineItem(productId: string, price: Money, quantity: number): void {
    this.#assertStatus('DRAFT', 'add line items to');
    if (quantity <= 0) throw new RangeError('Quantity must be positive');

    const existing = this.#lineItems.find(li => li.productId === productId);
    if (existing) {
      existing.increaseQuantity(quantity);
    } else {
      this.#lineItems.push(new LineItem(productId, price, quantity));
    }
  }

  confirm(): void {
    this.#assertStatus('DRAFT', 'confirm');
    if (this.#lineItems.length === 0) {
      throw new Error('Cannot confirm an order with no line items');
    }
    this.#status = 'CONFIRMED';
  }

  #assertStatus(required: OrderStatus, operation: string): void {
    if (this.#status !== required) {
      throw new Error(`Cannot ${operation} an order in ${this.#status} status`);
    }
  }
}

type OrderStatus = 'DRAFT' | 'CONFIRMED' | 'SHIPPED' | 'CANCELLED';

class LineItem {
  #quantity: number;

  constructor(
    readonly productId: string,
    readonly unitPrice: Money,
    quantity: number,
  ) {
    this.#quantity = quantity;
  }

  increaseQuantity(delta: number): void {
    if (delta <= 0) throw new RangeError('Quantity delta must be positive');
    this.#quantity += delta;
  }

  get quantity(): number { return this.#quantity; }
  get subtotal(): Money { return new Money(this.#quantity * this.unitPrice.cents, this.unitPrice.currency); }
}

Summary

Concept Rule
Association Independent lifecycles — neither object owns the other's teardown
Aggregation Shared but non-exclusive — children can outlive the parent
Composition (Aggregate) Strict ownership — root creates children; root teardown destroys children
Aggregate Root Only public surface for interaction; guards all invariants; returns ReadonlyArray projections
Value Object Immutable; equality by structure; create a new instance to express a change
Entity Has an identity that persists across mutations; lives inside an aggregate
using / Symbol.dispose Deterministic teardown at scope exit — the TypeScript 5.2+ answer to defer and with
Memory leaks Always return a disposal handle from subscriptions; never register listeners without off()

What's Next

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.

#TypeScript#DDD#Domain Aggregates#Object Lifecycle#Explicit Resource Management#LLD Interview
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.