Skip to content

Adding or Updating Endpoints

This guide walks you through creating new features using Clean Architecture with CQRS pattern and MediatR.

Overview

When adding a new feature, follow this order to maintain proper layer dependencies:

  1. Domain: Entity, Value Objects, Repository Interface, Errors
  2. Application: Command/Query, Handler, Validator, DTO, Mapping
  3. Infrastructure: Repository Implementation, EF Configuration
  4. API: Controller endpoint
  5. Tests: Unit tests for all layers, Integration test for API
  6. Migration: Create and apply database migration

Key Principles:

  • Start from the inside (Domain) and work outward
  • Use CQRS: Separate read (Query) and write (Command) operations
  • MediatR: Decouple controllers from business logic
  • FluentValidation: Declarative validation with automatic pipeline execution
  • Result Pattern: Type-safe error handling without exceptions
  • Unit of Work: Coordinate database transactions

Domain

Value Objects

Create Value Objects first (if needed).

Location: Domain/ValueObjects/

  • ✅ Value Objects are defined solely by their attributes and do not have a unique identity.
  • ✅ Value objects are often used for elements where only the content of the data matters, not its specific instance

Domain Entities

Domain entities contain your business logic and rules. They should be rich models with behaviour, not just data containers.

Location: Domain/Entities/ (e.g., Animal.cs, Account.cs, User.cs)

  • ✅ Entities should inherit from Entity which provides Id, CreatedAt, and UpdatedAt. This base class is also responsible for managing DomainEvents.
  • The entity should include:
    • ✅ User private setters to enforce encapsulation
    • ✅ A private parameterless function - this will be used by EF Core
    • ✅ A private constructor - this will be used by the factory method
    • ✅ A factory Create method - this will be the only available function for creating an instance of the entity
  • ✅ Validation should be carried out within the factory method
  • ✅ Any additional business functions, for example on a User we might have UpdateLastLoginDate
  • ✅ Create the {Entity}Errors class in Domain/Entities/Errors/

Configure the DB

  1. Add DbSet to DbContext
    • Location: Infrastructure/Persistence/PetfolioDbContext.cs
  2. Create Entity Configuration
    • ✅ Use HasConversion for value objects
    • ✅ Add indexes for fields used in queries (like Email)
    • ✅ Set IsRequired() and HasMaxLength() appropriately
    • ✅ Configuration is automatically discovered by EF Core
    • Location: Infrastructure/Persistence/Configurations/
  3. Create Database Migration
    1
    2
    3
    4
    5
    6
    7
    8
    # Create migration
    dotnet ef migrations add <Name> --project Petfolio.Service.Infrastructure --startup-project Petfolio.Service.Api
    
    # Apply migration
    dotnet ef database update --project Petfolio.Service.Infrastructure --startup-project Petfolio.Service.Api
    
    # Remove last migration (if not applied)
    dotnet ef migrations remove --project Petfolio.Service.Infrastructure --startup-project Petfolio.Service.Api
    

Repository Interface

Location: Domain/Repositories/

  • ✅ Define only what you need (don't add methods you won't use)
  • ✅ Methods should be async and accept CancellationToken
  • ✅ Repository methods don't call SaveChangesAsync - that's the Unit of Work's job
  • ✅ Return domain entities, not DTOs

Infrastructure

Repository Implementation

  • Create the repository implementation
    • Location: Infrastructure/Repositories/
  • Register the repository for dependency injection
    • Location: Infrastructure/DependencyInjection.cs

Application

Create response Dto

Location: Application/Dtos/

  • ✅ DTOs are simple data containers (no behavior)
  • ✅ Response DTOs expose what the client sees
  • ✅ Separate DTOs from Commands/Queries for flexibility

Update Mapping Profile

Location: Application/Common/Mapping/MappingProfile.cs

Add mappings to the existing MappingProfile class

  • ✅ Map value objects to their primitive values for DTOs
  • ✅ Use domain methods (like GetFullName()) when mapping
  • ✅ Keep mapping configuration centralised in MappingProfile

Define UseCases

Command and Query objects

For write operations, we use a Command, this will implement ICommand<TResponse> or ICommand.

For read operations, we use a Query, this will implement IQuery<TResponse>.

Location: Application/UseCases/{Feature}/

  • ✅ Use sealed record for commands and queries (they're immutable data containers)
  • ✅ Commands implement ICommand<TResponse> (or ICommand if no response needed)
  • ✅ Queries implement IQuery<TResponse>
  • ✅ Use nullable properties for optional parameters

Validation Command / Query objects

Validators are automatically discovered by AddValidatorsFromAssembly and automatically executed by ValidationBehaviour before the handler runs.

Location: Application/Validators/{Feature}/

  • ✅ Use internal sealed class for validators (they're implementation details)
  • ✅ Inherit from AbstractValidator<TCommand> or AbstractValidator<TQuery>
  • ✅ Define all validation rules in the constructor
  • No manual registration needed - auto-discovered by AddValidatorsFromAssembly
  • No manual invocation needed - auto-executed by ValidationBehaviour pipeline
  • ✅ Use centralised validation constants from Validation class when available
  • ✅ Use .When() for conditional validation

Create Command/Query Handlers

Handlers process commands and queries. They contain the orchestration logic but delegate business rules to domain entities.

  • Command and Query Handlers should live alongside their Command/Query counterpart.
  • Command handlers implement ICommandHandler<TCommand, TResponse>.
  • Query handlers implement IQueryHandler<TQuery, TResponse>.

Location: Application/UseCases/{Feature}/

  • ✅ Use internal sealed class for handlers
  • ✅ Inject dependencies via primary constructor
  • ✅ Inject IUnitOfWork for commands that modify data
  • ✅ Inject IMapper to map entities to DTOs
  • ✅ Return Result<T> for type-safe error handling
  • ✅ Use Result.Success() and Result.Failure() factory methods
  • ✅ Call unitOfWork.SaveChangesAsync() to persist changes
  • ✅ Use domain factory methods and business methods (don't manipulate state directly)
  • ✅ Catch ArgumentException from domain to convert to Result.Failure()

API

Endpoints

Location: Api/Controllers/

Controllers are thin - they only handle HTTP concerns and delegate to MediatR.

  • ✅ Inject IMediator (not services or repositories)
  • ✅ Keep controllers thin - only HTTP concerns
  • ✅ Use mediator.Send(command) or mediator.Send(query)
  • No manual validation - ValidationBehaviour handles it automatically
  • ✅ Check result.IsFailure and return appropriate HTTP status codes
  • ✅ Use [ProducesResponseType] for API documentation
  • ✅ Use CreatedAtAction for POST requests
  • ✅ Commands are passed directly from the request body

Testing

Test Data Generation

Create Faker classes for generating test data:

Location: Tests/PetFolio.Service.TestHelpers/Data/Owners/

// CreateOwnerCommandFaker.cs
using Bogus;
using Petfolio.Service.Application.Owners.UseCases.CreateOwner;

namespace PetFolio.Service.TestHelpers.Data.Owners;

public class CreateOwnerCommandFaker : Faker<CreateOwnerCommand>
{
    public CreateOwnerCommandFaker()
    {
        CustomInstantiator(f => new CreateOwnerCommand(
            FirstName: f.Name.FirstName(),
            LastName: f.Name.LastName(),
            Email: f.Internet.Email(),
            PhoneNumber: f.Phone.PhoneNumber()
        ));
    }

    public static CreateOwnerCommand WithRequiredFieldsOnly()
    {
        var faker = new Faker();
        return new CreateOwnerCommand(
            FirstName: faker.Name.FirstName(),
            LastName: faker.Name.LastName(),
            Email: faker.Internet.Email(),
            PhoneNumber: null
        );
    }

    public static CreateOwnerCommand WithEmail(string email)
    {
        var command = new CreateOwnerCommandFaker().Generate();
        return command with { Email = email };
    }
}

Integration Tests

Integration tests verify the full stack (API → Application → Domain → Database) works correctly with real dependencies.

  • Podman or Docker must be running
  • Tests use Testcontainers to automatically spin up MySQL
  • No manual database setup required
1
2
3
4
5
6
7
8
# Run all integration tests
dotnet test Tests/Petfolio.Service.IntegrationTests

# Run specific test class
dotnet test --filter "FullyQualifiedName~CreateOwnerTests"

# Verbose output
dotnet test Tests/Petfolio.Service.IntegrationTests --verbosity detailed

Location: Tests/Petfolio.Service.IntegrationTests/{Feature}/

Key Points:

  • ✅ Use [Collection(nameof(IntegrationTestCollection))] attribute
  • ✅ Inherit from BaseIntegrationTest
  • ✅ Test happy path AND validation failures
  • ✅ Verify both HTTP response AND database persistence
  • ✅ Use Faker classes for test data generation
  • ✅ Focus on critical user journeys, not every edge case
  • ✅ Keep tests focused and readable

Key Components

  • IntegrationTestCollection: Shared collection fixture
  • IntegrationTestWebApplicationFactory: Manages MySQL container
  • BaseIntegrationTest: Provides database cleanup and helpers
    1
    2
    3
    4
    5
    6
    7
    8
    9
    // Get entity from database by ID
    var owner = await GetEntityFromDbAsync<Owner>(ownerId);
    
    // Get all entities of a type
    var allOwners = await GetAllEntitiesFromDbAsync<Owner>();
    
    // HttpClient is already configured and ready to use
    var response = await HttpClient.HandleCommandAsync(route, command);
    var response = await HttpClient.HandleQueryAsync(route);
    

Shared Container Pattern

All integration tests share a single MySQL container

  • Container starts once at test session start
  • Custom SQL cleanup resets database between tests (~50ms)
  • Tests run in ~111ms each (very fast)
  • Every integration test class MUST include a Collection attribute, ie [Collection(nameof(IntegrationTestCollection))]
    • Without it, each test class creates its own container (slow)
    • With it, all tests share one container (fast)
  • No manual cleanup needed:
    • Each test gets a clean database automatically via BaseIntegrationTest.InitializeAsync()
    • Don't create setup/teardown methods
    • Don't manually delete test data
    • Each test starts with empty database
    • Previous test data is automatically removed

When to write integration tests

Write integration tests when:

  • ✅ Testing critical user journeys (Create, Get, Update, Delete)
  • ✅ Verifying data persistence and retrieval
  • ✅ Testing API endpoints with real HTTP requests
  • ✅ Validating that ValidationBehaviour returns 400 for invalid requests
  • ✅ Verifying database constraints and transactions
  • ✅ Testing complex queries or data relationships

Don't write integration tests for:

  • ❌ Testing validation rules (use Application unit tests)
  • ❌ Testing domain logic (use Domain unit tests)
  • ❌ Testing mapping logic (use Application unit tests)
  • ❌ Testing every edge case (use unit tests for that)

Common Gotchas to avoid

  1. Check dependencies flow in the correct direction
    • Domain has no dependencies on other layers ✅
    • Application only references Domain ✅
    • Infrastructure references Domain ✅
    • API references Application and Infrastructure ✅
  2. Don't create service interfaces unnecessarily
    • Use MediatR handlers instead of traditional services
    • Commands/Queries + Handlers replace service pattern
  3. Don't manually validate in controllers
    • ValidationBehaviour handles it automatically
    • No need for if (!ModelState.IsValid) checks
  4. Don't manually register handlers or validators
    • MediatR auto-discovers handlers via RegisterServicesFromAssembly
    • FluentValidation auto-discovers validators via AddValidatorsFromAssembly
  5. Don't call SaveChangesAsync in repositories
    • Let handlers control transactions via IUnitOfWork
  6. Don't create anemic entities
    • Add behavior and validation to entities
    • Use factory methods and business methods
  7. Don't expose entities directly from controllers
    • Always use DTOs for external communication
    • Map entities to DTOs in handlers
  8. Don't put business logic in handlers
    • Business logic belongs in domain entities
    • Handlers only orchestrate
  9. Don't use ICommand directly - use MediatR through handlers
    • Commands are data containers, handlers contain logic
    • Controllers send commands via mediator.Send()
  10. Don't forget the [Collection] attribute on integration tests
    • Every integration test class must use [Collection(nameof(IntegrationTestCollection))]
    • Without it, tests will create separate containers (very slow)
    • With it, all tests share one container (97% faster)