04 — OOP at Senior Depth
Why This Page Is Short
You already know OOP. You've written classes for a decade. The last thing you need is another page explaining that class Dog extends Animal or that encapsulation means "making fields private."
This page exists because senior interviewers don't probe OOP the way textbooks teach it. They listen for ~8 specific distinctions that separate a candidate who has used OOP from one who has reasoned about it. Confusing aggregation with composition, or dependency injection with the dependency inversion principle, or interfaces with abstract classes when the difference actually matters — those are the moments where the interview note shifts from "strong" to "meets bar."
So: no zoos, no shapes, no vehicles. Every example below is a backend primitive you'd find in a service — a logger, a cache, a rate limiter, a payment processor. The goal isn't to teach you OOP. It's to sharpen the vocabulary you'll use on the whiteboard.
Class vs Object vs Instance
One table. This isn't what senior interviews probe, but getting the vocabulary wrong is a tell.
| Term | What it is | Lives in |
|---|---|---|
| Class | A type — the blueprint. Declares fields, methods, invariants. | Source code / class metadata at runtime. |
| Object | Any entity with identity and state. In a class-based language, every object is an instance of some class. | Heap. |
| Instance | An object viewed through the lens of its class. "r is an instance of RateLimiter" is about the relationship, not the thing. | Same heap object; different framing. |
In languages like JavaScript where objects can exist without classes (object literals, prototypes), "object" and "instance" diverge. In Java/C#/TypeScript classes, they're effectively synonyms, and "instance" is the word you use when you want to emphasize the class relationship.
Senior interviewers don't grade this. They notice when you misuse it.
Interface vs Abstract Class
The single most common senior-level confusion. Both declare contracts. Both allow polymorphism. The distinction is about what you're committing to when you reach for each.
When to use an interface
An interface is a capability contract. It says: "any type that implements me promises these methods exist and behave correctly." No state. No implementation. Just shape.
Use an interface when:
- Multiple unrelated types need to satisfy the same contract (a
Cacheinterface implemented byRedisCache,InMemoryCache,NoopCache). - You want to type a dependency without naming a concrete class — constructor parameters, method returns, generic bounds.
- The contract is likely to be implemented by types you don't own (third-party adapters).
interface Cache {
get(key: string): Promise<string | null>;
set(key: string, value: string, ttlMs: number): Promise<void>;
delete(key: string): Promise<void>;
}Every implementation starts from scratch. That's the point.
When to use an abstract class
An abstract class is a partial implementation with a contract baked in. It carries state and shared behavior; subclasses fill in the holes.
Use an abstract class when:
- You have genuine shared implementation across subclasses — cleanup logic, metric emission, retry wrapping — that would be duplicated if each concrete class started from an interface.
- The abstraction has a stable "skeleton" and variation happens at specific steps (the Template Method pattern).
- You need protected state that subclasses manipulate but outside code cannot.
abstract class RetryingCache implements Cache {
constructor(protected readonly maxAttempts: number) {}
async get(key: string): Promise<string | null> {
return this.withRetry(() => this.doGet(key));
}
async set(key: string, value: string, ttlMs: number): Promise<void> {
return this.withRetry(() => this.doSet(key, value, ttlMs));
}
async delete(key: string): Promise<void> {
return this.withRetry(() => this.doDelete(key));
}
protected abstract doGet(key: string): Promise<string | null>;
protected abstract doSet(key: string, value: string, ttlMs: number): Promise<void>;
protected abstract doDelete(key: string): Promise<void>;
private async withRetry<T>(fn: () => Promise<T>): Promise<T> {
let lastErr: unknown;
for (let i = 0; i < this.maxAttempts; i++) {
try { return await fn(); } catch (e) { lastErr = e; }
}
throw lastErr;
}
}RedisCache extends RetryingCache gets retries for free. The contract (Cache) is orthogonal to the sharing mechanism (RetryingCache).
The hybrid trap
Junior and mid candidates often reach for an abstract class because "some logic is shared." This is the trap: the abstract class becomes a load-bearing parent that couples all subclasses to a common lineage. Later, when one subclass needs to also extend something else, or when the shared logic belongs in two different places, the hierarchy fights you.
Senior take: interfaces for contracts, abstract classes for shared implementation — and if you're reaching for an abstract class, first ask if composition would be cleaner. RetryingCache in the example above could equally be written as class RetryWrapper implements Cache { constructor(private readonly inner: Cache, private readonly maxAttempts: number) {} ... } — a decorator. The decorator works across unrelated Cache implementations without forcing them into a hierarchy.
| Dimension | Interface | Abstract Class | Composition (decorator) |
|---|---|---|---|
| Shared behavior | None | Yes (inherited) | Yes (delegated) |
| State | No | Yes | Yes (in wrapper) |
| Multiple "parents" | Yes (multi-interface) | Usually no | Yes (stack decorators) |
| Replace at runtime | Implementations are swappable | Subclass is locked in | Fully dynamic |
| Good default for | Contracts | Template Method over a fixed algorithm | Cross-cutting behavior (retry, metrics, caching) |
Rule of thumb interviewers like to hear: "I reach for an interface by default. I use an abstract class when the Template Method pattern genuinely fits. If I'm tempted to use it just to share a helper, I reach for composition instead."
Inheritance
Where it's right
Inheritance is the right tool when:
- There's a strict is-a relationship —
PremiumUsertruly is aUser, everyUseroperation applies to it. - You need polymorphism across a closed set — an
Eventhierarchy in an event-sourced system, where consumers dispatch on type and every new subclass is a deliberate product decision. - The base class's invariants are genuinely invariants — not just "things that happen to be true today."
Where it bites
Inheritance's dangers are well documented but still routinely underestimated.
Fragile base class. A change in the parent — a new pre-condition, a different call order in a template method, a private helper becoming protected — can silently break every subclass, including ones in other repos. The parent's authors and the subclass authors rarely coordinate.
Deep hierarchies. By the third level, reasoning about which doWork() runs requires mentally climbing the chain. Refactors become terrifying because the blast radius is invisible.
The is-a test. If someone asks "is a RateLimiter a TokenBucket?" — the answer is no. TokenBucket is one algorithm for rate limiting; other algorithms (leaky bucket, fixed window) are peers, not siblings. Making TokenBucket extends RateLimiter abstract class ties every algorithm to the same base and leaks implementation. Better: interface RateLimiter { tryAcquire(): boolean } with independent class TokenBucketLimiter, class LeakyBucketLimiter, class FixedWindowLimiter implementations.
The has-a test. "Does an OrderService have a PaymentClient?" Yes. "Does OrderService extend PaymentClient?" Absurd. When the relationship is "one component uses another," it's composition, not inheritance. 80% of the time a junior candidate reaches for extends, has-a is what they actually wanted.
Senior rule of thumb: prefer composition unless you need polymorphism across a strict hierarchy, and even then, the hierarchy should be one level deep if you can swing it.
Polymorphism
Three flavors. Senior candidates can name all three and match them to use cases; juniors know only the first.
Subtype polymorphism
The classical OOP flavor. Different subtypes of a common supertype are interchangeable through the supertype's contract.
interface MetricsSink {
emit(name: string, value: number, tags: Record<string, string>): void;
}
class StatsdSink implements MetricsSink { /* UDP packet */ }
class PrometheusSink implements MetricsSink { /* pull endpoint */ }
class NoopSink implements MetricsSink { emit() {} }
function recordLatency(sink: MetricsSink, ms: number) {
sink.emit("request_latency_ms", ms, { service: "checkout" });
}recordLatency doesn't know or care which sink it got. That's the whole mechanism — the dispatch happens at runtime based on the object's actual type.
Parametric polymorphism (generics)
A single piece of code works over many types without constraining behavior beyond what's syntactically required. A Cache<V> holds values of some type V; the cache logic doesn't depend on what V is.
interface Cache<V> {
get(key: string): Promise<V | null>;
set(key: string, value: V, ttlMs: number): Promise<void>;
}
class InMemoryCache<V> implements Cache<V> {
private store = new Map<string, { value: V; expiresAt: number }>();
async get(key: string): Promise<V | null> {
const entry = this.store.get(key);
if (!entry || entry.expiresAt < Date.now()) return null;
return entry.value;
}
async set(key: string, value: V, ttlMs: number): Promise<void> {
this.store.set(key, { value, expiresAt: Date.now() + ttlMs });
}
}
const sessions = new InMemoryCache<SessionToken>();
const quotes = new InMemoryCache<PriceQuote>();Generics give you type safety without duplication. The alternative is Cache<any>, which is just giving up.
Ad-hoc polymorphism (overloading / pattern matching)
The same name means different things for different argument shapes. In TypeScript, function overloads let a single symbol present multiple signatures:
interface Logger {
log(message: string): void;
log(level: "info" | "warn" | "error", message: string): void;
log(level: "info" | "warn" | "error", message: string, meta: Record<string, unknown>): void;
}
class ConsoleLogger implements Logger {
log(arg1: string | "info" | "warn" | "error", arg2?: string, arg3?: Record<string, unknown>): void {
if (arg2 === undefined) return console.log(`[info] ${arg1}`);
if (arg3 === undefined) return console.log(`[${arg1}] ${arg2}`);
return console.log(`[${arg1}] ${arg2}`, arg3);
}
}In functional languages, this shows up as pattern matching on constructors. In Rust it's match. In Scala it's case classes. The family trait is: the dispatch depends on the shape of the arguments, not on a class hierarchy.
| Flavor | Dispatch key | Example |
|---|---|---|
| Subtype | Runtime type of receiver | metricsSink.emit(...) |
| Parametric | Compile-time type parameter | Cache<SessionToken> |
| Ad-hoc | Shape of arguments | logger.log(level, msg, meta) |
Interviewers sometimes ask "what kinds of polymorphism are there?" Name all three, give a backend example of each, and you've already signalled senior.
Aggregation vs Composition
The distinction is about ownership and lifecycle. UML uses a diamond for both — hollow for aggregation, filled for composition.
Aggregation (hollow diamond) Composition (filled diamond)
───────────────────────────── ─────────────────────────────
Library ◇─────── Book Car ◆─────── Engine
Books exist outside any library. An Engine belongs to exactly one Car.
Close the library — books remain. Destroy the Car — the Engine goes with it.
A book can be lent to another library. The Engine isn't reusable elsewhere.In backend terms:
- Aggregation: a
RateLimiterRegistryaggregatesRateLimiterinstances. The limiters exist independently; the registry just holds references. Destroying the registry doesn't destroy the limiters — another subsystem may still hold them. - Composition: a
ConnectionPoolcomposesPooledConnectionobjects. When the pool shuts down, every connection is closed. Connections don't meaningfully exist outside the pool; their lifecycle is welded to it.
// Aggregation: RateLimiterRegistry does NOT own the limiters
class RateLimiterRegistry {
private readonly limiters = new Map<string, RateLimiter>();
register(key: string, limiter: RateLimiter): void {
this.limiters.set(key, limiter);
}
get(key: string): RateLimiter | undefined {
return this.limiters.get(key);
}
// No close/destroy — limiters outlive the registry
}
// Composition: ConnectionPool OWNS the connections
class ConnectionPool {
private readonly conns: PooledConnection[] = [];
constructor(size: number, factory: () => PooledConnection) {
for (let i = 0; i < size; i++) this.conns.push(factory());
}
async close(): Promise<void> {
// Pool is responsible for tearing down every connection
await Promise.all(this.conns.map(c => c.close()));
this.conns.length = 0;
}
}The interview-facing test is: "if you destroy the container, does the contained thing survive?" Yes → aggregation. No → composition.
Why it matters: lifecycle bugs are some of the nastiest production incidents. "Who closes this connection?" "Who cancels this timer?" "Who flushes this batch on shutdown?" — the answer is always the composing parent, never the aggregator. Being fluent with this distinction helps you reason about resource ownership, which every senior reviewer probes.
Encapsulation
Textbooks say encapsulation is "making fields private." That's the mechanism, not the goal.
Senior framing: encapsulation protects invariants, not fields.
Consider a naive TokenBucket:
class TokenBucket {
public tokens: number;
public capacity: number;
public refillRatePerMs: number;
public lastRefillMs: number;
constructor(capacity: number, refillRatePerMs: number) {
this.capacity = capacity;
this.refillRatePerMs = refillRatePerMs;
this.tokens = capacity;
this.lastRefillMs = Date.now();
}
tryAcquire(): boolean {
this.refill();
if (this.tokens >= 1) { this.tokens -= 1; return true; }
return false;
}
private refill(): void {
const now = Date.now();
const elapsed = now - this.lastRefillMs;
this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillRatePerMs);
this.lastRefillMs = now;
}
}The fields are public, but that's not what makes it broken. What makes it broken is that any caller can violate the invariant: tokens >= 0, tokens <= capacity, lastRefillMs <= Date.now(). Someone sets bucket.tokens = -500. Someone sets bucket.capacity = 0. Someone sets bucket.lastRefillMs to tomorrow. The class can't enforce its own correctness.
Now make them private:
class TokenBucket {
private tokens: number;
private lastRefillMs: number;
constructor(
private readonly capacity: number,
private readonly refillRatePerMs: number,
) {
this.tokens = capacity;
this.lastRefillMs = Date.now();
}
tryAcquire(): boolean { /* same */ }
private refill(): void { /* same */ }
}Better. But private alone doesn't encapsulate — it just forbids external mutation. True encapsulation is the combination of:
- Restricted access —
private/readonly. - Invariants established in the constructor — bounds-checked inputs, no way to construct an invalid object.
- Every public mutation preserves the invariants — the only code that can violate them is inside the class, where you control it.
The "senior version" of encapsulation: private fields aren't encapsulation — they're a tool you use to achieve encapsulation. A class with private fields that still exposes invariant-breaking methods (setTokens(n)) is just as broken as one with public fields.
A related senior observation: language-level privacy isn't real at runtime in most languages (JavaScript has #, Python has _name, Java has reflection). Privacy is a design contract, enforced by discipline and tooling. The invariants are what actually matter.
Association
The weakest of the relationships. "These two classes know about each other."
Every aggregation is an association. Every composition is an association. But plain association is the catch-all for transient relationships — where the collaboration exists for a method call, not for the object's lifetime.
Example: OrderService takes a DiscountCalculator as a method parameter, not as a field.
class OrderService {
priceOrder(order: Order, calculator: DiscountCalculator): Money {
const discount = calculator.calculate(order);
return order.subtotal.minus(discount);
}
}OrderService doesn't hold a DiscountCalculator. It just uses one for this call. That's association. If instead it had private readonly calculator: DiscountCalculator set in the constructor and used across many calls, that would be aggregation.
When an interviewer asks "how are these classes related?" and the honest answer is "they just interact when needed" — that's association. Don't reach for aggregation or composition because they sound heavier. Association is often correct, and over-modeling a transient collaboration as a permanent relationship adds noise to the design.
| Relationship | Lifetime coupling | Typical code shape |
|---|---|---|
| Association | None — both exist independently | Method parameter, return value |
| Aggregation | Container holds the thing, doesn't own it | Field, but doesn't clean up on destroy |
| Composition | Container owns; destroy container, destroy contained | Field; constructor creates it; cleanup propagates |
Dependency Injection
Depended-on collaborators are passed in to a class rather than constructed by the class. This gives you testability, configurability, and loose coupling. DI is not a framework — it's the pattern. Spring, Guice, NestJS are containers that automate the pattern; the pattern works without them.
Three styles:
Constructor injection
Dependencies are parameters to the constructor. Stored as private readonly.
class CheckoutService {
constructor(
private readonly payments: PaymentClient,
private readonly inventory: InventoryClient,
private readonly metrics: MetricsSink,
) {}
async checkout(order: Order): Promise<Receipt> { /* ... */ }
}The object is fully initialized after construction. Dependencies can be readonly. The compiler enforces that you supply them. Test doubles are trivially injected.
Setter injection
Dependencies are supplied after construction via setter methods.
class CheckoutService {
private payments!: PaymentClient;
private inventory!: InventoryClient;
setPayments(p: PaymentClient) { this.payments = p; }
setInventory(i: InventoryClient) { this.inventory = i; }
}Useful when a dependency is genuinely optional (a metrics sink, a cache) or when there's a circular dependency that must be broken. The object has a window of "partially initialized" state, which is a source of bugs.
Field injection
Dependencies are stuck onto fields directly by a framework, often via reflection and annotations.
class CheckoutService {
@Inject payments!: PaymentClient; // framework sets this magically
@Inject inventory!: InventoryClient;
}No explicit constructor, no setters, no signal at the type level that the class has dependencies. In a Spring/Guice codebase this is common but controversial.
| Style | Explicit | Testable without container | Supports readonly | Good for |
|---|---|---|---|---|
| Constructor | Yes | Yes | Yes | Default. Required dependencies. |
| Setter | Yes-ish | Yes | No | Optional dependencies, cycles. |
| Field | No (hidden in framework) | No (needs reflection setup) | No | Honestly — nothing in code you control. |
Senior take: constructor injection is the default. Setter injection is acceptable for optional dependencies or unavoidable cycles. Field injection is a red flag in code you own — it hides the dependency graph from the type system, makes the class hard to test outside the framework, and invites classes to accrete hidden dependencies over time. Field injection is tolerable only in framework-glued classes where the framework is how the object is assembled.
Related distinction interviewers love: DI is not the Dependency Inversion Principle (DIP). DIP says "depend on abstractions, not concretions." DI is a technique for implementing code that follows DIP. You can do DI while still violating DIP (inject a concrete PostgresUserRepo instead of an UserRepo interface). You can follow DIP without DI (a class that constructs its collaborators behind an interface). Getting this wrong is the single most common terminology slip in senior interviews.
The Expression Problem
Two axes of extensibility pull against each other:
- Adding new types — new cases, new variants of a thing.
- Adding new operations — new behavior that works across all existing types.
OOP makes #1 easy and #2 hard: adding a new PaymentMethod is a new class implementing an interface. But adding a new operation that must work on every existing PaymentMethod requires touching every class (or inventing a visitor and doing the dance).
Functional design flips it: operations are just functions, so adding new operations is trivial (just write a new function that pattern-matches on the variants). But adding a new variant requires touching every function that pattern-matches.
You can't get both for free. This tradeoff — baked into the type system, not a coding style — is the Expression Problem, and it explains most religious wars over OOP vs FP.
Senior candidates name it by the 45-minute mark when the interviewer asks "what if we had to add a new payment method?" or "what if we need to support a new operation?" The answer isn't "it's fine" — it's "this axis of change is cheap in the design I picked; the orthogonal axis would require touching every existing class, and here's what I'd do about it (visitor pattern, tagged union + function dispatch, etc.)." Naming the problem signals you understand the shape of the tradeoff rather than just coping with it.
Interview-Facing Distinctions
Interviewers rarely ask "what is polymorphism?" They ask questions that require you to know the distinction. Translation table:
| Interviewer phrasing | The concept they're probing |
|---|---|
| "How does class X know about class Y?" | Association vs aggregation vs composition — pick the right one based on lifecycle. |
| "If I destroy the Order, does the LineItem go with it?" | Composition vs aggregation. |
| "Should this be an interface or an abstract class?" | Contract-only vs contract-plus-shared-implementation; also: is composition cleaner? |
| "What if we added a new PaymentMethod?" | Subtype polymorphism. Probably also testing the Expression Problem. |
| "How would you test this class in isolation?" | Dependency injection (usually constructor injection). |
| "What stops a caller from leaving this object in a bad state?" | Encapsulation of invariants — not "are the fields private." |
| "Why not just extend the existing class?" | Fragile base class, is-a vs has-a, favor composition. |
| "How do you handle cross-cutting concerns like retry / metrics / caching?" | Decorator (composition) over inheritance. |
| "DI or DIP?" | Trap. Know both — DI is how; DIP is what. |
| "What kinds of polymorphism does this language support?" | Subtype, parametric, ad-hoc. Name all three. |
If you can answer the question on the left by naming the concept on the right and explaining why that concept is the right frame, you're already speaking the senior dialect. If you reach for textbook definitions first and concept-to-use-case second, you're still at mid-level. Practice the reverse translation — start from the interviewer phrasing, arrive at the concept, then talk about tradeoffs — and the OOP section of the interview disappears as a problem.