Vasu Patel logoVasu Patel
← Objects First: OOP Fundamentals
All chapters in this track
  1. 01Chapter 1: Why Object Orientation Exists
  2. 02Chapter 2: Classes, Objects, and Instances, Precisely Defined
  3. 03Chapter 3: The Four Pillars I: Encapsulation
  4. 04Chapter 4: The Four Pillars II: Abstraction
  5. 05Chapter 5: The Four Pillars III: Inheritance
  6. 06Chapter 6: The Four Pillars IV: Polymorphism
  7. 07Chapter 7: Composition vs. Inheritance
  8. 08Chapter 8: Interfaces and Abstract Classes: When to Use Which
  9. 09Chapter 9: SOLID, Part 1: Single Responsibility & Open/Closed
  10. 10Chapter 10: SOLID, Part 2: Liskov Substitution, Interface Segregation, Dependency Inversion
  11. 11Chapter 11: Basic UML for Communicating Design
  12. 12Chapter 12: Anti-Patterns: God Objects, Tight Coupling, Deep Hierarchies, the Fragile Base Class, Anemic Domain Models
  13. 13Chapter 13: OOP Idioms Across TypeScript and Python — and Where OOP Is the Wrong Tool
  14. 14Chapter 14: When OOP Breaks Down: Real-World Failure Modes
  15. 15Chapter 15: Design Exercise: Parking Lot System
  16. 16Chapter 16: Design Exercise: Library Management System
  17. 17Chapter 17: Design Exercise: Elevator Controller
  18. 18Chapter 18: OOP in Interviews: What a Strong Answer Looks Like

Chapter 1 of 18

Chapter 1: Why Object Orientation Exists

Why this matters

Every engineer eventually hits the same wall: a program that started as a handful of functions has grown into a web of global state, and nobody can change one part without breaking three others. Object orientation is a response to that specific failure mode. It isn't a religion, a syntax requirement, or a box to check — it's a way of organizing code so that data and the operations that keep it valid travel together, instead of drifting apart as the codebase grows.

Understanding why OOP exists, not just its syntax, is what lets you recognize when it's the right tool and when reaching for it is the mistake. That judgment call — not the ability to define "encapsulation" — is what separates engineers who use OOP well from engineers who use it because it's the default.

The core concept

[BEGINNER] Object-oriented programming bundles data (state) and behavior (the functions that operate on that state) into a single unit — an object — and hides the internal representation of that data behind a controlled interface. The goal is to keep the number of places that can put an object into an invalid state as small and as well-guarded as possible.

Before OOP became mainstream, the dominant style was procedural programming: data structures were passed around as plain records, and free-standing functions operated on them. This works fine at small scale. It breaks down as a codebase grows, because nothing stops any function anywhere in the program from reaching in and mutating that data incorrectly — validity becomes everyone's responsibility, which in practice means it's no one's responsibility.

Here is the failure mode, concretely, in TypeScript.

Before — procedural style, data and behavior kept apart:

// A plain record. Nothing enforces its invariants.
interface Account {
  id: string;
  balanceCents: number;
}

const accounts: Account[] = [];

function openAccount(id: string): Account {
  const account = { id, balanceCents: 0 };
  accounts.push(account);
  return account;
}

function deposit(account: Account, amountCents: number): void {
  account.balanceCents += amountCents;
}

function withdraw(account: Account, amountCents: number): void {
  // Written by one engineer, who remembered the balance check.
  if (account.balanceCents >= amountCents) {
    account.balanceCents -= amountCents;
  }
}

// Elsewhere in the codebase, written by a different engineer,
// months later, who didn't know the withdraw() convention existed:
function applyLateFee(account: Account, feeCents: number): void {
  account.balanceCents -= feeCents; // No balance check. Now it can go negative.
}

Nothing here is "wrong" in isolation — applyLateFee is a perfectly reasonable function to write. The problem is architectural: balanceCents is a public field on a plain object, so every function in the program that can see an Account can mutate it however it wants. The rule "never let the balance go negative" exists only as a convention inside withdraw. It is not enforced anywhere near the data itself, so it will eventually be violated by code that doesn't know the convention exists.

After — the invariant lives with the data:

class Account {
  private balanceCents: number = 0;

  constructor(public readonly id: string) {}

  deposit(amountCents: number): void {
    this.assertPositive(amountCents);
    this.balanceCents += amountCents;
  }

  withdraw(amountCents: number): void {
    this.assertPositive(amountCents);
    if (amountCents > this.balanceCents) {
      throw new Error(`Insufficient funds: balance is ${this.balanceCents}`);
    }
    this.balanceCents -= amountCents;
  }

  applyLateFee(feeCents: number): void {
    // Forced to go through withdraw() — the invariant can't be bypassed.
    this.withdraw(feeCents);
  }

  getBalanceCents(): number {
    return this.balanceCents;
  }

  private assertPositive(amountCents: number): void {
    if (amountCents <= 0) throw new Error("Amount must be positive");
  }
}

balanceCents is now private. There is exactly one path to changing it — through methods that enforce the invariant — and it is structurally impossible for new code to bypass that path by accident. The rule didn't move from a comment to a better comment; it moved from a convention into the type system, where a compiler and a private-field boundary enforce it whether or not the next engineer read the docs.

The same idea in Python (which uses convention — a leading underscore — rather than a hard compiler boundary, a distinction covered fully in Chapter 3):

class Account:
    def __init__(self, account_id: str) -> None:
        self.id = account_id
        self._balance_cents = 0

    def deposit(self, amount_cents: int) -> None:
        self._assert_positive(amount_cents)
        self._balance_cents += amount_cents

    def withdraw(self, amount_cents: int) -> None:
        self._assert_positive(amount_cents)
        if amount_cents > self._balance_cents:
            raise ValueError(f"Insufficient funds: balance is {self._balance_cents}")
        self._balance_cents -= amount_cents

    def apply_late_fee(self, fee_cents: int) -> None:
        self.withdraw(fee_cents)

    @property
    def balance_cents(self) -> int:
        return self._balance_cents

    def _assert_positive(self, amount_cents: int) -> None:
        if amount_cents <= 0:
            raise ValueError("Amount must be positive")

[IN PRACTICE] This is the entire pitch for OOP in one example: as a codebase grows past what one person can hold in their head, the question stops being "can I write correct code?" and becomes "can I make it structurally hard for anyone — including future me — to write incorrect code?" Object orientation's answer to that question is: put the data and the rules that protect it in the same place, and make the rules the only door in.

Where OOP came from, briefly

[BEGINNER] You don't need the full history to use OOP well, but the shape of it explains why the pillars are what they are:

  • Simula (1960s) introduced classes and objects to model discrete-event simulations — the original use case was literally "model real-world entities with their own state and behavior."
  • Smalltalk (1970s) generalized this into a full programming paradigm and coined "object-oriented programming." Everything is an object, and objects communicate by sending messages — this is where the emphasis on encapsulation and late binding (polymorphism) comes from.
  • C++, then Java (1980s–1990s) brought OOP to mainstream industry use, adding static typing and formalizing interfaces, access modifiers, and the class-based model most engineers now think of as "OOP by default."
  • Modern multi-paradigm languages (Python, TypeScript, Kotlin, Swift, Rust, ...) treat OOP as one tool among several — most codebases today mix objects, functions, and modules rather than being purely object-oriented.

The throughline: OOP was never "the correct way to write software." It was a solution to a specific pain — data and behavior drifting apart as systems grew — that happened to also give us a useful vocabulary (classes, inheritance, polymorphism) for talking about design.

When OOP is the wrong tool

[SENIOR INSIGHT] This is the part junior engineers skip and senior engineers lead with: OOP is a good fit when you have state that needs to be protected and behavior that's tightly coupled to that state. It is a poor fit — or actively harmful — in several common situations:

SituationWhy OOP strugglesBetter fit
Pure data transformation pipelines (parse → filter → map → aggregate)There's no state to protect; wrapping stateless transformations in objects just adds ceremonyPlain functions, function composition
Highly concurrent stateMutable objects shared across threads are a classic source of race conditions (see the companion Concurrency book)Immutable data + pure functions, message passing
Simple scripts and one-off toolsThe overhead of designing a class hierarchy outweighs the benefit for code that runs onceProcedural scripting
Data with no meaningful behavior (DTOs, API payloads)Wrapping a plain record in getters/setters that do nothing but return the field is ceremony without protectionPlain structs / records / dataclasses
Cross-cutting concerns (logging, retries, validation) that apply uniformly across unrelated typesForcing these into a class hierarchy leads to awkward inheritance just to share behaviorHigher-order functions, decorators, middleware

This table is the seed of a recurring theme in this book (fully explored in Chapter 13): the four pillars are tools for managing state and behavior together — reach for them when you have state worth protecting, not by default.

Common mistakes

  1. Reaching for a class before there's state to protect. A function that takes an input and returns an output, with no internal state, doesn't need a class wrapped around it. class Calculator { add(a, b) { return a + b; } } is strictly worse than a function named add.
  2. Treating "object-oriented" as synonymous with "well-designed." A codebase can be full of classes and still have the exact procedural problem from the "before" example above, if those classes expose public mutable fields instead of protecting invariants. Using class is not the same as encapsulating.
  3. Modeling the real world instead of modeling the problem. New OOP learners often try to make their class hierarchy mirror physical reality (Animal → Dog → Poodle) rather than the actual behavior their program needs. Model what the program needs to do, not a biology taxonomy.
  4. Ignoring that OOP and functional/procedural styles compose. Real codebases mix paradigms: an OOP domain model with functional-style data transformations around its edges is extremely common and often the strongest design, not a contradiction.

Checklist: deciding whether OOP fits

  1. Is there state that must stay valid across multiple operations? If yes, that state benefits from being encapsulated in an object — the invariant needs one enforced home.
  2. Do multiple operations need to share and coordinate that state? If a single function transforms an input to an output with no persisted state, a class adds ceremony, not protection.
  3. Will this concept need multiple interchangeable implementations? If so, that's a signal for interfaces and polymorphism (Chapters 6 and 8) — a strong sign OOP's vocabulary will help.
  4. Is the team already using an OOP-idiomatic style in this codebase? Consistency with the surrounding codebase is itself a reason to follow the existing paradigm, independent of which paradigm is "purer" for this specific piece.
  5. Would a plain data structure plus free functions be just as clear? If yes — and there's no invariant to protect — prefer the simpler thing. OOP is a cost (indirection, ceremony, more to navigate), and costs need to buy something.

Discussion / interview questions

  1. "What problem does object-oriented programming solve that procedural programming doesn't?" Strong answer: Names the specific failure mode — invariants enforced only by convention, not structure, becoming unenforceable as a codebase grows past what one person can track — and gives a concrete example, not just "it's more organized."

  2. "When would you not use OOP for a piece of code?" Strong answer: Cites at least one concrete case from the table above (stateless transformations, DTOs, highly concurrent state) rather than saying "when it's simple" without specifics. Bonus: mentions that most production codebases are multi-paradigm, not purely OOP.

  3. "Is a codebase that uses classes everywhere automatically well object-oriented?" Strong answer: No — points out that classes with public mutable fields and no enforced invariants have all the ceremony of OOP with none of the protection, using the "before" example's shape (procedural logic wrapped in a class) as the counter-case.

  4. "Give an example of an invariant, and show two ways of enforcing it — one fragile, one robust." Strong answer: States an invariant (e.g., "balance never goes negative"), shows the fragile version (a plain object with a convention-only rule spread across functions), and the robust version (private state + methods that are the only way to change it).

  5. "How do the origins of OOP (Simula, Smalltalk) explain why the four pillars look the way they do?" Strong answer: Connects Simula's "model discrete entities with state and behavior" origin to why classes bundle data and behavior, and Smalltalk's message-passing model to why polymorphism (an object deciding how to respond to a message) is central rather than incidental.

Chapter summary / cheat sheet

  • OOP bundles data and the operations that keep it valid into one unit, and restricts how that data can be changed.
  • The core failure mode it fixes: invariants enforced only by convention become unenforceable at scale, because any code that can see the data can mutate it.
  • OOP emerged from Simula (model entities) and was formalized by Smalltalk (objects + message passing); it is one paradigm among several, not "the correct way to code."
  • Use it when there's state worth protecting and behavior tightly coupled to that state. Skip it for stateless transformations, DTOs, and highly concurrent state where immutability serves better.
  • A codebase full of class keywords is not automatically well object-oriented — public mutable fields with no enforced invariant is procedural code wearing a class as a costume.