← Back to list

Implementing Clean Architecture with Vertical Slice Architecture in .NET 8

In modern enterprise application development, maintaining clean, scalable, and maintainable codebases is crucial. This tutorial…

Vineet Sharma · 2026-02-09 09:55 · 9 claps · 14.9 min read paywalled
#clean-architecture #vertical-scaling #net8
Open on Medium ↗
Wiki topics: 🏛️ · Architecture

Implementing Clean Architecture with Vertical Slice Architecture in .NET 8

Introduction

In modern enterprise application development, maintaining clean, scalable, and maintainable codebases is crucial. This tutorial demonstrates how to combine Clean Architecture with Vertical Slice Architecture (VSA) to build robust .NET applications. We’ll create a Unit Management System from scratch, focusing on practical implementation details.

Architecture Overview

[embed]Clean Architecture with Vertical Slice Architecture in .NET 8 *This presentation provides a comprehensive, step-by-step guide to merging the layered discipline of *Clean…www.slideshare.net

Architecture Overview

Architecture Overview

👉Read full story here

Architecture Flow Diagram

Architecture Flow Diagram

Architecture Flow Diagram

Prerequisites

  • .NET 8 SDK or later
  • Visual Studio 2022+ or VS Code
  • Basic understanding of C# and ASP.NET Core
  • Familiarity with Entity Framework Core

Project Setup

Step 1: Create Solution Structure

Solution Structure

Solution Structure

# Create solution
dotnet new sln -n UnitManagementSystem

# Create projects
dotnet new classlib -n UnitManagementSystem.Domain -f net8.0
dotnet new classlib -n UnitManagementSystem.Application -f net8.0
dotnet new classlib -n UnitManagementSystem.Infrastructure -f net8.0
dotnet new webapi -n UnitManagementSystem.Api -f net8.0

# Add projects to solution
dotnet sln add UnitManagementSystem.Domain
dotnet sln add UnitManagementSystem.Application
dotnet sln add UnitManagementSystem.Infrastructure
dotnet sln add UnitManagementSystem.Api

# Add project references
cd UnitManagementSystem.Application
dotnet add reference ../UnitManagementSystem.Domain
cd ../UnitManagementSystem.Infrastructure
dotnet add reference ../UnitManagementSystem.Application
cd ../UnitManagementSystem.Api
dotnet add reference ../UnitManagementSystem.Application

Domain Layer Implementation

Step 2: Define Core Entities

Domain Entity Structure

Domain Entity Structure

// Domain/Entities/Entity.cs
namespace UnitManagementSystem.Domain.Entities;

public abstract class Entity
{
    private readonly List<IDomainEvent> _domainEvents = new();

    public Guid Id { get; private init; } = Guid.NewGuid();
    public DateTime CreatedAt { get; private set; } = DateTime.UtcNow;
    public DateTime? UpdatedAt { get; private set; }

    public IReadOnlyCollection<IDomainEvent> DomainEvents => 
        _domainEvents.AsReadOnly();

    protected Entity() { }

    protected void AddDomainEvent(IDomainEvent domainEvent)
    {
        _domainEvents.Add(domainEvent);
    }

    public void ClearDomainEvents()
    {
        _domainEvents.Clear();
    }

    protected void UpdateTimestamp()
    {
        UpdatedAt = DateTime.UtcNow;
    }
}

// Domain/Entities/Unit.cs
namespace UnitManagementSystem.Domain.Entities;

public sealed class Unit : Entity
{
    public string Name { get; private set; }
    public string Symbol { get; private set; }
    public string Description { get; private set; }
    public UnitGroup Group { get; private set; }
    public decimal ConversionFactor { get; private set; }
    public bool IsBaseUnit { get; private set; }

    // Private constructor for EF Core
    private Unit() { }

    public Unit(
        string name,
        string symbol,
        string description,
        UnitGroup group,
        decimal conversionFactor = 1,
        bool isBaseUnit = false)
    {
        ValidateParameters(name, symbol, description);

        Name = name;
        Symbol = symbol;
        Description = description;
        Group = group;
        ConversionFactor = conversionFactor;
        IsBaseUnit = isBaseUnit;

        AddDomainEvent(new UnitCreatedDomainEvent(Id, Name, Group));
    }

    public void Update(
        string name,
        string symbol,
        string description,
        UnitGroup group,
        decimal conversionFactor,
        bool isBaseUnit)
    {
        ValidateParameters(name, symbol, description);

        Name = name;
        Symbol = symbol;
        Description = description;
        Group = group;
        ConversionFactor = conversionFactor;
        IsBaseUnit = isBaseUnit;

        UpdateTimestamp();
        AddDomainEvent(new UnitUpdatedDomainEvent(Id));
    }

    private static void ValidateParameters(
        string name, string symbol, string description)
    {
        if (string.IsNullOrWhiteSpace(name))
            throw new ArgumentException("Name cannot be empty", nameof(name));

        if (string.IsNullOrWhiteSpace(symbol))
            throw new ArgumentException("Symbol cannot be empty", nameof(symbol));

        if (string.IsNullOrWhiteSpace(description))
            throw new ArgumentException("Description cannot be empty", nameof(description));

        if (name.Length > 100)
            throw new ArgumentException("Name cannot exceed 100 characters", nameof(name));

        if (symbol.Length > 10)
            throw new ArgumentException("Symbol cannot exceed 10 characters", nameof(symbol));
    }
}

// Domain/Enums/UnitGroup.cs
namespace UnitManagementSystem.Domain.Enums;

public enum UnitGroup
{
    Length,
    Mass,
    Time,
    Temperature,
    ElectricCurrent,
    LuminousIntensity,
    AmountOfSubstance,
    Other
}

Step 3: Define Domain Events

// Domain/Events/IDomainEvent.cs
namespace UnitManagementSystem.Domain.Events;

public interface IDomainEvent
{
    DateTime OccurredOn { get; }
}

// Domain/Events/UnitCreatedDomainEvent.cs
namespace UnitManagementSystem.Domain.Events;

public sealed record UnitCreatedDomainEvent(
    Guid UnitId,
    string UnitName,
    UnitGroup UnitGroup,
    DateTime OccurredOn = default) : IDomainEvent
{
    public UnitCreatedDomainEvent(
        Guid unitId,
        string unitName,
        UnitGroup unitGroup) 
        : this(unitId, unitName, unitGroup, DateTime.UtcNow)
    {
    }
}

// Domain/Events/UnitUpdatedDomainEvent.cs
namespace UnitManagementSystem.Domain.Events;

public sealed record UnitUpdatedDomainEvent(
    Guid UnitId,
    DateTime OccurredOn = default) : IDomainEvent
{
    public UnitUpdatedDomainEvent(Guid unitId) 
        : this(unitId, DateTime.UtcNow)
    {
    }
}

Step 4: Define Repository Interfaces

// Domain/Repositories/IUnitRepository.cs
namespace UnitManagementSystem.Domain.Repositories;

public interface IUnitRepository
{
    Task<Unit?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
    Task<IEnumerable<Unit>> GetAllAsync(CancellationToken cancellationToken = default);
    Task<IEnumerable<Unit>> GetByGroupAsync(UnitGroup group, CancellationToken cancellationToken = default);
    Task<Unit?> GetBaseUnitAsync(UnitGroup group, CancellationToken cancellationToken = default);
    Task<bool> ExistsAsync(string name, string symbol, CancellationToken cancellationToken = default);
    Task AddAsync(Unit unit, CancellationToken cancellationToken = default);
    void Update(Unit unit);
    void Delete(Unit unit);
    Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}

Application Layer Implementation

Step 5: Set Up Application Abstractions

Application Abstractions

Application Abstractions

// Application/Abstractions/ICommand.cs
namespace UnitManagementSystem.Application.Abstractions.Messaging;

public interface ICommand : IRequest<Result>
{
}

public interface ICommand<TResponse> : IRequest<Result<TResponse>>
{
}

// Application/Abstractions/IQuery.cs
namespace UnitManagementSystem.Application.Abstractions.Messaging;

public interface IQuery<TResponse> : IRequest<Result<TResponse>>
{
}

// Application/Abstractions/ICommandHandler.cs
namespace UnitManagementSystem.Application.Abstractions.Messaging;

public interface ICommandHandler<TCommand> : IRequestHandler<TCommand, Result>
    where TCommand : ICommand
{
}

public interface ICommandHandler<TCommand, TResponse> 
    : IRequestHandler<TCommand, Result<TResponse>>
    where TCommand : ICommand<TResponse>
{
}

// Application/Abstractions/IQueryHandler.cs
namespace UnitManagementSystem.Application.Abstractions.Messaging;

public interface IQueryHandler<TQuery, TResponse> 
    : IRequestHandler<TQuery, Result<TResponse>>
    where TQuery : IQuery<TResponse>
{
}

Step 6: Implement Result Pattern

Result Pattern

Result Pattern

// Application/Common/Result.cs
namespace UnitManagementSystem.Application.Common;

public class Result
{
    public bool IsSuccess { get; }
    public bool IsFailure => !IsSuccess;
    public Error Error { get; }

    protected Result(bool isSuccess, Error error)
    {
        if (isSuccess && error != Error.None)
            throw new InvalidOperationException();

        if (!isSuccess && error == Error.None)
            throw new InvalidOperationException();

        IsSuccess = isSuccess;
        Error = error;
    }

    public static Result Success() => new(true, Error.None);
    public static Result<TValue> Success<TValue>(TValue value) => new(value, true, Error.None);
    public static Result Failure(Error error) => new(false, error);
    public static Result<TValue> Failure<TValue>(Error error) => new(default, false, error);
}

public class Result<TValue> : Result
{
    private readonly TValue? _value;

    public TValue Value => IsSuccess 
        ? _value! 
        : throw new InvalidOperationException("Cannot access value of failed result");

    protected internal Result(TValue? value, bool isSuccess, Error error)
        : base(isSuccess, error)
    {
        _value = value;
    }
}

// Application/Common/Error.cs
namespace UnitManagementSystem.Application.Common;

public record Error(string Code, string Message)
{
    public static readonly Error None = new(string.Empty, string.Empty);
    public static readonly Error NullValue = new("Error.NullValue", "Null value was provided");
}

// Application/Common/UnitErrors.cs
namespace UnitManagementSystem.Application.Common.Errors;

public static class UnitErrors
{
    public static Error NotFound(Guid id) => new(
        "Unit.NotFound",
        $"Unit with ID '{id}' was not found");

    public static Error AlreadyExists(string name, string symbol) => new(
        "Unit.AlreadyExists",
        $"Unit with name '{name}' and symbol '{symbol}' already exists");

    public static Error InvalidConversionFactor => new(
        "Unit.InvalidConversionFactor",
        "Conversion factor must be greater than 0");

    public static Error NoBaseUnit(UnitGroup group) => new(
        "Unit.NoBaseUnit",
        $"No base unit defined for group '{group}'");
}

Step 7: Implement Create Unit Vertical Slice

Unit Vertical Slice

Unit Vertical Slice

Application/
├── Units/
│   ├── CreateUnit/
│   │   ├── CreateUnitCommand.cs
│   │   ├── CreateUnitCommandHandler.cs
│   │   ├── CreateUnitCommandValidator.cs
│   │   └── CreateUnitEndpoint.cs
│   ├── UpdateUnit/
│   ├── GetUnits/
│   └── DeleteUnit/
└── Behaviors/
// Application/Units/CreateUnit/CreateUnitCommand.cs
namespace UnitManagementSystem.Application.Units.CreateUnit;

public sealed record CreateUnitCommand(
    string Name,
    string Symbol,
    string Description,
    UnitGroup Group,
    decimal ConversionFactor = 1,
    bool IsBaseUnit = false) : ICommand<Guid>
// Application/Units/CreateUnit/CreateUnitCommandValidator.cs
namespace UnitManagementSystem.Application.Units.CreateUnit;

public sealed class CreateUnitCommandValidator : AbstractValidator<CreateUnitCommand>
{
    public CreateUnitCommandValidator()
    {
        RuleFor(x => x.Name)
            .NotEmpty().WithMessage("Name is required")
            .MaximumLength(100).WithMessage("Name must not exceed 100 characters")
            .Matches("^[a-zA-Z0-9 ]+$").WithMessage("Name can only contain letters, numbers, and spaces");

        RuleFor(x => x.Symbol)
            .NotEmpty().WithMessage("Symbol is required")
            .MaximumLength(10).WithMessage("Symbol must not exceed 10 characters")
            .Matches("^[a-zA-Z0-9°µ]+$").WithMessage("Symbol contains invalid characters");

        RuleFor(x => x.Description)
            .NotEmpty().WithMessage("Description is required")
            .MaximumLength(500).WithMessage("Description must not exceed 500 characters");

        RuleFor(x => x.ConversionFactor)
            .GreaterThan(0).WithMessage("Conversion factor must be greater than 0");

        RuleFor(x => x.Group)
            .IsInEnum().WithMessage("Invalid unit group");

        When(x => x.IsBaseUnit, () =>
        {
            RuleFor(x => x.ConversionFactor)
                .Equal(1).WithMessage("Base unit must have conversion factor of 1");
        });
    }
}
// Application/Units/CreateUnit/CreateUnitCommandHandler.cs
namespace UnitManagementSystem.Application.Units.CreateUnit;

public sealed class CreateUnitCommandHandler : ICommandHandler<CreateUnitCommand, Guid>
{
    private readonly IUnitRepository _unitRepository;
    private readonly IUnitOfWork _unitOfWork;

    public CreateUnitCommandHandler(
        IUnitRepository unitRepository,
        IUnitOfWork unitOfWork)
    {
        _unitRepository = unitRepository;
        _unitOfWork = unitOfWork;
    }

    public async Task<Result<Guid>> Handle(
        CreateUnitCommand command,
        CancellationToken cancellationToken)
    {
        try
        {
            // Check if unit already exists
            var exists = await _unitRepository.ExistsAsync(
                command.Name, 
                command.Symbol, 
                cancellationToken);

            if (exists)
                return Result.Failure<Guid>(
                    UnitErrors.AlreadyExists(command.Name, command.Symbol));

            // Validate conversion factor
            if (command.ConversionFactor <= 0)
                return Result.Failure<Guid>(UnitErrors.InvalidConversionFactor);

            // If this is a base unit, verify no other base unit exists for this group
            if (command.IsBaseUnit)
            {
                var existingBaseUnit = await _unitRepository.GetBaseUnitAsync(
                    command.Group, 
                    cancellationToken);

                if (existingBaseUnit != null && existingBaseUnit.Id != Guid.Empty)
                {
                    return Result.Failure<Guid>(
                        UnitErrors.AlreadyExists(
                            existingBaseUnit.Name, 
                            existingBaseUnit.Symbol));
                }
            }

            // Create unit entity
            var unit = new Domain.Entities.Unit(
                command.Name,
                command.Symbol,
                command.Description,
                command.Group,
                command.ConversionFactor,
                command.IsBaseUnit);

            // Add to repository
            await _unitRepository.AddAsync(unit, cancellationToken);

            // Save changes
            await _unitOfWork.SaveChangesAsync(cancellationToken);

            // Return unit ID
            return Result.Success(unit.Id);
        }
        catch (Exception ex)
        {
            // Log error here
            return Result.Failure<Guid>(new Error(
                "CreateUnit.Error",
                $"An error occurred while creating the unit: {ex.Message}"));
        }
    }
}

Step 8: Implement Get Units Vertical Slice

// Application/Units/GetUnits/GetUnitsQuery.cs
namespace UnitManagementSystem.Application.Units.GetUnits;

public sealed record GetUnitsQuery(
    int PageNumber = 1,
    int PageSize = 10,
    UnitGroup? Group = null,
    string? SearchTerm = null) : IQuery<PagedList<UnitResponse>>;
// Application/Units/GetUnits/UnitResponse.cs
namespace UnitManagementSystem.Application.Units.GetUnits;
public sealed record UnitResponse(
    Guid Id,
    string Name,
    string Symbol,
    string Description,
    string Group,
    decimal ConversionFactor,
    bool IsBaseUnit,
    DateTime CreatedAt);
// Application/Units/GetUnits/GetUnitsQueryHandler.cs
namespace UnitManagementSystem.Application.Units.GetUnits;
public sealed class GetUnitsQueryHandler : IQueryHandler<GetUnitsQuery, PagedList<UnitResponse>>
{
    private readonly IUnitRepository _unitRepository;

    public GetUnitsQueryHandler(IUnitRepository unitRepository)
    {
        _unitRepository = unitRepository;
    }

    public async Task<Result<PagedList<UnitResponse>>> Handle(
        GetUnitsQuery query,
        CancellationToken cancellationToken)
    {
        try
        {
            // Get all units (in real app, use specification pattern for filtering)
            var units = await _unitRepository.GetAllAsync(cancellationToken);

            // Apply filtering
            var filteredUnits = units.AsQueryable();

            if (query.Group.HasValue)
            {
                filteredUnits = filteredUnits.Where(u => u.Group == query.Group.Value);
            }

            if (!string.IsNullOrWhiteSpace(query.SearchTerm))
            {
                var searchTerm = query.SearchTerm.ToLower();
                filteredUnits = filteredUnits.Where(u =>
                    u.Name.ToLower().Contains(searchTerm) ||
                    u.Symbol.ToLower().Contains(searchTerm) ||
                    u.Description.ToLower().Contains(searchTerm));
            }

            // Apply pagination
            var totalCount = filteredUnits.Count();
            var items = filteredUnits
                .Skip((query.PageNumber - 1) * query.PageSize)
                .Take(query.PageSize)
                .Select(u => new UnitResponse(
                    u.Id,
                    u.Name,
                    u.Symbol,
                    u.Description,
                    u.Group.ToString(),
                    u.ConversionFactor,
                    u.IsBaseUnit,
                    u.CreatedAt))
                .ToList();

            // Return paged result
            var pagedList = new PagedList<UnitResponse>(
                items,
                totalCount,
                query.PageNumber,
                query.PageSize);

            return Result.Success(pagedList);
        }
        catch (Exception ex)
        {
            return Result.Failure<PagedList<UnitResponse>>(new Error(
                "GetUnits.Error",
                $"An error occurred while retrieving units: {ex.Message}"));
        }
    }
}
// Application/Common/PagedList.cs
namespace UnitManagementSystem.Application.Common;
public class PagedList<T>
{
    public List<T> Items { get; }
    public int PageNumber { get; }
    public int PageSize { get; }
    public int TotalCount { get; }
    public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize);
    public bool HasPreviousPage => PageNumber > 1;
    public bool HasNextPage => PageNumber < TotalPages;

    public PagedList(List<T> items, int totalCount, int pageNumber, int pageSize)
    {
        Items = items;
        TotalCount = totalCount;
        PageNumber = pageNumber;
        PageSize = pageSize;
    }
}

Step 9: Implement Pipeline Behaviors

Pipeline Behaviors

Pipeline Behaviors

// Application/Behaviors/ValidationBehavior.cs
namespace UnitManagementSystem.Application.Behaviors;
public sealed class ValidationBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IBaseRequest
{
    private readonly IEnumerable<IValidator<TRequest>> _validators;

    public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
    {
        _validators = validators;
    }

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        if (!_validators.Any())
            return await next();

        var context = new ValidationContext<TRequest>(request);

        var validationResults = await Task.WhenAll(
            _validators.Select(v => v.ValidateAsync(context, cancellationToken)));

        var failures = validationResults
            .SelectMany(r => r.Errors)
            .Where(f => f != null)
            .ToList();

        if (failures.Count != 0)
            throw new ValidationException(failures);

        return await next();
    }
}
// Application/Behaviors/LoggingBehavior.cs
namespace UnitManagementSystem.Application.Behaviors;
public sealed class LoggingBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IBaseRequest
{
    private readonly ILogger<LoggingBehavior<TRequest, TResponse>> _logger;

    public LoggingBehavior(ILogger<LoggingBehavior<TRequest, TResponse>> logger)
    {
        _logger = logger;
    }

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        var requestName = typeof(TRequest).Name;

        _logger.LogInformation(
            "Handling request: {RequestName} {@Request}",
            requestName, request);

        try
        {
            var response = await next();

            _logger.LogInformation(
                "Completed request: {RequestName}",
                requestName);

            return response;
        }
        catch (Exception ex)
        {
            _logger.LogError(
                ex,
                "Error handling request: {RequestName} {@Request}",
                requestName, request);

            throw;
        }
    }
}
// Application/Behaviors/UnitOfWorkBehavior.cs
namespace UnitManagementSystem.Application.Behaviors;
public sealed class UnitOfWorkBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : ICommandBase
{
    private readonly IUnitOfWork _unitOfWork;

    public UnitOfWorkBehavior(IUnitOfWork unitOfWork)
    {
        _unitOfWork = unitOfWork;
    }

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        if (IsNotCommand())
            return await next();

        var response = await next();

        await _unitOfWork.SaveChangesAsync(cancellationToken);

        return response;
    }

    private static bool IsNotCommand()
    {
        return !typeof(TRequest).Name.EndsWith("Command");
    }
}

Infrastructure Layer Implementation

Step 10: Set Up Database Context

// Infrastructure/Database/ApplicationDbContext.cs
namespace UnitManagementSystem.Infrastructure.Database;

public class ApplicationDbContext : DbContext, IUnitOfWork
{
    public DbSet<Unit> Units => Set<Unit>();

    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
        : base(options)
    {
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.ApplyConfiguration(new UnitConfiguration());
        base.OnModelCreating(modelBuilder);
    }

    public override async Task<int> SaveChangesAsync(
        CancellationToken cancellationToken = default)
    {
        // Update timestamps
        var entries = ChangeTracker
            .Entries<Entity>()
            .Where(e => e.State == EntityState.Added || 
                       e.State == EntityState.Modified);

        foreach (var entry in entries)
        {
            if (entry.State == EntityState.Added)
            {
                entry.Entity.UpdateTimestamp();
            }
        }

        return await base.SaveChangesAsync(cancellationToken);
    }
}
// Infrastructure/Database/Interfaces/IUnitOfWork.cs
namespace UnitManagementSystem.Infrastructure.Database.Interfaces;
public interface IUnitOfWork
{
    Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}

Step 11: Configure Entity

// Infrastructure/Database/Configurations/UnitConfiguration.cs
namespace UnitManagementSystem.Infrastructure.Database.Configurations;

public class UnitConfiguration : IEntityTypeConfiguration<Unit>
{
    public void Configure(EntityTypeBuilder<Unit> builder)
    {
        builder.ToTable("Units");

        builder.HasKey(u => u.Id);

        builder.Property(u => u.Name)
            .IsRequired()
            .HasMaxLength(100);

        builder.Property(u => u.Symbol)
            .IsRequired()
            .HasMaxLength(10);

        builder.Property(u => u.Description)
            .IsRequired()
            .HasMaxLength(500);

        builder.Property(u => u.Group)
            .IsRequired()
            .HasConversion<string>()
            .HasMaxLength(50);

        builder.Property(u => u.ConversionFactor)
            .IsRequired()
            .HasPrecision(18, 6);

        builder.Property(u => u.IsBaseUnit)
            .IsRequired();

        builder.Property(u => u.CreatedAt)
            .IsRequired();

        builder.Property(u => u.UpdatedAt);

        // Create indexes
        builder.HasIndex(u => u.Name)
            .IsUnique();

        builder.HasIndex(u => u.Symbol)
            .IsUnique();

        builder.HasIndex(u => u.Group);

        builder.HasIndex(u => new { u.Group, u.IsBaseUnit })
            .HasFilter("[IsBaseUnit] = 1");
    }
}

Step 12: Implement Repository

// Infrastructure/Repositories/UnitRepository.cs
namespace UnitManagementSystem.Infrastructure.Repositories;

public sealed class UnitRepository : IUnitRepository
{
    private readonly ApplicationDbContext _context;

    public UnitRepository(ApplicationDbContext context)
    {
        _context = context;
    }

    public async Task<Unit?> GetByIdAsync(
        Guid id, 
        CancellationToken cancellationToken = default)
    {
        return await _context.Units
            .FirstOrDefaultAsync(u => u.Id == id, cancellationToken);
    }

    public async Task<IEnumerable<Unit>> GetAllAsync(
        CancellationToken cancellationToken = default)
    {
        return await _context.Units
            .ToListAsync(cancellationToken);
    }

    public async Task<IEnumerable<Unit>> GetByGroupAsync(
        UnitGroup group, 
        CancellationToken cancellationToken = default)
    {
        return await _context.Units
            .Where(u => u.Group == group)
            .ToListAsync(cancellationToken);
    }

    public async Task<Unit?> GetBaseUnitAsync(
        UnitGroup group, 
        CancellationToken cancellationToken = default)
    {
        return await _context.Units
            .FirstOrDefaultAsync(u => u.Group == group && u.IsBaseUnit, cancellationToken);
    }

    public async Task<bool> ExistsAsync(
        string name, 
        string symbol, 
        CancellationToken cancellationToken = default)
    {
        return await _context.Units
            .AnyAsync(u => 
                u.Name == name || 
                u.Symbol == symbol, 
                cancellationToken);
    }

    public async Task AddAsync(
        Unit unit, 
        CancellationToken cancellationToken = default)
    {
        await _context.Units.AddAsync(unit, cancellationToken);
    }

    public void Update(Unit unit)
    {
        _context.Units.Update(unit);
    }

    public void Delete(Unit unit)
    {
        _context.Units.Remove(unit);
    }

    public async Task<int> SaveChangesAsync(
        CancellationToken cancellationToken = default)
    {
        return await _context.SaveChangesAsync(cancellationToken);
    }
}

Step 13: Configure Dependency Injection

Dependency Injection

Dependency Injection

// Infrastructure/DependencyInjection.cs
namespace UnitManagementSystem.Infrastructure;

public static class DependencyInjection
{
    public static IServiceCollection AddInfrastructure(
        this IServiceCollection services,
        IConfiguration configuration)
    {
        // Add database context
        services.AddDbContext<ApplicationDbContext>(options =>
            options.UseSqlServer(
                configuration.GetConnectionString("DefaultConnection"),
                sqlOptions =>
                {
                    sqlOptions.MigrationsAssembly(
                        typeof(ApplicationDbContext).Assembly.FullName);
                    sqlOptions.EnableRetryOnFailure(
                        maxRetryCount: 5,
                        maxRetryDelay: TimeSpan.FromSeconds(30),
                        errorNumbersToAdd: null);
                }));

        // Register repositories
        services.AddScoped<IUnitRepository, UnitRepository>();
        services.AddScoped<IUnitOfWork>(sp => 
            sp.GetRequiredService<ApplicationDbContext>());

        // Add health checks
        services.AddHealthChecks()
            .AddDbContextCheck<ApplicationDbContext>();

        return services;
    }
}

Presentation Layer Implementation

Step 14: Create API Controllers

API Controller Flow

API Controller Flow

// Api/Controllers/UnitsController.cs
namespace UnitManagementSystem.Api.Controllers;

[ApiController]
[Route("api/[controller]")]
public class UnitsController : ControllerBase
{
    private readonly ISender _sender;

    public UnitsController(ISender sender)
    {
        _sender = sender;
    }

    [HttpGet]
    [ProducesResponseType(typeof(PagedList<UnitResponse>), StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status400BadRequest)]
    public async Task<IActionResult> GetUnits(
        [FromQuery] GetUnitsQuery query,
        CancellationToken cancellationToken)
    {
        var result = await _sender.Send(query, cancellationToken);

        return result.IsSuccess
            ? Ok(result.Value)
            : BadRequest(result.Error);
    }

    [HttpGet("{id:guid}")]
    [ProducesResponseType(typeof(UnitResponse), StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<IActionResult> GetUnitById(
        Guid id,
        CancellationToken cancellationToken)
    {
        var query = new GetUnitByIdQuery(id);
        var result = await _sender.Send(query, cancellationToken);

        return result.IsSuccess
            ? Ok(result.Value)
            : NotFound(result.Error);
    }

    [HttpPost]
    [ProducesResponseType(typeof(Guid), StatusCodes.Status201Created)]
    [ProducesResponseType(StatusCodes.Status400BadRequest)]
    public async Task<IActionResult> CreateUnit(
        [FromBody] CreateUnitCommand command,
        CancellationToken cancellationToken)
    {
        var result = await _sender.Send(command, cancellationToken);

        if (!result.IsSuccess)
            return BadRequest(result.Error);

        return CreatedAtAction(
            nameof(GetUnitById),
            new { id = result.Value },
            result.Value);
    }

    [HttpPut("{id:guid}")]
    [ProducesResponseType(StatusCodes.Status204NoContent)]
    [ProducesResponseType(StatusCodes.Status400BadRequest)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<IActionResult> UpdateUnit(
        Guid id,
        [FromBody] UpdateUnitCommand command,
        CancellationToken cancellationToken)
    {
        var updateCommand = command with { UnitId = id };
        var result = await _sender.Send(updateCommand, cancellationToken);

        return result.IsSuccess
            ? NoContent()
            : result.Error.Code == "Unit.NotFound" 
                ? NotFound(result.Error)
                : BadRequest(result.Error);
    }

    [HttpDelete("{id:guid}")]
    [ProducesResponseType(StatusCodes.Status204NoContent)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<IActionResult> DeleteUnit(
        Guid id,
        CancellationToken cancellationToken)
    {
        var command = new DeleteUnitCommand(id);
        var result = await _sender.Send(command, cancellationToken);

        return result.IsSuccess
            ? NoContent()
            : NotFound(result.Error);
    }
}

Step 15: Configure Application Services

// Api/Program.cs
var builder = WebApplication.CreateBuilder(args);

// Add services
builder.Services.AddControllers();
// Add Swagger
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
    options.SwaggerDoc("v1", new OpenApiInfo
    {
        Title = "Unit Management System API",
        Version = "v1",
        Description = "API for managing measurement units"
    });

    options.CustomSchemaIds(type => type.FullName);
});
// Add MediatR
builder.Services.AddMediatR(config =>
{
    config.RegisterServicesFromAssembly(typeof(Program).Assembly);
    config.AddOpenBehavior(typeof(LoggingBehavior<,>));
    config.AddOpenBehavior(typeof(ValidationBehavior<,>));
    config.AddOpenBehavior(typeof(UnitOfWorkBehavior<,>));
});
// Add validation
builder.Services.AddValidatorsFromAssembly(
    typeof(Application.DependencyInjection).Assembly);
// Add infrastructure
builder.Services.AddInfrastructure(builder.Configuration);
// Add CORS
builder.Services.AddCors(options =>
{
    options.AddPolicy("AllowAll",
        builder =>
        {
            builder.AllowAnyOrigin()
                   .AllowAnyMethod()
                   .AllowAnyHeader();
        });
});
var app = builder.Build();
// Configure pipeline
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
    app.UseDeveloperExceptionPage();
}
app.UseHttpsRedirection();
app.UseCors("AllowAll");
app.UseAuthorization();
app.MapControllers();
// Add health check endpoint
app.MapHealthChecks("/health");
app.Run();

Step 16: Create Global Exception Handler

// Api/Middleware/GlobalExceptionHandlerMiddleware.cs
namespace UnitManagementSystem.Api.Middleware;

public sealed class GlobalExceptionHandlerMiddleware : IMiddleware
{
    private readonly ILogger<GlobalExceptionHandlerMiddleware> _logger;
    private readonly IWebHostEnvironment _environment;

    public GlobalExceptionHandlerMiddleware(
        ILogger<GlobalExceptionHandlerMiddleware> logger,
        IWebHostEnvironment environment)
    {
        _logger = logger;
        _environment = environment;
    }

    public async Task InvokeAsync(
        HttpContext context, 
        RequestDelegate next)
    {
        try
        {
            await next(context);
        }
        catch (Exception ex)
        {
            _logger.LogError(
                ex, 
                "An unhandled exception occurred: {Message}", 
                ex.Message);

            await HandleExceptionAsync(context, ex);
        }
    }

    private async Task HandleExceptionAsync(HttpContext context, Exception exception)
    {
        context.Response.ContentType = "application/json";

        var statusCode = exception switch
        {
            ValidationException => StatusCodes.Status400BadRequest,
            NotFoundException => StatusCodes.Status404NotFound,
            _ => StatusCodes.Status500InternalServerError
        };

        context.Response.StatusCode = statusCode;

        var response = new
        {
            error = new
            {
                message = exception.Message,
                type = exception.GetType().Name,
                stackTrace = _environment.IsDevelopment() ? exception.StackTrace : null,
                innerException = _environment.IsDevelopment() ? exception.InnerException?.Message : null
            }
        };

        await context.Response.WriteAsync(JsonSerializer.Serialize(response));
    }
}
// Register in Program.cs
builder.Services.AddTransient<GlobalExceptionHandlerMiddleware>();
app.UseMiddleware<GlobalExceptionHandlerMiddleware>();

Testing the Implementation

Test Pyramid Implementation

Test Pyramid Implementation

Step 17: Create Integration Tests

// Tests/Integration/UnitsControllerTests.cs
namespace UnitManagementSystem.Tests.Integration;

[Collection("Database")]
public class UnitsControllerTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly WebApplicationFactory<Program> _factory;
    private readonly HttpClient _client;

    public UnitsControllerTests(WebApplicationFactory<Program> factory)
    {
        _factory = factory.WithWebHostBuilder(builder =>
        {
            builder.ConfigureServices(services =>
            {
                // Replace database with in-memory for testing
                var descriptor = services.SingleOrDefault(
                    d => d.ServiceType == typeof(DbContextOptions<ApplicationDbContext>));

                if (descriptor != null)
                    services.Remove(descriptor);

                services.AddDbContext<ApplicationDbContext>(options =>
                    options.UseInMemoryDatabase("TestDatabase"));
            });
        });

        _client = _factory.CreateClient();
    }

    [Fact]
    public async Task CreateUnit_ValidRequest_ReturnsCreated()
    {
        // Arrange
        var command = new
        {
            Name = "Meter",
            Symbol = "m",
            Description = "SI unit of length",
            Group = "Length",
            ConversionFactor = 1,
            IsBaseUnit = true
        };

        // Act
        var response = await _client.PostAsJsonAsync("/api/units", command);

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.Created);
        var location = response.Headers.Location?.ToString();
        location.Should().NotBeNull();
        location.Should().Contain("/api/units/");
    }

    [Fact]
    public async Task CreateUnit_DuplicateName_ReturnsBadRequest()
    {
        // Arrange
        var command = new
        {
            Name = "Meter",
            Symbol = "m",
            Description = "SI unit of length",
            Group = "Length",
            ConversionFactor = 1,
            IsBaseUnit = true
        };

        // Create first unit
        await _client.PostAsJsonAsync("/api/units", command);

        // Act - Try to create duplicate
        var response = await _client.PostAsJsonAsync("/api/units", command);

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
    }

    [Fact]
    public async Task GetUnits_ReturnsPagedList()
    {
        // Arrange
        // Create some test units
        for (int i = 0; i < 15; i++)
        {
            var command = new
            {
                Name = $"Unit{i}",
                Symbol = $"U{i}",
                Description = $"Test unit {i}",
                Group = "Length",
                ConversionFactor = 1,
                IsBaseUnit = i == 0
            };

            await _client.PostAsJsonAsync("/api/units", command);
        }

        // Act
        var response = await _client.GetAsync("/api/units?pageNumber=2&pageSize=5");

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.OK);

        var content = await response.Content.ReadFromJsonAsync<PagedList<UnitResponse>>();
        content.Should().NotBeNull();
        content!.Items.Should().HaveCount(5);
        content.TotalCount.Should().Be(15);
        content.PageNumber.Should().Be(2);
        content.PageSize.Should().Be(5);
    }
}

Deployment Considerations

Step 18: Docker Configuration

Docker Configuration

Docker Configuration

# Dockerfile
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY ["UnitManagementSystem.Api/UnitManagementSystem.Api.csproj", "UnitManagementSystem.Api/"]
COPY ["UnitManagementSystem.Application/UnitManagementSystem.Application.csproj", "UnitManagementSystem.Application/"]
COPY ["UnitManagementSystem.Domain/UnitManagementSystem.Domain.csproj", "UnitManagementSystem.Domain/"]
COPY ["UnitManagementSystem.Infrastructure/UnitManagementSystem.Infrastructure.csproj", "UnitManagementSystem.Infrastructure/"]
RUN dotnet restore "UnitManagementSystem.Api/UnitManagementSystem.Api.csproj"
COPY . .
WORKDIR "/src/UnitManagementSystem.Api"
RUN dotnet build "UnitManagementSystem.Api.csproj" -c Release -o /app/build
FROM build AS publish
RUN dotnet publish "UnitManagementSystem.Api.csproj" -c Release -o /app/publish
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "UnitManagementSystem.Api.dll"]

Step 19: Database Migrations

// Infrastructure/Database/Migrations/InitialCreate.cs
public partial class InitialCreate : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.CreateTable(
            name: "Units",
            columns: table => new
            {
                Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
                Name = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
                Symbol = table.Column<string>(type: "nvarchar(10)", maxLength: 10, nullable: false),
                Description = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
                Group = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
                ConversionFactor = table.Column<decimal>(type: "decimal(18,6)", precision: 18, scale: 6, nullable: false),
                IsBaseUnit = table.Column<bool>(type: "bit", nullable: false),
                CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
                UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true)
            },
            constraints: table =>
            {
                table.PrimaryKey("PK_Units", x => x.Id);
            });

        migrationBuilder.CreateIndex(
            name: "IX_Units_Group",
            table: "Units",
            column: "Group");

        migrationBuilder.CreateIndex(
            name: "IX_Units_Group_IsBaseUnit",
            table: "Units",
            columns: new[] { "Group", "IsBaseUnit" },
            filter: "[IsBaseUnit] = 1");

        migrationBuilder.CreateIndex(
            name: "IX_Units_Name",
            table: "Units",
            column: "Name",
            unique: true);

        migrationBuilder.CreateIndex(
            name: "IX_Units_Symbol",
            table: "Units",
            column: "Symbol",
            unique: true);
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.DropTable(name: "Units");
    }
}

Monitoring and Logging

Step 20: Add Observability

Telemetry Architecture

Telemetry Architecture

// Infrastructure/Telemetry/ApplicationTelemetry.cs
public static class ApplicationTelemetry
{
    public static void AddApplicationTelemetry(
        this IServiceCollection services,
        IConfiguration configuration)
    {
        services.AddApplicationInsightsTelemetry(options =>
        {
            options.ConnectionString = 
                configuration["ApplicationInsights:ConnectionString"];
        });

        services.AddOpenTelemetry()
            .WithMetrics(metrics =>
            {
                metrics.AddAspNetCoreInstrumentation()
                       .AddHttpClientInstrumentation()
                       .AddRuntimeInstrumentation()
                       .AddEventCountersInstrumentation(c =>
                       {
                           c.AddEventSources(
                               "Microsoft.AspNetCore.Hosting",
                               "System.Runtime");
                       });
            })
            .WithTracing(tracing =>
            {
                tracing.AddAspNetCoreInstrumentation()
                       .AddHttpClientInstrumentation()
                       .AddEntityFrameworkCoreInstrumentation();
            });
    }
}

// Register in Program.cs
builder.Services.AddApplicationTelemetry(builder.Configuration);

Best Practices and Recommendations

1. Project Structure Consistency

  • Maintain consistent naming conventions across all layers
  • Use feature folders within each vertical slice
  • Keep shared interfaces in the domain layer

2. Testing Strategy

  • Unit tests for domain entities and value objects
  • Integration tests for command/query handlers
  • End-to-end tests for API endpoints
  • Test data builders for complex test scenarios

3. Performance Optimization

  • Use ***IAsyncEnumerable*** for large data sets
  • Implement caching at the repository level
  • Consider read/write separation for heavy read operations
  • Use compiled queries for frequently executed database queries

4. Security Considerations

  • Validate all inputs at the application layer
  • Implement rate limiting for public endpoints
  • Use API keys or OAuth for external access
  • Audit all data modifications

5. Error Handling

  • Use the Result pattern for domain operations
  • Implement global exception handling middleware
  • Log all errors with appropriate context
  • Return user-friendly error messages for client errors

Conclusion

This tutorial has demonstrated how to implement Clean Architecture with Vertical Slice Architecture in .NET 8. The combination provides:

  1. Clear separation of concerns through distinct layers
  2. Feature-centric organization for better maintainability
  3. Testability through dependency injection and clean boundaries
  4. Scalability through modular design
  5. Flexibility to adapt to changing requirements

The complete implementation provides a solid foundation for building enterprise applications that are maintainable, testable, and scalable. Each vertical slice can be developed independently, making it ideal for team-based development and continuous delivery.

[embed]

Remember that architecture should serve your business needs, not dictate them. Start with the principles demonstrated here, then adapt as your application evolves and requirements change.


메타데이터
post_id
6fa47bbb8e97
slug
implementing-clean-architecture-with-vertical-slice-architecture-in-net-8-6fa47bbb8e97
url
https://medium.com/@mvineetsharma/implementing-clean-architecture-with-vertical-slice-architecture-in-net-8-6fa47bbb8e97
canonical_url
https://medium.com/@mvineetsharma/implementing-clean-architecture-with-vertical-slice-architecture-in-net-8-6fa47bbb8e97
author_url
https://medium.com/@mvineetsharma
status
ok
fetched_at
2026-06-23 17:05:31