THE ARCHITECTURE LAB

Published

- 6 min read

3 DDD patterns you'll regret ignoring in 2 years

img of 3 DDD patterns you'll regret ignoring in 2 years
Reach 13,300+ engaged devs by sponsoring this newsletter

One of the most common mistakes I see when joining existing projects:

Developers put all business logic inside service classes.

But here is today’s insight for you:

Service classes should orchestrate, not decide.

Ok, that’s a fancy sentence. But it doesn’t help.

So, let me clear that up a bit.

Putting all business logic, and everything else, inside Handler/Service classes may look fine at first. Because you are moving fast and finishing features one after another.

But after a while, the problems appear:

  • Service classes become bigger and messier.
  • There is no way to write simple unit tests for the logic. Instead, you need to write integration tests, which are slow, or write unit tests that abuse mocking libraries harder than a junior dev abuses Console.WriteLine as their main logging strategy.
  • Side effects, such as sending emails, background jobs, HTTP, or messaging concerns, blend with the domain logic. This adds another level of complexity.

To combat those problems, I use some DDD (Domain Driven Design) patterns.

You are probably thinking now:

“Oh, great, so you tackle complexity by adding even more complexity with DDD? Thanks, but no thanks. I don’t need Aggregate Roots, Bounded Context, or any other of those fancy patterns that 50% of the dev community doesn’t understand, and the other 50% use, but don’t know why they use it.”

And immediately after that, move this email into the trash folder.

Well, not quite.

Yes, DDD is a complex topic.

And that complexity is better saved for large projects.

However, there are 3 DDD patterns that can help you untangle the mess you are starting to get drawn into.

They are:

  • Rich domain model - BENEFIT: push business logic to domain classes so you can test it without mocking
  • Specification pattern - BENEFIT: easily reuse and unit test filtering logic that contains business rules
  • Domain events - BENEFIT: decouple main processing from side effects (sending emails, background jobs), so the handler classes have fewer dependencies and are simpler

Let me show you some code examples, so it’s easier to understand their benefits.

Rich domain model

In the simplest terms, a rich domain model involves placing business logic that operates on data into classes that hold that data.

For example, if you have a service class that updates the invoice status:

   public async Task<Result> Execute(long invoiceId, string statusString, string userId)
{
    // Validate input status string
    if (!Enum.TryParse<InvoiceStatus>(statusString, true, out var newStatus))
    {
        return Result.Error($"Invalid invoice status: {statusString}");
    }

    // Check if the invoice exists and belongs to the user
    var invoice = await _dbContext.Set<Invoice>()
        .FirstOrDefaultAsync(x => x.Id == invoiceId && x.AppUserId == userId);

    if (invoice == null)
    {
        return Result.NotFound("Invoice not found");
    }

    // Allow staying in the same status (no-op)
    if (invoice.Status == newStatus)
    {
        return Result.Success();
    }

    // Validate status transition
    if (IsInvalidStatusTransition(invoice.Status, newStatus))
    {
        return Result.Error($"Cannot transition from {invoice.Status} to {newStatus}.");
    }

    // Update the invoice status
    invoice.Status = newStatus;
    await _dbContext.SaveChangesAsync();

    // Send email if the new status is sent
    if (newStatus == InvoiceStatus.Sent)
    {
        await _emailSender.SendInvoiceEmail(invoice.Customer.Email, invoice.InvoiceNumber.ToString());
    }

    return Result.Success();
}

You’ll notice that:

  • Business rules (the criteria for updating invoice status) are tangled between validation logic, DB querying, and email sending
  • To write tests, you need to figure out how to mock DBContext and use Moq/NSubstitute for IEmailSender

But when you push that status change logic to the Invoice class:

   public Result UpdateStatus(InvoiceStatus newStatus)
{
    // Allow staying in the same status (no-op)
    if (Status == newStatus)
    {
        return Result.Success();
    }

    // Validate status transition
    if (IsInvalidStatusTransition(Status, newStatus))
    {
        return Result.Error($"Cannot transition from {Status} to {newStatus}.");
    }

    // Update the invoice status
    Status = newStatus;
    return Result.Success();
}

The unit tests are stupidly simple to write:

   [Fact]
public void Status_change_is_changed_to_new_one_for_valid_transition()
{
    var invoice = new Invoice
    {
        Status = InvoiceStatus.Draft
    };

    var result = invoice.UpdateStatus(InvoiceStatus.Sent);

    result.IsSuccess.ShouldBeTrue();
    invoice.Status.ShouldBe(InvoiceStatus.Sent);
}

No need to mock anything. And if you ever need to reuse that logic, you can easily call it with invoice.UpdateStatus.

Rule of thumb: It’s okay to start with business logic in service classes. In fact, that’s the situation I run into when joining the latest project. But once the rules got more complicated, I started moving the logic into domain classes.

Specification pattern

The specification pattern is a way to put complex query logic in a separate class, so that you can easily reuse it and test it.

Here’s a code snippet to filter invoices by search term:

   var query = _dbContext
    .Set<Invoice>()
    .Where(x => x.AppUserId == userId);

if (!string.IsNullOrWhiteSpace(search))
{
    var searchLower = search.ToLower();
    query = query.Where(x =>
        x.InvoiceNumber.ToString().Contains(searchLower) ||
        x.Customer.Name.Contains(searchLower) ||
        x.Customer.Email.Contains(searchLower) ||
        x.Status.ToString().Contains(searchLower) ||
        x.Items.Any(i => i.Product.Name.Contains(searchLower)));
}

var totalCount = await query.CountAsync();

Again, the criteria for searching for invoices come from a business perspective. But it’s implemented in a way that’s intertwined with the DB query logic.

However, with the specification pattern, you put that business decision in a class that doesn’t depend on a DB context:

   public class InvoiceSearchSpecification: SpecificationPattern<Invoice>
{
    public InvoiceSearchSpecification(string search)
    {
        var searchLower = search.ToLower();
        Criteria = invoice =>
            invoice.InvoiceNumber.ToString().Contains(searchLower) ||
            invoice.Customer.Name.Contains(searchLower) ||
            invoice.Customer.Email.Contains(searchLower) ||
            invoice.Status.ToString().ToLower().Contains(searchLower) ||
            invoice.Items.Any(item => item.Product.Name.ToLower().Contains(searchLower));
    }
}

And now, unit test focuses on testing the business requirements:

   [Fact]
public void Invoice_that_has_matching_customer_name_is_included()
{
    var specification = new InvoiceSearchSpecification("john");

    var invoice = new Invoice
    {
        Customer = new Customer
        {
            Name = "John Doe",
            Email = "john@example.com"
        },
    };

    var result = specification.IsSatisfiedBy(invoice);

    result.ShouldBeTrue();
}

Domain events

Domain events are a way to express that something important has happened in the business domain.

For example, sending emails when some state changes.

Instead of mixing side effects (like sending an email) directly into your service methods, you raise an event and let other parts of the system react to it.

If you take a look again at the first code snippet of this email, the method ends with sending an email when the invoice status changes to Sent:

   // Send email if the new status is sent
if (newStatus == InvoiceStatus.Sent)
{
    await _emailSender.SendInvoiceEmail(invoice.Id);
}

Some drawbacks of this:

  • The service class has an additional dependency on IEmailSender
  • If you want to add another side effect (background job, sync to CRM, audit log…), the service method will continue to grow

But when you use domain events, you flip it around:

The Invoice class raises an InvoiceStatusSent event

   if (newStatus == InvoiceStatus.Sent)
{
    RaiseDomainEvent(new InvoiceSentEvent());
}

Event handlers listen for that event and perform side effects (send email, publish message, etc.)

   public class InvoiceSentEventHandler : IDomainEventHandler<InvoiceSentEvent>
{
    public Task Handle(InvoiceSentEvent domainEvent)
    {
        // Send email
    }
}

Your domain logic stays focused on what happened, not what to do about it

This separation means that:

  • New side effects are easier to add
  • Service classes don’t get additional dependencies
  • Unit tests for the domain just assert “event was raised”
  • Unit tests for handlers just assert “when event is handled, email is sent”

To recap, use these 3 DDD patterns:

  • Rich domain model - put rules where data lives, so it’s easier to reuse and test it.
  • Specification pattern - keep complex query criteria.
  • Domain events - separate side effects from the main processing logic.

This will keep your services less bloated, testing less painful, and making new changes easier.

If you're a .NET developer ready to architect scalable systems...

Every Friday I reveal insights with frameworks, tools & easy-to-implement strategies you can start using almost overnight.

Join the inner circle of 13,300+ .NET developers