NestJSとASP.NET Coreにおけるリポジトリパターンを用いたデータアクセス分離
Min-jun Kim
Dev Intern · Leapcell

はじめに
バックエンド開発の複雑な世界では、データ操作の管理はしばしば重要な課題となります。アプリケーションが複雑化するにつれて、ビジネスロジックと特定のデータアクセス機構が直接絡み合うと、壊れやすく、保守が困難で、テストしにくいコードベースにつながる可能性があります。データベース技術やORMの変更、アプリケーションの大部分のオーバーホールが必要になるシナリオを想像してみてください。これはまさに、関心の分離のために設計されたパターンが非常に役立つ場所です。これらのパターンの中でも、リポジトリパターンはモジュール性とテスト容易性を強力に促進するものであり、ドメインロジックと基盤となるデータストアとの間にクリーンなインターフェイスとして効果的に機能します。この記事では、2つの著名なバックエンドフレームワーク、NestJSとASP.NET Coreにおけるリポジトリパターンの実践的な実装について掘り下げ、アプリケーションのアーキテクチャを劇的に改善する方法を示します。
リポジトリパターンのコアコンセプト
実際の実装に入る前に、関連する主要な概念を明確に理解しましょう。
ドメインモデル: これは、アプリケーションのコアエンティティとビジネスロジックを表し、データ永続化の懸念から独立しています。例えば、eコマースアプリケーションでは、Product、Order、Customerはドメインモデルの一部となります。
データアクセスレイヤー (DAL): このレイヤーは、データベース(SQL、NoSQL)、外部API、あるいはファイルシステムなどのデータ永続化メカニズムとのやり取りを担当します。データのクエリ、挿入、更新、削除などのタスクはここに属します。
リポジトリパターン: データ取得および保存方法を抽象化するデザインパターンであり、アプリケーションのドメインオブジェクトを基盤となるデータアクセス技術から独立させます。これは、ドメインオブジェクトのインメモリコレクションとして機能し、これらのオブジェクトの追加、削除、検索、更新のためのメソッドを提供します。
ユニットオブワーク (オプションですが推奨): リポジトリパターンと組み合わせてよく使用されるユニットオブワークパターンは、一連の操作を単一のトランザクションとして管理します。ビジネス取引内のすべての変更がまとめてコミットされるか、まとめてロールバックされることを保証し、データの一貫性を維持します。
リポジトリパターンの主な目標は、ビジネスロジックをデータアクセス技術の変更から保護することです。抽象化レイヤーを導入することで、サービスやコントローラーは、ORMや生のデータベースクエリに直接ではなく、リポジトリとやり取りします。
リポジトリパターンの実装
NestJSとASP.NET Coreの両方でリポジトリパターンの実際の実装を探り、コア原則を強調し、説明的なコード例を共有しましょう。
リポジトリがない場合の課題
典型的なサービスがORMと直接やり取りする例を考えてみましょう。
// NestJS without Repository // product.service.ts import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Product } from './product.entity'; @Injectable() export class ProductService { constructor( @InjectRepository(Product) private productRepository: Repository<Product>, ) {} async createProduct(name: string, price: number): Promise<Product> { const product = this.productRepository.create({ name, price }); return this.productRepository.save(product); } async findAllProducts(): Promise<Product[]> { return this.productRepository.find(); } }
このコードは、TypeORMのRepositoryメソッドを直接公開しています。TypeORMからMongooseやカスタムデータベースクライアントに切り替える場合、ProductServiceの直接の変更が必要になります。この密結合はテストをより困難にし、単純な単体テストでもデータベースの設定が必要になります。
NestJSでのリポジトリパターンの実装
NestJSでは、TypeScriptインターフェイスと依存性注入を活用して、リポジトリパターンを効果的に実装できます。
まず、リポジトリのコントラクトを定義します。
// src/product/interfaces/product-repository.interface.ts import { Product } from '../product.entity'; export interface IProductRepository { create(product: Partial<Product>): Promise<Product>; findAll(): Promise<Product[]>; findById(id: number): Promise<Product | undefined>; update(id: number, product: Partial<Product>): Promise<Product | undefined>; delete(id: number): Promise<void>; } // 依存性注入のためのトークンを定義します export const PRODUCT_REPOSITORY = 'PRODUCT_REPOSITORY';
次に、TypeORMを使用してこのインターフェイスを実装します。
// src/product/infrastructure/typeorm-product.repository.ts import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Product } from '../product.entity'; import { IProductRepository } from '../interfaces/product-repository.interface'; @Injectable() export class TypeOrmProductRepository implements IProductRepository { constructor( @InjectRepository(Product) private readonly ormRepository: Repository<Product>, ) {} async create(productData: Partial<Product>): Promise<Product> { const product = this.ormRepository.create(productData); return await this.ormRepository.save(product); } async findAll(): Promise<Product[]> { return await this.ormRepository.find(); } async findById(id: number): Promise<Product | undefined> { return await this.ormRepository.findOne({ where: { id } }); } async update(id: number, productData: Partial<Product>): Promise<Product | undefined> { await this.ormRepository.update(id, productData); return this.findById(id); // 更新されたエンティティを再取得 } async delete(id: number): Promise<void> { await this.ormRepository.delete(id); } }
これで、ProductServiceはIProductRepositoryインターフェイスのみに依存するようになります。
// src/product/product.service.ts import { Injectable, Inject } from '@nestjs/common'; import { Product } from './product.entity'; import { IProductRepository, PRODUCT_REPOSITORY } from './interfaces/product-repository.interface'; @Injectable() export class ProductService { constructor( @Inject(PRODUCT_REPOSITORY) private readonly productRepository: IProductRepository, ) {} async createProduct(name: string, price: number): Promise<Product> { return this.productRepository.create({ name, price }); } async getAllProducts(): Promise<Product[]> { return this.productRepository.findAll(); } async getProductById(id: number): Promise<Product | undefined> { return await this.productRepository.findById(id); } }
最後に、モジュールで依存性注入を設定します。
// src/product/product.module.ts import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Product } from './product.entity'; import { ProductService } from './product.service'; import { ProductController } from './product.controller'; import { IProductRepository, PRODUCT_REPOSITORY } from './interfaces/product-repository.interface'; import { TypeOrmProductRepository } from './infrastructure/typeorm-product.repository'; @Module({ imports: [TypeOrmModule.forFeature([Product])], controllers: [ProductController], providers: [ ProductService, { provide: PRODUCT_REPOSITORY, useClass: TypeOrmProductRepository, }, ], exports: [ProductService], }) export class ProductModule {}
このセットアップにより、TypeOrmProductRepositoryを別の実装(例:MongooseProductRepositoryやテスト用のMockProductRepository)と簡単に切り替えることができます。ProductServiceを変更せずに済みます。
ASP.NET Coreでのリポジトリパターンの実装
ASP.NET Coreでのアプローチは、C#インターフェイスと組み込みの依存性注入を活用しており、非常に似ています。
インターフェイスを定義します。
// Interfaces/IProductRepository.cs using System.Collections.Generic; using System.Threading.Tasks; using YourApp.Domain.Models; // Productはドメインモデルであると仮定 namespace YourApp.Application.Interfaces { public interface IProductRepository { Task<Product> AddAsync(Product product); Task<IEnumerable<Product>> GetAllAsync(); Task<Product> GetByIdAsync(int id); Task UpdateAsync(Product product); Task DeleteAsync(int id); } }
Entity Framework Coreを使用してインターフェイスを実装します。
// Infrastructure/Data/Repositories/ProductRepository.cs using Microsoft.EntityFrameworkCore; using System.Collections.Generic; using System.Threading.Tasks; using YourApp.Application.Interfaces; using YourApp.Domain.Models; using YourApp.Infrastructure.Data; // Your DbContext namespace YourApp.Infrastructure.Data.Repositories { public class ProductRepository : IProductRepository { private readonly ApplicationDbContext _dbContext; public ProductRepository(ApplicationDbContext dbContext) { _dbContext = dbContext; } public async Task<Product> AddAsync(Product product) { await _dbContext.Products.AddAsync(product); await _dbContext.SaveChangesAsync(); return product; } public async Task<IEnumerable<Product>> GetAllAsync() { return await _dbContext.Products.ToListAsync(); } public async Task<Product> GetByIdAsync(int id) { return await _dbContext.Products.FindAsync(id); } public async Task UpdateAsync(Product product) { _dbContext.Entry(product).State = EntityState.Modified; await _dbContext.SaveChangesAsync(); } public async Task DeleteAsync(int id) { var product = await _dbContext.Products.FindAsync(id); if (product != null) { _dbContext.Products.Remove(product); await _dbContext.SaveChangesAsync(); } } } }
サービス層はインターフェイスのみに依存します。
// Application/Services/ProductService.cs using System.Collections.Generic; using System.Threading.Tasks; using YourApp.Application.Interfaces; using YourApp.Domain.Models; namespace YourApp.Application.Services { public class ProductService { private readonly IProductRepository _productRepository; public ProductService(IProductRepository productRepository) { _productRepository = productRepository; } public async Task<Product> CreateProduct(string name, decimal price) { var product = new Product { Name = name, Price = price }; return await _productRepository.AddAsync(product); } public async Task<IEnumerable<Product>> GetAllProducts() { return await _productRepository.GetAllAsync(); } public async Task<Product> GetProductById(int id) { return await _productRepository.GetByIdAsync(id); } // ... その他のビジネスロジックメソッド } }
Startup.cs(または.NET 6+ Minimal APIのProgram.cs)で依存性注入を設定します。
// Startup.cs (ConfigureServicesメソッド) public void ConfigureServices(IServiceCollection services) { // ... その他のサービス services.AddDbContext<ApplicationDbContext>(options => options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection"))); services.AddScoped<IProductRepository, ProductRepository>(); services.AddScoped<ProductService>(); // サービスも登録 }
アプリケーションシナリオとメリット
リポジトリパターンは、いくつかのシナリオで特に効果を発揮します。
- データベース移行: データベースシステム(例:SQL ServerからPostgreSQLへ)やORM(例:Entity Framework CoreからDapperへ、データベースを変更せずに)を変更する必要がある場合、既存のインターフェイスに準拠した新しいリポジトリ実装を作成するだけで済みます。
- テスト: 単体テストを大幅に簡略化します。実際のデータベース接続を必要とせずに、ProductServiceのIProductRepositoryインターフェイスを簡単にモックまたはスタブできます。これにより、高速で信頼性の高い、分離されたテストが実現します。
- ドメイン駆動設計 (DDD): リポジトリパターンはDDDの礎であり、集約ルートへのコレクションのようなインターフェイスを提供します。
- 一貫性: データアクセスロジックを一元化し、アプリケーション全体でデータにアクセスするための統一された方法を促進します。
- パフォーマンス最適化: 特定の操作(例:複雑な結合、ストアドプロシージャ)を、基盤となる実装の詳細をサービス層に公開することなく、リポジトリ内にカプセル化できます。
メリットの概要:
- 分離: ビジネスロジックはデータアクセス上の懸念から分離されます。
- テスト容易性: モック化およびスタブ化が容易で、単体テストに適しています。
- 保守性: データアクセス技術の変更による影響が限定的です。
- モジュール性: 関心の明確な分離を促進します。
- 柔軟性: 複数のデータソース実装を可能にします。
結論
リポジトリパターンは、データアクセスロジックをアプリケーションのドメインおよびビジネスサービスから分離するための堅牢で効果的な戦略を提供します。抽象化レイヤーを導入することで、モジュール性を促進し、テスト容易性を大幅に向上させ、NestJSおよびASP.NET Coreアプリケーション全体の保守性を改善します。このパターンを実装することで、データ操作がクリーンで一貫性があり、進化する技術要件に対して回復力があることが保証され、バックエンドシステムはより適応しやすく、長期的には管理しやすくなります。より柔軟で持続可能なバックエンドアーキテクチャを構築するために、リポジトリパターンを採用しましょう。