Reference Application: GloboTicket Ticket Management
Table of Contents
- Course Overview
- Introduction and GloboTicket Context
- Core Architectural Principles
- Setting Up the Application Core
- Creating the Infrastructure Project
- Adding an API with ASP.NET Core
- Testing the Application Code
- Adding the Blazor UI
- Improving Application Behavior
- Best Practices and Anti-patterns
- 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:
| Feature | Description |
|---|---|
| Event management | Create, update, delete events |
| Category listing | View all events grouped by category |
| Order tracking | View all orders/sales by month |
| CSV export | Export the events list |
| Pagination | Paginate the orders list |
| Authentication | Login/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
| Layer | Technology |
|---|---|
| API | ASP.NET Core 8/9 |
| Data access | Entity Framework Core 8/9 |
| Internal messaging | MediatR |
| Object mapping | AutoMapper |
| Validation | FluentValidation |
| Client UI | Blazor WebAssembly |
| Database | SQL Server |
| API documentation | Swagger (Swashbuckle) |
| Client generation | NSwag / NSwagStudio |
| SendGrid | |
| Logging | Serilog |
| Authentication | ASP.NET Core Identity (cookies) |
| Tests | xUnit, 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 eventCreateEventCommandValidator→ only validates the create commandEventsController→ 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
| Advantage | Explanation |
|---|---|
| Testability | The core (Domain + Application) can be tested without starting infrastructure |
| Maintainability | Each layer can change independently |
| Framework independence | Changing ORM or email provider does not touch the core |
| Database independence | IAsyncRepository<T> can have a SQL Server, PostgreSQL or in-memory implementation |
| Clear organization | Every 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 onSystem.ComponentModel.DataAnnotationsor EF Core, violating persistence ignorance. EF Core configuration is done in dedicatedIEntityTypeConfiguration<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:
- Start the API
- Copy the URL
https://localhost:{port}/swagger/v1/swagger.json - Open NSwagStudio → paste in “Specification URL” → “Create local Copy”
- In “Outputs”: check “CSharp Client”
- Configure: namespace, “Inject HttpClient via constructor” (enables HttpClientFactory), “Generate DTO types”
- 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 Model | Description |
|---|---|
| Blazor WASM | Runs in the browser via WebAssembly. Ideal for SPAs. |
| Blazor Server | Runs on the server, SignalR communication. |
| Blazor Hybrid | In 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
| Category | Best Practice |
|---|---|
| Domain | Keep entities as pure POCOs without infrastructure dependencies |
| Application | Organize by feature (vertical slice), not by type |
| Validators | One validator class per command/query |
| Handlers | One handler per command/query — SRP |
| Controllers | Thin controllers — zero business logic |
| Repositories | Interface in Application, implementation in Persistence |
| Email/External | Interface in Application, implementation in Infrastructure |
| Tests | Mock 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
- What is the fundamental rule of Clean Architecture regarding dependencies?
- Why do we use MediatR instead of calling services directly from controllers?
- What is the difference between a Command and a Query in CQRS?
- Why does the Application project not reference Persistence or Infrastructure?
- What is a Pipeline Behavior and what are its use cases?
- Why do we use
IEntityTypeConfiguration<T>instead of Data Annotations on entities? - What is the role of the
AuditableEntitybase class? - Why does the handler send an email notification and not the controller?
- What is NSwag and what problem does it solve in the Blazor project?
- How does
WebApplicationFactoryenable integration tests without a real database?
Answers:
- Dependencies always point inward — outer circles can reference inner circles, but never the reverse.
- Decoupling: the controller doesn’t need to know what handler will process the request. Easier to test, maintain, and extend.
- A Command modifies state (creates/updates/deletes) and returns a result. A Query reads state without modifying it.
- Because Application defines the contracts (interfaces). If it referenced Persistence, it would depend on implementation, violating DIP.
- A Pipeline Behavior intercepts all requests before/after the handler. Use cases: validation, logging, caching, transactions.
IEntityTypeConfigurationcentralizes EF Core configuration in Persistence, keeping Domain independent.- Centralizes audit fields (CreatedBy, CreatedDate, LastModifiedBy, LastModifiedDate) for all entities.
- Sending an email is a cross-cutting concern within the use case. The handler is responsible for the entire use case.
- NSwag reads the Swagger specification and generates a typed C# client, avoiding manually writing HTTP calls.
WebApplicationFactorystarts 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