Skip to main content

16.4 — 3. Clean Architecture (Uncle Bob) — dependency rule

Summary

Clean Architecture rút gọn thành một quy tắc: mã nguồn ở vòng trong không bao giờ biết gì về vòng ngoài. Cơ chế làm điều đó khả thi là dependency inversion — vòng trong định nghĩa interface (port), vòng ngoài cài đặt nó (adapter), nên mũi tên phụ thuộc bị đảo ngược so với dòng chảy dữ liệu. Nhưng bài này nói cả cái giá, thứ các bài giới thiệu thường bỏ qua: thuần khiết hoá Domain nghĩa là không dùng được IQueryable trong nghiệp vụ, mọi truy vấn cần một phương thức repository mới, và thao tác hàng loạt trở nên vụng về. Nhiều đội chấp nhận một thoả hiệp có chủ đích: giữ Domain thuần, nhưng cho Application biết về EF Core — và với phần lớn dự án, đó là lựa chọn đúng.

Mục tiêu bài học​

Sau bài này bạn có thể:

  • Phát biểu dependency rule và giải thích cơ chế đảo phụ thuộc.
  • Thiết kế port và adapter.
  • Đặt composition root đúng chỗ.
  • Nêu ba cái giá thật và cách thoả hiệp hợp lý.

Nội dung bài học​

16.4.1 — Dependency rule​

     ┌──────────────────────────────┐
│ Frameworks & Drivers │ ASP.NET Core, EF Core, Serilog
│ ┌────────────────────────┐ │
│ │ Interface Adapters │ │ Controller, Repository impl
│ │ ┌──────────────────┐ │ │
│ │ │ Use Cases │ │ │ CreateLeadHandler
│ │ │ ┌────────────┐ │ │ │
│ │ │ │ Entities │ │ │ │ Lead, Money
│ │ │ └────────────┘ │ │ │
│ │ └──────────────────┘ │ │
│ └────────────────────────┘ │
└──────────────────────────────┘
Phụ thuộc chỉ hướng VÀO TRONG

Không thứ gì ở vòng trong được biết tên bất cứ thứ gì ở vòng ngoài.

Cụ thể với .NET:

Vòng trongKhông được biết
Lead, MoneyDbContext, HttpContext, IConfiguration
CreateLeadHandlerSqlConnection, HttpClient, ILogger của Serilog

Chú ý ILogger: Microsoft.Extensions.Logging.ILogger<T> là abstraction, nên dùng ở Application là chấp nhận được. Serilog.ILogger là implementation — không.

Ranh giới không phải "có phải thư viện bên thứ ba không" mà là "nó có phải chi tiết có thể thay thế không".

16.4.2 — Dependency inversion làm nó khả thi​

Nếu Application cần lưu dữ liệu, mà nó không được biết về database — làm sao gọi được?

// Application định nghĩa PORT
namespace Crm.Application.Abstractions;

public interface ILeadRepository
{
Task<Lead?> GetByIdAsync(LeadId id, CancellationToken ct);
Task<bool> EmailExistsAsync(string email, CancellationToken ct);
void Add(Lead lead);
}
// Infrastructure cai dat ADAPTER
namespace Crm.Infrastructure.Persistence;

internal sealed class EfLeadRepository(CrmDbContext db) : ILeadRepository
{
public Task<Lead?> GetByIdAsync(LeadId id, CancellationToken ct)
=> db.Leads.FirstOrDefaultAsync(l => l.Id == id, ct);

public Task<bool> EmailExistsAsync(string email, CancellationToken ct)
=> db.Leads.AnyAsync(l => l.Email == email, ct);

public void Add(Lead lead) => db.Leads.Add(lead);
}
Dòng chảy DỮ LIỆU:       Application ──► Database
Dòng chảy PHỤ THUỘC: Application ◄── Infrastructure

Mũi tên bị đảo. Application định nghĩa thứ nó cần; Infrastructure phải thích nghi theo.

Đây chính là chữ D trong SOLID, và nó áp dụng cho mọi ranh giới với thế giới bên ngoài:

public interface IEmailSender     { Task SendAsync(EmailMessage message, CancellationToken ct); }
public interface IPaymentGateway { Task<PaymentResult> ChargeAsync(PaymentRequest request, CancellationToken ct); }
public interface IDateTimeProvider { DateTime UtcNow { get; } }

Interface cuối đáng chú ý: DateTime.UtcNow cũng là phụ thuộc vào thế giới bên ngoài, và nó khiến test không lặp lại được (bài 7.2). .NET 8 có TimeProvider sẵn, nên không cần tự định nghĩa nữa.

16.4.3 — Composition root​

// Api/Program.cs — nơi DUY NHẤT nối dây
builder.Services.AddScoped<ILeadRepository, EfLeadRepository>();
builder.Services.AddScoped<IEmailSender, SmtpEmailSender>();
builder.Services.AddSingleton(TimeProvider.System);

Đây là chỗ duy nhất trong hệ thống biết cả interface lẫn implementation. Mọi nơi khác chỉ biết interface.

Tổ chức theo layer, đúng mẫu ở bài 7.7:

builder.Services
.AddDomain()
.AddApplication()
.AddInfrastructure(builder.Configuration);
// Infrastructure/DependencyInjection.cs
public static IServiceCollection AddInfrastructure(
this IServiceCollection services, IConfiguration configuration)
{
services.AddDbContext<CrmDbContext>(o =>
o.UseSqlServer(configuration.GetConnectionString("Default")));

services.AddScoped<ILeadRepository, EfLeadRepository>();
services.AddScoped<IEmailSender, SmtpEmailSender>();

return services;
}

Chú ý AddApplication() không nhận IConfiguration — nếu một ngày nó cần, đó là tín hiệu Application đang biết về hạ tầng.

Đây là loại tín hiệu kiến trúc mà một chữ ký hàm phát ra rõ hơn bất kỳ tài liệu nào.

16.4.4 — Ba cái giá thật​

Phần này là thứ các bài giới thiệu Clean Architecture thường bỏ qua.

1. Mất IQueryable.

// Không làm được nếu Application không biết về EF Core
var leads = await _db.Leads
.Where(l => l.Status == LeadStatus.New)
.OrderByDescending(l => l.CreatedAt)
.Skip(skip).Take(20)
.Select(l => new LeadDto(l.Id, l.Name))
.ToListAsync(ct);

Bạn mất projection, Include, AsNoTracking, phân trang hiệu quả — toàn bộ nội dung bài 13.6 và 13.8.

Cách giữ thuần khiết là cho repository trả về kết quả hoàn chỉnh, nghĩa là mỗi truy vấn mới cần một phương thức mới:

public interface ILeadRepository
{
Task<PagedResult<LeadSummary>> SearchAsync(LeadSearchCriteria criteria, CancellationToken ct);
Task<IReadOnlyList<Lead>> GetStaleAsync(TimeSpan olderThan, CancellationToken ct);
// ... và một phương thức nữa cho mỗi màn hình
}

2. Thao tác hàng loạt trở nên vụng về.

ExecuteUpdateAsync không diễn đạt được qua một repository thuần khiết, nên cập nhật 50.000 dòng phải nạp entity — chậm gấp hàng chục lần (bài 13.8).

3. Rất nhiều file cho một thay đổi nhỏ.

Thêm một trường vào response: sửa entity, sửa DTO, sửa mapping, sửa interface nếu cần, sửa implementation, sửa handler, sửa test. Bảy file cho một cột.

16.4.5 — Thoả hiệp hợp lý​

Nhận ra rằng ba cái giá trên hầu như chỉ ảnh hưởng đường ĐỌC, còn giá trị của dependency rule hầu như chỉ ở đường GHI (nơi có quy tắc nghiệp vụ).

Từ đó có ba mức thoả hiệp, theo độ chặt giảm dần:

Mức 1 — thuần khiết hoàn toàn. Application không biết gì về EF Core. Đúng chuẩn, và đắt.

Mức 2 — CQRS nhẹ (được dùng nhiều nhất):

// Đường GHI — qua repository, bảo vệ bất biến
public sealed class ConvertLeadHandler(ILeadRepository leads, IUnitOfWork uow) { ... }

// Duong DOC — dung DbContext THANG, toi uu tu do
public sealed class LeadQueryService(CrmDbContext db)
{
public Task<PagedResult<LeadSummary>> SearchAsync(...) =>
db.Leads.AsNoTracking()
.Where(...)
.Select(l => new LeadSummary(...))
.ToPagedResultAsync(...);
}

Đường ghi cần trừu tượng vì nó chứa quy tắc; đường đọc cần tự do vì nó chỉ cần nhanh. Đây cũng là kết luận ở bài 13.10.

Mức 3 — chỉ giữ Domain thuần. Application tham chiếu EF Core thoải mái; chỉ Crm.Domain là không reference gì. Đơn giản nhất, và vẫn giữ được lợi ích lớn nhất: bất biến nghiệp vụ test được mà không cần database.

Với phần lớn dự án, mức 2 hoặc 3 là lựa chọn đúng. Mức 1 đáng khi bạn thật sự cần thay hạ tầng, hoặc khi domain phức tạp tới mức lợi ích vượt chi phí.

Và nhớ lý do gốc ở bài 16.2: mục tiêu là chi phí thay đổi thấp. Nếu thuần khiết làm chi phí tăng, nó đang đi ngược mục tiêu — dù nó "đúng chuẩn".

16.4.6 — Câu hỏi kiểm tra bạn có đang làm đúng​

Ba câu, trả lời được cả ba nghĩa là dependency rule đang làm việc của nó:

  1. Test một quy tắc nghiệp vụ mà không dựng database được không?
  2. Đọc entity có biết nó dùng ORM nào không? (Không biết = tốt.)
  3. Quy tắc nghiệp vụ có đúng một nhà không?

Nếu cả ba đều "có", cấu trúc project cụ thể ít quan trọng hơn bạn nghĩ. Nếu một trong ba là "không", cấu trúc dù đẹp tới đâu cũng chưa giải quyết được vấn đề.

16.4.7 — Rà lại code của bạn​

Danh sách rà soát dependency rule

  • •Domain không reference EF Core, ASP.NET Core hay thư viện hạ tầng nào.
  • •Entity không có attribute của EF Core.
  • •Mọi phụ thuộc ra thế giới bên ngoài đều qua interface do vòng trong định nghĩa.
  • •Thời gian lấy qua TimeProvider, không gọi DateTime.UtcNow trong nghiệp vụ.
  • •Composition root là nơi duy nhất nối interface với implementation.
  • •AddApplication() không nhận IConfiguration.
  • •Đã chọn có ý thức mức thoả hiệp 1, 2 hay 3 — không mặc định làm theo hình vẽ.
  • •Đường đọc không bị ép qua repository nếu nó cần tối ưu.
  • •Trả lời được cả ba câu hỏi kiểm tra ở mục 16.4.6.

Bài tập áp dụng​

Bài 1 — Kiểm tra vòng trong​

Mở một entity bất kỳ và tìm mọi using. Đánh dấu cái nào thuộc hạ tầng.

Tiêu chí hoàn thành: bạn phân loại được using thành ba nhóm, và biết cách xử lý những thứ trông như hạ tầng nhưng thật ra không phải.

Gợi ý và lời giải — Bài 1

Gợi ý. Dependency rule nói vòng trong không được biết gì về vòng ngoài. "Biết" nghĩa là gì trong C#?

Lời giải — một entity điển hình chưa được dọn:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Http;
using Newtonsoft.Json;
using AutoMapper;
using Crm.Infrastructure.Email;

namespace Crm.Domain.Entities;

[Table("Leads")]
public class Lead
{
[Key]
public int Id { get; set; }

[JsonProperty("ten")]
public string Name { get; set; } = null!;

[NotMapped]
public string HienThi => $"{Name} ({Value:N0})";

public async Task GuiEmailChaoMungAsync(IEmailSender sender) // hạ tầng trong entity
=> await sender.GuiAsync(Email, "Chào mừng", "...");
}

Ba nhóm:

Nhóm 1 — Thuộc về Domain, giữ nguyên:

using System;                            // kiểu cơ bản của BCL
using System.Collections.Generic; // List, Dictionary
using System.Linq; // LINQ to Objects
using Crm.Domain.ValueObjects; // Money, Email
using Crm.Domain.Events; // domain event

Nhóm này là ngôn ngữ để mô tả nghiệp vụ. Chúng không mang theo quyết định hạ tầng nào.

Nhóm 2 — Hạ tầng, phải gỡ:

using Microsoft.EntityFrameworkCore;     // ORM
using Microsoft.AspNetCore.Http; // HTTP
using Crm.Infrastructure.Email; // cài đặt cụ thể
using StackExchange.Redis; // cache
using Dapper; // truy cập dữ liệu

Mỗi using ở nhóm này là một lần Domain cam kết với một công nghệ cụ thể. Hậu quả không phải là lý thuyết:

Domain biết EF Core     -> test Domain phải tham chiếu EF Core
-> đổi ORM phải sửa Domain
-> entity không dùng lại được ở một dịch vụ không dùng EF Core

Domain biết HTTP -> job nền và message consumer không gọi được logic đó
-> người ta viết bản sao (bài 16.1)

Nhóm 3 — Trông như hạ tầng nhưng cần xem kỹ:

usingKết luận
System.ComponentModel.DataAnnotationsTuỳ — xem bên dưới
System.Text.Json.SerializationGỡ — serialize là mối quan tâm của vòng ngoài
Newtonsoft.JsonGỡ — như trên, và đây là thư viện bên thứ ba
AutoMapperGỡ — mapping thuộc về Application
Microsoft.Extensions.LoggingGỡ — entity không nên ghi log
System.DiagnosticsGỡ nếu là Activity; giữ nếu là Debug.Assert

Trường hợp DataAnnotations đáng bàn kỹ, vì nó chia làm hai loại:

// Ràng buộc NGHIỆP VỤ — diễn đạt được bằng attribute, chấp nhận được
[Required]
[MaxLength(200)]
public string Name { get; private set; } = null!;

// Ánh xạ CƠ SỞ DỮ LIỆU — thuộc về Infrastructure
[Table("Leads")]
[Column("lead_name", TypeName = "nvarchar(200)")]
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]

System.ComponentModel.DataAnnotations nằm trong BCL, không phải EF Core, nên về mặt kỹ thuật Domain dùng được. Nhưng [Table], [Column], [Key] là quyết định ánh xạ và thuộc về Infrastructure.

Cách sạch nhất: không dùng attribute nào, đẩy hết sang Fluent API:

// Crm.Domain/Entities/Lead.cs — không có attribute
public class Lead
{
public int Id { get; private set; }
public string Name { get; private set; } = null!;
}
// Crm.Infrastructure/Configurations/LeadConfiguration.cs
public class LeadConfiguration : IEntityTypeConfiguration<Lead>
{
public void Configure(EntityTypeBuilder<Lead> b)
{
b.ToTable("Leads");
b.HasKey(l => l.Id);
b.Property(l => l.Name).HasMaxLength(200).IsRequired();
b.Property(l => l.Status).HasConversion<string>().HasMaxLength(20);
b.OwnsOne(l => l.Value, v =>
{
v.Property(m => m.Amount).HasColumnName("Value").HasPrecision(18, 2);
v.Property(m => m.Currency).HasColumnName("Currency").HasMaxLength(3);
});
}
}

Cách này còn ba lợi ích ngoài chuyện sạch: Fluent API biểu đạt được nhiều hơn attribute (owned type, value converter, index có lọc), cấu hình nằm gọn một chỗ thay vì rải trên các thuộc tính, và đổi tên cột không phải chạm vào Domain.

Gỡ phương thức gọi hạ tầng:

// Trước — entity biết cách gửi email
public async Task GuiEmailChaoMungAsync(IEmailSender sender) { ... }

// Sau — entity PHÁT sự kiện, ai quan tâm thì xử lý
public static Lead Tao(string ten, Money giaTri, Email email)
{
var lead = new Lead { Name = ten, Value = giaTri, Email = email };
lead.Raise(new LeadDaTao(lead.Id, email));
return lead;
}
// Crm.Application/EventHandlers/GuiEmailChaoMungHandler.cs
public class GuiEmailChaoMungHandler : INotificationHandler<LeadDaTao>
{
public async Task Handle(LeadDaTao e, CancellationToken ct)
=> await _email.GuiAsync(e.Email, "Chào mừng", NoiDung(e));
}

Domain nói "một lead đã được tạo"; Application quyết định "gửi email". Domain không biết email tồn tại, và điều đó đúng — gửi email không phải là một quy tắc nghiệp vụ về lead.

Kiểm tra tự động cho cả assembly:

[Fact]
public void Domain_chi_duoc_phu_thuoc_BCL_va_chinh_no()
{
var choPhep = new[] { "System", "netstandard", "Crm.Domain", "Crm.SharedKernel" };

var viPham = typeof(Lead).Assembly
.GetReferencedAssemblies()
.Select(a => a.Name!)
.Where(n => !choPhep.Any(p => n.StartsWith(p, StringComparison.Ordinal)))
.ToList();

viPham.Should().BeEmpty(
"Domain đang phụ thuộc: {0}. Hãy định nghĩa interface trong Domain " +
"và cài đặt ở Infrastructure.",
string.Join(", ", viPham));
}

Test này kiểm tra ở mức assembly reference, nên nó bắt được cả những phụ thuộc đến qua PackageReference mà chưa có using nào — tức là bắt được trước khi ai đó bắt đầu dùng.

Và một kiểm tra rẻ hơn, chạy được ngay hôm nay:

# Xem Domain đang tham chiếu gì
dotnet list src/Crm.Domain/Crm.Domain.csproj package --include-transitive
Project 'Crm.Domain' has the following package references
[net9.0]:
Top-level Package Requested Resolved
> Microsoft.EntityFrameworkCore 9.0.0 9.0.0 <- không nên có

Một project Domain khoẻ mạnh thường có không package nào, hoặc chỉ một vài gói thuần tuý như Ardalis.GuardClauses hay CSharpFunctionalExtensions.


Bài 2 — Đếm file cho một cột mới​

Thêm một trường vào response của một endpoint và đếm số file phải sửa. So sánh với một dự án không phân tầng.

Tiêu chí hoàn thành: bạn đo được cả hai và không kết luận vội rằng ít file hơn là tốt hơn.

Gợi ý và lời giải — Bài 2

Gợi ý. Chi phí thêm một trường là một loại chi phí. Chi phí đổi một quy tắc nghiệp vụ là loại khác. Kiến trúc đánh đổi giữa hai loại đó.

Lời giải — thêm trường SoNgayTonTai vào GET /leads/{id}:

Clean Architecture đầy đủ — 7 file:

// 1. Crm.Domain/Entities/Lead.cs
public int SoNgayTonTai(DateTime bayGio) => (bayGio - CreatedUtc).Days;

// 2. Crm.Application/Leads/Queries/LayLeadDto.cs
public record LayLeadDto(int Id, string Name, decimal Value, string Status, int SoNgayTonTai);

// 3. Crm.Application/Leads/Queries/LayLeadHandler.cs
return new LayLeadDto(l.Id, l.Name, l.Value.Amount, l.Status.ToString(), l.SoNgayTonTai(bayGio));

// 4. Crm.Application/Mapping/LeadProfile.cs
.ForMember(d => d.SoNgayTonTai, o => o.MapFrom(s => s.SoNgayTonTai(DateTime.UtcNow)))

// 5. Crm.Api/Contracts/LeadResponse.cs
public record LeadResponse(int Id, string Name, decimal Value, string Status, int SoNgayTonTai);

// 6. tests/Crm.Tests/LayLeadHandlerTests.cs
kq.SoNgayTonTai.Should().Be(14);

// 7. docs/api.yaml — đặc tả OpenAPI

Không phân tầng (controller gọi thẳng DbContext) — 1 file:

// Crm.Api/Controllers/LeadsController.cs
app.MapGet("/leads/{id}", async (int id, CrmDbContext db) =>
await db.Leads.Where(l => l.Id == id)
.Select(l => new
{
l.Id, l.Name, l.Value, l.Status,
SoNgayTonTai = (DateTime.UtcNow - l.CreatedUtc).Days, // MỘT dòng
})
.FirstOrDefaultAsync());

7 file so với 1 file. Và đây là điểm mà bài tập này muốn bạn dừng lại.

Vì sao không kết luận vội — vì hai kiến trúc tối ưu cho hai loại thay đổi khác nhau, và bạn vừa đo đúng loại mà kiến trúc phân tầng không tối ưu:

Loại thay đổiPhân tầngKhông phân tầng
Thêm một trường vào response7 file1 file
Thêm một endpoint đọc đơn giản5 file1 file
Đổi một quy tắc nghiệp vụ1 file8 file (bài 16.1)
Thêm một đường ghi mới (job import)1 file — gọi lại phương thức domain4 file, và dễ viết sai quy tắc
Đổi ORM1 projectCả codebase
Test một quy tắc8 dòng, không hạ tầng33 dòng, cần database
Người mới hiểu luồngPhải đi qua nhiều fileĐọc một file

Hai dòng in đậm ở mỗi cột cho thấy sự đánh đổi. Kiến trúc phân tầng cố tình làm cho thay đổi hình dạng dữ liệu đắt hơn, để đổi lấy việc thay đổi quy tắc nghiệp vụ rẻ hơn.

Câu hỏi đúng không phải "cái nào ít file hơn?" mà là:

"Trong dự án này, loại thay đổi nào xảy ra thường xuyên hơn?"

Hệ thống báo cáo, dashboard, API đọc dữ liệu:
-> phần lớn thay đổi là "thêm trường", "thêm bộ lọc", "đổi định dạng"
-> quy tắc nghiệp vụ ít và ổn định
-> phân tầng nhiều tầng là CHI PHÍ không đổi lấy gì

Hệ thống nghiệp vụ, CRM, ERP, tài chính:
-> quy tắc nghiệp vụ nhiều, phức tạp, và ĐỔI LIÊN TỤC
-> nhiều đường ghi: API, job, import, message
-> phân tầng trả lại nhiều hơn chi phí

Và phần lớn hệ thống thật có CẢ HAI loại — đây là kết luận thực dụng nhất của bài.

Giải pháp: CQRS bất đối xứng. Đường ghi đi qua domain đầy đủ; đường đọc đi thẳng:

// ĐỌC — đi thẳng, tối ưu cho việc thêm trường
app.MapGet("/leads/{id}", async (int id, CrmDbContext db, TimeProvider clock) =>
{
var bayGio = clock.GetUtcNow().UtcDateTime;
return await db.Leads.AsNoTracking()
.Where(l => l.Id == id)
.Select(l => new LeadResponse(
l.Id, l.Name, l.Value, l.Status.ToString(),
EF.Functions.DateDiffDay(l.CreatedUtc, bayGio)))
.FirstOrDefaultAsync();
});

// GHI — đi qua domain, tối ưu cho việc bảo vệ quy tắc
app.MapPost("/leads/{id}/chot", async (int id, IMediator mediator, CancellationToken ct) =>
{
var kq = await mediator.Send(new ChotLeadCommand(id), ct);
return kq.ThanhCong ? Results.NoContent() : Results.BadRequest(kq.Loi);
});

Lý do nền tảng của sự bất đối xứng này:

ĐỌC:   không thay đổi trạng thái -> không có bất biến nào để bảo vệ
-> lớp trừu tượng chỉ là chi phí

GHI: thay đổi trạng thái -> có bất biến phải bảo vệ
-> lớp trừu tượng là thứ đảm bảo mọi đường ghi tuân thủ quy tắc

Điều này cũng giải thích vì sao IRepository<T> generic thường gây hại (bài 13.9): nó áp cùng một mức trừu tượng cho cả đọc và ghi, trong khi hai bên có nhu cầu ngược nhau.

Tiêu chí quyết định cho từng đường:

Đường ĐỌC đi thẳng được khi:
- không có quy tắc phân quyền phức tạp (ngoài global query filter)
- không cần tính toán nghiệp vụ phức tạp
- DTO gần với hình dạng dữ liệu

Đường ĐỌC nên qua domain khi:
- phải áp quy tắc phân quyền theo từng trường
- phải tính toán dựa trên nhiều aggregate
- kết quả được dùng để ra quyết định nghiệp vụ, không chỉ để hiển thị

Và một cách giảm chi phí "thêm trường" mà không bỏ phân tầng:

// Thay vì 3 record giống hệt nhau (Dto, Response, ViewModel), dùng MỘT
namespace Crm.Application.Leads;
public record LeadDto(int Id, string Name, decimal Value, string Status, int SoNgayTonTai);

Ba record khác nhau chỉ đáng khi chúng thật sự khác nhau — ví dụ API công khai cần giữ ổn định trong khi DTO nội bộ được tự do đổi. Nếu ba record luôn giống hệt và luôn được sửa cùng lúc, chúng đang là chi phí thuần tuý.

Đo trên dự án của bạn và ghi lại:

## Chi phí thay đổi — đo ngày 2026-09-25

| Loại thay đổi | Số file | Tần suất ước tính |
|---|---:|---|
| Thêm trường vào response | 7 | 3 lần/tuần |
| Thêm endpoint đọc | 5 | 1 lần/tuần |
| Đổi quy tắc nghiệp vụ | 1 | 1 lần/tháng |
| Thêm đường ghi mới | 1 | 1 lần/quý |

Kết luận: thay đổi hình dạng dữ liệu chiếm phần lớn khối lượng công việc.
-> Chuyển đường đọc sang truy vấn thẳng, giữ đường ghi qua domain.

Bảng có cột tần suất là bảng duy nhất trả lời được câu hỏi. Số file một mình không đủ — 7 file cho một thay đổi hiếm rẻ hơn 1 file cho một thay đổi hằng ngày.


Bài 3 — So sánh hai đường truy vấn​

Cài một truy vấn danh sách có phân trang, một lần qua repository thuần khiết và một lần dùng DbContext trực tiếp. So sánh SQL sinh ra và số dòng code.

Tiêu chí hoàn thành: bạn đo được cả SQL lẫn số dòng, và nêu được ba cái giá thật của việc Domain không được biết gì về hạ tầng.

Gợi ý và lời giải — Bài 3

Gợi ý. Repository thuần khiết trả về IEnumerable<Lead>. Bạn phân trang ở đâu?

Lời giải — repository thuần khiết:

// Crm.Domain/Repositories/ILeadRepository.cs — Domain không biết IQueryable
public interface ILeadRepository
{
Task<IReadOnlyList<Lead>> LayTheoTenantAsync(string tenantId, CancellationToken ct);
}
// Crm.Infrastructure/Repositories/LeadRepository.cs
public async Task<IReadOnlyList<Lead>> LayTheoTenantAsync(string tenantId, CancellationToken ct)
=> await _db.Leads.Where(l => l.TenantId == tenantId).ToListAsync(ct);
// Crm.Application/Leads/Queries/LayDanhSachHandler.cs
public async Task<PagedResult<LeadDto>> Handle(LayDanhSachQuery q, CancellationToken ct)
{
var tatCa = await _repo.LayTheoTenantAsync(q.TenantId, ct);

var loc = tatCa
.Where(l => q.Status is null || l.Status == q.Status)
.Where(l => q.TuNgay is null || l.CreatedUtc >= q.TuNgay)
.OrderByDescending(l => l.CreatedUtc);

var tong = loc.Count();
var trang = loc.Skip((q.Trang - 1) * q.CoTrang).Take(q.CoTrang)
.Select(l => new LeadDto(l.Id, l.Name, l.Value.Amount, l.Status.ToString()))
.ToList();

return new PagedResult<LeadDto>(trang, tong, q.Trang, q.CoTrang);
}
SELECT [l].[Id], [l].[Name], [l].[Value], [l].[Currency], [l].[Status],
[l].[CreatedUtc], [l].[TenantId], [l].[AssignedTo], [l].[ClosedUtc], ...
FROM [Leads] AS [l]
WHERE [l].[TenantId] = @__tenantId_0
-- 48.291 dòng, MỌI cột
Trả về:      48.291 entity đầy đủ
Bộ nhớ: ~420 MB
Thời gian: 3,4 giây
Kết quả cuối: 20 dòng

Dùng DbContext trực tiếp:

public async Task<PagedResult<LeadDto>> Handle(LayDanhSachQuery q, CancellationToken ct)
{
var truyVan = _db.Leads.AsNoTracking().Where(l => l.TenantId == q.TenantId);

if (q.Status is not null) truyVan = truyVan.Where(l => l.Status == q.Status);
if (q.TuNgay is not null) truyVan = truyVan.Where(l => l.CreatedUtc >= q.TuNgay);

var tong = await truyVan.CountAsync(ct);

var trang = await truyVan
.OrderByDescending(l => l.CreatedUtc)
.Skip((q.Trang - 1) * q.CoTrang).Take(q.CoTrang)
.Select(l => new LeadDto(l.Id, l.Name, l.Value.Amount, l.Status.ToString()))
.ToListAsync(ct);

return new PagedResult<LeadDto>(trang, tong, q.Trang, q.CoTrang);
}
SELECT COUNT(*) FROM [Leads] WHERE [TenantId] = @p0 AND [Status] = @p1

SELECT [l].[Id], [l].[Name], [l].[Value], [l].[Status]
FROM [Leads] AS [l]
WHERE [l].[TenantId] = @p0 AND [l].[Status] = @p1
ORDER BY [l].[CreatedUtc] DESC
OFFSET @p2 ROWS FETCH NEXT @p3 ROWS ONLY
Trả về:      20 dòng, 4 cột
Bộ nhớ: ~40 KB
Thời gian: 12 ms

Nhanh hơn 280 lần, tốn ít hơn 10.000 lần bộ nhớ. Số dòng code gần như bằng nhau (14 so với 16), nhưng bản repository cần thêm một interface và một lớp cài đặt.

Ba cái giá thật của "Domain không được biết gì về hạ tầng":

Cái giá 1 — lọc và phân trang chuyển từ SQL sang bộ nhớ.

Đây là cái giá đắt nhất và nó xuất phát từ một ràng buộc có vẻ hợp lý: IQueryable là khái niệm của System.Linq.Expressions, và dùng nó trong interface của Domain nghĩa là Domain cam kết với mô hình truy vấn dịch-sang-SQL.

Nhưng hệ quả là repository phải trả về dữ liệu đã vật chất hoá, và mọi thao tác sau đó chạy trong bộ nhớ. Với bảng nhỏ thì không sao; với bảng lớn thì đây là vấn đề chặn đứng.

Ba cách xử lý:

// a. Chấp nhận IQueryable trong interface — thực dụng, phổ biến nhất
public interface ILeadRepository
{
IQueryable<Lead> Query();
}

Nhiều người coi đây là "vi phạm", nhưng IQueryable nằm trong BCL và không cam kết với EF Core hay database nào. Cái giá thật của nó là khác: nơi gọi có thể viết truy vấn tệ, và bạn mất khả năng kiểm soát tập truy vấn.

// b. Specification pattern — Domain mô tả điều kiện, Infrastructure dịch
public interface ILeadRepository
{
Task<PagedResult<Lead>> TimAsync(ISpecification<Lead> spec, int trang, int coTrang, CancellationToken ct);
}

Giữ được ranh giới, nhưng bạn đang dựng lại IQueryable — kém đầy đủ hơn, phải tự bảo trì, và không ai ngoài dự án biết cách dùng.

// c. Tách đọc và ghi — cách thực dụng nhất
public interface ILeadRepository // GHI: trả về aggregate đầy đủ
{
Task<Lead?> LayAsync(LeadId id, CancellationToken ct);
Task ThemAsync(Lead lead, CancellationToken ct);
}

public interface ILeadQueries // ĐỌC: trả về DTO, cài bằng gì cũng được
{
Task<PagedResult<LeadDto>> TimAsync(string tenantId, string? status, int trang, int coTrang, CancellationToken ct);
}

Cách (c) là cách mà phần lớn dự án nghiêm túc chọn, và nó khớp với kết luận ở bài 2.

Cái giá 2 — không dùng được tính năng của ORM.

Mất khi repository thuần khiết:
AsNoTracking -> mất 2× hiệu năng đo được
Include / ThenInclude -> phải nạp riêng rồi ghép trong bộ nhớ
AsSplitQuery -> không tránh được cartesian explosion
ExecuteUpdate/Delete -> mất 14× cho cập nhật hàng loạt
GroupBy dịch sang SQL -> báo cáo phải gộp trong bộ nhớ
FromSql -> không dùng được SQL viết tay
Compiled query -> không tối ưu được đường chạy nóng

Và điều đáng chú ý: những thứ mất mát tập trung ở đường đọc, nơi mà lớp trừu tượng vốn mang lại ít giá trị nhất.

Cái giá 3 — nhiều lớp trừu tượng hơn, luồng khó lần theo hơn.

Controller -> Handler -> IRepository -> Repository -> DbContext -> SQL
^^^^^^^^^^^ hai bước chỉ để chuyển tiếp

Với người mới, "Go to Definition" trên _repo.LayTheoTenantAsync dẫn tới interface, không tới code chạy thật. Với dự án có 40 repository, đó là 40 interface và 40 lớp cài đặt phải bảo trì.

Khi nào cái giá này đáng trả:

Tình huốngĐáng
Domain phức tạp, nhiều quy tắc, nhiều đường ghiCó
Thật sự có nhiều nguồn dữ liệu (SQL, API, file)Có
Cần test domain hoàn toàn không cần hạ tầngCó
Nhóm lớn, nhiều người cùng sửaCó
CRUD với vài quy tắcKhông
Nhóm dưới 5 người, một databaseThường không
Đường đọc và báo cáoKhông — dùng truy vấn thẳng

Và cách dung hoà mà phần lớn dự án thật nên chọn:

ĐỌC:  Handler -> DbContext trực tiếp (hoặc ILeadQueries) -> DTO
-> tối ưu cho tốc độ và cho việc thêm trường

GHI: Handler -> ILeadRepository -> aggregate đầy đủ -> quy tắc domain -> SaveChanges
-> tối ưu cho việc bảo vệ bất biến

Cách này giữ được lợi ích thật của Clean Architecture — quy tắc nghiệp vụ nằm một chỗ và không thể đi vòng qua — mà không trả cái giá của nó ở nơi nó không mang lại gì.

Và đây cũng là câu trả lời cho một hiểu nhầm phổ biến: Clean Architecture không yêu cầu mọi truy cập dữ liệu phải qua repository. Dependency rule nói vòng trong không phụ thuộc vòng ngoài; nó không nói vòng ngoài phải đi qua một lớp trừu tượng để đọc dữ liệu cho chính nó.

Tự kiểm tra​

Frequently asked questions

Dependency rule phát biểu thế nào?

Mã nguồn ở vòng trong không bao giờ được biết tên bất cứ thứ gì ở vòng ngoài. Entity không biết về DbContext hay HttpContext; use case không biết về SqlConnection hay HttpClient. Ranh giới không phải có phải thư viện bên thứ ba hay không, mà là nó có phải chi tiết có thể thay thế không.

Dependency inversion làm quy tắc đó khả thi thế nào?

Vòng trong định nghĩa interface, vòng ngoài cài đặt nó. Nhờ vậy dòng chảy dữ liệu đi từ Application xuống database, nhưng dòng chảy phụ thuộc thì ngược lại: Infrastructure phụ thuộc Application chứ không ngược lại.

Composition root là gì?

Nơi duy nhất trong hệ thống biết cả interface lẫn implementation, thường là Program.cs. Mọi nơi khác chỉ biết interface. Một tín hiệu tốt là extension method AddApplication không nhận IConfiguration; nếu một ngày nó cần thì đó là dấu hiệu Application đang biết về hạ tầng.

Ba cái giá thật của việc thuần khiết hoá là gì?

Mất IQueryable nên mất projection, Include, AsNoTracking và phân trang hiệu quả. Thao tác hàng loạt như ExecuteUpdate không diễn đạt được nên phải nạp entity. Và mỗi thay đổi nhỏ phải sửa nhiều file, ví dụ bảy file cho một cột mới.

Ba mức thoả hiệp là gì?

Mức một là thuần khiết hoàn toàn, Application không biết gì về EF Core. Mức hai là CQRS nhẹ, đường ghi qua repository còn đường đọc dùng DbContext trực tiếp. Mức ba là chỉ giữ Domain thuần còn Application dùng EF Core thoải mái. Với phần lớn dự án thì mức hai hoặc ba là đúng.

Ba câu hỏi kiểm tra dependency rule đang làm việc là gì?

Test được quy tắc nghiệp vụ mà không dựng database không, đọc entity có biết nó dùng ORM nào không, và quy tắc nghiệp vụ có đúng một nhà không. Trả lời được cả ba thì cấu trúc project cụ thể ít quan trọng hơn bạn nghĩ.

Kết luận​

Ba điều đáng nhớ nhất:

  1. Vòng trong không biết tên vòng ngoài. Interface do vòng trong định nghĩa.
  2. Thuần khiết hoá có giá thật — mất IQueryable là cái đắt nhất.
  3. Đường ghi cần trừu tượng, đường đọc cần tự do. Đó là thoả hiệp đúng cho hầu hết dự án.

Tham khảo​

Điều hướng​