オニオンアーキテクチャにおけるインフラ層の設計とテスト

2024/12/22に公開

概要

  • オニオンアーキテクチャ: ドメインを中心に、UI・アプリケーション層・インフラ層を外側に配置し、疎結合と保守性を高める設計手法
  • 本記事の目的: NestJS + TypeORM を想定し、インフラ層(リポジトリ)の設計やテスト戦略、複数集約連携で使われるドメインイベントとSagaの概念を解説し、より具体的な実装例やテストのポイントを共有する

1. オニオンアーキテクチャとは

  1. UI層
    • コントローラなどでリクエスト受付・レスポンス生成
  2. アプリケーション層
    • ユースケース(ビジネスロジックの大まかな流れ)を定義し、外部サービスや複数集約間のやりとりをまとめる
  3. ドメイン層
    • ビジネスルールをエンティティや集約 (Aggregate) として定義。ドメインイベントもここで扱う
  4. インフラ層
    • DBや外部APIとの接続、リポジトリ実装など技術的関心を集約し、上層に抽象化して提供

Key Point: 「ドメイン(ビジネスロジック)中心の設計」と「外部依存をインフラ層に閉じ込める」ことが重要


2. インフラ層の役割

2.1 リポジトリ

  • データアクセスを抽象化し、ドメイン層から DB や ORM の詳細を隠す
  • エンティティごと、あるいは集約単位で用意する (例: UserRepository, OrderRepository)
  • 大規模になるにつれて、単一責務の原則を厳守すると保守性が高まる

2.1.1 基本的な実装例

// IUserRepository.ts (インターフェース)
export interface IUserRepository {
  findById(id: number): Promise<User | null>;
  save(user: User): Promise<User>;
}
// user.repository.impl.ts (実装例)
@Injectable()
export class UserRepositoryImpl implements IUserRepository {
  constructor(private dataSource: DataSource) {}

  private get repo(): Repository<User> {
    return this.dataSource.getRepository(User);
  }

  async findById(id: number): Promise<User | null> {
    return this.repo.findOne({ where: { id } });
  }

  async save(user: User): Promise<User> {
    return this.repo.save(user);
  }
}

2.1.2 createQueryBuilder を使った複数条件検索・集計

TypeORM の createQueryBuilder を使うと、より柔軟なクエリを組み立てられます。
例えば、複数のカラムを AND / OR 検索したり、集計や結合 (JOIN) を行うケースで便利です。

// IUserRepository.ts (インターフェース拡張例)
export interface IUserRepository {
  findByNameAndStatus(name: string, status: string): Promise<User[]>;
  getAverageAgeByStatus(status: string): Promise<number>;
  findActiveUsersWithRoles(): Promise<User[]>;
  // ほかCRUD系メソッド...
}
// user.repository.impl.ts (QueryBuilderサンプル)
import { Injectable } from '@nestjs/common';
import { DataSource, Repository } from 'typeorm';
import { User } from '../entities/user.entity';
import { IUserRepository } from './IUserRepository';

@Injectable()
export class UserRepositoryImpl implements IUserRepository {
  constructor(private dataSource: DataSource) {}

  private get repo(): Repository<User> {
    return this.dataSource.getRepository(User);
  }

  /**
   * 名前とステータスをAND条件検索する例
   */
  async findByNameAndStatus(name: string, status: string): Promise<User[]> {
    return this.repo
      .createQueryBuilder('user')
      .where('user.name = :name', { name })
      .andWhere('user.status = :status', { status })
      .orderBy('user.createdAt', 'DESC')
      .getMany();
  }

  /**
   * ステータスごとの平均年齢を算出する例
   */
  async getAverageAgeByStatus(status: string): Promise<number> {
    const result = await this.repo
      .createQueryBuilder('user')
      .select('AVG(user.age)', 'avg_age')
      .where('user.status = :status', { status })
      .getRawOne<{ avg_age: string }>();

    return result?.avg_age ? Number(result.avg_age) : 0;
  }

  /**
   * 他テーブルとのJOIN例 (UserがRoleを参照しているケース)
   */
  async findActiveUsersWithRoles(): Promise<User[]> {
    return this.repo
      .createQueryBuilder('user')
      .leftJoinAndSelect('user.roles', 'role')
      .where('user.status = :status', { status: 'ACTIVE' })
      .orderBy('user.createdAt', 'ASC')
      .getMany();
  }
}

Note: クエリが複雑化しすぎる場合は、ドメインロジックを侵食しないよう注意しましょう。リポジトリはあくまでもデータアクセスを担い、ビジネス上の判断はドメインサービスやアプリケーションサービスに委譲するのが基本方針です。

2.2 外部サービス連携

  • REST API、gRPC、メッセージング(RabbitMQ, Kafkaなど)といった技術的依存を一手に引き受ける
  • アプリケーション層やドメイン層には抽象インターフェースだけ提供し、実装詳細は隠す

3. ドメインイベントと Saga とは?

3.1 ドメインイベント

  • ドメイン層で起きる重要な出来事をオブジェクトとして表現する概念
  • 例:
    • UserRegistered: ユーザーが新規登録された
    • OrderCompleted: 注文が完了した
  • ドメインイベントを発行すると、イベントハンドラーがそれを受け取って別の集約を更新したり、通知を送ったりする
  • 同期でも非同期でも可。非同期の場合はイベントブローカー(Kafkaなど)を使うことが多い

メリット

  1. 疎結合: イベントを通して連携するため、発行元と受信先の直接的依存が減る
  2. 拡張性: 新たな機能を追加したい場合、イベントリスナーを追加するだけでOK

ドメインイベントの簡単な実装例

// user-registered.event.ts
export class UserRegisteredEvent {
  constructor(
    public readonly userId: number,
    public readonly registeredAt: Date,
  ) {}
}
// domain/user.entity.ts
export class User {
  // ...fields...
  private domainEvents: any[] = []; // DomainEventベースの型配列

  constructor(
    // constructor fields...
  ) {
    // 例: 新規作成時にイベントをプッシュ
    this.domainEvents.push(new UserRegisteredEvent(this.id, new Date()));
  }

  getDomainEvents() {
    return this.domainEvents;
  }

  clearDomainEvents() {
    this.domainEvents = [];
  }
}
// application/user.service.ts
@Injectable()
export class UserService {
  constructor(private userRepository: IUserRepository) {}

  async registerUser(name: string): Promise<void> {
    const newUser = new User(name);
    await this.userRepository.save(newUser);

    // 発行されたドメインイベントを取得してハンドリングする・キューに送るなど
    const events = newUser.getDomainEvents();
    // 例: メッセージブローカーに送信 or Syncハンドラを呼ぶ
  }
}

3.2 Saga パターン

  • 分散トランザクションや複数の集約・マイクロサービス間のワークフローを管理する設計パターン
  • 大規模システムやマイクロサービスアーキテクチャで、各サービスのローカルトランザクションを「ゆるやかに」つなぐ
  • Sagaはドメインイベントをトリガーに次のステップを呼び出し、ロールバック(補償トランザクション)を発行する流れを整備する

  1. OrderPlaced イベントが発火
  2. Saga が受け取り、PaymentService に支払い要求
  3. PaymentConfirmed イベントが発火
  4. ShippingService へ出荷指示
  5. エラー時は補償トランザクションで状態を元に戻す

Key Point: Sagaはイベント駆動でステップを連鎖させる仕組み。単一DB内のトランザクションが困難な場合に力を発揮

簡単なSaga(補償トランザクション)の例

@Injectable()
export class OrderSaga {
  // Sagaが購読するイベントの例
  @OnEvent('OrderPlaced')
  async handleOrderPlaced(event: OrderPlacedEvent) {
    try {
      // PaymentService呼び出し → paymentConfirmedイベント or エラー
    } catch (err) {
      // 補償トランザクション (例: 在庫を戻す、状態を"Canceled"へ戻すなど)
    }
  }
}

4. 複数集約連携:トランザクション vs ドメインイベント/Saga

4.1 単一DB・小規模ならトランザクション

  • アプリケーション層で複数集約を同一トランザクションにまとめ、整合性を保つ
  • 外部サービス呼び出しはトランザクション外にするのがベター
@Injectable()
export class OrderService {
  constructor(
    private dataSource: DataSource,
    private userRepo: UserRepositoryImpl,
    private orderRepo: OrderRepositoryImpl
  ) {}

  async processOrder(userId: number, orderId: number): Promise<void> {
    const queryRunner = this.dataSource.createQueryRunner();
    await queryRunner.startTransaction();

    try {
      // ユーザー集約 (ポイント操作)
      const user = await queryRunner.manager.findOneOrFail(User, { where: { id: userId } });
      user.points -= 10;
      await queryRunner.manager.save(user);

      // オーダー集約 (ステータス更新)
      const order = await queryRunner.manager.findOneOrFail(Order, { where: { id: orderId } });
      order.status = 'COMPLETED';
      await queryRunner.manager.save(order);

      await queryRunner.commitTransaction();
    } catch (err) {
      await queryRunner.rollbackTransaction();
      throw err;
    } finally {
      await queryRunner.release();
    }
  }
}

4.2 大規模・分散ならドメインイベント or Saga

  • ドメインイベント: 集約Aで状態が変わったら DomainEvent を発行し、集約Bや別サービスがそれを受けて処理する
  • Saga: 分散した複数サービス間のトランザクションシーケンスをイベント駆動で管理。エラー時の補償トランザクションも定義

Best Practice:

  • シンプルなアプリ ⇒ トランザクションを使い倒す
  • 大規模 or マイクロサービス ⇒ イベント + Saga でゆるやかな整合性

5. テスト戦略(ユニット/統合/E2E)

5.1 ユニットテスト

  • 目的: ビジネスロジック(ドメインサービスやアプリケーションサービス)の検証
  • 手法: リポジトリをモック化し、期待どおりの呼び出しが行われるかテスト
describe('UserService', () => {
  let userService: UserService;
  let mockUserRepo: IUserRepository;

  beforeEach(() => {
    mockUserRepo = {
      findById: jest.fn(),
      save: jest.fn(),
    } as IUserRepository;

    userService = new UserService(mockUserRepo);
  });

  it('should update user name correctly', async () => {
    (mockUserRepo.findById as jest.Mock).mockResolvedValue({ id: 1, name: 'OldName' });
    await userService.updateUserName(1, 'NewName');

    expect(mockUserRepo.save).toHaveBeenCalledWith({ id: 1, name: 'NewName' });
  });
});

ポイント: 「リポジトリが特定のタイミングで呼ばれるか」「ビジネスロジックが期待通りに動くか」を中心に検証しましょう。

5.2 統合テスト

  • 目的: 実際のDBやリポジトリを使用し、CRUDやトランザクション動作をチェック
  • 手法: Docker などでテスト用DBを立ち上げ、TypeOrmModule.forRoot をテスト環境設定にする
describe('UserRepository Integration', () => {
  let userRepository: UserRepositoryImpl;

  beforeAll(async () => {
    const moduleRef = await Test.createTestingModule({
      imports: [
        TypeOrmModule.forRoot({
          type: 'postgres',
          host: 'localhost',
          port: 5432,
          username: 'test',
          password: 'test',
          database: 'testdb',
          synchronize: true,
          entities: [User],
        }),
        TypeOrmModule.forFeature([User]),
      ],
      providers: [UserRepositoryImpl],
    }).compile();

    userRepository = moduleRef.get(UserRepositoryImpl);
  });

  it('should create a user', async () => {
    const user = await userRepository.save({ name: 'IntegrationTestUser' } as User);
    expect(user.id).toBeDefined();
    expect(user.name).toBe('IntegrationTestUser');
  });
});

ポイント: テスト用のDBを使って実際のクエリ・マイグレーションなどの動作を確認し、エラーやクエリの不備を早期発見できます。

5.3 E2Eテスト

  • 目的: Controller経由で複数集約・外部サービスを含むシナリオを通し、DBの整合性やレスポンスを検証
  • 活用シーン: Sagaを実装している場合や、ドメインイベント発行を含むフローの確認

Key Point: テスト段階でも、トランザクション or イベント駆動のシナリオをしっかりカバーする。モックを減らし、本番同様の挙動を通せるように工夫しましょう。


6. アンチパターンと回避策

  1. リポジトリにビジネスロジックを詰め込む
    • 回避: リポジトリはデータアクセスのみ、ロジックはドメインサービスやアプリケーションサービスへ
  2. 巨大なトランザクション
    • 回避: 集約ごとに切り分け、必要に応じてイベント駆動 (Saga) も検討
  3. トランザクション内で外部サービスを呼ぶ
    • 回避: ロールバック時に整合性が崩れるリスクあり、基本的に外部呼び出しは分離
  4. Anemic Domain Model
    • 回避: ドメインイベントやドメインサービスなどを活用し、ビジネスロジックをドメイン層に集約
  5. 1つのリポジトリに複数集約を詰め込む
    • 回避: 単一責務 (SRP) を守り、集約ごとにリポジトリを分割

7. まとめ

  1. オニオンアーキテクチャ: ドメインを最重要視し、インフラ依存を外側に隔離する設計
  2. ドメインイベント: ドメインで起こった「重要な出来事」を通知し、疎結合に集約間・サービス間を連携する
  3. Saga: 分散トランザクションや複数サービス連携をイベント駆動で管理し、ロールバック(補償トランザクション)にも対応
  4. テスト戦略: ユニット・統合・E2Eの3レイヤーで、トランザクションやイベント駆動シナリオを含めて検証

これらのポイントを踏まえることで、ドメインロジックをしっかり守りながら、複数集約や分散環境での整合性を確保できます。プロジェクトの規模や要件に応じて、トランザクションとイベント駆動(Saga)を使い分けましょう。ぜひ、ご自身のプロジェクトに応用してみてください。


Discussion