Part 01 · Low Level Design
OOP Basics: Classes, Objects and the Four Pillars
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:
| Symptom | Cause |
|---|---|
| A rule is skipped "just this once" | The data can be changed without going through the rule |
| A new payment method touches ten files | Code everywhere asks "is it card or bKash or cash?" |
| Two copies of the same wallet disagree | Nobody 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.
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.
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
- 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.
Walletis the class; Rahim's wallet with 500 taka is an object. - 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 outsidepub, in Python a_nameconvention. - 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).
- Polymorphism means one call, many behaviours:
pm.Pay(amount)runs the card code, the bKash code or the cash code depending on which objectpmholds. The caller has noiffor the type, so adding a new method of payment doesn't touch the caller. - 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 Walletsounds fine but a bKash payment isn't a wallet; it uses one. Prefer an interface plus composition; the next chapter shows why. - 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
checkout(Bkash, 300 taka)
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?
Value object or entity?
Money{paisa: 50000}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)
}from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol
@dataclass(frozen=True)
class Money:
"""A value object: no identity, never changes, equal when the amounts are equal."""
paisa: int
@staticmethod
def taka(t: int) -> Money:
return Money(t * 100)
def add(self, o: Money) -> Money:
return Money(self.paisa + o.paisa)
def sub(self, o: Money) -> Money:
return Money(self.paisa - o.paisa)
def percent(self, bp: int) -> Money: # bp = basis points
return Money(self.paisa * bp // 10000)
def __str__(self) -> str:
return f"{self.paisa // 100}.{self.paisa % 100:02d} tk"
class InsufficientBalance(Exception):
def __str__(self) -> str:
return "insufficient balance"
class Wallet:
"""An entity: it has an ID and its balance changes over time.
Encapsulation: _balance is private by convention; change it only through methods."""
def __init__(self, id: str):
self.id = id
self._balance = Money(0)
def deposit(self, m: Money) -> None:
self._balance = self._balance.add(m)
@property
def balance(self) -> Money:
return self._balance
def withdraw(self, m: Money) -> None:
if self._balance.paisa < m.paisa:
raise InsufficientBalance()
self._balance = self._balance.sub(m)
class PaymentMethod(Protocol):
"""Abstraction: checkout only knows 'something that can pay'."""
name: str
def pay(self, amount: Money) -> Money: ... # returns the fee
class Card:
name = "card"
def pay(self, amount: Money) -> Money:
return amount.percent(150) # 1.5%
class Bkash:
name = "bkash"
def __init__(self, wallet: Wallet):
self.wallet = wallet
def pay(self, amount: Money) -> Money:
fee = amount.percent(185) # 1.85%
self.wallet.withdraw(amount.add(fee))
return fee
class Cash:
name = "cash"
def pay(self, amount: Money) -> Money:
return Money(0)
def checkout(pm: PaymentMethod, amount: Money) -> None:
"""Polymorphism: one call, and each payment method does its own thing."""
try:
fee = pm.pay(amount)
print(f"{pm.name:<6} pays {amount}, fee {fee}")
except InsufficientBalance as e:
print(f"{pm.name:<6} pays {amount}: {e}")
w = Wallet("W-1")
w.deposit(Money.taka(500))
print(f"wallet {w.id} balance {w.balance}")
checkout(Card(), Money.taka(1200))
checkout(Bkash(w), Money.taka(300))
print(f"wallet {w.id} balance {w.balance}")
checkout(Bkash(w), Money.taka(300))
checkout(Cash(), Money.taka(80))
print("Taka(5) == Taka(5):", str(Money.taka(5) == Money.taka(5)).lower())
# Entities are the same when their IDs match, even if other fields differ.
loaded = Wallet("W-1") # e.g. the same wallet loaded again from the database
print("same wallet (by ID):", str(w.id == loaded.id).lower())// A value object: no identity, never changes, equal when the amounts are equal.
class Money {
readonly paisa: number;
constructor(paisa: number) {
this.paisa = paisa;
}
static taka(t: number): Money {
return new Money(t * 100);
}
add(o: Money): Money {
return new Money(this.paisa + o.paisa);
}
sub(o: Money): Money {
return new Money(this.paisa - o.paisa);
}
percent(bp: number): Money {
return new Money(Math.floor((this.paisa * bp) / 10000)); // bp = basis points
}
equals(o: Money): boolean {
return this.paisa === o.paisa;
}
toString(): string {
return `${Math.floor(this.paisa / 100)}.${String(this.paisa % 100).padStart(2, "0")} tk`;
}
}
class InsufficientBalance extends Error {
constructor() {
super("insufficient balance");
}
}
// An entity: it has an ID and its balance changes over time.
// Encapsulation: #balance is truly private; change it only through methods.
class Wallet {
readonly id: string;
#balance = new Money(0);
constructor(id: string) {
this.id = id;
}
deposit(m: Money): void {
this.#balance = this.#balance.add(m);
}
get balance(): Money {
return this.#balance;
}
withdraw(m: Money): void {
if (this.#balance.paisa < m.paisa) throw new InsufficientBalance();
this.#balance = this.#balance.sub(m);
}
}
// Abstraction: checkout only knows "something that can pay".
interface PaymentMethod {
readonly name: string;
pay(amount: Money): Money; // returns the fee
}
class Card implements PaymentMethod {
readonly name = "card";
pay(amount: Money): Money {
return amount.percent(150); // 1.5%
}
}
class Bkash implements PaymentMethod {
readonly name = "bkash";
readonly wallet: Wallet;
constructor(wallet: Wallet) {
this.wallet = wallet;
}
pay(amount: Money): Money {
const fee = amount.percent(185); // 1.85%
this.wallet.withdraw(amount.add(fee));
return fee;
}
}
class Cash implements PaymentMethod {
readonly name = "cash";
pay(): Money {
return new Money(0);
}
}
// Polymorphism: one call, and each payment method does its own thing.
function checkout(pm: PaymentMethod, amount: Money): void {
try {
const fee = pm.pay(amount);
console.log(`${pm.name.padEnd(6)} pays ${amount}, fee ${fee}`);
} catch (e) {
console.log(`${pm.name.padEnd(6)} pays ${amount}: ${(e as Error).message}`);
}
}
const w = new Wallet("W-1");
w.deposit(Money.taka(500));
console.log(`wallet ${w.id} balance ${w.balance}`);
checkout(new Card(), Money.taka(1200));
checkout(new Bkash(w), Money.taka(300));
console.log(`wallet ${w.id} balance ${w.balance}`);
checkout(new Bkash(w), Money.taka(300));
checkout(new Cash(), Money.taka(80));
console.log("Taka(5) == Taka(5):", Money.taka(5).equals(Money.taka(5)));
// Entities are the same when their IDs match, even if other fields differ.
const loaded = new Wallet("W-1"); // e.g. the same wallet loaded again from the database
console.log("same wallet (by ID):", w.id === loaded.id);use std::fmt;
// A value object: Copy + PartialEq. No identity, never changes, equal when amounts are equal.
#[derive(Clone, Copy, PartialEq, Debug)]
struct Money { paisa: i64 }
impl Money {
fn taka(t: i64) -> Money { Money { paisa: t * 100 } }
fn add(self, o: Money) -> Money { Money { paisa: self.paisa + o.paisa } }
fn sub(self, o: Money) -> Money { Money { paisa: self.paisa - o.paisa } }
fn percent(self, bp: i64) -> Money { Money { paisa: self.paisa * bp / 10000 } } // bp = basis points
}
impl fmt::Display for Money {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}.{:02} tk", self.paisa / 100, self.paisa % 100)
}
}
// An entity: it has an ID and its balance changes over time.
// Encapsulation: `balance` is private to this module; change it only through methods.
struct Wallet { id: String, balance: Money }
impl Wallet {
fn new(id: &str) -> Wallet { Wallet { id: id.to_string(), balance: Money { paisa: 0 } } }
fn deposit(&mut self, m: Money) { self.balance = self.balance.add(m); }
fn balance(&self) -> Money { self.balance }
fn withdraw(&mut self, m: Money) -> Result<(), String> {
if self.balance.paisa < m.paisa {
return Err("insufficient balance".into());
}
self.balance = self.balance.sub(m);
Ok(())
}
}
// Abstraction: checkout only knows "something that can pay".
trait PaymentMethod {
fn name(&self) -> &str;
fn pay(&mut self, amount: Money) -> Result<Money, String>; // returns the fee
}
struct Card;
impl PaymentMethod for Card {
fn name(&self) -> &str { "card" }
fn pay(&mut self, a: Money) -> Result<Money, String> { Ok(a.percent(150)) } // 1.5%
}
struct Bkash<'a> { wallet: &'a mut Wallet }
impl PaymentMethod for Bkash<'_> {
fn name(&self) -> &str { "bkash" }
fn pay(&mut self, a: Money) -> Result<Money, String> {
let fee = a.percent(185); // 1.85%
self.wallet.withdraw(a.add(fee))?;
Ok(fee)
}
}
struct Cash;
impl PaymentMethod for Cash {
fn name(&self) -> &str { "cash" }
fn pay(&mut self, _: Money) -> Result<Money, String> { Ok(Money { paisa: 0 }) }
}
// Polymorphism: one call (through a trait object), and each method does its own thing.
fn checkout(pm: &mut dyn PaymentMethod, amount: Money) {
match pm.pay(amount) {
Ok(fee) => println!("{:<6} pays {amount}, fee {fee}", pm.name()),
Err(e) => println!("{:<6} pays {amount}: {e}", pm.name()),
}
}
fn main() {
let mut w = Wallet::new("W-1");
w.deposit(Money::taka(500));
println!("wallet {} balance {}", w.id, w.balance());
checkout(&mut Card, Money::taka(1200));
checkout(&mut Bkash { wallet: &mut w }, Money::taka(300));
println!("wallet {} balance {}", w.id, w.balance());
checkout(&mut Bkash { wallet: &mut w }, Money::taka(300));
checkout(&mut Cash, Money::taka(80));
println!("Taka(5) == Taka(5): {}", Money::taka(5) == Money::taka(5));
// Entities are the same when their IDs match, even if other fields differ.
let loaded = Wallet::new("W-1"); // e.g. the same wallet loaded again from the database
println!("same wallet (by ID): {}", w.id == loaded.id);
}public class Main {
// A value object: a record is immutable and compares by its fields.
record Money(long paisa) {
static Money taka(long t) { return new Money(t * 100); }
Money add(Money o) { return new Money(paisa + o.paisa); }
Money sub(Money o) { return new Money(paisa - o.paisa); }
Money percent(long bp) { return new Money(paisa * bp / 10000); } // bp = basis points
@Override public String toString() { return String.format("%d.%02d tk", paisa / 100, paisa % 100); }
}
static final class InsufficientBalance extends Exception {
private static final long serialVersionUID = 1L;
InsufficientBalance() { super("insufficient balance"); }
}
// An entity: it has an ID and its balance changes over time.
// Encapsulation: balance is private; change it only through methods.
static final class Wallet {
final String id;
private Money balance = new Money(0);
Wallet(String id) { this.id = id; }
void deposit(Money m) { balance = balance.add(m); }
Money balance() { return balance; }
void withdraw(Money m) throws InsufficientBalance {
if (balance.paisa() < m.paisa()) throw new InsufficientBalance();
balance = balance.sub(m);
}
}
// Abstraction: checkout only knows "something that can pay".
interface PaymentMethod {
String name();
Money pay(Money amount) throws InsufficientBalance; // returns the fee
}
static final class Card implements PaymentMethod {
public String name() { return "card"; }
public Money pay(Money a) { return a.percent(150); } // 1.5%
}
static final class Bkash implements PaymentMethod {
private final Wallet wallet;
Bkash(Wallet wallet) { this.wallet = wallet; }
public String name() { return "bkash"; }
public Money pay(Money a) throws InsufficientBalance {
Money fee = a.percent(185); // 1.85%
wallet.withdraw(a.add(fee));
return fee;
}
}
static final class Cash implements PaymentMethod {
public String name() { return "cash"; }
public Money pay(Money a) { return new Money(0); }
}
// Polymorphism: one call, and each payment method does its own thing.
static void checkout(PaymentMethod pm, Money amount) {
try {
Money fee = pm.pay(amount);
System.out.printf("%-6s pays %s, fee %s%n", pm.name(), amount, fee);
} catch (InsufficientBalance e) {
System.out.printf("%-6s pays %s: %s%n", pm.name(), amount, e.getMessage());
}
}
public static void main(String[] args) {
Wallet w = new Wallet("W-1");
w.deposit(Money.taka(500));
System.out.printf("wallet %s balance %s%n", w.id, w.balance());
checkout(new Card(), Money.taka(1200));
checkout(new Bkash(w), Money.taka(300));
System.out.printf("wallet %s balance %s%n", w.id, w.balance());
checkout(new Bkash(w), Money.taka(300));
checkout(new Cash(), Money.taka(80));
System.out.println("Taka(5) == Taka(5): " + Money.taka(5).equals(Money.taka(5)));
// Entities are the same when their IDs match, even if other fields differ.
Wallet loaded = new Wallet("W-1"); // e.g. the same wallet loaded again from the database
System.out.println("same wallet (by ID): " + w.id.equals(loaded.id));
}
}#include <cstdio>
#include <stdexcept>
#include <string>
// A value object: no identity, never changes, equal when the amounts are equal.
struct Money {
long long paisa = 0;
static Money taka(long long t) { return {t * 100}; }
Money add(Money o) const { return {paisa + o.paisa}; }
Money sub(Money o) const { return {paisa - o.paisa}; }
Money percent(long long bp) const { return {paisa * bp / 10000}; } // bp = basis points
bool operator==(const Money&) const = default;
std::string str() const {
char buf[32];
std::snprintf(buf, sizeof buf, "%lld.%02lld tk", paisa / 100, paisa % 100);
return buf;
}
};
struct InsufficientBalance : std::runtime_error {
InsufficientBalance() : std::runtime_error("insufficient balance") {}
};
// An entity: it has an ID and its balance changes over time.
// Encapsulation: balance_ is private; change it only through methods.
class Wallet {
public:
explicit Wallet(std::string id) : id(std::move(id)) {}
const std::string id;
void deposit(Money m) { balance_ = balance_.add(m); }
Money balance() const { return balance_; }
void withdraw(Money m) {
if (balance_.paisa < m.paisa) throw InsufficientBalance();
balance_ = balance_.sub(m);
}
private:
Money balance_;
};
// Abstraction: checkout only knows "something that can pay".
class PaymentMethod {
public:
virtual ~PaymentMethod() = default;
virtual const char* name() const = 0;
virtual Money pay(Money amount) = 0; // returns the fee
};
class Card : public PaymentMethod {
public:
const char* name() const override { return "card"; }
Money pay(Money a) override { return a.percent(150); } // 1.5%
};
class Bkash : public PaymentMethod {
public:
explicit Bkash(Wallet& w) : wallet_(w) {}
const char* name() const override { return "bkash"; }
Money pay(Money a) override {
Money fee = a.percent(185); // 1.85%
wallet_.withdraw(a.add(fee));
return fee;
}
private:
Wallet& wallet_;
};
class Cash : public PaymentMethod {
public:
const char* name() const override { return "cash"; }
Money pay(Money) override { return {}; }
};
// Polymorphism: one call (a virtual function), and each method does its own thing.
void checkout(PaymentMethod& pm, Money amount) {
try {
Money fee = pm.pay(amount);
std::printf("%-6s pays %s, fee %s\n", pm.name(), amount.str().c_str(), fee.str().c_str());
} catch (const InsufficientBalance& e) {
std::printf("%-6s pays %s: %s\n", pm.name(), amount.str().c_str(), e.what());
}
}
int main() {
Wallet w("W-1");
w.deposit(Money::taka(500));
std::printf("wallet %s balance %s\n", w.id.c_str(), w.balance().str().c_str());
Card card;
Bkash bkash(w);
Cash cash;
checkout(card, Money::taka(1200));
checkout(bkash, Money::taka(300));
std::printf("wallet %s balance %s\n", w.id.c_str(), w.balance().str().c_str());
checkout(bkash, Money::taka(300));
checkout(cash, Money::taka(80));
std::printf("Taka(5) == Taka(5): %s\n", Money::taka(5) == Money::taka(5) ? "true" : "false");
// Entities are the same when their IDs match, even if other fields differ.
Wallet loaded("W-1"); // e.g. the same wallet loaded again from the database
std::printf("same wallet (by ID): %s\n", w.id == loaded.id ? "true" : "false");
}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
Withdrawrefuses and the error comes back throughPayto checkout. The balance never went negative, and checkout didn't need to know why. - cash: no fee. Three different behaviours, zero
ifstatements about the type incheckout. - Value vs entity: two separately created
Taka(5)are equal. TwoWalletobjects are the same wallet only because they share the IDW-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'sBkashborrows the wallet mutably (&'a mut Wallet), so the compiler stops two payments touching it at once. Java'srecordand Rust's#[derive(PartialEq)]give value equality for free; C++ asks for it withoperator== = 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.2in floating point isn't0.3; that's a real bug class in payment code. - Make value objects immutable. If
Moneycould change in place, two wallets sharing aMoneywould 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
Pick the broken design
wallet.Balance -= fee // in the refund script
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 touchcheckout. - 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 →