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