ソフトウェア開発の世界では、日々新しい技術や概念が生まれています。その中でも「クリーンアーキテクチャ」という言葉を耳にする機会は多いのではないでしょうか。しかし、その厳密な定義や実践方法に気圧されてしまい、なかなか手が出せないと感じている方もいるかもしれません。
この記事では、そんな「クリーンアーキテクチャ」をより
やさしく、そして実践的に捉え直した「ソフトクリーンアーキテクチャ」 について、Node.jsプロジェクトを例に解説していきます。複雑な概念を完璧に理解する前に、まずはそのエッセンスを掴み、日々の開発に活かすための第一歩を踏み出しましょう。


合同会社タコスキングダム|蛸壺の技術ブログ【効果的学習法レポート・2025年最新】nodejsをこれから学びたい人のためのオススメ書籍&教材特集

クリーンアーキテクチャとは?その本質を理解する

クリーンアーキテクチャ とは、ソフトウェアの設計思想の一つで、システムを複数の層に分割し、それぞれの層が特定の役割と責任を持つように構造化するアプローチです。 その中心にあるのは、 「依存性のルール(Dependency Rule)」 と呼ばれる原則です。これは、内側の層は外側の層に依存せず、外側の層は内側の層に依存するという一方通行の依存関係を強制するものです。これにより、ビジネスロジック(最も内側の層)がデータベースやUIといった技術的な詳細(外側の層)から独立し、変更に強く、テストしやすいシステムを構築することを目指します。
主な目的は以下の通りです。

  • 独立性: フレームワーク、データベース、UIなど、特定の技術に依存しない設計を可能にします。
  • テスト容易性: ビジネスロジックが独立しているため、UIやデータベースなしで単体テストが容易に行えます。
  • 保守性: 各層が明確な責任を持つため、コードの変更が他の部分に与える影響を最小限に抑えられます。

AI時代に「クリーンアーキテクチャ」がますます重要になる(?)理由



近年、AIによる
Vibe Coding (AIがコードの大部分を生成する開発スタイル)が急速に普及しつつあります。このような時代において、「クリーンアーキテクチャ」の考え方はこれまで以上に重要性を増しています。
AIが生成するコードは、時に特定のパターンやフレームワークに強く依存する傾向があります。しかし、クリーンアーキテクチャの原則に従っていれば、AIが生成したコードであっても、その
ビジネスロジックを核として、技術的な詳細から切り離す ことができます。これにより、AIが生成したコードの品質や保守性を人間がコントロールしやすくなり、将来的な技術変更や要件変更にも柔軟に対応できるシステムを維持することが可能になります。
AIがコードを書く時代だからこそ、人間はより上位の設計思想、すなわち「アーキテクチャ」の視点からシステム全体を俯瞰し、その健全性を保つ役割が求められるのです。

「クリーンアーキテクチャ」に対するあくまで個人的な見解



そもそもクリーンアーキテクチャやその他のプログラミング技法にも共通することではありますが、これらは間違った道に進んでいたときにすぐに気づいて引き返せるようにする
"保険"のようなもの であって、「プログラミングはかくあるべき」といった"正解"や"答え"を保証してくれるものではありません。
一般論として、野放図にアプリケーションを作っていくと、自然と不要なコードや潜在的なバグが増えていきます。早い段階で何も対策しないとあっという間にゴミの山と化し、さらに放置したらどうしようもなくなるものです。実際、非の打ち所のない完璧な「クリーンなアーキテクチャ」が存在しているわけではなく、「極力汚れないようにするには〇〇するといいかも」くらいの感覚で、経験豊富な先達たちが整理整頓の心得をまとめたものが「クリーンアーキテクチャ」なのです。
ただし、ソフトウェア開発を目的地も定まっていない長い旅に例えるなら、クリーンアーキテクチャは
"サバイバル道具" のようなものであり、あるとないとでは安心感がかなり違います。「クリーンアーキテクチャ」というワードに気圧されることなく、関連書籍を完璧に読破してからスタートするより、コードを作りながら「なんだか汚く感じられる」ときに一旦立ち止まってから、「クリーンアーキテクチャ」を紐解いてキレイに保つテクニックを考えてみる、という方法のほうが経験として身につく(気がします...)。


合同会社タコスキングダム|蛸壺の技術ブログ【効果的学習法レポート・2025年最新】nodejsをこれから学びたい人のためのオススメ書籍&教材特集

修正クリーンアーキテクチャ:ソフトクリーンアーキテクチャの提案



クリーンアーキテクチャの"厳密バージョン"を理解する前段階として、ここでは
"修正版"、すなわち「ソフトクリーンアーキテクチャ」 について解説します。
提唱されている方の元ネタは以下のサイトにあります。

やさしいクリーンアーキテクチャ

これはクリーンアーキテクチャをコンパクト化し、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

        

のように簡素化できる、というのがソフトクリーンアーキテクチャの大きなメリットです。


合同会社タコスキングダム|蛸壺の技術ブログ【効果的学習法レポート・2025年最新】nodejsをこれから学びたい人のためのオススメ書籍&教材特集

簡単な実践例



ここからは、TypeScriptとNode.jsを使ったプロジェクトを想定し、既存のプロジェクトをソフトクリーンアーキテクチャに「仕訳」する具体的な手順を見ていきましょう。

1. プロジェクトの準備



まず、TypeScript/Node.jsプロジェクトを準備します。今回は、既存のクリーンアーキテクチャのサンプルプロジェクトをベースに、ソフトクリーンアーキテクチャへ落とし込む作業を行います。
参考にするのは、以下のGitHubリポジトリです。

start-ddd-and-clean-architecture/

このプロジェクトにはNode.jsの他、種々のフレームワークでのクリーンアーキテクチャプロジェクトの導入方法が紹介されています。
ちなみに、用意されていたサンプルをそのままdocker-composeで実行してみると、バグ(?)なのかtsxがそのままだと認識してくれないので、今回はサンプルの動作確認よりも、
vitestによるテストを実行する ことを目的とします。
今回はここで紹介されている
backend_nodejs に収録されている既存のソースコードをベースに、ソフトクリーンアーキテクチャのプロジェクトへ落とし込みます。
まずはプロジェクトをクローンし、
backend_nodejs フォルダを clean-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
#...

        

ユースケースだったものをコア層のどこに置こうかなど、少し判断に迷う気ところもありましたが、そこは"ソフト"に気の赴くまま配置してみて、違えば後で修正を加えるようにすれば良いことです。


合同会社タコスキングダム|蛸壺の技術ブログ【効果的学習法レポート・2025年最新】nodejsをこれから学びたい人のためのオススメ書籍&教材特集

まとめ



以上、小難しいルールの多いクリーンアーキテクチャをちょっとだけやさしくした「ソフトクリーンアーキテクチャ」のガイダンスを紹介してみました。
慣れてくれば必要に応じてクリーンアーキテクチャへ移行するのも良いかと思います。