NestJSとASP.NET Coreにおけるヘキサゴナルアーキテクチャを用いた堅牢なアプリケーション構築
Lukas Schneider
DevOps Engineer · Leapcell

はじめに
バックエンド開発が絶えず進化する状況において、堅牢で保守性が高く、変化に適応できるアプリケーションを構築することは極めて重要です。システムが複雑化するにつれて、密結合なアーキテクチャは、テストを困難にし、リファクタリングを危険にし、スケーリングを悪夢にするという大きな問題を引き起こす可能性があります。ここで、ポーツ・アンド・アダプターとしても知られるヘキサゴナルアーキテクチャのようなアーキテクチャパターンが、魅力的なソリューションを提供します。このパターンは、コアビジネスロジックを外部の依存関係やフレームワークから分離することで、技術的な変化やインフラストラクチャの変更に対して回復力のあるシステムを構築することを開発者に可能にします。この記事では、Node.jsエコシステムのNestJSと.NETの世界のASP.NET Coreという2つの人気のあるバックエンドフレームワーク内で、ヘキサゴナルアーキテクチャの実装について掘り下げ、真に柔軟でテスト可能なアプリケーションを作成するために、その原則を活用する方法を示します。
ヘキサゴナルアーキテクチャのコアコンセプト
コードに入る前に、ヘキサゴナルアーキテクチャの基礎となる基本的な概念を明確に理解しましょう。
- ヘキサゴナルアーキテクチャ(ポーツ・アンド・アダプター): このアーキテクチャパターンは、コアビジネスロジック(「内部」)を外部の懸念事項(「外部」)から分離することにより、疎結合なアプリケーションコンポーネントを作成することを目的としています。「六角形」はアプリケーションコアを表し、その側面は外部システムとの対話を可能にする「ポート」です。
- ポート: これらは、対話の契約を定義するアプリケーションコアが所有するインターフェースです。これらは、アプリケーションの「意図」または「機能」を表します。ポートには主に2つのタイプがあります。
- ドライビングポート(プライマリポート): UI、APIクライアントなどの外部アクターによって、アプリケーションの動作を駆動するために呼び出されます。これらはアプリケーションのAPIを表します。
- ドリブンポート(セカンダリポート): データベース、メッセージキューなどの外部サービスによって実装され、アプリケーションコアによって操作を実行するために呼び出されます。これらは、外部インフラストラクチャに対するアプリケーションの依存関係を表します。
- アダプター: これらは、ポートを介して「外部」世界をアプリケーションの「内部」に接続する具体的な実装です。
- ドライビングアダプター(プライマリアダプター): 外部リクエストをアプリケーションのドライビングポートへの呼び出しに変換します(例:RESTコントローラー、GraphQLリゾルバー)。
- ドリブンアダプター(セカンダリアダプター): ドリブンポートを実装し、アプリケーションコアのリクエストを特定のテクノロジー呼び出しに変換します(例:データベースリポジトリ、HTTPクライアント)。
- アプリケーションコア(ドメイン): これはアプリケーションの中核であり、ビジネスロジック、エンティティ、ユースケースを含みます。特定のテクノロジーやフレームワークに依存しないようにする必要があります。
この分離の主な利点は、アプリケーションコアがアダプターで使用される特定のテクノロジーを認識しないことです。リレーショナルデータベースをNoSQLデータベースに切り替えたり、メッセージキューを変更したりしても、コアビジネスロジックを変更せずに済みます。
NestJSでのヘキサゴナルアーキテクチャの実装
NestJSは、そのモジュールベースの構造と依存性注入への強い依存性により、ヘキサゴナルアーキテクチャの実装に最適です。簡単な例として、Product
管理機能を見てみましょう。
1. アプリケーションコア(ドメインレイヤー)
まず、コアのProduct
エンティティと、それを操作するユースケース(サービス)を定義します。
// src/product/domain/entities/product.entity.ts export class Product { constructor( public id: string, public name: string, public description: string, public price: number, ) {} // Productに関連するビジネスロジック updatePrice(newPrice: number): void { if (newPrice <= 0) { throw new Error('Price must be positive'); } this.price = newPrice; } } // src/product/domain/ports/product.repository.port.ts (ドリブンポート) export interface ProductRepositoryPort { findById(id: string): Promise<Product | null>; save(product: Product): Promise<Product>; findAll(): Promise<Product[]>; delete(id: string): Promise<void>; } // src/product/domain/ports/product.service.port.ts (ドライビングポート) - アプリケーションサービスの概念的なポートです。 // NestJSでは、これはコントローラーから消費される注入可能なサービスに直接マッピングされることがよくあります。 // 具体的なサービスはアプリケーションレイヤーの一部として定義します。 // src/product/application/dtos/create-product.dto.ts export class CreateProductDto { name: string; description: string; price: number; } // src/product/application/dtos/update-product.dto.ts export class UpdateProductDto { name?: string; description?: string; price?: number; } // src/product/application/services/product.service.ts (アプリケーションサービス - 概念的なドライビングポートを実装) import { Injectable, Inject } from '@nestjs/common'; import { ProductRepositoryPort } from '../../domain/ports/product.repository.port'; import { Product } from '../../domain/entities/product.entity'; import { CreateProductDto } from '../dtos/create-product.dto'; import { UpdateProductDto } from '../dtos/update-product.dto'; import { v4 as uuidv4 } from 'uuid'; @Injectable() export class ProductService { constructor( @Inject('ProductRepositoryPort') private readonly productRepository: ProductRepositoryPort, ) {} async createProduct(dto: CreateProductDto): Promise<Product> { const newProduct = new Product(uuidv4(), dto.name, dto.description, dto.price); return this.productRepository.save(newProduct); } async getProductById(id: string): Promise<Product | null> { return this.productRepository.findById(id); } async getAllProducts(): Promise<Product[]> { return this.productRepository.findAll(); } async updateProduct(id: string, dto: UpdateProductDto): Promise<Product> { let product = await this.productRepository.findById(id); if (!product) { throw new Error(`Product with ID ${id} not found.`); } if (dto.name) product.name = dto.name; if (dto.description) product.description = dto.description; if (dto.price) product.updatePrice(dto.price); // ドメインロジックを使用 return this.productRepository.save(product); } async deleteProduct(id: string): Promise<void> { await this.productRepository.delete(id); } }
2. インフラストラクチャアダプター
次に、ProductRepositoryPort
の具体的なアダプターを実装します。
// src/product/infrastructure/adapters/in-memory-product.repository.ts (ドリブンアダプター) import { Injectable } from '@nestjs/common'; import { ProductRepositoryPort } from '../../domain/ports/product.repository.port'; import { Product } from '../../domain/entities/product.entity'; @Injectable() export class InMemoryProductRepository implements ProductRepositoryPort { private products: Product[] = []; constructor() { // デモンストレーションのために初期データでシード this.products.push(new Product('1', 'Laptop', 'Powerful laptop', 1200)); this.products.push(new Product('2', 'Mouse', 'Ergonomic mouse', 25)); } async findById(id: string): Promise<Product | null> { return this.products.find(p => p.id === id) || null; } async save(product: Product): Promise<Product> { const index = this.products.findIndex(p => p.id === product.id); if (index > -1) { this.products[index] = product; } else { this.products.push(product); } return product; } async findAll(): Promise<Product[]> { return [...this.products]; } async delete(id: string): Promise<void> { this.products = this.products.filter(p => p.id !== id); } } // TypeORMProductRepositoryで簡単に置き換えることができます。 // src/product/infrastructure/adapters/typeorm-product.repository.ts // import { Injectable } from '@nestjs/common'; // import { InjectRepository } from '@nestjs/typeorm'; // import { Repository } from 'typeorm'; // import { ProductRepositoryPort } from '../../domain/ports/product.repository.port'; // import { Product } from '../../domain/entities/product.entity'; // import { ProductORMEntity } from '../entities/product.orm-entity'; // TypeORMエンティティ定義 // // @Injectable() // export class TypeORMProductRepository implements ProductRepositoryPort { // constructor( // @InjectRepository(ProductORMEntity) // private readonly typeormRepo: Repository<ProductORMEntity>, // ) {} // // async findById(id: string): Promise<Product | null> { // const ormEntity = await this.typeormRepo.findOneBy({ id }); // return ormEntity ? ProductMapper.toDomain(ormEntity) : null; // } // // ... save, findAll, delete の同様の実装 // }
3. ドライビングアダプター(プレゼンテーションレイヤー)
REST APIコントローラーはドライビングアダプターとして機能し、HTTPリクエストをProductService
への呼び出しに変換します。
// src/product/presentation/product.controller.ts (ドライビングアダプター) import { Controller, Get, Post, Put, Delete, Body, Param } from '@nestjs/common'; import { ProductService } from '../application/services/product.service'; import { CreateProductDto } from '../application/dtos/create-product.dto'; import { UpdateProductDto } from '../application/dtos/update-product.dto'; import { Product } from '../domain/entities/product.entity'; @Controller('products') export class ProductController { constructor(private readonly productService: ProductService) {} @Post() async create(@Body() createProductDto: CreateProductDto): Promise<Product> { return this.productService.createProduct(createProductDto); } @Get(':id') async findOne(@Param('id') id: string): Promise<Product | null> { return this.productService.getProductById(id); } @Get() async findAll(): Promise<Product[]> { return this.productService.getAllProducts(); } @Put(':id') async update(@Param('id') id: string, @Body() updateProductDto: UpdateProductDto): Promise<Product> { return this.productService.updateProduct(id, updateProductDto); } @Delete(':id') async remove(@Param('id') id: string): Promise<void> { await this.productService.deleteProduct(id); } }
4. モジュール設定
NestJSモジュールは依存関係のオーケストレーションに不可欠です。ここでは、ProductService
をProductController
にバインドし、InMemoryProductRepository
をProductRepositoryPort
の実装として提供します。
// src/product/product.module.ts import { Module } from '@nestjs/common'; import { ProductService } from './application/services/product.service'; import { ProductController } from './presentation/product.controller'; import { InMemoryProductRepository } from './infrastructure/adapters/in-memory-product.repository'; @Module({ imports: [], controllers: [ProductController], providers: [ ProductService, { provide: 'ProductRepositoryPort', // インターフェイストークンを提供 useClass: InMemoryProductRepository, // 具体的な実装を使用 }, ], exports: [ProductService], // 他のモジュールがProductServiceを消費する必要がある場合 }) export class ProductModule {} // app.module.tsでProductModuleをインポート // import { ProductModule } from './product/product.module'; // @Module({ // imports: [ProductModule], // controllers: [], // providers: [], // }) // export class AppModule {}
このセットアップにより、ドメインロジック(Product
、ProductRepositoryPort
)は、データベース実装(InMemoryProductRepository
)とAPIレイヤー(ProductController
)の両方から明確に分離されます。TypeORMに切り替えたい場合、ProductModule
でuseClass
プロバイダーを変更し、TypeORMProductRepository
を作成するだけで済みます。ProductService
とProductController
は変更されません。
ASP.NET Coreでのヘキサゴナルアーキテクチャの実装
ASP.NET Coreの組み込み依存性注入とレイヤー化されたアーキテクチャは、自然にヘキサゴナルアーキテクチャに適しています。Product
の例を再現してみましょう。
1. アプリケーションコア(ドメインレイヤー)
Product
エンティティと、製品ストレージのコア契約を定義します。
// Products/Domain/Entities/Product.cs namespace HexagonalNetCore.Products.Domain.Entities { public class Product { public Guid Id { get; private set; } public string Name { get; private set; } public string Description { get; private set; } public decimal Price { get; private set; } public Product(Guid id, string name, string description, decimal price) { if (id == Guid.Empty) throw new ArgumentException("Id cannot be empty.", nameof(id)); if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Name cannot be empty.", nameof(name)); if (price <= 0) throw new ArgumentException("Price must be positive.", nameof(price)); Id = id; Name = name; Description = description; Price = price; } // ビジネスロジックのためのメソッド public void UpdatePrice(decimal newPrice) { if (newPrice <= 0) { throw new ArgumentException("Price must be positive.", nameof(newPrice)); } Price = newPrice; } public void UpdateDetails(string? name, string? description) { if (!string.IsNullOrWhiteSpace(name)) Name = name; if (!string.IsNullOrWhiteSpace(description)) Description = description; } } } // Products/Domain/Ports/IProductRepository.cs (ドリブンポート) using HexagonalNetCore.Products.Domain.Entities; using System.Collections.Generic; using System.Threading.Tasks; namespace HexagonalNetCore.Products.Domain.Ports { public interface IProductRepository { Task<Product?> GetByIdAsync(Guid id); Task<IEnumerable<Product>> GetAllAsync(); Task AddAsync(Product product); Task UpdateAsync(Product product); Task DeleteAsync(Product product); } } // Products/Application/DTOs/CreateProductDto.cs namespace HexagonalNetCore.Products.Application.DTOs { public class CreateProductDto { public string Name { get; set; } = string.Empty; public string Description { get; set; } = string.Empty; public decimal Price { get; set; } } } // Products/Application/DTOs/UpdateProductDto.cs namespace HexagonalNetCore.Products.Application.DTOs { public class UpdateProductDto { public string? Name { get; set; } public string? Description { get; set; } public decimal? Price { get; set; } } } // Products/Application/Services/ProductService.cs (アプリケーションサービス - 概念的なドライビングポートを実装) using HexagonalNetCore.Products.Application.DTOs; using HexagonalNetCore.Products.Domain.Entities; using HexagonalNetCore.Products.Domain.Ports; using System; using System.Collections.Generic; using System.Threading.Tasks; namespace HexagonalNetCore.Products.Application.Services { public class ProductService { private readonly IProductRepository _productRepository; public ProductService(IProductRepository productRepository) { _productRepository = productRepository; } public async Task<Product> CreateProductAsync(CreateProductDto dto) { var product = new Product(Guid.NewGuid(), dto.Name, dto.Description, dto.Price); await _productRepository.AddAsync(product); return product; } public async Task<Product?> GetProductByIdAsync(Guid id) { return await _productRepository.GetByIdAsync(id); } public async Task<IEnumerable<Product>> GetAllProductsAsync() { return await _productRepository.GetAllAsync(); } public async Task<Product> UpdateProductAsync(Guid id, UpdateProductDto dto) { var product = await _productRepository.GetByIdAsync(id); if (product == null) { throw new ArgumentException($