Lộ Trình .NET: Sử Dụng MediatR cho CQRS và Truyền Thông Tách Biệt

Chào mừng các bạn quay trở lại với series “ASP.NET Core Roadmap – Lộ trình học ASP.NET Core 2025“! Trên hành trình xây dựng các ứng dụng .NET ngày càng lớn mạnh và phức tạp, chúng ta thường gặp phải một thử thách chung: quản lý sự phụ thuộc giữa các thành phần. Code của chúng ta có thể trở nên rối rắm, khó kiểm thử và bảo trì khi các đối tượng gọi trực tiếp lẫn nhau.

Trong bài viết này, chúng ta sẽ khám phá cách giải quyết vấn đề này bằng cách kết hợp hai khái niệm mạnh mẽ: CQRS (Command Query Responsibility Segregation) và Mediator Pattern, sử dụng một thư viện .NET rất phổ biến là MediatR. Sự kết hợp này không chỉ giúp giảm thiểu sự phụ thuộc (decoupling) mà còn tổ chức code một cách rõ ràng, hỗ trợ rất tốt cho các kiến trúc hiện đại như Kiến Trúc Sạch (Clean Architecture) hay Thiết Kế Hướng Miền (DDD).

Sự Phụ Thuộc Chặt Chẽ (Tight Coupling) – Vấn Đề Cần Giải Quyết

Hãy tưởng tượng một kịch bản phổ biến trong một ứng dụng web: khi người dùng gửi yêu cầu tạo một đơn hàng mới. Trong mô hình truyền thống, controller hoặc service xử lý yêu cầu này có thể thực hiện các bước sau:

  • Xác thực dữ liệu đầu vào.
  • Gọi một service hoặc repository để lưu đơn hàng vào cơ sở dữ liệu.
  • Gọi một service khác để gửi email xác nhận đơn hàng.
  • Gọi một service khác nữa để cập nhật số lượng sản phẩm tồn kho.
  • … và có thể nhiều bước khác.
public class OrderController : ControllerBase
{
    private readonly OrderService _orderService;
    private readonly EmailService _emailService;
    private readonly InventoryService _inventoryService;

    public OrderController(OrderService orderService, EmailService emailService, InventoryService inventoryService)
    {
        _orderService = orderService;
        _emailService = emailService;
        _inventoryService = inventoryService;
    }

    [HttpPost("create")]
    public async Task<IActionResult> CreateOrder([FromBody] CreateOrderRequest request)
    {
        // ... validation ...

        var order = await _orderService.CreateOrderAsync(request);

        await _emailService.SendOrderConfirmationEmailAsync(order.CustomerId, order.Id);
        await _inventoryService.UpdateInventoryAsync(order.Items);

        return Ok(order.Id);
    }
}

Trong ví dụ này, OrderController phụ thuộc trực tiếp vào OrderService, EmailService, và InventoryService. Nếu sau này bạn muốn thay đổi cách gửi email (ví dụ: chuyển từ email sang tin nhắn SMS), hoặc thêm một bước xử lý khác (ví dụ: ghi log chi tiết việc tạo đơn hàng), bạn sẽ phải sửa đổi controller này. Điều này dẫn đến:

  • Khó bảo trì: Thay đổi ở một nơi có thể ảnh hưởng đến nhiều nơi khác.
  • Khó kiểm thử (Unit Testing): Khi kiểm thử OrderController, bạn cần mock tất cả các dependency của nó (OrderService, EmailService, InventoryService). Số lượng dependency tăng lên nhanh chóng khiến việc viết unit test trở nên phức tạp hơn rất nhiều. (Tìm hiểu thêm về Kiểm thử UnitMocking Dependencies).
  • Giảm khả năng tái sử dụng: Logic tạo đơn hàng chỉ có thể được kích hoạt thông qua controller này với các dependency cụ thể.
  • Code trở nên “mập” (bloated): Controller hoặc service chứa quá nhiều logic và phụ thuộc vào quá nhiều thứ.

Đây chính là vấn đề của sự phụ thuộc chặt chẽ (tight coupling). Chúng ta cần một cách để “nới lỏng” (decouple) các thành phần này.

Mediator Pattern – Người Trung Gian Hữu Ích

Mediator Pattern là một mẫu thiết kế hành vi (behavioral design pattern) giúp giảm sự phụ thuộc trực tiếp giữa các đối tượng bằng cách giới thiệu một đối tượng trung gian (mediator). Các đối tượng không giao tiếp trực tiếp với nhau nữa mà thay vào đó gửi yêu cầu hoặc thông báo đến mediator, và mediator sẽ chuyển tiếp chúng đến các đối tượng phù hợp.

Hãy hình dung mediator như một người điều phối giao thông. Thay vì các xe cộ tự tìm đường và va chạm, chúng đi qua một giao lộ được điều khiển bởi đèn tín hiệu (mediator). Các xe chỉ cần “nói” với đèn tín hiệu nơi chúng muốn đi, và đèn tín hiệu sẽ quyết định khi nào xe nào được đi.

Trong lập trình, các “xe cộ” là các đối tượng (controllers, services, components), và “giao lộ” là mediator. Các đối tượng gửi “thông điệp” (message) đến mediator, và mediator sẽ tìm “người xử lý” (handler) phù hợp cho thông điệp đó và ủy quyền việc xử lý.

MediatR – Triển Khai Mediator trong .NET

MediatR là một thư viện mã nguồn mở phổ biến trong .NET giúp triển khai Mediator Pattern một cách dễ dàng và hiệu quả. Nó đặc biệt được ưa chuộng trong việc xây dựng các ứng dụng theo phong cách CQRS.

Các thành phần chính của MediatR bao gồm:

  • Requests (Yêu cầu): Đây là các thông điệp mà bạn muốn xử lý. Có hai loại chính:
    • IRequest<TResponse>: Một yêu cầu mong đợi một kết quả trả về kiểu TResponse.
    • IRequest<Unit>: Một yêu cầu không mong đợi kết quả trả về (Unit là một kiểu void đặc biệt của MediatR).

    Requests thường là các lớp hoặc records chứa dữ liệu cần thiết để thực hiện hành động hoặc truy vấn.

  • Handlers (Bộ xử lý): Đây là các lớp chứa logic xử lý cho một loại Request cụ thể.
    • IRequestHandler<TRequest, TResponse>: Xử lý TRequest và trả về TResponse.
    • IRequestHandler<TRequest> (tương đương IRequestHandler<TRequest, Unit>): Xử lý TRequest mà không trả về kết quả.

    Mỗi Request chỉ có *một* Handler.

  • Notifications (Thông báo): Đây là các thông điệp “phát và quên” (fire-and-forget) mà bạn muốn nhiều bộ xử lý khác nhau phản ứng lại.
    • INotification: Một thông báo.
  • Notification Handlers (Bộ xử lý thông báo): Các lớp xử lý INotification.
    • INotificationHandler<TNotification>: Xử lý TNotification.

    Một Notification có thể có *nhiều* Handlers.

  • Mediator (IMediator): Đây là giao diện trung tâm mà các đối tượng (ví dụ: controllers) sử dụng để gửi Requests hoặc Notifications. Bạn inject IMediator vào lớp gọi và gọi các phương thức Send (cho Requests) hoặc Publish (cho Notifications).

MediatR hoạt động dựa trên Dependency Injection (DI). Nó tự động tìm kiếm các Handlers và Notification Handlers trong các assembly của bạn và đăng ký chúng vào DI container. Khi bạn gửi một Request hoặc Publish một Notification thông qua IMediator, MediatR sẽ sử dụng DI container để tạo instance của Handler(s) phù hợp và gọi phương thức Handle của chúng.

Bạn có thể tối ưu hóa việc đăng ký các Handlers và Behaviors của MediatR trong DI bằng cách sử dụng các kỹ thuật như quét Assembly, tương tự như cách bạn làm với Scrutor.

CQRS – Tách Biệt Lệnh và Truy Vấn

CQRS (Command Query Responsibility Segregation) là một mẫu thiết kế không phải là thư viện hay framework, mà là một nguyên tắc kiến trúc. Nó đề xuất tách biệt các hoạt động đọc dữ liệu (Queries) khỏi các hoạt động ghi dữ liệu (Commands).

  • Commands (Lệnh): Đại diện cho các yêu cầu thay đổi trạng thái của hệ thống (ví dụ: CreateUserCommand, UpdateProductCommand, DeleteOrderCommand). Commands nên là các đối tượng mang tính chất mệnh lệnh, không trả về dữ liệu ngoại trừ xác nhận thành công/thất bại hoặc ID của thực thể mới được tạo.
  • Queries (Truy vấn): Đại diện cho các yêu cầu lấy dữ liệu từ hệ thống (ví dụ: GetProductByIdQuery, GetOrderHistoryQuery, ListAllUsersQuery). Queries không được phép thay đổi trạng thái của hệ thống, chúng chỉ trả về dữ liệu.

Tại sao lại tách biệt? Vì nhu cầu cho hoạt động đọc và ghi thường rất khác nhau:

  • Hoạt động ghi (Commands) thường liên quan đến logic nghiệp vụ phức tạp, xác thực, ràng buộc dữ liệu, transaction.
  • Hoạt động đọc (Queries) thường chỉ đơn giản là lấy dữ liệu và định dạng lại. Chúng có thể hưởng lợi từ các kỹ thuật tối ưu hóa đọc như caching (Chiến lược Cache, Redis, Cache In-Memory vs Cache Phân Tán), view model chuyên biệt, hoặc thậm chí là cơ sở dữ liệu riêng được tối ưu cho việc đọc (ví dụ: sử dụng NoSQL như MongoDB hoặc công cụ tìm kiếm như Elasticsearch cho các truy vấn phức tạp hoặc tìm kiếm toàn văn), trong khi hoạt động ghi sử dụng cơ sở dữ liệu quan hệ (SQL, SQL Server, PostgreSQL, MySQL) với các ràng buộc (Constraints).

Bằng cách tách biệt, bạn có thể tối ưu hóa từng luồng độc lập, quản lý độ phức tạp tốt hơn và dễ dàng mở rộng hệ thống (ví dụ: nhân rộng số lượng instance xử lý Queries nhiều hơn Commands nếu đó là bottleneck).

Kết Hợp MediatR và CQRS – Sự Phối Hợp Hoàn Hảo

MediatR là một thư viện lý tưởng để triển khai CQRS trong .NET. Mỗi Command hoặc Query có thể được biểu diễn như một Request của MediatR, và logic xử lý cho Command/Query đó được đặt trong một Handler tương ứng.

Hãy quay lại ví dụ tạo đơn hàng với sự kết hợp này:

// 1. Define the Command (a MediatR Request)
public record CreateOrderCommand : IRequest<int> // Returns the new OrderId
{
    public Guid CustomerId { get; init; }
    public List<OrderItemDto> Items { get; init; }
    // ... other properties ...
}

// 2. Define the Handler for the Command
public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, int>
{
    private readonly ApplicationDbContext _dbContext; // Or a dedicated Command DbContext
    // private readonly IEmailService _emailService; // Moved to Notification Handler
    // private readonly IInventoryService _inventoryService; // Moved to Notification Handler
    private readonly IMediator _mediator; // To publish events/notifications

    public CreateOrderCommandHandler(ApplicationDbContext dbContext, IMediator mediator)
    {
        _dbContext = dbContext;
        _mediator = mediator;
    }

    public async Task<int> Handle(CreateOrderCommand request, CancellationToken cancellationToken)
    {
        // Business logic to create the order entity
        var order = new Order
        {
            CustomerId = request.CustomerId,
            OrderDate = DateTime.UtcNow,
            // ... map items and other properties ...
        };

        _dbContext.Orders.Add(order);
        await _dbContext.SaveChangesAsync(cancellationToken); // Save changes here

        // Publish a notification AFTER the core command logic is done and persisted
        await _mediator.Publish(new OrderCreatedNotification(order.Id, order.CustomerId, order.Items), cancellationToken);

        return order.Id; // Return the new order ID
    }
}

// 3. Define a Notification (a MediatR Notification)
public record OrderCreatedNotification(int OrderId, Guid CustomerId, List<OrderItemDto>) : INotification;

// 4. Define Handlers for the Notification (side effects)
// Handler for sending email
public class SendOrderConfirmationEmailHandler : INotificationHandler<OrderCreatedNotification>
{
    private readonly IEmailService _emailService;

    public SendOrderConfirmationEmailHandler(IEmailService emailService)
    {
        _emailService = emailService;
    }

    public async Task Handle(OrderCreatedNotification notification, CancellationToken cancellationToken)
    {
        // Logic to send email
        await _emailService.SendOrderConfirmationEmailAsync(notification.CustomerId, notification.OrderId);
        // This handler is decoupled from the main command handler
    }
}

// Handler for updating inventory
public class UpdateInventoryHandler : INotificationHandler<OrderCreatedNotification>
{
    private readonly IInventoryService _inventoryService;

    public UpdateInventoryHandler(IInventoryService inventoryService)
    {
        _inventoryService = inventoryService;
    }

    public async Task Handle(OrderCreatedNotification notification, CancellationToken cancellationToken)
    {
        // Logic to update inventory
        await _inventoryService.UpdateInventoryAsync(notification.Items);
        // This handler is also decoupled
    }
}

// 5. The Controller uses the Mediator to send the Command
public class OrderController : ControllerBase
{
    private readonly IMediator _mediator;

    public OrderController(IMediator mediator)
    {
        _mediator = mediator;
    }

    [HttpPost("create")]
    public async Task<IActionResult> CreateOrder([FromBody] CreateOrderRequest request)
    {
        // ... validation ...

        var command = new CreateOrderCommand
        {
            CustomerId = request.CustomerId,
            Items = request.Items.Select(i => new OrderItemDto { ProductId = i.ProductId, Quantity = i.Quantity }).ToList()
            // ... map other properties ...
        };

        int orderId = await _mediator.Send(command); // Send the command via mediator

        // Side effects (email, inventory) are handled by Notification Handlers,
        // triggered by the mediator. The controller doesn't know about them.

        return Ok(orderId);
    }

    // Example Query usage
    [HttpGet("{id}")]
    public async Task<IActionResult> GetOrderById(int id)
    {
        var query = new GetOrderQuery { OrderId = id };
        var orderDto = await _mediator.Send(query); // Send the query via mediator

        if (orderDto == null)
        {
            return NotFound();
        }

        return Ok(orderDto);
    }
}

// Example Query and Handler
public record GetOrderQuery : IRequest<OrderDto?>
{
    public int OrderId { get; init; }
}

public class GetOrderQueryHandler : IRequestHandler<GetOrderQuery, OrderDto?>
{
    private readonly ApplicationDbContext _dbContext; // Or a dedicated Query DbContext

    public GetOrderQueryHandler(ApplicationDbContext dbContext)
    {
        _dbContext = dbContext;
    }

    public async Task<OrderDto?> Handle(GetOrderQuery request, CancellationToken cancellationToken)
    {
        // Simple read logic, potentially optimized
        var order = await _dbContext.Orders
            .Include(o => o.Items) // Potentially use Dapper or raw SQL for complex reads
            .FirstOrDefaultAsync(o => o.Id == request.OrderId, cancellationToken);

        if (order == null)
        {
            return null;
        }

        // Use AutoMapper or Mapperly to map to DTO
        // See: https://tuyendung.evotek.vn/anh-xa-du-lieu-trong-net-automapper-vs-anh-xa-thu-cong-uu-nhuoc-diem-va-nhung-cam-bay-can-tranh/
        // Or: https://tuyendung.evotek.vn/bat-dau-voi-mapperly-trong-du-an-asp-net-core-anh-xa-doi-tuong-hieu-qua-voi-source-generator-lo-trinh-net/
        var orderDto = new OrderDto // Using a simple DTO here
        {
            Id = order.Id,
            CustomerId = order.CustomerId,
            OrderDate = order.OrderDate,
            Items = order.Items.Select(item => new OrderItemDto { ProductId = item.ProductId, Quantity = item.Quantity }).ToList()
        };

        return orderDto;
    }
}

// Example DTOs (usually defined in shared contracts or application layer)
public record OrderDto
{
    public int Id { get; init; }
    public Guid CustomerId { get; init; }
    public DateTime OrderDate { get; init; }
    public List<OrderItemDto> Items { get; init; } = new();
}

public record OrderItemDto
{
    public Guid ProductId { get; init; }
    public int Quantity { get; init; }
}

Trong cấu trúc này:

  • Controller chỉ biết về IMediator và các loại Request (CreateOrderCommand, GetOrderQuery). Nó không biết bất kỳ Service cụ thể nào.
  • Logic xử lý chính việc tạo Order nằm gọn trong CreateOrderCommandHandler. Handler này chỉ phụ thuộc vào những gì nó cần (ApplicationDbContext, IMediator để publish event).
  • Các “side effects” (gửi email, cập nhật tồn kho) được tách ra thành các Notification Handlers riêng biệt, lắng nghe sự kiện OrderCreatedNotification. Chúng hoàn toàn không phụ thuộc vào Command Handler hay Controller.
  • Logic truy vấn Order nằm trong GetOrderQueryHandler, tách biệt khỏi logic ghi.

Lợi ích khi sử dụng MediatR với CQRS

Việc áp dụng MediatR và CQRS mang lại nhiều lợi ích đáng kể:

  1. Giảm sự phụ thuộc (Decoupling): Đây là lợi ích lớn nhất. Các thành phần gọi (như Controllers) chỉ phụ thuộc vào IMediator và các lớp Command/Query. Chúng không cần biết ai sẽ xử lý yêu cầu đó. Các Handlers cũng chỉ phụ thuộc vào các dependency cần thiết cho logic của *chính nó*. Điều này giúp code dễ thay đổi và mở rộng hơn.
  2. Tăng khả năng kiểm thử: Mỗi Handler (Command hoặc Query) là một lớp độc lập, thực hiện một nhiệm vụ cụ thể. Việc kiểm thử unit test cho từng Handler trở nên rất dễ dàng vì bạn chỉ cần tạo instance của Handler và mock các dependency ít hơn đáng kể so với việc kiểm thử một Controller “mập”. (Tìm hiểu thêm về Tiêm Phụ Thuộc và Kiểm Thử).
  3. Tổ chức code rõ ràng hơn: Các Command, Query và Handler được nhóm lại một cách logic, thường theo tính năng hoặc miền nghiệp vụ. Điều này giúp cấu trúc dự án dễ hiểu và quản lý hơn.
  4. Hỗ trợ CQRS một cách tự nhiên: MediatR là một thư viện tuyệt vời để triển khai mẫu CQRS, cung cấp cấu trúc cho việc gửi Commands và Queries đến các Handlers riêng biệt.
  5. Pipeline Behaviors: MediatR hỗ trợ Pipeline Behaviors, cho phép bạn chèn các logic xử lý trước hoặc sau khi một Handler được gọi (ví dụ: logging, validation, transaction handling, caching). Điều này giúp giảm lặp code và tập trung các cross-cutting concerns.
  6. Xử lý side effects với Notifications: Cơ chế Notifications cho phép bạn dễ dàng thêm các hành động phụ trợ (side effects) mà không làm phình to Command Handler chính. Bất kỳ số lượng Notification Handlers nào cũng có thể lắng nghe một Notification, giúp mở rộng chức năng hệ thống dễ dàng.

Để hình dung rõ hơn sự khác biệt về sự phụ thuộc, hãy xem bảng so sánh dưới đây:

Đặc Điểm Mô Hình Truyền Thống (Coupling cao) Sử Dụng MediatR + CQRS (Decoupling)
Dòng chảy xử lý Controller/Service gọi trực tiếp nhiều Service/Repository khác Controller/Service gửi Request (Command/Query) đến Mediator, Mediator ủy quyền cho đúng Handler
Sự phụ thuộc Controller phụ thuộc vào nhiều Service cụ thể; Service phụ thuộc vào nhiều Service/Repository khác. Controller/Service chỉ phụ thuộc vào IMediator và các lớp Request. Handler chỉ phụ thuộc vào các dependency cần thiết cho logic của nó. Notification Handlers độc lập lắng nghe Notifications.
Khả năng kiểm thử Unit Test Khó khăn, cần mock nhiều dependency cho Controller/Service phức tạp. Dễ dàng hơn, từng Handler là một unit độc lập với ít dependency hơn để mock. (Công cụ Mocking hữu ích).
Tổ chức Code Logic nghiệp vụ và side effects có thể phân tán hoặc gom nhóm kém. Commands/Queries/Handlers được nhóm theo tính năng/miền. Logic chính trong Handler, side effects trong Notification Handlers.
Mở rộng chức năng Cần sửa đổi các lớp hiện có để thêm logic mới (ví dụ: thêm bước gửi SMS khi tạo đơn hàng). Chỉ cần thêm một Notification Handler mới lắng nghe Notification phù hợp (ví dụ: thêm Handler gửi SMS khi nhận OrderCreatedNotification).
Độ phức tạp ban đầu Dễ bắt đầu cho ứng dụng nhỏ. Tốn thêm công sức thiết lập cấu trúc ban đầu (tạo các lớp Command/Query/Handler), có thể cảm giác “nhiều file” hơn cho các tác vụ đơn giản.

Nhược điểm và Khi nào nên sử dụng?

MediatR và CQRS không phải là giải pháp cho mọi vấn đề và mọi loại ứng dụng. Một số nhược điểm cần cân nhắc:

  • Tăng mức độ gián tiếp (Indirection): Dòng chảy xử lý không còn trực tiếp (Controller -> Service -> Repository) mà qua trung gian Mediator. Điều này có thể khiến việc theo dõi luồng thực thi ban đầu cảm thấy hơi “lạc” đối với người mới.
  • Tăng số lượng file: Mỗi Command, Query, Notification và Handler là một lớp riêng biệt, dẫn đến số lượng file trong dự án tăng lên đáng kể, đặc biệt với các tác vụ đơn giản.
  • Có thể quá mức cần thiết cho ứng dụng đơn giản: Đối với các ứng dụng CRUD (Create, Read, Update, Delete) đơn giản với ít logic nghiệp vụ, việc áp dụng MediatR và CQRS có thể là quá phức tạp và không mang lại nhiều lợi ích so với cấu trúc service truyền thống.

Vậy khi nào nên cân nhắc sử dụng MediatR kết hợp CQRS?

  • Khi xây dựng các ứng dụng có logic nghiệp vụ phức tạp.
  • Khi cần tách biệt rõ ràng giữa các hoạt động đọc và ghi để tối ưu hóa hoặc mở rộng độc lập.
  • Khi làm việc trong các kiến trúc như Clean Architecture, Domain-Driven Design (DDD), nơi sự tách biệt và tổ chức code là quan trọng.
  • Khi làm việc trong các dự án có nhiều lập trình viên cùng tham gia, nơi cấu trúc rõ ràng giúp giảm xung đột và hiểu code của nhau dễ hơn.
  • Khi cần khả năng mở rộng tốt và dễ dàng thêm các side effects mới.
  • Khi việc kiểm thử (đặc biệt là Unit Test) là ưu tiên hàng đầu.

Lời khuyên thực tế

  • Giữ cho Handlers tập trung: Mỗi Handler nên chỉ làm một việc duy nhất: xử lý Request của nó. Logic nghiệp vụ phức tạp nên được đặt trong các Domain Entities hoặc Domain Services (nếu bạn theo DDD), và Handler chỉ điều phối việc thực thi logic đó.
  • Sử dụng Records cho Commands/Queries/Notifications: Records trong C# là bất biến (immutable) theo mặc định, rất phù hợp cho các đối tượng mang dữ liệu như Requests và Notifications.
  • Tận dụng Pipeline Behaviors: Implement IPipelineBehavior<TRequest, TResponse> để thêm các bước xử lý chung như validation (ví dụ với FluentValidation), logging, transaction scoping quanh việc xử lý Request.
  • Cân nhắc kích thước dự án: Đối với các microservice rất nhỏ hoặc các API đơn giản, việc áp dụng MediatR/CQRS có thể là overkill. Hãy bắt đầu đơn giản và áp dụng khi độ phức tạp tăng lên.
  • Đọc tài liệu chính thức của MediatR: Thư viện này có tài liệu rất đầy đủ và ví dụ minh họa rõ ràng.

Kết luận

MediatR là một thư viện tuyệt vời giúp triển khai Mediator Pattern trong .NET, và nó đặc biệt mạnh mẽ khi kết hợp với nguyên tắc kiến trúc CQRS. Bằng cách áp dụng sự kết hợp này, bạn có thể xây dựng các ứng dụng .NET có cấu trúc rõ ràng hơn, giảm sự phụ thuộc giữa các thành phần, cải thiện khả năng kiểm thử và dễ dàng mở rộng trong tương lai.

Mặc dù có thể có một chút chi phí ban đầu về sự phức tạp và số lượng file, lợi ích mà MediatR và CQRS mang lại cho các ứng dụng có quy mô và độ phức tạp trung bình đến lớn là rất đáng giá. Đây chắc chắn là một kỹ năng quan trọng mà bạn nên bổ sung vào hành trang của mình trên Lộ Trình .NET.

Hãy thử nghiệm MediatR trong dự án tiếp theo của bạn và cảm nhận sự khác biệt mà nó mang lại nhé! Hẹn gặp lại các bạn trong bài viết tiếp theo của series!

Chỉ mục