npx skills add ...
npx skills add codewithmukesh/dotnet-claude-kit --skill clean-architecture
Clean Architecture for .NET applications. Covers the 4-project layout (Domain, Application, Infrastructure, Api), dependency inversion, use case handlers, domain entities with behavior, and infrastructure as a plugin. Load this skill when building a project with Clean Architecture, discussing layered architecture, dependency inversion, use cases, or when the architecture-advisor recommends Clean Architecture.
npx skills add codewithmukesh/dotnet-claude-kit --skill clean-architecture
Define a minimal interface in Application; implement in Infrastructure:
Why IAppDbContext over IRepository? EF Core's DbSet already IS a repository. Adding another abstraction on top adds indirection without value in most cases.
Every endpoint group implements IEndpointGroup and is auto-discovered via app.MapEndpoints(). Program.cs never changes when adding new endpoints. See the minimal-api skill for the full IEndpointGroup interface and EndpointExtensions setup.
| Scenario | Recommendation |
|---|---|
| When to use CA over VSA | Medium+ domain complexity, long-lived system, team familiar with layers |
| When to add a Domain layer | Business rules involve invariants across entity groups |
| IAppDbContext vs repositories | Prefer IAppDbContext; add repository only for complex reusable queries |
| Mediator vs raw handlers in CA | Mediator for pipeline behaviors (validation, logging); raw handlers for simplicity |
| When to add Domain events | When side effects (notifications, audit) should be decoupled from the main flow |
| Evolving from VSA to CA | When handlers start needing shared domain logic that does not belong in Common/ |
// Application/Common/Interfaces/IAppDbContext.cs
public interface IAppDbContext
{
DbSet<Order> Orders { get; }
DbSet<Product> Products { get; }
Task<int> SaveChangesAsync(CancellationToken ct = default);
}
// Infrastructure/Persistence/AppDbContext.cs
public class AppDbContext(DbContextOptions<AppDbContext> options)
: DbContext(options), IAppDbContext
{
public DbSet<Order> Orders => Set<Order>();
public DbSet<Product> Products => Set<Product>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
}
}// Application/Orders/Commands/CreateOrder/CreateOrderCommand.cs
public record CreateOrderCommand(
string CustomerId,
List<OrderItemDto> Items) : IRequest<Result<Guid>>;
public record OrderItemDto(string ProductId, int Quantity, decimal UnitPrice);
// Application/Orders/Commands/CreateOrder/CreateOrderHandler.cs — uses Mediator (source-generated, MIT)
internal sealed class CreateOrderHandler(
IAppDbContext db,
TimeProvider clock) : IRequestHandler<CreateOrderCommand, Result<Guid>>
{
public async ValueTask<Result<Guid>> Handle(CreateOrderCommand request, CancellationToken ct)
{
var order = Order.Create(
request.CustomerId,
request.Items.Select(i => new OrderItem(i.ProductId, i.Quantity, i.UnitPrice)),
clock.GetUtcNow());
db.Orders.Add(order);
await db.SaveChangesAsync(ct);
return Result.Success(order.Id);
}
}
// Application/Orders/Commands/CreateOrder/CreateOrderValidator.cs
public class CreateOrderValidator : AbstractValidator<CreateOrderCommand>
{
public CreateOrderValidator()
{
RuleFor(x => x.CustomerId).NotEmpty();
RuleFor(x => x.Items).NotEmpty();
RuleForEach(x => x.Items).ChildRules(item =>
{
item.RuleFor(x => x.ProductId).NotEmpty();
item.RuleFor(x => x.Quantity).GreaterThan(0);
item.RuleFor(x => x.UnitPrice).GreaterThan(0);
});
}
}// Application/Orders/Queries/GetOrder/GetOrderQuery.cs
public record GetOrderQuery(Guid OrderId) : IRequest<Result<OrderDto>>;
public record OrderDto(Guid Id, string CustomerId, decimal Total, string Status, DateTimeOffset CreatedAt);
// Application/Orders/Queries/GetOrder/GetOrderHandler.cs
internal sealed class GetOrderHandler(IAppDbContext db) : IRequestHandler<GetOrderQuery, Result<OrderDto>>
{
public async ValueTask<Result<OrderDto>> Handle(GetOrderQuery request, CancellationToken ct)
{
var order = await db.Orders
.Where(o => o.Id == request.OrderId)
.Select(o => new OrderDto(o.Id, o.CustomerId, o.Total, o.Status.ToString(), o.CreatedAt))
.FirstOrDefaultAsync(ct);
return order is not null
? Result.Success(order)
: Result.Failure<OrderDto>("Order not found");
}
}// Domain/Entities/Order.cs
public class Order : Entity
{
private readonly List<OrderItem> _items = [];
private Order() { } // EF Core
public string CustomerId { get; private set; } = null!;
public OrderStatus Status { get; private set; }
public decimal Total { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();
public static Order Create(string customerId, IEnumerable<OrderItem> items, DateTimeOffset now)
{
var order = new Order
{
Id = Guid.CreateVersion7(),
CustomerId = customerId,
Status = OrderStatus.Pending,
CreatedAt = now
};
foreach (var item in items)
order.AddItem(item);
return order;
}
public void AddItem(OrderItem item)
{
_items.Add(item);
Total = _items.Sum(i => i.Quantity * i.UnitPrice);
}
public Result Cancel()
{
if (Status is not OrderStatus.Pending)
return Result.Failure("Only pending orders can be cancelled");
Status = OrderStatus.Cancelled;
return Result.Success();
}
}// Api/Endpoints/OrderEndpoints.cs
public sealed class OrderEndpoints : IEndpointGroup
{
public void Map(IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/orders").WithTags("Orders");
group.MapPost("/", CreateOrder)
.WithName("CreateOrder");
group.MapGet("/{id:guid}", GetOrder)
.WithName("GetOrder");
group.MapGet("/", ListOrders)
.WithName("ListOrders");
}
private static async Task<IResult> CreateOrder(
CreateOrderCommand command, ISender sender, CancellationToken ct)
{
var result = await sender.Send(command, ct);
return result.IsSuccess
? TypedResults.Created($"/api/orders/{result.Value}", result.Value)
: result.ToProblemDetails();
}
private static async Task<IResult> GetOrder(
Guid id, ISender sender, CancellationToken ct)
{
var result = await sender.Send(new GetOrderQuery(id), ct);
return result.IsSuccess
? TypedResults.Ok(result.Value)
: TypedResults.NotFound();
}
private static async Task<IResult> ListOrders(
[AsParameters] ListOrdersQuery query, ISender sender, CancellationToken ct)
{
var result = await sender.Send(query, ct);
return TypedResults.Ok(result);
}
}// Infrastructure/DependencyInjection.cs
public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(
this IServiceCollection services,
IConfiguration config)
{
services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(config.GetConnectionString("DefaultConnection")));
services.AddScoped<IAppDbContext>(sp => sp.GetRequiredService<AppDbContext>());
return services;
}
}// BAD — entity is just a data bag, all logic in handler
public class Order
{
public Guid Id { get; set; }
public string CustomerId { get; set; } = null!;
public decimal Total { get; set; }
public List<OrderItem> Items { get; set; } = [];
}
// Handler sets everything directly
order.Total = order.Items.Sum(i => i.Quantity * i.UnitPrice);
order.Status = OrderStatus.Pending;
// GOOD — entity encapsulates its own rules (see Domain Entity pattern above)
var order = Order.Create(customerId, items, clock.GetUtcNow());// BAD — Domain references EF Core
// Domain/Services/OrderService.cs
public class OrderService(AppDbContext db) { } // Domain depends on Infrastructure!
// GOOD — Domain defines interfaces, Infrastructure implements
// Domain/Interfaces/IOrderRepository.cs (only if you need query abstraction beyond DbSet)
// Application/Common/Interfaces/IAppDbContext.cs (preferred)// BAD — business logic in the endpoint
app.MapPost("/orders", async (CreateOrderRequest req, AppDbContext db) =>
{
var order = new Order { CustomerId = req.CustomerId };
foreach (var item in req.Items)
{
order.Items.Add(new OrderItem { ProductId = item.ProductId, Quantity = item.Quantity });
}
order.Total = order.Items.Sum(i => i.Quantity * i.UnitPrice);
db.Orders.Add(order);
await db.SaveChangesAsync();
return TypedResults.Created($"/orders/{order.Id}", order);
});
// GOOD — endpoint delegates to a use case
app.MapPost("/orders", async (CreateOrderCommand command, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(command, ct);
return result.IsSuccess
? TypedResults.Created($"/orders/{result.Value}", result.Value)
: result.ToProblemDetails();
});// BAD — repository per entity duplicates DbSet functionality
public interface IOrderRepository { Task<Order?> GetByIdAsync(Guid id); }
public interface IProductRepository { Task<Product?> GetByIdAsync(Guid id); }
public interface ICustomerRepository { Task<Customer?> GetByIdAsync(Guid id); }
// GOOD — use IAppDbContext with DbSet<T> directly
// Only create a repository interface when you have complex query logic
// that you want to test in isolation or reuse across multiple use cases