Introduction
Building a timesheet management system requires careful architectural planning to ensure scalability, maintainability, and clear business logic separation. In this article, we'll explore how to design and implement a Phase 1 MVP using Domain-Driven Design (DDD), Command Query Responsibility Segregation (CQRS), and MediatR as the mediator pattern.
This approach provides several key benefits:
- Clear separation between read and write operations
- Rich domain models that encapsulate business logic
- Testable architecture with minimal coupling
- Scalable foundation for future enhancements
Architecture Overview
Core Principles
Our timesheet system follows these architectural principles:
- Domain-Driven Design: Business logic lives in domain entities and aggregates
- CQRS: Commands handle writes, Queries handle reads
- MediatR: Mediates between application layers, enabling pipeline behaviors
- Clean Architecture: Dependency inversion with clear layer boundaries
Layer Structure
┌─────────────────────────────────────┐
│ Presentation Layer │
│ (Controllers, API Endpoints) │
└─────────────────────────────────────┘
↓
┌─────────────────────────────────────┐
│ Application Layer │
│ (Commands, Queries, Handlers) │
│ MediatR Pipeline │
└─────────────────────────────────────┘
↓
┌─────────────────────────────────────┐
│ Domain Layer │
│ (Aggregates, Entities, Value Obj) │
│ Business Logic & Rules │
└─────────────────────────────────────┘
↓
┌─────────────────────────────────────┐
│ Infrastructure Layer │
│ (EF Core, Repositories, External) │
└─────────────────────────────────────┘
Domain Model Design
Core Aggregates
For our Phase 1 MVP, we'll focus on these core aggregates:
1. Timesheet Aggregate
The Timesheet aggregate is the root entity that manages all time entries for an employee during a specific week.
public class Timesheet : AggregateRoot
{
public Guid EmployeeId { get; private set; }
public DateTime MondayDate { get; private set; }
public ApprovalStatus Status { get; private set; }
private readonly List<WorkTask> _workTasks;
private readonly List<LeaveTask> _leaveTasks;
public IEnumerable<WorkTask> WorkTasks => _workTasks.AsReadOnly();
public IEnumerable<LeaveTask> LeaveTasks => _leaveTasks.AsReadOnly();
public Timesheet(Guid employeeId, DateTime mondayDate)
{
EmployeeId = employeeId;
MondayDate = mondayDate.Date;
Status = ApprovalStatus.Draft;
_workTasks = new List<WorkTask>();
_leaveTasks = new List<LeaveTask>();
}
public void AddWorkTask(WorkTask task)
{
ValidateCanModify();
_workTasks.Add(task);
AddDomainEvent(new WorkTaskAddedEvent(Id, task.Id));
}
public void Submit()
{
ValidateCanSubmit();
Status = ApprovalStatus.Submitted;
AddDomainEvent(new TimesheetSubmittedEvent(Id, EmployeeId, MondayDate));
}
private void ValidateCanModify()
{
if (Status == ApprovalStatus.Approved)
{
throw new DomainException("Cannot modify approved timesheet");
}
}
private void ValidateCanSubmit()
{
if (!_workTasks.Any() && !_leaveTasks.Any())
{
throw new DomainException("Cannot submit empty timesheet");
}
// Validate total hours per day
var totalHours = CalculateTotalHours();
if (totalHours > MaxWorkingHours)
{
throw new DomainException("Total hours exceed maximum allowed");
}
}
}
2. WorkTask Entity
Represents a work task entry within a timesheet.
public class WorkTask : Entity
{
public Guid AllocationId { get; private set; }
public DateTime Date { get; private set; }
public double Hours { get; private set; }
public string Description { get; private set; }
public WorkTask(Guid allocationId, DateTime date, double hours, string description)
{
AllocationId = allocationId;
Date = date.Date;
Hours = hours;
Description = description;
Validate();
}
private void Validate()
{
if (Hours <= 0)
{
throw new DomainException("Hours must be greater than zero");
}
if (Hours > 24)
{
throw new DomainException("Hours cannot exceed 24 per day");
}
}
}
CQRS Implementation with MediatR
Base Request Types
First, we define base request types for our CQRS pattern:
// Base command (write operation)
public abstract class EtRequest<TResponse> : IRequest<TResponse>
{
}
// Base query (read operation)
public abstract class EtQuery<TResponse> : IRequest<TResponse>
{
}
Commands (Write Operations)
Commands represent intent to change system state. They are handled by command handlers.
Submit Timesheet Command
public class SubmitTimesheetCommand : EtRequest<Unit>
{
public Guid EmployeeId { get; set; }
public DateTime MondayDate { get; set; }
public class Handler : IRequestHandler<SubmitTimesheetCommand, Unit>
{
private readonly ITimesheetRepository _repository;
private readonly IMediator _mediator;
public Handler(ITimesheetRepository repository, IMediator mediator)
{
_repository = repository;
_mediator = mediator;
}
public async Task<Unit> Handle(SubmitTimesheetCommand request, CancellationToken cancellationToken)
{
var timesheet = await _repository.GetByEmployeeAndWeekAsync(
request.EmployeeId,
request.MondayDate);
if (timesheet == null)
{
throw new NotFoundException("Timesheet not found");
}
timesheet.Submit();
await _repository.SaveAsync(timesheet);
// Publish domain events
foreach (var domainEvent in timesheet.DomainEvents)
{
await _mediator.Publish(domainEvent);
}
return Unit.Value;
}
}
}
Add Work Task Command
public class AddWorkTaskCommand : EtRequest<Guid>
{
public Guid TimesheetId { get; set; }
public Guid AllocationId { get; set; }
public DateTime Date { get; set; }
public double Hours { get; set; }
public string Description { get; set; }
public class Handler : IRequestHandler<AddWorkTaskCommand, Guid>
{
private readonly ITimesheetRepository _repository;
public Handler(ITimesheetRepository repository)
{
_repository = repository;
}
public async Task<Guid> Handle(AddWorkTaskCommand request, CancellationToken cancellationToken)
{
var timesheet = await _repository.GetByIdAsync(request.TimesheetId);
var workTask = new WorkTask(
request.AllocationId,
request.Date,
request.Hours,
request.Description);
timesheet.AddWorkTask(workTask);
await _repository.SaveAsync(timesheet);
return workTask.Id;
}
}
}
Queries (Read Operations)
Queries are read-only operations that don't modify state.
Get User Timesheet Query
public class GetUserTimesheetQuery : EtQuery<TimesheetDto>
{
public Guid EmployeeId { get; set; }
public DateTime MondayDate { get; set; }
public class Handler : IRequestHandler<GetUserTimesheetQuery, TimesheetDto>
{
private readonly ITimesheetRepository _repository;
public Handler(ITimesheetRepository repository)
{
_repository = repository;
}
public async Task<TimesheetDto> Handle(GetUserTimesheetQuery request, CancellationToken cancellationToken)
{
var timesheet = await _repository.GetByEmployeeAndWeekAsync(
request.EmployeeId,
request.MondayDate);
if (timesheet == null)
{
return null;
}
return new TimesheetDto
{
Id = timesheet.Id,
EmployeeId = timesheet.EmployeeId,
MondayDate = timesheet.MondayDate,
Status = timesheet.Status.ToString(),
WorkTasks = timesheet.WorkTasks.Select(t => new WorkTaskDto
{
Id = t.Id,
AllocationId = t.AllocationId,
Date = t.Date,
Hours = t.Hours,
Description = t.Description
}).ToList()
};
}
}
}
MediatR Pipeline Behaviors
MediatR's pipeline behaviors allow us to add cross-cutting concerns like validation, logging, and authorization.
Validation Pipeline
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)
{
var context = new ValidationContext<TRequest>(request);
var failures = _validators
.Select(v => v.Validate(context))
.SelectMany(result => result.Errors)
.Where(f => f != null)
.ToList();
if (failures.Count != 0)
{
throw new ValidationException(failures);
}
return await next();
}
}
Logging Pipeline
public class LoggingBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, 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}", typeof(TRequest).Name);
var response = await next();
_logger.LogInformation("Handled {RequestName}", typeof(TRequest).Name);
return response;
}
}
Domain Events
Domain events represent something important that happened in the domain.
Timesheet Submitted Event
public class TimesheetSubmittedEvent : INotification
{
public Guid TimesheetId { get; }
public Guid EmployeeId { get; }
public DateTime MondayDate { get; }
public TimesheetSubmittedEvent(Guid timesheetId, Guid employeeId, DateTime mondayDate)
{
TimesheetId = timesheetId;
EmployeeId = employeeId;
MondayDate = mondayDate;
}
}
Event Handler
public class TimesheetSubmittedEventHandler : INotificationHandler<TimesheetSubmittedEvent>
{
private readonly IEmailService _emailService;
public TimesheetSubmittedEventHandler(IEmailService emailService)
{
_emailService = emailService;
}
public async Task Handle(TimesheetSubmittedEvent notification, CancellationToken cancellationToken)
{
// Send notification to manager
await _emailService.SendTimesheetSubmittedNotification(
notification.EmployeeId,
notification.MondayDate);
}
}
Repository Pattern
The repository abstracts data access, keeping domain logic separate from infrastructure concerns.
public interface ITimesheetRepository
{
Task<Timesheet> GetByIdAsync(Guid id);
Task<Timesheet> GetByEmployeeAndWeekAsync(Guid employeeId, DateTime mondayDate);
Task SaveAsync(Timesheet timesheet);
Task<IEnumerable<Timesheet>> GetByEmployeeAsync(Guid employeeId, DateTime startDate, DateTime endDate);
}
public class TimesheetRepository : ITimesheetRepository
{
private readonly ApplicationDbContext _context;
public TimesheetRepository(ApplicationDbContext context)
{
_context = context;
}
public async Task<Timesheet> GetByIdAsync(Guid id)
{
return await _context.Timesheets
.Include(t => t.WorkTasks)
.Include(t => t.LeaveTasks)
.FirstOrDefaultAsync(t => t.Id == id);
}
public async Task<Timesheet> GetByEmployeeAndWeekAsync(Guid employeeId, DateTime mondayDate)
{
return await _context.Timesheets
.Include(t => t.WorkTasks)
.Include(t => t.LeaveTasks)
.FirstOrDefaultAsync(t =>
t.EmployeeId == employeeId &&
t.MondayDate == mondayDate.Date);
}
public async Task SaveAsync(Timesheet timesheet)
{
_context.Timesheets.Update(timesheet);
await _context.SaveChangesAsync();
}
}
API Controller
Controllers are thin and delegate to MediatR.
[ApiController]
[Route("api/timesheets")]
public class TimesheetsController : ControllerBase
{
private readonly IMediator _mediator;
public TimesheetsController(IMediator mediator)
{
_mediator = mediator;
}
[HttpGet("{employeeId}/{mondayDate}")]
public async Task<ActionResult<TimesheetDto>> GetTimesheet(
Guid employeeId,
DateTime mondayDate)
{
var query = new GetUserTimesheetQuery
{
EmployeeId = employeeId,
MondayDate = mondayDate
};
var result = await _mediator.Send(query);
if (result == null)
{
return NotFound();
}
return Ok(result);
}
[HttpPost("submit")]
public async Task<IActionResult> SubmitTimesheet([FromBody] SubmitTimesheetCommand command)
{
await _mediator.Send(command);
return Ok();
}
[HttpPost("tasks")]
public async Task<ActionResult<Guid>> AddWorkTask([FromBody] AddWorkTaskCommand command)
{
var taskId = await _mediator.Send(command);
return Ok(taskId);
}
}
Dependency Injection Setup
Configure MediatR and register handlers in your Program.cs or Startup.cs:
builder.Services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssembly(typeof(Program).Assembly);
});
// Register pipeline behaviors
builder.Services.AddScoped(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
builder.Services.AddScoped(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
// Register repositories
builder.Services.AddScoped<ITimesheetRepository, TimesheetRepository>();
// Register validators (FluentValidation)
builder.Services.AddValidatorsFromAssembly(typeof(Program).Assembly);
Phase 1 MVP Scope
For the initial MVP, focus on these core features:
Must-Have Features
- Create Timesheet: Employees can create a new timesheet for a week
- Add Work Tasks: Add work entries with allocation, date, hours, and description
- Submit Timesheet: Submit timesheet for approval
- View Timesheet: View current week's timesheet
- Basic Validation: Ensure hours don't exceed daily/weekly limits
Nice-to-Have (Future Phases)
- Leave management
- Approval workflow
- Reporting and analytics
- Integration with project management tools
- Mobile app support
Best Practices
1. Keep Domain Logic in Domain Layer
Business rules should live in domain entities, not in handlers or controllers.
// ✅ Good: Domain logic in aggregate
public void Submit()
{
ValidateCanSubmit();
Status = ApprovalStatus.Submitted;
}
// ❌ Bad: Domain logic in handler
public async Task<Unit> Handle(SubmitTimesheetCommand request)
{
if (timesheet.WorkTasks.Count == 0)
{
throw new Exception("Cannot submit");
}
// ...
}
2. Use Value Objects for Complex Types
public class TimeEntry : ValueObject
{
public DateTime Date { get; }
public double Hours { get; }
public TimeEntry(DateTime date, double hours)
{
if (hours <= 0 || hours > 24)
{
throw new DomainException("Invalid hours");
}
Date = date.Date;
Hours = hours;
}
}
3. Keep Commands and Queries Focused
Each command should do one thing. If you need to do multiple things, create separate commands or use domain events.
4. Use Domain Events for Side Effects
Don't call external services directly from domain logic. Use domain events instead.
Conclusion
Building a timesheet system with DDD, CQRS, and MediatR provides a solid foundation for growth. The separation of concerns makes the codebase maintainable, testable, and scalable.
Key Takeaways
- Domain-Driven Design keeps business logic where it belongs
- CQRS separates read and write operations for better performance and clarity
- MediatR enables clean architecture with pipeline behaviors
- Domain Events handle side effects without coupling