kickflow Tech Blog

株式会社kickflowのプロダクト開発本部によるブログ

oRPC を調査したら、Rails + Nuxt の自分たちのスタックが思ったより型安全だと気づいた話

oRPC の調査イメージ

こんにちは、kickflow でエンジニアをしている芳賀です。

最近、TypeScript 界隈で oRPC というフレームワークの名前をよく見かけるようになりました。「tRPC の開発体験そのままに、OpenAPI も一級市民として扱える」という触れ込みで、OpenAPI を軸に API を運用している身としてはかなり気になる存在です。

kickflow のバックエンドは Rails、フロントエンドは Nuxt で、API は OpenAPI の YAML を手書きする spec-first 運用をしています。この構成で日々開発していると「サーバーの実装とスキーマとフロントの型、同じ形を 3 か所に書いてるな……」と思う瞬間が定期的にやってきます。oRPC はまさにそこを解決するフレームワークに見えたので、腰を据えて調査してみました。

結論から書くと、oRPC は導入しませんでした。以下要点です。

  • oRPC の価値(1 つの定義から検証、型、OpenAPI が同時に出る)は、サーバーが TypeScript であって初めて成立する。バックエンドが Rails の kickflow には乗らない
  • 代わりに、実装からフロントの型までを繋ぐリンクを棚卸ししたら、切れていたのは手書きレスポンス型の 1 本だけだった。そこは OpenAPI から生成した型への委譲で閉じられる

詳しくは本文でご紹介していきます。

oRPC とは何か

oRPC(OpenAPI Remote Procedure Call)は、TypeScript でエンドツーエンドの型安全な API を作るためのフレームワークです。tRPC の後発にあたり、名前のとおり OpenAPI を一級市民として扱うのが最大の特徴です。ただ、この「OpenAPI を一級市民として扱う」が何を意味するのかは、前身の tRPC が何を割り切っていたかを知るとよく分かります。先に tRPC の話をさせてください。

tRPC の何がすごかったのか

tRPC は「サーバーで定義した手続きを、クライアントからローカル関数のように型付きで呼ぶ」体験を、コード生成なしで実現したフレームワークです。Next.js / T3 スタック圏で広く普及しました。

// server: 手続きを定義
const t = initTRPC.create()
export const appRouter = t.router({
  planet: t.router({
    create: t.procedure
      .input(z.object({ name: z.string() }))
      .mutation(({ input }) => ({ id: 1, name: input.name })),
  }),
})
export type AppRouter = typeof appRouter // ← クライアントへ渡すのは「型」だけ

// client: 型を import して呼ぶ(codegen なし)
const trpc = createTRPCClient<AppRouter>({ /* link */ })
const planet = await trpc.planet.create.mutate({ name: 'Earth' })
//    ^? { id: number; name: string } が推論される

サーバーの .input() を変えた瞬間にクライアント側の型も変わり、食い違えばコンパイルエラーになる。型生成スクリプトを回し忘れて型がズレる、という事故が構造的に起きません。

ただし tRPC には割り切りがあります。tRPC の「API」は標準的な REST / OpenAPI 契約ではなく、TypeScript の型そのものです。クライアントとサーバーが同一の TypeScript 型に密結合することで型安全を得ているので、

  • TypeScript 以外のクライアントや外部公開 API から叩けない
  • OpenAPI ドキュメントが標準では出ない(別プラグインが必要)

という弱点があります。「tRPC の DX は欲しい、でも言語非依存の契約(OpenAPI)も欲しい」という要求に応えるために生まれたのが oRPC です。だからこそ、OpenAPI を後付けプラグインではなく中心に据えています。

「1 定義 → 3 射影」という設計

調査してみて、oRPC の価値は一言でいうと 「1 つの procedure(手続き)の定義が、3 つの成果物に同時に射影される」 ことだと理解しました。

import { os } from '@orpc/server'
import * as z from 'zod'

const PlanetSchema = z.object({
  id: z.number().int().min(1),
  name: z.string(),
  description: z.string().optional(),
})

export const createPlanet = os
  .route({ method: 'POST', path: '/planets' }) // ← (c) OpenAPI のメソッド/パス
  .input(PlanetSchema.omit({ id: true }))      // ← (a) 入力スキーマ
  .output(PlanetSchema)                        // ← (a) 出力スキーマ
  .handler(async ({ input }) => {
    // input は「検証済み」かつ「型付き」で渡ってくる
    return await db.planet.create(input)
  })

export const router = { planet: { create: createPlanet } }

この定義 1 つを原本(SSoT: Single Source of Truth)として、次の 3 つが別々に書かなくても手に入ります。

(a) サーバー検証。リクエストが来ると、handler が呼ばれる前に .input() のスキーマで入力が検証されます。違反すれば handler は実行されず、バリデーションエラーが自動で返る。Rails でいう Strong Parameters + モデルバリデーションに相当する層です。

(b) クライアント型。クライアントは router の「型」を import するだけです。コード生成のステップがありません。

import type { RouterClient } from '@orpc/server'
import { createORPCClient } from '@orpc/client'
import { RPCLink } from '@orpc/client/fetch'

const link = new RPCLink({ url: 'https://example.com/rpc' })
const orpc: RouterClient<typeof router> = createORPCClient(link)

const planet = await orpc.planet.create({ name: 'Earth' })
//    ^? PlanetSchema の型(= .output() から推論)。codegen 不要

(c) OpenAPI。同じ router から OpenAPI 3.x のドキュメントを生成でき、REST エンドポイントとしても公開できます。

import { OpenAPIGenerator } from '@orpc/openapi'
import { ZodToJsonSchemaConverter } from '@orpc/zod/zod4'

const generator = new OpenAPIGenerator({
  schemaConverters: [new ZodToJsonSchemaConverter()],
})
const spec = await generator.generate(router, {
  info: { title: 'Planet API', version: '1.0.0' },
})

サーバー検証、クライアント型、OpenAPI が同一定義から導かれるので、構造的にズレようがない。これが oRPC の嬉しさです。

ただしこの図式には大前提があります。procedure は TypeScript の関数で、Node.js などの JavaScript ランタイムがサーバー側で実行します。つまり 「1 定義 → 3 射影」はサーバーが TypeScript であって初めて成立します。ここが今回の調査の分かれ道でした。

kickflow の現状: 同じ「形」を 3 か所に書いている

oRPC と比較する前に、kickflow の OpenAPI 運用を整理しておきます。

kickflow は手書きの OpenAPI YAML を正とする spec-first 運用です。内部 API だけで paths 262 ファイル、components 66 ファイル。これを redocly CLI でバンドルと lint にかけ、openapi-typescript で 2 万行超の型定義ファイル(openapi.gen.ts)を生成して、フロントの手書き API クライアントから参照しています。

SSoT = 手書き YAML(spec-first)
  openapi/api/v1/paths/        262 ファイル
  openapi/api/v1/components/    66 ファイル
        │
        ├─ redocly bundle → schema_*.bundled.yaml
        ├─ redocly lint
        └─ openapi-typescript → openapi.gen.ts(約 21,000 行)
                    │
                手書きの API クライアント(frontend/app/api/*.ts)

この運用で、たとえばチケットに 1 フィールド追加すると、次の 3 層を手で揃えることになります。

書く場所
Rails Strong Parameters + シリアライザ + モデル
OpenAPI paths/*.yaml + components/*.yaml
フロント openapi.gen.ts 経由の型 + API クライアント

oRPC が 1 定義から自動で出す 3 つを、うちは 3 か所に書いている。この構図だけ見ると更新漏れによるズレ(ドリフト)がいくらでも起きそうですが、実際には後述するとおり、層と層の間には検証の仕組みがいくつも挟まっています。それがどこまで効いていて、どこが切れているのかを確かめるのが今回の調査の後半戦でした。

で、oRPC は導入できるのか

結論: Rails コアには乗らない

先に書いたとおり、oRPC の価値はサーバーが TypeScript であって初めて成立します。kickflow のコアは Rails なので、乗せるには API 層を TypeScript で書き直すしかありません。paths 262 ファイルの規模で、それは現実的ではないと考えています。

導入パターンを整理するとこうなります。

パターン 可否 評価
Rails API を oRPC に置換 API 層の TypeScript 全面書き直し。非現実的
新規 TypeScript サービスで使う 本来の使いどころ。新しくマイクロサービスや BFF(フロント専用の中継サーバー)を建てるなら有力
Nuxt に BFF を建てて使う 条件次第(後述の落とし穴あり)

BFF 案の落とし穴

「Nuxt には Nitro というサーバーエンジンが同梱されているので、そこに oRPC を置いて、内部で Rails を呼べばいいのでは?」というのは自然な発想で、私も一瞬これでいける気がしました。

でも冷静に考えると、この構成で型安全になるのはフロント ↔ BFF の間だけです。BFF ↔ Rails の継ぎ目は依然として手作業のまま。つまり「型安全でない継ぎ目」を消したのではなく、フロントの一段奥に移動しただけなんですよね。BFF がレスポンスの集約や認証、エッジキャッシュのような独自の価値を持たない限り、管理する層が 1 つ増えるコストに見合いません。

しかも kickflow のフロントは openapi.gen.ts ですでに型を得ています。oRPC を挟んでも、フロント ↔ Rails の型安全は今と大きく変わらない。ここで「oRPC を入れること」が目的化しかけていたことに気づきました。

「リンク」を 1 本ずつ棚卸しする

そもそもの目的は「既存の Rails + Nuxt の開発体験を良くしたい」です。oRPC はその手段の候補にすぎません。

oRPC が構造的に保証しているのは、突き詰めると「サーバー実装 ↔ スキーマ ↔ クライアント型が絶対にズレない」ことです。であれば、自分たちのスタックで対応するリンクが繋がっているかを 1 本ずつ確かめればいい。結果はこうでした。

リンク 状態 担保している仕組み
OpenAPI YAML ↔ 生成型 ✅ 繋がっている CI(再生成 + git diff --exit-code
Rails 実装 ↔ OpenAPI YAML ✅ 繋がっている json_schemer による自前の検証(後述)
生成型 ↔ フロントの手書き型 ❌ 切れている なし。ここが唯一のギャップ

リンク①: OpenAPI YAML と生成型

フロント側の CI で OpenAPI を再ビルドして openapi.gen.ts を再生成し、git diff --exit-code でコミット済みのものと比較する。生成し忘れがあれば CI が落ちます。バックエンド側でも redocly のバンドルと lint が回っていて、YAML 自体の文法とスタイルも検査されています。

リンク②: Rails 実装と OpenAPI YAML

kickflow では json_schemer(JSON Schema のバリデータ gem)を使って、手書きの OpenAPI スキーマと実装を突き合わせる検証を自前で組んでいます。手書きしているスキーマをそのまま「契約」として扱い、実装がそこから外れたら機械的に検出する、いわゆる契約テストの考え方です。かつては契約テストの定番 gem である committee を使っていましたが、メンテナンスの停滞で OpenAPI 3.1 への対応が見込めなくなったため自前実装へ移行した、という経緯だけ補足しておきます。

検証の分担はこうなっています。

レスポンス側はテストで検証。 request spec に 1 行足すだけで、レスポンスボディがスキーマどおりかを検証できます。このアサーションは現在 50 を超える request spec ファイルで使われています。

example "200 OK かつレスポンスが OpenAPI スキーマに準拠している" do
  get api_v1_ticket_comments_url(ticket), headers: request_headers(user)

  expect(response).to have_http_status(:ok)
  assert_response_schema_confirm(200) # ← スキーマ違反でテストが落ちる
end

リクエスト側は本番のランタイムで検証。 Rack ミドルウェアが全リクエストを OpenAPI スキーマと突き合わせ、違反していれば 400 を返します。つまり「スキーマに書いていないリクエスト」はそもそも Rails のコントローラに到達しません。ちなみに、コントローラ内ではなくミドルウェアで検証しているのは、コントローラに届く頃にはパラメータのキャメルケース → スネークケース変換が挟まっていて、素直に突き合わせられないからです。こうした設計理由がコードコメントに残っているのはありがたいですね。

oRPC の 3 射影と並べると、あちらが「1 定義から検証を導出する」のに対し、こちらは「別々に書かれた実装とスキーマを突き合わせてズレを検出する」アプローチです。あちらはズレを構造的に作れなくし、こちらはズレが生まれてもすぐに検出する。手段は違いますが、ドリフトを放置しないという目的はどちらでも果たせます。

契約テストの限界

このアプローチにも効かない領域はあります。

  • 「定義外のキーが増えた」の検出は、スキーマ側で additionalProperties: false を宣言している場合のみ。OpenAPI は既定で追加プロパティを許すためです
  • ビジネス的な正しさは対象外。実装とスキーマが「同じ間違った形」で一致していれば素通りしますし、スキーマが緩ければ緩くしか検証されません

検証されるのはあくまで「実装がスキーマどおりか」であって、「スキーマが正しいか」ではありません。検出力はスキーマの質に依存します。

リンク③: 生成型とフロントの手書き型

というわけで、oRPC が保証するリンクのうち 2 本はすでに繋がっていました。唯一切れていたのがフロントの内側です。

せっかく openapi.gen.ts に生成型があるのに、中核のレスポンス型は手書きのまま並行管理されている箇所が残っています。

// 生成型を使っている(YAML が変われば自動追従する)
export type TicketFieldPermissions = components['schemas']['TicketFieldPermissions']

// だが中核のレスポンス型は手書き(並行管理 → ズレても気づけない)
export type Ticket = { /* ...手書きで全フィールド列挙... */ }

この状態だと、Rails とスキーマを変えて openapi.gen.ts が更新されても、手書きの Ticket 型は自動追従せず、ズレたままコンパイルが通ってしまう。リンク①②がどれだけ堅くても、最後のこの 1 本が切れていたらフロントのコードは守られません。

手書きレスポンス型を生成型に寄せる

幸い、直し方はシンプルです。手書きしていたレスポンス型の「出どころ」を生成型に切り替えるだけ。

import type { components } from '~~/openapi.gen'

// Before: 手書き(ドリフトする)
export type Ticket = { id: string; ticketNumber: number; /* ... */ }

// After: 生成型に委譲(YAML 変更がコンパイルエラーで顕在化する)
export type Ticket = components['schemas']['Ticket']

// フロント専用の派生フィールドが必要なら交差型で足す
export type DetailedTicket = components['schemas']['DetailedTicket'] & {
  // クライアント側でだけ持たせたい計算プロパティがあればここに
}

この方法のいいところは、API クライアントや呼び出し側のコンポーネントを一切変えなくていいことです。型の出どころだけが生成物に切り替わり、以後は型に影響する YAML の変更がコンパイルエラーとして顕在化します。

さらに踏み込むなら、openapi-typescript と同系列の openapi-fetch を使うと、パス文字列やパスパラメータまでコンパイル時に検査できます。ただ、既存の手書きクライアントの規約からの方針転換になるので、やるとしても新規エンドポイントから並走させる形かなと考えています。

まとめ

長くなってしまったので、最後に要点を振り返ります。

  • oRPC は「RPC のシンプルさ」と「OpenAPI 準拠」を両立した TypeScript 製フレームワーク。1 か所の定義からサーバー検証、クライアント型、OpenAPI が同時に出るところに価値がある
  • ただしその価値はサーバーが TypeScript であって初めて成立する。Rails バックエンドの置き換えにはならないし、BFF を挟む案は「型安全でない継ぎ目」を移動させるだけになりがち
  • oRPC を物差しに自分たちのリンクを棚卸ししたら、YAML ↔ 生成型は CI が、Rails 実装 ↔ YAML は json_schemer の自前検証(テスト + 本番ミドルウェア)がすでに担保していた。切れていたのはフロントの手書きレスポンス型の 1 本だけで、これは生成型への委譲で閉じられる
  • oRPC 自体は、今後新しく TypeScript のサービスを建てるときの選択肢として素直に有力

「流行りのツールを調べた結果、導入しないことにした」というオチですが、oRPC の設計を「リンクの束」として理解したことで、自分たちの運用のどこが繋がっていてどこが切れているのかを言語化できたのは大きな収穫でした。羨ましがるだけでなく、同じ構造を自分たちのスタックでどう作るか(そして実はどこまで作れているか)を確かめる。そういう調査の仕方もあるよ、という話でした。

同じように「Rails バックエンドだけど tRPC/oRPC が羨ましい」と思っている方の参考になれば幸いです。

We are hiring!

kickflow(キックフロー)は、運用・メンテナンスの課題を解決する「圧倒的に使いやすい」クラウドワークフローです。

kickflow.com

サービスを開発・運用する仲間を募集しています。株式会社kickflowはソフトウェアエンジニアリングの力で社会の課題をどんどん解決していく会社です。こうした仕事に楽しさとやりがいを感じるという方は、カジュアル面談・ご応募お待ちしています!

careers.kickflow.co.jp