Enterprise Patterns cho ASP.NET Core Minimal API: Data Mapper Pattern – Chris Woody Woodruff | Fractional Architect

Bài viết bởi Chris Woodruff | Ngày 19 tháng 12, 2025 | Chuyên mục: Patterns | Tags: .NET, C#, dotnet, patterns, programming


Nếu mọi class thú vị trong hệ thống của bạn âm thầm biết đến một connection string, thì domain của bạn không phải là một model. Nó chỉ là một lớp code mỏng phía trên một tầng truy cập dữ liệu.

Bạn sẽ thấy điều này khi:

  • Các Domain class inject trực tiếp DbContext
  • Entity tự gọi SaveChanges trên chính chúng
  • Các kiểm thử rule đơn giản lại yêu cầu truy vấn vào database thật

Lúc đó, việc thay đổi tên một cột trong bảng cảm giác rất rủi ro. Việc thay đổi một rule quan trọng còn tệ hơn vì nó bị ràng buộc chặt chẽ với SQL và infrastructure.

Pattern Data Mapper tồn tại để cắt bỏ nút thắt đó. Nó đảm nhận việc di chuyển dữ liệu giữa object model và data store. Mapper lo về bảng biểu. Domain lo về hành vi.

Bài viết này sẽ hướng dẫn cách triển khai Data Mapper sạch sẽ bằng C#, đưa ra ví dụ trước-sau với Minimal API, và đưa ra nhận định rõ ràng về thời điểm pattern này thực sự xứng đáng với độ phức tạp của nó và khi nào thì không.

Data Mapper Thực Sự Làm Gì?

Nói một cách đơn giản, Data Mapper:

  • Biết cách tải domain objects từ một nguồn dữ liệu
  • Biết cách lưu trữ các object đó trở lại nguồn dữ liệu
  • Giữ cho domain model hoàn toàn không biết gì về chi tiết ánh xạ

Vì vậy, class Customer của bạn tập trung vào các rule tín dụng, chứ không phải vào cách chạy SELECT và UPDATE.

Khi các rule và quy trình nghiệp vụ của bạn phát triển vượt ra khỏi mức đơn giản, sự tách biệt đó không còn chỉ là lý thuyết suông. Nó trở thành ranh giới giữa một model có thể tiến hóa và một model bị dán chặt vào schema hiện tại.

Domain Class Customer: Không Có SQL

Bắt đầu với một domain object hiểu về khách hàng, không phải về bảng biểu.

public class Customer
{
    public Customer(int id, string email, decimal creditLimit)
    {
        Id = id;
        ChangeEmail(email);
        CreditLimit = creditLimit;
    }

    public int Id { get; }
    public string Email { get; private set; } = string.Empty;
    public decimal CreditLimit { get; private set; }

    public void ChangeEmail(string newEmail)
    {
        if (string.IsNullOrWhiteSpace(newEmail))
        {
            throw new ArgumentException("Email không được để trống.", nameof(newEmail));
        }

        Email = newEmail;
    }

    public void UpgradeCredit(decimal newLimit)
    {
        if (newLimit < CreditLimit)
        {
            throw new InvalidOperationException(
                "Hạn mức mới không được thấp hơn hạn mức hiện tại.");
        }

        CreditLimit = newLimit;
    }
}

Các đặc điểm chính:

  • Không có DbContext
  • Không có connection string
  • Không có cấu trúc SQL nào

Class này biết cách xác thực và thay đổi trạng thái của chính nó. Nó không biết cách kết nối đến database.

Contract của Data Mapper

Tiếp theo, định nghĩa một interface mapper mô tả cách di chuyển các instance Customer đến và đi từ kho lưu trữ.

public interface ICustomerMapper
{
    Task<Customer?> FindAsync(int id, CancellationToken cancellationToken = default);
    Task SaveAsync(Customer customer, CancellationToken cancellationToken = default);
}

Interface này thuộc về tầng domain hoặc application layer. Nó là một sự trừu tượng hóa: cách triển khai có thể dùng ADO.NET thuần, EF Core, Dapper, hoặc bất kỳ thứ gì khác.

Một SQL Mapper Cụ Thể

Bây giờ hãy triển khai ICustomerMapper sử dụng ADO.NET thuần. Trong dự án thực tế, bạn có thể dùng EF Core, nhưng nguyên lý là giống hệt nhau.

using System.Data;
using System.Data.SqlClient;

public class CustomerSqlMapper : ICustomerMapper
{
    private readonly string _connectionString;

    public CustomerSqlMapper(string connectionString)
    {
        _connectionString = connectionString;
    }

    public async Task<Customer?> FindAsync(
        int id,
        CancellationToken cancellationToken = default)
    {
        await using var conn = new SqlConnection(_connectionString);
        await conn.OpenAsync(cancellationToken);

        var cmd = new SqlCommand(
            "SELECT Id, Email, CreditLimit FROM Customers WHERE Id = @Id",
            conn);

        cmd.Parameters.AddWithValue("@Id", id);

        await using var reader = await cmd.ExecuteReaderAsync(
            CommandBehavior.SingleRow,
            cancellationToken);

        if (!await reader.ReadAsync(cancellationToken))
        {
            return null;
        }

        var customerId = reader.GetInt32(0);
        var email = reader.GetString(1);
        var creditLimit = reader.GetDecimal(2);

        return new Customer(customerId, email, creditLimit);
    }

    public async Task SaveAsync(
        Customer customer,
        CancellationToken cancellationToken = default)
    {
        await using var conn = new SqlConnection(_connectionString);
        await conn.OpenAsync(cancellationToken);

        var cmd = new SqlCommand(
            @"UPDATE Customers
              SET Email = @Email, CreditLimit = @CreditLimit
              WHERE Id = @Id",
            conn);

        cmd.Parameters.AddWithValue("@Email", customer.Email);
        cmd.Parameters.AddWithValue("@CreditLimit", customer.CreditLimit);
        cmd.Parameters.AddWithValue("@Id", customer.Id);

        await cmd.ExecuteNonQueryAsync(cancellationToken);
    }
}

Bây giờ trách nhiệm đã rõ ràng.

  • Customer đại diện cho hành vi và trạng thái
  • CustomerSqlMapper biết cách dịch trạng thái đó thành các thao tác SQL

Domain model được tự do phát triển. Mapper chịu trách nhiệm hấp thụ những thay đổi trong schema.

Trước Đây: Minimal API Nói Chuyện Trực Tiếp Với SQL

Xét một use case đơn giản: tăng hạn mức tín dụng của khách hàng.

Đây là phiên bản bỏ qua Data Mapper và để endpoint xử lý mọi thứ.

app.MapPost("/customers/{id:int}/upgrade-credit", async (
    int id,
    UpgradeCreditRequest dto,
    IConfiguration config,
    CancellationToken ct) =>
{
    if (dto.NewLimit <= 0)
    {
        return Results.BadRequest("Hạn mức mới phải là số dương.");
    }

    var connectionString = config.GetConnectionString("DefaultConnection");
    if (string.IsNullOrWhiteSpace(connectionString))
    {
        return Results.Problem("Thiếu connection string.");
    }

    await using var conn = new SqlConnection(connectionString);
    await conn.OpenAsync(ct);

    // Load
    var selectCmd = new SqlCommand(
        "SELECT Id, Email, CreditLimit FROM Customers WHERE Id = @Id",
        conn);

    selectCmd.Parameters.AddWithValue("@Id", id);

    await using var reader = await selectCmd.ExecuteReaderAsync(
        CommandBehavior.SingleRow,
        ct);

    if (!await reader.ReadAsync(ct))
    {
        return Results.NotFound();
    }

    var currentEmail = reader.GetString(1);
    var currentLimit = reader.GetDecimal(2);

    // Domain rule, thực thi ngay trong endpoint
    if (dto.NewLimit < currentLimit)
    {
        return Results.BadRequest("Hạn mức mới không được thấp hơn hạn mức hiện tại.");
    }

    // Save
    reader.Close();

    var updateCmd = new SqlCommand(
        "UPDATE Customers SET CreditLimit = @NewLimit WHERE Id = @Id",
        conn);

    updateCmd.Parameters.AddWithValue("@NewLimit", dto.NewLimit);
    updateCmd.Parameters.AddWithValue("@Id", id);

    await updateCmd.ExecuteNonQueryAsync(ct);

    return Results.Ok(new
    {
        Id = id,
        Email = currentEmail,
        CreditLimit = dto.NewLimit
    });
});

public sealed class UpgradeCreditRequest
{
    public decimal NewLimit { get; set; }
}

Endpoint này biết:

  • Các luật validation
  • Bảng Customers nằm ở đâu
  • Cách xây dựng và cập nhật một dòng dữ liệu khách hàng

Nó hoạt động được. Nhưng nó cũng khiến mọi rule liên quan đến hạn mức tín dụng khó tái sử dụng hơn. Bất kỳ thay đổi nào cũng đụng đến cả code HTTP lẫn SQL.

Sau Khi Áp Dụng: Minimal API Sử Dụng Data Mapper Và Domain Model

Bây giờ hãy dùng Customer và ICustomerMapper để tái cấu trúc cùng một endpoint.

Đầu tiên, đăng ký mapper trong Program.cs:

builder.Services.AddScoped<ICustomerMapper>(sp =>
{
    var config = sp.GetRequiredService<IConfiguration>();
    var connectionString = config.GetConnectionString("DefaultConnection")
        ?? throw new InvalidOperationException("Thiếu connection string.");
    return new CustomerSqlMapper(connectionString);
});

Sau đó định nghĩa endpoint:

app.MapPost("/customers/{id:int}/upgrade-credit", async (
    int id,
    UpgradeCreditRequest dto,
    ICustomerMapper mapper,
    CancellationToken ct) =>
{
    if (dto.NewLimit <= 0)
    {
        return Results.BadRequest("Hạn mức mới phải là số dương.");
    }

    var customer = await mapper.FindAsync(id, ct);
    if (customer is null)
    {
        return Results.NotFound();
    }

    try
    {
        customer.UpgradeCredit(dto.NewLimit);
    }
    catch (InvalidOperationException ex)
    {
        return Results.BadRequest(ex.Message);
    }

    await mapper.SaveAsync(customer, ct);

    return Results.Ok(new
    {
        customer.Id,
        customer.Email,
        customer.CreditLimit
    });
});

Endpoint bây giờ:

  • Nhận đầu vào và trả về đầu ra
  • Ủy quyền các rule cho Customer.UpgradeCredit
  • Ủy quyền persistence cho ICustomerMapper

HTTP trở thành tầng vận chuyển, không phải dàn nhạc giao hưởng. SQL trở thành tầng infrastructure, không còn là những chuỗi inline rải rác khắp nơi.

Rule tín dụng nằm ở một nơi duy nhất:

public void UpgradeCredit(decimal newLimit)
{
    if (newLimit < CreditLimit)
    {
        throw new InvalidOperationException(
            "Hạn mức mới không được thấp hơn hạn mức hiện tại.");
    }

    CreditLimit = newLimit;
}

Bất kỳ client nào khác cũng có thể tái sử dụng rule đó mà không cần sao chép.

Data Mapper Liên Quan Như Thế Nào Đến Active Record Và Repository

Data Mapper thoạt nhìn trông giống Active Record. Cả hai đều xử lý việc lưu trữ object. Sự khác biệt cốt lõi nằm ở chủ thể sở hữu trách nhiệm đó.

  • Active Record: bản thân domain object tự load và tự lưu
  • Data Mapper: một component riêng biệt đảm nhận việc load và lưu

Repository thường nằm phía trên Data Mapper:

  • Repository phơi bày các thao tác bằng ngôn ngữ domain, như GetById hoặc FindByEmail
  • Repository sử dụng Data Mapper bên trong để giao tiếp với data store

Nói cách khác, Data Mapper là phần đường ống ngầm (plumbing). Repository là facade giống như collection nằm phía trên nó.

Khi Nào Nên Sử Dụng Data Mapper

Data Mapper không miễn phí. Nó đưa vào một tầng trừu tượng hóa. Điều quan trọng là đảm bảo tầng này tự bù đắp được chi phí của nó.

Các Model Domain Phức Tạp hoặc Đang Phát Triển

Nếu domain của bạn có:

  • Hành vi phong phú vượt xa các kiểm tra trường đơn giản
  • Các bất biến (invariants) quan trọng xuyên suốt nhiều entity
  • Các rule thay đổi thường xuyên

bạn cần các domain class có thể thay đổi hình dạng mà không làm hỏng persistence mỗi lần. Data Mapper tách rời những mối quan tâm đó.

Bạn có thể tái cấu trúc Customer rộng rãi và chỉ cần điều chỉnh code mapping ở một nơi duy nhất.

Hệ Thống Có Yêu Cầu Persistence Thay Đổi

Xem xét các kịch bản như:

  • Di chuyển từ cơ sở dữ liệu quan hệ này sang cơ sở dữ liệu quan hệ khác
  • Đưa vào một read model hoặc cache song song với database chính
  • Chuyển từ SQL sang kết hợp SQL và document stores

Không có Data Mapper, domain của bạn thường tham chiếu trực tiếp đến các persistence API cụ thể. Điều đó khiến việc thay đổi trở nên đau đớn.

Với Data Mapper, bạn có thể đưa vào các triển khai thay thế đằng sau cùng một interface, sau đó chuyển đổi chúng thông qua cấu hình.

Khả Năng Kiểm Thử Và Sự Rõ Ràng Trong Thiết Kế

Nếu bạn muốn kiểm thử Customer.UpgradeCredit mà không cần khởi tạo database, Data Mapper làm điều đó trở nên dễ dàng. Mock hoặc fake mapper. Kiểm thử domain objects một cách trực tiếp.

Unit test trên domain types trở nên nhỏ và nhanh. Integration test tập trung vào việc xác minh rằng các mapper giao tiếp đúng với database.

Nhiều Nguồn Dữ Liệu Phía Sau Một Domain Model

Đôi khi cùng một domain object được hỗ trợ bởi:

  • Một relational database trong môi trường production
  • Một in-memory store trong quá trình kiểm thử
  • Một cache cho các đường dẫn đọc nhiều

Triển khai ICustomerMapper theo nhiều cách, sau đó chọn triển khai phù hợp tại runtime. Code domain không biết và không quan tâm.

Một bộ lọc hữu ích: nếu bạn dự đoán sẽ tiến hóa cả rule lẫn persistence theo thời gian, Data Mapper hiếm khi cảm thấy tùy chọn.

Khi Nào Data Mapper Là Overkill

Có những trường hợp Data Mapper thêm độ phức tạp mà không mang lại lợi ích thực sự.

Hệ Thống Chỉ CRUD Đơn Giản

Nếu toàn bộ ứng dụng:

  • Tạo, đọc, cập nhật và xóa các dòng dữ liệu
  • Hầu như không chứa business rules

một tầng mapping nặng nề có thể làm chậm bạn với ít lợi ích. Trong thế giới đó, Active Record hoặc sử dụng trực tiếp EF Core trong controllers có thể là đủ, miễn là bạn thừa nhận sự đánh đổi.

Công Cụ Và Script Ngắn Hạn

Đối với:

  • Công cụ migration một lần
  • Các quy trình ETL đơn giản
  • Các tiện ích nội bộ với phạm vi hạn chế

bạn thường có thể chấp nhận sự ràng buộc chặt chẽ hơn giữa logic và persistence. Khả năng bảo trì lâu dài ít quan trọng hơn.

Các Component Thuần Túy Dữ Liệu

Các hệ thống con báo cáo và pipeline phân tích thường nói bằng ngôn ngữ của bảng và truy vấn theo thiết kế. Nếu không có object model có ý nghĩa, Data Mapper không giúp ích nhiều. Dữ liệu chính là model.

Mối nguy hiểm xuất hiện khi những khu vực này phát triển thành domain thực sự mà không ai nhận ra, vẫn bị bọc trong tư duy lấy bảng làm trung tâm.

Data Mapper Trong Thế Giới EF Core

Các ORM hiện đại như EF Core đã thực hiện ánh xạ object-relational. Điều này có thể khiến bạn nghĩ rằng pattern này đã “được tích hợp sẵn” và do đó không đáng để thảo luận.

Hai điều làm rõ sẽ giúp ích.

Thứ nhất, EF Core hoạt động như một Data Mapper tinh vi:

  • Nó ánh xạ các class vào bảng hoặc view
  • Nó theo dõi trạng thái và tạo ra các câu lệnh insert/update

Thứ hai, persistence ignorance vẫn quan trọng:

  • Giữ cho domain types của bạn không có tham chiếu trực tiếp đến DbContext
  • Ưu tiên cấu hình fluent trong một infrastructure project thay vì rải rác các attribute khắp domain classes
  • Tránh đặt truy vấn và logic SaveChanges trực tiếp lên domain types

Bạn có thể coi EF Core như một chi tiết triển khai đằng sau một sự trừu tượng như ICustomerRepository hoặc ICustomerMapper, để phần còn lại của hệ thống được tự do giả vờ rằng database không tồn tại.

Tích Hợp Data Mapper Vào Một Solution ASP.NET Core

Một cấu trúc điển hình có thể trông như thế này.

  • MyApp.Domain
    • Domain entities như Customer, Order, và domain services
    • Interfaces cho ICustomerMapper, IOrderMapper hoặc repositories
  • MyApp.Infrastructure
    • Các triển khai như CustomerSqlMapper, EF Core DbContexts, configurations
    • Các triển khai Transaction và Unit of Work
  • MyApp.Application
    • Application services hoặc use case handlers phối hợp domain objects và mappers
  • MyApp.Web
    • Minimal API endpoints hoặc MVC controllers gọi vào application services

Các dependency chảy vào trong:

  • Web phụ thuộc vào Application và Domain
  • Application phụ thuộc vào Domain và các abstraction cho mapping
  • Infrastructure phụ thuộc vào Domain và Application, và cung cấp các triển khai cụ thể mà DI kết nối với nhau

Domain types không bao giờ tham chiếu đến infrastructure. Đó chính là toàn bộ mục đích.

Di Chuyển Từ Active Record Sang Data Mapper

Nhiều đội bắt đầu với Active Record hoặc logic tập trung ở controller vì nó nhanh. Câu hỏi là làm thế nào để di chuyển một khi độ phức tạp vượt quá một ranh giới nhất định.

Một lộ trình thực tế.

  1. Xác định một class vừa đóng gói các rule vừa biết cách truy cập database.
  2. Trích xuất một domain class thuần túy không có bất kỳ code persistence nào. Di chuyển các business methods vào đó.
  3. Tạo một mapper hoặc repository để:
    • Sử dụng code persistence cũ
    • Xây dựng và lưu domain object mới
  4. Cập nhật endpoints và services để sử dụng domain type và mapper mới thay vì class hỗn hợp cũ.
  5. Loại bỏ hoặc làm mỏng object Active Record gốc khi các code khác được di chuyển.

Bạn không cần phải viết lại toàn bộ. Bạn chỉ cần chọn một rule tại một thời điểm và cho nó một ngôi nhà sạch sẽ hơn.

Lời Kết

Data Mapper không hào nhoáng. Nó là hệ thống ống nước (plumbing). Chính vì thế nó mới quan trọng.

Nếu domain model của bạn là phần quan trọng nhất của hệ thống — thứ mang các rule và giá trị của bạn — nó xứng đáng được giải phóng khỏi những chi tiết bảng biểu. Mỗi lần bạn để SQL trôi dạt vào các class đó, bạn đang đánh đổi sự tự do đó lấy một chút tiện lợi ngắn hạn.

Hãy coi mapping như infrastructure. Hãy để domain của bạn quên rằng database tồn tại.


Tags: .NET, C#, dotnet, patterns, programming