Tại Sao Bạn Cần Viết Kiểm Thử Kiến Trúc (Architecture Tests) Trong .NET

Ngày 25 tháng 11, 2025 · 6 phút đọc


Tôi đã làm việc trên nhiều dự án .NET. Thông thường, khi dự án phát triển, việc duy trì kiến trúc trở nên khó khăn hơn là viết các tính năng mới.

Các lập trình viên vô tình thêm các phụ thuộc giữa các tầng (layer), vi phạm ranh giới module, hoặc tạo các lớp không có interface, điều này có thể làm suy yếu khả năng kiểm thử và thiết kế của bạn. Qua nhiều tháng, kiến trúc của bạn dần lệch khỏi các quy tắc ban đầu, và việc tái cấu trúc trở nên rủi ro và đắt đỏ.

Mọi lập trình viên .NET đều biết tầm quan trọng của unit tests và integration tests – chúng xác minh rằng code hoạt động chính xác. Tuy nhiên, ngay cả bộ kiểm thử hoàn chỉnh nhất cũng không thể cho bạn biết liệu cấu trúc codebase của bạn có còn tuân theo thiết kế kiến trúc dự kiến hay không.

Sau đó tôi phát hiện ra rằng Kiểm Thử Kiến Trúc (Architecture Tests) có thể ngăn chặn những vấn đề như vậy.

Architecture Tests đảm bảo rằng các quy tắc thiết kế hệ thống của bạn được thực thi và code của bạn tuân theo kiến trúc bạn đã thiết lập.

Trong bài viết này, chúng ta sẽ khám phá:

  • Kiểm thử kiến trúc là gì và tại sao chúng quan trọng
  • Viết kiểm thử kiến trúc đầu tiên của bạn trong .NET với NetArchTest
  • Thực thi Clean Architecture và cô lập tầng (layer isolation)
  • Kiểm thử ranh giới module trong Modular Monoliths

Hãy bắt đầu.

Kiểm Thử Kiến Trúc Là Gì và Tại Sao Chúng Quan Trọng

Kiểm thử kiến trúc là các kiểm tra tự động xác minh các quy tắc thiết kế cấp cao của hệ thống. Không chỉ kiểm tra xem các phương thức hay API endpoints có trả về kết quả chính xác hay không, mà còn kiểm tra xem cấu trúc dự án và các phụ thuộc của bạn có khớp với kiến trúc dự kiến hay không.

Chúng đảm bảo rằng các tầng, module, lớp và namespace của bạn tương tác chính xác. Ví dụ:

  • Tầng Domain không được phụ thuộc vào tầng Infrastructure.
  • Tầng Application chỉ được tham chiếu đến Domain, không phải project Presentation.
  • Các module bên trong Modular Monolith phải giao tiếp thông qua các hợp đồng công khai (public contracts), không được tham chiếu trực tiếp lẫn nhau.
  • Tất cả các entity phải kế thừa từ lớp Entity cơ sở chung.
  • Tất cả các handler trong ứng dụng phải có hậu tố Handler trong tên của chúng.

Bạn có thể coi kiểm thử kiến trúc như unit tests cho kiến trúc của bạn, chúng xác minh cấu trúc.

Giả sử bạn có giải pháp .NET sau:

OrdersApi.sln
│
├── Domain
├── Application
├── Infrastructure
└── Presentation

Khi một dự án bắt đầu, kiến trúc rất đẹp và sạch sẽ. Mỗi lập trình viên đều hiểu rằng ranh giới tầng và các phụ thuộc rất đơn giản.

Nhưng khi dự án phát triển, các lập trình viên thực hiện các lối tắt:

  • “Cứ gọi thẳng repository từ controller đi.”
  • “Chúng ta cần một lớp helper từ Infrastructure bên trong Domain.”
  • “Có thể tái sử dụng service này qua các module – chỉ cần thêm một tham chiếu.”
  • Quên thêm các tiền tố và hậu tố cần thiết vào tên lớp.
  • Quên kế thừa các lớp từ một lớp cơ sở hoặc interface chung.

Những vi phạm nhỏ này tích lũy một cách âm thầm. Nếu không có kiểm thử kiến trúc, không ai để ý – bản dựng vẫn thành công, ứng dụng vẫn chạy.

Thế nhưng, kiến trúc của bạn bắt đầu lệch lạc – và đến khi nó trở nên rõ ràng, việc tái cấu trúc trở nên tốn kém.

Với kiểm thử kiến trúc, bản dựng sẽ thất bại ngay lập tức, cảnh báo nhóm rằng phụ thuộc đã vi phạm kiến trúc.

Điều này biến kiến trúc của bạn thành tài liệu có thể thực thi được.

Viết Kiểm Thử Kiến Trúc Đầu Tiên Trong .NET Với NetArchTest

Để viết kiểm thử kiến trúc, bạn có thể sử dụng một trong hai thư viện:

Tôi chủ yếu sử dụng NetArchTest, vì vậy hãy xem cách bắt đầu với nó.

Đối với kiểm thử kiến trúc, tôi tạo một project kiểm thử riêng với xUnit.

Để bắt đầu với NetArchTest, thêm gói NuGet vào project kiểm thử của bạn:

dotnet add package NetArchTest.Rules

Hãy viết kiểm thử kiến trúc đầu tiên của chúng ta.

Với NetArchTest, bạn có thể thực thi các quy ước.

Hãy xác minh rằng tất cả Controllers đều có thuộc tính [ApiController]:

[Fact]
public void Controllers_Should_Have_ApiController_Attribute()
{
    // Act
    var result = Types
        .InAssembly(ApiAssembly)
        .That()
        .HaveNameEndingWith("Controller")
        .Should()
        .HaveCustomAttribute(typeof(Microsoft.AspNetCore.Mvc.ApiControllerAttribute))
        .GetResult();

    // Assert
    Assert.True(result.IsSuccessful, FormatFailure(result));
}

private static string FormatFailure(TestResult result)
{
    if (result.FailingTypes is null || !result.FailingTypes.Any())
    {
        return "Architecture rule failed.";
    }

    return "Architecture rule failed. Offending types: " +
       string.Join(", ", result.FailingTypeNames);
}

Hãy khám phá một ví dụ thực tế khác.

Trong hầu hết các giải pháp .NET theo Clean Architecture hoặc DDD, bạn định nghĩa một lớp Entity cơ sở trong tầng Domain của mình.

Mọi aggregate root và entity nên kế thừa từ lớp này để đảm bảo:

  • Xử lý định danh nhất quán
  • So sánh bằng nhau
  • Sử dụng triển khai Domain events
  • Các quy ước chung trên toàn bộ Domain của bạn

Hãy viết một kiểm thử kiến trúc xác minh quy tắc này:

[Fact]
public void All_Domain_Entities_Should_Inherit_From_Base_Entity()
{
    // Arrange
    var result = Types
        .InAssembly(DomainAssembly)
        .That()
        .ResideInNamespace(DomainNamespace)
        .And()
        .AreClasses()
        .Should()
        .Inherit(typeof(Common.Domain.Entity))
        .GetResult();

    // Assert
    Assert.True(result.IsSuccessful, FormatFailure(result));
}

Thực Thi Clean Architecture và Cô Lập Tầng

Trong Clean Architecture, bạn có tầng Domain, tầng Application, tầng Infrastructure và tầng Presentation.

Đây là các ranh giới tầng:

  • Domain → không được phụ thuộc vào bất kỳ tầng nào khác
  • Application → chỉ có thể phụ thuộc vào tầng Domain
  • Presentation → có thể phụ thuộc vào Domain và Application
  • Infrastructure → có thể phụ thuộc vào Application và Domain

Chúng ta có thể thực thi điều này với NetArchTest.

Domain không được phụ thuộc vào bất kỳ tầng nào khác:

using NetArchTest.Rules;
using Xunit;

namespace Shipments.Tests.Architecture;

public class CleanArchitectureTests
{
    private const string Domain = "Shipments.Domain";
    private const string Application = "Shipments.Application";
    private const string Infrastructure = "Shipments.Infrastructure";
    private const string Api = "Shipments.Api";

    [Fact]
    public void Domain_Should_Not_Depend_On_Application_Infrastructure_Or_Api()
    {
        var result = Types
            .InAssembly(DomainAssembly)
            .That()
            .ResideInNamespace(Domain)
            .ShouldNot()
            .HaveDependencyOn(Application)
            .AndShouldNot()
            .HaveDependencyOn(Infrastructure)
            .AndShouldNot()
            .HaveDependencyOn(Api)
            .GetResult();

        Assert.True(result.IsSuccessful, FormatFailure(result));
    }
}

Kiểm thử này tìm tất cả các kiểu trong Shipments.Domain. Nó xác nhận rằng không kiểu nào trong số chúng phụ thuộc vào Shipments.Application, Shipments.Infrastructure hoặc Shipments.Api.

Nếu ai đó thêm một tham chiếu, kiểm thử sẽ thất bại và hiển thị các kiểu vi phạm. Đây chính xác là cách chúng ta ngăn chặn sự lệch lạc thiết kế vô tình.

Hãy thêm nhiều quy tắc hơn để phù hợp với thiết lập Clean Architecture điển hình:

public class CleanArchitectureTests
{
    [Fact]
    public void Application_Should_Depend_On_Domain_Only()
    {
        var result = Types
            .InAssembly(ApplicationAssembly)
            .That()
            .ResideInNamespace(Application)
            .ShouldNot()
            .HaveDependencyOn(Infrastructure)
            .AndShouldNot()
            .HaveDependencyOn(Api)
            .GetResult();

        Assert.True(result.IsSuccessful, FormatFailure(result));
    }

    [Fact]
    public void Api_Should_Not_Depend_On_Infrastructure()
    {
        var result = Types
            .InAssembly(ApiAssembly)
            .That()
            .ResideInNamespace(Api)
            .ShouldNot()
            .HaveDependencyOn(Infrastructure)
            .GetResult();

        Assert.True(result.IsSuccessful, FormatFailure(result));
    }
}

Kiểm Thử Ranh Giới Module Trong Modular Monoliths

Kiểm thử kiến trúc cũng rất hữu ích trong Modular Monoliths vì chúng có các yêu cầu sau:

  • Modular Monolith có ranh giới rõ ràng giữa các module của nó.
  • Các module không thể truy cập trực tiếp vào cơ sở dữ liệu của module khác.
  • Các module chỉ có thể nói chuyện với nhau thông qua một API công khai.

Một modular monolith tổ chức code thành các module riêng biệt xử lý các khả năng nghiệp vụ khác nhau.

Những ranh giới này không chỉ là thư mục hay namespace. Chúng đại diện cho các lĩnh vực nghiệp vụ nên duy trì sự độc lập với nhau.

Khi ranh giới rõ ràng, các nhóm có thể làm việc trên các module khác nhau mà không ảnh hưởng đến các module khác.

Vấn đề là ranh giới trong một modular monolith là logic, không phải vật lý. Không giống như microservices, nơi các module chạy trong các tiến trình riêng biệt, tất cả các module trong một modular monolith chia sẻ cùng một codebase. Điều này khiến cho việc vi phạm ranh giới trở nên dễ dàng.

Một lập trình viên đang làm việc trên module Shipments cần thông tin về hãng vận chuyển. Thay vì sử dụng API tích hợp phù hợp, họ tham chiếu trực tiếp đến namespace Carriers.Domain.

Trình biên dịch cho phép điều đó. Code vẫn chạy. Nhưng kiến trúc đã bị phá vỡ.

Hãy khám phá một ứng dụng với ba module cốt lõi: Shipments, Carriers và Stocks:

  • Module Shipments: Xử lý việc tạo đơn hàng vận chuyển.
  • Module Carriers: Duy trì thông tin về các đối tác vận chuyển và đăng ký lô hàng để giao hàng.
  • Module Stocks: Quản lý mức tồn kho sản phẩm.

Mỗi module hiển thị một interface cho phép các module khác gọi nó. Bên dưới, đó là một lời gọi phương thức trong cùng một tiến trình.

Mỗi module có schema cơ sở dữ liệu và DbContext riêng cho EF Core, cung cấp sự phân tách dữ liệu rõ ràng.

Khi tạo một lô hàng, Module Shipments cần:

  1. Kiểm tra xem Shipment cho một OrderId nhất định đã được tạo trong cơ sở dữ liệu của nó chưa
  2. Gọi Module Stocks để kiểm tra tính khả dụng của sản phẩm
  3. Tạo Shipment trong cơ sở dữ liệu
  4. Gọi Module Carrier để lưu chi tiết lô hàng
  5. Gọi Module Stocks để cập nhật mức tồn kho

Kiểm thử kiến trúc cần tham chiếu đến các assembly mà chúng sẽ kiểm tra. Hãy tạo một lớp helper chứa tất cả các tham chiếu assembly của module:

internal static class ModuleAssemblies
{
    // Shipments module assemblies
    internal static readonly Assembly ShipmentsDomainAssembly = Shipments.Domain.AssemblyReference.Assembly;
    internal static readonly Assembly ShipmentsFeaturesAssembly = Shipments.Features.AssemblyReference.Assembly;
    internal static readonly Assembly ShipmentsInfrastructureAssembly = Shipments.Infrastructure.AssemblyReference.Assembly;

    // Stocks module assemblies
    internal static readonly Assembly StocksDomainAssembly = Stocks.Domain.AssemblyReference.Assembly;
    internal static readonly Assembly StocksFeaturesAssembly = Stocks.Features.AssemblyReference.Assembly;
    internal static readonly Assembly StocksInfrastructureAssembly = Stocks.Infrastructure.AssemblyReference.Assembly;
    internal static readonly Assembly StocksPublicApiAssembly = Stocks.PublicApi.AssemblyReference.Assembly;

    // Carriers module assemblies
    internal static readonly Assembly CarriersDomainAssembly = Carriers.Domain.AssemblyReference.Assembly;
    internal static readonly Assembly CarriersFeaturesAssembly = Carriers.Features.AssemblyReference.Assembly;
    internal static readonly Assembly CarriersInfrastructureAssembly = Carriers.Infrastructure.AssemblyReference.Assembly;
    internal static readonly Assembly CarriersPublicApiAssembly = Carriers.PublicApi.AssemblyReference.Assembly;
}

Cách tiếp cận này yêu cầu mỗi project trong mỗi module phải có một lớp AssemblyReference:

public static class AssemblyReference
{
    public static readonly Assembly Assembly = typeof(AssemblyReference).Assembly;
}

Đây là một lớp đánh dấu đơn giản. Nó tồn tại chỉ để cung cấp một kiểu thuộc về assembly. Project kiểm thử tham chiếu đến kiểu này, buộc assembly phải được tải tại runtime.

Đây là cách chúng ta có thể kiểm tra rằng module Stocks không phụ thuộc vào bất kỳ module nào khác:

private const string ShipmentsNamespace = "Modules.Shipments";
private const string CarriersNamespace = "Modules.Carriers";
private const string StocksNamespace = "Modules.Stocks";

[Fact]
public void StocksModule_ShouldNotHaveDependencyOn_AnyOtherModule()
{
    var result = Types.InAssemblies(GetStocksModuleAssemblies())
        .Should()
        .NotHaveDependencyOnAny(
            CarriersNamespace,
            ShipmentsNamespace)
        .GetResult();

    Assert.True(result.IsSuccessful, string.Join(", ", result.FailingTypeNames ?? []));
}

Kiểm thử này tải tất cả các assembly trong module Stocks và kiểm tra mọi kiểu. Nếu bất kỳ kiểu nào có câu lệnh using, field, parameter hoặc property tham chiếu đến namespace Carriers hoặc Shipments, kiểm thử sẽ thất bại.

Bạn có thể tải mã nguồn đầy đủ cho dự án này từ .NET Backend Template của tôi.

Hãy viết một kiểm thử kiểm tra rằng module Shipments chỉ tham chiếu đến các dự án Public API từ Carriers hoặc Stocks:

private const string ShipmentsNamespace = "Modules.Shipments";
private const string CarriersNamespace = "Modules.Carriers";
private const string StocksNamespace = "Modules.Stocks";

// Project namespaces
private const string CarriersDomainNamespace = CarriersNamespace + ".Domain";
private const string CarriersInfraNamespace = CarriersNamespace + ".Infrastructure";
private const string CarriersFeaturesNamespace = CarriersNamespace + ".Features";
private const string CarriersPublicApiNamespace = CarriersNamespace + ".PublicApi";

private const string StocksDomainNamespace = StocksNamespace + ".Domain";
private const string StocksInfraNamespace = StocksNamespace + ".Infrastructure";
private const string StocksFeaturesNamespace = StocksNamespace + ".Features";
private const string StocksPublicApiNamespace = StocksNamespace + ".PublicApi";

[Fact]
public void ShipmentsModule_ShouldOnlyReference_PublicApiProjects()
{
    var shipmentsAssemblies = GetShipmentsModuleAssemblies();

    // Kiểm tra module Shipments không tham chiếu đến các dự án nội bộ của Carriers
    var carriersInternalResult = Types.InAssemblies(shipmentsAssemblies)
        .Should()
        .NotHaveDependencyOnAny(
            CarriersDomainNamespace,
            CarriersInfraNamespace,
            CarriersFeaturesNamespace)
        .GetResult();

    Assert.True(carriersInternalResult.IsSuccessful,
        $"Shipments module should not reference internal Carriers projects: {string.Join(", ", carriersInternalResult.FailingTypeNames ?? [])}");

    // Kiểm tra module Shipments không tham chiếu đến các dự án nội bộ của Stocks
    var stocksInternalResult = Types.InAssemblies(shipmentsAssemblies)
        .Should()
        .NotHaveDependencyOnAny(
            StocksDomainNamespace,
            StocksInfraNamespace,
            StocksFeaturesNamespace)
        .GetResult();

    Assert.True(stocksInternalResult.IsSuccessful,
        $"Shipments module should not reference internal Stocks projects: {string.Join(", ", stocksInternalResult.FailingTypeNames ?? [])}");

    // Xác minh rằng nó CÓ tham chiếu đến các dự án PublicApi (kiểm thử khẳng định tùy chọn)
    var hasCarriersPublicApiDep = Types.InAssemblies(shipmentsAssemblies)
        .That()
        .HaveDependencyOn(CarriersPublicApiNamespace)
        .GetTypes()
        .Any();

    var hasStocksPublicApiDep = Types.InAssemblies(shipmentsAssemblies)
        .That()
        .HaveDependencyOn(StocksPublicApiNamespace)
        .GetTypes()
        .Any();

    Assert.True(hasCarriersPublicApiDep || hasStocksPublicApiDep,
        "Shipments module should reference at least one PublicApi project");
}

private static Assembly[] GetShipmentsModuleAssemblies()
{
    return [
        ModuleAssemblies.ShipmentsDomainAssembly,
        ModuleAssemblies.ShipmentsInfrastructureAssembly,
        ModuleAssemblies.ShipmentsFeaturesAssembly
    ];
}

Kiểm thử này có ba phần.

Kiểm tra đầu tiên đảm bảo Shipments không tham chiếu đến Carriers.Domain, Carriers.Infrastructure hoặc Carriers.Features. Đây là các phần nội bộ của module Carriers.

Kiểm tra thứ hai thực hiện tương tự cho module Stocks.

Kiểm tra thứ ba xác minh rằng Shipments chỉ tham chiếu đến các dự án Public API của module CarriersStocks.

Tổng Kết

Kiểm thử kiến trúc là một công cụ mạnh mẽ trong bộ công cụ của lập trình viên. Chúng đảm bảo rằng kiến trúc của bạn nhất quán và thực thi các quy tắc bạn đã đặt ra.

Chúng hoạt động tốt hơn bất kỳ tài liệu nào. Một lập trình viên có thể quên một số quy tắc, và các bài kiểm thử sẽ bắt lỗi chúng.

Kiểm thử kiến trúc rất dễ viết, và thông thường tôi sao chép một bộ kiểm thử từ dự án này sang dự án khác.

Kiểm thử kiến trúc chỉ là các bài kiểm thử thông thường; đó là lý do tại sao tôi khuyên bạn nên chạy chúng trong pipeline CI/CD khi xây dựng ứng dụng của mình.

Hy vọng bạn thấy bài viết này hữu ích. Hẹn gặp lại lần sau.

Chỉ mục