Clean Architecture · Additional notes

4 min read
Mid-level13 min read
Rapid overview

Additional notes

Entities (Domain Layer)

Entities encapsulate enterprise-wide business rules. They are the least likely to change when something external changes. They have no dependency on frameworks, databases, or UI concerns.

Characteristics of a Well-Designed Entity

  • Protects its invariants via private setters and factory methods.
  • Contains domain logic, not just data (rich domain model).
  • Raises domain events when meaningful state transitions occur.
  • Has no [JsonProperty], [Column], or framework-specific attributes.
public sealed class Trade
{
    public Guid Id { get; private set; }
    public string Symbol { get; private set; } = default!;
    public decimal Quantity { get; private set; }
    public decimal EntryPrice { get; private set; }
    public TradeStatus Status { get; private set; }
    public DateTime OpenedAtUtc { get; private set; }
    public DateTime? ClosedAtUtc { get; private set; }

    private readonly List<IDomainEvent> _domainEvents = new();
    public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();

    private Trade() { } // EF Core / serialization

    public static Trade Open(string symbol, decimal quantity, decimal entryPrice)
    {
        if (string.IsNullOrWhiteSpace(symbol))
            throw new DomainException("Symbol is required.");
        if (quantity <= 0)
            throw new DomainException("Quantity must be positive.");
        if (entryPrice <= 0)
            throw new DomainException("Entry price must be positive.");

        var trade = new Trade
        {
            Id = Guid.NewGuid(),
            Symbol = symbol.ToUpperInvariant(),
            Quantity = quantity,
            EntryPrice = entryPrice,
            Status = TradeStatus.Open,
            OpenedAtUtc = DateTime.UtcNow
        };

        trade._domainEvents.Add(new TradeOpenedEvent(trade.Id, trade.Symbol));
        return trade;
    }

    public void Close(decimal exitPrice)
    {
        if (Status != TradeStatus.Open)
            throw new DomainException("Only open trades can be closed.");

        Status = TradeStatus.Closed;
        ClosedAtUtc = DateTime.UtcNow;
        _domainEvents.Add(new TradeClosedEvent(Id, exitPrice));
    }

    public void ClearDomainEvents() => _domainEvents.Clear();
}

public enum TradeStatus { Open, Closed, Cancelled }

Value Objects

Value objects are immutable, compared by structural equality, and have no identity.

public sealed record Money(decimal Amount, string Currency)
{
    public static Money Zero(string currency) => new(0m, currency);

    public Money Add(Money other)
    {
        if (Currency != other.Currency)
            throw new DomainException("Cannot add different currencies.");
        return new Money(Amount + other.Amount, Currency);
    }
}

Domain Events

public interface IDomainEvent
{
    DateTime OccurredOnUtc { get; }
}

public sealed record TradeOpenedEvent(Guid TradeId, string Symbol) : IDomainEvent
{
    public DateTime OccurredOnUtc { get; } = DateTime.UtcNow;
}

public sealed record TradeClosedEvent(Guid TradeId, decimal ExitPrice) : IDomainEvent
{
    public DateTime OccurredOnUtc { get; } = DateTime.UtcNow;
}

Use Cases (Application Layer)

Use cases orchestrate the flow of data to and from entities, and direct those entities to use their enterprise-wide business rules to achieve the goals of the use case. This layer contains application-specific business rules.

What Lives Here

  • Command and query handlers (CQRS)
  • Application services
  • Interfaces (ports) for infrastructure
  • DTOs for input/output across boundaries
  • Validators (e.g., FluentValidation)
  • Mapping profiles (AutoMapper, Mapster)
// Command
public sealed record OpenTradeCommand(
    string Symbol,
    decimal Quantity,
    decimal EntryPrice) : IRequest<Guid>;

// Handler
public sealed class OpenTradeHandler : IRequestHandler<OpenTradeCommand, Guid>
{
    private readonly ITradeRepository _trades;
    private readonly IUnitOfWork _unitOfWork;
    private readonly ILogger<OpenTradeHandler> _logger;

    public OpenTradeHandler(
        ITradeRepository trades,
        IUnitOfWork unitOfWork,
        ILogger<OpenTradeHandler> logger)
    {
        _trades = trades;
        _unitOfWork = unitOfWork;
        _logger = logger;
    }

    public async Task<Guid> Handle(OpenTradeCommand request, CancellationToken ct)
    {
        var trade = Trade.Open(request.Symbol, request.Quantity, request.EntryPrice);

        await _trades.AddAsync(trade, ct);
        await _unitOfWork.CommitAsync(ct);

        _logger.LogInformation("Trade {TradeId} opened for {Symbol}", trade.Id, trade.Symbol);

        return trade.Id;
    }
}

Interface Adapters

This layer converts data between the format most convenient for use cases/entities and the format most convenient for external agencies (database, web, etc.). Controllers, presenters, gateways, and repository implementations all live here conceptually.

In .NET solutions, this maps to both the Infrastructure project and the Presentation (API) project.


Frameworks & Drivers

The outermost layer. This is where all the details go: the web framework, the database driver, the message broker client, etc. You keep this layer thin -- it is glue code that connects to the next circle inward.


Project Structure in .NET

A canonical Clean Architecture solution in .NET looks like this:

MySolution/
  src/
    MyApp.Domain/               <-- Innermost: entities, value objects, domain events, interfaces
      Common/
        IDomainEvent.cs
        DomainException.cs
      Entities/
        Trade.cs
        Portfolio.cs
      ValueObjects/
        Money.cs
      Enums/
        TradeStatus.cs

    MyApp.Application/          <-- Use cases, CQRS handlers, validators, DTOs
      Common/
        Interfaces/
          IUnitOfWork.cs
          IDateTimeProvider.cs
        Behaviors/
          ValidationBehavior.cs
          LoggingBehavior.cs
        Mappings/
          MappingProfile.cs
      Trades/
        Commands/
          OpenTrade/
            OpenTradeCommand.cs
            OpenTradeHandler.cs
            OpenTradeValidator.cs
        Queries/
          GetTradeById/
            GetTradeByIdQuery.cs
            GetTradeByIdHandler.cs
            TradeDto.cs

    MyApp.Infrastructure/       <-- EF Core, external services, file system, messaging
      Persistence/
        AppDbContext.cs
        Configurations/
          TradeConfiguration.cs
        Repositories/
          TradeRepository.cs
        UnitOfWork.cs
      Services/
        DateTimeProvider.cs
        EmailService.cs
      DependencyInjection.cs    <-- Extension method to register infra services

    MyApp.Api/                  <-- ASP.NET Core, controllers, middleware, DI composition root
      Controllers/
        TradesController.cs
      Middleware/
        ExceptionHandlingMiddleware.cs
      Program.cs

  tests/
    MyApp.Domain.Tests/
    MyApp.Application.Tests/
    MyApp.Infrastructure.Tests/
    MyApp.Api.Tests/

Project References (Enforcing the Dependency Rule)

MyApp.Api           --> MyApp.Application, MyApp.Infrastructure
MyApp.Infrastructure --> MyApp.Application
MyApp.Application    --> MyApp.Domain
MyApp.Domain         --> (nothing)

The API project references Infrastructure only to register services at the composition root. At runtime, Application code never calls Infrastructure types directly -- it always goes through abstractions.

Enforcing with Architecture Tests

using NetArchTest.Rules;

[Fact]
public void Domain_Should_Not_Depend_On_Application()
{
    var result = Types
        .InAssembly(typeof(Trade).Assembly)
        .ShouldNot()
        .HaveDependencyOn("MyApp.Application")
        .GetResult();

    Assert.True(result.IsSuccessful);
}

[Fact]
public void Domain_Should_Not_Depend_On_Infrastructure()
{
    var result = Types
        .InAssembly(typeof(Trade).Assembly)
        .ShouldNot()
        .HaveDependencyOn("MyApp.Infrastructure")
        .GetResult();

    Assert.True(result.IsSuccessful);
}

[Fact]
public void Application_Should_Not_Depend_On_Infrastructure()
{
    var result = Types
        .InAssembly(typeof(OpenTradeCommand).Assembly)
        .ShouldNot()
        .HaveDependencyOn("MyApp.Infrastructure")
        .GetResult();

    Assert.True(result.IsSuccessful);
}

Dependency Injection in Clean Architecture

DI is the mechanism that makes the Dependency Rule work at runtime. Without it, inner layers would have to new up outer-layer implementations, violating the rule.

Composition Root

The composition root is the single place in your application where all dependencies are wired. In ASP.NET Core, this is Program.cs (or Startup.cs).

// Program.cs -- Composition Root
var builder = WebApplication.CreateBuilder(args);

// Application layer registrations
builder.Services.AddMediatR(cfg =>
    cfg.RegisterServicesFromAssembly(typeof(OpenTradeCommand).Assembly));
builder.Services.AddValidatorsFromAssembly(typeof(OpenTradeCommand).Assembly);
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));

// Infrastructure layer registrations (via extension method)
builder.Services.AddInfrastructure(builder.Configuration);

builder.Services.AddControllers();

var app = builder.Build();
app.UseMiddleware<ExceptionHandlingMiddleware>();
app.MapControllers();
app.Run();

Infrastructure Registration Extension

// MyApp.Infrastructure/DependencyInjection.cs
public static class DependencyInjection
{
    public static IServiceCollection AddInfrastructure(
        this IServiceCollection services,
        IConfiguration configuration)
    {
        services.AddDbContext<AppDbContext>(options =>
            options.UseSqlServer(configuration.GetConnectionString("Default")));

        services.AddScoped<ITradeRepository, TradeRepository>();
        services.AddScoped<IPortfolioRepository, PortfolioRepository>();
        services.AddScoped<IUnitOfWork, UnitOfWork>();
        services.AddSingleton<IDateTimeProvider, DateTimeProvider>();

        return services;
    }
}

Lifetime Guidance

Service TypeLifetimeExample
DbContextScopedAppDbContext
RepositoriesScopedTradeRepository
Unit of WorkScopedUnitOfWork
Stateless servicesTransientValidationBehavior<,>
Configuration/clocksSingletonDateTimeProvider

CQRS with Clean Architecture

Command Query Responsibility Segregation separates read and write operations into distinct models. This maps naturally to Clean Architecture because commands and queries are use cases.

Why CQRS Fits

  • Commands change state (write side) and go through the full domain model with validation and invariants.
  • Queries read state (read side) and can bypass the domain model entirely for performance.

Command Example

// Command + Handler (write side -- goes through the domain)
public sealed record CloseTradeCommand(Guid TradeId, decimal ExitPrice) : IRequest<Unit>;

public sealed class CloseTradeHandler : IRequestHandler<CloseTradeCommand, Unit>
{
    private readonly ITradeRepository _trades;
    private readonly IUnitOfWork _unitOfWork;

    public CloseTradeHandler(ITradeRepository trades, IUnitOfWork unitOfWork)
    {
        _trades = trades;
        _unitOfWork = unitOfWork;
    }

    public async Task<Unit> Handle(CloseTradeCommand request, CancellationToken ct)
    {
        var trade = await _trades.GetByIdAsync(request.TradeId, ct)
            ?? throw new NotFoundException(nameof(Trade), request.TradeId);

        trade.Close(request.ExitPrice); // domain logic enforces invariants

        await _unitOfWork.CommitAsync(ct);
        return Unit.Value;
    }
}

Query Example

// Query + Handler (read side -- can use lightweight read models)
public sealed record GetTradeByIdQuery(Guid TradeId) : IRequest<TradeDto?>;

public sealed class GetTradeByIdHandler : IRequestHandler<GetTradeByIdQuery, TradeDto?>
{
    private readonly ITradeReadRepository _readRepo;

    public GetTradeByIdHandler(ITradeReadRepository readRepo)
        => _readRepo = readRepo;

    public async Task<TradeDto?> Handle(GetTradeByIdQuery request, CancellationToken ct)
        => await _readRepo.GetTradeDtoByIdAsync(request.TradeId, ct);
}

Pipeline Behaviors (Cross-Cutting Concerns)

MediatR pipeline behaviors wrap every command/query, providing a clean way to handle validation, logging, and transactions.

public sealed class ValidationBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    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 ct)
    {
        if (_validators.Any())
        {
            var context = new ValidationContext<TRequest>(request);
            var failures = (await Task.WhenAll(
                    _validators.Select(v => v.ValidateAsync(context, ct))))
                .SelectMany(r => r.Errors)
                .Where(f => f is not null)
                .ToList();

            if (failures.Count > 0)
                throw new ValidationException(failures);
        }

        return await next();
    }
}

Validators with FluentValidation

public sealed class OpenTradeValidator : AbstractValidator<OpenTradeCommand>
{
    public OpenTradeValidator()
    {
        RuleFor(x => x.Symbol)
            .NotEmpty().WithMessage("Symbol is required.")
            .MaximumLength(10).WithMessage("Symbol must be 10 characters or fewer.");

        RuleFor(x => x.Quantity)
            .GreaterThan(0).WithMessage("Quantity must be positive.");

        RuleFor(x => x.EntryPrice)
            .GreaterThan(0).WithMessage("Entry price must be positive.");
    }
}

Repository Pattern and Unit of Work

Repository Pattern

Repositories provide a collection-like interface for accessing domain entities, hiding persistence details from the Application layer.

// Domain or Application layer -- the abstraction
public interface ITradeRepository
{
    Task<Trade?> GetByIdAsync(Guid id, CancellationToken ct = default);
    Task<IReadOnlyList<Trade>> GetOpenTradesAsync(CancellationToken ct = default);
    Task AddAsync(Trade trade, CancellationToken ct = default);
    void Remove(Trade trade);
}

// Infrastructure layer -- the implementation
public sealed class TradeRepository : ITradeRepository
{
    private readonly AppDbContext _db;

    public TradeRepository(AppDbContext db) => _db = db;

    public async Task<Trade?> GetByIdAsync(Guid id, CancellationToken ct)
        => await _db.Trades.FirstOrDefaultAsync(t => t.Id == id, ct);

    public async Task<IReadOnlyList<Trade>> GetOpenTradesAsync(CancellationToken ct)
        => await _db.Trades
            .Where(t => t.Status == TradeStatus.Open)
            .ToListAsync(ct);

    public async Task AddAsync(Trade trade, CancellationToken ct)
        => await _db.Trades.AddAsync(trade, ct);

    public void Remove(Trade trade)
        => _db.Trades.Remove(trade);
}

Unit of Work

The Unit of Work coordinates writes across multiple repositories in a single transaction.

// Application layer -- abstraction
public interface IUnitOfWork
{
    Task<int> CommitAsync(CancellationToken ct = default);
}

// Infrastructure layer -- implementation wrapping DbContext.SaveChanges
public sealed class UnitOfWork : IUnitOfWork
{
    private readonly AppDbContext _db;
    private readonly IMediator _mediator;

    public UnitOfWork(AppDbContext db, IMediator mediator)
    {
        _db = db;
        _mediator = mediator;
    }

    public async Task<int> CommitAsync(CancellationToken ct)
    {
        // Dispatch domain events before saving
        await DispatchDomainEventsAsync(ct);

        return await _db.SaveChangesAsync(ct);
    }

    private async Task DispatchDomainEventsAsync(CancellationToken ct)
    {
        var entities = _db.ChangeTracker
            .Entries<Trade>()
            .Where(e => e.Entity.DomainEvents.Any())
            .Select(e => e.Entity)
            .ToList();

        var events = entities.SelectMany(e => e.DomainEvents).ToList();

        foreach (var entity in entities)
            entity.ClearDomainEvents();

        foreach (var domainEvent in events)
            await _mediator.Publish(domainEvent, ct);
    }
}

When to Skip Repository (Direct DbContext Access for Reads)

For read-heavy query handlers where you need projections and do not care about domain invariants, injecting AppDbContext or a thin read-only interface is acceptable. This avoids bloating repository interfaces with query-specific methods.

// Lightweight read repository for the query side
public interface ITradeReadRepository
{
    Task<TradeDto?> GetTradeDtoByIdAsync(Guid id, CancellationToken ct = default);
    Task<IReadOnlyList<TradeListItemDto>> GetOpenTradeListAsync(CancellationToken ct = default);
}

public sealed class TradeReadRepository : ITradeReadRepository
{
    private readonly AppDbContext _db;

    public TradeReadRepository(AppDbContext db) => _db = db;

    public async Task<TradeDto?> GetTradeDtoByIdAsync(Guid id, CancellationToken ct)
        => await _db.Trades
            .Where(t => t.Id == id)
            .Select(t => new TradeDto(t.Id, t.Symbol, t.Quantity, t.EntryPrice, t.Status.ToString()))
            .FirstOrDefaultAsync(ct);

    public async Task<IReadOnlyList<TradeListItemDto>> GetOpenTradeListAsync(CancellationToken ct)
        => await _db.Trades
            .Where(t => t.Status == TradeStatus.Open)
            .Select(t => new TradeListItemDto(t.Id, t.Symbol, t.Quantity))
            .ToListAsync(ct);
}

Mapping Between Layers (DTOs, ViewModels, Domain Models)

Each boundary crossing should have its own data-transfer model. Do not let domain entities leak into API responses or database schemas leak into use cases.

The Models

LayerModel TypePurpose
DomainEntity / Value ObjectEnforces invariants, contains behavior
ApplicationDTO (Data Transfer Object)Carries data across use case boundary
PresentationViewModel / ApiResponseShapes data for the consumer (API, UI)
InfrastructureEF Entity / DB modelMaps to database schema

When Domain Entity = EF Entity

In many .NET projects, the domain entity is also mapped directly by EF Core using IEntityTypeConfiguration<T>. This is acceptable as long as:

  • EF configuration is in the Infrastructure layer (not attributes on the entity).
  • The entity has no EF-specific attributes or navigation property compromises.
  • Private setters and constructors are used (EF Core supports this).

Mapping with Manual Projection

// Application layer DTO
public sealed record TradeDto(
    Guid Id,
    string Symbol,
    decimal Quantity,
    decimal EntryPrice,
    string Status);

// Presentation layer ViewModel (API response)
public sealed record TradeResponse(
    Guid Id,
    string Symbol,
    decimal Quantity,
    decimal EntryPrice,
    string Status,
    string DisplayName);

// In the controller -- map DTO to response
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id, CancellationToken ct)
{
    var dto = await _mediator.Send(new GetTradeByIdQuery(id), ct);

    if (dto is null)
        return NotFound();

    var response = new TradeResponse(
        dto.Id,
        dto.Symbol,
        dto.Quantity,
        dto.EntryPrice,
        dto.Status,
        $"{dto.Symbol} x {dto.Quantity}");

    return Ok(response);
}

Mapping with AutoMapper / Mapster

// AutoMapper profile (Application layer)
public sealed class TradeMappingProfile : Profile
{
    public TradeMappingProfile()
    {
        CreateMap<Trade, TradeDto>()
            .ForMember(d => d.Status, opt => opt.MapFrom(s => s.Status.ToString()));
    }
}

// Usage in handler
public sealed class GetTradeByIdHandler : IRequestHandler<GetTradeByIdQuery, TradeDto?>
{
    private readonly ITradeRepository _trades;
    private readonly IMapper _mapper;

    public GetTradeByIdHandler(ITradeRepository trades, IMapper mapper)
    {
        _trades = trades;
        _mapper = mapper;
    }

    public async Task<TradeDto?> Handle(GetTradeByIdQuery request, CancellationToken ct)
    {
        var trade = await _trades.GetByIdAsync(request.TradeId, ct);
        return trade is null ? null : _mapper.Map<TradeDto>(trade);
    }
}

Anti-Pattern: Returning Domain Entities from the API

// BAD -- domain entity exposed to the outside world
[HttpGet("{id:guid}")]
public async Task<Trade> GetById(Guid id)
{
    return await _trades.GetByIdAsync(id);
    // Exposes internal state, domain events, private methods via serialization.
    // Any change to the entity breaks the API contract.
}

Testing Strategies Per Layer

Domain Layer Tests

Domain tests are pure unit tests. No mocking frameworks needed -- just instantiate entities and assert invariants.

public class TradeTests
{
    [Fact]
    public void Open_WithValidInputs_CreatesTradeWithOpenStatus()
    {
        var trade = Trade.Open("EURUSD", 1.5m, 1.1050m);

        Assert.Equal("EURUSD", trade.Symbol);
        Assert.Equal(1.5m, trade.Quantity);
        Assert.Equal(TradeStatus.Open, trade.Status);
    }

    [Fact]
    public void Open_WithEmptySymbol_ThrowsDomainException()
    {
        Assert.Throws<DomainException>(() => Trade.Open("", 1.0m, 1.0m));
    }

    [Fact]
    public void Close_WhenAlreadyClosed_ThrowsDomainException()
    {
        var trade = Trade.Open("GBPUSD", 1.0m, 1.2500m);
        trade.Close(1.2600m);

        Assert.Throws<DomainException>(() => trade.Close(1.2700m));
    }

    [Fact]
    public void Open_RaisesTradeOpenedEvent()
    {
        var trade = Trade.Open("USDJPY", 2.0m, 110.50m);

        Assert.Single(trade.DomainEvents);
        Assert.IsType<TradeOpenedEvent>(trade.DomainEvents.First());
    }
}

Application Layer Tests

Mock infrastructure interfaces. Test that handlers call the right repository methods and coordinate correctly.

public class OpenTradeHandlerTests
{
    private readonly Mock<ITradeRepository> _tradeRepoMock = new();
    private readonly Mock<IUnitOfWork> _unitOfWorkMock = new();
    private readonly Mock<ILogger<OpenTradeHandler>> _loggerMock = new();

    [Fact]
    public async Task Handle_ValidCommand_AddsTradeAndCommits()
    {
        // Arrange
        _unitOfWorkMock.Setup(u => u.CommitAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(1);

        var handler = new OpenTradeHandler(
            _tradeRepoMock.Object,
            _unitOfWorkMock.Object,
            _loggerMock.Object);

        var command = new OpenTradeCommand("EURUSD", 1.0m, 1.1050m);

        // Act
        var tradeId = await handler.Handle(command, CancellationToken.None);

        // Assert
        Assert.NotEqual(Guid.Empty, tradeId);
        _tradeRepoMock.Verify(r => r.AddAsync(
            It.Is<Trade>(t => t.Symbol == "EURUSD"),
            It.IsAny<CancellationToken>()), Times.Once);
        _unitOfWorkMock.Verify(u => u.CommitAsync(It.IsAny<CancellationToken>()), Times.Once);
    }

    [Fact]
    public async Task Handle_InvalidSymbol_ThrowsDomainException()
    {
        var handler = new OpenTradeHandler(
            _tradeRepoMock.Object,
            _unitOfWorkMock.Object,
            _loggerMock.Object);

        var command = new OpenTradeCommand("", 1.0m, 1.1050m);

        await Assert.ThrowsAsync<DomainException>(
            () => handler.Handle(command, CancellationToken.None));
    }
}

Infrastructure Layer Tests

Use integration tests with a real (or in-memory/container) database.

public class TradeRepositoryTests : IAsyncLifetime
{
    private AppDbContext _db = default!;
    private TradeRepository _sut = default!;

    public async Task InitializeAsync()
    {
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseInMemoryDatabase(Guid.NewGuid().ToString())
            .Options;

        _db = new AppDbContext(options);
        _sut = new TradeRepository(_db);
        await Task.CompletedTask;
    }

    public async Task DisposeAsync() => await _db.DisposeAsync();

    [Fact]
    public async Task AddAsync_And_GetByIdAsync_RoundTrips()
    {
        var trade = Trade.Open("EURUSD", 1.0m, 1.1050m);

        await _sut.AddAsync(trade);
        await _db.SaveChangesAsync();

        var retrieved = await _sut.GetByIdAsync(trade.Id);

        Assert.NotNull(retrieved);
        Assert.Equal("EURUSD", retrieved!.Symbol);
    }
}

Presentation / API Layer Tests

Use WebApplicationFactory<T> for end-to-end integration tests.

public class TradesControllerTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;

    public TradesControllerTests(WebApplicationFactory<Program> factory)
    {
        _client = factory.WithWebHostBuilder(builder =>
        {
            builder.ConfigureServices(services =>
            {
                // Replace real DB with in-memory for testing
                services.RemoveAll<DbContextOptions<AppDbContext>>();
                services.AddDbContext<AppDbContext>(opts =>
                    opts.UseInMemoryDatabase("TestDb"));
            });
        }).CreateClient();
    }

    [Fact]
    public async Task Post_ValidTrade_ReturnsOkWithId()
    {
        var payload = new { Symbol = "EURUSD", Quantity = 1.0, EntryPrice = 1.1050 };
        var content = new StringContent(
            JsonSerializer.Serialize(payload),
            Encoding.UTF8,
            "application/json");

        var response = await _client.PostAsync("/api/trades", content);

        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        var body = await response.Content.ReadAsStringAsync();
        Assert.Contains("tradeId", body);
    }
}

Testing Strategy Summary

LayerTest TypeDependenciesSpeed
DomainPure unit testsNoneFastest
ApplicationUnit tests with mocksMocked interfacesFast
InfrastructureIntegration testsReal/in-memory DBMedium
PresentationIntegration / E2EWebApplicationFactorySlowest

See also