Development

Building a Timesheet System with Domain-Driven Design, CQRS, and MediatR: A Phase 1 MVP Guide

By Ginbok9 min read

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:


Architecture Overview

Core Principles

Our timesheet system follows these architectural principles:

  1. Domain-Driven Design: Business logic lives in domain entities and aggregates
  2. CQRS: Commands handle writes, Queries handle reads
  3. MediatR: Mediates between application layers, enabling pipeline behaviors
  4. 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

  1. Create Timesheet: Employees can create a new timesheet for a week
  2. Add Work Tasks: Add work entries with allocation, date, hours, and description
  3. Submit Timesheet: Submit timesheet for approval
  4. View Timesheet: View current week's timesheet
  5. Basic Validation: Ensure hours don't exceed daily/weekly limits

Nice-to-Have (Future Phases)


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

#architecture#backend#asp.net-core#workflow#Timesheet#ddd#cqrs#mediatr
← Back to Articles