Table of Contents
I’ve lost count of how many times I’ve seen developers struggle with this question: “Should this logic go in the Domain layer or the Application layer?” Clean Architecture diagrams make it look simple, but when you’re staring at your actual codebase, the lines blur fast.
You know the situation. You’ve got a business rule to implement. You open your project, see both folders, and pause. Put it in the wrong place, and six months later you’re wrestling with a mess of tangled dependencies and logic that’s impossible to test.
This post cuts through the theory and shows you exactly where your code belongs, using real C# examples from production applications.
Clean Architecture Structure (The Real Version)
Here’s the structure I use in production ASP.NET Core apps:
src/
Domain/
Entities/
ValueObjects/
Events/
Exceptions/
Application/
Commands/
Queries/
Interfaces/
Behaviors/
Infrastructure/
Persistence/
Services/
Web/
Controllers/
Middleware/
The dependency rule is simple: inner layers don’t know about outer layers. Domain knows nothing about Application. Application knows nothing about Infrastructure. That’s not theory, that’s how you keep code maintainable.
graph TD
A[Web/API Layer] --> B[Application Layer]
B --> C[Domain Layer]
A --> D[Infrastructure Layer]
D --> B
D -.implements.-> B
style C fill:#e1f5e1
style B fill:#e3f2fd
style D fill:#fff3e0
style A fill:#fce4ec
What the Domain Layer Actually Owns
The Domain layer is where your business lives. Not your application’s workflows, not your database structure. The actual business behavior and rules that would exist even if you switched from SQL Server to MongoDB tomorrow.
Core Responsibilities
Entities and Aggregates: Objects that have identity and enforce invariants.
Value Objects: Immutable objects defined by their attributes (Money, Email, Address).
Domain Events: Things that happened in your business that other parts care about.
Business Rules: The logic that makes your domain unique.
Real Code Example
Here’s an Order entity from a multi-tenant e-commerce system:
public class Order : Entity<Guid>
{
private readonly List<OrderItem> _items = new();
private OrderStatus _status;
public Guid CustomerId { get; private set; }
public Guid TenantId { get; private set; }
public IReadOnlyCollection<OrderItem> Items => _items.AsReadOnly();
public Money Total => new Money(_items.Sum(i => i.Subtotal.Amount));
public OrderStatus Status => _status;
private Order() { } // EF Core
public static Order Create(Guid customerId, Guid tenantId)
{
var order = new Order
{
Id = Guid.NewGuid(),
CustomerId = customerId,
TenantId = tenantId,
_status = OrderStatus.Draft
};
order.AddDomainEvent(new OrderCreatedEvent(order.Id, customerId, tenantId));
return order;
}
public void AddItem(Guid productId, int quantity, Money unitPrice)
{
if (quantity <= 0)
throw new DomainException("Quantity must be positive.");
if (_status != OrderStatus.Draft)
throw new DomainException("Cannot modify submitted orders.");
var existingItem = _items.FirstOrDefault(i => i.ProductId == productId);
if (existingItem != null)
{
existingItem.UpdateQuantity(existingItem.Quantity + quantity);
}
else
{
_items.Add(new OrderItem(productId, quantity, unitPrice));
}
}
public void Submit()
{
if (!_items.Any())
throw new DomainException("Cannot submit empty order.");
if (_status != OrderStatus.Draft)
throw new DomainException("Order already submitted.");
_status = OrderStatus.Submitted;
AddDomainEvent(new OrderSubmittedEvent(Id, Total));
}
}
Notice what’s missing: no async, no DbContext, no HTTP concerns. This is pure business behavior. The Submit() method doesn’t save to the database. It changes state and raises an event. That’s it.
Personal Note: I’ve seen teams put database calls directly in entities. It feels convenient at first, but you pay for it later when you need to test business logic without spinning up a database. Keep your domain pure.
What the Application Layer Actually Does
The Application layer orchestrates use cases. It’s the conductor of your business logic symphony, not the musician. It coordinates domain objects, manages transactions, and defines the boundaries of what your system can do.
Core Responsibilities
Use Case Orchestration: Commands and Queries that represent what users can do.
Transaction Boundaries: When to save, when to rollback.
Cross-Cutting Concerns: Validation, logging, authorization.
Interface Definitions: Repository contracts, external service contracts.
Real Code Example
Here’s the command handler for creating an order:
public record CreateOrderCommand : IRequest<Result<Guid>>
{
public Guid CustomerId { get; init; }
public Guid TenantId { get; init; }
public List<OrderItemDto> Items { get; init; } = new();
}
public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, Result<Guid>>
{
private readonly IOrderRepository _orders;
private readonly IProductRepository _products;
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<CreateOrderCommandHandler> _logger;
public CreateOrderCommandHandler(
IOrderRepository orders,
IProductRepository products,
IUnitOfWork unitOfWork,
ILogger<CreateOrderCommandHandler> logger)
{
_orders = orders;
_products = products;
_unitOfWork = unitOfWork;
_logger = logger;
}
public async Task<Result<Guid>> Handle(
CreateOrderCommand request,
CancellationToken cancellationToken)
{
var order = Order.Create(request.CustomerId, request.TenantId);
foreach (var itemDto in request.Items)
{
var product = await _products.GetByIdAsync(
itemDto.ProductId,
cancellationToken);
if (product == null)
return Result<Guid>.Failure($"Product {itemDto.ProductId} not found.");
order.AddItem(product.Id, itemDto.Quantity, product.Price);
}
await _orders.AddAsync(order, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);
_logger.LogInformation(
"Order {OrderId} created for customer {CustomerId}",
order.Id,
request.CustomerId);
return Result<Guid>.Success(order.Id);
}
}
The handler doesn’t contain business rules. It fetches products, calls domain methods, and saves. The actual rule about positive quantities? That’s in the Order.AddItem() method where it belongs.
The Line Between Layers: A Decision Matrix
| Concern | Domain Layer | Application Layer |
|---|---|---|
| Validating quantity > 0 | ✓ (Business invariant) | |
| Checking if product exists | ✓ (Infrastructure concern) | |
| Calculating order total | ✓ (Business rule) | |
| Managing transaction scope | ✓ (Use case concern) | |
| Raising OrderCreated event | ✓ (Domain event) | |
| Handling OrderCreated event | ✓ (Side effect coordination) | |
| Defining IOrderRepository | ✓ (Application contract) | |
| Implementing IOrderRepository | Infrastructure |
Common Mistakes I See Repeatedly
Mistake 1: Business Logic in Application Layer
Wrong:
public async Task<Result> Handle(SubmitOrderCommand request, CancellationToken ct)
{
var order = await _orders.GetByIdAsync(request.OrderId);
// This business rule belongs in the domain!
if (order.Items.Count == 0)
return Result.Failure("Cannot submit empty order.");
order.Status = OrderStatus.Submitted;
await _unitOfWork.SaveChangesAsync(ct);
return Result.Success();
}
Correct:
public async Task<Result> Handle(SubmitOrderCommand request, CancellationToken ct)
{
var order = await _orders.GetByIdAsync(request.OrderId);
// Domain enforces its own rules
order.Submit();
await _unitOfWork.SaveChangesAsync(ct);
return Result.Success();
}
Mistake 2: Infrastructure in Domain
Wrong:
public class Order : Entity<Guid>
{
public async Task<bool> CanShipToAddress(string zipCode)
{
// Domain entity shouldn't call external services!
var shippingService = new ShippingService();
return await shippingService.IsServiceableAsync(zipCode);
}
}
Correct:
// Domain
public class Order : Entity<Guid>
{
public void SetShippingAddress(Address address)
{
if (!address.IsComplete())
throw new DomainException("Incomplete address.");
ShippingAddress = address;
}
}
// Application
public class SetShippingAddressHandler : IRequestHandler<SetShippingAddressCommand>
{
private readonly IShippingService _shippingService;
public async Task<Result> Handle(SetShippingAddressCommand cmd, CancellationToken ct)
{
var address = new Address(cmd.Street, cmd.City, cmd.ZipCode);
// Application coordinates infrastructure check
if (!await _shippingService.IsServiceableAsync(address.ZipCode))
return Result.Failure("We don't ship to this area.");
var order = await _orders.GetByIdAsync(cmd.OrderId);
order.SetShippingAddress(address);
await _unitOfWork.SaveChangesAsync(ct);
return Result.Success();
}
}
Mistake 3: Async Domain Methods
Wrong:
public class Order : Entity<Guid>
{
public async Task AddItemAsync(Guid productId, int quantity)
{
var product = await _productRepository.GetByIdAsync(productId);
_items.Add(new OrderItem(product.Id, quantity, product.Price));
}
}
Domain entities should never be async. They should never touch the database. If you need data, fetch it in the Application layer and pass it to the domain method.
Real-World Example: Feature Flag System
I built a multi-tenant feature flag system recently. Here’s how the layers split:
Domain Layer:
public class FeatureFlag : AggregateRoot<Guid>
{
private readonly List<TenantOverride> _overrides = new();
public string Key { get; private set; }
public bool DefaultValue { get; private set; }
public IReadOnlyCollection<TenantOverride> Overrides => _overrides.AsReadOnly();
public void EnableForTenant(Guid tenantId)
{
if (_overrides.Any(o => o.TenantId == tenantId))
throw new DomainException("Override already exists for this tenant.");
_overrides.Add(new TenantOverride(tenantId, true));
AddDomainEvent(new FeatureFlagEnabledEvent(Id, tenantId));
}
public bool IsEnabledFor(Guid tenantId)
{
var tenantOverride = _overrides.FirstOrDefault(o => o.TenantId == tenantId);
return tenantOverride?.IsEnabled ?? DefaultValue;
}
}
Application Layer:
public record EnableFeatureForTenantCommand : IRequest<Result>
{
public Guid FeatureFlagId { get; init; }
public Guid TenantId { get; init; }
}
public class EnableFeatureForTenantHandler
: IRequestHandler<EnableFeatureForTenantCommand, Result>
{
private readonly IFeatureFlagRepository _flags;
private readonly ITenantRepository _tenants;
private readonly IUnitOfWork _unitOfWork;
public async Task<Result> Handle(
EnableFeatureForTenantCommand request,
CancellationToken ct)
{
var tenant = await _tenants.GetByIdAsync(request.TenantId, ct);
if (tenant == null)
return Result.Failure("Tenant not found.");
var flag = await _flags.GetByIdAsync(request.FeatureFlagId, ct);
if (flag == null)
return Result.Failure("Feature flag not found.");
flag.EnableForTenant(request.TenantId);
await _unitOfWork.SaveChangesAsync(ct);
return Result.Success();
}
}
The Domain defines what it means to enable a feature for a tenant. The Application coordinates the workflow: check tenant exists, fetch the flag, call the domain method, save.
How Layers Interact in Practice
sequenceDiagram
participant C as Controller
participant H as Command Handler
participant D as Domain Entity
participant R as Repository
participant DB as Database
C->>H: CreateOrderCommand
H->>R: GetProductAsync()
R->>DB: Query
DB-->>R: Product
R-->>H: Product
H->>D: Order.Create()
D->>D: Validate rules
D-->>H: Order instance
H->>D: order.AddItem()
D->>D: Enforce invariants
D-->>H: Updated order
H->>R: AddAsync(order)
H->>R: SaveChangesAsync()
R->>DB: Insert
DB-->>R: Success
R-->>H: Success
H-->>C: Result<Guid>
Signs You’re Mixing Layers
Watch for these red flags in code reviews:
Application Layer Issues:
- Direct use of
DbContextinstead of repositories - Business rules in command handlers
- Domain events handled in the same handler that raises them
Domain Layer Issues:
- Methods with
async Tasksignatures - Constructor injection of infrastructure services
- References to
ILogger,IConfiguration, or any framework types
Quick Test: Can you test your domain logic without starting a web server or database? If not, you’ve leaked infrastructure into your domain.
Practical Guidelines for Staying Clean
1. Repository Pattern Done Right
Define interfaces in Application, implement in Infrastructure:
// Application/Interfaces/IOrderRepository.cs
public interface IOrderRepository
{
Task<Order?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<List<Order>> GetByCustomerAsync(Guid customerId, CancellationToken ct = default);
Task AddAsync(Order order, CancellationToken ct = default);
}
// Infrastructure/Persistence/OrderRepository.cs
public class OrderRepository : IOrderRepository
{
private readonly AppDbContext _context;
public async Task<Order?> GetByIdAsync(Guid id, CancellationToken ct = default)
{
return await _context.Orders
.Include(o => o.Items)
.FirstOrDefaultAsync(o => o.Id == id, ct);
}
// Implementation details...
}
2. Validation Split
Input validation in Application, business invariants in Domain:
// Application: Check command structure
public class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
public CreateOrderCommandValidator()
{
RuleFor(x => x.CustomerId).NotEmpty();
RuleFor(x => x.Items).NotEmpty();
RuleFor(x => x.Items).Must(items => items.All(i => i.Quantity > 0))
.WithMessage("All quantities must be positive.");
}
}
// Domain: Enforce business rules
public void AddItem(Guid productId, int quantity, Money unitPrice)
{
if (_status != OrderStatus.Draft)
throw new DomainException("Cannot modify submitted orders.");
// Business rule enforcement
}
3. Domain Events for Side Effects
Raise events in Domain, handle them in Application:
// Domain
public void Submit()
{
// ... validation ...
_status = OrderStatus.Submitted;
AddDomainEvent(new OrderSubmittedEvent(Id, CustomerId, Total));
}
// Application
public class OrderSubmittedEventHandler : INotificationHandler<OrderSubmittedEvent>
{
private readonly IEmailService _emailService;
private readonly ICustomerRepository _customers;
public async Task Handle(OrderSubmittedEvent notification, CancellationToken ct)
{
var customer = await _customers.GetByIdAsync(notification.CustomerId, ct);
await _emailService.SendOrderConfirmationAsync(
customer.Email,
notification.OrderId);
}
}
From Experience: I used to handle domain events in the same transaction as the command. Big mistake. If email sending fails, the whole transaction rolls back. Now I publish events after the transaction commits, using an outbox pattern for reliability.
Testing Strategy
Your architecture is only as good as your ability to test it:
Domain Tests (Fast, No Dependencies):
[Fact]
public void AddItem_WithNegativeQuantity_ThrowsDomainException()
{
// Arrange
var order = Order.Create(Guid.NewGuid(), Guid.NewGuid());
// Act & Assert
var exception = Assert.Throws<DomainException>(
() => order.AddItem(Guid.NewGuid(), -1, new Money(10m)));
Assert.Equal("Quantity must be positive.", exception.Message);
}
Application Tests (With Mocks):
[Fact]
public async Task Handle_ValidCommand_CreatesOrder()
{
// Arrange
var mockOrders = new Mock<IOrderRepository>();
var mockProducts = new Mock<IProductRepository>();
var mockUnitOfWork = new Mock<IUnitOfWork>();
var product = Product.Create("Test Product", new Money(25m));
mockProducts.Setup(x => x.GetByIdAsync(It.IsAny<Guid>(), default))
.ReturnsAsync(product);
var handler = new CreateOrderCommandHandler(
mockOrders.Object,
mockProducts.Object,
mockUnitOfWork.Object,
Mock.Of<ILogger<CreateOrderCommandHandler>>());
var command = new CreateOrderCommand
{
CustomerId = Guid.NewGuid(),
TenantId = Guid.NewGuid(),
Items = new List<OrderItemDto>
{
new() { ProductId = product.Id, Quantity = 2 }
}
};
// Act
var result = await handler.Handle(command, default);
// Assert
Assert.True(result.IsSuccess);
mockOrders.Verify(x => x.AddAsync(It.IsAny<Order>(), default), Times.Once);
mockUnitOfWork.Verify(x => x.SaveChangesAsync(default), Times.Once);
}
Summary
The distinction boils down to this:
Domain Layer = What your system IS
- Business entities and their behavior
- Rules that define your domain
- Pure, testable, framework-free code
Application Layer = What your system DOES
- Use cases and workflows
- Coordination and orchestration
- Transaction and persistence boundaries
When you’re stuck deciding where to put code, ask yourself: “If I changed from SQL Server to PostgreSQL, would this code need to change?” If yes, it doesn’t belong in Domain. “If I changed from REST to gRPC, would this need to change?” If yes, it doesn’t belong in Application.
Go audit one of your existing features. Find a piece of business logic in your application layer and move it to domain. Find an async method in your domain entity and refactor it out. You’ll feel the difference immediately.
The next time you’re building a feature, you’ll know exactly where each piece belongs. And six months from now, when requirements change, you’ll thank yourself for keeping these boundaries clean.
References
- Clean Architecture by Robert C. Martin - The original source defining layer responsibilities
- Domain-Driven Design by Eric Evans - Foundational concepts for domain modeling and aggregates
- Microsoft’s eShopOnWeb Reference Architecture - Production-ready ASP.NET Core Clean Architecture example
- Vladimir Khorikov’s DDD and EF Core Course - Practical implementation patterns for C#
- Udi Dahan on Service Boundaries - Understanding how to properly separate concerns in distributed systems