Part 01 · Low Level Design

OOP Basics: Classes, Objects and the Four Pillars

EncapsulationAbstractionPolymorphismInheritanceValue vs Entity

Data and behaviour kept together: guarding an object's state, interfaces vs abstract classes, one call with many shapes, the traps of inheritance, and value objects vs entities.

⏱ 2-3 hours · Station 2 of 80

The balance that went negative

A small wallet app keeps each user's balance in a struct with a public field. The withdraw screen checks "is there enough money?" before subtracting. Everything works.

Then someone writes a refund script. It sets wallet.balance = wallet.balance - fee directly, because the field is right there. No check. A week later, finance finds wallets at −340 taka.

Nobody wrote "allow negative balances". The code simply let anyone change the number from anywhere. The rule lived in one screen, but the data was open to every screen.

Object-oriented programming is mostly about stopping this: keeping data and the rules that protect it together, and deciding who is allowed to touch what.

The problem: data and rules drift apart

In procedural code, data sits in plain structures and functions all over the program change it. That's fine for a script. In a growing system it causes three problems:

SymptomCause
A rule is skipped "just this once"The data can be changed without going through the rule
A new payment method touches ten filesCode everywhere asks "is it card or bKash or cash?"
Two copies of the same wallet disagreeNobody decided what makes two wallets "the same"

OOP gives four tools for this, usually called the four pillars, plus one idea that interviews love: the difference between a value and an entity.

Think of a bank counter

You don't walk behind the counter and change your balance in the ledger yourself. You go to the counter and say "withdraw 500". The teller checks your balance and your ID, then updates the ledger. The ledger is private; the counter is the public interface.

The counter also doesn't care how you pay a bill: card, mobile wallet or cash all go through the same "pay" desk, and each one is handled its own way behind it.

encapsulationpolymorphismreport scriptWalletbalance (private)Withdraw()checks balancebalance = 1e6blocked: field is privateWithdraw(305)the rule runs before the balance changescheckout()Cardfee 1.5%Bkashfee 1.85%Cashno feepay(amount)same call, three behaviours
Press play. Left: a script tries to set the balance directly and is stopped; the same change through Withdraw is checked first. Right: one pay() call reaches card, bKash and cash, and each answers in its own way.

Where the comparison breaks down. A real teller can use judgement. An object can't: it only enforces the rules you wrote into its methods. If a method forgets a check, the object will happily do the wrong thing, very politely.

Predict first

Wallet has a private balance and a Withdraw method that refuses if the balance is too low. A refund script now needs to take a 20 taka fee from a wallet. What's the right change?

The four pillars and two kinds of objects

  1. Class and object. A class is a template: which data and which behaviour belong together. An object is one real thing made from it, with its own data. Wallet is the class; Rahim's wallet with 500 taka is an object.
  2. Encapsulation means hiding the data and exposing only methods that keep it valid. Callers can't put the object into an impossible state, because the only doors in check the rules. In Go that's a lowercase field, in Java/C++ private, in TypeScript #field, in Rust a field outside pub, in Python a _name convention.
  3. Abstraction means callers depend on what something does, not how. Checkout knows "a payment method can pay and has a name". It doesn't know about card networks or bKash APIs. In code this is an interface (Go, Java, TS), a trait (Rust), an abstract class (C++), or a protocol (Python).
  4. Polymorphism means one call, many behaviours: pm.Pay(amount) runs the card code, the bKash code or the cash code depending on which object pm holds. The caller has no if for the type, so adding a new method of payment doesn't touch the caller.
  5. Inheritance means a class reuses another class's code by being a special kind of it (SavingsAccount is an Account). It's powerful but tightly couples the two, and it's easy to misuse: Bkash extends Wallet sounds fine but a bKash payment isn't a wallet; it uses one. Prefer an interface plus composition; the next chapter shows why.
  6. Value vs entity. A value object (Money, Address, DateRange) has no identity: two 5-taka amounts are the same thing, and values never change; you make a new one instead. An entity (Wallet, Order, User) has an identity that stays the same while its data changes: wallet W-1 is still W-1 after every withdrawal. Values compare by content; entities compare by ID.

Inside a payment: who decides what

Step through it

checkout(Bkash, 300 taka)

checkoutPaymentMethodBkashWallet

Step 1/5 Checkout makes one call

checkout() calls pm.Pay(300). It doesn't know or ask which payment method this is.

call
pm.Pay(300 tk)

The two questions to ask of every class

  • What must always be true about this object? (A wallet's balance is never negative. A loan always has a due date.) Make the fields private and let only methods that keep those facts true change them.
  • Is it a value or an entity? If two copies with the same data are interchangeable, it's a value: make it immutable and compare by content. If it has a life of its own and changes over time, it's an entity: give it an ID and compare by that.

Try it: value or entity?

Try each case

Value object or entity?

Money{paisa: 50000}
works: Value. Any 500 taka is the same as any other. Make it immutable; adding returns a new Money.

The wallet in code

Money as a value object, Wallet as an entity with a private balance, and three payment methods behind one interface. The program pays with each method, runs out of balance once, and compares a value and an entity.

package main

import (
	"errors"
	"fmt"
)

// Money is a value object: no identity, never changes, equal when the amounts are equal.
type Money struct{ paisa int64 }

func Taka(t int64) Money               { return Money{t * 100} }
func (m Money) Add(o Money) Money      { return Money{m.paisa + o.paisa} }
func (m Money) Sub(o Money) Money      { return Money{m.paisa - o.paisa} }
func (m Money) Less(o Money) bool      { return m.paisa < o.paisa }
func (m Money) Percent(bp int64) Money { return Money{m.paisa * bp / 10000} } // bp = basis points
func (m Money) String() string         { return fmt.Sprintf("%d.%02d tk", m.paisa/100, m.paisa%100) }

var ErrInsufficient = errors.New("insufficient balance")

// Wallet is an entity: it has an ID and its balance changes over time.
// Encapsulation: balance is private, so the only way to change it is a method that checks the rules.
type Wallet struct {
	ID      string
	balance Money
}

func (w *Wallet) Deposit(m Money) { w.balance = w.balance.Add(m) }
func (w *Wallet) Balance() Money  { return w.balance }

func (w *Wallet) Withdraw(m Money) error {
	if w.balance.Less(m) {
		return ErrInsufficient
	}
	w.balance = w.balance.Sub(m)
	return nil
}

// Abstraction: checkout only knows "something that can pay". It never asks which kind.
type PaymentMethod interface {
	Name() string
	Pay(amount Money) (fee Money, err error)
}

type Card struct{}

func (Card) Name() string               { return "card" }
func (Card) Pay(a Money) (Money, error) { return a.Percent(150), nil } // 1.5%

type Bkash struct{ wallet *Wallet }

func (Bkash) Name() string { return "bkash" }
func (b Bkash) Pay(a Money) (Money, error) {
	fee := a.Percent(185) // 1.85%
	if err := b.wallet.Withdraw(a.Add(fee)); err != nil {
		return Money{}, err
	}
	return fee, nil
}

type Cash struct{}

func (Cash) Name() string             { return "cash" }
func (Cash) Pay(Money) (Money, error) { return Money{}, nil }

// Polymorphism: one call, and each payment method does its own thing.
func checkout(pm PaymentMethod, amount Money) {
	fee, err := pm.Pay(amount)
	if err != nil {
		fmt.Printf("%-6s pays %s: %v\n", pm.Name(), amount, err)
		return
	}
	fmt.Printf("%-6s pays %s, fee %s\n", pm.Name(), amount, fee)
}

func main() {
	w := &Wallet{ID: "W-1"}
	w.Deposit(Taka(500))
	fmt.Printf("wallet %s balance %s\n", w.ID, w.Balance())

	checkout(Card{}, Taka(1200))
	checkout(Bkash{w}, Taka(300))
	fmt.Printf("wallet %s balance %s\n", w.ID, w.Balance())
	checkout(Bkash{w}, Taka(300))
	checkout(Cash{}, Taka(80))

	fmt.Println("Taka(5) == Taka(5):", Taka(5) == Taka(5))
	// Entities are the same when their IDs match, even if other fields differ.
	loaded := &Wallet{ID: "W-1"} // e.g. the same wallet loaded again from the database
	fmt.Println("same wallet (by ID):", w.ID == loaded.ID)
}

All six print exactly this:

wallet W-1 balance 500.00 tk
card   pays 1200.00 tk, fee 18.00 tk
bkash  pays 300.00 tk, fee 5.55 tk
wallet W-1 balance 194.45 tk
bkash  pays 300.00 tk: insufficient balance
cash   pays 80.00 tk, fee 0.00 tk
Taka(5) == Taka(5): true
same wallet (by ID): true

Reading the output:

  • card: 1.5% of 1200 is 18 taka. Card doesn't touch the wallet at all; that's its own business.
  • bkash, first time: fee 5.55, and 305.55 leaves the wallet, which drops from 500.00 to 194.45. Money is kept in paisa, so there's no rounding drift from floats.
  • bkash, second time: 305.55 is more than 194.45, so Withdraw refuses and the error comes back through Pay to checkout. The balance never went negative, and checkout didn't need to know why.
  • cash: no fee. Three different behaviours, zero if statements about the type in checkout.
  • Value vs entity: two separately created Taka(5) are equal. Two Wallet objects are the same wallet only because they share the ID W-1.
  • What each language changed: TypeScript uses #balance, a real private field. Python can only signal privacy with _balance, so the property and methods are the contract. Rust's Bkash borrows the wallet mutably (&'a mut Wallet), so the compiler stops two payments touching it at once. Java's record and Rust's #[derive(PartialEq)] give value equality for free; C++ asks for it with operator== = default.

Where OOP goes wrong in real code

  • Getters and setters for every field aren't encapsulation. SetBalance(x) is a public field with extra steps. Expose operations (Withdraw, Deposit) that keep the rules, not raw fields.
  • Use integer paisa (or a decimal type) for money. 0.1 + 0.2 in floating point isn't 0.3; that's a real bug class in payment code.
  • Make value objects immutable. If Money could change in place, two wallets sharing a Money would change together. Returning a new value avoids that whole bug class.
  • Give entities a stable ID from day one, and compare them by it. Loading the same order twice from the database gives two objects that must count as one order.
  • Reach for interfaces where behaviour truly differs (payment methods, notification channels), not for every class. One implementation behind an interface is usually just noise.

When OOP goes wrong

Try each case

Pick the broken design

wallet.Balance -= fee  // in the refund script
breaks: Any code can skip the rules. Make the field private and route changes through Withdraw.

Pause and think

A teammate suggests: "Let's add GetBalance() and SetBalance() to Wallet. Then the field is private, so it's encapsulated, right?"

Reveal

Not really. SetBalance lets any caller put any number in, including a negative one, so the rule "never negative" still isn't protected. The field is private, but the invariant is public. Encapsulation means exposing operations that keep the object valid: Deposit, Withdraw, maybe ApplyFee. A read-only Balance() is fine; a free-for-all setter is the old public field in disguise.

Quick quiz

1. What's the difference between encapsulation and abstraction?

Answer

Encapsulation hides an object's data and guards it with methods that keep it valid. Abstraction hides which implementation you're talking to behind an interface, so callers depend on what it does, not how.

2. Why doesn't checkout need an if for card versus bKash?

Answer

Polymorphism. It calls Pay on the PaymentMethod interface, and the language runs the method of whatever concrete type is behind it.

3. Is a delivery address usually a value object or an entity? Why?

Answer

A value object: two addresses with the same fields are interchangeable, and when it changes you replace it rather than edit it. (If your product tracks addresses as saved, named records with history, it can become an entity; the domain decides.)

4. Why is Bkash extends Wallet a poor design?

Answer

A bKash payment isn't a kind of wallet; it uses a wallet. Inheritance would hand the payment all of Wallet's public methods and tie the two classes together. Holding a wallet reference (composition) says what's really going on.

Your challenge: a new way to pay

Add a Nagad payment method with a 1.5% fee that, like bKash, takes money from a wallet.

  • Add the type and make it satisfy PaymentMethod. Don't touch checkout.
  • At the end of the story, deposit 1000 taka into the wallet and pay 200 taka with Nagad.

Before you run it, predict the fee and the balance left.

You've succeeded when the new lines are nagad pays 200.00 tk, fee 3.00 tk and wallet W-1 balance 991.45 tk, every earlier line is unchanged, and checkout has no new code.

In one line

Keep data and its rules together and private (encapsulation), depend on what things do rather than how (abstraction), let one call mean different things for different types (polymorphism), and use inheritance sparingly. Values compare by content and never change; entities compare by ID and live through their changes.

Station stamp

Finish the chapter

Done reading? Press Complete to save your progress. XP is Pro-only — on Pro this chapter is worth +10 XP.

Go Pro to earn XP →