Architecture Patterns with Functional DDD
Level: Intermediate to Advanced 📚→🚀 | Time: 30-60 min | Prerequisites: Basics, Error Handling
Learn how to architect applications with Railway-Oriented Programming and Domain-Driven Design, from simple controller-based patterns to advanced CQRS architectures. Choose the pattern that fits your needs.
Table of Contents
- Overview
- Choosing Your Architecture
- Simple Pattern: Controller → Domain → Repository
- Advanced Pattern: CQRS with Application Layer
- Testing
- Benefits
- Best Practices
Overview
This guide demonstrates two production-ready architectural patterns that combine:
- Railway-Oriented Programming (ROP) - Explicit error handling with
Result<T> - Domain-Driven Design (DDD) - Rich domain models with business logic
- Functional Programming - Immutability, pure functions, composition
- Clean Architecture Principles - Dependencies point inward, testable layers
Both patterns are valid for production - choose based on your application's complexity and team size.
Choosing Your Architecture
| Factor | Simple Pattern | CQRS Pattern |
|---|---|---|
| Complexity | Low to Medium | Medium to High |
| Team Size | 1-10 developers | 10+ developers |
| Domain Complexity | Straightforward business rules | Complex, evolving business logic |
| Read vs Write | Similar concerns | Need separate optimization |
| Layers | 3 (API, Domain, Infrastructure) | 3 (API, Domain*, Infrastructure) |
| Assemblies | 3 assemblies | 4 assemblies (2 in Domain Layer) |
| Learning Curve | Gentle - Start here ✅ | Steep - Graduate to this |
| Example Code | SampleWebApplication (Examples folder) | Enterprise applications |
| Time to Market | Fast 🚀 | Slower (more structure) |
*Domain Layer contains 2 assemblies: Application (use cases) + Domain (business rules)
Recommendation: Start with the Simple Pattern and evolve to CQRS only when you need it.
Simple Pattern: Controller → Domain → Repository
The Simple Pattern goes directly from Controllers to Domain logic, with minimal layers. This is perfect for most applications and matches the SampleWebApplication example in the Examples folder.
Simple Architecture Diagram
graph TB
subgraph API["API Layer (ASP.NET Core)"]
C[Controllers]
VO[Value Objects]
D[Domain Logic]
end
subgraph Domain["Domain Layer (DDD)"]
A[Aggregates]
E[Entities]
VO2[Value Objects]
DL[Domain Logic]
end
subgraph Infrastructure["Infrastructure Layer"]
R[Repositories]
DB[(Database)]
EXT[External Services]
end
C --> VO
VO --> D
D --> A
A --> E
A --> VO2
D --> R
R --> DB
D --> EXT
style API fill:#e1f5ff
style Domain fill:#fff4e1
style Infrastructure fill:#f0f0f0
Simple Example: User Registration
1. Controller (API Layer)
The controller validates inputs, calls domain logic, and converts to HTTP:
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
private readonly IUserRepository _repository;
public UsersController(IUserRepository repository)
=> _repository = repository;
[HttpPost]
public ActionResult<User> Register([FromBody] RegisterUserRequest request) =>
FirstName.TryCreate(request.FirstName)
.Combine(LastName.TryCreate(request.LastName))
.Combine(EmailAddress.TryCreate(request.Email))
.Bind((firstName, lastName, email) =>
User.TryCreate(firstName, lastName, email, request.Password))
.Tap(user => _repository.Save(user))
.ToActionResult(this);
}
What happens:
- Validate -
TryCreatevalidates each value object - Combine - Collects all validations (returns all errors if multiple fail)
- Bind - Creates domain aggregate (User)
- Tap - Saves to database (side effect)
- ToActionResult - Converts
Result<User>→ActionResult<User>
Railway-Oriented Flow
graph TB
START[HTTP Request] --> VC1{FirstName<br/>TryCreate}
START --> VC2{LastName<br/>TryCreate}
START --> VC3{EmailAddress<br/>TryCreate}
VC1 -->|Success| CMB{Combine}
VC2 -->|Success| CMB
VC3 -->|Success| CMB
VC1 -.->|Failure| ERR[Aggregate Errors]
VC2 -.->|Failure| ERR
VC3 -.->|Failure| ERR
CMB -->|Success| BIND{Bind<br/>User.TryCreate}
CMB -.->|Failure| ERR
BIND -->|Success| TAP{Tap<br/>Save to DB}
BIND -.->|Failure| ERR
TAP --> MATCH{ToActionResult}
ERR --> MATCH
MATCH -->|Success| SUCCESS[200 OK<br/>User JSON]
MATCH -->|Failure| FAIL[400/409<br/>Error Details]
style SUCCESS fill:#90EE90
style FAIL fill:#FFB6C6
style ERR fill:#FFD700
2. Domain Layer
// Value Object
public record EmailAddress
{
public string Value { get; }
private EmailAddress(string value) => Value = value;
public static Result<EmailAddress> TryCreate(string? email)
{
if (string.IsNullOrWhiteSpace(email))
return Error.Validation("Email is required", nameof(email));
if (!email.Contains('@'))
return Error.Validation("Invalid email format", nameof(email));
return new EmailAddress(email.ToLowerInvariant());
}
public override string ToString() => Value;
}
// Aggregate Root
public class User : Aggregate<UserId>
{
public FirstName FirstName { get; }
public LastName LastName { get; }
public EmailAddress Email { get; }
public string Password { get; }
public static Result<User> TryCreate(
FirstName firstName,
LastName lastName,
EmailAddress email,
string password)
{
var user = new User(firstName, lastName, email, password);
return Validator.ValidateToResult(user);
}
private User(FirstName firstName, LastName lastName, EmailAddress email, string password)
: base(UserId.NewUnique())
{
FirstName = firstName;
LastName = lastName;
Email = email;
Password = password; // Should use Password value object in production
}
private static readonly InlineValidator<User> Validator = new()
{
v => v.RuleFor(x => x.FirstName).NotNull(),
v => v.RuleFor(x => x.LastName).NotNull(),
v => v.RuleFor(x => x.Email).NotNull(),
v => v.RuleFor(x => x.Password)
.NotEmpty().WithMessage("Password must not be empty.")
.MinimumLength(8).WithMessage("Password must be at least 8 characters long.")
.Matches("[A-Z]").WithMessage("Password must contain at least one uppercase letter.")
};
}
3. Infrastructure Layer (Repository)
public interface IUserRepository
{
Maybe<User> GetById(UserId id);
Maybe<User> GetByEmail(EmailAddress email);
Result<Unit> Save(User user);
}
public class UserRepository : IUserRepository
{
private readonly ApplicationDbContext _context;
public Maybe<User> GetById(UserId id)
{
var user = _context.Users.Find(id);
return Maybe.From(user);
}
public Result<Unit> Save(User user)
{
try
{
_context.Users.Add(user);
_context.SaveChanges();
return Result.Success();
}
catch (DbUpdateException ex) when (IsDuplicateKey(ex))
{
return Error.Conflict("Email already in use");
}
}
private static bool IsDuplicateKey(DbUpdateException ex)
=> ex.InnerException?.Message.Contains("duplicate key") ?? false;
}
When to Use Simple Pattern
Use the Simple Pattern when:
- ✅ Small to medium applications - Most web APIs, microservices
- ✅ Straightforward business logic - CRUD + validation + some rules
- ✅ Small teams - 1-10 developers
- ✅ Fast iteration - Need to ship features quickly
- ✅ Learning ROP/DDD - Best place to start
- ✅ Microservices - Each service is focused and simple
Benefits:
- Less code - Fewer layers, less boilerplate
- Easier to understand - Direct flow from controller → domain → database
- Faster development - No command/query objects, no handlers
- Still testable - Domain logic pure, easy to test
- Production-ready - Fully capable for most applications
Limitations:
- Controllers become complex if orchestrating multiple services
- Read/write concerns not separated (acceptable for most apps)
- No built-in cross-cutting concerns (add as needed)
Advanced Pattern: CQRS with Application Layer
The CQRS Pattern adds a dedicated Application Layer between API and Domain for complex orchestration, separate read/write models, and cross-cutting concerns.
⚠️ Only graduate to this pattern when you experience pain with the Simple Pattern.
CQRS Architecture Diagram
graph TB
subgraph API["API Layer (ASP.NET Core)"]
CTRL[Controllers/Endpoints]
HTTP[ToActionResult/ToHttpResult]
end
subgraph DomainCore["Domain Layer - Core Business Logic"]
subgraph Application["Application Assembly<br/>(Use Cases & Orchestration)"]
CMD[Commands]
QRY[Queries]
HAND[Handlers]
VAL[Validation]
end
subgraph Domain["Domain Assembly<br/>(Business Rules & Models)"]
AGG[Aggregates]
ENT[Entities]
VO[Value Objects]
EVT[Domain Events]
end
end
subgraph Infrastructure["Infrastructure Layer"]
REPO[Repositories]
DBCTX[DbContext]
EXT[External Services]
DB[(Database)]
end
CTRL --> HTTP
HTTP --> CMD
HTTP --> QRY
CMD --> HAND
QRY --> HAND
HAND --> VAL
VAL --> AGG
AGG --> ENT
AGG --> VO
AGG --> EVT
HAND --> REPO
REPO --> DBCTX
DBCTX --> DB
HAND --> EXT
style API fill:#e1f5ff
style DomainCore fill:#fff4e1
style Application fill:#fffacd
style Domain fill:#ffe4b5
style Infrastructure fill:#f0f0f0
Architecture Layers
Note: In this architecture, both the Application and Domain assemblies are part of the Domain Layer. They represent different concerns within the core business logic but share the same architectural layer.
Assembly Organization
The Domain Layer is split into two assemblies for better organization:
Domain Assembly - Pure business logic with zero dependencies
- Aggregates, Entities, Value Objects
- Domain Events
- Repository interfaces
- Domain services
Application Assembly - Use case orchestration (depends only on Domain)
- Commands and Queries (CQRS)
- Command/Query Handlers
- Application services
- DTOs and mapping logic
This separation allows you to:
- Keep domain logic pure and framework-agnostic
- Organize complex use cases separately from core business rules
- Test business logic in complete isolation
- Share the domain assembly across multiple applications
1. API Layer (Presentation)
Responsibility: HTTP concerns only
Dependencies: Application Layer
Returns: ActionResult<T> or IResult
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
private readonly IMediator _mediator;
public UsersController(IMediator mediator) => _mediator = mediator;
[HttpPost]
public async Task<ActionResult<UserDto>> Register(
[FromBody] RegisterUserRequest request,
CancellationToken ct)
=> await RegisterUserCommand.TryCreate( request.Email, request.FirstName, request.LastName)
.BindAsync(_mediator.Send, ct)
.Map(user => new UserDto(user))
.ToActionResult(this);
}
What it does:
- Receives HTTP requests
- Validates raw inputs using
TryCreate - Sends validated command to application layer via mediator
- Converts
Result<T>to HTTP responses - No business logic
2. Application Layer (Use Cases)
Responsibility: Application workflows, orchestration
Dependencies: Domain Layer
Returns: Result<T>
public record RegisterUserCommand(
EmailAddress Email,
FirstName FirstName,
LastName LastName
) : IRequest<Result<User>>
{
// Factory method for validation
public static Result<RegisterUserCommand> TryCreate(
string email,
string firstName,
string lastName)
{
return EmailAddress.TryCreate(email)
.Combine(FirstName.TryCreate(firstName))
.Combine(LastName.TryCreate(lastName))
.Map((e, f, l) => new RegisterUserCommand(e, f, l));
}
}
public class RegisterUserCommandHandler
: IRequestHandler<RegisterUserCommand, Result<User>>
{
private readonly IUserRepository _repository;
private readonly IEmailService _emailService;
public async ValueTask<Result<User>> Handle(
RegisterUserCommand command,
CancellationToken ct)
{
// Command is already validated, no need to re-validate
// Check uniqueness (application-level concern)
var maybeExistingUser = await _repository.GetByEmailAsync(command.Email, ct);
if (maybeExistingUser.HasValue)
return Error.Conflict($"Email {command.Email} already registered");
// Create aggregate (domain operation)
return User.Create(command.Email, command.FirstName, command.LastName)
// Save to database (transaction boundary)
.TapAsync(async (user, cancellationToken) =>
await _repository.SaveAsync(user, cancellationToken), ct)
// Send welcome email (side effect)
.TapAsync(async (user, cancellationToken) =>
await _emailService.SendWelcomeEmailAsync(user, cancellationToken), ct);
}
}
What it does:
- Command validates inputs via
TryCreate(value objects) - Handler receives pre-validated command
- Orchestrates domain operations
- Manages transaction boundaries
- No HTTP knowledge
- No database knowledge (uses repositories)
3. Domain Layer (Business Logic)
Responsibility: Business rules, invariants
Dependencies: None (pure domain)
Returns: Result<T> for operations that can fail
// Aggregate Root
public class User : Aggregate<UserId>
{
public EmailAddress Email { get; private set; }
public FirstName FirstName { get; private set; }
public LastName LastName { get; private set; }
public bool IsActive { get; private set; }
public DateTime CreatedAt { get; private set; }
// Factory method with validation
public static Result<User> Create(
EmailAddress email,
FirstName firstName,
LastName lastName)
{
var user = new User(email, firstName, lastName);
return Validator.ValidateToResult(user);
}
private User(
EmailAddress email,
FirstName firstName,
LastName lastName)
: base(UserId.NewUnique())
{
Email = email;
FirstName = firstName;
LastName = lastName;
IsActive = true;
CreatedAt = DateTime.UtcNow;
// Domain event
AddDomainEvent(new UserCreated(Id, Email));
}
// Business operation
public Result<User> Deactivate(string reason)
{
if (!IsActive)
return Error.Domain("User is already deactivated");
if (string.IsNullOrWhiteSpace(reason))
return Error.Validation("Deactivation reason is required", nameof(reason));
IsActive = false;
AddDomainEvent(new UserDeactivated(Id, reason));
return Result.Success(this);
}
// Domain validator
private static readonly InlineValidator<User> Validator = new()
{
v => v.RuleFor(x => x.Email).NotNull(),
v => v.RuleFor(x => x.FirstName).NotNull(),
v => v.RuleFor(x => x.LastName).NotNull()
};
}
// Value Objects
public record EmailAddress
{
public string Value { get; }
private EmailAddress(string value) => Value = value;
public static Result<EmailAddress> TryCreate(string? email)
{
if (string.IsNullOrWhiteSpace(email))
return Error.Validation("Email is required", nameof(email));
if (!email.Contains('@'))
return Error.Validation("Invalid email format", nameof(email));
return new EmailAddress(email.ToLowerInvariant());
}
public override string ToString() => Value;
}
// Domain Events
public record UserCreated(UserId UserId, EmailAddress Email) : DomainEvent;
public record UserDeactivated(UserId UserId, string Reason) : DomainEvent;
What it does:
- Enforces business rules (invariants)
- Validates domain operations
- Raises domain events
- No infrastructure dependencies
- No knowledge of HTTP, database, etc.
- Fully testable in isolation
4. Infrastructure Layer (Data Access)
Responsibility: External concerns
Dependencies: Domain Layer (interfaces)
Returns: Result<T> (no exceptions)
public class UserRepository : IUserRepository
{
private readonly ApplicationDbContext _context;
private readonly ILogger<UserRepository> _logger;
public async Task<Result<User>> GetByIdAsync(
UserId id,
CancellationToken ct)
{
var user = await _context.Users
.FirstOrDefaultAsync(u => u.Id == id, ct);
return user.ToResult(Error.NotFound($"User {id} not found"));
}
public async Task<bool> ExistsByEmailAsync(
EmailAddress email,
CancellationToken ct)
{
return await _context.Users
.AnyAsync(u => u.Email == email, ct);
}
public async Task<Result<Unit>> SaveAsync(
User user,
CancellationToken ct)
{
try
{
_context.Users.Update(user);
await _context.SaveChangesAsync(ct);
return Result.Success();
}
catch (DbUpdateConcurrencyException)
{
return Error.Conflict("User was modified by another process");
}
catch (DbUpdateException ex) when (IsDuplicateKey(ex))
{
return Error.Conflict("Email already in use");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error saving user");
return Error.Unexpected("Failed to save user");
}
}
private static bool IsDuplicateKey(DbUpdateException ex)
=> ex.InnerException?.Message.Contains("duplicate key") ?? false;
}
// EF Core Configuration
public class UserConfiguration : IEntityTypeConfiguration<User>
{
public void Configure(EntityTypeBuilder<User> builder)
{
builder.ToTable("Users");
builder.HasKey(u => u.Id);
builder.Property(u => u.Id)
.HasConversion(id => id.Value, value => new UserId(value));
builder.Property(u => u.Email)
.HasConversion(
email => email.Value,
value => EmailAddress.TryCreate(value).Value)
.HasMaxLength(256)
.IsRequired();
builder.HasIndex(u => u.Email).IsUnique();
}
}
What it does:
- Implements repository interfaces from domain
- Handles database exceptions →
Result<T>errors - Configures EF Core mappings for value objects
- No business logic
Complete Example: User Registration
Let's see how all layers work together for a complete user registration flow:
User Registration Sequence Diagram
sequenceDiagram
participant Client
participant Controller
participant ValueObjects
participant Domain
participant Repository
participant Database
Client->>Controller: POST /api/users/register<br/>{email, firstName, lastName}
Controller->>ValueObjects: FirstName.TryCreate()
ValueObjects-->>Controller: Result<FirstName>
Controller->>ValueObjects: LastName.TryCreate()
ValueObjects-->>Controller: Result<LastName>
Controller->>ValueObjects: EmailAddress.TryCreate()
ValueObjects-->>Controller: Result<EmailAddress>
Note over Controller: Combine all validations
alt All validations pass
Controller->>Domain: User.TryCreate(...)
Domain->>Domain: Validate business rules
Domain-->>Controller: Result<User>
Controller->>Repository: Save(user)
Repository->>Database: INSERT INTO Users
alt Save successful
Database-->>Repository: Success
Repository-->>Controller: Result.Success()
Controller-->>Client: 200 OK + User JSON
else Duplicate email
Database-->>Repository: Duplicate key error
Repository-->>Controller: Error.Conflict()
Controller-->>Client: 409 Conflict
end
else Validation fails
Controller-->>Client: 400 Bad Request<br/>+ Validation errors
end
CQRS Command Flow Diagram
sequenceDiagram
participant Client
participant Controller
participant Mediator
participant CommandHandler
participant Domain
participant Repository
participant EventBus
Client->>Controller: POST /api/users/register
Controller->>Controller: RegisterUserCommand.TryCreate()
Controller->>Mediator: Send(command)
Mediator->>CommandHandler: Handle(command)
CommandHandler->>Repository: GetByEmailAsync(email)
Repository-->>CommandHandler: Maybe<User>
CommandHandler->>Domain: User.Create()
Domain-->>CommandHandler: Result<User>
CommandHandler->>Repository: SaveAsync(user)
Repository-->>CommandHandler: Result<Unit>
CommandHandler->>Domain: user.UncommittedEvents()
Domain-->>CommandHandler: [UserCreated]
CommandHandler->>EventBus: Publish(events)
EventBus-->>CommandHandler: Ack
CommandHandler->>Domain: user.AcceptChanges()
CommandHandler-->>Mediator: Result<User>
Mediator-->>Controller: Result<User>
Controller-->>Client: 200 OK
HTTP Response Examples
Success Response (200 OK):
HTTP/1.1 200 OK
Content-Type: application/json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "john@example.com",
"firstName": "John",
"lastName": "Doe",
"isActive": true,
"createdAt": "2024-01-15T10:30:00Z"
}
Validation Failure (400 Bad Request):
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"email": ["'Email' must not be empty."],
"firstName": ["'First Name' must not be empty."]
}
}
Conflict Response (409 Conflict):
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.8",
"title": "Conflict",
"status": 409,
"detail": "Email john@example.com already registered"
}
When to Use CQRS Pattern
Use the CQRS Pattern when you experience these pain points:
- ✅ Complex orchestration - Controllers juggling multiple services/repositories
- ✅ Cross-cutting concerns - Need logging, caching, authorization across many operations
- ✅ Different read/write needs - Queries need denormalized views, commands need rich validation
- ✅ Large teams - Need clear boundaries between teams working on different features
- ✅ Event-driven architecture - Publishing domain events to message buses
- ✅ Complex transactions - Multi-aggregate transactions with compensation logic
Benefits of CQRS:
- Dedicated orchestration layer - Commands/Queries handle complex workflows
- Separation of concerns - Read models optimized separately from write models
- Testability - Handlers easy to test in isolation
- Scalability - Read/write can scale independently
- Clear contracts - Commands/Queries define explicit contracts
Trade-offs:
- More code - Additional layer, command/query objects, handlers
- Learning curve - Team needs to understand mediator pattern, CQRS
- Overkill for simple apps - Unnecessary complexity for CRUD operations
Migration path from Simple Pattern:
- Start with Simple Pattern
- When controllers get complex, introduce handlers for those specific controllers
- Gradually migrate complex operations to CQRS
- Keep simple CRUD in Simple Pattern if it works
You don't need to choose one pattern for the entire application - mix both based on complexity per feature!
Testing
Unit Tests (Domain Layer)
public class UserTests
{
[Fact]
public void Create_ValidData_ReturnsSuccess()
{
// Arrange
var email = EmailAddress.TryCreate("john@example.com").Value;
var firstName = FirstName.TryCreate("John").Value;
var lastName = LastName.TryCreate("Doe").Value;
// Act
var result = User.Create(email, firstName, lastName);
// Assert
result.IsSuccess.Should().BeTrue();
result.Value.Email.Should().Be(email);
result.Value.IsActive.Should().BeTrue();
}
[Fact]
public void Deactivate_WhenActive_ReturnsSuccess()
{
// Arrange
var user = CreateValidUser();
// Act
var result = user.Deactivate("No longer needed");
// Assert
result.IsSuccess.Should().BeTrue();
user.IsActive.Should().BeFalse();
}
[Fact]
public void Deactivate_WhenAlreadyInactive_ReturnsFailure()
{
// Arrange
var user = CreateValidUser();
user.Deactivate("First time");
// Act
var result = user.Deactivate("Second time");
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().BeOfType<DomainError>();
}
}
Integration Tests (Application Layer)
public class RegisterUserCommandHandlerTests
{
[Fact]
public async Task Handle_ValidCommand_CreatesUser()
{
// Arrange
var repository = new InMemoryUserRepository();
var handler = new RegisterUserCommandHandler(repository, new EmailServiceFake());
var command = new RegisterUserCommand(
EmailAddress.TryCreate("john@example.com").Value,
FirstName.TryCreate("John").Value,
LastName.TryCreate("Doe").Value);
// Act
var result = await handler.Handle(command, CancellationToken.None);
// Assert
result.IsSuccess.Should().BeTrue();
result.Value.Email.Value.Should().Be("john@example.com");
var saved = await repository.GetByIdAsync(result.Value.Id, CancellationToken.None);
saved.IsSuccess.Should().BeTrue();
}
[Fact]
public async Task Handle_DuplicateEmail_ReturnsConflict()
{
// Arrange
var repository = new InMemoryUserRepository();
var existingEmail = EmailAddress.TryCreate("john@example.com").Value;
await repository.SaveAsync(CreateExistingUser(existingEmail));
var handler = new RegisterUserCommandHandler(repository, new EmailServiceFake());
var command = new RegisterUserCommand(
existingEmail,
FirstName.TryCreate("Jane").Value,
LastName.TryCreate("Smith").Value);
// Act
var result = await handler.Handle(command, CancellationToken.None);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().BeOfType<ConflictError>();
}
}
API Tests (End-to-End)
public class UsersControllerTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public UsersControllerTests(WebApplicationFactory<Program> factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task Register_ValidRequest_Returns200WithUser()
{
// Arrange
var request = new RegisterUserRequest
{
Email = "john@example.com",
FirstName = "John",
LastName = "Doe"
};
// Act
var response = await _client.PostAsJsonAsync("/api/users/register", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var user = await response.Content.ReadFromJsonAsync<UserDto>();
user.Should().NotBeNull();
user!.Email.Should().Be("john@example.com");
}
[Fact]
public async Task Register_InvalidEmail_Returns400WithValidationErrors()
{
// Arrange
var request = new RegisterUserRequest
{
Email = "",
FirstName = "John",
LastName = "Doe"
};
// Act
var response = await _client.PostAsJsonAsync("/api/users/register", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var problemDetails = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>();
problemDetails.Should().NotBeNull();
problemDetails!.Errors.Should().ContainKey("email");
}
}
Benefits
1. Testability
- Domain logic testable without infrastructure
- Application logic testable with in-memory repositories
- API testable end-to-end with WebApplicationFactory
2. Maintainability
- Clear separation of concerns
- Changes isolated to specific layers
- Easy to understand and modify
3. Explicit Error Handling
- No hidden exceptions
- All failures represented as
Result<T> - HTTP status codes automatically mapped
4. Type Safety
- Value objects prevent primitive obsession
- Compiler catches errors early
- Impossible states impossible
5. Business Logic Clarity
- Domain rules explicit in code
- No business logic leaked to infrastructure
- Easy to communicate with domain experts
Best Practices
1. Dependencies Always Point Inward
API → Application → Domain ← Infrastructure
(Both in Domain Layer)
- Domain Assembly has zero external dependencies
- Application Assembly depends only on Domain Assembly
- Infrastructure implements domain interfaces
- Application orchestrates domain + infrastructure
- API depends on Application (or Domain for simple scenarios)
Layer vs Assembly:
- 3 Layers: API, Domain, Infrastructure
- 4 Assemblies: API, Application (in Domain Layer), Domain (in Domain Layer), Infrastructure