この記事はうぐいすソリューションズ Advent Calendar 2025の8日目の記事です。
自己紹介
初めまして、Youです。開発者歴は5年ほど。
最近はNext.jsとかNestJSばかり触ってますが、好きなのはJava(Spring)とDDDによる開発です。
ネカマ。
はじめに
「This is a "Basic" skill.」
タイトルにもなっているこの言葉は、とあるプロジェクトの引継ぎとして参加した際、私が実際に言われた煽りアドバイスです。
ニフ○ムとかザ○キを唱えたい気持ちはグッと飲み込みました。
この言葉は、マイグレーションファイルの取り扱いに疑問を抱いた私の質問への回答だったものですが、思い返してみるとそれ以外にも納得できない件がいくつかありました。
年の瀬ということで今年の振り返りをしていたのですが、この件は読者諸兄の共感や意見も気になるところなので筆を執ってアドベントカレンダーの1記事として投稿し、開発し辛かった事例として取り上げたいと思います。
お品書きは以下の通り
- 人力でいじるよマイグレーションファイル
- 理解するのが辛かったER
- わざわざデータ取得大変な書き方にしてない?
- 修正チョロですねフフン~そんなわけなかった影響範囲~
以下事例
人力でいじるよマイグレーションファイル
マイグレーションファイルといえばそう、Railsのような何かしらフレームワークを使っている人や、各種ORMを使っている人にはお馴染みの、
- テーブルの構造を記述したファイルから作成され、
- その内容をSQLとして記述し、
- バージョン管理する
ためのファイルですね。
例えばcompanyというテーブルに対してCompanyクラスに対して修正を加え、typeORMでマイグレーションファイルの生成をすると以下のような出力を得るでしょう。
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddCommentsToCompanyTable1234567890123 implements MigrationInterface {
name = 'AddCommentsToCompanyTable1234567890123'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`COMMENT ON COLUMN "company"."id" IS '会社ID'`);
await queryRunner.query(`COMMENT ON COLUMN "company"."name" IS '会社名'`);
await queryRunner.query(`COMMENT ON COLUMN "company"."address" IS '住所'`);
await queryRunner.query(`COMMENT ON COLUMN "company"."phone" IS '電話番号'`);
await queryRunner.query(`COMMENT ON COLUMN "company"."created_at" IS '作成日時'`);
await queryRunner.query(`COMMENT ON COLUMN "company"."updated_at" IS '更新日時'`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`COMMENT ON COLUMN "company"."id" IS NULL`);
await queryRunner.query(`COMMENT ON COLUMN "company"."name" IS NULL`);
await queryRunner.query(`COMMENT ON COLUMN "company"."address" IS NULL`);
await queryRunner.query(`COMMENT ON COLUMN "company"."phone" IS NULL`);
await queryRunner.query(`COMMENT ON COLUMN "company"."created_at" IS NULL`);
await queryRunner.query(`COMMENT ON COLUMN "company"."updated_at" IS NULL`);
}
}
ところが実際に出力されたのは以下のようなものでした。
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddCommentsToCompanyAndOtherChanges1234567890123 implements MigrationInterface {
name = 'AddCommentsToCompanyAndOtherChanges1234567890123'
public async up(queryRunner: QueryRunner): Promise<void> {
// companyテーブルへのコメント追加
await queryRunner.query(`COMMENT ON COLUMN "company"."id" IS '会社ID'`);
await queryRunner.query(`COMMENT ON COLUMN "company"."name" IS '会社名'`);
await queryRunner.query(`COMMENT ON COLUMN "company"."address" IS '住所'`);
await queryRunner.query(`COMMENT ON COLUMN "company"."phone" IS '電話番号'`);
await queryRunner.query(`COMMENT ON COLUMN "company"."email" IS 'メールアドレス'`);
await queryRunner.query(`COMMENT ON COLUMN "company"."created_at" IS '作成日時'`);
await queryRunner.query(`COMMENT ON COLUMN "company"."updated_at" IS '更新日時'`);
// hogeテーブルにカラム追加
await queryRunner.query(`ALTER TABLE "hoge" ADD "status" integer NOT NULL DEFAULT 0`);
// hogeテーブルにインデックス追加
await queryRunner.query(`CREATE INDEX "IDX_hoge_company_id" ON "hoge" ("company_id")`);
// fugaテーブルのカラム型変更
await queryRunner.query(`ALTER TABLE "fuga" ALTER COLUMN "price" TYPE numeric(10,2)`);
// fugaテーブルにNOT NULL制約追加
await queryRunner.query(`ALTER TABLE "fuga" ALTER COLUMN "description" SET NOT NULL`);
// fooテーブルにカラム追加
await queryRunner.query(`ALTER TABLE "foo" ADD "priority" integer NOT NULL DEFAULT 0`);
await queryRunner.query(`ALTER TABLE "foo" ADD "deleted_at" TIMESTAMP`);
// barテーブルにユニーク制約追加
await queryRunner.query(`ALTER TABLE "bar" ADD CONSTRAINT "UQ_bar_code" UNIQUE ("code")`);
// barテーブルに外部キー追加
await queryRunner.query(`ALTER TABLE "bar" ADD CONSTRAINT "FK_bar_foo_id" FOREIGN KEY ("foo_id") REFERENCES "foo"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// barテーブルの外部キー削除
await queryRunner.query(`ALTER TABLE "bar" DROP CONSTRAINT "FK_bar_foo_id"`);
// barテーブルのユニーク制約削除
await queryRunner.query(`ALTER TABLE "bar" DROP CONSTRAINT "UQ_bar_code"`);
// fooテーブルのカラム削除
await queryRunner.query(`ALTER TABLE "foo" DROP COLUMN "deleted_at"`);
await queryRunner.query(`ALTER TABLE "foo" DROP COLUMN "priority"`);
// fugaテーブルのNOT NULL制約削除
await queryRunner.query(`ALTER TABLE "fuga" ALTER COLUMN "description" DROP NOT NULL`);
// fugaテーブルのカラム型を戻す
await queryRunner.query(`ALTER TABLE "fuga" ALTER COLUMN "price" TYPE integer`);
// hogeテーブルのインデックス削除
await queryRunner.query(`DROP INDEX "IDX_hoge_company_id"`);
// hogeテーブルのカラム削除
await queryRunner.query(`ALTER TABLE "hoge" DROP COLUMN "status"`);
// companyテーブルのコメント削除
await queryRunner.query(`COMMENT ON COLUMN "company"."updated_at" IS NULL`);
await queryRunner.query(`COMMENT ON COLUMN "company"."created_at" IS NULL`);
await queryRunner.query(`COMMENT ON COLUMN "company"."email" IS NULL`);
await queryRunner.query(`COMMENT ON COLUMN "company"."phone" IS NULL`);
await queryRunner.query(`COMMENT ON COLUMN "company"."address" IS NULL`);
await queryRunner.query(`COMMENT ON COLUMN "company"."name" IS NULL`);
await queryRunner.query(`COMMENT ON COLUMN "company"."id" IS NULL`);
}
}
companyテーブル以外の、hogeテーブルその他の変更どこから出てきた????
私「なんか知らない変更が入ってるんだけどこれ何?」
前任者「使わない行は消せば良いじゃん!」
とのこと。聞けば彼がシステムのほとんどを作り上げたそうなので意図を尋ねました。
私「What is the purpose to edit migration file manually?」
前任者「This is a "Basic" skill.」
(オフショアなので英語です。ほぼ原文ママ。)
いや、あの、え??
目的を聞いているんですが???
ていうか何?ベーシックスキルって。
質問を続けましたが、彼が英語ネイティブではないこともあり、何を言っているかが分からず、どうやらマイグレーションファイルを編集することで進めているということだけは分かりましたが、この件以降めっきり返信が遅くなり結局目的も分からないまま別れを迎えました。
マイグレーションファイルはDB変更の助けになると思っていたのですが、ここでは違ったようです。
1行の変更を加えるために毎回100行弱を手作業で消してね、ということでモヤモヤする工程でした。
理解するのが辛かったER
ER図、便利ですよね。
資料として見ればエンティティ同士の関係性が見え、(Entity-Relationそのままですね)
作成者の立場で見れば、サービスを成立させるのに必要なデータ構造と、それを操作するためのAPIの整理の土台として用いることができ、それ自身が成果物でもあります。
件のプロジェクトではドキュメントがありませんでしたので、
(ドキュメントが無いくらいではもはや驚きませんのでそれは記事にはしません。)
ビジネス側の人へのヒアリングを経てサービスの実態を掴もうとしたのですが、聞いている話とエンティティ定義がどうにもうまく結び付きませんでした。
そこで、ER図を持ってたら欲しいと前任者に伝えたらER図の提供には別料金とのこと。
ツールの支援を受けてER図吐かせても理解し難い図となったので、とりあえずコアとなる機能に関連する部分だけを自分で書き起こしました。
サービスの登場人物として以下があるとしましょう。
- メーカー(manufacturer)
- 卸業者(wholesaler)
- 小売業者(retailer)
メーカーと卸業者、卸業者と小売業者は多対多の関係になりますから、以下5つのテーブルがあれば表現できそうですね。
- manufacturers
- wholesalers
- retailers
- manufacturer_wholesaler
- wholesaler_retailer
ER図にするとこう↓。
ですが実際にはそんなテーブルは存在しません。
companyテーブルに全て集約されていました。
company同士の中間テーブルもありません。
描くとすればこんな感じです。

テーブルから出た線がテーブル自身に帰っています!!!
お帰り。私も帰っていいですか?
自己参照自体が悪いというつもりはありません。
ですがそれは、
- ドキュメントが理解の助けになる状態であること
- テーブル内のカラムが整頓されていること
が前提にないと成立しないのではないかと思います。
実際には、物理名と論理名を紐付けたコメントやドキュメントの不在、カラム名と保存されているデータの意味の食い違い、もはや使ってないカラムや同データの別カラムでの2重管理などオンパレードでしたので、サービスでの登場概念に一致しないエンティティの定義は読むだけで辛いものがありました。
以降このプロジェクトで何をするにしても、このテーブル及びその周辺の理解と、それに引きずられて発生する手間に苦しめられることになります。
わざわざデータ取得大変な書き方にしてない?
当該プロジェクトではそのエンティティの定義の皺寄せがアプリケーションに寄っていたように思います。
例えば、卸業者と小売業者の関連を取りたかったら↓のように書けますが、
// typeORM
const wholesalers = await wholesalerRepository.find({
relations: ['retailers'],
});
companyだけで表現されるとどうなるでしょうか。
実践していないので全体は書きませんが。多分こんな感じ。
@ManyToMany(() => Company, (company) => company.wholesale_retailer)
@JoinTable({
name: 'wholesaler_retailer',
joinColumn: { name: 'wholesaler_id', referencedColumnName: 'id' },
inverseJoinColumn: { name: 'retailer_id', referencedColumnName: 'id' }
})
wholesale_retailer: Company[];
const companies = await companyRepository.find({
relations: ['wholesale_retailer'],
});
まだ耐え、ですね。
しかし当該プロジェクトでは、大部分が文字列べた書きでデータの取得を記述していました。
複雑なサブクエリの跋扈に読解のリソースが持っていかれます。
ORM活用しないの・・・?
多分、直感的でないERをORMで扱えるように落とし込むところまでやらなかったんだと思いますが、そんなに文字列で頑張るならER整理頑張ってもよかったんじゃないの?と思わざるを得ませんでした。
修正チョロですねフフン~そんなわけなかった影響範囲~
さて、そんな文字列ベタ書きSQLですが、
サービス層に書かれていました。
作業前私「一箇所直せばチョロで終わる修正だろう」
そう、レポジトリ層に書かれていれば、ね。
同じデータを同じレポジトリを介して欲しいだけのはずなのに、ありとあらゆるユースケース内のサービス層に同じ目的のSQLが複製されている状況。
- 文字列だからコードジャンプできません。
- 表記揺れやタイポのせいで検索で全てが引っかかってきません。
- ドキュメントが無いので、同じ目的のSQLに見えてもユースケース毎に改修対象どうか逐一判断する必要があります。
想定より一気に広がる修正範囲、次々発覚する既存バグ、その間も飛んでくるエスカレ対応、一向に出せないアウトプット。
とっても楽しい改修作業の日々でした、ええ。
あとがき
DB周りで辛い思いをした諸兄に「あるある」と思ってもらえたら幸いです。
あるいはこれから設計・実装の先陣を切る方には、もしかしてわざわざ大変な方に行ってないだろうかと思いを馳せていただければ。
当時を思い出しながら思いつくままに勢いで書いてます。致命的なツッコミどころがあればコメントで刺してください。
「This is a "Basic" skill.」と言われたことの恨み節でした。
明日はcrawling_catさんによる記事です。引き続きよろしくお願いいたします。