クリーンアーキテクチャとは、業務ルールを中心に置き、ソースコードの依存をすべて内側(業務ルール)へ向ける構成のことです。 Robert C. Martin 氏が2012年の記事で示した同心円の図で知られています。

この記事は、円の名前を覚えることよりも依存の向きをコードでどう作るかに絞ります。同じ「備品の予約」処理を、依存性逆転を適用する前と後でPythonとTypeScriptの両方で書き、保存先を差し替えたテストが通るところまでを実行結果つきで示します。

確認環境:Ubuntu 24.04 / Python 3.11.15(pytest 9.1.1、mypy 2.3.1、標準ライブラリの sqlite3)/ Node.js 22.22.2(node:test、node:sqlite)/ TypeScript 7.0.2。図は @mermaid-js/mermaid-cli 12.0.0 で描画できることを確認。確認日 2026-10-06。

この記事の結論

  • 覚えるのは円の数ではなく、「依存を内側(業務ルール)へ向ける」の1点だけです。 元の記事も、円は4つと決まっているわけではないと書いています。
  • 向きを逆にする道具はインターフェースです。内側が「してほしいこと」を宣言し、外側がそれを実装します(依存性逆転)。
  • 具体的なクラスを選ぶ場所は、いちばん外側の組み立て役(main)1か所にします。
  • こうすると、業務の手順をデータベースなしでテストでき、保存先の差し替えが組み立て役の1行で済みます。
  • 画面数が少なくCRUD中心なら、層を減らしてよい構成です。判断表を後半に載せています。

クリーンアーキテクチャの層と置くものの早見表

内側から順に並べています。フォルダー名は、この記事のコードで使う名前です。

層(元の記事の名前) フォルダー 責務 置くもの 置かないもの import してよい相手
Entities entities 業務ルールそのもの 値の検査、計算、状態の変化(例:予約期間の重なり判定) SQL、HTTP、フレームワークの型、設定値の読み込み 標準ライブラリだけ
Use Cases usecases 1つの操作の手順 ユースケースのクラス、ポート(インターフェース)、入出力のデータ型 具体的なDBクラス、リクエストやレスポンスの型、画面の文言の組み立て entities
Interface Adapters adapters 内側と外側の形の変換 リポジトリの実装、コントローラー、プレゼンター、外部APIのクライアント 業務ルールの判定(重なり判定などを SQL に書き込むこと) usecases、entities
Frameworks and Drivers main、設定、起動 部品の組み立てと起動 接続の作成、具体的なクラスの選択、ルーティングの登録 業務の手順や判定 すべて

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「クリーンアーキテクチャとは|依存の向きとディレクトリ構成例」

依存の向きをMermaidで描く

矢印は「import する側 → される側」です。点線は「インターフェースを実装する」関係を表します。

flowchart LR
    subgraph outer["Frameworks and Drivers(main・DB・Web フレームワーク)"]
        MAIN["main<br>組み立て役"]
        subgraph adapters["Interface Adapters(adapters)"]
            CTRL["コントローラー"]
            REPO["SqliteReservationRepository<br>InMemoryReservationRepository"]
            subgraph usecases["Use Cases(usecases)"]
                UC["ReserveEquipment"]
                PORT["ReservationRepository<br>ポート(インターフェース)"]
                subgraph entities["Entities(entities)"]
                    ENT["Reservation<br>業務ルール"]
                end
            end
        end
    end
    CTRL --> UC
    UC --> PORT
    UC --> ENT
    REPO -. 実装する .-> PORT
    REPO --> ENT
    MAIN --> CTRL
    MAIN --> REPO
    MAIN --> UC

実線の矢印はすべて、同じ枠の中か、より内側の枠へ向かっています。 外へ出ていく矢印が1本でもあれば、依存性のルールから外れています。

実行時の呼び出しの流れは、依存の向きとは別です。リポジトリは外側にありますが、ユースケースから呼び出されます。

sequenceDiagram
    participant C as コントローラー(adapters)
    participant U as ReserveEquipment(usecases)
    participant E as Reservation(entities)
    participant R as SqliteReservationRepository(adapters)
    C->>U: execute(利用者, 備品, 開始, 終了)
    U->>E: 予約を作る(期間の検査)
    U->>R: find_by_equipment(備品)
    R-->>U: 既存の予約
    U->>E: overlaps(既存の予約)
    U->>R: add(予約)
    R-->>U: 予約番号
    U-->>C: 予約番号

呼び出しは内側から外側へ進むのに、import は外側から内側へ向く。この「呼ぶ向き」と「依存する向き」のずれを作るのが依存性逆転です。

依存性逆転を適用する前のコード

題材は社内の備品予約です。決まりは2つで、「開始は終了より前」「同じ備品で時間が重なる予約は受け付けない」です。

Python(適用前)

# before/reserve.py … 手順・業務ルール・SQL が1つの関数に入っている
import sqlite3
from datetime import datetime

def reserve_equipment(db_path: str, user_id: int, equipment_id: int,
                      start: datetime, end: datetime) -> int:
    if start >= end:
        raise ValueError("開始は終了より前にしてください")
    con = sqlite3.connect(db_path)                       # ← 業務の手順が DB の種類を知っている
    try:
        row = con.execute(
            "SELECT COUNT(*) FROM reservations"
            " WHERE equipment_id = ? AND start_at < ? AND end_at > ?",
            (equipment_id, end.isoformat(), start.isoformat())).fetchone()
        if row[0] > 0:                                   # ← 「重なりは不可」という業務ルールが SQL の中にある
            raise ValueError("その時間帯は予約済みです")
        cur = con.execute(
            "INSERT INTO reservations (user_id, equipment_id, start_at, end_at) VALUES (?, ?, ?, ?)",
            (user_id, equipment_id, start.isoformat(), end.isoformat()))
        con.commit()
        return cur.lastrowid
    finally:
        con.close()
# before/test_reserve.py … テストのたびに DB ファイルとテーブルを用意する
import sqlite3
from datetime import datetime
import pytest
from reserve import reserve_equipment

@pytest.fixture
def db_path(tmp_path):
    path = str(tmp_path / "test.db")
    con = sqlite3.connect(path)
    con.execute("CREATE TABLE reservations (id INTEGER PRIMARY KEY, user_id INT, equipment_id INT, start_at TEXT, end_at TEXT)")
    con.commit(); con.close()
    return path

def test_重なる予約は受け付けない(db_path):
    reserve_equipment(db_path, 1, 10, datetime(2026, 10, 6, 10), datetime(2026, 10, 6, 12))
    with pytest.raises(ValueError, match="予約済み"):
        reserve_equipment(db_path, 2, 10, datetime(2026, 10, 6, 11), datetime(2026, 10, 6, 13))
$ python3 -m pytest -q
.                                                                        [100%]
1 passed in 0.01s

TypeScript(適用前)

// before/reserve.ts … 手順・業務ルール・SQL が1つの関数に入っている
import { DatabaseSync } from "node:sqlite";

export function reserveEquipment(dbPath: string, userId: number, equipmentId: number,
                                 start: Date, end: Date): number {
  if (start.getTime() >= end.getTime()) {
    throw new Error("開始は終了より前にしてください");
  }
  const db = new DatabaseSync(dbPath);                    // ← 業務の手順が DB の種類を知っている
  try {
    const row = db
      .prepare("SELECT COUNT(*) AS n FROM reservations WHERE equipment_id = ? AND start_at < ? AND end_at > ?")
      .get(equipmentId, end.toISOString(), start.toISOString());
    if (Number(row?.n) > 0) {                             // ← 「重なりは不可」という業務ルールが SQL の中にある
      throw new Error("その時間帯は予約済みです");
    }
    const result = db
      .prepare("INSERT INTO reservations (user_id, equipment_id, start_at, end_at) VALUES (?, ?, ?, ?)")
      .run(userId, equipmentId, start.toISOString(), end.toISOString());
    return Number(result.lastInsertRowid);
  } finally {
    db.close();
  }
}

テーブルを作ってから2回呼んだ結果です。

予約番号: 1
受け付けなかった予約: その時間帯は予約済みです

どちらも正しく動きます。気になるのは次の3点です。

気になる点 具体的に起きること
業務ルールがSQLの中にある 「重なりの判定」を変えるにはSQLを読み解く必要がある
テストにDBが要る テストのたびにファイルとテーブルを用意する。DBの種類を変えるとテストも書き直す
手順がDBの種類を知っている 保存先を変えると、業務の手順が書かれたファイルを編集することになる

依存性逆転を適用した後のコード(Python)

内側:業務ルール(entities)

# entities/reservation.py … 業務ルール。標準ライブラリ以外を import しない
from dataclasses import dataclass
from datetime import datetime

class ReservationError(Exception):
    pass

@dataclass(frozen=True)
class Reservation:
    user_id: int
    equipment_id: int
    start: datetime
    end: datetime

    def __post_init__(self):
        if self.start >= self.end:
            raise ReservationError("開始は終了より前にしてください")

    def overlaps(self, other: "Reservation") -> bool:
        return (self.equipment_id == other.equipment_id
                and self.start < other.end and other.start < self.end)

重なりの判定がSQLから出て、名前の付いたメソッドになりました。

内側:ポートと手順(usecases)

# usecases/ports.py … 内側が「外側にしてほしいこと」を宣言する(ポート)
from typing import Protocol
from reservation.entities.reservation import Reservation

class ReservationRepository(Protocol):
    def find_by_equipment(self, equipment_id: int) -> list[Reservation]: ...
    def add(self, reservation: Reservation) -> int: ...
# usecases/reserve_equipment.py … 手順。知っているのは entities と ports だけ
from datetime import datetime
from reservation.entities.reservation import Reservation, ReservationError
from reservation.usecases.ports import ReservationRepository

class ReserveEquipment:
    def __init__(self, repo: ReservationRepository):
        self._repo = repo                                # 具体的なクラスは外から受け取る

    def execute(self, user_id: int, equipment_id: int, start: datetime, end: datetime) -> int:
        new = Reservation(user_id, equipment_id, start, end)
        for existing in self._repo.find_by_equipment(equipment_id):
            if new.overlaps(existing):
                raise ReservationError("その時間帯は予約済みです")
        return self._repo.add(new)

インターフェース(ポート)を、使う側である usecases の中に置くのが要点です。実装する側に置くと、依存の向きは逆になりません。

外側:保存の実装(adapters)

# adapters/sqlite_repository.py … SQLite に保存する実装。SQL はここだけに書く
import sqlite3
from datetime import datetime
from reservation.entities.reservation import Reservation

class SqliteReservationRepository:
    def __init__(self, con: sqlite3.Connection):
        self._con = con
        con.execute("CREATE TABLE IF NOT EXISTS reservations ("
                    "id INTEGER PRIMARY KEY, user_id INT, equipment_id INT, start_at TEXT, end_at TEXT)")

    def find_by_equipment(self, equipment_id: int) -> list[Reservation]:
        rows = self._con.execute(
            "SELECT user_id, equipment_id, start_at, end_at FROM reservations WHERE equipment_id = ?",
            (equipment_id,)).fetchall()
        return [Reservation(u, e, datetime.fromisoformat(s), datetime.fromisoformat(t)) for u, e, s, t in rows]

    def add(self, reservation: Reservation) -> int:
        cur = self._con.execute(
            "INSERT INTO reservations (user_id, equipment_id, start_at, end_at) VALUES (?, ?, ?, ?)",
            (reservation.user_id, reservation.equipment_id,
             reservation.start.isoformat(), reservation.end.isoformat()))
        self._con.commit()
        return cur.lastrowid
# adapters/memory_repository.py … リストに保存する実装(テストや試作用)
from reservation.entities.reservation import Reservation

class InMemoryReservationRepository:
    def __init__(self):
        self._rows: list[Reservation] = []

    def find_by_equipment(self, equipment_id: int) -> list[Reservation]:
        return [r for r in self._rows if r.equipment_id == equipment_id]

    def add(self, reservation: Reservation) -> int:
        self._rows.append(reservation)
        return len(self._rows)

いちばん外側:組み立て役(main)

# main.py … 組み立て役。具体的なクラスを選ぶのはここだけ
import sqlite3, sys
from datetime import datetime
from reservation.adapters.memory_repository import InMemoryReservationRepository
from reservation.adapters.sqlite_repository import SqliteReservationRepository
from reservation.entities.reservation import ReservationError
from reservation.usecases.reserve_equipment import ReserveEquipment

repo = (InMemoryReservationRepository() if "--memory" in sys.argv
        else SqliteReservationRepository(sqlite3.connect(":memory:")))
usecase = ReserveEquipment(repo)
print("保存先:", type(repo).__name__)
print("予約番号:", usecase.execute(1, 10, datetime(2026, 10, 6, 10), datetime(2026, 10, 6, 12)))
try:
    usecase.execute(2, 10, datetime(2026, 10, 6, 11), datetime(2026, 10, 6, 13))
except ReservationError as e:
    print("受け付けなかった予約:", e)
print("予約番号:", usecase.execute(2, 10, datetime(2026, 10, 6, 12), datetime(2026, 10, 6, 13)))
$ python3 main.py
保存先: SqliteReservationRepository
予約番号: 1
受け付けなかった予約: その時間帯は予約済みです
予約番号: 2
$ python3 main.py --memory
保存先: InMemoryReservationRepository
予約番号: 1
受け付けなかった予約: その時間帯は予約済みです
予約番号: 2

保存先を変えても、結果は同じです。書き換えたのは main の1か所だけで、entities と usecases には触れていません。

保存先を差し替えたテスト

同じ5つのテストを、メモリとSQLiteの両方で実行します。

# tests/test_reserve_equipment.py … 同じテストを、保存先を差し替えて2回ずつ実行する
import sqlite3
from datetime import datetime
import pytest
from reservation.adapters.memory_repository import InMemoryReservationRepository
from reservation.adapters.sqlite_repository import SqliteReservationRepository
from reservation.entities.reservation import ReservationError
from reservation.usecases.reserve_equipment import ReserveEquipment

@pytest.fixture(params=["memory", "sqlite"])
def usecase(request):
    if request.param == "memory":
        repo = InMemoryReservationRepository()
    else:
        repo = SqliteReservationRepository(sqlite3.connect(":memory:"))
    return ReserveEquipment(repo)

def at(hour):
    return datetime(2026, 10, 6, hour)

def test_空いていれば予約できる(usecase):
    assert usecase.execute(1, 10, at(10), at(12)) == 1

def test_重なる予約は受け付けない(usecase):
    usecase.execute(1, 10, at(10), at(12))
    with pytest.raises(ReservationError, match="予約済み"):
        usecase.execute(2, 10, at(11), at(13))

def test_終了と同時に始まる予約は受け付ける(usecase):
    usecase.execute(1, 10, at(10), at(12))
    assert usecase.execute(2, 10, at(12), at(13)) == 2

def test_別の備品なら同じ時間でも予約できる(usecase):
    usecase.execute(1, 10, at(10), at(12))
    assert usecase.execute(2, 11, at(10), at(12)) == 2

def test_開始が終了より後ならエラー(usecase):
    with pytest.raises(ReservationError, match="開始は終了より前"):
        usecase.execute(1, 10, at(12), at(10))
$ python3 -m pytest -v
collected 10 items

tests/test_reserve_equipment.py::test_空いていれば予約できる[memory] PASSED [ 10%]
tests/test_reserve_equipment.py::test_空いていれば予約できる[sqlite] PASSED [ 20%]
tests/test_reserve_equipment.py::test_重なる予約は受け付けない[memory] PASSED [ 30%]
tests/test_reserve_equipment.py::test_重なる予約は受け付けない[sqlite] PASSED [ 40%]
tests/test_reserve_equipment.py::test_終了と同時に始まる予約は受け付ける[memory] PASSED [ 50%]
tests/test_reserve_equipment.py::test_終了と同時に始まる予約は受け付ける[sqlite] PASSED [ 60%]
tests/test_reserve_equipment.py::test_別の備品なら同じ時間でも予約できる[memory] PASSED [ 70%]
tests/test_reserve_equipment.py::test_別の備品なら同じ時間でも予約できる[sqlite] PASSED [ 80%]
tests/test_reserve_equipment.py::test_開始が終了より後ならエラー[memory] PASSED [ 90%]
tests/test_reserve_equipment.py::test_開始が終了より後ならエラー[sqlite] PASSED [100%]

============================== 10 passed in 0.03s ==============================

手順のテストは memory 側だけで足り、sqlite 側は「実装がポートの約束どおりに動くか」の確認になります。 保存先を別の製品に変えるときは、fixture に1行足せば同じテストがそのまま使えます。

依存性逆転を適用した後のコード(TypeScript)

構成はPythonと同じです。import type を使うと、型だけを取り込んでいることがはっきりします。

// entities/reservation.ts … 業務ルール。ほかの層を import しない
export class ReservationError extends Error {}

export type Reservation = Readonly<{
  userId: number;
  equipmentId: number;
  start: Date;
  end: Date;
}>;

export function createReservation(userId: number, equipmentId: number, start: Date, end: Date): Reservation {
  if (start.getTime() >= end.getTime()) {
    throw new ReservationError("開始は終了より前にしてください");
  }
  return { userId, equipmentId, start, end };
}

export function overlaps(a: Reservation, b: Reservation): boolean {
  return a.equipmentId === b.equipmentId && a.start < b.end && b.start < a.end;
}
// usecases/ports.ts … 内側が「外側にしてほしいこと」を宣言する(ポート)
import type { Reservation } from "../entities/reservation.ts";

export interface ReservationRepository {
  findByEquipment(equipmentId: number): Reservation[];
  add(reservation: Reservation): number;
}
// usecases/reserveEquipment.ts … 手順。知っているのは entities と ports だけ
import { createReservation, overlaps, ReservationError } from "../entities/reservation.ts";
import type { ReservationRepository } from "./ports.ts";

export class ReserveEquipment {
  private readonly repo: ReservationRepository;

  constructor(repo: ReservationRepository) {
    this.repo = repo; // 具体的なクラスは外から受け取る
  }

  execute(userId: number, equipmentId: number, start: Date, end: Date): number {
    const created = createReservation(userId, equipmentId, start, end);
    for (const existing of this.repo.findByEquipment(equipmentId)) {
      if (overlaps(created, existing)) {
        throw new ReservationError("その時間帯は予約済みです");
      }
    }
    return this.repo.add(created);
  }
}
// adapters/memoryRepository.ts … 配列に保存する実装(テストや試作用)
import type { Reservation } from "../entities/reservation.ts";
import type { ReservationRepository } from "../usecases/ports.ts";

export class InMemoryReservationRepository implements ReservationRepository {
  private rows: Reservation[] = [];

  findByEquipment(equipmentId: number): Reservation[] {
    return this.rows.filter((r) => r.equipmentId === equipmentId);
  }

  add(reservation: Reservation): number {
    this.rows.push(reservation);
    return this.rows.length;
  }
}
// adapters/sqliteRepository.ts … SQLite に保存する実装。SQL はここだけに書く
import { DatabaseSync } from "node:sqlite";
import type { Reservation } from "../entities/reservation.ts";
import type { ReservationRepository } from "../usecases/ports.ts";

export class SqliteReservationRepository implements ReservationRepository {
  private readonly db: DatabaseSync;

  constructor(db: DatabaseSync) {
    this.db = db;
    db.exec(`CREATE TABLE IF NOT EXISTS reservations (
      id INTEGER PRIMARY KEY, user_id INT, equipment_id INT, start_at TEXT, end_at TEXT)`);
  }

  findByEquipment(equipmentId: number): Reservation[] {
    const rows = this.db
      .prepare("SELECT user_id, equipment_id, start_at, end_at FROM reservations WHERE equipment_id = ?")
      .all(equipmentId);
    return rows.map((r) => ({
      userId: Number(r.user_id),
      equipmentId: Number(r.equipment_id),
      start: new Date(String(r.start_at)),
      end: new Date(String(r.end_at)),
    }));
  }

  add(reservation: Reservation): number {
    const result = this.db
      .prepare("INSERT INTO reservations (user_id, equipment_id, start_at, end_at) VALUES (?, ?, ?, ?)")
      .run(reservation.userId, reservation.equipmentId,
           reservation.start.toISOString(), reservation.end.toISOString());
    return Number(result.lastInsertRowid);
  }
}
// tests/reserveEquipment.test.ts … 同じテストを、保存先を差し替えて実行する
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { DatabaseSync } from "node:sqlite";
import { InMemoryReservationRepository } from "../src/adapters/memoryRepository.ts";
import { SqliteReservationRepository } from "../src/adapters/sqliteRepository.ts";
import { ReservationError } from "../src/entities/reservation.ts";
import type { ReservationRepository } from "../src/usecases/ports.ts";
import { ReserveEquipment } from "../src/usecases/reserveEquipment.ts";

const at = (hour: number) => new Date(2026, 9, 6, hour);

const factories: Record<string, () => ReservationRepository> = {
  memory: () => new InMemoryReservationRepository(),
  sqlite: () => new SqliteReservationRepository(new DatabaseSync(":memory:")),
};

for (const [name, makeRepo] of Object.entries(factories)) {
  describe(`ReserveEquipment [${name}]`, () => {
    it("空いていれば予約できる", () => {
      const usecase = new ReserveEquipment(makeRepo());
      assert.equal(usecase.execute(1, 10, at(10), at(12)), 1);
    });
    it("重なる予約は受け付けない", () => {
      const usecase = new ReserveEquipment(makeRepo());
      usecase.execute(1, 10, at(10), at(12));
      assert.throws(() => usecase.execute(2, 10, at(11), at(13)), ReservationError);
    });
    it("終了と同時に始まる予約は受け付ける", () => {
      const usecase = new ReserveEquipment(makeRepo());
      usecase.execute(1, 10, at(10), at(12));
      assert.equal(usecase.execute(2, 10, at(12), at(13)), 2);
    });
    it("開始が終了より後ならエラー", () => {
      const usecase = new ReserveEquipment(makeRepo());
      assert.throws(() => usecase.execute(1, 10, at(12), at(10)), /開始は終了より前/);
    });
  });
}

型の検査と、テストの実行結果です(Node.js 22.22.2 では .ts ファイルをそのまま実行できました。所要時間と件数0の行は省いています)。

$ npx tsc --noEmit
$ node --test --test-reporter=spec "tests/*.test.ts"
▶ ReserveEquipment [memory]
  ✔ 空いていれば予約できる
  ✔ 重なる予約は受け付けない
  ✔ 終了と同時に始まる予約は受け付ける
  ✔ 開始が終了より後ならエラー
✔ ReserveEquipment [memory]
▶ ReserveEquipment [sqlite]
  ✔ 空いていれば予約できる
  ✔ 重なる予約は受け付けない
  ✔ 終了と同時に始まる予約は受け付ける
  ✔ 開始が終了より後ならエラー
✔ ReserveEquipment [sqlite]
ℹ tests 8
ℹ suites 2
ℹ pass 8
ℹ fail 0

使った tsconfig.json は次のとおりです。

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "erasableSyntaxOnly": true,
    "types": ["node"]
  },
  "include": ["src", "tests", "before"]
}

実装がポートを満たさないとき、いつ気づけるか

add を書き忘れた実装を用意して比べました。

TypeScript(npx tsc --noEmit)
src/adapters/brokenRepository.ts(4,14): error TS2420: Class 'BrokenRepository' incorrectly implements interface 'ReservationRepository'.
  Property 'add' is missing in type 'BrokenRepository' but required in type 'ReservationRepository'.

Python(そのまま実行)
    return self._repo.add(new)
           ^^^^^^^^^^^^^^
AttributeError: 'BrokenRepository' object has no attribute 'add'

Python(mypy)
broken.py:8: error: Argument 1 to "ReserveEquipment" has incompatible type "BrokenRepository"; expected "ReservationRepository"  [arg-type]
broken.py:8: note: "BrokenRepository" is missing following "ReservationRepository" protocol member:

TypeScriptは型の検査で、Pythonは実行して add が呼ばれた時点で分かります。Pythonでは mypy などの型検査を併用すると、実行前に気づけます。

言語別のディレクトリ構成例

今回動かした2つの構成です。

Python
reservation-app/
├── main.py                         # 組み立て役(Frameworks and Drivers)
├── reservation/
│   ├── entities/
│   │   └── reservation.py          # 業務ルール
│   ├── usecases/
│   │   ├── ports.py                # ポート(インターフェース)
│   │   └── reserve_equipment.py    # 手順
│   └── adapters/
│       ├── sqlite_repository.py    # ポートの実装(SQLite)
│       └── memory_repository.py    # ポートの実装(メモリ)
└── tests/
    └── test_reserve_equipment.py
TypeScript
reservation-app/
├── package.json
├── tsconfig.json
├── src/
│   ├── entities/
│   │   └── reservation.ts
│   ├── usecases/
│   │   ├── ports.ts
│   │   └── reserveEquipment.ts
│   └── adapters/
│       ├── sqliteRepository.ts
│       └── memoryRepository.ts
└── tests/
    └── reserveEquipment.test.ts

同じ分け方をJavaとGoの習慣的な配置に当てはめると、次の形になります(名前の付け方の例です)。

Java
src/main/java/com/example/reservation/
├── entity/Reservation.java
├── usecase/
│   ├── ReservationRepository.java      # インターフェース
│   └── ReserveEquipment.java
├── adapter/
│   ├── JdbcReservationRepository.java
│   └── ReservationController.java
└── Main.java
Go
reservation-app/
├── cmd/app/main.go                     # 組み立て役
└── internal/
    ├── entity/reservation.go
    ├── usecase/
    │   ├── ports.go                    # インターフェース
    │   └── reserve_equipment.go
    └── adapter/
        ├── sqlite_repository.go
        └── memory_repository.go

機能が増えてきたら、層で分ける前に機能で分ける形(reservation/、equipment/ の下にそれぞれ entities・usecases・adapters)も選べます。どちらの形でも、守ることは依存の向きだけです。

向きを保つための小さな検査

内側の層が外側を import していないかを調べる、標準ライブラリだけのスクリプトです。

# check_imports.py … 内側の層が外側の層を import していないかを調べる
import ast, pathlib, sys

ORDER = ["entities", "usecases", "adapters"]            # 内側 → 外側
bad = 0
for path in sorted(pathlib.Path("reservation").rglob("*.py")):
    layer = path.parts[1]
    if layer not in ORDER:
        continue
    for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))):
        names = ([a.name for a in node.names] if isinstance(node, ast.Import)
                 else [node.module or ""] if isinstance(node, ast.ImportFrom) else [])
        for name in names:
            parts = name.split(".")
            if parts[0] == "reservation" and len(parts) > 1 and parts[1] in ORDER \
                    and ORDER.index(parts[1]) > ORDER.index(layer):
                print(f"NG {path}: {layer} が {parts[1]} を import ({name})"); bad += 1
print("外向きの import:", bad, "件")
sys.exit(1 if bad else 0)
$ python3 check_imports.py
外向きの import: 0 件

usecases の末尾に adapters の import を1行足して、もう一度流した結果です。

NG reservation/usecases/reserve_equipment.py: usecases が adapters を import (reservation.adapters.sqlite_repository)
外向きの import: 1 件

終了コードが1になるので、自動チェックに組み込めます。

レイヤード・ヘキサゴナル・オニオン・クリーンの用語対応表

名前は違っても、同じ役割を指している言葉が多くあります。今回のコードのどのファイルに当たるかを添えました。

今回のコード レイヤード(3層) ヘキサゴナル オニオン クリーン
entities/reservation.py ビジネスロジック層の一部 アプリケーション(六角形の内側) ドメインモデル(中心) Entities
usecases/reserve_equipment.py ビジネスロジック層 アプリケーション(六角形の内側) ドメインモデルの外側の層 Use Cases
usecases/ports.py 該当なし(下の層を直接呼ぶ) ポート リポジトリのインターフェース 内側の円で宣言するインターフェース
adapters/sqlite_repository.py データアクセス層 動かされる側(secondary / driven)のアダプター インフラ(外側) Interface Adapters
コントローラー、CLI プレゼンテーション層 動かす側(primary / driving)のアダプター UI(外側) Interface Adapters
tests/ 対応する層は無い 動かす側のアダプターの1つ テスト(外側) 外側の円
main.py、DB、フレームワーク 各層に分散 六角形の外側 外側 Frameworks and Drivers
方式 発表 強調していること(原典の記載)
ヘキサゴナル(ポートとアダプター) Alistair Cockburn 氏、2005年 利用者・プログラム・自動テスト・バッチのどれからでも同じように動かせ、実行時の機器やデータベースから切り離して開発・テストできる
オニオン Jeffrey Palermo 氏、2008年 すべてのコードは中心側の層に依存でき、外側の層には依存できない
クリーン Robert C. Martin 氏、2012年 ソースコードの依存は内側にだけ向かう。内側の円は外側の円のことを何も知らない

クリーンアーキテクチャの記事自体が、ヘキサゴナルやオニオンを先行する方式として挙げています。3つの違いは図の形と部品の呼び名で、依存を内側へ向けるという決まりは共通です。従来の3層レイヤードだけが、業務ロジックからデータアクセスへ依存が向く点で異なります。

どこまで層を分けるか:選び方の基準

条件 選ぶ構成
画面が数個、処理は登録・参照・更新・削除がほとんど 層は2つ(画面とデータ取得)。フレームワークの標準の形のまま
CRUD中心だが、検査や計算が少しずつ増えてきた 3層レイヤード。業務ロジックの置き場を1つ決める
業務ルールの判定が多く、DBなしで素早くテストしたい ポートを置いて依存を逆にする(この記事の形)
保存先や外部サービスを途中で入れ替える見込みがある 入れ替える境界にだけポートを置く
同じ業務ルールを、Web・バッチ・CLIなど複数の入口から使う usecases を独立させ、入口ごとにアダプターを書く
試作や期間の短い検証用のコード 層を分けずに書き、続けると決まった時点で分ける

層を増やすと、ファイル数と受け渡しのコードが増えます。今回の例では、適用前の1ファイルが適用後は6ファイル(entities 1、usecases 2、adapters 2、main 1)になりました。全部の境界にポートを置く必要はなく、「テストで切り離したい所」「入れ替えそうな所」から置くのが進めやすい順番です。

段階的に移すなら、次の順が小さく始められます。

  1. SQLの中にある判定を、名前の付いた関数やメソッドとして取り出す(entities)。
  2. 取り出した関数を呼ぶ手順を、DBのクラスを引数で受け取る形にする(usecases)。
  3. 引数の型をインターフェースにして、メモリ用の実装でテストを書く(ports と adapters)。
  4. 具体的なクラスを作る行を main に集める。

確かめて分かったこと

  • 保存先を差し替えても、同じテストがそのまま通りました。 Pythonは10件(5つ×2)、TypeScriptは8件(4つ×2)で、ユースケースのコードには手を入れていません。
  • usecases を import しても sqlite3 は読み込まれませんでした。 'sqlite3' in sys.modules は False で、内側がDBを知らないことを実行時にも確認できました。
  • ポートを満たさない実装は、TypeScriptでは型検査(TS2420)で、Pythonでは実行して該当メソッドが呼ばれた時点(AttributeError)で分かりました。 PythonのProtocolは継承を書かなくても使える反面、検査には mypy などが要ります。
  • 「終了と同時に始まる予約」の扱いは、SQLの比較演算子に埋もれがちな決まりでした。 entities の overlaps に出したことで、テスト名として読める形になりました。
  • ファイル数は1から6に増えました。 小さな処理では、この増加と得られるテストのしやすさを比べて決めることになります。
  • Node.js 22.22 の node:sqlite は実験的機能の表示が出ます。 動作は今回の範囲で問題ありませんでしたが、採用時はNode.jsの版を確かめてください。

よくある質問

Q. クリーンアーキテクチャとは何ですか? A. 業務ルールを中心に置き、ソースコードの依存を内側にだけ向ける構成です。DB・画面・フレームワークは外側に置き、内側からは見えないようにします。

Q. 依存性逆転とは何ですか? A. 呼び出される側ではなく、呼び出す側がインターフェースを持つことで、依存の向きを呼び出しの向きと逆にすることです。この記事では usecases が ReservationRepository を宣言し、adapters がそれを実装しています。

Q. クリーンアーキテクチャとヘキサゴナル・オニオンの違いは何ですか? A. 図の形と部品の呼び名が違い、依存を内側へ向けるという決まりは同じです。ポートはインターフェース、アダプターはその実装や入口のコードに当たります。

Q. 円は必ず4つに分けるのですか? A. 4つに決まってはいません。元の記事は、円は模式的なもので4つより多くなることもあり、依存性のルールだけは常に当てはまると説明しています。

Q. クリーンアーキテクチャが向かないのはどんなときですか? A. 画面数が少なくCRUD中心で、業務ルールの判定がほとんど無いときです。層を増やすほど受け渡しのコードが増えるので、2層や3層から始めて必要な境界にだけポートを足すほうが合います。

まとめ

  • クリーンアーキテクチャの中心は「依存を内側(業務ルール)へ向ける」という1つの決まり。 円の数や名前は目安。
  • 向きを逆にする道具はインターフェース。 使う側(usecases)に置き、外側(adapters)が実装する。
  • 具体的なクラスを選ぶのは組み立て役(main)だけ。 保存先の差し替えはそこで行う。
  • 同じテストをメモリとSQLiteの両方で実行でき、 PythonでもTypeScriptでもユースケースを変えずに通った。
  • ヘキサゴナルのポート=インターフェース、アダプター=実装や入口。 オニオン・クリーンとも向きの決まりは共通。
  • CRUD中心で画面が少ないなら層を減らす。 ポートは切り離したい境界から置く。

参考・出典

  • Robert C. Martin「The Clean Architecture」(2012年8月13日) https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html
  • Alistair Cockburn「Hexagonal architecture」 https://alistair.cockburn.us/hexagonal-architecture
  • Jeffrey Palermo「The Onion Architecture : part 1」(2008年7月29日) https://jeffreypalermo.com/2008/07/the-onion-architecture-part-1/

(いずれも 2026-10-06 に内容を確認)

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「クリーンアーキテクチャとは|依存の向きとディレクトリ構成例」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?