
Architecture
- 29 installs
- 466 repo stars
- Updated July 25, 2026
- managedcode/dotnet-skills
Helps with ai & agent building tasks.
About
architecture is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- architecture
- AI & Agent Building
- AI-coding skill
Architecture by the numbers
- 29 all-time installs (skills.sh)
- +2 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #9,369 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/managedcode/dotnet-skills --skill architectureAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 29 |
|---|---|
| repo stars | ★ 466 |
| Last updated | July 25, 2026 |
| Repository | managedcode/dotnet-skills ↗ |
What it does
Helps with ai & agent building tasks.
Files
.NET Architecture
Trigger On
- choosing architecture for a new or evolving .NET system
- reviewing layer boundaries, domain boundaries, or service decomposition
- deciding whether clean architecture, vertical slices, CQRS, or microservices are justified
Workflow
1. Start from business capability boundaries and change frequency, not from a preferred diagram style. 2. Use simple modular monolith patterns by default, and move to microservices only when team autonomy, scale, or deployment boundaries justify the added operational cost. 3. Apply DDD and CQRS where business rules are genuinely complex; avoid forcing aggregates and command pipelines into CRUD-heavy code with no payoff. 4. Keep dependencies flowing inward when using clean architecture, but avoid creating extra projects that add ceremony without ownership clarity. 5. Make integration boundaries explicit: contracts, storage ownership, messaging, consistency model, and observability expectations. 6. Use aspire when local orchestration, service discovery, and developer observability are part of the architecture story.
Deliver
- an architecture direction that matches system complexity
- clear project and dependency boundaries
- migration notes or tradeoffs when changing an existing structure
Validate
- the proposed structure reduces rather than increases accidental complexity
- data ownership and integration paths are explicit
- the architecture is testable and operable, not just diagram-friendly
References
- references/patterns.md - detailed implementations of Clean Architecture, Vertical Slices, DDD, CQRS, Modular Monolith, and Microservices with C# 12+ examples
- references/anti-patterns.md - common architectural mistakes including over-abstraction, anemic domain models, premature microservices, and cargo cult patterns
{
"version": "1.0.0",
"category": "Core"
}
Architectural Anti-Patterns in .NET
This reference documents common architectural mistakes in .NET systems and how to avoid them. Examples use C# 12+ features including primary constructors.
---
Over-Abstraction
Problem: Unnecessary Interfaces
Creating interfaces for every class, even when there is only one implementation and no testing or extension need.
// Anti-pattern: Interface for the sake of interface
public interface IOrderService
{
Task<Order> GetOrderAsync(Guid id);
}
public class OrderService(AppDbContext db) : IOrderService
{
public async Task<Order> GetOrderAsync(Guid id)
=> await db.Orders.FindAsync(id);
}
// Then injected as:
services.AddScoped<IOrderService, OrderService>();Solution
Use interfaces when there is a real need: multiple implementations, testing seams, or abstraction over infrastructure.
// Better: Direct class when no abstraction is needed
public sealed class OrderService(AppDbContext db)
{
public async Task<Order?> GetOrderAsync(Guid id, CancellationToken ct = default)
=> await db.Orders.FindAsync([id], ct);
}
// Register directly:
services.AddScoped<OrderService>();
// Use interface when there is a reason:
// - Repository abstracts persistence (testable, swappable)
// - External service client abstracts HTTP (mockable)
// - Strategy pattern requires polymorphism---
Anemic Domain Model
Problem: Logic in Services, Data in Entities
Entities become data bags while business logic lives in service classes, losing the benefits of encapsulation.
// Anti-pattern: Anemic entity
public class Order
{
public Guid Id { get; set; }
public Guid CustomerId { get; set; }
public List<OrderLine> Lines { get; set; } = [];
public string Status { get; set; } = "Draft";
public decimal Total { get; set; }
}
// Logic scattered in service
public class OrderService(AppDbContext db)
{
public async Task SubmitOrderAsync(Guid orderId)
{
var order = await db.Orders.Include(o => o.Lines).FirstAsync(o => o.Id == orderId);
if (order.Lines.Count == 0)
throw new Exception("Cannot submit empty order");
if (order.Status != "Draft")
throw new Exception("Order already submitted");
order.Status = "Submitted";
order.Total = order.Lines.Sum(l => l.Quantity * l.UnitPrice);
await db.SaveChangesAsync();
}
}Solution
Encapsulate behavior in the entity. Keep invariants protected.
// Better: Rich domain model
public sealed class Order
{
public OrderId Id { get; }
public CustomerId CustomerId { get; }
private readonly List<OrderLine> _lines = [];
public IReadOnlyList<OrderLine> Lines => _lines.AsReadOnly();
public OrderStatus Status { get; private set; }
public Money Total => _lines.Aggregate(Money.Zero, (sum, l) => sum + l.Subtotal);
private Order(OrderId id, CustomerId customerId)
{
Id = id;
CustomerId = customerId;
Status = OrderStatus.Draft;
}
public static Order Create(CustomerId customerId)
=> new(OrderId.New(), customerId);
public void Submit()
{
if (_lines.Count == 0)
throw new DomainException("Cannot submit an empty order.");
if (Status != OrderStatus.Draft)
throw new DomainException("Only draft orders can be submitted.");
Status = OrderStatus.Submitted;
}
}---
Big Ball of Mud
Problem: No Clear Boundaries
Everything depends on everything. Controllers call repositories directly. Business logic lives in controllers. Database models leak into API responses.
// Anti-pattern: Controller doing everything
[ApiController]
public class OrderController(AppDbContext db, IEmailService email) : ControllerBase
{
[HttpPost]
public async Task<IActionResult> CreateOrder(OrderDto dto)
{
// Validation mixed with persistence
if (dto.Lines.Count == 0)
return BadRequest("Order must have lines");
var customer = await db.Customers.FindAsync(dto.CustomerId);
if (customer == null)
return NotFound("Customer not found");
// Business logic in controller
var order = new Order
{
Id = Guid.NewGuid(),
CustomerId = dto.CustomerId,
Lines = dto.Lines.Select(l => new OrderLine
{
ProductId = l.ProductId,
Quantity = l.Quantity,
UnitPrice = l.UnitPrice
}).ToList(),
Status = "Created",
Total = dto.Lines.Sum(l => l.Quantity * l.UnitPrice)
};
// Infrastructure concern in controller
db.Orders.Add(order);
await db.SaveChangesAsync();
// Side effect mixed in
await email.SendOrderConfirmationAsync(customer.Email, order.Id);
// Database entity leaked to response
return Ok(order);
}
}Solution
Separate concerns into layers or slices with clear responsibilities.
// Better: Clear separation
[ApiController]
public class OrderController(IMediator mediator) : ControllerBase
{
[HttpPost]
public async Task<IActionResult> CreateOrder(CreateOrderRequest request, CancellationToken ct)
{
var result = await mediator.Send(new CreateOrderCommand(request), ct);
return result.Match(
success: id => CreatedAtAction(nameof(GetOrder), new { id }, new { id }),
failure: errors => BadRequest(errors));
}
}
// Handler owns the use case
public sealed class CreateOrderHandler(
IOrderRepository orders,
ICustomerRepository customers,
IEventPublisher events) : IRequestHandler<CreateOrderCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(CreateOrderCommand command, CancellationToken ct)
{
var customer = await customers.GetByIdAsync(command.CustomerId, ct);
if (customer is null)
return Result<Guid>.NotFound("Customer not found.");
var order = Order.Create(customer.Id);
foreach (var line in command.Lines)
order.AddLine(line.ProductId, line.Quantity, line.UnitPrice);
await orders.AddAsync(order, ct);
await orders.SaveChangesAsync(ct);
await events.PublishAsync(new OrderCreatedEvent(order.Id, customer.Id), ct);
return Result<Guid>.Success(order.Id.Value);
}
}---
Premature Microservices
Problem: Distributed Monolith
Splitting into microservices before understanding domain boundaries. Services are tightly coupled, require synchronous calls, and share databases.
// Anti-pattern: Synchronous cross-service calls in critical path
public sealed class OrderService(
HttpClient customerClient,
HttpClient inventoryClient,
HttpClient pricingClient)
{
public async Task<Order> CreateOrderAsync(CreateOrderRequest request, CancellationToken ct)
{
// Synchronous dependency on customer service
var customer = await customerClient.GetFromJsonAsync<CustomerDto>(
$"/customers/{request.CustomerId}", ct);
if (customer is null)
throw new Exception("Customer not found");
// Synchronous dependency on inventory
foreach (var item in request.Items)
{
var available = await inventoryClient.GetFromJsonAsync<bool>(
$"/inventory/{item.ProductId}/available?quantity={item.Quantity}", ct);
if (!available)
throw new Exception($"Product {item.ProductId} not available");
}
// Synchronous dependency on pricing
var prices = await pricingClient.PostAsJsonAsync("/pricing/calculate", request.Items, ct);
// If any service is down, order creation fails completely
// ...
}
}Solution
Start with a modular monolith. Extract services only when there is a clear ownership, scale, or deployment boundary need.
// Better: Modular monolith with clear contracts
public sealed class OrderService(
ISalesModule sales,
IInventoryModule inventory,
IEventBus events)
{
public async Task<Result<Guid>> CreateOrderAsync(CreateOrderRequest request, CancellationToken ct)
{
// In-process call to customer module
var customer = await sales.GetCustomerAsync(request.CustomerId, ct);
if (customer is null)
return Result<Guid>.NotFound("Customer not found.");
// Reserve inventory (can be made async with events if needed)
var reservation = await inventory.ReserveAsync(request.Items, ct);
if (!reservation.IsSuccess)
return Result<Guid>.Failure("Inventory unavailable.");
var order = Order.Create(customer.Id, reservation.Items);
await sales.SaveOrderAsync(order, ct);
// Async event for downstream processing
await events.PublishAsync(new OrderCreatedEvent(order.Id), ct);
return Result<Guid>.Success(order.Id.Value);
}
}
// Module boundary is explicit and can be extracted later
public interface IInventoryModule
{
Task<ReservationResult> ReserveAsync(IEnumerable<OrderItem> items, CancellationToken ct);
Task ReleaseReservationAsync(Guid reservationId, CancellationToken ct);
}---
Repository Explosion
Problem: One Repository Per Entity
Creating a repository for every entity, leading to dozens of nearly identical classes with slight variations.
// Anti-pattern: Repository per entity
public interface IOrderRepository
{
Task<Order?> GetByIdAsync(Guid id);
Task AddAsync(Order order);
Task UpdateAsync(Order order);
Task DeleteAsync(Guid id);
}
public interface IOrderLineRepository
{
Task<OrderLine?> GetByIdAsync(Guid id);
Task AddAsync(OrderLine line);
// ... same methods
}
public interface ICustomerRepository { /* same pattern */ }
public interface IProductRepository { /* same pattern */ }
// ... 30 more repositoriesSolution
Repository per aggregate root, not per entity. Use the DbContext directly for simple queries.
// Better: Repository per aggregate
public interface IOrderRepository
{
Task<Order?> GetByIdAsync(OrderId id, CancellationToken ct = default);
Task<Order?> GetWithLinesAsync(OrderId id, CancellationToken ct = default);
Task AddAsync(Order order, CancellationToken ct = default);
Task SaveChangesAsync(CancellationToken ct = default);
}
// Order is the aggregate root; OrderLine is part of the aggregate
// No separate OrderLineRepository needed
// For simple read queries, use DbContext directly or a query service
public sealed class OrderQueryService(AppDbContext db)
{
public async Task<List<OrderSummaryDto>> GetRecentOrdersAsync(
CustomerId customerId,
int count = 10,
CancellationToken ct = default)
{
return await db.Orders
.Where(o => o.CustomerId == customerId)
.OrderByDescending(o => o.CreatedAt)
.Take(count)
.Select(o => new OrderSummaryDto(o.Id, o.Status, o.Total, o.CreatedAt))
.ToListAsync(ct);
}
}---
God Object / God Service
Problem: One Class Does Everything
A single service class grows to handle all operations for a domain area, becoming unmaintainable.
// Anti-pattern: God service
public class OrderService(
AppDbContext db,
IEmailService email,
IPaymentGateway payments,
IInventoryService inventory,
IShippingService shipping,
IPricingEngine pricing,
IDiscountCalculator discounts,
ITaxCalculator taxes,
ILogger<OrderService> logger)
{
public async Task<Order> CreateOrderAsync(...) { /* 200 lines */ }
public async Task SubmitOrderAsync(...) { /* 150 lines */ }
public async Task CancelOrderAsync(...) { /* 100 lines */ }
public async Task RefundOrderAsync(...) { /* 180 lines */ }
public async Task UpdateShippingAsync(...) { /* 80 lines */ }
public async Task ApplyDiscountAsync(...) { /* 90 lines */ }
public async Task RecalculateTotalsAsync(...) { /* 120 lines */ }
// ... 20 more methods, 3000+ lines total
}Solution
Split by use case or subdomain. Use vertical slices or focused handlers.
// Better: Focused handlers per use case
public sealed class CreateOrderHandler(
IOrderRepository orders,
ICustomerRepository customers) : IRequestHandler<CreateOrderCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(CreateOrderCommand command, CancellationToken ct)
{
// Focused, testable, ~50 lines
}
}
public sealed class SubmitOrderHandler(
IOrderRepository orders,
IPaymentService payments,
IEventPublisher events) : IRequestHandler<SubmitOrderCommand, Result>
{
public async Task<Result> Handle(SubmitOrderCommand command, CancellationToken ct)
{
// Focused, testable, ~60 lines
}
}
public sealed class CancelOrderHandler(
IOrderRepository orders,
IRefundService refunds,
IInventoryService inventory) : IRequestHandler<CancelOrderCommand, Result>
{
public async Task<Result> Handle(CancelOrderCommand command, CancellationToken ct)
{
// Focused, testable, ~70 lines
}
}---
Leaky Abstractions
Problem: Infrastructure Concerns Leak Upward
Database implementation details, HTTP concerns, or framework specifics appear in business logic.
// Anti-pattern: EF Core specifics in domain
public class Order
{
// EF navigation property concerns in domain entity
public virtual Customer Customer { get; set; }
public virtual ICollection<OrderLine> Lines { get; set; }
// EF change tracking awareness
public void AddLine(OrderLine line)
{
Lines ??= new List<OrderLine>();
Lines.Add(line);
}
}
// Anti-pattern: HTTP concerns in application layer
public sealed class OrderHandler(HttpClient http)
{
public async Task<Order> GetOrderAsync(Guid id)
{
var response = await http.GetAsync($"/api/orders/{id}");
// HTTP status codes in business logic
if (response.StatusCode == HttpStatusCode.NotFound)
throw new OrderNotFoundException(id);
if (response.StatusCode == HttpStatusCode.Forbidden)
throw new UnauthorizedException();
// JSON parsing in handler
var json = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<Order>(json);
}
}Solution
Keep infrastructure concerns in infrastructure layer. Use domain-appropriate abstractions.
// Better: Clean domain entity
public sealed class Order
{
public OrderId Id { get; }
private readonly List<OrderLine> _lines = [];
public IReadOnlyList<OrderLine> Lines => _lines.AsReadOnly();
public void AddLine(ProductId productId, Quantity quantity, Money unitPrice)
{
_lines.Add(new OrderLine(productId, quantity, unitPrice));
}
}
// Better: Infrastructure handles HTTP, exposes domain result
public sealed class OrderServiceClient(HttpClient http) : IOrderServiceClient
{
public async Task<Result<Order>> GetOrderAsync(OrderId id, CancellationToken ct = default)
{
try
{
var response = await http.GetAsync($"/api/orders/{id.Value}", ct);
if (response.StatusCode == HttpStatusCode.NotFound)
return Result<Order>.NotFound();
response.EnsureSuccessStatusCode();
var dto = await response.Content.ReadFromJsonAsync<OrderDto>(ct);
return Result<Order>.Success(dto!.ToDomain());
}
catch (HttpRequestException)
{
return Result<Order>.Failure("Order service unavailable.");
}
}
}
// Application layer uses domain abstractions
public sealed class OrderQueryHandler(IOrderServiceClient client)
{
public async Task<Result<OrderDto>> Handle(GetOrderQuery query, CancellationToken ct)
{
var result = await client.GetOrderAsync(query.OrderId, ct);
return result.Map(order => OrderDto.FromDomain(order));
}
}---
Cargo Cult Architecture
Problem: Copying Patterns Without Understanding
Implementing patterns because "that's how it's done" without understanding the problem they solve.
// Anti-pattern: CQRS + Event Sourcing for a simple CRUD app
public sealed class UpdateCustomerEmailHandler(
IEventStore eventStore,
IEventBus eventBus,
IReadModelProjector projector)
{
public async Task Handle(UpdateCustomerEmailCommand command, CancellationToken ct)
{
// Load entire event history
var events = await eventStore.GetEventsAsync(command.CustomerId, ct);
var customer = Customer.FromHistory(events);
// Apply change
customer.UpdateEmail(command.NewEmail);
// Save event
var newEvent = new CustomerEmailUpdatedEvent(command.CustomerId, command.NewEmail);
await eventStore.AppendAsync(command.CustomerId, newEvent, ct);
// Publish for projections
await eventBus.PublishAsync(newEvent, ct);
// Update read model
await projector.ProjectAsync(newEvent, ct);
}
}
// All this for: UPDATE Customers SET Email = @Email WHERE Id = @IdSolution
Match architecture complexity to problem complexity. Simple problems deserve simple solutions.
// Better: Simple solution for simple problem
public sealed class UpdateCustomerEmailHandler(
AppDbContext db,
IValidator<UpdateCustomerEmailCommand> validator) : IRequestHandler<UpdateCustomerEmailCommand, Result>
{
public async Task<Result> Handle(UpdateCustomerEmailCommand command, CancellationToken ct)
{
var validation = await validator.ValidateAsync(command, ct);
if (!validation.IsValid)
return Result.Invalid(validation.Errors);
var customer = await db.Customers.FindAsync([command.CustomerId], ct);
if (customer is null)
return Result.NotFound();
customer.Email = command.NewEmail;
await db.SaveChangesAsync(ct);
return Result.Success();
}
}
// Use CQRS/Event Sourcing when you actually need:
// - Audit trail of all changes
// - Temporal queries (state at point in time)
// - Complex event-driven workflows
// - Different read/write scaling requirements---
Missing Error Handling Strategy
Problem: Inconsistent Error Handling
Errors handled differently across the codebase. Some throw exceptions, some return null, some return result objects.
// Anti-pattern: Inconsistent error handling
public class OrderService
{
public async Task<Order> GetOrderAsync(Guid id)
{
var order = await db.Orders.FindAsync(id);
return order; // Returns null, caller must check
}
public async Task SubmitOrderAsync(Guid id)
{
var order = await db.Orders.FindAsync(id);
if (order == null)
throw new NotFoundException("Order not found"); // Throws exception
}
public async Task<bool> CancelOrderAsync(Guid id)
{
var order = await db.Orders.FindAsync(id);
if (order == null)
return false; // Returns bool
order.Cancel();
await db.SaveChangesAsync();
return true;
}
}Solution
Adopt a consistent result pattern across the application.
// Better: Consistent result type
public readonly struct Result<T>
{
public T? Value { get; }
public ResultError? Error { get; }
public bool IsSuccess => Error is null;
private Result(T value) => Value = value;
private Result(ResultError error) => Error = error;
public static Result<T> Success(T value) => new(value);
public static Result<T> Failure(string message) => new(new ResultError(message));
public static Result<T> NotFound(string? message = null) => new(new ResultError(message ?? "Not found", ResultErrorType.NotFound));
public TResult Match<TResult>(Func<T, TResult> success, Func<ResultError, TResult> failure)
=> IsSuccess ? success(Value!) : failure(Error!);
}
// Consistent usage across handlers
public sealed class GetOrderHandler(IOrderRepository orders) : IRequestHandler<GetOrderQuery, Result<OrderDto>>
{
public async Task<Result<OrderDto>> Handle(GetOrderQuery query, CancellationToken ct)
{
var order = await orders.GetByIdAsync(query.OrderId, ct);
if (order is null)
return Result<OrderDto>.NotFound();
return Result<OrderDto>.Success(OrderDto.FromDomain(order));
}
}
public sealed class SubmitOrderHandler(IOrderRepository orders) : IRequestHandler<SubmitOrderCommand, Result>
{
public async Task<Result> Handle(SubmitOrderCommand command, CancellationToken ct)
{
var order = await orders.GetByIdAsync(command.OrderId, ct);
if (order is null)
return Result.NotFound();
var submitResult = order.Submit();
if (!submitResult.IsSuccess)
return submitResult;
await orders.SaveChangesAsync(ct);
return Result.Success();
}
}---
Summary: Pattern Selection Guidance
| Smell | Question to Ask | If Yes | If No |
|---|---|---|---|
| Adding interface for every class | Is there more than one implementation or a testing need? | Keep interface | Remove interface |
| Logic in services, data in entities | Are there business invariants to protect? | Move logic to entity | Service is fine for orchestration |
| Considering microservices | Do teams need independent deployment? Different scale needs? | Consider microservices | Stay with modular monolith |
| Adding event sourcing | Need audit trail, temporal queries, or complex workflows? | Event sourcing may help | Simple persistence is fine |
| Repository per entity | Is this entity an aggregate root? | Repository is appropriate | Access through aggregate root |
| God service growing | Does this class have multiple reasons to change? | Split by use case | Keep if cohesive |
---
References
- Anemic Domain Model by Martin Fowler
- Big Ball of Mud by Brian Foote and Joseph Yoder
- Monolith First by Martin Fowler
- Don't Start with Microservices by Sam Newman
Architectural Patterns for .NET
This reference covers practical implementations of common architectural patterns in modern .NET, using C# 12+ features including primary constructors.
---
Clean Architecture
Clean Architecture organizes code into concentric layers with dependencies pointing inward. The domain layer has no external dependencies; infrastructure concerns live at the outer edge.
Layer Structure
src/
Domain/ # Entities, value objects, domain events, interfaces
Application/ # Use cases, DTOs, validators, command/query handlers
Infrastructure/ # EF Core, external APIs, messaging, file storage
WebApi/ # Controllers, middleware, composition rootDomain Layer
// Domain/Entities/Order.cs
public sealed class Order
{
public OrderId Id { get; }
public CustomerId CustomerId { get; }
private readonly List<OrderLine> _lines = [];
public IReadOnlyList<OrderLine> Lines => _lines.AsReadOnly();
public OrderStatus Status { get; private set; }
public Money Total => _lines.Aggregate(Money.Zero, (sum, line) => sum + line.Subtotal);
private Order(OrderId id, CustomerId customerId)
{
Id = id;
CustomerId = customerId;
Status = OrderStatus.Draft;
}
public static Order Create(CustomerId customerId)
=> new(OrderId.New(), customerId);
public void AddLine(Product product, Quantity quantity)
{
if (Status != OrderStatus.Draft)
throw new DomainException("Cannot modify a submitted order.");
var existing = _lines.Find(l => l.ProductId == product.Id);
if (existing is not null)
existing.IncreaseQuantity(quantity);
else
_lines.Add(new OrderLine(product.Id, product.Price, quantity));
}
public void Submit()
{
if (_lines.Count == 0)
throw new DomainException("Cannot submit an empty order.");
Status = OrderStatus.Submitted;
}
}
// Domain/ValueObjects/Money.cs
public readonly record struct Money(decimal Amount, string Currency)
{
public static Money Zero => new(0, "USD");
public static Money operator +(Money a, Money b)
{
if (a.Currency != b.Currency)
throw new InvalidOperationException("Currency mismatch.");
return new Money(a.Amount + b.Amount, a.Currency);
}
}
// Domain/Interfaces/IOrderRepository.cs
public interface IOrderRepository
{
Task<Order?> GetByIdAsync(OrderId id, CancellationToken ct = default);
Task AddAsync(Order order, CancellationToken ct = default);
Task SaveChangesAsync(CancellationToken ct = default);
}Application Layer
// Application/Orders/Commands/SubmitOrder/SubmitOrderCommand.cs
public sealed record SubmitOrderCommand(Guid OrderId) : IRequest<Result>;
// Application/Orders/Commands/SubmitOrder/SubmitOrderHandler.cs
public sealed class SubmitOrderHandler(
IOrderRepository orders,
IEventPublisher events) : IRequestHandler<SubmitOrderCommand, Result>
{
public async Task<Result> Handle(SubmitOrderCommand command, CancellationToken ct)
{
var order = await orders.GetByIdAsync(new OrderId(command.OrderId), ct);
if (order is null)
return Result.NotFound("Order not found.");
order.Submit();
await orders.SaveChangesAsync(ct);
await events.PublishAsync(new OrderSubmittedEvent(order.Id, order.Total), ct);
return Result.Success();
}
}Infrastructure Layer
// Infrastructure/Persistence/OrderRepository.cs
public sealed class OrderRepository(AppDbContext db) : IOrderRepository
{
public async Task<Order?> GetByIdAsync(OrderId id, CancellationToken ct = default)
=> await db.Orders
.Include(o => o.Lines)
.FirstOrDefaultAsync(o => o.Id == id, ct);
public async Task AddAsync(Order order, CancellationToken ct = default)
=> await db.Orders.AddAsync(order, ct);
public Task SaveChangesAsync(CancellationToken ct = default)
=> db.SaveChangesAsync(ct);
}---
Vertical Slice Architecture
Vertical slices organize code by feature instead of by layer. Each slice owns its request, handler, validator, and data access. This reduces cross-cutting changes and keeps related code together.
Folder Structure
src/
Features/
Orders/
CreateOrder/
CreateOrderEndpoint.cs
CreateOrderHandler.cs
CreateOrderRequest.cs
CreateOrderValidator.cs
GetOrder/
GetOrderEndpoint.cs
GetOrderHandler.cs
Products/
...
Shared/
Infrastructure/
Extensions/Feature Implementation
// Features/Orders/CreateOrder/CreateOrderRequest.cs
public sealed record CreateOrderRequest(
Guid CustomerId,
List<OrderLineDto> Lines);
public sealed record OrderLineDto(Guid ProductId, int Quantity);
// Features/Orders/CreateOrder/CreateOrderValidator.cs
public sealed class CreateOrderValidator : AbstractValidator<CreateOrderRequest>
{
public CreateOrderValidator()
{
RuleFor(x => x.CustomerId).NotEmpty();
RuleFor(x => x.Lines).NotEmpty();
RuleForEach(x => x.Lines).ChildRules(line =>
{
line.RuleFor(l => l.ProductId).NotEmpty();
line.RuleFor(l => l.Quantity).GreaterThan(0);
});
}
}
// Features/Orders/CreateOrder/CreateOrderHandler.cs
public sealed class CreateOrderHandler(
AppDbContext db,
IValidator<CreateOrderRequest> validator) : IRequestHandler<CreateOrderRequest, Result<Guid>>
{
public async Task<Result<Guid>> Handle(CreateOrderRequest request, CancellationToken ct)
{
var validation = await validator.ValidateAsync(request, ct);
if (!validation.IsValid)
return Result<Guid>.Invalid(validation.Errors);
var order = Order.Create(new CustomerId(request.CustomerId));
foreach (var line in request.Lines)
{
var product = await db.Products.FindAsync([line.ProductId], ct);
if (product is null)
return Result<Guid>.NotFound($"Product {line.ProductId} not found.");
order.AddLine(product, new Quantity(line.Quantity));
}
await db.Orders.AddAsync(order, ct);
await db.SaveChangesAsync(ct);
return Result<Guid>.Success(order.Id.Value);
}
}
// Features/Orders/CreateOrder/CreateOrderEndpoint.cs
public static class CreateOrderEndpoint
{
public static void Map(IEndpointRouteBuilder app) =>
app.MapPost("/orders", async (
CreateOrderRequest request,
IMediator mediator,
CancellationToken ct) =>
{
var result = await mediator.Send(request, ct);
return result.Match(
success: id => Results.Created($"/orders/{id}", new { id }),
failure: errors => Results.BadRequest(errors));
})
.WithName("CreateOrder")
.WithTags("Orders")
.Produces<Guid>(StatusCodes.Status201Created)
.ProducesValidationProblem();
}---
Domain-Driven Design (DDD)
DDD applies when business rules are complex and need explicit modeling. Avoid forcing DDD into simple CRUD scenarios.
Aggregate Root
// Domain/Aggregates/Booking/Booking.cs
public sealed class Booking : AggregateRoot<BookingId>
{
public RoomId RoomId { get; }
public GuestId GuestId { get; }
public DateRange Period { get; private set; }
public BookingStatus Status { get; private set; }
private Booking(BookingId id, RoomId roomId, GuestId guestId, DateRange period)
: base(id)
{
RoomId = roomId;
GuestId = guestId;
Period = period;
Status = BookingStatus.Pending;
}
public static Booking Create(RoomId roomId, GuestId guestId, DateRange period)
{
var booking = new Booking(BookingId.New(), roomId, guestId, period);
booking.AddDomainEvent(new BookingCreatedEvent(booking.Id, roomId, period));
return booking;
}
public Result Confirm()
{
if (Status != BookingStatus.Pending)
return Result.Failure("Only pending bookings can be confirmed.");
Status = BookingStatus.Confirmed;
AddDomainEvent(new BookingConfirmedEvent(Id));
return Result.Success();
}
public Result Cancel(string reason)
{
if (Status == BookingStatus.Cancelled)
return Result.Failure("Booking is already cancelled.");
if (Status == BookingStatus.Confirmed && Period.Start <= DateTime.UtcNow.AddDays(1))
return Result.Failure("Cannot cancel confirmed booking within 24 hours.");
Status = BookingStatus.Cancelled;
AddDomainEvent(new BookingCancelledEvent(Id, reason));
return Result.Success();
}
}
// Domain/ValueObjects/DateRange.cs
public readonly record struct DateRange(DateTime Start, DateTime End)
{
public int Nights => (End - Start).Days;
public bool Overlaps(DateRange other)
=> Start < other.End && End > other.Start;
public static DateRange Create(DateTime start, DateTime end)
{
if (end <= start)
throw new DomainException("End date must be after start date.");
return new DateRange(start, end);
}
}Domain Events
// Domain/Events/BookingConfirmedEvent.cs
public sealed record BookingConfirmedEvent(BookingId BookingId) : IDomainEvent;
// Application/Handlers/BookingConfirmedHandler.cs
public sealed class BookingConfirmedHandler(
IEmailService email,
IBookingRepository bookings) : IDomainEventHandler<BookingConfirmedEvent>
{
public async Task Handle(BookingConfirmedEvent @event, CancellationToken ct)
{
var booking = await bookings.GetByIdAsync(@event.BookingId, ct);
if (booking is null) return;
await email.SendConfirmationAsync(booking.GuestId, booking.Period, ct);
}
}Domain Service
// Domain/Services/BookingPolicyService.cs
public sealed class BookingPolicyService(IRoomRepository rooms)
{
public async Task<Result> CanBookAsync(
RoomId roomId,
DateRange period,
CancellationToken ct = default)
{
var room = await rooms.GetByIdAsync(roomId, ct);
if (room is null)
return Result.Failure("Room not found.");
if (!room.IsAvailable)
return Result.Failure("Room is not available for booking.");
var conflicts = await rooms.GetOverlappingBookingsAsync(roomId, period, ct);
if (conflicts.Count > 0)
return Result.Failure("Room is already booked for the requested period.");
if (period.Nights > room.MaxStayNights)
return Result.Failure($"Maximum stay is {room.MaxStayNights} nights.");
return Result.Success();
}
}---
CQRS (Command Query Responsibility Segregation)
CQRS separates write operations (commands) from read operations (queries). Apply it when read and write models have different optimization needs.
Command Side
// Application/Commands/PlaceOrder/PlaceOrderCommand.cs
public sealed record PlaceOrderCommand(
Guid CustomerId,
List<OrderItemDto> Items) : ICommand<Result<Guid>>;
// Application/Commands/PlaceOrder/PlaceOrderHandler.cs
public sealed class PlaceOrderHandler(
IOrderRepository orders,
IUnitOfWork unitOfWork,
IEventBus events) : ICommandHandler<PlaceOrderCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(PlaceOrderCommand command, CancellationToken ct)
{
var order = Order.Create(new CustomerId(command.CustomerId));
foreach (var item in command.Items)
{
order.AddItem(new ProductId(item.ProductId), item.Quantity, item.UnitPrice);
}
await orders.AddAsync(order, ct);
await unitOfWork.CommitAsync(ct);
await events.PublishAsync(new OrderPlacedIntegrationEvent(
order.Id.Value,
order.CustomerId.Value,
order.Total.Amount), ct);
return Result<Guid>.Success(order.Id.Value);
}
}Query Side with Dedicated Read Model
// Application/Queries/GetOrderSummary/GetOrderSummaryQuery.cs
public sealed record GetOrderSummaryQuery(Guid OrderId) : IQuery<OrderSummaryDto?>;
// Application/Queries/GetOrderSummary/OrderSummaryDto.cs
public sealed record OrderSummaryDto(
Guid Id,
string CustomerName,
DateTime OrderDate,
string Status,
decimal Total,
List<OrderItemSummaryDto> Items);
public sealed record OrderItemSummaryDto(
string ProductName,
int Quantity,
decimal UnitPrice,
decimal Subtotal);
// Application/Queries/GetOrderSummary/GetOrderSummaryHandler.cs
public sealed class GetOrderSummaryHandler(
IDbConnection db) : IQueryHandler<GetOrderSummaryQuery, OrderSummaryDto?>
{
public async Task<OrderSummaryDto?> Handle(GetOrderSummaryQuery query, CancellationToken ct)
{
const string sql = """
SELECT o.Id, c.Name AS CustomerName, o.OrderDate, o.Status, o.Total
FROM Orders o
JOIN Customers c ON o.CustomerId = c.Id
WHERE o.Id = @OrderId;
SELECT oi.ProductName, oi.Quantity, oi.UnitPrice, oi.Subtotal
FROM OrderItems oi
WHERE oi.OrderId = @OrderId;
""";
await using var multi = await db.QueryMultipleAsync(sql, new { query.OrderId });
var order = await multi.ReadSingleOrDefaultAsync<OrderSummaryDto>();
if (order is null) return null;
var items = (await multi.ReadAsync<OrderItemSummaryDto>()).ToList();
return order with { Items = items };
}
}Event Sourcing Integration
// Infrastructure/EventStore/OrderEventStore.cs
public sealed class OrderEventStore(EventStoreClient client) : IOrderEventStore
{
public async Task AppendAsync(OrderId id, IEnumerable<IDomainEvent> events, CancellationToken ct)
{
var streamName = $"order-{id.Value}";
var eventData = events.Select(e => new EventData(
Uuid.NewUuid(),
e.GetType().Name,
JsonSerializer.SerializeToUtf8Bytes(e),
null)).ToArray();
await client.AppendToStreamAsync(streamName, StreamState.Any, eventData, cancellationToken: ct);
}
public async Task<Order> RehydrateAsync(OrderId id, CancellationToken ct)
{
var streamName = $"order-{id.Value}";
var events = new List<IDomainEvent>();
await foreach (var resolved in client.ReadStreamAsync(
Direction.Forwards, streamName, StreamPosition.Start, cancellationToken: ct))
{
var eventType = Type.GetType($"Domain.Events.{resolved.Event.EventType}");
var domainEvent = (IDomainEvent)JsonSerializer.Deserialize(
resolved.Event.Data.Span, eventType!)!;
events.Add(domainEvent);
}
return Order.FromHistory(id, events);
}
}---
Modular Monolith
A modular monolith keeps code in one deployable unit while enforcing module boundaries. Modules communicate through defined contracts, enabling future extraction.
Module Structure
src/
Modules/
Sales/
Sales.Api/
Sales.Application/
Sales.Domain/
Sales.Infrastructure/
Sales.Contracts/ # Public contracts other modules can depend on
Inventory/
Inventory.Api/
Inventory.Application/
Inventory.Domain/
Inventory.Infrastructure/
Inventory.Contracts/
Host/ # Composition root, startup, shared infrastructureModule Contracts
// Modules/Sales/Sales.Contracts/Events/OrderPlacedIntegrationEvent.cs
public sealed record OrderPlacedIntegrationEvent(
Guid OrderId,
Guid CustomerId,
List<OrderedProduct> Products,
DateTime OccurredAt) : IIntegrationEvent;
public sealed record OrderedProduct(Guid ProductId, int Quantity);
// Modules/Sales/Sales.Contracts/Services/ISalesModule.cs
public interface ISalesModule
{
Task<OrderSummaryDto?> GetOrderSummaryAsync(Guid orderId, CancellationToken ct = default);
}Inter-Module Communication
// Modules/Inventory/Inventory.Application/Handlers/OrderPlacedHandler.cs
public sealed class OrderPlacedHandler(
IInventoryRepository inventory,
IUnitOfWork unitOfWork) : IIntegrationEventHandler<OrderPlacedIntegrationEvent>
{
public async Task Handle(OrderPlacedIntegrationEvent @event, CancellationToken ct)
{
foreach (var product in @event.Products)
{
var item = await inventory.GetByProductIdAsync(product.ProductId, ct);
if (item is null) continue;
item.Reserve(product.Quantity);
}
await unitOfWork.CommitAsync(ct);
}
}
// Host/Program.cs - Module registration
builder.Services
.AddSalesModule(builder.Configuration)
.AddInventoryModule(builder.Configuration)
.AddSharedInfrastructure(builder.Configuration);---
Microservices Boundaries
When microservices are justified, define clear ownership and communication patterns.
Service Contract
// Contracts/OrderService/IOrderServiceClient.cs
public interface IOrderServiceClient
{
Task<OrderDto?> GetOrderAsync(Guid orderId, CancellationToken ct = default);
Task<Guid> PlaceOrderAsync(PlaceOrderRequest request, CancellationToken ct = default);
}
// Infrastructure/Clients/OrderServiceClient.cs
public sealed class OrderServiceClient(
HttpClient http,
ILogger<OrderServiceClient> logger) : IOrderServiceClient
{
public async Task<OrderDto?> GetOrderAsync(Guid orderId, CancellationToken ct = default)
{
try
{
return await http.GetFromJsonAsync<OrderDto>($"/orders/{orderId}", ct);
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
}
public async Task<Guid> PlaceOrderAsync(PlaceOrderRequest request, CancellationToken ct = default)
{
var response = await http.PostAsJsonAsync("/orders", request, ct);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<PlaceOrderResponse>(ct);
return result!.OrderId;
}
}Saga / Process Manager
// Application/Sagas/OrderFulfillmentSaga.cs
public sealed class OrderFulfillmentSaga(
IOrderRepository orders,
IInventoryServiceClient inventory,
IPaymentServiceClient payments,
IShippingServiceClient shipping,
IEventBus events) : ISaga<OrderFulfillmentState>
{
public async Task<OrderFulfillmentState> HandleAsync(
OrderPlacedEvent @event,
OrderFulfillmentState state,
CancellationToken ct)
{
state = state with { OrderId = @event.OrderId, Status = FulfillmentStatus.Started };
// Reserve inventory
var reserved = await inventory.ReserveAsync(@event.OrderId, @event.Items, ct);
if (!reserved.IsSuccess)
{
await CompensateAsync(state, ct);
return state with { Status = FulfillmentStatus.Failed, FailureReason = "Inventory unavailable" };
}
state = state with { InventoryReserved = true };
// Process payment
var paid = await payments.ChargeAsync(@event.OrderId, @event.Total, ct);
if (!paid.IsSuccess)
{
await CompensateAsync(state, ct);
return state with { Status = FulfillmentStatus.Failed, FailureReason = "Payment failed" };
}
state = state with { PaymentProcessed = true };
// Create shipment
var shipment = await shipping.CreateShipmentAsync(@event.OrderId, @event.ShippingAddress, ct);
state = state with { ShipmentId = shipment.Id, Status = FulfillmentStatus.Completed };
await events.PublishAsync(new OrderFulfilledEvent(@event.OrderId, shipment.Id), ct);
return state;
}
private async Task CompensateAsync(OrderFulfillmentState state, CancellationToken ct)
{
if (state.PaymentProcessed)
await payments.RefundAsync(state.OrderId, ct);
if (state.InventoryReserved)
await inventory.ReleaseReservationAsync(state.OrderId, ct);
await orders.MarkFailedAsync(state.OrderId, state.FailureReason, ct);
}
}---
Pattern Selection Guide
| Scenario | Recommended Pattern |
|---|---|
| New project, small team, unclear requirements | Simple layered or modular monolith |
| Complex domain with many business rules | DDD with aggregates and domain events |
| High read/write ratio disparity | CQRS with separate read models |
| Independent team deployments required | Microservices with clear contracts |
| Rapid feature development focus | Vertical slices |
| Need future extraction flexibility | Modular monolith with explicit contracts |
---