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:
| Pattern | Domain Object Biết Về DB? | Mapping Layer? | Phù hợp |
|---|---|---|---|
| Table Data Gateway | Không có domain objects | Không | Raw data access, reporting |
| Row Data Gateway | Không (gateway riêng) | Không | Tách biệt không cần ORM |
| Active Record | Có — tự save | Không (built-in) | Simple domains, CRUD |
| Data Mapper | Không — hoàn toàn không biết | Có (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ạnh | Row Data Gateway | Active Record |
|---|---|---|
| Data access | ✅ Có | ✅ Có |
| Business logic | ❌ Không — tách riêng | ✅ Có — cùng class |
| Domain identity | Không — chỉ là data wrapper | Có — nó LÀ domain object |
| Dùng kèm | Transaction Script | Standalone (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ự saveJava 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) # QueryKhi 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 table2. 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 DB4. 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 RecordPattern 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ý persistenceJava 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ống | Tại sao Active Record phù hợp |
|---|---|
| Simple CRUD với ít business rules | Direct mapping, không extra layers |
| Rapid prototyping | Ít code, iteration nhanh hơn |
| Small đến medium applications | Simplicity > architectural purity |
| Team nhỏ và full-stack | Ít concepts cần học |
| Domain map 1:1 với database tables | Không impedance mismatch |
Khi Nào Dùng Data Mapper
| Tình huống | Tại sao Data Mapper phù hợp |
|---|---|
| Complex domain với rich behavior | Domain objects focus vào business logic |
| Domain model không match DB schema | Mapper xử lý translation |
| Cần test domain logic mà không cần DB | Domain objects thuần |
| Nhiều persistence targets | Cùng domain, storage khác nhau |
| Clean/Hexagonal architecture | Domain không phụ thuộc infrastructure |
| Team lớn với separate domain experts | Domain model phản ánh business, không phải tables |
So Sánh Trực Tiếp
| Khía cạnh | Active Record | Data 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 |
| Frameworks | Rails, Django, Laravel, Eloquent | JPA/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! # PersistenceSpring 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() # PersistencePrisma: 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 ở boundaryStep 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ừ BaseEntityData 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ì 1Tạ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 MapperSai 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 overheadSai 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 frameworkBà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":
- Active Record style (object tự save)
- 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
- Patterns of Enterprise Application Architecture Roadmap — Tổng quan series và lộ trình học
- Domain Logic Patterns — Cách tổ chức business logic (bài trước)
- Domain-Driven Design — DDD dùng Data Mapper để giữ domain thuần
- Clean Architecture — Data Mapper hỗ trợ clean architecture
- Hexagonal Architecture — Ports và adapters với Data Mapper
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.