docs/minimal-clean-architecture.md
The Minimal Clean Architecture template provides a simplified, pragmatic approach to Clean Architecture for ASP.NET Core applications. It maintains the core principles of Clean Architecture—separation of concerns, dependency inversion, and testability—while reducing complexity through a single-project Vertical Slice Architecture (VSA).
MinimalClean.Architecture.Web/
├── Domain/ # Domain Layer
│ ├── CartAggregate/
│ │ ├── Cart.cs # Aggregate root
│ │ ├── CartItem.cs # Entity
│ │ └── Events/ # Domain events (optional)
│ ├── OrderAggregate/
│ └── ProductAggregate/
├── Infrastructure/ # Infrastructure Layer
│ ├── Data/
│ │ ├── AppDbContext.cs # EF Core DbContext
│ │ ├── Config/ # EF configurations
│ │ │ ├── CartConfiguration.cs
│ │ │ └── OrderConfiguration.cs
│ │ └── Migrations/ # EF migrations
│ ├── Email/ # External services
│ └── Services/ # Infrastructure services
├── Endpoints/ # Presentation Layer
│ ├── Cart/
│ │ ├── Create.cs # Create cart endpoint
│ │ ├── AddItem.cs # Add item to cart
│ │ └── List.cs # List carts
│ ├── Order/
│ └── Product/
└── Program.cs # Application startup
Each feature (Cart, Order, Product) contains:
This keeps related code together, making it easier to understand and modify features independently.
Endpoints ──→ Domain
↓
Infrastructure ──→ Domain
Status: Accepted
Context: Need to balance architectural guidance with simplicity for smaller applications.
Decision: Use a single Web project with clear folder structure instead of multiple projects.
Consequences:
Migration Path: Can be extracted into multiple projects later if needed.
Status: Accepted
Context: Need to organize code in a way that's easy to understand and modify.
Decision: Organize by feature (vertical slices) rather than by layer (horizontal).
Consequences:
Status: Accepted
Context: Full DDD patterns can be overkill for simpler domains.
Decision: Use essential DDD patterns (entities, aggregates) but keep it simple.
Patterns Included:
Patterns Simplified/Optional:
Consequences:
Status: Accepted
Context: CQRS with Mediator adds valuable patterns but also complexity.
Decision: Make Mediator optional; allow business logic in endpoints for simple cases. Trade-off is no ability to use custom pipeline for cross-cutting concerns.
Usage Guidelines:
Consequences:
Status: Accepted
Context: Need clean, testable API endpoints.
Decision: Use FastEndpoints with REPR pattern.
Consequences:
MVPs and Prototypes
Small to Medium Applications
Learning Clean Architecture
Vertical Slice Architecture Preference
Large Enterprise Applications
Microservices from Start
Regulatory/Compliance Heavy
As your application grows, you can migrate to the full template:
# Create new Core project
dotnet new classlib -n YourProject.Core
# Move domain entities
mv Domain/* ../YourProject.Core/
# Update namespaces
# Update project references
# Create Infrastructure project
dotnet new classlib -n YourProject.Infrastructure
# Move infrastructure code
mv Infrastructure/* ../YourProject.Infrastructure/
# Add reference to Core
dotnet add YourProject.Infrastructure reference YourProject.Core
# Create UseCases project
dotnet new classlib -n YourProject.UseCases
# Move business logic from endpoints to use cases
# Add Mediator (if not already using)
# Create command/query handlers
# Leverage Mediator Behaviors for cross-cutting concerns
If you find the full template too complex:
# Copy all code into Web project
# Organize by vertical slices
// Good: Encapsulated entity
public class Cart
{
private readonly List<CartItem> _items = new();
public IReadOnlyCollection<CartItem> Items => _items.AsReadOnly();
public void AddItem(Product product, int quantity)
{
// Business logic here
var existingItem = _items.FirstOrDefault(i => i.ProductId == product.Id);
if (existingItem != null)
{
existingItem.IncreaseQuantity(quantity);
}
else
{
_items.Add(new CartItem(product, quantity));
}
}
}
// Avoid: Anemic domain model
public class Cart
{
public List<CartItem> Items { get; set; } = new();
}
// Good: Clear, focused endpoint
public class CreateCart : EndpointWithoutRequest<CartResponse>
{
private readonly AppDbContext _db;
public CreateCart(AppDbContext db) => _db = db;
public override void Configure()
{
Post("/carts");
AllowAnonymous();
}
public override async Task HandleAsync(CancellationToken ct)
{
var cart = new Cart(guestUserId: Guid.NewGuid());
_db.Carts.Add(cart);
await _db.SaveChangesAsync(ct);
await Send.Async(new CartResponse(cart.Id, cart.Items.Count), cancellation: ct);
}
}
// Good: Focused EF configuration
public class CartConfiguration : IEntityTypeConfiguration<Cart>
{
public void Configure(EntityTypeBuilder<Cart> builder)
{
builder.HasKey(c => c.Id);
builder.HasMany(c => c.Items)
.WithOne()
.HasForeignKey("CartId");
}
}
Focus on domain logic:
public class CartTests
{
[Fact]
public void AddItem_NewProduct_AddsToCart()
{
// Arrange
var cart = new Cart(guestUserId: Guid.NewGuid());
var product = new Product("Test", 10m);
// Act
cart.AddItem(product, 2);
// Assert
Assert.Single(cart.Items);
Assert.Equal(2, cart.Items.First().Quantity);
}
}
Test endpoints end-to-end:
public class CartEndpointsTests : IClassFixture<WebApplicationFactory<Program>>
{
[Fact]
public async Task CreateCart_ReturnsNewCart()
{
// Arrange
var client = _factory.CreateClient();
// Act
var response = await client.PostAsync("/carts", null);
// Assert
response.EnsureSuccessStatusCode();
var cart = await response.Content.ReadFromJsonAsync<CartResponse>();
Assert.NotNull(cart);
}
}
Contributions are welcome! Please see the main Contributing Guide.
MIT - see LICENSE