Skip to main content
Each module in Wolfix.Server follows Clean Architecture (also known as Onion Architecture or Hexagonal Architecture) to maintain separation of concerns and testability.

The Layers

Clean Architecture organizes code into concentric layers, with dependencies pointing inward:
┌────────────────────────────────────────┐
│  Endpoints (HTTP API)                      │
│  ┌─────────────────────────────────┐  │
│  │  Infrastructure                  │  │
│  │  (EF Core, HTTP, Azure)          │  │
│  │  ┌─────────────────────────┐  │  │
│  │  │  Application             │  │  │
│  │  │  (Services, DTOs)        │  │  │
│  │  │  ┌─────────────────┐  │  │  │
│  │  │  │    Domain      │  │  │  │
│  │  │  │  (Entities)  │  │  │  │
│  │  │  └─────────────────┘  │  │  │
│  │  └─────────────────────────┘  │  │
│  └─────────────────────────────────┘  │
└────────────────────────────────────────┘
         Dependencies point inward →

1. Domain Layer (Core)

The innermost layer contains pure business logic with zero dependencies. Project: {Module}.Domain Responsibilities:
  • Business entities and aggregates
  • Value objects
  • Domain services
  • Business rules and validation
  • Repository interfaces (contracts)
Example: Product aggregate from Catalog module:
Catalog.Domain/ProductAggregate/Product.cs
using Shared.Domain.Entities;
using Shared.Domain.Models;

namespace Catalog.Domain.ProductAggregate;

public sealed class Product : BaseEntity
{
    public string Title { get; private set; }
    public string Description { get; private set; }
    public decimal Price { get; private set; }
    public ProductStatus Status { get; private set; }
    public decimal FinalPrice { get; private set; }
    public Guid SellerId { get; private set; }
    
    private readonly List<Review> _reviews = [];
    public IReadOnlyCollection<ReviewInfo> Reviews => _reviews
        .Select(r => (ReviewInfo)r)
        .ToList()
        .AsReadOnly();
    
    // Factory method with validation
    public static Result<Product> Create(
        string title, 
        string description, 
        decimal price, 
        ProductStatus status, 
        Guid categoryId, 
        Guid sellerId)
    {
        if (IsTextInvalid(title, out string titleErrorMessage))
            return Result<Product>.Failure(titleErrorMessage);

        if (IsPriceInvalid(price, out string priceErrorMessage))
            return Result<Product>.Failure(priceErrorMessage);

        var product = new Product(title, description, price, status, categoryId, sellerId);
        product.RecalculateBonuses();
        product.FinalPrice = price;
        
        return Result<Product>.Success(product, HttpStatusCode.Created);
    }
    
    // Business logic method
    public VoidResult AddReview(string title, string text, uint rating, Guid customerId)
    {
        Result<Review> createReviewResult = Review.Create(title, text, rating, this, customerId);

        return createReviewResult.Map(
            onSuccess: review =>
            {
                _reviews.Add(review);
                RecalculateAverageRating();
                return VoidResult.Success();
            },
            onFailure: errorMessage => VoidResult.Failure(errorMessage, createReviewResult.StatusCode)
        );
    }
    
    // Private validation
    private static bool IsPriceInvalid(decimal price, out string errorMessage)
    {
        if (price <= 0)
        {
            errorMessage = $"{nameof(price)} must be positive";
            return true;
        }
        
        errorMessage = string.Empty;
        return false;
    }
}
The Domain layer has no dependencies on frameworks, databases, or external services. It’s pure business logic.

2. Application Layer

The orchestration layer contains use cases and application services. Project: {Module}.Application Responsibilities:
  • Application services (use cases)
  • DTOs (Data Transfer Objects)
  • Mapping between domain and DTOs
  • Event handlers for integration events
  • Service interfaces
Example: Product service:
Catalog.Application/Services/ProductService.cs
public class ProductService
{
    private readonly IProductRepository _productRepository;
    private readonly ICategoryRepository _categoryRepository;
    private readonly IToxicityService _toxicityService;
    
    public async Task<Result<ProductDto>> CreateProductAsync(
        CreateProductDto dto, 
        Guid sellerId, 
        CancellationToken ct)
    {
        // 1. Validate category exists
        var categoryExists = await _categoryRepository.ExistsAsync(dto.CategoryId, ct);
        if (!categoryExists)
            return Result<ProductDto>.Failure("Category not found", HttpStatusCode.NotFound);
        
        // 2. Check for toxic content
        Result<bool> toxicityResult = await _toxicityService.IsToxic(dto.Title, ct);
        if (toxicityResult.IsSuccess && toxicityResult.Value)
            return Result<ProductDto>.Failure("Title contains inappropriate content");
        
        // 3. Create domain entity
        Result<Product> createResult = Product.Create(
            dto.Title,
            dto.Description,
            dto.Price,
            ProductStatus.Draft,
            dto.CategoryId,
            sellerId
        );
        
        if (createResult.IsFailure)
            return Result<ProductDto>.Failure(createResult);
        
        Product product = createResult.Value!;
        
        // 4. Save to repository
        await _productRepository.AddAsync(product, ct);
        await _productRepository.SaveChangesAsync(ct);
        
        // 5. Map to DTO
        ProductDto productDto = product.ToDto();
        
        return Result<ProductDto>.Success(productDto, HttpStatusCode.Created);
    }
}
DTOs:
Catalog.Application/Dto/CreateProductDto.cs
public record CreateProductDto(
    string Title,
    string Description,
    decimal Price,
    Guid CategoryId
);

public record ProductDto(
    Guid Id,
    string Title,
    string Description,
    decimal Price,
    decimal FinalPrice,
    string Status,
    Guid SellerId
);

3. Infrastructure Layer

The implementation layer for external concerns. Project: {Module}.Infrastructure Responsibilities:
  • Repository implementations (EF Core)
  • Database context and configurations
  • Migrations
  • External service implementations (HTTP, Azure, etc.)
  • Third-party integrations
Example: Repository implementation:
Catalog.Infrastructure/Repositories/ProductRepository.cs
using Catalog.Domain.Interfaces;
using Catalog.Domain.ProductAggregate;
using Microsoft.EntityFrameworkCore;

public class ProductRepository : IProductRepository
{
    private readonly CatalogDbContext _context;
    
    public ProductRepository(CatalogDbContext context)
    {
        _context = context;
    }
    
    public async Task<Product?> GetByIdAsync(Guid id, CancellationToken ct)
    {
        return await _context.Products
            .Include(p => p.Reviews)
            .Include(p => p.ProductMedias)
            .FirstOrDefaultAsync(p => p.Id == id, ct);
    }
    
    public async Task AddAsync(Product product, CancellationToken ct)
    {
        await _context.Products.AddAsync(product, ct);
    }
    
    public async Task SaveChangesAsync(CancellationToken ct)
    {
        await _context.SaveChangesAsync(ct);
    }
}
DbContext:
Catalog.Infrastructure/CatalogDbContext.cs
public class CatalogDbContext : DbContext
{
    public DbSet<Product> Products { get; set; }
    public DbSet<Category> Categories { get; set; }
    
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly());
    }
}
Entity Configuration:
Catalog.Infrastructure/Configurations/ProductConfiguration.cs
public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
    public void Configure(EntityTypeBuilder<Product> builder)
    {
        builder.ToTable("catalog_products");
        
        builder.HasKey(p => p.Id);
        
        builder.Property(p => p.Title)
            .IsRequired()
            .HasMaxLength(200);
        
        builder.Property(p => p.Price)
            .HasPrecision(18, 2);
        
        // Configure relationships
        builder.HasMany(p => p.Reviews)
            .WithOne()
            .OnDelete(DeleteBehavior.Cascade);
    }
}
External Service:
Catalog.Infrastructure/Services/ToxicityService.cs
using Catalog.Application.Contracts;

internal sealed class ToxicityService : IToxicityService
{
    private readonly HttpClient _httpClient;
    
    public ToxicityService(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }
    
    public async Task<Result<bool>> IsToxic(string text, CancellationToken ct)
    {
        try
        {
            var payload = new { text };
            var response = await _httpClient.PostAsJsonAsync("check", payload, ct);
            response.EnsureSuccessStatusCode();
            var result = await response.Content.ReadFromJsonAsync<bool>(ct);
            
            return Result<bool>.Success(result);
        }
        catch (Exception ex)
        {
            return Result<bool>.Failure(ex.Message);
        }
    }
}

4. Endpoints Layer (Presentation)

The HTTP API layer using ASP.NET Core Minimal APIs. Project: {Module}.Endpoints Responsibilities:
  • HTTP endpoints
  • Request/response models
  • Authorization policies
  • Endpoint registration
Example: Product endpoints:
Catalog.Endpoints/Endpoints/ProductEndpoints.cs
public static class ProductEndpoints
{
    public static IEndpointRouteBuilder MapProductEndpoints(this IEndpointRouteBuilder app)
    {
        var group = app.MapGroup("/api/products")
            .WithTags("Products");
        
        group.MapPost("/", CreateProduct)
            .RequireAuthorization("Seller");
        
        group.MapGet("/{id:guid}", GetProduct);
        
        group.MapPut("/{id:guid}", UpdateProduct)
            .RequireAuthorization("Seller");
        
        group.MapDelete("/{id:guid}", DeleteProduct)
            .RequireAuthorization("Admin");
        
        return app;
    }
    
    private static async Task<IResult> CreateProduct(
        [FromBody] CreateProductDto dto,
        [FromServices] ProductService productService,
        ClaimsPrincipal user,
        CancellationToken ct)
    {
        var sellerId = Guid.Parse(user.FindFirst("ProfileId")!.Value);
        
        var result = await productService.CreateProductAsync(dto, sellerId, ct);
        
        return result.IsSuccess
            ? Results.Created($"/api/products/{result.Value!.Id}", result.Value)
            : Results.BadRequest(result.ErrorMessage);
    }
    
    private static async Task<IResult> GetProduct(
        Guid id,
        [FromServices] ProductService productService,
        CancellationToken ct)
    {
        var result = await productService.GetProductByIdAsync(id, ct);
        
        return result.IsSuccess
            ? Results.Ok(result.Value)
            : Results.NotFound(result.ErrorMessage);
    }
}

Dependency Direction

The Dependency Rule: Dependencies point inward only.
Endpoints → Application → Domain


Infrastructure
  • Domain has no dependencies
  • Application depends on Domain only
  • Infrastructure depends on Domain and Application
  • Endpoints depends on Application
Never let the Domain layer depend on Application or Infrastructure. This keeps business logic pure and testable.

Dependency Inversion

The Domain defines interfaces, Infrastructure provides implementations:
Catalog.Domain/Interfaces/IProductRepository.cs
// Domain defines the contract
public interface IProductRepository
{
    Task<Product?> GetByIdAsync(Guid id, CancellationToken ct);
    Task AddAsync(Product product, CancellationToken ct);
    Task SaveChangesAsync(CancellationToken ct);
}
Catalog.Infrastructure/Repositories/ProductRepository.cs
// Infrastructure implements the contract
public class ProductRepository : IProductRepository
{
    // Implementation using EF Core
}
Registration in DI container:
services.AddScoped<IProductRepository, ProductRepository>();

Benefits

Testability

Business logic is isolated and easy to unit test

Independence

Domain logic doesn’t depend on frameworks or databases

Flexibility

Swap implementations (e.g., EF Core to Dapper) without changing business logic

Maintainability

Clear separation makes code easier to understand and modify

Testing Strategy

Unit Tests (Domain)

Test business logic in isolation:
Catalog.Tests/Domain/ProductTests.cs
public class ProductTests
{
    [Fact]
    public void Create_WithValidData_ShouldSucceed()
    {
        // Arrange
        var title = "Test Product";
        var description = "Description";
        var price = 99.99m;
        
        // Act
        var result = Product.Create(title, description, price, 
            ProductStatus.Draft, Guid.NewGuid(), Guid.NewGuid());
        
        // Assert
        Assert.True(result.IsSuccess);
        Assert.Equal(title, result.Value!.Title);
        Assert.Equal(price, result.Value.Price);
    }
    
    [Fact]
    public void Create_WithNegativePrice_ShouldFail()
    {
        // Act
        var result = Product.Create("Test", "Desc", -10m, 
            ProductStatus.Draft, Guid.NewGuid(), Guid.NewGuid());
        
        // Assert
        Assert.True(result.IsFailure);
        Assert.Contains("must be positive", result.ErrorMessage);
    }
}

Integration Tests (Application)

Test use cases with mocked infrastructure:
Catalog.Tests/Application/ProductServiceTests.cs
public class ProductServiceTests
{
    [Fact]
    public async Task CreateProduct_WithToxicTitle_ShouldFail()
    {
        // Arrange
        var mockRepo = new Mock<IProductRepository>();
        var mockToxicity = new Mock<IToxicityService>();
        mockToxicity.Setup(x => x.IsToxic(It.IsAny<string>(), It.IsAny<CancellationToken>()))
            .ReturnsAsync(Result<bool>.Success(true));
        
        var service = new ProductService(mockRepo.Object, mockToxicity.Object);
        
        // Act
        var result = await service.CreateProductAsync(new CreateProductDto(...), Guid.NewGuid(), CancellationToken.None);
        
        // Assert
        Assert.True(result.IsFailure);
        Assert.Contains("inappropriate content", result.ErrorMessage);
    }
}

Next Steps

Domain-Driven Design

Deep dive into DDD patterns used in the Domain layer

Result Pattern

Learn about error handling in Wolfix.Server

Build docs developers (and LLMs) love