Part 01 · Low Level Design
Clean Architecture and Dependency Injection
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 নীতির উপর নির্ভর করে; নীতি কারো উপর নির্ভর করে না।
তবে একটা জায়গায় মেলে না। একটা ব্যাংক মোটা নীতির বই আর অনেক কর্মচারী রাখতে পারে। ছোট একটা 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
- Domain (কেন্দ্রে): entity আর value object, তাদের rule সহ:
Account.Withdrawদৈনিক limit আর balance check করে। Database, HTTP, framework বা ঘড়ি কোনো কিছুই import করে না। এটাই সেই code যেটা সবচেয়ে বেশি test করতে চাও, আর technical কারণে সবচেয়ে কম বদলাতে চাও। - Application (use case): একটা কাজের সমন্বয় করে:
TransferService.Transferদুইটা account load করে, domain কে withdraw আর deposit করতে বলে, তারপর save করে। তার যে port গুলো লাগে, যেমনAccountRepointerface, সেগুলো সে নিজের ভাষায় ঘোষণা করে। - Infrastructure (বাইরে): HTTP handler, database repository, message queue, email provider। এরা port গুলো implement করে (
PostgresRepo,MemoryRepo) বা use case গুলো ডাকে। - Dependency এর নিয়ম: source code এর dependency যায় শুধু ভেতরের দিকে। Infrastructure import করে application আর domain; application import করে domain; domain কিছুই import করে না। এটা chapter 6 এর dependency inversion, পুরো codebase এর উপর।
- Repository: aggregate load আর save করার জন্য collection এর মতো একটা interface (
Get(id),Save(account)), যেটা SQL, ORM আর cache লুকিয়ে রাখে। বাকি code row না, account নিয়ে ভাবে। - Dependency injection: object নিজের dependency নিজে না বানিয়ে বাইরে থেকে পায় (সাধারণত constructor এ)।
main(composition root) আসল গুলো বানায়; test বানায় নকল গুলো। কোনো framework লাগে না। - একটু DDD (Domain-Driven Design): জিনিসের নাম দাও business যেভাবে বলে (transfer, daily limit), আর প্রতিটা aggregate (নিজের rule সহ একটা account) কে নিজে নিজেই ঠিক রাখো। এই দুইটা idea ব্যবহার করতে পুরো DDD লাগে না।
Layer গুলোর ভেতর দিয়ে একটা transfer
আগে ৫,০০০ এর পরে Transfer(A-1 → A-2, ৩০,০০০)
ধাপ 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,AccountRepoport। Domain import করে। - বাইরে: infrastructure। HTTP, Postgres, queue,
main। মাঝের আর কেন্দ্রের দুটোই import করে।
একটা import যোগ করার আগে জিজ্ঞেস করো: এই তীর কি ভেতরের দিকে? Domain এর যদি বাইরের কিছু লাগে (সময়, exchange rate), application সেটা একটা value হিসেবে বা একটা port দিয়ে পাঠিয়ে দেবে।
নিজে বলো: এটা কোন layer এ যাবে?
ঠিক layer এ বসাও
Account.Withdraw checks balance
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())
}from __future__ import annotations
import copy
from typing import Protocol
# ===== Domain layer: business rules only. No database, no HTTP, no imports from other layers.
DAILY_LIMIT = 25000 # taka
class DomainError(Exception):
pass
class Account:
"""An entity (and a small aggregate): its rules live with its data."""
def __init__(self, id: str, balance: int):
self.id = id
self._balance = balance
self._sent_today = 0
def withdraw(self, amount: int) -> None:
if self._sent_today + amount > DAILY_LIMIT:
raise DomainError(f"over daily limit {DAILY_LIMIT}")
if amount > self._balance:
raise DomainError("insufficient balance")
self._balance -= amount
self._sent_today += amount
def deposit(self, amount: int) -> None:
self._balance += amount
@property
def balance(self) -> int:
return self._balance
# ===== Application layer: use cases. It owns the interface it needs (the "port").
class AccountRepo(Protocol):
def get(self, id: str) -> Account: ...
def save(self, a: Account) -> None: ...
class TransferService:
def __init__(self, repo: AccountRepo): # dependency injected, never created here
self.repo = repo
def transfer(self, frm: str, to: str, amount: int) -> None:
src, dst = self.repo.get(frm), self.repo.get(to)
src.withdraw(amount) # the rule lives in the domain
dst.deposit(amount)
self.repo.save(src)
self.repo.save(dst)
# ===== Infrastructure layer: one implementation of the port. Postgres would be another.
class MemoryRepo:
def __init__(self, accounts: list[Account]):
self._rows = {a.id: a for a in accounts}
def get(self, id: str) -> Account:
if id not in self._rows:
raise DomainError(f"account {id} not found")
return copy.deepcopy(self._rows[id]) # a copy: changes count only after save
def save(self, a: Account) -> None:
self._rows[a.id] = copy.deepcopy(a)
# ===== The composition root wires the layers together.
repo = MemoryRepo([Account("A-1", 20000), Account("A-2", 2000)])
svc = TransferService(repo)
for frm, to, amount in [("A-1", "A-2", 5000), ("A-1", "A-2", 30000), ("A-1", "A-2", 20000), ("A-1", "A-9", 500)]:
try:
svc.transfer(frm, to, amount)
print(f"transfer {amount:5d} {frm} -> {to}: ok")
except DomainError as e:
print(f"transfer {amount:5d} {frm} -> {to}: refused: {e}")
print(f"balances: A-1 {repo.get('A-1').balance}, A-2 {repo.get('A-2').balance}")// ===== Domain layer: business rules only. No database, no HTTP, no imports from other layers.
const DAILY_LIMIT = 25000; // taka
class DomainError extends Error {}
// An entity (and a small aggregate): its rules live with its data.
class Account {
readonly id: string;
#balance: number;
#sentToday: number;
constructor(id: string, balance: number, sentToday = 0) {
this.id = id;
this.#balance = balance;
this.#sentToday = sentToday;
}
withdraw(amount: number): void {
if (this.#sentToday + amount > DAILY_LIMIT) throw new DomainError(`over daily limit ${DAILY_LIMIT}`);
if (amount > this.#balance) throw new DomainError("insufficient balance");
this.#balance -= amount;
this.#sentToday += amount;
}
deposit(amount: number): void {
this.#balance += amount;
}
get balance(): number {
return this.#balance;
}
// Plain data for storage, so the repository never touches private fields.
snapshot(): { id: string; balance: number; sentToday: number } {
return { id: this.id, balance: this.#balance, sentToday: this.#sentToday };
}
}
// ===== Application layer: use cases. It owns the interface it needs (the "port").
interface AccountRepo {
get(id: string): Account;
save(a: Account): void;
}
class TransferService {
readonly repo: AccountRepo;
constructor(repo: AccountRepo) {
this.repo = repo; // dependency injected, never created here
}
transfer(from: string, to: string, amount: number): void {
const src = this.repo.get(from);
const dst = this.repo.get(to);
src.withdraw(amount); // the rule lives in the domain
dst.deposit(amount);
this.repo.save(src);
this.repo.save(dst);
}
}
// ===== Infrastructure layer: one implementation of the port. Postgres would be another.
class MemoryRepo implements AccountRepo {
readonly #rows = new Map<string, { id: string; balance: number; sentToday: number }>();
constructor(accounts: Account[]) {
for (const a of accounts) this.save(a);
}
get(id: string): Account {
const row = this.#rows.get(id);
if (!row) throw new DomainError(`account ${id} not found`);
return new Account(row.id, row.balance, row.sentToday); // a copy: changes count only after save
}
save(a: Account): void {
this.#rows.set(a.id, a.snapshot());
}
}
// ===== The composition root wires the layers together.
const repo = new MemoryRepo([new Account("A-1", 20000), new Account("A-2", 2000)]);
const svc = new TransferService(repo);
const cases: [string, string, number][] = [["A-1", "A-2", 5000], ["A-1", "A-2", 30000], ["A-1", "A-2", 20000], ["A-1", "A-9", 500]];
for (const [from, to, amount] of cases) {
try {
svc.transfer(from, to, amount);
console.log(`transfer ${String(amount).padStart(5)} ${from} -> ${to}: ok`);
} catch (e) {
console.log(`transfer ${String(amount).padStart(5)} ${from} -> ${to}: refused: ${(e as Error).message}`);
}
}
console.log(`balances: A-1 ${repo.get("A-1").balance}, A-2 ${repo.get("A-2").balance}`);use std::collections::HashMap;
// ===== Domain layer: business rules only. No database, no HTTP, no imports from other layers.
const DAILY_LIMIT: u32 = 25000; // taka
// An entity (and a small aggregate): its rules live with its data.
#[derive(Clone)]
struct Account { id: String, balance: u32, sent_today: u32 }
impl Account {
fn new(id: &str, balance: u32) -> Self { Account { id: id.into(), balance, sent_today: 0 } }
fn withdraw(&mut self, amount: u32) -> Result<(), String> {
if self.sent_today + amount > DAILY_LIMIT { return Err(format!("over daily limit {DAILY_LIMIT}")); }
if amount > self.balance { return Err("insufficient balance".into()); }
self.balance -= amount;
self.sent_today += amount;
Ok(())
}
fn deposit(&mut self, amount: u32) { self.balance += amount; }
fn balance(&self) -> u32 { self.balance }
}
// ===== Application layer: use cases. It owns the trait it needs (the "port").
trait AccountRepo {
fn get(&self, id: &str) -> Result<Account, String>;
fn save(&mut self, a: &Account) -> Result<(), String>;
}
// Generic over the repository: the dependency is injected, never created here.
struct TransferService<R: AccountRepo> { repo: R }
impl<R: AccountRepo> TransferService<R> {
fn transfer(&mut self, from: &str, to: &str, amount: u32) -> Result<(), String> {
let mut src = self.repo.get(from)?;
let mut dst = self.repo.get(to)?;
src.withdraw(amount)?; // the rule lives in the domain
dst.deposit(amount);
self.repo.save(&src)?;
self.repo.save(&dst)
}
}
// ===== Infrastructure layer: one implementation of the port. Postgres would be another.
struct MemoryRepo { rows: HashMap<String, Account> }
impl AccountRepo for MemoryRepo {
fn get(&self, id: &str) -> Result<Account, String> {
self.rows.get(id).cloned().ok_or(format!("account {id} not found")) // a copy
}
fn save(&mut self, a: &Account) -> Result<(), String> { self.rows.insert(a.id.clone(), a.clone()); Ok(()) }
}
// ===== main: the composition root wires the layers together.
fn main() {
let rows = [Account::new("A-1", 20000), Account::new("A-2", 2000)].into_iter().map(|a| (a.id.clone(), a)).collect();
let mut svc = TransferService { repo: MemoryRepo { rows } };
for (from, to, amount) in [("A-1", "A-2", 5000), ("A-1", "A-2", 30000), ("A-1", "A-2", 20000), ("A-1", "A-9", 500)] {
match svc.transfer(from, to, amount) {
Ok(()) => println!("transfer {amount:5} {from} -> {to}: ok"),
Err(e) => println!("transfer {amount:5} {from} -> {to}: refused: {e}"),
}
}
let b = |id| svc.repo.get(id).unwrap().balance();
println!("balances: A-1 {}, A-2 {}", b("A-1"), b("A-2"));
}import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class Main {
// ===== Domain layer: business rules only. No database, no HTTP, no imports from other layers.
static final int DAILY_LIMIT = 25000; // taka
static final class DomainException extends Exception {
private static final long serialVersionUID = 1L;
DomainException(String msg) { super(msg); }
}
// An entity (and a small aggregate): its rules live with its data.
static final class Account {
final String id;
private int balance, sentToday;
Account(String id, int balance, int sentToday) { this.id = id; this.balance = balance; this.sentToday = sentToday; }
void withdraw(int amount) throws DomainException {
if (sentToday + amount > DAILY_LIMIT) throw new DomainException("over daily limit " + DAILY_LIMIT);
if (amount > balance) throw new DomainException("insufficient balance");
balance -= amount;
sentToday += amount;
}
void deposit(int amount) { balance += amount; }
int balance() { return balance; }
Account copy() { return new Account(id, balance, sentToday); }
}
// ===== Application layer: use cases. It owns the interface it needs (the "port").
interface AccountRepo {
Account get(String id) throws DomainException;
void save(Account a);
}
static final class TransferService {
private final AccountRepo repo;
TransferService(AccountRepo repo) { this.repo = repo; } // dependency injected, never created here
void transfer(String from, String to, int amount) throws DomainException {
Account src = repo.get(from), dst = repo.get(to);
src.withdraw(amount); // the rule lives in the domain
dst.deposit(amount);
repo.save(src);
repo.save(dst);
}
}
// ===== Infrastructure layer: one implementation of the port. Postgres would be another.
static final class MemoryRepo implements AccountRepo {
private final Map<String, Account> rows = new HashMap<>();
MemoryRepo(List<Account> accounts) { accounts.forEach(this::save); }
public Account get(String id) throws DomainException {
Account a = rows.get(id);
if (a == null) throw new DomainException("account " + id + " not found");
return a.copy(); // a copy: changes count only after save
}
public void save(Account a) { rows.put(a.id, a.copy()); }
}
// ===== The composition root wires the layers together.
record Case(String from, String to, int amount) {}
public static void main(String[] args) throws DomainException {
MemoryRepo repo = new MemoryRepo(List.of(new Account("A-1", 20000, 0), new Account("A-2", 2000, 0)));
TransferService svc = new TransferService(repo);
for (Case c : List.of(new Case("A-1", "A-2", 5000), new Case("A-1", "A-2", 30000),
new Case("A-1", "A-2", 20000), new Case("A-1", "A-9", 500))) {
try {
svc.transfer(c.from(), c.to(), c.amount());
System.out.printf("transfer %5d %s -> %s: ok%n", c.amount(), c.from(), c.to());
} catch (DomainException e) {
System.out.printf("transfer %5d %s -> %s: refused: %s%n", c.amount(), c.from(), c.to(), e.getMessage());
}
}
System.out.printf("balances: A-1 %d, A-2 %d%n", repo.get("A-1").balance(), repo.get("A-2").balance());
}
}#include <cstdio>
#include <map>
#include <stdexcept>
#include <string>
#include <tuple>
#include <vector>
// ===== Domain layer: business rules only. No database, no HTTP, no includes from other layers.
constexpr int kDailyLimit = 25000; // taka
struct DomainError : std::runtime_error {
using std::runtime_error::runtime_error;
};
// An entity (and a small aggregate): its rules live with its data.
class Account {
public:
Account(std::string id, int balance) : id(std::move(id)), balance_(balance) {}
std::string id;
void withdraw(int amount) {
if (sentToday_ + amount > kDailyLimit) throw DomainError("over daily limit " + std::to_string(kDailyLimit));
if (amount > balance_) throw DomainError("insufficient balance");
balance_ -= amount;
sentToday_ += amount;
}
void deposit(int amount) { balance_ += amount; }
int balance() const { return balance_; }
private:
int balance_, sentToday_ = 0;
};
// ===== Application layer: use cases. It owns the interface it needs (the "port").
class AccountRepo {
public:
virtual ~AccountRepo() = default;
virtual Account get(const std::string& id) const = 0; // returns a copy
virtual void save(const Account& a) = 0;
};
class TransferService {
public:
explicit TransferService(AccountRepo& repo) : repo_(repo) {} // dependency injected, never created here
void transfer(const std::string& from, const std::string& to, int amount) {
Account src = repo_.get(from), dst = repo_.get(to);
src.withdraw(amount); // the rule lives in the domain
dst.deposit(amount);
repo_.save(src);
repo_.save(dst);
}
private:
AccountRepo& repo_;
};
// ===== Infrastructure layer: one implementation of the port. Postgres would be another.
class MemoryRepo : public AccountRepo {
public:
explicit MemoryRepo(std::vector<Account> accounts) {
for (auto& a : accounts) save(a);
}
Account get(const std::string& id) const override {
auto it = rows_.find(id);
if (it == rows_.end()) throw DomainError("account " + id + " not found");
return it->second; // a copy: changes count only after save
}
void save(const Account& a) override { rows_.insert_or_assign(a.id, a); }
private:
std::map<std::string, Account> rows_;
};
// ===== main: the composition root wires the layers together.
int main() {
MemoryRepo repo({Account("A-1", 20000), Account("A-2", 2000)});
TransferService svc(repo);
std::vector<std::tuple<std::string, std::string, int>> cases{
{"A-1", "A-2", 5000}, {"A-1", "A-2", 30000}, {"A-1", "A-2", 20000}, {"A-1", "A-9", 500}};
for (const auto& [from, to, amount] : cases) {
try {
svc.transfer(from, to, amount);
std::printf("transfer %5d %s -> %s: ok\n", amount, from.c_str(), to.c_str());
} catch (const DomainError& e) {
std::printf("transfer %5d %s -> %s: refused: %s\n", amount, from.c_str(), to.c_str(), e.what());
}
}
std::printf("balances: A-1 %d, A-2 %d\n", repo.get("A-1").balance(), repo.get("A-2").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 একটা সাধারণ datasnapshot()থেকে আবার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 করে
যে design টা পরে ভোগাবে
type Account struct { ... } func (a *Account) Save(db *sql.DB)একটু থেমে ভাবো
দৈনিক 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এ একটাVerifiedfield যোগ করো; 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 পাও →