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

In the previous post, we covered how to organize business logic — Transaction Script, Domain Model, Table Module, and Service Layer. But business logic doesn't exist in a vacuum. It needs data. And that raises the next fundamental question:
How do domain objects talk to the database?
This question has sparked one of the longest-running debates in software engineering: Active Record vs Data Mapper. Rails developers swear by Active Record. Spring developers live in Data Mapper. Django developers use Active Record but call it "ORM." And most developers use one or the other without knowing why their framework made that choice.
Martin Fowler defined four data source patterns in Patterns of Enterprise Application Architecture. Understanding them is the key to understanding every ORM you'll ever use.
In this post, we'll cover:
✅ Active Record — domain object knows how to persist itself
✅ Data Mapper — separate layer maps between domain and database
✅ Table Data Gateway — one gateway class per table
✅ Row Data Gateway — one gateway object per row
✅ Active Record vs Data Mapper — the great debate
✅ How Rails, Spring, Django, and Prisma implement these patterns
✅ TypeORM: the framework that supports both Active Record and Data Mapper
✅ The N+1 query problem — Active Record's most common pitfall
✅ When to use each pattern — decision framework
✅ Migration strategies when you outgrow a pattern
The Data Source Spectrum
Data source patterns exist on a spectrum from simple and coupled to complex and decoupled:
| Pattern | Domain Object Knows About DB? | Mapping Layer? | Best For |
|---|---|---|---|
| Table Data Gateway | No domain objects | No | Raw data access, reporting |
| Row Data Gateway | No (separate gateway) | No | Separation without ORM |
| Active Record | Yes — saves itself | No (built-in) | Simple domains, CRUD |
| Data Mapper | No — completely ignorant | Yes (separate mapper) | Complex domains, DDD |
Pattern 1: Table Data Gateway
What It Is
Table Data Gateway is the simplest data source pattern. There's one gateway class per database table, and it handles all SQL for that table. Methods return raw data structures (records, arrays, maps) — not domain objects.
Think of it as a repository for raw data without any domain model.
TypeScript Example
// Table Data Gateway: one class per table, returns 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 — not domain objects
interface OrderRow {
id: string;
customer_id: string;
total: number;
status: string;
created_at: Date;
updated_at: Date | null;
}
interface InsertOrderData {
id: string;
customerId: string;
total: number;
status: string;
}When to Use Table Data Gateway
- Reporting and analytics — you need raw data, not domain objects
- Simple CRUD — especially with Transaction Script domain logic
- Legacy systems — wrapping existing SQL in a clean API
- Performance-critical paths — avoiding ORM overhead
Not suitable for: Complex domains with rich behavior (use Data Mapper instead).
Pattern 2: Row Data Gateway
What It Is
Row Data Gateway gives you one gateway object per database row. Each object wraps a single row and provides methods to read/write its columns. It looks like Active Record at first glance, but there's a critical difference: Row Data Gateway contains no business logic — it's purely for data access.
TypeScript Example
// Row Data Gateway: one object per row, no 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 — loads a row from 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 this row
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 is SEPARATE — in a Transaction Script or Domain Model
class OrderService {
async placeOrder(customerId: string, total: number): Promise<string> {
// Business rules here...
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
The difference is subtle but important:
| Aspect | Row Data Gateway | Active Record |
|---|---|---|
| Data access | ✅ Yes | ✅ Yes |
| Business logic | ❌ No — separate | ✅ Yes — in the same class |
| Domain identity | No — it's a data wrapper | Yes — it IS the domain object |
| Typical usage | With Transaction Script | Standalone (Rails, Django) |
In practice, Row Data Gateway is rarely used in modern frameworks. Active Record absorbed the data access responsibility, and Data Mapper separated it entirely. Row Data Gateway lives in the middle and isn't well-served by today's ORMs.
Pattern 3: Active Record
What It Is
Active Record is the most well-known data source pattern. A domain object wraps a database row, adds domain logic, and knows how to save itself to the database. The object IS both the domain representation and the data access mechanism.
This is the pattern that powers Rails, Django ORM, Laravel Eloquent, and TypeORM (in Active Record mode).
TypeScript Example
// Active Record: domain object + data access in one class
class Order extends ActiveRecordBase {
id: string;
customerId: string;
total: number;
status: string;
items: OrderItem[] = [];
// Data access: the object knows how to persist itself
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: lives right here in the same 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;
}
}
// Usage: clean, intuitive
const order = await Order.findById('order-123');
order.addItem(product, 2);
order.confirm();
await order.save(); // Object saves itselfJava Example (Simplified Active Record)
// Active Record style in Java (not typical — Java prefers 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: How Rails Does It
# Rails Active Record — the canonical implementation
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 in the 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
# Query interface — static methods
scope :pending, -> { where(status: "draft") }
scope :for_customer, ->(id) { where(customer_id: id) }
end
# Usage
order = Order.find(123) # Load from DB
order.confirm! # Domain logic
order.save! # Persist changes
Order.pending.for_customer(42) # QueryWhen Active Record Breaks Down
Active Record works beautifully for simple to moderate domains. But it has structural limitations:
1. Domain objects are coupled to the database schema
// If the DB column is "customer_id", the domain object has "customerId"
// If the DB changes, the domain object changes
// Can't have a domain concept that doesn't map 1:1 to a table2. Complex domain logic gets mixed with persistence
class Order extends ActiveRecordBase {
// 200 lines of domain logic
// + 100 lines of data access
// + 50 lines of validation
// + 30 lines of callbacks
// = God class
}3. Hard to test domain logic without a database
// To test order.confirm(), you need a database
// because the Order object IS the database row
const order = await Order.findById('test-id'); // Needs DB
order.confirm();
await order.save(); // Needs DB4. One-to-one table mapping limits domain modeling
Domain concept "Money" (amount + currency) → needs 2 columns, not a table
Domain concept "Address" (value object) → embedded, not a separate entity
Inheritance hierarchy → awkward in Active RecordPattern 4: Data Mapper
What It Is
Data Mapper is a layer of software that separates domain objects from the database. Domain objects have no knowledge of the database — no save(), no findById(), no SQL. A separate mapper (or repository) handles all persistence.
This is the pattern that powers JPA/Hibernate (Java), Entity Framework Core (.NET), SQLAlchemy (Python, classical mapping), TypeORM (Data Mapper mode), and Prisma/Drizzle (TypeScript).
TypeScript Example
// Domain object: ZERO knowledge of the 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';
}
// Notice: NO save(), NO findById(), NO SQL
// The domain object is pure business logic
}
// Data Mapper: handles all 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 separately
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]
);
}
}
async delete(order: Order): Promise<void> {
await this.db.query('DELETE FROM order_items WHERE order_id = $1', [order.id]);
await this.db.query('DELETE FROM orders WHERE id = $1', [order.id]);
}
// Private: translate between DB rows and domain objects
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);
// Reconstitute the order state from DB
// (in practice, the mapper uses reflection or a hydration mechanism)
Object.assign(order, { _status: row.status });
return order;
}
}
// Usage: domain object is ignorant of persistence
const mapper = new OrderMapper(db);
const order = await mapper.findById('order-123');
order.confirm(); // Pure domain logic
await mapper.save(order); // Mapper handles persistenceJava Example: JPA as Data Mapper
JPA/Hibernate is the canonical Data Mapper implementation. The EntityManager acts as the mapper:
// Domain entity: annotated for JPA mapping, but logic is pure
@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 — no SQL, no 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;
}
private void recalculateTotal() {
this.total = items.stream()
.map(OrderItem::lineTotal)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
}
// Repository: Spring Data JPA generates the Data Mapper
public interface OrderRepository extends JpaRepository<Order, String> {
List<Order> findByCustomerId(String customerId);
List<Order> findByStatus(OrderStatus status);
}
// Service: uses the mapper (repository) to 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) handles persistence
return order.getId();
}
}Why Data Mapper Enables Clean Architecture
Data Mapper naturally supports Clean Architecture and Hexagonal Architecture:
The domain defines a repository interface. The infrastructure layer provides the Data Mapper implementation. The domain never depends on the database — dependencies point inward.
// Domain layer: defines interface
interface OrderRepository {
findById(id: string): Promise<Order | null>;
save(order: Order): Promise<void>;
delete(order: Order): Promise<void>;
}
// Infrastructure layer: implements with Data Mapper
class PostgresOrderRepository implements OrderRepository {
async findById(id: string): Promise<Order | null> {
// SQL, mapping logic — all here
}
async save(order: Order): Promise<void> {
// Translates domain object to SQL
}
}
// Test: swap the database with an 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);
}
}The Great Debate: Active Record vs Data Mapper
This is the question that divides framework communities. Here's a clear framework for choosing:
When to Use Active Record
| Situation | Why Active Record Works |
|---|---|
| Simple CRUD with minimal business rules | Direct mapping, no extra layers |
| Rapid prototyping | Less code, faster iteration |
| Small to medium applications | Simplicity > architectural purity |
| Team is small and full-stack | Less concepts to learn |
| Domain maps 1:1 to database tables | No impedance mismatch |
When to Use Data Mapper
| Situation | Why Data Mapper Works |
|---|---|
| Complex domain with rich behavior | Domain objects stay focused on business logic |
| Domain model doesn't match DB schema | Mapper handles the translation |
| You need to test domain logic without DB | Domain objects are pure |
| Multiple persistence targets | Same domain, different storage |
| Clean/Hexagonal architecture | Domain doesn't depend on infrastructure |
| Large team with separate domain experts | Domain model reflects business, not tables |
Side-by-Side Comparison
| Aspect | Active Record | Data Mapper |
|---|---|---|
| Simplicity | ✅ One class = model + persistence | ❌ Separate domain + mapper + repository |
| Speed to build | ✅ Fast — less code upfront | ❌ More initial setup |
| Testability | ❌ Needs DB to test domain logic | ✅ Domain objects are pure, testable |
| Domain purity | ❌ Persistence mixed with logic | ✅ Domain knows nothing about DB |
| Schema flexibility | ❌ 1:1 with tables | ✅ Domain can differ from DB |
| Clean Architecture | ❌ Domain depends on DB framework | ✅ Dependencies point inward |
| Learning curve | ✅ Low — intuitive API | ❌ Higher — more concepts |
| Refactoring | ❌ Changes ripple to domain | ✅ DB changes don't affect domain |
| Frameworks | Rails, Django, Laravel, Eloquent | JPA/Hibernate, EF Core, SQLAlchemy |
The Decision Flowchart
How Popular Frameworks Implement These Patterns
Rails: Active Record
# Rails is THE 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 is the Data Mapper
// Spring Data generates repository implementations
@Entity
public class User {
@Id @GeneratedValue
private Long id;
private String email;
// Domain logic, no persistence methods
public boolean isVip(BigDecimal orderTotal) {
return orderTotal.compareTo(BigDecimal.valueOf(1000)) > 0;
}
}
// Repository interface — Spring generates Data Mapper implementation
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
}Django: Active Record (Called "ORM")
# Django ORM is Active Record — same pattern as 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 (Leaning Data Mapper)
// Prisma generates a client — domain objects are separate
const user = await prisma.user.findUnique({ where: { id: 1 } });
// Domain logic lives in a separate class or function
class UserDomain {
static isVip(user: User, orderTotal: number): boolean {
return orderTotal > 1000;
}
}
// Prisma client IS the Data Mapper — but without rich domain objects
await prisma.user.update({
where: { id: 1 },
data: { email: 'new@example.com' }
});Migration: When You Outgrow Active Record
Many projects start with Active Record and later need Data Mapper. Here's how to migrate gradually:
Step 1: Extract Domain Logic Into Separate Methods
// Before: business logic mixed with persistence
class Order extends ActiveRecordBase {
confirm(): void {
if (this.items.length === 0) throw new Error('Empty');
this.status = 'confirmed';
this.save(); // Persistence mixed in
}
}
// After: separate the side effect
class Order extends ActiveRecordBase {
confirm(): void {
if (this.items.length === 0) throw new Error('Empty');
this.status = 'confirmed';
// Don't save here — let the caller decide
}
}
// Caller handles persistence
const order = await Order.findById(id);
order.confirm();
await order.save(); // Explicit save at the boundaryStep 2: Introduce a Repository Interface
// Define what you need — not how it's done
interface OrderRepository {
findById(id: string): Promise<Order | null>;
save(order: Order): Promise<void>;
}
// Start with Active Record behind the interface
class ActiveRecordOrderRepository implements OrderRepository {
async findById(id: string): Promise<Order | null> {
return Order.findById(id); // Still uses Active Record
}
async save(order: Order): Promise<void> {
await order.save(); // Still uses Active Record
}
}Step 3: Create Pure Domain Objects
// New domain object: no persistence
class OrderDomain {
constructor(readonly id: string, private _status: OrderStatus) {}
confirm(): void {
if (this._status !== 'draft') throw new Error('Cannot confirm');
this._status = 'confirmed';
}
}
// New mapper: translates between domain and 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 to SQL
}
}Step 4: Swap the Implementation
// Switch from Active Record to Data Mapper
// No changes needed in services — they depend on the interface
const repo: OrderRepository = new DataMapperOrderRepository(db);
// Previously: new ActiveRecordOrderRepository()TypeORM: The Framework That Supports Both
TypeORM is unique — it's a TypeScript ORM that explicitly supports both Active Record and Data Mapper modes. This makes it the best tool for understanding the difference hands-on.
Active Record Mode
// entity/Order.ts — Active Record mode
import { Entity, PrimaryGeneratedColumn, Column, BaseEntity, ManyToOne, 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 in one 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';
}
}
// Usage: Active Record style
const order = await Order.findOne({ where: { id: 'order-123' } });
order.confirm();
await order.save(); // Object saves itself — inherited from BaseEntityData Mapper Mode
// entity/Order.ts — Data Mapper mode
import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm';
@Entity()
export class Order { // <-- No 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[];
// Only domain logic — no save(), no 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';
}
}
// repository/OrderRepository.ts — the Data Mapper
import { Repository } from 'typeorm';
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 on the object
await this.orderRepository.save(order); // Repository handles persistence
}
}Side-by-Side in TypeORM
Active Record mode:
Order.findOne(...) → Order.save() (object-centric)
Data Mapper mode:
repo.findOne(...) → repo.save(order) (repository-centric)The domain entity (Order) is almost identical in both modes — the key difference is whether it extends BaseEntity and whether persistence lives in the object or the repository.
TypeORM recommendation:
- Small projects / rapid development → Active Record mode
- Large projects / clean architecture → Data Mapper mode (configure
useClass: DataSourcein NestJS)
The N+1 Query Problem: Active Record's Achilles Heel
This is the most common performance trap when using Active Record patterns (and lazy-loading ORMs in general). Understanding it is essential.
What Is N+1?
You load 1 list of orders, then for each order you execute 1 additional query to load its customer. That's 1 + N queries instead of 1 joined query.
// ❌ Classic N+1 — looks innocent, kills performance
const orders = await Order.findAll(); // Query 1: SELECT * FROM orders (returns 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}`);
}
// Result: 101 database queries instead of 1Why Active Record Makes This Easy to Trigger
In Active Record (Rails, TypeORM Active Record mode, Eloquent), relationships are lazy-loaded by default:
// TypeORM Active Record — lazy loading
@Entity()
export class Order extends BaseEntity {
@ManyToOne(() => Customer, { lazy: true }) // Loaded on access
customer: Promise<Customer>; // Notice: Promise<Customer>
}
// In a loop, each `order.customer` fires a query
const orders = await Order.find();
for (const order of orders) {
const c = await order.customer; // QUERY FIRED HERE — N times
}The Fix: Eager Loading
// ✅ Active Record fix: eager load with relations
const orders = await Order.find({ relations: ['customer', 'items'] });
// Now: 1 query with JOINs — or 2 queries max (orders + batch customer fetch)
for (const order of orders) {
const customer = order.customer; // Already loaded — no extra query
console.log(`${customer.name}: ${order.total}`);
}# ✅ Rails fix: eager loading with includes
orders = Order.includes(:customer, :order_items).all
# 1 query for orders + 1 for customers (batch) + 1 for items — total 3
orders.each do |order|
puts "#{order.customer.name}: #{order.total}" # No extra queries
end// ✅ Spring Data JPA fix: use @EntityGraph or JPQL JOIN FETCH
@Query("SELECT o FROM Order o JOIN FETCH o.customer WHERE o.status = :status")
List<Order> findByStatusWithCustomer(@Param("status") String status);
// Or: EntityGraph for flexible eager loading
@EntityGraph(attributePaths = { "customer", "items" })
List<Order> findAll();N+1 in Data Mapper vs Active Record
Data Mapper doesn't magically avoid N+1, but it makes the issue more visible:
// Data Mapper — repository forces you to be explicit about loading
const orders = await orderRepository.find(); // No lazy loading by default
// order.customer is undefined unless you specify relations
// You're forced to think about what to load upfront:
const ordersWithCustomers = await orderRepository.find({
relations: { customer: true, items: true }
});The discipline of explicitly specifying what to load in repository queries reduces the chance of accidental N+1 queries compared to transparent lazy-loading in Active Record.
Detecting N+1 in Your App
// TypeORM: enable query logging
const dataSource = new DataSource({
logging: true, // Prints every SQL query
logger: 'advanced-console',
});
// Count queries per request — if it's proportional to data, you have N+1
// Tools: DataDog, New Relic, or simply TypeORM's built-in logger# Rails: use the Bullet gem
# gem 'bullet'
# Bullet.rails_logger = true
# Bullet will warn you about N+1 queries in development logsCommon Mistakes
Mistake 1: Active Record With Complex Domain Logic
❌ 500-line Active Record class with business rules + SQL + callbacks
❌ Business rules that depend on database state within the model
✅ If your Active Record class exceeds ~200 lines of domain logic,
consider extracting to Domain Model + Data MapperMistake 2: Data Mapper For Simple CRUD
❌ Domain object + Repository interface + Repository impl + Mapper
... for a todo app with create/read/update/delete
✅ Use Active Record or even Table Data Gateway for simple CRUD
✅ Data Mapper pays off when domain complexity justifies the overheadMistake 3: Exposing ORM Entities as API Responses
// ❌ Returning Active Record or JPA entity directly
app.get('/orders/:id', async (req, res) => {
const order = await Order.findById(req.params.id);
res.json(order); // Exposes DB structure, lazy-load proxies, circular refs
});
// ✅ Map to a 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,
});
});Mistake 4: Fighting Your Framework's Default
❌ Building Data Mapper patterns in Rails (it fights you)
❌ Using Active Record patterns in Spring Boot (JPA is Data Mapper)
✅ Work WITH your framework's chosen pattern
✅ If you need the other pattern, consider switching frameworksPractice Exercises
Exercise 1: Identify the Pattern
For each framework you've used, identify which data source pattern it uses:
- Is
User.findById()Active Record or Data Mapper? - Does the domain object know about the database?
- Where does the SQL live — in the model or in a separate layer?
Exercise 2: Build Both
Implement the same "bank account with transfer" feature using:
- Active Record style (object saves itself)
- Data Mapper style (separate repository)
Compare: how easy is it to test the transfer logic without a database?
Exercise 3: Evaluate Your Current Project
- Which data source pattern does your project use?
- Is it the right choice for your domain complexity?
- If you're using Active Record, are any classes getting too large?
- If you're using Data Mapper, is the overhead justified?
Summary and Key Takeaways
✅ Table Data Gateway — one class per table, returns raw data. Simplest, no domain objects.
✅ Row Data Gateway — one object per row, no business logic. Rarely used in modern frameworks.
✅ Active Record — domain object IS the database row. Simple, fast, great for CRUD. Breaks down with complex domains.
✅ Data Mapper — separate layer between domain and DB. Domain is pure, testable, flexible. More overhead.
✅ Active Record vs Data Mapper — choose based on domain complexity, not framework popularity
✅ Simple CRUD → Active Record. Complex domain → Data Mapper. Always.
✅ Your framework already chose — Rails/Django = Active Record, Spring/EF Core = Data Mapper
✅ TypeORM supports both — extend BaseEntity for Active Record, use Repository<T> for Data Mapper
✅ N+1 problem — Active Record's biggest pitfall. Always eager-load relationships in list queries
✅ You can migrate from Active Record to Data Mapper gradually using repository interfaces
What's Next?
Now that you understand data source patterns, the next question is: what happens inside the Data Mapper? In Post #4: Object-Relational Mapping Patterns, we'll cover Unit of Work, Identity Map, Lazy Load, and Query Object — the patterns that make ORMs work.
Related Posts
- Patterns of Enterprise Application Architecture Roadmap — Series overview and learning paths
- Domain Logic Patterns — How to organize business logic (previous post)
- Domain-Driven Design — DDD uses Data Mapper to keep domain pure
- Clean Architecture — Data Mapper enables clean architecture
- Hexagonal Architecture — Ports and adapters with Data Mapper
Series: Patterns of Enterprise Application Architecture
Previous: Domain Logic Patterns
Next: Post #4 — Object-Relational Mapping Patterns (Unit of Work, Identity Map, Lazy Load, Query Object)
📬 Subscribe to Newsletter
Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.
We respect your privacy. Unsubscribe at any time.
💬 Comments
Sign in to leave a comment
We'll never post without your permission.