Back to blog

Data Source Patterns: Active Record, Data Mapper, Table Data Gateway & Row Data Gateway

software-architecturedesign-patternsenterprisebackenddatabase
Data Source Patterns: Active Record, Data Mapper, Table Data Gateway & Row Data Gateway

Trong bài trước, chúng ta đã cover cách tổ chức business logic — Transaction Script, Domain Model, Table Module và Service Layer. Nhưng business logic không tồn tại trong chân không. Nó cần data. Và điều đó đặt ra câu hỏi tiếp theo:

Domain objects giao tiếp với database thế nào?

Câu hỏi này đã gây ra một trong những cuộc tranh luận dài nhất trong software engineering: Active Record vs Data Mapper. Rails developers thề với Active Record. Spring developers sống trong Data Mapper. Django developers dùng Active Record nhưng gọi là "ORM." Và hầu hết developers dùng cái này hay cái kia mà không biết tại sao framework đã chọn như vậy.

Martin Fowler định nghĩa bốn data source patterns trong Patterns of Enterprise Application Architecture. Hiểu chúng là chìa khóa để hiểu mọi ORM bạn từng dùng.

Trong bài này, chúng ta sẽ cover:

✅ Active Record — domain object tự biết cách persist
✅ Data Mapper — layer riêng map giữa domain và database
✅ Table Data Gateway — một gateway class cho mỗi table
✅ Row Data Gateway — một gateway object cho mỗi row
✅ Active Record vs Data Mapper — cuộc tranh luận lớn
✅ Cách Rails, Spring, Django và Prisma implement các patterns này
✅ TypeORM: framework hỗ trợ cả hai Active Record và Data Mapper
✅ Vấn đề N+1 query — pitfall phổ biến nhất của Active Record
✅ Khi nào dùng pattern nào — decision framework
✅ Migration strategies khi bạn outgrow một pattern


Phổ Data Source

Data source patterns nằm trên phổ từ đơn giản và coupled đến phức tạp và decoupled:

PatternDomain Object Biết Về DB?Mapping Layer?Phù hợp
Table Data GatewayKhông có domain objectsKhôngRaw data access, reporting
Row Data GatewayKhông (gateway riêng)KhôngTách biệt không cần ORM
Active RecordCó — tự saveKhông (built-in)Simple domains, CRUD
Data MapperKhông — hoàn toàn không biếtCó (mapper riêng)Complex domains, DDD

Pattern 1: Table Data Gateway

Nó Là Gì

Table Data Gateway là data source pattern đơn giản nhất. Có một gateway class cho mỗi database table, xử lý tất cả SQL cho table đó. Methods trả về raw data structures (records, arrays, maps) — không phải domain objects.

Hãy nghĩ nó như repository cho raw data không có domain model.

TypeScript Example

// Table Data Gateway: một class cho mỗi table, trả raw data
class OrderGateway {
  constructor(private db: Database) {}
 
  async findById(id: string): Promise<OrderRow | null> {
    const rows = await this.db.query(
      'SELECT * FROM orders WHERE id = $1', [id]
    );
    return rows[0] ?? null;
  }
 
  async findByCustomer(customerId: string): Promise<OrderRow[]> {
    return this.db.query(
      'SELECT * FROM orders WHERE customer_id = $1 ORDER BY created_at DESC',
      [customerId]
    );
  }
 
  async findByStatus(status: string): Promise<OrderRow[]> {
    return this.db.query(
      'SELECT * FROM orders WHERE status = $1', [status]
    );
  }
 
  async insert(data: InsertOrderData): Promise<OrderRow> {
    const rows = await this.db.query(
      `INSERT INTO orders (id, customer_id, total, status, created_at)
       VALUES ($1, $2, $3, $4, NOW()) RETURNING *`,
      [data.id, data.customerId, data.total, data.status]
    );
    return rows[0];
  }
 
  async updateStatus(id: string, status: string): Promise<void> {
    await this.db.query(
      'UPDATE orders SET status = $1, updated_at = NOW() WHERE id = $2',
      [status, id]
    );
  }
 
  async delete(id: string): Promise<void> {
    await this.db.query('DELETE FROM orders WHERE id = $1', [id]);
  }
}
 
// Raw data types — không phải domain objects
interface OrderRow {
  id: string;
  customer_id: string;
  total: number;
  status: string;
  created_at: Date;
  updated_at: Date | null;
}

Khi Nào Dùng Table Data Gateway

  • Reporting và analytics — bạn cần raw data, không cần domain objects
  • Simple CRUD — đặc biệt với Transaction Script domain logic
  • Legacy systems — wrap SQL tồn tại trong clean API
  • Performance-critical paths — tránh ORM overhead

Không phù hợp cho: Complex domains với rich behavior (dùng Data Mapper).


Pattern 2: Row Data Gateway

Nó Là Gì

Row Data Gateway cho bạn một gateway object cho mỗi database row. Mỗi object wrap một row đơn lẻ và cung cấp methods để đọc/ghi columns. Thoạt nhìn giống Active Record, nhưng có khác biệt quan trọng: Row Data Gateway không chứa business logic — thuần data access.

TypeScript Example

// Row Data Gateway: một object cho mỗi row, không business logic
class OrderRowGateway {
  constructor(
    private db: Database,
    public id: string,
    public customerId: string,
    public total: number,
    public status: string,
    public createdAt: Date
  ) {}
 
  // Static finder — load row từ DB
  static async findById(db: Database, id: string): Promise<OrderRowGateway | null> {
    const rows = await db.query('SELECT * FROM orders WHERE id = $1', [id]);
    if (rows.length === 0) return null;
    const row = rows[0];
    return new OrderRowGateway(
      db, row.id, row.customer_id, row.total, row.status, row.created_at
    );
  }
 
  // Instance methods — persist row này
  async insert(): Promise<void> {
    await this.db.query(
      `INSERT INTO orders (id, customer_id, total, status, created_at)
       VALUES ($1, $2, $3, $4, $5)`,
      [this.id, this.customerId, this.total, this.status, this.createdAt]
    );
  }
 
  async update(): Promise<void> {
    await this.db.query(
      `UPDATE orders SET customer_id = $1, total = $2, status = $3
       WHERE id = $4`,
      [this.customerId, this.total, this.status, this.id]
    );
  }
 
  async delete(): Promise<void> {
    await this.db.query('DELETE FROM orders WHERE id = $1', [this.id]);
  }
}
 
// Business logic TÁCH RIÊNG — trong Transaction Script hoặc Domain Model
class OrderService {
  async placeOrder(customerId: string, total: number): Promise<string> {
    if (total <= 0) throw new Error('Total must be positive');
 
    const gateway = new OrderRowGateway(
      this.db, generateId(), customerId, total, 'confirmed', new Date()
    );
    await gateway.insert();
    return gateway.id;
  }
}

Row Data Gateway vs Active Record

Khác biệt tinh tế nhưng quan trọng:

Khía cạnhRow Data GatewayActive Record
Data access✅ Có✅ Có
Business logic❌ Không — tách riêng✅ Có — cùng class
Domain identityKhông — chỉ là data wrapperCó — nó LÀ domain object
Dùng kèmTransaction ScriptStandalone (Rails, Django)

Thực tế, Row Data Gateway hiếm khi dùng trong framework hiện đại. Active Record đã hấp thụ trách nhiệm data access, còn Data Mapper tách hoàn toàn. Row Data Gateway nằm ở giữa và không được ORM hiện đại hỗ trợ tốt.


Pattern 3: Active Record

Nó Là Gì

Active Record là data source pattern nổi tiếng nhất. Domain object wrap một database row, thêm domain logic, và tự biết cách save vào database. Object vừa là domain representation vừa là data access mechanism.

Đây là pattern đằng sau Rails, Django ORM, Laravel Eloquent, và TypeORM (Active Record mode).

TypeScript Example

// Active Record: domain object + data access trong một class
class Order extends ActiveRecordBase {
  id: string;
  customerId: string;
  total: number;
  status: string;
  items: OrderItem[] = [];
 
  // Data access: object tự biết cách persist
  static async findById(id: string): Promise<Order | null> {
    const row = await db.query('SELECT * FROM orders WHERE id = $1', [id]);
    if (!row[0]) return null;
    return Order.fromRow(row[0]);
  }
 
  static async findByCustomer(customerId: string): Promise<Order[]> {
    const rows = await db.query(
      'SELECT * FROM orders WHERE customer_id = $1', [customerId]
    );
    return rows.map(Order.fromRow);
  }
 
  async save(): Promise<void> {
    if (this.isNew) {
      await db.query(
        'INSERT INTO orders (id, customer_id, total, status) VALUES ($1, $2, $3, $4)',
        [this.id, this.customerId, this.total, this.status]
      );
    } else {
      await db.query(
        'UPDATE orders SET total = $1, status = $2 WHERE id = $3',
        [this.total, this.status, this.id]
      );
    }
  }
 
  async delete(): Promise<void> {
    await db.query('DELETE FROM orders WHERE id = $1', [this.id]);
  }
 
  // Domain logic: nằm ngay trong cùng class
  addItem(product: Product, quantity: number): void {
    if (this.status !== 'draft') {
      throw new Error('Cannot modify confirmed order');
    }
    this.items.push(new OrderItem(product, quantity));
    this.recalculateTotal();
  }
 
  confirm(): void {
    if (this.items.length === 0) {
      throw new Error('Cannot confirm empty order');
    }
    this.status = 'confirmed';
  }
 
  cancel(): void {
    if (this.status === 'shipped') {
      throw new Error('Cannot cancel shipped order');
    }
    this.status = 'cancelled';
  }
 
  private recalculateTotal(): void {
    this.total = this.items.reduce(
      (sum, item) => sum + item.price * item.quantity, 0
    );
  }
 
  private static fromRow(row: any): Order {
    const order = new Order();
    order.id = row.id;
    order.customerId = row.customer_id;
    order.total = row.total;
    order.status = row.status;
    order.isNew = false;
    return order;
  }
}
 
// Sử dụng: sạch, trực quan
const order = await Order.findById('order-123');
order.addItem(product, 2);
order.confirm();
await order.save(); // Object tự save

Java Example (Active Record Đơn Giản)

// Active Record style trong Java (không phổ biến — Java thích Data Mapper)
public class Order {
    private static JdbcTemplate jdbc;
 
    private String id;
    private String customerId;
    private BigDecimal total;
    private String status;
    private boolean isNew = true;
 
    // Static finder
    public static Order findById(String id) {
        return jdbc.queryForObject(
            "SELECT * FROM orders WHERE id = ?",
            (rs, rowNum) -> {
                var order = new Order();
                order.id = rs.getString("id");
                order.customerId = rs.getString("customer_id");
                order.total = rs.getBigDecimal("total");
                order.status = rs.getString("status");
                order.isNew = false;
                return order;
            },
            id
        );
    }
 
    // Instance save
    public void save() {
        if (isNew) {
            jdbc.update(
                "INSERT INTO orders (id, customer_id, total, status) VALUES (?, ?, ?, ?)",
                id, customerId, total, status
            );
            isNew = false;
        } else {
            jdbc.update(
                "UPDATE orders SET total = ?, status = ? WHERE id = ?",
                total, status, id
            );
        }
    }
 
    // Domain logic
    public void confirm() {
        if (!"draft".equals(status)) {
            throw new IllegalStateException("Cannot confirm non-draft order");
        }
        this.status = "confirmed";
    }
}

Real-World: Cách Rails Triển Khai

# Rails Active Record — implementation chuẩn mực
class Order < ApplicationRecord
  belongs_to :customer
  has_many :order_items
  has_many :products, through: :order_items
 
  validates :status, inclusion: { in: %w[draft confirmed shipped cancelled] }
  validates :total, numericality: { greater_than_or_equal_to: 0 }
 
  # Domain logic trong model
  def confirm!
    raise "Cannot confirm empty order" if order_items.empty?
    update!(status: "confirmed")
  end
 
  def cancel!
    raise "Cannot cancel shipped order" if status == "shipped"
    update!(status: "cancelled")
  end
 
  scope :pending, -> { where(status: "draft") }
  scope :for_customer, ->(id) { where(customer_id: id) }
end
 
# Sử dụng
order = Order.find(123)         # Load từ DB
order.confirm!                  # Domain logic
order.save!                     # Persist changes
Order.pending.for_customer(42)  # Query

Khi Active Record Sụp Đổ

Active Record hoạt động tốt cho simple đến moderate domains. Nhưng có hạn chế cấu trúc:

1. Domain objects coupled với database schema

// Nếu DB column là "customer_id", domain object có "customerId"
// Nếu DB thay đổi, domain object thay đổi
// Không thể có domain concept không map 1:1 với table

2. Complex domain logic trộn lẫn với persistence

class Order extends ActiveRecordBase {
  // 200 dòng domain logic
  // + 100 dòng data access
  // + 50 dòng validation
  // + 30 dòng callbacks
  // = God class
}

3. Khó test domain logic mà không cần database

// Để test order.confirm(), bạn cần database
// vì Order object CHÍNH LÀ database row
const order = await Order.findById('test-id'); // Cần DB
order.confirm();
await order.save(); // Cần DB

4. One-to-one table mapping hạn chế domain modeling

Domain concept "Money" (amount + currency) → cần 2 columns, không phải table
Domain concept "Address" (value object) → embedded, không phải entity riêng
Inheritance hierarchy → vụng về trong Active Record

Pattern 4: Data Mapper

Nó Là Gì

Data Mapper là layer software tách biệt domain objects khỏi database. Domain objects không biết gì về database — không save(), không findById(), không SQL. Một mapper (hoặc repository) riêng xử lý tất cả persistence.

Đây là pattern đằng sau JPA/Hibernate (Java), Entity Framework Core (.NET), SQLAlchemy (Python, classical mapping), TypeORM (Data Mapper mode), và Prisma/Drizzle (TypeScript).

TypeScript Example

// Domain object: KHÔNG biết gì về database
class Order {
  private _items: OrderItem[] = [];
  private _status: OrderStatus = 'draft';
 
  constructor(
    readonly id: string,
    readonly customer: Customer
  ) {}
 
  get items(): ReadonlyArray<OrderItem> { return this._items; }
  get status(): OrderStatus { return this._status; }
 
  addItem(product: Product, quantity: number): void {
    if (this._status !== 'draft') {
      throw new Error('Cannot modify confirmed order');
    }
    this._items.push(new OrderItem(product, quantity));
  }
 
  calculateTotal(): Money {
    return this._items.reduce(
      (sum, item) => sum.add(item.lineTotal),
      new Money(0)
    );
  }
 
  confirm(): void {
    if (this._items.length === 0) {
      throw new Error('Cannot confirm empty order');
    }
    this._status = 'confirmed';
  }
 
  cancel(): void {
    if (this._status === 'shipped') {
      throw new Error('Cannot cancel shipped order');
    }
    this._status = 'cancelled';
  }
 
  // Chú ý: KHÔNG save(), KHÔNG findById(), KHÔNG SQL
  // Domain object là pure business logic
}
 
// Data Mapper: xử lý toàn bộ persistence
class OrderMapper {
  constructor(private db: Database) {}
 
  async findById(id: string): Promise<Order | null> {
    const row = await this.db.query(
      `SELECT o.*, c.id as c_id, c.name as c_name, c.tier as c_tier
       FROM orders o JOIN customers c ON o.customer_id = c.id
       WHERE o.id = $1`,
      [id]
    );
    if (!row[0]) return null;
    return this.toDomain(row[0]);
  }
 
  async save(order: Order): Promise<void> {
    const exists = await this.db.query(
      'SELECT 1 FROM orders WHERE id = $1', [order.id]
    );
 
    if (exists.length === 0) {
      await this.db.query(
        `INSERT INTO orders (id, customer_id, total, status)
         VALUES ($1, $2, $3, $4)`,
        [order.id, order.customer.id, order.calculateTotal().amount, order.status]
      );
    } else {
      await this.db.query(
        'UPDATE orders SET total = $1, status = $2 WHERE id = $3',
        [order.calculateTotal().amount, order.status, order.id]
      );
    }
 
    // Save items riêng
    await this.db.query('DELETE FROM order_items WHERE order_id = $1', [order.id]);
    for (const item of order.items) {
      await this.db.query(
        'INSERT INTO order_items (order_id, product_id, quantity) VALUES ($1, $2, $3)',
        [order.id, item.product.id, item.quantity]
      );
    }
  }
 
  private toDomain(row: any): Order {
    const customer = new Customer(row.c_id, row.c_name, row.c_tier);
    const order = new Order(row.id, customer);
    Object.assign(order, { _status: row.status });
    return order;
  }
}
 
// Sử dụng: domain object không biết persistence
const mapper = new OrderMapper(db);
const order = await mapper.findById('order-123');
order.confirm(); // Pure domain logic
await mapper.save(order); // Mapper xử lý persistence

Java Example: JPA Là Data Mapper

JPA/Hibernate là Data Mapper implementation chuẩn mực. EntityManager đóng vai trò mapper:

// Domain entity: annotated cho JPA mapping, nhưng logic thuần
@Entity
@Table(name = "orders")
public class Order {
    @Id
    private String id;
 
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "customer_id")
    private Customer customer;
 
    @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
    @JoinColumn(name = "order_id")
    private List<OrderItem> items = new ArrayList<>();
 
    @Enumerated(EnumType.STRING)
    private OrderStatus status = OrderStatus.DRAFT;
 
    private BigDecimal total = BigDecimal.ZERO;
 
    // Business logic — không SQL, không persistence
    public void addItem(Product product, int quantity) {
        if (status != OrderStatus.DRAFT) {
            throw new IllegalStateException("Cannot modify confirmed order");
        }
        items.add(new OrderItem(product, quantity));
        recalculateTotal();
    }
 
    public void confirm() {
        if (items.isEmpty()) {
            throw new IllegalStateException("Cannot confirm empty order");
        }
        this.status = OrderStatus.CONFIRMED;
    }
 
    public void cancel() {
        if (status == OrderStatus.SHIPPED) {
            throw new IllegalStateException("Cannot cancel shipped order");
        }
        this.status = OrderStatus.CANCELLED;
    }
}
 
// Repository: Spring Data JPA generate Data Mapper implementation
public interface OrderRepository extends JpaRepository<Order, String> {
    List<Order> findByCustomerId(String customerId);
    List<Order> findByStatus(OrderStatus status);
}
 
// Service: dùng mapper (repository) để persist domain objects
@Service
@Transactional
public class OrderApplicationService {
    private final OrderRepository orderRepo;
 
    public String placeOrder(PlaceOrderCommand cmd) {
        var customer = customerRepo.findById(cmd.customerId()).orElseThrow();
        var order = new Order(UUID.randomUUID().toString(), customer);
 
        for (var item : cmd.items()) {
            var product = productRepo.findById(item.productId()).orElseThrow();
            order.addItem(product, item.quantity());
        }
        order.confirm();
 
        orderRepo.save(order); // Data Mapper (JPA) xử lý persistence
        return order.getId();
    }
}

Tại Sao Data Mapper Hỗ Trợ Clean Architecture

Data Mapper tự nhiên hỗ trợ Clean Architecture và Hexagonal Architecture:

Domain định nghĩa repository interface. Infrastructure layer cung cấp Data Mapper implementation. Domain không bao giờ phụ thuộc database — dependencies hướng vào trong.

// Domain layer: định nghĩa interface
interface OrderRepository {
  findById(id: string): Promise<Order | null>;
  save(order: Order): Promise<void>;
  delete(order: Order): Promise<void>;
}
 
// Infrastructure layer: implement với Data Mapper
class PostgresOrderRepository implements OrderRepository {
  async findById(id: string): Promise<Order | null> {
    // SQL, mapping logic — tất cả ở đây
  }
  async save(order: Order): Promise<void> {
    // Dịch domain object sang SQL
  }
}
 
// Test: swap database bằng in-memory implementation
class InMemoryOrderRepository implements OrderRepository {
  private orders = new Map<string, Order>();
 
  async findById(id: string): Promise<Order | null> {
    return this.orders.get(id) ?? null;
  }
  async save(order: Order): Promise<void> {
    this.orders.set(order.id, order);
  }
}

Cuộc Tranh Luận Lớn: Active Record vs Data Mapper

Đây là câu hỏi chia rẽ các framework communities. Framework rõ ràng để chọn:

Khi Nào Dùng Active Record

Tình huốngTại sao Active Record phù hợp
Simple CRUD với ít business rulesDirect mapping, không extra layers
Rapid prototypingÍt code, iteration nhanh hơn
Small đến medium applicationsSimplicity > architectural purity
Team nhỏ và full-stackÍt concepts cần học
Domain map 1:1 với database tablesKhông impedance mismatch

Khi Nào Dùng Data Mapper

Tình huốngTại sao Data Mapper phù hợp
Complex domain với rich behaviorDomain objects focus vào business logic
Domain model không match DB schemaMapper xử lý translation
Cần test domain logic mà không cần DBDomain objects thuần
Nhiều persistence targetsCùng domain, storage khác nhau
Clean/Hexagonal architectureDomain không phụ thuộc infrastructure
Team lớn với separate domain expertsDomain model phản ánh business, không phải tables

So Sánh Trực Tiếp

Khía cạnhActive RecordData Mapper
Simplicity✅ Một class = model + persistence❌ Tách domain + mapper + repository
Tốc độ build✅ Nhanh — ít code ban đầu❌ Setup nhiều hơn
Testability❌ Cần DB để test domain logic✅ Domain objects thuần, testable
Domain purity❌ Persistence trộn với logic✅ Domain không biết DB
Schema flexibility❌ 1:1 với tables✅ Domain có thể khác DB
Clean Architecture❌ Domain phụ thuộc DB framework✅ Dependencies hướng vào trong
Learning curve✅ Thấp — API trực quan❌ Cao hơn — nhiều concepts
Refactoring❌ Thay đổi lan sang domain✅ DB changes không ảnh hưởng domain
FrameworksRails, Django, Laravel, EloquentJPA/Hibernate, EF Core, SQLAlchemy

Decision Flowchart


Framework Phổ Biến Implement Patterns Thế Nào

Rails: Active Record

# Rails LÀ Active Record framework
class User < ApplicationRecord
  has_many :orders
  validates :email, presence: true, uniqueness: true
 
  def vip?
    orders.sum(:total) > 1000
  end
end
 
user = User.find(1)      # Data access
user.vip?                 # Domain logic
user.save!                # Persistence

Spring Boot + JPA: Data Mapper

// JPA EntityManager là Data Mapper
// Spring Data generate repository implementations
 
@Entity
public class User {
    @Id @GeneratedValue
    private Long id;
    private String email;
 
    // Domain logic, không persistence methods
    public boolean isVip(BigDecimal orderTotal) {
        return orderTotal.compareTo(BigDecimal.valueOf(1000)) > 0;
    }
}
 
// Repository interface — Spring generate Data Mapper implementation
public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String email);
}

Django: Active Record (Gọi Là "ORM")

# Django ORM là Active Record — cùng pattern như Rails
class User(models.Model):
    email = models.EmailField(unique=True)
 
    def is_vip(self):
        return self.orders.aggregate(Sum('total'))['total__sum'] > 1000
 
user = User.objects.get(pk=1)  # Data access
user.is_vip()                   # Domain logic
user.save()                     # Persistence

Prisma: Hybrid (Nghiêng Data Mapper)

// Prisma generate client — domain objects tách riêng
const user = await prisma.user.findUnique({ where: { id: 1 } });
 
// Domain logic nằm trong class hoặc function riêng
class UserDomain {
  static isVip(user: User, orderTotal: number): boolean {
    return orderTotal > 1000;
  }
}
 
// Prisma client CHÍNH LÀ Data Mapper — nhưng không có rich domain objects
await prisma.user.update({
  where: { id: 1 },
  data: { email: '[email protected]' }
});

Migration: Khi Bạn Outgrow Active Record

Nhiều projects bắt đầu với Active Record và sau đó cần Data Mapper. Cách migrate dần dần:

Step 1: Extract Domain Logic Thành Methods Riêng

// Trước: business logic trộn với persistence
class Order extends ActiveRecordBase {
  confirm(): void {
    if (this.items.length === 0) throw new Error('Empty');
    this.status = 'confirmed';
    this.save(); // Persistence trộn trong
  }
}
 
// Sau: tách side effect
class Order extends ActiveRecordBase {
  confirm(): void {
    if (this.items.length === 0) throw new Error('Empty');
    this.status = 'confirmed';
    // Không save ở đây — để caller quyết định
  }
}
 
// Caller xử lý persistence
const order = await Order.findById(id);
order.confirm();
await order.save(); // Explicit save ở boundary

Step 2: Introduce Repository Interface

// Định nghĩa cần gì — không phải làm thế nào
interface OrderRepository {
  findById(id: string): Promise<Order | null>;
  save(order: Order): Promise<void>;
}
 
// Bắt đầu với Active Record đằng sau interface
class ActiveRecordOrderRepository implements OrderRepository {
  async findById(id: string): Promise<Order | null> {
    return Order.findById(id); // Vẫn dùng Active Record
  }
  async save(order: Order): Promise<void> {
    await order.save(); // Vẫn dùng Active Record
  }
}

Step 3: Tạo Pure Domain Objects

// Domain object mới: không persistence
class OrderDomain {
  constructor(readonly id: string, private _status: OrderStatus) {}
 
  confirm(): void {
    if (this._status !== 'draft') throw new Error('Cannot confirm');
    this._status = 'confirmed';
  }
}
 
// Mapper mới: dịch giữa domain và DB
class DataMapperOrderRepository implements OrderRepository {
  async findById(id: string): Promise<OrderDomain | null> {
    const row = await db.query('SELECT * FROM orders WHERE id = $1', [id]);
    if (!row[0]) return null;
    return new OrderDomain(row[0].id, row[0].status);
  }
 
  async save(order: OrderDomain): Promise<void> {
    // Map domain object sang SQL
  }
}

Step 4: Swap Implementation

// Chuyển từ Active Record sang Data Mapper
// Không cần thay đổi services — chúng depend on interface
const repo: OrderRepository = new DataMapperOrderRepository(db);
// Trước: new ActiveRecordOrderRepository()

TypeORM: Framework Hỗ Trợ Cả Hai

TypeORM là framework TypeScript độc đáo — nó hỗ trợ cả Active Record và Data Mapper mode. Đây là công cụ tốt nhất để hiểu sự khác biệt trực tiếp.

Active Record Mode

// entity/Order.ts — Active Record mode
import { Entity, PrimaryGeneratedColumn, Column, BaseEntity, OneToMany } from 'typeorm';
 
@Entity()
export class Order extends BaseEntity { // <-- extends BaseEntity = Active Record
  @PrimaryGeneratedColumn('uuid')
  id: string;
 
  @Column()
  customerId: string;
 
  @Column('decimal')
  total: number;
 
  @Column({ default: 'draft' })
  status: string;
 
  @OneToMany(() => OrderItem, item => item.order, { cascade: true })
  items: OrderItem[];
 
  // Domain logic + data access trong một class
  confirm(): void {
    if (!this.items || this.items.length === 0) {
      throw new Error('Cannot confirm empty order');
    }
    this.status = 'confirmed';
  }
 
  cancel(): void {
    if (this.status === 'shipped') {
      throw new Error('Cannot cancel shipped order');
    }
    this.status = 'cancelled';
  }
}
 
// Sử dụng: Active Record style
const order = await Order.findOne({ where: { id: 'order-123' } });
order.confirm();
await order.save(); // Object tự save — kế thừa từ BaseEntity

Data Mapper Mode

// entity/Order.ts — Data Mapper mode
import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm';
 
@Entity()
export class Order { // <-- Không BaseEntity = Data Mapper mode
  @PrimaryGeneratedColumn('uuid')
  id: string;
 
  @Column()
  customerId: string;
 
  @Column('decimal')
  total: number;
 
  @Column({ default: 'draft' })
  status: string;
 
  @OneToMany(() => OrderItem, item => item.order, { cascade: true })
  items: OrderItem[];
 
  // Chỉ domain logic — không save(), không findOne()
  confirm(): void {
    if (!this.items || this.items.length === 0) {
      throw new Error('Cannot confirm empty order');
    }
    this.status = 'confirmed';
  }
 
  cancel(): void {
    if (this.status === 'shipped') {
      throw new Error('Cannot cancel shipped order');
    }
    this.status = 'cancelled';
  }
}
 
// Service dùng Repository — Data Mapper pattern
export class OrderService {
  constructor(private orderRepository: Repository<Order>) {}
 
  async confirmOrder(orderId: string): Promise<void> {
    const order = await this.orderRepository.findOne({
      where: { id: orderId },
      relations: ['items'],
    });
    if (!order) throw new Error('Order not found');
 
    order.confirm();                        // Domain logic trên object
    await this.orderRepository.save(order); // Repository xử lý persistence
  }
}

So Sánh Trong TypeORM

Active Record mode:
  Order.findOne(...)   → Order.save()       (object-centric)
 
Data Mapper mode:
  repo.findOne(...)    → repo.save(order)   (repository-centric)

Domain entity (Order) gần như giống nhau trong cả hai mode — sự khác biệt chính là có extend BaseEntity hay không và persistence nằm ở object hay repository.

Khuyến nghị TypeORM:

  • Project nhỏ / phát triển nhanh → Active Record mode
  • Project lớn / clean architecture → Data Mapper mode (cấu hình Repository<T> trong NestJS)

Vấn Đề N+1 Query: Gót Chân Achilles Của Active Record

Đây là performance trap phổ biến nhất khi dùng Active Record patterns và lazy-loading ORMs. Hiểu nó là thiết yếu.

N+1 Là Gì?

Bạn load 1 danh sách orders, rồi với mỗi order bạn thực thi thêm 1 query để load customer. Đó là 1 + N queries thay vì 1 joined query.

// ❌ Classic N+1 — trông vô hại, giết performance
const orders = await Order.findAll();       // Query 1: SELECT * FROM orders (100 rows)
 
for (const order of orders) {
  const customer = await order.customer;   // Query 2...101: SELECT * FROM customers WHERE id = ?
  console.log(`${customer.name}: ${order.total}`);
}
 
// Kết quả: 101 database queries thay vì 1

Tại Sao Active Record Dễ Trigger Lỗi Này

Trong Active Record (Rails, TypeORM Active Record mode, Eloquent), relationships được lazy-loaded theo mặc định:

// TypeORM Active Record — lazy loading
@Entity()
export class Order extends BaseEntity {
  @ManyToOne(() => Customer, { lazy: true }) // Loaded khi truy cập
  customer: Promise<Customer>;               // Chú ý: Promise<Customer>
}
 
// Trong loop, mỗi `order.customer` fire một query
const orders = await Order.find();
for (const order of orders) {
  const c = await order.customer; // QUERY FIRED ĐÂY — N lần
}

Cách Fix: Eager Loading

// ✅ Active Record fix: eager load với relations
const orders = await Order.find({ relations: ['customer', 'items'] });
// 1 query với JOINs — hoặc tối đa 2 queries (orders + batch customer fetch)
 
for (const order of orders) {
  const customer = order.customer; // Đã loaded — không thêm query
  console.log(`${customer.name}: ${order.total}`);
}
# ✅ Rails fix: eager loading với includes
orders = Order.includes(:customer, :order_items).all
# 1 query orders + 1 cho customers (batch) + 1 cho items — tổng 3
 
orders.each do |order|
  puts "#{order.customer.name}: #{order.total}"  # Không thêm queries
end
// ✅ Spring Data JPA fix: dùng @EntityGraph hoặc JPQL JOIN FETCH
@Query("SELECT o FROM Order o JOIN FETCH o.customer WHERE o.status = :status")
List<Order> findByStatusWithCustomer(@Param("status") String status);

N+1 Trong Data Mapper vs Active Record

Data Mapper không tự động tránh N+1, nhưng làm vấn đề hiện rõ hơn:

// Data Mapper — repository bắt buộc bạn explicit về loading
const orders = await orderRepository.find(); // Không lazy loading mặc định
// order.customer là undefined trừ khi bạn chỉ định relations
 
// Bạn buộc phải nghĩ về cái gì cần load từ đầu:
const ordersWithCustomers = await orderRepository.find({
  relations: { customer: true, items: true }
});

Discipline của việc explicit chỉ định cần load gì trong repository queries giảm nguy cơ vô tình gây ra N+1 queries so với transparent lazy-loading trong Active Record.


Sai Lầm Thường Gặp

Sai Lầm 1: Active Record Với Complex Domain Logic

❌ Class Active Record 500 dòng với business rules + SQL + callbacks
❌ Business rules phụ thuộc database state bên trong model
 
✅ Nếu Active Record class vượt ~200 dòng domain logic,
   xem xét extract sang Domain Model + Data Mapper

Sai Lầm 2: Data Mapper Cho Simple CRUD

❌ Domain object + Repository interface + Repository impl + Mapper
   ... cho todo app chỉ có create/read/update/delete
 
✅ Dùng Active Record hoặc Table Data Gateway cho simple CRUD
✅ Data Mapper đáng giá khi domain complexity xứng đáng overhead

Sai Lầm 3: Expose ORM Entities Làm API Responses

// ❌ Trả Active Record hoặc JPA entity trực tiếp
app.get('/orders/:id', async (req, res) => {
  const order = await Order.findById(req.params.id);
  res.json(order); // Expose DB structure, lazy-load proxies, circular refs
});
 
// ✅ Map sang DTO
app.get('/orders/:id', async (req, res) => {
  const order = await Order.findById(req.params.id);
  res.json({
    id: order.id,
    total: order.total,
    status: order.status,
    itemCount: order.items.length,
  });
});

Sai Lầm 4: Chống Lại Default Của Framework

❌ Build Data Mapper patterns trong Rails (framework chống lại bạn)
❌ Dùng Active Record patterns trong Spring Boot (JPA là Data Mapper)
 
✅ Làm việc CÙNG pattern mà framework đã chọn
✅ Nếu cần pattern khác, xem xét đổi framework

Bài Tập Thực Hành

Bài Tập 1: Nhận Diện Pattern

Cho mỗi framework bạn từng dùng, xác định data source pattern nào:

  • User.findById() là Active Record hay Data Mapper?
  • Domain object có biết về database không?
  • SQL nằm ở đâu — trong model hay trong layer riêng?

Bài Tập 2: Build Cả Hai

Implement cùng tính năng "tài khoản ngân hàng với chuyển tiền":

  1. Active Record style (object tự save)
  2. Data Mapper style (repository riêng)

So sánh: dễ test transfer logic mà không cần database không?

Bài Tập 3: Đánh Giá Project Hiện Tại

  • Project bạn dùng data source pattern nào?
  • Đó có phải lựa chọn đúng cho domain complexity?
  • Nếu dùng Active Record, có class nào đang quá lớn?
  • Nếu dùng Data Mapper, overhead có xứng đáng?

Tóm Tắt và Điểm Chính

✅ Table Data Gateway — một class cho mỗi table, trả raw data. Đơn giản nhất, không domain objects.
✅ Row Data Gateway — một object cho mỗi row, không business logic. Hiếm dùng trong framework hiện đại.
✅ Active Record — domain object CHÍNH LÀ database row. Đơn giản, nhanh, tốt cho CRUD. Sụp đổ với complex domains.
✅ Data Mapper — layer riêng giữa domain và DB. Domain thuần, testable, linh hoạt. Overhead nhiều hơn.
✅ Active Record vs Data Mapper — chọn dựa trên domain complexity, không phải framework popularity
✅ Simple CRUD → Active Record. Complex domain → Data Mapper. Luôn luôn.
✅ Framework đã chọn — Rails/Django = Active Record, Spring/EF Core = Data Mapper
✅ TypeORM hỗ trợ cả hai — extend BaseEntity cho Active Record, dùng Repository<T> cho Data Mapper
✅ N+1 problem — pitfall lớn nhất của Active Record. Luôn eager-load relationships trong list queries
✅ Có thể migrate từ Active Record sang Data Mapper dần dần qua repository interfaces


Tiếp Theo?

Bây giờ bạn hiểu data source patterns, câu hỏi tiếp: điều gì xảy ra bên trong Data Mapper? Trong Bài #4: Object-Relational Mapping Patterns, chúng ta sẽ cover Unit of Work, Identity Map, Lazy Load và Query Object — patterns khiến ORM hoạt động.


Bài Viết Liên Quan


Series: Patterns of Enterprise Application Architecture
Trước: Domain Logic Patterns
Tiếp theo: Bài #4 — Object-Relational Mapping Patterns (Unit of Work, Identity Map, Lazy Load, Query Object)

📬 Đăng ký nhận bản tin

Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.

Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.

💬 Comments

Đăng nhập để bình luận

Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.