オニオンアーキテクチャとは ─ 誕生の背景と基本思想

オニオンアーキテクチャ(Onion Architecture)は、2008年にJeffrey Palermo氏が自身のブログ記事「The Onion Architecture」シリーズで発表した設計パターンです。ビジネスロジック(ドメイン)を中心に据え、データベースやUIといった外部要素への依存を排除することで、変更に強く、テストしやすいアプリケーションを実現します。

従来のレイヤードアーキテクチャ(3層アーキテクチャ)では、プレゼンテーション層→ビジネスロジック層→データアクセス層の順に依存関係が形成されます。この構造では、ビジネスロジックがデータアクセス層に直接依存するため、データベースの変更がビジネスロジック全体に波及するという問題がありました。

オニオンアーキテクチャはこの問題を**依存性逆転の原則(Dependency Inversion Principle: DIP)**で解決します。ドメイン層がインターフェースを定義し、インフラ層がそれを実装するという関係に逆転させることで、「最も重要なビジネスロジックが、最も変わりやすい技術的詳細に依存しない」構造を作ります。

名前の由来は、各層を同心円状に描くと玉ねぎ(Onion)の断面に似ることからきています。

オニオンアーキテクチャの4層同心円構造:中心のDomain Modelから外側へDomain Service・Application Service・Infrastructure/UIの順に配置され、依存方向は外から内へ向かう

依存の矢印は常に外側から内側へ向かいます。内側の層は外側の層を一切知りません。

4つの層とその責務

オニオンアーキテクチャは、中心から順に4つの層で構成されます。

Domain Model層(中心)

エンティティ(Entity)と値オブジェクト(Value Object)を配置する層です。アプリケーションが扱うビジネス概念そのものを表現します。

  • ビジネスルールの不変条件(invariant)を保持
  • 外部依存を一切持たない純粋なオブジェクト
  • 例:Orderエンティティ、Money値オブジェクト、Email値オブジェクト

関数型プログラミングの観点では、この層は**純粋関数(calculation)**のみで構成されます。副作用を一切持たず、同じ入力に対して常に同じ結果を返す処理だけが属します。この考え方はEric Normand氏の著書「Grokking Simplicity」(Manning, 2021)で詳しく説明されており、オニオンアーキテクチャの本質を理解するうえで有効な視点です。

Domain Service層(第2層)

ドメインモデルを横断する操作や、リポジトリのインターフェースを定義する層です。

  • 複数のエンティティにまたがるビジネスロジック
  • IOrderRepositoryIUserRepositoryなどのリポジトリインターフェース定義
  • ドメインイベントの定義

ここで重要なのは、リポジトリのインターフェースだけを定義し、実装は持たない点です。「データをどう永続化するか」は外側の関心事であり、この層が知るべきことではありません。

Application Service層(第3層)

ユースケースを実現する層です。ドメインオブジェクトやリポジトリインターフェースを組み合わせて、アプリケーション固有のワークフローを記述します。

  • ユースケースの実行手順(オーケストレーション)
  • トランザクション境界の定義
  • DTOへの変換処理

この層はシンプルな「台本」のように読めるべきです。「リクエストを受け取る → バリデーション → リポジトリからデータ取得 → ドメインロジック実行 → 結果を返す」という一連の流れを記述します。

Infrastructure / UI層(最外層)

データベースアクセス、Web API、ファイルI/O、フレームワーク連携など、外部世界とのやり取りをすべて担当する層です。

  • リポジトリインターフェースの具象実装(PostgresOrderRepositoryなど)
  • HTTPコントローラー、メッセージリスナー
  • DIコンテナの設定(依存性の注入はここで行う)
  • 外部API連携

UIとInfrastructureが同じ層に位置するのは、従来のレイヤードアーキテクチャとの大きな違いです。どちらもドメインにとっては「外部の詳細」であり、対等な存在として扱われます。

依存性逆転の原則が解決する現実の課題

「データベースやフレームワークの変更なんて本当に起きるの?」という疑問は当然です。しかし実際のプロダクト開発では、以下のような変更が発生します。

変更要因具体例
パフォーマンス限界RDBMSからBigQueryやElasticsearchへの参照系移行
コスト最適化商用DBからOSSデータベースへの切り替え
スケール要件単一DBからリードレプリカ構成やシャーディングへ
外部サービス終了利用中のSaaS APIの廃止・代替サービスへの移行
技術負債解消レガシーフレームワークから現行フレームワークへ

株式会社ログラスの事例(JJUG CCC 2024 Fall講演より)では、プロダクトの成長に伴いPostgreSQLの参照クエリがパフォーマンス限界に達し、BigQueryへデータソースを移行する必要が生じました。オニオンアーキテクチャを採用していたことで、アプリケーション層の改修を最小限に抑え、開発工数の大部分をBigQuery向けのデータ構造設計・クエリ最適化という本質的な作業に集中できたとされています。

ディレクトリ構成の実例

TypeScriptプロジェクトを例に、各層をディレクトリ構造で表現します。

src/
├── domain/                    # Domain Model層 + Domain Service層
│   ├── model/
│   │   ├── Order.ts           # エンティティ
│   │   ├── OrderItem.ts
│   │   └── Money.ts           # 値オブジェクト
│   ├── service/
│   │   └── PricingService.ts  # ドメインサービス
│   └── repository/
│       └── IOrderRepository.ts # リポジトリインターフェース
├── application/               # Application Service層
│   ├── usecase/
│   │   ├── CreateOrderUseCase.ts
│   │   └── GetOrderUseCase.ts
│   └── dto/
│       ├── CreateOrderRequest.ts
│       └── OrderResponse.ts
├── infrastructure/            # Infrastructure層
│   ├── persistence/
│   │   └── PrismaOrderRepository.ts  # リポジトリ実装
│   ├── web/
│   │   └── OrderController.ts        # HTTPコントローラー
│   └── config/
│       └── container.ts              # DIコンテナ設定
└── main.ts                    # エントリーポイント

この構成のポイントは、domain/ 配下のファイルが infrastructure/application/ のモジュールを一切 import しないことです。コードレビューやCIでこのルールを守れば、依存方向の逆転を維持できます。

TypeScriptで実装するオニオンアーキテクチャ

具体的なコード例を通じて各層の実装を確認します。

Domain Model層:エンティティと値オブジェクト

// domain/model/Money.ts
export class Money {
  private constructor(
    readonly amount: number,
    readonly currency: string
  ) {
    if (amount < 0) throw new Error("金額は0以上である必要があります");
  }

  static of(amount: number, currency: string): Money {
    return new Money(amount, currency);
  }

  add(other: Money): Money {
    if (this.currency !== other.currency) {
      throw new Error("異なる通貨同士は加算できません");
    }
    return Money.of(this.amount + other.amount, this.currency);
  }

  multiply(quantity: number): Money {
    return Money.of(this.amount * quantity, this.currency);
  }
}
// domain/model/Order.ts
import { Money } from "./Money";

export type OrderStatus = "draft" | "confirmed" | "shipped" | "cancelled";

export class Order {
  private constructor(
    readonly id: string,
    readonly customerId: string,
    private _items: OrderItem[],
    private _status: OrderStatus
  ) {}

  static create(id: string, customerId: string): Order {
    return new Order(id, customerId, [], "draft");
  }

  addItem(productId: string, unitPrice: Money, quantity: number): void {
    if (this._status !== "draft") {
      throw new Error("確定済みの注文には商品を追加できません");
    }
    this._items.push({ productId, unitPrice, quantity });
  }

  confirm(): void {
    if (this._items.length === 0) {
      throw new Error("商品が空の注文は確定できません");
    }
    this._status = "confirmed";
  }

  get totalAmount(): Money {
    return this._items.reduce(
      (sum, item) => sum.add(item.unitPrice.multiply(item.quantity)),
      Money.of(0, "JPY")
    );
  }

  get status(): OrderStatus { return this._status; }
  get items(): ReadonlyArray<OrderItem> { return this._items; }
}

interface OrderItem {
  productId: string;
  unitPrice: Money;
  quantity: number;
}

Orderエンティティが外部ライブラリに一切依存していない点に注目してください。ORMのデコレータもフレームワークの型も使いません。

Domain Service層:リポジトリインターフェース

// domain/repository/IOrderRepository.ts
import { Order } from "../model/Order";

export interface IOrderRepository {
  findById(id: string): Promise<Order | null>;
  save(order: Order): Promise<void>;
  nextId(): Promise<string>;
}

このインターフェースは「注文をIDで検索できる」「注文を保存できる」というドメインの要求だけを表現します。SQLを使うか、NoSQLを使うか、ファイルに保存するかはこの層の関心事ではありません。

Application Service層:ユースケース

// application/usecase/CreateOrderUseCase.ts
import { IOrderRepository } from "../../domain/repository/IOrderRepository";
import { Order } from "../../domain/model/Order";
import { Money } from "../../domain/model/Money";
import { CreateOrderRequest } from "../dto/CreateOrderRequest";
import { OrderResponse } from "../dto/OrderResponse";

export class CreateOrderUseCase {
  constructor(private readonly orderRepo: IOrderRepository) {}

  async execute(request: CreateOrderRequest): Promise<OrderResponse> {
    const orderId = await this.orderRepo.nextId();
    const order = Order.create(orderId, request.customerId);

    for (const item of request.items) {
      order.addItem(
        item.productId,
        Money.of(item.unitPrice, "JPY"),
        item.quantity
      );
    }

    order.confirm();
    await this.orderRepo.save(order);

    return OrderResponse.fromDomain(order);
  }
}

CreateOrderUseCaseはコンストラクタでIOrderRepository(インターフェース)を受け取ります。具体的なDB実装クラスには依存しません。

Infrastructure層:リポジトリ実装とDI設定

// infrastructure/persistence/PrismaOrderRepository.ts
import { PrismaClient } from "@prisma/client";
import { IOrderRepository } from "../../domain/repository/IOrderRepository";
import { Order } from "../../domain/model/Order";
import { Money } from "../../domain/model/Money";
import { randomUUID } from "crypto";

export class PrismaOrderRepository implements IOrderRepository {
  constructor(private readonly prisma: PrismaClient) {}

  async findById(id: string): Promise<Order | null> {
    const record = await this.prisma.order.findUnique({
      where: { id },
      include: { items: true },
    });
    if (!record) return null;
    return this.toDomain(record);
  }

  async save(order: Order): Promise<void> {
    await this.prisma.order.upsert({
      where: { id: order.id },
      create: {
        id: order.id,
        customerId: order.customerId,
        status: order.status,
        items: {
          create: order.items.map((item) => ({
            productId: item.productId,
            unitPrice: item.unitPrice.amount,
            currency: item.unitPrice.currency,
            quantity: item.quantity,
          })),
        },
      },
      update: {
        status: order.status,
      },
    });
  }

  async nextId(): Promise<string> {
    return randomUUID();
  }

  private toDomain(record: any): Order {
    // DBレコードからドメインオブジェクトへの変換
    const order = Order.create(record.id, record.customerId);
    for (const item of record.items) {
      order.addItem(
        item.productId,
        Money.of(item.unitPrice, item.currency),
        item.quantity
      );
    }
    if (record.status === "confirmed") order.confirm();
    return order;
  }
}
// infrastructure/config/container.ts
import { PrismaClient } from "@prisma/client";
import { PrismaOrderRepository } from "../persistence/PrismaOrderRepository";
import { CreateOrderUseCase } from "../../application/usecase/CreateOrderUseCase";

// DIコンテナ:依存関係の組み立てはここで一括管理
const prisma = new PrismaClient();
const orderRepository = new PrismaOrderRepository(prisma);

export const createOrderUseCase = new CreateOrderUseCase(orderRepository);

エントリーポイント(container.ts)でインターフェースと実装を結びつけます。この仕組みにより、テスト時にはモック実装を注入するだけで、データベースなしでユースケースのテストが可能になります。

テスト:モック実装によるユニットテスト

// __tests__/CreateOrderUseCase.test.ts
import { CreateOrderUseCase } from "../application/usecase/CreateOrderUseCase";
import { IOrderRepository } from "../domain/repository/IOrderRepository";
import { Order } from "../domain/model/Order";

class InMemoryOrderRepository implements IOrderRepository {
  private store = new Map<string, Order>();
  private counter = 0;

  async findById(id: string): Promise<Order | null> {
    return this.store.get(id) ?? null;
  }

  async save(order: Order): Promise<void> {
    this.store.set(order.id, order);
  }

  async nextId(): Promise<string> {
    return `test-order-${++this.counter}`;
  }
}

describe("CreateOrderUseCase", () => {
  it("注文を作成して確定状態で保存する", async () => {
    const repo = new InMemoryOrderRepository();
    const useCase = new CreateOrderUseCase(repo);

    const result = await useCase.execute({
      customerId: "customer-1",
      items: [
        { productId: "product-A", unitPrice: 1000, quantity: 2 },
        { productId: "product-B", unitPrice: 500, quantity: 3 },
      ],
    });

    expect(result.status).toBe("confirmed");
    expect(result.totalAmount).toBe(3500);

    const saved = await repo.findById(result.id);
    expect(saved).not.toBeNull();
  });
});

InMemoryOrderRepositoryはデータベースの代わりにMapを使うだけの実装です。Prismaもテスト用DBも不要で、ビジネスロジックの正しさだけを高速に検証できます。

ビルドツールで依存方向を強制する

コードレビューだけで依存方向のルールを守り続けるのは現実的に困難です。チームの規模が大きくなるほど、うっかり内側の層から外側のモジュールをimportしてしまう事故が増えます。

海外のプラクティスとして、ビルドツールを使った層境界の強制が注目されています。

Gradle マルチモジュール構成(Java/Kotlin)

Javaプロジェクトの場合、GradleやMavenで層ごとにモジュールを分離すると、コンパイラレベルで依存方向を強制できます。

project-root/
├── domain/
│   └── build.gradle       # 依存なし
├── application/
│   └── build.gradle       # implementation project(':domain')
├── infrastructure/
│   └── build.gradle       # implementation project(':domain', ':application')
└── settings.gradle

domainモジュールは他モジュールに一切依存しないため、infrastructureのクラスをimportしようとするとコンパイルエラーになります。

ArchUnit によるアーキテクチャテスト(Java)

モジュール分割が難しい場合は、ArchUnit というライブラリで依存ルールをテストコードとして記述できます。

@AnalyzeClasses(packages = "com.example")
class ArchitectureTest {
    @ArchTest
    static final ArchRule domainShouldNotDependOnInfrastructure =
        noClasses()
            .that().resideInAPackage("..domain..")
            .should().dependOnClassesThat()
            .resideInAPackage("..infrastructure..");
}

CIパイプラインに組み込めば、依存ルール違反をプルリクエストの段階で自動検出できます。

TypeScript での依存ルール検証

TypeScriptプロジェクトでは、ESLintの import/no-restricted-paths ルールや dependency-cruiser を使って同様の制約を実現できます。

// .dependency-cruiser.cjs(簡略例)
module.exports = {
  forbidden: [
    {
      name: "domain-cannot-import-infrastructure",
      from: { path: "^src/domain" },
      to: { path: "^src/infrastructure" },
    },
    {
      name: "domain-cannot-import-application",
      from: { path: "^src/domain" },
      to: { path: "^src/application" },
    },
  ],
};

類似アーキテクチャとの違い ─ 体系的な比較

オニオンアーキテクチャは単独で存在するわけではなく、類似の設計パターンがいくつかあります。それぞれの特徴を整理します。

観点レイヤードヘキサゴナルオニオンクリーン
提唱者慣習的(定説なし)Alistair Cockburn(2005)Jeffrey Palermo(2008)Robert C. Martin(2012)
依存方向上→下(一方向)内↔外(ポート&アダプタ)外→内(同心円)外→内(同心円)
中心に置くものデータアクセス層が基盤ドメインロジックドメインモデルエンティティ
層の数3層が典型明示的な層定義なし4層(DM/DS/AS/Infra)4層(Entity/UC/Adapter/FW)
UIとDBの扱い別の層どちらも外部アダプタ同じ最外層同じ最外層
DIP不要(順方向依存)ポートで実現インターフェースで実現インターフェースで実現
抽象度低(具体的)中(概念的)中〜高高(抽象的)
DDD親和性低い高い非常に高い高い

ヘキサゴナルアーキテクチャとの違い

ヘキサゴナルアーキテクチャ(Ports & Adapters)はAlistair Cockburn氏が2005年に提唱しました。「ポート」(アプリケーションとの接点を定義するインターフェース)と「アダプタ」(ポートの具体的な実装)という二項対立で構造化します。

オニオンアーキテクチャとの最大の違いは層の粒度です。ヘキサゴナルは「内側(アプリケーション)」と「外側(アダプタ)」の2分割がベースで、内側の構造には踏み込みません。オニオンはドメインモデル・ドメインサービス・アプリケーションサービスと内側を細かく層分けすることで、ドメイン駆動設計との整合性を高めています。

クリーンアーキテクチャとの違い

クリーンアーキテクチャはRobert C. Martin(Uncle Bob)氏が2012年に発表したもので、オニオンアーキテクチャを含む複数のアーキテクチャの共通原則を抽出・統合したものです。

実装上の主な違いは命名と責務の境界にあります。

オニオンの層クリーンの対応層備考
Domain ModelEntityほぼ同義
Domain Service(明示的な層なし)クリーンではEntity層に含まれることが多い
Application ServiceUse Case責務は同じ
InfrastructureInterface Adapters + Frameworks & Driversクリーンは2層に分割

クリーンアーキテクチャは最外層を「Interface Adapters」と「Frameworks & Drivers」に分割しますが、この区分が実装上あいまいになりやすいという声があります。オニオンアーキテクチャはInfrastructure層を1つにまとめることで、判断に迷う場面を減らしています。

DDD(ドメイン駆動設計)との関係

オニオンアーキテクチャとDDDはしばしばセットで語られますが、両者は独立した概念です。

  • DDD:ソフトウェアの設計をビジネスドメインの知識に基づいて行う手法論。エンティティ、値オブジェクト、集約、リポジトリなどの戦術的パターンを含みます
  • オニオンアーキテクチャ:アプリケーションの層構造と依存方向を定義するアーキテクチャパターン

DDDの戦術的パターンはオニオンアーキテクチャの層構造と非常に相性がよく、ドメインモデル層にエンティティ・値オブジェクトを、ドメインサービス層にリポジトリインターフェースを配置するという対応関係が自然に成立します。ただし、オニオンアーキテクチャはDDDなしでも採用できますし、DDDを実践するにあたってオニオンアーキテクチャ以外の選択肢(ヘキサゴナルアーキテクチャなど)もあります。

関数型プログラミングの視点で理解する

オニオンアーキテクチャの層構造は、関数型プログラミングの「純粋関数(calculation)」と「副作用のある処理(action)」の分離として捉えることもできます。

  • 中心のDomain層 = 純粋関数の集合:入力に対して決定論的な結果を返す。DBアクセスもAPI呼び出しも行わない
  • 外側のInfrastructure層 = action(副作用を持つ処理)の集合:ファイルI/O、ネットワーク通信、DB操作を実行

よくある設計ミスとして、「ドメインロジックの途中でDBから追加データを取得する」というパターンがあります。関数型の視点に立つと、これは「純粋関数の中にactionを混入させている」ことを意味します。

正しいアプローチは、ドメインロジックが追加データを必要とする場合に「このデータが必要です」という情報を返し、外側のApplication Service層がデータ取得を行ったうえで再度ドメインロジックを呼び出す、という流れです。これにより、ドメイン層の純粋性が保たれ、テストも容易になります。

マイクロサービスへの拡張

オニオンアーキテクチャは単一のアプリケーション内に閉じるものではなく、マイクロサービス構成にも自然に拡張できます。

各サービスを独立したオニオンとして設計

DDDの境界づけられたコンテキスト(Bounded Context)に沿ってサービスを分割し、各サービス内部をオニオンアーキテクチャで構成するアプローチが有効です。

order-service/
├── domain/
├── application/
└── infrastructure/

payment-service/
├── domain/
├── application/
└── infrastructure/

notification-service/
├── domain/
├── application/
└── infrastructure/

イベント駆動アーキテクチャとの組み合わせ

サービス間の連携にはApache KafkaやAmazon SQSなどのメッセージングを利用し、Infrastructure層にイベントパブリッシャー/サブスクライバーのアダプタを配置します。ドメイン層はイベントの「発行」というドメインの意図だけを持ち、メッセージブローカーの技術的詳細(Kafka、RabbitMQ、SQS等)はInfrastructure層が吸収します。

可観測性(Observability)の確保

分散システムでは、各サービス内のオニオン構造を維持しながら、横断的な可観測性を確保する必要があります。OpenTelemetryによる分散トレーシングやPrometheus/Grafanaによるメトリクス収集はInfrastructure層の関心事として扱い、ドメイン層のコードに計装用のコードを混入させないことが重要です。

導入すべきケースと避けるべきケース

オニオンアーキテクチャは万能ではありません。プロジェクトの特性に合わせて採用を判断する必要があります。

適しているケース

  • ビジネスロジックが複雑:ドメインルールが多く、頻繁に変更される
  • 長期運用が前提:3年以上の保守が見込まれ、インフラ変更の可能性がある
  • チーム規模が中〜大:複数人が並行開発し、層ごとの責務分離が生産性に寄与する
  • テスタビリティが重要:ビジネスロジックのユニットテストカバレッジを高く保ちたい
  • DDDを採用済みまたは導入予定:戦術的パターンとの親和性が高い

適さないケース

  • シンプルなCRUDアプリ:テーブル1〜2個の管理画面など、ビジネスロジックがほぼない場合はオーバーエンジニアリング
  • プロトタイプ / MVP:仮説検証が目的で、コードの寿命が短い場合
  • 高スループットが最優先:リアルタイムプロキシやストリーミング処理など、レイテンシの最小化が最重要な技術指向のサービス
  • 小規模チーム(1〜2人):層分離の恩恵よりも、ボイラープレートの負担が上回る場合がある

よくある疑問と回答

Q. オニオンアーキテクチャとは何ですか?

Jeffrey Palermo氏が2008年に提唱した、ドメインモデルを中心に同心円状の層で構成するアーキテクチャパターンです。依存性逆転の原則により、ビジネスロジックがデータベースやUIに依存しない構造を実現します。

Q. クリーンアーキテクチャとオニオンアーキテクチャの違いは?

クリーンアーキテクチャ(Robert C. Martin, 2012)はオニオンアーキテクチャを含む複数のパターンを統合したものです。主な違いは最外層の分割方法と命名規則にあり、クリーンは「Interface Adapters」と「Frameworks & Drivers」に分割しますが、オニオンは「Infrastructure」として1層にまとめます。本質的な原則(依存方向は外→内、ドメインが中心)は共通です。

Q. 学習にお勧めの書籍は?

  • 「Clean Architecture 達人に学ぶソフトウェアの構造と設計」(Robert C. Martin著、角征典・高木正弘訳、KADOKAWA、2018年):クリーンアーキテクチャの原典ですが、オニオンアーキテクチャの原則も包含しています
  • 「ドメイン駆動設計入門 ボトムアップでわかる!ドメイン駆動設計の基本」(成瀬允宣著、翔泳社、2020年):DDDの戦術的パターンとオニオンアーキテクチャの実装を日本語で学べます
  • 「Grokking Simplicity」(Eric Normand著、Manning、2021年):関数型プログラミングの視点からオニオンアーキテクチャの本質を理解できます

まとめ ─ オニオンアーキテクチャを現場で活かすために

オニオンアーキテクチャの核心は「ビジネスロジックを技術的詳細から守る」というシンプルな原則です。4つの同心円状の層と依存性逆転の原則により、データベースやフレームワークの変更がビジネスロジックに波及しない構造を作ります。

導入にあたっては、次の3点を意識すると効果的です。

  1. まずディレクトリ構成から始める:最初から完璧な層分離を目指すのではなく、domain/application/infrastructure/ のディレクトリ分割から着手し、依存方向のルールを徐々に厳格にしていく
  2. ビルドツールやリンターで依存方向を自動検証する:人間の注意力だけに頼らず、dependency-cruiser や ArchUnit で層境界の違反をCIで検出する
  3. 関数型の視点でドメイン層を検証する:ドメイン層のコードに副作用(DB呼び出し、API通信)が混入していないかを定期的にチェックする

クリーンアーキテクチャやヘキサゴナルアーキテクチャと比較して、オニオンアーキテクチャはDDDとの親和性と実装の明快さのバランスに優れています。長期運用される中〜大規模プロジェクトで、ドメインロジックの複雑さに立ち向かうための有力な選択肢です。