カテゴリー
Node.jsで始めるソフトクリーンアーキテクチャ概要:複雑さを乗りこなすための第一歩
※ 当ページには【広告/PR】を含む場合があります。
2025/10/25

ソフトウェア開発の世界では、日々新しい技術や概念が生まれています。その中でも「クリーンアーキテクチャ」という言葉を耳にする機会は多いのではないでしょうか。しかし、その厳密な定義や実践方法に気圧されてしまい、なかなか手が出せないと感じている方もいるかもしれません。
この記事では、そんな「クリーンアーキテクチャ」をより
クリーンアーキテクチャとは?その本質を理解する
主な目的は以下の通りです。
独立性: フレームワーク、データベース、UIなど、特定の技術に依存しない設計を可能にします。 テスト容易性: ビジネスロジックが独立しているため、UIやデータベースなしで単体テストが容易に行えます。 保守性: 各層が明確な責任を持つため、コードの変更が他の部分に与える影響を最小限に抑えられます。
AI時代に「クリーンアーキテクチャ」がますます重要になる(?)理由
近年、AIによる
AIが生成するコードは、時に特定のパターンやフレームワークに強く依存する傾向があります。しかし、クリーンアーキテクチャの原則に従っていれば、AIが生成したコードであっても、その
AIがコードを書く時代だからこそ、人間はより上位の設計思想、すなわち「アーキテクチャ」の視点からシステム全体を俯瞰し、その健全性を保つ役割が求められるのです。
「クリーンアーキテクチャ」に対するあくまで個人的な見解
そもそもクリーンアーキテクチャやその他のプログラミング技法にも共通することではありますが、これらは間違った道に進んでいたときにすぐに気づいて引き返せるようにする
一般論として、野放図にアプリケーションを作っていくと、自然と不要なコードや潜在的なバグが増えていきます。早い段階で何も対策しないとあっという間にゴミの山と化し、さらに放置したらどうしようもなくなるものです。実際、非の打ち所のない完璧な「クリーンなアーキテクチャ」が存在しているわけではなく、「極力汚れないようにするには〇〇するといいかも」くらいの感覚で、経験豊富な先達たちが整理整頓の心得をまとめたものが「クリーンアーキテクチャ」なのです。
ただし、ソフトウェア開発を目的地も定まっていない長い旅に例えるなら、クリーンアーキテクチャは
修正クリーンアーキテクチャ:ソフトクリーンアーキテクチャの提案
クリーンアーキテクチャの"厳密バージョン"を理解する前段階として、ここでは
提唱されている方の元ネタは以下のサイトにあります。
これはクリーンアーキテクチャをコンパクト化し、3層アーキテクチャやレイヤードアーキテクチャの考え方と融合させたシンプルな構成(レイヤ構造)となっています。

出図:
やさしいクリーンアーキテクチャ: https://zenn.dev/sre_holdings/articles/a57f088e9ca07dソフトクリーンアーキテクチャは、クリーンアーキテクチャをこれからじっくり学習したい人向けのエントリーモデルのような位置づけです。 プロジェクトが大きくなり、ビジネスロジックとユースケースを分けたくなったら、機を見てクリーンアーキテクチャへスケールアップさせることもできます。
このソフトクリーンアーキテクチャの意味合いを理解するのに以下のスライド資料はとても良い説明を与えてくれます。
結局、「ソフトクリーンアーキテクチャ」はこちらの資料で紹介されている
「エンジニアドーナツ」
出図:
クリーンアーキテクチャから見る依存の向きの大切さ: https://speakerdeck.com/shimabox/kurinakitekutiyakarajian-ruyi-cun-noxiang-kinoda-qie-saソフトクリーンアーキテクチャの構成
ソフトクリーンアーキテクチャは、以下の3つの層で構成されます。
コア層 (Core Layer): もっとも内側のレイヤーで、 ビジネスロジックやサービス機能などを表現するクラスが含まれます。 ドメインエンティティ、ドメインサービス、リポジトリインターフェース、ユースケースなどがここに位置します。 他のどの層にも依存せず、独立しています。
インフラ層 (Infrastructure Layer): コア層の外側で、 データベースアクセス、外部API連携、ファイルシステム操作などの技術的詳細を扱うクラスが含まれます。 コア層で定義されたリポジトリインターフェースの実装などがここに該当します。 コア層に依存しますが、ウェブ層には依存しません。
ウェブ層 (Web Layer): 最も外側のレイヤーで、 UIやDB、外部のライブラリ、フレームワーク(例えばExpress等のミドルウェアやHTTPハンドラ)に依存するクラスが含まれます。 コントローラー、プレゼンター、DTO(Data Transfer Object)などがここに位置します。 コア層とインフラ層の両方に依存します。
なお、3層アーキテクチャやレイヤードアーキテクチャに関しては、以下の参考サイトも併せてご覧ください。
先程のエンジニアドーナツをソフトクリーンアーキテクチャ的に描き換えると、

となっている理解です。
単純にこれをディレクトリ構造で表すと、
src
├ core
│ ├ domain
│ ├ entity
│ ├ repository
│ └ service
├ infra
│ └ repository
└ web
└ controller
のように簡素化できる、というのがソフトクリーンアーキテクチャの大きなメリットです。
簡単な実践例
ここからは、TypeScriptとNode.jsを使ったプロジェクトを想定し、既存のプロジェクトをソフトクリーンアーキテクチャに「仕訳」する具体的な手順を見ていきましょう。
1. プロジェクトの準備
まず、TypeScript/Node.jsプロジェクトを準備します。今回は、既存のクリーンアーキテクチャのサンプルプロジェクトをベースに、ソフトクリーンアーキテクチャへ落とし込む作業を行います。
参考にするのは、以下のGitHubリポジトリです。
このプロジェクトにはNode.jsの他、種々のフレームワークでのクリーンアーキテクチャプロジェクトの導入方法が紹介されています。
ちなみに、用意されていたサンプルをそのままdocker-composeで実行してみると、バグ(?)なのかtsxがそのままだと認識してくれないので、今回はサンプルの動作確認よりも、
今回はここで紹介されている
backend_nodejsまずはプロジェクトをクローンし、
backend_nodejsclean-arch $ git clone https://github.com/ikedadada/start-ddd-and-clean-architecture.git
$ mv start-ddd-and-clean-architecture/backend_nodejs clean-arch
$ rm -rf start-ddd-and-clean-architecture
$ cd clean-arch/
$ tree
.
├── Dockerfile.dev
├── biome.json
├── mise.toml
├── package-lock.json
├── package.json
├── prisma
│ ├── schema.prisma
│ └── test.prisma
├── src
│ ├── application_service
│ │ ├── service
│ │ │ └── transactionService.ts
│ │ └── usecase
│ │ ├── createTodoUsecase.test.ts
│ │ ├── createTodoUsecase.ts
│ │ ├── deleteTodoUsecase.test.ts
│ │ ├── deleteTodoUsecase.ts
│ │ ├── getAllTodosUsecase.test.ts
│ │ ├── getAllTodosUsecase.ts
│ │ ├── getTodoUsecase.test.ts
│ │ ├── getTodoUsecase.ts
│ │ ├── markAsCompletedTodoUsecase.test.ts
│ │ ├── markAsCompletedTodoUsecase.ts
│ │ ├── markAsNotCompletedTodoUsecase.test.ts
│ │ ├── markAsNotCompletedTodoUsecase.ts
│ │ ├── updateTodoUsecase.test.ts
│ │ └── updateTodoUsecase.ts
│ ├── domain
│ │ ├── model
│ │ │ ├── errors.ts
│ │ │ ├── newId.ts
│ │ │ ├── todo.test.ts
│ │ │ └── todo.ts
│ │ └── repository
│ │ ├── context.ts
│ │ ├── errors.ts
│ │ └── todoRepository.ts
│ ├── infrastructure
│ │ ├── repository
│ │ │ ├── context.infra.test.ts
│ │ │ ├── context.ts
│ │ │ ├── todoRepository.infra.test.ts
│ │ │ └── todoRepository.ts
│ │ └── service
│ │ ├── transactionService.infra.test.ts
│ │ └── transactionService.ts
│ ├── main.ts
│ └── presentation
│ ├── handler
│ │ ├── createTodoHandler.test.ts
│ │ ├── createTodoHandler.ts
│ │ ├── deleteTodoHandler.test.ts
│ │ ├── deleteTodoHandler.ts
│ │ ├── getAllTodoHandler.test.ts
│ │ ├── getAllTodoHandler.ts
│ │ ├── getTodoHandler.test.ts
│ │ ├── getTodoHandler.ts
│ │ ├── markAsCompletedTodoHandler.test.ts
│ │ ├── markAsCompletedTodoHandler.ts
│ │ ├── markAsNotCompletedTodoHandler.test.ts
│ │ ├── markAsNotCompletedTodoHandler.ts
│ │ ├── updateTodoHandler.test.ts
│ │ └── updateTodoHandler.ts
│ └── middleware
│ ├── errorHandler.test.ts
│ ├── errorHandler.ts
│ ├── routeNotFoundHandler.test.ts
│ └── routeNotFoundHandler.ts
├── tsconfig.json
├── vitest.config.ts
└── vitest.setup.ts
Dockerfile.dev # ...中略
# CMD [ "npm" , "run", "dev" ]
# 👇
CMD [ "npm" , "run", "test" ]
Dockerイメージをビルドして実行するとvitestテストが走ります。
$ docker build . -f ./Dockerfile.dev -t clean-arch-dev
$ docker run --rm clean-arch-dev
> backend_nodejs@0.0.0 pretest
> prisma generate --schema=prisma/test.prisma
Environment variables loaded from .env
Prisma schema loaded from prisma/test.prisma
✔ Generated Prisma Client (v6.16.1) to ./generated/prisma-test in 120ms
Start by importing your Prisma Client (See: https://pris.ly/d/importing-client)
Tip: Need your database queries to be 1000x faster? Accelerate offers you that and more: https://pris.ly/tip-2-accelerate
> backend_nodejs@0.0.0 test
> vitest run
RUN v3.2.4 /app
✓ src/domain/model/todo.test.ts (8 tests) 30ms
✓ src/presentation/handler/deleteTodoHandler.test.ts (3 tests) 23ms
✓ src/presentation/handler/createTodoHandler.test.ts (2 tests) 31ms
✓ src/presentation/handler/updateTodoHandler.test.ts (3 tests) 45ms
✓ src/presentation/handler/getTodoHandler.test.ts (3 tests) 35ms
✓ src/presentation/handler/markAsNotCompletedTodoUsecase.test.ts (4 tests) 36ms
✓ src/presentation/handler/markAsCompletedTodoUsecase.test.ts (4 tests) 74ms
✓ src/presentation/handler/getAllTodoHandler.test.ts (1 test) 19ms
stderr | src/presentation/middleware/errorHandler.test.ts > errorHandler middleware > serializes HTTPError subclasses with their status and message
BadRequestError: bad req
at /app/src/presentation/middleware/errorHandler.test.ts:21:8
at file:///app/node_modules/@vitest/runner/dist/chunk-hooks.js:155:11
at file:///app/node_modules/@vitest/runner/dist/chunk-hooks.js:752:26
at file:///app/node_modules/@vitest/runner/dist/chunk-hooks.js:1897:20
at new Promise (<anonymous>)
at runWithTimeout (file:///app/node_modules/@vitest/runner/dist/chunk-hooks.js:1863:10)
at runTest (file:///app/node_modules/@vitest/runner/dist/chunk-hooks.js:1574:12)
at processTicksAndRejections (node:internal/process/task_queues:105:5)
at runSuite (file:///app/node_modules/@vitest/runner/dist/chunk-hooks.js:1729:8)
at runSuite (file:///app/node_modules/@vitest/runner/dist/chunk-hooks.js:1729:8) {
statusCode: 400
}
NotFoundError: not found
at /app/src/presentation/middleware/errorHandler.test.ts:22:8
at file:///app/node_modules/@vitest/runner/dist/chunk-hooks.js:155:11
at file:///app/node_modules/@vitest/runner/dist/chunk-hooks.js:752:26
at file:///app/node_modules/@vitest/runner/dist/chunk-hooks.js:1897:20
at new Promise (<anonymous>)
at runWithTimeout (file:///app/node_modules/@vitest/runner/dist/chunk-hooks.js:1863:10)
at runTest (file:///app/node_modules/@vitest/runner/dist/chunk-hooks.js:1574:12)
at processTicksAndRejections (node:internal/process/task_queues:105:5)
at runSuite (file:///app/node_modules/@vitest/runner/dist/chunk-hooks.js:1729:8)
at runSuite (file:///app/node_modules/@vitest/runner/dist/chunk-hooks.js:1729:8) {
statusCode: 404
}
ConflictError: conflict
at /app/src/presentation/middleware/errorHandler.test.ts:23:8
at file:///app/node_modules/@vitest/runner/dist/chunk-hooks.js:155:11
at file:///app/node_modules/@vitest/runner/dist/chunk-hooks.js:752:26
at file:///app/node_modules/@vitest/runner/dist/chunk-hooks.js:1897:20
at new Promise (<anonymous>)
at runWithTimeout (file:///app/node_modules/@vitest/runner/dist/chunk-hooks.js:1863:10)
at runTest (file:///app/node_modules/@vitest/runner/dist/chunk-hooks.js:1574:12)
at processTicksAndRejections (node:internal/process/task_queues:105:5)
at runSuite (file:///app/node_modules/@vitest/runner/dist/chunk-hooks.js:1729:8)
at runSuite (file:///app/node_modules/@vitest/runner/dist/chunk-hooks.js:1729:8) {
statusCode: 409
}
HTTPError: teapot
at /app/src/presentation/middleware/errorHandler.test.ts:24:8
at file:///app/node_modules/@vitest/runner/dist/chunk-hooks.js:155:11
at file:///app/node_modules/@vitest/runner/dist/chunk-hooks.js:752:26
at file:///app/node_modules/@vitest/runner/dist/chunk-hooks.js:1897:20
at new Promise (<anonymous>)
at runWithTimeout (file:///app/node_modules/@vitest/runner/dist/chunk-hooks.js:1863:10)
at runTest (file:///app/node_modules/@vitest/runner/dist/chunk-hooks.js:1574:12)
at processTicksAndRejections (node:internal/process/task_queues:105:5)
at runSuite (file:///app/node_modules/@vitest/runner/dist/chunk-hooks.js:1729:8)
at runSuite (file:///app/node_modules/@vitest/runner/dist/chunk-hooks.js:1729:8) {
statusCode: 418
}
✓ src/application_service/usecase/markAsNotCompletedTodoUsecase.test.ts (1 test) 21ms
stderr | src/presentation/middleware/errorHandler.test.ts > errorHandler middleware > maps generic Error to 500 Internal Server Error
Error: boom
at /app/src/presentation/middleware/errorHandler.test.ts:37:17
at file:///app/node_modules/@vitest/runner/dist/chunk-hooks.js:155:11
at file:///app/node_modules/@vitest/runner/dist/chunk-hooks.js:752:26
at file:///app/node_modules/@vitest/runner/dist/chunk-hooks.js:1897:20
at new Promise (<anonymous>)
at runWithTimeout (file:///app/node_modules/@vitest/runner/dist/chunk-hooks.js:1863:10)
at runTest (file:///app/node_modules/@vitest/runner/dist/chunk-hooks.js:1574:12)
at processTicksAndRejections (node:internal/process/task_queues:105:5)
at runSuite (file:///app/node_modules/@vitest/runner/dist/chunk-hooks.js:1729:8)
at runSuite (file:///app/node_modules/@vitest/runner/dist/chunk-hooks.js:1729:8)
✓ src/presentation/middleware/errorHandler.test.ts (2 tests) 69ms
✓ src/infrastructure/repository/todoRepository.infra.test.ts (3 tests) 203ms
✓ src/infrastructure/service/transactionService.infra.test.ts (2 tests) 169ms
✓ src/infrastructure/repository/context.infra.test.ts (3 tests) 51ms
✓ src/application_service/usecase/updateTodoUsecase.test.ts (1 test) 40ms
✓ src/application_service/usecase/markAsCompletedTodoUsecase.test.ts (1 test) 23ms
✓ src/application_service/usecase/deleteTodoUsecase.test.ts (1 test) 18ms
✓ src/application_service/usecase/createTodoUsecase.test.ts (1 test) 14ms
✓ src/application_service/usecase/getTodoUsecase.test.ts (1 test) 18ms
✓ src/presentation/middleware/routeNotFoundHandler.test.ts (1 test) 18ms
✓ src/application_service/usecase/getAllTodosUsecase.test.ts (1 test) 22ms
Test Files 20 passed (20)
Tests 46 passed (46)
Start at 03:08:11
Duration 14.19s (transform 793ms, setup 75.73s, collect 3.24s, tests 957ms, environment 10ms, prepare 4.19s)
このプロジェクトはすでにクリーンアーキテクチャになっているので言うまでもなく、理想的なvitestのテストが行えるのが確認できます。
ソフトクリーンアーキテクチャ化する
今回はちょっと例のとり方がクリーンアーキテクチャだったものをダウングレードさせるものでしたので、逆に汚しているような感じもするのですが、実際のクリーンアーキテクチャになっていない既存のプロジェクトみたいなのを想像しながら読み替えてみてください。
先程のように
src src
├ core
│ ├ domain
│ ├ entity
│ ├ repository
│ └ service
├ infra
│ └ repository
└ web
└ controller
個人的なやり方として、アーキテクチャ化していない既存のプロジェクトがあったとして、既存のソースコードファイルを見て、おおよそどの層に属しているかざっくりと判断し、対応するフォルダへ「仕訳」する感覚で放り込みます。また、"責任の分離"がなされていないファイルなどは、必要に応じて空のファイルを対象のフォルダに「予約」という形で追加しておくのも良いでしょう。
以下に一旦仕訳してみた例を示します。
.
├── src
│ ├── core
│ │ ├── domain
│ │ │ ├── errors.ts
│ │ │ ├── newId.ts
│ │ │ ├── todo.test.ts
│ │ │ └── todo.ts
│ │ ├── entity
│ │ ├── repository
│ │ │ ├── context.ts
│ │ │ ├── errors.ts
│ │ │ └── todoRepository.ts
│ │ └── service
│ │ ├── createTodoUsecase.test.ts
│ │ ├── createTodoUsecase.ts
│ │ ├── deleteTodoUsecase.test.ts
│ │ ├── deleteTodoUsecase.ts
│ │ ├── getAllTodosUsecase.test.ts
│ │ ├── getAllTodosUsecase.ts
│ │ ├── getTodoUsecase.test.ts
│ │ ├── getTodoUsecase.ts
│ │ ├── markAsCompletedTodoUsecase.test.ts
│ │ ├── markAsCompletedTodoUsecase.ts
│ │ ├── markAsNotCompletedTodoUsecase.test.ts
│ │ ├── markAsNotCompletedTodoUsecase.ts
│ │ ├── transactionService.ts
│ │ ├── updateTodoUsecase.test.ts
│ │ └── updateTodoUsecase.ts
│ ├── infra
│ │ └── repository
│ │ ├── context.infra.test.ts
│ │ ├── context.ts
│ │ ├── todoRepository.infra.test.ts
│ │ ├── todoRepository.ts
│ │ ├── transactionService.infra.test.ts
│ │ └── transactionService.ts
│ ├── main.ts
│ └── web
│ └── controller
│ ├── createTodoHandler.test.ts
│ ├── createTodoHandler.ts
│ ├── deleteTodoHandler.test.ts
│ ├── deleteTodoHandler.ts
│ ├── errorHandler.test.ts
│ ├── errorHandler.ts
│ ├── getAllTodoHandler.test.ts
│ ├── getAllTodoHandler.ts
│ ├── getTodoHandler.test.ts
│ ├── getTodoHandler.ts
│ ├── markAsCompletedTodoHandler.test.ts
│ ├── markAsCompletedTodoHandler.ts
│ ├── markAsNotCompletedTodoHandler.test.ts
│ ├── markAsNotCompletedTodoHandler.ts
│ ├── routeNotFoundHandler.test.ts
│ ├── routeNotFoundHandler.ts
│ ├── updateTodoHandler.test.ts
│ └── updateTodoHandler.ts
#...
ユースケースだったものをコア層のどこに置こうかなど、少し判断に迷う気ところもありましたが、そこは"ソフト"に気の赴くまま配置してみて、違えば後で修正を加えるようにすれば良いことです。
まとめ
以上、小難しいルールの多いクリーンアーキテクチャをちょっとだけやさしくした「ソフトクリーンアーキテクチャ」のガイダンスを紹介してみました。
慣れてくれば必要に応じてクリーンアーキテクチャへ移行するのも良いかと思います。