A modern, enterprise-grade REST API built with .NET 8 demonstrating advanced software architecture patterns including CQRS (Command Query Responsibility Segregation), MediatR for request/response handling, and comprehensive validation. This project exemplifies clean code principles and scalable web application design.
The CompanyEmployees API is a RESTful web service that manages company and employee data. It showcases professional software architecture with clear separation of concerns through:
- CQRS Pattern - Separating read (queries) and write (commands) operations
- MediatR Pipeline - Request/response mediation with built-in validation
- Repository Pattern - Data access abstraction
- Dependency Injection - Clean service registration and resolution
- Centralized Exception Handling - Consistent error responses
The solution follows a multi-layer architecture with CQRS pattern and strict separation of concerns:
CompanyEmployees (API Entry Point & Configuration)
βββ CompanyEmployees.Presentation (Controllers & HTTP Routing)
βββ Application (CQRS & MediatR - Queries, Commands, Handlers, Validators, Behaviors)
βββ Repository (Data Access Layer & EF Core)
βββ Contracts (Interface Definitions)
βββ Entities (Domain Models, DTOs, Exceptions)
βββ LoggerService (NLog Integration)
βββ Shared (Shared Data Transfer Objects)
| Layer | Purpose |
|---|---|
| CompanyEmployees | API startup, middleware configuration, DI setup, exception handling, MediatR/AutoMapper registration |
| CompanyEmployees.Presentation | HTTP controllers, route handling, MediatR request dispatching (ISender/IPublisher) |
| Application | CQRS implementation - Queries, Commands, Handlers, Validators, Pipeline behaviors, Notifications |
| Repository | EF Core context, repository implementations, database queries |
| Contracts | Interface definitions for repositories and services |
| Entities | Domain models, custom exceptions, error models, DTOs |
| LoggerService | NLog logging abstraction and implementation |
| Shared | Shared DTOs and data structures |
- Framework: .NET 8
- ORM: Entity Framework Core 8.0
- Database: SQL Server
- CQRS & Mediation: MediatR 14.0.0
- Validation: FluentValidation 12.1.1
- Mapping: AutoMapper 12.0.1
- Logging: NLog
- Language: C# 12 (with nullable reference types enabled)
- API Style: RESTful with JSON serialization
The application separates read operations (Queries) from write operations (Commands), improving scalability and maintainability:
Queries (Read-only operations in Application/Queries/):
public sealed record GetCompaniesQuery(bool TrackChanges) : IRequest<IEnumerable<CompanyDto>>;
public sealed record GetCompanyQuery(Guid Id, bool TrackChanges) : IRequest<CompanyDto>;Commands (Write operations in Application/Commands/):
public sealed record CreateCompanyCommand(CompanyForCreationDto Company) : IRequest<CompanyDto>;
public sealed record UpdateCompanyCommand(Guid Id, CompanyForUpdateDto Company) : IRequest<Unit>;
public sealed record DeleteCompanyCommand(Guid Id) : IRequest<Unit>;MediatR provides a mediator pattern implementation for decoupled request handling:
Query Handlers (in Application/Handlers/):
internal sealed class GetCompaniesHandler : IRequestHandler<GetCompaniesQuery, IEnumerable<CompanyDto>>
{
private readonly IRepositoryManager _repository;
private readonly IMapper _mapper;
public GetCompaniesHandler(IRepositoryManager repository, IMapper mapper)
{
_repository = repository;
_mapper = mapper;
}
public async Task<IEnumerable<CompanyDto>> Handle(GetCompaniesQuery request,
CancellationToken cancellationToken)
{
var companies = await _repository.Company.GetAllCompaniesAsync(request.TrackChanges);
return _mapper.Map<IEnumerable<CompanyDto>>(companies);
}
}Command Handlers:
internal sealed class CreateCompanyHandler : IRequestHandler<CreateCompanyCommand, CompanyDto>
{
private readonly IRepositoryManager _repository;
private readonly IMapper _mapper;
public CreateCompanyHandler(IRepositoryManager repository, IMapper mapper)
{
_repository = repository;
_mapper = mapper;
}
public async Task<CompanyDto> Handle(CreateCompanyCommand request, CancellationToken cancellationToken)
{
var companyEntity = _mapper.Map<Company>(request.Company);
_repository.Company.CreateCompany(companyEntity);
await _repository.SaveAsync();
return _mapper.Map<CompanyDto>(companyEntity);
}
}Pipeline behaviors act as middleware for the MediatR request/response pipeline, enabling cross-cutting concerns like validation:
public sealed 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())
return await next();
var context = new ValidationContext<TRequest>(request);
var failures = _validators
.Select(x => x.Validate(context))
.SelectMany(x => x.Errors)
.Where(x => x != null)
.ToList();
if (failures.Any())
throw new ValidationAppException(/* error details */);
return await next();
}
}Validators are defined for commands using FluentValidation fluent API:
public sealed class CreateCompanyCommandValidator : AbstractValidator<CreateCompanyCommand>
{
public CreateCompanyCommandValidator()
{
RuleFor(c => c.Company.Name).NotEmpty().MaximumLength(60);
RuleFor(c => c.Company.Address).NotEmpty().MaximumLength(60);
}
public override ValidationResult Validate(ValidationContext<CreateCompanyCommand> context)
{
return context.InstanceToValidate.Company is null
? new ValidationResult(new[] { new ValidationFailure("CompanyForCreationDto",
"CompanyForCreationDto object is null") })
: base.Validate(context);
}
}Publish notifications for domain events without direct coupling:
public sealed record CompanyDeletedNotification(Guid Id, bool TrackChanges) : INotification;
// In controller:
await _publisher.Publish(new CompanyDeletedNotification(id, TrackChanges: false));MappingProfile defines transformations between domain entities and DTOs:
public class MappingProfile : Profile
{
public MappingProfile()
{
CreateMap<Company, CompanyDto>()
.ForMember(c => c.FullAddress,
opt => opt.MapFrom(x => string.Join(' ', x.Address, x.Country)));
CreateMap<CompanyForCreationDto, Company>();
CreateMap<CompanyForUpdateDto, Company>();
}
}Data access is abstracted through repository interfaces:
public interface IRepositoryBase<T>
{
IQueryable<T> FindAll(bool trackChanges);
IQueryable<T> FindByCondition(Expression<Func<T, bool>> expression, bool trackChanges);
void Create(T entity);
void Update(T entity);
void Delete(T entity);
}Child repositories in RepositoryManager are initialized lazily:
private readonly Lazy<ICompanyRepository> _companyRepository;
public ICompanyRepository Company => _companyRepository.Value;Services are registered through extension methods:
public static void ConfigureRepositoryManager(this IServiceCollection services) =>
services.AddScoped<IRepositoryManager, RepositoryManager>();A global exception handler middleware catches exceptions and returns appropriate HTTP status codes:
BadRequestExceptionβ HTTP 400NotFoundExceptionβ HTTP 404ValidationAppExceptionβ HTTP 422 (Unprocessable Entity)- Generic exceptions β HTTP 500
- .NET 8 SDK
- SQL Server (local or remote)
- Visual Studio, Rider, or VS Code with C# extension
# Clone/open the repository
cd CompanyEmployees
# Restore dependencies
dotnet restore
# Build the solution
dotnet build
# Run the API
dotnet run --project CompanyEmployees
# API starts on: https://localhost:5001 (HTTPS) or http://localhost:5000 (HTTP)The API uses Entity Framework Core for database management:
# Create or update database with migrations
dotnet ef database update -p CompanyEmployees
# Create a new migration after model changes
dotnet ef migrations add MigrationName -p CompanyEmployeesThe connection string is configured in appsettings.json:
{
"ConnectionStrings": {
"sqlConnection": "Server=.;Database=CompanyEmployees;Trusted_Connection=true;"
}
}CompanyEmployees/
βββ CompanyEmployees/ # Main API project
β βββ Program.cs # Startup, MediatR, AutoMapper, validation registration
β βββ MappingProfile.cs # AutoMapper entity-to-DTO mappings
β βββ appsettings.json # Configuration (connection strings, etc.)
β βββ nlog.config # NLog configuration
β βββ Extensions/
β β βββ ServiceExtensions.cs # DI service registrations
β β βββ ExceptionMiddlewareExtensions.cs # Global exception handler middleware
β βββ Migrations/ # EF Core migrations
β βββ ContextFactory/ # Migration context factory
β
βββ CompanyEmployees.Presentation/ # Controllers and routing
β βββ Controllers/
β β βββ CompaniesController.cs # HTTP endpoints using MediatR (ISender/IPublisher)
β βββ AssemblyReference.cs # Assembly reference for DI
β
βββ Application/ # CQRS & MediatR implementation
β βββ Queries/ # Read-only operations
β β βββ GetCompaniesQuery.cs
β β βββ GetCompanyQuery.cs
β β βββ GetEmployeesQuery.cs
β βββ Commands/ # Write operations
β β βββ CreateCompanyCommand.cs
β β βββ UpdateCompanyCommand.cs
β β βββ DeleteCompanyCommand.cs
β βββ Handlers/ # Query & Command handlers
β β βββ GetCompaniesHandler.cs
β β βββ GetCompanyHandler.cs
β β βββ CreateCompanyHandler.cs
β β βββ UpdateCompanyHandler.cs
β β βββ DeleteCompanyHandler.cs
β β βββ EmailHandler.cs
β βββ Validators/ # FluentValidation validators
β β βββ CreateCompanyCommandValidator.cs
β βββ Behaviors/ # MediatR pipeline behaviors
β β βββ ValidationBehavior.cs
β βββ Notifications/ # Domain events
β β βββ CompanyDeletedNotification.cs
β βββ Application.csproj # MediatR, FluentValidation, AutoMapper dependencies
β
βββ Repository/ # Data access layer
β βββ RepositoryContext.cs # EF Core DbContext
β βββ RepositoryManager.cs # Repository manager with Lazy initialization
β βββ RepositoryBase.cs # Base repository implementation
β βββ CompanyRepository.cs # Company-specific repository
β βββ Configuration/ # EF entity configurations
β
βββ Contracts/ # Interface definitions
β βββ IRepositoryManager.cs
β βββ IRepositoryBase.cs
β βββ ICompanyRepository.cs
β βββ ILoggerManager.cs
β
βββ Entities/ # Domain models and exceptions
β βββ Models/ # Domain entities
β β βββ Company.cs
β βββ Exceptions/ # Custom exception classes
β β βββ BadRequestException.cs
β β βββ NotFoundException.cs
β β βββ ValidationAppException.cs
β β βββ CompanyNotFoundException.cs
β β βββ ...
β βββ ErrorModel/ # Error response models
β βββ ErrorDetails.cs
β
βββ LoggerService/ # Logging abstraction
β βββ LoggerManager.cs # NLog implementation
β
βββ Shared/ # Shared DTOs
βββ DataTransferObjects/ # DTO classes
βββ CompanyDto.cs
βββ CompanyForCreationDto.cs
βββ CompanyForUpdateDto.cs
// Register MediatR with Application assembly
builder.Services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(typeof(Application.AssemblyReference).Assembly));
// Register AutoMapper
builder.Services.AddAutoMapper(typeof(Program));
// Register pipeline behaviors (e.g., ValidationBehavior)
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
// Register all FluentValidation validators from Application assembly
builder.Services.AddValidatorsFromAssembly(typeof(Application.AssemblyReference).Assembly);Controllers inject ISender (for queries/commands) and IPublisher (for notifications):
[ApiController]
[Route("api/companies")]
public class CompaniesController : ControllerBase
{
private readonly ISender _sender;
private readonly IPublisher _publisher;
public CompaniesController(ISender sender, IPublisher publisher)
{
_sender = sender;
_publisher = publisher;
}
[HttpGet]
public async Task<IActionResult> GetCompanies()
{
// Send query through MediatR pipeline
var companies = await _sender.Send(new GetCompaniesQuery(TrackChanges: false));
return Ok(companies);
}
[HttpPost]
public async Task<IActionResult> CreateCompany([FromBody] CompanyForCreationDto company)
{
// Send command through MediatR pipeline (includes validation behavior)
var createdCompany = await _sender.Send(new CreateCompanyCommand(company));
return CreatedAtRoute("CompanyById", new { id = createdCompany.Id }, createdCompany);
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> DeleteCompany(Guid id)
{
await _sender.Send(new DeleteCompanyCommand(id));
// Publish domain event
await _publisher.Publish(new CompanyDeletedNotification(id, TrackChanges: false));
return NoContent();
}
}Configured in ServiceExtensions.ConfigureCors() to allow all origins, methods, and headers. Modify as needed for production:
options.AddPolicy("CorsPolicy", builder =>
builder.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader());Configured for scenarios where the API runs behind IIS or a reverse proxy:
services.Configure<IISOptions>(options => { });
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.All
});NLog is configured via nlog.config. Logs can be sent to files, console, or other targets. Access the logger in middleware:
var logger = app.Services.GetRequiredService<ILoggerManager>();
logger.LogError($"Error message: {exception}");- Controller β Injects
ISender, calls_sender.Send(new GetCompaniesQuery(...)) - MediatR Pipeline β Routes to
GetCompaniesHandler - Handler β Calls repository, maps result using AutoMapper, returns DTO
- Controller β Returns
200 Okwith results
- Controller β Receives
CompanyForCreationDtofrom request body - Controller β Calls
_sender.Send(new CreateCompanyCommand(dto)) - MediatR Pipeline β Enters
ValidationBehavior - ValidationBehavior β Runs
CreateCompanyCommandValidatorthrough FluentValidation - If Valid β Routes to
CreateCompanyHandler - If Invalid β Throws
ValidationAppException, caught by global exception handler - Handler β Maps DTO to entity, persists to database via repository, returns DTO
- Controller β Returns
201 Createdwith location and DTO
- Controller β Calls
_publisher.Publish(new CompanyDeletedNotification(...)) - MediatR β Finds all
INotificationHandler<CompanyDeletedNotification>implementations - Handlers β Execute asynchronously (fire-and-forget pattern)
- Create the Query in
Application/Queries/:
public sealed record GetDepartmentsQuery(bool TrackChanges)
: IRequest<IEnumerable<DepartmentDto>>;- Create the Query Handler in
Application/Handlers/:
internal sealed class GetDepartmentsHandler
: IRequestHandler<GetDepartmentsQuery, IEnumerable<DepartmentDto>>
{
private readonly IRepositoryManager _repository;
private readonly IMapper _mapper;
public GetDepartmentsHandler(IRepositoryManager repository, IMapper mapper)
{
_repository = repository;
_mapper = mapper;
}
public async Task<IEnumerable<DepartmentDto>> Handle(GetDepartmentsQuery request,
CancellationToken cancellationToken)
{
var departments = await _repository.Department.GetAllDepartmentsAsync(request.TrackChanges);
return _mapper.Map<IEnumerable<DepartmentDto>>(departments);
}
}- Add to Controller:
[HttpGet]
public async Task<IActionResult> GetDepartments()
{
var departments = await _sender.Send(new GetDepartmentsQuery(TrackChanges: false));
return Ok(departments);
}- Create the Command in
Application/Commands/:
public sealed record CreateDepartmentCommand(DepartmentForCreationDto Department)
: IRequest<DepartmentDto>;- Create the Validator in
Application/Validators/:
public sealed class CreateDepartmentCommandValidator
: AbstractValidator<CreateDepartmentCommand>
{
public CreateDepartmentCommandValidator()
{
RuleFor(c => c.Department.Name)
.NotEmpty()
.MaximumLength(100);
}
public override ValidationResult Validate(
ValidationContext<CreateDepartmentCommand> context)
{
return context.InstanceToValidate.Department is null
? new ValidationResult(new[] { new ValidationFailure("DepartmentForCreationDto",
"DepartmentForCreationDto object is null") })
: base.Validate(context);
}
}- Create the Command Handler in
Application/Handlers/:
internal sealed class CreateDepartmentHandler
: IRequestHandler<CreateDepartmentCommand, DepartmentDto>
{
private readonly IRepositoryManager _repository;
private readonly IMapper _mapper;
public CreateDepartmentHandler(IRepositoryManager repository, IMapper mapper)
{
_repository = repository;
_mapper = mapper;
}
public async Task<DepartmentDto> Handle(CreateDepartmentCommand request,
CancellationToken cancellationToken)
{
var departmentEntity = _mapper.Map<Department>(request.Department);
_repository.Department.CreateDepartment(departmentEntity);
await _repository.SaveAsync();
return _mapper.Map<DepartmentDto>(departmentEntity);
}
}- Add to Controller:
[HttpPost]
public async Task<IActionResult> CreateDepartment(
[FromBody] DepartmentForCreationDto department)
{
var createdDepartment = await _sender.Send(
new CreateDepartmentCommand(department));
return CreatedAtRoute("DepartmentById",
new { id = createdDepartment.Id }, createdDepartment);
}-
Add DTOs in
Shared/DataTransferObjects/ -
Add AutoMapper Mapping in
MappingProfile.cs:
CreateMap<Department, DepartmentDto>();
CreateMap<DepartmentForCreationDto, Department>();
CreateMap<DepartmentForUpdateDto, Department>();- Add Repository (see "Adding a New Entity" below)
- Create the entity in
Entities/Models/:
public class Department
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
}- Add to DbContext in
Repository/RepositoryContext.cs:
public DbSet<Department> Departments { get; set; }- Create repository interface in
Contracts/:
public interface IDepartmentRepository : IRepositoryBase<Department>
{
Task<IEnumerable<Department>> GetAllDepartmentsAsync(bool trackChanges);
Task<Department?> GetDepartmentAsync(Guid id, bool trackChanges);
}- Implement repository in
Repository/:
public class DepartmentRepository : RepositoryBase<Department>, IDepartmentRepository
{
public DepartmentRepository(RepositoryContext repositoryContext)
: base(repositoryContext) { }
public async Task<IEnumerable<Department>> GetAllDepartmentsAsync(bool trackChanges) =>
await FindAll(trackChanges).ToListAsync();
public async Task<Department?> GetDepartmentAsync(Guid id, bool trackChanges) =>
await FindByCondition(d => d.Id.Equals(id), trackChanges).SingleOrDefaultAsync();
}- Add to RepositoryManager in
Repository/RepositoryManager.cs:
private readonly Lazy<IDepartmentRepository> _departmentRepository;
public IDepartmentRepository Department => _departmentRepository.Value;
// In constructor:
_departmentRepository = new Lazy<IDepartmentRepository>(() =>
new DepartmentRepository(repositoryContext));- Update IRepositoryManager in
Contracts/IRepositoryManager.cs:
public interface IRepositoryManager
{
ICompanyRepository Company { get; }
IDepartmentRepository Department { get; }
Task SaveAsync();
}The API uses typed exceptions for different error scenarios:
// Base classes
public abstract class BadRequestException : Exception { }
public abstract class NotFoundException : Exception { }
public class ValidationAppException : Exception { }
// Specific implementations
public class CompanyNotFoundException : NotFoundException { }
public class CompanyCollectionBadRequest : BadRequestException { }
public class IdParametersBadRequestException : BadRequestException { }The ExceptionMiddlewareExtensions.ConfigureExceptionHandler() middleware catches exceptions and maps them to HTTP responses:
context.Response.StatusCode = contextFeature.Error switch
{
NotFoundException => StatusCodes.Status404NotFound,
BadRequestException => StatusCodes.Status400BadRequest,
ValidationAppException => StatusCodes.Status422UnprocessableEntity,
_ => StatusCodes.Status500InternalServerError
};The ValidationBehavior in the MediatR pipeline intercepts all requests and validates them:
- Checks if validators exist for the request type
- Runs all applicable FluentValidation validators
- Collects all validation errors into a dictionary
- Throws
ValidationAppExceptionwith grouped errors if validation fails - Proceeds to handler if validation passes
// Throw exception if resource not found
if (company is null)
throw new CompanyNotFoundException(id);
// Throw exception for invalid input
if (ids.Count == 0)
throw new CollectionByIdsBadRequestException();The project includes two migrations:
DatabaseCreation- Initial schema with Company and Employee entitiesInitialData- Seed data for development
To reapply migrations:
dotnet ef database drop -p CompanyEmployees # Remove database
dotnet ef database update -p CompanyEmployees # Recreate with migrationsEntity Framework Core Configuration:
- Uses SQL Server as the database provider
- Lazy loading is disabled (explicit loading required)
- Change tracking is controlled via repository methods
- Migrations track all schema changes
- Repositories use
trackChangesparameter to control EF Core tracking - Set
trackChanges = falsefor read-only queries - Set
trackChanges = trueonly when modifications are needed
- Child repositories in
RepositoryManageruseLazy<T>to defer instantiation - Only created when first accessed, reducing overhead
- All database operations are async
- MediatR handlers use async/await for non-blocking I/O
- Validation pipeline respects
CancellationTokenfor cancellation support
- HTTPS: Enable HTTPS in production (configured via
appsettings) - CORS: Configure appropriate CORS policy for allowed origins
- Input Validation: All input validated through FluentValidation before processing
- Parameterized Queries: EF Core automatically uses parameterized queries
- Exception Information: Exception details are never exposed in responses
- Future: Authentication/Authorization: Add
[Authorize]attributes and middleware for protected endpoints
- .NET 8 Documentation
- MediatR Documentation
- CQRS Pattern
- Entity Framework Core
- FluentValidation
- AutoMapper
- NLog Documentation
- Repository Pattern
This project is provided as-is for educational and development purposes.
Follow the established patterns when adding new features:
- Separate read operations (Queries) from write operations (Commands)
- Keep handlers focused and single-responsibility
- Use
IRequest<T>for queries/commands,IRequest<Unit>for commands with no return value - Return DTOs from queries/commands, not domain entities
- Create a
FluentValidatorfor each command that modifies data - Validate object state in
Validate()method override - Use RuleSets for complex validation scenarios
- Let
ValidationBehaviorhandle validationβdon't do it in handlers
- Keep business logic out of repositories (use handlers instead)
- Repositories should only handle data access
- Always use the
trackChangesparameter appropriately - Call
SaveAsync()after mutations
- Queries:
Get{Resource}Query,Get{Resources}Query - Commands:
Create{Resource}Command,Update{Resource}Command,Delete{Resource}Command - Handlers:
{Query/Command}Handler - DTOs:
{Resource}Dto,{Resource}ForCreationDto,{Resource}ForUpdateDto - Exceptions:
{Resource}NotFoundException,{Reason}BadRequestException
- Keep handlers internal (
internal sealed class) to prevent direct instantiation - Use sealed classes for handlers (optimization for performance)
- Use records for queries, commands, and notifications (immutability, value equality)
- Dependency injection through constructor