Aufbau robuster Anwendungen mit Hexagonaler Architektur in NestJS und ASP.NET Core
Lukas Schneider
DevOps Engineer · Leapcell

Einleitung
In der sich ständig weiterentwickelnden Landschaft der Backend-Entwicklung ist der Aufbau von Anwendungen, die robust, wartbar und anpassungsfähig an Veränderungen sind, von größter Bedeutung. Mit zunehmender Komplexität von Systemen können eng gekoppelte Architekturen zu erheblichen Schwierigkeiten führen, die Tests erschweren, Refactoring riskant machen und die Skalierung zu einem Albtraum werden lassen. Hier bieten Architekturmuster wie die Hexagonale Architektur, auch bekannt als Ports und Adapter, eine überzeugende Lösung. Durch die Entkopplung der Kern-Geschäftslogik von externen Abhängigkeiten und Frameworks ermöglicht dieses Muster den Entwicklern, Systeme zu erstellen, die widerstandsfähig gegenüber technologischen Veränderungen und Infrastrukturänderungen sind. Dieser Artikel befasst sich mit der praktischen Implementierung der Hexagonalen Architektur innerhalb von zwei beliebten Backend-Frameworks: NestJS für das Node.js-Ökosystem und ASP.NET Core für die .NET-Welt, und demonstriert, wie man seine Prinzipien nutzt, um wirklich flexible und testbare Anwendungen zu erstellen.
Kernkonzepte der Hexagonalen Architektur
Bevor wir uns mit dem Code befassen, wollen wir ein klares Verständnis der grundlegenden Konzepte entwickeln, die der Hexagonalen Architektur zugrunde liegen.
- Hexagonale Architektur (Ports und Adapter): Dieses Architekturmuster zielt darauf ab, lose gekoppelte Anwendungskomponenten zu erstellen, indem die Kern-Geschäftslogik (das "Innere") von externen Belangen (dem "Äußeren") isoliert wird. Der "Hexagon" repräsentiert den Anwendungskern, und seine Seiten sind die "Ports", die die Interaktion mit externen Systemen ermöglichen.
- Ports: Dies sind vom Anwendungskern besessene Schnittstellen, die den Vertrag für die Interaktion definieren. Sie repräsentieren die "Absichten" oder "Fähigkeiten" der Anwendung. Es gibt zwei Haupttypen von Ports:
- Treiber-Ports (Primäre Ports): Diese werden von externen Akteuren (z.B. UI, API-Clients) aufgerufen, um das Verhalten der Anwendung zu steuern. Sie repräsentieren die API der Anwendung.
- Gefahrene Ports (Sekundäre Ports): Diese werden von externen Diensten (z.B. Datenbanken, Message Queues) implementiert und vom Anwendungskern aufgerufen, um Operationen durchzuführen. Sie repräsentieren die Abhängigkeiten der Anwendung von externer Infrastruktur.
- Adapter: Dies sind konkrete Implementierungen, die die "Außenwelt" über ihre Ports mit dem "Inneren" der Anwendung verbinden.
- Treiber-Adapter (Primäre Adapter): Diese übersetzen externe Anfragen in Aufrufe der Treiber-Ports der Anwendung (z.B. REST-Controller, GraphQL-Resolver).
- Gefahrene Adapter (Sekundäre Adapter): Diese implementieren die gefahrenen Ports und übersetzen Anfragen des Anwendungskerns in spezifische Technikanrufe (z.B. Datenbank-Repositories, HTTP-Clients).
- Anwendungskern (Domäne): Dies ist das Herzstück der Anwendung, das die Geschäftslogik, Entitäten und Anwendungsfälle enthält. Er sollte unabhängig von spezifischer Technologie oder Framework sein.
Der Hauptvorteil dieser Trennung besteht darin, dass der Anwendungskern die spezifischen Technologien, die von seinen Adaptern verwendet werden, nicht kennt. Sie können eine relationale Datenbank durch eine NoSQL-Datenbank ersetzen oder Ihre Messaging-Queue wechseln, ohne die Kern-Geschäftslogik zu ändern.
Implementierung der Hexagonalen Architektur in NestJS
NestJS ist mit seiner modulbasierten Struktur und seiner starken Abhängigkeit von Dependency Injection hervorragend für die Implementierung der Hexagonalen Architektur geeignet. Betrachten wir ein einfaches Beispiel: eine Produktverwaltungsfunktion.
1. Anwendungskern (Domänenschicht)
Definieren Sie zuerst die Kern-Produktentität und die Anwendungsfälle (Dienste), die mit ihr arbeiten.
// src/product/domain/entities/product.entity.ts export class Product { constructor( public id: string, public name: string, public description: string, public price: number, ) {} // Geschäftslogik bezüglich Produkt 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 (Driven Port) 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 (Driving Port) - Dies ist ein konzeptioneller Port für den Anwendungsdienst. // In NestJS bildet dies oft direkt einen injizierbaren Dienst ab, der von Controllern konsumiert wird. // Wir definieren den konkreten Dienst als Teil der Anwendungsschicht. // 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 (Anwendungsdienst - Implementiert den konzeptionellen Treiber-Port) 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); // Domänenlogik verwenden return this.productRepository.save(product); } async deleteProduct(id: string): Promise<void> { await this.productRepository.delete(id); } }
2. Infrastruktur-Adapter
Implementieren Sie nun konkrete Adapter für unser ProductRepositoryPort
.
// src/product/infrastructure/adapters/in-memory-product.repository.ts (Driven Adapter) 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() { // Startdaten für Demonstrationszwecke this.products.push(new Product('1', 'Laptop', 'Leistungsstarker Laptop', 1200)); this.products.push(new Product('2', 'Maus', 'Ergonomische Maus', 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); } } // Sie könnten dies leicht mit einem TypeORMProductRepository austauschen: // 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'; // Eine TypeORM-Entitätsdefinition // // @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; // } // // ... ähnliche Implementierungen für save, findAll, delete // }
3. Treiber-Adapter (Präsentationsschicht)
Der REST-API-Controller fungiert als Treiber-Adapter und übersetzt HTTP-Anfragen in Aufrufe unseres ProductService
.
// src/product/presentation/product.controller.ts (Treiber Adapter) 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. Modulkonfiguration
NestJS-Module sind entscheidend für die Steuerung von Abhängigkeiten. Hier binden wir den ProductService
an den ProductController
und stellen das InMemoryProductRepository
als Implementierung für ProductRepositoryPort
bereit.
// 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', // Token der Schnittstelle bereitstellen useClass: InMemoryProductRepository, // Konkrete Implementierung verwenden }, ], exports: [ProductService], // Wenn andere Module ProductService nutzen müssen }) export class ProductModule {} // In app.module.ts, ProductModule importieren // import { ProductModule } from './product/product.module'; // @Module({ // imports: [ProductModule], // controllers: [], // providers: [], // }) // export class AppModule {}
Diese Einrichtung trennt die Domänenlogik (Product
, ProductRepositoryPort
) klar sowohl von der Datenbankimplementierung (InMemoryProductRepository
) als auch von der API-Schicht (ProductController
). Wenn wir zu TypeORM wechseln wollten, müssten wir nur ein TypeORMProductRepository
erstellen und die useClass
-Bereitstellung im ProductModule
ändern. Der ProductService
und der ProductController
blieben unverändert.
Implementierung der Hexagonalen Architektur in ASP.NET Core
Die integrierte Dependency Injection und die geschichtete Architektur von ASP.NET Core eignen sich von Natur aus für die Hexagonale Architektur. Lassen Sie uns das Produktbeispiel nachbilden.
1. Anwendungskern (Domänenschicht)
Definieren Sie die Produktentität und den Kernvertrag für die Produktspeicherung.
// 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; } // Methoden für Geschäftslogik 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 (Driven Port) 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 (Anwendungsdienst - Implementiert den konzeptionellen Treiber-Port) 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($