Here's how OOP is typically taught: four buzzwords, a textbook chapter, and a grade. Here's what happens next: developers spend their careers writing functions, just with classes around them. Four walls, no architecture.
Object-oriented programming isn't a file structure. It's a way of thinking about software — one that takes years to internalize and repays that investment every time a large system needs to change without falling apart.
The vocabulary is simple. A class is the definition. An object is the thing.
Think of a floor plan versus an actual building. An architect draws a plan: three bedrooms, a hallway, a back entrance. That plan can produce a hundred buildings. Each building becomes its own thing — its own history, its own residents, its own wear and tear. The plan doesn't change. The buildings do.
class House:
def __init__(self, color, bedrooms):
self.color = color
self.bedrooms = bedrooms
my_house = House("white", 3)
your_house = House("brown", 2)
my_house and your_house come from the same class. They're not the same object. They never will be. That independence is the point.
Every object exists across three dimensions at once.
State: what it holds — a balance, an address, a timestamp. Behavior: what it can do — withdraw, update, expire. Identity: what makes it it and not something else — even two objects with identical state remain distinct.
The constructor is where an object gets built. Its contract is simple: produce a valid object and exit cleanly.
public class BankAccount {
private String owner;
private double balance;
public BankAccount(String owner, double initialDeposit) {
this.owner = owner;
this.balance = initialDeposit;
}
}
No network requests inside the constructor. No database writes. No logic that can silently fail. Just initial state, fully set up and ready to go.
Methods carry responsibility — and the right question about any method isn't "does it run?" It's "does it belong here?" A User class shouldn't know how to sendInvoice(). If it does, the design is already starting to drift.
Encapsulation is frequently summarized as "use private fields." That's the implementation, not the idea.
The idea: an object should control how its own state gets modified. External code shouldn't be able to reach in and change things in ways the object didn't plan for.
The vending machine puts it well. You can see what's inside. You can pay and press a button. You cannot open the back panel and rearrange the inventory. The interface is deliberate. The internals are protected.
class VendingMachine {
private stock: number = 10;
public purchase(): string {
if (this.stock <= 0) return "Out of stock";
this.stock--;
return "Here's your item";
}
}
stock can't be touched from the outside. Every change goes through purchase(), which checks the rules first. That's encapsulation doing exactly what it's supposed to do.
Abstraction is the practice of hiding how something works so that users of it only need to know what it does.
You've been using abstraction since you first pressed a button. Pressing a car's accelerator doesn't require understanding anything about fuel systems. The complexity exists — you just don't have to manage it.
class PaymentGateway:
def charge(self, amount: float):
raise NotImplementedError
class StripeGateway(PaymentGateway):
def charge(self, amount: float):
# API handshake, retry logic, error handling — all hidden here
print(f"Charged {amount} via Stripe")
The calling code calls .charge(). That's it. Add a new payment provider tomorrow — PayPalGateway, SquareGateway, anything — and the calling code stays exactly as it was. The interface absorbs the variation.
Inheritance is for "is-a" relationships. A Dog is an Animal. A SavingsAccount is a BankAccount. You define the shared foundation once, then extend it where each type diverges.
public class Animal {
public void eat() {
System.out.println("Eating...");
}
}
public class Dog extends Animal {
public void bark() {
System.out.println("Woof!");
}
}
The problem arrives when inheritance gets used as a shortcut for borrowing functionality. Subclassing something because it happens to have two useful methods isn't a relationship — it's a dependency you'll regret the moment the parent class needs to evolve. Inheritance should reflect something genuinely true about the types involved.
Polymorphism means the same call, different behavior, depending on who receives it.
interface Shape {
area(): number;
}
class Circle implements Shape {
constructor(private radius: number) {}
area(): number { return Math.PI * this.radius ** 2; }
}
class Rectangle implements Shape {
constructor(private w: number, private h: number) {}
area(): number { return this.w * this.h; }
}
function printArea(shape: Shape) {
console.log(shape.area());
}
printArea calls .area() on whatever arrives. It never needs to check what kind of shape it's dealing with. It doesn't need to change when you add a Triangle. The object handles its own behavior. That's what makes polymorphism powerful in real systems.
Before committing to how two classes relate, ask a single question: does one become the other, or does one use the other?
A car doesn't inherit from an engine. It isn't an engine. It has one. It uses one.
class Engine:
def start(self):
return "Engine started"
class Car:
def __init__(self):
self.engine = Engine()
def start(self):
return self.engine.start()
Composition means you can swap Engine for ElectricEngine without touching Car. The parts stay decoupled. Swap freely.
Inheritance locks the relationship in. When the parent changes, the children feel it. Composition makes change local. That's a significant operational difference when you're working in a live codebase under pressure.
Default to composition. Use inheritance when you have a relationship that's real, stable, and worth expressing explicitly in the type hierarchy.
Objects relate to each other at different degrees of tightness. The degree matters.
Association is a relationship without ownership. A doctor and a patient have a connection. Neither depends on the other for their existence. They can both go on without the relationship.
Aggregation is a whole that contains parts that could stand alone. A team has players. If the team dissolves, the players don't. They find other teams.
Composition means the parts don't survive the whole. A house has rooms. If you demolish the house, the rooms aren't independent entities anymore — they're just rubble.
Knowing which you're dealing with determines how you build schemas, how you handle object deletion, and who's responsible for cleanup when a relationship ends.
If you want a fast read on a codebase's design, check two things.
Coupling tells you how tangled classes are with each other. High coupling: change one thing, update ten others. Low coupling: change one thing, everything else keeps running. Low coupling is what you want — it's what makes changes feel surgical instead of explosive.
Cohesion tells you how focused each class is. A class that handles user registration, sends emails, exports PDFs, and logs activity is doing four jobs and doing none of them particularly well. Split it. Four classes with one clear purpose each are always easier to work with than one class with four muddled ones.
The target is a codebase where each class has a single, identifiable reason to exist — and where that class receives its dependencies from outside rather than building them internally. A UserRegistrationService that only registers users, and gets its mailer and logger injected, is the textbook example of this done right.
Every time you write a class, ask: how many places break if this changes? The lower that number, the better the design.
The God Object. One massive class that accumulates everything because nobody wanted to design a proper alternative. It's almost always called Manager or Handler. It needs to be broken up. Do it now, not after it gets worse.
The Anemic Domain Model. Classes with nothing but fields and accessors, with all the real logic extracted into service layers. This is procedural programming wearing object-oriented clothing. Behavior belongs alongside the data it operates on — not in a parallel universe of stateless services.
Deep Inheritance Chains. Once a hierarchy runs four or five levels deep, nobody can reason about what any given class actually inherits. The relationship that made sense at level two becomes a liability by level five. Flatten it with composition before it collapses under its own weight.
Abstractions Built Too Early. Interfaces and base classes written for variations that don't exist yet. Write the concrete thing. When the second real variation appears, you'll see the pattern clearly. Abstract then — not from imagination.
An order processing system makes a clean illustration.
The naive version: one Order class that calculates prices, applies discounts, sends emails, and updates stock. Every new feature and every bug fix lands in the same file. The class grows without limit. Nothing can be tested in isolation.
The better version:
class Order:
def __init__(self, items, customer):
self.items = items
self.customer = customer
self.total = sum(item.price for item in items)
class DiscountService:
def apply(self, order: Order, rate: float) -> float:
return order.total * (1 - rate)
class OrderNotifier:
def notify(self, order: Order):
print(f"Confirmation sent to {order.customer.email}")
Order owns its own data. DiscountService owns pricing decisions. OrderNotifier owns communication.
When the business decides to replace email with SMS, you change OrderNotifier. Nothing else. The classes that don't own that responsibility don't get involved. That's what low coupling and high cohesion look like in an actual codebase — not in a diagram. Practical, testable, and boring in exactly the right way.
OOP doesn't begin with syntax and end with four terms. It's a set of principles for organizing software so that it can grow, change, and survive contact with real requirements over time.
The developers who use it well ask different questions than everyone else. Not just "how do I implement this?" but "who should own this? What happens when it changes? How does this interact with everything else?"
They approach their object boundaries the way good architects approach load-bearing walls — thinking ahead, knowing what they're supporting.
The vocabulary is a starting point. The design sense is what you build over years of making the wrong call and working out why. That's where the actual craft lives.