👉🏼 Click here to Join I ❤️ .NET WhatsApp Channel to get 🔔 notified about new articles and other updates.
Thin Application Services in DDD with .NET

Thin Application Services in DDD with .NET

Author - Abdul Rahman (Bhai)

DDD

22 Articles

Improve

Table of Contents

  1. What we gonna do?
  2. Why we gonna do?
  3. How we gonna do?
  4. Summary

What we gonna do?

Here's a pattern that kills DDD projects quietly and slowly: the fat service. Business logic piling up in service methods. Validation scattered everywhere. Domain rules duplicated across a dozen handlers. Tests that require fifteen mocks to run a single assertion.

In this article, we'll explore Thin Application Services in Domain-Driven Design - the coordination layer that orchestrates domain work without making any decisions itself. We'll look at exactly what an application service does, what it absolutely must not do, and how to enforce a clean boundary between orchestration and business logic.

Why we gonna do?

The Fat Service Problem

Fat application services start small and grow until they become the actual domain. Here is what that looks like:


// ❌ Fat service — doing way too much
public class LicenseService
{
    private readonly ILicenseRepository _licenseRepo;
    private readonly ICustomerRepository _customerRepo;
    private readonly IEmailService _emailService;

    public async Task ActivateLicenseAsync(Guid rawLicenseId, string hardwareId)
    {
        // ❌ Validation logic duplicated across many methods
        if (string.IsNullOrWhiteSpace(hardwareId))
            throw new ArgumentException("Hardware ID is required.");

        var licenseId = new LicenseId(rawLicenseId);
        var license = await _licenseRepo.FindByIdAsync(licenseId)
            ?? throw new NotFoundException("License not found.");

        // ❌ Business rules living in the service
        if (license.ExpiresAt < DateOnly.FromDateTime(DateTime.UtcNow))
            throw new InvalidOperationException("License is expired.");

        if (license.ActivationCount >= license.MaxActivations)
            throw new InvalidOperationException("Activation limit reached.");

        // ❌ Mutation of internals from outside the aggregate
        license.ActivationCount++;
        await _licenseRepo.SaveAsync(license);

        // ❌ Infrastructure wired directly in
        var customer = await _customerRepo.FindByIdAsync(license.CustomerId)
            ?? throw new NotFoundException("Customer not found.");

        await _emailService.SendActivationEmailAsync(customer.Email);
    }
}

This service has crippling problems. Business logic is scattered across service methods instead of living in the domain. The service knows too much about domain internals. Any domain change forces service changes. And tests need mocks for every single dependency.

The Separation of Concerns Table

Before writing any code, it helps to have a clear mental model of what belongs where. Keep coming back to this table whenever you are unsure:


Layer                   Responsibility
─────────────────────────────────────────────────────────────
Application Service     Orchestration, load aggregates,
                        save aggregates, publish domain events

Domain                  Business rules, validation and invariants,
                        calculations, state changes

Infrastructure          Database and ORM, email and SMS,
                        external APIs, message brokers
─────────────────────────────────────────────────────────────

If you find business logic in your application service, it belongs in the domain. If you find infrastructure calls directly in your application service (other than publishing events), they belong in event handlers.

How we gonna do?

Step 1: The Five-Step Application Service Pattern

Every application service method follows the same five steps. Memorise this pattern and let it govern every method you write at this layer:


// ✅ Thin application service — orchestrates only
public class LicenseApplicationService
{
    private readonly ILicenseRepository _licenseRepository;
    private readonly IDomainEventPublisher _eventPublisher;

    public LicenseApplicationService(
        ILicenseRepository licenseRepository,
        IDomainEventPublisher eventPublisher)
    {
        _licenseRepository = licenseRepository;
        _eventPublisher = eventPublisher;
    }

    public async Task ActivateLicenseAsync(
        LicenseId licenseId,
        HardwareFingerprint hardware,
        CancellationToken cancellationToken = default)
    {
        // Step 1: Load the aggregate
        var license = await _licenseRepository.FindByIdAsync(
            licenseId, cancellationToken)
            ?? throw new LicenseNotFoundException(licenseId);

        // Step 2: Execute domain logic — the domain decides everything
        license.Activate(hardware);

        // Step 3: Save the aggregate
        await _licenseRepository.SaveAsync(license, cancellationToken);

        // Step 4: Publish domain events
        await _eventPublisher.PublishAllAsync(
            license.DomainEvents, cancellationToken);

        // Step 5: Clear events to prevent accidental re-publication
        license.ClearDomainEvents();
    }
}

The service does not validate hardware IDs, does not check expiry dates, does not send emails. All of that is handled by the domain and by infrastructure event handlers. The service only coordinates.

Step 2: One Method Per Transaction

Each application service method should define a single transaction boundary. This keeps transactions small, focused, and easy to reason about:


public class LicenseApplicationService
{
    private readonly ILicenseRepository _licenseRepository;
    private readonly IDomainEventPublisher _eventPublisher;

    public LicenseApplicationService(
        ILicenseRepository licenseRepository,
        IDomainEventPublisher eventPublisher)
    {
        _licenseRepository = licenseRepository;
        _eventPublisher = eventPublisher;
    }

    // Each method = one transaction boundary
    public async Task ActivateLicenseAsync(
        LicenseId licenseId,
        HardwareFingerprint hardware,
        CancellationToken cancellationToken = default)
    {
        var license = await _licenseRepository.FindByIdAsync(
            licenseId, cancellationToken)
            ?? throw new LicenseNotFoundException(licenseId);

        license.Activate(hardware);

        await _licenseRepository.SaveAsync(license, cancellationToken);
        await _eventPublisher.PublishAllAsync(license.DomainEvents, cancellationToken);
        license.ClearDomainEvents();
    }

    // Another transaction — one responsibility, one boundary
    public async Task SuspendLicenseAsync(
        LicenseId licenseId,
        string reason,
        CancellationToken cancellationToken = default)
    {
        var license = await _licenseRepository.FindByIdAsync(
            licenseId, cancellationToken)
            ?? throw new LicenseNotFoundException(licenseId);

        license.Suspend(reason);

        await _licenseRepository.SaveAsync(license, cancellationToken);
        await _eventPublisher.PublishAllAsync(license.DomainEvents, cancellationToken);
        license.ClearDomainEvents();
    }

    // Another transaction — transfers are their own atomic operation
    public async Task TransferLicenseAsync(
        LicenseId licenseId,
        CustomerId newCustomerId,
        CancellationToken cancellationToken = default)
    {
        var license = await _licenseRepository.FindByIdAsync(
            licenseId, cancellationToken)
            ?? throw new LicenseNotFoundException(licenseId);

        license.TransferTo(newCustomerId);

        await _licenseRepository.SaveAsync(license, cancellationToken);
        await _eventPublisher.PublishAllAsync(license.DomainEvents, cancellationToken);
        license.ClearDomainEvents();
    }
}

Step 3: Integrating Transaction Management

In ASP.NET Core, you can use a unit of work pattern or a scoped DbContext to manage transactions cleanly. The application service drives the transaction; infrastructure handles the mechanics:


// Using a simple unit of work for transaction management
public interface IUnitOfWork
{
    Task CommitAsync(CancellationToken cancellationToken = default);
}

public class LicenseApplicationService
{
    private readonly ILicenseRepository _licenseRepository;
    private readonly IDomainEventPublisher _eventPublisher;
    private readonly IUnitOfWork _unitOfWork;

    public LicenseApplicationService(
        ILicenseRepository licenseRepository,
        IDomainEventPublisher eventPublisher,
        IUnitOfWork unitOfWork)
    {
        _licenseRepository = licenseRepository;
        _eventPublisher = eventPublisher;
        _unitOfWork = unitOfWork;
    }

    public async Task ActivateLicenseAsync(
        LicenseId licenseId,
        HardwareFingerprint hardware,
        CancellationToken cancellationToken = default)
    {
        var license = await _licenseRepository.FindByIdAsync(
            licenseId, cancellationToken)
            ?? throw new LicenseNotFoundException(licenseId);

        license.Activate(hardware);

        await _licenseRepository.SaveAsync(license, cancellationToken);

        // Commit before publishing — events fire only on success
        await _unitOfWork.CommitAsync(cancellationToken);

        await _eventPublisher.PublishAllAsync(license.DomainEvents, cancellationToken);
        license.ClearDomainEvents();
    }
}

Step 4: Registering Services in ASP.NET Core

Wire up the application service and its dependencies using .NET's built-in dependency injection. The domain interface is resolved to the infrastructure implementation:


// Program.cs — register interfaces against concrete implementations
builder.Services.AddScoped<ILicenseRepository, EfCoreLicenseRepository>();
builder.Services.AddScoped<IDomainEventPublisher, InProcessDomainEventPublisher>();
builder.Services.AddScoped<IUnitOfWork, EfCoreUnitOfWork>();
builder.Services.AddScoped<LicenseApplicationService>();

The presentation layer receives LicenseApplicationService via constructor injection and calls its methods directly. It never reaches into the domain or calls repositories.

What Application Services Must Never Do

Every time you are tempted to write logic in an application service, ask yourself: is this a business rule or is this coordination? Here is a concrete checklist:


// ❌ Business rules — these belong in the domain
if (license.ActivationCount >= license.MaxActivations)
    throw new ActivationLimitReachedException(license.Id);

// ❌ Calculations — belong in the domain
var tax = license.BasePrice * 0.2m;

// ❌ Validation logic — belongs in the domain or a dedicated validator
if (hardware.Value.Length < 8 || hardware.Value.Length > 64)
    throw new InvalidHardwareFingerprintException(hardware);

// ❌ Direct infrastructure calls (bypassing the event system)
await _emailService.SendEmailAsync(customer.Email);  // Use an event handler instead

// ✅ What application services DO — pure coordination
var license = await _licenseRepository.FindByIdAsync(
    licenseId, cancellationToken)
    ?? throw new LicenseNotFoundException(licenseId);
license.Activate(hardware);
await _licenseRepository.SaveAsync(license, cancellationToken);
await _unitOfWork.CommitAsync(cancellationToken);
await _eventPublisher.PublishAllAsync(license.DomainEvents, cancellationToken);
license.ClearDomainEvents();

Summary

In this article, we explored Thin Application Services in DDD and how to keep the coordination layer clean in .NET. Here's what matters:

  • Application services coordinate — they do not decide. All business rules, validations, and calculations live in the domain. The application service is an orchestrator, not a decision-maker.
  • Five-step pattern: Load aggregate → Execute domain logic → Save aggregate → Publish events → Clear events. Apply this consistently to every application service method.
  • One method per transaction. Each application service method defines a single transaction boundary, keeping operations focused and easy to reason about.
  • Use the separation of concerns table as a guide. Application services orchestrate. Domain enforces. Infrastructure reacts. When you are unsure where logic belongs, this table has your answer.
  • Thin services make testing effortless. Business logic in the domain can be tested without mocks. Orchestration logic in the service can be tested with simple stub repositories.
👉🏼 Click here to Join I ❤️ .NET WhatsApp Channel to get 🔔 notified about new articles and other updates.
  • DDD
  • Domain-Driven Design
  • DDD
  • Application Services
  • Coordination Layer
  • Transaction Boundary
  • Separation of Concerns
  • .NET
  • C#
  • Clean Architecture
  • Domain Orchestration