Lưu trữ mô hình miền giàu hành vi bằng EF Core

EF Core không yêu cầu setter công khai hay constructor không tham số công khai. EF Core có thể tạo entity thông qua constructor private, đồng thời đọc và ghi trạng thái qua các backing field. Vì vậy, một aggregate được đóng gói hoàn toàn vẫn có thể được tải và lưu như mọi entity khác. ID kiểu mạnh, collection private, value object và domain event đều có thể được xử lý trong các lớp cấu hình mà không cần để domain tham chiếu đến EF Core.

Mỗi khi tôi viết về việc chuyển mô hình miền nghèo thành mô hình miền giàu, luôn có một ý kiến phản đối quen thuộc:

Nghe hay trên lý thuyết, nhưng EF Core cần setter công khai và constructor không tham số công khai. ORM buộc chúng ta phải dùng mô hình nghèo.

Điều đó từng đúng cách đây một thập kỷ, nhưng hiện nay thì không. EF Core có thể lưu một aggregate được đóng gói hoàn toàn. Đổi lại, mô hình miền có thể đảm bảo các bất biến tại một nơi duy nhất, còn ORM âm thầm hoàn thành phần việc của nó.

Aggregate cần được lưu trữ

Ví dụ lần này dùng domain sản xuất bia tại nhà, vì các ví dụ về aggregate đơn hàng đã xuất hiện quá nhiều. Một Batch sẽ lên men, bạn lấy các chỉ số tỷ trọng, rồi đóng chai khi tỷ trọng ổn định. Nếu đóng chai quá sớm, khi men vẫn đang tiêu thụ đường, chai có thể phát nổ.

Dưới đây là cách tôi muốn viết Batch: không có setter công khai, khởi tạo thông qua factory và mọi thay đổi trạng thái đều đi qua các phương thức.

public sealed class Batch
{
    private readonly List<FermentationReading> _readings = [];
    private readonly List<IDomainEvent> _domainEvents = [];
    private DateTime? _bottledAtUtc;

    private Batch() { } // Dành cho EF Core

    private Batch(BatchId id, RecipeId recipeId, Volume volume)
    {
        Id = id;
        RecipeId = recipeId;
        Volume = volume;
        Status = BatchStatus.Fermenting;
    }

    public BatchId Id { get; private set; }
    public RecipeId RecipeId { get; private set; }
    public BatchStatus Status { get; private set; }
    public Volume Volume { get; private set; }

    public IReadOnlyCollection<FermentationReading> Readings => _readings.AsReadOnly();
    public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();

    public static Batch Start(RecipeId recipeId, Volume volume) =>
        new(new BatchId(Guid.CreateVersion7()), recipeId, volume);

    public void AddReading(Gravity gravity, DateTime takenAtUtc)
    {
        if (Status != BatchStatus.Fermenting)
        {
            throw new DomainException("Readings only make sense while the batch is fermenting.");
        }

        _readings.Add(new FermentationReading(gravity, takenAtUtc));
    }

    public void Bottle(TimeProvider timeProvider)
    {
        if (Status != BatchStatus.Fermenting)
        {
            throw new DomainException("Only a fermenting batch can be bottled.");
        }

        Gravity[] lastTwo = _readings
            .OrderBy(r => r.TakenAtUtc)
            .TakeLast(2)
            .Select(r => r.Gravity)
            .ToArray();

        if (lastTwo.Length < 2 || lastTwo[0] != lastTwo[1])
        {
            throw new DomainException(
                "Gravity must hold steady across two readings before bottling.");
        }

        Status = BatchStatus.Bottled;
        _bottledAtUtc = timeProvider.GetUtcNow().UtcDateTime;
        _domainEvents.Add(new BatchBottled(Id));
    }

    public void ClearDomainEvents() => _domainEvents.Clear();
}

Các kiểu hỗ trợ là record, nhờ đó có sẵn cơ chế so sánh theo giá trị:

public readonly record struct BatchId(Guid Value);
public readonly record struct RecipeId(Guid Value);
public readonly record struct Gravity(decimal Value);

public sealed record Volume(decimal Amount, string Unit);

Ba lựa chọn trong ví dụ bắt nguồn trực tiếp từ thiết kế aggregate: RecipeId chỉ tham chiếu aggregate khác bằng ID; FermentationReading là entity con có vòng đời gắn với batch; còn _bottledAtUtc là một field private hoàn toàn không có property. Hãy lần lượt xem EF Core xử lý từng trường hợp như thế nào.

Constructor private và setter private vẫn hoạt động

Khi EF Core materialize một entity, nó không sử dụng API công khai của bạn. EF gọi constructor không tham số private rồi ghi vào các property thông qua backing field, kể cả khi setter là private. Đó là lý do tồn tại của private Batch() { }; đây cũng là nhượng bộ duy nhất mà mô hình miền dành cho ORM.

Việc tải và thay đổi một batch không khác các đoạn mã EF thông thường:

Batch batch = await context.Batches
    .SingleAsync(b => b.Id == batchId);

batch.AddReading(new Gravity(1.012m), timeProvider.GetUtcNow().UtcDateTime);

await context.SaveChangesAsync();

Cơ chế change tracking cũng đọc chính các backing field đó, vì vậy setter private không che giấu điều gì khỏi SaveChanges.

EF thậm chí có thể liên kết với constructor có tham số bằng cách đối chiếu tên và kiểu của tham số với các property được ánh xạ, bất kể chúng có private hay không. Tuy nhiên, navigation không thể được bind vào constructor. Do đó, aggregate có collection vẫn cần constructor không tham số. EF cũng bỏ qua phần validation trong factory khi tải dữ liệu. Đây là hành vi đúng: nếu chạy lại các quy tắc trong lúc materialize, những dòng dữ liệu lịch sử có thể không tải được sau khi quy tắc thay đổi.

ID kiểu mạnh và tham chiếu đến aggregate khác

BatchId và RecipeId là ID kiểu mạnh. Chúng được ánh xạ bằng value conversion trong IEntityTypeConfiguration<Batch>:

public sealed class BatchConfiguration : IEntityTypeConfiguration<Batch>
{
    public void Configure(EntityTypeBuilder<Batch> builder)
    {
        builder.ToTable("batches");

        builder.HasKey(b => b.Id);

        builder.Property(b => b.Id)
            .HasConversion(id => id.Value, value => new BatchId(value))
            .ValueGeneratedNever();

        builder.Property(b => b.RecipeId)
            .HasConversion(id => id.Value, value => new RecipeId(value));
    }
}

ValueGeneratedNever rất quan trọng. Việc sinh giá trị thông qua converter là khu vực có giới hạn đã được tài liệu hóa. Vì vậy, hãy sinh ID trong mã, chẳng hạn bằng Guid.CreateVersion7() trong factory, rồi yêu cầu EF không tự sinh giá trị.

Hãy chú ý RecipeId không phải là navigation property Recipe. Recipe là một aggregate riêng và bạn không cần toàn bộ công thức để lấy chỉ số tỷ trọng. Cột khóa ngoại vẫn tồn tại trong cơ sở dữ liệu, nhưng mô hình miền không cần duyệt qua quan hệ đó.

Collection được đóng gói

Theo quy ước, EF có thể tìm backing field _readings cho navigation có tên Readings. Tuy nhiên, tôi cấu hình tường minh để mapping vẫn hoạt động nếu đổi tên:

builder.HasMany<FermentationReading>("_readings")
    .WithOne()
    .HasForeignKey("batch_id");

builder.Navigation("_readings")
    .UsePropertyAccessMode(PropertyAccessMode.Field)
    .AutoInclude();

PropertyAccessMode.Field yêu cầu EF đọc và ghi trực tiếp vào field, không chạm vào view công khai. AutoInclude là lựa chọn mặc định hợp lý cho aggregate: bất biến trong Bottle cần đọc các reading, nên một Batch chỉ được tải một phần sẽ không an toàn khi sử dụng.

WithOne() không có tham số nghĩa là entity con không có navigation quay lại Batch. Khóa ngoại batch_id chỉ tồn tại dưới dạng shadow property.

Trạng thái không có property

_bottledAtUtc chỉ là một field private, không có property, nhưng EF vẫn có thể ánh xạ:

builder.Property<DateTime?>("_bottledAtUtc")
    .HasColumnName("bottled_at_utc");

Chi phí xuất hiện ở phía truy vấn: khi lọc theo field, bạn phải viết EF.Property<DateTime?>(b, "_bottledAtUtc") trong truy vấn LINQ. Quy tắc thực tế của tôi là: dùng setter private cho trạng thái mà truy vấn cần lọc; dùng mapping chỉ dành cho field đối với trạng thái chỉ bản thân aggregate cần.

var bottledThisWeek = await context.Batches
    .Where(b => EF.Property<DateTime?>(b, "_bottledAtUtc") >= weekAgo)
    .ToListAsync();

Value object: complex type, owned type và conversion

Volume là một value object. Value object có nhiều property có thể được ánh xạ thành complex type. Các thành phần của nó được lưu ngay trong bảng của entity sở hữu, không cần join và không có identity riêng:

builder.ComplexProperty(b => b.Volume, volume =>
{
    volume.Property(v => v.Amount).HasColumnName("volume_amount");
    volume.Property(v => v.Unit).HasColumnName("volume_unit");
});

Complex type xuất hiện trong EF Core 8 nhưng ban đầu chưa hỗ trợ property tùy chọn và collection. EF Core 10 đã gỡ bỏ cả hai giới hạn. Với EF Core 8 hoặc 9, collection của value object, chẳng hạn lịch bổ sung hoa bia, có thể dùng owned type. Owned type hoạt động rộng rãi hơn nhưng có một shadow key ẩn, vì EF xem chúng như entity đang giả dạng value object:

builder.OwnsMany(b => b.HopAdditions, hop =>
{
    hop.ToTable("hop_additions");
    hop.WithOwner().HasForeignKey("batch_id");
});

Enum có thể dùng conversion thông thường và được lưu dưới dạng text để cơ sở dữ liệu dễ đọc:

builder.Property(b => b.Status)
    .HasConversion<string>()
    .HasMaxLength(20);

Tất cả property dùng conversion đều có một điểm cần lưu ý: LINQ hoạt động trên kiểu của provider. Vì vậy, sắp xếp theo Status sẽ cho thứ tự chữ cái, chẳng hạn Bottled, Dumped, Fermenting, thay vì thứ tự vòng đời nghiệp vụ.

Domain event không nằm trong schema

IDomainEvent không phải entity, vì vậy hãy yêu cầu EF bỏ qua collection DomainEvents:

builder.Ignore(b => b.DomainEvents);

Các event vẫn cần được xử lý. SaveChangesInterceptor là nơi tự nhiên để thực hiện việc đó:

public sealed class DomainEventsInterceptor(IDomainEventsDispatcher dispatcher)
    : SaveChangesInterceptor
{
    public override async ValueTask<int> SavedChangesAsync(
        SaveChangesCompletedEventData eventData,
        int result,
        CancellationToken cancellationToken = default)
    {
        var domainEvents = eventData.Context!.ChangeTracker
            .Entries<Batch>()
            .SelectMany(entry =>
            {
                var events = entry.Entity.DomainEvents.ToList();
                entry.Entity.ClearDomainEvents();
                return events;
            })
            .ToList();

        await dispatcher.DispatchAsync(domainEvents, cancellationToken);

        return await base.SavedChangesAsync(eventData, result, cancellationToken);
    }
}

IDomainEventsDispatcher là dispatcher kiểu mạnh, không phụ thuộc vào MediatR. Nếu các handler cần dùng chung transaction, bạn cũng có thể phát event trước khi lưu.

Domain không bao giờ tham chiếu EF Core

Tất cả đoạn mapping đều nằm trong BatchConfiguration, không nằm trong Batch: không có mapping attribute và cũng không có lớp cơ sở của ORM. Đây là persistence ignorance, được thực hiện nhờ Fluent API.

DbContext nằm trong infrastructure layer và tự động nạp mọi cấu hình từ assembly của nó:

public sealed class BreweryDbContext(DbContextOptions<BreweryDbContext> options)
    : DbContext(options)
{
    public DbSet<Batch> Batches => Set<Batch>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.ApplyConfigurationsFromAssembly(
            typeof(BreweryDbContext).Assembly);
    }
}

Tóm tắt

Ý kiến cho rằng “EF Core buộc chúng ta phải dùng mô hình nghèo” đã lỗi thời từ nhiều năm trước. Constructor private, backing field, value conversion và complex type đáp ứng mọi nhu cầu của một aggregate được đóng gói hoàn toàn. Tất cả đều nằm trong các lớp cấu hình mà domain không cần biết đến.

Giới hạn thực tế chủ yếu chỉ còn hai điểm: navigation không thể được bind vào constructor; còn các member dùng conversion hoặc chỉ dành cho field sẽ được chuyển dịch trong truy vấn thông qua kiểu của provider.

ORM chưa bao giờ là nguyên nhân khiến mô hình miền của bạn trở nên nghèo. Đây chỉ là một bài toán mapping, được thực hiện một lần cho mỗi aggregate. Sau đó, tính đóng gói vẫn được giữ nguyên.

Câu hỏi thường gặp

EF Core có yêu cầu setter công khai và constructor không tham số công khai không?

Không. Khi materialize entity, EF Core có thể gọi constructor không tham số private và ghi property thông qua backing field, kể cả khi setter là private. Change tracking đọc chính các backing field đó, nên aggregate được đóng gói hoàn toàn vẫn có thể tải và lưu như entity thông thường.

Làm thế nào để ánh xạ ID kiểu mạnh trong EF Core?

Hãy dùng value conversion trong một lớp IEntityTypeConfiguration để chuyển ID thành kiểu nguyên thủy và chuyển ngược lại. Kết hợp với ValueGeneratedNever và sinh ID trong mã, vì việc sinh giá trị phía sau converter là khu vực có giới hạn đã được tài liệu hóa trong EF Core.

Làm thế nào để ánh xạ một field collection private trong EF Core?

Cấu hình navigation bằng HasMany cùng tên backing field, sau đó đặt UsePropertyAccessMode(PropertyAccessMode.Field) để EF đọc và ghi trực tiếp vào field. AutoInclude là lựa chọn hợp lý cho aggregate có bất biến phụ thuộc vào collection đó.

Nên dùng complex type hay owned type cho value object?

Ưu tiên complex type. Các thành phần của complex type được lưu ngay trong bảng của entity sở hữu, không cần join và không có identity ẩn. EF Core 8 giới thiệu complex type nhưng chưa hỗ trợ property tùy chọn và collection; EF Core 10 đã gỡ bỏ cả hai giới hạn. Với EF Core 8 hoặc 9, collection của value object vẫn cần owned type.

EF Core có thể ánh xạ field private không có property không?

Có. Hãy cấu hình bằng builder.Property cùng tên field và tên cột. Chi phí xuất hiện trong truy vấn, nơi việc lọc đòi hỏi dùng EF.Property trong LINQ. Quy tắc thực tế là dùng setter private cho trạng thái mà truy vấn cần lọc, còn mapping chỉ dành cho field đối với trạng thái chỉ aggregate cần.

Làm thế nào để giữ mô hình miền không phụ thuộc vào EF Core?

Đặt toàn bộ mapping trong các lớp IEntityTypeConfiguration thuộc infrastructure layer và đăng ký chúng bằng ApplyConfigurationsFromAssembly. Entity không cần mapping attribute hay lớp cơ sở của ORM. Đây chính là persistence ignorance mà Fluent API giúp bạn đạt được.

Nếu muốn tìm hiểu sâu hơn về cách mô hình hóa aggregate, value object và hành vi giàu trong một hệ thống thực tế, bạn có thể tham khảo khóa học Pragmatic Domain-Driven Design.