Chuyển đến nội dung

Triển khai Clean Architecture trong .NET 10: Hướng dẫn từng bước

Clean Architecture tổ chức ứng dụng thành các lớp có trách nhiệm rõ ràng, trong đó mọi dependency đều hướng về phần nghiệp vụ cốt lõi. Với .NET 10, chúng ta có thể áp dụng mô hình này bằng bốn project chính: Domain, Application, Infrastr...

•••
#clean architecture #clean architecture .net 10 #clean architecture dotnet #dotnet 10 #ef core 10
00:00 / --:--

Được đọc bằng giọng nói có sẵn của trình duyệt.

Clean Architecture tổ chức ứng dụng thành các lớp có trách nhiệm rõ ràng, trong đó mọi dependency đều hướng về phần nghiệp vụ cốt lõi. Với .NET 10, chúng ta có thể áp dụng mô hình này bằng bốn project chính: Domain, Application, Infrastructure và API.

Mục tiêu không phải là tạo thêm thật nhiều project hay abstraction. Mục tiêu là giữ cho quy tắc nghiệp vụ không bị phụ thuộc vào EF Core, PostgreSQL, HTTP hoặc một framework giao diện cụ thể. Khi hạ tầng thay đổi, phần lõi của hệ thống vẫn ổn định.

Trong bài này, chúng ta sẽ xây dựng cấu trúc cho một Web API quản lý phim, sử dụng:

  • .NET 10;

  • ASP.NET Core Minimal API;

  • Entity Framework Core;

  • PostgreSQL;

  • .NET Aspire;

  • FluentValidation;

  • xUnit.

Clean Architecture là gì?

Clean Architecture đặt nghiệp vụ ở trung tâm ứng dụng. Database, giao diện, framework và các dịch vụ bên ngoài chỉ là những chi tiết triển khai nằm ở vòng ngoài.

Nguyên tắc quan trọng nhất là Dependency Rule: source code chỉ được phụ thuộc theo hướng đi vào bên trong.

API → Infrastructure → Application → Domain

Điều đó có nghĩa:

  • Domain không biết EF Core, PostgreSQL hay HTTP là gì.

  • Application có thể sử dụng Domain nhưng không tham chiếu Infrastructure.

  • Infrastructure triển khai các abstraction do Application định nghĩa.

  • API là nơi kết nối các layer thông qua Dependency Injection.

Trong .NET, project reference giúp compiler bảo vệ quy tắc này. Nếu Domain không tham chiếu Infrastructure, lập trình viên không thể vô tình sử dụng một class thuộc Infrastructure trong Domain.

Bốn layer và trách nhiệm của từng layer

Layer

Chịu trách nhiệm

Không nên chứa

Domain

Entity, Value Object, enum và quy tắc nghiệp vụ

EF Core, HTTP, JSON, Dependency Injection

Application

Use case, DTO, interface và service điều phối

Controller, database provider cụ thể

Infrastructure

DbContext, cấu hình EF Core, email, file storage, API client

Endpoint và quy tắc nghiệp vụ

API

Endpoint, middleware, authentication và composition root

Business rule và truy vấn database trực tiếp

Domain là phần ổn định nhất. API và Infrastructure là những phần có khả năng thay đổi nhiều hơn. Vì vậy, layer bên ngoài phụ thuộc vào layer bên trong, không phải ngược lại.

Khi nào nên dùng Clean Architecture?

Clean Architecture phù hợp khi:

  1. Hệ thống có nghiệp vụ đủ phức tạp, không chỉ là CRUD.

  2. Dự án dự kiến duy trì và phát triển trong nhiều năm.

  3. Nhiều lập trình viên hoặc nhiều nhóm cùng làm việc.

  4. Hệ thống có thể thay database, giao diện hoặc dịch vụ tích hợp.

  5. Cần kiểm thử nghiệp vụ độc lập với database và web server.

Ngược lại, Clean Architecture có thể là quá mức đối với một prototype, webhook nhỏ hoặc API chỉ đọc và ghi dữ liệu đơn giản. Trong những trường hợp đó, một project duy nhất hoặc Vertical Slice Architecture thường nhanh và dễ bảo trì hơn.

Một cách kiểm tra thực tế: nếu bạn chưa thể chỉ ra ít nhất ba quy tắc nghiệp vụ thật sự thuộc về Domain, có thể bạn chưa cần tạo Domain layer riêng.

Cấu trúc solution đề xuất

clean-architecture-dotnet/
├── MovieManagement.slnx
├── Directory.Packages.props
├── Directory.Build.props
├── .editorconfig
├── src/
│   ├── MovieManagement.Domain/
│   ├── MovieManagement.Application/
│   ├── MovieManagement.Infrastructure/
│   └── MovieManagement.Api/
├── aspire/
│   ├── MovieManagement.AppHost/
│   └── MovieManagement.ServiceDefaults/
└── tests/
    └── MovieManagement.Domain.Tests/

Cấu trúc thư mục phải giúp người mới mở solution có thể hiểu ngay dependency direction và vị trí của từng loại code.

Bước 1: Tạo solution và các project

mkdir clean-architecture-dotnet
cd clean-architecture-dotnet

dotnet new classlib -n MovieManagement.Domain -o src/MovieManagement.Domain
dotnet new classlib -n MovieManagement.Application -o src/MovieManagement.Application
dotnet new classlib -n MovieManagement.Infrastructure -o src/MovieManagement.Infrastructure
dotnet new web -n MovieManagement.Api -o src/MovieManagement.Api

dotnet new aspire-apphost -n MovieManagement.AppHost -o aspire/MovieManagement.AppHost
dotnet new aspire-servicedefaults -n MovieManagement.ServiceDefaults -o aspire/MovieManagement.ServiceDefaults

Tiếp theo, thiết lập project reference:

dotnet add src/MovieManagement.Application reference src/MovieManagement.Domain
dotnet add src/MovieManagement.Infrastructure reference src/MovieManagement.Application
dotnet add src/MovieManagement.Api reference src/MovieManagement.Application src/MovieManagement.Infrastructure aspire/MovieManagement.ServiceDefaults
dotnet add aspire/MovieManagement.AppHost reference src/MovieManagement.Api

Điểm quan trọng nhất là MovieManagement.Domain không tham chiếu bất kỳ project nào khác.

Có thể sử dụng định dạng solution .slnx, vốn là XML gọn và dễ đọc khi review thay đổi:

dotnet new sln -n MovieManagement --format slnx
dotnet sln add src/MovieManagement.Domain
dotnet sln add src/MovieManagement.Application
dotnet sln add src/MovieManagement.Infrastructure
dotnet sln add src/MovieManagement.Api
dotnet sln add aspire/MovieManagement.AppHost
dotnet sln add aspire/MovieManagement.ServiceDefaults

Bước 2: Quản lý package tập trung

Khi solution có nhiều project, việc khai báo version package trong từng file .csproj dễ tạo ra sai lệch phiên bản. Central Package Management giải quyết vấn đề này bằng file Directory.Packages.props ở thư mục gốc.

<Project>
  <PropertyGroup>
    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
    <CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
  </PropertyGroup>

  <ItemGroup>
    <PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
    <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8" />
    <PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
  </ItemGroup>
</Project>

Sau đó, mỗi project chỉ cần khai báo tên package mà không ghi version. Việc nâng cấp EF Core cho toàn bộ solution chỉ còn là một thay đổi tại một nơi.

Directory.Build.props có thể dùng để thống nhất cấu hình compiler:

<Project>
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <LangVersion>latest</LangVersion>
    <EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
  </PropertyGroup>
</Project>

Ngoài ra, hãy thêm .editorconfig để thống nhất code style, naming convention và cách sử dụng các tính năng C# hiện đại.

Bước 3: Xây dựng Domain layer

Domain chỉ nên chứa C# thuần và quy tắc nghiệp vụ. Không cài EF Core và không tham chiếu bất kỳ project bên ngoài nào.

Một base entity có thể sử dụng UUID v7 để tạo ID có tính tuần tự tốt hơn khi lập chỉ mục:

namespace MovieManagement.Domain.Common;

public abstract class Entity
{
    public Guid Id { get; protected set; } = Guid.CreateVersion7();
}

Tạo exception riêng cho lỗi nghiệp vụ:

namespace MovieManagement.Domain.Common;

public sealed class DomainException(string message) : Exception(message);

Entity không nên là một tập hợp property có public setter. Nó phải tự bảo vệ trạng thái của mình:

public sealed class Movie : Entity
{
    private Movie()
    {
    }

    private Movie(
        string title,
        string director,
        DateOnly releaseDate,
        Genre genre,
        string synopsis)
    {
        Title = title;
        Director = director;
        ReleaseDate = releaseDate;
        Genre = genre;
        Synopsis = synopsis;
        CreatedAtUtc = DateTime.UtcNow;
    }

    public string Title { get; private set; } = default!;
    public string Director { get; private set; } = default!;
    public DateOnly ReleaseDate { get; private set; }
    public Genre Genre { get; private set; }
    public string Synopsis { get; private set; } = string.Empty;
    public double? AverageRating { get; private set; }
    public int RatingCount { get; private set; }
    public DateTime CreatedAtUtc { get; private set; }

    public static Movie Create(
        string title,
        string director,
        DateOnly releaseDate,
        Genre genre,
        string synopsis)
    {
        if (string.IsNullOrWhiteSpace(title))
            throw new DomainException("Phim phải có tiêu đề.");

        if (string.IsNullOrWhiteSpace(director))
            throw new DomainException("Phim phải có đạo diễn.");

        return new Movie(
            title.Trim(),
            director.Trim(),
            releaseDate,
            genre,
            synopsis?.Trim() ?? string.Empty);
    }

    public void AddRating(int score)
    {
        if (score is < 1 or > 10)
            throw new DomainException("Điểm đánh giá phải từ 1 đến 10.");

        var total = (AverageRating ?? 0) * RatingCount + score;
        RatingCount++;
        AverageRating = Math.Round(total / RatingCount, 2);
    }
}

Quy tắc điểm đánh giá và cách tính trung bình thuộc về Movie, không nên nằm rải rác trong controller hoặc service. Nhờ vậy, một đối tượng Movie không thể bị đưa vào trạng thái không hợp lệ theo những đường đi thông thường.

Bước 4: Xây dựng Application layer

Application chứa các use case của hệ thống. Layer này sử dụng Domain nhưng không biết Infrastructure triển khai database như thế nào.

Thay vì tạo Generic Repository, có thể định nghĩa một interface cho DbContext:

public interface IApplicationDbContext
{
    DbSet<Movie> Movies { get; }

    Task<int> SaveChangesAsync(
        CancellationToken cancellationToken = default);
}

Cách này dựa trên thực tế rằng:

  • DbContext đã thực hiện vai trò Unit of Work;

  • DbSet<T> đã cung cấp phần lớn khả năng của Repository;

  • Application vẫn dùng được LINQ, projection và AsNoTracking;

  • không cần thêm Generic Repository chỉ để bọc lại EF Core.

Application có thể chứa IMovieService, MovieService, DTO và các validator. Service thực hiện truy vấn và điều phối use case, nhưng quy tắc luôn đúng của nghiệp vụ vẫn phải nằm trong Domain.

Đây là lựa chọn thực dụng, không phải quy tắc bắt buộc. Vì interface vẫn lộ DbSet<T>, Application còn phụ thuộc vào abstraction của EF Core. Nếu hệ thống cần khả năng thay hoàn toàn persistence technology hoặc muốn Application thuần C#, hãy sử dụng query/command abstraction riêng.

Bước 5: Xây dựng Infrastructure layer

Infrastructure cung cấp implementation cho các interface được định nghĩa ở Application:

public sealed class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
    : DbContext(options), IApplicationDbContext
{
    public DbSet<Movie> Movies => Set<Movie>();

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

Entity configuration, migration, database provider và các tích hợp bên ngoài đều thuộc layer này. Domain không cần thay đổi khi PostgreSQL được thay bằng SQL Server, miễn là contract của Application vẫn được giữ nguyên.

Bước 6: Xây dựng API layer

API là composition root: nơi duy nhất được phép biết và kết nối các layer.

Minimal API endpoint nên thật mỏng:

app.MapGet("/movies", async (
    IMovieService service,
    CancellationToken cancellationToken) =>
{
    var movies = await service.GetAllAsync(cancellationToken);
    return Results.Ok(movies);
});

Endpoint chỉ nên:

  1. nhận request;

  2. gọi Application service;

  3. chuyển kết quả thành HTTP response.

Không nên đặt business rule hoặc truy vấn trực tiếp DbContext trong endpoint.

Bước 7: Tổ chức validation và xử lý lỗi

Validation nên được chia thành hai mức:

  • Input validation kiểm tra hình dạng request: trường bắt buộc, độ dài, phạm vi số và giá trị enum. Phần này có thể dùng FluentValidation trong Application.

  • Domain validation bảo vệ invariant: những điều luôn phải đúng bất kể use case nào gọi entity.

API nên có global exception handler để chuyển DomainException thành phản hồi 400 Bad Request theo chuẩn ProblemDetails. Những exception không xác định vẫn được xử lý như lỗi 500 và ghi log nội bộ.

Cách phân chia này cho phép client nhận được lỗi input rõ ràng, đồng thời Domain vẫn là lớp bảo vệ cuối cùng cho dữ liệu nghiệp vụ.

Bước 8: Chạy hệ thống bằng .NET Aspire

.NET Aspire giúp mô tả và chạy toàn bộ topology phát triển cục bộ bằng code:

var builder = DistributedApplication.CreateBuilder(args);

var postgres = builder
    .AddPostgres("postgres")
    .WithDataVolume();

var moviesDb = postgres.AddDatabase("moviesdb");

builder.AddProject<Projects.MovieManagement_Api>("api")
    .WithReference(moviesDb)
    .WaitFor(moviesDb);

builder.Build().Run();

WithReference cung cấp connection string cho API. WaitFor bảo đảm PostgreSQL sẵn sàng trước khi API khởi động, tránh lỗi kết nối do database chưa hoàn tất quá trình khởi tạo.

Aspire còn cung cấp dashboard, log, trace, metrics, health check và service discovery cho môi trường phát triển.

Chạy toàn bộ hệ thống bằng một lệnh:

dotnet run --project aspire/MovieManagement.AppHost

Migration và seed dữ liệu

Tạo migration bằng EF Core CLI:

dotnet ef migrations add InitialCreate \
  --project src/MovieManagement.Infrastructure \
  --startup-project src/MovieManagement.Api \
  --output-dir Persistence/Migrations

Trong môi trường Development, ứng dụng có thể gọi MigrateAsync() lúc khởi động rồi seed một số dữ liệu mẫu. Seeder phải idempotent để việc restart không tạo dữ liệu trùng.

Không nên tự động migration khi startup trong production. Nếu nhiều instance khởi động cùng lúc, chúng có thể cạnh tranh thực thi thay đổi schema. Với production, migration nên là một bước riêng trong deployment pipeline hoặc một migration job chỉ chạy một lần.

Ngoài ra, không nên dùng EnsureCreated() cho hệ thống cần quản lý schema lâu dài, vì nó bỏ qua cơ chế migration của EF Core.

Kiểm thử Domain

Lợi ích rõ ràng nhất của kiến trúc này là Domain có thể được kiểm thử mà không cần database hoặc web server:

public class MovieTests
{
    [Fact]
    public void Create_WhenTitleIsEmpty_ShouldThrow()
    {
        Assert.Throws<DomainException>(() =>
            Movie.Create(
                "",
                "Christopher Nolan",
                new DateOnly(2010, 7, 16),
                Genre.SciFi,
                "Nội dung phim"));
    }

    [Fact]
    public void AddRating_ShouldCalculateAverage()
    {
        var movie = Movie.Create(
            "Inception",
            "Christopher Nolan",
            new DateOnly(2010, 7, 16),
            Genre.SciFi,
            "Nội dung phim");

        movie.AddRating(10);
        movie.AddRating(8);
        movie.AddRating(6);

        Assert.Equal(8, movie.AverageRating);
        Assert.Equal(3, movie.RatingCount);
    }
}

Các test này chạy rất nhanh vì không cần mock, container hoặc hạ tầng bên ngoài. Integration test dành cho API và database thật nên được tổ chức riêng, có thể sử dụng Testcontainers để khởi tạo PostgreSQL trong quá trình test.

Giữ kiến trúc đơn giản có chủ đích

Clean Architecture không đồng nghĩa với việc bắt buộc phải sử dụng MediatR, AutoMapper, Generic Repository, Specification Pattern và Unit of Work tùy chỉnh trong mọi dự án.

Một cấu trúc hợp lý có thể chỉ cần:

  • service class thông thường thay cho MediatR handler;

  • DbContext thông qua interface thay cho Generic Repository;

  • mapping thủ công thay cho AutoMapper;

  • CQRS chỉ được bổ sung khi read model và write model thực sự khác nhau.

Mỗi abstraction đều có chi phí học tập, debug và bảo trì. Chỉ nên thêm một pattern khi nó giải quyết được vấn đề cụ thể của dự án.

Kết luận

Clean Architecture trong .NET 10 có thể được tóm gọn bằng một nguyên tắc: dependency luôn hướng về Domain.

Bốn layer có trách nhiệm rõ ràng:

  • Domain bảo vệ nghiệp vụ;

  • Application triển khai use case;

  • Infrastructure cung cấp chi tiết kỹ thuật;

  • API tiếp nhận request và kết nối toàn bộ hệ thống.

Cấu trúc này đem lại giá trị lớn cho những hệ thống phức tạp, tồn tại lâu dài và có nhiều người phát triển. Tuy nhiên, với một API CRUD nhỏ hoặc prototype, hãy bắt đầu đơn giản. Kiến trúc tốt không phải là kiến trúc có nhiều layer nhất, mà là kiến trúc giúp thay đổi hệ thống với ít rủi ro nhất.

Hãy giữ Dependency Rule, đặt business rule đúng chỗ và chỉ thêm abstraction khi có nhu cầu thực tế. Đó mới là phần quan trọng nhất của Clean Architecture.

Dựa trên 1 lượt đánh giá

Chấm điểm bài viết này trên thang 5

1,0 / 5

Đăng nhập để đánh giá bài viết này.
S
selectgo
IT

Chia sẻ các ghi chú kỹ thuật thực tế trên blog này.

Xem tất cả bài viết