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ách biệt rõ ràng giữa các hoạt động đọc (read) và ghi (write).
- Domain models phong phú giúp đóng gói toàn bộ logic nghiệp vụ.
- Kiến trúc dễ kiểm thử với mức độ phụ thuộc (coupling) tối thiểu.
- Nền tảng vững chắc để nâng cấp và mở rộng trong tương lai.
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:
- Domain-Driven Design: Logic nghiệp vụ nằm trong các domain entities và aggregates.
- CQRS: Commands xử lý các thao tác ghi, Queries xử lý các thao tác đọc.
- MediatR: Đóng vai trò trung gian giữa các lớp ứng dụng, cho phép triển khai pipeline behaviors.
- 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
- DDD giúp tập trung vào logic nghiệp vụ cốt lõi.
- CQRS tách biệt đọc/ghi để tối ưu hiệu suất và sự rõ ràng.
- MediatR hỗ trợ kiến trúc sạch với các pipeline linh hoạt.
- Domain Events xử lý các tác vụ phụ mà không làm tăng sự phụ thuộc giữa các thành phần.