CRUD REST API With Clean Architecture & DDD in .NET 10
Building Production-Grade APIs That Don’t Fall Apart at 10 Endpoints
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
- Mukesh Murugan — Implementing Clean Architecture in .NET 10
- Mukesh Murugan — CQRS with MediatR in ASP.NET Core
- Mukesh Murugan — Validation with MediatR Pipeline Behavior and FluentValidation
- C-Sharp Corner — Repository Pattern in 2026: Still Relevant or an Anti-Pattern?
- Brandon Spann — Clean Architecture in .NET 10: The Application Layer
- Brandon Spann — Clean Architecture in .NET 10: The API Layer
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