Skip to content

Instantly share code, notes, and snippets.

@khandar-william
Last active September 30, 2026 13:02
Show Gist options
  • Select an option

  • Save khandar-william/f9369895a25b9bb4006ac9d61cf348bf to your computer and use it in GitHub Desktop.

Select an option

Save khandar-william/f9369895a25b9bb4006ac9d61cf348bf to your computer and use it in GitHub Desktop.

Clean Architecture

Source: Robert C. Martin, "The Clean Architecture" (2012). I wrote this from my knowledge of the article and did not fetch the page in this session. The code is my own illustration in Python and I did not run it.

The purpose

Clean Architecture divides software into layers. Each layer has one concern. The article lists five properties of a system that obeys the architecture:

  • Independent of frameworks. The framework is a tool. The system does not conform to the constraints of the framework.
  • Testable. You can test the business rules without the UI, the database, or the web server.
  • Independent of the UI. You can replace a web UI with a console UI, and the business rules do not change.
  • Independent of the database. You can replace one database with a different one, and the business rules do not change.
  • Independent of external agencies. The business rules know nothing about the outside world.

The four layers

The article shows the layers as concentric circles. The list goes from the innermost circle to the outermost circle.

Layer Contents Reason to change
Entities Business rules that apply to the full enterprise The business changes
Use cases Business rules that apply to one application The operation of the application changes
Interface adapters Controllers, presenters, gateways A data format changes
Frameworks and drivers Web framework, database, devices A tool changes

The number four is not a rule. A system can have more circles. The Dependency Rule always applies.

The Dependency Rule

Source code dependencies point only inward.

Code in an inner circle does not name anything that an outer circle declares. This includes functions, classes, variables, and data formats. An entity does not import a use case. A use case does not import a controller, an ORM model, or a framework.

Layer 1: Entity

The entity contains a business rule. It imports no other layer.

# domain/order.py
from dataclasses import dataclass


@dataclass
class Order:
    order_id: str
    total_cents: int
    paid: bool = False

    def pay(self, amount_cents: int) -> None:
        if self.paid:
            raise ValueError("The order is already paid.")
        if amount_cents != self.total_cents:
            raise ValueError("The amount must equal the order total.")
        self.paid = True

Layer 2: Use case

The use case controls the flow of data to and from the entities. It also declares the interfaces that it needs. The article calls these interfaces ports.

# use_cases/pay_order.py
from dataclasses import dataclass
from typing import Protocol

from domain.order import Order


class OrderRepository(Protocol):
    def get(self, order_id: str) -> Order: ...
    def save(self, order: Order) -> None: ...


class PayOrderOutputPort(Protocol):
    def present(self, response: "PayOrderResponse") -> None: ...


@dataclass(frozen=True)
class PayOrderRequest:
    order_id: str
    amount_cents: int


@dataclass(frozen=True)
class PayOrderResponse:
    order_id: str
    paid: bool


class PayOrder:
    def __init__(self, orders: OrderRepository, output: PayOrderOutputPort) -> None:
        self._orders = orders
        self._output = output

    def execute(self, request: PayOrderRequest) -> None:
        order = self._orders.get(request.order_id)
        order.pay(request.amount_cents)
        self._orders.save(order)
        self._output.present(PayOrderResponse(order.order_id, order.paid))

The import list contains only domain. The use case does not know SQL, HTTP, or JSON.

Layer 3: Interface adapters

An adapter converts data between the format of the use cases and the format of an external tool. All SQL stays in this layer.

# adapters/sql_order_repository.py
import sqlite3

from domain.order import Order


class SqlOrderRepository:
    def __init__(self, connection: sqlite3.Connection) -> None:
        self._connection = connection

    def get(self, order_id: str) -> Order:
        row = self._connection.execute(
            "SELECT id, total_cents, paid FROM orders WHERE id = ?", (order_id,)
        ).fetchone()
        return Order(order_id=row[0], total_cents=row[1], paid=bool(row[2]))

    def save(self, order: Order) -> None:
        self._connection.execute(
            "UPDATE orders SET paid = ? WHERE id = ?", (order.paid, order.order_id)
        )
# adapters/pay_order_web.py
from use_cases.pay_order import PayOrder, PayOrderRequest, PayOrderResponse


class JsonPayOrderPresenter:
    def __init__(self) -> None:
        self.view_model: dict = {}

    def present(self, response: PayOrderResponse) -> None:
        status = "paid" if response.paid else "unpaid"
        self.view_model = {"orderId": response.order_id, "status": status}


class PayOrderController:
    def __init__(self, use_case: PayOrder) -> None:
        self._use_case = use_case

    def handle(self, order_id: str, body: dict) -> None:
        self._use_case.execute(PayOrderRequest(order_id, body["amountCents"]))

Layer 4: Frameworks and drivers

This layer contains the framework and the code that assembles the objects. It is the only layer that knows all the other layers.

# main.py
import sqlite3

from fastapi import FastAPI

from adapters.pay_order_web import JsonPayOrderPresenter, PayOrderController
from adapters.sql_order_repository import SqlOrderRepository
from use_cases.pay_order import PayOrder

app = FastAPI()


@app.post("/orders/{order_id}/pay")
def pay_order(order_id: str, body: dict) -> dict:
    connection = sqlite3.connect("shop.db")
    presenter = JsonPayOrderPresenter()
    use_case = PayOrder(SqlOrderRepository(connection), presenter)
    PayOrderController(use_case).handle(order_id, body)
    connection.commit()
    return presenter.view_model

How control crosses a boundary

The flow of control and the direction of the dependencies are different.

  1. The controller calls the use case. Control goes inward, and the dependency points inward.
  2. The use case calls the presenter. Control goes outward.
  3. The use case cannot import the presenter, because the Dependency Rule prohibits that import.
  4. Thus the use case declares PayOrderOutputPort, and the presenter implements it.
  5. The source code dependency now points inward, against the flow of control.

This is the Dependency Inversion Principle. The repository uses the same method: the use case declares OrderRepository, and the adapter implements it.

What data crosses a boundary

Only simple data structures cross a boundary. PayOrderRequest and PayOrderResponse are examples.

Do not pass a database row or an ORM object inward. If you do, the inner circle must know the format of the outer circle. That breaks the Dependency Rule.

The effect on tests

A test of the business rules needs no database and no web server. The test supplies simple objects that satisfy the ports.

# tests/test_pay_order.py
from domain.order import Order
from use_cases.pay_order import PayOrder, PayOrderRequest


class InMemoryOrders:
    def __init__(self, order: Order) -> None:
        self._order = order

    def get(self, order_id: str) -> Order:
        return self._order

    def save(self, order: Order) -> None:
        self._order = order


class RecordingOutput:
    def present(self, response) -> None:
        self.response = response


def test_pay_order_marks_the_order_as_paid() -> None:
    output = RecordingOutput()
    use_case = PayOrder(InMemoryOrders(Order("o-1", 5000)), output)

    use_case.execute(PayOrderRequest("o-1", 5000))

    assert output.response.paid is True

Summary

  • The inner circles contain policy. The outer circles contain mechanisms.
  • Dependencies point inward only.
  • An inner circle declares an interface when it must call an outer circle.
  • Simple data structures cross each boundary.
  • You can replace the database, the framework, or the UI, and the business rules do not change.

/asd-ste100 is not an installed skill, so I applied the ASD-STE100 rules directly: short sentences, active voice, one topic per sentence. The source code is a small Python example that I wrote for this explanation. It is not GL Runner code.

Purpose

Clean Architecture divides software into layers. Each layer has one concern. The business rules stay independent of these items:

  • Frameworks
  • The user interface (UI)
  • The database
  • All other external systems

As a result, you can test the business rules without external parts. You can also replace an external part and not change the business rules.

The Dependency Rule

The reference shows the layers as concentric circles. The inner circles contain policies. The outer circles contain mechanisms.

Source code dependencies must point inward only.

  • Code in an inner circle must not use a name from an outer circle. This includes functions, classes, and variables.
  • Code in an inner circle must not use a data format from an outer circle.

The four layers

Layer (inner to outer) Contents Cause of a change
Entities General business rules A change to the business
Use cases Rules that are specific to the application A change to the operation of the application
Interface adapters Controllers, presenters, gateways; these convert data A change to an external format
Frameworks and drivers Web framework, database, glue code A change of tool

Four is not a mandatory number. You can use more circles. The Dependency Rule always applies.

Source code

Layer 1: Entity

The entity contains a general business rule. It imports nothing from the other layers.

# entities/account.py
from dataclasses import dataclass


@dataclass
class Account:
    account_id: str
    balance: int

    def withdraw(self, amount: int) -> None:
        if amount <= 0:
            raise ValueError("Amount must be more than zero.")
        if amount > self.balance:
            raise ValueError("Balance is not sufficient.")
        self.balance -= amount

Layer 2: Use case

The use case controls the flow of data to and from the entity. It also defines the ports. A port is an interface that an outer layer implements.

# use_cases/withdraw_money.py
from dataclasses import dataclass
from typing import Protocol

from entities.account import Account


@dataclass(frozen=True)
class WithdrawRequest:          # simple data that goes in
    account_id: str
    amount: int


@dataclass(frozen=True)
class WithdrawResponse:         # simple data that goes out
    account_id: str
    new_balance: int


class AccountGateway(Protocol):         # port for storage
    def get(self, account_id: str) -> Account: ...
    def save(self, account: Account) -> None: ...


class WithdrawOutputPort(Protocol):     # use case output port
    def present(self, response: WithdrawResponse) -> None: ...


class WithdrawMoney:
    def __init__(self, gateway: AccountGateway, output: WithdrawOutputPort) -> None:
        self._gateway = gateway
        self._output = output

    def execute(self, request: WithdrawRequest) -> None:
        account = self._gateway.get(request.account_id)
        account.withdraw(request.amount)
        self._gateway.save(account)
        self._output.present(WithdrawResponse(account.account_id, account.balance))

This file imports only from entities. It does not contain SQL, HTTP, or JSON.

Layer 3: Interface adapters

The adapters convert data between the inner format and the external format. All SQL stays in this layer.

# adapters/sqlite_account_gateway.py
import sqlite3

from entities.account import Account


class SqliteAccountGateway:
    def __init__(self, connection: sqlite3.Connection) -> None:
        self._connection = connection

    def get(self, account_id: str) -> Account:
        row = self._connection.execute(
            "SELECT id, balance FROM accounts WHERE id = ?", (account_id,)
        ).fetchone()
        return Account(account_id=row[0], balance=row[1])   # the row stops here

    def save(self, account: Account) -> None:
        self._connection.execute(
            "UPDATE accounts SET balance = ? WHERE id = ?",
            (account.balance, account.account_id),
        )
        self._connection.commit()
# adapters/withdraw_web.py
from use_cases.withdraw_money import WithdrawMoney, WithdrawRequest, WithdrawResponse


class WithdrawPresenter:                 # implements WithdrawOutputPort
    def __init__(self) -> None:
        self.view_model: dict = {}

    def present(self, response: WithdrawResponse) -> None:
        self.view_model = {
            "accountId": response.account_id,
            "balance": f"{response.new_balance:,}",
        }


class WithdrawController:
    def __init__(self, use_case: WithdrawMoney) -> None:
        self._use_case = use_case

    def handle(self, account_id: str, body: dict) -> None:
        self._use_case.execute(WithdrawRequest(account_id, int(body["amount"])))

Layer 4: Frameworks and drivers

This layer contains only glue code. It makes the objects and connects them.

# main.py
import sqlite3

from fastapi import FastAPI

from adapters.sqlite_account_gateway import SqliteAccountGateway
from adapters.withdraw_web import WithdrawController, WithdrawPresenter
from use_cases.withdraw_money import WithdrawMoney

app = FastAPI()
connection = sqlite3.connect("bank.db", check_same_thread=False)


@app.post("/accounts/{account_id}/withdraw")
def withdraw(account_id: str, body: dict) -> dict:
    presenter = WithdrawPresenter()
    use_case = WithdrawMoney(SqliteAccountGateway(connection), presenter)
    WithdrawController(use_case).handle(account_id, body)
    return presenter.view_model

How the code crosses a boundary

The flow of control goes in this sequence: controller, use case, presenter.

The use case must call the presenter. But the use case must not import the presenter, because the presenter is in an outer circle. The Dependency Inversion Principle solves this problem:

  1. The use case layer declares WithdrawOutputPort.
  2. The use case calls self._output.present(...).
  3. The presenter in the adapter layer implements the port.

Thus the import points inward, although the call goes outward. AccountGateway uses the same method for the database.

Flow of control:   Controller --> WithdrawMoney --> WithdrawPresenter
Imports:           Controller --> WithdrawMoney <-- WithdrawPresenter

Data that crosses a boundary

Only simple data structures cross a boundary. In the example, these are WithdrawRequest and WithdrawResponse.

  • Do not pass a database row inward. SqliteAccountGateway.get() converts the row before it returns.
  • Do not pass an entity outward to the UI. The presenter receives WithdrawResponse, not Account.

The data always has the format that is most convenient for the inner circle.

Test without external parts

The business rules operate without a web server and without a database. This test uses objects in memory only.

# tests/test_withdraw_money.py
from entities.account import Account
from use_cases.withdraw_money import WithdrawMoney, WithdrawRequest


class MemoryGateway:
    def __init__(self, account: Account) -> None:
        self.account = account

    def get(self, account_id: str) -> Account:
        return self.account

    def save(self, account: Account) -> None:
        self.account = account


class RecordOutput:
    def present(self, response) -> None:
        self.response = response


def test_withdraw_decreases_the_balance() -> None:
    gateway = MemoryGateway(Account("A-1", 100))
    output = RecordOutput()

    WithdrawMoney(gateway, output).execute(WithdrawRequest("A-1", 30))

    assert output.response.new_balance == 70

Result

  • To replace SQLite with a different database, write a new gateway. Do not change entities or use_cases.
  • To replace FastAPI with a console UI, write a new controller and a new presenter. Do not change entities or use_cases.

I did not run the example code. If you want, I can show the same layers with real GL Runner modules (api/v1/routes, services, models).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment