Development

Thiết kế Hệ thống Timesheet với DDD, CQRS và MediatR

By Ginbok6 min read

Giới thiệu

Xây dựng một hệ thống quản lý timesheet (bảng chấm công) đòi hỏi việc lập kế hoạch kiến trúc cẩn thận để đảm bảo khả năng mở rộng, dễ bảo trì và tách biệt rõ ràng logic nghiệp vụ. Trong bài viết này, chúng ta sẽ tìm hiểu cách thiết kế và triển khai một MVP Giai đoạn 1 sử dụng Domain-Driven Design (DDD), Command Query Responsibility Segregation (CQRS), và MediatR như một mediator pattern.

Phương pháp này mang lại một số lợi ích chính:


Tổng quan kiến trúc

Các nguyên tắc cốt lõi

Hệ thống timesheet của chúng ta tuân thủ các nguyên tắc kiến trúc sau:

  1. Domain-Driven Design: Logic nghiệp vụ nằm trong các domain entities và aggregates.
  2. CQRS: Commands xử lý các thao tác ghi, Queries xử lý các thao tác đọc.
  3. MediatR: Đóng vai trò trung gian giữa các lớp ứng dụng, cho phép triển khai pipeline behaviors.
  4. Clean Architecture: Đảo ngược phụ thuộc (dependency inversion) với ranh giới các lớp rõ ràng.

Cấu trúc các lớp

┌─────────────────────────────────────┐
│          Lớp Presentation           │
│    (Controllers, API Endpoints)     │
└─────────────────────────────────────┘
              ↓
┌─────────────────────────────────────┐
│          Lớp Application            │
│   (Commands, Queries, Handlers)     │
│         MediatR Pipeline            │
└─────────────────────────────────────┘
              ↓
┌─────────────────────────────────────┐
│             Lớp Domain              │
│  (Aggregates, Entities, Value Obj)  │
│      Logic & Quy tắc nghiệp vụ      │
└─────────────────────────────────────┘
              ↓
┌─────────────────────────────────────┐
│         Lớp Infrastructure          │
│  (EF Core, Repositories, External)  │
└─────────────────────────────────────┘

Thiết kế Domain Model

Các Aggregates chính

Đối với MVP Giai đoạn 1, chúng ta sẽ tập trung vào các aggregate cốt lõi sau:

1. Timesheet Aggregate

Aggregate Timesheet là entity gốc (root) quản lý tất cả các mục nhập thời gian của một nhân viên trong một tuần cụ thể.

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("Không thể sửa đổi timesheet đã được duyệt");
        }
    }
    
    private void ValidateCanSubmit()
    {
        if (!_workTasks.Any() && !_leaveTasks.Any())
        {
            throw new DomainException("Không thể gửi timesheet trống");
        }
        
        var totalHours = CalculateTotalHours();
        if (totalHours > MaxWorkingHours)
        {
            throw new DomainException("Tổng số giờ vượt quá mức cho phép");
        }
    }
}

2. WorkTask Entity

Đại diện cho một mục công việc được nhập trong 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("Số giờ phải lớn hơn 0");
        }
        
        if (Hours > 24)
        {
            throw new DomainException("Số giờ không thể vượt quá 24h mỗi ngày");
        }
    }
}

Triển khai CQRS với MediatR

Các kiểu Request cơ bản

Đầu tiên, chúng ta định nghĩa các base request cho pattern CQRS:

// Base command (thao tác ghi)
public abstract class EtRequest<TResponse> : IRequest<TResponse> { }

// Base query (thao tác đọc)
public abstract class EtQuery<TResponse> : IRequest<TResponse> { }

Commands (Thao tác ghi)

Commands đại diện cho ý định thay đổi trạng thái hệ thống. Chúng được xử lý bởi các 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("Không tìm thấy timesheet");
            
            timesheet.Submit();
            await _repository.SaveAsync(timesheet);
            
            foreach (var domainEvent in timesheet.DomainEvents)
            {
                await _mediator.Publish(domainEvent);
            }
            
            return Unit.Value;
        }
    }
}

MediatR Pipeline Behaviors

Pipeline behaviors của MediatR cho phép chúng ta thêm các xử lý xuyên suốt (cross-cutting concerns) như validation, logging và authorization.

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();
    }
}

Lời khuyên chuyên gia (Best Practices)

1. Đặt Logic Nghiệp vụ tại lớp Domain

Các quy tắc nghiệp vụ phải nằm trong domain entities, không nên nằm ở handlers hay controllers. Điều này giúp tránh tình trạng "anemic domain model" (model thiếu sức sống).

2. Sử dụng Value Objects

Đối với các kiểu dữ liệu phức tạp nhưng không có danh tính riêng (như Tiền tệ, Địa chỉ, hoặc Khoảng thời gian), hãy sử dụng Value Objects để đảm bảo tính bất biến và tự xác thực.

3. Domain Events cho các tác vụ phụ trợ

Đừng gọi trực tiếp các dịch vụ bên ngoài (như gửi Email/SMS) trong Command Handler. Hãy phát đi một Domain Event và xử lý nó bất đồng bộ để giữ cho luồng chính luôn nhanh chóng và ít phụ thuộc.


Kết luận

Xây dựng hệ thống timesheet với DDD, CQRS và MediatR cung cấp một nền tảng vững chắc cho sự phát triển lâu dài. Việc tách biệt các mối quan tâm giúp mã nguồn dễ bảo trì, dễ kiểm thử và có khả năng mở rộng cao.

Tóm tắt nội dung chính

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