← Low Level Design
LLDArticle 03 · 60 min read

Object Oriented Programming

Stuck on a concept here? Ask the AI coach

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

  1. Invariants are unprotected. "Balance never goes negative" is a hope, not a guarantee.
  2. Rules are duplicated. Every caller re-implements the frozen check, or skips it.
  3. Change spreads. A new rule ("withdrawals above 50,000 need approval") means touching every place that mutates the fields.
  4. 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:

  1. Validate at the door. CartItem cannot be constructed with a bad quantity, and it is immutable, so it can never become invalid later.
  2. No leaked internals. items() returns a copy. The caller can read it, but changing it changes nothing in the cart.
  3. Intent-named operations. Callers say add and clear, which are deliberate, not getItems().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

  1. Write the client code first, as you wish it looked: notifier.send(customerId, message).
  2. List what the client must supply and what it needs back. Anything else is a detail to hide.
  3. Name the abstraction after the need (Notifier), not the mechanism (SmtpHelper).
  4. 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:

  1. 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 CartItem owns it.
  2. Who changes for the same reason? Things that change together live together. Things that change independently should be separated.
  3. Who would otherwise have to reach into someone else's state? If a method calls x.getA(), x.getB() and x.getC() and computes something, the method probably belongs on x.

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, Util or Processor that 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: Cart and CartItem carry their own rules instead of being data bags.
  • State and behavior: the cart stores items only; total is derived.
  • Encapsulation: items() returns a copy, and CartItem is immutable and validated.
  • Abstraction: Checkout sees PaymentService, 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?

  1. Two Account objects have the same id and balance. Are they the same object? How would you make equals() say they are equal, and why might balance be the wrong thing to compare?
  2. Why is a class with only public fields and no methods a warning sign? When is a record the right choice anyway?
  3. A Cart stores total as a field. Name the bug that will eventually appear and the fix.
  4. A class has a private list and a getItems() that returns it. Why is the private meaningless?
  5. What is the difference between encapsulation and abstraction? Give one sentence each.
  6. A method calls five getters on Order and computes a shipping fee. Where does it probably belong, and why?

Can you design?

  1. 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 change balance or spentToday, 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; } }
  1. BookCopy. A bookstore tracks physical copies of books. Before any code, list what a BookCopy knows, what it can do and what it must never allow. Decide whether Book and BookCopy are one object or two, and justify it.
  2. 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.
  3. 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:

  1. What does this object know, what can it do, and what must it never allow?
  2. Which of its fields could be computed instead?
  3. What would outside code have to do to break it?
  4. What does the client actually need to say?
  5. 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.

Your notes

Notes & links

Saved automatically.

Object Oriented Programming | Low Level Design | ProgVale