With code examples, learn how to develop ASP.NET Core C# 12 using domain-driven design

Overview

As part of Domain-Driven Design (DDD), software should be modeled based on the business domain in order to achieve the most success. By focusing on the core domain logic, using a shared language (ubiquitous language), and organizing code around the domain's concepts, it aims to bridge the gap between complex business needs and software design.

For robust, maintainable, and scalable applications, DDD can be effectively implemented with C# 12 and ASP.NET Core. In this article, we'll dive into the key principles of Domain-Driven Design, examine its implementation in an ASP.NET Core application, and provide detailed code examples.

Key Principles of Domain-Driven Design

To gain a better understanding of DDD, let us briefly outline a few of its core principles.

  1. Domain: We must define a domain for your application. This is your application's core business logic.
  2. Entity: An entity is an object with a distinct identity, even when its attributes change.
  3. Value Object: The value of an object is determined by its attributes rather than by its unique identity.
  4. Aggregate: The aggregate concept refers to the clustering of entities and value objects into a single object.
  5. Repository: A repository provides an abstraction layer over data access, allowing aggregates to persist.
  6. Service: A service encapsulates domain logic that does not naturally fit within an entity or value object.
  7. Ubiquitous Language: A shared language between developers and business stakeholders, rooted in domain models.

Setting Up the ASP.NET Core Project

We will implement DDD concepts in an ASP.NET Core project.

Create the ASP.NET Core project

dotnet new webapi -n ZiggyRafiq.API 
cd ZiggyRafiq.API
  • Install necessary packages.
  • Add EF Core and other relevant packages.

Step-by-Step Implementation of DDD
 

1. Define the Domain Model

We'll begin by modeling a simple e-commerce domain that includes Orders and Customers.

Order.cs

namespace ZiggyRafiq.Domain.Models
{
    public record Order(Guid Id, DateTime OrderDate, Customer Customer, List<OrderItem> Items)
    {
        // Constructor with only customer as parameter
        public Order(Customer customer)
            : this(Guid.NewGuid(), DateTime.UtcNow, customer ?? throw new ArgumentNullException(nameof(customer)), new List<OrderItem>())
        {
        }

        // AddItem method for adding items
        public void AddItem(OrderItem item)
        {
            if (item == null) throw new ArgumentNullException(nameof(item));
            Items.Add(item);
        }

        // TotalAmount calculated property
        public decimal TotalAmount => Items.Sum(i => i.TotalPrice);
    }
}

OrderItem.cs

namespace ZiggyRafiq.Domain.Models
{
    public record OrderItem(Guid Id, string ProductName, decimal UnitPrice, int Quantity)
    {
        // Constructor for initializing without the Id
        public OrderItem(string productName, decimal unitPrice, int quantity)
            : this(Guid.NewGuid(), productName, unitPrice, quantity)
        {
        }

        // TotalPrice calculated property
        public decimal TotalPrice => UnitPrice * Quantity;
    }
}

Customer.cs

using ZiggyRafiq.Domain.ValueObjects;

namespace ZiggyRafiq.Domain.Models
{
    public record Customer(Guid Id, string Name, string Email)
    {
        public Address? Address { get; set; }

        public Customer(string name, string email, Address address)
            : this(Guid.NewGuid(), name ?? throw new ArgumentNullException(nameof(name)),
                  email ?? throw new ArgumentNullException(nameof(email)))
        {
            Address = address ?? throw new ArgumentNullException(nameof(address));
        }
    }
}

In this domain model,

  • There are multiple OrderItem entities in an Order, which is an aggregate root.
  • The customer is another entity associated with the order.

2. Create a Value Object

In a value object, concepts such as currency, dates, or complex types such as addresses are represented immutably.

Address.cs

namespace ZiggyRafiq.Domain.ValueObjects
{
    public record Address(string Street, string City, string PostalCode);
}

Address is a value object because it is defined by its attributes and does not have a unique identity.

3. Implement a Repository

The persistence and retrieval of aggregates is handled by repositories.

IOrderRepository.cs

using ZiggyRafiq.Domain.Models;

namespace ZiggyRafiq.Domain.Repository
{
    public interface IOrderRepository
    {
        Order GetById(Guid orderId);
        void Add(Order order);
        void Update(Order order);
        void Delete(Order order);
    }
}

OrderRepository.cs

using Microsoft.EntityFrameworkCore;
using ZiggyRafiq.Domain.Models;
using ZiggyRafiq.Domain.Repository;

namespace ZiggyRafiq.Infrastructure
{
    public class OrderRepository : IOrderRepository
    {
        private readonly DbEntities _context;

        public OrderRepository(DbEntities context)
        {
            _context = context;
        }

        public Order GetById(Guid orderId)
        {
            return _context.Orders
                .Include(o => o.Items)
                .Include(o => o.Customer)
                .SingleOrDefault(o => o.Id == orderId);
        }

        public void Add(Order order)
        {
            _context.Orders.Add(order);
            _context.SaveChanges();
        }

        public void Update(Order order)
        {
            _context.Orders.Update(order);
            _context.SaveChanges();
        }

        public void Delete(Order order)
        {
            _context.Orders.Remove(order);
            _context.SaveChanges();
        }
    }
}

For data access, OrderRepository uses Entity Framework Core, abstracting the persistence logic from the domain layer.

4. Define a Domain Service

The logic in domain services is not naturally suited to be contained in entities or value objects.

OrderService.cs

using MediatR;
using ZiggyRafiq.Domain.Events;
using ZiggyRafiq.Domain.Models;
using ZiggyRafiq.Domain.Repository;

namespace ZiggyRafiq.Domain.Services
{
    public class OrderService
    {
        private readonly IOrderRepository _orderRepository;
        private readonly IMediator _mediator;

        public OrderService(IOrderRepository orderRepository, IMediator mediator)
        {
            _orderRepository = orderRepository;
            _mediator = mediator;
        }

        public async Task PlaceOrder(Order order)
        {
            if (order.TotalAmount <= 0)
                throw new InvalidOperationException("Order total must be greater than zero.");

            _orderRepository.Add(order);
            await _mediator.Publish(new OrderPlacedEvent(order.Id));
        }

        public void CancelOrder(Guid orderId)
        {
            var order = _orderRepository.GetById(orderId);
            if (order == null)
                throw new InvalidOperationException("Order not found.");

            _orderRepository.Delete(order);
        }
    }
}

When placing or canceling an order, OrderService deals with complex business rules spanning multiple entities.

5. Integrate with ASP.NET Core

As a final step, let's wire everything up in an ASP.NET Core controller.

OrderController.cs

using ZiggyRafiq.Domain.ValueObjects;

namespace ZiggyRafiq.API.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class OrderController : ControllerBase
    {
        private readonly OrderService _orderService;

        public OrderController(OrderService orderService)
        {
            _orderService = orderService;
        }

        [HttpPost]
        public IActionResult PlaceOrder([FromBody] OrderDto orderDto)
        {
            var customer = new Customer(orderDto.CustomerName, orderDto.CustomerEmail, new Address("123 Street Name", "London", "SW1 1AB"));
            var order = new Order(customer);

            foreach (var item in orderDto.Items)
            {
                var orderItem = new OrderItem(item.ProductName, item.UnitPrice, item.Quantity);
                order.AddItem(orderItem);
            }

            _orderService.PlaceOrder(order);
            return Ok(order.Id);
        }

        [HttpDelete("{orderId}")]
        public IActionResult CancelOrder(Guid orderId)
        {
            _orderService.CancelOrder(orderId);
            return NoContent();
        }
    }
}

In this case, OrderController interacts with the domain through OrderService, adhering to DDD's principles of separating business logic from controllers.

OrderDto.cs

namespace ZiggyRafiq.API.Dtos
{
    public class OrderDto
    {
        public string CustomerName { get; set; } = string.Empty;
        public string CustomerEmail { get; set; } = string.Empty;
        public List<OrderItemDto> Items { get; set; }
    }
}

OrderItemDto

namespace ZiggyRafiq.API.Dtos
{
    public class OrderItemDto
    {
        public string ProductName { get; set; } = string.Empty;
        public decimal UnitPrice { get; set; }
        public int Quantity { get; set; }
    }
}

Let's continue by exploring how we can further integrate Domain-Driven Design into our ASP.NET Core application, focusing on aspects such as Infrastructure, Database Context, and Dependency Injection.

6. Define the Infrastructure Layer

A DDD-based application's infrastructure layer handles interactions with external systems, such as databases, messaging systems, and APIs. It also implements the repository interfaces defined in the domain layer.

Database Context

We'll use Entity Framework Core to handle data persistence. Define a DbEntities class to manage database operations.

DbEntities.cs

using Microsoft.EntityFrameworkCore;
using ZiggyRafiq.Domain.Models;

namespace ZiggyRafiq.Infrastructure
{
    public class DbEntities : DbContext
    {
        public DbSet<Order> Orders { get; set; }
        public DbSet<Customer> Customers { get; set; }

        public DbEntities(DbContextOptions<DbEntities> options)
            : base(options)
        {
        }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            // Configure domain models
            modelBuilder.Entity<Order>().HasKey(o => o.Id);
            modelBuilder.Entity<Order>().HasMany(o => o.Items);
            modelBuilder.Entity<Order>().OwnsOne(o => o.Customer);

            modelBuilder.Entity<OrderItem>().HasKey(oi => oi.Id);
            modelBuilder.Entity<Customer>().HasKey(c => c.Id);

            // Value object configuration
            modelBuilder.Entity<Customer>().OwnsOne(c => c.Address);
        }
    }
}

In this context, we've set up the basic configuration for Order, OrderItem, and Customer entities. Notice how OwnsOne is used to manage value objects, such as Address, which is embedded within the Customer entity.

Migrations

Using your domain model, create and apply migrations to the database schema using these commands:

7. Configure Dependency Injection

Registering and injecting services, repositories, and other dependencies with ASP.NET Core's Dependency Injection framework is easy.

Configure the services in Program.cs.

Program.cs (ASP.NET Core 6+)

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddControllers();

// Register MediatR services
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));

// Configure database context
builder.Services.AddDbContext<DbEntities>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("ZiggyRafiqConnection")));

// Register domain services
builder.Services.AddScoped<OrderService>();

// Register repositories
builder.Services.AddScoped<IOrderRepository, OrderRepository>();

// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();

Here’s what we’re doing.

  • DbContext Configuration: We configure the DbEntities to use SQL Server (as specified in the connection string).
  • Registering Services: The OrderService and IOrderRepository are registered using AddScoped, meaning a new instance is created per request.

8. Handling Domain Events

As part of DDD, domain events represent significant occurrences within the domain that other parts of the system may be interested in. For example, you might need to send a confirmation email after an order has been placed.

Defining a Domain Event

OrderPlacedEvent.cs

using MediatR;

namespace ZiggyRafiq.Domain.Events;

public class OrderPlacedEvent : INotification
{
    public Guid OrderId { get; }

    public OrderPlacedEvent(Guid orderId)
    {
        OrderId = orderId;
    }
}

In this example, OrderPlacedEvent is a domain event that is triggered when an order has been placed successfully.

Handle the Event

Create an event handler to respond to the domain event.

OrderPlacedEventHandler.cs

using MediatR;

namespace ZiggyRafiq.Domain.Events.Handlers
{
    public class OrderPlacedEventHandler : INotificationHandler<OrderPlacedEvent>
    {
        public Task Handle(OrderPlacedEvent notification, CancellationToken cancellationToken)
        {
            // Logic to handle the event, e.g., sending an email
            Console.WriteLine($"Order placed with ID: {notification.OrderId}");

            // For example, send a notification email to the customer
            // EmailService.SendOrderConfirmation(notification.OrderId);

            return Task.CompletedTask;
        }
    }
}

Register MediatR in Dependency Injection.

Update your Program.cs to include MediatR.

Program.cs

builder.Services.AddMediatR(cfg => 
    cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));

9. Testing the Domain Logic

The best way to test domain service and repository logic in DDD is to use either xUnit or NUnit together with a mocking framework like Moq.

The following is an example xUnit test for OrderService.

using ZiggyRafiq.Domain.ValueObjects;

namespace ZiggyRafiq.Tests
{
    public class OrderServiceTests
    {
        private readonly Mock<IOrderRepository> _orderRepositoryMock;
        private readonly Mock<IMediator> _mediatorMock;
        private readonly OrderService _orderService;

        public OrderServiceTests()
        {
            _orderRepositoryMock = new Mock<IOrderRepository>();
            _mediatorMock = new Mock<IMediator>();
            _orderService = new OrderService(_orderRepositoryMock.Object, _mediatorMock.Object);
        }

        [Fact]
        public async Task PlaceOrder_ShouldPublishOrderPlacedEvent()
        {
            // Arrange
            var customer = new Customer("Tom Jack", "[email protected]", new Address("123 Street Name", "London", "SW1 1AB"));
            var order = new Order(customer);
            order.AddItem(new OrderItem("Product A", 10, 2));

            // Act
            await _orderService.PlaceOrder(order);

            // Assert
            _orderRepositoryMock.Verify(r => r.Add(It.IsAny<Order>()), Times.Once);
            _mediatorMock.Verify(m => m.Publish(It.IsAny<OrderPlacedEvent>(), default), Times.Once);
        }
    }
}

As part of this test, we verify the behavior of your service layer when an order is placed by publishing the OrderPlacedEvent.

10. Putting It All Together

Having covered the core concepts and implementation details of DDD in ASP.NET Core with C# 12, here's a recap.

  1. Domain Modeling: Defined entities, value objects, aggregates, and domain services that represent the business logic.
  2. Repositories: Implemented repositories to handle data persistence, abstracting away the underlying data source.
  3. Dependency Injection: Leveraged ASP.NET Core’s DI framework to manage service lifetimes and dependencies.
  4. Domain Events: Introduced domain events and used MediatR to handle events in a decoupled manner.
  5. Testing: Ensured domain logic is correct and reliable through unit testing.

Summary

With C# 12, developers can build software that is closely aligned with the business domain by implementing Domain-Driven Design in ASP.NET Core applications. Maintaining, scaling, and adapting to changing business needs can be easier if you focus on the domain, use a shared language, and structure your application around domain concepts.

This article has covered the essentials of DDD, from defining domain models to handling domain events, and demonstrated how these principles can be implemented in a modern ASP.NET Core application. By applying these practices to your projects, you can create software that not only meets functional requirements but also embodies the underlying business logic in an understandable and maintainable way.

For the code examples in this article please check out my GitHub repository source code. If you enjoyed this article, please follow me on LinkedIn https://www.linkedin.com/in/ziggyrafiq/ and like it. Thank you for your support.


Capgemini
Capgemini is a global leader in consulting, technology services, and digital transformation.