Tại Sao Bạn Không Cần Repository Trong EF Core

Tác giả: Anton Martyniuk

Hầu hết các lập trình viên senior đều khuyên bạn nên bọc EF Core bên trong các repository interface của riêng mình.

Nhưng bạn có bao giờ tự hỏi: tại sao chúng ta cần một repository trên một repository?

Trong các dự án thực tế, lời khuyên này thường dẫn đến việc viết rất nhiều code boilerplate thừa và tạo ra các giải pháp quá phức tạp.

Mỗi tính năng giờ đây tốn nhiều thời gian hơn để triển khai và bảo trì so với nó nên có.

Có một cách tốt hơn.

Tại Sao Bạn Không Cần Repository Trong EF Core

Một trong những vấn đề phổ biến nhất là Repository có xu hướng phình to nhanh chóng khi yêu cầu nghiệp vụ phát triển.

Khi ứng dụng của bạn nhỏ, sử dụng Repository Pattern có vẻ dễ dàng.

Những gì bắt đầu như một thao tác CRUD đơn giản với 4 phương thức nhanh chóng trở thành một lớp lớn với các truy vấn đọc và ghi cơ sở dữ liệu cho tất cả các trường hợp có thể.

Khi domain của bạn phát triển, bạn phải đối mặt với một quyết định quan trọng: bạn có nên tạo Repository cho mỗi entity không?

Mỗi yêu cầu nghiệp vụ mới có nghĩa là thêm một phương thức khác vào Repository. Theo thời gian, bạn sẽ kết thúc với các lớp đầy các phương thức tương tự:

public class ShipmentRepository
{
    public Task<List<Shipment>> GetShipmentsByOrder(int userId) { ... }
    public Task<List<Shipment>> GetCancelledPosts() { ... }
    public Task<List<Shipment>> GetDeliveredShipmentsByCategory(string category) { ... }
    public Task<List<Shipment>> GetRecentShipments(int daysBack) { ... }
    // ...và nhiều hơn nữa!
}

Trở nên khó khăn hơn để tìm phương thức chính xác hoặc thậm chí nhớ những gì đã có trong mỗi repository.

Điều gì sẽ xảy ra nếu bạn có nhiều entity?

Khi làm việc với `Shipments`, `ShipmentItems` và `Orders`, việc tuân theo cách tiếp cận truyền thống dẫn đến:

public interface IShipmentRepository
{
    Task<ShipmentDto> GetByIdAsync(int id);
    Task<IEnumerable<ShipmentDto>> GetAllAsync();
    // ...
}

public interface IShipmentItemRepository
{
    Task<ShipmentItemDto> GetByIdAsync(int id);
    Task<IEnumerable<ShipmentItemDto>> GetByShipmentIdAsync(int shipmentId);
    // ...
}

public interface IOrderRepository
{
    Task<OrderDto> GetByIdAsync(int id);
    Task<IEnumerable<OrderDto>> GetByUserIdAsync(int userId);
    // ...
}

Nhưng điều gì sẽ xảy ra khi bạn cần tải các entity liên quan cùng nhau? Ví dụ:

  • Lấy một shipment với tất cả các mục hàng của nó
  • Lấy một order với các shipment liên quan
  • Lấy dữ liệu lịch sử shipment tải nhiều entity

Các phương thức cross-entity này thuộc về đâu?

Nhiều lập trình viên cuối cùng có rất nhiều repository nhưng không làm đủ. Và khi bạn triển khai một tính năng mới, bạn bắt đầu suy nghĩ về việc thêm phương thức mới vào một trong N repository.

Tôi thường nghe những lý do phổ biến cho việc sử dụng Repository:

1. “Chúng ta có thể chuyển đổi cơ sở dữ liệu sau này”

Đây là lý do phổ biến nhất.

Nhưng bạn thực sự có chuyển từ cơ sở dữ liệu này sang cơ sở dữ liệu khác trong môi trường production bao nhiêu lần?

Trong 99% trường hợp, bạn không cần chuyển đổi cơ sở dữ liệu. Tuy nhiên, ngay cả khi bạn chuyển từ một cơ sở dữ liệu SQL này sang cơ sở dữ liệu SQL khác (ví dụ: Postgres → SQL Server), 95%+ code trong EF Core sẽ không thay đổi.

Trong thực tế:

  • Chuyển từ SQL Server sang PostgreSQL? EF Core hỗ trợ cả hai.
  • Chuyển sang Cosmos DB hoặc MongoDB? Đó là việc viết lại toàn bộ logic truy cập dữ liệu, không chỉ là thay đổi repositories.

Vì vậy, trừ khi bạn đang tích cực xây dựng một abstraction layer đa cơ sở dữ liệu (phần lớn ứng dụng không cần), lý do này không hợp lý.

2. “Nó làm cho việc testing dễ dàng hơn”

Một số người cho rằng mocking một repository dễ dàng hơn mocking một DbContext.

Nhưng điều này che giấu một vấn đề lớn hơn: bạn đang testing một abstraction của một abstraction.

Mocking repositories thường dẫn đến các bài test dễ vỡ không phản ánh hành vi truy vấn thực tế.

Thay vào đó, tốt hơn là sử dụng EF Core thực với cơ sở dữ liệu in-memory hoặc, thậm chí tốt hơn, viết integration tests.

3. “Nó thực thi sự phân tách mối quan tâm”

Repositories thường được sử dụng trong N-Layered và Clean Architectures để giữ cho business layer tách biệt khỏi EF Core.

Nhưng trong thực tế, sự tách biệt này tạo ra nhiều sự nhầm lẫn hơn là sự rõ ràng.

Thay vì sự tách biệt sạch sẽ, bạn nhận được nhiều indirection hơn, nhiều boilerplate hơn và code khó theo dõi hơn.

Cách Sử Dụng EF Core Không Cần Repository

EF Core’s DbContext đã triển khai Repository và Unit of Work patterns, như được nêu trong code summary chính thức của DbContext.

Khi chúng ta tạo một repository trên EF Core, chúng ta tạo một abstraction trên một abstraction, dẫn đến các giải pháp quá phức tạp.

Mỗi `DbSet` trong DbContext của bạn đại diện cho một collection của các entity, giống như một repository điển hình.

Nó cho phép bạn:

  • Truy vấn dữ liệu bằng LINQ
  • Thêm, cập nhật và xóa entity
  • Project dữ liệu sang các loại khác

Khi bạn cần tìm tất cả shipments cho một order, bạn viết code như thế này:

var shipments = await dbContext.Shipments
    .Include(s => s.Items)
    .Where(s => s.OrderId == orderId)
    .ToListAsync();

Nó cực kỳ đơn giản và trực tiếp. Bạn không cần một repository để truy vấn dữ liệu này.

Nếu bạn cần lấy order shipments từ một vài use case? Chỉ cần sao chép truy vấn đơn giản này ở một vài nơi, không phải vấn đề lớn.

Nhưng nếu bạn có một truy vấn phức tạp hơn? Bạn luôn có thể trích xuất nó như một extension method cho `DbSet` và sử dụng lại thuận tiện hơn:

var shipments = await dbContext.Shipments
    .GetActiveShipmentsForOrder(orderId)
    .ToListAsync();

Inject DbContext Trong Services Hoặc Use Cases

Thay vì inject `IShipmentRepository`, `IShipmentItemRepository` và `IOrderRepository`, chỉ cần inject DbContext.

internal sealed class CreateShipmentCommandHandler(
    ShipmentsDbContext context,
    ILogger<CreateShipmentCommandHandler> logger)
    : IRequestHandler<CreateShipmentCommand, ErrorOr<ShipmentResponse>>
{
    public async Task<ErrorOr<ShipmentResponse>> Handle(
        CreateShipmentCommand request,
        CancellationToken cancellationToken)
    {
        var shipmentExists = await context.Shipments
            .AnyAsync(x => x.OrderId == request.OrderId, cancellationToken);

        if (shipmentExists)
        {
            return Error.Conflict("Shipment already exists");
        }

        var shipment = request.MapToShipment();
        await context.Shipments.AddAsync(shipment, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        return shipment.MapToResponse();
    }
}

Code này tập trung, có thể kiểm thử và dễ theo dõi.

Sử Dụng EF Core Trực Tiếp Trong N-Layered Applications

N-Layered Architecture vẫn rất phổ biến trong các codebase.

Nó được xây dựng xung quanh việc tách trách nhiệm thành các layers logic, thường là:

  • Presentation Layer: Controllers, API endpoints hoặc UI components
  • Business Logic Layer (Service): Application services đóng gói business rules
  • Data Access Layer (Repository): Abstraction trên data persistence

Trong kiến trúc N-Layered, Application Layer của bạn nên chứa business logic và use cases. Nó nên điều phối workflows, thực thi policies và gọi domain model hoặc infrastructure.

Thay vì inject repositories vào các application service, bạn có thể inject DbContext trực tiếp:

public sealed class ShipmentService(
    ShipmentsDbContext context,
    ILogger<ShipmentService> logger)
{
    public async Task<ErrorOr<ShipmentResponse>> CreateAsync(
        CreateShipmentCommand request,
        CancellationToken token = default)
    {
        var shipmentAlreadyExists = await context.Shipments
            .AnyAsync(x => x.OrderId == request.OrderId, token);

        if (shipmentAlreadyExists)
        {
            logger.LogInformation("Shipment for order '{OrderId}' is already created", request.OrderId);
            return Error.Conflict("Shipment for order '{request.OrderId}' is already created");
        }

        var shipmentNumber = new Faker().Commerce.Ean8();
        var shipment = request.MapToShipment(shipmentNumber);

        await context.Shipments.AddAsync(shipment, token);
        await context.SaveChangesAsync(token);

        logger.LogInformation("Created shipment: {@Shipment}", shipment);
        return shipment.MapToResponse();
    }

    public async Task<ErrorOr<ShipmentResponse>> GetByIdAsync(
        Guid shipmentId,
        CancellationToken token = default)
    {
        var shipment = await context.Shipments
            .Include(s => s.Items)
            .FirstOrDefaultAsync(s => s.Id == shipmentId, token);

        if (shipment is null)
        {
            return Error.NotFound("Shipment '{shipmentId}' not found");
        }

        return shipment.MapToResponse();
    }
}

Liệu code có trở nên khó khăn hơn? Hoàn toàn không.

Thay vào đó, cách tiếp cận này sẽ giúp bạn tiết kiệm thời gian:

  • Không phải tạo phương thức repository mới mỗi lần
  • Không phải điều hướng từ service đến repository và ngược lại để xem toàn bộ implementation

Sử Dụng EF Core Trực Tiếp Trong Clean Architecture Và Vertical Slice Architecture

Sử Dụng EF Core Trực Tiếp Trong Clean Architecture

Clean Architecture nhằm mục đích tách biệt các mối quan tâm của ứng dụng thành các layers riêng biệt, thúc đẩy high cohesion và low coupling.

Nó bao gồm các layers sau:

  • Domain: chứa các business object cốt lõi như entities
  • Application: implementation của business use cases
  • Infrastructure: implementation của các external dependencies như database, cache, message queue, etc.
  • Presentation: implementation của interface với thế giới bên ngoài

Nhưng theo thời gian, Clean Architecture đã phát triển thành một cách tiếp cận Pragmatic: nơi các lập trình viên đồng ý rằng họ có thể sử dụng EF Core bên trong các use case của Application.

Bằng cách sử dụng DbContext trực tiếp, bạn giảm boilerplate trong khi vẫn giữ cho Domain cốt lõi không phụ thuộc vào EF.

Nếu một ngày nào đó bạn thay thế EF Core, đó không chỉ là repositories bạn sẽ viết lại — đó là toàn bộ persistence logic của bạn.

Sử Dụng EF Core Trực Tiếp Trong Vertical Slice Architecture

Vertical Slice Architecture tập trung vào các tính năng, không phải layers.

Tôi tin rằng sự tiến hóa tự nhiên của Clean Architecture, với các feature folders, đã dẫn đến sự chuyển đổi của nó thành Vertical Slice Architecture.

Điểm chính là các lớp của inner layers không thể gọi các lớp của outer layers. Với Vertical Slice Architecture, bạn có thể đạt được điều tương tự nhưng với ít projects hơn.

Và nếu chúng ta sử dụng EF Core trực tiếp trong các application use cases của Clean Architecture, nó sẽ giống hệt với Vertical Slice Architecture.

Sử Dụng Specification Pattern Với EF Core

Một trong các tùy chọn được đề cập ở trên để tránh trùng lặp code khi sử dụng EF Core trực tiếp là sử dụng Specification Pattern.

Mỗi Specification đại diện cho một filter hoặc một rule có thể được áp dụng cho một truy vấn. Điều này cho phép bạn xây dựng các truy vấn phức tạp bằng cách kết hợp các lớp đơn giản, dễ hiểu.

Thay vì viết hàng chục phương thức trong Repository của bạn, bạn có thể tạo các specification mới khi yêu cầu phát triển. Bạn sau đó có thể truyền các specification này đến DbContext của bạn.

Dưới đây là một ví dụ về Specification trả về các bài đăng viral trong một ứng dụng mạng xã hội với ít nhất 150 lượt thích:

public class ViralPostSpecification : Specification<Post>
{
    public ViralPostSpecification(int minLikesCount = 150)
    {
        AddFilteringQuery(post => post.Likes.Count >= minLikesCount);
        AddOrderByDescendingQuery(post => post.Likes.Count);
    }
}

Bạn có thể sử dụng lại Specification này ở bất cứ đâu trong code của mình để lấy các bài đăng “viral”.

Một vài trường hợp Specifications có thể hữu ích:

  • Trích xuất các truy vấn phức tạp phổ biến thành các lớp có thể sử dụng lại
  • Kết hợp nhiều truy vấn thành một truy vấn duy nhất

Sức mạnh thực sự nằm ở việc cho phép nhiều specification được kết hợp động dựa trên dữ liệu đầu vào.

Testability Với EF Core

Một lý do phổ biến các lập trình viên đưa ra để tạo repositories là testability.

Nhưng đây là thực tế:

  • Mocking repositories thường dẫn đến hành vi giả không khớp với EF Core
  • Các bài test của bạn trở nên dễ vỡ và ít giá trị hơn
  • Bạn không thực sự test các truy vấn của mình, thường là phần quan trọng nhất

Tùy Chọn 1: Sử Dụng EF Core InMemory Provider

EF Core có một in-memory database provider, cho phép bạn chạy tests mà không cần cơ sở dữ liệu vật lý.

var options = new DbContextOptionsBuilder<ShipmentsDbContext>()
    .UseInMemoryDatabase("ShipmentsTestDb")
    .Options;

using var context = new ShipmentsDbContext(options);

// Arrange
context.Shipments.Add(new Shipment { OrderId = Guid.NewGuid(), Address = "Berlin" });
await context.SaveChangesAsync();

// Act
var shipment = await context.Shipments.FirstOrDefaultAsync();

// Assert
Assert.NotNull(shipment);

Tùy Chọn 2: Viết Integration Tests

Đây là tùy chọn yêu thích của tôi.

Viết các bài test nói chuyện với cơ sở dữ liệu thực là cách tốt nhất để đảm bảo ứng dụng của bạn hoạt động như mong đợi.

Tại sao điều này tốt hơn mocking repositories:

  • Mocks nói dối: chúng không sao chép EF Core’s LINQ-to-SQL translation, eager loading, hoặc tracking behavior
  • Các bài test EF Core thực bắt các vấn đề thực: như các phép join không chính xác, projections xấu hoặc Includes bị thiếu
  • Integration tests cover nhiều hơn: đảm bảo không chỉ code của bạn hoạt động, mà còn DB schema của bạn chính xác

Bằng cách test với EF Core trực tiếp, bạn không mất testability — bạn thực sự đạt được độ tin cậy.

Khi Bạn Vẫn Có Thể Cần Một Custom Repository

Cho đến nay, chúng ta đã tranh luận rằng hầu hết thời gian bạn không cần repositories với EF Core. Nhưng giống như mọi quy tắc, có những ngoại lệ.

Dưới đây là các trường hợp mà một custom repository có thể có ý nghĩa:

  • Các Truy Vấn Rất Phức Tạp Được Sử Dụng Ở Nhiều Nơi: Nếu bạn có một truy vấn kéo dài nhiều aggregates, liên quan đến filtering nặng, sorting, hoặc joins — và nó được sử dụng trên nhiều tính năng — bọc nó vào một phương thức repository có thể giảm trùng lặp và tập trung logic
  • Team Conventions Hoặc Project Constraints: Trong một số tổ chức, các hướng dẫn kiến trúc yêu cầu chặt chẽ repositories để nhất quán
  • Cross-Cutting Infrastructure Concerns: Đôi khi, bạn muốn trang trí repositories với hành vi bổ sung, chẳng hạn như caching, logging hoặc auditing
  • External Integrations: Nếu dự án của bạn truy vấn nhiều nguồn dữ liệu, một repository có thể hoạt động như một facade để thống nhất các nguồn này phía sau một single abstraction
  • Khi Sử Dụng Dapper: Khi sử dụng Dapper, repositories là cần thiết vì chúng abstract SQL khỏi phần còn lại của ứng dụng của bạn

Tóm Tắt

Hãy tóm tắt các điểm chính:

  • EF Core đã triển khai Repository và Unit of Work. `DbSet` là repository của bạn. `DbContext.SaveChangesAsync()` là unit of work của bạn
  • Repositories thường thêm sự phức tạp không cần thiết. Chúng dẫn đến fat repositories, quá nhiều small repositories hoặc các truy vấn trùng lặp trên các services
  • Sử dụng EF Core trực tiếp trong các application services, handlers hoặc vertical slices. Điều này giữ cho code của bạn đơn giản hơn, tập trung hơn và dễ bảo trì hơn
  • Sử dụng Specification Pattern cho query reuse. Nó tránh trùng lặp và giữ cho các truy vấn phức tạp có thể kết hợp
  • Testing hoạt động tốt mà không cần repositories. Sử dụng EF Core InMemory hoặc integration tests — thay vì mocking repositories
  • Repositories vẫn có các công dụng đặc biệt. Cho các truy vấn phức tạp dùng chung, cross-cutting concerns hoặc multi-source data access, một repository có thể hữu ích

Trong các ứng dụng .NET hiện đại, sử dụng EF Core trực tiếp trong application layer hoặc vertical slices thường là lựa chọn sạch nhất, đơn giản nhất và pragmatic nhất.

Repositories không chết — nhưng chúng không còn là mặc định nữa. Chỉ sử dụng chúng khi chúng thực sự thêm giá trị.

Không có cách duy nhất đúng để viết phần mềm; bạn cần chọn bất cứ điều gì hoạt động tốt nhất trong mỗi dự án và trường hợp cụ thể.

Chỉ mục