Hướng dẫn Clean Architecture: Cấu trúc Solution .NET có khả năng mở rộng thực sự

Hướng dẫn thực tế về cấu trúc thư mục Clean Architecture, tổ chức project và quản lý dependency trong .NET — những pattern đã được kiểm chứng giúp team duy trì codebase lớn.


Thú thật, tôi đã thấy nhiều thứ rồi. Tôi đã mở những solution với 47 projects, mỗi project chứa đúng một class (tôi đang nói quá đấy!). Tôi đã phải điều hướng qua những cấu trúc thư mục sâu đến nỗi Visual Studio cũng cần một cái la bàn. Tôi đã lần theo những project references tạo thành đồ thị dependency trông như mì Ý bị ném vào tường vậy.

Và lần nào cũng vậy, ai đó nói: “Để sau chúng ta sẽ refactor.”

Spoiler: họ đã không làm.


Mục lục


Các vấn đề phổ biến với cấu trúc .NET Project

Đây là những gì thường xảy ra. Bạn bắt đầu một dự án. Nó nhỏ. Mọi thứ sống trong một project vì sao không? Rồi tính năng phát triển. Bạn thêm thư mục. Thêm thư mục nữa. Thư mục lồng trong thư mục.

Sáu tháng sau:

MyApp/
├── Controllers/
├── Services/
├── Repositories/
├── Models/
├── ViewModels/
├── DTOs/
├── Helpers/
├── Utilities/
├── Extensions/
├── Common/
├── Shared/
├── Core/
├── Infrastructure/
└── Misc/   ← Nơi những giấc mơ đến để chết

Mọi thứ phụ thuộc vào mọi thứ. “Repository” của bạn gọi “Helper” gọi “Service” gọi lại “Repository” của bạn. Bạn đã tạo ra một circular reference, nhưng compiler không phàn nàn vì tất cả nằm trong một project.

Rồi một developer mới tham gia. Họ hỏi: “Tôi nên đặt tính năng mới này ở đâu?”

Bạn ngẩn người. “Ừm… chắc là Services? Hay Helpers? Nó làm gì cơ?”


Cấu trúc Solution .NET thực sự mở rộng được

Sau nhiều năm thử và sai (chủ yếu là sai), đây là những gì hiệu quả:

src/
├── MyApp.Domain/           # Zero dependencies. Chỉ business logic của bạn.
├── MyApp.Application/      # Use cases. Chỉ reference Domain.
├── MyApp.Infrastructure/   # External concerns. Database, API, file.
├── MyApp.Api/              # Web host của bạn. Mỏng. Composition root.
└── MyApp.Shared.Kernel/    # Cross-cutting primitives (không bắt buộc)

tests/
├── MyApp.Domain.Tests/
├── MyApp.Application.Tests/
├── MyApp.Infrastructure.Tests/
└── MyApp.Api.Tests/

Năm projects. Ranh giới rõ ràng. Dependencies chảy một hướng: vào trong.

“Nhưng đó chỉ là Clean Architecture thôi mà!”

Đúng vậy. Và có lý do tại sao mọi người cứ tái phát minh lại nó. Nó hiệu quả.


Quy tắc Dependency trong Clean Architecture

Đây là một quy tắc quan trọng:

Dependencies hướng vào trong. Luôn luôn.

Api → Application → Domain
Infrastructure → Application → Domain

Domain không biết gì về database. Application không biết gì về HTTP. Infrastructure triển khai các interface mà Application định nghĩa.

Phá vỡ quy tắc này một lần, bạn đã phá vỡ nó mãi mãi. Tôi đã chứng kiến các team thêm “chỉ một tham chiếu nhỏ” từ Domain đến Infrastructure. Ba tháng sau, business logic của bạn import System.Data.SqlClient.

Compiler sẽ không cứu bạn ở đây. Bạn cần kỷ luật. Hoặc một công cụ như NetArchTest để thực thi nó:

[Fact]
public void Domain_Should_Not_Reference_Infrastructure()
{
    var result = Types.InAssembly(typeof(Order).Assembly)
        .ShouldNot()
        .HaveReferenceTo("MyApp.Infrastructure")
        .GetResult();

    result.IsSuccessful.Should().BeTrue();
}

Hãy viết test này ngay từ ngày đầu tiên. Cảm ơn tôi sau nhé.


Quy ước thư mục trong mỗi Project

Cấu trúc Project Domain

MyApp.Domain/
├── Entities/
│   ├── Order.cs
│   └── Customer.cs
├── ValueObjects/
│   ├── Money.cs
│   └── Address.cs
├── Enums/
│   └── OrderStatus.cs
├── Events/
│   └── OrderPlacedEvent.cs
├── Exceptions/
│   └── InsufficientStockException.cs
└── Interfaces/
    └── IOrderRepository.cs       # Chỉ interface. Không có implementation.

Hãy chú ý những gì không có ở đây: không có thư mục Services. Domain services tồn tại, nhưng hiếm. Nếu bạn đang tạo domain services cho mọi entity, có lẽ bạn đang làm anemic domain modeling.

Cấu trúc Project Application

MyApp.Application/
├── Features/
│   ├── Orders/
│   │   ├── Commands/
│   │   │   ├── PlaceOrder/
│   │   │   │   ├── PlaceOrderCommand.cs
│   │   │   │   ├── PlaceOrderHandler.cs
│   │   │   │   └── PlaceOrderValidator.cs
│   │   │   └── CancelOrder/
│   │   │       └── ...
│   │   └── Queries/
│   │       └── GetOrderById/
│   │           ├── GetOrderByIdQuery.cs
│   │           ├── GetOrderByIdHandler.cs
│   │           └── OrderDto.cs
│   └── Customers/
│       └── ...
├── Common/
│   ├── Behaviors/
│   │   ├── ValidationBehavior.cs
│   │   └── LoggingBehavior.cs
│   └── Interfaces/
│       └── IApplicationDbContext.cs
└── DependencyInjection.cs

Đây là sự kết hợp giữa “Vertical Slice Architecture” và “CQRS lite.” Mỗi tính năng tự chứa. Muốn hiểu cách đặt hàng hoạt động? Nhìn vào một thư mục.

Thư mục Misc từ trước? Nó không tồn tại vì không có chỗ cho code ngẫu nhiên ẩn náu.

Cấu trúc Project Infrastructure

MyApp.Infrastructure/
├── Persistence/
│   ├── ApplicationDbContext.cs
│   ├── Configurations/
│   │   └── OrderConfiguration.cs
│   └── Repositories/
│       └── OrderRepository.cs
├── Services/
│   ├── EmailService.cs
│   └── PaymentGateway.cs
├── Identity/
│   └── IdentityService.cs
└── DependencyInjection.cs

Mọi thứ chạm đến thế giới bên ngoài đều sống ở đây. Database? Ở đây. Email? Ở đây. Third-party APIs? Ở đây.

Khi bạn cần đổi nhà cung cấp thanh toán, bạn thay đổi một thư mục. Phần còn lại của ứng dụng không quan tâm.


Quy ước đặt tên trong .NET

Sau khi theo dõi các team tranh luận về điều này trong nhiều năm, đây là những gì tôi đã chốt:

Projects

{CôngTy}.{SảnPhẩm}.{Layer}

Ví dụ: Contoso.Ordering.Domain

Đừng tỏ ra thông minh. Contoso.Ordering.SuperCore.Base.Abstractions.V2 chẳng giúp ích gì cho ai.

Thư mục

  • Số nhiều cho collection: Entities/, Services/, Handlers/
  • Số ít cho tính năng: Orders/, Customers/ (mỗi cái là một khu vực tính năng)

File

  • Hậu tố với vai trò: OrderService.cs, OrderRepository.cs, OrderController.cs
  • Commands/Queries có tên đầy đủ: PlaceOrderCommand.cs, GetOrderByIdQuery.cs

Đặt tên và vị trí Interface

// Trong Domain hoặc Application (bên nào định nghĩa contract)
public interface IOrderRepository
{
    Task<Order?> GetByIdAsync(OrderId id, CancellationToken ct);
    Task AddAsync(Order order, CancellationToken ct);
}

// Trong Infrastructure (implementation)
public class OrderRepository : IOrderRepository
{
    // EF Core, Dapper, v.v.
}

Interface sống cùng với code sử dụng nó, không phải code triển khai nó. Đây là Dependency Inversion cơ bản, nhưng tôi vẫn thấy các team đặt IOrderRepository trong project Infrastructure.


Project References và Dependency Graph

Đây là thiết lập .csproj thực thi quy tắc dependency:

Domain.csproj – Không tham chiếu gì (có thể là Shared.Kernel)

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
  </PropertyGroup>
</Project>

Application.csproj – Chỉ tham chiếu Domain

<ItemGroup>
  <ProjectReference Include="..\MyApp.Domain\MyApp.Domain.csproj" />
</ItemGroup>

Infrastructure.csproj – Tham chiếu Application (và gián tiếp Domain)

<ItemGroup>
  <ProjectReference Include="..\MyApp.Application\MyApp.Application.csproj" />
</ItemGroup>

Api.csproj – Tham chiếu mọi thứ (nó là composition root)

<ItemGroup>
  <ProjectReference Include="..\MyApp.Application\MyApp.Application.csproj" />
  <ProjectReference Include="..\MyApp.Infrastructure\MyApp.Infrastructure.csproj" />
</ItemGroup>

Project Api kết nối mọi thứ trong Program.cs:

builder.Services
    .AddApplication()      // Từ DependencyInjection.cs của Application
    .AddInfrastructure();  // Từ DependencyInjection.cs của Infrastructure

Cuộc tranh luận về Shared Project

Mọi team cuối cùng đều muốn một project “Shared” hoặc “Common”. Thường dùng cho:

  • Extension methods
  • Base classes
  • Cross-cutting attributes
  • Result types

Lời khuyên của tôi: hãy cưỡng lại càng lâu càng tốt. Khi bạn không thể cưỡng lại được nữa:

MyApp.Shared.Kernel/
├── Primitives/
│   ├── Entity.cs
│   ├── ValueObject.cs
│   └── DomainEvent.cs
├── Results/
│   └── Result.cs
└── Extensions/
    └── StringExtensions.cs

Hãy giữ nó nhỏ. Khoảnh khắc project này trở thành bãi rác, bạn đã thua.

Nguyên tắc: nếu bạn không thể giải thích tại sao một thứ nằm trong Shared trong một câu, thì nó không thuộc về đó.


Khi nào nên tách thành nhiều Solution

“Chúng ta nên có một solution hay nhiều?”

Đây là heuristic của tôi:

Quy mô Team Triển khai Khuyến nghị
1-5 devs Monolith Một solution
5-15 devs Monolith Một solution, ranh giới chặt chẽ
15+ devs Services Nhiều solution, shared packages

Nhiều solution mang đến những khó khăn thực sự: package versioning, độ phức tạp CI/CD, chi phí kiểm thử tích hợp. Đừng trả cái giá đó cho đến khi bạn phải trả.

Khi bạn tách:

solutions/
├── ordering/
│   └── Contoso.Ordering.sln
├── inventory/
│   └── Contoso.Inventory.sln
└── shared/
    └── Contoso.Shared.sln   # Được publish dưới dạng NuGet packages

Các Shared packages trở thành internal NuGet packages. Mỗi team sở hữu solution của họ. Tích hợp xảy ra thông qua API hoặc messages, không phải project references. Đây về cơ bản là cách tiếp cận modular monolith.


Những sai lầm kiến trúc thường gặp

1. Anti-pattern “Mọi thứ đều là Service”

// Không. Dừng lại.
public class OrderService
{
    public Order GetOrder(int id) { ... }
    public void PlaceOrder(Order order) { ... }
    public void CancelOrder(int id) { ... }
    public void UpdateOrder(Order order) { ... }
    public decimal CalculateTotal(Order order) { ... }
    public bool ValidateOrder(Order order) { ... }
    public void SendOrderEmail(Order order) { ... }
    // 47 methods nữa
}

Đây là một God Class đội lốt “Service.” Hãy chia nhỏ nó theo use case.

2. Circular Project References (được “sửa” bằng Shared Project)

Nếu bạn cần một Shared project chỉ để phá vỡ circular dependencies, kiến trúc của bạn đã sai. Hãy lùi lại và vẽ lại ranh giới.

3. Infrastructure như một ngăn kéo rác

Infrastructure/
├── Database stuff
├── Email stuff
├── PDF generation
├── Image processing
├── Rate limiting
├── Background jobs
├── Feature flags
├── Analytics
└── Một tích hợp kỳ lạ với hệ thống legacy không ai hiểu

Khi Infrastructure quá lớn, hãy tách nó ra:

MyApp.Infrastructure.Persistence/
MyApp.Infrastructure.Email/
MyApp.Infrastructure.BackgroundJobs/

4. Đặt DTOs sai layer

DTOs rời khỏi ranh giới API? Chúng thuộc về Application hoặc một project Contracts riêng.

DTOs cho database mapping? Chúng không phải DTOs, chúng là entities. Đặt chúng trong Domain.

DTOs được chia sẻ giữa các services? Public chúng dưới dạng NuGet package từ một project Contracts.


Checklist kiến trúc

Trước khi commit, hãy tự hỏi:

  • Một dev mới có thể tìm nơi thêm tính năng trong vòng dưới 2 phút?
  • Mỗi project có một trách nhiệm duy nhất, rõ ràng?
  • Dependencies chảy một hướng (vào trong)?
  • Có chính xác một nơi cho mỗi loại code?
  • Bạn có thể giải thích cấu trúc cho một junior dev trong 5 phút?

Nếu bất kỳ câu trả lời nào là “không,” bạn còn việc phải làm.


Tái cấu trúc codebase hiện có

Không thể bắt đầu lại từ đầu? Đây là cách tiếp cận từng bước:

  1. Thêm architecture tests – Bắt đầu thực thi các quy tắc trước khi thêm nhiều vi phạm hơn
  2. Extract Domain – Kéo entities và business rules ra trước
  3. Định nghĩa interfaces – Tạo ranh giới với interfaces trước khi di chuyển implementations
  4. Extract Infrastructure – Di chuyển external dependencies ra sau các interfaces đó
  5. Tái tổ chức thành tính năng – Chuyển từ thư mục-theo-kiểu sang thư mục-theo-tính-năng

Đừng cố refactor mọi thứ cùng một lúc. Tôi đã xem bộ phim đó rồi. Nó kết thúc với một codebase đang được di cư dở dang và một team mất tinh thần.


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

Một .NET solution nên có bao nhiêu projects?

Một Clean Architecture solution điển hình có 4-5 projects: Domain, Application, Infrastructure, API, và tùy chọn Shared.Kernel. Bắt đầu tối thiểu — bạn luôn có thể tách ra sau, nhưng gộp projects lại rất đau đớn. Mỗi project nên có một trách nhiệm duy nhất, rõ ràng.

Domain có nên tham chiếu Infrastructure trong .NET không?

Không bao giờ. Dependencies phải luôn hướng vào trong. Domain không nên có bất kỳ tham chiếu bên ngoài nào ngoài thư viện .NET cơ bản. Nếu Domain project của bạn tham chiếu Infrastructure, bạn đã vi phạm Dependency Rule và tạo ra sự kết nối chặt chẽ giữa business logic và implementation details.

Sự khác biệt giữa Clean Architecture và Onion Architecture là gì?

Chúng rất giống nhau. Cả hai đều thực thi dependencies hướng vào trong với Domain ở trung tâm. Onion Architecture (Jeffrey Palermo, 2008) có trước Clean Architecture (Robert C. Martin, 2012). Clean Architecture thêm các layer rõ ràng hơn và khái niệm “Use Cases” trong Application layer. Trong thực tế, hầu hết các implementation .NET pha trộn cả hai.

Khi nào tôi nên tách thành nhiều .NET solutions?

Khi team của bạn vượt quá 15 developers hoặc bạn triển khai dưới dạng các services riêng biệt. Nhiều solutions thêm độ phức tạp (package versioning, CI/CD, integration testing). Một solution đơn với ranh giới project chặt chẽ hoạt động tốt cho hầu hết các team.

Interfaces nên sống ở đâu trong Clean Architecture?

Interfaces sống cùng với code sử dụng chúng, không phải code triển khai chúng. IOrderRepository thuộc về Domain hoặc Application (bất cứ nơi nào nó được tiêu thụ). Implementation OrderRepository sống trong Infrastructure. Đây là Dependency Inversion Principle trong hành động.

Làm thế nào để thực thi các quy tắc kiến trúc trong .NET?

Sử dụng NetArchTest để viết unit tests xác minh các quy tắc dependency của bạn. Chạy các test này trong CI để phát hiện vi phạm sớm. Ví dụ: test rằng Domain không tham chiếu Infrastructure. Hãy viết các test này ngay từ ngày đầu tiên — chúng khó thêm vào sau này hơn nhiều.


Kết luận

Cấu trúc tốt không phải là tuân theo một template cụ thể. Nó là làm cho con đường dễ dàng trở thành con đường đúng đắn.

Khi việc thêm code mới là hiển nhiên, developers đưa ra quyết định tốt. Khi nó gây nhầm lẫn, họ đưa ra quyết định nhanh chóng. Và những quyết định nhanh chóng tích lũy thành nợ kiến trúc.

Mục tiêu không phải là có cấu trúc “hoàn hảo” từ ngày đầu tiên. Mà là có một cấu trúc hướng dẫn các quyết định tốt khi team phát triển.

Bắt đầu đơn giản. Thực thi ranh giới. Refactor khi sự đau đớn đòi hỏi nó.

Bây giờ xin phép tôi đi gỡ rối một legacy solution với 73 projects. Chúc tôi may mắn nhé.


Cấu trúc solution của bạn trông như thế nào? Hãy để lại cây thư mục của bạn trong phần bình luận — tôi rất muốn xem điều gì đang hiệu quả (hoặc không hiệu quả) cho team của bạn.