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 các đội nhóm duy trì codebase lớn.
Nghe này, tôi đã từng thấy nhiều thứ. Tôi đã mở những solution với 47 project, mỗi project chứa đúng một class (tôi đang nói hơi quá đấy!). Tôi đã điều hướng qua những hệ thống phân cấp thư mục sâu đến nỗi Visual Studio cần có la bàn. Tôi đã lần theo các project reference tạo thành đồ thị dependency trông như mì Ý quăng vào tường.
Và lần nào cũng vậy, ai đó nói: “Để sau tụi mình refactor.”
Spoiler: họ đã không làm.
Mục lục
Mục lục
- Các vấn đề thường gặp với cấu trúc Project .NET
- Cấu trúc Solution .NET có khả năng mở rộng thực sự
- Quy tắc Dependency trong Clean Architecture
- Quy ước thư mục trong mỗi Project
- Quy tắc đặt tên Project .NET
- Project References và Dependency Graph
- Cuộc tranh luận về Shared Project
- Khi nào nên tách thành nhiều Solution
- Những sai lầm kiến trúc thường gặp cần tránh
- Checklist Kiến trúc
- Refactor một Codebase hiện có
- Các câu hỏi thường gặp
- Lời kết
Các vấn đề thường gặp với cấu trúc Project .NET
Đây là những gì thường xảy ra. Bạn bắt đầu một dự án. Nó nhỏ. Mọi thứ đều sống trong một project vì sao không chứ? 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” của bạn, Helper gọi “Service” của bạn, Service lại gọi “Repository” của bạn. Bạn đã tạo ra một vòng tham chiếu, nhưng trình biên dịch không phàn nàn vì tất cả đều nằm trong một project.
Rồi một developer mới gia nhập. Họ hỏi: “Mình nên đặt tính năng mới này ở đâu?”
Bạn nhìn trân trối. “Ừm… chắc là Services? Hay Helpers? Nó làm gì nhỉ?”
Cấu trúc Solution .NET có khả năng mở rộng thực sự
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ỉ có business logic.
├── MyApp.Application/ # Use cases. Chỉ tham chiếu Domain.
├── MyApp.Infrastructure/ # Các vấn đề bên ngoài. Database, APIs, files.
├── MyApp.Api/ # Web host. Mỏng. Composition root.
└── MyApp.Shared.Kernel/ # Các primitives xuyên suốt (tùy chọn)
tests/
├── MyApp.Domain.Tests/
├── MyApp.Application.Tests/
├── MyApp.Infrastructure.Tests/
└── MyApp.Api.Tests/
Năm project. Ranh giới rõ ràng. Dependency chảy theo một hướng: 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 nó. Nó hiệu quả.
Quy tắc Dependency trong Clean Architecture
Đây là một quy tắc quan trọng:
Dependency luôn 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, và bạn đã phá vỡ nó mãi mãi. Tôi đã từng chứng kiến các đội thêm “chỉ một tham chiếu nhỏ” từ Domain sang Infrastructure. Ba tháng sau, business logic của bạn import System.Data.SqlClient.
Trình biên dịch 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 Domain Project
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ỉ là interface. Không có implementation.
Hãy để ý những gì không có ở đây: không có thư mục Services. Domain services có tồn tại, nhưng rất 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 Application Project
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à “Vertical Slice Architecture” kết hợp với “CQRS nhẹ.” Mỗi tính năng là độc lập. 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ừ ban đầu? 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 Infrastructure Project
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. API bên thứ ba? Ở đâ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 tắc đặt tên Project .NET
Sau khi xem các đội tranh luận về điều này trong nhiều năm, đây là những gì tôi đã đúc kết:
Projects
{Company}.{Product}.{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 cả.
Folders
- Số nhiều cho collections:
Entities/,Services/,Handlers/ - Số ít cho features:
Orders/,Customers/(mỗi cái là một vùng tính năng duy nhất)
Files
- Hậu tố với role:
OrderService.cs,OrderRepository.cs,OrderController.cs - Commands/Queries có tên đầy đủ:
PlaceOrderCommand.cs,GetOrderByIdQuery.cs
Chuyện hậu tố gây tranh cãi. Một số người ghét IOrderRepository. Tôi đã thử bỏ tiền tố I. Nó tệ hơn. Tính năng tự động hoàn thành trong IDE của bạn trở nên vô dụng.
Đặt tên và vị trí Interface
// Trong Domain hoặc Application (bên đị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 đội đặt IOrderRepository trong Infrastructure project.
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ể 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>
Api project kết nối mọi thứ trong Program.cs:
builder.Services
.AddApplication() // Từ Application's DependencyInjection.cs
.AddInfrastructure(); // Từ Infrastructure's DependencyInjection.cs
Cuộc tranh luận về Shared Project
Mọi đội cuối cùng đều muốn có một project “Shared” hoặc “Common.” Thường là 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 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 thứ gì đó 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 solution?”
Đây là heuristic của tôi:
| Quy mô đội | 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 vấn đề thực sự: phiên bản package, độ phức tạp CI/CD, chi phí kiểm thử tích hợp. Đừng trả chi phí đó cho đến khi bạn buộc 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 đội sở hữu solution của họ. Tích hợp xảy ra thông qua APIs 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 cần tránh
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) { ... }
// Thêm 47 methods nữa
}
Đây là một God Class đội lốt “Service.” Hãy chia nhỏ 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ỡ vòng tham chiếu, 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 thứ tích hợp với hệ thống legacy kỳ lạ không ai hiểu
Khi Infrastructure quá lớn, hãy chia nhỏ nó:
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 ánh xạ database? Chúng không phải DTOs, chúng là entities. Đặt chúng trong Domain.
DTOs chia sẻ giữa các services? Publish 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:
- Developer mới có thể tìm nơi thêm tính năng trong vòng dưới 2 phút không?
- Mỗi project có một trách nhiệm duy nhất, rõ ràng không?
- Các dependency có chảy theo một hướng (hướng vào trong) không?
- Có chính xác một nơi cho mỗi loại code không?
- Bạn có thể giải thích cấu trúc cho một dev mới trong 5 phút không?
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.
Refactor một 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:
- 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
- Extract Domain – Kéo entities và business rules ra trước
- Define interfaces – Tạo ranh giới với interfaces trước khi di chuyển implementations
- Extract Infrastructure – Di chuyển các dependency bên ngoài ra sau các interfaces đó
- Reorganize into features – 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 đó. Nó kết thúc với một codebase nửa vời và một đội nhóm mất tinh thần.
Các câu hỏi thường gặp
Một .NET solution nên có bao nhiêu project?
Một solution Clean Architecture điển hình có 4-5 project: Domain, Application, Infrastructure, API, và tùy chọn Shared.Kernel. Bắt đầu tối giản — bạn luôn có thể tách ra sau, nhưng gộp project lại thì 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 được 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 Quy tắc Dependency và tạo ra sự kết nối chặt chẽ giữa business logic và chi tiết triển khai.
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 đều kết hợp cả hai.
Khi nào tôi nên tách thành nhiều .NET solution?
Khi đội của bạn vượt quá 15 developers hoặc bạn đang triển khai dưới dạng các service riêng biệt. Nhiều solution thêm độ phức tạp (phiên bản package, CI/CD, kiểm thử tích hợp). Một solution đơn lẻ với ranh giới project chặt chẽ hoạt động tốt cho hầu hết các đội.
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à Nguyên lý Dependency Inversion 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 tests 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 những tests này ngay từ ngày đầu tiên — chúng khó thêm vào hơn nhiều sau này.
Lời kết
Cấu trúc tốt không phải là tuân theo một template cụ thể. Mà 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, developer đư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 tụ 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” ngay từ ngày đầu. Mà là có một cấu trúc định hướng các quyết định tốt khi đội nhóm phát triển.
Hãy bắt đầu đơn giản. Thực thi ranh giới. Refactor khi nỗi đau đòi hỏi.
Bây giờ, nếu bạn cho phép, tôi có một solution legacy với 73 project cần gỡ rối. 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 thả 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 đội của bạn.
Về tác giả
Tôi là Mashrul Haque, một Systems Architect với hơn 15 năm kinh nghiệm xây dựng ứng dụng doanh nghiệp với .NET, Blazor, ASP.NET Core và SQL Server. Tôi chuyên về kiến trúc cloud Azure, tích hợp AI và tối ưu hiệu suất.
Khi production bốc cháy lúc 2 giờ sáng, tôi là người họ gọi.



