Decoupling Your Domain: Understanding the Data Mapper Pattern
Connecting your application’s core logic (the domain objects) to a persistent store (like a database) is a fundamental task. However, directly embedding database logic within your domain objects can lead to tight coupling, making your code harder to test, maintain, and evolve. How can we keep these concerns separate?
Enter the Data Mapper pattern. As Martin Fowler describes in “Patterns of Enterprise Application Architecture” (P of EAA, page 165):
A layer of Mappers that moves data between objects and a database while keeping them independent of each other and the mapper itself.
Let’s dive into what this means, why it’s beneficial, and how to implement it using TypeScript.
What is the Data Mapper Pattern?
The core idea is simple but powerful:
The Data Mapper is a layer of software that separates the in-memory objects (your domain model) from the database. Its responsibility is to transfer data between the two and also to isolate them from each other.
Think of it as a dedicated translator or mediator standing between your application’s business logic and the details of database storage.
Key Responsibilities:
- Separation: It keeps your domain objects blissfully unaware of the database schema, SQL queries, or ORM specifics. Domain objects focus purely on business rules and behavior.
- Data Transfer: It handles the mechanics of loading data from the database and constructing domain objects (hydration), and taking domain objects and saving their state back to the database (persistence).
- Isolation: It shields the domain model from changes in the database schema and vice-versa, as long as the mapper can still bridge the gap.
Why Use a Data Mapper?
Employing the Data Mapper pattern offers several significant advantages:
- Improved Decoupling: This is the primary benefit. Your domain model and persistence layer can evolve independently. Changing a database table structure might only require updating the mapper, not the domain object itself.
- Enhanced Testability: Domain objects can be tested without needing a database connection because they contain no persistence logic. Mappers can also be tested in isolation, potentially using mock database interactions or an in-memory store.
- Adherence to Single Responsibility Principle (SRP): Domain objects handle business logic, while mappers handle persistence logic. Each has a clear, single responsibility.
- Flexibility: Mappers can handle complex mapping scenarios, such as mapping one domain object to multiple tables or vice-versa. You have full control over the SQL or database interactions if needed.
- Clear Boundaries: It establishes a distinct persistence layer, making the overall application architecture easier to understand.
How it Works: The Flow
A typical interaction involving a Data Mapper looks like this:
- Application Layer Request: A service or use case needs a domain object (e.g., find user by ID).
- Call the Mapper: The service calls the appropriate method on the Data Mapper interface (e.g.,
userMapper.findById(id)
). - Mapper Interacts with Database: The concrete mapper implementation generates and executes the necessary database query (e.g.,
SELECT * FROM users WHERE id = ?
). - Mapper Receives Data: The mapper gets the raw data (e.g., a database row) back from the database.
- Mapper Creates Domain Object: The mapper transforms the raw data into a domain object, potentially using conversion logic.
- Mapper Returns Object: The mapper returns the fully constructed domain object to the application layer.
Saving data follows a similar reverse flow: Application Layer -> Mapper -> Extract Data -> Database Interaction.
Implementation Example (TypeScript)
Let’s model a simple User
domain object and its corresponding mapper.
// --- Domain Layer ---
// Plain Old TypeScript Object (POTO) - No database logic!
class User {
constructor(
public readonly id: string,
public name: string,
public email: string,
private _version: number = 0 // Example: for optimistic locking
) {}
// Business logic methods can go here...
changeEmail(newEmail: string): void {
if (!newEmail.includes('@')) {
throw new Error("Invalid email format");
}
this.email = newEmail;
this._version++; // Increment version on change
}
getVersion(): number {
return this._version;
}
}
// --- Persistence Layer Abstraction ---
// Defines the contract for interacting with User persistence
interface IUserMapper {
findById(id: string): Promise<User | null>;
findByEmail(email: string): Promise<User | null>;
save(user: User): Promise<void>; // Handles both insert and update
delete(user: User): Promise<void>;
}
// --- Persistence Layer Implementation ---
// Concrete implementation using a (simulated) database connection
// In a real app, this would use a DB driver (like pg, mysql2) or an ORM query builder
// Represents raw data structure from the DB table 'users'
interface UserRow {
user_id: string;
user_name: string;
email_address: string;
row_version: number;
}
class UserDbMapper implements IUserMapper {
// In a real app, inject a database connection pool or client here
private dbClient: any; // Placeholder for DB connection/client
constructor(/* dbClient: DbClient */) {
// this.dbClient = dbClient;
console.log("UserDbMapper initialized (simulated DB connection)");
}
// Helper to map raw DB row to User object
private mapRowToUser(row: UserRow): User {
// Note: Constructor handles setting initial private fields like _version
const user = new User(row.user_id, row.user_name, row.email_address);
// Manually set internal state if needed (use with caution, maybe via specific methods)
(user as any)._version = row.row_version;
return user;
}
// Helper to map User object to data suitable for DB insertion/update
private mapUserToRowData(user: User): Omit<UserRow, 'user_id'> {
return {
user_name: user.name,
email_address: user.email,
row_version: user.getVersion()
};
}
async findById(id: string): Promise<User | null> {
console.log(`Mapper: Finding user by id=${id}`);
// Simulate DB query: SELECT * FROM users WHERE user_id = $1
// const result = await this.dbClient.query('...', [id]);
// const row = result.rows[0];
const row: UserRow | undefined = FAKE_DB.find(u => u.user_id === id); // Simulation
if (!row) {
return null;
}
return this.mapRowToUser(row);
}
async findByEmail(email: string): Promise<User | null> {
console.log(`Mapper: Finding user by email=${email}`);
// Simulate DB query: SELECT * FROM users WHERE email_address = $1
const row: UserRow | undefined = FAKE_DB.find(u => u.email_address === email); // Simulation
if (!row) {
return null;
}
return this.mapRowToUser(row);
}
async save(user: User): Promise<void> {
const rowData = this.mapUserToRowData(user);
const existingIndex = FAKE_DB.findIndex(u => u.user_id === user.id);
if (existingIndex !== -1) {
// Update (Implement optimistic locking check)
const currentDbVersion = FAKE_DB[existingIndex].row_version;
if (currentDbVersion !== user.getVersion() -1 && currentDbVersion !== 0) { // Allow saving new objects V0 -> V1
throw new Error(`Concurrency conflict: User ${user.id} modified by another transaction.`);
}
const nextVersion = user.getVersion();
console.log(`Mapper: Updating user id=${user.id}, version=${nextVersion}`);
// Simulate DB query: UPDATE users SET user_name=$1, email_address=$2, row_version=$3 WHERE user_id=$4 AND row_version=$5
FAKE_DB[existingIndex] = { user_id: user.id, ...rowData, row_version: nextVersion };
} else {
// Insert
const nextVersion = user.getVersion(); // Should typically be 1 after initial creation/modification
console.log(`Mapper: Inserting user id=${user.id}, version=${nextVersion}`);
// Simulate DB query: INSERT INTO users (user_id, user_name, email_address, row_version) VALUES ($1, $2, $3, $4)
FAKE_DB.push({ user_id: user.id, ...rowData, row_version: nextVersion });
// Update the user's version in memory if the DB assigns it (less common with manual versioning)
(user as any)._version = nextVersion;
}
}
async delete(user: User): Promise<void> {
console.log(`Mapper: Deleting user id=${user.id}`);
// Simulate DB query: DELETE FROM users WHERE user_id = $1
const index = FAKE_DB.findIndex(u => u.user_id === user.id);
if (index !== -1) {
FAKE_DB.splice(index, 1);
}
}
}
// --- Application Layer (Example Service) ---
class UserService {
// Depends on the abstraction (interface), not the concrete implementation!
constructor(private userMapper: IUserMapper) {}
async getUserProfile(userId: string): Promise<{ name: string; email: string } | null> {
const user = await this.userMapper.findById(userId);
if (!user) {
return null;
}
// Could use a DTO here, but returning simple object for brevity
return { name: user.name, email: user.email };
}
async registerUser(id: string, name: string, email: string): Promise<User> {
// Check if email already exists
const existing = await this.userMapper.findByEmail(email);
if (existing) {
throw new Error(`Email ${email} is already registered.`);
}
const newUser = new User(id, name, email);
await this.userMapper.save(newUser);
console.log(`Service: Registered user ${newUser.name}`);
return newUser;
}
async updateUserEmail(userId: string, newEmail: string): Promise<User> {
const user = await this.userMapper.findById(userId);
if (!user) {
throw new Error(`User ${userId} not found.`);
}
// Apply business logic
user.changeEmail(newEmail);
// Persist changes via mapper
await this.userMapper.save(user);
console.log(`Service: Updated email for user ${user.name}`);
return user;
}
}
// --- Simulation & Usage ---
const FAKE_DB: UserRow[] = []; // Simulate the database table
// Dependency Injection: Create concrete mapper and inject it into the service
const userMapperInstance = new UserDbMapper(/* real DB client */);
const userService = new UserService(userMapperInstance);
async function runDemo() {
try {
const user1 = await userService.registerUser('u1', 'Alice', '[email protected]');
const user2 = await userService.registerUser('u2', 'Bob', '[email protected]');
console.log("--- Current DB State ---");
console.log(FAKE_DB);
const fetchedUser1 = await userService.getUserProfile('u1');
console.log("Fetched Profile:", fetchedUser1);
await userService.updateUserEmail('u1', '[email protected]');
console.log("--- DB State After Update ---");
console.log(FAKE_DB);
const updatedUser1 = await userMapperInstance.findById('u1'); // Get full user object
console.log("Updated User Object:", updatedUser1);
} catch (error: any) {
console.error("Error:", error.message);
}
}
runDemo();
Handling Complex Mappings: The Role of Converters
Sometimes, the mapping between a database row (or multiple rows) and a domain object isn’t a simple one-to-one field assignment. Column names might differ significantly, data types might need conversion (e.g., DB timestamp to Date object), or complex objects might need to be assembled from joins.
This is where dedicated Converters or explicit mapping functions within the mapper become crucial. Your note mentions a generic interface:
interface Converter<A, B> {
convert(input: A): B;
}
You could implement this to handle specific transformations:
// Example: Convert DB row structure to User domain object
class DbRowToUserConverter implements Converter<UserRow, User> {
convert(row: UserRow): User {
// Complex logic here... might involve checking flags, formatting data, etc.
const user = new User(row.user_id, row.user_name, row.email_address);
(user as any)._version = row.row_version; // Assign internal state safely
return user;
}
}
// Example: Convert User domain object to structure for DB update/insert
class UserToDbRowDataConverter implements Converter<User, Omit<UserRow, 'user_id'>> {
convert(user: User): Omit<UserRow, 'user_id'> {
return {
user_name: user.name,
email_address: user.email,
row_version: user.getVersion()
};
}
}
// The Mapper would then use these converters:
class UserDbMapperWithConverters implements IUserMapper {
private toUserConverter = new DbRowToUserConverter();
private toRowDataConverter = new UserToDbRowDataConverter();
// ... other dependencies
async findById(id: string): Promise<User | null> {
// ... fetch row from DB ...
const row: UserRow | undefined = /* ... fetch logic ... */ FAKE_DB.find(u => u.user_id === id);
if (!row) return null;
return this.toUserConverter.convert(row); // Use converter
}
async save(user: User): Promise<void> {
const rowData = this.toRowDataConverter.convert(user); // Use converter
// ... DB insert/update logic using rowData ...
}
// ... other methods (findByEmail, delete) using converters similarly ...
}
Using separate converters makes the mapping logic explicit, reusable, and independently testable, further enhancing separation of concerns, especially when mappings become non-trivial. For simple mappings, inline private methods within the mapper (like mapRowToUser
in the first example) are often sufficient.
Data Mapper vs. Active Record
It’s useful to contrast Data Mapper with another common persistence pattern, Active Record (popularized by frameworks like Ruby on Rails, and available in some ORMs like TypeORM).
- Active Record: Mixes domain logic and persistence logic within the same object. The object typically has methods like
.save()
,.delete()
,.find()
. (e.g.,user.save()
).- Pros: Simple for basic CRUD, less boilerplate initially.
- Cons: Violates SRP, tightly couples domain to persistence, harder to test domain logic in isolation, less flexible for complex mappings.
- Data Mapper: Separates domain logic (in domain objects) from persistence logic (in mapper objects).
- Pros: Better decoupling, adheres to SRP, improved testability, more flexible.
- Cons: More upfront boilerplate code (domain object + mapper interface + mapper implementation).
Conclusion
The Data Mapper pattern is a powerful tool for building maintainable, testable, and decoupled applications, particularly when dealing with complex domain models or when you anticipate changes in either your domain logic or your database schema over time. By acting as a dedicated intermediary, it enforces a clean separation between how your application thinks about data (domain objects) and how that data is stored (database). While it involves writing more code initially compared to patterns like Active Record, the long-term benefits in flexibility and maintainability often make it a worthwhile investment.