Siddhant Deval
Siddhant Deval
backend5 min read

OOP Theory Foundations: Classes, Encapsulation, Inheritance, Polymorphism & SOLID

A rigorous, application-first walkthrough of every core OOP concept — classes, objects, encapsulation, inheritance, polymorphism, abstraction, and all five SOLID principles — through the lens of a Node.js e-commerce billing domain. This is the conceptual bedrock that every subsequent article in the series builds upon.

Series·Part 1 of 14

Backend Clean Architecture & Domain-Driven Design

OOP Theory Foundations: Classes, Encapsulation, Inheritance, Polymorphism & SOLID

Domain objects are autonomous state machines with enforced invariants — they are not passive bags of data passed between controller functions. That sentence is the load-bearing claim of this entire series. Before we build the e-commerce billing engine, we need to know why it is true — why an Order that lets any caller set order.status = 'SHIPPED' without checking payment state is not just bad practice, but structurally broken. This article defines the vocabulary precisely, grounds each OOP concept in the billing domain, and draws the exact line between what sounds right and what is mechanically true at runtime.

This is the conceptual bedrock. Parts 3–14 build domain Aggregates, Clean Architecture layers, Ports & Adapters, CQRS, and Event Sourcing on top of every concept defined here. Read this carefully, not for trivia, but because the architectural decisions in every subsequent article are derived from these first principles.

Architectural Note

Who this is for: Node.js/TypeScript engineers who have worked with classes but use them primarily as named object wrappers. If you have encountered Domain-Driven Design terminology (Entity, Aggregate, Repository) but are unsure what the underlying OOP concepts it depends on actually mean in TypeScript, this article is your foundation.


1. The Object and the Class

1.1 State, Behavior, and Identity: The Three Pillars

An object is defined by three things:

Pillar What It Means Billing Example
State The data it holds at a point in time Order holds its line items, total, status, and customer ID
Behavior The operations it can perform order.place(), order.cancel(), order.ship()
Identity The mechanism that distinguishes two objects even if their state is identical Two different Orders with the same items and total are still different Orders

Notice what is missing from a plain JavaScript object literal:

TYPESCRIPT
// ❌ Anti-Pattern: Plain object — has state, no behavior, no identity boundary
const order = {
  id: 'ord_001',
  status: 'PENDING',
  items: [{ productId: 'prod_42', quantity: 2, unitPrice: 49.99 }],
  totalAmount: 99.98,
  currency: 'USD',
};

// Any module anywhere in the codebase can do this:
order.status = 'SHIPPED';          // No payment check
order.totalAmount = -500;          // Negative total accepted
order.items = [];                  // Empty order silently accepted

This plain object has state (the fields), but its behavior is purely external (whoever calls it decides what happens) and its identity is implicit (it is just a reference, not a tracked entity). When an e-commerce system tries to enforce the rule "an Order cannot be shipped without confirmed payment," there is no single place to enforce it — every caller has to remember to check, and in production, one will not.

A class solves this by making the object the guardian of its own invariants.

1.2 Classes: Blueprint vs. Instance vs. Runtime Constructor

In TypeScript, a class declaration is a dual-natured entity:

  • At compile time it declares a structural type — a description of what an instance looks like that the TypeScript type-checker uses during development.
  • At runtime it emits a JavaScript prototype-chained constructor function that V8 executes.
TYPESCRIPT
class Order {
  readonly id: string;
  private status: OrderStatus;

  private constructor(id: string, status: OrderStatus) {
    this.id = id;
    this.status = status;
  }

  static create(id: string): Order {
    return new Order(id, OrderStatus.PENDING);
  }
}

// 'Order' used as a type:
function findOrder(id: string): Order | null { /* ... */ }

// 'Order' used as a constructor:
const order = Order.create('ord_001');

The private constructor pattern here is important — it means the only way to get an Order into memory is through the Order.create() factory, which guarantees that every Order starts with status: PENDING. This is not yet encapsulation (§2 covers that), but it is the foundation: the class controls its own birth.


2. Encapsulation: Invariant Enforcement, Not Just Privacy

Encapsulation is the most misunderstood concept in OOP. It is commonly taught as "hide your fields with private." That is the mechanism, not the purpose. The purpose is invariant enforcement: ensuring that an object can only ever be in a valid state, and that it is structurally impossible to put it into an invalid state from outside.

2.1 The Broken Pattern: Public Mutable State

TYPESCRIPT
// ❌ Anti-Pattern: Anemic Domain Model — all state is public
class Order {
  public id: string;
  public status: string;       // Any string accepted — 'SHIPPED', 'xyz', 'null' all compile
  public items: LineItem[];
  public totalAmount: number;  // Can be negative
  public customerId: string;

  constructor(id: string, customerId: string) {
    this.id = id;
    this.status = 'PENDING';
    this.items = [];
    this.totalAmount = 0;
    this.customerId = customerId;
  }
}

// In a controller, 3 months later, written by a different engineer:
const order = await orderRepo.findById(req.params.id);
order.status = 'SHIPPED';   // ← No payment check. No line items check. No warehouse assignment.
await orderRepo.save(order);
// This is now in production. The order was never paid for.

The order.status = 'SHIPPED' line compiles cleanly. TypeScript does not complain because status is public string. The domain rule "an Order cannot be shipped without confirmed payment" lives nowhere in the class — it lives in the documentation, in the team wiki, or more likely just in the original engineer's memory.

This is the Anemic Domain Model anti-pattern: a class with getters and setters but no behavior, where all business logic is forced into external services that directly manipulate state.

2.2 The Correct Pattern: Method-Mediated State Transitions

TYPESCRIPT
// ✅ Correct Pattern: Encapsulated state machine — behavior lives with the state
class Order {
  readonly id: OrderId;
  private _status: OrderStatus;
  private _items: ReadonlyArray<LineItem>;
  private _payment: PaymentId | null;

  private constructor(
    id: OrderId,
    status: OrderStatus,
    items: LineItem[],
    payment: PaymentId | null,
  ) {
    this.id = id;
    this._status = status;
    this._items = items;
    this._payment = payment;
  }

  static create(id: OrderId, customerId: CustomerId): Order {
    if (!customerId) throw new DomainError('Customer ID is required');
    return new Order(id, OrderStatus.PENDING, [], null);
  }

  ship(warehouseId: WarehouseId): void {
    if (this._status !== OrderStatus.PAID)
      throw new DomainError(`Cannot ship Order ${this.id} — status is ${this._status}, expected PAID`);
    if (this._items.length === 0)
      throw new DomainError(`Cannot ship Order ${this.id} — no line items`);
    this._status = OrderStatus.SHIPPED;
    // domain event raised here (covered in Part 5)
  }

  get status(): OrderStatus { return this._status; }
  get items(): ReadonlyArray<LineItem> { return this._items; }
}

Now order.ship(warehouseId) cannot succeed if the order is not PAID. The invariant is enforced at the only entry point that changes status to SHIPPED. It does not matter who calls ship() — the controller, a Kafka consumer, a CLI command, or a test — the guard executes unconditionally.

Crucial Requirement

The encapsulation principle: An object in memory must always be in a valid state. If a transition would violate a domain invariant, the method must throw a typed DomainError rather than silently allowing the transition. Every field that participates in an invariant must be private.

2.3 The ECMAScript #private Field Distinction

TypeScript's private keyword is a compile-time assertion only — the field is fully accessible at runtime via (obj as any).field or JavaScript reflection. For fields that must be genuinely inaccessible at runtime (e.g., a payment authorization token), use ECMAScript #private fields:

TYPESCRIPT
class PaymentProcessor {
  // TypeScript private — erased at compile time, accessible via (obj as any)._token
  private _tokenTs: string;

  // ECMAScript private — enforced by the JavaScript runtime itself, NOT by TypeScript
  #tokenHard: string;

  constructor(token: string) {
    this._tokenTs = token;
    this.#tokenHard = token;
  }
}

const p = new PaymentProcessor('sk_live_abc123');
(p as any)._tokenTs;  // ✅ Compiles and runs — 'sk_live_abc123' is readable
(p as any).#tokenHard; // ❌ SyntaxError at runtime — hard private field

For domain invariant enforcement (the purpose of encapsulation in DDD), TypeScript private is sufficient — the goal is to prevent accidental mutation within the team's TypeScript codebase, not to prevent JavaScript reflection attacks. Use #private only when you need genuine runtime isolation of a secret.


3. Inheritance: Is-A Relationships and the Subtype Contract

Inheritance is the most overused concept in OOP and the source of more architectural rot than any other. The only legitimate use of inheritance in a domain model is when a genuine Is-A relationship exists and when subtype substitutability (Liskov Substitution Principle, §5.3) is the explicit design goal.

3.1 When Inheritance Is Appropriate

A PremiumSubscription IS-A Subscription. It can be substituted wherever a Subscription is expected. It extends the base behavior with additional capabilities (priority support, higher rate limits) but never narrows or violates the base contract:

TYPESCRIPT
abstract class Subscription {
  abstract charge(amount: Money): Promise<ChargeReceipt>;
  abstract cancel(reason: CancellationReason): void;

  isActive(): boolean {
    return this.status === SubscriptionStatus.ACTIVE;
  }
}

class PremiumSubscription extends Subscription {
  // ✅ Extends the contract with additional capabilities
  async charge(amount: Money): Promise<ChargeReceipt> {
    const receipt = await stripeAdapter.chargeWithPriority(amount, this.id);
    return receipt;
  }

  cancel(reason: CancellationReason): void {
    this.notifyAccountManager(reason);  // Extra behavior
    this.status = SubscriptionStatus.CANCELLED_PREMIUM;
  }

  requestPrioritySupport(): SupportTicket { /* ... */ }
}

Any code that holds a reference to Subscription can call charge() and cancel() — and PremiumSubscription fulfills both contracts correctly.

3.2 When Inheritance Breaks: The Fragile Base Class Problem

The classic mistake is using inheritance for code reuse rather than for substitutability:

TYPESCRIPT
// ❌ Anti-Pattern: Inheritance for code reuse — no genuine Is-A relationship
class PhysicalProduct {
  private weight: Weight;
  private dimensions: Dimensions;

  calculateShippingCost(): Money { /* uses weight + dimensions */ }
  generateShippingLabel(): ShippingLabel { /* ... */ }
}

// A digital product is NOT a physical product. It has no weight. It cannot be shipped.
// But someone wanted to reuse 'getProductDetails()' from PhysicalProduct:
class DigitalProduct extends PhysicalProduct {
  // ❌ Now DigitalProduct inherits calculateShippingCost() which returns nonsense
  // ❌ generateShippingLabel() will fail — there is no physical item to ship
}

The symptom appears immediately in the billing domain: order.calculateShipping() iterates over items and calls item.product.calculateShippingCost(). When item.product is a DigitalProduct, it either throws, returns zero (silently wrong), or returns a shipping cost for a product that will never be physically shipped.

Performance / Safety Warning

The inheritance heuristic: If you cannot complete the sentence "B IS-A A in all contexts where A is expected, including edge cases," then inheritance is wrong. Use composition instead (Part 2 of the frontend series covers this for UI; the same principle applies in the domain layer).

The correct solution for PhysicalProduct vs DigitalProduct is a common interface with separate implementations, not an inheritance relationship:

TYPESCRIPT
interface IProduct {
  id: ProductId;
  price: Money;
  getLineItemSummary(): LineItemSummary;
}

class PhysicalProduct implements IProduct { /* ships physically */ }
class DigitalProduct implements IProduct { /* delivers via download */ }
// No inheritance. No shared implementation. No surprise.

4. Polymorphism: Dispatch Without Type Checks

Polymorphism is the runtime mechanism that allows the billing engine to call processor.charge(amount) without knowing — or caring — whether processor is a StripeProcessor, a PayPalProcessor, or a MockProcessor in a test.

4.1 Interface-Based Dispatch

TYPESCRIPT
// ✅ The polymorphic contract — defined in the Domain layer
interface IPaymentProcessor {
  charge(amount: Money, orderId: OrderId): Promise<PaymentReceipt>;
  refund(receiptId: ReceiptId, amount: Money): Promise<RefundConfirmation>;
}

// Three separate implementations — none knows the others exist
class StripeProcessor implements IPaymentProcessor {
  async charge(amount: Money, orderId: OrderId): Promise<PaymentReceipt> {
    const intent = await stripe.paymentIntents.create({
      amount: amount.toCents(),
      currency: amount.currency.toLowerCase(),
      metadata: { orderId: orderId.value },
    });
    return PaymentReceipt.from(intent);
  }
  // ...
}

class PayPalProcessor implements IPaymentProcessor {
  async charge(amount: Money, orderId: OrderId): Promise<PaymentReceipt> {
    const order = await paypalClient.orders.create({ /* ... */ });
    return PaymentReceipt.from(order);
  }
  // ...
}

class MockPaymentProcessor implements IPaymentProcessor {
  async charge(amount: Money, orderId: OrderId): Promise<PaymentReceipt> {
    return PaymentReceipt.createMock(amount, orderId); // Returns instantly — no HTTP
  }
  // ...
}

The ProcessPaymentUseCase (Part 6) receives an IPaymentProcessor through its constructor. It calls processor.charge(amount, orderId) and gets back a PaymentReceipt. It never contains a line of code like if (processor instanceof StripeProcessor).

4.2 Eliminating Conditional Type Checks

The anti-pattern is dispatching on type with conditionals:

TYPESCRIPT
// ❌ Anti-Pattern: Type dispatch via conditionals — violates OCP (§5.2)
async function processPayment(order: Order, method: string): Promise<PaymentReceipt> {
  if (method === 'stripe') {
    const intent = await stripe.paymentIntents.create({ /* ... */ });
    return PaymentReceipt.fromStripe(intent);
  } else if (method === 'paypal') {
    const paypalOrder = await paypalClient.orders.create({ /* ... */ });
    return PaymentReceipt.fromPayPal(paypalOrder);
  } else if (method === 'bank_transfer') {
    // ...
  }
  throw new Error(`Unknown payment method: ${method}`);
}

Adding Apple Pay, Klarna, or Crypto payment support means modifying this function — a function that, by then, is 150 lines long and tested by twelve separate test cases, all of which have to be touched.

The polymorphic replacement: add a new class implementing IPaymentProcessor. Register it in the DI container (Part 9). Zero existing code modified.

Pro Tip & Optimization

The polymorphism test: If adding a new variant of something (a new payment method, a new discount type, a new shape) requires finding and editing an existing if/switch statement, polymorphism is missing. The correct structure lets new variants be added by creating a new class in a new file.


5. All Five SOLID Principles — Applied to the Billing Domain

SOLID is not a checklist of rules to memorize. It is five heuristics, each of which identifies a specific kind of architectural coupling, fragility, or non-extensibility. In a backend billing system, all five violations appear with high frequency and high cost.

5.1 Single Responsibility Principle (SRP)

The violation: a class has more than one reason to change.

TYPESCRIPT
// ❌ SRP Violation: OrderService has 6 reasons to change
class OrderService {
  // Responsibility 1: HTTP request parsing
  parseOrderRequest(req: Request): OrderDto { /* ... */ }

  // Responsibility 2: Input validation
  validateOrder(dto: OrderDto): ValidationResult { /* ... */ }

  // Responsibility 3: Discount calculation
  applyBestDiscount(order: Order, customer: Customer): Money { /* ... */ }

  // Responsibility 4: Persistence
  async saveOrder(order: Order): Promise<void> {
    await this.db.orders.upsert({ /* ... Prisma query ... */ });
  }

  // Responsibility 5: Payment processing
  async chargePayment(order: Order): Promise<void> {
    await stripe.paymentIntents.create({ /* ... */ });
  }

  // Responsibility 6: Email notification
  async sendConfirmation(order: Order, customer: Customer): Promise<void> {
    await nodemailer.sendMail({ /* ... */ });
  }
}

A change to the email template, the payment gateway, the Prisma schema, or the HTTP request format all require modifying OrderService. This class will grow to 800 lines and become untestable.

The fix: each responsibility becomes its own focused class:

TYPESCRIPT
// ✅ SRP: Each class has exactly one reason to change

// Parses HTTP — changes only when HTTP request format changes
class OrderRequestParser {
  parse(req: Request): PlaceOrderCommand { /* ... */ }
}

// Business rule — changes only when discount policy changes
class DiscountCalculationService {
  bestDiscount(order: Order, customer: Customer): DiscountResult { /* ... */ }
}

// Persistence — changes only when the database schema changes
class PrismaOrderRepository implements IOrderRepository {
  async save(order: Order): Promise<void> { /* ... */ }
}

// Communication — changes only when email template or provider changes
class OrderConfirmationEmailHandler {
  handle(event: OrderPlaced): Promise<void> { /* ... */ }
}

5.2 Open/Closed Principle (OCP)

The violation: adding a new discount type requires modifying existing code.

TYPESCRIPT
// ❌ OCP Violation: Every new discount type requires editing this method
function applyDiscount(order: Order, customer: Customer): Money {
  if (customer.tier === 'VIP') {
    return order.total.multiply(0.80);  // 20% VIP discount
  } else if (order.promoCode === 'SUMMER25') {
    return order.total.multiply(0.75);  // 25% promo
  } else if (order.total.greaterThan(Money.of('USD', 500))) {
    return order.total.multiply(0.90);  // 10% bulk discount
  }
  return order.total;
}

Adding a Black Friday flash sale or a referral discount means opening this method and adding another branch — a method that may have been tested and deployed for months.

The fix: a Strategy registry where each discount type is its own class:

TYPESCRIPT
// ✅ OCP: Adding a new discount = add a new class, change zero existing files

interface IDiscountStrategy {
  isApplicable(order: Order, customer: Customer): boolean;
  apply(total: Money): Money;
  readonly priority: number; // Higher = applied first
}

class VIPDiscountStrategy implements IDiscountStrategy {
  readonly priority = 100;
  isApplicable(_: Order, customer: Customer) { return customer.tier === 'VIP'; }
  apply(total: Money) { return total.multiply(0.80); }
}

class SummerPromoStrategy implements IDiscountStrategy {
  readonly priority = 90;
  isApplicable(order: Order) { return order.promoCode === 'SUMMER25'; }
  apply(total: Money) { return total.multiply(0.75); }
}

// Adding Black Friday: create BlackFridayStrategy in a new file. Register it. Done.
// Zero existing files modified.

class DiscountEngine {
  constructor(private readonly strategies: IDiscountStrategy[]) {}

  bestDiscount(order: Order, customer: Customer): Money {
    const applicable = this.strategies
      .filter(s => s.isApplicable(order, customer))
      .sort((a, b) => b.priority - a.priority);
    return applicable.length > 0
      ? applicable[0].apply(order.total)
      : order.total;
  }
}

5.3 Liskov Substitution Principle (LSP)

The violation: a subtype cannot honor the contract of its parent.

LSP states that if B extends A, then anywhere A is expected, B must be fully substitutable without the caller needing to know the difference, including all preconditions, postconditions, and exception contracts.

TYPESCRIPT
// ❌ LSP Violation: FreeTrialSubscription cannot honor the charge() contract

abstract class Subscription {
  // Contract: charge() always returns a PaymentReceipt and never throws DomainError
  abstract charge(amount: Money): Promise<PaymentReceipt>;
}

class FreeTrialSubscription extends Subscription {
  async charge(amount: Money): Promise<PaymentReceipt> {
    // ❌ Free trial cannot be charged — this silently fails or throws
    throw new DomainError('Free trial subscriptions cannot be charged');
  }
}

// In the billing use case — no instanceof check, as it should be:
async function renewSubscription(subscription: Subscription): Promise<void> {
  const receipt = await subscription.charge(subscription.renewalAmount());
  // ❌ Throws for FreeTrialSubscription — LSP is violated
}

The fix: extract a IBillableSubscription interface. FreeTrialSubscription does not implement it. The billing use case depends on IBillableSubscription, not Subscription:

TYPESCRIPT
// ✅ LSP: FreeTrialSubscription correctly excluded from the billing contract
interface IBillableSubscription {
  charge(amount: Money): Promise<PaymentReceipt>;
  readonly renewalAmount: Money;
}

class PaidSubscription implements IBillableSubscription {
  async charge(amount: Money): Promise<PaymentReceipt> { /* real charge */ }
}

class FreeTrialSubscription {
  // Does NOT implement IBillableSubscription — it cannot be charged
  upgrade(): PaidSubscription { /* ... */ }
}

// The billing use case only handles IBillableSubscription — type system prevents the violation
async function renewSubscription(sub: IBillableSubscription): Promise<void> {
  const receipt = await sub.charge(sub.renewalAmount); // Always safe
}

5.4 Interface Segregation Principle (ISP)

The violation: clients are forced to depend on methods they do not use.

TYPESCRIPT
// ❌ ISP Violation: Fat interface forces unrelated concerns on all consumers
interface IOrderManager {
  placeOrder(command: PlaceOrderCommand): Promise<OrderId>;
  cancelOrder(id: OrderId, reason: string): Promise<void>;
  shipOrder(id: OrderId, warehouseId: WarehouseId): Promise<void>;
  getOrder(id: OrderId): Promise<OrderSummary>;
  listCustomerOrders(customerId: CustomerId): Promise<OrderSummary[]>;
  generateInvoice(id: OrderId): Promise<Invoice>;
  exportOrderReport(filter: ReportFilter): Promise<Report>;
  // ... 18 more methods
}

A webhook handler that only needs to mark an order as shipped must depend on an interface with 24 methods. If any of those 24 methods changes signature, the webhook handler must be recompiled and redeployed even though it touches only shipOrder.

The fix: decompose by role:

TYPESCRIPT
// ✅ ISP: Focused role interfaces — clients depend only on what they use
interface IOrderWriter {
  placeOrder(command: PlaceOrderCommand): Promise<OrderId>;
  cancelOrder(id: OrderId, reason: string): Promise<void>;
}

interface IOrderShipper {
  shipOrder(id: OrderId, warehouseId: WarehouseId): Promise<void>;
}

interface IOrderReader {
  getOrder(id: OrderId): Promise<OrderSummary>;
  listCustomerOrders(customerId: CustomerId): Promise<OrderSummary[]>;
}

// Webhook handler only declares the dependency it actually uses:
class ShipmentWebhookHandler {
  constructor(private readonly shipper: IOrderShipper) {}
}

5.5 Dependency Inversion Principle (DIP)

The violation: high-level policy (business logic) depends on low-level detail (database, HTTP, email).

DIP states: high-level modules must not depend on low-level modules. Both must depend on abstractions. Abstractions must not depend on details. Details must depend on abstractions.

TYPESCRIPT
// ❌ DIP Violation: Use case depends directly on Prisma (a low-level detail)
import { PrismaClient } from '@prisma/client';
import Stripe from 'stripe';
import nodemailer from 'nodemailer';

class PlaceOrderUseCase {
  private db = new PrismaClient();
  private stripe = new Stripe(process.env.STRIPE_KEY!);

  async execute(command: PlaceOrderCommand): Promise<OrderId> {
    // Business logic is permanently entangled with Prisma and Stripe
    const customer = await this.db.customer.findUnique({ where: { id: command.customerId } });
    // ...
    const intent = await this.stripe.paymentIntents.create({ /* ... */ });
    // ...
  }
}

This use case cannot run without a live database and a live Stripe account. It cannot be tested in isolation. It cannot be reused from a Kafka consumer without also bringing Prisma and Stripe along.

The fix: depend on domain interfaces (Ports), inject concrete implementations (Adapters):

TYPESCRIPT
// ✅ DIP: Use case depends only on abstractions defined in the Domain layer
class PlaceOrderUseCase {
  constructor(
    private readonly orders: IOrderRepository,    // Domain interface — no Prisma
    private readonly payment: IPaymentProcessor,  // Domain interface — no Stripe
    private readonly events: IEventBus,           // Domain interface — no Kafka
  ) {}

  async execute(command: PlaceOrderCommand): Promise<OrderId> {
    const order = Order.create(OrderId.generate(), command.customerId);
    for (const item of command.items) order.addItem(item);
    order.place();                           // Business logic — pure domain
    await this.orders.save(order);           // Calls IOrderRepository — agnostic of Prisma
    const events = order.pullDomainEvents(); // Collect events post-commit
    for (const event of events) await this.events.publish(event);
    return order.id;
  }
}

The production DI container binds IOrderRepository → PrismaOrderRepository. The test DI container binds IOrderRepository → InMemoryOrderRepository. The use case source file changes in neither case.

Crucial Requirement

DIP is not about injecting dependencies via a constructor. Constructor injection is the mechanism. DIP is about the direction of the interface: the interface (IOrderRepository) must be defined in the Domain layer and owned by the use case, not by the infrastructure layer that implements it. If the interface is defined alongside PrismaOrderRepository, it is still a DIP violation.


Summary

Concept Domain Rule
Encapsulation State changes only through validated methods; illegal state transitions are structurally impossible
Inheritance Use exclusively for genuine Is-A substitutability, never for code reuse alone
Polymorphism Dispatch to implementations through interfaces; zero instanceof or type-switch in orchestrators
SRP Each class has exactly one reason to change; when in doubt, split by axis of change
OCP New variants extend the system by adding files, not by editing existing ones
LSP A subtype must honor every precondition, postcondition, and exception contract of its parent
ISP Clients depend only on the methods they actually call; fat interfaces are always a DIP symptom
DIP High-level domain policy defines the interface; low-level infrastructure implements it — the arrow of dependency points inward

What's Next

In Part 2, we apply these foundations to a real-world Node.js project structure — drawing the Clean Architecture dependency rule as an enforced TypeScript project boundary and showing precisely where most Node.js applications violate it on the very first day. Part 2: Clean Architecture Layers in Node.js →

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#OOP#TypeScript#Node.js#SOLID#Encapsulation#Polymorphism#Architecture
Siddhant Deval

Written by Siddhant Deval

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