Objects, classes and the four pillars

Encapsulation, abstraction, inheritance and polymorphism, shown on one small app instead of four definitions.

Pattern: Foundations25 min read
Quick recall2 questions
  1. 01Where does low-level design sit, between an idea and the code?

    After high-level design (services and databases) and before the code. It names the classes, what each one owns, and how they call each other.

  2. 02In a requirement, what do the nouns and the verbs usually become?

    Nouns become candidate classes. Verbs become methods, on the class that owns the data they change.

In college you met four words: encapsulation, abstraction, inheritance and polymorphism. Most people can define them. Fewer can say which bug each one stops.

In this lesson, all four appear in one small module: the checkout of a food delivery app that takes a wallet, UPI and a card. The code grows by one pillar at a time.

Classes and objects

A describes a kind of thing. An is one thing built from it.

One class, two objects. Each object has its own balance; both share the class's methods.
  • new Wallet(300) builds an object. Asha's wallet and Ravi's wallet are two objects of one class.
  • Paying from Asha's wallet changes only her balance.

The four pillars are rules for how classes guard their data and share their work.

1. Encapsulation: guard the data

Say balance is a public field. Then any class in the app can write wallet.balance = -500, and nothing stops it. Every caller must remember the rule "never below zero", and one of them will forget.

  1. Public balanceanyone writes it
  2. -any class can set balance to -500
  3. -the rule is copied into every caller
  4. -a bug can come from anywhere
  5. Private balanceonly pay() writes it
  6. +outside code cannot touch balance
  7. +the rule lives in one method
  8. +a bug can come from one place

means keep the data private, and change it only through methods that check the rules.

class Wallet {
    private int balance;

    Wallet(int balance) { this.balance = balance; }

    boolean pay(int amount) {
        if (amount <= 0 || amount > balance) return false;
        balance -= amount;
        return true;
    }

    int getBalance() { return balance; }
}
  • pay checks the rule once: no zero or negative amounts, and no more than the balance. A wallet with 300 refuses 450 and accepts 200, which leaves 100.
  • getBalance lets others read the balance. Nothing lets them write it.
  • In Java, wallet.balance = -500 does not compile: "balance has private access in Wallet". The TypeScript compiler stops it too.
  • Python has no private fields. The leading underscore in _balance says "do not touch", and linters and reviewers enforce it.

2. Abstraction: show what, hide how

The checkout needs to take money. It does not need to know about PINs, bank calls or wallet balances.

gives the checkout a short list of what a payment method does, and hides how. Here the list has one item: pay(amount).

abstract class PaymentMethod {
    abstract boolean pay(int amount);
}

class Checkout {
    boolean pay(PaymentMethod method, int amount) {
        return method.pay(amount);
    }
}
  • PaymentMethod is . It says that every payment method can pay, but not how.
  • You cannot create a bare PaymentMethod. Java and TypeScript refuse to compile it. Python raises a TypeError when you try.
  • Checkout knows only pay. When a bank changes its API, Checkout does not change.

3. Inheritance: share the common part

Every payment method prints the same receipt: "Paid 450 by UPI asha@upi". Write that once, in the parent, and let each child add only what is different.

means a child class reuses its parent's code and fills in its own part.

abstract class PaymentMethod {
    private final String label;

    PaymentMethod(String label) { this.label = label; }

    abstract boolean pay(int amount);

    String receipt(int amount) {
        return "Paid " + amount + " by " + label;
    }
}

class Upi extends PaymentMethod {
    Upi(String upiId) { super("UPI " + upiId); }
    @Override
    boolean pay(int amount) {
        System.out.println("UPI: ask for PIN, send " + amount);
        return true;
    }
}

class Card extends PaymentMethod {
    Card(String last4) { super("card " + last4); }
    @Override
    boolean pay(int amount) {
        System.out.println("Card: ask the bank to charge " + amount);
        return true;
    }
}
  • super(...) passes the label up to the parent's constructor. The parent stores it and builds the receipt.
  • Each child writes only pay. receipt comes from the parent, unchanged.
  • Wallet joins the family the same way: extends PaymentMethod, and super("wallet") as the first line of its constructor. Its pay stays as it was.

Before you extend a class, say the sentence "a Card is a PaymentMethod". If the sentence sounds wrong, do not inherit. DRY, KISS, YAGNI and composition shows the other tool, composition, for "has a".

Here is the whole module:

Read it top to bottom: Checkout uses the abstract PaymentMethod, and three classes extend it.
  • Checkout points only at PaymentMethod. It has no line to Wallet, Upi or Card.
  • The hollow head (△) means "extends". Each child writes its own pay.
  • label is private to the parent. The children reach it only through receipt.

4. Polymorphism: one call, many forms

Asha has 300 in her wallet and orders food for 450. The checkout should try her wallet first, then UPI, then her card.

class Checkout {
    String pay(List<PaymentMethod> methods, int amount) {
        for (PaymentMethod method : methods) {
            if (method.pay(amount)) return method.receipt(amount);
        }
        return "Payment failed";
    }
}

// Asha has 300 in her wallet and orders food for 450.
List<PaymentMethod> methods =
    List.of(new Wallet(300), new Upi("asha@upi"), new Card("4242"));
System.out.println(new Checkout().pay(methods, 450));

The run prints UPI: ask for PIN, send 450, then Paid 450 by UPI asha@upi. The wallet said no, UPI said yes, and the card was never asked.

The loop has one line that matters: method.pay(amount). The same call runs three different methods, picked at run time by the object's real class. That is : one call, many forms.

How it runs

Checkoutorder of 450
Walletbalance 300
Upiasha@upi
  1. Checkout to Wallet:
    pay(450)
  2. Wallet to Checkout:
    false
  3. Checkout to Upi:
    pay(450)
  4. Upi to Checkout:
    true
  5. Checkout to Upi:
    receipt(450)
  6. Upi to Checkout:
    Paid 450 by UPI
Read it top to bottom. Checkout sends the same call to two objects and gets two different answers.

The change request

"Add net banking." Without polymorphism, the checkout would hold a chain of checks on the type:

  1. A check on the typeinside Checkout
  2. -if wallet, check the balance
  3. -else if UPI, ask for a PIN
  4. -else if card, call the bank
  5. -net banking edits this chain
  6. One callmethod.pay(amount)
  7. +each class knows its own steps
  8. +Checkout stays the same
  9. +net banking is one new class

With polymorphism, net banking is one new class, NetBanking extends PaymentMethod, with its own pay. Checkout does not change. A run with a wallet and net banking prints Paid 450 by net banking HDFC.

The four pillars side by side

PillarWhat it isThe testThe bug it stops
Encapsulationprivate data, guarded methodscan outside code break a rule?a balance of -500
Abstractionwhat, not howdoes the caller know only pay?checkout breaks on a bank change
Inheritancea child reuses its parentis a Card a PaymentMethod?copied receipt code that drifts
Polymorphismone call, many formsdoes a new type edit the caller?an if-else chain on the type

The pillars work as a team. Abstraction gives the list of methods, inheritance lets each class fill it in, and polymorphism lets one caller use them all.

Abstract class or interface?

Interviewers often ask why PaymentMethod is an abstract class and not an interface.

  • An abstract class can hold fields and shared code. PaymentMethod holds label and receipt, so it fits.
  • An interface is a list of methods that unrelated classes can promise. A class can implement many interfaces, but extend only one class.

If PaymentMethod had only pay, an interface would be the lighter choice. Python has no separate keyword: an abc.ABC class plays both roles.

Remember4 points
  1. 01

    Encapsulation: keep data private and change it only through methods that check the rules.

  2. 02

    Abstraction: callers see a short list of what an object does, such as pay(amount), and nothing of how.

  3. 03

    Inheritance: a child reuses its parent's code. Use it only when "is a" sounds right.

  4. 04

    Polymorphism: one call runs different code for each class, so a new payment method is one new class.

Read more

Next: DRY, KISS, YAGNI and composition, four rules that keep a design small.