Object-Oriented Programming
OOP is one of those things that gets explained with shapes and animals in textbooks, which is why so many developers end up writing class hierarchies that collapse into maintenance nightmares. The actual idea is simple: group data with the behavior that operates on it, and control what the outside world can touch.
🟢 Junior
Encapsulation
Encapsulation means hiding internal state and only exposing what needs to be exposed. The point isn’t to make things hard to access — it’s to protect invariants. If nothing can set a field directly, you can guarantee it’s always valid.
class BankAccount {
private balance: number;
constructor(initialBalance: number) {
if (initialBalance < 0) throw new Error('Balance cannot be negative');
this.balance = initialBalance;
}
deposit(amount: number): void {
if (amount <= 0) throw new Error('Deposit must be positive');
this.balance += amount;
}
withdraw(amount: number): void {
if (amount > this.balance) throw new Error('Insufficient funds');
this.balance -= amount;
}
getBalance(): number {
return this.balance;
}
}
The balance field is never exposed directly. Every mutation goes through a method that enforces the rule that balance can never go negative. That’s encapsulation doing its job.
Inheritance
Inheritance lets a class take on the fields and methods of another. The child is a more specific version of the parent.
class Animal {
protected String name;
Animal(String name) { this.name = name; }
String speak() { return "..."; }
}
class Dog extends Animal {
Dog(String name) { super(name); }
@Override
String speak() { return name + " says: Woof!"; }
}
class Cat extends Animal {
Cat(String name) { super(name); }
@Override
String speak() { return name + " says: Meow."; }
}
The rule of thumb: inheritance should model an is-a relationship. A Dog is an Animal. A Cat is an Animal. If you’re reaching for inheritance to share code rather than to express a real categorical relationship, you’re in trouble.
Polymorphism
Polymorphism means you can treat objects of different types through a common interface and each one responds appropriately.
List<Animal> animals = List.of(new Dog("Rex"), new Cat("Whiskers"), new Dog("Buddy"));
for (Animal a : animals) {
System.out.println(a.speak());
}
// Rex says: Woof!
// Whiskers says: Meow.
// Buddy says: Woof!
The caller doesn’t know or care what the actual type is. This is what makes polymorphism valuable — you can add a new Animal subclass without changing any code that loops over a List<Animal>.
Abstraction
Abstraction means working with “what something does” rather than “how it does it.” Interfaces and abstract classes are the main tools.
interface PaymentProcessor {
charge(amount: number, currency: string): Promise<string>;
refund(transactionId: string): Promise<void>;
}
class StripeProcessor implements PaymentProcessor {
async charge(amount: number, currency: string) {
return stripe.charges.create({ amount, currency });
}
async refund(transactionId: string) {
await stripe.refunds.create({ charge: transactionId });
}
}
class PayPalProcessor implements PaymentProcessor {
async charge(amount: number, currency: string) { /* ... */ return 'txn_123'; }
async refund(transactionId: string) { /* ... */ }
}
The OrderService depends on PaymentProcessor, not on Stripe or PayPal specifically. Swapping payment providers doesn’t require touching OrderService.
🟡 Medior
Composition over inheritance
Inheritance couples classes tightly. Every time the parent changes, every child inherits the change whether it wants it or not. Deep hierarchies amplify this — a change three levels up can break something five levels down.
Composition solves this by building objects from smaller, interchangeable pieces.
interface Logger { log(msg: string): void; }
interface Cache { get(key: string): unknown; set(key: string, value: unknown): void; }
class ConsoleLogger implements Logger {
log(msg: string) { console.log(`[LOG] ${msg}`); }
}
class InMemoryCache implements Cache {
private store = new Map<string, unknown>();
get(key: string) { return this.store.get(key); }
set(key: string, v: unknown) { this.store.set(key, v); }
}
class UserService {
constructor(
private logger: Logger,
private cache: Cache,
) {}
async getUser(id: string) {
const cached = this.cache.get(id);
if (cached) { this.logger.log(`cache hit for ${id}`); return cached; }
const user = await fetchFromDb(id);
this.cache.set(id, user);
return user;
}
}
UserService doesn’t inherit logging or caching behavior — it uses it. You can swap either for a different implementation, test with fakes, or disable caching for specific environments without touching UserService.
SOLID — what actually matters in practice
Single Responsibility — a class should have one reason to change. A class that validates input, persists to a database, and sends email notifications has three reasons to change. Split it.
class UserRegistration {
void register(String email, String password) throws Exception {
if (!email.contains("@")) throw new Exception("Invalid email");
db.query("INSERT INTO users VALUES (?, ?)", email, hash(password));
emailService.send(email, "Welcome!");
}
}
Three responsibilities in one method — validation, storage, notification. Changing any of them risks breaking the others. The fix is three separate classes, each with one job, wired together by a RegistrationService.
Open/Closed — open for extension, closed for modification. New behavior should be added by writing new code, not by editing existing code.
void processPayment(Order order, String type) {
if (type.equals("stripe")) { /* ... */ }
else if (type.equals("paypal")) { /* ... */ }
else if (type.equals("crypto")) { /* ... */ }
}
Every new payment type requires editing this method. Instead, define an interface and let each payment type implement it:
interface PaymentStrategy {
String process(Order order);
}
class StripePayment implements PaymentStrategy {
public String process(Order order) { return stripe.charge(order.getTotal()); }
}
String processPayment(Order order, PaymentStrategy strategy) {
return strategy.process(order);
}
Adding CryptoPayment is a new class — the method above never changes.
Liskov Substitution — a subclass should be usable wherever its parent is used without the caller knowing the difference. The canonical violation is Square extends Rectangle:
class Rectangle {
int width, height;
void setWidth(int v) { this.width = v; }
void setHeight(int v) { this.height = v; }
int area() { return width * height; }
}
class Square extends Rectangle {
@Override void setWidth(int v) { width = v; height = v; }
@Override void setHeight(int v) { width = v; height = v; }
}
static int stretchWidth(Rectangle rect) {
rect.setWidth(10);
rect.setHeight(5);
return rect.area();
}
stretchWidth(new Rectangle()); // 50 — correct
stretchWidth(new Square()); // 100 — silent wrong answer
Square broke the contract Rectangle established. The fix is to not model this with inheritance — both implement a Shape interface independently.
Interface Segregation — many small, specific interfaces beat one large one. Don’t force implementers to depend on methods they don’t use.
interface Worker {
void work();
void eat();
void sleep();
}
class Robot implements Worker {
public void work() { /* ... */ }
public void eat() { throw new UnsupportedOperationException("Robots don't eat"); }
public void sleep() { throw new UnsupportedOperationException("Robots don't sleep"); }
}
Robot is forced to implement methods that don’t apply to it. Split the interface:
interface Workable { void work(); }
interface Eatable { void eat(); }
interface Sleepable { void sleep(); }
class Human implements Workable, Eatable, Sleepable { /* all three */ }
class Robot implements Workable { public void work() { /* ... */ } }
Dependency Inversion — high-level code should depend on abstractions, not concrete implementations.
class OrderService {
private SMTPMailer mailer = new SMTPMailer(); // hardcoded concrete class
void completeOrder(Order order) {
mailer.send(order.getEmail(), "Your order is complete");
}
}
OrderService is locked to SMTP. Testing requires a real mail server. Switching providers requires editing this class. Invert it:
interface Mailer {
void send(String to, String subject);
}
class OrderService {
private final Mailer mailer;
OrderService(Mailer mailer) { this.mailer = mailer; }
void completeOrder(Order order) {
mailer.send(order.getEmail(), "Your order is complete");
}
}
// Testing: new OrderService(new MockMailer())
// Production: new OrderService(new SMTPMailer())
// Switching: new OrderService(new SESMailer()) — OrderService untouched
The examples throughout this article follow this principle — UserService depends on Logger and Cache interfaces, not on ConsoleLogger or InMemoryCache.
The diamond problem
Multiple inheritance — where a class inherits from two parents that share a common ancestor — creates ambiguity. If Dog and Robot both have a move() method, and RobotDog inherits from both, which move() does it get?
Java and C# sidestep this by not allowing multiple class inheritance (only multiple interface implementation). C++ allows it and resolves it with virtual base classes — a feature almost everyone gets wrong. Python uses the Method Resolution Order (MRO), a linearization algorithm that defines the lookup order explicitly.
class A:
def greet(self): return 'A'
class B(A):
def greet(self): return 'B'
class C(A):
def greet(self): return 'C'
class D(B, C):
pass
D().greet() # 'B' — MRO: D → B → C → A
print(D.__mro__)
# (<class 'D'>, <class 'B'>, <class 'C'>, <class 'A'>, <class 'object'>)
The MRO is depth-first, left-to-right, with each class appearing only once. It’s deterministic and consistent, which is why Python’s approach works — but the complexity of thinking about it is itself a sign that deep inheritance is a code smell.
🔴 Senior
The expression problem
The expression problem, stated by Philip Wadler in 1998, is this: you have a set of types and a set of operations. You want to add new types without rewriting existing operations, and add new operations without rewriting existing types — and you want both to be type-safe.
OOP handles new types well. Adding a Crocodile to an Animal hierarchy requires adding one class. Existing speak() calls work automatically.
OOP handles new operations badly. Adding a serialize() operation to every Animal requires touching every class. If those classes are in a library you don’t own, you can’t.
Functional programming (with pattern matching on sum types) handles new operations well but handles new types badly — adding a new variant to a sum type breaks every pattern match.
The visitor pattern is OOP’s workaround for adding operations:
interface AnimalVisitor<T> {
T visitDog(Dog dog);
T visitCat(Cat cat);
}
interface Animal {
<T> T accept(AnimalVisitor<T> visitor);
}
class Dog implements Animal {
public <T> T accept(AnimalVisitor<T> v) { return v.visitDog(this); }
}
class Serializer implements AnimalVisitor<String> {
public String visitDog(Dog d) { return "dog:" + d.name; }
public String visitCat(Cat c) { return "cat:" + c.name; }
}
It works but requires a visit method on every class, and every new operation requires a new visitor class. The ceremony is real. Languages like Kotlin, Swift, and Scala reduce this friction with sealed classes and exhaustive pattern matching.
Mixins and traits
Mixins let you inject shared behavior into a class without full inheritance. They’re the composition-friendly answer to the code-sharing problem.
TypeScript supports mixins via a factory pattern:
type Constructor<T = {}> = new (...args: any[]) => T;
function Timestamped<TBase extends Constructor>(Base: TBase) {
return class extends Base {
createdAt = new Date();
updatedAt = new Date();
touch() { this.updatedAt = new Date(); }
};
}
function Activatable<TBase extends Constructor>(Base: TBase) {
return class extends Base {
isActive = false;
activate() { this.isActive = true; }
deactivate() { this.isActive = false; }
};
}
class User { constructor(public name: string) {} }
const TimestampedActivatableUser = Activatable(Timestamped(User));
const user = new TimestampedActivatableUser('Alice');
user.activate();
user.touch();
Rust’s traits and Haskell’s typeclasses are the purest form of this — they define behavior that any type can opt into without inheritance, with no runtime overhead.
When to not use OOP
OOP is a tool. Using it by default everywhere is the mistake.
Data transformation pipelines — a chain of functions that transforms data has no benefit from classes. A Processor class with one process() method is just a function wearing a suit.
Pure computation — statistical calculations, algorithms, parsers — functional code is often shorter, testable without instantiation, and easier to reason about.
State machines — explicit state machine implementations are clearer as a set of functions and a state enum than as a class hierarchy where each state is a subclass.
Small scripts — ceremony has a cost. A 50-line script doesn’t benefit from a class hierarchy.
The right question isn’t “should I use OOP?” It’s “does modeling this as objects with encapsulated state make the code clearer?” If yes, use it. If not, don’t.