← Back to list

CRUD REST API With Clean Architecture & DDD in .NET 10

Building Production-Grade APIs That Don’t Fall Apart at 10 Endpoints

Mori · 2026-07-07 17:01 · 1 claps · 9.7 min read paywalled
#software-development #software-engineering #programming #dotnet #csharp
Open on Medium ↗
Wiki topics: FT · Fine-tuning & Adaptation 💻 · Programming 🏛️ · Architecture

CRUD REST API With Clean Architecture & DDD in .NET 10

Building Production-Grade APIs That Don’t Fall Apart at 10 Endpoints

“Most Clean Architecture tutorials stop at the folder structure. They don’t show you what happens when a real user sends invalid data, when two requests collide, or when your API needs to scale beyond a single instance.”

I’ve reviewed dozens of .NET codebases that claim to follow Clean Architecture. The pattern is consistent: beautiful onion diagrams, pristine folder separation, and then — a ProductsController with 600 lines of business logic, direct EF Core queries, and zero error handling. The "Domain" project? A few POCOs with public setters and no behavior.

This isn’t Clean Architecture. It’s spaghetti with extra folders.

In this guide, I’ll show you how to build a real CRUD REST API using Clean Architecture, Domain-Driven Design, CQRS with MediatR, and .NET 10 — with production-ready code you can deploy today.

Architecture Overview

┌─────────────────────────────────────────────────────────────┐
│                        API Layer                             │
│  ┌─────────────┐  ┌──────────────┐  ┌─────────────────────┐ │
│  │  Minimal    │  │   Global     │  │  OpenAPI / Scalar   │ │
│  │  Endpoints  │  │   Exception  │  │  Documentation      │ │
│  └─────────────┘  │   Handler    │  └─────────────────────┘ │
│                   └──────────────┘                            │
├─────────────────────────────────────────────────────────────┤
│                    Application Layer                          │
│  ┌─────────────┐  ┌──────────────┐  ┌─────────────────────┐ │
│  │  Commands   │  │   Queries    │  │  Pipeline Behaviors │ │
│  │  (Write)    │  │   (Read)     │  │  (Validation/Log)   │ │
│  └─────────────┘  └──────────────┘  └─────────────────────┘ │
├─────────────────────────────────────────────────────────────┤
│                      Domain Layer                             │
│  ┌─────────────┐  ┌──────────────┐  ┌─────────────────────┐ │
│  │  Entities   │  │   Value      │  │  Domain Events      │ │
│  │  (Rich)     │  │   Objects    │  │  (Business Facts)     │ │
│  └─────────────┘  └──────────────┘  └─────────────────────┘ │
├─────────────────────────────────────────────────────────────┤
│                   Infrastructure Layer                        │
│  ┌─────────────┐  ┌──────────────┐  ┌─────────────────────┐ │
│  │  EF Core    │  │   Repository │  │  Unit of Work       │ │
│  │  DbContext  │  │   Pattern    │  │  (Transactions)     │ │
│  └─────────────┘  └──────────────┘  └─────────────────────┘ │
└─────────────────────────────────────────────────────────────┘

Dependencies flow inward. The Domain layer has zero NuGet packages.

Project Structure

src/
├── CleanCrud.Domain/              # Zero dependencies
├── CleanCrud.Application/         # References Domain only
├── CleanCrud.Infrastructure/      # References Application
└── CleanCrud.API/                 # Composition root
tests/
├── CleanCrud.Domain.Tests/
├── CleanCrud.Application.Tests/
└── CleanCrud.Integration.Tests/

Layer 1: The Domain Layer (The Heart)

Strongly Typed IDs with UUID v7

.NET 9+ introduces Guid.CreateVersion7() — sequential GUIDs that index far better in PostgreSQL than random v4 GUIDs. However, there's a critical caveat: Microsoft's implementation has byte-order issues that can still cause index fragmentation. For production, use NpgsqlSequentialGuidValueGenerator or a custom sequential GUID generator.

// Domain/Products/ProductId.cs
namespace CleanCrud.Domain.Products;
public readonly record struct ProductId(Guid Value)
{
    public static ProductId New() => new(Guid.CreateVersion7());
    public override string ToString() => Value.ToString();
}

Rich Domain Entity with Encapsulated Behavior

// Domain/Products/Product.cs
namespace CleanCrud.Domain.Products;
public sealed class Product : Entity<ProductId>
{
    public string Name { get; private set; }
    public string Description { get; private set; }
    public Money Price { get; private set; }
    public int StockQuantity { get; private set; }
    public ProductStatus Status { get; private set; }
    public DateTime CreatedAt { get; private set; }
    public DateTime? UpdatedAt { get; private set; }
    // EF Core requires parameterless constructor
    private Product() : base(default) { }
    private Product(ProductId id, string name, string description, Money price)
        : base(id)
    {
        Name = name;
        Description = description;
        Price = price;
        StockQuantity = 0;
        Status = ProductStatus.Draft;
        CreatedAt = DateTime.UtcNow;
    }
    // Factory method - the ONLY way to create a valid Product
    public static Product Create(string name, string description, Money price)
    {
        if (string.IsNullOrWhiteSpace(name))
            throw new DomainException("Product name is required");
        if (name.Length > 200)
            throw new DomainException("Product name cannot exceed 200 characters");
        if (price.Amount <= 0)
            throw new DomainException("Price must be greater than zero");
        return new Product(ProductId.New(), name, description, price);
    }
    public void Update(string name, string description, Money price)
    {
        if (Status == ProductStatus.Discontinued)
            throw new DomainException("Cannot update discontinued product");
        Name = name;
        Description = description;
        Price = price;
        UpdatedAt = DateTime.UtcNow;
    }
    public void AddStock(int quantity)
    {
        if (quantity <= 0)
            throw new DomainException("Quantity must be positive");
        StockQuantity += quantity;
        if (Status == ProductStatus.OutOfStock && StockQuantity > 0)
            Status = ProductStatus.Active;
    }
    public void RemoveStock(int quantity)
    {
        if (quantity <= 0)
            throw new DomainException("Quantity must be positive");
        if (quantity > StockQuantity)
            throw new DomainException("Insufficient stock");
        StockQuantity -= quantity;
        if (StockQuantity == 0)
            Status = ProductStatus.OutOfStock;
    }
    public void Discontinue()
    {
        Status = ProductStatus.Discontinued;
        UpdatedAt = DateTime.UtcNow;
    }
}
public enum ProductStatus
{
    Draft,
    Active,
    OutOfStock,
    Discontinued
}

Value Object: Money

// Domain/ValueObjects/Money.cs
namespace CleanCrud.Domain.ValueObjects;
public sealed record Money
{
    public decimal Amount { get; }
    public string Currency { get; }
    private Money(decimal amount, string currency)
    {
        if (amount < 0)
            throw new DomainException("Amount cannot be negative");
        if (string.IsNullOrWhiteSpace(currency) || currency.Length != 3)
            throw new DomainException("Currency must be a 3-letter ISO code");
        Amount = amount;
        Currency = currency.ToUpperInvariant();
    }
    public static Money Create(decimal amount, string currency) => 
        new(amount, currency);
    public Money Add(Money other)
    {
        if (Currency != other.Currency)
            throw new DomainException("Cannot add money with different currencies");
        return new Money(Amount + other.Amount, Currency);
    }
}

Domain Exception

// Domain/Exceptions/DomainException.cs
namespace CleanCrud.Domain.Exceptions;
public sealed class DomainException : Exception
{
    public DomainException(string message) : base(message) { }
}

Layer 2: The Application Layer (Use Cases)

CQRS with MediatR: Commands and Queries

MediatR is the go-to library for implementing CQRS in .NET. With over 350 million NuGet downloads, it’s the most widely adopted mediator library. Note: MediatR moved to a commercial license from v13.0, but remains free for companies under $5M revenue.

// Application/Products/Commands/CreateProduct/CreateProductCommand.cs
namespace CleanCrud.Application.Products.Commands.CreateProduct;
public sealed record CreateProductCommand(
    string Name,
    string Description,
    decimal Price,
    string Currency
) : IRequest<Result<Guid>>;
// Application/Products/Commands/CreateProduct/CreateProductCommandHandler.cs
public sealed class CreateProductCommandHandler : 
    IRequestHandler<CreateProductCommand, Result<Guid>>
{
    private readonly IProductRepository _productRepository;
    private readonly IUnitOfWork _unitOfWork;
    public CreateProductCommandHandler(
        IProductRepository productRepository,
        IUnitOfWork unitOfWork)
    {
        _productRepository = productRepository;
        _unitOfWork = unitOfWork;
    }
    public async Task<Result<Guid>> Handle(
        CreateProductCommand request, 
        CancellationToken cancellationToken)
    {
        var product = Product.Create(
            request.Name,
            request.Description,
            Money.Create(request.Price, request.Currency));
        await _productRepository.AddAsync(product, cancellationToken);
        await _unitOfWork.SaveChangesAsync(cancellationToken);
        return Result<Guid>.Success(product.Id.Value);
    }
}

Query Handler (Optimized Reads)

// Application/Products/Queries/GetProductById/GetProductByIdQuery.cs
namespace CleanCrud.Application.Products.Queries.GetProductById;
public sealed record GetProductByIdQuery(Guid Id) : IRequest<ProductDto?>;
// Application/Products/Queries/GetProductById/GetProductByIdQueryHandler.cs
public sealed class GetProductByIdQueryHandler : 
    IRequestHandler<GetProductByIdQuery, ProductDto?>
{
    private readonly IApplicationDbContext _dbContext;
    public GetProductByIdQueryHandler(IApplicationDbContext dbContext)
    {
        _dbContext = dbContext;
    }
    public async Task<ProductDto?> Handle(
        GetProductByIdQuery request, 
        CancellationToken cancellationToken)
    {
        return await _dbContext.Products
            .AsNoTracking()
            .Where(p => p.Id == new ProductId(request.Id))
            .Select(p => new ProductDto
            {
                Id = p.Id.Value,
                Name = p.Name,
                Description = p.Description,
                Price = p.Price.Amount,
                Currency = p.Price.Currency,
                StockQuantity = p.StockQuantity,
                Status = p.Status.ToString(),
                CreatedAt = p.CreatedAt
            })
            .FirstOrDefaultAsync(cancellationToken);
    }
}

The Result Pattern

// Application/Common/Result.cs
namespace CleanCrud.Application.Common;
public sealed class Result<T>
{
    public bool IsSuccess { get; }
    public bool IsFailure => !IsSuccess;
    public T Value { get; }
    public string Error { get; }
    private Result(bool isSuccess, T value, string error)
    {
        IsSuccess = isSuccess;
        Value = value;
        Error = error;
    }
    public static Result<T> Success(T value) => new(true, value, null!);
    public static Result<T> Failure(string error) => new(false, default!, error);
}

FluentValidation with MediatR Pipeline Behavior

Validate once, centrally, before the request ever reaches a handler.

// Application/Behaviors/ValidationBehavior.cs
namespace CleanCrud.Application.Behaviors;
public sealed class ValidationBehavior<TRequest, TResponse> : 
    IPipelineBehavior<TRequest, TResponse>
    where TRequest : class
    where TResponse : class
{
    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 is not null)
            .ToList();
        if (failures.Count > 0)
            throw new ValidationException(failures);
        return await next();
    }
}
// Application/Products/Commands/CreateProduct/CreateProductCommandValidator.cs
namespace CleanCrud.Application.Products.Commands.CreateProduct;
public sealed class CreateProductCommandValidator : 
    AbstractValidator<CreateProductCommand>
{
    public CreateProductCommandValidator()
    {
        RuleFor(x => x.Name)
            .NotEmpty().WithMessage("Product name is required")
            .MaximumLength(200).WithMessage("Name cannot exceed 200 characters");
        RuleFor(x => x.Description)
            .MaximumLength(2000).WithMessage("Description cannot exceed 2000 characters");
        RuleFor(x => x.Price)
            .GreaterThan(0).WithMessage("Price must be greater than zero");
        RuleFor(x => x.Currency)
            .NotEmpty().WithMessage("Currency is required")
            .Length(3).WithMessage("Currency must be a 3-letter ISO code")
            .Must(BeValidCurrency).WithMessage("Invalid currency code");
    }
    private static bool BeValidCurrency(string currency) => 
        new[] { "USD", "EUR", "GBP", "JPY" }.Contains(currency.ToUpperInvariant());
}

Repository Abstraction (Aggregate-Only)

// Application/Products/IProductRepository.cs
namespace CleanCrud.Application.Products;
public interface IProductRepository
{
    Task<Product?> GetByIdAsync(ProductId id, CancellationToken ct);
    Task<IReadOnlyCollection<Product>> GetAllAsync(CancellationToken ct);
    Task AddAsync(Product product, CancellationToken ct);
    Task UpdateAsync(Product product, CancellationToken ct);
    Task DeleteAsync(ProductId id, CancellationToken ct);
}

Layer 3: The Infrastructure Layer

EF Core Configuration

// Infrastructure/Persistence/Configurations/ProductConfiguration.cs
namespace CleanCrud.Infrastructure.Persistence.Configurations;
public sealed class ProductConfiguration : IEntityTypeConfiguration<Product>
{
    public void Configure(EntityTypeBuilder<Product> builder)
    {
        builder.ToTable("products");
        builder.HasKey(p => p.Id);

        builder.Property(p => p.Id)
            .HasConversion(
                id => id.Value,
                value => new ProductId(value));
        builder.Property(p => p.Name)
            .IsRequired()
            .HasMaxLength(200);
        builder.Property(p => p.Description)
            .HasMaxLength(2000);
        // Complex type for Money value object
        builder.ComplexProperty(p => p.Price, money =>
        {
            money.Property(m => m.Amount)
                .HasPrecision(18, 2)
                .HasColumnName("price_amount");

            money.Property(m => m.Currency)
                .HasColumnName("price_currency")
                .HasMaxLength(3);
        });
        builder.Property(p => p.Status)
            .HasConversion<string>()
            .HasMaxLength(50);
        builder.HasIndex(p => p.Status);
        builder.HasIndex(p => p.Name);
    }
}

Repository Implementation

// Infrastructure/Persistence/Repositories/ProductRepository.cs
namespace CleanCrud.Infrastructure.Persistence.Repositories;
public sealed class ProductRepository : IProductRepository
{
    private readonly ApplicationDbContext _dbContext;
    public ProductRepository(ApplicationDbContext dbContext)
    {
        _dbContext = dbContext;
    }
    public async Task<Product?> GetByIdAsync(ProductId id, CancellationToken ct)
    {
        return await _dbContext.Products
            .FirstOrDefaultAsync(p => p.Id == id, ct);
    }
    public async Task<IReadOnlyCollection<Product>> GetAllAsync(CancellationToken ct)
    {
        return await _dbContext.Products
            .AsNoTracking()
            .OrderBy(p => p.CreatedAt)
            .ToListAsync(ct);
    }
    public async Task AddAsync(Product product, CancellationToken ct)
    {
        await _dbContext.Products.AddAsync(product, ct);
    }
    public Task UpdateAsync(Product product, CancellationToken ct)
    {
        _dbContext.Products.Update(product);
        return Task.CompletedTask;
    }
    public async Task DeleteAsync(ProductId id, CancellationToken ct)
    {
        var product = await GetByIdAsync(id, ct);
        if (product is not null)
            _dbContext.Products.Remove(product);
    }
}

Unit of Work

// Infrastructure/Persistence/UnitOfWork.cs
namespace CleanCrud.Infrastructure.Persistence;
public sealed class UnitOfWork : IUnitOfWork
{
    private readonly ApplicationDbContext _dbContext;
    public UnitOfWork(ApplicationDbContext dbContext)
    {
        _dbContext = dbContext;
    }
    public async Task<int> SaveChangesAsync(CancellationToken ct = default)
    {
        return await _dbContext.SaveChangesAsync(ct);
    }
    public async Task<IDbContextTransaction> BeginTransactionAsync(CancellationToken ct = default)
    {
        return await _dbContext.Database.BeginTransactionAsync(ct);
    }
}

Layer 4: The API Layer

Minimal API Endpoints with Clean Separation

.NET 10 continues the Minimal APIs evolution. The endpoint is a thin routing layer — logic lives in focused handlers.

// API/Endpoints/ProductEndpoints.cs
namespace CleanCrud.API.Endpoints;
public static class ProductEndpoints
{
    public static IEndpointRouteBuilder MapProductEndpoints(this IEndpointRouteBuilder app)
    {
        var group = app.MapGroup("/api/products")
            .WithTags("Products")
            .WithOpenApi();
        // CREATE
        group.MapPost("/", async (
            CreateProductCommand command,
            ISender mediator,
            CancellationToken ct) =>
        {
            var result = await mediator.Send(command, ct);
            return result.IsSuccess 
                ? Results.Created($"/api/products/{result.Value}", result.Value)
                : Results.BadRequest(new { error = result.Error });
        })
        .WithName("CreateProduct")
        .Produces<Guid>(StatusCodes.Status201Created)
        .ProducesValidationProblem();
        // READ ONE
        group.MapGet("/{id:guid}", async (
            Guid id,
            ISender mediator,
            CancellationToken ct) =>
        {
            var product = await mediator.Send(new GetProductByIdQuery(id), ct);
            return product is not null ? Results.Ok(product) : Results.NotFound();
        })
        .WithName("GetProduct")
        .Produces<ProductDto>()
        .Produces(StatusCodes.Status404NotFound);
        // READ ALL
        group.MapGet("/", async (
            ISender mediator,
            CancellationToken ct) =>
        {
            var products = await mediator.Send(new ListProductsQuery(), ct);
            return Results.Ok(products);
        })
        .WithName("ListProducts")
        .Produces<IReadOnlyCollection<ProductDto>>();
        // UPDATE
        group.MapPut("/{id:guid}", async (
            Guid id,
            UpdateProductCommand command,
            ISender mediator,
            CancellationToken ct) =>
        {
            if (id != command.Id)
                return Results.BadRequest(new { error = "ID mismatch" });
            var result = await mediator.Send(command, ct);
            return result.IsSuccess 
                ? Results.NoContent() 
                : Results.BadRequest(new { error = result.Error });
        })
        .WithName("UpdateProduct")
        .Produces(StatusCodes.Status204NoContent)
        .ProducesValidationProblem();
        // DELETE
        group.MapDelete("/{id:guid}", async (
            Guid id,
            ISender mediator,
            CancellationToken ct) =>
        {
            await mediator.Send(new DeleteProductCommand(id), ct);
            return Results.NoContent();
        })
        .WithName("DeleteProduct")
        .Produces(StatusCodes.Status204NoContent);
        // STOCK OPERATIONS
        group.MapPost("/{id:guid}/stock/add", async (
            Guid id,
            AddStockCommand command,
            ISender mediator,
            CancellationToken ct) =>
        {
            if (id != command.ProductId)
                return Results.BadRequest(new { error = "ID mismatch" });
            var result = await mediator.Send(command, ct);
            return result.IsSuccess 
                ? Results.Ok() 
                : Results.BadRequest(new { error = result.Error });
        })
        .WithName("AddStock")
        .Produces(StatusCodes.Status200OK)
        .ProducesValidationProblem();
        return app;
    }
}

Global Exception Handler

// API/Middleware/GlobalExceptionHandler.cs
namespace CleanCrud.API.Middleware;
public sealed class GlobalExceptionHandler : IExceptionHandler
{
    private readonly ILogger<GlobalExceptionHandler> _logger;
    public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
    {
        _logger = logger;
    }
    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext,
        Exception exception,
        CancellationToken cancellationToken)
    {
        _logger.LogError(exception, "Unhandled exception: {Message}", exception.Message);
        var problemDetails = exception switch
        {
            ValidationException validationEx => new ProblemDetails
            {
                Status = StatusCodes.Status400BadRequest,
                Title = "Validation Error",
                Detail = string.Join(", ", validationEx.Errors.Select(e => e.ErrorMessage)),
                Instance = httpContext.Request.Path
            },
            DomainException domainEx => new ProblemDetails
            {
                Status = StatusCodes.Status422UnprocessableEntity,
                Title = "Business Rule Violation",
                Detail = domainEx.Message,
                Instance = httpContext.Request.Path
            },
            _ => new ProblemDetails
            {
                Status = StatusCodes.Status500InternalServerError,
                Title = "Internal Server Error",
                Detail = "An unexpected error occurred",
                Instance = httpContext.Request.Path
            }
        };
        httpContext.Response.StatusCode = problemDetails.Status.Value;
        await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
        return true;
    }
}

Program.cs — The Composition Root

// API/Program.cs
var builder = WebApplication.CreateBuilder(args);
// Infrastructure (DbContext, Repositories, Unit of Work)
builder.Services.AddInfrastructure(builder.Configuration);
// MediatR + Pipeline Behaviors
builder.Services.AddMediatR(cfg =>
{
    cfg.RegisterServicesFromAssembly(typeof(ApplicationAssemblyReference).Assembly);
    cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
    cfg.AddOpenBehavior(typeof(LoggingBehavior<,>));
});
// FluentValidation
builder.Services.AddValidatorsFromAssembly(
    typeof(ApplicationAssemblyReference).Assembly, 
    includeInternalTypes: true);
// API
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();
builder.Services.AddOpenApi();
var app = builder.Build();
app.UseExceptionHandler();
app.MapProductEndpoints();
// .NET 10 uses OpenAPI + Scalar (Swagger UI is no longer built-in)
if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
    app.MapScalarApiReference();
}
app.Run();

Testing Strategy

Domain Unit Tests (Pure, Fast, No Mocks)

// Domain.Tests/Products/ProductTests.cs
public sealed class ProductTests
{
    [Fact]
    public void Create_WithValidData_CreatesDraftProduct()
    {
        var product = Product.Create("Test Product", "Description", Money.Create(10.00m, "USD"));
product.Status.Should().Be(ProductStatus.Draft);
        product.StockQuantity.Should().Be(0);
        product.Name.Should().Be("Test Product");
    }
    [Fact]
    public void Create_WithEmptyName_ThrowsDomainException()
    {
        var action = () => Product.Create("", "Description", Money.Create(10m, "USD"));
        action.Should().Throw<DomainException>()
            .WithMessage("*name is required*");
    }
    [Fact]
    public void AddStock_IncreasesQuantityAndActivatesProduct()
    {
        var product = Product.Create("Test", "Desc", Money.Create(10m, "USD"));

        product.AddStock(5);
        product.StockQuantity.Should().Be(5);
        product.Status.Should().Be(ProductStatus.Active);
    }
    [Fact]
    public void RemoveStock_WhenInsufficient_ThrowsDomainException()
    {
        var product = Product.Create("Test", "Desc", Money.Create(10m, "USD"));
        product.AddStock(3);
        var action = () => product.RemoveStock(5);
        action.Should().Throw<DomainException>()
            .WithMessage("*Insufficient stock*");
    }
    [Fact]
    public void Update_DiscontinuedProduct_ThrowsDomainException()
    {
        var product = Product.Create("Test", "Desc", Money.Create(10m, "USD"));
        product.Discontinue();
        var action = () => product.Update("New Name", "New Desc", Money.Create(15m, "USD"));
        action.Should().Throw<DomainException>()
            .WithMessage("*Cannot update discontinued*");
    }
}

Integration Tests with Testcontainers

// Integration.Tests/Products/ProductApiTests.cs
public class ProductApiTests : IClassFixture<IntegrationTestFixture>
{
    private readonly HttpClient _client;
    private readonly ApplicationDbContext _dbContext;
public ProductApiTests(IntegrationTestFixture fixture)
    {
        _client = fixture.CreateClient();
        _dbContext = fixture.Services.GetRequiredService<ApplicationDbContext>();
    }
    [Fact]
    public async Task CreateProduct_WithValidData_Returns201AndPersists()
    {
        var request = new CreateProductCommand(
            "Integration Test Product",
            "A product created during integration testing",
            29.99m,
            "USD");
        var response = await _client.PostAsJsonAsync("/api/products", request);
        var result = await response.Content.ReadFromJsonAsync<Guid>();
        response.StatusCode.Should().Be(HttpStatusCode.Created);
        result.Should().NotBe(Guid.Empty);
        var dbProduct = await _dbContext.Products
            .FirstOrDefaultAsync(p => p.Id == new ProductId(result));
        dbProduct.Should().NotBeNull();
        dbProduct!.Name.Should().Be(request.Name);
    }
    [Fact]
    public async Task CreateProduct_WithInvalidData_Returns400WithErrors()
    {
        var request = new CreateProductCommand("", "", -5m, "INVALID");
        var response = await _client.PostAsJsonAsync("/api/products", request);
        var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
        response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
        problem!.Detail.Should().Contain("name is required");
    }
    [Fact]
    public async Task GetProduct_WhenNotExists_Returns404()
    {
        var response = await _client.GetAsync($"/api/products/{Guid.NewGuid()}");
        response.StatusCode.Should().Be(HttpStatusCode.NotFound);
    }
}

Key Takeaways

When to Use This Architecture

Use it when:

  • You have 10+ endpoints with complex business rules
  • Multiple developers work on the same codebase
  • You need audit trails, event sourcing, or microservice evolution
  • Testing is non-negotiable

Skip it when:

  • Simple CRUD with no business rules (3–5 endpoints)
  • Prototypes or throwaway spikes
  • Team of 1–2 with fast iteration needs

References

The full source code is available at github.com/yourusername/cleancrud-dotnet10. Clone it, run the integration tests, and adapt it to your domain.

What patterns have worked best in your Clean Architecture implementations? Drop a comment below.


메타데이터
post_id
1d4c18df6a7b
slug
crud-rest-api-with-clean-architecture-ddd-in-net-10-1d4c18df6a7b
url
https://medium.com/@mariammaurice/crud-rest-api-with-clean-architecture-ddd-in-net-10-1d4c18df6a7b
canonical_url
https://medium.com/@mariammaurice/crud-rest-api-with-clean-architecture-ddd-in-net-10-1d4c18df6a7b
author_url
https://medium.com/@mariammaurice
status
ok
fetched_at
2026-07-08 16:25:30