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.
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.
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:
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.
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
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
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.
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:
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:
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:
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.
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:
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
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:
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.
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.
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:
5.2 Open/Closed Principle (OCP)
The violation: adding a new discount type requires modifying existing code.
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:
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.
The fix: extract a IBillableSubscription interface. FreeTrialSubscription does not implement it. The billing use case depends on IBillableSubscription, not Subscription:
5.4 Interface Segregation Principle (ISP)
The violation: clients are forced to depend on methods they do not use.
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:
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.
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):
The production DI container binds IOrderRepository → PrismaOrderRepository. The test DI container binds IOrderRepository → InMemoryOrderRepository. The use case source file changes in neither case.
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 →
This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.