When one of our major e-commerce clients asked us to improve both the scalability and maintainability of their .NET application, we knew it was time to shift gears from a traditional CRUD-based approach to CQRS (Command Query Responsibility Segregation) combined with Domain-Driven Design (DDD). This architectural pattern not only streamlines complex business logic but also provides a clear separation between write (commands) and read (queries) operations. In this post, I’ll share how we implemented CQRS and DDD using ASP.NET Core, EF Core, and Azure services—while keeping performance, maintainability, and security top of mind.
Why CQRS + DDD?
1. Scalability
By separating reads and writes, we can independently scale these operations. Reads often represent the bulk of traffic, so decoupling them from writes helps manage load more efficiently.
2. Maintainability
Domain-Driven Design enforces a clear separation of business logic into bounded contexts. This keeps the codebase organized and makes it easier for multiple teams to collaborate without stepping on each other’s toes.
3. Performance
Read operations can be optimized for quick lookups (even using distinct data stores if needed), while write operations focus on ensuring business rules are correctly enforced.
4. Security & Auditing
Separate read and write endpoints allow for more granular control over permissions. You can also maintain detailed audit logs for commands without cluttering your read services.
Designing the Domain
We started by identifying the core domains of our e-commerce system:
- Catalog – Manages products, categories, and inventory.
- Ordering – Handles orders, payments, and order status.
- Customer – Manages user profiles, addresses, and preferences.
Within each bounded context, we defined domain entities, value objects, and aggregates. For instance, in the Ordering context:
namespace ECommerce.Ordering.Domain
{
public class Order
{
public Guid OrderId { get; private set; }
public Guid CustomerId { get; private set; }
public DateTime OrderDate { get; private set; }
public decimal TotalAmount { get; private set; }
public OrderStatus Status { get; private set; }
private readonly List<OrderItem> _orderItems = new();
// Aggregate root constructor
public Order(Guid customerId)
{
OrderId = Guid.NewGuid();
CustomerId = customerId;
OrderDate = DateTime.UtcNow;
Status = OrderStatus.Pending;
}
public IReadOnlyCollection<OrderItem> OrderItems => _orderItems.AsReadOnly();
// Domain behavior
public void AddOrderItem(Guid productId, int quantity, decimal unitPrice)
{
_orderItems.Add(new OrderItem(productId, quantity, unitPrice));
TotalAmount += quantity * unitPrice;
}
public void ConfirmOrder()
{
if (Status != OrderStatus.Pending)
throw new InvalidOperationException("Only pending orders can be confirmed.");
Status = OrderStatus.Confirmed;
}
}
}
By modeling our entities around business needs (rather than database schemas), we ensured our code truly reflected the ubiquitous language shared with stakeholders.
Implementing CQRS
Command and Query Layers
We created two primary layers:
- Commands: Perform business logic, validate inputs, and modify the state.
- Queries: Fetch data for read operations, optimized for performance.
Using MediatR (a popular mediator library for .NET), we implemented a clear separation:
using MediatR;
namespace ECommerce.Ordering.Application.Commands
{
public record ConfirmOrderCommand(Guid OrderId) : IRequest<bool>;
public class ConfirmOrderHandler : IRequestHandler<ConfirmOrderCommand, bool>
{
private readonly IOrderRepository _orderRepository;
public ConfirmOrderHandler(IOrderRepository orderRepository)
{
_orderRepository = orderRepository;
}
public async Task<bool> Handle(ConfirmOrderCommand request, CancellationToken cancellationToken)
{
var order = await _orderRepository.GetOrderAsync(request.OrderId);
if (order is null) return false;
order.ConfirmOrder();
await _orderRepository.SaveChangesAsync();
return true;
}
}
}
ConfirmOrderHandler fetches the existing Order
aggregate, updates its status, and then persists changes via the repository.
For queries, we might have something like:
using MediatR;
namespace ECommerce.Ordering.Application.Queries
{
public record GetOrderQuery(Guid OrderId) : IRequest<OrderDto>;
public class GetOrderHandler : IRequestHandler<GetOrderQuery, OrderDto>
{
private readonly OrderingDbContext _dbContext;
public GetOrderHandler(OrderingDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task<OrderDto> Handle(GetOrderQuery request, CancellationToken cancellationToken)
{
var order = await _dbContext.Orders
.Where(o => o.OrderId == request.OrderId)
.Select(o => new OrderDto
{
OrderId = o.OrderId,
CustomerId = o.CustomerId,
OrderDate = o.OrderDate,
TotalAmount = o.TotalAmount,
Status = o.Status.ToString(),
Items = o.OrderItems.Select(i => new OrderItemDto
{
ProductId = i.ProductId,
Quantity = i.Quantity,
UnitPrice = i.UnitPrice
}).ToList()
})
.FirstOrDefaultAsync(cancellationToken);
return order;
}
}
}
Here, GetOrderHandler fetches data optimized for presentation, returning an OrderDto
without exposing domain entities.
Persisting Data with EF Core
We leveraged Entity Framework Core in each bounded context. Some key practices:
- Separate DbContext for each microservice or bounded context.
- Fluent Configuration to map entities, ensuring we adhere to our domain model without leaking EF-specific concerns.
- Migrations to handle schema changes over time.
public class OrderingDbContext : DbContext
{
public OrderingDbContext(DbContextOptions<OrderingDbContext> options)
: base(options)
{
}
public DbSet<Order> Orders { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Configure entity-to-table mapping here
modelBuilder.Entity<Order>(entity =>
{
entity.HasKey(o => o.OrderId);
entity.OwnsMany(o => o.OrderItems, i =>
{
i.WithOwner().HasForeignKey("OrderId");
});
});
}
}
Integrating with Azure
Azure SQL & Managed Identity
We hosted our OrderingDbContext in Azure SQL and used Managed Identities for secure, passwordless connections.
Azure Storage & Event Handling
For domain events (e.g., “OrderConfirmed”), we integrated Azure Service Bus to notify external services like “Billing” or “Shipping.” This ensured a loosely coupled system that could scale independently.
Continuous Integration & Deployment
We used Azure DevOps pipelines to build, test, and deploy our services:
trigger:
- main
pool:
vmImage: 'windows-latest'
steps:
- task: DotNetCoreCLI@2
inputs:
command: 'restore'
projects: '**/*.csproj'
- task: DotNetCoreCLI@2
inputs:
command: 'build'
projects: '**/*.csproj'
arguments: '--configuration Release'
- task: DotNetCoreCLI@2
inputs:
command: 'test'
projects: '**/*Tests.csproj'
arguments: '--configuration Release'
- task: AzureWebApp@1
inputs:
azureSubscription: 'MyAzureSubscription'
appName: 'OrderingService'
package: '$(System.DefaultWorkingDirectory)/**/*.zip'
Performance Tuning and Observability
- Query Optimization: We selectively denormalized read models to reduce expensive joins.
- Caching: Used Azure Cache for Redis to speed up frequently accessed read queries.
- Application Insights: Instrumented each service with logs, metrics, and distributed tracing.
- Load Testing: Ran Azure Load Testing scenarios to ensure our CQRS layers could handle peak demand.
SELECT TOP 5
SUBSTRING(st.TEXT, (qs.statement_start_offset/2)+1,
((CASE qs.statement_end_offset
WHEN -1 THEN DATALENGTH(st.TEXT)
ELSE qs.statement_end_offset
END - qs.statement_start_offset)/2)+1) AS [QueryText],
qs.total_elapsed_time / qs.execution_count AS [AvgTime]
FROM sys.dm_exec_query_stats qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) st
ORDER BY [AvgTime] DESC;
Using this query, we pinpointed slow queries and refined indexes accordingly.
Security Considerations
- Azure Key Vault for storing secrets and certificates.
- HTTPS/TLS enforcement across all endpoints.
- RBAC (Role-Based Access Control) for separating read/write permissions, especially critical in financial transactions.
- Regular penetration tests to ensure that endpoints handling commands are protected against common vulnerabilities like SQL injection or cross-site scripting.
Final Results
After six months of implementation and iterative refinements:
- Increased Throughput: Separating reads and writes improved performance by ~30% under peak load.
- Simplified Codebase: Teams found it easier to locate and modify logic within specific domains, avoiding monolithic code smells.
- Faster Feature Delivery: New features were shipped 40% faster due to clear domain boundaries and streamlined CI/CD pipelines.
- Better Developer Experience: Junior devs could onboard quickly, thanks to the clear, modular architecture.
Key Takeaways
- Domain-Driven Design fosters clarity by modeling your software around the business domain.
- CQRS provides a robust way to scale, optimize, and secure your application by splitting reads and writes.
- Azure Services offer powerful tooling for CI/CD, data storage, and messaging that seamlessly integrate with .NET.
- Performance and Observability require constant vigilance—keep tuning queries, adding caches, and monitoring everything.
Looking Ahead
Our next steps include:
- Exploring Event Sourcing for audit trails and full history of state changes.
- Integrating Azure Functions for lightweight triggers that react to domain events.
- Leveraging .NET 8 features that could further reduce boilerplate and enhance performance.
- Machine Learning integrations to predict product demand and optimize inventory in near real-time.
Modernizing .NET applications is not just about adopting new libraries—it’s about embracing architectural patterns like CQRS and DDD that lay a solid foundation for future growth. If you’re feeling the limits of traditional CRUD, consider implementing these patterns to unlock scalability, maintainability, and a streamlined development workflow.
Until next time, keep exploring, keep innovating, and keep building solutions that empower your business to move faster and more confidently in the digital world.