Skip to content

ORM hate in 2024

Published: at 02:30 PM

Translates:

ORM とは

ORM とは Object Relational Mapping を意味し、リレーショナルデータベース (実務上では DB クライアント) から取得したバイナリや文字列 をパースしてアプリケーション内で使用できるクラスインスタンスなどに詰め替えることです。

ORM が必要だった根本には RDB が正規化されたデータを扱うのに対して、アプリケーションが扱うデータがクラスとそのフィールド の集合、つまり非正規化されたデータであるという、情報の管理手法についてのミスマッチがあります。

ORM への期待と現実のギャップ

データベース固有の処理を完全に隠蔽してマッピングの作業を自動化することを ORM は期待されることが多いですが、 実際は開発の早い段階で固有のDBを意識する必要が多く、また ORM の作法に従うためにアプリケーションのコアロジック (ドメインモデル) が歪になってしまうか、歪になることを避けるために ORM に依存するクラスとドメインモデルを別々に書く必要が発生し、 結果的にマッパーを二重に実装しなければならなくなったりします。 こうなると ORM の学習コストがかかる上に冗長な変換レイヤーを書く必要も出てきてしまい、本末転倒です。

以下は SQLAlchemy でモデルを定義しています。 先入観なく見て欲しいのであえてコアドメインを実装する際にあまり選ばないであろう Python を採用しました。 ぱっと見ごちゃついています。 Base とはなんでしょう? Mapped 型はどのように初期化、 unwrap すれば良いのでしょうか?

ORM は退屈な変換処理を肩代わりしてくれる代わりに、オンボーディングコストを上げ、開発チームの人的流動性を下げます。 また言語やライブラリによってはドメインモデル上で記憶領域の情報を視覚的に強調することで、開発者の注意を問題領域から逸らします。

class Base(DeclarativeBase):
    pass

class User(Base):
    __tablename__ = "user_account"

    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(30))
    fullname: Mapped[Optional[str]]

    addresses: Mapped[List["Address"]] = relationship(
        back_populates="user", cascade="all, delete-orphan"
    )

class Address(Base):
    __tablename__ = "address"

    id: Mapped[int] = mapped_column(primary_key=True)
    email_address: Mapped[str]
    user_id: Mapped[int] = mapped_column(ForeignKey("user_account.id"))

    user: Mapped["User"] = relationship(back_populates="addresses")

Implement ORM by hand

現在はビルドインの SQL 関数が充実し SQL だけで直感的に非正規化されたデータを構築することが可能です。 ORM が代替えしていた処理はスクラッチでも以下のように簡潔に書くことができます (RDB には PostgreSQL を使用しています)。

from typing import Optional, NewType
from dataclasses import dataclass
import psycopg3
import jsonpickle

Address = NewType('Address', str)
UserId = NewType('UserId', int)

@dataclass
class User:
    id: UserId
    name: str
    fullname: Optional[str]
    addresses: list["Address"]

class UserRepository:
    def __init__(self, conn):
        self.conn = conn

    def userById(self, id: UserId) -> Optional[User]:
        with self.conn.cursor() as cur:
            cur.execute("""
                SELECT json_build_object(
                    'id', id,
                    'name', name,
                    'fullname', fullname,
                    'addresses', addresses
                ) AS user_json
                FROM users WHERE id = %s
            """, (id,))

            result = cur.fetchone()
            if result is None:
                return None

            user_json = result[0]
            user = jsonpickle.decode(user_json)
            return user

使用するのはプリミティブな DB アダプタとシリアライズライブラリのみです。 おそらくそれぞれのライブラリを知らない人でも、一目でなにをしているのかすぐにわかるのではないでしょうか。

データアクセス層は SQL さえ知っていれば簡単にコードリーディングができるため、新しいエンジニアでも即座に高い生産性を発揮できます。 オンボーディングコストが低いことは特にスタートアップなどで時間的制約が大きく人材が流動的な環境で有効です。 また SQL 自体はプログラミング言語に依存しないため、可搬性が高い点も利点です。 ドメインモデルとDBレイヤーがほとんど完全に分離され、チームでの分業もスムーズになります。

2024年現在では DDD やクリーンアーキテクチャが一般的な技術となっているため、ドメインロジックのほとんどは フレームワークに依存しないプレーンな構造となっていることが多いでしょう。 そのため言語に依存した複雑な ORM フレームワークを使っていなければ、一目でデータの取得ロジックが把握でき、 ドメインロジックと合わせてアプリケーションの言語の切り替えも開発者の誰もが確信を持って行うことができます。

さきほど「ドメインモデルとDBレイヤーがほとんど完全に分離され」と言いましたが、ほとんどのシリアライゼーションライブラリで シリアライズ対象のオブジェクトにアノテーションを付与する必要があったりフィールドが public でなければいけないなどの一定の制約があります。 しかしその制約は、特にマクロやアノテーションなどのプリプロセッサが提供されている言語では、問題になる水準ではありません。 以下は Rust でシリアライザブルなオブジェクトを定義している例です。単純なケースであれば derive(De)Serialize を指定するだけです。

use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize, Debug)]
struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let point = Point { x: 1, y: 2 };

    let serialized = serde_json::to_string(&point).unwrap();
    println!("serialized = {}", serialized);

    let deserialized: Point = serde_json::from_str(&serialized).unwrap();
    println!("deserialized = {:?}", deserialized);
}

// output:
// serialized = {"x":1,"y":2}
// deserialized = Point { x: 1, y: 2 }

以下は Kotlin の場合です。公式ライブラリとして提供されています。

import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.decodeFromString

@Serializable
data class Data(val a: Int, val b: String)

fun main() {
   val obj = Json.decodeFromString<Data>("""{"a":42, "b": "str"}""")
}

当然、例えば不変条件の保証など、シリアライゼーションの要件が複雑化すればうまくいかなくなってくることもありますが、 その臨界点は総じて ORM よりも高い位置にあります。

ORM を使うべき時

その言語にデファクトスタンダードの ORM があればそれを使うことも検討しても良いでしょう。 あるいは Ruby on Rails のように ORM に相当するレイヤーとフレームワークが完全にコンビネーションしている場合は もちろんそれを活用すべきです。 結局のところ ORM ライブラリを採用するかしないかはアプリケーションコードの品質というよりは オンボーディングコスト、可搬性、可読性などの外部環境に依るところが大きいです。

ただしマイクロサービスや軽量なフレームワークで ORM を使うことは、 お互いのメリットを潰し合ってしまうことになるため避けたほうがよいでしょう。

ユースケースにマッチしない ORM を使わなくてはならない時

時には何らかの理由でユースケースにマッチしない ORM を使わざる得ない場合があります。 例えば Rails で開発したアプリケーションが成長して Read モデルと Write モデルを分けて表現したくなった場合などです。 その場合は先ほど少し触れたように、 ORM がマッピングするオブジェクトと、ドメインレイヤーで使用するモデル をそれぞれ定義して、その間の変換処理を ORM 側のオブジェクトに定義することでビジネスロジックに ORM が影響するのを避けることができます。

議論していない考慮すべき事項