Data Mapper Pattern

Data Mapperパターンは、メモリ内のオブジェクトと永続化データストア(通常はリレーショナルデータベース)の間で双方向のデータ転送を行うデータアクセス層パターンです。このパターンの目的は、メモリ表現と永続化データストアを互いに独立に保ち、データマッパー自体も独立させることです。Martin Fowlerが2003年の著書「Patterns of Enterprise Application Architecture」で命名し、ドメインオブジェクトがデータベースの存在を知る必要がなく、SQLインターフェースコードや明確なデータベーススキーマの知識を必要としない設計を可能にします。

ORMパターンアーキテクチャデータ永続化設計パターンドメイン駆動設計

ライブラリ

Data Mapper Pattern

概要

Data Mapperパターンは、メモリ内のオブジェクトと永続化データストア(通常はリレーショナルデータベース)の間で双方向のデータ転送を行うデータアクセス層パターンです。このパターンの目的は、メモリ表現と永続化データストアを互いに独立に保ち、データマッパー自体も独立させることです。Martin Fowlerが2003年の著書「Patterns of Enterprise Application Architecture」で命名し、ドメインオブジェクトがデータベースの存在を知る必要がなく、SQLインターフェースコードや明確なデータベーススキーマの知識を必要としない設計を可能にします。

詳細

Data Mapperパターン 2025年版は、現代のソフトウェアアーキテクチャにおいて重要な役割を果たし続けています。ドメイン駆動設計(DDD)、クリーンアーキテクチャ、ヘキサゴナルアーキテクチャなどの現代的なアーキテクチャパターンとの親和性が高く、ビジネスロジックとデータ永続化の完全な分離を実現します。TypeORM、Hibernate、MyBatis、SQLAlchemy等の主要ORMフレームワークで実装されており、Active Recordパターンと比較してより複雑ですが、大規模システムでの保守性と拡張性に優れています。

主な特徴

  • 完全な分離: ドメインオブジェクトとデータベースの完全な独立性
  • 柔軟なスキーマ対応: レガシーデータベースや複雑なスキーマに対する高い適応性
  • テスタビリティ: ビジネスロジックの単体テストが容易
  • 責任の単一性: データアクセス責任の明確な分離
  • スケーラビリティ: 大規模システムでの優れた保守性

メリット・デメリット

メリット

  • ドメインオブジェクトの純粋性によるビジネスロジックの明確化
  • データベーススキーマとオブジェクトモデルの独立した進化が可能
  • レガシーデータベースや複雑なスキーマへの対応力
  • 単体テスト実行時のデータベース依存関係排除による高速化
  • 複数データソースの統合や抽象化が容易
  • チーム開発での責任分担の明確化

デメリト

  • Active Recordと比較して実装の複雑性とコード量の増加
  • 学習コストが高く、初心者には理解が困難
  • 小規模プロジェクトでは設計の複雑化により開発効率低下
  • マッパークラスの実装と保守に追加工数が必要
  • パフォーマンスチューニングが複雑で専門知識が必要
  • 設計ミスによる非効率なN+1問題等が発生しやすい

参考ページ

書き方の例

基本的なData Mapperパターン実装

// TypeScript/Node.js実装例

// ドメインエンティティ(データベースを知らない)
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;
    }

    // ビジネスロジック
    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();
    }
}

// データベースレコード(永続化層)
interface UserRecord {
    id: string;
    name: string;
    email: string;
    created_at: string;
}

// Data Mapperインターフェース
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>;
}

// 具体的なData Mapper実装
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;
    }

    // データベースレコードからドメインオブジェクトへの変換
    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();
    }
}

// サービス層での使用例
export class UserService {
    constructor(private userMapper: UserMapper) {}

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

        // ドメインオブジェクト作成
        const user = new User(null, name, email, new Date());
        
        // 永続化
        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');
        }

        // ビジネスロジック実行
        user.updateEmail(newEmail);
        
        // 永続化
        return await this.userMapper.save(user);
    }

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

複雑なリレーションシップ対応のData Mapper

// 投稿エンティティ
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();
    }
}

// 投稿データベースレコード
interface PostRecord {
    id: string;
    title: string;
    content: string;
    author_id: string;
    created_at: string;
}

// 結合クエリ結果
interface PostWithAuthorRecord extends PostRecord {
    author_name: string;
    author_email: string;
    author_created_at: string;
}

// 投稿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));
    }

    // 著者情報を含む投稿取得(結合クエリ)
    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();
    }
}

// ブログサービス例
export class BlogService {
    constructor(
        private userMapper: UserMapper,
        private postMapper: DatabasePostMapper
    ) {}

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

        // 投稿作成
        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

// テスト用In-Memory実装
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);
    }
}

// 単体テスト例
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('田中太郎', '[email protected]');
        
        expect(user.id).toBeTruthy();
        expect(user.name).toBe('田中太郎');
        expect(user.email).toBe('[email protected]');
    });

    test('should not register duplicate email', async () => {
        await userService.registerUser('田中太郎', '[email protected]');
        
        await expect(
            userService.registerUser('田中次郎', '[email protected]')
        ).rejects.toThrow('User already exists with this email');
    });

    test('should update user email', async () => {
        const user = await userService.registerUser('田中太郎', '[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('田中太郎', '[email protected]');
        
        await expect(
            userService.updateUserEmail(user.id!, 'invalid-email')
        ).rejects.toThrow('Invalid email format');
    });
});

トランザクション対応のData Mapper

// トランザクション管理
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>;
}

// トランザクション対応の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);
    }

    // その他のメソッドは省略

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

// トランザクション使用例
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);

            // ユーザー作成
            const user = new User(null, name, email, new Date());
            const savedUser = await userMapper.save(user);

            // 投稿作成
            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;
        }
    }
}