Skip to main content

Introduction

Clean Architecture, popularized by Robert C. Martin (Uncle Bob), is a software design philosophy that emphasizes separation of concerns and independence of frameworks, UI, and databases. The Ordering service in AspNetRun demonstrates a full implementation of Clean Architecture combined with Domain-Driven Design.
Service Location: src/Services/Ordering/This service handles order processing, order history, and order lifecycle management with complex business rules.

The Clean Architecture Layers

Project Structure

The Ordering service is organized into four distinct projects:
Ordering/
├── Ordering.Domain/              # Core business logic
│   ├── Abstractions/
│   │   ├── Aggregate.cs         # Base aggregate root
│   │   ├── Entity.cs            # Base entity
│   │   ├── IAggregate.cs
│   │   └── IDomainEvent.cs
│   ├── Models/
│   │   ├── Order.cs             # Order aggregate root
│   │   ├── OrderItem.cs         # Order entity
│   │   └── Customer.cs
│   ├── ValueObjects/
│   │   ├── Address.cs
│   │   ├── Payment.cs
│   │   ├── OrderId.cs
│   │   └── CustomerId.cs
│   ├── Events/
│   │   ├── OrderCreatedEvent.cs
│   │   └── OrderUpdatedEvent.cs
│   └── Enums/
│       └── OrderStatus.cs

├── Ordering.Application/         # Use cases and business rules
│   ├── Orders/
│   │   ├── Commands/
│   │   │   ├── CreateOrder/
│   │   │   │   ├── CreateOrderCommand.cs
│   │   │   │   └── CreateOrderHandler.cs
│   │   │   ├── UpdateOrder/
│   │   │   └── DeleteOrder/
│   │   ├── Queries/
│   │   │   ├── GetOrders/
│   │   │   │   ├── GetOrdersQuery.cs
│   │   │   │   └── GetOrdersHandler.cs
│   │   │   ├── GetOrdersByCustomer/
│   │   │   └── GetOrdersByName/
│   │   └── EventHandlers/
│   │       ├── Domain/
│   │       │   └── OrderCreatedEventHandler.cs
│   │       └── Integration/
│   │           └── BasketCheckoutEventHandler.cs
│   ├── Data/
│   │   └── IApplicationDbContext.cs
│   ├── Dtos/
│   └── Extensions/

├── Ordering.Infrastructure/      # External concerns
│   ├── Data/
│   │   ├── ApplicationDbContext.cs
│   │   ├── Configurations/      # EF Core entity configurations
│   │   ├── Interceptors/
│   │   │   ├── AuditableEntityInterceptor.cs
│   │   │   └── DispatchDomainEventsInterceptor.cs
│   │   ├── Extensions/
│   │   │   └── DatabaseExtensions.cs
│   │   └── Migrations/
│   └── DependencyInjection.cs

└── Ordering.API/                 # Presentation layer
    ├── Endpoints/
    │   ├── CreateOrder.cs
    │   ├── GetOrders.cs
    │   ├── UpdateOrder.cs
    │   └── DeleteOrder.cs
    ├── Program.cs
    └── DependencyInjection.cs

Layer Responsibilities

1. Domain Layer (Core)

The Heart of the Application

Contains all business logic, domain models, and business rules. This layer has no dependencies on other layers.

Aggregate Root Example

The Order aggregate is the entry point for all order-related operations:
src/Services/Ordering/Ordering.Domain/Models/Order.cs
namespace Ordering.Domain.Models;

public class Order : Aggregate<OrderId>
{
    private readonly List<OrderItem> _orderItems = new();
    public IReadOnlyList<OrderItem> OrderItems => _orderItems.AsReadOnly();

    public CustomerId CustomerId { get; private set; } = default!;
    public OrderName OrderName { get; private set; } = default!;
    public Address ShippingAddress { get; private set; } = default!;
    public Address BillingAddress { get; private set; } = default!;
    public Payment Payment { get; private set; } = default!;
    public OrderStatus Status { get; private set; } = OrderStatus.Pending;
    
    public decimal TotalPrice
    {
        get => OrderItems.Sum(x => x.Price * x.Quantity);
        private set { }
    }

    // Factory method enforces invariants
    public static Order Create(
        OrderId id, 
        CustomerId customerId, 
        OrderName orderName, 
        Address shippingAddress, 
        Address billingAddress, 
        Payment payment)
    {
        var order = new Order
        {
            Id = id,
            CustomerId = customerId,
            OrderName = orderName,
            ShippingAddress = shippingAddress,
            BillingAddress = billingAddress,
            Payment = payment,
            Status = OrderStatus.Pending
        };

        // Raise domain event
        order.AddDomainEvent(new OrderCreatedEvent(order));

        return order;
    }

    // Business logic encapsulated in aggregate
    public void Add(ProductId productId, int quantity, decimal price)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantity);
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(price);

        var orderItem = new OrderItem(Id, productId, quantity, price);
        _orderItems.Add(orderItem);
    }

    public void Remove(ProductId productId)
    {
        var orderItem = _orderItems.FirstOrDefault(x => x.ProductId == productId);
        if (orderItem is not null)
        {
            _orderItems.Remove(orderItem);
        }
    }
}
Key Characteristics:
  • Private setters protect invariants
  • Factory method Create() ensures valid construction
  • Business logic methods (Add, Remove) maintain consistency
  • Domain events communicate changes
  • Read-only collection prevents external modification

Value Objects

Value Objects represent concepts with no identity, compared by value:
src/Services/Ordering/Ordering.Domain/ValueObjects/Address.cs
namespace Ordering.Domain.ValueObjects;

public record Address
{
    public string FirstName { get; } = default!;
    public string LastName { get; } = default!;
    public string? EmailAddress { get; } = default!;
    public string AddressLine { get; } = default!;
    public string Country { get; } = default!;
    public string State { get; } = default!;
    public string ZipCode { get; } = default!;
    
    protected Address() { }

    private Address(string firstName, string lastName, string emailAddress, 
        string addressLine, string country, string state, string zipCode)
    {
        FirstName = firstName;
        LastName = lastName;
        EmailAddress = emailAddress;
        AddressLine = addressLine;
        Country = country;
        State = state;
        ZipCode = zipCode;
    }

    // Factory method with validation
    public static Address Of(string firstName, string lastName, string emailAddress, 
        string addressLine, string country, string state, string zipCode)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(emailAddress);
        ArgumentException.ThrowIfNullOrWhiteSpace(addressLine);

        return new Address(firstName, lastName, emailAddress, 
            addressLine, country, state, zipCode);
    }
}

Strongly-Typed IDs

Prevent primitive obsession by wrapping IDs in types:
src/Services/Ordering/Ordering.Domain/ValueObjects/OrderId.cs
namespace Ordering.Domain.ValueObjects;

public record OrderId
{
    public Guid Value { get; }
    
    private OrderId(Guid value) => Value = value;
    
    public static OrderId Of(Guid value)
    {
        ArgumentNullException.ThrowIfNull(value);
        
        if (value == Guid.Empty)
        {
            throw new DomainException("OrderId cannot be empty.");
        }

        return new OrderId(value);
    }
}

Domain Events

Communicate state changes within the domain:
src/Services/Ordering/Ordering.Domain/Events/OrderCreatedEvent.cs
namespace Ordering.Domain.Events;

public record OrderCreatedEvent(Order order) : IDomainEvent;

2. Application Layer

Use Cases and Business Workflows

Orchestrates domain objects to fulfill use cases. Depends on Domain layer but not on Infrastructure.

Commands (Write Operations)

Commands represent intentions to change state:
src/Services/Ordering/Ordering.Application/Orders/Commands/CreateOrder/CreateOrderCommand.cs
using BuildingBlocks.CQRS;
using FluentValidation;

namespace Ordering.Application.Orders.Commands.CreateOrder;

public record CreateOrderCommand(OrderDto Order)
    : ICommand<CreateOrderResult>;

public record CreateOrderResult(Guid Id);

public class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
    public CreateOrderCommandValidator()
    {
        RuleFor(x => x.Order.OrderName)
            .NotEmpty().WithMessage("Name is required");
        
        RuleFor(x => x.Order.CustomerId)
            .NotNull().WithMessage("CustomerId is required");
        
        RuleFor(x => x.Order.OrderItems)
            .NotEmpty().WithMessage("OrderItems should not be empty");
    }
}

Command Handlers

Execute commands and coordinate domain objects:
src/Services/Ordering/Ordering.Application/Orders/Commands/CreateOrder/CreateOrderHandler.cs
namespace Ordering.Application.Orders.Commands.CreateOrder;

public class CreateOrderHandler(IApplicationDbContext dbContext)
    : ICommandHandler<CreateOrderCommand, CreateOrderResult>
{
    public async Task<CreateOrderResult> Handle(
        CreateOrderCommand command, 
        CancellationToken cancellationToken)
    {
        // Create Order entity from command object
        var order = CreateNewOrder(command.Order);

        // Save to database
        dbContext.Orders.Add(order);
        await dbContext.SaveChangesAsync(cancellationToken);

        // Return result
        return new CreateOrderResult(order.Id.Value);
    }

    private Order CreateNewOrder(OrderDto orderDto)
    {
        var shippingAddress = Address.Of(
            orderDto.ShippingAddress.FirstName, 
            orderDto.ShippingAddress.LastName,
            orderDto.ShippingAddress.EmailAddress, 
            orderDto.ShippingAddress.AddressLine,
            orderDto.ShippingAddress.Country, 
            orderDto.ShippingAddress.State,
            orderDto.ShippingAddress.ZipCode);

        var billingAddress = Address.Of(
            orderDto.BillingAddress.FirstName, 
            orderDto.BillingAddress.LastName,
            orderDto.BillingAddress.EmailAddress, 
            orderDto.BillingAddress.AddressLine,
            orderDto.BillingAddress.Country, 
            orderDto.BillingAddress.State,
            orderDto.BillingAddress.ZipCode);

        var newOrder = Order.Create(
            id: OrderId.Of(Guid.NewGuid()),
            customerId: CustomerId.Of(orderDto.CustomerId),
            orderName: OrderName.Of(orderDto.OrderName),
            shippingAddress: shippingAddress,
            billingAddress: billingAddress,
            payment: Payment.Of(
                orderDto.Payment.CardName, 
                orderDto.Payment.CardNumber,
                orderDto.Payment.Expiration, 
                orderDto.Payment.Cvv,
                orderDto.Payment.PaymentMethod)
        );

        foreach (var orderItemDto in orderDto.OrderItems)
        {
            newOrder.Add(
                ProductId.Of(orderItemDto.ProductId), 
                orderItemDto.Quantity, 
                orderItemDto.Price);
        }
        
        return newOrder;
    }
}

Queries (Read Operations)

Queries retrieve data without side effects:
src/Services/Ordering/Ordering.Application/Orders/Queries/GetOrders/GetOrdersQuery.cs
using BuildingBlocks.Pagination;

namespace Ordering.Application.Orders.Queries.GetOrders;

public record GetOrdersQuery(PaginationRequest PaginationRequest) 
    : IQuery<GetOrdersResult>;

public record GetOrdersResult(PaginatedResult<OrderDto> Orders);
src/Services/Ordering/Ordering.Application/Orders/Queries/GetOrders/GetOrdersHandler.cs
namespace Ordering.Application.Orders.Queries.GetOrders;

public class GetOrdersHandler(IApplicationDbContext dbContext)
    : IQueryHandler<GetOrdersQuery, GetOrdersResult>
{
    public async Task<GetOrdersResult> Handle(
        GetOrdersQuery query, 
        CancellationToken cancellationToken)
    {
        var pageIndex = query.PaginationRequest.PageIndex;
        var pageSize = query.PaginationRequest.PageSize;

        var totalCount = await dbContext.Orders.LongCountAsync(cancellationToken);

        var orders = await dbContext.Orders
            .Include(o => o.OrderItems)
            .OrderBy(o => o.OrderName.Value)
            .Skip(pageSize * pageIndex)
            .Take(pageSize)
            .ToListAsync(cancellationToken);

        return new GetOrdersResult(
            new PaginatedResult<OrderDto>(
                pageIndex,
                pageSize,
                totalCount,
                orders.ToOrderDtoList()));
    }
}

Domain Event Handlers

React to domain events for side effects:
src/Services/Ordering/Ordering.Application/Orders/EventHandlers/Domain/OrderCreatedEventHandler.cs
using MassTransit;
using Microsoft.FeatureManagement;

namespace Ordering.Application.Orders.EventHandlers.Domain;

public class OrderCreatedEventHandler
    (IPublishEndpoint publishEndpoint, 
     IFeatureManager featureManager, 
     ILogger<OrderCreatedEventHandler> logger)
    : INotificationHandler<OrderCreatedEvent>
{
    public async Task Handle(
        OrderCreatedEvent domainEvent, 
        CancellationToken cancellationToken)
    {
        logger.LogInformation("Domain Event handled: {DomainEvent}", 
            domainEvent.GetType().Name);

        if (await featureManager.IsEnabledAsync("OrderFullfilment"))
        {
            var orderCreatedIntegrationEvent = domainEvent.order.ToOrderDto();
            await publishEndpoint.Publish(
                orderCreatedIntegrationEvent, 
                cancellationToken);
        }
    }
}

Dependency Registration

src/Services/Ordering/Ordering.Application/DependencyInjection.cs
namespace Ordering.Application;

public static class DependencyInjection
{
    public static IServiceCollection AddApplicationServices(
        this IServiceCollection services, 
        IConfiguration configuration)
    {
        // Register MediatR with behaviors
        services.AddMediatR(config =>
        {
            config.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly());
            config.AddOpenBehavior(typeof(ValidationBehavior<,>));
            config.AddOpenBehavior(typeof(LoggingBehavior<,>));
        });

        services.AddFeatureManagement();
        services.AddMessageBroker(configuration, Assembly.GetExecutingAssembly());

        return services;
    }
}

3. Infrastructure Layer

External Concerns

Implements interfaces defined in Domain/Application layers. Handles databases, external APIs, file systems, etc.

Entity Framework DbContext

namespace Ordering.Infrastructure.Data;

public class ApplicationDbContext : DbContext, IApplicationDbContext
{
    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
        : base(options) { }

    public DbSet<Order> Orders => Set<Order>();
    public DbSet<OrderItem> OrderItems => Set<OrderItem>();
    public DbSet<Customer> Customers => Set<Customer>();

    protected override void OnModelCreating(ModelBuilder builder)
    {
        builder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly());
        base.OnModelCreating(builder);
    }
}

Domain Event Dispatcher

Automatically dispatches domain events when saving:
src/Services/Ordering/Ordering.Infrastructure/Data/Interceptors/DispatchDomainEventsInterceptor.cs
using MediatR;
using Microsoft.EntityFrameworkCore.Diagnostics;

namespace Ordering.Infrastructure.Data.Interceptors;

public class DispatchDomainEventsInterceptor(IMediator mediator) 
    : SaveChangesInterceptor
{
    public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData, 
        InterceptionResult<int> result, 
        CancellationToken cancellationToken = default)
    {
        await DispatchDomainEvents(eventData.Context);
        return await base.SavingChangesAsync(eventData, result, cancellationToken);
    }

    public async Task DispatchDomainEvents(DbContext? context)
    {
        if (context == null) return;

        // Get all aggregates with domain events
        var aggregates = context.ChangeTracker
            .Entries<IAggregate>()
            .Where(a => a.Entity.DomainEvents.Any())
            .Select(a => a.Entity);

        // Collect all domain events
        var domainEvents = aggregates
            .SelectMany(a => a.DomainEvents)
            .ToList();

        // Clear events from aggregates
        aggregates.ToList().ForEach(a => a.ClearDomainEvents());

        // Publish events via MediatR
        foreach (var domainEvent in domainEvents)
            await mediator.Publish(domainEvent);
    }
}

Dependency Registration

src/Services/Ordering/Ordering.Infrastructure/DependencyInjection.cs
namespace Ordering.Infrastructure;

public static class DependencyInjection
{
    public static IServiceCollection AddInfrastructureServices(
        this IServiceCollection services, 
        IConfiguration configuration)
    {
        var connectionString = configuration.GetConnectionString("Database");

        // Register interceptors
        services.AddScoped<ISaveChangesInterceptor, AuditableEntityInterceptor>();
        services.AddScoped<ISaveChangesInterceptor, DispatchDomainEventsInterceptor>();

        // Configure DbContext with interceptors
        services.AddDbContext<ApplicationDbContext>((sp, options) =>
        {
            options.AddInterceptors(sp.GetServices<ISaveChangesInterceptor>());
            options.UseSqlServer(connectionString);
        });

        services.AddScoped<IApplicationDbContext, ApplicationDbContext>();

        return services;
    }
}

4. API Layer (Presentation)

User Interface / HTTP API

Exposes functionality via HTTP endpoints. Uses Minimal APIs with Carter for clean endpoint organization.

Minimal API Endpoint

src/Services/Ordering/Ordering.API/Endpoints/CreateOrder.cs
namespace Ordering.API.Endpoints;

public record CreateOrderRequest(OrderDto Order);
public record CreateOrderResponse(Guid Id);

public class CreateOrder : ICarterModule
{
    public void AddRoutes(IEndpointRouteBuilder app)
    {
        app.MapPost("/orders", async (CreateOrderRequest request, ISender sender) =>
        {
            // Map request to command
            var command = request.Adapt<CreateOrderCommand>();

            // Send command via MediatR
            var result = await sender.Send(command);

            // Map result to response
            var response = result.Adapt<CreateOrderResponse>();

            return Results.Created($"/orders/{response.Id}", response);
        })
        .WithName("CreateOrder")
        .Produces<CreateOrderResponse>(StatusCodes.Status201Created)
        .ProducesProblem(StatusCodes.Status400BadRequest)
        .WithSummary("Create Order")
        .WithDescription("Create Order");
    }
}

Program.cs

src/Services/Ordering/Ordering.API/Program.cs
using Ordering.API;
using Ordering.Application;
using Ordering.Infrastructure;
using Ordering.Infrastructure.Data.Extensions;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container
builder.Services
    .AddApplicationServices(builder.Configuration)
    .AddInfrastructureServices(builder.Configuration)
    .AddApiServices(builder.Configuration);

var app = builder.Build();

// Configure the HTTP request pipeline
app.UseApiServices();

if (app.Environment.IsDevelopment())
{
    await app.InitialiseDatabaseAsync();
}

app.Run();

Dependency Flow

The Dependency Rule: Source code dependencies must point inward toward the domain.
  • Domain has no dependencies (pure business logic)
  • Application depends only on Domain
  • Infrastructure depends on Domain and Application
  • API depends on Application

Benefits of Clean Architecture

Testability

Business logic can be tested without databases or frameworks

Maintainability

Clear separation makes code easier to understand and modify

Framework Independence

Not locked into any specific framework or library

Database Independence

Can switch databases without affecting business logic

Scalability

Each layer can scale independently

Team Collaboration

Teams can work on different layers with minimal conflicts

DDD Principles

Learn about Domain-Driven Design patterns used in the Domain layer

CQRS Pattern

Understand how CQRS is implemented in the Application layer

Vertical Slice

Compare with the simpler Vertical Slice pattern in Catalog service

Microservices Pattern

See how Clean Architecture fits into the microservices context

Build docs developers (and LLMs) love