Advanced

ASP.NET Core Clean Architecture

Implement Clean Architecture with the GloboTicket app — core, infrastructure, API, Blazor UI and testing.

Reference Application: GloboTicket Ticket Management


Table of Contents

  1. Course Overview
  2. Introduction and GloboTicket Context
  3. Core Architectural Principles
  4. Setting Up the Application Core
  5. Creating the Infrastructure Project
  6. Adding an API with ASP.NET Core
  7. Testing the Application Code
  8. Adding the Blazor UI
  9. Improving Application Behavior
  10. Best Practices and Anti-patterns
  11. Review Questions

1. Course Overview

This course teaches how to build an enterprise-grade ASP.NET Core API using Clean Architecture while applying best practices and sound architectural principles.

Main Goals

  • Understand Clean Architecture and apply it to an ASP.NET Core API
  • Learn best practices, including dependency inversion and separation of concerns
  • Create a testable and maintainable codebase
  • Expose the API via Swagger and consume it from a Blazor WebAssembly application
  • Implement cross-cutting concerns: logging (Serilog), exception handling (middleware), authentication (ASP.NET Core Identity)
  • Use CQRS with MediatR, AutoMapper, FluentValidation

Prerequisites

  • Good knowledge of C# (records, generics, async/await)
  • Familiarity with ASP.NET Core and REST APIs
  • Basic knowledge of Entity Framework Core
  • Visual Studio 2022 with .NET 8 SDK or higher

What You Will Build

graph TB
    subgraph "GloboTicket Solution"
        Domain["Domain\n(Pure POCOs)"]
        Application["Application\n(CQRS, MediatR, FluentValidation, AutoMapper)"]
        Persistence["Persistence\n(EF Core, SQL Server)"]
        Infrastructure["Infrastructure\n(SendGrid, external services)"]
        Identity["Identity\n(ASP.NET Identity + cookies)"]
        API["ASP.NET Core API\n(Thin controllers, Swagger, Serilog)"]
        Blazor["Blazor WASM\n(NSwag-generated client)"]
    end
    Domain --> Application
    Application --> Persistence
    Application --> Infrastructure
    Application --> Identity
    Persistence --> API
    Infrastructure --> API
    Identity --> API
    API <-->|"HTTP/JSON"| Blazor

2. Introduction and GloboTicket Context

2.1 The Client: GloboTicket

GloboTicket is a global leader in event ticket sales, operating for over 20 years. The existing system is built with legacy technologies (.NET Framework, ASP.NET Web Forms). Bob T. Hickett, the new IT director, has secured a budget to modernize the stack with ASP.NET Core. His team is guided by Bethany, the team lead.

Mary Goodsale, account manager, uses the system daily and is the primary source of business requirements.

2.2 Business Requirements

Based on interviews with Mary:

FeatureDescription
Event managementCreate, update, delete events
Category listingView all events grouped by category
Order trackingView all orders/sales by month
CSV exportExport the events list
PaginationPaginate the orders list
AuthenticationLogin/Register with email + password

2.3 Identified Entities

erDiagram
    Category {
        Guid CategoryId PK
        string Name
    }
    Event {
        Guid EventId PK
        string Name
        int Price
        string Artist
        DateTime Date
        string Description
        string ImageUrl
        Guid CategoryId FK
    }
    Order {
        Guid OrderId PK
        DateTime OrderPlaced
        bool OrderPaid
        Guid UserId
    }
    OrderLineItem {
        Guid OrderLineItemId PK
        int Price
        int Count
        Guid EventId FK
        Guid OrderId FK
    }
    Category ||--o{ Event : "has"
    Order ||--o{ OrderLineItem : "contains"
    Event ||--o{ OrderLineItem : "referenced by"

2.4 Technology Stack

LayerTechnology
APIASP.NET Core 8/9
Data accessEntity Framework Core 8/9
Internal messagingMediatR
Object mappingAutoMapper
ValidationFluentValidation
Client UIBlazor WebAssembly
DatabaseSQL Server
API documentationSwagger (Swashbuckle)
Client generationNSwag / NSwagStudio
EmailSendGrid
LoggingSerilog
AuthenticationASP.NET Core Identity (cookies)
TestsxUnit, Moq, Shouldly

3. Core Architectural Principles

3.1 Fundamental Principles

Dependency Inversion (DIP — SOLID)

Dependencies should point toward abstractions (interfaces), not toward concrete details (implementations).

Before DIP:   Controller --> EventService --> EventRepository
              (tight coupling at compile time, hard to test)

After DIP:    Controller --> IEventService <-- EventService
                              IEventRepository <-- EventRepository
              (each component only knows the contract)
// Bad — direct coupling to implementation
public class EventsController
{
    private readonly EventService _service = new EventService(); // impossible to mock
}

// Good — coupling via an interface
public class EventsController
{
    private readonly IMediator _mediator; // interface = abstraction

    public EventsController(IMediator mediator) => _mediator = mediator;
}

Separation of Concerns (SoC)

Each module should have only one reason to change. A class should not simultaneously access the database, send emails, AND validate data.

Single Responsibility Principle (SRP)

One class = one responsibility.

  • CreateEventCommandHandler → only creates an event
  • CreateEventCommandValidator → only validates the create command
  • EventsController → only routes HTTP requests to MediatR

DRY (Don’t Repeat Yourself)

Avoid code duplication. AuditableEntity centralizes audit logic for all entities.

Persistence Ignorance

Domain entities should not know how they are persisted. We use POCOs (Plain Old CLR Objects) without EF Core attributes and without a base class imposed by the ORM.

// Pure POCO — no reference to EF Core
public class Event : AuditableEntity
{
    public Guid EventId { get; set; }
    public string Name { get; set; } = string.Empty;
    // ...
}

// Coupled to EF Core — violates persistence ignorance
// [Table("Events")]
// public class Event
// {
//     [Key]
//     [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
//     public Guid EventId { get; set; }
// }

3.2 Common Application Architecture Styles

graph TD
    A[Architecture Styles] --> B[All-in-one / File New Project]
    A --> C[N-Tier Layered Architecture]
    A --> D[Hexagonal / Onion Architecture]
    A --> E[Clean Architecture]

    B --> B1["Single project, everything inside\nEasy to start\nHard to maintain\nNo physical separation"]
    C --> C1["UI to BLL to DAL\nOne-way dependencies\nProblem: BL still depends on infra"]
    D --> D1["Domain at the center\nAdapters around it\nVery similar to Clean"]
    E --> E1["Domain + Application = Core\nInfrastructure wraps around\nUI wraps infrastructure\nDependencies point inward"]

N-Tier Layered Architecture

┌────────────────────────────┐
│   UI / Presentation        │  ← Accesses BLL
├────────────────────────────┤
│   Business Logic (BLL)     │  ← Accesses DAL
├────────────────────────────┤
│   Data Access (DAL)        │  ← Accesses DB
└────────────────────────────┘

Problem: The BLL layer still depends on infrastructure (DAL). Changing databases requires modifying the BLL.


3.3 Clean Architecture

graph TD
    subgraph "Concentric Circles — Clean Architecture"
        D["Domain\nEntities, Pure business rules\nNo external references"]
        A["Application\nUse cases, Interfaces/Contracts\nReferences Domain only"]
        I["Infrastructure\nEF Core, Email, Logging\nImplements Application interfaces"]
        U["UI / API\nControllers, Blazor\nUses Application via MediatR"]
    end
    D --> A --> I --> U

Core rule: Inner circles never know about outer circles. Dependencies always point inward.

graph LR
    API["API Layer"] -->|references| AppLayer["Application Layer"]
    API -->|references| PersistLayer["Persistence Layer"]
    API -->|references| InfraLayer["Infrastructure Layer"]
    AppLayer -->|references| DomainLayer["Domain Layer"]
    PersistLayer -->|references| AppLayer
    InfraLayer -->|references| AppLayer

Clean Architecture Advantages

AdvantageExplanation
TestabilityThe core (Domain + Application) can be tested without starting infrastructure
MaintainabilityEach layer can change independently
Framework independenceChanging ORM or email provider does not touch the core
Database independenceIAsyncRepository<T> can have a SQL Server, PostgreSQL or in-memory implementation
Clear organizationEvery developer knows where to put each type of code

4. Setting Up the Application Core

4.1 Complete Solution Structure

GloboTicket.TicketManagement/
├── src/
│   ├── Core/
│   │   ├── GloboTicket.TicketManagement.Domain/
│   │   │   ├── Common/
│   │   │   │   └── AuditableEntity.cs
│   │   │   └── Entities/
│   │   │       ├── Event.cs
│   │   │       ├── Category.cs
│   │   │       └── Order.cs
│   │   └── GloboTicket.TicketManagement.Application/
│   │       ├── Contracts/
│   │       │   ├── Persistence/
│   │       │   │   ├── IAsyncRepository.cs
│   │       │   │   ├── IEventRepository.cs
│   │       │   │   ├── ICategoryRepository.cs
│   │       │   │   └── IOrderRepository.cs
│   │       │   └── Infrastructure/
│   │       │       └── IEmailService.cs
│   │       ├── Features/
│   │       │   ├── Events/
│   │       │   │   ├── Commands/
│   │       │   │   │   ├── CreateEvent/
│   │       │   │   │   ├── UpdateEvent/
│   │       │   │   │   └── DeleteEvent/
│   │       │   │   └── Queries/
│   │       │   │       ├── GetEventsList/
│   │       │   │       ├── GetEventDetail/
│   │       │   │       └── GetEventsExport/
│   │       │   ├── Categories/
│   │       │   └── Orders/
│   │       ├── Models/
│   │       │   └── Mail/
│   │       │       └── Email.cs
│   │       ├── Exceptions/
│   │       │   ├── BadRequestException.cs
│   │       │   ├── NotFoundException.cs
│   │       │   └── ValidationException.cs
│   │       ├── Behaviors/
│   │       │   ├── ValidationBehavior.cs
│   │       │   └── LoggingBehavior.cs
│   │       └── ApplicationServiceRegistration.cs
│   ├── Infrastructure/
│   │   ├── GloboTicket.TicketManagement.Persistence/
│   │   │   ├── GloboTicketDbContext.cs
│   │   │   ├── Repositories/
│   │   │   ├── Configurations/
│   │   │   ├── Migrations/
│   │   │   └── PersistenceServiceRegistration.cs
│   │   ├── GloboTicket.TicketManagement.Infrastructure/
│   │   │   ├── Mail/
│   │   │   │   └── EmailService.cs
│   │   │   └── InfrastructureServiceRegistration.cs
│   │   └── GloboTicket.TicketManagement.Identity/
│   │       ├── GloboTicketIdentityDbContext.cs
│   │       ├── Models/
│   │       │   └── ApplicationUser.cs
│   │       └── IdentityServiceExtensions.cs
│   ├── API/
│   │   └── GloboTicket.TicketManagement.Api/
│   │       ├── Controllers/
│   │       ├── Middleware/
│   │       │   └── ExceptionHandlerMiddleware.cs
│   │       ├── StartupExtensions.cs
│   │       ├── Program.cs
│   │       └── appsettings.json
│   └── UI/
│       └── GloboTicket.TicketManagement.App/  (Blazor WASM)
└── test/
    ├── GloboTicket.TicketManagement.Application.UnitTests/
    ├── GloboTicket.TicketManagement.API.IntegrationTests/
    └── GloboTicket.TicketManagement.Persistence.IntegrationTests/

4.2 The Domain Project

The domain contains pure business entities (POCOs) with no reference to EF Core or other infrastructure. It is the most stable layer — it only changes when business rules change.

Base Entity: AuditableEntity

namespace GloboTicket.TicketManagement.Domain.Common
{
    // Base class for all entities that need audit traceability
    public class AuditableEntity
    {
        public string? CreatedBy { get; set; }
        public DateTime CreatedDate { get; set; }
        public string? LastModifiedBy { get; set; }
        public DateTime? LastModifiedDate { get; set; }
    }
}

Event Entity

using GloboTicket.TicketManagement.Domain.Common;

namespace GloboTicket.TicketManagement.Domain.Entities
{
    public class Event : AuditableEntity
    {
        public Guid EventId { get; set; }
        public string Name { get; set; } = string.Empty;
        public int Price { get; set; }
        public string? Artist { get; set; }
        public DateTime Date { get; set; }
        public string? Description { get; set; }
        public string? ImageUrl { get; set; }
        public Guid CategoryId { get; set; }

        // Navigation property — configured via IEntityTypeConfiguration in Persistence
        public Category Category { get; set; } = default!;
    }
}

Category Entity

public class Category : AuditableEntity
{
    public Guid CategoryId { get; set; }
    public string Name { get; set; } = string.Empty;

    // Navigation collection
    public ICollection<Event> Events { get; set; } = new List<Event>();
}

Order Entity

public class Order : AuditableEntity
{
    public Guid OrderId { get; set; }
    public DateTime OrderPlaced { get; set; }
    public bool OrderPaid { get; set; }
    public Guid UserId { get; set; }
    public ICollection<OrderLineItem> OrderLineItems { get; set; } = new List<OrderLineItem>();
}

Why no [Key], [Required], or [Column]? These attributes create a dependency on System.ComponentModel.DataAnnotations or EF Core, violating persistence ignorance. EF Core configuration is done in dedicated IEntityTypeConfiguration<T> classes in the Persistence project.


4.3 The Application Project — Detailed Architecture

This is the core of Clean Architecture. It contains application use cases, organized into independent features.

graph TD
    Application["Application Layer"] --> Contracts["Contracts/\nInterfaces"]
    Application --> Features["Features/\nCQRS with MediatR"]
    Application --> Models["Models/\nInternal DTOs"]
    Application --> Exceptions["Exceptions/\nbusiness exceptions"]
    Application --> Profiles["Profiles/\nAutoMapper"]
    Application --> Behaviors["Behaviors/\nMediatR Pipeline"]

    Contracts --> CPersistence["Persistence/\nIAsyncRepository, IEventRepository"]
    Contracts --> CInfra["Infrastructure/\nIEmailService"]

    Features --> FEvents["Events/"]
    Features --> FCategories["Categories/"]
    Features --> FOrders["Orders/"]
    FEvents --> Commands["Commands/\nCreateEvent UpdateEvent DeleteEvent"]
    FEvents --> Queries["Queries/\nGetEventsList GetEventDetail GetEventsExport"]

Application Project .csproj File

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <!-- References only Domain — never Persistence or Infrastructure -->
    <ProjectReference Include="..\GloboTicket.TicketManagement.Domain\GloboTicket.TicketManagement.Domain.csproj" />
  </ItemGroup>

  <ItemGroup>
    <PackageReference Include="AutoMapper" Version="12.0.1" />
    <PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" />
    <PackageReference Include="FluentValidation" Version="11.9.0" />
    <PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.9.0" />
    <PackageReference Include="MediatR" Version="12.2.0" />
    <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
  </ItemGroup>
</Project>

IAsyncRepository Interface (generic persistence contract)

namespace GloboTicket.TicketManagement.Application.Contracts.Persistence
{
    public interface IAsyncRepository<T> where T : class
    {
        Task<T?> GetByIdAsync(Guid id);
        Task<IReadOnlyList<T>> ListAllAsync();
        Task<IReadOnlyList<T>> GetPagedResponseAsync(int page, int size);
        Task<T> AddAsync(T entity);
        Task UpdateAsync(T entity);
        Task DeleteAsync(T entity);
    }
}

IEventRepository Interface (specific repository)

namespace GloboTicket.TicketManagement.Application.Contracts.Persistence
{
    public interface IEventRepository : IAsyncRepository<Event>
    {
        // Business rule: name + date uniqueness
        Task<bool> IsEventNameAndDateUnique(string name, DateTime eventDate);

        // Specific query not covered by the generic repository
        Task<IReadOnlyList<Event>> GetEventsByCategory(Guid categoryId);
    }
}

ICategoryRepository Interface

public interface ICategoryRepository : IAsyncRepository<Category>
{
    Task<List<Category>> GetCategoriesWithEvents(bool includePassedEvents);
}

IOrderRepository Interface

public interface IOrderRepository : IAsyncRepository<Order>
{
    Task<IReadOnlyList<Order>> GetPagedOrdersForMonth(DateTime date, int page, int size);
    Task<int> GetTotalCountOfOrdersForMonth(DateTime date);
}

4.4 MediatR Pattern — Detailed Operation

Why MediatR?

MediatR introduces a mediator between components. Instead of the controller calling a service directly (tight coupling), it sends a message (command or query). The corresponding handler takes care of that message.

sequenceDiagram
    participant C as Controller
    participant M as MediatR
    participant B as Pipeline Behaviors
    participant H as Handler
    participant R as Repository
    participant DB as Database

    C->>M: mediator.Send(GetEventsListQuery)
    M->>B: LoggingBehavior.Handle()
    B->>B: ValidationBehavior — FluentValidation
    B->>H: GetEventsListQueryHandler.Handle()
    H->>R: eventRepository.ListAllAsync()
    R->>DB: SELECT * FROM Events
    DB-->>R: List Event
    R-->>H: List Event
    H-->>M: List EventListVm mapped by AutoMapper
    M-->>C: List EventListVm

Pipeline Behaviors — Cross-cutting concerns

MediatR allows inserting behaviors that execute around each handler, without modifying the handler itself.

graph LR
    Request["Request/Command"] --> LB["LoggingBehavior"]
    LB --> VB["ValidationBehavior"]
    VB --> Handler["Business Handler"]
    Handler --> VB2["ValidationBehavior return"]
    VB2 --> LB2["LoggingBehavior return"]
    LB2 --> Response["Response"]
// Pipeline Behavior for FluentValidation
public class ValidationBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly IEnumerable<IValidator<TRequest>> _validators;

    public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
    {
        _validators = validators;
    }

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        if (_validators.Any())
        {
            var context = new ValidationContext<TRequest>(request);

            var validationResults = await Task.WhenAll(
                _validators.Select(v => v.ValidateAsync(context, cancellationToken)));

            var failures = validationResults
                .SelectMany(r => r.Errors)
                .Where(f => f != null)
                .ToList();

            if (failures.Any())
                throw new ValidationException(failures);
        }

        return await next(); // Call next handler in pipeline
    }
}
// Pipeline Behavior for automatic logging of all requests
public class LoggingBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly ILogger<LoggingBehavior<TRequest, TResponse>> _logger;

    public LoggingBehavior(ILogger<LoggingBehavior<TRequest, TResponse>> logger)
    {
        _logger = logger;
    }

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        _logger.LogInformation("Handling {RequestName} — {Request}",
            typeof(TRequest).Name, request);

        var response = await next();

        _logger.LogInformation("Handled {ResponseName}", typeof(TResponse).Name);

        return response;
    }
}

Registering Behaviors in ApplicationServiceRegistration

public static class ApplicationServiceRegistration
{
    public static IServiceCollection AddApplicationServices(this IServiceCollection services)
    {
        services.AddAutoMapper(Assembly.GetExecutingAssembly());

        services.AddMediatR(cfg =>
        {
            cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly());
            // Register behaviors in execution order
            cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
            cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
        });

        // Automatically register all validators from the assembly
        services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());

        return services;
    }
}

4.5 CQRS — Separating Commands and Queries

CQRS (Command Query Responsibility Segregation) separates read operations (Queries) from write operations (Commands), each with its own models.

graph TB
    subgraph "Commands — Data Modification"
        C1["CreateEventCommand\nIRequest Guid"] --> CH1["CreateEventCommandHandler"]
        C2["UpdateEventCommand\nIRequest Unit"] --> CH2["UpdateEventCommandHandler"]
        C3["DeleteEventCommand\nIRequest Unit"] --> CH3["DeleteEventCommandHandler"]
    end
    subgraph "Queries — Data Reading"
        Q1["GetEventsListQuery\nIRequest List EventListVm"] --> QH1["GetEventsListQueryHandler"]
        Q2["GetEventDetailQuery\nIRequest EventDetailVm"] --> QH2["GetEventDetailQueryHandler"]
        Q3["GetEventsExportQuery\nIRequest EventExportFileVm"] --> QH3["GetEventsExportQueryHandler"]
    end

Feature Organization (vertical cohesion principle)

Each feature is a folder containing all code related to a use case:

Features/
├── Events/
│   ├── Commands/
│   │   ├── CreateEvent/
│   │   │   ├── CreateEventCommand.cs
│   │   │   ├── CreateEventCommandHandler.cs
│   │   │   └── CreateEventCommandValidator.cs
│   │   ├── UpdateEvent/
│   │   │   ├── UpdateEventCommand.cs
│   │   │   ├── UpdateEventCommandHandler.cs
│   │   │   └── UpdateEventCommandValidator.cs
│   │   └── DeleteEvent/
│   │       ├── DeleteEventCommand.cs
│   │       └── DeleteEventCommandHandler.cs
│   └── Queries/
│       ├── GetEventsList/
│       │   ├── GetEventsListQuery.cs
│       │   ├── GetEventsListQueryHandler.cs
│       │   └── EventListVm.cs
│       └── GetEventDetail/
│           ├── GetEventDetailQuery.cs
│           ├── GetEventDetailQueryHandler.cs
│           └── EventDetailVm.cs

4.6 Complete Query Example: GetEventsList

Message (Query)

using MediatR;

namespace GloboTicket.TicketManagement.Application.Features.Events.Queries.GetEventsList
{
    // No parameters: returns all events
    public class GetEventsListQuery : IRequest<List<EventListVm>>
    {
    }
}

List ViewModel (contains only necessary properties)

namespace GloboTicket.TicketManagement.Application.Features.Events.Queries.GetEventsList
{
    public class EventListVm
    {
        public Guid EventId { get; set; }
        public string Name { get; set; } = string.Empty;
        public DateTime Date { get; set; }
        public string? ImageUrl { get; set; }
        public Guid CategoryId { get; set; }
        public string? CategoryName { get; set; }
        public int Price { get; set; }
    }
}

Query Handler

public class GetEventsListQueryHandler : IRequestHandler<GetEventsListQuery, List<EventListVm>>
{
    private readonly IEventRepository _eventRepository;
    private readonly IMapper _mapper;

    public GetEventsListQueryHandler(IMapper mapper, IEventRepository eventRepository)
    {
        _mapper = mapper;
        _eventRepository = eventRepository;
    }

    public async Task<List<EventListVm>> Handle(
        GetEventsListQuery request,
        CancellationToken cancellationToken)
    {
        var allEvents = (await _eventRepository.ListAllAsync()).OrderBy(x => x.Date);
        return _mapper.Map<List<EventListVm>>(allEvents);
    }
}

AutoMapper Profile for Events

using AutoMapper;

namespace GloboTicket.TicketManagement.Application.Profiles
{
    public class MappingProfile : Profile
    {
        public MappingProfile()
        {
            // Domain Entity -> ViewModel (read)
            CreateMap<Event, EventListVm>()
                .ForMember(dest => dest.CategoryName,
                    opt => opt.MapFrom(src => src.Category != null ? src.Category.Name : string.Empty));

            CreateMap<Event, EventDetailVm>();

            // Command -> Domain Entity (write)
            CreateMap<CreateEventCommand, Event>();
            CreateMap<UpdateEventCommand, Event>();

            // Category mappings
            CreateMap<Category, CategoryListVm>();
            CreateMap<Category, CategoryWithEventsVm>()
                .ForMember(dest => dest.Events,
                    opt => opt.MapFrom(src => src.Events));

            CreateMap<CreateCategoryCommand, Category>();

            // Order mappings
            CreateMap<Order, OrdersForMonthVm>();
        }
    }
}

4.7 Complete Command Example: CreateEvent

Message (Command)

using MediatR;

namespace GloboTicket.TicketManagement.Application.Features.Events.Commands.CreateEvent
{
    public class CreateEventCommand : IRequest<Guid>
    {
        public string Name { get; set; } = string.Empty;
        public int Price { get; set; }
        public string? Artist { get; set; }
        public DateTime Date { get; set; }
        public string? Description { get; set; }
        public string? ImageUrl { get; set; }
        public Guid CategoryId { get; set; }
    }
}

Command Handler

public class CreateEventCommandHandler : IRequestHandler<CreateEventCommand, Guid>
{
    private readonly IEventRepository _eventRepository;
    private readonly IMapper _mapper;
    private readonly IEmailService _emailService;
    private readonly ILogger<CreateEventCommandHandler> _logger;

    public CreateEventCommandHandler(
        IMapper mapper,
        IEventRepository eventRepository,
        IEmailService emailService,
        ILogger<CreateEventCommandHandler> logger)
    {
        _mapper = mapper;
        _eventRepository = eventRepository;
        _emailService = emailService;
        _logger = logger;
    }

    public async Task<Guid> Handle(
        CreateEventCommand request,
        CancellationToken cancellationToken)
    {
        // NOTE: Validation is handled by ValidationBehavior in the MediatR pipeline
        // If not using the pipeline behavior, validate manually here:
        // var validator = new CreateEventCommandValidator(_eventRepository);
        // var validationResult = await validator.ValidateAsync(request, cancellationToken);
        // if (validationResult.Errors.Any())
        //     throw new ValidationException(validationResult.Errors);

        // Mapping Command -> Entity
        var @event = _mapper.Map<Event>(request);

        // Persistence via repository (never EF Core directly here)
        @event = await _eventRepository.AddAsync(@event);

        // Cross-cutting concern: send notification email
        var email = new Email
        {
            To = "admin@globoticket.com",
            Body = $"A new event has been created: {request.Name}",
            Subject = "New GloboTicket Event"
        };

        try
        {
            await _emailService.SendEmail(email);
        }
        catch (Exception ex)
        {
            // Email failure should not block event creation
            _logger.LogError("Email sending failed for event {EventId}: {Error}",
                @event.EventId, ex.Message);
        }

        return @event.EventId;
    }
}

UpdateEventCommand and its Handler

public class UpdateEventCommand : IRequest<Unit>
{
    public Guid EventId { get; set; }
    public string Name { get; set; } = string.Empty;
    public int Price { get; set; }
    public string? Artist { get; set; }
    public DateTime Date { get; set; }
    public string? Description { get; set; }
    public string? ImageUrl { get; set; }
    public Guid CategoryId { get; set; }
}

public class UpdateEventCommandHandler : IRequestHandler<UpdateEventCommand, Unit>
{
    private readonly IEventRepository _eventRepository;
    private readonly IMapper _mapper;

    public UpdateEventCommandHandler(IMapper mapper, IEventRepository eventRepository)
    {
        _mapper = mapper;
        _eventRepository = eventRepository;
    }

    public async Task<Unit> Handle(UpdateEventCommand request, CancellationToken cancellationToken)
    {
        var eventToUpdate = await _eventRepository.GetByIdAsync(request.EventId);

        if (eventToUpdate == null)
            throw new NotFoundException(nameof(Event), request.EventId);

        // AutoMapper updates the existing entity (no new())
        _mapper.Map(request, eventToUpdate, typeof(UpdateEventCommand), typeof(Event));

        await _eventRepository.UpdateAsync(eventToUpdate);

        return Unit.Value;
    }
}

4.8 Validation with FluentValidation

Instead of DataAnnotations on entities, we use FluentValidation in dedicated classes per feature.

Validator for CreateEventCommand

using FluentValidation;

namespace GloboTicket.TicketManagement.Application.Features.Events.Commands.CreateEvent
{
    public class CreateEventCommandValidator : AbstractValidator<CreateEventCommand>
    {
        private readonly IEventRepository _eventRepository;

        public CreateEventCommandValidator(IEventRepository eventRepository)
        {
            _eventRepository = eventRepository;

            RuleFor(p => p.Name)
                .NotEmpty().WithMessage("{PropertyName} is required.")
                .NotNull()
                .MaximumLength(50).WithMessage("{PropertyName} must not exceed 50 characters.");

            RuleFor(p => p.Date)
                .NotEmpty().WithMessage("{PropertyName} is required.")
                .GreaterThan(DateTime.Now).WithMessage("{PropertyName} must be in the future.");

            RuleFor(p => p.Price)
                .NotEmpty().WithMessage("{PropertyName} is required.")
                .GreaterThan(0).WithMessage("{PropertyName} must be greater than 0.");

            RuleFor(p => p.CategoryId)
                .NotEqual(Guid.Empty).WithMessage("{PropertyName} is required.");

            // Combined business rule — impossible with simple DataAnnotations
            RuleFor(e => e)
                .MustAsync(EventNameAndDateUnique)
                .WithMessage("An event with the same name and date already exists.");
        }

        private async Task<bool> EventNameAndDateUnique(
            CreateEventCommand e,
            CancellationToken token)
        {
            // Returns true if the event is UNIQUE (no duplicate)
            return !(await _eventRepository.IsEventNameAndDateUnique(e.Name, e.Date));
        }
    }
}

Validator for UpdateEventCommand

public class UpdateEventCommandValidator : AbstractValidator<UpdateEventCommand>
{
    public UpdateEventCommandValidator()
    {
        RuleFor(p => p.EventId)
            .NotEqual(Guid.Empty).WithMessage("{PropertyName} is required.");

        RuleFor(p => p.Name)
            .NotEmpty().WithMessage("{PropertyName} is required.")
            .MaximumLength(50).WithMessage("{PropertyName} must not exceed 50 characters.");

        RuleFor(p => p.Date)
            .NotEmpty().WithMessage("{PropertyName} is required.")
            .GreaterThan(DateTime.Now).WithMessage("{PropertyName} must be in the future.");
    }
}

Custom Exceptions in Application

// NotFoundException — for entities not found — corresponds to HTTP 404
public class NotFoundException : ApplicationException
{
    public NotFoundException(string name, object key)
        : base($"Entity \"{name}\" ({key}) was not found.")
    {
    }
}

// BadRequestException — for invalid requests — corresponds to HTTP 400
public class BadRequestException : ApplicationException
{
    public BadRequestException(string message) : base(message)
    {
    }
}

// ValidationException — for FluentValidation errors — corresponds to HTTP 422
public class ValidationException : ApplicationException
{
    public List<string> ValidationErrors { get; }

    public ValidationException(IList<ValidationFailure> failures)
    {
        ValidationErrors = failures
            .Select(f => f.ErrorMessage)
            .ToList();
    }
}

4.9 Query with CSV Export

Query, ViewModel and Handler

// Message
public class GetEventsExportQuery : IRequest<EventExportFileVm> { }

// ViewModel containing the file
public class EventExportFileVm
{
    public string EventExportFileName { get; set; } = string.Empty;
    public string ContentType { get; set; } = string.Empty;
    public byte[]? Data { get; set; }
}

// Interface in Application (the contract)
public interface ICsvExporter
{
    byte[] ExportEventsToCsv(List<EventExportDto> eventExportDtos);
}

// Handler
public class GetEventsExportQueryHandler
    : IRequestHandler<GetEventsExportQuery, EventExportFileVm>
{
    private readonly IEventRepository _eventRepository;
    private readonly IMapper _mapper;
    private readonly ICsvExporter _csvExporter;

    public GetEventsExportQueryHandler(
        IMapper mapper, IEventRepository eventRepository, ICsvExporter csvExporter)
    {
        _mapper = mapper;
        _eventRepository = eventRepository;
        _csvExporter = csvExporter;
    }

    public async Task<EventExportFileVm> Handle(
        GetEventsExportQuery request, CancellationToken cancellationToken)
    {
        var allEvents = _mapper.Map<List<EventExportDto>>(
            await _eventRepository.ListAllAsync());

        var fileData = _csvExporter.ExportEventsToCsv(allEvents);

        return new EventExportFileVm
        {
            ContentType = "text/csv",
            Data = fileData,
            EventExportFileName = $"EventExport_{DateTime.Now:dd-MMM-yyyy}.csv"
        };
    }
}

5. Creating the Infrastructure Project

5.1 Role of Infrastructure Projects

graph TD
    Core["Core — Domain + Application\nAbstractions / Contracts"]

    subgraph "Concrete Infrastructure"
        Persistence["Persistence\nGloboTicketDbContext\nBaseRepository\nSpecific Repositories\nEF Core Migrations\nSeed data"]
        Infrastructure["Infrastructure\nEmailService SendGrid\nCsvExporter CsvHelper\nMessage bus, external APIs"]
        Identity["Identity\nGloboTicketIdentityDbContext\nApplicationUser\nIdentity Endpoints"]
    end

    Core -->|"defines IAsyncRepository,\nIEmailService, ICsvExporter"| Persistence
    Core -->|"defines IEmailService"| Infrastructure
    Persistence -->|"implements"| Core
    Infrastructure -->|"implements"| Core

Principle: Whenever an external mechanism is introduced (database, email, file, third-party API), the code goes into Infrastructure.


5.2 EF Core Configuration with IEntityTypeConfiguration

Instead of using Data Annotations on entities, we use separate configuration classes in the Persistence project.

Event Entity Configuration

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

namespace GloboTicket.TicketManagement.Persistence.Configurations
{
    public class EventConfiguration : IEntityTypeConfiguration<Event>
    {
        public void Configure(EntityTypeBuilder<Event> builder)
        {
            builder.HasKey(e => e.EventId);

            builder.Property(e => e.Name)
                .IsRequired()
                .HasMaxLength(50);

            builder.Property(e => e.Price)
                .IsRequired();

            builder.Property(e => e.Date)
                .IsRequired();

            builder.Property(e => e.Description)
                .HasMaxLength(500);

            // Relationship: an Event belongs to a Category
            builder.HasOne(e => e.Category)
                .WithMany(c => c.Events)
                .HasForeignKey(e => e.CategoryId)
                .OnDelete(DeleteBehavior.Restrict);

            // Seed data — demo data
            builder.HasData(
                new Event
                {
                    EventId = Guid.Parse("{EE272F8B-6096-4CB6-8625-BB4BB2D89E8B}"),
                    Name = "John Egbert Live",
                    Price = 65,
                    Artist = "John Egbert",
                    Date = new DateTime(2024, 6, 10),
                    Description = "An amazing concert",
                    CategoryId = Guid.Parse("{B0788D2F-8003-43C1-92A4-EDC76A7C5DDE}"),
                    CreatedDate = DateTime.Now
                }
            );
        }
    }
}

Category Entity Configuration

public class CategoryConfiguration : IEntityTypeConfiguration<Category>
{
    public void Configure(EntityTypeBuilder<Category> builder)
    {
        builder.HasKey(c => c.CategoryId);

        builder.Property(c => c.Name)
            .IsRequired()
            .HasMaxLength(50);

        // Unique index on category name
        builder.HasIndex(c => c.Name).IsUnique();

        // Seed data
        builder.HasData(
            new Category
            {
                CategoryId = Guid.Parse("{B0788D2F-8003-43C1-92A4-EDC76A7C5DDE}"),
                Name = "Concerts"
            },
            new Category
            {
                CategoryId = Guid.Parse("{6313179F-7837-473A-A4D5-A5571B43E6A6}"),
                Name = "Musicals"
            },
            new Category
            {
                CategoryId = Guid.Parse("{BF3F3002-7E53-441E-8B76-F6280BE284AA}"),
                Name = "Plays"
            },
            new Category
            {
                CategoryId = Guid.Parse("{FE98F549-E790-4E9F-AA16-18C2292A2EE9}"),
                Name = "Conferences"
            }
        );
    }
}

5.3 GloboTicketDbContext

using Microsoft.EntityFrameworkCore;

namespace GloboTicket.TicketManagement.Persistence
{
    public class GloboTicketDbContext : DbContext
    {
        private readonly ILoggedInUserService _loggedInUserService;

        public GloboTicketDbContext(
            DbContextOptions<GloboTicketDbContext> options,
            ILoggedInUserService loggedInUserService)
            : base(options)
        {
            _loggedInUserService = loggedInUserService;
        }

        // DbSets — EF Core collections for each entity
        public DbSet<Event> Events => Set<Event>();
        public DbSet<Category> Categories => Set<Category>();
        public DbSet<Order> Orders => Set<Order>();

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            // Automatically applies all IEntityTypeConfiguration<T>
            // found in this assembly
            modelBuilder.ApplyConfigurationsFromAssembly(
                typeof(GloboTicketDbContext).Assembly);

            base.OnModelCreating(modelBuilder);
        }

        // Override to automatically populate audit fields
        public override Task<int> SaveChangesAsync(
            CancellationToken cancellationToken = default)
        {
            foreach (var entry in ChangeTracker.Entries<AuditableEntity>())
            {
                switch (entry.State)
                {
                    case EntityState.Added:
                        entry.Entity.CreatedDate = DateTime.UtcNow;
                        entry.Entity.CreatedBy = _loggedInUserService.UserId;
                        break;
                    case EntityState.Modified:
                        entry.Entity.LastModifiedDate = DateTime.UtcNow;
                        entry.Entity.LastModifiedBy = _loggedInUserService.UserId;
                        break;
                }
            }

            return base.SaveChangesAsync(cancellationToken);
        }
    }
}

5.4 BaseRepository and Specific Repositories

BaseRepository (generic implementation)

public class BaseRepository<T> : IAsyncRepository<T> where T : class
{
    protected readonly GloboTicketDbContext _dbContext;

    public BaseRepository(GloboTicketDbContext dbContext)
    {
        _dbContext = dbContext;
    }

    public virtual async Task<T?> GetByIdAsync(Guid id)
    {
        return await _dbContext.Set<T>().FindAsync(id);
    }

    public async Task<IReadOnlyList<T>> ListAllAsync()
    {
        return await _dbContext.Set<T>().ToListAsync();
    }

    public async Task<IReadOnlyList<T>> GetPagedResponseAsync(int page, int size)
    {
        return await _dbContext.Set<T>()
            .Skip((page - 1) * size)
            .Take(size)
            .AsNoTracking()   // Optimization: read-only
            .ToListAsync();
    }

    public async Task<T> AddAsync(T entity)
    {
        await _dbContext.Set<T>().AddAsync(entity);
        await _dbContext.SaveChangesAsync();
        return entity;
    }

    public async Task UpdateAsync(T entity)
    {
        _dbContext.Entry(entity).State = EntityState.Modified;
        await _dbContext.SaveChangesAsync();
    }

    public async Task DeleteAsync(T entity)
    {
        _dbContext.Set<T>().Remove(entity);
        await _dbContext.SaveChangesAsync();
    }
}

EventRepository (specific repository)

public class EventRepository : BaseRepository<Event>, IEventRepository
{
    public EventRepository(GloboTicketDbContext dbContext) : base(dbContext) { }

    public async Task<bool> IsEventNameAndDateUnique(string name, DateTime eventDate)
    {
        return await _dbContext.Events
            .AnyAsync(e => e.Name == name && e.Date == eventDate);
    }

    public async Task<IReadOnlyList<Event>> GetEventsByCategory(Guid categoryId)
    {
        return await _dbContext.Events
            .Where(e => e.CategoryId == categoryId)
            .Include(e => e.Category)
            .AsNoTracking()
            .ToListAsync();
    }
}

CategoryRepository

public class CategoryRepository : BaseRepository<Category>, ICategoryRepository
{
    public CategoryRepository(GloboTicketDbContext dbContext) : base(dbContext) { }

    public async Task<List<Category>> GetCategoriesWithEvents(bool includePassedEvents)
    {
        var allCategories = await _dbContext.Categories
            .Include(c => c.Events)
            .AsNoTracking()
            .ToListAsync();

        if (!includePassedEvents)
        {
            allCategories.ForEach(c =>
                c.Events = c.Events
                    .Where(e => e.Date >= DateTime.Today)
                    .ToList());
        }

        return allCategories;
    }
}

OrderRepository with Pagination

public class OrderRepository : BaseRepository<Order>, IOrderRepository
{
    public OrderRepository(GloboTicketDbContext dbContext) : base(dbContext) { }

    public async Task<IReadOnlyList<Order>> GetPagedOrdersForMonth(
        DateTime date, int page, int size)
    {
        return await _dbContext.Orders
            .Where(x => x.OrderPlaced.Month == date.Month
                     && x.OrderPlaced.Year == date.Year)
            .Skip((page - 1) * size)
            .Take(size)
            .AsNoTracking()
            .ToListAsync();
    }

    public async Task<int> GetTotalCountOfOrdersForMonth(DateTime date)
    {
        return await _dbContext.Orders
            .CountAsync(x => x.OrderPlaced.Month == date.Month
                          && x.OrderPlaced.Year == date.Year);
    }
}

5.5 Registering Persistence Services

public static class PersistenceServiceRegistration
{
    public static IServiceCollection AddPersistenceServices(
        this IServiceCollection services,
        IConfiguration configuration)
    {
        services.AddDbContext<GloboTicketDbContext>(options =>
            options.UseSqlServer(
                configuration.GetConnectionString("GloboTicketConnectionString"),
                sqlOptions => sqlOptions.EnableRetryOnFailure()));

        // Repository registrations
        services.AddScoped(typeof(IAsyncRepository<>), typeof(BaseRepository<>));
        services.AddScoped<IEventRepository, EventRepository>();
        services.AddScoped<ICategoryRepository, CategoryRepository>();
        services.AddScoped<IOrderRepository, OrderRepository>();

        return services;
    }
}

5.6 Infrastructure Project — EmailService with SendGrid

IEmailService Interface (in Application — the contract)

namespace GloboTicket.TicketManagement.Application.Contracts.Infrastructure
{
    public interface IEmailService
    {
        Task<bool> SendEmail(Email email);
    }
}

// Email model — in Application/Models/Mail/
public class Email
{
    public string To { get; set; } = string.Empty;
    public string Subject { get; set; } = string.Empty;
    public string Body { get; set; } = string.Empty;
}

EmailService Implementation with SendGrid (in Infrastructure)

using Microsoft.Extensions.Options;
using SendGrid;
using SendGrid.Helpers.Mail;

namespace GloboTicket.TicketManagement.Infrastructure.Mail
{
    public class EmailService : IEmailService
    {
        private readonly EmailSettings _emailSettings;
        private readonly ILogger<EmailService> _logger;

        public EmailService(
            IOptions<EmailSettings> mailSettings,
            ILogger<EmailService> logger)
        {
            _emailSettings = mailSettings.Value;
            _logger = logger;
        }

        public async Task<bool> SendEmail(Email email)
        {
            var client = new SendGridClient(_emailSettings.ApiKey);
            var from = new EmailAddress(_emailSettings.FromAddress, _emailSettings.FromName);
            var to = new EmailAddress(email.To);

            var message = MailHelper.CreateSingleEmail(
                from, to, email.Subject, email.Body, email.Body);

            var response = await client.SendEmailAsync(message);

            if (!response.IsSuccessStatusCode)
            {
                _logger.LogError("SendGrid failed. Status: {StatusCode}", response.StatusCode);
                return false;
            }

            return true;
        }
    }
}

EmailSettings (bound to appsettings.json)

public class EmailSettings
{
    public string ApiKey { get; set; } = string.Empty;
    public string FromAddress { get; set; } = string.Empty;
    public string FromName { get; set; } = string.Empty;
}

Registering Infrastructure Services

public static class InfrastructureServiceRegistration
{
    public static IServiceCollection AddInfrastructureServices(
        this IServiceCollection services,
        IConfiguration configuration)
    {
        services.Configure<EmailSettings>(
            configuration.GetSection("EmailSettings"));

        services.AddTransient<IEmailService, EmailService>();
        services.AddTransient<ICsvExporter, CsvExporter>();

        return services;
    }
}

5.7 ILoggedInUserService

// Contract (in Application)
public interface ILoggedInUserService
{
    string? UserId { get; }
}

// Implementation (in API)
public class LoggedInUserService : ILoggedInUserService
{
    private readonly IHttpContextAccessor _httpContextAccessor;

    public LoggedInUserService(IHttpContextAccessor httpContextAccessor)
    {
        _httpContextAccessor = httpContextAccessor;
    }

    public string? UserId =>
        _httpContextAccessor.HttpContext?.User?
            .FindFirstValue(ClaimTypes.NameIdentifier);
}

6. Adding an API with ASP.NET Core

6.1 Role of the API in Clean Architecture

The API plays the role of the UI layer in Clean Architecture. It contains no business logic — it delegates everything to the Application layer via MediatR.

graph LR
    subgraph "Clients"
        Blazor["Blazor WASM"]
        Angular["Angular App"]
        Mobile["Mobile App"]
    end
    subgraph "API Layer — thin layer"
        EC["EventsController"]
        CC["CategoryController"]
        OC["OrderController"]
        MW["ExceptionMiddleware"]
        SW["Swagger"]
    end
    subgraph "Application Core"
        Med["MediatR"]
        H["Handlers"]
        Val["Validators"]
        AutoM["AutoMapper"]
    end
    subgraph "Infrastructure"
        EF["EF Core"]
        SG["SendGrid"]
        Log["Serilog"]
    end

    Blazor & Angular & Mobile -->|HTTP/HTTPS| EC & CC & OC
    EC & CC & OC --> Med --> H
    H --> Val
    H --> AutoM
    H --> EF & SG
    MW -.->|"handles exceptions"| EC & CC & OC
    SW -.->|"documents"| EC & CC & OC

6.2 EventsController — MediatR Approach (thin controller)

using MediatR;
using Microsoft.AspNetCore.Mvc;

namespace GloboTicket.TicketManagement.Api.Controllers
{
    [ApiController]
    [Route("api/[controller]")]
    public class EventsController : ControllerBase
    {
        private readonly IMediator _mediator;

        public EventsController(IMediator mediator)
        {
            _mediator = mediator;
        }

        // GET api/events
        [HttpGet(Name = "GetAllEvents")]
        [ProducesResponseType(StatusCodes.Status200OK)]
        public async Task<ActionResult<List<EventListVm>>> GetAllEvents()
        {
            var result = await _mediator.Send(new GetEventsListQuery());
            return Ok(result);
        }

        // GET api/events/{id}
        [HttpGet("{id}", Name = "GetEventById")]
        [ProducesResponseType(StatusCodes.Status200OK)]
        [ProducesResponseType(StatusCodes.Status404NotFound)]
        public async Task<ActionResult<EventDetailVm>> GetEventById(Guid id)
        {
            var query = new GetEventDetailQuery { Id = id };
            return Ok(await _mediator.Send(query));
        }

        // POST api/events
        [HttpPost(Name = "AddEvent")]
        [ProducesResponseType(StatusCodes.Status200OK)]
        [ProducesResponseType(StatusCodes.Status400BadRequest)]
        [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
        public async Task<ActionResult<Guid>> Create(
            [FromBody] CreateEventCommand createEventCommand)
        {
            var id = await _mediator.Send(createEventCommand);
            return Ok(id);
        }

        // PUT api/events
        [HttpPut(Name = "UpdateEvent")]
        [ProducesResponseType(StatusCodes.Status204NoContent)]
        [ProducesResponseType(StatusCodes.Status404NotFound)]
        [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
        public async Task<ActionResult> Update(
            [FromBody] UpdateEventCommand updateEventCommand)
        {
            await _mediator.Send(updateEventCommand);
            return NoContent();
        }

        // DELETE api/events/{id}
        [HttpDelete("{id}", Name = "DeleteEvent")]
        [ProducesResponseType(StatusCodes.Status204NoContent)]
        [ProducesResponseType(StatusCodes.Status404NotFound)]
        public async Task<ActionResult> Delete(Guid id)
        {
            await _mediator.Send(new DeleteEventCommand { EventId = id });
            return NoContent();
        }

        // GET api/events/export
        [HttpGet("export", Name = "ExportEvents")]
        [FileResultContentType("text/csv")]
        public async Task<FileResult> ExportEvents()
        {
            var fileDto = await _mediator.Send(new GetEventsExportQuery());
            return File(fileDto.Data!, fileDto.ContentType, fileDto.EventExportFileName);
        }
    }
}

Thin controller principle: The controller only receives the HTTP request, builds the MediatR message, sends it, and returns the response. Zero business logic.


6.3 CategoryController

[ApiController]
[Route("api/[controller]")]
public class CategoryController : ControllerBase
{
    private readonly IMediator _mediator;

    public CategoryController(IMediator mediator)
    {
        _mediator = mediator;
    }

    // GET api/category/all
    [HttpGet("all")]
    public async Task<ActionResult<List<CategoryListVm>>> GetAllCategories()
    {
        return Ok(await _mediator.Send(new GetCategoriesListQuery()));
    }

    // GET api/category/allwithevents?includeHistory=false
    [HttpGet("allwithevents")]
    public async Task<ActionResult<List<CategoryWithEventsVm>>> GetCategoriesWithEvents(
        bool includeHistory)
    {
        return Ok(await _mediator.Send(
            new GetCategoriesWithEventsQuery { IncludeHistory = includeHistory }));
    }

    // POST api/category
    [HttpPost]
    public async Task<ActionResult<CreateCategoryCommandResponse>> Create(
        [FromBody] CreateCategoryCommand createCategoryCommand)
    {
        var response = await _mediator.Send(createCategoryCommand);
        return Ok(response);
    }
}

6.4 OrderController with Pagination

[ApiController]
[Route("api/[controller]")]
public class OrderController : ControllerBase
{
    private readonly IMediator _mediator;

    public OrderController(IMediator mediator)
    {
        _mediator = mediator;
    }

    // GET api/order?date=2024-01-01&page=1&size=10
    [HttpGet(Name = "GetPagedOrdersForMonth")]
    public async Task<ActionResult<PagedOrdersForMonthVm>> GetPagedOrdersForMonth(
        DateTime date,
        int page = 1,
        int size = 5)
    {
        var query = new GetPagedOrdersForMonthQuery
        {
            Date = date,
            Page = page,
            Size = size
        };

        return Ok(await _mediator.Send(query));
    }
}

6.5 Service Configuration (StartupExtensions)

public static class StartupExtensions
{
    public static WebApplication ConfigureServices(this WebApplicationBuilder builder)
    {
        // Register services by layer
        builder.Services.AddApplicationServices();
        builder.Services.AddInfrastructureServices(builder.Configuration);
        builder.Services.AddPersistenceServices(builder.Configuration);
        builder.Services.AddIdentityServices(builder.Configuration);

        // API-specific services
        builder.Services.AddScoped<ILoggedInUserService, LoggedInUserService>();
        builder.Services.AddHttpContextAccessor();

        // CORS
        builder.Services.AddCors(options =>
        {
            options.AddPolicy("open", policy =>
                policy.AllowAnyOrigin()
                      .AllowAnyHeader()
                      .AllowAnyMethod());
        });

        builder.Services.AddControllers();
        builder.Services.AddEndpointsApiExplorer();
        builder.Services.AddSwaggerGen(c =>
        {
            c.SwaggerDoc("v1", new OpenApiInfo
            {
                Version = "v1",
                Title = "GloboTicket Ticket Management API",
                Description = "GloboTicket event management API"
            });
        });

        return builder.Build();
    }

    public static WebApplication ConfigurePipeline(this WebApplication app)
    {
        app.UseCors("open");

        if (app.Environment.IsDevelopment())
        {
            app.UseSwagger();
            app.UseSwaggerUI(c =>
            {
                c.SwaggerEndpoint("/swagger/v1/swagger.json", "GloboTicket API v1");
            });
        }

        app.UseCustomExceptionHandler();   // Custom exception middleware
        app.UseSerilogRequestLogging();    // HTTP request logging
        app.UseHttpsRedirection();
        app.UseAuthentication();
        app.UseAuthorization();
        app.MapControllers();

        return app;
    }
}

6.6 Program.cs

using Serilog;

Log.Logger = new LoggerConfiguration()
    .WriteTo.Console()
    .CreateBootstrapLogger();

Log.Information("GloboTicket API starting...");

try
{
    var builder = WebApplication.CreateBuilder(args);

    builder.Host.UseSerilog((context, services, configuration) =>
        configuration
            .ReadFrom.Configuration(context.Configuration)
            .ReadFrom.Services(services)
            .Enrich.FromLogContext());

    var app = builder
        .ConfigureServices()
        .ConfigurePipeline();

    // Identity API endpoints (register/login/logout)
    app.MapGroup("/api").MapIdentityApi<ApplicationUser>();

    app.Run();
}
catch (Exception ex)
{
    Log.Fatal(ex, "Application terminated unexpectedly.");
}
finally
{
    Log.CloseAndFlush();
}

public partial class Program { }

6.7 appsettings.json

{
  "ConnectionStrings": {
    "GloboTicketConnectionString": "Server=(localdb)\\mssqllocaldb;Database=GloboTicket;Trusted_Connection=True;",
    "GloboTicketIdentityConnectionString": "Server=(localdb)\\mssqllocaldb;Database=GloboTicketIdentity;Trusted_Connection=True;"
  },
  "EmailSettings": {
    "ApiKey": "SG.YOUR_SENDGRID_KEY",
    "FromAddress": "noreply@globoticket.com",
    "FromName": "GloboTicket Team"
  },
  "Serilog": {
    "MinimumLevel": {
      "Default": "Information",
      "Override": {
        "Microsoft": "Warning",
        "Microsoft.Hosting.Lifetime": "Information",
        "System": "Warning"
      }
    },
    "WriteTo": [
      { "Name": "Console" },
      {
        "Name": "File",
        "Args": {
          "path": "logs/log-.txt",
          "rollingInterval": "Day",
          "retainedFileCountLimit": 30
        }
      }
    ],
    "Enrich": ["FromLogContext", "WithMachineName", "WithThreadId"]
  },
  "AllowedHosts": "*"
}

6.8 Swagger and NSwag

graph LR
    API["ASP.NET Core API\nSwashbuckle"] -->|"generates"| Spec["swagger.json\nOpenAPI spec"]
    Spec -->|"read by"| SwaggerUI["Swagger UI\n/swagger"]
    Spec -->|"read by"| NSwag["NSwagStudio"]
    NSwag -->|"generates"| ClientCode["C# or TypeScript client\nfor Blazor/Angular"]

NSwagStudio steps:

  1. Start the API
  2. Copy the URL https://localhost:{port}/swagger/v1/swagger.json
  3. Open NSwagStudio → paste in “Specification URL” → “Create local Copy”
  4. In “Outputs”: check “CSharp Client”
  5. Configure: namespace, “Inject HttpClient via constructor” (enables HttpClientFactory), “Generate DTO types”
  6. Click “Generate Files”

After every API change: re-run NSwag to regenerate the Blazor client.


7. Testing the Application Code

7.1 Types of Tests

graph LR
    T["Test Pyramid"] --> U["Unit Tests 70%\nFast, isolated\nMoq, xUnit, Shouldly"]
    T --> I["Integration Tests 20%\nMultiple layers\nIn-memory DB\nWebApplicationFactory"]
    T --> F["Functional Tests 10%\nFull application\nSelenium / Playwright\nSlowest"]

Why our architecture is testable:

  • Handlers only depend on interfaces → easy to mock
  • EF Core supports an in-memory DB for integration tests
  • Domain is pure POCO → no dependencies to instantiate

7.2 Unit Tests — Handler with xUnit, Moq, Shouldly

using AutoMapper;
using Moq;
using Shouldly;
using Xunit;

namespace GloboTicket.TicketManagement.Application.UnitTests.Features
{
    public class GetCategoryListQueryHandlerTests
    {
        private readonly IMapper _mapper;
        private readonly Mock<IAsyncRepository<Category>> _mockCategoryRepository;

        public GetCategoryListQueryHandlerTests()
        {
            _mockCategoryRepository = RepositoryMocks.GetCategoryRepository();

            var configurationProvider = new MapperConfiguration(cfg =>
            {
                cfg.AddProfile<MappingProfile>();
            });
            _mapper = configurationProvider.CreateMapper();
        }

        [Fact]
        public async Task Handle_ReturnsAllCategories()
        {
            // Arrange
            var handler = new GetCategoriesListQueryHandler(
                _mapper,
                _mockCategoryRepository.Object);

            // Act
            var result = await handler.Handle(
                new GetCategoriesListQuery(),
                CancellationToken.None);

            // Assert — Shouldly for readable assertions
            result.ShouldNotBeNull();
            result.ShouldBeOfType<List<CategoryListVm>>();
            result.Count.ShouldBe(4);
        }
    }
}

7.3 Mocking the Repository with Moq

public static class RepositoryMocks
{
    public static Mock<IAsyncRepository<Category>> GetCategoryRepository()
    {
        var concertGuid = Guid.Parse("{B0788D2F-8003-43C1-92A4-EDC76A7C5DDE}");
        var musicalGuid = Guid.Parse("{6313179F-7837-473A-A4D5-A5571B43E6A6}");

        var categories = new List<Category>
        {
            new Category { CategoryId = concertGuid, Name = "Concerts" },
            new Category { CategoryId = musicalGuid, Name = "Musicals" },
            new Category { Name = "Conferences" },
            new Category { Name = "Plays" },
        };

        var mockCategoryRepository = new Mock<IAsyncRepository<Category>>();

        mockCategoryRepository
            .Setup(repo => repo.ListAllAsync())
            .ReturnsAsync(categories);

        return mockCategoryRepository;
    }

    public static Mock<IEventRepository> GetEventRepository()
    {
        var events = new List<Event>
        {
            new Event
            {
                EventId = Guid.NewGuid(),
                Name = "John Egbert Live",
                Price = 65,
                Date = DateTime.Now.AddMonths(3)
            }
        };

        var mockEventRepository = new Mock<IEventRepository>();

        mockEventRepository
            .Setup(r => r.ListAllAsync())
            .ReturnsAsync(events);

        // By default: no duplicate
        mockEventRepository
            .Setup(r => r.IsEventNameAndDateUnique(It.IsAny<string>(), It.IsAny<DateTime>()))
            .ReturnsAsync(false);

        return mockEventRepository;
    }
}

7.4 FluentValidation Validator Tests

public class CreateEventCommandValidatorTests
{
    private readonly Mock<IEventRepository> _mockEventRepository;

    public CreateEventCommandValidatorTests()
    {
        _mockEventRepository = RepositoryMocks.GetEventRepository();
    }

    [Fact]
    public async Task Validate_ValidCommand_ShouldHaveNoErrors()
    {
        var validator = new CreateEventCommandValidator(_mockEventRepository.Object);
        var command = new CreateEventCommand
        {
            Name = "Test Concert",
            Price = 50,
            Date = DateTime.Now.AddMonths(1),
            CategoryId = Guid.NewGuid()
        };

        var result = await validator.ValidateAsync(command);

        result.IsValid.ShouldBeTrue();
    }

    [Fact]
    public async Task Validate_EmptyName_ShouldHaveError()
    {
        var validator = new CreateEventCommandValidator(_mockEventRepository.Object);
        var command = new CreateEventCommand
        {
            Name = string.Empty,
            Price = 50,
            Date = DateTime.Now.AddMonths(1),
            CategoryId = Guid.NewGuid()
        };

        var result = await validator.ValidateAsync(command);

        result.IsValid.ShouldBeFalse();
        result.Errors.ShouldContain(e => e.PropertyName == "Name");
    }

    [Fact]
    public async Task Validate_PastDate_ShouldHaveError()
    {
        var validator = new CreateEventCommandValidator(_mockEventRepository.Object);
        var command = new CreateEventCommand
        {
            Name = "Valid Name",
            Price = 50,
            Date = DateTime.Now.AddDays(-1), // Past date — invalid
            CategoryId = Guid.NewGuid()
        };

        var result = await validator.ValidateAsync(command);

        result.IsValid.ShouldBeFalse();
        result.Errors.ShouldContain(e => e.PropertyName == "Date");
    }
}

7.5 Integration Tests — DbContext with In-Memory DB

public class GloboTicketDbContextTests
{
    private readonly GloboTicketDbContext _dbContext;
    private const string CurrentUserId = "00000000-0000-0000-0000-000000000001";

    public GloboTicketDbContextTests()
    {
        var dbContextOptions = new DbContextOptionsBuilder<GloboTicketDbContext>()
            .UseInMemoryDatabase(Guid.NewGuid().ToString()) // Unique Guid = isolated DB per test
            .Options;

        var loggedInUserServiceMock = new Mock<ILoggedInUserService>();
        loggedInUserServiceMock.Setup(s => s.UserId).Returns(CurrentUserId);

        _dbContext = new GloboTicketDbContext(dbContextOptions, loggedInUserServiceMock.Object);
    }

    [Fact]
    public async Task SaveChanges_SetsCreatedByAndDate_ForAddedEntity()
    {
        // Arrange
        var ev = new Event
        {
            EventId = Guid.NewGuid(),
            Name = "Test Event",
            Price = 50,
            Date = DateTime.Now.AddMonths(1)
        };

        // Act
        _dbContext.Events.Add(ev);
        await _dbContext.SaveChangesAsync();

        // Assert
        ev.CreatedBy.ShouldBe(CurrentUserId);
        ev.CreatedDate.ShouldNotBe(default);
        ev.LastModifiedDate.ShouldBeNull();
    }

    [Fact]
    public async Task SaveChanges_SetsLastModifiedBy_ForModifiedEntity()
    {
        var ev = new Event { EventId = Guid.NewGuid(), Name = "Test", Price = 50 };
        _dbContext.Events.Add(ev);
        await _dbContext.SaveChangesAsync();

        ev.Name = "Modified Name";
        _dbContext.Events.Update(ev);
        await _dbContext.SaveChangesAsync();

        ev.LastModifiedBy.ShouldBe(CurrentUserId);
        ev.LastModifiedDate.ShouldNotBeNull();
    }
}

7.6 Integration Tests — API with WebApplicationFactory

// Custom factory replacing the real DB with an in-memory DB
public class CustomWebApplicationFactory<TProgram> : WebApplicationFactory<TProgram>
    where TProgram : class
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureServices(services =>
        {
            var descriptor = services.SingleOrDefault(
                d => d.ServiceType == typeof(DbContextOptions<GloboTicketDbContext>));
            if (descriptor != null) services.Remove(descriptor);

            services.AddDbContext<GloboTicketDbContext>(options =>
                options.UseInMemoryDatabase("InMemoryDbForTesting"));

            var sp = services.BuildServiceProvider();
            using var scope = sp.CreateScope();
            var db = scope.ServiceProvider.GetRequiredService<GloboTicketDbContext>();
            db.Database.EnsureCreated();
            SeedData(db);
        });
    }

    private static void SeedData(GloboTicketDbContext db)
    {
        db.Categories.AddRange(
            new Category
            {
                CategoryId = Guid.Parse("{B0788D2F-8003-43C1-92A4-EDC76A7C5DDE}"),
                Name = "Concerts"
            },
            new Category
            {
                CategoryId = Guid.Parse("{6313179F-7837-473A-A4D5-A5571B43E6A6}"),
                Name = "Musicals"
            }
        );
        db.SaveChanges();
    }
}

[Collection("IntegrationTests")]
public class CategoryControllerTests
    : IClassFixture<CustomWebApplicationFactory<Program>>
{
    private readonly HttpClient _client;

    public CategoryControllerTests(CustomWebApplicationFactory<Program> factory)
    {
        _client = factory.CreateClient();
    }

    [Fact]
    public async Task GetAllCategories_ReturnsSuccessAndCorrectContentType()
    {
        // Act — Real HTTP call (with in-memory DB)
        var response = await _client.GetAsync("/api/category/all");

        // Assert
        response.EnsureSuccessStatusCode();
        response.Content.Headers.ContentType?.MediaType
            .ShouldBe("application/json");

        var categories = await response.Content
            .ReadFromJsonAsync<List<CategoryListVm>>();
        categories.ShouldNotBeNull();
        categories.Count.ShouldBe(2);
    }
}

8. Adding the Blazor UI

8.1 Blazor WebAssembly — Overview

sequenceDiagram
    participant Browser as Browser
    participant CDN as Static File Server
    participant API as ASP.NET Core API

    Browser->>CDN: GET /index.html
    CDN-->>Browser: HTML + blazor.webassembly.js
    Browser->>CDN: Download .NET DLLs + WASM runtime
    CDN-->>Browser: app.dll, runtime.wasm, etc.
    Note over Browser: Application runs in C# in the browser
    Browser->>API: POST /api/login (Cookie Auth)
    API-->>Browser: Set-Cookie
    Browser->>API: GET /api/events with cookie
    API-->>Browser: JSON
    Browser->>Browser: Render Blazor components
Hosting ModelDescription
Blazor WASMRuns in the browser via WebAssembly. Ideal for SPAs.
Blazor ServerRuns on the server, SignalR communication.
Blazor HybridIn a MAUI/WPF app, via WebView.

8.2 Blazor Project Architecture

GloboTicket.TicketManagement.App/
├── Pages/
│   ├── EventOverview.razor
│   ├── EventOverview.razor.cs
│   └── CategoryOverview.razor
├── Services/
│   ├── IEventDataService.cs
│   ├── EventDataService.cs
│   └── ICategoryDataService.cs
├── ViewModels/
│   └── EventListViewModel.cs
├── Profiles/
│   └── MappingProfile.cs   (AutoMapper client-side)
├── Auth/
│   ├── CookieAuthenticationStateProvider.cs
│   └── AuthenticationService.cs
├── ServiceClient.cs         (generated by NSwag — do not edit manually)
└── Program.cs

8.3 Blazor Data Service

// Interface
public interface IEventDataService
{
    Task<List<EventListViewModel>> GetAllEvents();
    Task<EventDetailViewModel?> GetEventById(Guid id);
    Task<Guid> CreateEvent(EventCreateViewModel createViewModel);
    Task UpdateEvent(EventUpdateViewModel updateViewModel);
    Task DeleteEvent(Guid id);
}

// Implementation using NSwag-generated code
public class EventDataService : IEventDataService
{
    private readonly IClient _client;  // Interface generated by NSwag
    private readonly IMapper _mapper;

    public EventDataService(IClient client, IMapper mapper)
    {
        _client = client;
        _mapper = mapper;
    }

    public async Task<List<EventListViewModel>> GetAllEvents()
    {
        // _client is NSwag code that handles HTTP, serialization, errors
        var allEvents = await _client.GetAllEventsAsync();
        return _mapper.Map<List<EventListViewModel>>(allEvents);
    }
}

9. Improving Application Behavior

Key improvements to add after the initial implementation:

  • Global exception handling: Custom middleware that catches exceptions and returns appropriate HTTP status codes (404, 400, 422, 500)
  • Serilog structured logging: Structured log output to console and files
  • Response caching: Cache GET endpoints for better performance
  • Health checks: app.MapHealthChecks("/health") for monitoring
  • API versioning: Support multiple API versions simultaneously

10. Best Practices and Anti-patterns

Good Practices

CategoryBest Practice
DomainKeep entities as pure POCOs without infrastructure dependencies
ApplicationOrganize by feature (vertical slice), not by type
ValidatorsOne validator class per command/query
HandlersOne handler per command/query — SRP
ControllersThin controllers — zero business logic
RepositoriesInterface in Application, implementation in Persistence
Email/ExternalInterface in Application, implementation in Infrastructure
TestsMock interfaces, not concrete classes

Anti-patterns to Avoid

// ❌ Calling EF Core directly in a controller
public class EventsController : ControllerBase
{
    private readonly GloboTicketDbContext _context;
    
    public async Task<IActionResult> GetAll()
    {
        return Ok(await _context.Events.ToListAsync()); // Bypasses Clean Architecture
    }
}

// ✅ Using MediatR
public async Task<IActionResult> GetAll()
{
    return Ok(await _mediator.Send(new GetEventsListQuery()));
}

// ❌ Domain entity with EF Core attributes
[Table("Events")]
public class Event
{
    [Key]
    public Guid EventId { get; set; }
}

// ✅ Pure POCO + IEntityTypeConfiguration
public class Event : AuditableEntity
{
    public Guid EventId { get; set; }
    // Configuration in EventConfiguration.cs
}

// ❌ Business logic in a controller
public async Task<IActionResult> Create(CreateEventCommand cmd)
{
    if (await _context.Events.AnyAsync(e => e.Name == cmd.Name))
        return BadRequest("Name already exists");
    // ...
}

// ✅ Business logic in a validator + handler
public class CreateEventCommandValidator : AbstractValidator<CreateEventCommand>
{
    // Validation here, not in the controller
}

11. Review Questions

  1. What is the fundamental rule of Clean Architecture regarding dependencies?
  2. Why do we use MediatR instead of calling services directly from controllers?
  3. What is the difference between a Command and a Query in CQRS?
  4. Why does the Application project not reference Persistence or Infrastructure?
  5. What is a Pipeline Behavior and what are its use cases?
  6. Why do we use IEntityTypeConfiguration<T> instead of Data Annotations on entities?
  7. What is the role of the AuditableEntity base class?
  8. Why does the handler send an email notification and not the controller?
  9. What is NSwag and what problem does it solve in the Blazor project?
  10. How does WebApplicationFactory enable integration tests without a real database?

Answers:

  1. Dependencies always point inward — outer circles can reference inner circles, but never the reverse.
  2. Decoupling: the controller doesn’t need to know what handler will process the request. Easier to test, maintain, and extend.
  3. A Command modifies state (creates/updates/deletes) and returns a result. A Query reads state without modifying it.
  4. Because Application defines the contracts (interfaces). If it referenced Persistence, it would depend on implementation, violating DIP.
  5. A Pipeline Behavior intercepts all requests before/after the handler. Use cases: validation, logging, caching, transactions.
  6. IEntityTypeConfiguration centralizes EF Core configuration in Persistence, keeping Domain independent.
  7. Centralizes audit fields (CreatedBy, CreatedDate, LastModifiedBy, LastModifiedDate) for all entities.
  8. Sending an email is a cross-cutting concern within the use case. The handler is responsible for the entire use case.
  9. NSwag reads the Swagger specification and generates a typed C# client, avoiding manually writing HTTP calls.
  10. WebApplicationFactory starts the real application but with services replaced (DB in-memory, mocked services).

Search Terms

asp.net · core · clean · architecture · testing · patterns · c# · .net · development · application · entity · handler · infrastructure · interface · query · tests · blazor · configuration · api · command · mediatr · persistence · registering · repository

Interested in this course?

Contact us to book it or get a custom training plan for your team.