著者: Dmitriy · · 8 分で読めます

長年 dynamoose を使ってきた私たちが、独自の DynamoDB ORM を書いた理由

私たちは、多数のサービスからなる TypeScript のコードベースで、長年 dynamoose を使ってきました。Mongoose から借りたその API は優れたものです。テーブルごとに 1 つのモデル、キーによる get と create、$SET と $ADD を使う update、そして組み立てるリクエストのとおりに読めるクエリチェーンがあります。

置き換えを決めた理由は 2 つあります。1 つ目は CPU です。約 60 属性のモデルで、dynamoose 4.2.0 の create は私たちのベンチマークで約 3.9 ms の CPU を消費します。これは DynamoDB が通常応答にかける時間と同じか、それ以上です。

2 つ目は適合性です。私たちのモデルは Zod スキーマで、一部の呼び出しは ORM を迂回して生の AWS SDK を使っており、そこでモデルの型が失われていました。CPU コストをなくすには新しいコアが必要で、Zod スキーマを使うには別のスキーマ入力が必要でした。そこで、新しいライブラリを書きました。

dynotable-orm は dynamoose の呼び出しの形を保ちます。アイテム全体の書き込みは dynamoose 4.2.0 と同じバイトを保存するため、古いコードと新しいコードが 1 つのテーブルを共有できます。MIT ライセンスのオープンソースです。

ORM の CPU 時間がデータベースの応答時間に並んでいた

AWS は DynamoDB を 1 桁ミリ秒の応答時間のデータベースと説明しています。私たちのベンチマークはスタブクライアント上で CPU だけを測るため、ネットワーク時間は含みません。約 60 属性のモデルで、dynamoose 4.2.0 は create に約 3.9 ms、更新後のアイテムを返す update に約 12.7 ms の CPU を使います。

このコストは、dynamoose が実行時に行う処理から生じます。書き込みでも読み込みでも、すべてのアイテムをスキーマを通して属性ごとに変換し、読み込みでは返すすべての属性の型をチェックします。

この処理によって、dynamoose は柔軟なスキーマ言語と、コンパイラなしで動くチェックを提供しています。一方で、属性ごとに大きなコストがかかり、私たちの最大のモデルには多くの属性がありました。

私たちは、ORM のコストを手書きの AWS SDK コードとほぼ同じにしたいと考えました。そこで dynotable-orm は、モデルごとに最初の呼び出しで 1 回だけスキーマをコンパイルし、Date、デフォルト値、宣言されていないキーのためのコンバーターを作ります。

書き込みでは、スキーマ自身のバリデーターを実行し、その前後でこれらのコンバーターを実行します。読み込みではコンバーターだけを実行し、型チェックは行いません。誤った型で保存された値は、保存されたとおりに返されます。そのため読み込みでは、型はスキーマが期待するものを示しており、チェック済みの内容を示すものではありません。

2026-11-06 に、スタブクライアント上で CPU だけを測定しました。約 60 属性のスキーマでの create または update は、dynamoose 4.2.0 より 93 から 854 倍高速で、手書きの AWS SDK コードと同等かそれ以上の速さです。5 属性のスキーマでは、一部の呼び出しで手書きの SDK コードのほうが高速です。

すべての比較対象は書き込み時に検証を行います。dynamoose は独自のスキーマで、dynotable-orm と手書きのコードは同じ Zod スキーマで検証します。各数値は、交互に実行した 20 ラウンドの中央値です。

読み込みは異なる処理を比較しており、更新後のアイテムを返す update の一部も同様です。dynamoose 4.2.0 は読み込むすべてのアイテムの各属性の型をチェックし、dynotable-orm は読み込み時にチェックを行いません。表と測定方法はベンチマークのページにあります。

1 つの Zod スキーマが、2 つのスキーマとコンバーターを置き換えた

私たちのモデルは Zod スキーマでした。自作のコンバーターがそれぞれを dynamoose スキーマに変換し、Zod の拡張がキー、インデックス、Date の保存方法を引き継いでいました。

スキーマが 2 つあるということは、信頼できる情報源が 2 つあるということです。TypeScript の型は一方から、チェックされて保存される内容はもう一方から来ていました。

dynotable-orm は、モデルのスキーマを任意の Standard Schema として受け取ります。これは Zod、Valibot、ArkType が実装しているインターフェースです。Zod アダプターは、DynamoDB に関する情報をフィールド自体に置きます。、、インデックス、そして Date の保存方法です。

型も同じスキーマから得られます。キー、トップレベル属性から 4 レベル下までの更新パス、条件の値、インデックスが射影する属性はすべてこのスキーマから型付けされるため、パスの誤字や値の型の誤りはコンパイル時にエラーになります。

import {model} from 'dynotable-orm';
import {hashKey} from 'dynotable-orm/zod';
import {z} from 'zod';

const User = model(
  'User',
  z.object({
    id: hashKey(z.string()),
    plan: z.enum(['free', 'team']),
    address: z.object({city: z.string()})
  })
);

await User.update('u1', {$SET: {'address.city': 'Valencia'}});

// @ts-expect-error not a path of the item
await User.update('u1', {$SET: {'adress.city': 'Valencia'}});
// @ts-expect-error not a plan
await User.update('u1', {plan: 'enterprise'});

一部の呼び出しは ORM を迂回して生の SDK を使っていた

適合性の低さを最もはっきり示していたのは、私たち自身のコードでした。一部の呼び出しは ORM が提供していない機能を必要としたため、代わりに生の AWS SDK を使っていました:

  • 属性がまだ存在しない場合にだけ値を設定する、if_not_exists を使った更新
  • 保存された値がもうスキーマと一致しないアイテムの読み込み
  • キー変換が生成するものとは大文字小文字が異なるキーを持つ、古いアイテムの読み込み
  • 1 回の呼び出しだけで使う別のクライアント、または中止シグナル
  • トランザクションのエントリの条件を失敗させた、保存済みのアイテム

これらの呼び出しはどれもモデルの型を失い、それぞれが独自のマーシャリングコードを持っていました。私たちはそのすべてを API の一部にしました: ifNotExists()、{raw: true}、{rawKey: true}、呼び出しごとの client と abortSignal、そしてキャンセルされた各トランザクションエントリの失敗したアイテムをそのモデルで読み込んだものです。

import {ifNotExists, model} from 'dynotable-orm';
import {hashKey} from 'dynotable-orm/zod';
import {z} from 'zod';

const Account = model(
  'Account',
  z.object({id: hashKey(z.string()), balance: z.number(), createdAt: z.number().optional()})
);

const request = await Account.update(
  'a1',
  {$SET: {createdAt: ifNotExists(Date.now())}, $ADD: {balance: 10}},
  {return: 'request'}
);
console.log(request.UpdateExpression);
// ADD #a1 :v1 SET #a0 = if_not_exists(#a0, :v0)

新しいライブラリが守るべきものは 2 つだけだった

速度の一部は、dynamoose のユーザーが頼りにしている読み込み時の型チェックをなくしたことから来ています。ユーザーが書くスキーマも、dynamoose 独自のスキーマ言語から、Zod または他の Standard Schema に変わります。ユーザーにとってそれは別のライブラリです。そのため私たちは別のライブラリとして作り、私たちのコードとテーブルが依存していた 2 つのものだけを残しました。

1 つ目は呼び出しの形です。get、create、update、delete、query、scan、更新演算子、条件チェーンはすべて dynamoose と同じように動作します。移行時の変更の大部分はインポートとスキーマで、それは codemod が行います。

2 つ目は保存されるバイトです。アイテム全体の書き込みは、dynamoose 4.2.0 と同じアイテムを保存します。トップレベルの更新も同様ですが、移行ガイドが番号を付けて挙げている 3 つのエッジケースは例外です。combine 属性を持つモデルでそのソースを 1 つも書き込まない更新、1 回の呼び出しでのセットの $REMOVE とリストの $SET、そしてタイムスタンプを持つモデルで独自の updatedAt を渡す更新です。

等価性テストスイートは、すべてのプルリクエストで CI 上で実行されます。DynamoDB Local 上で両方のライブラリに同じ入力を与え、各呼び出しが返す値と保存された生のアイテムを比較し、各ライブラリに相手が書き込んだアイテムを読み込ませます。既知のリストにない差異があれば、スイートは失敗します。

保存されるアイテムが一致するため、サービスを 1 つずつ移行する間も、古いコードと新しいコードが 1 つのテーブルを共有できます。

すべての差異は一覧化されており、ロールアウト中に重要なのは 2 つ

移行ガイドは各差異に番号を付け、その理由を示しています。一部の dynamoose の機能は意図的に削除しました: item.save()、コールバック、populate、テーブル管理です。チェックもより厳格です: 書き込む内容のない更新は、何かが送信される前に例外をスローします。

古いコードと新しいコードが 1 つのテーブルを共有している間は、2 つの差異が重要です。1 つ目はネストされたパスの更新です。dynotable-orm は a.b をマップ a の内部に書き込みますが、dynamoose 4.2.0 は a.b という名前のトップレベル属性として書き込みます。

dynotable-orm は読み込み時にそのような属性を取り除くため、読み込みの後にアイテム全体を書き込むと、その属性は削除されます。移行の前に、名前にドットを含むトップレベル属性を探してください。すべてのサービスの移行が終わるまでは、ネストされたパスの更新は dynotable-orm からだけ行ってください。そのため、そのコードを最初に移行してください。

2 つ目は Date です。dynotable-orm は Date を、dynamoose のデフォルトと同じくエポックミリ秒で保存します。アイテムに ISO 文字列を保持しているコードベースでは、各モデルに defaultDateStorage: 'iso' を設定してください。そうしないと、新しい書き込みが古い文字列の隣に数値を置くことになります。

移行の最初のステップは次のとおりです:

npm install dynotable-orm @aws-sdk/client-dynamodb zod
npm install --save-dev dynotable-orm-codemod
npx dynotable-orm-codemod tsconfig.json --dry-run

どのライブラリの置き換えにも通用する 5 つのチェック

  • スタブクライアント上で CPU を測定する。 各呼び出しを、同じ処理を行う手書きの SDK コードと比較し、計測の前に両方が同じリクエストを送ることを確認してください。遅いライブラリは、遅いデータベースのように見えます。
  • ライブラリを迂回する呼び出しを数える。 その 1 つひとつが、ライブラリに欠けている機能を示しています。
  • 古い呼び出しの形を保つ。 そうすれば移行の大部分は機械的になり、codemod で行えます。
  • 保存されるバイトを保ち、それをテストする。 両方のライブラリに同じ入力を与え、保存された生のアイテムを比較し、各ライブラリに相手が書き込んだものを読み込ませてください。そうすればデータ移行は誰も行う必要がなく、ロールアウトを段階的に進められます。
  • 残すすべての差異を、その理由とともに一覧にする。 それらを一覧にした移行ガイドには、完全な互換性の主張よりも価値があります。

dynotable-orm は GitHub で公開しており、npm では dynotable-orm として入手できます。はじめにでは最初のモデルを設定します。現在 dynamoose を使っているなら、dynamoose の代替ページで変わらない点を、移行ガイドで変わる点を確認できます。

ロールアウトの前にテーブルに ISO 形式の日付文字列があるか確認するには、DynamoDB 用のデスクトップ GUI である DynoTable をダウンロードしてください。

Console なしで DynamoDB を扱う

DynamoDB では実行できない本物の SQL(JOINs、GROUP BY、集計)を実行する高速な DynamoDB デスクトップクライアント。ビジュアル編集と、あなた自身の Bedrock キーで動く AI エージェントを備えています。

30日間無料トライアル、クレジットカード不要 — その後は期限のない Free プラン。