Architecture
This solution implements Clean Architecture with CQRS (Command Query Responsibility Segregation) pattern using MediatR.
This architecture ensures that PetFolio remains maintainable, testable, and adaptable to changing requirements while keeping the core business logic isolated and protected.
Key Patterns
- Clean Architecture: Dependency inversion with layers pointing inward to Domain
- CQRS: Separate models for reading and writing data using Commands and Queries
- MediatR: Mediator pattern for decoupling request/response from handlers
- Result Pattern: Type-safe error handling without exceptions
- FluentValidation: Declarative validation with ValidationBehaviour pipeline
- Repository Pattern: Abstract data access through interfaces
- Unit of Work: Coordinate database transactions
| ┌────────────────────────────────────────────────────┐
│ API Layer │
│ (Petfolio.Service.Api) │
│ • Controllers │
│ • Program.cs, Startup configuration │
│ │
│ Depends on: ↓ Application, ↓ Infrastructure │
└────────────┬───────────────────────────┬───────────┘
│ │
┌────────────▼───────────┐ ┌────────────▼───────────┐
│ Application Layer │ │ Infrastructure Layer │
│ (Application) │ │ (Infrastructure) │
│ • Services │ │ • Repositories │
│ • DTOs │ │ • DbContext │
│ • Interfaces │ │ • EF Core Config │
│ • Mapping │ │ • UnitOfWork │
│ │ │ │
│ Depends on: ↓ Domain │ │ Depends on: ↓ Domain │
└────────────┬───────────┘ └────────────┬───────────┘
│ │
└─────────────┬─────────────┘
│
┌─────────────▼────────────┐
│ Domain Layer │
│ (Domain) │
│ • Entities │
│ • Value Objects │
│ • Repository Interfaces │
│ • IUnitOfWork │
│ │
│ Dependencies: NONE │
└──────────────────────────┘
|
Validation Pipeline
Petfolio uses FluentValidation with MediatR Pipeline Behaviors to automatically validate all commands and queries before they reach their handlers. This ensures consistent validation across the entire application without manual checks in controllers or handlers.
Location: Petfolio.Service.Application/DependencyInjection.cs
| public static IServiceCollection AddApplication(this IServiceCollection services)
{
// Auto-register all validators from the assembly
services.AddValidatorsFromAssembly(typeof(DependencyInjection).Assembly, includeInternalTypes: true);
services.AddMediatR(configuration =>
{
configuration.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly);
// Add ValidationBehaviour to the MediatR pipeline
configuration.AddOpenBehavior(typeof(ValidationBehaviour<,>));
});
return services;
}
|
Key Configuration Steps:
1. Line 15: AddValidatorsFromAssembly - Automatically discovers and registers all AbstractValidator<T> classes
2. Line 20: AddOpenBehavior(typeof(ValidationBehaviour<,>)) - Adds validation to the MediatR pipeline
3. Validators are registered as scoped services and automatically injected into ValidationBehaviour
How Validation Works
Request Flow:
| Controller
↓ mediator.Send(command)
MediatR Pipeline
↓
[1] ValidationBehaviour ← Runs FIRST for all IBaseCommand requests
↓ Collects all validators for TRequest
↓ Executes all validators in parallel
↓ If any validation fails → throws ValidationException
↓ If validation passes → continues
[2] Command/Query Handler ← Only executes if validation passed
↓
Return Result<T>
|
ValidationBehaviour Implementation (Petfolio.Service.Application/Common/Validators/ValidationBehaviour.cs):
| public sealed class ValidationBehaviour<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IBaseCommand
{
private readonly IEnumerable<IValidator<TRequest>> _validators;
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
// Get validation context
var context = new ValidationContext<TRequest>(request);
// Execute ALL validators for this request type in parallel
var validationResults = await Task.WhenAll(
_validators.Select(v => v.ValidateAsync(context, cancellationToken))
);
// Collect all validation errors
var errors = validationResults
.Where(vr => !vr.IsValid)
.SelectMany(vr => vr.Errors)
.Select(e => new ValidationError(e.PropertyName, e.ErrorMessage))
.ToList();
// Throw exception if any validation failed
if (errors.Count != 0)
throw new ValidationException(errors);
// Continue to handler if validation passed
return await next(cancellationToken);
}
}
|
Creating Validators
Location: Petfolio.Service.Application/{Feature}/Validators/
Example: CreateAnimalCommandValidator.cs
| internal sealed class CreateAnimalCommandValidator : AbstractValidator<CreateAnimalCommand>
{
public CreateAnimalCommandValidator()
{
// Name Validation
RuleFor(x => x.Name)
.NotNull().WithMessage(Validation.Name.NullOrEmptyError)
.NotEmpty().WithMessage(Validation.Name.NullOrEmptyError)
.MinimumLength(Validation.Name.MinLength).WithMessage(Validation.Name.LengthError)
.MaximumLength(Validation.Name.MaxLength).WithMessage(Validation.Name.LengthError)
.Matches(Validation.Name.Pattern).WithMessage(Validation.Name.PatternError);
// Species Validation
RuleFor(x => x.Species)
.NotNull().WithMessage(Validation.Species.NullOrEmptyError)
.NotEmpty().WithMessage(Validation.Species.NullOrEmptyError)
.MinimumLength(Validation.Species.MinLength).WithMessage(Validation.Species.LengthError)
.MaximumLength(Validation.Species.MaxLength).WithMessage(Validation.Species.LengthError)
.Matches(Validation.Species.Pattern).WithMessage(Validation.Species.PatternError);
// Gender Validation
RuleFor(x => x.Gender)
.NotNull().WithMessage($"Invalid Gender: {Validation.Errors.MissingRequiredField}")
.IsInEnum().WithMessage($"Invalid Gender: {Validation.Gender.InvalidError}");
}
}
|
Key Points:
- ✅ Use internal sealed class for validators (they're implementation details)
- ✅ Inherit from AbstractValidator<TCommand>
- ✅ Define all validation rules in the constructor
- ✅ No manual registration needed - auto-discovered by AddValidatorsFromAssembly
- ✅ Use centralized validation constants from Validation class in Domain layer
- ✅ Validators automatically execute before the command handler runs
Validation Error Handling
When validation fails, ValidationBehaviour throws ValidationException with all collected errors. Controllers handle this via the Result pattern:
Controller Example:
| [HttpPost("Create")]
public async Task<IActionResult> Create([FromBody] CreateAnimalCommand command)
{
var result = await mediator.Send(command); // Validation runs automatically here
if (result.IsFailure)
{
return BadRequest(result.PetfolioError); // Contains all validation errors
}
return CreatedAtAction(nameof(GetById), new { id = result.Value.Id }, result.Value);
}
|
Benefits of this approach:
- ✅ Zero boilerplate - No if (!ModelState.IsValid) checks in controllers
- ✅ Consistent validation - All commands validated the same way
- ✅ Single responsibility - Controllers only handle HTTP, validators only validate
- ✅ Easy testing - Test validators independently, no need to mock validation logic
- ✅ Fail fast - Invalid requests never reach handlers
- ✅ Centralized error handling - All validation errors collected and returned together
Layers
| Item |
Purpose |
Components |
Key Characteristics |
| Domain Layer |
Contains the business logic and domain rules |
- Entities: Core business objects with behaviour, for example
Animal with validation and business rules.
- Value Objects: Immutable objects representing domain concepts, for example
Species, Weight, Breed, Gender.
- Repository Interfaces (Ports): Define data access contracts, for example
IAnimalRepository, IUnitOfWork.
|
- No dependencies on external libraries.
- Rich domain models with behaviour.
- Business rule enforcement.
- Immutable value objects with validation.
|
| Application Layer |
Orchestrates business operations using CQRS pattern with MediatR |
- Commands: Write operations that change state, for example
CreateAnimalCommand.
- Queries: Read operations that return data, for example
GetAnimalQuery, GetAllAnimalsQuery.
- Handlers: Process commands and queries, for example
CreateAnimalCommandHandler.
- Validators: FluentValidation validators for commands and queries, for example
CreateAnimalCommandValidator.
- DTOs: Data transfer objects for external communication, for example
AnimalDto, WeightDto.
- Mapping: AutoMapper profiles for entity-DTO transformations.
- ValidationBehaviour: MediatR pipeline behavior that validates all requests before handling.
|
- Depends only on domain layer.
- Uses MediatR for decoupled request/response.
- Separates reads (queries) from writes (commands).
- Validates requests with FluentValidation.
- Returns Result<T> for type-safe error handling.
- Manages transaction boundaries via IUnitOfWork.
|
| Infrastructure Layer |
Implements external concerns and provides concrete implementations. |
- Data Access: Entity Framework Core implementation, including DBContext, Value Object conversions for EF Core and Database Schema definitions.
- Repository Implementations (Adapters): Concrete data access implementations, for example
AnimalRepository: EF Core implementation of IAnimalRepository (Defined in Domain layer).
- Unit of Work: Coordinates persistence operations across repositories, for example
UnitOfWork: Manages transaction boundaries and SaveChangesAsync.
|
- Implements domain interfaces.
- Handles external dependencies.
- Database concerns.
- Transaction management.
- External service integration.
|
| API Layer (Presentation) |
Handles HTTP concerns and user interface interactions. |
- Controllers: REST API endpoints, for example
AnimalsController.
- Configuration: Application setup and dependency injection.
|
- HTTP-specific concerns.
- Request/Response handling.
- Error handling and status codes.
- Authentication/Authorization.
|
Principles
Prefer rich domain models
Entities contain data AND behaviour. Business rules are encapsulated.
✅DO: Encapsulate business logic and data together in entities
| // ✅ GOOD - Real example from Animal entity
public class Animal : BaseEntity
{
private string _name;
private Species _species;
private Breed _breed;
private Gender _gender;
public string Name
{
get => _name;
private set
{
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentException("Animal name cannot be null or empty.");
if (value.Length > 100)
throw new ArgumentException("Animal name cannot exceed 100 characters.");
_name = value.Trim();
}
}
public Weight? CurrentWeight { get; private set; }
public void SetCurrentWeight(double? weight, string unit = "kg")
{
CurrentWeight = weight.HasValue ? Weight.Create(weight.Value, unit) : null;
UpdateTimestamp();
}
public void SetDateOfBirth(DateTime? dateOfBirth)
{
if (dateOfBirth.HasValue && dateOfBirth.Value > DateTime.Today)
throw new ArgumentException("Date of birth cannot be in the future.");
DateOfBirth = dateOfBirth;
UpdateTimestamp();
}
}
|
❌DON'T: Create entities with only properties and no behaviour
| // ❌ BAD - Anemic model
public class Animal
{
public string Name { get; set; } // No validation
public decimal Weight { get; set; } // No validation, no behaviour
public DateTime DateOfBirth { get; set; } // No validation
}
|
Respect Layer Dependencies
Dependency Rules:
- Domain → No dependencies
- Application → Depends on Domain only
- Infrastructure → Depends on Domain (and optionally Application)
- API → Depends on Application and Infrastructure
✅DO: Ensure dependencies always point toward the Domain (inner layers)
| // ✅ GOOD - Infrastructure implements Domain interface
// Domain Layer
public interface IPetRepository { }
// Infrastructure Layer
public class PetRepository : IPetRepository { } // Depends on Domain
|
❌DON'T: Let inner layers depend on outer layers
| // ❌ BAD - Domain depending on Infrastructure
// Domain Layer
public class Pet
{
private readonly DbContext _context; // NEVER! Domain shouldn't know about EF Core
}
|
Abstract at boundaries only
When to create interfaces:
- ✅ Repository interfaces (abstracts data access)
- ✅ Unit of Work interface (abstracts transaction management)
- ✅ External service interfaces (email, storage, APIs)
- ✅ Multiple implementations exist or planned
- ❌ Application services (unless you have a concrete reason)
- ❌ Domain services (usually)
✅DO: Create interfaces for external dependencies (databases, APIs, file systems)
| // ✅ GOOD - Interfaces at boundaries
public interface IAnimalRepository { } // Abstracts data access
public interface IUnitOfWork { } // Abstracts transaction management
public interface IEmailService { } // Abstracts external service
|
❌DON'T: Create interfaces for everything, especially application services
| // ❌ BAD - Unnecessary abstraction
public interface IPetService { }
public class PetService : IPetService { } // Why? No alternative implementation
|
Keep controllers thin
Controllers should only:
- Handle HTTP requests/responses
- Call application services
- Map request DTOs to commands
- Return appropriate status codes
✅DO: Keep controllers thin. They should only handle HTTP concerns and delegate to services
| // ✅ GOOD - Thin controller
[ApiController]
[Route("api/pets")]
public class PetsController : ControllerBase
{
private readonly PetManagementService _service;
[HttpPost("{id}/weight")]
public async Task<IActionResult> UpdateWeight(Guid id, [FromBody] UpdateWeightRequest request)
{
await _service.UpdateWeightAsync(id, request.WeightKg);
return NoContent();
}
}
|
❌DON'T: Put business logic, validation, or data access in controllers
| // ❌ BAD - Fat controller
[HttpPost("{id}/weight")]
public async Task<IActionResult> UpdateWeight(Guid id, [FromBody] UpdateWeightRequest request)
{
// Validation in controller
if (request.WeightKg <= 0)
return BadRequest("Invalid weight");
// Data access in controller
var pet = await _context.Pets.FindAsync(id);
// Business logic in controller
if (request.WeightKg > pet.Weight * 2)
return BadRequest("Weight change too drastic");
pet.Weight = request.WeightKg;
await _context.SaveChangesAsync();
return NoContent();
}
|
Enforce rules in the Domain
Where validation belongs:
- ✅ Domain entities (business invariants)
- ✅ Value objects (format/range validation)
- ✅ Domain services (multi-entity rules)
- ⚠️ Application layer (use case-specific validation)
- ⚠️ API layer (HTTP input validation only)
✅DO: Place all business rules and validation in domain entities and value objects
| // ✅ GOOD - Real example from Weight value object
public record Weight
{
public double Value { get; }
public string Unit { get; }
private Weight(double value, string unit = "kg")
{
Value = value;
Unit = unit;
}
public static Weight Create(double value, string unit = "kg")
{
// Validation in value object
if (value <= 0)
throw new ArgumentException("Weight must be greater than zero.");
if (value > 1000)
throw new ArgumentException("Weight cannot exceed 1000kg.");
var validUnits = new[] { "kg", "lb", "g" };
if (!validUnits.Contains(unit.ToLowerInvariant()))
throw new ArgumentException($"Invalid unit '{unit}'. Valid units are: {string.Join(", ", validUnits)}.");
return new Weight(value, unit.ToLowerInvariant());
}
public Weight ConvertTo(string targetUnit)
{
// Conversion logic with validation
var valueInKg = Unit switch
{
"kg" => Value,
"lb" => Value * 0.453592,
"g" => Value / 1000,
_ => throw new InvalidOperationException($"Unknown unit: {Unit}")
};
var convertedValue = targetUnit.ToLowerInvariant() switch
{
"kg" => valueInKg,
"lb" => valueInKg / 0.453592,
"g" => valueInKg * 1000,
_ => throw new ArgumentException($"Invalid target unit: {targetUnit}")
};
return new Weight(Math.Round(convertedValue, 2), targetUnit.ToLowerInvariant());
}
}
|
❌DON'T: Scatter validation across controllers, services, or skip it entirely
| // ❌ BAD - Rules scattered everywhere
// In Controller
if (request.Weight <= 0)
return BadRequest();
// In Service
if (newWeight > oldWeight * 2)
throw new Exception();
// In Domain
public decimal Weight { get; set; } // No validation at all!
|
Maintain separation of concerns
| Layer |
Responsibility |
Should NOT Contain |
| Domain |
Business logic, rules, entities |
Database, HTTP, external services |
| Application |
Use case orchestration |
Business rules, data access details |
| Infrastructure |
Database, external APIs, file system |
Business logic |
| API |
HTTP, routing, auth |
Business logic, data access |
✅DO: Keep each layer focused on its specific responsibility
| // ✅ GOOD - Clear separation
// Domain - Business logic
public class Pet
{
public void UpdateWeight(Weight newWeight)
{
// Pure business rule
if (newWeight.Kilograms <= 0)
throw new DomainException("Weight must be positive");
_weight = newWeight;
}
}
// Application - Use case orchestration
public class PetManagementService
{
public async Task UpdateWeightAsync(Guid petId, decimal weightKg)
{
var pet = await _repo.GetByIdAsync(petId);
pet.UpdateWeight(Weight.FromKilograms(weightKg));
await _repo.UpdateAsync(pet);
}
}
// Infrastructure - Data access implementation
public class PetRepository : IPetRepository
{
public async Task<Pet> GetByIdAsync(Guid id)
{
return await _context.Pets
.Include(p => p.Vaccinations)
.FirstOrDefaultAsync(p => p.Id == id);
}
}
// API - HTTP concerns
[HttpPost("{id}/weight")]
public async Task<IActionResult> UpdateWeight(Guid id, UpdateWeightRequest request)
{
await _service.UpdateWeightAsync(id, request.WeightKg);
return NoContent();
}
|
❌DON'T: Mix responsibilities across layers
| // ❌ BAD - Mixed concerns
public class PetService
{
public async Task UpdateWeight(Guid petId, decimal weightKg)
{
// HTTP concern in service layer
if (!HttpContext.User.IsAuthenticated)
throw new UnauthorizedException();
// Data access logic in service
var pet = await _context.Pets.FindAsync(petId);
// Business rule buried in service
if (weightKg <= 0)
throw new Exception("Invalid weight");
pet.Weight = weightKg;
// Direct DbContext usage (infrastructure concern)
await _context.SaveChangesAsync();
// Logging mixed with business logic
_logger.LogInformation($"Updated weight for pet {petId}");
// Email sending (external service) mixed in
await _emailService.SendWeightUpdateEmail(pet.OwnerId);
}
}
|
Make testing easy
Testability Guidelines:
- ✅ DO:
- Use dependency injection for all external dependencies
- Keep domain entities free of infrastructure concerns
- Make methods pure when possible (same input = same output)
- Use interfaces for boundaries (repositories, external services)
- Keep methods small and focused
- ❌ DON'T:
- Create dependencies with new inside classes
- Use static methods for anything that touches I/O
- Mix business logic with infrastructure
- Hard-code configuration values
- Create methods with many side effects
✅DO: Write code that's easy to test by using dependency injection and interfaces
| // ✅ GOOD - Testable design
// Application Service
public class PetManagementService
{
private readonly IPetRepository _petRepo;
private readonly IEmailService _emailService;
public PetManagementService(IPetRepository petRepo, IEmailService emailService)
{
_petRepo = petRepo;
_emailService = emailService;
}
public async Task RegisterPetAsync(RegisterPetCommand command)
{
var pet = Pet.Create(
PetName.Create(command.Name),
Weight.FromKilograms(command.WeightKg)
);
await _petRepo.AddAsync(pet);
await _emailService.SendWelcomeEmailAsync(command.OwnerEmail);
}
}
// Easy to unit test with mocks
[Fact]
public async Task RegisterPet_ShouldCreatePetAndSendEmail()
{
// Arrange
var mockRepo = new Mock<IPetRepository>();
var mockEmail = new Mock<IEmailService>();
var service = new PetManagementService(mockRepo.Object, mockEmail.Object);
var command = new RegisterPetCommand
{
Name = "Fluffy",
WeightKg = 5.2m,
OwnerEmail = "owner@example.com"
};
// Act
await service.RegisterPetAsync(command);
// Assert
mockRepo.Verify(r => r.AddAsync(It.IsAny<Pet>()), Times.Once);
mockEmail.Verify(e => e.SendWelcomeEmailAsync("owner@example.com"), Times.Once);
}
// Domain entities are easy to test (no dependencies)
[Fact]
public void UpdateWeight_WithNegativeValue_ShouldThrowException()
{
// Arrange
var pet = Pet.Create(PetName.Create("Fluffy"), Weight.FromKilograms(5));
// Act & Assert
Assert.Throws<DomainException>(() =>
pet.UpdateWeight(Weight.FromKilograms(-1))
);
}
|
❌DON'T: Create tightly coupled code that's hard to test
| // ❌ BAD - Hard to test
public class PetService
{
// No dependency injection - creates own dependencies
public async Task RegisterPet(string name, decimal weightKg, string email)
{
// Hard-coded dependency
var context = new PetFolioDbContext();
var emailService = new SmtpEmailService();
// Can't test without real database
var pet = new Pet
{
Name = name,
Weight = weightKg
};
context.Pets.Add(pet);
await context.SaveChangesAsync();
// Can't test without sending real emails
await emailService.SendEmail(email, "Welcome!");
}
}
// Impossible to unit test - requires real database and SMTP server
// Static dependencies make mocking impossible
public class PetValidator
{
public bool IsValid(Pet pet)
{
// Static call - can't mock
var settings = ConfigurationManager.AppSettings["MaxWeight"];
// Direct database access - can't test without DB
using var context = new PetFolioDbContext();
var existingPet = context.Pets.FirstOrDefault(p => p.Name == pet.Name);
return pet.Weight < decimal.Parse(settings) && existingPet == null;
}
}
|
Manage transactions with Unit of Work
Transaction Management:
- Use the Unit of Work pattern to coordinate database operations
- Repository methods add entities to the context without saving
- Application services coordinate work and call SaveChangesAsync
- Keep transaction boundaries in the Application layer
✅DO: Use IUnitOfWork to manage transaction boundaries in the Application layer
| // ✅ GOOD - Unit of Work manages transactions
// Domain Layer - Interface definition
public interface IUnitOfWork
{
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}
public interface IAnimalRepository
{
Task CreateAsync(Animal animal, CancellationToken cancellationToken = default);
}
// Infrastructure Layer - Implementation
public class UnitOfWork(PetfolioDbContext context) : IUnitOfWork
{
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
return await context.SaveChangesAsync(cancellationToken);
}
}
public class AnimalRepository(PetfolioDbContext context) : IAnimalRepository
{
public async Task CreateAsync(Animal animal, CancellationToken cancellationToken = default)
{
await context.Animals.AddAsync(animal, cancellationToken);
// Note: Does NOT call SaveChangesAsync - that's the Unit of Work's job
}
}
// Application Layer - Coordinates work and manages transaction
public class AnimalService
{
private readonly IAnimalRepository _animalRepository;
private readonly IUnitOfWork _unitOfWork;
public AnimalService(IAnimalRepository animalRepository, IUnitOfWork unitOfWork)
{
_animalRepository = animalRepository;
_unitOfWork = unitOfWork;
}
public async Task<AnimalDto> CreateAsync(CreateAnimalDto dto)
{
var animal = Animal.Create(dto.Name, dto.Species, dto.Breed, dto.Gender);
if (dto.DateOfBirth.HasValue)
animal.SetDateOfBirth(dto.DateOfBirth);
if (dto.Weight.HasValue)
animal.SetCurrentWeight(dto.Weight, dto.WeightUnit);
await _animalRepository.CreateAsync(animal);
await _unitOfWork.SaveChangesAsync(); // Transaction boundary
return mapper.Map<AnimalDto>(animal);
}
}
|
❌DON'T: Reference DbContext directly in Application layer or save in repositories
| // ❌ BAD - Application layer depends on Infrastructure
using Petfolio.Service.Infrastructure.Persistence; // WRONG!
public class AnimalService
{
private readonly IAnimalRepository _animalRepository;
private readonly PetfolioDbContext _context; // Application layer shouldn't know about DbContext
public async Task<AnimalDto> CreateAsync(CreateAnimalDto dto)
{
var animal = Animal.Create(dto.Name, dto.Species, dto.Breed, dto.Gender);
await _animalRepository.CreateAsync(animal);
await _context.SaveChangesAsync(); // Violates Clean Architecture!
return mapper.Map<AnimalDto>(animal);
}
}
// ❌ ALSO BAD - Repository handles SaveChanges
public class AnimalRepository(PetfolioDbContext context) : IAnimalRepository
{
public async Task CreateAsync(Animal animal, CancellationToken cancellationToken = default)
{
await context.Animals.AddAsync(animal, cancellationToken);
await context.SaveChangesAsync(cancellationToken); // Repository shouldn't control transactions!
// This makes it impossible to coordinate multiple repository calls in one transaction
}
}
|