Building Robust Applications with Hexagonal Architecture in NestJS and ASP.NET Core
Lukas Schneider
DevOps Engineer · Leapcell

Introduction
In the ever-evolving landscape of backend development, constructing applications that are robust, maintainable, and adaptable to change is paramount. As systems grow in complexity, tightly coupled architectures can lead to significant headaches, making testing difficult, refactoring risky, and scaling a nightmare. This is where architectural patterns like the Hexagonal Architecture, also known as Ports and Adapters, offer a compelling solution. By decoupling the core business logic from external dependencies and frameworks, this pattern empowers developers to build systems that are resilient to technological shifts and infrastructure changes. This article will delve into the practical implementation of Hexagonal Architecture within two popular backend frameworks: NestJS for the Node.js ecosystem and ASP.NET Core for the .NET world, demonstrating how to leverage its principles to create truly flexible and testable applications.
Core Concepts of Hexagonal Architecture
Before we dive into the code, let's establish a clear understanding of the fundamental concepts that underpin Hexagonal Architecture.
- Hexagonal Architecture (Ports and Adapters): This architectural pattern aims to create loosely coupled application components by isolating the core business logic (the "inside") from external concerns (the "outside"). The "hexagon" represents the application core, and its sides are the "ports" that allow interaction with external systems.
- Ports: These are interfaces owned by the application core that define the contract for interaction. They represent the "intents" or "capabilities" of the application. There are two main types of ports:
- Driving Ports (Primary Ports): These are invoked by external actors (e.g., UI, API clients) to drive the application's behavior. They represent the API of the application.
- Driven Ports (Secondary Ports): These are implemented by external services (e.g., databases, message queues) and are invoked by the application core to perform operations. They represent the application's dependencies on external infrastructure.
- Adapters: These are concrete implementations that connect the "outside" world to the "inside" of the application via its ports.
- Driving Adapters (Primary Adapters): These translate external requests into calls to the driving ports of the application (e.g., REST controllers, GraphQL resolvers).
- Driven Adapters (Secondary Adapters): These implement the driven ports, translating application core requests into specific technology calls (e.g., database repositories, HTTP clients).
- Application Core (Domain): This is the heart of the application, containing the business logic, entities, and use cases. It should be independent of any specific technology or framework.
The primary benefit of this separation is that the application core remains oblivious to the specific technologies used by its adapters. You can swap out a relational database for a NoSQL database, or change your messaging queue, without altering the core business logic.
Implementing Hexagonal Architecture in NestJS
NestJS, with its module-based structure and strong reliance on dependency injection, is an excellent fit for implementing Hexagonal Architecture. Let's consider a simple example: a Product
management feature.
1. Application Core (Domain Layer)
First, define the core Product
entity and the use cases (services) that operate on it.
// src/product/domain/entities/product.entity.ts export class Product { constructor( public id: string, public name: string, public description: string, public price: number, ) {} // Business logic related to 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 (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) - This is a conceptual port for the application service. // In NestJS, this often maps directly to an injectable service that is consumed by controllers. // We'll define the concrete service as part of the application layer. // 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 (Application Service - Implements the conceptual Driving 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); // Use domain logic return this.productRepository.save(product); } async deleteProduct(id: string): Promise<void> { await this.productRepository.delete(id); } }
2. Infrastructure Adapters
Now, let's implement concrete adapters for our 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() { // Seed with some initial data for demonstration 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); } } // You could easily swap this with a 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'; // A TypeORM entity definition // // @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; // } // // ... similar implementations for save, findAll, delete // }
3. Driving Adapters (Presentation Layer)
The REST API controller acts as a driving adapter, translating HTTP requests into calls to our ProductService
.
// src/product/presentation/product.controller.ts (Driving 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. Module Configuration
NestJS modules are crucial for orchestrating dependencies. Here, we bind the ProductService
to ProductController
and provide the InMemoryProductRepository
as the implementation for 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', // Provide the interface token useClass: InMemoryProductRepository, // Use the concrete implementation }, ], exports: [ProductService], // If other modules need to consume ProductService }) export class ProductModule {} // In app.module.ts, import ProductModule // import { ProductModule } from './product/product.module'; // @Module({ // imports: [ProductModule], // controllers: [], // providers: [], // }) // export class AppModule {}
This setup clearly isolates the domain logic (Product
, ProductRepositoryPort
) from both the database implementation (InMemoryProductRepository
) and the API layer (ProductController
). If we wanted to switch to TypeORM, we would only need to create a TypeORMProductRepository
and change the useClass
provider in ProductModule
. The ProductService
and ProductController
would remain untouched.
Implementing Hexagonal Architecture in ASP.NET Core
ASP.NET Core's built-in dependency injection and layered architecture naturally lend themselves to Hexagonal Architecture. Let's recreate the Product
example.
1. Application Core (Domain Layer)
Define the Product
entity and the core contract for product storage.
// 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; } // Methods for business logic 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 (Application Service - Implements the conceptual Driving 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($"Product with ID {id} not found."); } product.UpdateDetails(dto.Name, dto.Description); if (dto.Price.HasValue) { product.UpdatePrice(dto.Price.Value); } await _productRepository.UpdateAsync(product); return product; } public async Task DeleteProductAsync(Guid id) { var product = await _productRepository.GetByIdAsync(id); if (product == null) { throw new ArgumentException($"Product with ID {id} not found."); } await _productRepository.DeleteAsync(product); } } }
2. Infrastructure Adapters
Implement the IProductRepository
using an in-memory collection.
// Products/Infrastructure/Adapters/InMemoryProductRepository.cs (Driven Adapter) using HexagonalNetCore.Products.Domain.Entities; using HexagonalNetCore.Products.Domain.Ports; using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; namespace HexagonalNetCore.Products.Infrastructure.Adapters { public class InMemoryProductRepository : IProductRepository { private readonly List<Product> _products; public InMemoryProductRepository() { _products = new List<Product> { new Product(Guid.Parse("d347d4e0-9e6b-4e0e-8a7c-1b7c8e8d8a7c"), "Laptop", "Powerful laptop", 1200), new Product(Guid.Parse("a1b2c3d4-e5f6-7890-1234-567890abcdef"), "Mouse", "Ergonomic mouse", 25) }; } public Task<Product?> GetByIdAsync(Guid id) { return Task.FromResult(_products.FirstOrDefault(p => p.Id == id)); } public Task<IEnumerable<Product>> GetAllAsync() { return Task.FromResult<IEnumerable<Product>>(_products); } public Task AddAsync(Product product) { _products.Add(product); return Task.CompletedTask; } public Task UpdateAsync(Product product) { var existingProduct = _products.FirstOrDefault(p => p.Id == product.Id); if (existingProduct != null) { _products.Remove(existingProduct); _products.Add(product); // Replace with updated } return Task.CompletedTask; } public Task DeleteAsync(Product product) { _products.RemoveAll(p => p.Id == product.Id); return Task.CompletedTask; } } } // For a real database, you'd have an EFCoreProductRepository: // Products/Infrastructure/Adapters/EFCoreProductRepository.cs // using HexagonalNetCore.Products.Domain.Entities; // using HexagonalNetCore.Products.Domain.Ports; // using HexagonalNetCore.Products.Infrastructure.Data; // Your DbContext // using Microsoft.EntityFrameworkCore; // using System.Collections.Generic; // using System.Threading.Tasks; // // namespace HexagonalNetCore.Products.Infrastructure.Adapters // { // public class EFCoreProductRepository : IProductRepository // { // private readonly ProductDbContext _context; // // public EFCoreProductRepository(ProductDbContext context) // { // _context = context; // } // // public async Task<Product?> GetByIdAsync(Guid id) // { // // Map EF core entity to domain entity // var dbProduct = await _context.Products.FindAsync(id); // return dbProduct != null ? ProductMapper.ToDomain(dbProduct) : null; // } // // ... similar implementations for other methods using _context // } // }
3. Driving Adapters (Presentation Layer)
The API controller serves as the driving adapter.
// Products/Presentation/Controllers/ProductsController.cs (Driving Adapter) using HexagonalNetCore.Products.Application.DTOs; using HexagonalNetCore.Products.Application.Services; using HexagonalNetCore.Products.Domain.Entities; using Microsoft.AspNetCore.Mvc; using System; using System.Collections.Generic; using System.Threading.Tasks; namespace HexagonalNetCore.Products.Presentation.Controllers { [ApiController] [Route("api/[controller]")] public class ProductsController : ControllerBase { private readonly ProductService _productService; public ProductsController(ProductService productService) { _productService = productService; } [HttpPost] public async Task<ActionResult<Product>> CreateProduct(CreateProductDto dto) { var product = await _productService.CreateProductAsync(dto); return CreatedAtAction(nameof(GetProductById), new { id = product.Id }, product); } [HttpGet("{id}")] public async Task<ActionResult<Product>> GetProductById(Guid id) { var product = await _productService.GetProductByIdAsync(id); if (product == null) { return NotFound(); } return Ok(product); } [HttpGet] public async Task<ActionResult<IEnumerable<Product>>> GetAllProducts() { var products = await _productService.GetAllProductsAsync(); return Ok(products); } [HttpPut("{id}")] public async Task<ActionResult<Product>> UpdateProduct(Guid id, UpdateProductDto dto) { try { var product = await _productService.UpdateProductAsync(id, dto); return Ok(product); } catch (ArgumentException ex) { return NotFound(ex.Message); } } [HttpDelete("{id}")] public async Task<ActionResult> DeleteProduct(Guid id) { try { await _productService.DeleteProductAsync(id); return NoContent(); } catch (ArgumentException ex) { return NotFound(ex.Message); } } } }
4. Dependency Injection Configuration
In ASP.NET Core, the Startup.cs
(or Program.cs
in .NET 6+) is where we configure our services.
// Program.cs (for .NET 6+) or Startup.cs (ConfigureServices method for .NET 5 or earlier) using HexagonalNetCore.Products.Application.Services; using HexagonalNetCore.Products.Domain.Ports; using HexagonalNetCore.Products.Infrastructure.Adapters; var builder = WebApplication.CreateBuilder(args); // Add services to the container. builder.Services.AddControllers(); // Configure Hexagonal Architecture dependencies builder.Services.AddScoped<IProductRepository, InMemoryProductRepository>(); // Bind port to adapter builder.Services.AddScoped<ProductService>(); // Register the application service var app = builder.Build(); // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { app.UseDeveloperExceptionPage(); } app.UseHttpsRedirection(); app.UseAuthorization(); app.MapControllers(); app.Run();
Similar to NestJS, swapping out the InMemoryProductRepository
for an EFCoreProductRepository
would involve just changing a single line in Program.cs
(or Startup.cs
), leaving the ProductService
and ProductsController
entirely unaware of the underlying persistence mechanism. This clear separation fosters independent development, easier testing of the core logic, and enhanced flexibility for future changes.
Conclusion
Hexagonal Architecture, or Ports and Adapters, is a powerful paradigm for building resilient and maintainable backend applications. By strictly decoupling the core business logic from external concerns through well-defined ports and concrete adapters, developers can achieve unparalleled flexibility, testability, and independence from infrastructure choices. Both NestJS and ASP.NET Core, with their robust dependency injection containers, provide excellent environments for implementing this pattern, enabling the construction of elegant and adaptable systems that stand the test of time and technological evolution. Embrace Hexagonal Architecture to build backend solutions that are not just functional, but truly future-proof.