概要
- オニオンアーキテクチャ: ドメインを中心に、UI・アプリケーション層・インフラ層を外側に配置し、疎結合と保守性を高める設計手法
- 本記事の目的: NestJS + TypeORM を想定し、インフラ層(リポジトリ)の設計やテスト戦略、複数集約連携で使われるドメインイベントとSagaの概念を解説
1. オニオンアーキテクチャとは
-
UI層
- コントローラなどでリクエスト受付・レスポンス生成
-
アプリケーション層
- ユースケース(ビジネスロジックの大まかな流れ)を定義し、外部サービスや複数集約間のやりとりをまとめる
-
ドメイン層
- ビジネスルールをエンティティや集約 (Aggregate) として定義。ドメインイベントもここで扱う
-
インフラ層
- DBや外部APIとの接続、リポジトリ実装など技術的関心を集約し、上層に抽象化して提供
Key Point: 「ドメイン(ビジネスロジック)中心の設計」と「外部依存をインフラ層に閉じ込める」ことが重要
2. インフラ層の役割
2.1 リポジトリ
- データアクセスを抽象化し、ドメイン層から DB や ORM の詳細を隠す
- エンティティごと、あるいは集約単位で用意する (例:
UserRepository
,OrderRepository
)
// 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.2 外部サービス連携
- REST API、gRPC、メッセージング(RabbitMQ, Kafkaなど)といった 技術的依存を一手に引き受ける
- アプリケーション層やドメイン層には 抽象インターフェースだけ提供し、実装詳細は隠す
3. ドメインイベントと Saga とは?
3.1 ドメインイベント
- ドメイン層で起きる重要な出来事をオブジェクトとして表現する概念
- 例:
-
UserRegistered
: ユーザーが新規登録された -
OrderCompleted
: 注文が完了した
-
- ドメインイベントを発行すると、イベントハンドラーがそれを受け取って別の集約を更新したり、通知を送ったりする
- 同期でも非同期でも可。非同期の場合はイベントブローカー(Kafkaなど)を使うことが多い
メリット
- 疎結合: イベントを通して連携するため、発行元と受信先の直接的依存が減る
- 拡張性: 新たな機能を追加したい場合、イベントリスナーを追加するだけでOK
3.2 Saga パターン
- 分散トランザクションや複数の集約・マイクロサービス間のワークフローを管理する設計パターン
- 大規模システムやマイクロサービスアーキテクチャで、各サービスのローカルトランザクションを「ゆるやかに」つなぐ
- Sagaはドメインイベントをトリガーに次のステップを呼び出し、ロールバック(補償トランザクション)を発行する流れを整備する
例
-
OrderPlaced
イベントが発火 - Saga が受け取り、
PaymentService
に支払い要求 -
PaymentConfirmed
イベントが発火 -
ShippingService
へ出荷指示 - エラー時は補償トランザクションで状態を元に戻す
Key Point: Saga はイベント駆動でステップを連鎖させる仕組み。単一DB内のトランザクションが困難な場合に力を発揮
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');
});
});
5.3 E2Eテスト
- 目的: Controller経由で複数集約・外部サービスを含むシナリオを通し、DBの整合性やレスポンスを検証
- 活用シーン: Sagaを実装している場合や、ドメインイベント発行を含むフローの確認
Key Point: テスト段階でも、トランザクション or イベント駆動のシナリオをしっかりカバーする
6. アンチパターンと回避策
-
リポジトリにビジネスロジックを詰め込む
- 回避: リポジトリはデータアクセスのみ、ロジックはドメインサービスやアプリケーションサービスへ
-
巨大なトランザクション
- 回避: 集約ごとに切り分け、必要に応じてイベント駆動 (Saga) も検討
-
トランザクション内で外部サービスを呼ぶ
- 回避: ロールバック時に整合性が崩れるリスクあり、基本的に外部呼び出しは分離
-
Anemic Domain Model
- 回避: ドメインイベントやドメインサービスなどを活用し、ビジネスロジックをドメイン層に集約
-
1つのリポジトリに複数集約を詰め込む
- 回避: 単一責務 (SRP) を守り、集約ごとにリポジトリを分割
7. まとめ
- オニオンアーキテクチャ: ドメインを最重要視し、インフラ依存を外側に隔離する設計
- ドメインイベント: ドメインで起こった「重要な出来事」を通知し、疎結合に集約間・サービス間を連携する
- Saga: 分散トランザクションや複数サービス連携をイベント駆動で管理し、ロールバックにも対応するパターン
- テスト戦略: ユニット・統合・E2Eの3レイヤーで、トランザクションやイベント駆動のシナリオを含めて検証
以上のポイントを踏まえれば、ドメインロジックをしっかり守りながら、複数集約・分散環境での整合性確保を実現できます。ぜひご自身のプロジェクトに応用してみてください。