Five lessons, one idea: an object knows things, does things and protects itself, and good design is deciding who should do what.
Chapter overview
This chapter takes you from "I know OOP syntax" to "I think in objects." It covers lessons 2 to 6 of the roadmap. Each lesson answers one question, and each builds on the one before.
| # | Lesson | Question it answers | Main outcome |
|---|---|---|---|
| 2 | Classes vs objects | What is an object, really? | Think in objects rather than data structures |
| 3 | State and behavior | What lives inside it? | Understand what belongs inside an object |
| 4 | Encapsulation | How does it protect itself? | Hide implementation and protect invariants |
| 5 | Abstraction | What does the outside world see? | Identify what clients actually need |
| 6 | Responsibility | Which object owns which job? | Decide who should do what |
By the end of the chapter you should be able to:
- Describe any object by its identity, state and behavior.
- Decide what state an object should hold, and what it should compute instead.
- Make it impossible for outside code to break an object's rules.
- Design the smallest interface a client actually needs.
- Assign each behavior to the object best placed to own it, and explain the trade-off.
Prerequisites: you know Java (or similar) syntax for classes, fields, methods and constructors, and you have read What is LLD?
The examples use a bank account and a shopping cart. Lessons 2 to 4 mostly use the account, lessons 5 and 6 move to notifications and the cart, and the chapter exercise brings everything together.
Lesson 2: Classes vs objects
Outcome: think in objects rather than data structures.
The problem
A teammate is asked to model bank accounts. They write what feels natural if you think in data structures: a class that holds fields, and a service full of functions that operate on them.
class AccountData {
public String id;
public long balance; // in paise
public boolean frozen;
}
class BankUtils {
static void withdraw(AccountData a, long amount) {
if (a.frozen) throw new IllegalStateException("Account frozen");
if (amount > a.balance) throw new IllegalArgumentException("Insufficient funds");
a.balance -= amount;
}
}
It compiles and the tests pass. Two weeks later a payments module is added by someone else:
account.balance -= fee; // skips the frozen check and the funds check
Now an account can go negative, and nobody can tell which code path did it. The class was never the problem. The way of thinking was.
The concept
A class is a description. It says what kind of state its instances carry and what they can do. It exists in your source code and costs nothing at runtime.
An object is a live participant. It exists in memory while the program runs and has three properties:
| Property | Meaning | Bank example |
|---|---|---|
| Identity | It is this one, distinct from every other object, even an identical-looking one | Account A-101 is not Account A-102 |
| State | What it currently knows | Balance is 5,000, not frozen |
| Behavior | What it can do, and what it refuses to do | withdraw, deposit, freeze |
Data-structure thinking asks: what fields do I need to store? The result is a passive record that other code manipulates.
Object thinking asks: what is this thing responsible for, and what must it never allow? The result is something that protects itself.
A useful test: if every rule about a thing lives somewhere other than the thing, you have a data structure wearing a class costume.
Example: one class, many objects
class Account {
private final String id;
private long balance;
Account(String id, long openingBalance) {
this.id = id;
this.balance = openingBalance;
}
}
Account a = new Account("A-101", 5_000);
Account b = new Account("A-101", 5_000);
Account c = a;
a == b; // false: two objects, two identities
a == c; // true: one object, two names for it
Three variables, but only two objects. c is not a copy of a; it is another reference to the same object, so whatever happens through c is visible through a.
This is why equals() exists. == asks "same object?" (identity). equals() asks "same value by our definition?" You decide what "same" means, and for a bank account that is usually the id, not the balance.
Why the data bag is bad
- Invariants are unprotected. "Balance never goes negative" is a hope, not a guarantee.
- Rules are duplicated. Every caller re-implements the frozen check, or skips it.
- Change spreads. A new rule ("withdrawals above 50,000 need approval") means touching every place that mutates the fields.
- Nobody owns the behavior. When a bug appears there is no single place to look.
When NOT to use a rich object
A plain carrier of data with no rules, such as an API response or a statement line you are about to print, is fine as a Java record:
record StatementLine(String date, String description, long amount) {}
Upgrade it to a real object the moment a rule appears ("amount must be non-zero"). Until then, adding behavior is overengineering. The next four lessons show how to build the rich version properly, starting with what goes inside.
Lesson 3: State and behavior
Outcome: understand what belongs inside an object.
The problem
Lesson 2 said an object knows things and does things. But "knows" is easy to overdo. Look at this shopping cart:
class Cart {
private List<CartItem> items = new ArrayList<>();
private long total; // kept up to date by hand
private String customerName; // only used when printing a receipt
private long lastCalculatedAt;
void addItem(CartItem item) {
items.add(item);
total += item.price() * item.quantity();
}
void removeItem(CartItem item) {
items.remove(item);
// forgot to update total
}
}
removeItem forgot to update total, so the cart now lies. Every field is a promise that must stay true forever, and this class made more promises than it needed.
The concept
State is what an object must remember between method calls. Behavior is what it does with that memory: read it, change it, or answer questions about it.
The rule of thumb is to store the minimum, and compute the rest. Ask four questions about every field you are tempted to add:
| Question | If yes | Cart example |
|---|---|---|
| Must the object remember it across calls? | State: keep it | The list of items |
| Can it be calculated from other state? | Derived: compute it, do not store it | The total |
| Is it only needed for one operation? | Parameter: pass it in | The customer name on a receipt |
| Is it a fixed setting rather than something that changes? | Configuration: pass it at construction | A maximum number of items |
Stored values that can be derived will eventually disagree with their source. That is the bug in removeItem above.
Behavior should describe intent
Name behavior by what it means in the domain, not by which field it touches.
account.setFrozen(true); // describes a field
account.freeze(); // describes an intent
Intent-named behavior also gives the object a chance to enforce rules: freeze() can refuse if the account is closed, while setFrozen(true) cannot say no.
State changes follow rules
Many objects move through states, and not every move is legal. An account is ACTIVE, FROZEN or CLOSED:
| From | To | Allowed? |
|---|---|---|
| ACTIVE | FROZEN | Yes |
| FROZEN | ACTIVE | Yes |
| ACTIVE or FROZEN | CLOSED | Yes, if balance is zero |
| CLOSED | anything | No |
The object that owns the state is the one that should enforce this table. Part of an object's behavior is refusing illegal transitions.
Better design
class Cart {
private final List<CartItem> items = new ArrayList<>();
void add(CartItem item) {
items.add(item);
}
void remove(CartItem item) {
items.remove(item);
}
long total() {
long sum = 0;
for (CartItem item : items) sum += item.lineTotal();
return sum;
}
}
One field instead of four. The total can no longer be wrong, because it is never stored. Customer name moved to the receipt code, where it is used.
When NOT to follow the rule
Derive by default, but cache when calculating is genuinely expensive and measured to matter. If you cache, hide it behind the same method (total()) and make sure every mutation invalidates it. The caller should never be able to tell.
Lesson 4: Encapsulation
Outcome: hide implementation and protect invariants.
The problem
The cart from lesson 3 now stores only what it needs. But look at how it is used:
class Cart {
private final List<CartItem> items = new ArrayList<>();
public List<CartItem> getItems() { return items; }
}
// somewhere else in the codebase
cart.getItems().add(new CartItem(product, -3)); // negative quantity
cart.getItems().clear(); // wipes the cart silently
The field is private, yet anyone can still do anything to it. The getter handed out the cart's internals, so private protected nothing.
The concept
An invariant is a statement about an object that must be true whenever anyone can observe it. For the cart: every quantity is greater than zero, and no product appears twice.
Encapsulation is the discipline of making it impossible for outside code to break those statements. It has two halves:
- Protect invariants. Only the object's own methods can change its state, and each method leaves the object valid.
- Hide implementation. Callers depend on what the object does, not on how it stores things, so you can change the how freely.
Note that private fields are only the starting point. A getter and setter pair for every field is the data bag from lesson 2 with extra typing.
Better design
class CartItem {
private final Product product;
private final int quantity;
CartItem(Product product, int quantity) {
if (quantity <= 0) throw new IllegalArgumentException("Quantity must be positive");
this.product = Objects.requireNonNull(product);
this.quantity = quantity;
}
Product product() { return product; }
int quantity() { return quantity; }
long lineTotal() { return product.price() * quantity; }
}
class Cart {
private final List<CartItem> items = new ArrayList<>();
void add(Product product, int quantity) {
items.add(new CartItem(product, quantity));
}
void clear() { items.clear(); }
List<CartItem> items() {
return List.copyOf(items); // a snapshot, not the real list
}
}
Three techniques at work:
- Validate at the door.
CartItemcannot be constructed with a bad quantity, and it is immutable, so it can never become invalid later. - No leaked internals.
items()returns a copy. The caller can read it, but changing it changes nothing in the cart. - Intent-named operations. Callers say
addandclear, which are deliberate, notgetItems().add(...).
Hiding implementation pays off
A new requirement: adding the same product twice should merge quantities. Because no client knows about the list, you can change the storage without touching them:
class Cart {
private final Map<Product, Integer> quantities = new LinkedHashMap<>();
void add(Product product, int quantity) {
if (quantity <= 0) throw new IllegalArgumentException("Quantity must be positive");
quantities.merge(product, quantity, Integer::sum);
}
long total() {
return quantities.entrySet().stream()
.mapToLong(e -> e.getKey().price() * e.getValue())
.sum();
}
}
add and total look identical from outside. A hundred callers needed zero changes. That is the practical payoff.
Tell, don't ask
Encapsulation also shapes how callers talk to objects. Compare:
// asking, then deciding for the object
if (account.getBalance() >= amount) {
account.setBalance(account.getBalance() - amount);
}
// telling
account.withdraw(amount);
The first version pulls state out and makes the decision in the caller, where the rule can be forgotten. The second leaves the rule where the data lives.
When NOT to encapsulate heavily
Encapsulation protects things that have rules. A throwaway value object, a test fixture or a record that only carries data does not need defensive copies and validation ceremony. Match the protection to the risk.
Lesson 5: Abstraction
Outcome: identify what clients actually need.
The problem
An order service needs to tell the customer their order shipped. A first attempt:
class OrderService {
void markShipped(Order order) {
order.setStatus("SHIPPED");
SmtpClient smtp = new SmtpClient("smtp.example.com", 587);
smtp.authenticate("user", "secret");
smtp.sendRaw(order.customerEmail(), "Shipped", "Your order " + order.id() + " has shipped.");
smtp.close();
}
}
OrderService is about orders. Yet it knows hostnames, ports, authentication and connection lifecycles. When the business wants SMS as well, or when you want to test markShipped without sending real email, you must open this method and untangle it.
The concept
Abstraction means deciding what the outside world gets to see, and leaving everything else out. It starts from the client's point of view: what does the caller actually want?
The order service does not want to "open an SMTP connection." It wants to "tell this customer something." That need, stated in the client's own language, is the abstraction.
Encapsulation and abstraction are easy to mix up, so keep them apart:
| Encapsulation | Abstraction | |
|---|---|---|
| Question | How do I stop outsiders breaking my rules? | What should outsiders see at all? |
| Direction | Looks inward, at the object's state | Looks outward, at the client's need |
| Tool | Private state, validated operations | A small, purposeful interface |
Better design
interface Notifier {
void send(String customerId, String message);
}
class EmailNotifier implements Notifier {
private final SmtpClient smtp;
EmailNotifier(SmtpClient smtp) { this.smtp = smtp; }
public void send(String customerId, String message) {
// look up address, connect, send, close: all hidden here
}
}
class OrderService {
private final Notifier notifier;
OrderService(Notifier notifier) { this.notifier = notifier; }
void markShipped(Order order) {
order.markShipped();
notifier.send(order.customerId(), "Your order " + order.id() + " has shipped.");
}
}
OrderService now speaks only the language of its own problem. SMS, push, or a fake that records messages for tests are each a new Notifier, with no change to OrderService.
How to find the right abstraction
- Write the client code first, as you wish it looked:
notifier.send(customerId, message). - List what the client must supply and what it needs back. Anything else is a detail to hide.
- Name the abstraction after the need (
Notifier), not the mechanism (SmtpHelper). - Keep it small. Every method you add is a promise every implementation must keep.
Leaky abstractions
An abstraction leaks when callers must know the mechanism to use it correctly:
notifier.send(customerId, message); // fine
notifier.sendEmail(address, subject, body); // leaks: callers now know it is email
The second method name, and its parameters, push email details back onto every client. If you catch yourself adding if (notifier instanceof EmailNotifier) anywhere, the abstraction is leaking.
When NOT to abstract
Do not create an interface because "we might need another one someday." With one implementation and no concrete second need, an interface is extra code to read and a guess about the future that is often wrong. Introduce the abstraction when you feel one of these pains:
- You need a second implementation.
- You cannot test without a real external system.
- The client is changing for reasons unrelated to its own job.
In this example the third pain was already present, which is why the abstraction earns its place. Later lessons on KISS and YAGNI return to this rule.
Lesson 6: Responsibility
Outcome: decide who should do what.
The problem
You now have objects that hold state, protect it and expose small interfaces. The hardest design question remains: when a new behavior appears, which object gets it? The lazy answer is to create a service and put it there.
class CartService {
long calculateTotal(Cart cart) {
long sum = 0;
for (CartItem item : cart.getItems()) {
sum += item.getProduct().getPrice() * item.getQuantity();
}
return sum;
}
long applyDiscount(Cart cart, String code) { /* reads items, prices, customer... */ }
void checkout(Cart cart, Card card) { /* validate, charge, email, update stock */ }
}
Count the getters. Every method reaches deep into other objects, pulls their data out, and does the work for them. The cart, the item and the product are passive, and CartService knows everything about all of them. This is a god class, and the reaching-in pattern is called feature envy: behavior that envies data it does not own.
The concept
A responsibility is a job an object is accountable for: something it knows, something it does, or a decision it makes. Good design gives each responsibility to exactly one object, chosen on purpose.
Three questions help decide the owner:
- Who already has the information? Behavior that needs an object's data usually belongs on that object. A line total needs price and quantity, so
CartItemowns it. - Who changes for the same reason? Things that change together live together. Things that change independently should be separated.
- Who would otherwise have to reach into someone else's state? If a method calls
x.getA(),x.getB()andx.getC()and computes something, the method probably belongs onx.
Better design
class CartItem {
long lineTotal() { return product.price() * quantity; }
}
class Cart {
long total() {
long sum = 0;
for (CartItem item : items) sum += item.lineTotal();
return sum;
}
}
Each object now answers a question about itself. Cart does not know how a line total is computed, and CartItem does not know carts exist. Checkout, which involves payment and stock, is a job for neither of them; it is a separate responsibility with its own object, and the chapter exercise places it.
There is not always one right answer
Responsibility assignment is a trade-off, not a lookup. Take "who calculates the parking fee?"
| Candidate | Reasoning for | Reasoning against |
|---|---|---|
Ticket |
Knows entry time | Pricing rules are not about tickets |
Vehicle |
Knows its type (car, bike) | A vehicle should not know tariffs |
ParkingLot |
Sees everything | Becomes a god class fast |
PricingPolicy |
Rules change independently of the rest | One more object to maintain |
If the tariff is fixed and trivial, Ticket.fee() may be enough. If pricing changes often or varies by lot, a separate pricing object wins. The answer depends on what changes independently, and you should be able to say why you chose.
Warning signs of misplaced responsibility
- A class named
Manager,Helper,UtilorProcessorthat grows without limit. - Methods that are mostly chains of getters on another object.
- Changing one business rule forces edits in many classes.
- You cannot describe a class's job in one sentence without the word "and".
When NOT to split further
Do not carve responsibilities so finely that every action becomes its own class. A class that only forwards calls to another is noise. Split when two things change for different reasons, not because a method felt long.
Chapter exercise: design a shopping cart
This is the critical exercise of the week. Do not write code first. Stop here and answer these five questions on paper, using everything from lessons 2 to 6:
What objects exist?
Who owns the items?
Who calculates the total?
Who applies the discount?
Who handles payment?
Then compare with the answers below. They are one reasonable design, not the only one. What matters is that your reasoning matches.
| Question | One reasonable answer | Why |
|---|---|---|
| What objects exist? | Product, CartItem, Cart, Discount, PaymentService |
Each has its own state or its own rules |
| Who owns the items? | Cart |
It owns the list, enforces "quantity above zero" and exposes only add, remove, items() (a copy) |
| Who calculates the total? | CartItem.lineTotal(), then Cart.total() |
Each object has the data it needs; nothing is stored, so nothing can drift |
| Who applies the discount? | A Discount object that takes the cart's total and returns the reduced amount |
Discount rules change independently of cart storage |
| Who handles payment? | PaymentService, outside the cart |
Charging a card is a different job that changes for different reasons |
A sketch of how they meet:
class Checkout {
private final PaymentService payments;
Checkout(PaymentService payments) { this.payments = payments; }
Receipt pay(Cart cart, Discount discount, Card card) {
long due = discount.apply(cart.total());
payments.charge(card, due);
return new Receipt(cart.items(), due);
}
}
Map each lesson to a decision in this design:
- Classes vs objects:
CartandCartItemcarry their own rules instead of being data bags. - State and behavior: the cart stores items only; total is derived.
- Encapsulation:
items()returns a copy, andCartItemis immutable and validated. - Abstraction:
CheckoutseesPaymentService, not card-network details. - Responsibility: pricing, discounting and payment each have one owner.
The discount rules, and what Discount should look like when there are many of them, are the first thing the next chapters will push on.
Check yourself
Can you explain?
- Two
Accountobjects have the same id and balance. Are they the same object? How would you makeequals()say they are equal, and why might balance be the wrong thing to compare? - Why is a class with only public fields and no methods a warning sign? When is a
recordthe right choice anyway? - A
Cartstorestotalas a field. Name the bug that will eventually appear and the fix. - A class has a private list and a
getItems()that returns it. Why is theprivatemeaningless? - What is the difference between encapsulation and abstraction? Give one sentence each.
- A method calls five getters on
Orderand computes a shipping fee. Where does it probably belong, and why?
Can you design?
- Wallet. Refactor this data bag into one object.
pay(amount)must refuse if the balance is too low or the daily limit would be exceeded, nothing outside may changebalanceorspentToday, and a negative opening balance must be impossible.
class WalletData { public long balance, dailyLimit, spentToday; }
class WalletUtils { static void pay(WalletData w, long amount) { w.balance -= amount; w.spentToday += amount; } }
- BookCopy. A bookstore tracks physical copies of books. Before any code, list what a
BookCopyknows, what it can do and what it must never allow. Decide whetherBookandBookCopyare one object or two, and justify it. - ReportExporter. A report screen currently builds CSV, PDF and Excel inline. Write the client code you wish you had, name the abstraction after the need, and say what you would hide.
- Parking fee. Give two defensible owners for the fee calculation and say what change would make you switch from one to the other.
Completion criteria
You are done with this chapter when you can:
- State identity, state and behavior for any object you meet, in under a minute.
- Look at a field and decide: state, derived, parameter or configuration.
- Name the invariants of an object and show how your design makes them unbreakable.
- Write client code first and derive the smallest interface from it.
- Answer "who should own this behavior?" and defend the choice with what changes independently.
- Complete the shopping cart exercise before looking at the answers.
Recall
Revisit this chapter after 1 day, 3 days and 10 days. Without scrolling up, answer these five questions, one per lesson:
- What does this object know, what can it do, and what must it never allow?
- Which of its fields could be computed instead?
- What would outside code have to do to break it?
- What does the client actually need to say?
- Who should own this job?
These five questions return in every later exercise, and in the next chapter, Object collaboration, where objects start working together.
Notes & links
Saved automatically.