こんにちは。 人材プラットフォームジョブメドレープラス開発室長の德永です。 この記事では、人材紹介の業務基盤を TypeScript と CQRS+イベントソーシングで0から構築し、約4か月で本番リリースするまでを振り返ります。 私は PdM と実装を兼ね、構想の検証から本番稼働、その後の運用まで関わっています。
こちらの記事は「MEDLEY Summer Tech Blog Relay」の27日目の記事です。
人材紹介サービス「ジョブメドレーエージェント」では、2022年の立ち上げから3年あまり、数千行のスプレッドシートを複数並行で使って業務を回してきました。 システム化の構想を始めたのは2025年12月です。 翌月に内製を決め、2026年4月に業務を切り替えました。 現在も運用しています。 構想から本番リリースまでのおよそ4か月で、約1,200本の PR をマージしています。
アーキテクチャには CQRS とイベントソーシングを選びました。 データを上書きせず、起きた出来事を順に記録していく作り方です。 0→1の立ち上げにしては作り込みすぎではないか。 着手前から何度も議論し、開発中も判断を見直してきました。
リリースから4か月が経ったいま、この事業では過剰な選択ではなかったと考えています。 もちろん、安く作れたわけではありません。 実装量は増え、リリースを優先して負債として残した課題もあります。 それでも、仕様を決めきれないまま開発とデータ移行を並行でき、リリース後の変更にも対応できました。 私たちの条件では、そのためにかけたコストに見合っていました。
スプレッドシートで支えていた業務
ジョブメドレーエージェントは、医療介護求人サイト「ジョブメドレー」を運営するメドレーが、2022年に立ち上げた人材紹介サービスです。 求人サイトが求職者と事業所を直接つなぐのに対し、人材紹介ではキャリアアドバイザーが求職者と面談し、求人を探し、応募から入職まで伴走します。
この業務を支えていたのが、冒頭で触れたスプレッドシート群です。 求職者、面接、顧客、求人、社内の依頼、KPI など、用途ごとにシートが分かれていました。
シートが用途ごとに分かれているため、同じ情報を何度も書き写していました。 求職者にその場で求人を紹介したくても、現在の状況を知るには複数のシートとツールを行き来しなければなりません。 マニュアルはあっても、手順やシートの使い方には人ごとの差が残り、新しいメンバーが業務を覚えるまでにも時間がかかっていました。

実際に使っていた管理シートの一部です。画面下部には、用途別のタブが並んでいます。個人情報にあたる内容はぼかしています。
システム化で解決したかったことは、次の4つです。
- 情報をひとつにまとめて状況把握を速くしたい
- 手作業の転記をなくして、担当者の時間を求職者と向き合うことに使いたい
- 業務の手順を形式知として残し、新しいメンバーがすぐ立ち上がれるようにしたい
- データを構造化し、AI を業務に組み込める土台を作りたい
目指しているのは、各メンバーが顧客や求職者と向き合う交渉や提案だけに集中できる状態です。 すぐに実現できる状態ではないため、まずは業務をシステムに載せ、日々起きていることを構造化して記録し、分析するところから始めます。
どの課題も、事業が小さいうちは対応を急ぐものではありません。 しかし、扱う職種と人数が増えるほど、影響は大きくなります。 2025年12月、さらなる規模拡大と体制強化を前に、システム導入の起案が出ました。
外部SaaSと内製を比べる
最初から内製を決めていたわけではありません。 人材紹介の業務管理には導入実績の豊富な外部SaaSがあるため、外部SaaSの導入と内製を並行して検討しました。
起案の直後には、最も不確実だった求人検索から検証を始めました。 ジョブメドレーの求人データと OpenSearch を借りると、半日で最初の画面ができました。 事業部にその画面を触ってもらいながらヒアリングを進め、並行して職種ごとの業務フローを文書化し、システム内で完結できる業務の範囲と運用コストを試算しました。 翌月に内製を決めた時点では、比較資料だけでなく、実際に動くシステムも手元にありました。
どの期間と利用者数で比べるかによって、コストの結論は変わりました。 外部SaaSの費用は利用アカウント数に応じて増えますが、内製の構築費は利用者数では増えません。 単年では外部SaaSが安くても、当時見込んでいた利用者の増加を含めて数年で比べると、差は縮まります。 どちらかが明らかに安いとは判断できませんでした。
内製に決めた理由は、社内のデータとのつなぎ方でした。 求人や事業所のデータを、ジョブメドレー側の更新に追従して取り込む必要があります。 さらに、ジョブメドレーに蓄積された求人や応募のデータをリアルタイムでつなぎ、社内で活用できる範囲を広げたいと考えていました。 検討した外部SaaSでは、この連携を運用できませんでした。
CQRS+イベントソーシングを選んだ理由
起案直後に作った検証用リポジトリでは、CQRS とイベントソーシング(Event Sourcing、以下 ES)を前提にしました。 ドメイン層は関数型 DDD で書いています。 状態を持つオブジェクトに振る舞いを持たせるのではなく、不変なデータと純粋関数を使い、状態遷移をステートマシンとして表す方針です。
ES を選んだ理由のひとつは、テーブル設計を早々に固めたくなかったことです。 現場ではスプレッドシート上の業務が毎週変わり、職種ごとのヒアリングもこれからでした。 この時点で引いた設計が3か月後までそのまま残るとは思えません。 開発中も事業の施策は続くため、システム化を理由に現場の変化を止めてもらうわけにもいきませんでした。 早く決めるほど、決め直す回数も増えます。
かといって、仕様が固まるまで待てば、リリースは遅れます。 実際に業務を整理すると、すでに固まっているものと、使い始めてから決めたいものが混ざっていました。
すでに固まっていたのは、業務で使う言葉です。 キックオフ直後に5人全員で、DDD の設計手法であるイベントストーミングを行いました。 求職者の獲得から初回面談、求人探索と提案、面接と選考、入職と請求までを並べました。 顧客側についても、開拓と契約、求人の掲載と運用、利用停止と解約までを洗い出しました。 法人名の変更や、事業譲渡による運営法人の変更など、頻度は低くても起きると困る流れも付箋にしました。 数時間で出てきたドメインイベントは、およそ100個です。 このときの言葉がすべて残ったわけではなく、開発中に概念ごと捨てたものもあります。

付箋の個別内容ではなく、扱った業務範囲と量を示すための全景です。
画面で何を目立たせるか、何をどう測るかは事情が違いました。 分析やヒアリングだけでは決めきれず、現場で使い始めてから分かることが多い領域です。 この部分まで先に固めて作り直すより、業務上の出来事を先に記録し、表示や集計は後から足せる構造にしたほうが速いと考えました。
この考え方は目新しいものではありません。 リーン開発の7つの原則にも「決定を遅らせる(Defer Commitment)」があります。
Many people like to get tough decisions out of the way, to address risks head-on, to reduce the number of unknowns. However, in the face of uncertainty especially when it is accompanied by complexity, the more successful approach is to tackle tough problems by experimenting with various solutions, leaving critical options open until a decision must be made.
— Mary Poppendieck, Tom Poppendieck『Implementing Lean Software Development: From Concept to Cash』(Addison-Wesley, 2006。邦訳は『リーン開発の本質』日経BP)
難しい決定を早く片付け、未知を減らしたくなる。 しかし、不確実さと複雑さが重なる場面では、いくつかの解決策を試しながら、決める必要が生じるまで選択肢を残したほうがよい、という考え方です。
私たちが決定を遅らせたのは、画面での見え方と、業務の測り方です。 この二つを後から変えられるように、イベントを一次記録にするアーキテクチャだけは先に決めました。
CRUD で作り、仕様が変わるたびに migration を書く進め方もあります。 実際、その方法でもリリースはできたと思います。 ただし、状態を上書きする設計では、状態の持ち方を変えると、テーブル定義の変更や過去データの補正が必要になることがあります。 変化の多い立ち上げ期に、この作業を何度も繰り返すことが気になりました。
ES では、「起きた事実」を表すイベントを記録します。 画面や集計が参照するテーブルは、そのイベントから作ります。 書き込みと読み取りでモデルを分ける考え方が CQRS で、読み取り側のテーブルを Read Model、それを作る処理を projector と呼びます。 事実と見え方を分けておけば、表示や集計の仕様が変わっても、記録済みのイベントを書き換える必要はありません。 Read Model は作り直しますが、必要な事実がイベントに含まれていれば、表示や集計の変更を投影側に閉じられます。
イベントストーミングで整理した業務上の事実はイベントへ、使い始めてから決める表示や集計は投影へ置きました。 表示や集計が変わり続ける0→1では、この分離に手間をかける価値があると考えました。
この選択が合うかどうかは、後から決めたいことがどれだけ残っているかで変わります。 表示や集計まで仕様が固まっている場合、固める時間を取れる場合、変更が少ない場合には、最初からテーブルを設計したほうが速いはずです。
TypeScript での実装
実装は TypeScript のモノレポです。 イベントの payload と集約の状態は Zod スキーマを一次情報とし、型はそこから導出します。 以降のコード例は、説明に必要な部分だけを残して簡略化しています。
export const memberRegisteredPayloadSchema = z.object({
memberId: memberIdSchema,
name: z.string().trim().min(1),
registeredAt: isoDateTimeSchema,
});
export type MemberRegisteredPayload =
z.infer<typeof memberRegisteredPayloadSchema>;
ドメイン層は、ドメイン駆動設計(DDD)の集約ごとに、decide と evolve の2つの純粋関数で構成します。
集約とは、一度に整合性を保つ単位です。
decide(command, state) は、現在の状態でコマンドを実行できるか判定し、イベントを返します。
evolve(state, event) は、イベントを状態に適用します。
どちらも DB や時刻に依存しないため、状態遷移のテストは入出力だけで書けます。
この構成は、Decider パターン(Functional Event Sourcing Decider)として知られています。
decide の戻り値には Result を使い、失敗時には業務ルールを表す union を返します。
承認を挟む集約の例として、早期離職時の紹介料を扱う返金ケースの型を挙げます。
export type DecideRefundCaseError =
| { kind: "refund_case_not_created" }
| { kind: "refund_case_closed" }
| { kind: "refund_case_cannot_update_while_approval_pending" }
| { kind: "refund_case_cannot_update_refund_content_after_content_approval" }
| { kind: "refund_case_cannot_update_invoice_before_content_approval" };
API 層はエラーの kind で分岐し、画面に出すメッセージを決められます。
ドメイン層で throw したエラーを、ハンドラが文字列で判別する必要はありません。
集約の状態も、ひとつの大きなオブジェクトに optional な項目を足すのではなく、判別可能な union で表します。 たとえば、「退会済みなのに面談予定が残っている」という矛盾した状態は型の上で作れません。 optional は、それぞれの状態で本当に任意な項目にだけ使います。
export const memberStateSchema = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("initial") }),
z.object({
kind: z.literal("active"),
memberId: memberIdSchema,
interviewScheduledAt: isoDateTimeSchema.optional(),
}),
z.object({
kind: z.literal("withdrawn"),
memberId: memberIdSchema,
withdrawnAt: isoDateTimeSchema,
}),
]);
export type MemberState = z.infer<typeof memberStateSchema>;
API には Hono.js と zod-openapi を使い、リクエストの検証から OpenAPI 契約の生成までをスキーマに寄せています。 フロントエンドは React Router の SPA で、契約から生成したクライアントを使います。
一般的な構成と異なる3つの判断
CQRS+イベントソーシングでは、イベントストアに追記したイベントを非同期で投影し、Read Model を作る構成をよく見かけます。 私たちの実装は、この構成と3つの点で異なります。
投影を同期にする
Read Model への投影は非同期にせず、イベントの追記と同じトランザクションで行っています。 CQRS の解説では、投影を非同期にし、保存直後の画面に少し前の状態が見えることを許容する構成がよく紹介されます。 ただし、CQRS が分けるのは書き込みと読み取りのモデルであって、投影のタイミングではありません。 たとえば .NET の Marten には、投影の実行方式として Inline、Live、Async があります。 Inline を選ぶと、イベントの追記と同じトランザクションで Read Model が更新されます。
非同期投影でも、読み取り側を工夫すれば、保存直後の画面に最新の状態を見せられます。 その場合は、投影の遅延を考慮した画面、遅延の監視、失敗した投影の再試行を最初から用意することになります。
今回ほしかったのはイベントを一次記録にする設計であり、非同期投影によるスケーラビリティではありませんでした。 利用者は社内のオペレーター数十名で、同時に書き込む人数はさらに限られます。 一般公開のサービスとは、想定する負荷が違います。
そこで、初期構成は同期投影にしました。 規模が変われば判断を見直しますが、イベントが一次記録として残っている限り、投影の方式は後から変えられます。
同期投影にも代償はあります。 投影と書き込みが同じトランザクションにいるため、projector に不具合があり、投影に失敗すると、登録や更新そのものも失敗します。 失敗するのは操作した1件で、システム全体が止まるわけではありません。 非同期投影なら書き込みは通り、画面への反映だけが遅れます。 私たちは、誤った表示のまま業務が進むより、その場で操作が失敗して原因を追えるほうが、立ち上げ期には扱いやすいと判断しました。 そのぶん、投影には CI で AST レベルの制約をかけ、projector のテストを書くことも規約にしています。
イベントストアを MySQL のテーブルにする
イベントストアには、EventStoreDB のような専用のミドルウェアではなく、Aurora MySQL の通常のテーブルを使いました。
agent_events には INSERT しか行わないと決め、追記専用のテーブルとして運用しています。
業務基盤のデータベースは、ジョブメドレーが使っている Aurora クラスタ内に置いています。 新しいデータベース基盤を立てずに済み、バックアップや監視、社内のデータ基盤へ送るパイプラインも共用できます。 同じクラスタを使うため、権限は接続ごとに分けました。 ジョブメドレーのデータはリードレプリカから読むだけで、書き込み権限はありません。 取り込める範囲も権限で絞っています。 業務基盤への書き込みに使う接続は、自分たちの論理データベースの外に出られません。
データベースの配置を決めたあと、イベントストアも MySQL のテーブルにしました。 イベントと Read Model を同じ MySQL トランザクションで更新するため、両方を同じデータベースに置いています。 4か月という期間に、新しいミドルウェアの学習と運用を持ち込まずに済むことも判断材料でした。
export const agentEvents = mysqlTable("agent_events", {
eventSeq: bigint("event_seq", { mode: "number" })
.autoincrement().primaryKey(),
streamKey: varchar("stream_key", { length: 255 }).notNull(),
streamVersion: int("stream_version").notNull(),
eventType: varchar("event_type", { length: 100 }).notNull(),
payload: json("payload").notNull(),
occurredAt: datetime("occurred_at", { fsp: 6 }).notNull(),
}, (t) => ({
streamVersion: unique("uk_stream_version")
.on(t.streamKey, t.streamVersion),
}));
テーブルをシンプルにしたぶん、イベントの型はドメイン層で厳密にしています。 Zod の enum を一次情報にして、集約ごとに eventType と payload の組み合わせを union で定義します。
export const EventTypeSchema = z.enum([
`${EVENT_TYPE_PREFIX}.introduction.refund-case-created.v1`,
`${EVENT_TYPE_PREFIX}.introduction.refund-case-closed.v1`,
// ...
]);
export type EventType = z.infer<typeof EventTypeSchema>;
export type RefundCaseEvent =
| (StoredEventBase & {
eventType: typeof EVENT_TYPES.introductionRefundCaseCreated;
payload: RefundCaseCreatedPayload;
})
| (StoredEventBase & {
eventType: typeof EVENT_TYPES.introductionRefundCaseClosed;
payload: RefundCaseClosedPayload;
});
// ...
evolve では switch (event.eventType) で分岐し、各 case の中で event.payload の型を絞り込めます。
DB に保存される eventType は文字列ですが、書き込む側と読み出す側は型で制約されています。
ひとつの集約に積まれたイベントの並びをストリームと呼び、streamKey で識別します。
イベントを追記するときは、ストリームを読み込んだ時点の版に1を足した値を streamVersion に書きます。
同じ集約を並行して更新すると、複合ユニーク制約が後から来た書き込みを弾きます。
専用のイベントストアが expectedVersion で提供する楽観的並行制御を、複合ユニーク制約で実装した形です。
制約が担うのは衝突の検出で、書き込みに成功したイベントは streamVersion によってストリーム内で順序づけられます。
「1トランザクション1集約」を守る限り、集約の不変条件も維持できます。
競合した操作は自動でやり直さず、画面にエラーを返します。 同じ求職者を同時に操作する場面はまれなので、裏で再実行するより、操作した人に競合を伝えることにしました。
専用のイベントストアが持つ機能のうち、必要だったのは追記と競合検出だけでした。
購読や、すべてのイベントを対象にした順序保証は使っていません。
Read Model を再投影するときは、eventSeq の昇順でイベントを流します。
eventSeq は autoincrement の採番順であり、コミット順とは一致しません。
同じストリームへの並行追記はユニーク制約で競合するため、正常に保存された同一ストリームのイベントでは、eventSeq と streamVersion の順序が一致します。
いまの projector は集約をまたいだ順序に依存しないので、この方法で足りています。
将来、非同期投影へ移す場合は、採番済みで未コミットのイベントを飛ばして読む可能性があるため、順序の追い方から見直す必要があります。
スナップショットを作らない
スナップショットを作らないと積極的に決めたわけではありません。
まだ必要になる規模に達していないだけです。
ストリームが長くなると再生に時間がかかるため、途中の状態を保存し、そこから後のイベントだけを読むのが一般的です。
私たちは decide のたびに、そのストリームのイベントを最初から読み直しています。
ひとつの求職者や契約に積まれるイベントはまだ少なく、現状の再生時間で問題ありません。
必要になった時点で追加するつもりです。
移行専用イベントでデータ移行を先に進める
ES を前提にしたことは、データ移行で役に立ちました。 一般的なデータ移行では、先に移行先のスキーマを固め、旧データを新しい構造へ変換します。 今回は、移行先であるシステムの仕様自体が、事業部へのヒアリングと並行して変わっていました。 理想のデータ構造が固まるまで待っていると、データ移行がリリースのクリティカルパスになります。
早くリリースしたかったのは、日程だけが理由ではありません。 イベントは、システムが使われ始めてから蓄積されます。 理想形まで仕上げてから出すより、現場が使える状態を早く作り、業務上の事実を記録し始めたいと考えていました。 蓄積を早く始めるほど、後から追加する集計や施策で参照できる期間も長くなります。
そこで、スプレッドシートの1行をほぼそのまま payload へ写し取る、移行専用のイベント型を定義しました。 移行イベントには「移行時点でシートにこう書かれていた」という事実だけを記録します。 アプリケーションの操作や、移行後に目指すデータ構造とは切り離し、移行元の値を Read Model にどう表すかは projector に任せました。
移行元の記録と解釈を分けたため、抽出、クレンジング、投入、検証からなる移行処理を、仕様の確定を待たずに準備できました。 Read Model の仕様が変わっても、projector を直し、移行専用イベントの生成と投入の手順は保てました。
ただし、移行専用イベントは負債として残ります。
ES では過去のイベントを書き換えないため、移行イベントも履歴から消えません。
集約の evolve と Read Model の projector は、通常イベントと移行イベントの両方を解釈し続ける必要があります。
欠損値の扱いなど、移行イベントにしか必要のない分岐も残りました。
機能を追加するたびに、この分岐を考慮する手間がかかり始めています。
将来は、移行イベントを通常イベントの並びへ分解して投入し直し、この分岐をなくす計画です。
CRUD との比較
コードを書く作業だけなら、CRUD のほうが速かったはずです。 ES 固有の投影基盤や再生処理を先に用意する必要がなく、集約ひとつあたりのコードも減ります。
ただし、今回想定していた CRUD の構成では、移行先のスキーマを固めてから旧データの変換を準備することになります。 仕様の確定を待つぶん、リリース日までの余裕はさらに少なくなっていたと考えています。
ここで比較しているのは、今回検討した構成です。 CRUD に履歴テーブルや変更データキャプチャ(CDC)を組み合わせれば、保持できる記録は変わります。
| 比較軸 | CRUD | CRUD+履歴・CDC | ES |
|---|---|---|---|
| 初期実装 | 小さい | 履歴基盤が加わる | 再生・投影基盤を含み大きい |
| 先に決めるもの | 現在状態のスキーマ | 残す列、または全変更の取得方法 | イベントの境界と payload |
| 残る記録 | 基本は現在状態 | 行や列の変更差分 | 業務上の出来事 |
| 後から集計できる範囲 | 上書き前の状態は復元できない | 記録した差分と、列の意味が保たれる範囲 | 必要な事実がイベントに含まれる範囲 |
| 向く条件 | 仕様と測り方が安定している | 必要な履歴を先に設計できる | 表示と集計を後から変えたい |
人材紹介では、ファネルの通過率やリードタイムが施策の判断材料になります。 その詳しい測り方が決まったのは、リリース後です。 ファネルの到達履歴テーブルはリリースから1か月半後、分析用の Read Model 群は3か月後に作りました。 どちらも過去に遡ってデータが埋まっています。
イベントには、「ステータス列が A から B に変わった」ではなく、「打診を始めた」「面接を希望した」「NG になった」と記録していました。 ファネルの各段階も同じ業務用語で定義されるため、投影を書くときに、行の変更を業務上の出来事へ読み替える必要がありませんでした。
CRUD でも、履歴テーブルを最初から用意すれば同じ分析はできます。 ただし、その場合はどの列の履歴を残すかを先に決めます。 ファネルの到達履歴に何が必要か分かったのはリリース後なので、必要な履歴をリリース前にすべて選ぶのは難しい状況でした。
列を選ばず、すべての変更を記録する方法もあります。
Fivetran のようなツールで CDC を行うか、監査ログを使い、テーブルの全変更を分析基盤へ送る方法です。
集約や decide を実装しないぶん、アプリケーション側は軽くなります。
CDC で残るのは行の差分です。 「いつ NG になったか」のように、ある時点で起きた事実なら、変更履歴から復元できます。
難しくなるのは、業務上の概念自体を作り替えた場合です。 開発中に、求人サイトの「応募」をそのまま持ち込んだ設計を捨て、人材紹介の「紹介」へ一本化したことがありました。 この変更では、同じ列でも前後で意味が異なります。 どの変更が業務上何を意味したのか、別に記録を残して補うこともできます。 ただ、業務上の意味まで別に記録して復元できるようにすると、ES と同じく、記録時の意味を設計して保つコストが生じます。
もちろん、ES でもすべてを後から決められるわけではありません。 イベントの境界と payload に何を含めるかは、記録する時点で決めます。 後から項目を追加しても、過去のイベントにその値は入りません。 今回遅らせられたのは、記録済みの事実から何を表示し、どう集計するかという判断です。
AI エージェントに渡しやすかった実装
ES は実装量の多いアーキテクチャです。
ひとつの機能に、イベント定義、decide、evolve、projector、テストが必要で、似た構成のファイルが並びます。
一例として、返金ケースの集約は実装コードが2,215行、テストを含めると4,487行になりました。
人材紹介の業務には、契約、承認、解約、返金、ヒアリングなど、似た構造を持つ集約が数多くあります。
最終的に実装した集約は20種です。
投影や再生の共通基盤も、機能開発に先立って用意する必要があります。
この実装量は、ES を採りにくくする理由のひとつでした。 リポジトリを作った週に、コーディング規約とドメインの前提を AI エージェント向けの指示ファイルに書き、CI のガードも整備しました。 各ファイルの責務を狭くし、型と CI で規約違反を検出できるようにしたことで、同じ形の実装を繰り返す部分は AI エージェントに渡せました。
AI エージェントに渡す作業の境界は、CRUD でも作れます。
ES が定めるのはイベントを一次記録にするところまでで、関数の分け方までは決まりません。
私たちは前述の Decider パターンを組み合わせ、業務上の決定を decide、イベントによる状態遷移を evolve に分けました。
evolve が扱うのは、「このイベントを適用すると状態のどこが変わるか」です。
イベント型を追加したのに evolve の case を書き忘れると、default 節の never 代入がコンパイルエラーになります。
default: {
const unhandled: never = event;
return err({ kind: "refund_case_unhandled_event", eventType: String(unhandled) });
}
decide には業務ルールが集まるため、人が内容を読んで判断します。
同じ集約の中でも、定型的な状態遷移と、業務上の判断を伴う処理を関数単位で分けられました。
CRUD のハンドラでは、AI エージェントに渡す作業の境界があらかじめ引かれているわけではありません。 入力の検証、業務ルールの判定、テーブルの更新をひとつの関数に置くなら、どこまで任せるかを実装ごとに判断する必要があります。
開発の中盤からは、進め方が固まった作業を、AI エージェント向けの手順書(skill)として切り出しました。 集約の実装、イベントのバージョニング、migration の生成、ローカル DB の再構築などです。 リリース時点で62本になりました。
ただし、ガードを用意せずにボイラープレートだけを生成させると、形の整ったコードに概念的な誤りが紛れ込みます。 本格的に作り始める前、アーキテクチャを理解するため、小さなアプリを作っては壊していました。 動くところまではすぐにたどり着くのに、機能を追加すると数日で行き詰まることが何度かありました。 後から確認すると、ドメインの分け方を誤ったまま、ファイルの形だけが揃っていました。
設計判断は ADR(Architecture Decision Record)として残し、リリースまでに57本になりました。 ADR は、AI エージェントに判断の背景を渡せるように整えました。

AI エージェントによって、実装のコストは下がり続けています。 ただし、速く作れるぶん、設計を誤ったときに直すコードも増えます。 作って確かめる開発では、書く速さだけでなく、間違いに気づいたときにどこまで戻るかが進み方を左右します。 事実と見え方を分けたことで、画面や集計に関する変更の多くは projector の書き換えで済みました。
リリース後の評価
2026年4月のリリース当日、最初のチームの業務をスプレッドシートから内製システムへ切り替えました。 切り替え後の1か月は細かなデータ修正が続き、本番の Read Model を3回再投影しました。 それでも業務は止まりませんでした。 記録済みのイベントには手を入れず、projector を直して Read Model を作り直せたためです。
この結果だけを見て、どんな0→1にも CQRS+イベントソーシングを勧めたいわけではありません。 今回の判断には、少なくとも次の条件がありました。
- リリース後も画面や測り方が変わり続ける
- 業務上の事実を後から何度も参照する
- 移行元データの品質を事前に読み切れない
これらの条件がなければ、もっとシンプルな設計を選んだと思います。
AI を組み込める業務基盤へ
このシステムで目指しているのは、事業部のメンバーが顧客や求職者と向き合う交渉や提案だけに集中できる状態です。 業務の流れがシステム上で見えるようになり、事務作業や報告、マネジメントを AI が引き受けられれば、人は求人サイトだけでは代替できない人材紹介の仕事に時間を使えます。
業務プロセスの可視化と AI ネイティブ化は、メドレー全体で進めている方針です。 今回の業務基盤も、人材紹介の領域でその方針を進める取り組みのひとつです。 業務の事実を構造化して記録しておけば、その記録をもとに AI が業務の流れを把握し、判断や実行に関われるようになります。
メドレーでは生成AI利用のガイドラインを社内に展開し、各部門はそのガイドラインに沿って生成AIを利用しています。
MEDLEY Summer Tech Blog Relay 28日目の記事は倉林さんです!
メドレーでは、「医療ヘルスケアの未来をつくる」仲間を募集しています。 少しでも興味をお持ちいただけましたら、ぜひカジュアル面談にお越しください。