Specification Pattern trong EF Core: Truy Cập Dữ Liệu Linh Hoạt Không Cần Repository

Tại sao Repository Trở Thành Nút Cổ Chai Trong Dự Án Thực Tế

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

Bạn đặt tất cả các truy vấn dữ liệu vào một chỗ, như PostRepository hoặc UserRepository. Mỗi phương thức được thiết kế để trả lời một câu hỏi cụ thể, như “Lấy tất cả bài viết gần đây” hoặc “Tìm người dùng theo email”.

Nhưng khi dự án phát triển, bạn sẽ nhận thấy một vài vấn đề lớn:

1. Repository Trở Nên Quá Lớn

Mỗi yêu cầu kinh doanh 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ẽ có các lớp đầy những phương thức tương tự nhau:

public class PostRepository
{
public Task<List<Post>> GetPostsByUser(int userId) { ... }
public Task<List<Post>> GetPopularPosts() { ... }
public Task<List<Post>> GetPostsByCategory(string category) { ... }
public Task<List<Post>> GetRecentViralPosts(int daysBack) { ... }
// ...và nhiều hơn nữa!
}

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

2. Đặt Tên Phương Thức và Trùng Lặp

Bạn bắt đầu viết những tên phương thức dài và khó hiểu để mô tả từng bộ lọc. Ví dụ: GetPostsByCategoryAndLikesCountAndDate.

Và nếu bạn cần cùng một phương thức nhưng với sắp xếp theo ngày giảm dần thì sao? Điều này dẫn đến rất nhiều mã trùng lặp.

3. Quá Nhiều Repository

Cố gắng giữ repository nhỏ đôi khi có nghĩa là chia chúng thành nhiều lớp nhỏ. Nhưng sau đó bạn mất đi lợi ích của việc có mọi thứ ở một chỗ.

Bây giờ, việc tìm repository cần thiết và tái sử dụng logic giữa các repository trở nên khó khăn hơn.

Hãy nhớ rằng DbContext của EF Core đã triển khai Repository và Unit of Work patterns, như đã nêu trong phần tóm tắt mã chính thức của DbContext. Khi chúng ta tạo một repository trên EF Core, chúng ta đang tạo một abstraction trên một abstraction, dẫn đến các giải pháp được thiết kế quá phức tạp.

Specification Pattern Là Gì?

Specification Pattern là một cách để mô tả dữ liệu bạn muốn từ cơ sở dữ liệu bằng cách sử dụng các lớp nhỏ, có thể tái sử dụng được gọi là “specifications”.

Mỗi Specification đại diện cho một bộ lọc hoặc quy tắc 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.

Specification Pattern mang lại những lợi ích sau:

  • Tái sử dụng: Bạn có thể viết một specification một lần và sử dụng nó ở bất kỳ đâu trong dự án của mình
  • Kết hợp: Bạn có thể kết hợp hai hoặc nhiều specifications để tạo các truy vấn nâng cao hơn
  • Có thể kiểm thử: Specifications là các lớp trên EF Core (hoặc bất kỳ ORM nào khác), vì vậy bạn có thể kiểm thử chúng với unit tests, hoặc tốt hơn – integration tests
  • Tách biệt mối quan tâm: Logic truy vấn của bạn được tách biệt khỏi mã truy cập dữ liệu. Điều này giữ cho mọi thứ sạch sẽ

Thay vì viết hàng chục phương thức trong Repository, bạn chỉ cần tạo các specifications mới khi yêu cầu phát triển. Sau đó, bạn có thể chuyển các specifications này cho DbContext (hoặc thậm chí một repository, nếu bạn vẫn muốn sử dụng một cái).

Đây là một ví dụ về Specification trả về các bài viết viral trong ứ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ể tái sử dụng Specification này ở bất kỳ đâu trong mã của mình để lấy các bài viết “viral”.

Cách Triển Khai Specifications Trong EF Core

Để triển khai specifications trong EF Core, bạn cần làm theo các bước sau:

Bước 1: Định Nghĩa Interface Specification

Bạn cần một cách chung để mô tả bộ lọc, includes và sắp xếp cho bất kỳ entity nào. Đây là một interface đơn giản:

public interface ISpecification<TEntity>
where TEntity : class
{
Expression<Func<TEntity, bool>>? FilterQuery { get; }
IReadOnlyCollection<Expression<Func<TEntity, object>>>? IncludeQueries { get; }
IReadOnlyCollection<Expression<Func<TEntity, object>>>? OrderByQueries { get; }
IReadOnlyCollection<Expression<Func<TEntity, object>>>? OrderByDescendingQueries { get; }
}

Bước 2: Tạo Lớp Specification Cơ Bản

Lớp cơ bản này giữ logic để xây dựng specifications. Bạn thêm bộ lọc, includes và biểu thức sắp xếp ở một nơi:

public abstract class Specification<TEntity> : ISpecification<TEntity>
where TEntity : class
{
private List<Expression<Func<TEntity, object>>>? _includeQueries;
private List<Expression<Func<TEntity, object>>>? _orderByQueries;
private List<Expression<Func<TEntity, object>>>? _orderByDescendingQueries;

public Expression<Func<TEntity, bool>>? FilterQuery { get; private set; }
public IReadOnlyCollection<Expression<Func<TEntity, object>>>? IncludeQueries => _includeQueries;
public IReadOnlyCollection<Expression<Func<TEntity, object>>>? OrderByQueries => _orderByQueries;
public IReadOnlyCollection<Expression<Func<TEntity, object>>>? OrderByDescendingQueries => _orderByDescendingQueries;

protected Specification() {}

protected Specification(Expression<Func<TEntity, bool>> query)
{
FilterQuery = query;
}

protected Specification(ISpecification<TEntity> specification)
{
FilterQuery = specification.FilterQuery;

_includeQueries = specification.IncludeQueries?.ToList();
_orderByQueries = specification.OrderByQueries?.ToList();
_orderByDescendingQueries = specification.OrderByDescendingQueries?.ToList();
}

protected void AddFilteringQuery(Expression<Func<TEntity, bool>> query)
{
FilterQuery = query;
}

protected void AddIncludeQuery(Expression<Func<TEntity, object>> query)
{
_includeQueries ??= new();
_includeQueries.Add(query);
}

protected void AddOrderByQuery(Expression<Func<TEntity, object>> query)
{
_orderByQueries ??= new();
_orderByQueries.Add(query);
}

protected void AddOrderByDescendingQuery(Expression<Func<TEntity, object>> query)
{
_orderByDescendingQueries ??= new();
_orderByDescendingQueries.Add(query);
}
}

Bước 3: Áp Dụng Specification Trong EF Core

Bây giờ hãy kết nối lớp Specification của chúng ta với EF Core:

public class EfCoreSpecification<TEntity> : Specification<TEntity>
where TEntity : class
{
public EfCoreSpecification(ISpecification<TEntity> specification)
: base(specification) { }

public virtual IQueryable<TEntity> Apply(IQueryable<TEntity> queryable)
{
if (FilterQuery is not null)
{
queryable = queryable.Where(FilterQuery);
}

if (IncludeQueries?.Count > 0)
{
queryable = IncludeQueries.Aggregate(queryable,
(current, includeQuery) => current.Include(includeQuery));
}

if (OrderByQueries?.Count > 0)
{
var orderedQueryable = queryable.OrderBy(OrderByQueries.First());

orderedQueryable = OrderByQueries.Skip(1)
.Aggregate(orderedQueryable, (current, orderQuery) => current.ThenBy(orderQuery));

queryable = orderedQueryable;
}

if (OrderByDescendingQueries?.Count > 0)
{
var orderedQueryable = queryable.OrderByDescending(OrderByDescendingQueries.First());

orderedQueryable = OrderByDescendingQueries.Skip(1)
.Aggregate(orderedQueryable, (current, orderQuery) => current.ThenByDescending(orderQuery));

queryable = orderedQueryable;
}

return queryable;
}
}

Phương thức Apply lấy một truy vấn cơ sở dữ liệu và thêm các loại bộ lọc và sắp xếp khác nhau vào nó từng bước một.

Đầu tiên, nó kiểm tra xem có điều kiện lọc nào không (như “chỉ hiển thị bài viết có hơn 10 lượt thích”) và áp dụng nó bằng phương thức Where.

Tiếp theo, nó tìm kiếm bất kỳ dữ liệu liên quan nào cần được tải cùng nhau (được gọi là “includes”) và thêm chúng bằng phương thức Include (EF Core Eager Loading).

Sau đó, nó xử lý sắp xếp – áp dụng cái đầu tiên với OrderBy và bất kỳ cái bổ sung nào với ThenBy.

Cuối cùng, phương thức trả về truy vấn đã được sửa đổi với tất cả các điều kiện này được áp dụng, sẵn sàng để gửi đến cơ sở dữ liệu.

Bước 4: Sử Dụng Specifications Trực Tiếp Trong Endpoints

Với pattern này, bạn đặt logic truy vấn của mình vào các lớp nhỏ, có thể tái sử dụng được gọi là specifications. Bạn có thể sử dụng chúng trực tiếp với DbContext trong EF Core. Điều đó có nghĩa là bạn hoàn toàn không cần Repository Pattern.

Bây giờ bạn có thể viết các endpoints đơn giản, sạch sẽ:

public class GetViralPostsEndpoint : IEndpoint
{
public void MapEndpoint(WebApplication app)
{
app.MapGet("/api/social-media/viral-posts", Handle);
}

private static async Task<IResult> Handle(
[FromQuery] int? minLikesCount,
[FromServices] ApplicationDbContext dbContext,
[FromServices] ILogger<GetViralPostsEndpoint> logger,
CancellationToken cancellationToken)
{
var specification = new ViralPostSpecification(minLikesCount ?? 150);

var response = await dbContext
.ApplySpecification(specification)
.Select(post => post.ToDto())
.ToListAsync(cancellationToken);

logger.LogInformation("Retrieved {Count} viral posts with minimum {MinLikes} likes",
response.Count, minLikesCount ?? 150);

return Results.Ok(response);
}
}

Specifications Nâng Cao

Một trong những điều tốt nhất về Specification Pattern là bạn có thể dễ dàng xây dựng các truy vấn nâng cao bằng cách kết hợp các specifications nhỏ, tập trung.

Bạn có thể kết hợp hai hoặc nhiều specifications với nhau bằng cách sử dụng các toán tử logic như ANDOR.

Đây là cách tạo AndSpecificationOrSpecification:

public class AndSpecification<TEntity> : Specification<TEntity>
where TEntity : class
{
public AndSpecification(Specification<TEntity> left, Specification<TEntity> right)
{
RegisterFilteringQuery(left, right);
}

private void RegisterFilteringQuery(Specification<TEntity> left, Specification<TEntity> right)
{
var leftExpression = left.FilterQuery;
var rightExpression = right.FilterQuery;

if (leftExpression is null && rightExpression is null)
{
return;
}

if (leftExpression is not null && rightExpression is null)
{
AddFilteringQuery(leftExpression);
return;
}

if (leftExpression is null && rightExpression is not null)
{
AddFilteringQuery(rightExpression);
return;
}

var replaceVisitor = new ReplaceExpressionVisitor(rightExpression!.Parameters.Single(), leftExpression!.Parameters.Single());
var replacedBody = replaceVisitor.Visit(rightExpression.Body);

var andExpression = Expression.AndAlso(leftExpression.Body, replacedBody);
var lambda = Expression.Lambda<Func<TEntity, bool>>(andExpression, leftExpression.Parameters.Single());

AddFilteringQuery(lambda);
}
}

Để kết hợp hai truy vấn, chúng ta cần sử dụng Expression.AndAlso hoặc Expression.OrElse với một Expression Visitor:

internal class ReplaceExpressionVisitor : ExpressionVisitor
{
private readonly Expression _oldValue;
private readonly Expression _newValue;

public ReplaceExpressionVisitor(Expression oldValue, Expression newValue)
{
_oldValue = oldValue;
_newValue = newValue;
}

public override Expression Visit(Expression? node)
=> (node == _oldValue ? _newValue : base.Visit(node))!;
}

Để đơn giản hóa việc sử dụng các Specifications này, hãy tạo 2 phương thức trợ giúp trong lớp Specification:

public Specification<TEntity> And(Specification<TEntity> specification)
=> new AndSpecification<TEntity>(this, specification);

public Specification<TEntity> Or(Specification<TEntity> specification)
=> new OrSpecification<TEntity>(this, specification);

Hãy cùng khám phá một ví dụ thực tế.

Giả sử bạn có một Specification tìm kiếm bài viết trong một danh mục nhất định:

public class PostByCategorySpecification : Specification<Post>
{
public PostByCategorySpecification(string categoryName)
{
AddFilteringQuery(post => post.Category.Name == categoryName);
}
}

Bạn có thể kết hợp các specifications để chọn các bài viết thuộc danh mục “.NET” hoặc “Architecture”:

public class DotNetAndArchitecturePostSpecification : Specification<Post>
{
public DotNetAndArchitecturePostSpecification()
{
var dotNetSpec = new PostByCategorySpecification(".NET");
var architectureSpec = new PostByCategorySpecification("Architecture");

// Kết hợp 2 specifications với OrSpecification
var combinedSpec = dotNetSpec.Or(architectureSpec);

AddFilteringQuery(combinedSpec.FilterQuery!);

AddOrderByDescendingQuery(post => post.Id);
}
}

Ví dụ khác, nơi chúng ta chọn các bài viết vừa gần đây có mức độ tương tác cao:

public class HighEngagementRecentPostSpecification : Specification<Post>
{
public HighEngagementRecentPostSpecification(int daysBack = 7,
int minLikes = 100, int minComments = 30)
{
var recentSpec = new RecentPostSpecification(daysBack);
var highEngagementSpec = new HighEngagementPostSpecification(minLikes, minComments);

// Kết hợp 2 specifications với AndSpecification
var combinedSpec = recentSpec.And(highEngagementSpec);

AddFilteringQuery(combinedSpec.FilterQuery!);

AddOrderByDescendingQuery(post => post.Likes.Count + post.Comments.Count);
}
}

Điều này cung cấp cho chúng ta tính linh hoạt và khả năng tái sử dụng của các Specifications hiện có để tạo thành các specifications mới.

Tổng Kết

Specification Pattern là một công cụ mạnh mẽ để xây dựng các truy vấn cơ sở dữ liệu linh hoạt và có thể tái sử dụng trong các dự án .NET của bạn. Bằng cách định nghĩa các bộ lọc, includes và quy tắc sắp xếp của bạn dưới dạng các lớp specification, bạn tránh được các vấn đề đi kèm với các repositories lớn, khó bảo trì.

Với EF Core, bạn không cần Repository Pattern — bạn có thể áp dụng specifications của mình trực tiếp vào DbContext.

Phương pháp này:

  • Giữ cho codebase của bạn sạch sẽ và dễ thay đổi
  • Làm cho các truy vấn của bạn có thể tái sử dụng trên nhiều phần của ứng dụng
  • Cho phép bạn kết hợp và tổ hợp các specifications cho các kịch bản nâng cao
  • Giúp bạn kiểm thử logic truy vấn của mình một cách riêng biệt với cơ sở dữ liệu

Bất cứ khi nào bạn thấy mình thêm ngày càng nhiều phương thức vào một repository hoặc viết logic truy vấn trùng lặp, hãy cân nhắc sử dụng Specification Pattern thay thế. Nó sẽ giúp dự án của bạn phát triển một cách lành mạnh, dễ bảo trì.

Chỉ mục