はじめに
ORMの設計パターンとして、Active RecordパターンとData Mapperパターンの2種類が存在します。この違いについてまとめます。
まずORMとは?
オブジェクト指向プログラミング言語とRDBの間でデータをマッピングし、データベース操作を抽象化する技術です。SQLを直接記述することなく、プログラム上のオブジェクトを使ってデータを操作できるようにするものです。
Node.jsではprismaやTypeORM代表的なORMライブラリとして利用されています。
ORMはなぜ必要なのか?
オブジェクト関係マッピング#背景を基に説明します。
オブジェクト指向プログラミングでは、データ管理タスクは一般に単純なスカラー値ではないオブジェクトを操作するよう実装されます。
一方、データベースでは格納し操作できるのはスカラー値(整数、文字列)だけであり、スカラー値による表を形成している。
データベースから取得した値をオブジェクトモデルに変換する工程が必要になります。
具体的なモデルで説明します。
ユーザー情報を定義する場合、以下のようにユーザーは電話番号を持っていて、電話番号はそれぞれ異なる種類を持つような構成を考えます。
export enum PhoneType {
HOME = 'home',
WORK = 'work',
}
export class PhoneNumber {
id: number;
number: string;
type: PhoneType;
}
export class User {
id: number;
firstName: string;
lastName: string;
numbers: PhoneNumber[];
constructor(
id: number,
firstName: string,
lastName: string,
numbers: PhoneNumber[]
) {
this.id = id;
this.firstName = firstName;
this.lastName = lastName;
this.numbers = numbers;
}
}
データベースの構成例としては以下となります。
id | firstName | lastName |
---|---|---|
1 | Timber | Saw |
id | number | type | userid |
---|---|---|---|
1 | 123-456-7890 | home | 1 |
2 | 987-654-3210 | work | 1 |
このように、オブジェクト指向では関連する情報をネストした構造で管理しますが、RDBではデータを正規化し、テーブル間の関連付けをして管理します。
この違いを吸収し、オブジェクトとデータベース間のやり取りをスムーズにするためORMが必要になります。
Active Recordパターン
Active Recordは、データベース操作をモデルの中に組み込む考え方です。
TypeORMではエンティティクラスにBaseEntity
を継承することで使用することができます。
BaseEntityにはsaveやremoveなどの共通操作メソッドが用意されています。
@Entity()
export class User extends BaseEntity {
@PrimaryGeneratedColumn()
id: number;
@Column()
firstName: string;
@Column()
lastName: string;
static findByName(firstName: string, lastName: string) {
return this.createQueryBuilder('user')
.where('user.firstName = :firstName', { firstName })
.andWhere('user.lastName = :lastName', { lastName })
.getMany();
}
}
メリット:
CRUD操作をシンプルに行えるため、単純なアプリケーションを開発するのに役立つ。
デメリット:
エンティティクラスの中でDB操作を行うため、ビジネスロジックが混在する。
モック化が難しくDBが必要になるため、テストしづらくなる。
@Entity()
export class User extends BaseEntity {
@PrimaryGeneratedColumn()
id: number;
@Column()
firstName: string;
@Column()
lastName: string;
static findByName(firstName: string, lastName: string) {
return this.createQueryBuilder('user')
.where('user.firstName = :firstName', { firstName })
.andWhere('user.lastName = :lastName', { lastName })
.getMany();
}
// 推奨される電話番号を取得
static async getPreferredPhoneNumber(
userId: number
): Promise<PhoneNumber | null> {
const preferredPhone = await PhoneNumber.findOneBy({
userId: userId,
type: PhoneType.HOME,
});
return preferredPhone || null;
}
// 自宅電話番号を取得
static async getHomePhoneNumber(
userId: number
): Promise<PhoneNumber | null> {
return await PhoneNumber.findOneBy({
userId: userId,
type: PhoneType.HOME,
});
}
}
Data Mapper パターン
Data Mapperパターンでは、エンティティクラスはDB操作のロジックを持ちません。
DB操作は専用のマッパークラス(TypeORMのRepository)を用いて行い、ビジネスロジックは別途サービスクラスを用いて実施します。
// TypeORMのエンティティ
@Entity()
export class UserEntity {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
}
// ドメインのエンティティ
export class User {
constructor(
public readonly id: number,
public readonly firstName: string,
public readonly lastName: string,
public readonly phoneNumbers: PhoneNumber[]
) {}
}
//リポジトリがデータベースアクセスを担当し、エンティティと分離。
export class UserConverter {
static toDomain(entity: UserEntity): User {
return new User(
entity.id,
entity.firstName,
entity.lastName,
entity.phoneNumbers
);
}
}
//リポジトリがデータベースアクセスを担当し、エンティティと分離。
class UserRepository {
constructor(private readonly repository: Repository<UserEntity>) {}
async findAll(): Promise<Array<User> | null> {
const entities = await this.repository.find();
return entities.map((entity) => UserConverter.toDomain(entity));
}
}
// サービスクラスでビジネスロジックを組み立てる役割を担う
class UserService {
constructor(private readonly userRepository: UserRepository) {}
async getUsers(): Promise<Array<User> | null> {
return await this.userRepository.findAll();
}
async getUser(id: number): Promise<User | null> {
const user = await this.userRepository.findById(id);
return user;
}
}
メリット:
ドメインロジックとデータアクセスを明確に分けることができる。
デメリット:
実装がActive Recordに比べて多く学習コストが増える。
CRUD操作だけが求められるようなシンプルなプロジェクトの場合、分離されたロジックが冗長となる。
参考