Part 01 · Low Level Design
SOLID Principles
SOLID এর প্রতিটা অক্ষর শেখানো হয়েছে যে সমস্যা সারায় সেটা দিয়ে: সব কাজ করা একটা class, বাড়তেই থাকা if-else, অবাক করা subclass, মোটা interface, আর database এর সাথে শক্ত করে বাঁধা code।
⏱ 2-3 ঘণ্টা · Station 6 / 80
প্রতি ঈদে invoice এর code ভাঙে
একটা দোকানের invoice এর পুরো code একটা class এ: InvoiceService। এটা total হিসাব করে, লম্বা একটা if/else দিয়ে discount বসায়, PDF এর লেখা সাজায়, আবার একই method গুলোর ভেতরে SQL লিখে Postgres এ save করে।
প্রতি ঈদে marketing একটা নতুন discount আনে। প্রতি ঈদে কেউ ওই if/else edit করে, আর একই commit এ PDF এর layout বা save ভেঙে যায়, কারণ সব আছে ওই একই ৬০০ লাইনে। Test চালাতে আসল database লাগে, তাই ঈদের আগে কেউ test চালায়ই না।
এর কোনোটাই একটা বড় ভুল না। এগুলো পাঁচটা ছোট ভুল, আর এত চেনা যে এদের একটা নামই আছে: SOLID। প্রতিটা অক্ষর এমন একটা সমস্যা যেটা code review তে চোখে পড়ে, আর তার একটা সমাধান।
সমস্যা: এক class এ পাঁচটা সমস্যা
| যা চোখে পড়ে | যে অক্ষর এটা সারায় |
|---|---|
| একটা class বদলায় দাম, PDF layout আর database, তিন কারণে | Single Responsibility |
প্রতিটা নতুন discount মানে একই if/else edit করা | Open/Closed |
| একটা "special" discount minus সংখ্যা দেয় আর total ভেঙে যায় | Liskov Substitution |
| Checkout ১৫টা method এর একটা store interface চায়, অথচ ব্যবহার করে ১টা | Interface Segregation |
Business logic নিজেই একটা Postgres class new করে, তাই test এ database লাগে | Dependency Inversion |
একটা restaurant এর রান্নাঘরের কথা ভাবো
ভালো রান্নাঘরে chef রাঁধে, cashier bill করে, cleaner পরিষ্কার করে: প্রত্যেকের একটা কাজ (S)। Menu তে নতুন dish আসে একটা recipe card যোগ করে, পুরো রান্নাঘর নতুন করে বানিয়ে না (O)। Shift এ যে রাঁধুনিই থাকুক, card এ যেমন লেখা তেমনই biryani বানাবে; কোনো বদলি রাঁধুনি যদি চাল ছাড়া "উন্নত" biryani বানায়, তাহলে কথা রাখা হলো না (L)। Delivery rider কে বলা হয় "order 42 নিয়ে যাও", পুরো রান্নাঘরের manual তার হাতে ধরিয়ে দেওয়া হয় না (I)। আর chef চায় "gas এর লাইন", "৭ নম্বর রোডের দোকানের cylinder" না, যাতে supplier বদলালেও রান্না বদলাতে না হয় (D)।
তবে একটা জায়গায় মেলে না। রান্নাঘরের মানুষ নিজেরা মানিয়ে নেয়। Code এ প্রতিটা সীমানা এমন একটা সিদ্ধান্ত যেটা লিখে দিতে হয়: একটা interface, একটা আলাদা type, constructor এর একটা parameter। খুব বেশি সীমানা খুব কমের মতোই ভোগায়, তাই SOLID লাগাও সেখানে, যেখানে সত্যিই change হয়।
Discount গুলো এখন object এর একটা list, প্রত্যেকে Discount implement করে (Name, Amount)। Marketing আনলো 'bKash cashback, 5%'। কোন code বদলাবে?
পাঁচটা অক্ষর, পাঁচটা সমস্যা
- S, Single Responsibility: একটা class এর বদলানোর কারণ একটাই হওয়া উচিত। "একটা method" না, একজন মালিক বা এক রকম change। হিসাব করা, print করা আর save করা বদলায় আলাদা কারণে (pricing team, design team, infrastructure), তাই এরা আলাদা।
- O, Open/Closed: code হবে বাড়ানোর জন্য খোলা, পরিবর্তনের জন্য বন্ধ। নতুন রকম কিছু এলে (একটা discount, একটা payment method) তুমি একটা type যোগ করো, কোনো switch edit করো না। সাধারণত interface আর তার implementation এর একটা list দিয়ে করা হয়।
- L, Liskov Substitution: interface যেখানে চাওয়া হয়েছে, যেকোনো implementation সেখানে চলতে হবে, caller কে অবাক না করে।
Discountযদি কথা দেয় "০ থেকে subtotal", তাহলে যে discount −৫০ বা subtotal এর দ্বিগুণ ফেরত দেয়, সে compile হলেও প্রতিটা caller কে ভেঙে দেয়। Subclass আর implementation কে parent এর দেওয়া কথা রাখতে হবে। - I, Interface Segregation: caller যে method ব্যবহার করে না, তার উপর নির্ভর করা উচিত না। Checkout শুধু save করে, তাই সে চায়
InvoiceSaver{Save}, ১৫ method এর একটাInvoiceRepositoryনা। ছোট interface implement করা, নকল বানানো আর বোঝা সহজ। - D, Dependency Inversion: উপরের স্তরের নীতি (checkout) নিচের স্তরের খুঁটিনাটির (Postgres) উপর নির্ভর করবে না। দুজনেই নির্ভর করবে একটা abstraction এর উপর (
InvoiceSaver), আর খুঁটিনাটি টা বাইরে থেকে দেওয়া হবে (dependency injection)। তাহলে test এ দেওয়া যায় memory store, আর production এ Postgres। - এগুলো tool, আইন না। প্রতিটা অক্ষর একটা নতুন সীমানা যোগ করে। যেখানে change সত্যিই হয় সেখানে যোগ করো (discount প্রতি ঈদে বদলায়), যেখানে হয় না সেখানে বাদ দাও (invoice ID এর format বছরের পর বছর একই)।
নতুন design এর ভেতর দিয়ে একটা invoice
Checkout.Process("INV-1", রহিম, ১২০০)
ধাপ 1/5 Checkout শুধু সমন্বয় করে
সে calculator এর কাছে invoice চায়, printer কে print করতে বলে, saver কে save করতে বলে। তার নিজের code এ কোনো দাম হিসাব, formatting বা SQL নেই।
- customer
- Rahim (student)
- subtotal
- 1200
Code review তে পাঁচটা প্রশ্ন
- S: এই class বদলাতে কে কে আমাকে বলতে পারে? উত্তরে একটার বেশি team থাকলে ভাগ করো।
- O: পরের নতুন রকমটা আসলে আমি code যোগ করবো, নাকি code edit করবো?
- L: এই interface এর কোনো implementation কি এমন caller কে অবাক করতে পারে, যে শুধু interface টা পড়েছে?
- I: এই caller কি যে interface চায় তার সব method ব্যবহার করে?
- D: আমার business logic কি নিজেই database, HTTP client বা clock
newকরে? তার বদলে বাইরে থেকে দাও।
নিজে বলো: কোন অক্ষরটা ভাঙছে?
SOLID এর কোন সমস্যা
class UserService { 900 lines }Code এ invoice
একটা Calculator যে শুধু হিসাব করে আর Discount rule গুলোর উপর loop চালায় (S, O, L), আলাদা একটা printer (S), আর একটা Checkout যে database বানানোর বদলে ছোট একটা InvoiceSaver interface হাতে পায় (I, D)।
package main
import "fmt"
type Customer struct {
Name string
Student bool
}
type Applied struct {
Name string
Amount int
}
type Invoice struct {
ID string
Customer Customer
Subtotal int
Discounts []Applied
Total int
}
// OCP: a new discount is a new type, not an edit to existing code.
// LSP: every Discount must honour the same promise: return 0..subtotal, never negative.
type Discount interface {
Name() string
Amount(c Customer, subtotal int) int
}
type PercentOff struct {
Label string
Percent int
}
func (p PercentOff) Name() string { return p.Label }
func (p PercentOff) Amount(_ Customer, sub int) int { return sub * p.Percent / 100 }
type StudentOff struct{ Taka int }
func (StudentOff) Name() string { return "student" }
func (s StudentOff) Amount(c Customer, sub int) int {
if !c.Student {
return 0
}
return min(s.Taka, sub)
}
// SRP: Calculator only works out numbers.
type Calculator struct{ rules []Discount }
func (calc Calculator) Build(id string, c Customer, subtotal int) Invoice {
inv := Invoice{ID: id, Customer: c, Subtotal: subtotal, Total: subtotal}
for _, r := range calc.rules {
if a := r.Amount(c, subtotal); a > 0 {
inv.Discounts = append(inv.Discounts, Applied{r.Name(), a})
inv.Total -= a
}
}
return inv
}
// SRP: printing is a separate job with a separate reason to change.
func Print(inv Invoice) {
fmt.Printf("invoice %s for %s\n", inv.ID, inv.Customer.Name)
fmt.Printf(" %-15s %5d tk\n", "subtotal", inv.Subtotal)
for _, d := range inv.Discounts {
fmt.Printf(" %-15s %5d tk\n", "- "+d.Name, d.Amount)
}
fmt.Printf(" %-15s %5d tk\n", "total", inv.Total)
}
// ISP: checkout only needs to save, so it asks for a small interface, not the whole store.
type InvoiceSaver interface {
Save(inv Invoice) string
}
// MemoryStore can do more (Count), but checkout never sees that.
type MemoryStore struct{ saved []Invoice }
func (m *MemoryStore) Save(inv Invoice) string { m.saved = append(m.saved, inv); return "memory" }
func (m *MemoryStore) Count() int { return len(m.saved) }
// DIP: Checkout depends on abstractions (Discount, InvoiceSaver), not on a database class.
type Checkout struct {
calc Calculator
saver InvoiceSaver
}
func (co Checkout) Process(id string, c Customer, subtotal int) {
inv := co.calc.Build(id, c, subtotal)
Print(inv)
fmt.Printf("saved %s to %s\n", inv.ID, co.saver.Save(inv))
}
func main() {
store := &MemoryStore{}
co := Checkout{
calc: Calculator{rules: []Discount{PercentOff{"eid sale 10%", 10}, StudentOff{Taka: 50}}},
saver: store,
}
co.Process("INV-1", Customer{"Rahim", true}, 1200)
co.Process("INV-2", Customer{"Nadia", false}, 300)
fmt.Println("store has", store.Count(), "invoices")
}from __future__ import annotations
from dataclasses import dataclass, field
from typing import Protocol
@dataclass(frozen=True)
class Customer:
name: str
student: bool
@dataclass
class Invoice:
id: str
customer: Customer
subtotal: int
discounts: list[tuple[str, int]] = field(default_factory=list)
total: int = 0
class Discount(Protocol):
"""OCP: a new discount is a new class, not an edit to existing code.
LSP: every Discount must honour the same promise: return 0..subtotal, never negative."""
name: str
def amount(self, c: Customer, subtotal: int) -> int: ...
class PercentOff:
def __init__(self, label: str, percent: int):
self.name, self.percent = label, percent
def amount(self, c: Customer, subtotal: int) -> int:
return subtotal * self.percent // 100
class StudentOff:
name = "student"
def __init__(self, taka: int):
self.taka = taka
def amount(self, c: Customer, subtotal: int) -> int:
return min(self.taka, subtotal) if c.student else 0
class Calculator:
"""SRP: only works out numbers."""
def __init__(self, rules: list[Discount]):
self.rules = rules
def build(self, id: str, c: Customer, subtotal: int) -> Invoice:
inv = Invoice(id, c, subtotal, total=subtotal)
for r in self.rules:
a = r.amount(c, subtotal)
if a > 0:
inv.discounts.append((r.name, a))
inv.total -= a
return inv
def print_invoice(inv: Invoice) -> None:
"""SRP: printing is a separate job with a separate reason to change."""
print(f"invoice {inv.id} for {inv.customer.name}")
print(f" {'subtotal':<15} {inv.subtotal:>5} tk")
for name, amount in inv.discounts:
print(f" {'- ' + name:<15} {amount:>5} tk")
print(f" {'total':<15} {inv.total:>5} tk")
class InvoiceSaver(Protocol):
"""ISP: checkout only needs to save, so it asks for a small interface."""
def save(self, inv: Invoice) -> str: ...
class MemoryStore:
"""Can do more (count), but checkout never sees that."""
def __init__(self) -> None:
self.saved: list[Invoice] = []
def save(self, inv: Invoice) -> str:
self.saved.append(inv)
return "memory"
def count(self) -> int:
return len(self.saved)
class Checkout:
"""DIP: depends on abstractions (Discount, InvoiceSaver), not on a database class."""
def __init__(self, calc: Calculator, saver: InvoiceSaver):
self.calc, self.saver = calc, saver
def process(self, id: str, c: Customer, subtotal: int) -> None:
inv = self.calc.build(id, c, subtotal)
print_invoice(inv)
print(f"saved {inv.id} to {self.saver.save(inv)}")
store = MemoryStore()
co = Checkout(Calculator([PercentOff("eid sale 10%", 10), StudentOff(50)]), store)
co.process("INV-1", Customer("Rahim", True), 1200)
co.process("INV-2", Customer("Nadia", False), 300)
print("store has", store.count(), "invoices")interface Customer {
readonly name: string;
readonly student: boolean;
}
interface Invoice {
readonly id: string;
readonly customer: Customer;
readonly subtotal: number;
readonly discounts: { name: string; amount: number }[];
total: number;
}
// OCP: a new discount is a new class, not an edit to existing code.
// LSP: every Discount must honour the same promise: return 0..subtotal, never negative.
interface Discount {
readonly name: string;
amount(c: Customer, subtotal: number): number;
}
class PercentOff implements Discount {
readonly name: string;
readonly percent: number;
constructor(label: string, percent: number) {
this.name = label;
this.percent = percent;
}
amount(_c: Customer, subtotal: number): number {
return Math.floor((subtotal * this.percent) / 100);
}
}
class StudentOff implements Discount {
readonly name = "student";
readonly taka: number;
constructor(taka: number) {
this.taka = taka;
}
amount(c: Customer, subtotal: number): number {
return c.student ? Math.min(this.taka, subtotal) : 0;
}
}
// SRP: only works out numbers.
class Calculator {
readonly rules: Discount[];
constructor(rules: Discount[]) {
this.rules = rules;
}
build(id: string, customer: Customer, subtotal: number): Invoice {
const inv: Invoice = { id, customer, subtotal, discounts: [], total: subtotal };
for (const r of this.rules) {
const a = r.amount(customer, subtotal);
if (a > 0) {
inv.discounts.push({ name: r.name, amount: a });
inv.total -= a;
}
}
return inv;
}
}
// SRP: printing is a separate job with a separate reason to change.
function printInvoice(inv: Invoice): void {
const row = (label: string, n: number) => console.log(` ${label.padEnd(15)} ${String(n).padStart(5)} tk`);
console.log(`invoice ${inv.id} for ${inv.customer.name}`);
row("subtotal", inv.subtotal);
for (const d of inv.discounts) row(`- ${d.name}`, d.amount);
row("total", inv.total);
}
// ISP: checkout only needs to save, so it asks for a small interface.
interface InvoiceSaver {
save(inv: Invoice): string;
}
// Can do more (count), but checkout never sees that.
class MemoryStore implements InvoiceSaver {
readonly saved: Invoice[] = [];
save(inv: Invoice): string {
this.saved.push(inv);
return "memory";
}
count(): number {
return this.saved.length;
}
}
// DIP: depends on abstractions (Discount, InvoiceSaver), not on a database class.
class Checkout {
readonly calc: Calculator;
readonly saver: InvoiceSaver;
constructor(calc: Calculator, saver: InvoiceSaver) {
this.calc = calc;
this.saver = saver;
}
process(id: string, c: Customer, subtotal: number): void {
const inv = this.calc.build(id, c, subtotal);
printInvoice(inv);
console.log(`saved ${inv.id} to ${this.saver.save(inv)}`);
}
}
const store = new MemoryStore();
const co = new Checkout(new Calculator([new PercentOff("eid sale 10%", 10), new StudentOff(50)]), store);
co.process("INV-1", { name: "Rahim", student: true }, 1200);
co.process("INV-2", { name: "Nadia", student: false }, 300);
console.log("store has", store.count(), "invoices");struct Customer { name: String, student: bool }
struct Invoice { id: String, customer_name: String, subtotal: u32, discounts: Vec<(String, u32)>, total: u32 }
// OCP: a new discount is a new type, not an edit to existing code.
// LSP: every Discount must honour the same promise: return 0..=subtotal.
trait Discount {
fn name(&self) -> &str;
fn amount(&self, c: &Customer, subtotal: u32) -> u32;
}
struct PercentOff { label: String, percent: u32 }
impl Discount for PercentOff {
fn name(&self) -> &str { &self.label }
fn amount(&self, _: &Customer, sub: u32) -> u32 { sub * self.percent / 100 }
}
struct StudentOff { taka: u32 }
impl Discount for StudentOff {
fn name(&self) -> &str { "student" }
fn amount(&self, c: &Customer, sub: u32) -> u32 { if c.student { self.taka.min(sub) } else { 0 } }
}
// SRP: only works out numbers.
struct Calculator { rules: Vec<Box<dyn Discount>> }
impl Calculator {
fn build(&self, id: &str, c: &Customer, subtotal: u32) -> Invoice {
let mut inv = Invoice { id: id.into(), customer_name: c.name.clone(), subtotal, discounts: vec![], total: subtotal };
for r in &self.rules {
let a = r.amount(c, subtotal);
if a > 0 {
inv.discounts.push((r.name().to_string(), a));
inv.total -= a;
}
}
inv
}
}
// SRP: printing is a separate job with a separate reason to change.
fn print_invoice(inv: &Invoice) {
println!("invoice {} for {}", inv.id, inv.customer_name);
println!(" {:<15} {:>5} tk", "subtotal", inv.subtotal);
for (name, amount) in &inv.discounts {
println!(" {:<15} {:>5} tk", format!("- {name}"), amount);
}
println!(" {:<15} {:>5} tk", "total", inv.total);
}
// ISP: checkout only needs to save, so it asks for a small trait.
trait InvoiceSaver { fn save(&mut self, inv: Invoice) -> String; }
// Can do more (count), but checkout never sees that.
struct MemoryStore { saved: Vec<Invoice> }
impl InvoiceSaver for MemoryStore {
fn save(&mut self, inv: Invoice) -> String { self.saved.push(inv); "memory".into() }
}
impl MemoryStore { fn count(&self) -> usize { self.saved.len() } }
// DIP: depends on abstractions (Discount, InvoiceSaver), not on a database type.
struct Checkout<'a> { calc: Calculator, saver: &'a mut dyn InvoiceSaver }
impl Checkout<'_> {
fn process(&mut self, id: &str, c: &Customer, subtotal: u32) {
let inv = self.calc.build(id, c, subtotal);
print_invoice(&inv);
let id = inv.id.clone();
println!("saved {id} to {}", self.saver.save(inv));
}
}
fn main() {
let mut store = MemoryStore { saved: vec![] };
{
let calc = Calculator { rules: vec![
Box::new(PercentOff { label: "eid sale 10%".into(), percent: 10 }),
Box::new(StudentOff { taka: 50 }),
] };
let mut co = Checkout { calc, saver: &mut store };
co.process("INV-1", &Customer { name: "Rahim".into(), student: true }, 1200);
co.process("INV-2", &Customer { name: "Nadia".into(), student: false }, 300);
}
println!("store has {} invoices", store.count());
}import java.util.ArrayList;
import java.util.List;
public class Main {
record Customer(String name, boolean student) {}
record Applied(String name, int amount) {}
record Invoice(String id, Customer customer, int subtotal, List<Applied> discounts, int total) {}
// OCP: a new discount is a new class, not an edit to existing code.
// LSP: every Discount must honour the same promise: return 0..subtotal, never negative.
interface Discount {
String name();
int amount(Customer c, int subtotal);
}
record PercentOff(String name, int percent) implements Discount {
public int amount(Customer c, int subtotal) { return subtotal * percent / 100; }
}
record StudentOff(int taka) implements Discount {
public String name() { return "student"; }
public int amount(Customer c, int subtotal) { return c.student() ? Math.min(taka, subtotal) : 0; }
}
// SRP: only works out numbers.
record Calculator(List<Discount> rules) {
Invoice build(String id, Customer c, int subtotal) {
List<Applied> applied = new ArrayList<>();
int total = subtotal;
for (Discount r : rules) {
int a = r.amount(c, subtotal);
if (a > 0) {
applied.add(new Applied(r.name(), a));
total -= a;
}
}
return new Invoice(id, c, subtotal, applied, total);
}
}
// SRP: printing is a separate job with a separate reason to change.
static void print(Invoice inv) {
System.out.printf("invoice %s for %s%n", inv.id(), inv.customer().name());
System.out.printf(" %-15s %5d tk%n", "subtotal", inv.subtotal());
for (Applied d : inv.discounts()) System.out.printf(" %-15s %5d tk%n", "- " + d.name(), d.amount());
System.out.printf(" %-15s %5d tk%n", "total", inv.total());
}
// ISP: checkout only needs to save, so it asks for a small interface.
interface InvoiceSaver {
String save(Invoice inv);
}
// Can do more (count), but checkout never sees that.
static final class MemoryStore implements InvoiceSaver {
private final List<Invoice> saved = new ArrayList<>();
public String save(Invoice inv) { saved.add(inv); return "memory"; }
int count() { return saved.size(); }
}
// DIP: depends on abstractions (Discount, InvoiceSaver), not on a database class.
record Checkout(Calculator calc, InvoiceSaver saver) {
void process(String id, Customer c, int subtotal) {
Invoice inv = calc.build(id, c, subtotal);
print(inv);
System.out.printf("saved %s to %s%n", inv.id(), saver.save(inv));
}
}
public static void main(String[] args) {
MemoryStore store = new MemoryStore();
Checkout co = new Checkout(new Calculator(List.of(new PercentOff("eid sale 10%", 10), new StudentOff(50))), store);
co.process("INV-1", new Customer("Rahim", true), 1200);
co.process("INV-2", new Customer("Nadia", false), 300);
System.out.println("store has " + store.count() + " invoices");
}
}#include <algorithm>
#include <cstdio>
#include <memory>
#include <string>
#include <utility>
#include <vector>
struct Customer {
std::string name;
bool student;
};
struct Invoice {
std::string id;
Customer customer;
int subtotal;
std::vector<std::pair<std::string, int>> discounts;
int total;
};
// OCP: a new discount is a new class, not an edit to existing code.
// LSP: every Discount must honour the same promise: return 0..subtotal, never negative.
class Discount {
public:
virtual ~Discount() = default;
virtual std::string name() const = 0;
virtual int amount(const Customer& c, int subtotal) const = 0;
};
class PercentOff : public Discount {
public:
PercentOff(std::string label, int percent) : label_(std::move(label)), percent_(percent) {}
std::string name() const override { return label_; }
int amount(const Customer&, int sub) const override { return sub * percent_ / 100; }
private:
std::string label_;
int percent_;
};
class StudentOff : public Discount {
public:
explicit StudentOff(int taka) : taka_(taka) {}
std::string name() const override { return "student"; }
int amount(const Customer& c, int sub) const override { return c.student ? std::min(taka_, sub) : 0; }
private:
int taka_;
};
// SRP: only works out numbers.
class Calculator {
public:
explicit Calculator(std::vector<std::unique_ptr<Discount>> rules) : rules_(std::move(rules)) {}
Invoice build(const std::string& id, const Customer& c, int subtotal) const {
Invoice inv{id, c, subtotal, {}, subtotal};
for (const auto& r : rules_) {
if (int a = r->amount(c, subtotal); a > 0) {
inv.discounts.push_back({r->name(), a});
inv.total -= a;
}
}
return inv;
}
private:
std::vector<std::unique_ptr<Discount>> rules_;
};
// SRP: printing is a separate job with a separate reason to change.
void print(const Invoice& inv) {
std::printf("invoice %s for %s\n", inv.id.c_str(), inv.customer.name.c_str());
std::printf(" %-15s %5d tk\n", "subtotal", inv.subtotal);
for (const auto& [name, amount] : inv.discounts) std::printf(" %-15s %5d tk\n", ("- " + name).c_str(), amount);
std::printf(" %-15s %5d tk\n", "total", inv.total);
}
// ISP: checkout only needs to save, so it asks for a small interface.
class InvoiceSaver {
public:
virtual ~InvoiceSaver() = default;
virtual std::string save(const Invoice& inv) = 0;
};
// Can do more (count), but checkout never sees that.
class MemoryStore : public InvoiceSaver {
public:
std::string save(const Invoice& inv) override {
saved_.push_back(inv);
return "memory";
}
size_t count() const { return saved_.size(); }
private:
std::vector<Invoice> saved_;
};
// DIP: depends on abstractions (Discount, InvoiceSaver), not on a database class.
class Checkout {
public:
Checkout(Calculator calc, InvoiceSaver& saver) : calc_(std::move(calc)), saver_(saver) {}
void process(const std::string& id, const Customer& c, int subtotal) {
Invoice inv = calc_.build(id, c, subtotal);
print(inv);
std::printf("saved %s to %s\n", inv.id.c_str(), saver_.save(inv).c_str());
}
private:
Calculator calc_;
InvoiceSaver& saver_;
};
int main() {
MemoryStore store;
std::vector<std::unique_ptr<Discount>> rules;
rules.push_back(std::make_unique<PercentOff>("eid sale 10%", 10));
rules.push_back(std::make_unique<StudentOff>(50));
Checkout co(Calculator(std::move(rules)), store);
co.process("INV-1", {"Rahim", true}, 1200);
co.process("INV-2", {"Nadia", false}, 300);
std::printf("store has %zu invoices\n", store.count());
}ছয়টাই হুবহু এটা print করে:
invoice INV-1 for Rahim
subtotal 1200 tk
- eid sale 10% 120 tk
- student 50 tk
total 1030 tk
saved INV-1 to memory
invoice INV-2 for Nadia
subtotal 300 tk
- eid sale 10% 30 tk
total 270 tk
saved INV-2 to memory
store has 2 invoices
Output টা পড়ি:
- INV-1: রহিম student। Eid sale ১২০০ এর 10% = ১২০, student ৫০, total ১০৩০। দুইটা rule ই চললো, অথচ calculator জানেও না ওরা কী।
- INV-2: নাদিয়া student না, তাই
StudentOff০ দেয়, আর calculator ওটা invoice এ রাখে না। ৩০০ এর 10% = ৩০, total ২৭০। - "saved … to memory": Checkout একটা interface এর
Saveডেকেছে। Postgres এর saver বসালে এই লাইনে আসতোpostgres, Checkout এ কোনো change ছাড়াই। - "store has 2 invoices": Memory store এ
Countআছে, কিন্তু Checkout সেটা দেখেই না, কারণ সে শুধুInvoiceSaverচেয়েছে (ISP)। - Language ভেদে কী আলাদা: Go তে interface নিজে থেকেই মেনে নেওয়া হয়, তাই
MemoryStoreকোথাও "implements" লেখে না। Java তে discount গুলো record, তাই খুব ছোট। Rust এ rule এর list হলোBox<dyn Discount>আর store হলো&mut dyn InvoiceSaver; borrow শেষ হয় বন্ধ বন্ধনীতে, তাই পরেstore.count()ডাকা যায়। C++ rule গুলোর মালিকunique_ptrএ, আর saver নেয় reference হিসেবে, যার মানে "আমি ব্যবহার করি, মালিক না"।
আসল codebase এ SOLID
- SRP এর বিষয় মানুষ, লাইনের সংখ্যা না। ৩০০ লাইনের একটা class, যেটা একটা team এর আর এক কারণেই বদলায়, ঠিক আছে। ৬০ লাইনের একটা class নিয়ে তিনটা team টানাটানি করলে ঠিক নেই।
- OCP সবচেয়ে ভালো চলে একটা list বা registry দিয়ে। নতুন আচরণ মানে এক জায়গায় যোগ করা নতুন একটা type (discount rule এর list, payment method এর map)। মূল loop কখনো বদলায় না।
- চুক্তিটা লিখে রাখো। LSP টিকিয়ে রাখতে interface এর কথাটা পরিষ্কার হতে হবে: "০ থেকে subtotal ফেরত দেয়", "কখনো block করে না", "idempotent"। একটা comment এ লেখো, আর একটা shared test রাখো যেটা প্রতিটা implementation চালাবে।
- অনেক ছোট interface ভালো, আর সেটা বানাবে caller। বিশেষ করে Go তে, যে ব্যবহার করবে সে ঠিক যেটুকু লাগবে সেই interface ঘোষণা করে (
InvoiceSaver), আর বড় type গুলো না জেনেই সেটা মেনে চলে। - DIP এর কারণেই unit test দ্রুত হয়। Database, clock, HTTP client আর random number generator বাইরে থেকে দাও; test নকল গুলো দেয় আর millisecond এ চলে।
- পাঁচটা সব জায়গায় লাগিও না। প্রতিটা class এর জন্য একটা interface আর প্রতিটা interface এর জন্য একটা factory মানে দরকারের বেশি engineering। SOLID সেই অংশ গুলোর জন্য, যেগুলো বদলায়।
যে design গুলো পরে problem create করে
যে design টা পরে ভোগাবে
ServiceFee.Amount() returns -30
একটু থেমে ভাবো
নতুন একটা rule আসলো: "student discount অন্য কোনো discount এর সাথে মেলানো যাবে না; যেটা বড়, customer সেটা পাবে।" Calculator edit না করে কি এটা যোগ করা যায়?
উত্তর দেখো
সাধারণ একটা নতুন Discount হিসেবে না, কারণ এটা কোনো discount না, এটা discount গুলো কীভাবে মিলবে তার rule। এটা নীতির একটা সত্যিকারের পরিবর্তন, তাই calculator এ হাত পড়াটা ন্যায্য, অথবা আরো ভালো, একটা নতুন জিনিস আনো: একটা CombinePolicy (সব যোগ / সবচেয়ে বড়টা / আলাদা আলাদা group), যেটাকে calculator জিজ্ঞেস করবে। Open/Closed মানে "কখনো কিছু edit করবে না" না; মানে সাধারণ রকমের change (আরেকটা discount) এ কোনো edit লাগবে না। নতুন রকমের change এলে তার জন্য একবার একটা নতুন extension point বানাও।
ছোট্ট quiz
১. SRP এ "বদলানোর একটাই কারণ" মানে কী?
উত্তর
একটা class এর সব code একই রকম কারণে বদলাবে, সাধারণত একই মানুষ বা team এর চাওয়ায়। Pricing rule, PDF layout আর storage তিনটা কারণ, তাই তিনটা জায়গা।
২. Interface আর implementation এর একটা list কীভাবে Open/Closed মানে?
উত্তর
নতুন আচরণ আসে নতুন একটা implementation হিসেবে, যেটা list এ যোগ হয়; list এর উপর loop চালানো code বদলায় না।
৩. Liskov Substitution ভাঙার একটা উদাহরণ দাও।
উত্তর
এমন একটা implementation যেটা interface এর কথা রাখে না, যেমন minus amount ফেরত দেওয়া discount, বা read-only file যার Write throw করে; এতে interface এর উপর ভরসা করা caller রা অবাক হয়।
৪. Dependency inversion আর dependency injection এর পার্থক্য কী?
উত্তর
Inversion হলো design এর নিয়ম: উপরের স্তরের code একটা abstraction এর উপর নির্ভর করে, নিচের স্তরের নির্দিষ্ট class এর উপর না। Injection হলো কৌশল: নির্দিষ্ট object টা ভেতরে বানানো হয় না, বাইরে থেকে দেওয়া হয় (সাধারণত constructor দিয়ে)।
তোমার challenge: bKash cashback
Marketing তৃতীয় একটা discount আনলো: bKash cashback, 5%, যারা bKash দিয়ে pay করে তাদের জন্য।
- Customer এ একটা
PaysByBkashfield দাও, আর একটা নতুন discount type বানাও যেটা এটা true হলে 5% দেয়। - এটাকে rule এর list এ যোগ করো। Calculator, printer বা checkout এ হাত দেবে না।
- রহিম কে bKash দিয়ে pay করাও।
চালানোর আগে আন্দাজ করো রহিমের নতুন total কত।
হয়েছে বুঝবে যখন INV-1 এ নতুন একটা লাইন আসবে - bkash 5% 60 tk আর total 970 tk, INV-2 একই থাকবে, আর Calculator, printer আর Checkout এ কোনো edit লাগবে না।
এক কথায়
প্রতিটা class এর বদলানোর কারণ একটা, নতুন আচরণ আসবে নতুন branch এ না নতুন type হিসেবে, implementation রা interface এর কথা রাখবে, interface ছোট আর caller এর মাপে, আর business logic নিজের dependency নিজে না বানিয়ে বাইরে থেকে পাবে। SOLID লাগাও সেখানে, যেখানে সত্যিই change হয়।
Station stamp
Chapter শেষ করো
পড়া শেষ হলে Complete চাপো, progress save হবে। XP শুধু Pro তে — Pro থাকলে এই chapter এ +10 XP পেতে।
Pro নিয়ে XP পাও →