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:
- Domain: Entity, Value Objects, Repository Interface, Errors
- Application: Command/Query, Handler, Validator, DTO, Mapping
- Infrastructure: Repository Implementation, EF Configuration
- API: Controller endpoint
- Tests: Unit tests for all layers, Integration test for API
- 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
Entitywhich providesId,CreatedAt, andUpdatedAt. 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
Createmethod - 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
Userwe might haveUpdateLastLoginDate - ✅ Create the
{Entity}Errorsclass inDomain/Entities/Errors/
Configure the DB¶
- Add DbSet to DbContext
- Location:
Infrastructure/Persistence/PetfolioDbContext.cs
- Location:
- Create Entity Configuration
- ✅ Use
HasConversionfor value objects - ✅ Add indexes for fields used in queries (like Email)
- ✅ Set
IsRequired()andHasMaxLength()appropriately - ✅ Configuration is automatically discovered by EF Core
- Location:
Infrastructure/Persistence/Configurations/
- ✅ Use
- Create Database Migration
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/
- Location:
- Register the repository for dependency injection
- Location:
Infrastructure/DependencyInjection.cs
- Location:
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 recordfor commands and queries (they're immutable data containers) - ✅ Commands implement
ICommand<TResponse>(orICommandif 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 classfor validators (they're implementation details) - ✅ Inherit from
AbstractValidator<TCommand>orAbstractValidator<TQuery> - ✅ Define all validation rules in the constructor
- ✅ No manual registration needed - auto-discovered by
AddValidatorsFromAssembly - ✅ No manual invocation needed - auto-executed by
ValidationBehaviourpipeline - ✅ Use centralised validation constants from
Validationclass 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 classfor handlers - ✅ Inject dependencies via primary constructor
- ✅ Inject
IUnitOfWorkfor commands that modify data - ✅ Inject
IMapperto map entities to DTOs - ✅ Return
Result<T>for type-safe error handling - ✅ Use
Result.Success()andResult.Failure()factory methods - ✅ Call
unitOfWork.SaveChangesAsync()to persist changes - ✅ Use domain factory methods and business methods (don't manipulate state directly)
- ✅ Catch
ArgumentExceptionfrom domain to convert toResult.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)ormediator.Send(query) - ✅ No manual validation - ValidationBehaviour handles it automatically
- ✅ Check
result.IsFailureand return appropriate HTTP status codes - ✅ Use
[ProducesResponseType]for API documentation - ✅ Use
CreatedAtActionfor 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/
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
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 fixtureIntegrationTestWebApplicationFactory: Manages MySQL containerBaseIntegrationTest: Provides database cleanup and helpers
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
Collectionattribute, 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
- Each test gets a clean database automatically via
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¶
- 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 ✅
- Don't create service interfaces unnecessarily
- Use MediatR handlers instead of traditional services
- Commands/Queries + Handlers replace service pattern
- Don't manually validate in controllers
- ValidationBehaviour handles it automatically
- No need for
if (!ModelState.IsValid)checks
- Don't manually register handlers or validators
- MediatR auto-discovers handlers via
RegisterServicesFromAssembly - FluentValidation auto-discovers validators via
AddValidatorsFromAssembly
- MediatR auto-discovers handlers via
- Don't call SaveChangesAsync in repositories
- Let handlers control transactions via
IUnitOfWork
- Let handlers control transactions via
- Don't create anemic entities
- Add behavior and validation to entities
- Use factory methods and business methods
- Don't expose entities directly from controllers
- Always use DTOs for external communication
- Map entities to DTOs in handlers
- Don't put business logic in handlers
- Business logic belongs in domain entities
- Handlers only orchestrate
- Don't use
ICommanddirectly - use MediatR through handlers- Commands are data containers, handlers contain logic
- Controllers send commands via
mediator.Send()
- 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)
- Every integration test class must use