Skip to main content

Application Layer Architecture

The Application layer implements CQRS (Command Query Responsibility Segregation) using MediatR, providing:
  • Commands: State-changing operations (Create, Update, Delete)
  • Queries: Read-only operations with optimized projections
  • Event Handlers: Domain and integration event processing
  • Validation: FluentValidation for input validation
  • DTOs: Data transfer objects for API contracts
  • Behaviors: Cross-cutting concerns (validation, logging)

Service Registration

namespace Ordering.Application;

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

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

        return services;
    }
}
Registered Services:
  • MediatR: Command/query mediator
  • ValidationBehavior: Automatic FluentValidation execution
  • LoggingBehavior: Request/response logging
  • Feature Management: Feature flag support
  • Message Broker: MassTransit for event publishing/consumption

Commands

Commands modify system state and follow a consistent pattern.

CreateOrderCommand

Creates a new order from customer checkout:
using BuildingBlocks.CQRS;
using FluentValidation;
using Ordering.Application.Dtos;

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");
    }
}
CreateOrderHandler (see Ordering.Application/Orders/Commands/CreateOrder/CreateOrderHandler.cs:2):
namespace Ordering.Application.Orders.Commands.CreateOrder;

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

        dbContext.Orders.Add(order);
        await dbContext.SaveChangesAsync(cancellationToken);

        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;
    }
}
Handler Flow:
  1. Receives CreateOrderCommand from API
  2. Maps DTO to domain value objects
  3. Creates Order aggregate using factory method
  4. Adds OrderItems to aggregate
  5. Persists to database
  6. SaveChanges triggers domain event dispatch
  7. Returns new order ID

UpdateOrderCommand

Updates an existing order:
using BuildingBlocks.CQRS;
using FluentValidation;
using Ordering.Application.Dtos;

namespace Ordering.Application.Orders.Commands.UpdateOrder;

public record UpdateOrderCommand(OrderDto Order)
    : ICommand<UpdateOrderResult>;

public record UpdateOrderResult(bool IsSuccess);

public class UpdateOrderCommandValidator : AbstractValidator<UpdateOrderCommand>
{
    public UpdateOrderCommandValidator()
    {
        RuleFor(x => x.Order.Id).NotEmpty().WithMessage("Id is required");
        RuleFor(x => x.Order.OrderName).NotEmpty().WithMessage("Name is required");
        RuleFor(x => x.Order.CustomerId).NotNull().WithMessage("CustomerId is required");
    }
}
UpdateOrderHandler (see Ordering.Application/Orders/Commands/UpdateOrder/UpdateOrderHandler.cs:2):
namespace Ordering.Application.Orders.Commands.UpdateOrder;

public class UpdateOrderHandler(IApplicationDbContext dbContext)
    : ICommandHandler<UpdateOrderCommand, UpdateOrderResult>
{
    public async Task<UpdateOrderResult> Handle(UpdateOrderCommand command, 
        CancellationToken cancellationToken)
    {
        var orderId = OrderId.Of(command.Order.Id);
        var order = await dbContext.Orders
            .FindAsync([orderId], cancellationToken: cancellationToken);

        if (order is null)
        {
            throw new OrderNotFoundException(command.Order.Id);
        }

        UpdateOrderWithNewValues(order, command.Order);

        dbContext.Orders.Update(order);
        await dbContext.SaveChangesAsync(cancellationToken);

        return new UpdateOrderResult(true);
    }

    public void UpdateOrderWithNewValues(Order order, OrderDto orderDto)
    {
        var updatedShippingAddress = Address.Of(orderDto.ShippingAddress.FirstName, 
            orderDto.ShippingAddress.LastName, orderDto.ShippingAddress.EmailAddress, 
            orderDto.ShippingAddress.AddressLine, orderDto.ShippingAddress.Country, 
            orderDto.ShippingAddress.State, orderDto.ShippingAddress.ZipCode);
            
        var updatedBillingAddress = Address.Of(orderDto.BillingAddress.FirstName, 
            orderDto.BillingAddress.LastName, orderDto.BillingAddress.EmailAddress, 
            orderDto.BillingAddress.AddressLine, orderDto.BillingAddress.Country, 
            orderDto.BillingAddress.State, orderDto.BillingAddress.ZipCode);
            
        var updatedPayment = Payment.Of(orderDto.Payment.CardName, 
            orderDto.Payment.CardNumber, orderDto.Payment.Expiration, 
            orderDto.Payment.Cvv, orderDto.Payment.PaymentMethod);

        order.Update(
            orderName: OrderName.Of(orderDto.OrderName),
            shippingAddress: updatedShippingAddress,
            billingAddress: updatedBillingAddress,
            payment: updatedPayment,
            status: orderDto.Status);
    }
}
Handler Flow:
  1. Retrieves order by ID
  2. Throws OrderNotFoundException if not found
  3. Maps DTO to value objects
  4. Calls order.Update() method (raises OrderUpdatedEvent)
  5. Persists changes
  6. Returns success result

DeleteOrderCommand

Deletes an order:
using FluentValidation;

namespace Ordering.Application.Orders.Commands.DeleteOrder;

public record DeleteOrderCommand(Guid OrderId)
    : ICommand<DeleteOrderResult>;

public record DeleteOrderResult(bool IsSuccess);

public class DeleteOrderCommandValidator : AbstractValidator<DeleteOrderCommand>
{
    public DeleteOrderCommandValidator()
    {
        RuleFor(x => x.OrderId).NotEmpty().WithMessage("OrderId is required");
    }
}
DeleteOrderHandler (see Ordering.Application/Orders/Commands/DeleteOrder/DeleteOrderHandler.cs:2):
namespace Ordering.Application.Orders.Commands.DeleteOrder;

public class DeleteOrderHandler(IApplicationDbContext dbContext)
    : ICommandHandler<DeleteOrderCommand, DeleteOrderResult>
{
    public async Task<DeleteOrderResult> Handle(DeleteOrderCommand command, 
        CancellationToken cancellationToken)
    {
        var orderId = OrderId.Of(command.OrderId);
        var order = await dbContext.Orders
            .FindAsync([orderId], cancellationToken: cancellationToken);

        if (order is null)
        {
            throw new OrderNotFoundException(command.OrderId);
        }

        dbContext.Orders.Remove(order);
        await dbContext.SaveChangesAsync(cancellationToken);

        return new DeleteOrderResult(true);
    }
}

Queries

Queries are read-only operations optimized for data retrieval.

GetOrdersQuery

Retrieves orders with pagination:
using BuildingBlocks.Pagination;

namespace Ordering.Application.Orders.Queries.GetOrders;

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

public record GetOrdersResult(PaginatedResult<OrderDto> Orders);
GetOrdersHandler (see Ordering.Application/Orders/Queries/GetOrders/GetOrdersHandler.cs:4):
using BuildingBlocks.Pagination;

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()));
    }
}
Features:
  • Pagination support with configurable page size
  • Includes related OrderItems
  • Sorted by order name
  • Maps entities to DTOs

GetOrdersByCustomerQuery

Retrieves all orders for a specific customer:
namespace Ordering.Application.Orders.Queries.GetOrdersByCustomer;

public record GetOrdersByCustomerQuery(Guid CustomerId) 
    : IQuery<GetOrdersByCustomerResult>;

public record GetOrdersByCustomerResult(IEnumerable<OrderDto> Orders);
GetOrdersByCustomerHandler (see Ordering.Application/Orders/Queries/GetOrdersByCustomer/GetOrdersByCustomerHandler.cs:2):
namespace Ordering.Application.Orders.Queries.GetOrdersByCustomer;

public class GetOrdersByCustomerHandler(IApplicationDbContext dbContext)
    : IQueryHandler<GetOrdersByCustomerQuery, GetOrdersByCustomerResult>
{
    public async Task<GetOrdersByCustomerResult> Handle(GetOrdersByCustomerQuery query, 
        CancellationToken cancellationToken)
    {
        var orders = await dbContext.Orders
            .Include(o => o.OrderItems)
            .AsNoTracking()
            .Where(o => o.CustomerId == CustomerId.Of(query.CustomerId))
            .OrderBy(o => o.OrderName.Value)
            .ToListAsync(cancellationToken);

        return new GetOrdersByCustomerResult(orders.ToOrderDtoList());
    }
}
Optimizations:
  • AsNoTracking() for read-only queries
  • Filtered by customer ID
  • Includes order items in single query

GetOrdersByNameQuery

Searches orders by name:
namespace Ordering.Application.Orders.Queries.GetOrdersByName;

public record GetOrdersByNameQuery(string Name)
    : IQuery<GetOrdersByNameResult>;

public record GetOrdersByNameResult(IEnumerable<OrderDto> Orders);
GetOrdersByNameHandler (see Ordering.Application/Orders/Queries/GetOrdersByName/GetOrdersByNameHandler.cs:2):
namespace Ordering.Application.Orders.Queries.GetOrdersByName;

public class GetOrdersByNameHandler(IApplicationDbContext dbContext)
    : IQueryHandler<GetOrdersByNameQuery, GetOrdersByNameResult>
{
    public async Task<GetOrdersByNameResult> Handle(GetOrdersByNameQuery query, 
        CancellationToken cancellationToken)
    {
        var orders = await dbContext.Orders
            .Include(o => o.OrderItems)
            .AsNoTracking()
            .Where(o => o.OrderName.Value.Contains(query.Name))
            .OrderBy(o => o.OrderName.Value)
            .ToListAsync(cancellationToken);

        return new GetOrdersByNameResult(orders.ToOrderDtoList());
    }
}
Features:
  • Partial text search using Contains()
  • No tracking for performance
  • Eager loading of order items

Event Handlers

Domain Event Handlers

Handle domain events raised by aggregates.

OrderCreatedEventHandler

Publishes integration event when order is created (see Ordering.Application/Orders/EventHandlers/Domain/OrderCreatedEventHandler.cs:5):
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);
        }
    }
}
Flow:
  1. Triggered automatically when order is saved
  2. Logs domain event
  3. Checks feature flag OrderFullfilment
  4. Publishes OrderDto as integration event via MassTransit
  5. Other services can consume this event

OrderUpdatedEventHandler

Logs when order is updated (see Ordering.Application/Orders/EventHandlers/Domain/OrderUpdatedEventHandler.cs:2):
namespace Ordering.Application.Orders.EventHandlers.Domain;

public class OrderUpdatedEventHandler(ILogger<OrderUpdatedEventHandler> logger)
    : INotificationHandler<OrderUpdatedEvent>
{
    public Task Handle(OrderUpdatedEvent notification, CancellationToken cancellationToken)
    {
        logger.LogInformation("Domain Event handled: {DomainEvent}", 
            notification.GetType().Name);
        return Task.CompletedTask;
    }
}

Integration Event Handlers

Consume events from other services.

BasketCheckoutEventHandler

Consumes basket checkout events from Basket service (see Ordering.Application/Orders/EventHandlers/Integration/BasketCheckoutEventHandler.cs:6):
using BuildingBlocks.Messaging.Events;
using MassTransit;
using Ordering.Application.Orders.Commands.CreateOrder;

namespace Ordering.Application.Orders.EventHandlers.Integration;

public class BasketCheckoutEventHandler(
    ISender sender, 
    ILogger<BasketCheckoutEventHandler> logger)
    : IConsumer<BasketCheckoutEvent>
{
    public async Task Consume(ConsumeContext<BasketCheckoutEvent> context)
    {
        logger.LogInformation("Integration Event handled: {IntegrationEvent}", 
            context.Message.GetType().Name);

        var command = MapToCreateOrderCommand(context.Message);
        await sender.Send(command);
    }

    private CreateOrderCommand MapToCreateOrderCommand(BasketCheckoutEvent message)
    {
        // Create full order with incoming event data
        var addressDto = new AddressDto(message.FirstName, message.LastName, 
            message.EmailAddress, message.AddressLine, message.Country, 
            message.State, message.ZipCode);
            
        var paymentDto = new PaymentDto(message.CardName, message.CardNumber, 
            message.Expiration, message.CVV, message.PaymentMethod);
            
        var orderId = Guid.NewGuid();

        var orderDto = new OrderDto(
            Id: orderId,
            CustomerId: message.CustomerId,
            OrderName: message.UserName,
            ShippingAddress: addressDto,
            BillingAddress: addressDto,
            Payment: paymentDto,
            Status: Ordering.Domain.Enums.OrderStatus.Pending,
            OrderItems:
            [
                new OrderItemDto(orderId, new Guid("5334c996-8457-4cf0-815c-ed2b77c4ff61"), 2, 500),
                new OrderItemDto(orderId, new Guid("c67d6323-e8b1-4bdf-9a75-b0d0d2e7e914"), 1, 400)
            ]);

        return new CreateOrderCommand(orderDto);
    }
}
Integration Flow:
  1. Customer checks out basket in Basket service
  2. Basket service publishes BasketCheckoutEvent to message broker
  3. BasketCheckoutEventHandler consumes event
  4. Maps event data to CreateOrderCommand
  5. Sends command via MediatR
  6. Order is created through normal command flow
Note: The order items are currently hardcoded as placeholder values. In production, basket items should be passed in the event.

Data Transfer Objects

OrderDto

using Ordering.Domain.Enums;

namespace Ordering.Application.Dtos;

public record OrderDto(
    Guid Id,
    Guid CustomerId,
    string OrderName,
    AddressDto ShippingAddress,
    AddressDto BillingAddress,
    PaymentDto Payment,
    OrderStatus Status,
    List<OrderItemDto> OrderItems);

OrderItemDto

namespace Ordering.Application.Dtos;

public record OrderItemDto(Guid OrderId, Guid ProductId, int Quantity, decimal Price);

AddressDto

namespace Ordering.Application.Dtos;

public record AddressDto(string FirstName, string LastName, string EmailAddress, 
    string AddressLine, string Country, string State, string ZipCode);

PaymentDto

namespace Ordering.Application.Dtos;

public record PaymentDto(string CardName, string CardNumber, string Expiration, 
    string Cvv, int PaymentMethod);
Design Notes:
  • DTOs are immutable records
  • Used for API contracts and event payloads
  • Separated from domain models to prevent leaking domain logic

Database Context Interface

namespace Ordering.Application.Data;

public interface IApplicationDbContext
{
    DbSet<Customer> Customers { get; }
    DbSet<Product> Products { get; }
    DbSet<Order> Orders { get; }
    DbSet<OrderItem> OrderItems { get; }
    
    Task<int> SaveChangesAsync(CancellationToken cancellationToken);
}
Benefits:
  • Application layer depends on abstraction, not EF Core
  • Enables unit testing with mock implementations
  • Follows Dependency Inversion Principle

Cross-Cutting Concerns

Validation Behavior

Automatically executes FluentValidation validators before command/query execution:
// Registered in DependencyInjection.cs
config.AddOpenBehavior(typeof(ValidationBehavior<,>));
Flow:
  1. Command/query enters pipeline
  2. ValidationBehavior executes validators
  3. Throws ValidationException if validation fails
  4. Handler executes only if validation passes

Logging Behavior

Logs all command/query requests and responses:
// Registered in DependencyInjection.cs
config.AddOpenBehavior(typeof(LoggingBehavior<,>));

CQRS Benefits

Separation of Concerns

  • Commands focus on state changes
  • Queries focus on data retrieval
  • Different optimization strategies for each

Scalability

  • Can scale read and write operations independently
  • Read queries can use denormalized views
  • Write operations ensure consistency

Testability

  • Handlers are simple, single-purpose classes
  • Easy to unit test in isolation
  • Clear dependencies through constructor injection

Maintainability

  • Each use case is a separate class
  • Easy to find and modify specific operations
  • New features don’t impact existing code

Integration Patterns

Event-Driven Communication

Published Events:
  • Order created (via OrderCreatedEventHandler)
  • Feature flag controlled
  • Async publish via MassTransit
Consumed Events:
  • BasketCheckoutEvent (via BasketCheckoutEventHandler)
  • Triggers order creation
  • Decoupled from Basket service

Transactional Outbox Pattern

Domain events are dispatched during the same transaction as database changes, ensuring:
  • Events are only published if database commit succeeds
  • No lost events due to failures
  • Consistency between state and events

Error Handling

Custom Exceptions

namespace Ordering.Application.Exceptions;

public class OrderNotFoundException : NotFoundException
{
    public OrderNotFoundException(Guid id) 
        : base("Order", id)
    {
    }
}

Validation Exceptions

FluentValidation throws ValidationException with detailed error messages.

Exception Handling Pipeline

  1. Validation exceptions return 400 Bad Request
  2. Not found exceptions return 404 Not Found
  3. Domain exceptions return 400 Bad Request
  4. Unhandled exceptions return 500 Internal Server Error

Best Practices

Command Handlers

  • Keep thin, delegate to domain models
  • Map DTOs to domain objects
  • Let aggregates enforce business rules
  • Return simple result objects

Query Handlers

  • Use AsNoTracking() for performance
  • Project to DTOs directly when possible
  • Include related entities to avoid N+1 queries
  • Consider caching for frequently accessed data

Event Handlers

  • Keep fast and lightweight
  • Don’t throw exceptions (events should be fire-and-forget)
  • Log errors instead of propagating
  • Consider eventual consistency

Next Steps

  • Domain Model - Understand the domain entities and value objects
  • API Reference - Explore the REST endpoints that trigger these commands and queries

Build docs developers (and LLMs) love