Part 01 · Low Level Design
Clean Architecture and Dependency Injection
Where the business rules live and what they may depend on: dependency injection, the repository, clean architecture layers, the useful bits of DDD, and designs that are easy to test.
⏱ 2-3 hours · Station 11 of 80
The rule that lived in three places
A mobile wallet has a rule: no account may send more than 25,000 taka a day. In the first version, the HTTP handler for transfers checks it, using a SQL query that sums today's transfers. The bulk-payment job written later checks it again with its own query. The admin tool doesn't check it at all.
Then the limit changes to 30,000 for verified users. Three places to edit, two SQL queries to rewrite, and no way to test the rule without a real database. A tester finds that the admin tool lets anyone send 1 lakh.
The rule wasn't hard. It was just mixed in with HTTP and SQL, so each new entry point had to rebuild it. Clean architecture is a way of arranging code so the rules sit in the middle, depend on nothing, and every entry point goes through them.
The problem: business rules tangled with frameworks
| Symptom | Cause |
|---|---|
| Each new entry point (API, job, admin tool) re-implements the rules | Rules live in handlers, not in a shared core |
| Unit tests need a database and an HTTP server | Rules call SQL and read requests directly |
| Switching database or framework touches business code | Business code imports the framework |
| Nobody can find "the transfer rules" in one place | They're spread across queries and handlers |
Think of a bank branch
In a bank branch, the rules ("max 25,000 a day", "no overdraft") are in the bank's policy book. Tellers at the counter, the ATM outside and the mobile app are just different doors to the same policy. The vault and ledger in the back store the money and records.
The policy book doesn't say "if the customer came through the ATM, then…". It doesn't mention which brand of safe is in the vault. Doors and storage depend on the policy; the policy depends on nothing.
Where the comparison breaks down. A bank can afford a thick policy book and many clerks. A small app can drown in layers: four folders and six interfaces for a CRUD screen. Use the full structure where the rules are rich and long-lived; keep it light for simple screens.
The 25,000 daily limit lives in Account.Withdraw (domain). The API, a bulk-payment job and the admin tool all call TransferService.Transfer. The limit becomes 30,000 for verified users. Where do you change code?
Layers, ports and injection
- Domain (the centre): entities and value objects with their rules:
Account.Withdrawchecks the daily limit and balance. No imports of databases, HTTP, frameworks or the clock. This is the code you most want to test and least want to change for technical reasons. - Application (use cases): orchestrates one action:
TransferService.Transferloads two accounts, asks the domain to withdraw and deposit, and saves. It defines the ports it needs, like theAccountRepointerface, in its own terms. - Infrastructure (the outside): HTTP handlers, database repositories, message queues, email providers. They implement the ports (
PostgresRepo,MemoryRepo) or call the use cases. - The dependency rule: source-code dependencies point inward only. Infrastructure imports application and domain; application imports domain; domain imports nothing. That's dependency inversion (chapter 6) applied to a whole codebase.
- Repository: a collection-like interface for loading and saving aggregates (
Get(id),Save(account)), hiding SQL, ORMs and caches. The rest of the code thinks in accounts, not in rows. - Dependency injection: objects receive their dependencies (usually in the constructor) instead of creating them.
main(the composition root) builds the real ones; tests build fakes. No framework is required. - A little DDD (Domain-Driven Design): name things the way the business does (transfer, daily limit), and keep each aggregate (an account with its rules) consistent on its own. You don't need all of DDD to use these two ideas.
One transfer through the layers
Transfer(A-1 → A-2, 30,000) after an earlier 5,000
Step 1/5 The handler only translates
It reads JSON, calls svc.Transfer("A-1", "A-2", 30000), and turns the result into an HTTP status. No rules here.
- request
- A-1 -> A-2, 30000
Which way do the arrows point?
Picture three rings:
- Centre: domain.
Account,Money, rules. Imports nothing. - Middle: application.
TransferService, theAccountRepoport. Imports the domain. - Outside: infrastructure. HTTP, Postgres, queues,
main. Imports the middle and the centre.
Before adding an import, ask: is this arrow pointing inward? If the domain wants something from outside (the time, an exchange rate), the application passes it in as a value or through a port.
Try it: which layer does this belong to?
Put it in the right layer
Account.Withdraw checks balance
Transfers through clean layers in code
The domain (Account with its rules), the application (TransferService and the AccountRepo port), the infrastructure (MemoryRepo), and main wiring them together by 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());
}All six print exactly this:
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
Reading the output:
- 5,000: within the limit and the balance, so it goes through and both accounts are saved.
- 30,000: 5,000 + 30,000 is over the 25,000 daily limit. The domain refuses; nothing is saved.
- 20,000: within the limit (5,000 + 20,000 = 25,000) but A-1 only has 15,000 left. Refused by the balance rule.
- A-9: the repository can't find it, so the use case stops before touching any money.
- Balances 15,000 and 7,000: only the first transfer changed anything, because the repository hands out copies and the service saves only after every rule has passed.
- What each language changed: Go and C++ copy the account by value when loading. Python uses
copy.deepcopy; TypeScript rebuilds anAccountfrom a plain-datasnapshot()so the repository never touches private#fields. Rust makes the service generic over the repository (TransferService<R: AccountRepo>), so injection is checked at compile time with no virtual calls. Java uses an explicitcopy()method.
Clean architecture on a real project
- Start with the dependency rule, not the folder names. You can have
domain/,app/andadapters/, or one package per feature; what matters is that business code imports nothing outward. - Test the domain and the use cases without I/O. A
MemoryRepo(or a fake) and the realTransferServicelet you test every rule in milliseconds. Keep a smaller set of integration tests for the real Postgres repository. - Transactions belong to the use case. "Save both accounts or neither" is decided in the application layer, typically with a unit-of-work or a transaction passed through the repository.
- Keep handlers thin. Parse, call one use case, map the result. If a handler has an
ifabout business rules, the rule is in the wrong place. - Don't build four layers for a settings screen. A simple CRUD feature can talk to a repository directly. Use the full structure where rules are rich: money, orders, bookings.
Designs that cause problems later
Pick the design that will hurt
type Account struct { ... } func (a *Account) Save(db *sql.DB)Pause and think
The daily limit should reset at midnight Dhaka time. Account.Withdraw needs to know "today". Should it call the system clock?
Reveal
No. The domain shouldn't reach outward, and a hidden clock makes "11:59 p.m. then 12:01 a.m." impossible to test. Pass the current date in: Withdraw(amount, today), with the account resetting sentToday when today differs from the day it last sent money. The application layer gets today from a Clock port, which production implements with the real clock in Asia/Dhaka and tests implement with a fixed date.
Quick quiz
1. What is the dependency rule?
Answer
Source-code dependencies point only inward: infrastructure depends on application and domain, application on domain, and the domain on nothing outside itself.
2. Who defines the AccountRepo interface, and why?
Answer
The application layer, in its own terms. Then the database code depends on the application (by implementing the port), not the other way round, and the application can be tested with any implementation.
3. What is dependency injection, without any framework?
Answer
Passing an object's dependencies to it (usually through the constructor) instead of letting it create them. main passes real ones; tests pass fakes.
4. Why does the service save only after the domain accepts the transfer?
Answer
So a refused transfer leaves no partial changes. The use case decides the transaction boundary: both accounts are saved together, or neither.
Your challenge: verified users
Verified accounts get a 30,000 daily limit.
- Add a
Verifiedfield toAccount;Withdrawuses 30,000 for verified accounts and 25,000 otherwise. - Make A-1 verified. Don't change
TransferServiceor the repository.
Before you run it, predict each transfer's result.
You've succeeded when the 30,000 transfer is still refused (5,000 + 30,000 is over 30,000), with the message over daily limit 30000, the 20,000 transfer is still refused for insufficient balance, the other lines are unchanged, and the only edits are in the domain.
In one line
Put the business rules in a domain that imports nothing, let use cases coordinate them through ports they define, keep databases and HTTP on the outside implementing those ports, and pass dependencies in from
main. Then every entry point shares the same rules, and the rules can be tested without any infrastructure.
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 →