Skip to content

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
!!! success "DO: Ensure dependencies always point toward the Domain (inner layers)"

!!! failure "DON'T: Let inner layers depend on outer layers"
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
**When to create interfaces:**

- Repository interfaces (abstracts data access)
- Unit of Work interface (abstracts transaction management)
- External service interfaces (email, storage, APIs)
- Cross-cutting service interfaces (user context, tenant context, claims)
- Multiple implementations exist or are planned

**When NOT to create interfaces:**

- Application services with a single implementation
- Domain services (usually)

!!! success "DO: Create interfaces for external dependencies (databases, APIs, file systems)"

!!! failure "DON'T: Create interfaces for everything, especially application services"
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.props ensures 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:

  1. Controller receives the HTTP request and sends a command or query via MediatR
  2. ValidationBehaviour runs FluentValidation validators automatically - if validation fails, the handler never executes
  3. Handler orchestrates the work - calling domain logic for commands, or reading data for queries
  4. 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 ValidationBehaviour pipeline Optional
    State change Yes - persists via IUnitOfWork No - read-only
    Return type Result<T> (success or failure) Result<T> with DTOs
    Mapping 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. ArgumentException from value object Create() methods)
  • Application layer: Handlers catch domain exceptions and return Result.Failure(error), or return Result.Success(value) on the happy path
  • API layer: Controllers check result.IsFailure and convert to the appropriate HTTP status code (400, 404, etc.)
// Application layer handler returns Result
public async Task<Result<AnimalDto>> Handle(GetAnimalQuery query, CancellationToken cancellationToken)
{
    var animal = await _repository.GetByIdAsync(query.Id);

    if (animal is null)
        return Result.Failure<AnimalDto>(AnimalErrors.NotFound);

    return Result.Success(_mapper.Map<AnimalDto>(animal));
}

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 + ValidationBehaviour MediatR 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 as Result.Failure()

See Domain Layer - Validation for details.