Part 01 · Low Level Design

Clean Architecture and Dependency Injection

Dependency InjectionRepositoryClean ArchitectureDDD basicsTestability

Business rule কোথায় থাকবে আর কীসের উপর depend করতে পারবে: dependency injection, repository, clean architecture এর layer, DDD এর কাজের অংশটুকু, আর এমন design যেটা সহজে test করা যায়।

⏱ 2-3 ঘণ্টা · Station 11 / 80

যে rule তিন জায়গায় থাকতো

একটা mobile wallet এর একটা rule আছে: কোনো account দিনে ২৫,০০০ টাকার বেশি পাঠাতে পারবে না। প্রথম version এ transfer এর HTTP handler এটা check করে, একটা SQL query দিয়ে আজকের transfer গুলো যোগ করে। পরে লেখা bulk-payment job আবার check করে, নিজের query দিয়ে। Admin tool একদমই check করে না।

তারপর verified user দের জন্য limit হলো ৩০,০০০। Edit করার জায়গা তিনটা, নতুন করে লিখতে হবে দুইটা SQL query, আর আসল database ছাড়া rule টা test করার কোনো উপায় নেই। একজন tester দেখলো admin tool দিয়ে যে কেউ ১ লাখ পাঠাতে পারছে।

Rule টা কঠিন ছিল না। শুধু HTTP আর SQL এর সাথে মিশে ছিল, তাই প্রতিটা নতুন প্রবেশপথকে নতুন করে বানাতে হয়েছে। Clean architecture হলো code সাজানোর এমন একটা উপায়, যেখানে rule গুলো থাকে মাঝখানে, কারো উপর নির্ভর করে না, আর প্রতিটা প্রবেশপথ ওদের ভেতর দিয়েই যায়।

সমস্যা: business rule আর framework জট পাকিয়ে আছে

যা দেখা যায়কারণ
প্রতিটা নতুন প্রবেশপথ (API, job, admin tool) rule গুলো আবার লেখেRule থাকে handler এ, shared কোনো core এ না
Unit test এ database আর HTTP server লাগেRule নিজেই SQL ডাকে আর request পড়ে
Database বা framework বদলালে business code এ হাত পড়েBusiness code framework import করে
"Transfer এর rule" এক জায়গায় কেউ খুঁজে পায় নাQuery আর handler জুড়ে ছড়ানো

একটা ব্যাংকের শাখার কথা ভাবো

ব্যাংকের শাখায় rule গুলো ("দিনে সর্বোচ্চ ২৫,০০০", "overdraft না") থাকে ব্যাংকের নীতির বইয়ে। Counter এর teller, বাইরের ATM আর mobile app একই নীতিতে ঢোকার আলাদা আলাদা দরজা মাত্র। পেছনের ভল্ট আর খাতা টাকা আর হিসাব রাখে।

নীতির বইয়ে লেখা থাকে না "customer ATM দিয়ে আসলে তাহলে…"। ভল্টে কোন brand এর safe, সেটাও লেখা থাকে না। দরজা আর storage নীতির উপর নির্ভর করে; নীতি কারো উপর নির্ভর করে না।

implementsHTTP handlerinfrastructureTransferServiceapplicationAccountdomain rulesAccountRepoport (interface)PostgresRepoinfrastructureTransfer()Withdraw: limit + balanceSave()SQL?every arrow points inward: the domain imports nothingdomain reaching for the database: not allowed
Play চাপো। একটা transfer HTTP handler দিয়ে ঢোকে, service Account কে rule জিজ্ঞেস করে (limit আর balance), তারপর AccountRepo port দিয়ে save করে, যেটা Postgres implement করে। প্রতিটা dependency ভেতরের দিকে; domain যখন SQL এর দিকে হাত বাড়ায়, তাকে আটকে দেওয়া হয়।

তবে একটা জায়গায় মেলে না। একটা ব্যাংক মোটা নীতির বই আর অনেক কর্মচারী রাখতে পারে। ছোট একটা app layer এর ভারে ডুবে যেতে পারে: একটা CRUD screen এর জন্য চারটা folder আর ছয়টা interface। পুরো structure টা ব্যবহার করো যেখানে rule গুলো সমৃদ্ধ আর অনেকদিন বাঁচবে; সাধারণ screen এর জন্য হালকা রাখো।

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

দিনে ২৫,০০০ এর limit থাকে Account.Withdraw এ (domain)। API, bulk-payment job আর admin tool সবাই TransferService.Transfer ডাকে। Verified user দের জন্য limit হলো ৩০,০০০। কোথায় code বদলাবে?

Layer, port আর injection

  1. Domain (কেন্দ্রে): entity আর value object, তাদের rule সহ: Account.Withdraw দৈনিক limit আর balance check করে। Database, HTTP, framework বা ঘড়ি কোনো কিছুই import করে না। এটাই সেই code যেটা সবচেয়ে বেশি test করতে চাও, আর technical কারণে সবচেয়ে কম বদলাতে চাও।
  2. Application (use case): একটা কাজের সমন্বয় করে: TransferService.Transfer দুইটা account load করে, domain কে withdraw আর deposit করতে বলে, তারপর save করে। তার যে port গুলো লাগে, যেমন AccountRepo interface, সেগুলো সে নিজের ভাষায় ঘোষণা করে।
  3. Infrastructure (বাইরে): HTTP handler, database repository, message queue, email provider। এরা port গুলো implement করে (PostgresRepo, MemoryRepo) বা use case গুলো ডাকে।
  4. Dependency এর নিয়ম: source code এর dependency যায় শুধু ভেতরের দিকে। Infrastructure import করে application আর domain; application import করে domain; domain কিছুই import করে না। এটা chapter 6 এর dependency inversion, পুরো codebase এর উপর।
  5. Repository: aggregate load আর save করার জন্য collection এর মতো একটা interface (Get(id), Save(account)), যেটা SQL, ORM আর cache লুকিয়ে রাখে। বাকি code row না, account নিয়ে ভাবে।
  6. Dependency injection: object নিজের dependency নিজে না বানিয়ে বাইরে থেকে পায় (সাধারণত constructor এ)। main (composition root) আসল গুলো বানায়; test বানায় নকল গুলো। কোনো framework লাগে না।
  7. একটু DDD (Domain-Driven Design): জিনিসের নাম দাও business যেভাবে বলে (transfer, daily limit), আর প্রতিটা aggregate (নিজের rule সহ একটা account) কে নিজে নিজেই ঠিক রাখো। এই দুইটা idea ব্যবহার করতে পুরো DDD লাগে না।

Layer গুলোর ভেতর দিয়ে একটা transfer

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

আগে ৫,০০০ এর পরে Transfer(A-1 → A-2, ৩০,০০০)

HTTP handlerTransferServiceAccount (domain)AccountRepo

ধাপ 1/5 Handler শুধু অনুবাদ করে

JSON পড়ে, svc.Transfer("A-1", "A-2", 30000) ডাকে, আর ফলটাকে একটা HTTP status এ বদলায়। এখানে কোনো rule নেই।

request
A-1 -> A-2, 30000

তীর গুলো কোন দিকে?

তিনটা বৃত্তের কথা ভাবো:

  • কেন্দ্র: domain। Account, Money, rule। কিছুই import করে না।
  • মাঝে: application। TransferService, AccountRepo port। Domain import করে।
  • বাইরে: infrastructure। HTTP, Postgres, queue, main। মাঝের আর কেন্দ্রের দুটোই import করে।

একটা import যোগ করার আগে জিজ্ঞেস করো: এই তীর কি ভেতরের দিকে? Domain এর যদি বাইরের কিছু লাগে (সময়, exchange rate), application সেটা একটা value হিসেবে বা একটা port দিয়ে পাঠিয়ে দেবে।

নিজে বলো: এটা কোন layer এ যাবে?

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

ঠিক layer এ বসাও

Account.Withdraw checks balance
কাজ করে: Domain। Account এর নিজের data নিয়ে একটা business rule।

Code এ clean layer দিয়ে transfer

Domain (নিজের rule সহ Account), application (TransferService আর AccountRepo port), infrastructure (MemoryRepo), আর main, যেটা injection দিয়ে এদের জোড়া লাগায়।

package main

import (
	"errors"
	"fmt"
)

// ===== Domain layer: business rules only. No database, no HTTP, no imports from other layers.

const DailyLimit = 25000 // taka

var (
	ErrInsufficient = errors.New("insufficient balance")
	ErrDailyLimit   = fmt.Errorf("over daily limit %d", DailyLimit)
)

// Account is an entity (and a small aggregate): its rules live with its data.
type Account struct {
	ID        string
	balance   int
	sentToday int
}

func (a *Account) Withdraw(amount int) error {
	if a.sentToday+amount > DailyLimit {
		return ErrDailyLimit
	}
	if amount > a.balance {
		return ErrInsufficient
	}
	a.balance -= amount
	a.sentToday += amount
	return nil
}

func (a *Account) Deposit(amount int) { a.balance += amount }
func (a *Account) Balance() int       { return a.balance }

// ===== Application layer: use cases. It owns the interface it needs (the "port").

type AccountRepo interface {
	Get(id string) (*Account, error)
	Save(a *Account) error
}

type TransferService struct{ repo AccountRepo } // dependency injected, never created here

func NewTransferService(repo AccountRepo) *TransferService { return &TransferService{repo: repo} }

func (s *TransferService) Transfer(from, to string, amount int) error {
	src, err := s.repo.Get(from)
	if err != nil {
		return err
	}
	dst, err := s.repo.Get(to)
	if err != nil {
		return err
	}
	if err := src.Withdraw(amount); err != nil { // the rule lives in the domain
		return err
	}
	dst.Deposit(amount)
	if err := s.repo.Save(src); err != nil {
		return err
	}
	return s.repo.Save(dst)
}

// ===== Infrastructure layer: one implementation of the port. Postgres would be another.

type MemoryRepo struct{ rows map[string]Account }

func (m *MemoryRepo) Get(id string) (*Account, error) {
	a, ok := m.rows[id]
	if !ok {
		return nil, fmt.Errorf("account %s not found", id)
	}
	return &a, nil // a copy: changes count only after Save
}

func (m *MemoryRepo) Save(a *Account) error { m.rows[a.ID] = *a; return nil }

// ===== main: the composition root wires the layers together.

func main() {
	repo := &MemoryRepo{rows: map[string]Account{
		"A-1": {ID: "A-1", balance: 20000},
		"A-2": {ID: "A-2", balance: 2000},
	}}
	svc := NewTransferService(repo)

	cases := []struct {
		from, to string
		amount   int
	}{{"A-1", "A-2", 5000}, {"A-1", "A-2", 30000}, {"A-1", "A-2", 20000}, {"A-1", "A-9", 500}}
	for _, c := range cases {
		if err := svc.Transfer(c.from, c.to, c.amount); err != nil {
			fmt.Printf("transfer %5d %s -> %s: refused: %v\n", c.amount, c.from, c.to, err)
		} else {
			fmt.Printf("transfer %5d %s -> %s: ok\n", c.amount, c.from, c.to)
		}
	}
	a1, _ := repo.Get("A-1")
	a2, _ := repo.Get("A-2")
	fmt.Printf("balances: A-1 %d, A-2 %d\n", a1.Balance(), a2.Balance())
}

ছয়টাই হুবহু এটা print করে:

transfer  5000 A-1 -> A-2: ok
transfer 30000 A-1 -> A-2: refused: over daily limit 25000
transfer 20000 A-1 -> A-2: refused: insufficient balance
transfer   500 A-1 -> A-9: refused: account A-9 not found
balances: A-1 15000, A-2 7000

Output টা পড়ি:

  • ৫,০০০: limit আর balance দুটোর মধ্যেই, তাই হয়ে যায় আর দুইটা account ই save হয়।
  • ৩০,০০০: ৫,০০০ + ৩০,০০০ দৈনিক ২৫,০০০ এর বেশি। Domain ফিরিয়ে দেয়; কিছুই save হয় না।
  • ২০,০০০: limit এর মধ্যে (৫,০০০ + ২০,০০০ = ২৫,০০০), কিন্তু A-1 এ আছে মাত্র ১৫,০০০। Balance এর rule ফিরিয়ে দেয়।
  • A-9: repository এটা খুঁজে পায় না, তাই কোনো টাকায় হাত দেওয়ার আগেই use case থেমে যায়।
  • Balance ১৫,০০০ আর ৭,০০০: শুধু প্রথম transfer টাই কিছু বদলেছে, কারণ repository copy দেয়, আর service save করে শুধু সব rule pass হওয়ার পরে।
  • Language ভেদে কী আলাদা: Go আর C++ load করার সময় account কে value হিসেবে copy করে। Python ব্যবহার করে copy.deepcopy; TypeScript একটা সাধারণ data snapshot() থেকে আবার Account বানায়, যাতে repository কখনো private # field ছোঁয় না। Rust service কে repository র উপর generic বানায় (TransferService<R: AccountRepo>), তাই injection compile এর সময়েই check হয়, কোনো virtual call ছাড়া। Java তে আলাদা একটা copy() method।

Real project এ clean architecture

  • Folder এর নাম না, dependency এর নিয়ম দিয়ে শুরু করো। domain/, app/, adapters/ রাখতে পারো, বা প্রতিটা feature এর জন্য একটা package; আসল কথা হলো business code বাইরের কিছু import করে না।
  • Domain আর use case test করো I/O ছাড়া। একটা MemoryRepo (বা একটা নকল) আর আসল TransferService দিয়ে প্রতিটা rule millisecond এ test হয়। আসল Postgres repository র জন্য অল্প কয়েকটা integration test রাখো।
  • Transaction এর মালিক use case। "দুইটা account ই save, নয়তো একটাও না" ঠিক হয় application layer এ, সাধারণত একটা unit-of-work বা repository র ভেতর দিয়ে পাঠানো একটা transaction দিয়ে।
  • Handler পাতলা রাখো। Parse করো, একটা use case ডাকো, ফলটা বদলাও। Handler এ business rule নিয়ে কোনো if থাকলে rule টা ভুল জায়গায় আছে।
  • একটা settings screen এর জন্য চারটা layer বানিও না। সাধারণ CRUD feature সরাসরি repository র সাথে কথা বলতে পারে। পুরো structure ব্যবহার করো যেখানে rule সমৃদ্ধ: টাকা, order, booking।

যে design গুলো পরে problem create করে

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

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

type Account struct { ... } func (a *Account) Save(db *sql.DB)
ভাঙে: Domain এখন database এর উপর নির্ভরশীল। Test এ DB লাগে, আর schema বদলালে business code এ হাত পড়ে। Save করা repository র কাজ।

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

দৈনিক limit ঢাকার সময় রাত ১২টায় নতুন করে শুরু হবে। Account.Withdraw কে জানতে হবে "আজ" কোন দিন। সে কি system clock ডাকবে?

উত্তর দেখো

না। Domain এর বাইরের দিকে হাত বাড়ানো উচিত না, আর লুকানো একটা ঘড়ি থাকলে "রাত ১১:৫৯ তারপর ১২:০১" test করা অসম্ভব। আজকের তারিখ বাইরে থেকে দাও: Withdraw(amount, today), আর account শেষবার যেদিন টাকা পাঠিয়েছিল সেদিনের সাথে today না মিললে sentToday আবার শূন্য করবে। Application layer today পাবে একটা Clock port থেকে, যেটা production এ Asia/Dhaka র আসল ঘড়ি দিয়ে implement করা, আর test এ একটা নির্দিষ্ট তারিখ দিয়ে।

ছোট্ট quiz

১. Dependency এর নিয়মটা কী?

উত্তর

Source code এর dependency যায় শুধু ভেতরের দিকে: infrastructure নির্ভর করে application আর domain এর উপর, application domain এর উপর, আর domain নিজের বাইরের কিছুর উপর না।

২. AccountRepo interface কে ঘোষণা করে, আর কেন?

উত্তর

Application layer, নিজের ভাষায়। তাহলে database এর code application এর উপর নির্ভর করে (port implement করে), উল্টোটা না, আর application কে যেকোনো implementation দিয়ে test করা যায়।

৩. কোনো framework ছাড়া dependency injection মানে কী?

উত্তর

একটা object এর dependency গুলো তাকে নিজে বানাতে না দিয়ে বাইরে থেকে দেওয়া (সাধারণত constructor দিয়ে)। main আসল গুলো দেয়; test দেয় নকল গুলো।

৪. Domain transfer মেনে নেওয়ার পরেই কেন service save করে?

উত্তর

যাতে ফিরিয়ে দেওয়া একটা transfer কোনো অর্ধেক পরিবর্তন না রেখে যায়। Transaction এর সীমানা ঠিক করে use case: দুইটা account একসাথে save, নয়তো একটাও না।

তোমার challenge: verified user

Verified account এর দৈনিক limit হবে ৩০,০০০।

  • Account এ একটা Verified field যোগ করো; verified হলে Withdraw ৩০,০০০ ব্যবহার করবে, নইলে ২৫,০০০।
  • A-1 কে verified করো। TransferService বা repository বদলাবে না।

চালানোর আগে আন্দাজ করো প্রতিটা transfer এর ফল কী হবে।

হয়েছে বুঝবে যখন ৩০,০০০ এর transfer তবুও ফিরিয়ে দেওয়া হবে (৫,০০০ + ৩০,০০০ হলো ৩০,০০০ এর বেশি), message আসবে over daily limit 30000, ২০,০০০ এর transfer তবুও insufficient balance এ ফিরবে, বাকি লাইন গুলো একই থাকবে, আর edit হবে শুধু domain এ।

এক কথায়

Business rule রাখো এমন একটা domain এ যেটা কিছুই import করে না, use case গুলো নিজের ঘোষণা করা port দিয়ে সেগুলোর সমন্বয় করুক, database আর HTTP বাইরে থেকে ওই port গুলো implement করুক, আর dependency আসুক main থেকে। তাহলে প্রতিটা প্রবেশপথ একই rule মানে, আর rule গুলো কোনো infrastructure ছাড়াই test করা যায়।

Station stamp

Chapter শেষ করো

পড়া শেষ হলে Complete চাপো, progress save হবে। XP শুধু Pro তে — Pro থাকলে এই chapter এ +10 XP পেতে।

Pro নিয়ে XP পাও →