Skip to content

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.

How Validation is Configured

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
1
2
3
4
5
6
7
// ❌ 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)
1
2
3
4
5
6
// ✅ 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
1
2
3
4
5
6
// ❌ 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)
1
2
3
4
// ✅ 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
1
2
3
// ❌ 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
    }
}