MikroORM
MikroORMは「TypeScriptで構築されたNode.js向けの最新ORM」として開発された、Data Mapper、Unit of Work、Identity Mapパターンに基づく高性能ORM。MongoDB、MySQL、MariaDB、PostgreSQL、SQLiteなど幅広いデータベースをサポートし、厳密な型安全性とテスタビリティを重視した設計。自動マイグレーション、エンティティジェネレーター、クエリビルダーなど豊富な機能を提供し、エンタープライズ開発に最適化されています。
GitHub概要
mikro-orm/mikro-orm
TypeScript ORM for Node.js based on Data Mapper, Unit of Work and Identity Map patterns. Supports MongoDB, MySQL, MariaDB, MS SQL Server, PostgreSQL and SQLite/libSQL databases.
トピックス
スター履歴
ライブラリ
MikroORM
概要
MikroORMは「TypeScriptで構築されたNode.js向けの最新ORM」として開発された、Data Mapper、Unit of Work、Identity Mapパターンに基づく高性能ORM。MongoDB、MySQL、MariaDB、PostgreSQL、SQLiteなど幅広いデータベースをサポートし、厳密な型安全性とテスタビリティを重視した設計。自動マイグレーション、エンティティジェネレーター、クエリビルダーなど豊富な機能を提供し、エンタープライズ開発に最適化されています。
詳細
MikroORM 2025年版は現代的なTypeScript開発のベストプラクティスを体現したORM実装として注目されています。Data Mapperパターンの採用により、ドメインロジックとデータアクセス層の明確な分離を実現し、保守性と拡張性に優れたアーキテクチャを構築可能。デコレーターベースのエンティティ定義、リポジトリパターンによるデータアクセス抽象化、Identity Mapによる効率的なオブジェクト管理などの特徴により、大規模アプリケーション開発に適しています。TypeScript完全対応、充実したテストサポート、豊富なリレーションシップ管理機能を提供します。
主な特徴
- Data Mapperパターン: ドメインロジックとデータアクセス層の完全分離
- TypeScript完全対応: 厳密な型安全性とIDEサポート
- マルチデータベース対応: MongoDB、MySQL、PostgreSQL、SQLite等
- リポジトリパターン: 統一されたデータアクセスインターフェース
- 自動マイグレーション: スキーマ変更の自動管理
- 高パフォーマンス: Identity MapとUnit of Workによる最適化
メリット・デメリット
メリット
- TypeScript完全対応による優れた開発体験と型安全性
- Data Mapperパターンによる疎結合で保守性の高いアーキテクチャ
- 包括的なテストサポートと高いテスタビリティ
- 豊富なリレーションシップ管理とクエリ最適化機能
- 自動マイグレーションと強力なスキーマ管理
- 活発なコミュニティとドキュメントの充実
デメリット
- Active Recordパターンに慣れた開発者には学習コストが高い
- 小規模プロジェクトには過度に複雑になる可能性
- パフォーマンスチューニングには高度な知識が必要
- MongoDB使用時はリレーショナルORM機能が一部制限
- 他のTypeScript ORMと比較して設定項目が多い
- デバッグ時にData Mapperの抽象化により問題箇所の特定が困難
参考ページ
書き方の例
インストールと基本セットアップ
# Core パッケージのインストール
npm install @mikro-orm/core @mikro-orm/cli
# データベース固有ドライバーのインストール
# PostgreSQL
npm install @mikro-orm/postgresql
# MySQL
npm install @mikro-orm/mysql
# SQLite
npm install @mikro-orm/sqlite
# MongoDB
npm install @mikro-orm/mongodb
# リフレクションメタデータサポート
npm install reflect-metadata
エンティティ定義と基本操作
import { Entity, PrimaryKey, Property, ManyToOne, OneToMany, Collection } from '@mikro-orm/core';
@Entity()
export class User {
@PrimaryKey()
id!: number;
@Property()
name!: string;
@Property({ unique: true })
email!: string;
@Property()
createdAt: Date = new Date();
@Property({ onUpdate: () => new Date() })
updatedAt: Date = new Date();
@OneToMany(() => Article, article => article.author)
articles = new Collection<Article>(this);
}
@Entity()
export class Article {
@PrimaryKey()
id!: number;
@Property()
title!: string;
@Property({ type: 'text' })
content!: string;
@Property()
publishedAt?: Date;
@ManyToOne(() => User)
author!: User;
}
// MikroORM設定
import { MikroORM } from '@mikro-orm/core';
import { PostgreSqlDriver } from '@mikro-orm/postgresql';
const orm = await MikroORM.init({
entities: [User, Article],
driver: PostgreSqlDriver,
dbName: 'my_database',
user: 'username',
password: 'password',
host: 'localhost',
port: 5432,
debug: true,
});
// 基本的なCRUD操作
const em = orm.em.fork();
// ユーザー作成
const user = new User();
user.name = '田中太郎';
user.email = '[email protected]';
await em.persist(user).flush();
// ユーザー検索
const users = await em.find(User, { name: '田中太郎' });
const userWithArticles = await em.findOneOrFail(User, 1, {
populate: ['articles']
});
リポジトリパターンとサービス層
import { EntityRepository } from '@mikro-orm/core';
import { InjectRepository } from '@mikro-orm/nestjs';
// カスタムリポジトリ
export class UserRepository extends EntityRepository<User> {
async findByEmail(email: string): Promise<User | null> {
return this.findOne({ email });
}
async findActiveUsers(): Promise<User[]> {
return this.find({
createdAt: { $gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) }
});
}
async getUserWithRecentArticles(userId: number): Promise<User | null> {
return this.findOne(userId, {
populate: {
articles: {
where: {
publishedAt: { $gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) }
},
orderBy: { publishedAt: 'DESC' }
}
}
});
}
}
// サービス層でのリポジトリ使用
import { Injectable } from '@nestjs/common';
@Injectable()
export class UserService {
constructor(
@InjectRepository(User)
private readonly userRepository: UserRepository,
@InjectRepository(Article)
private readonly articleRepository: EntityRepository<Article>
) {}
async createUser(userData: { name: string; email: string }): Promise<User> {
// 重複チェック
const existingUser = await this.userRepository.findByEmail(userData.email);
if (existingUser) {
throw new Error('ユーザーは既に存在します');
}
const user = new User();
user.name = userData.name;
user.email = userData.email;
await this.userRepository.getEntityManager().persist(user).flush();
return user;
}
async getUserDashboard(userId: number) {
const user = await this.userRepository.getUserWithRecentArticles(userId);
if (!user) {
throw new Error('ユーザーが見つかりません');
}
return {
user: {
id: user.id,
name: user.name,
email: user.email
},
recentArticles: user.articles.getItems().map(article => ({
id: article.id,
title: article.title,
publishedAt: article.publishedAt
}))
};
}
}
高度なクエリとトランザクション管理
import { QueryBuilder, LoadStrategy } from '@mikro-orm/core';
// 複雑なクエリビルダー使用例
class AdvancedUserService {
constructor(private em: EntityManager) {}
async getPopularAuthors(limit: number = 10) {
const qb = this.em.createQueryBuilder(User, 'u')
.select(['u.*', 'COUNT(a.id) as article_count'])
.leftJoin('u.articles', 'a')
.where('a.publishedAt > ?', [new Date(Date.now() - 90 * 24 * 60 * 60 * 1000)])
.groupBy('u.id')
.orderBy({ article_count: 'DESC' })
.limit(limit);
return await qb.getResult();
}
async searchArticlesWithFacets(searchTerm: string, authorId?: number) {
const qb = this.em.createQueryBuilder(Article, 'a')
.select('a.*')
.leftJoinAndSelect('a.author', 'author')
.where('a.title ILIKE ? OR a.content ILIKE ?',
[`%${searchTerm}%`, `%${searchTerm}%`]);
if (authorId) {
qb.andWhere('a.author = ?', [authorId]);
}
return await qb.getResult();
}
// トランザクション管理
async transferArticleOwnership(fromUserId: number, toUserId: number, articleIds: number[]) {
await this.em.transactional(async (em) => {
// 送信者と受信者の検証
const fromUser = await em.findOneOrFail(User, fromUserId);
const toUser = await em.findOneOrFail(User, toUserId);
// 記事の所有権確認と移転
const articles = await em.find(Article, {
id: { $in: articleIds },
author: fromUser
});
if (articles.length !== articleIds.length) {
throw new Error('指定された記事の一部が見つからないか、権限がありません');
}
// 所有権移転
articles.forEach(article => {
article.author = toUser;
});
// ログ記録(監査ログ)
console.log(`記事所有権移転: ${articleIds.length}件 ${fromUserId} -> ${toUserId}`);
});
}
// バッチ操作
async bulkUpdateArticleStatus(authorId: number, published: boolean) {
const result = await this.em.nativeUpdate(Article,
{ author: authorId },
{ publishedAt: published ? new Date() : null }
);
return { affected: result };
}
}
マイグレーションとスキーマ管理
// mikro-orm.config.ts
import { defineConfig } from '@mikro-orm/core';
import { PostgreSqlDriver } from '@mikro-orm/postgresql';
import { Migrator } from '@mikro-orm/migrations';
import { EntityGenerator } from '@mikro-orm/entity-generator';
export default defineConfig({
entities: ['./src/entities/**/*.ts'],
entitiesTs: ['./src/entities/**/*.ts'],
driver: PostgreSqlDriver,
dbName: 'my_database',
migrations: {
path: './src/migrations',
pattern: /^[\w-]+\d+\.[jt]s$/,
transactional: true,
allOrNothing: true,
},
extensions: [Migrator, EntityGenerator],
debug: process.env.NODE_ENV !== 'production',
});
// CLIコマンド例
// マイグレーション作成
// npx mikro-orm migration:create
// マイグレーション実行
// npx mikro-orm migration:up
// スキーマ更新
// npx mikro-orm schema:update --run
// エンティティ生成(既存DBから)
// npx mikro-orm generate-entities
テスト環境での利用
import { MikroORM } from '@mikro-orm/core';
import { SqliteDriver } from '@mikro-orm/sqlite';
// テスト用設定
export async function createTestORM() {
const orm = await MikroORM.init({
entities: [User, Article],
driver: SqliteDriver,
dbName: ':memory:', // インメモリDB
allowGlobalContext: true,
});
await orm.schema.createSchema();
return orm;
}
// JestテストでのORM使用例
describe('UserService', () => {
let orm: MikroORM;
let userService: UserService;
beforeAll(async () => {
orm = await createTestORM();
userService = new UserService(orm.em.getRepository(User), orm.em.getRepository(Article));
});
beforeEach(async () => {
await orm.schema.clearDatabase();
});
afterAll(async () => {
await orm.close();
});
test('should create user successfully', async () => {
const userData = { name: 'テストユーザー', email: '[email protected]' };
const user = await userService.createUser(userData);
expect(user.id).toBeDefined();
expect(user.name).toBe(userData.name);
expect(user.email).toBe(userData.email);
});
test('should throw error for duplicate email', async () => {
const userData = { name: 'ユーザー1', email: '[email protected]' };
await userService.createUser(userData);
await expect(userService.createUser(userData))
.rejects.toThrow('ユーザーは既に存在します');
});
});
パフォーマンス最適化とベストプラクティス
// N+1問題の回避
class OptimizedQueries {
constructor(private em: EntityManager) {}
// 悪い例: N+1問題が発生
async getBadUserArticles() {
const users = await this.em.find(User, {});
const results = [];
for (const user of users) {
const articles = await this.em.find(Article, { author: user }); // N+1!
results.push({ user, articles });
}
return results;
}
// 良い例: 適切なpopulateを使用
async getGoodUserArticles() {
return await this.em.find(User, {}, {
populate: ['articles'],
strategy: LoadStrategy.JOINED // JOINクエリを使用
});
}
// バッチローディング
async getUsersWithArticleCount() {
const qb = this.em.createQueryBuilder(User, 'u')
.select(['u.*', 'COUNT(a.id) as article_count'])
.leftJoin('u.articles', 'a')
.groupBy('u.id');
return await qb.getResultAndCount();
}
// 部分的ローディング
async getUserSummaries() {
return await this.em.find(User, {}, {
fields: ['id', 'name', 'email'], // 必要なフィールドのみ
});
}
// キャッシング戦略
private cache = new Map<string, any>();
async getCachedPopularArticles(ttl: number = 300000) { // 5分
const cacheKey = 'popular_articles';
const cached = this.cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < ttl) {
return cached.data;
}
const data = await this.em.find(Article,
{ publishedAt: { $ne: null } },
{
orderBy: { publishedAt: 'DESC' },
limit: 20,
populate: ['author']
}
);
this.cache.set(cacheKey, { data, timestamp: Date.now() });
return data;
}
}