HR Leave Management Sample
The HR Leave Management sample is the reference application included with RCommon. It demonstrates Clean Architecture, CQRS with MediatR, EF Core persistence, FluentValidation, JWT authentication, and SendGrid email — all wired together through RCommon's fluent builder. This walkthrough explains how each piece fits together.
Sample Location
Examples/CleanWithCQRS/
HR.LeaveManagement.Domain/
HR.LeaveManagement.Application/
HR.LeaveManagement.Persistence/
HR.LeaveManagement.Identity/
HR.LeaveManagement.API/
HR.LeaveManagement.MVC/
HR.LeaveManagement.Application.UnitTests/
What the Sample Covers
- A leave management system where employees request time off
- Administrators create leave types (e.g. Annual, Sick, Bereavement) and allocate days to employees
- Employees submit leave requests against their allocations
- Requests can be approved or rejected by administrators
- Email confirmation is sent on successful request submission
Domain Model
Entities
All entities inherit from BaseDomainEntity, which inherits from RCommon's AuditedEntity<int, string, string>. This provides Id, CreatedBy, ModifiedBy, CreateDate, and ModifyDate automatically:
public abstract class BaseDomainEntity : AuditedEntity<int, string, string>
{
}
public class LeaveType : BaseDomainEntity
{
public string Name { get; set; } // e.g. "Annual Leave"
public int DefaultDays { get; set; } // e.g. 14
}
public class LeaveAllocation : BaseDomainEntity
{
public int NumberOfDays { get; set; }
public LeaveType LeaveType { get; set; }
public int LeaveTypeId { get; set; }
public int Period { get; set; } // year
public string EmployeeId { get; set; }
}
public class LeaveRequest : BaseDomainEntity
{
public DateTime StartDate { get; set; }
public DateTime EndDate { get; set; }
public LeaveType LeaveType { get; set; }
public int LeaveTypeId { get; set; }
public DateTime DateRequested { get; set; }
public string RequestComments { get; set; }
public bool? Approved { get; set; }
public bool Cancelled { get; set; }
public string RequestingEmployeeId { get; set; }
}
Domain Specifications
A specification encapsulates a reusable query predicate. The AllocationExistsSpec checks whether an employee already has an allocation for a given leave type in a given year:
public class AllocationExistsSpec : Specification<LeaveAllocation>
{
public AllocationExistsSpec(string userId, int leaveTypeId, int period)
: base(q => q.EmployeeId == userId
&& q.LeaveTypeId == leaveTypeId
&& q.Period == period)
{
}
}
Usage in a command handler:
var allocationCount = await _leaveAllocationRepository
.GetCountAsync(new AllocationExistsSpec(emp.Id, leaveType.Id, period));
if (allocationCount > 0)
continue; // already allocated for this period
Application Layer
Commands and Queries
CQRS is expressed through RCommon's IAppRequest<TResponse> and IAppRequestHandler<TRequest, TResponse> interfaces.
// Command — modifies state
public class CreateLeaveTypeCommand : IAppRequest<BaseCommandResponse>
{
public CreateLeaveTypeDto LeaveTypeDto { get; set; }
}
// Query — reads state, no side effects
public class GetLeaveTypeListRequest : IAppRequest<List<LeaveTypeDto>>
{
}
Command Handler with Validation
The CreateLeaveTypeCommandHandler validates the incoming DTO via IValidationService before persisting:
public class CreateLeaveTypeCommandHandler
: IAppRequestHandler<CreateLeaveTypeCommand, BaseCommandResponse>
{
private readonly IGraphRepository<LeaveType> _leaveTypeRepository;
private readonly IValidationService _validationService;
public CreateLeaveTypeCommandHandler(
IGraphRepository<LeaveType> leaveTypeRepository,
IValidationService validationService)
{
_leaveTypeRepository = leaveTypeRepository;
_validationService = validationService;
_leaveTypeRepository.DataStoreName = DataStoreNamesConst.LeaveManagement;
}
public async Task<BaseCommandResponse> HandleAsync(
CreateLeaveTypeCommand request,
CancellationToken cancellationToken)
{
var response = new BaseCommandResponse();
var validationResult = await _validationService.ValidateAsync(request.LeaveTypeDto);
if (!validationResult.IsValid)
{
response.Success = false;
response.Message = "Creation Failed";
response.Errors = validationResult.Errors
.Select(q => q.ErrorMessage).ToList();
}
else
{
var leaveType = request.LeaveTypeDto.ToLeaveType();
await _leaveTypeRepository.AddAsync(leaveType);
response.Success = true;
response.Message = "Creation Successful";
response.Id = leaveType.Id;
}
return response;
}
}
Command Handler with Multiple Repositories and Email
The CreateLeaveRequestCommandHandler coordinates multiple repositories, the current user identity, and email delivery. The handler remains unit-testable because all dependencies are injected as interfaces:
public class CreateLeaveRequestCommandHandler
: IAppRequestHandler<CreateLeaveRequestCommand, BaseCommandResponse>
{
private readonly IReadOnlyRepository<LeaveType> _leaveTypeRepository;
private readonly IGraphRepository<LeaveAllocation> _leaveAllocationRepository;
private readonly IGraphRepository<LeaveRequest> _leaveRequestRepository;
private readonly IEmailService _emailSender;
private readonly ICurrentUser _currentUser;
private readonly IValidationService _validationService;
public async Task<BaseCommandResponse> HandleAsync(
CreateLeaveRequestCommand request,
CancellationToken cancellationToken)
{
var response = new BaseCommandResponse();
var validationResult = await _validationService.ValidateAsync(request.LeaveRequestDto);
var userId = _currentUser.FindClaimValue(CustomClaimTypes.Uid);
// Check allocation exists
var allocation = _leaveAllocationRepository.FirstOrDefault(
x => x.EmployeeId == userId
&& x.LeaveTypeId == request.LeaveRequestDto.LeaveTypeId);
if (allocation is null)
{
validationResult.Errors.Add(new ValidationFault(
nameof(request.LeaveRequestDto.LeaveTypeId),
"You do not have any allocations for this leave type."));
}
else
{
int daysRequested = (int)(request.LeaveRequestDto.EndDate
- request.LeaveRequestDto.StartDate).TotalDays;
if (daysRequested > allocation.NumberOfDays)
{
validationResult.Errors.Add(new ValidationFault(
nameof(request.LeaveRequestDto.EndDate),
"You do not have enough days for this request"));
}
}
if (!validationResult.IsValid)
{
response.Success = false;
response.Message = "Request Failed";
response.Errors = validationResult.Errors
.Select(q => q.ErrorMessage).ToList();
}
else
{
var leaveRequest = request.LeaveRequestDto.ToLeaveRequest();
leaveRequest.RequestingEmployeeId = userId;
await _leaveRequestRepository.AddAsync(leaveRequest);
response.Success = true;
response.Message = "Request Created Successfully";
response.Id = leaveRequest.Id;
// Send confirmation email — failure does not roll back the request
try
{
var emailAddress = _currentUser.FindClaimValue(ClaimTypes.Email);
var email = new MailMessage(
new MailAddress(fromEmail, fromName),
new MailAddress(emailAddress))
{
Subject = "Leave Request Submitted",
Body = $"Your leave request for {request.LeaveRequestDto.StartDate:D} " +
$"to {request.LeaveRequestDto.EndDate:D} has been submitted."
};
await _emailSender.SendEmailAsync(email);
}
catch (Exception)
{
// Log but do not propagate — email is non-critical
}
}
return response;
}
}
Allocation Command Handler
The CreateLeaveAllocationCommandHandler demonstrates using a specification to avoid duplicate allocations. It retrieves all employees via the IUserService contract and allocates days from the leave type's DefaultDays:
public async Task<BaseCommandResponse> HandleAsync(
CreateLeaveAllocationCommand request,
CancellationToken cancellationToken)
{
var leaveType = await _leaveTypeRepository
.FindAsync(request.LeaveAllocationDto.LeaveTypeId);
var employees = await _userService.GetEmployees();
var period = DateTime.Now.Year;
var allocations = new List<LeaveAllocation>();
foreach (var emp in employees)
{
var allocationCount = await _leaveAllocationRepository
.GetCountAsync(new AllocationExistsSpec(emp.Id, leaveType.Id, period));
if (allocationCount > 0)
continue;
allocations.Add(new LeaveAllocation
{
EmployeeId = emp.Id,
LeaveTypeId = leaveType.Id,
NumberOfDays = leaveType.DefaultDays,
Period = period
});
}
foreach (var item in allocations)
{
await _leaveAllocationRepository.AddAsync(item);
}
return new BaseCommandResponse { Success = true, Message = "Allocations Successful" };
}
Validation
Validators use FluentValidation. They are discovered automatically via AddValidatorsFromAssemblyContaining:
public class ILeaveTypeDtoValidator : AbstractValidator<ILeaveTypeDto>
{
public ILeaveTypeDtoValidator()
{
RuleFor(p => p.Name)
.NotEmpty().WithMessage("{PropertyName} is required.")
.NotNull()
.MaximumLength(50)
.WithMessage("{PropertyName} must not exceed {ComparisonValue} characters.");
RuleFor(p => p.DefaultDays)
.NotEmpty().WithMessage("{PropertyName} is required.")
.GreaterThan(0).WithMessage("{PropertyName} must be at least 1.")
.LessThan(100)
.WithMessage("{PropertyName} must be less than {ComparisonValue}.");
}
}
public class CreateLeaveTypeDtoValidator : AbstractValidator<CreateLeaveTypeDto>
{
public CreateLeaveTypeDtoValidator()
{
Include(new ILeaveTypeDtoValidator());
}
}
Persistence Layer
LeaveManagementDbContext inherits from AuditableDbContext. RCommon injects ICurrentUser and ISystemTime to stamp audit fields on every SaveChanges:
public class LeaveManagementDbContext : AuditableDbContext
{
public LeaveManagementDbContext(
DbContextOptions<LeaveManagementDbContext> options,
ICurrentUser currentUser,
ISystemTime systemTime)
: base(options, currentUser, systemTime)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(
typeof(LeaveManagementDbContext).Assembly);
}
public DbSet<LeaveRequest> LeaveRequests { get; set; }
public DbSet<LeaveType> LeaveTypes { get; set; }
public DbSet<LeaveAllocation> LeaveAllocations { get; set; }
}
The data store name constant keeps the name in one place:
public static class DataStoreNamesConst
{
public const string LeaveManagement = "LeaveManagement";
}
API Controllers
Controllers use IMediatorService exclusively — no direct handler references:
[Route("api/[controller]")]
[ApiController]
[Authorize]
public class LeaveTypesController : ControllerBase
{
private readonly IMediatorService _mediator;
public LeaveTypesController(IMediatorService mediator)
=> _mediator = mediator;
[HttpGet]
public async Task<ActionResult<List<LeaveTypeDto>>> Get()
{
var leaveTypes = await _mediator.Send<GetLeaveTypeListRequest, List<LeaveTypeDto>>(
new GetLeaveTypeListRequest());
return Ok(leaveTypes);
}
[HttpPost]
[Authorize(Roles = "Administrator")]
public async Task<ActionResult<BaseCommandResponse>> Post(
[FromBody] CreateLeaveTypeDto leaveType)
{
var command = new CreateLeaveTypeCommand { LeaveTypeDto = leaveType };
var response = await _mediator.Send<CreateLeaveTypeCommand, BaseCommandResponse>(command);
return Ok(response);
}
[HttpPut("{id}")]
[Authorize(Roles = "Administrator")]
public async Task<ActionResult> Put([FromBody] LeaveTypeDto leaveType)
{
var command = new UpdateLeaveTypeCommand { LeaveTypeDto = leaveType };
await _mediator.Send(command);
return NoContent();
}
[HttpDelete("{id}")]
[Authorize(Roles = "Administrator")]
public async Task<ActionResult> Delete(int id)
{
var command = new DeleteLeaveTypeCommand { Id = id };
await _mediator.Send(command);
return NoContent();
}
}
Composition Root
Everything is wired in Program.cs. This is the only place in the system where concrete implementations are referenced by name:
builder.Services.AddRCommon()
.WithClaimsAndPrincipalAccessor()
.WithSendGridEmailServices(x =>
{
var settings = builder.Configuration.Get<SendGridEmailSettings>();
x.SendGridApiKey = settings.SendGridApiKey;
x.FromNameDefault = settings.FromNameDefault;
x.FromEmailDefault = settings.FromEmailDefault;
})
.WithDateTimeSystem(dateTime => dateTime.Kind = DateTimeKind.Utc)
.WithSequentialGuidGenerator(guid =>
guid.DefaultSequentialGuidType = SequentialGuidType.SequentialAsString)
.WithMediator<MediatRBuilder>(mediator =>
{
mediator.AddRequest<CreateLeaveTypeCommand, BaseCommandResponse,
CreateLeaveTypeCommandHandler>();
mediator.AddRequest<GetLeaveTypeListRequest, List<LeaveTypeDto>,
GetLeaveTypeListRequestHandler>();
// ... all other request/handler pairs
mediator.Configure(config =>
{
config.RegisterServicesFromAssemblies(
typeof(ApplicationServicesRegistration).GetTypeInfo().Assembly);
});
mediator.AddLoggingToRequestPipeline();
mediator.AddUnitOfWorkToRequestPipeline();
})
.WithPersistence<EFCorePerisistenceBuilder>(ef =>
{
ef.AddDbContext<LeaveManagementDbContext>(
DataStoreNamesConst.LeaveManagement,
options => options.UseSqlServer(
builder.Configuration.GetConnectionString(
DataStoreNamesConst.LeaveManagement)));
ef.SetDefaultDataStore(dataStore =>
dataStore.DefaultDataStoreName = DataStoreNamesConst.LeaveManagement);
})
.WithUnitOfWork<DefaultUnitOfWorkBuilder>(unitOfWork =>
{
unitOfWork.SetOptions(options =>
{
options.AutoCompleteScope = true;
options.DefaultIsolation = IsolationLevel.ReadCommitted;
});
})
.WithValidation<FluentValidationBuilder>(validation =>
{
validation.AddValidatorsFromAssemblyContaining(
typeof(ApplicationServicesRegistration));
});
Unit Testing Handlers
Because every handler depends on interfaces, tests use mocks and require no database:
[TestFixture]
public class CreateLeaveTypeCommandHandlerTests
{
private readonly CreateLeaveTypeDto _leaveTypeDto;
private readonly CreateLeaveTypeCommandHandler _handler;
public CreateLeaveTypeCommandHandlerTests()
{
var repositoryMock = new Mock<IGraphRepository<LeaveType>>();
var validationMock = new Mock<IValidationService>();
_leaveTypeDto = new CreateLeaveTypeDto
{
DefaultDays = 15,
Name = "Test DTO"
};
validationMock
.Setup(x => x.ValidateAsync(_leaveTypeDto, false, CancellationToken.None))
.Returns(() => Task.FromResult(new ValidationOutcome()));
_handler = new CreateLeaveTypeCommandHandler(
repositoryMock.Object,
validationMock.Object);
}
[Test]
public async Task Valid_LeaveType_Added()
{
var result = await _handler.HandleAsync(
new CreateLeaveTypeCommand { LeaveTypeDto = _leaveTypeDto },
CancellationToken.None);
result.ShouldBeOfType<BaseCommandResponse>();
result.Success.ShouldBeTrue();
}
}
Key Takeaways
IGraphRepository<T>decouples handlers from the ORM. Switch from EF Core to NHibernate without touching a single handler.IValidationServicedecouples handlers from FluentValidation. The validator registration is a composition-root concern.ICurrentUserprovides the authenticated user identity without importing ASP.NET Core into the domain or application layers.AuditableDbContexteliminates boilerplate audit stamping across every save.- Pipeline behaviors (
AddLoggingToRequestPipeline,AddUnitOfWorkToRequestPipeline) apply cross-cutting concerns to every handler without modifying handler code. DataStoreNameenables routing to the correctDbContextwhen multiple databases are registered.