Data Mapper Pattern

The Data Mapper pattern is a data access layer that performs bidirectional transfer of data between in-memory objects and a persistent data store (typically a relational database). The goal of this pattern is to keep the in-memory representation and the persistent data store independent of each other and the data mapper itself. Named by Martin Fowler in his 2003 book "Patterns of Enterprise Application Architecture," this pattern enables a design where domain objects don't need to know about the database's existence, requiring no SQL interface code or explicit knowledge of the database schema.

ORM PatternArchitectureData PersistenceDesign PatternDomain Driven Design

Library

Data Mapper Pattern

Overview

The Data Mapper pattern is a data access layer that performs bidirectional transfer of data between in-memory objects and a persistent data store (typically a relational database). The goal of this pattern is to keep the in-memory representation and the persistent data store independent of each other and the data mapper itself. Named by Martin Fowler in his 2003 book "Patterns of Enterprise Application Architecture," this pattern enables a design where domain objects don't need to know about the database's existence, requiring no SQL interface code or explicit knowledge of the database schema.

Details

The Data Mapper pattern 2025 continues to play a crucial role in modern software architecture. It has high compatibility with contemporary architectural patterns such as Domain-Driven Design (DDD), Clean Architecture, and Hexagonal Architecture, achieving complete separation between business logic and data persistence. It's implemented in major ORM frameworks like TypeORM, Hibernate, MyBatis, and SQLAlchemy. While more complex compared to the Active Record pattern, it excels in maintainability and scalability for large-scale systems.

Key Features

  • Complete Separation: Full independence between domain objects and database
  • Flexible Schema Support: High adaptability to legacy databases and complex schemas
  • Testability: Easy unit testing of business logic
  • Single Responsibility: Clear separation of data access responsibilities
  • Scalability: Excellent maintainability in large-scale systems

Pros and Cons

Pros

  • Clarification of business logic through purity of domain objects
  • Independent evolution of database schema and object model
  • Adaptability to legacy databases and complex schemas
  • High-speed testing by eliminating database dependencies during unit tests
  • Easy integration and abstraction of multiple data sources
  • Clear responsibility allocation in team development

Cons

  • Increased implementation complexity and code volume compared to Active Record
  • High learning curve, difficult for beginners to understand
  • Reduced development efficiency in small projects due to design complexity
  • Additional effort required for implementing and maintaining mapper classes
  • Complex performance tuning requiring specialized knowledge
  • Prone to inefficient N+1 problems due to design mistakes

Reference Pages

Code Examples

Basic Data Mapper Pattern Implementation

// TypeScript/Node.js implementation example

// Domain Entity (database-agnostic)
export class User {
    constructor(
        private _id: string | null,
        private _name: string,
        private _email: string,
        private _createdAt: Date
    ) {}

    get id(): string | null {
        return this._id;
    }

    get name(): string {
        return this._name;
    }

    get email(): string {
        return this._email;
    }

    get createdAt(): Date {
        return this._createdAt;
    }

    // Business logic
    updateEmail(newEmail: string): void {
        if (!this.isValidEmail(newEmail)) {
            throw new Error('Invalid email format');
        }
        this._email = newEmail;
    }

    private isValidEmail(email: string): boolean {
        return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
    }

    changeName(newName: string): void {
        if (newName.trim().length === 0) {
            throw new Error('Name cannot be empty');
        }
        this._name = newName.trim();
    }
}

// Database Record (persistence layer)
interface UserRecord {
    id: string;
    name: string;
    email: string;
    created_at: string;
}

// Data Mapper Interface
interface UserMapper {
    findById(id: string): Promise<User | null>;
    findByEmail(email: string): Promise<User | null>;
    findAll(): Promise<User[]>;
    save(user: User): Promise<User>;
    delete(id: string): Promise<void>;
}

// Concrete Data Mapper Implementation
export class DatabaseUserMapper implements UserMapper {
    constructor(private database: Database) {}

    async findById(id: string): Promise<User | null> {
        const query = 'SELECT * FROM users WHERE id = ?';
        const record = await this.database.queryOne<UserRecord>(query, [id]);
        
        if (!record) {
            return null;
        }

        return this.toDomainObject(record);
    }

    async findByEmail(email: string): Promise<User | null> {
        const query = 'SELECT * FROM users WHERE email = ?';
        const record = await this.database.queryOne<UserRecord>(query, [email]);
        
        if (!record) {
            return null;
        }

        return this.toDomainObject(record);
    }

    async findAll(): Promise<User[]> {
        const query = 'SELECT * FROM users ORDER BY created_at DESC';
        const records = await this.database.queryMany<UserRecord>(query);
        
        return records.map(record => this.toDomainObject(record));
    }

    async save(user: User): Promise<User> {
        if (user.id) {
            return await this.update(user);
        } else {
            return await this.insert(user);
        }
    }

    async delete(id: string): Promise<void> {
        const query = 'DELETE FROM users WHERE id = ?';
        await this.database.execute(query, [id]);
    }

    private async insert(user: User): Promise<User> {
        const id = this.generateId();
        const query = `
            INSERT INTO users (id, name, email, created_at) 
            VALUES (?, ?, ?, ?)
        `;
        
        await this.database.execute(query, [
            id,
            user.name,
            user.email,
            user.createdAt.toISOString()
        ]);

        return new User(id, user.name, user.email, user.createdAt);
    }

    private async update(user: User): Promise<User> {
        const query = `
            UPDATE users 
            SET name = ?, email = ? 
            WHERE id = ?
        `;
        
        await this.database.execute(query, [
            user.name,
            user.email,
            user.id
        ]);

        return user;
    }

    // Convert database record to domain object
    private toDomainObject(record: UserRecord): User {
        return new User(
            record.id,
            record.name,
            record.email,
            new Date(record.created_at)
        );
    }

    private generateId(): string {
        return require('crypto').randomUUID();
    }
}

// Service layer usage example
export class UserService {
    constructor(private userMapper: UserMapper) {}

    async registerUser(name: string, email: string): Promise<User> {
        // Duplicate check
        const existingUser = await this.userMapper.findByEmail(email);
        if (existingUser) {
            throw new Error('User already exists with this email');
        }

        // Create domain object
        const user = new User(null, name, email, new Date());
        
        // Persist
        return await this.userMapper.save(user);
    }

    async updateUserEmail(userId: string, newEmail: string): Promise<User> {
        const user = await this.userMapper.findById(userId);
        if (!user) {
            throw new Error('User not found');
        }

        // Execute business logic
        user.updateEmail(newEmail);
        
        // Persist
        return await this.userMapper.save(user);
    }

    async getUserProfile(userId: string): Promise<User | null> {
        return await this.userMapper.findById(userId);
    }
}

Complex Relationship Handling Data Mapper

// Post Entity
export class Post {
    constructor(
        private _id: string | null,
        private _title: string,
        private _content: string,
        private _authorId: string,
        private _createdAt: Date,
        private _author?: User
    ) {}

    get id(): string | null { return this._id; }
    get title(): string { return this._title; }
    get content(): string { return this._content; }
    get authorId(): string { return this._authorId; }
    get createdAt(): Date { return this._createdAt; }
    get author(): User | undefined { return this._author; }

    setAuthor(author: User): void {
        this._author = author;
    }

    updateContent(title: string, content: string): void {
        if (title.trim().length === 0) {
            throw new Error('Title cannot be empty');
        }
        this._title = title.trim();
        this._content = content.trim();
    }
}

// Post Database Record
interface PostRecord {
    id: string;
    title: string;
    content: string;
    author_id: string;
    created_at: string;
}

// Join Query Result
interface PostWithAuthorRecord extends PostRecord {
    author_name: string;
    author_email: string;
    author_created_at: string;
}

// Post Data Mapper
export class DatabasePostMapper {
    constructor(
        private database: Database,
        private userMapper: UserMapper
    ) {}

    async findById(id: string): Promise<Post | null> {
        const query = 'SELECT * FROM posts WHERE id = ?';
        const record = await this.database.queryOne<PostRecord>(query, [id]);
        
        if (!record) {
            return null;
        }

        return this.toDomainObject(record);
    }

    async findByAuthorId(authorId: string): Promise<Post[]> {
        const query = 'SELECT * FROM posts WHERE author_id = ? ORDER BY created_at DESC';
        const records = await this.database.queryMany<PostRecord>(query, [authorId]);
        
        return records.map(record => this.toDomainObject(record));
    }

    // Get posts with author information (join query)
    async findWithAuthor(id: string): Promise<Post | null> {
        const query = `
            SELECT 
                p.*,
                u.name as author_name,
                u.email as author_email,
                u.created_at as author_created_at
            FROM posts p
            INNER JOIN users u ON p.author_id = u.id
            WHERE p.id = ?
        `;
        
        const record = await this.database.queryOne<PostWithAuthorRecord>(query, [id]);
        
        if (!record) {
            return null;
        }

        return this.toDomainObjectWithAuthor(record);
    }

    async findAllWithAuthors(): Promise<Post[]> {
        const query = `
            SELECT 
                p.*,
                u.name as author_name,
                u.email as author_email,
                u.created_at as author_created_at
            FROM posts p
            INNER JOIN users u ON p.author_id = u.id
            ORDER BY p.created_at DESC
        `;
        
        const records = await this.database.queryMany<PostWithAuthorRecord>(query);
        
        return records.map(record => this.toDomainObjectWithAuthor(record));
    }

    async save(post: Post): Promise<Post> {
        if (post.id) {
            return await this.update(post);
        } else {
            return await this.insert(post);
        }
    }

    async delete(id: string): Promise<void> {
        const query = 'DELETE FROM posts WHERE id = ?';
        await this.database.execute(query, [id]);
    }

    private async insert(post: Post): Promise<Post> {
        const id = this.generateId();
        const query = `
            INSERT INTO posts (id, title, content, author_id, created_at) 
            VALUES (?, ?, ?, ?, ?)
        `;
        
        await this.database.execute(query, [
            id,
            post.title,
            post.content,
            post.authorId,
            post.createdAt.toISOString()
        ]);

        return new Post(id, post.title, post.content, post.authorId, post.createdAt, post.author);
    }

    private async update(post: Post): Promise<Post> {
        const query = `
            UPDATE posts 
            SET title = ?, content = ? 
            WHERE id = ?
        `;
        
        await this.database.execute(query, [
            post.title,
            post.content,
            post.id
        ]);

        return post;
    }

    private toDomainObject(record: PostRecord): Post {
        return new Post(
            record.id,
            record.title,
            record.content,
            record.author_id,
            new Date(record.created_at)
        );
    }

    private toDomainObjectWithAuthor(record: PostWithAuthorRecord): Post {
        const author = new User(
            record.author_id,
            record.author_name,
            record.author_email,
            new Date(record.author_created_at)
        );

        const post = new Post(
            record.id,
            record.title,
            record.content,
            record.author_id,
            new Date(record.created_at)
        );

        post.setAuthor(author);
        return post;
    }

    private generateId(): string {
        return require('crypto').randomUUID();
    }
}

// Blog Service Example
export class BlogService {
    constructor(
        private userMapper: UserMapper,
        private postMapper: DatabasePostMapper
    ) {}

    async createPost(authorId: string, title: string, content: string): Promise<Post> {
        // Verify author exists
        const author = await this.userMapper.findById(authorId);
        if (!author) {
            throw new Error('Author not found');
        }

        // Create post
        const post = new Post(null, title, content, authorId, new Date());
        post.setAuthor(author);
        
        return await this.postMapper.save(post);
    }

    async getBlogPost(postId: string): Promise<Post | null> {
        return await this.postMapper.findWithAuthor(postId);
    }

    async getUserPosts(userId: string): Promise<Post[]> {
        return await this.postMapper.findByAuthorId(userId);
    }

    async getAllPosts(): Promise<Post[]> {
        return await this.postMapper.findAllWithAuthors();
    }

    async updatePost(postId: string, title: string, content: string): Promise<Post> {
        const post = await this.postMapper.findById(postId);
        if (!post) {
            throw new Error('Post not found');
        }

        post.updateContent(title, content);
        return await this.postMapper.save(post);
    }
}

In-Memory Data Mapper for Testing

// In-Memory implementation for testing
export class InMemoryUserMapper implements UserMapper {
    private users: Map<string, User> = new Map();
    private idCounter = 1;

    async findById(id: string): Promise<User | null> {
        const user = this.users.get(id);
        return user ? this.cloneUser(user) : null;
    }

    async findByEmail(email: string): Promise<User | null> {
        for (const user of this.users.values()) {
            if (user.email === email) {
                return this.cloneUser(user);
            }
        }
        return null;
    }

    async findAll(): Promise<User[]> {
        return Array.from(this.users.values()).map(user => this.cloneUser(user));
    }

    async save(user: User): Promise<User> {
        if (!user.id) {
            const id = this.idCounter.toString();
            this.idCounter++;
            const newUser = new User(id, user.name, user.email, user.createdAt);
            this.users.set(id, newUser);
            return this.cloneUser(newUser);
        } else {
            this.users.set(user.id, this.cloneUser(user));
            return this.cloneUser(user);
        }
    }

    async delete(id: string): Promise<void> {
        this.users.delete(id);
    }

    clear(): void {
        this.users.clear();
        this.idCounter = 1;
    }

    private cloneUser(user: User): User {
        return new User(user.id, user.name, user.email, user.createdAt);
    }
}

// Unit test example
describe('UserService', () => {
    let userService: UserService;
    let userMapper: InMemoryUserMapper;

    beforeEach(() => {
        userMapper = new InMemoryUserMapper();
        userService = new UserService(userMapper);
    });

    test('should register new user', async () => {
        const user = await userService.registerUser('John Doe', '[email protected]');
        
        expect(user.id).toBeTruthy();
        expect(user.name).toBe('John Doe');
        expect(user.email).toBe('[email protected]');
    });

    test('should not register duplicate email', async () => {
        await userService.registerUser('John Doe', '[email protected]');
        
        await expect(
            userService.registerUser('Jane Doe', '[email protected]')
        ).rejects.toThrow('User already exists with this email');
    });

    test('should update user email', async () => {
        const user = await userService.registerUser('John Doe', '[email protected]');
        
        const updatedUser = await userService.updateUserEmail(user.id!, '[email protected]');
        
        expect(updatedUser.email).toBe('[email protected]');
    });

    test('should validate email format', async () => {
        const user = await userService.registerUser('John Doe', '[email protected]');
        
        await expect(
            userService.updateUserEmail(user.id!, 'invalid-email')
        ).rejects.toThrow('Invalid email format');
    });
});

Transaction-Aware Data Mapper

// Transaction management
export interface Transaction {
    commit(): Promise<void>;
    rollback(): Promise<void>;
}

export interface UnitOfWork {
    registerNew(entity: any): void;
    registerDirty(entity: any): void;
    registerDeleted(entity: any): void;
    commit(): Promise<void>;
}

// Transaction-aware Data Mapper
export class TransactionalUserMapper implements UserMapper {
    constructor(
        private database: Database,
        private transaction?: Transaction
    ) {}

    async findById(id: string): Promise<User | null> {
        const query = 'SELECT * FROM users WHERE id = ?';
        const record = await this.database.queryOne<UserRecord>(
            query, 
            [id], 
            this.transaction
        );
        
        if (!record) {
            return null;
        }

        return this.toDomainObject(record);
    }

    async save(user: User): Promise<User> {
        if (user.id) {
            return await this.update(user);
        } else {
            return await this.insert(user);
        }
    }

    async delete(id: string): Promise<void> {
        const query = 'DELETE FROM users WHERE id = ?';
        await this.database.execute(query, [id], this.transaction);
    }

    // Other methods omitted

    private toDomainObject(record: UserRecord): User {
        return new User(
            record.id,
            record.name,
            record.email,
            new Date(record.created_at)
        );
    }
}

// Transaction usage example
export class TransactionalBlogService {
    constructor(
        private database: Database,
        private userMapperFactory: (tx?: Transaction) => UserMapper,
        private postMapperFactory: (tx?: Transaction) => DatabasePostMapper
    ) {}

    async createUserAndFirstPost(
        name: string, 
        email: string, 
        postTitle: string, 
        postContent: string
    ): Promise<{ user: User; post: Post }> {
        
        const transaction = await this.database.beginTransaction();
        
        try {
            const userMapper = this.userMapperFactory(transaction);
            const postMapper = this.postMapperFactory(transaction);

            // Create user
            const user = new User(null, name, email, new Date());
            const savedUser = await userMapper.save(user);

            // Create post
            const post = new Post(null, postTitle, postContent, savedUser.id!, new Date());
            post.setAuthor(savedUser);
            const savedPost = await postMapper.save(post);

            await transaction.commit();
            
            return { user: savedUser, post: savedPost };
            
        } catch (error) {
            await transaction.rollback();
            throw error;
        }
    }
}