What objects are actually for
Judge a class by how many files a rule change touches — count the edit sites in a procedural design, then behind one method, and see when plain functions win.
Object orientation is usually taught with animals: a Dog is an Animal, a Cat is
an Animal, both of them speak(). Easy to draw, and it teaches the wrong job. A
class is not a way to describe what a thing is; it is a way to decide where a
change lands.
The claim worth keeping is narrower and testable: bundle a piece of state with the operations that maintain its rules, so that changing a rule — or changing how the state is stored — edits one file. Encapsulation, polymorphism and the SOLID names are machinery for that outcome; a design that does not produce it has not earned the ceremony.
A rule with four homes
A cart holds lines and a discount percentage. Money is in integer paise, so nothing is a float. One rule: the discount is capped at 40%.
# cart = {"lines": [(price_paise, qty), ...], "discount_pct": 70}
# e.g. {"lines": [(200000, 1)], "discount_pct": 70} — ₹2,000 asking 70% off
def checkout_total(cart):
sub = sum(p * q for p, q in cart["lines"])
pct = min(cart["discount_pct"], 40) # the rule
return sub - sub * pct // 100
def invoice_total(cart):
sub = sum(p * q for p, q in cart["lines"])
pct = min(cart["discount_pct"], 40) # the rule, again
return sub - sub * pct // 100
Two more copies live in the receipt email and the nightly analytics export. Nobody planned that; each was written by whoever needed a total.
Then the cap moves to 60%. Count the edit sites: four. Miss one, and it will
be the analytics export: another service, no test asserting a total. That cart
asks for 70% off, so both caps bind: on its ₹2,000 subtotal the invoice now
charges 2,000 − 60% = ₹800 while the export still caps at 40% and records
₹1,200 — a ₹400 gap, 20% of subtotal, on every cart asking 60% or more, and
(pct − 40) points on anything between. Nothing crashes; it surfaces weeks
later as a reconciliation question.
Queue the next change — money becomes a currency-plus-amount pair, not a bare integer — and all four sites break: loudly in Java, quietly in Python. Four totals to re-type, and four caps to re-check.
The same design with one owner
Put the state and the rule in one place, and keep the raw fields out of reach: compiler-enforced in Java, convention in Python.
class Cart:
MAX_DISCOUNT_PCT = 40
def __init__(self, lines, discount_pct=0):
self._lines = list(lines)
if any(q < 1 for _, q in self._lines):
raise ValueError("every line needs qty >= 1")
self._discount_pct = max(0, min(discount_pct, self.MAX_DISCOUNT_PCT))
def total_paise(self):
sub = sum(p * q for p, q in self._lines)
return sub - sub * self._discount_pct // 100
public final class Cart {
private static final int MAX_DISCOUNT_PCT = 40;
private final List<Line> lines;
private final int discountPct;
public Cart(List<Line> lines, int discountPct) {
for (Line l : lines)
if (l.qty() < 1) throw new IllegalArgumentException("qty >= 1");
this.lines = List.copyOf(lines);
this.discountPct = Math.max(0, Math.min(discountPct, MAX_DISCOUNT_PCT));
}
public long totalPaise() {
long sub = 0;
for (Line l : lines) sub += (long) l.pricePaise() * l.qty();
return sub - sub * discountPct / 100;
}
}
The four callers now ask cart.total_paise() or cart.totalPaise(), nothing
else. Move the cap to 60% and the edit count is one: one constant, one file,
one test. Four to one is the whole argument for objects, made with a count, not
an adjective.
The storage change is sharper. Swap the list of lines for a running subtotal, or
for a query against the orders table, and total_paise() keeps its signature.
Callers cannot notice, because they were never allowed to depend on what is
inside — that, not secrecy, is what holds the edit count at one.
Python's underscore is a convention and Java's private is compiler-enforced;
the gap matters less than people expect, because what protects the rule is that
the total is computed in exactly one place.
The heap invariant is the same
arrangement with the class stripped off: an array, one rule, and two operations
allowed to touch it. So is
the BST invariant. Objects generalise
it to state that has nothing to do with data structures.
Write the invariant in one sentence
An invariant is a statement about the fields that is true before and after every
public operation, and false only in between. For Cart: the discount sits
between 0 and the cap, and every line has a quantity of at least 1. The
constructor establishes both halves — clamping the percentage at both ends and
rejecting a quantity below 1 — every method preserves them, and no outside code
assigns the fields: the compiler forbids it in Java; in Python the underscore
only asks. The one-sided clamp is the usual bug — min(pct, 40) takes −50 and
turns a ₹2,000 cart into a ₹3,000 charge, a 50% surcharge wearing a discount's
name.
That is the test for whether a class earns its keep. If the sentence comes out as "the fields hold whatever the caller put there", there is no invariant, and a class with no invariant is a namespace with extra steps.
The animal hierarchy fails that test: no rule about a Dog is maintained by
speak(). Taxonomy is also not what changes in production — rules are. So
inheritance-first teaching leaves people ready to answer "which is the base
class?" and stuck on "the cap moved, how many files do I open?"
When a class is the wrong tool
Static-only classes are a language artefact. A PricingUtils with six static
methods and no fields is a module in costume. Python has modules and C++ has
free functions in a namespace; Java has neither, which is why Java code reads as
more object-oriented than it is.
def clamp_pct(x, lo, hi):
return max(lo, min(x, hi))
namespace pricing {
long clamp_pct(long x, long lo, long hi);
}
Data with no rules stays data. A parsed CSV row, a config, a response body on
its way to JSON: a dataclass in Python, a record in Java, a struct in C++.
A getter and setter around a field that accepts anything is a public field with
six extra lines.
Short-lived state belongs in a function. A value built, used and dropped inside one call stack gains nothing from an object except a lifetime to reason about.
In an interview
Object-design questions — a parking lot, a deck of cards, an elevator bank — are
graded on where you put the rules, not on hierarchy depth. Name the invariant as
you place each class: "Slot owns whether it is free, because two arrivals race
on that field, so taking a slot goes through one method on Slot."
Then say what change you are optimising for: "If pricing moves from hourly to
slabs, I want to open one file, so the rate calculation sits behind a
RatePolicy interface." Same habit as
communicating trade-offs
in a design round: name the axis you optimised, and its cost.
The mistake that loses points: opening with a hierarchy of nouns lifted from the prompt — Vehicle, then Car, Truck and Motorcycle under it — before one rule has been named. It reads as taxonomy practice. Its twin is the interface with one implementation and no plausible second: indirection paid for on every read, never collected on.
Check yourself
A Rectangle has width and height, a getter and a setter for each, and an
area(). What is its invariant, and what should you do about the answer?
There isn't one — any pair the fields accept is a valid rectangle, so the setters enforce nothing. Make it plain data: a dataclass, a record, a struct. When a rule appears — a fixed aspect ratio, so setting the width scales the height — the setter finally has something to protect.
The 40% cap moves to 60%. Give the edit count for each design, and the failure mode of the one you get wrong.
Four versus one. The failure is silent: the missed site returns a well-formed number now wrong by up to 20% of subtotal, so nothing throws and no test fails — two reports simply stop agreeing.
Money must change from an integer of paise to a currency-plus-amount pair.
What does that cost in each design, and is it private that saves you?
Neither design absorbs it silently:
total_paise()names the currency in its own signature, so returning aMoneyretypes all four call sites either way. The class design costs one file for the rule and the storage, plus that retype at four callers; the procedural design costs the same retype and four cap edits on top.privateis not what saves you: it turns a violation into a compile error, but the edit count is held down by no caller depending on the representation, so a Python class using a leading underscore gets the same one-file change, while a Java class withprivatefields, public getters, and the rule copied into four callers still costs four. What a class absorbs untouched is a change behind an unchanged signature: the line list becoming a running subtotal.