PetFolio Service¶
The PetFolio Service is a .NET 9 backend API built with Clean Architecture and CQRS pattern for managing pet portfolios. The architecture emphasises separation of concerns, testability, and maintainability through strict layering and dependency rules.
- Clean Architecture: Dependency inversion with all 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 automatic pipeline execution
- Repository Pattern: Abstract data access through interfaces
- Unit of Work: Coordinate database transactions
Technology stack
| Technology | Category | Purpose |
|---|---|---|
| .NET 9 | Framework | Modern, high-performance web API framework |
| ASP.NET Core | Web Framework | RESTful API hosting and HTTP layer |
| MySQL | Database | Relational data storage |
| Entity Framework | ORM | Database access and migrations |
| MediatR | Mediator | CQRS implementation and request handling |
| FluentValidation | Validation | Input validation with fluent API |
| AutoMapper | Mapping | DTO to entity mapping |
| xUnit | Testing | Test framework for all test types |
| Shouldly | Assertions | Fluent assertion library |
| NSubstitute | Mocking | Test doubles for dependencies |
| Testcontainers | Integration Tests | Docker-based test database provisioning |
| Bogus | Test Data | Fake data generation for tests |
| Swashbuckle | Documentation | OpenAPI/Swagger documentation |
Principles¶
These apply across all layers. For layer-specific guidance, see the individual layer pages.
Restrict dependencies between layers
Dependencies flow inward toward the Domain layer. The core business logic has no external dependencies.
1 2 3 | |
Abstract on boundaries only
Create interfaces where there is a genuine boundary between layers or systems. Do not create interfaces for internal application services that have only one implementation.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | |
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
DON'T: Mix responsibilities across layers
Make testing easy
- 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
DO: Write code that is easy to test by using dependency injection and interfaces
DON'T: Create tightly coupled code that is hard to test
Use consistent naming
Rules:
- One class per file, file name matches class name
- Tests mirror source structure exactly
- Use cases grouped under feature folders
| File Type | Pattern | Example |
|---|---|---|
| Entity | PascalCase | Animal.cs |
| Value Object | PascalCase | Name.cs, Weight.cs |
| Command | [Verb][Entity]Command |
CreateAnimalCommand.cs |
| Query | Get[Entity(s)]Query |
GetAnimalQuery.cs |
| Handler | [Command/Query]Handler |
CreateAnimalCommandHandler.cs |
| Validator | [Command/Query]Validator |
CreateAnimalCommandValidator.cs |
| DTO | [Entity]Dto |
AnimalDto.cs |
| Repository | I[Entity]Repository |
IAnimalRepository.cs |
| Error Definitions | [Entity]Errors |
AnimalErrors.cs |
| Test | [ClassUnderTest]Tests |
AnimalTests.cs |
Think security, always
- FluentValidation: Automatic validation pipeline prevents invalid data reaching handlers
- Domain Validation: Business rules enforced at the domain boundary
- DTO Pattern: External data never directly mapped to entities
- Parameterised Queries: EF Core prevents SQL injection by default
- Connection String Management: Stored in appsettings (development) or environment variables (production)
- Migrations: Version-controlled schema changes
- HTTPS Only: Enforced in production
- CORS Configuration: Controlled cross-origin access
- Swagger: Disabled in production builds
Consider performance at all levels
- Async/Await: All database operations are asynchronous
- Query Efficiency: Repository pattern allows optimised queries
- Connection Pooling: Built-in ADO.NET connection pooling
- Centrally Managed Packages:
Directory.Packages.propsensures consistent versions - Incremental Builds: Only recompile changed projects
- Minimal API Surface: Thin controllers delegate to MediatR
- Shared Test Container: Integration tests reuse single MySQL container
- Parallel Execution: xUnit runs tests in parallel where possible
- Fast Feedback: Full test suite runs in ~1.5 seconds
Clean Architecture Layers¶
Dependencies flow inward toward the Domain layer. The core business logic has no external dependencies.
| Layer | Responsibility | Detail |
|---|---|---|
| Domain | Business logic, entities, value objects, repository interfaces | No external dependencies |
| Application | Use case orchestration - commands, queries, validation, DTOs | Depends on Domain only |
| Infrastructure | Database access, EF Core, repository implementations | Implements Domain interfaces |
| API | HTTP endpoints, controllers, middleware | Thin - delegates to Application |
graph TD
subgraph "Petfolio.Service.Api"
A[API Layer]
end
subgraph "Petfolio.Service.Application"
B[Application Layer]
end
subgraph "Petfolio.Service.Infrastructure"
C[Infrastructure Layer]
end
subgraph "Petfolio.Service.Domain"
D[Domain Layer]
end
A --> B
A --> C
B --> D
C --> D
classDef layer-api fill:#4a90d926,stroke:#4a90d9,stroke-width:2px
classDef layer-domain fill:#7ed32126,stroke:#7ed321,stroke-width:2px
classDef layer-infra fill:#f5a6232e,stroke:#f5a623,stroke-width:2px
classDef neutral fill:#c8c8c826,stroke:#999999,stroke-width:1.5px
class A layer-api
class B neutral
class C layer-infra
class D layer-domain
CQRS pattern¶
The system uses CQRS (Command Query Responsibility Segregation) - commands handle writes, queries handle reads - with MediatR orchestrating the pipeline.
Commands and Queries are strictly separated for clarity and scalability.
Every request follows the same pipeline:
- Controller receives the HTTP request and sends a command or query via MediatR
- ValidationBehaviour runs FluentValidation validators automatically - if validation fails, the handler never executes
- Handler orchestrates the work - calling domain logic for commands, or reading data for queries
- Result is returned to the controller, which converts it to an HTTP response
Commands vs Queries
- Commands: Write operations (Create, Update, Delete) returning
Result<T> - Queries: Read operations returning
Result<T> -
All requests routed through MediatR pipeline with automatic validation
Aspect Commands (write) Queries (read) Purpose Create, update, or delete data Read and return data Interface ICommand<TResponse>IQuery<TResponse>Validation Runs via ValidationBehaviourpipelineOptional State change Yes - persists via IUnitOfWorkNo - read-only Return type Result<T>(success or failure)Result<T>with DTOsMapping Domain entities created/modified Domain entities mapped to DTOs via AutoMapper
graph LR
A[Client Request] --> B{Command or Query?}
B -->|Write| C[Command Handler]
B -->|Read| D[Query Handler]
C --> E[Repository Write]
D --> F[Repository Read]
E --> G[Database]
F --> G
classDef layer-api fill:#4a90d926,stroke:#4a90d9,stroke-width:2px
classDef layer-domain fill:#7ed32126,stroke:#7ed321,stroke-width:2px
classDef layer-infra fill:#f5a6232e,stroke:#f5a623,stroke-width:2px
classDef layer-external fill:#9b9b9b2e,stroke:#9b9b9b,stroke-width:2px
classDef storage fill:#4a90d91f,stroke:#4a90d9,stroke-width:2px
class A layer-external
class C,D layer-domain
class E,F layer-infra
class G storage
Command request flow¶
This sequence diagram shows the complete pipeline for CreateEntityCommand, including both the validation and handler execution phases.
sequenceDiagram
actor User
participant Controller as EntityController
participant MediatR as MediatR Pipeline
participant Validator as ValidationBehaviour
participant FluentVal as CreateEntityCommandValidator
participant Handler as CreateEntityCommandHandler
participant Domain as Entity.Create()
participant Repo as IEntityRepository
participant UoW as IUnitOfWork
participant DB as MySQL Database
User->>Controller: POST /api/Entity/Create<br>{name, ...}
Controller->>MediatR: _sender.Send(CreateEntityCommand)
rect rgb(255, 245, 230)
Note over MediatR,FluentVal: Step 1: Automatic Validation
MediatR->>Validator: Execute ValidationBehaviour
Validator->>FluentVal: Validate(command)
alt Validation Fails
FluentVal-->>Validator: Validation errors
Validator-->>Controller: throw ValidationException
Controller-->>User: 400 Bad Request<br>{errors: [...]}
else Validation Passes
FluentVal-->>Validator: Valid
end
end
rect rgb(230, 245, 255)
Note over MediatR,DB: Step 2: Command Handler Execution
MediatR->>Handler: Handle(CreateEntityCommand)
Handler->>Domain: Entity.Create(accountId, name, ...)
alt Domain Validation Fails
Domain-->>Handler: throw ArgumentException
Handler->>Handler: Catch exception
Handler-->>MediatR: Result.Failure(error)
else Domain Validation Passes
Domain->>Domain: Raise EntityCreatedDomainEvent
Domain-->>Handler: Return Entity entity
Handler->>Repo: CreateAsync(entity)
Repo->>Repo: _context.Entities.Add(entity)
Repo-->>Handler: Task completed
Handler->>UoW: SaveChangesAsync()
UoW->>DB: INSERT INTO Entities...
DB-->>UoW: Success
UoW-->>Handler: Changes saved
Handler-->>MediatR: Result.Success(animalId)
end
end
MediatR-->>Controller: Result<Guid>
alt Result.IsSuccess
Controller-->>User: 201 Created<br>{id: "guid", ...}
else Result.IsFailure
Controller-->>User: 400 Bad Request<br>{error: {...}}
end
Query request flow¶
Queries are simpler - no validation pipeline, no state changes. The handler reads from the repository and maps the result to a DTO.
sequenceDiagram
actor User
participant Controller as AnimalsController
participant MediatR as MediatR Pipeline
participant Handler as GetAnimalQueryHandler
participant Repo as IAnimalRepository
participant DB as MySQL Database
participant Mapper as AutoMapper
User->>Controller: GET /api/Animals/Get/{id}
Controller->>MediatR: _sender.Send(GetAnimalQuery)
MediatR->>Handler: Handle(GetAnimalQuery)
Handler->>Repo: GetByIdAsync(id)
Repo->>DB: SELECT * FROM Animals WHERE Id = @id
alt Animal Not Found
DB-->>Repo: null
Repo-->>Handler: null
Handler-->>MediatR: Result.Failure(AnimalErrors.NotFound)
MediatR-->>Controller: Result<AnimalDto> (failure)
Controller-->>User: 404 Not Found
else Animal Found
DB-->>Repo: Animal entity
Repo-->>Handler: Animal entity
Handler->>Mapper: Map<AnimalDto>(animal)
Mapper-->>Handler: AnimalDto
Handler-->>MediatR: Result.Success(animalDto)
MediatR-->>Controller: Result<AnimalDto> (success)
Controller-->>User: 200 OK<br>{id, name, species, ...}
end
Result pattern¶
The Result pattern provides type-safe error handling without exceptions at the application layer. Domain exceptions are caught by handlers and converted to Result.Failure().
How the Result pattern is used across layers:
- Domain layer: Throws exceptions for business rule violations (e.g.
ArgumentExceptionfrom value objectCreate()methods) - Application layer: Handlers catch domain exceptions and return
Result.Failure(error), or returnResult.Success(value)on the happy path - API layer: Controllers check
result.IsFailureand convert to the appropriate HTTP status code (400, 404, etc.)
Two-layer validation¶
PetFolio validates data at two points in the pipeline. Both layers run automatically - no manual invocation required.
Layer 1: Application validation¶
- Technology: FluentValidation +
ValidationBehaviourMediatR pipeline - When it runs: Before the handler executes
- What it checks: Required fields, format checks, business constraints on input
- On failure: Throws
ValidationException, handler never runs, controller returns 400
See Application Layer - Validation Pipeline for implementation details.
Layer 2: Domain validation¶
- Technology: Value object
Create()factory methods + entity business methods - When it runs: Inside the handler, when domain objects are created or modified
- What it checks: Business invariants (e.g. Email must be valid RFC 5322, Name 1-100 chars, Weight must be positive)
- On failure: Throws
ArgumentException, caught by handler, returned asResult.Failure()
See Domain Layer - Validation for details.