Part 01 · Low Level Design

Clean Architecture and Dependency Injection

Dependency InjectionRepositoryClean ArchitectureDDD basicsTestability

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

SymptomCause
Each new entry point (API, job, admin tool) re-implements the rulesRules live in handlers, not in a shared core
Unit tests need a database and an HTTP serverRules call SQL and read requests directly
Switching database or framework touches business codeBusiness code imports the framework
Nobody can find "the transfer rules" in one placeThey'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.

implementsHTTP handlerinfrastructureTransferServiceapplicationAccountdomain rulesAccountRepoport (interface)PostgresRepoinfrastructureTransfer()Withdraw: limit + balanceSave()SQL?every arrow points inward: the domain imports nothingdomain reaching for the database: not allowed
Press play. A transfer comes in through the HTTP handler, the service asks the Account for the rules (limit and balance), then saves through the AccountRepo port, which Postgres implements. Every dependency points inward; when the domain tries to reach for SQL, it's refused.

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.

Predict first

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

  1. Domain (the centre): entities and value objects with their rules: Account.Withdraw checks 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.
  2. Application (use cases): orchestrates one action: TransferService.Transfer loads two accounts, asks the domain to withdraw and deposit, and saves. It defines the ports it needs, like the AccountRepo interface, in its own terms.
  3. Infrastructure (the outside): HTTP handlers, database repositories, message queues, email providers. They implement the ports (PostgresRepo, MemoryRepo) or call the use cases.
  4. 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.
  5. 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.
  6. 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.
  7. 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

Step through it

Transfer(A-1 → A-2, 30,000) after an earlier 5,000

HTTP handlerTransferServiceAccount (domain)AccountRepo

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, the AccountRepo port. 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?

Try each case

Put it in the right layer

Account.Withdraw checks balance
works: Domain. A business rule about the account's own data.

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

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 an Account from a plain-data snapshot() 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 explicit copy() method.

Clean architecture on a real project

  • Start with the dependency rule, not the folder names. You can have domain/, app/ and adapters/, 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 real TransferService let 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 if about 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

Try each case

Pick the design that will hurt

type Account struct { ... } func (a *Account) Save(db *sql.DB)
breaks: The domain now depends on the database. Tests need a DB and a schema change touches business code. Saving belongs to a repository.

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 Verified field to Account; Withdraw uses 30,000 for verified accounts and 25,000 otherwise.
  • Make A-1 verified. Don't change TransferService or 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 →