Part 01 · Low Level Design

SOLID Principles

SRPOCPLSPISPDIP

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)।

depends onimplementsCheckouthigh-level policyInvoiceSaverinterface: Save()MemoryStoretestsPostgresStoreproductionSave(inv)Save(inv)both stores point at the interfaceswap the store: Checkout's code doesn't change
Play চাপো। Checkout শুধু InvoiceSaver interface চেনে। একই Save call প্রথমে যায় memory store এ, তারপর Postgres এ, আর Checkout এর code একবারও বদলায় না।

তবে একটা জায়গায় মেলে না। রান্নাঘরের মানুষ নিজেরা মানিয়ে নেয়। Code এ প্রতিটা সীমানা এমন একটা সিদ্ধান্ত যেটা লিখে দিতে হয়: একটা interface, একটা আলাদা type, constructor এর একটা parameter। খুব বেশি সীমানা খুব কমের মতোই ভোগায়, তাই SOLID লাগাও সেখানে, যেখানে সত্যিই change হয়।

আগে অনুমান করো

Discount গুলো এখন object এর একটা list, প্রত্যেকে Discount implement করে (Name, Amount)। Marketing আনলো 'bKash cashback, 5%'। কোন code বদলাবে?

পাঁচটা অক্ষর, পাঁচটা সমস্যা

  1. S, Single Responsibility: একটা class এর বদলানোর কারণ একটাই হওয়া উচিত। "একটা method" না, একজন মালিক বা এক রকম change। হিসাব করা, print করা আর save করা বদলায় আলাদা কারণে (pricing team, design team, infrastructure), তাই এরা আলাদা।
  2. O, Open/Closed: code হবে বাড়ানোর জন্য খোলা, পরিবর্তনের জন্য বন্ধ। নতুন রকম কিছু এলে (একটা discount, একটা payment method) তুমি একটা type যোগ করো, কোনো switch edit করো না। সাধারণত interface আর তার implementation এর একটা list দিয়ে করা হয়।
  3. L, Liskov Substitution: interface যেখানে চাওয়া হয়েছে, যেকোনো implementation সেখানে চলতে হবে, caller কে অবাক না করে। Discount যদি কথা দেয় "০ থেকে subtotal", তাহলে যে discount −৫০ বা subtotal এর দ্বিগুণ ফেরত দেয়, সে compile হলেও প্রতিটা caller কে ভেঙে দেয়। Subclass আর implementation কে parent এর দেওয়া কথা রাখতে হবে।
  4. I, Interface Segregation: caller যে method ব্যবহার করে না, তার উপর নির্ভর করা উচিত না। Checkout শুধু save করে, তাই সে চায় InvoiceSaver{Save}, ১৫ method এর একটা InvoiceRepository না। ছোট interface implement করা, নকল বানানো আর বোঝা সহজ।
  5. D, Dependency Inversion: উপরের স্তরের নীতি (checkout) নিচের স্তরের খুঁটিনাটির (Postgres) উপর নির্ভর করবে না। দুজনেই নির্ভর করবে একটা abstraction এর উপর (InvoiceSaver), আর খুঁটিনাটি টা বাইরে থেকে দেওয়া হবে (dependency injection)। তাহলে test এ দেওয়া যায় memory store, আর production এ Postgres।
  6. এগুলো tool, আইন না। প্রতিটা অক্ষর একটা নতুন সীমানা যোগ করে। যেখানে change সত্যিই হয় সেখানে যোগ করো (discount প্রতি ঈদে বদলায়), যেখানে হয় না সেখানে বাদ দাও (invoice ID এর format বছরের পর বছর একই)।

নতুন design এর ভেতর দিয়ে একটা invoice

ধাপে ধাপে দেখো

Checkout.Process("INV-1", রহিম, ১২০০)

CheckoutCalculatorDiscount rulePrinterInvoiceSaver

ধাপ 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 করে? তার বদলে বাইরে থেকে দাও।

নিজে বলো: কোন অক্ষরটা ভাঙছে?

প্রতিটা case চালিয়ে দেখো

SOLID এর কোন সমস্যা

class UserService { 900 lines }
ভাঙে: S। বদলানোর চারটা কারণ। Validation, password hashing, email আর storage আলাদা করো।

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")
}

ছয়টাই হুবহু এটা 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 করে

প্রতিটা case চালিয়ে দেখো

যে design টা পরে ভোগাবে

ServiceFee.Amount() returns -30
ভাঙে: LSP ভাঙে: caller রা ধরে নেয় discount total কমায়। Fee কে তার নিজের rule সহ আলাদা একটা জিনিস হিসেবে model করো।

একটু থেমে ভাবো

নতুন একটা 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 এ একটা PaysByBkash field দাও, আর একটা নতুন 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 পাও →