# kosui > kosuiの活動記録。技術ブログ、登壇資料、外部メディア寄稿などを発信しています。 # Posts ## サーバサイドTypeScriptを選ぶ前に向き合ってほしいこと Coding Agentがコードを書く時代でも、アプリケーションコードを書くだけでは解決できない問題がある。サーバサイドTypeScriptが本当に自分たちの要求にフィットしているか、考えるきっかけにしてほしい。 私は、医療機関向けに幾多のサービスを展開する組織に所属し、認証基盤、ID基盤、ライセンス基盤、証明書基盤などを開発・運用するチームでテックリードをしている。この4年間、そうしたミッションクリティカルな領域のプラットフォームがサーバサイドTypeScriptで実現されているという状況に向き合い、苦しみ、そしてその苦しみを解決し続けてきた。そこから得た洞察を共有したい。 サーバサイドTypeScriptに限らず、せっかくこの記事を読んでくれている方々に対して、改めて技術やプログラミング言語に対する向き合い方を考え直す上でたった一つでもヒントを提供できたらうれしい。 ## なぜ今もプログラミング言語へ関心を払うのか さて、Coding Agentは、アプリケーションコードだろうがインフラだろうがDBのクエリだろうが、何でも設計して実装する。そうした環境にあって、昨今は「どの言語を選んでも目的を達成できる」という空気が広がっているように感じている。 だが、実行環境の特性、言語そのものの特性、非同期ランタイムの特性、ライブラリやフレームワークやSDKといったエコシステムの整備状況は、アプリケーションコードを書くだけではどうにも解決できない。アプリケーションコードをいくら書いても、例えばCPUバウンドな処理を苦手とする言語で複雑な計算処理をさせても性能は出ないし、VMの起動が遅い言語でサーバレス構成を選んでもスケールしないだろう。ビジネスやプロダクトの機能要求・非機能要求によってシステムに求められる能力は当然変わるし、それぞれの言語には必ず得意・不得意がある。コードをただ書き続ければそうした問題も解決するだろうという思考停止した態度をやめて、ビジネスとプロダクトと、何よりも技術ときちんと向き合い続けてほしい。 もちろん、求められる機能要求や非機能要求次第では言語の得意・不得意を無視できるかもしれない。そもそも自分たちでデータを持たないようなビジネスや、障害による影響が事業継続性へ大きな影響を与えないようなビジネスなら、とりあえず何の言語でもいいからコードを書いて、デプロイして、効果を測定して...というサイクルを高速に回していけばいいだろう。しかし、金融や医療、製造業や物流など、ミッションクリティカルな領域においてはその限りではないだろう。 ## TypeScriptを選ぶ目的を明らかにする ### コード資産の共有 サーバサイドにTypeScriptを選ぶ理由として最もよく聞くのは、フロントエンドとバックエンドで型やスキーマを共有したいというものだ。 しかし、スキーマを共有したいだけなら、OpenAPIを含めていくらでもやり方がある。複雑なロジックをコードで表現して共有したい場合であっても、例えばWASMという選択肢もあるはずだ。そもそも、フロントエンドとバックエンドで本当に同じコードを動かす必要があるケースはどれだけあるだろうか。例えばオフラインでも稼働する医療システムの診療報酬計算ロジックのように、ネットワークが切断された状態でもクライアントサイドで同じ計算結果を再現しなければならないケースであれば、コードを共有する明確な理由がある。しかし、そうしたケースは限られている。 他にも選択肢がある中で、それでもTypeScriptを選ぶ理由があるならば、それを感覚的なものから体系立てられた言葉にすることが大事だ。 ### 人材採用における母集団の広さ フロントエンドからサーバサイドに加え、IaCや負荷試験ツール、ファームウェアに至るまで、様々な領域でTypeScriptが利用されていることから、TypeScriptを使用したことがある人間の数は増えている。それを踏まえ、「TypeScriptは人材を確保しやすい」とする企業もちらほら見かける。 しかし、果たして本当に採用したい人材は「TypeScriptの経験者」だろうか。様々な領域でTypeScriptが利用されているとしても、領域によって払うべき関心は違うから、求められる設計もかなり変わってくるはずだ。それなら、それに適合できる人材の範囲は「TypeScriptの経験者」よりもきっと狭いだろう。 例えば、非機能要求という観点で見れば、フロントエンドでは使用性が重要だ。想定しないエラーが発生した時には、わざわざResult型を伝搬するよりも、例外をぶん投げてそれをキャッチし途中までフォームに入力された情報をどこかへ退避させた上で適切なエラー画面を提示する方が良いというケースが多いだろう。 一方、バックエンドであれば機能完全性や可用性が重要であって、エラーが発生した時には問題の種類を正しく判別できるように伝搬し、それを踏まえてロガーや関連するサービスなどへ情報を伝達し、DBのトランザクションを正しくキャンセルし、規定されたインタフェース通りにレスポンスを返し、必要に応じてリソースを解放するはずだ。 言い換えれば、例外による大域脱出を伴わないエラーの表現や、トランザクションやコネクションプールなどの管理はバックエンドという領域に特有の関心である。ゆえに、同じTypeScriptという言語であっても、求められる考え方、設計やパターンは大いに変わりうるのだ。 加えて、同じ領域であったとしても、それに対する解決策は組織やチームによって大いに異なるだろう。デコレータとクラスを活用したオブジェクト指向を採用するチームもあれば、関数型ドメインモデリングに基づいた設計を採用するチームもあるだろう。もしかしたら今もこの言語をプロトタイプベースとして使い倒しているチームもあるかもしれない。 もちろん、様々なパラダイムへ柔軟に適応できる人材ならどのチームでも活躍できるのかもしれないが、大いなる力には大いなる予算が必要だ。サーバサイドTypeScriptを採用する上で「人材採用における母集団」を理由に挙げるのであれば、自分たちがチームに必要とする人物像を明らかにしておこう。 ## 言語の特性と向き合う TypeScriptを選ぶと決めたなら、この言語が持つ特性を理解した上で向き合う覚悟が必要だ。TypeScriptには構造的部分型、型消去、プロトタイプベースという3つの特性があり、それぞれが固有の落とし穴を生む。これまでこのテーマで何度も記事を書き登壇したが、改めて振り返っておく。 ### 構造的部分型 TypeScriptの型の互換性は、クラス名ではなく構造で決まる。 ```typescript class User { name = "" } class Product { name = "" } const greet = (u: User) => `Hello, ${u.name}`; greet(new Product()); // エラーなし ``` `User`を受け取る関数に`Product`を渡してもエラーにならない。プロパティの構成が同じだからだ。テストダブルの差し替えが容易になるなどの利点はあるが、意図しない型の混同を許してしまうリスクも持っている。 ### 型消去 TypeScriptの型情報はトランスパイル時にすべて削除される。型検査の時点では高機能な型システムの恩恵を受けられるが、実行時には単なるJavaScriptだ。構造的部分型によって`Rectangle`型として受け入れられたオブジェクトに対して`instanceof Rectangle`がfalseを返すということが起こりえる。型検査時のメンタルモデルと実行時の振る舞いがずれる。 ### プロトタイプベースとclassの限界 JavaScriptのclassはprototypeに基づいて構築されており、`this`の指す先は呼び出し方で動的に決まる。メソッドを変数に代入して呼び出した瞬間に`this`がundefinedになり、TypeErrorで落ちる。型検査はこの問題を検出しない。 ECMAScriptのclassの表現力は他言語に比べてかなり限定的だ。`#private`が入ったがTypeScriptの`private`とは別物だし、継承時のsetterの振る舞いも`useDefineForClassFields`というフラグで変わる。これはTypeScriptがES2015より3年先にclassを実装し、後からECMAScript仕様と統合する過程で生まれた歴史的産物だ。同じコードでもtsconfigの設定で振る舞いが変わるという状況は、この言語を選ぶなら理解しておかなければならない。 そして、型の表現力が高いということは、それだけ自由度も高いということだ。行き過ぎた抽象化や難解なメタプログラミングを誘発する原因にもなりえる。 ### 私なりの乗り越え方 #### 全てを値で表現する こうした特性と4年間向き合った結果、私はclassを使わずに全てを値で表現するアプローチに辿り着いた。TypeScriptが構造的部分型を採用しているのだから、あらゆる情報をプレーンなオブジェクトで表現すれば型検査時と実行時の挙動に悩まされることもほぼない。 ```typescript import { z } from 'zod' import { UserId } from './userId.js' // Branded Type import { UserDisplayName } from './displayName.js' // Branded Type // エンティティは単なるオブジェクト const userSchema = z.object({ kind: z.literal('User'), id: UserId.schema, name: UserDisplayName.schema, }) export type User = z.infer // 振る舞いは単なる関数 const rename = (user: User, name: string): Result => user.name === name ? err({ kind: 'SameNameError' }) : ok({ ...user, name }) export const User = { schema: userSchema, rename, } as const ``` Branded Typeで構造が同じ型を区別し、Discriminated Unionで種別を判別し、thisを持たない関数で振る舞いを表現する方法だ。ただ、この方法も別に万能でもなんでもないし、注意点もある。詳細は下記の登壇資料や私が提供するCoding Agent向けスキルを見てほしい。
いずれにせよ、TypeScriptの特性を理解した上で、自分たちの領域に合った付き合い方を見つけることが大事だ。 ## 実行環境の特性と向き合う ところで、言語そのものだけではなく、実行環境にも目を向けてほしい。 Node.jsはI/Oバウンドなタスクを得意としている。データベースや外部サービスとの通信が支配的なワークロードでは十分に力を発揮する。 一方で、CPUバウンドなタスクはシングルスレッドという構造からして苦手だ。あるリクエストのCPUバウンドな処理をCPUが捌いている間、他のリクエストは全て待たされることになる。 Worker Threadsを使えばマルチスレッド化できるという反論はあるだろう。しかし、スレッドプールの管理やスレッド間のメッセージングなど、考えるべきことはたくさんある。その複雑性を受け入れてまでNode.jsでCPUバウンドな処理を行う必要があるのか。その要求にもっと自然にフィットするランタイムや言語があるのではないか。 私が業務で扱っている認証基盤でいえば、パスワードのハッシュ化がまさにCPUバウンドな処理だった。医療システムの認証基盤としてセキュリティは最重要であり、パスワードのハッシュ化アルゴリズムについてもセキュリティ観点での妥協はしがたい。一方で、ハッシュ化ライブラリによっては、libuvを活用したマルチスレッド化ができていることもあれば、Worker Threadsを利用したマルチスレッド化をライブラリの利用者に委ねていることもある。これはまさに冒頭で述べたような、ビジネスやプロダクトの要求と言語や実行環境の特性が相反するケースだ。 ただ、CPUバウンドな処理が支配的であっても、AWS LambdaやCloudflare Workersなどのサーバレス環境で実行し、1つのリクエストに対して1つの実行環境を割り当てるなら、この問題はかなり緩和される。少なくとも、あるリクエストが他のリクエストに迷惑をかけることはなくなるだろう。 つまり、ビジネスやプロダクトの要求に対して、言語だけでなく実行環境の得意・不得意がマッチしているかよく考える必要がある。 ## おわりに 最近、サーバサイドTypeScriptを辞める組織もあれば、今からサーバサイドTypeScriptに移行しようとする組織もある。 自分がそうした立場に立った時、チーム内外、そして組織内外へ説明責任を果たさなければならないだろう。本当にそうした移行をしなければならないのか。言語をスケープゴートにして本質的なプロダクト品質やチーム体制の課題から逃げていないか。目の前にある技術とどこまで向き合ったのか。 私は4年間、サーバサイドTypeScriptに非常に苦労しながら向き合い続けて、やっと自分たちのやり方が見えてきた。安易に選ぶのでも安易に辞めるのでもなく、ビジネスとプロダクトと、何よりも技術ときちんと向き合い続けたい。 ## SaaS企業のプラットフォームチームとして大切にしていること コンパウンドスタートアップを謳うSaaS企業が抱える課題を、プラットフォームチームはどのような考え方で解決するべきか提案する。 医療業界向けにサービスを展開するカケハシにおいて、私がプラットフォームチームのテックリードをする中で大切にしていることをできる限りこの記事にぶつける。 生成AIを使わずに執筆してみる。そもそも私が書く記事は以前から「論文チックに構造化しがち」と言われることも多いので、私が手書きしても生成AIっぽさが出てしまうが、これまで以上にメッセージやエゴを強く込めることでそれを払拭できていれば嬉しい。 なお、なるべく端的かつ一般化された読み物にしたいので、今回は具体的な事例やエピソードは省略する。 ## はじめに ### SaaS企業の実態 いつ頃からかは覚えていないが、日本でもコンパウンドスタートアップを謳う企業が相当に増えた。というか、日本のSaaS企業のほとんどがコンパウンドスタートアップを名乗っている気がする。これはただ単にコンパウンドスタートアップという言葉が流行っているというよりも、日本というミドルパワーな国家の企業が課題を解決するビジネスをするならば、深いドメイン知識を活かして同じドメインの中で多様なサービスを提供していくことが生存戦略として定石であるからだ。もっと人口が多ければシングルプロダクトでも十分に戦えるだろうし、もっと人口が少なければ自国に特化せずにグローバルなプロダクト展開を目指したほうがいいからだ。 しかし、コンパウンドスタートアップを自称するSaaS企業の多くは、実態としてはただ単に複数のプロダクトを垂直に立ち上げたまま、顧客体験とデータの両面で十分な相互連携を実現できていない。この問題を放置すると、単に複数の独立した事業を展開していることとなり、メガベンチャーがやっているカンパニー制をより小さな資金力と人員規模で再現しようとするようなものであり、遠くない未来に存続が難しくなるだろう。つまり、コンパウンドスタートアップとして利益を出すためには、複数のプロダクトがシームレスに連携して相互に価値を高め合う必要がある。 これは、例えばUIコンポーネントを揃えることで解決するような問題ではなく、品質要求の多様化・高度化へ横断的に応え、データの相互運用性を高めていく必要がある。 #### 品質要求の多様化・高度化 金融や医療や物流など、多くの分野ではシステムが24時間365日稼働することが当然に求められるようになっている。これまでオンプレミスで稼働しているシステムが多い事業領域でも、クラウドサービスを提供する事業者が増え、可用性に対する当たり前品質が高まっている。 サイバーテロも高度化している。金融庁や厚労省がそれぞれの事業者へ遵守を課しているガイドラインでは、二要素認証や監査ログなどのサポートが必須化されつつある。 そのような中で、コンパウンドスタートアップを謳うにも関わらず、それぞれのプロダクトチームが個別に求められている品質要求を検討し実現しようとするならば、顧客と向き合う時間をいたずらに減らし、プロダクトの進化を停滞させることになるだろう。 #### データの相互運用性 SaaS is deadという言葉が独り歩きしているが、実態としてはSaaSの役割が大きく変わりつつある。生成AIだろうが他社のSaaSだろうが独自の神マクロ付きExcelだろうが、それらとシームレスに連携できるように、公開されたインタフェースから洗練されたデータモデルを提供することがSaaSとして求められる能力となる。 そのような中で、顧客が求めているのはSaaS企業が提供するプロダクトに蓄積された多様なデータをシームレスに連携して活用できることだ。例えば、プロダクトごとにID体系が異なるような状況で、誰がそんなデータを連結して活用したがるのか。自分が顧客ならそんなプロダクトを今の時代に新規で選定したいか? ### 読者へ この記事が、SaaS企業で社内プラットフォームシステムを提供するチームがどのような振る舞いをするべきかを考え、戦略的に行動するためのヒントやきっかけになればうれしい。 プラットフォームチームが存在しない組織で働いている人は、他のプロダクトチームの同僚と一緒に、数年後の自組織の事業のあるべき姿を考えて、そこからプラットフォームとして必要なものを逆算してみてほしい。 プラットフォームチームで働いている人は、チーム内外の同僚と一緒に、自チームが提供するプラットフォームにおいて、あるべき姿、足りているもの、不足しているもの、過剰であるものを分類してみてほしい。 既にプラットフォームがある組織でプロダクトチームとして働いている人は、プラットフォームチームだけではなく他のプロダクトチームとも連携することが大事であることを理解し、まずは雑談でも良いから他チームと対話するきっかけにしてほしい。 ## SaaS企業のプラットフォームシステム ### 認証基盤に求められること プラットフォームチームとしてあるべき姿を考えるために、まずはプラットフォームシステムとして求められることを考えよう。 SaaS企業のプラットフォームチームは、多くの場合において認証基盤の統一から始まる。複数のプロダクトを導入した顧客にとって、一番最初に課題を感じる領域だからだ。それに、エンタープライズへの展開を目指すのであれば、パスワードの最低条件の厳格化や監査ログの記録が求められるだろうし、顧客のID基盤とのSSOが顧客のコーポレートIT組織にとって必須であることが多い。 ### 認証基盤統一の功罪 しかし、実は複数のプロダクトが同じ認証基盤へ接続するということは、大きな価値とリスクを同時にもたらすことになる。 まず、認証基盤を統一することで得られる価値は、何よりもそのユーザーに関するメタデータを全てのプロダクトへ提供できるインタフェースを構築できることだ。そのユーザーの情報だけではなく、ユーザーが所属する店舗やグループや組織、さらにそれらに関連するライセンスや権限情報も全てのプロダクトへ提供することができる。今はそこまで作り込むことができなくても、将来的にそれぞれのプロダクトが独自に顧客のグループや権限を管理する必要をなくすための、とても大事な一歩になる。 一方で、これは裏返せば、誤った設計が全てのプロダクトへ不可逆的に伝搬させてしまうリスクも持っている。例えば、恒久的に一意であり汎用的なフォーマットのユーザーIDを提供できれば、将来的にプロダクト間のデータ連携も容易だろう。しかし、ユーザーIDとしてメールアドレスを採用している場合、メールアドレス変更機能が実装された時に各プロダクトのユーザーIDをリアルタイムに更新できなければ容易に障害となるだろう。また、ユーザーIDの体系が世代によってブレてしまうと、それぞれのプロダクトチームがそれに合わせて複雑なバリデーションや条件分岐を実装しなければならなくなる。 一度でも社内へ公開されたデータとその振る舞いは、その瞬間にプロダクトチームが依存しうるものになる。「プラットフォームチームが新しい完璧なID体系を構築したから、プロダクトチームの皆さんは今から1週間以内に移行してください」なんてことができるなら別にこのリスクを気にしなくてもよいが、プロダクトチームにはプロダクトチームのロードマップがあり、予算があり、人員計画があるから、そんな簡単に話は運ばないことに注意すべきだ。 > With a sufficient number of users of an API, > it does not matter what you promise in the contract: > all observable behaviors of your system > will be depended on by somebody. > > 十分にユーザー数が多いAPIにおいては、 > 契約で何を約束しているかは問題ではなく、 > すべての観測可能な振る舞いは > 誰かしらによって依存される > > [ハイラムの法則](https://www.hyrumslaw.com/) ### 顧客ID基盤に求められること 認証基盤が統一された後、これを軸足に顧客ID基盤を展開できる。 これまで各プロダクトが独自に持っていた組織・店舗・グループなどの情報を、統一的かつ一元的に管理できれば、先述のデータ相互運用性を大きく向上できる。認証基盤と接続すれば、それぞれのプロダクトへユーザーがログインする時に、様々な関連データをプロダクトへ提供可能となる。 そして、ある程度普及してきたら、それぞれのプロダクトが顧客ID基盤からのデータ提供を望むだろう。あるプロダクトはAPI連携を要望し、あるプロダクトはイベント駆動を望み、あるプロダクトはデータ基盤で連携することを望むかもしれない。 さらに、顧客のシステムやデータ基盤ともうまく連携できれば、あらゆるプロダクトのデータ活用のハブとして顧客ID基盤はそれそのものが価値を生み出すことになるだろう。プラットフォームチームがコストセンターとして捉えられていた時代から脱却できると思えば、なかなか夢のある話ではないだろうか? ### 顧客ID基盤が注意するべきこと ここで、顧客ID基盤を提供する上で注意するべきことを考える。既に認証基盤の功罪でも述べたように、ここで公開するインタフェースは一度利用が開始されれば半永久的に後方互換性が求められ、抜本的なデータモデルの変更はきっと不可能になるだろう。 先ほども少し述べたが、プロダクトチームが求める連携手段は多様だ。外的要因によって予算やロードマップが変化しやすいプラットフォームチームにおいて、これらの要求にすべて応えることは難しい。 ここで、「まずは提供できるインタフェースから開発・運用し、段階的に裾野を広げていこう」と思うかもしれない。それはプロダクト開発なら非常に正しい考え方だ。 しかし、プラットフォームシステムの開発ではそうもいかない。ソフトウェアは人間より圧倒的に変化に対して柔軟性に乏しい。一度でもあるAPIやデータへの依存が生じた場合、そのインタフェースを廃止するコストは、そのインタフェースを公開するコストよりもずっと高くつく。 ところで、顧客ID基盤を提供する上で、実はもう一つ注意を払うべきリスクがある。 ### 可用性とパフォーマンスの問題を後回しにできるか 認証基盤も顧客ID基盤も、プロダクトチームにとっては必要不可欠なシステムであり、単一障害点であるとも言える。 結果として、これらの基盤は、顧客がプロダクトへ要求する可用性とパフォーマンスを背負うことになる。 一般的なプロダクト開発においては、品質要求について「問題が生じたら対策しよう」という方策を採用しやすい。可用性やパフォーマンスが顧客の要求を満たさない場合、一時的なスケールアウトや流量制限、機能縮小によって止血し、恒久的な対策を並行して進めることができるだろう。 しかし、プラットフォームシステムの開発はその限りではない。特定のプロダクトのワークロードが大きく変化した場合、その影響はプラットフォームを通じて他のすべてのプロダクトへ波及してしまう。全プロダクトのトラフィックを予測したキャパシティプランニングはとても大変だし、月日の経過によって変化してしまう。場当たり的なスケールアウトは顧客の信頼を損なうことになるので、「とりあえず最悪ケースを想定して大量にインスタンスを積んでおく」という結果につながるだろう。 さらに、プロダクトへ提供しているインタフェース次第では、そうした場当たり的な対応すら困難な場合がある。 例えば、イベント駆動を採用している顧客ID基盤があるとして、イベント配信を実現するための設計に根本的な問題があるとしよう。その基盤ではAWS EventBridgeを採用していたが、秒間で発行できるイベント数において引き上げ不可能なクオータにぶつかってしまったとする。 この場合、「来週からGraphQL APIを提供するので、来月までに切り替えてください」とすることもできないし、お金を払ってどうにかすることもできないし、これまで配信していたイベントの設計を抜本的に見直すとしてもプロダクト側が対応しなければ意味がない。顧客ID基盤への書き込み操作について流量制限しようにも、プロダクトごとにクォータを管理する仕組みを構築しなければ、組織全体へ与えるビジネス影響をコントロールできなくなってしまう。 ### コラム: IDaaSを採用するべきか 2020年代前半頃は、「自分たちで認証基盤を作らずにIDaaSを契約して運用するべきだ」という考えが一般的だった。SaaSを展開するスタートアップにおいては認証基盤はコストセンターであり、自分たちで内製してセキュリティリスクを背負うことはあまりにも不合理だと思われていたと思うし、当時は私もそのように考えていた。IDaaSはログイン画面やOAuth/OIDC/FAPIサポートを提供するだけでなく、その裏側にあるユーザー情報・組織情報・認証情報などをすべてマネージドに管理してくれるサービスであり、認証の専門家を抱えなくても高い品質で認証を実現できるものだ。 しかし、その後はAuthleteやOry Hydraのような、OpenID Connectの複雑なロジックの解決だけを肩代わりし、ユーザーや組織の情報、またパスワードやパスキーなどの認証情報は自分たちで管理する選択肢が増えてきた。また、IDaaSから非IDaaSへ乗り換える企業も増えている。 - [Authlete が医療系マルチプロダクトを展開するカケハシの共通認証基盤に採用](https://www.authlete.com/ja/news-jp/kakehashi-adopts-authlete) - [CADDiプロダクト横断の認証認可基盤を開発している話 - CADDi Tech Blog](https://caddi.tech/archives/4288) - [認証基盤をCognitoからOry Kratosへ:B2B SaaSの「当たり前品質」を守るためのリプレイス戦略](https://zenn.dev/dress_code/articles/212e5572b3cd9b) - [freee が Authlete を採用: オープンプラットフォームの中核を担う OAuth 2.0 基盤の刷新を実現](https://www.authlete.com/ja/news-jp/freee-adopts-authlete) これは、単にIDaaSの料金体系が成長した各SaaS企業のニーズに合わなくなっているだけではない。 顧客がデータの相互運用性をSaaS企業へ求めるようになったため、これまでIDaaSが管理してきた様々なデータを、各SaaS企業のデータ基盤やID基盤と高度に連携させる必要が生まれているのだ。 さらに、レガシーかつガラパゴス化した顧客のIT環境・商慣習の上で、高度化するサイバーテロへ備える体制を成立させるためには、グローバルなIDaaS企業が提供する選択肢がフィットしないことが少なくない。日本では工場や医療や金融などの領域で閉鎖的なネットワーク構成に置かれた共用端末を採用しているケースが多い。そもそもGoogleアカウントやMicrosoftアカウントを持っていないユーザーや、メールアドレスを持たないユーザーも想定しなければならない。共用端末を前提とした環境にあって、ブラウザの拡張機能として利用するパスワードマネージャーを導入したり、OSやブラウザが提供するパスキーを前提としたサービスを提供したりできるだろうか。 ## プラットフォームチームのあるべき姿 ### ゴールから逆算して小さくつくる プラットフォームチームは、プロダクトチームよりも長期目線でビジネス戦略やプロダクト戦略や技術戦略を考え、そこから逆算して今この瞬間にできることを考えなければならない。 これまで散々述べてきたように、プラットフォームというものはほとんどの場合において後方互換性を担保しなければならない。場当たり的な拡張が許されないから、最終的なプラットフォームシステムの理想状態を常に頭の片隅に浮かべておきつつも、その予測が外れても問題がないようにI/Fの追加・変更は小さくしておきたい。 もちろん、それぞれのプロダクトの戦略はそのプロダクトのリードが一番詳しいだろうし、外的要因によって市場環境はすぐ変化してしまうから、プラットフォームチームという立場から完璧な未来予想をすることは不可能だろう。しかし、どのような未来がありえて、その未来による影響範囲はどれほどであるか、その未来の発生確率はどれほどか、見積もっておくことが重要だ。 例えば顧客ID基盤ひとつとっても、「この業界で最も大規模な顧客企業の従業員数・店舗数はどれくらいか」が分かれば、テナントを分離する時のテナントあたりの最大のデータ量を見積もることができる。今は難しくても、将来的にそのデータ量を捌くことが理論的・原理的に可能かどうかは気にしておくべきだろう。 ### 変更の可逆性を大切にする 人間は柔軟なエージェントシステムであり、UI/UXの変化へ自律的に順応できる。もちろん、例えばみどりの窓口で駅員さんが高速に操作する業務システムのUIを突然に変えてしまったら、凄まじい顧客影響を生むことになるだろうが、それでも最終的には順応してしまうのが人間だ。 しかし、プロダクトはあくまでプラットフォームシステムの機能・品質が後方互換性を持っていることを前提としている。ID体系であれ、データモデルであれ、連携プロトコルであれ、一度公開されたインタフェースは簡単に変えられない。 だから、可逆的な変更はすぐにやってしまえばいいし、不可逆な変更にはなるべく慎重になるべきだ。手を動かしてトライアンドエラーをすることは大事だが、そのインタフェースは半永久的に公開されるものか、その設計は今後数年間に渡って変更し難いものなのか、慎重に考えて進もう。 ### ステークホルダーと対話し続けよう ほとんどのプロダクトチームは、普段はプラットフォームチームのことを意識しない。というか、その方がプラットフォームとして望ましい状況だろう。 ただし、プロダクトチームがプラットフォームチームへ何かリクエストしたくなった時、ほとんどの場合はもう十分な時間が残っていないものだ。「リリースまであと1か月ですが、何とかしてください」という状況では、ゴールから逆算してつくるどころではないだろうし、大体の場合はそういう時に実質的に解消不可能なほど巨大な技術的課題が生まれるものだ。 だから、今はプロダクトチームから求められていなくても、積極的にプラットフォームチームからプロダクトチームと会話しよう。 そして、実はプロダクトチームだけがステークホルダーではない。カスタマーサクセスやセールスのように顧客の課題を最先端で診ているチーム、契約管理や請求管理など重要なデータを管理しているチームなど、プラットフォームチームが関心を払うべきステークホルダーは多岐にわたる。すべてのステークホルダーと密接に連携し続けることは難しいから、それぞれのチームと自分たちの関連度を評価し、関連度が高いチームから優先的にコミュニケーションを図ろう。いきなり定例ミーティングを設定してもどうせうまくいかないから、まずは「あなたたちのチームは私たちにとって重要です!どんな課題を抱えているのかぜひ知りたいです」というメッセージと共に1on1を申し込んでもいいだろう。相手の立場に立って課題をヒアリングすることから始めよう。思わぬ発見があるかもしれないから。 ## おわりに 一緒にカケハシのプラットフォームを作りませんか。日本の医療の現場が直面している状況はそれなりにシビアですが、私はカケハシと一緒に少しでも日本の医療をマシにするつもりです。プロダクトチームは今困っている患者や薬剤師の課題に日々向き合ってくれていますが、プラットフォームという立場だからこそできる中長期的な取り組みがあります。この記事でもわかるように私たちが向き合っている課題は決して簡単ではありませんが、だからこその面白さがあります。ぜひ一緒に頑張りましょう。 [【認証・権限基盤】バックエンドエンジニア - 株式会社カケハシ](https://herp.careers/v1/kakehashiinc/eozlQqcnBbSQ) ## 人を増やしても減らしてもアウトプットの品質は向上しない 人を増やしても減らしても組織のアウトプットの品質は向上しないと考えています。前半では知識のエントロピーが文脈共有コストと中央集権化を引き起こす構造を、後半では自律的な存在の限界と規範レイヤーの設計について検討します。 ## 結論 人を増やしてもアウトプットの量は人数に比例して増えませんし、人を減らしてもアウトプットの品質は下がります。優秀な人やAIエージェントを増やしても、組織のあちこちでバラバラなものができたり、ナレッジが古くなって信用できなくなったり、特定の人に判断が集中して燃え尽きたりといった問題は残ります。 これを防ぐためには、組織として「何を許し、何を禁じるか」を、できるだけ少ないルールとして明文化することが必要だと考えています。ただし、ルールを増やしすぎると、組織はそれを守ることに気を取られて動きが遅くなります。だからこそ、壊れると最も困る機能や品質に絞って、リスクの大きさに応じて詳しさを決めることが大切です。 ## 「人を増やすか減らすか」の前にある問い チームの人数が増えるほど、それぞれのメンバーに求められる情報の発信量・受信量が増えていき、やがて情報の伝搬速度がチーム開発のボトルネックになりがちです。特に、AIエージェントが登場してからは表面上の開発速度が上がったように見え、「人間の人数を減らせば減らすほど、意思決定の速度は上がり、迅速に価値を提供できる」と考える人はいるでしょう。 一方で、個々ができることが増えたのだから、自律的な存在に開発テーマやエピックをまるっと担当させれば、コンテキストの伝搬を省略してデリバリー速度を上げられると考えることもできます。つまり、「自律的な存在を増やせば増やすほど、組織のアウトプットの総量が増える」と考える人も存在します。 その結果、「人を減らした/増やした方が組織のアウトプットは増えるのか」という二元論が度々話されることになるでしょう。しかし、こうした極端な二元論で考える前に、隠れた他の変数があるか考えるべきです。 この記事では、まず人を増やしても減らしてもアウトプットの品質は向上しない理由を整理し、その上で、ではどのように組織を構造化すれば良いかを検討していきます。 ## 人を増やしても量は増えない ソフトウェア開発の古典である『[人月の神話](https://en.wikipedia.org/wiki/The_Mythical_Man-Month)』は、人を追加することで生産量が増えていくわけではないことを示した本です。同書では、人数が増えるほどコミュニケーションパスが指数的に増えていくと指摘されています。N人いれば N(N-1)/2 のパスを抱えることになるので、10人なら45本、20人で190本、30人なら435本ものパスを抱えることになります。 ただし、人数の問題だけではないように思います。人数のみならず、各人がカバーする領域の広さによっても文脈共有コストは大きく変わります。 例えば、フロントエンドとバックエンドを分けて担当しているチームと、一人ひとりが両方を見ているチームを比べてみます。前者は役割を分けたことで、一人ひとりが抱える領域は狭くなっていますが、フロントエンドとバックエンドの境目では、すり合わせが頻繁に必要になります。後者は1人で全部見ているので、すり合わせは要らないかわりに、一人ひとりが知っておくべき範囲が広くなり、お互いのレビューでは双方が広い前提を分かっている必要があります。 このような、メンバーが持つ知識分布のばらつきを「知識のエントロピー」と呼ぶことにします。知識のエントロピーが大きくなるほど、互いに事前共有しておかないと整合しない事項が増え、文脈共有のための時間が増えます。 ```mermaid flowchart TD A1["知識のエントロピー"] B["すり合わせが必要な情報量"] C["文脈共有時間"] A1 -->|増やす| B B -->|増やす| C ``` つまり、人を増やすことで組織は、コミュニケーションパスの増加と知識のエントロピーの拡大の両方に直面することになるのではないでしょうか。 ## 人を減らしても品質は下がる では、逆に人を減らせばコミュニケーションパスが減るので、文脈共有が容易になって品質が上がるのでしょうか。残念ながら、そう単純ではないように思います。 人を減らしても、解くべき問題の複雑さは変わりません。例えば、5人で見ていた領域を3人で抱えることになれば、1人あたりの担当範囲は広がります。一人ひとりが抱える知識の幅、つまり一人ひとりが抱える知識のばらつきは広がるため、すり合わせの手間はむしろ増えていくかもしれません。同時に、各人の認知負荷が積み上がっていきます。 組織のアウトプットを支えているのは、メンバー同士の意思決定の速度と、判断を任せ合える分散度です。テックリードがすべてのプルリクエストを詳細にレビューするまでマージできないチームよりも、それぞれのメンバーが相互にレビューすることで十分に情報を共有し品質を担保できるチームの方が、アウトプットは多いでしょう。 ## リードへの依存 人を増やしてもコミュニケーションパスと知識のエントロピーが広がっていきますし、人を減らしても1人あたりの担当範囲が広がって、一人ひとりが抱える知識のばらつきが大きくなっていきます。いずれの場合でも、文脈共有時間と認知負荷が増えていき、意思決定の速度は直接遅くなりますし、分散的な意思決定を可能にするチームへ移行する準備もままならなくなってしまいます。 意思決定が遅くなれば、組織は短期的な逃げ道として特定の人が判断する体制に移行します。「テックリードやEM、PdMさえ承認すればその意思決定はチームで合意済みとみなす」という体制を見たことがある読者はきっと多いでしょう。 中央集権体制は意思決定速度を一時的に取り戻しますが、分散意思決定とは構造的に両立しません。さらに長く続けば、判断する側に認知負荷が積み上がり、判断品質や育成に割ける時間が削れていきます。 ```mermaid flowchart TD A1["知識のエントロピー"] C["文脈共有時間"] D["意思決定速度"] E["リードへの依存"] Z["学習機会の減少"] A1 -->|増やす| C C -->|落とす| D C -->|強化する| E E -->|悪化させる
中央の疲弊| Z Z -->|増大させる| A1 ``` このループは、人を増やしすぎたチームでも、人を減らしすぎたチームでも、同じ均衡点に向かっていきます。組織のアウトプットの質は「どれほど文脈が共有されているか」「どれほど意思決定を分散できるか」に大きく左右されると思います。「人を増やすか減らすか」の議論は、この構造に与える影響を語らない限り、あまり意味を持たないのではないでしょうか。 ## 議論すべきは人数ではなく構造 組織のアウトプットを支配しているのは人数そのものではなく、知識のエントロピーと、それが引き起こす中央集権化のループであるように思います。 では、人数を変えずに品質を改善するには何ができるでしょうか。一つの方向性として、自律的な存在を増やすという発想があります。これは人増減の二元論を超える解になりうるのか、次に検討していきます。 ## 自律的な存在は何を解決し、何を解決しないか 自律的な存在の一例として、仮にホラクラシー組織、つまり自主経営型のメタルールで知られる組織形態を経験した人材を起用すれば、知識のエントロピーやリードへの依存を減らすことが期待できそうです。あるいは、自律的な存在のもう一つの形として、高度なAIエージェントを導入すれば、文脈共有時間そのものが大きく減少し、意思決定が加速するかもしれません。 ただし、自律的な存在を増やすだけで前章のループから脱出できるとは限らないように思います。1人もしくはごく小規模なチームで構成された自律的な存在たちは、その場その場で「目の前にある課題を解決するための70点」を見つけることが得意です。しかし、ある程度のサイズの組織やチームでそれぞれの局所最適をそのまま許容することは、やがて大きな問題をもたらします。 ここでは、自律的な存在に期待してもなお残る3つの問題を見ていきます。 ### 一貫性の喪失 ![「ユーザー名」というラベルから3つのプロダクトに矢印が分岐し、それぞれの中身がメールアドレス・氏名・ハンドルIDとバラバラになっている図](/images/knowledge-entropy/inconsistency.svg) 成果への要求が強まる中で局所最適を許容すると、UI・データモデル・命名のばらつきが累積していきます。次の実装者が既存実装を読む手間が増えるため、ゼロから書きたくなる動機が生まれ、ばらつきがさらに加速する強化ループに入ります。同時に、ばらつきはレビュー側の負担を増やしていきます。リードとして一貫性を持った意思決定ができるような優秀なレビュワーが一所懸命にばらつきを収束させようとすればするほど、前章で見たようにリードへの依存が強化され、やがて前章と同じ問題にいきつきます。 ```mermaid flowchart TD B["局所最適な実装"] C["UI・データモデル・命名の
ばらつき"] F["レビュー負担"] G["リードへの依存"] B -->|累積させる| C C -->|読む手間が増え
ゼロから書きたくなる| B C -->|増やす| F F -->|増やす| G ``` ### ナレッジの劣化 ![最古の ADR v1 の中の○印を、AIエージェントが吹き出しの中にそのまま採用して返している図。背後では別の結論(△・□)を持つ新しい v2・v3 が無視されたまま薄く転がっている](/images/knowledge-entropy/knowledge-decay.svg) AI によるナレッジベースへの書き込みの頻度が高まるほど蓄積される知識の量は増えますが、ナレッジベースを剪定する役割が存在しなければ、加速度的に矛盾と陳腐化が累積し、AI の回答精度が落ちます。 生成されるナレッジの品質を人間が躍起になって修正しようとすればするほど、書き込みの頻度がさらに増えていきます。しかし、あくまでそれぞれが個別に判断し修正する構図は変わらないので、矛盾・陳腐化は悪化していきます。同時に陳腐化は人間側でナレッジベースへの不信を生み、暗黙知化を進めて知識のエントロピーを押し上げます。 ```mermaid flowchart TD A["AI による書き込み"] C["矛盾・陳腐化"] F["人手での修正"] H["暗黙知化"] I["知識のエントロピー"] A -->|蓄積させる| C C -->|品質低下を招き| F F -->|増やす| A C -->|不信を生み| H H -->|押し上げる| I ``` ### 自律的な人間の燃え尽き ![巨大に積み上がった仕様書とコードのスタックを、人間2人とAIロボットが見上げて立ち尽くしている図。上空には光輪と翼を持ち、燃え尽きて天に召されつつある自律的な人間が浮かんでいる](/images/knowledge-entropy/burnout.svg) AI を使いこなす度合いが高い人ほど一人にかかる判断の量が増え、認知負荷が育成に割ける時間を圧迫します。育成が止まれば後進に任せられない状態となり、特定の人への判断集中がさらに強まる強化ループに入ります。 こうした特定の人への認知負荷の累積は、やがて判断品質の低下や離脱を呼び、残ったメンバーの負担へと転じます。 ```mermaid flowchart TD F["特定の人への判断集中"] C["認知負荷"] D["育成時間の不足"] G["判断品質低下 / 離脱"] H["残ったメンバーの負担"] F -->|増やす| C C -->|招く| D D -->|後進に任せられず
集中をさらに強める| F C -->|招く| G G -->|増やす| H H -->|押し上げる| C ``` 当然ながら、ある物事について情報量を失わずに単純にすることは原理的に不可能です。よって、AIエージェントは文脈の共有を easy にしますが、共有すべき内容そのものを simple にはしません。今や既に居なくなった自律的な人材が構築した、あまりにも複雑で高度な設計や仕様の運用を、誰が責任を持って説明し実行するのでしょうか。 ## 規範レイヤーの未整備という診断 つまり、自律的な存在があれば自動的に解けるわけではなく、前節で挙げた3つの問題を補う装置が必要だと思います。 その装置の一つとして、私は「組織として何を許し何を禁じるか」を共有された規範として定義することを提案します。 ホラクラシー憲章には権力をどのように配分するかを表現したルールがあります。 しかし、その下に必要な行為規範のレイヤーが意図的に未整備のままなのではないでしょうか。 ホラクラシー憲章は、組織を運営する枠組み、つまり「誰がどんな役割を担うか」「どの単位で意思決定するか」「メンバー間で生じた違和感をどう扱うか」といった仕組みを定めています。一方で、組織横断で「何をしてよいか・何をしてはいけないか」という具体的な行為規範はほとんど含まれていません。各チームの自律性を尊重する設計上の選択ですが、結果として一貫性をどう担保するかの判断は、チームやメンバーの個別運用に委ねられます。 こうした規範を整備せずに、リードの暗黙知に依存した運用でしのぐと、情報の一貫性も剪定も持続せず、判断の重みはさらにリードへ積み上がっていきます。これは前章で見たループと同じ構造です。 ## 自動検出可能な少数の条文という設計 規範レイヤーに置く条文とはどういうものか。例えば次のようなルールです。 ```markdown この組織が提供するビジネスでは、安易な個人情報・要配慮個人情報の永続化を許容しない。 なぜならば、それを原因とした情報漏洩インシデントが我々のビジネスにとって最大のリスクとなるからだ。 一度でも情報漏洩が発生した場合、この事業領域ではエンタープライズ顧客ほどサービスを解約せざるを得なくなるだろう。 また、たとえ現時点では発生確率が低いインシデントであっても、影響範囲は極大であると評価するべきだ。 今後の設計の変化によって、気づかないうちに大きな脆弱性がもたらされてしまう可能性は拭えない。 よって、次の制約を設ける。 - PII (個人情報) を永続化する場合は ADR や Design Doc などで理由を必ず明示する - プラットフォームシステムでは PHI (要配慮個人情報) は永続化してはならない ``` チームレベルでは、もう少し具体的な規範にしてもよいでしょう。 - ID や金額などのリテラル値は固有の型を必須とし、取り違えを型検査で防げる形にする これらの条文は「組織やチームとして何を恐れるか」「どの正確性を優先する/捨てるか」という価値基準が含まれています。 そして、優先順位の宣言として読めることが重要です。型検査・CI・Linter の設定として表現できるものはそこに書き、自然言語の条文のように形式化しづらいものはAIエージェントが読み取ってパターン的に判断する形でも構いません。人間が読み合わせて運用しなければならない規範は、前節のナレッジ劣化と同じように陳腐化していきます。 あらゆる PRD や Design Doc、そして ADR はそれらの規則に準じて評価されます。人間がレビューする前に、機械的にそれらのドキュメントが規範によって評価されるべきでしょう。 また、規則は ADR によってのみ変更可能とされるべきです。 ## 条文設計の品質基準 — 条文を増やすほどデリバリ性能が下がりうる 規範レイヤーを設計せよと書いてきましたが、規則を増やすこと自体が組織文化を損なう可能性は無視できません。 Nicole Forsgren・Jez Humble・Gene Kim の『Accelerate』(IT Revolution Press, 2018) は、4年にわたる DORA (DevOps Research and Assessment) 調査を基に、デリバリ性能と組織文化の関係を示した本です。同書が引く社会学者 Ron Westrum の分類によると、組織文化は3類型に分かれます。情報が止まり責任を回避する「病的」、規則と縄張りが優先される「官僚的」、成果のために情報が自由に流れる「生成的」の3つで、生成的な文化のスコアが高い組織ほどデリバリ性能も高いというのが Accelerate の実証結果です。 ![Westrum の組織文化3類型を表す図。左から鉄格子の壁(病的)、分厚いルールブック(官僚的)、自由に行き交う矢印(生成的)が並び、右に向かうほどデリバリ性能が高い軸が引かれている](/images/knowledge-entropy/westrum-spectrum.svg) 条文を増やすほど、組織は官僚的な方向に近づいていきます。「ID は固有の型を使う」と決めれば、それを守らせるレビュー・違反の指摘・例外申請といった運用が生まれ、規則を仕事の中心に据える文化が育つからです。Accelerate の含意に従えば、これはデリバリ性能を引き下げます。あまりにも詳細に立ち入りすぎた大量のチェックリストが機能しなくなった現場を見たことがある人は少なくないはずです。 つまり、論点は「規範を持つべきか否か」ではなく、「規範の目的の明確化とそれを実現するための設計」に置くべきです。条文数が少なく、抽象度が高く、自動検出される比率が高いほど、組織は規則を意識せずに済み、生成的な文化を維持しやすくなります。つまり、規範に含まれる条文の集合を「人間が読まなくても自動で回る最小の集合」に絞り込めるかが分岐点で、絞り込めないなら未整備のままにしておく方が健全です。 詳細度の設計には、もう一段階の判断軸が要ります。あらゆる観点を細かい規則で縛ると、条文はチェックリスト化して読まれなくなり、形骸化します。そのシステムにとって最重要な機能・品質を見極め、それが守られないときに発生するリスクの大きさに応じて詳細度を配分すべきです。重大な領域だけを具体的な条文で押さえ、それ以外は原則レベルにとどめて判断の余地を残す方が、規範全体が運用に耐えます。 ![ISO 25010 の8つの品質特性(機能適合性・性能効率性・信頼性・互換性・使用性・セキュリティ・保守性・移植性)を 4 列 2 行の格子で並べ、認証基盤にとって最重要となる「信頼性」と「セキュリティ」だけを黄色く塗って強調した図](/images/knowledge-entropy/priority-allocation.svg) 例えば、24時間365日無停止で稼働するミッションクリティカルなサービスの共通認証基盤なら、セキュリティと可用性を何よりも最重要の品質特性とするべきであって、残念ではありますが使用性はあくまでそれらに劣後するものと考えるべきでしょう。また、非常に公共性が高いシステムでアクセシビリティよりも魅力性を重視することは非常に馬鹿げています。 なお Accelerate が高パフォーマンスチームの特徴として挙げる「疎結合なアーキテクチャ」と「分散した意思決定」も、抽象度の高い少数の条文がなければ成立しません。チーム間の連携を最小化するには、各チームが守るべき不変条件が明文化され自動的に検査されていることが前提だからです。マルチプロダクトなSaaS群を提供するコンパウンドスタートアップにあって、あらゆるプロダクトのサブドメインに対する条文が全て詳細に網羅された条文のセットをすべてのチームに押し付けた場合、おそらくそのチームが本当に払うべきだった関心へ目を向ける機会は奪われてしまいます。 ## おわりに 問いを「人を増やすか減らすか」という単純な二元論から「組織のアウトプットの品質を悪化させる問題が何であり、どのように解決するか」へ移した結果、この記事では「自律的な存在が生成的かつ継続的に価値を出すための仕組みはどうあるべきか」という問いにぶつかることとなりました。 人を増やすことでコミュニケーションパスと知識のエントロピーが広がるため、量が単純に増えていくわけではありません。逆に人を減らしても、1人あたりの担当範囲が広がることで知識のエントロピーはむしろ増し、認知負荷がリードに集中して品質が下がっていきます。これに自律的な存在を導入しても、一貫性の喪失・ナレッジの劣化・自律的な人間の燃え尽きという3つの問題は残り続けます。 これらを補う規範を、自動検出可能な少数の条文に絞り込むことの重要性をここでは訴えました。私は、まずボトムアップに「チームとしてあるべき規範の最小セット」を考えることから始めてみようと思います。 ## サーバサイドTypeScriptの関数型ドメインモデリングを実践するエージェント向けプラグインを公開 サーバーサイドTypeScriptで関数型ドメインモデリングを実践するための原則を、コーディングエージェント向けスキルプラグインとして公開した。 ## 何を作ったか [kamae-ts](https://github.com/iwasa-kosui/kamae-ts) というコーディングエージェント向けスキルプラグインを公開しました。サーバーサイドTypeScriptで関数型ドメインモデリングを実践するための原則を、Claude CodeやCodexなどのエージェントに教え込むためのものです。 インストールは1コマンドで済みます。 ```bash # gh の場合 gh skill install iwasa-kosui/kamae-ts # npx の場合 npx skills add iwasa-kosui/kamae-ts ``` スキルは2つ構成になっています。コード生成用の `kamae` はドメインモデルや状態遷移ロジックを書く際にエージェントが従うべき原則を定義します。レビュー用の `kamae-review` は既存コードを原則に照らして検証します。 ## なぜスキルという形にしたか 私はカケハシの認証基盤・ID基盤を担うチームのテックリードとして、3年間ほど精力的にチーム内でオンボーディングやレビューを実行し、無事にチーム内にも関数型ドメインモデリングの知見が蓄積されてきました。 例えば、Discriminated Unionによる状態のモデリング、Companion Objectパターン、Railway Oriented Programming、Zodなどのスキーマライブラリを使ったAlways-Valid Domain Modelの実践、Sensitive型によるPII保護など、これらの原則やプラクティスを社内外で広く使ってもらいたいと考えていました。 そこで、これまでブログ記事を書いたり社内ドキュメントを整備したり、積極的に社内外へ発信を続けてきました。しかし、以下のブログ記事を全部読んで理解して実践してもらうのはコストが高すぎます。 * [複雑な状態遷移: クラスではなく関数とDiscriminated Unionで状態の定義と遷移を表現する](https://kosui.me/posts/2025/02/20/005900) * [Discriminated Unionを利用したStateパターンの実現](https://kosui.me/posts/2025/02/25/021320) * [TypeScriptでドメインイベントを容易に記録できるコード設計を考える](https://kosui.me/posts/2025/05/06/142842) * [なぜTypeScriptでメソッド記法を避けるべきか?](https://kosui.me/posts/2025/06/02/221656) * [私がTypeScriptで interface よりも type を好む理由](https://kosui.me/posts/2025/10/23/214710) * [ログのPII漏洩を防止する: TypeScriptの型推論とランタイムの境界](https://kosui.me/posts/2026/03/16/typescript-pii-logging-defense) * [サーバーサイドTypeScriptの型システムをどう教えるか](https://kakehashi-dev.hatenablog.com/entry/2026/03/31/110000) * [TypeScriptのテストにはas const satisfiesが便利です](https://kakehashi-dev.hatenablog.com/entry/2025/12/14/110000) * [TypeScriptの宣言的な配列操作](https://kakehashi-dev.hatenablog.com/entry/2025/11/19/110000) * [他言語経験者が知っておきたいTypeScriptのクラスの注意点](https://kakehashi-dev.hatenablog.com/entry/2025/08/19/110000) それに、コーディングエージェントへの期待が高まっている中で、開発者コミュニティではこうしたナレッジに対する関心が薄れているように感じます。ビジネスとしての価値を生み出すには機能・品質・デリバリーの3つを両立させる必要があるのに、コーディングエージェントが生み出すデリバリー速度への関心があまりにも高くなっていて、こうしたナレッジによる品質の担保について関心が相対的に減っているように見えるのです。 そこで、エージェントスキルとして提供すれば、開発者が原則の詳細を把握していなくても、エージェントがコード生成時に原則を適用できると考えました。リンター・フォーマッターやCI、CodeRabbitなどのソリューションはあくまで実装後に違反を検出しますが、スキルは実装する時点で原則に沿ったコードを生成できます。 それに、レビュースキルを実行して対話していくことで、開発者が再び品質の担保について意識するきっかけを作れるかもしれません。さらに、GitHubのIssueやPRを通じたこのスキルへのフィードバックを踏まえて、より洗練された原則をコミュニティが適用できるようになるかもしれません。 ## 含まれる原則の概要 スキルが教える原則は大きく4つの領域に分かれます。 状態モデリングでは、Discriminated Unionで各状態を `kind` フィールドで区別される独立した型として定義し、純粋関数で状態遷移を表現します。引数型と戻り値型が有効な遷移のみを許可するので、無効な遷移はコンパイルエラーになります。型とその関連関数はCompanion Objectパターンでグループ化します。 エラーハンドリングでは、ドメイン層から `throw` を排除し、Result型で成功と失敗を表現します。エラーもDiscriminated Unionで定義し、`assertNever` による網羅性チェックを必須にします。また、手続き的なエラーハンドリング `if (Result.isOk(res) { … }` ではなく、`neverthrow`であればメソッドチェーンによるパイプライン化、`byethrow`や `fp-ts`であれば`pipe`関数を利用したdo記法風のパイプライン化を推奨します。 ```typescript Result.pipe( result, Result.map((value) => transform(value)), // 成功値を変換 Result.mapError((error) => transformErr(error)), // エラー値を変換 Result.andThen((value) => nextResult(value)), // 成功値から次のResultへ(flatMap) Result.orElse((error) => recover(error)), // エラーから回復 ); ``` 境界防御では、外部入力をZodスキーマでバリデーションし、内部では型を信頼します。`as` 型アサーションはBranded Typeのファクトリに限定します。 ```typescript // Bad const user = data as User; // Good const user = UserSchema.parse(data); ``` PII保護では、個人情報をクロージャベースの `Sensitive` 型でラップし、ログ出力時に自動マスクします。 ```typescript const sensitiveString = z.string().transform(Sensitive.of); const PatientSchema = z.object({ id: z.string().uuid(), name: sensitiveString, email: sensitiveString, diagnosis: sensitiveString, role: z.string(), // PIIではない }); const patient = PatientSchema.parse(rawData); console.log(JSON.stringify(patient)); // {"id":"...","name":"[REDACTED]","email":"[REDACTED]","diagnosis":"[REDACTED]","role":"doctor"} ``` レビュースキルはこれらの原則に対する違反を8つの観点で検出し、重大度付きで指摘します。 ## 今後 業務への本格的な適用はこれからです。スキルの原則自体はチーム内で実践してきたものですが、スキルという配布形態での運用はまだ始まったばかりです。実際に使ってみたフィードバックや、原則自体への改善提案があれば [GitHub](https://github.com/iwasa-kosui/kamae-ts) に寄せていただけると嬉しいです。 ## TSKaigi 2026にプロポーザルが採択されました TSKaigi 2026で「TypeScriptのclassはなぜこうなったのか」というテーマで30分セッションに登壇します。classの歴史的経緯・落とし穴・使いどころを体系的に整理するトークです。 ## TSKaigi 2026 2026年のTSKaigiにプロポーザルが採択されました。 TSKaigi 2024では「複雑なビジネスルールに挑む:正確性と効率性を両立するfp-tsのチーム活用術」というタイトルで登壇しました。TSKaigi 2025では「型システムが拓くセキュアな世界: TypeScriptで築くOIDC対応の認証基盤」で応募しましたが落選しており、今回はよりTypeScriptにフォーカスした話題で再挑戦しました。 ## トークの紹介 ### タイトル TypeScriptのclassはなぜこうなったのか — 歴史・落とし穴・そして使いどころを探る30分 ### 概要 TypeScriptのclassに癖を感じたことはありますか。2012年にTypeScriptが誕生した頃、classはまだJavaScriptの規格であるECMAScriptには存在しませんでした。TypeScriptが独自のclass実装を先行して提供し、ES2015でclassが標準化された後、両者の仕様を統合する長い旅が始まりました。 本セッションでは、この歴史を紐解いた上で、開発者が日常的に遭遇する落とし穴を根本原因から体系的に整理します。 - 構造的部分型 JavaやC#ではclass名が異なれば別の型ですが、TypeScriptでは構造が同じなら異なる名前のclassでも同じ型として扱われます。例えば、`UserId`と`OrderId`が相互に代入できてしまう、といった問題が起きます。 - 動的thisバインディング JavaやC#ではメソッド内の`this`はそのインスタンスに固定されますが、JavaScriptでは呼び出し方によって`this`が変わります。メソッドを変数に代入したりコールバックとして渡すと`this`が失われます。 - 型消去 TypeScriptの型情報はJavaScriptへトランスパイルされると消去されるため、`private`修飾子は型検査時には有効であるものの、実行時には何の保護にもなりません。JavaやC#の`private`とは根本的に異なります。 - `instanceof`の限界 構造的部分型と型消去が組み合わさることで、`instanceof`による型の絞り込みが期待通りに機能しないケースがあります。 特に他言語ユーザーがこれらの落とし穴に陥いるとき、構造的部分型・動的thisバインディング・型消去という3つの仕組みが寄与しています。これらを理解することで、classを使うべき場面・避けるべき場面の判断軸が明確になります。 ### 歴史パート トーク前半では、TypeScriptとECMAScriptのclass仕様が歩んできた歴史を追います。TypeScriptが先行実装した`private`修飾子やパラメータプロパティ、後からES2022で標準化された`#`プライベートフィールドの関係、`[[Define]]` vs `[[Set]]`論争や、デコレーター標準化に至るまでの10年の旅など、こうした歴史的経緯を知ることで、現在のclassの複雑さがなぜ生まれたのかが見えてきます。 ### 実践パート トーク後半では、4つの落とし穴それぞれに対して実際のコード例を示しながら、どう対処するかを解説します。構造的部分型によって意図しない型の互換性が生まれるケース、メソッドを変数に代入した際にthisが失われるケース、`private`修飾子が実行時には何の保護も提供しないケースなど、それぞれの落とし穴が実務上でいつ発生し、それに対してどのように向き合うと良いか紹介していきます。最終的には、classを使うなという話ではなく、なぜ使うのかを理解した上で選択するための判断軸を提示します。 ## プロポーザルを提出した背景 ### KAKEHASHI Tech Blogへの寄稿 2025年8月にKAKEHASHI Tech Blogへ「[他言語経験者が知っておきたいTypeScriptのクラスの注意点](https://kakehashi-dev.hatenablog.com/entry/2025/08/19/110000)」という記事を寄稿しました。この記事では、構造的部分型・thisバインディング・private修飾子・型消去という4つの注意点と、それぞれの実践パターンを解説しています。 記事への反響を通じて、classの複雑さに悩んでいる開発者が多いことを改めて実感しました。ブログ記事では紙面の制約から扱いきれなかった歴史的経緯 (`[[Define]]` vs `[[Set]]`論争、デコレーター標準化の10年の旅など) も含めて、30分のセッションで体系的に伝えたいと考え、プロポーザルを提出しました。 ## 当日に向けて 闇雲にclassを使うなと断言するようなメッセージではなく、なぜclassを選ぶのかを理解した上で意識的に選択できる状態を目指すトークにしたいと考えています。TypeScriptのclassに馴染みのある方も、classに違和感を感じている方も、ぜひ聴きに来てください。 ## ログのPII漏洩を防止する: TypeScriptの型推論とランタイムの境界 TypeScriptの構造的部分型はログへのPII混入を型レベルで防げない。型の限界を認めた上で、センシティブな値を関数クロージャに閉じ込めるSensitive型と、Pino redactによる多層防御の設計パターンを提案する。 import { CodePlayground } from '../../components/CodePlayground/CodePlayground'; ## ログにPIIが混入するとなぜ致命的か サーバサイド開発において、ログにPIIが混入する事故は珍しくありません。ユーザーのメールアドレスや氏名がログに書き込まれ、それがDatadogやNew Relicなどの外部SaaSに送信されてしまう。多くの開発チームにとっては気をつけるべきことではあるものの、直ちにビジネスへ影響するような問題になるとは限りません。 しかし、医療システムや金融システムのように厳格なデータ保護要件を持つドメインでは話が変わります。個人情報保護法やGDPRでは個人データの取り扱いに厳しい制約があり、ログ基盤として国外にデータセンターを持つSaaSを利用している場合、ログへのPII混入は個人データの国外移転に該当しうるわけです。医療情報を扱う場合は厚生労働省の「医療情報システムの安全管理に関するガイドライン」や総務省・経済産業省の関連ガイドラインによるさらに厳しい制約も加わります。いずれにせよ、単なるバグではなくインシデントです。 厄介なのは、QAプロセスでログの中身が見落とされやすいことです。品質管理ではUIの使いやすさやAPIレスポンスの正しさに意識が向きがちで、「ロガーに何が渡されているか」を毎リリースで検証するチームは少ないのではないでしょうか。リリースのたびに全ログ出力をチェックするのは現実的ではありませんし、人手のチェックリストではスケールしません。 だからこそ仕組みで防ぐ必要があります。TypeScriptで型を使えば防げそうな気がしますが、実はそう簡単ではありません。 ## 構造的部分型がPIIを透過させる TypeScriptは構造的部分型を採用しています。型の互換性を名前ではなく構造で判定するため、ある型が求めるプロパティをすべて持っていれば、余分なプロパティがあっても互換とみなされます。 以下のプレイグラウンドで実際に試してみてください。`logUserAction`は`LogPayload`(`id`と`role`だけ)を受け付けるので安全に見えますが、`User`をそのまま渡しても型エラーになりません。「実行」するとemailがそのまま出力されることが確認できます。 ;', '', 'type LogPayload = Readonly<{', ' id: string;', ' role: string;', '}>;', '', 'function logUserAction(action: string, payload: LogPayload): void {', ' console.log(JSON.stringify({ action, ...payload }));', '}', '', 'const user: User = { id: "1", role: "admin", email: "alice@example.com" };', '', '// 型エラーにならない!', 'logUserAction("login", user);', ].join('\n')} lang="typescript" title="構造的部分型によるPII漏洩" /> オブジェクトリテラルを直接代入する場合に限り余剰プロパティチェックが例外的に働きますが、変数経由やスプレッド構文では適用されません。スプレッド構文内でも余剰プロパティチェックを効かせてほしいという[要望](https://github.com/microsoft/TypeScript/issues/39998)はありますが、2026年3月時点では未対応です。 ## Branded Typeでも防げない 構造的部分型による漏洩を型レベルで防げないか、Branded Typeを使ったアプローチを考えてみます。`Sensitive`というintersection型でブランドを付け、Conditional Typesで再帰的に検出して`never`に推論することで型エラーを起こす戦略です。 以下のプレイグラウンドでは、`safeLog(user)`が型エラーになることを「問題」タブで確認できます。 = T & { readonly [sensitiveTag]: true };', '', 'type RejectSensitive = {', ' [K in keyof T]: T[K] extends { readonly [sensitiveTag]: unknown }', ' ? never', ' : T[K] extends object', ' ? T[K] & RejectSensitive', ' : T[K];', '};', '', 'function safeLog>(data: T & RejectSensitive): void {', ' console.log(JSON.stringify(data));', '}', '', 'const user = { id: "1", role: "admin", email: "alice@example.com" as Sensitive };', '', '// emailがSensitiveなのでneverに推論され、型エラーになる', 'safeLog(user);', 'safeLog({ ...user });', ].join('\n')} lang="typescript" title="RejectSensitiveによる型レベル検出" collapsedRanges={[[1, 14]]} /> しかし、`Sensitive`は`string & { readonly [sensitiveTag]: true }`というintersection型であり、`string`のサブタイプです。つまり、`string`型の変数に代入した時点でブランドが消え、型チェックをすり抜けます。以下のプレイグラウンドで確認できます。`safeLog(user)` のコメントアウトを外して「問題」タブの変化を見てみてください。 = T & { readonly [sensitiveTag]: true };', '', 'type RejectSensitive = {', ' [K in keyof T]: T[K] extends { readonly [sensitiveTag]: unknown }', ' ? never', ' : T[K] extends object', ' ? T[K] & RejectSensitive', ' : T[K];', '};', '', 'function safeLog>(data: T & RejectSensitive): void {', ' console.log(JSON.stringify(data));', '}', '', 'const user = { id: "1", role: "admin", email: "alice@example.com" as Sensitive };', '', '// ↓ これは型エラーになる(コメントアウトを外して確認)', '// safeLog(user);', '', '// ↓ string変数に代入するとブランドが消え、型エラーにならない', 'const email: string = user.email;', 'safeLog({ id: user.id, role: user.role, email });', ].join('\n')} lang="typescript" title="Branded Typeのバイパス" collapsedRanges={[[1, 14]]} /> これはBranded Typeの原理的な限界です。intersection型で付けたブランドはスーパータイプへの代入で消失するので、型レベルでの検出は必ずバイパスできてしまいます。 そもそもBranded Typeはコンパイル時だけの概念で、ランタイムには何も残りません。`JSON.stringify`の出力に影響を与えることはなく、値は素の`string`としてそのままシリアライズされます。 これらの限界は個別のテクニックの問題ではなく、TypeScriptの設計思想に起因しています。[TypeScript Design Goals](https://github.com/Microsoft/TypeScript/wiki/TypeScript-Design-Goals)のNon-goal #3に「Apply a sound or "provably correct" type system. Instead, strike a balance between correctness and productivity.」と明記されている通り、TypeScriptは意図的に健全(sound)な型システムを目指していません。型検査が通ったコードがランタイムで型通りに動く保証はないのです。 型レベルの防御は「ないよりはまし」ですが、すり抜けるパターンが構造的に存在し、すり抜けたときにランタイムでは何も守ってくれない以上、主防御にはなりえません。 ## 値をシリアライズ不能にする: Sensitive型 ここまで見てきた通り、型レベルの防御はコンパイル時のみでランタイムには何も残りません。 そこで、値そのものをシリアライズ不能にしてしまうアプローチを考えます。ScalaではCirisの[`Secret`型](https://cir.is/)として既に確立されているパターンです。 `Sensitive.of()`は値を関数クロージャに閉じ込めます。外から見えるのは`unwrap`、`toJSON`、`toString`という関数プロパティだけであり、値自体はクロージャの中に隠されています。`toJSON()`を定義しているため`JSON.stringify`でシリアライズされた場合は`"[REDACTED]"`が返り、`toString()`も同様にオーバーライドしているためテンプレートリテラル経由でも値は漏れません。`[Symbol.for("nodejs.util.inspect.custom")]`はNode.jsの`console.log`が内部で使う`util.inspect`のフックで、これを定義しておくと`console.log`でも`[REDACTED]`と表示されます。実行して確認してみてください。 = Readonly<{', ' unwrap(): T;', ' toJSON(): string;', ' toString(): string;', '}>;', '', 'const Sensitive = {', ' of: (value: T): Sensitive => ({', ' unwrap() { return value; },', ' toJSON() { return "[REDACTED]"; },', ' toString() { return "[REDACTED]"; },', ' [Symbol.for("nodejs.util.inspect.custom")]() { return "[REDACTED]"; },', ' }),', '};', '', 'type User = Readonly<{', ' id: string;', ' role: string;', ' email: Sensitive;', '}>;', '', 'const user: User = {', ' id: "1",', ' role: "admin",', ' email: Sensitive.of("alice@example.com"),', '};', '', '// スプレッドしてもemailは漏れない', 'console.log("stringify:", JSON.stringify({ ...user }));', '', '// テンプレートリテラルでもtoString()が呼ばれる', 'console.log(`template: User email is ${user.email}`);', '', '// unwrapすれば値を取り出せる', 'console.log("unwrap:", user.email.unwrap());', ].join('\n')} lang="typescript" title="Sensitive型によるPII防御" collapsedRanges={[[1, 14]]} /> Node.jsの`console.log`は内部で`util.inspect`を使っており、`util.inspect`はオブジェクトに`[Symbol.for("nodejs.util.inspect.custom")]`メソッドがあればその戻り値を表示します。以下のプレイグラウンドではNode.jsの`util.inspect`をシミュレートして、このシンボルが定義されている場合と定義されていない場合の出力の違いを確認できます。 = Readonly<{', ' unwrap(): T;', ' toJSON(): string;', ' toString(): string;', '}>;', '', 'const inspectSymbol = Symbol.for("nodejs.util.inspect.custom");', '', '// util.inspect.customなし', 'const withoutInspect = {', ' unwrap() { return "alice@example.com"; },', ' toJSON() { return "[REDACTED]"; },', ' toString() { return "[REDACTED]"; },', '};', '', '// util.inspect.customあり', 'const withInspect = {', ' unwrap() { return "alice@example.com"; },', ' toJSON() { return "[REDACTED]"; },', ' toString() { return "[REDACTED]"; },', ' [inspectSymbol]() { return "[REDACTED]"; },', '};', '', '// Node.jsのutil.inspectの挙動をシミュレート', 'function simulateUtilInspect(obj: Record): string {', ' const customInspect = obj[inspectSymbol];', ' if (typeof customInspect === "function") {', ' return customInspect.call(obj) as string;', ' }', ' // util.inspect.customがない場合、プロパティが列挙される', ' const keys = Object.keys(obj);', ' const entries = keys.map(k => `${k}: [Function]`);', ' return `{ ${entries.join(", ")} }`;', '}', '', 'console.log("customなし:", simulateUtilInspect(withoutInspect));', '// => { unwrap: [Function], toJSON: [Function], toString: [Function] }', '', 'console.log("customあり:", simulateUtilInspect(withInspect));', '// => [REDACTED]', ].join('\n')} lang="typescript" title="util.inspect.customによるconsole.log防御" collapsedRanges={[[1, 5]]} /> このアプローチのミソは、防御がデータ構造自体に組み込まれている点です。Sensitive型なら入口(ドメインモデル定義)で一度ラップすれば、出口で何もしなくても漏れません。 Branded Typeとは違ってランタイムに実体を持つため、型検査をすり抜けてもシリアライズ時に値が漏れることはありません。ログ出力では`"[REDACTED]"`と表示されるので、「意図的にマスクされている」ことが明確に伝わりますし、障害調査で空オブジェクトと誤認されることもありません。 ### Sensitive型の限界 ただし、Sensitive型にも限界があります。 まず、`unwrap()`で取り出した値は素の文字列に戻るため、取り出し後の扱いは開発者の責任です。 ```typescript // メール送信のためにunwrapした値を、うっかりログに含めてしまう const rawEmail = user.email.unwrap(); logger.info({ action: "send_email", to: rawEmail }); // 漏れる ``` Sensitive型が守るのは「ラップされた状態の値」であり、「unwrapされた後の値」ではありません。この弱点を補うには、`unwrap()`の呼び出し箇所をESLintカスタムルールで制限し、PIIを取り出せるコンテキストを限定するといった運用上の工夫を組み合わせる必要があります。 また、関数をプロパティに持つオブジェクトは`structuredClone`できません。Worker間のメッセージパッシングや`postMessage`など、構造化複製アルゴリズムに依存する処理ではSensitive型を含むオブジェクトをそのまま渡せないので、事前に`unwrap`するか受け側で再ラップする必要があります。 それから、ドメインモデル定義時にすべてのPIIフィールドを`Sensitive.of()`でラップする必要がある以上、漏れなく適用することが前提になります。ラップし忘れたフィールドは素の値のままログに出てしまいます。この問題に対してはPino redactが安全網として機能します(後述)。 パフォーマンス面では、PIIフィールドごとに関数オブジェクトが生成されます。通常のAPIサーバではまず問題になりませんが、大量のオブジェクトを生成するバッチ処理等では留意が必要です。 ### ドメインモデルへの組み込み Sensitive型を使うと、PIIフィールドをドメインモデルの定義段階で明示できます。 ```typescript // PIIフィールドが型から読み取れる type Patient = Readonly<{ id: string; department: string; name: Sensitive; diagnosis: Sensitive; insuranceNumber: Sensitive; }>; // 値が必要な場面では明示的にunwrapする function formatInsuranceClaim(patient: Patient) { return { patientName: patient.name.unwrap(), diagnosis: patient.diagnosis.unwrap(), insuranceNo: patient.insuranceNumber.unwrap(), }; } ``` `unwrap()`の呼び出しはコードレビューで目立つため、「ここでPIIを取り出しているが、この用途は妥当か?」という判断を促す効果もあります。 ### Zodスキーマとの連携 外部入力のバリデーションにZodを使っている場合、`transform`でパース時にSensitive型へ自動ラップできます。 ```typescript import { z } from "zod"; const sensitiveString = z.string().transform(Sensitive.of); const PatientSchema = z.object({ id: z.string(), department: z.string(), name: sensitiveString, diagnosis: sensitiveString, insuranceNumber: sensitiveString, }); type Patient = Readonly>; ``` `z.output`で推論される型は`name: Sensitive`のようになり、手動でSensitive型を定義する必要がなくなります。パース結果を受け取った時点でPIIフィールドはすでにクロージャに閉じ込められているため、ラップし忘れの余地がありません。 ```typescript const raw = await request.json(); const patient: Patient = PatientSchema.parse(raw); // パース直後からSensitive型で保護されている logger.info(patient); // => {"id":"1","department":"cardiology","name":"[REDACTED]","diagnosis":"[REDACTED]","insuranceNumber":"[REDACTED]"} ``` Zodのスキーマ定義がバリデーションとPII保護の両方を担うため、ドメインモデルの入口が一箇所に集約されます。 ## Pino redactとの併用 Sensitive型は便利ですが、全てのPIIフィールドに`Sensitive.of()`を付け忘れなく適用できるかという問題は残ります。ここで[Pino](https://github.com/pinojs/pino)のredactオプションがフォールバックとして使えます。 ```typescript import pino from "pino"; const logger = pino({ redact: { paths: ["email", "*.email", "password", "*.password"], censor: "[REDACTED]", }, }); ``` Pino redactはブラックリスト方式で、既知のセンシティブなフィールド名を指定してマスキングします。内部実装の[fast-redact](https://github.com/davidmarkclements/fast-redact)は`**.email`のような任意深度の再帰パターンをサポートしないので、ネストの深い構造では防御が不完全になりえます。新しいセンシティブフィールドが追加されたときにredactリストの更新を忘れるリスクもつきまといます。 なので、Pino redactは主防御ではなく安全網です。Sensitive型で値をクロージャに閉じ込めるのが主防御で、Pino redactはSensitive型の適用漏れがあったときに、フィールド名が既知であればキャッチしてくれるフォールバックという立ち位置です。 ## まとめ TypeScriptの構造的部分型は、余分なプロパティを持つオブジェクトを互換とみなします。Branded TypeとConditional Typesを組み合わせれば型レベルで検出できるように見えますが、intersection型のブランドは`string`変数への代入で消失するので、バイパスが常に可能です。そもそもランタイムには何も残りません。 この限界を踏まえて、本記事ではSensitive型とPino redactの二層構成を提案しました。Sensitive型はPIIを関数クロージャに閉じ込め、`JSON.stringify`や構造化ロガー経由では`toJSON()`により`"[REDACTED]"`として出力されます。防御がデータ構造自体に組み込まれているので、入口(フィールド定義)で一度ラップすれば出口での対応は要りません。Zodの`transform`と組み合わせれば、パース時に自動でラップされるためラップし忘れの余地もなくなります。そのうえで、Pino redactが既知のセンシティブフィールド名をブラックリスト方式でマスキングし、Sensitive型の適用漏れに対する安全網になります。 「型安全な開発」という言葉で安心してしまわず、型にできることとできないことを正確に理解した上で、ランタイムでの防御を設計していくのが大事ではないでしょうか。 ## 参考文献 - [TypeScript: Type Compatibility](https://www.typescriptlang.org/docs/handbook/type-compatibility.html) — 構造的部分型と余剰プロパティチェックの仕様 - [Suggestion: perform excess property checks when spreading an inline object literal (TypeScript Issue #39998)](https://github.com/microsoft/TypeScript/issues/39998) — スプレッド構文で余剰プロパティチェックを効かせる要望(未対応) - [TypeScript Design Goals](https://github.com/Microsoft/TypeScript/wiki/TypeScript-Design-Goals) — TypeScriptが意図的にSound型システムではないことの公式見解 - [Pino Redaction](https://github.com/pinojs/pino/blob/main/docs/redaction.md) — センシティブフィールドの自動マスキング - [fast-redact](https://github.com/davidmarkclements/fast-redact) — Pino redactの内部実装。パスパターンの制約 - [Ciris - Secret](https://cir.is/docs/configurations#secret) — Scalaにおけるセンシティブ値のラッパー型。同様のパターンの先行事例 - [Zod - transform](https://zod.dev/?id=transform) — パース時の値変換。Sensitive型との連携に利用 ## 仕様について考える前に要求分析をしよう SDDが話題だが、その前工程である要求分析が見落とされがちだ。RDRAの表形式フォーマットがコーディングエージェントと相性が良い理由と、Claude Codeを使った実践ワークフローを紹介する。 ## SDDの「仕様」とは何を指しているのか 仕様駆動開発が話題です。[Kiro](https://kiro.dev/)、[OpenSpec](https://openspec.dev/)、[Spec Kit](https://github.blog/ai-and-ml/generative-ai/spec-driven-development-with-ai-get-started-with-a-new-open-source-toolkit/)といったツールが登場し、[Martin Fowlerのブログ](https://martinfowler.com/articles/exploring-gen-ai/sdd-3-tools.html)でも詳細に分析されています。 一方で、「仕様」という言葉が指す範囲が人によって異なるし、どのようなプロダクトを想定しているかが人によって異なるから、SDDの是非を巡る議論は空中戦になりがちです。 各ツールの定義を確認してみます。 [Martin Fowlerの記事](https://martinfowler.com/articles/exploring-gen-ai/sdd-3-tools.html)では、「仕様」を次のように定義しています。 > A structured, behavior-oriented artifact - or a set of related artifacts - written in natural language that expresses **software functionality** and serves as guidance to AI coding agents. Kiroのspecは以下の3ファイルを順番に書いていきます。 1. `requirements.md` [EARS記法](https://alistairmavin.com/ears/)によるユーザーストーリーと要件定義。EARS記法はイベント駆動(「When...the system shall...」)や状態駆動(「While...the system shall...」)など複数のパターンを持ち、単純なユーザーストーリーにとどまらない構造的な要件記述を支援する 2. `design.md` アーキテクチャと技術判断 3. `tasks.md` 実装タスク Kiroの `requirements.md` はEARS記法によって要件を構造的に記述できる点で強力ですが、「なぜこのシステムを作るのか」「各ステークホルダーの業務目標は何か」に相当する独立した成果物はなく、ユーザーの自然言語プロンプトがその役割を担います。 つまり、EARS記法で書くべき要件の内容そのものをどう導出するかは、Kiroのスコープ外です。すでにプロダクトやシステムがある程度順調に育っているなら、人間がそれなりにスラスラとEARS記法で要件を書けるのかもしれませんが、私が普段実務で向き合っている社内共通基盤やプラットフォームシステムのような領域では「そもそも価値を提供する対象のチームの要求が、そのチームのリーダーですらまだ言語化できない」ということも多く、「そもそもどうやってrequirements.mdに書くべき内容を導出するか」が難しそうに感じました。 ![Kiroの想定するフローとプラットフォーム領域の現実の対比。Kiroは人間が要求を語れる前提だが、プラットフォーム領域ではチームリーダーですら要求を言語化できておらず、requirements.mdを書き始められないギャップが存在する。](/images/sdd-requirements-analysis/kiro-gap.svg) OpenSpecは少し異なり、変更ごとに以下の成果物を生成します。 ``` openspec/changes/add-dark-mode/ ├── proposal.md … なぜこの変更をするか、何が変わるか ├── specs/ … 要件とシナリオ ├── design.md … アーキテクチャと技術判断 └── tasks.md … 実装タスク ``` ただし、OpenSpecの `proposal.md` が扱う「Why」は「なぜこの変更をするか」という機能変更レベルのWhyです。「なぜこのシステムを作るのか」「各運用チームの業務目標は何か」「セールスオペレーションチームは契約管理システムに対して、いつ何のために何をするのか」など、事業・業務レベルのWhyを構造的に分析するフレームワークではありません。 ソフトウェア開発には「要望→要求→要件→仕様→設計」という階層があります。IPAの[ユーザのための要件定義ガイド 第2版](https://www.ipa.go.jp/archive/publish/tn20191220.html)では、要求を「~したい」と表現できる希望的なもの、要件を要求が固まり第三者に提示できる状態になったものと整理しています。顧客やステークホルダーが唱える「あったらいいな、できたらいいな」はあくまで表層化された要望であって、それを「顧客は、○○を解決するため、△△したい」のようにWho/Why/Whatを明確にしたのが要求で、それに対して「システムは、○○しなければならない」と主語をシステムに置き換えたものが要件です。 ![要望・要求・要件・仕様・設計の階層図。要望から要求へはWho/Why/Whatの明確化、要求から要件へは主語をシステムに置換する。要求分析は要望から要件まで、SDDは要件から設計までをカバーし、要件レイヤーで補完し合う。](/images/sdd-requirements-analysis/requirements-hierarchy.svg) このうち、SDDが主に扱うのは「要件→仕様→設計」のレイヤーです。「要望→要求」の構造化、つまりステークホルダーの業務を理解し、システム化の目的とスコープを明確にすることで、そのあとの要件もクリアになることでしょう。つまり、それぞれが補完関係にあります。 この記事では、この「要望➝要求➝要件」に落とし込んでいく仕事を要求分析と呼び、その方法として[RDRA](https://www.rdra.jp/)とClaude Codeで実践するワークフローを紹介します。 ## 要求分析が抜けるとどうなるか あくまで私のN=1の経験ですが、構造的な問題として共有します。 セールスオペレーションやカスタマーサクセスなど、顧客向き合いの運用チームが使うシステムを開発する場面を考えてみてください。例えば契約管理システムとID基盤の同期システムを開発するとき、以下のような判断が求められます。 - そもそも誰にとってなぜ契約管理システムとID基盤が同期されてほしいのか - どのタイミングで、どのチームのメンバーが契約管理システムを利用するのか - どのタイミングで、ID基盤へ反映されると誰が嬉しいのか - この同期システムとコンフリクトする業務は存在するか - 書き込みが失敗した場合、どのようなメッセージを誰に通知すべきか - 書き込みは一括で行うべきか、個別に行うべきか これらの判断は、それぞれの運用チームが何を目標とし、何に関心を置いて業務をしているかによって大きく変わります。にもかかわらず、曖昧な仮定を置いたまま開発を進めてしまうと、いびつな業務フローが爆誕し、顧客や関係チームの運用コストが増大してしまう。そして多くの場合、そのまま運用が開始され、運用コストが高いシステムを使い続けることになります。本来そのチームが向き合いたかった目標を達成するための時間が奪われ、最終的にはいびつな運用プロセスが固着してしまう。 SDDで仕様を丁寧に書いたとしても、その仕様の前提となる業務理解が間違っていれば意味がありません。仕様の前に要求があって、それぞれの要求が満たされることで誰にどんな価値が届くのか考える必要があります。 ## なぜRDRAか 要求分析のフレームワークはRDRAだけではありません。イベントストーミング、ユーザーストーリーマッピング、ICONIX、匠Methodなど、選択肢は多い。その中でRDRAを選ぶ理由は、コーディングエージェントとの相性にあります。なお、RDRA開発者の神崎善司氏による[RDRA Agent](https://rdra.jp/rdra-agent/)や、[KiroとRDRAを組み合わせた実践例](https://zenn.dev/tan_go238/articles/707dd25d510f80)など、RDRAとAIエージェントを連携させる取り組みはすでに始まっています。本記事では筆者独自のClaude Codeスキルを使ったワークフローを紹介します。 RDRAの特徴は、要求を4つのレイヤー(システム価値/外部環境/システム境界/システム)で構造化し、各要素を表形式で表現する点です。図も使いますが、RDRAの成果物の本体は表やリストなど、構造化されたテキストデータです。 これがなぜ重要か、イベントストーミングを例に考えてみます。イベントストーミングはFigJamやMiroで付箋を空間的に配置するビジュアルな手法で、ワークショップとしては非常に強力です。ビッグピクチャで全体像を素早く把握するには最適な手法のひとつでしょう。 ただ、2025年時点での筆者の経験では、その結果をコーディングエージェントに渡すのが難しいと感じました。付箋の位置関係、グルーピングの境界、矢印の意味などの空間的な情報をテキストに構造化する段階で情報が欠落しやすく、セクション境界をエージェントがうまく読み取ってくれないことが多かったです。スクリーンショットやCSV出力機能やMCPなど、あの手この手でデータをコーディングエージェントに転送することを試みましたが、どれも満足のいく結果は得られませんでした。 ![イベントストーミングとRDRAのエージェント連携比較。イベントストーミングは空間的な付箋配置をテキストに変換する段階で位置関係・グルーピング境界・矢印の意味が欠落し、エージェントが正しく解釈できない。RDRAは最初から表形式の構造化テキストなので変換不要で、エージェントがそのまま依存関係を追跡できる。](/images/sdd-requirements-analysis/event-storming-vs-rdra.svg) もちろん、イベントストーミングで全体像を把握してからRDRAで詳細化するという併用も有効だと思いますが、本記事ではエージェントとの親和性に焦点を絞って、RDRAの単独活用を紹介します。 一方、RDRAであれば、人間が読みやすいMarkdownでそのまま構造化データとして表現できます。実際に私が使っているClaude Codeの `/rdra` スキルでは、RDRAの成果物を以下のようなフォーマットで管理しています。 ```markdown ### アクター一覧 | ID | アクター | 種別 | 説明 | |----|---------|------|------| | ACTOR-001 | ユーザー | human | エンドユーザー。ロールに基づいてプロダクトを利用 | | ACTOR-002 | 組織管理者 | human | 組織全体を管理するロールを持つユーザー | | ACTOR-003 | 組織単位管理者 | human | 担当OU配下を管理するロールを持つユーザー | | ACTOR-004 | グループ管理者 | human | 担当グループを管理するロールを持つユーザー | | ACTOR-005 | プロダクト提供者 | system | 基盤上のSaaSプロダクト(複数) | | ACTOR-006 | 基盤提供者 | human | akashic-ts基盤の運営者 | | ACTOR-007 | セールス部門 | human | 基盤提供者側。顧客獲得・組織プロビジョニング | | ACTOR-008 | セールスオペレーション部門 | human | 基盤提供者側。組織初期設定・OU構築 | | ACTOR-009 | カスタマーサクセス部門 | human | 基盤提供者側。顧客支援・デプロビジョニング | ### ゴール一覧 システム化によって達成したいビジネスゴールのリストです。要求やユースケースはすべていずれかのゴールに紐づきます。 | ID | ゴール | 主なステークホルダー | |----|--------|---------------------| | GOAL-001 | 組織構造(組織・OU・グループ)を一元管理し、各プロダクトが個別に組織管理機能を持つ必要をなくす | ACTOR-005, ACTOR-006 | | GOAL-002 | 組織管理者・OU管理者・グループ管理者が、自身の管理範囲内でユーザーやロールをセルフサービスで管理できる | ACTOR-002, ACTOR-003, ACTOR-004 | | GOAL-003 | 契約開始から利用開始までのプロビジョニングを自動化し、セールスオペレーション部門の手作業を削減する | ACTOR-007, ACTOR-008 | | GOAL-004 | 契約終了時のデプロビジョニングを安全かつ確実に行い、データ残留リスクを排除する | ACTOR-009 | | GOAL-005 | プロダクト提供者が基盤のAPIを通じてユーザーの所属・ロール情報を取得し、プロダクト側のアクセス制御に活用できる | ACTOR-005 | ### 要求一覧 システムが満たすべき機能的・非機能的な要件のリストです。 | ID | 内容 | 関連ゴール (Traces to) | | --- | --- | --- | | **REQ-024** | ユーザーにOU(組織単位)またはグループに対するロールを割り当てできる | GOAL-002, GOAL-005 | | **REQ-025** | ロール割当を取り消しできる | GOAL-002, GOAL-005 | | **REQ-026** | ロール割当の一覧を参照できる(ユーザー別・OU別・グループ別) | GOAL-002, GOAL-005 | | **REQ-027** | ロール名の定義・パーミッション定義・アクセス制御評価は外部システムに委ね、基盤はロール割当の管理のみ行う | GOAL-005 | ### ビジネスユースケース一覧 外部環境レイヤーにおける、アクターとシステムの相互作用を定義したリストです。 | ID | ユースケース名 | 主なアクター | 内容 | 関連要求 (Traces to) | | --- | --- | --- | --- | --- | | **BUC-022** | ロールを割り当てる | ACTOR-002, 003, 004, 006 | ユーザーにOU(組織単位)またはグループに対するロールを割り当てる。ロール名は任意の文字列。 | REQ-024 | | **BUC-023** | ロール割当を取り消す | ACTOR-002, 003, 004, 006 | ユーザーのOU(組織単位)またはグループに対するロール割当を取り消す。 | REQ-025 | | **BUC-024** | ロール割当を参照する | ACTOR-001, 002, 003, 004, 005, 006 | ロール割当の一覧を参照する。ユーザー別・OU別・グループ別にフィルタ可能。 | REQ-026 | ``` 参照関係を明示することで、ゴール → 要求 → 業務 → ユースケースというWhyの依存チェーンが形成されます。「このタスクはなぜ必要か」を遡れば必ずゴールに到達するので、エージェントにとっても人間にとっても、構造が明示的で扱いやすい設計です。ただ、何でもかんでもすべての物事がきれいに構造化できるわけではないですし、あくまで自然言語で書いているわけですから矛盾も発生します。あくまでここで言いたいのは、RDRAなら人間とコーディングエージェントの両方にとって扱いやすい構造にしやすいということです。 ### 成果物の管理構造 ここまでの例ではアクター・ゴール・要求・ユースケースをフラットに並べましたが、実際のプロジェクトでは成果物の量がすぐに膨らみます。ゴールが10個、要求が50個、ユースケースが30個ともなると、1ページにすべてを載せるのは読みにくいし、Confluenceでの共同編集時にコンフリクトが頻発します。 私の運用では、規模が大きくなったらインデックスページと詳細ページを分離しています。インデックスページにはIDと概要だけを一覧化し、依存関係の全体像を俯瞰できるようにする。各項目の詳細(背景、議論の経緯、受け入れ条件など)は個別ページに書く。Confluenceのページツリーで表現すると以下のような構造です。 ``` 📁 プロジェクトX 要求分析 ├── 📄 インデックス(ゴール・要求・ユースケース一覧と依存関係) ├── 📁 ゴール │ ├── 📄 GOAL-001: 組織構造の一元管理 │ ├── 📄 GOAL-002: セルフサービスでのユーザー・ロール管理 │ ├── 📄 GOAL-003: プロビジョニング自動化 │ ├── 📄 GOAL-004: 安全なデプロビジョニング │ └── 📄 GOAL-005: プロダクト向けロール情報API提供 ├── 📁 要求 │ ├── 📄 REQ-001 〜 REQ-010: 組織管理系 │ ├── 📄 REQ-011 〜 REQ-023: ユーザーライフサイクル系 │ └── 📄 REQ-024 〜 REQ-027: ロール管理系 ├── 📁 ビジネスユースケース │ ├── 📄 BUC-001 〜 BUC-010: 組織管理 │ └── 📄 BUC-022 〜 BUC-024: ロール管理 └── 📁 業務フロー ├── 📄 契約開始〜組織プロビジョニング ├── 📄 ユーザー招待〜初回ログイン └── 📄 契約終了〜デプロビジョニング ``` インデックスページには先ほどのようなゴール一覧・要求一覧・ユースケース一覧の表を置き、各IDから詳細ページへリンクします。詳細ページにはそのゴールや要求の背景、関連するステークホルダーとの議論の経緯、受け入れ条件などを書く。こうすることで、エージェントにはインデックスページだけを読ませれば依存関係の全体像を把握でき、特定の要求を深掘りしたいときだけ詳細ページを参照させればよくなります。 ## Claude CodeとRDRAの実践ワークフロー ここからが本題です。私が実際に自作の [/rdra スキル](https://github.com/iwasa-kosui/dotfiles/tree/7b88856f93e3be4cde0b569998471839b43f942c/dot_claude/skills/rdra) を使って業務で行っているワークフローを紹介します。 RDRAのような要求分析の手法をチームの全員がよく理解して実践することは容易ではありません。それぞれの項目に何を記入すればいいのか、それぞれのワークは何のために存在するのか、途中までは試行錯誤して進めてみたものの、不慣れな手法を使って得られる成果と、それを得るために必死にファシリテーションするコストが見合わず、徒労感の中で要求分析の実践をやめてしまった人もそれなりにいるのではないでしょうか。 要求分析の書籍はどれも抽象的な話題が多く、いくら学習しても実践フェーズに入るまでがあまりにも大変すぎます。 そこで、コーディングエージェントにインタビュワーになってもらい、チームで同期的に議論しながらそのインタビュワーに回答していく、人間が受け身になるワークフローを提案します。 ### Step 1: スコープ把握 プロジェクトを推進する数名のエンジニアが、まずインセプションデッキや関連チームの業務マニュアルを読みます。その上で、Claude Codeのエージェントに社内のConfluenceやNotion、Slackを検索させ、関連するアクターや外部システムの仮説を立てさせます。 エージェントの出力は完璧ではありません。ただ、仮説があることで対話が始まります。エンジニアは「このチームも関わっているはずでは?」「過去のアーキテクチャ俯瞰図を見ると、このシステムも関係しそう?」とフィードバックを返す。エージェントはフィードバックを反映し、RDRA形式で「どんな人間・外部システムが関わるか」「それぞれの人間や外部システムは何を達成したいか」「そのためにプロダクトへ何を要求するか」を整理します。 例: 契約管理システムと連携するID基盤 ```mermaid graph TB user["ACTOR-001: ユーザー"] orgAdmin["ACTOR-002: 組織管理者"] product["ACTOR-003: プロダクト提供者\n(複数)"] platform["ACTOR-004: 基盤提供者"] salesops["ACTOR-005: セールスオペレーション部門"] crm["ACTOR-006: 契約管理システム"] system["ID基盤"] user -->|認証・権限確認| system orgAdmin -->|組織全体の管理| system system <-->|イベント受信・データ取得| product platform -->|組織管理・基盤設定| system salesops -->|契約開始登録・設定登録| crm crm -->|組織初期設定・OU構築・ユーザー操作| system ``` ### Step 2: 業務フロー生成 コンテキストが特定できたら、エージェントに各コンテキストのas-is業務フローをMermaidシーケンス図で生成させます。同時に、各業務プロセスでどんな課題があるかの仮説も提示させます。 ここで人間のレビューが効きます。例えば「契約開始時の業務であっても、初期ユーザーの作成と店舗の契約開始は実際には別の業務フローだから、分けて書いてほしい」というフィードバックが出る。こういった業務の実態は、ドキュメントには書かれていないことが多い。エンジニアの頭の中にある暗黙知がエージェントの仮説によって引き出されます。 as-is業務フローが固まったら、to-be候補を複数提案させます。各候補がどのゴール・要求を満たすかを紐づけることで、「なぜこの改善案を選ぶのか」の判断材料が構造化されます。 ```mermaid sequenceDiagram actor Ops as セールスオペレーション participant S as 既存ID基盤 participant UL as ユーザーライフサイクル
(BIZ-004) participant EV as イベント配信
(BIZ-006) Note over Ops,EV: === 既存業務 === Ops->>S: 組織作成要求
(組織名・組織コード・初期管理者メールアドレス) activate S S->>S: 組織コードの一意性を検証 alt 組織コードが重複 S-->>Ops: エラー(コード重複) else 初期管理者が既に別組織に所属 S-->>Ops: エラー(ユーザー既存) else 正常 S->>S: 組織を作成(状態: 有効) S->>UL: 初期管理者をユーザーとして招待 S->>EV: ドメインイベント「組織作成」を発行 S-->>Ops: 作成完了 end deactivate S ``` ### Step 3: 非同期の仮説検証ループ このワークフローの隠れた価値は、エージェントの処理待ち時間が人間の思考時間になることです。 エージェントが業務フローを生成したり、to-be候補を検討している間に、人間は別のドキュメントを読み込んだり、チーム内で議論を再開したりできます。例えば、エージェントがto-be候補を3案生成している間に、エンジニアAは運用チームにSlackで業務の実態を確認し、エンジニアBは既存のモニタリングダッシュボードからエラー傾向を調査する。エージェントの出力を待ってから次に進むのではなく、人間とエージェントが非同期に仮説を検証し合うリズムが生まれる。 ### Step 4: 成果物の共有 要求分析の成果物は、最終的にセールスオペレーションやカスタマーサクセスといった運用チームにレビューしてもらう必要があります。ここで「成果物をどこに置くか」が問題になります。 GitHubで管理するのが常にベストとは限りません。 まず、コストとリテラシーの壁があります。GitHubのライセンスは1シートあたりのコストが馬鹿にならないし、非エンジニアを含む運用チーム全員にアカウントを発行するのはコスト面で厳しい。それに、GitHubを使いこなすリテラシーを全員に求めるのは組織をスケールする上でボトルネックになります。 もうひとつ、リアルタイム共同編集の価値があります。ConfluenceやNotionであれば、ミーティング中に全員が同時に要求を修正できる。ローカルにあるMarkdownファイルをGitHubにプッシュするフローだと、誰か一人が議事録を取るついでに修正しなければならなくなる。全員が話しながらドキュメントを育て、その結果をClaude Codeに読み込ませて次のイテレーションに反映する。このサイクルが回ることで、要求分析がエンジニアだけの作業ではなくチーム全体の営みになります。 ただし、Confluenceに成果物を置く場合、Claude Codeへのデータ転送が課題になります。ConfluenceにはREST APIがあるものの、ページ取得・検索・更新を手軽に行うにはひと工夫が必要です。私はConfluence CLIを自作し、Claude CodeのBashツールからConfluenceのページを直接読み書きできるようにしています。MCPサーバーを使う方法もありますが、いずれにせよ「共同編集ツール上の成果物をエージェントに渡す橋渡し」は自前で用意する必要があります。 ![リアルタイム共同編集とエージェントの連携サイクル。全員で同時編集し、Claude Codeに読み込ませて仮説・業務フローを生成、次のイテレーションに反映するサイクルが回る。](/images/sdd-requirements-analysis/collaborative-workflow.svg) もちろん、スタートアップのアーリーフェーズでリテラシーの高いメンバーが揃っているなら、GitHubで一元管理するのも合理的です。チームの実態に合わせて選べばよいと思います。 ## まとめ SDDと要求分析は対立しません。SDDは「仕様を書いてからコードを生成する」アプローチであり、その前工程として「なぜこのシステムを作るのか、誰のどんな業務課題を解決するのか」を構造化する要求分析がある。両者は補完関係にあります。 RDRAの表形式はコーディングエージェントとの相性がよく、FigJamやMiroのビジュアル手法では構造化しにくい情報を、エージェントが読み書きしやすいフォーマットで表現できます。また、それぞれに依存関係を明示しておくことで、「なぜこのタスクが必要なのか」をゴールまで一気通貫で遡れる仕組みを提供します。 そして成果物の配置先はチームの実態に合わせる。GitHubが全てではありません。非エンジニアが気軽に書き込み、議論しながら育てられるツールと連携することで、要求分析は初めてチーム全体のものになります。 ## すぐにポストモーテムを準備したい!Claude Codeスキルの設計指針 ポストモーテムの準備は辛い。情報源が散在し、テキスト量が膨大で、インシデント直後の疲弊した状態でやらなければならない。Claude Codeのカスタムスキル機能を使って、この準備作業を大幅に軽くする設計指針を紹介します。 ## はじめに インシデント対応が終わったら、なるべく早く忘れないうちにポストモーテムの準備を済ませておきたいものです。ただ、インシデント対応が長時間に渡ってしまった場合、まとめるべき情報はものすごく多くなっていて、その一方で自分自身の体力は少しも残っていないことがほとんどです。 Slack のインシデントチャンネル、Datadog のアラートやダッシュボード、Jira のチケット、GitHub の Issue や Pull Request、Confluence に書かれたランブックや過去の意思決定など、情報はあちこちに散らばっていて、それを時系列に整理してテンプレートに落とし込む必要があります。インシデント直後の疲弊した状態でそれをやるのは、あまりにも辛く厳しいです。 この記事では、ポストモーテムの準備コストを [Claude Code](https://docs.anthropic.com/en/docs/claude-code) のカスタムスキルで低減することを提案します。 なお、incident.io や Rootly などのインシデント管理プラットフォームはすでに AI によるポストモーテム自動生成機能を備えています。これらのプラットフォームをすでにお使いであれば、まずはそれを試すことをお勧めします。ここで想定しているのは、そうしたプラットフォームを導入していないか、自チームのワークフローに合わせたカスタマイズが必要なケースです。 ## ポストモーテム準備の何が辛いのか ### 1. 情報源が多すぎる インシデントの情報は単一のツールに閉じていません。 - コミュニケーション: Slack のインシデントチャンネル - モニタリング: Datadog のアラート・ログ・APM - タスク管理: Jira のチケット - コード: GitHub の Issue・Pull Request - ドキュメント: Confluence のランブック・過去のポストモーテム・PRD・製品仕様 これらを横断しながら「何が起きたか」を再構成しなければなりません。 ### 2. テキスト量が膨大 Slack のインシデントチャンネルには大量のメッセージが流れていますし、Datadog のアラートも複数発火しています。インシデント発生中は情報が錯綜したり、あちこちでSlackスレッドが乱立したり、そうした混乱もしばしば発生します。分散してしまった情報も検索した上で、全てに目を通して関連情報を拾い出すだけで、かなりの時間を食います。 ### 3. タイムライン作成が手間 「いつ、何が起きたか」の時系列を正確に組み立てるには、複数の情報源のタイムスタンプを突き合わせる必要があります。Slack のメッセージ、アラートの発火時刻、デプロイのタイムスタンプなどを手作業で時系列に並べ替えるのは、単純ですが地味に時間がかかります。しかも、タイムゾーンがバラバラだったり、フォーマットが細かく異なったりします。 ### 4. インシデント直後は疲弊している そもそもこの準備作業、インシデント対応が終わった直後にやることが多いです。長時間の対応で疲弊した状態で冷静に情報を整理するのは、精神的にも体力的にもしんどいものがあります。 ## なぜコーディングエージェントが向いているのか 多くの方はご存知かと思いますが、コーディングエージェントの強みは単にコードを実装することだけではありません。私は、「分断された情報源を横断してデータを収集し、それらを構造化できること」がコーディングエージェントの強みの一つだと考えています。 `gh` コマンドを叩いて GitHub の PR を取得し、MCP サーバー経由で Slack のログを引っ張り、Datadog の API からメトリクスを収集し、それらをソースコードと紐付けてインシデントについて整理する作業では、コーディングエージェントの強みを大きく活かすことができます。 実際、Google Cloud の SRE チームは Gemini CLI でインシデントの会話履歴・メトリクス・ログからポストモーテムを自動生成しています([InfoQ, 2026](https://www.infoq.com/news/2026/02/google-sre-gemini-cli-outage/))。Datadog の Bits AI SRE はアラート発火時に自律的にランブックやテレメトリを分析し、オンコール担当者がログインする前に結論を出します([Datadog Blog, 2025](https://www.datadoghq.com/blog/bits-ai-sre/))。 ### ただし「ポストモーテムを書いて」では機能しない エージェントに丸投げしても、使い物にはなりません。スキルを設計しないと2つの問題が起きます。 #### 表層的な分析で終わる 多くの場合、コーディングエージェントにインシデントの根本原因を問うても、表層的な結果を返されてしまいます。人間が自分の意図を伝えなければ、コーディングエージェントは世の中に広く共有されている一般論と眼前のインシデントを直接的に紐づけるぐらいで精一杯になりがちです。 #### コンテキストが爆発する Slack の全メッセージ、Datadog の全アラート、GitHub の全 PR を一度にコンテキストに詰め込むと、コンテキストウィンドウが溢れて情報の取捨選択が雑になります。関連情報がコンテキストの中間部分に埋もれると正確性が30%以上低下する "Lost in the Middle" 問題も知られています。 どのエージェントのコンテキストウィンドウに何を載せ、何を載せないか、慎重に設計する必要があります。 ## Claude Code スキルによるアプローチ Claude Code のカスタムスキルを使えば、`/postmortem` のようなスラッシュコマンドを定義してポストモーテム準備を自動化できます。 ### カスタムスキルとは `.claude/skills//SKILL.md` に Markdown で定義するプロンプトテンプレートです。`/skill-name` で呼び出すと、そのプロンプトに基づいて Claude が動きます。 - `$ARGUMENTS` で呼び出し時の引数を受け取れる - `` !`command` `` で事前にシェルコマンドを実行し、結果をプロンプトに注入できる - Agent ツールでサブエージェントを並列に走らせられる - 設定済みの MCP サーバー経由で外部サービスにアクセスできる ### 全体のフロー ``` ユーザー: /postmortem INC-1234 1. スキルがインシデントIDを受け取る 2. 情報収集フェーズ(サブエージェントで並列実行) ├─ Agent A: Slack からインシデントチャンネルのログを取得 ├─ Agent B: Datadog からアラート・メトリクスを取得 ├─ Agent C: Jira からチケット情報を取得 └─ Agent D: GitHub から関連 PR・Issue を取得 3. 要約・構造化フェーズ(別のサブエージェント) └─ 収集した情報を統合し、テンプレートに落とし込む 4. ポストモーテムのドラフトを出力 5. 人間によるレビューと加筆 ``` 情報収集をサブエージェントで並列化しているのがミソです。逐次アクセスに比べて時間を短縮できるのに加え、コンテキスト爆発を構造的に防げます。詳しくは指針1で説明します。 ## スキルの設計指針 ### 指針1: 情報収集はサブエージェントで並列化する 各データソースへのアクセスは互いに独立しているので、サブエージェントで並列に走らせます。 ```markdown ## 情報収集 以下のサブエージェントを**並列**で起動し、情報を収集してください。 ### Slack ログの収集 Agent ツールを使い、MCP 経由で Slack のインシデントチャンネル (#incident-$ARGUMENTS) から以下を取得してください: - インシデント発生から解決までの全メッセージ - 対応者のアクションと判断のポイント ### Datadog アラートの収集 Agent ツールを使い、CLI (`datadog-ci`) 経由で Datadog から以下を取得してください: - インシデント期間中に発火したアラートの一覧 - 関連するメトリクスの変化 ### Jira チケットの収集 Agent ツールを使い、MCP 経由で関連する Jira チケットを取得してください。 ### GitHub 変更の収集 Agent ツールを使い、CLI (`gh` コマンド) 経由で インシデント前後の PR・デプロイを取得してください。 ``` 並列化の利点は時間短縮だけではありません。むしろ、コンテキスト爆発を構造的に防げることの方が大きいです。 Slack のインシデントチャンネルだけで数百メッセージ、Datadog のアラートログも数十件になることは珍しくありません。これを全部メインのコンテキストに流し込めば、たちまちコンテキストウィンドウが逼迫します。 サブエージェントは独立したコンテキストウィンドウを持っています。各サブエージェントが膨大な生データを読み込み、その中で要約・取捨選択を行った結果だけをメインに返す。メインのコンテキストはクリーンなまま保たれます。 #### CLIを優先し、MCPは必要な場合に使う 外部データソースへのアクセス手段として MCP サーバーと CLI の2つがありますが、2026年時点のベンチマークでは CLI の方がタスク完了率28%高、トークン効率33%優という結果が出ています([jannikreinhard.com](https://jannikreinhard.com/2026/02/22/why-cli-tools-are-beating-mcp-for-ai-agents/))。 MCP サーバーはツール定義をすべてコンテキストに読み込みます。たとえば GitHub の MCP サーバーは93ツールで約55,000トークンを消費します。一方、`gh` CLI は必要なコマンドだけ実行して予測可能な出力を返してくれます。 `gh`、`jira-cli`、`datadog-ci` などの CLI が使える場面ではそちらを優先し、CLI では対応しにくい Slack のメッセージ取得や Confluence のページ取得には MCP を使うのが現実的でしょう。 #### サブエージェントの数は2〜4に抑える Azure SRE Agent チームは当初100以上のツールと50以上のサブエージェントで構築しましたが、エージェントが多すぎてオーケストレーターが適切なエージェントにタスクを振り分けられない問題、1つのエージェントのプロンプトを変更すると他のエージェントの挙動まで壊れてしまう連鎖的な不具合、エージェント間でタスクをたらい回しにする無限ループに直面しています。結局、少数のジェネラリストエージェントに集約して劇的に改善したそうです([Microsoft Tech Community](https://techcommunity.microsoft.com/blog/appsonazureblog/context-engineering-lessons-from-building-azure-sre-agent/4481200/))。 Claude Code のサブエージェントも2〜4程度が妥当です。それ以上に増やしてもオーケストレーションが複雑になるだけで、見合ったリターンがありません。 ### 指針2: 収集した情報は別のサブエージェントで要約する 各サブエージェントが返す収集結果はある程度整理されていますが、データソースをまたいだ統合が必要です。収集と要約を別のサブエージェントに分けると、それぞれのタスクに集中でき、出力の質が上がります。 ```markdown ## 要約・構造化 収集した情報を以下の構造に整理してください: ### タイムライン 各データソースのタイムスタンプを突き合わせ、以下のカテゴリに 分類して時系列順に整理してください: - エンバグ(原因となった変更のデプロイ等) - 障害発生 - 障害検知(アラート発火、ユーザー報告等) - 顧客周知(ステータスページ更新等) - 止血対応(ロールバック、フェイルオーバー等) - 原因調査 - 復旧 形式: `HH:MM` [カテゴリ] イベント内容(情報源) ### 影響範囲 - 影響を受けたサービス・機能 - 影響を受けたユーザー数の推定 - ビジネスインパクト ### 対応アクション - 誰が、いつ、何をしたか - 判断のポイントとその根拠 ``` 収集サブエージェントは「できるだけ多くの関連情報を拾う」ことに専念し、要約サブエージェントは「拾った情報を構造化する」ことに専念する。責務の分離です。 ### 指針3: 出力を検証可能にする AI にドラフトを生成させた後、「レビューして修正して」と指示すれば品質が上がると思うかもしれません。しかし RefineBench の評価(2025-2026)によると、LLM のセルフレビューによる改善は5回の反復で+1.8ポイント以下です。「何度もレビューさせれば良くなる」というのは幻想です。 Zalando のポストモーテム分析では、高性能モデルでも約10%の表面帰属エラーが残りました。インシデントに関連するキーワードが文中に出てくるだけで、実際には因果関係がないのにあるかのように書いてしまう現象です([Zalando Engineering Blog, 2025](https://engineering.zalando.com/posts/2025/09/dead-ends-or-data-goldmines-ai-powered-postmortem-analysis.html))。 セルフレビューに期待しすぎるよりも、人間が検証しやすい形で出力する方が確実です。 なお、LLM は Markdown の表形式を好む傾向がありますが、日本語のタイムラインでは表のカラムが崩れやすいので、箇条書きで出力させる方が無難です。障害検知、止血対応などのカテゴリをラベルとして付与すれば、箇条書きでも十分に見通しがよくなります。 ```markdown ## Step 4: 検証可能な形で出力する タイムラインの各項目には、必ず情報源を明記してください: - `10:23` [障害検知] アラート発火(Datadog: monitor-12345) - `10:25` [障害検知] #incident-INC-1234 作成(Slack: msg-ts-xxx) - `10:30` [止血対応] ロールバック PR 作成(GitHub: PR #456) - `10:32` [止血対応] ロールバック PR マージ(GitHub: PR #456) - `10:45` [復旧] エラーレート正常値に復帰(Datadog: dashboard-789) 人間がドラフトをレビューする際に、各記述の根拠を即座に 確認できることが最も重要です。 ``` Zalando のチームは当初100%の人間キュレーションを行い、徐々に10〜20%のサンプリングに移行しました。最初から全面的に任せるのではなく、信頼を段階的に築いていくやり方です。 ### 指針4: テンプレートはチームの既存フォーマットに合わせる ポストモーテムのテンプレートは組織ごとに異なります。スキルには自チームのテンプレートを埋め込んでおきましょう。 ```markdown ## 出力フォーマット 以下のテンプレートに沿ってポストモーテムのドラフトを作成してください。 ### タイトル [日付] インシデント概要 ### サマリー - 何が起きたか(1-2文) - 影響範囲 ### タイムライン - `HH:MM` [カテゴリ] イベント内容(情報源) - カテゴリ: エンバグ / 障害発生 / 障害検知 / 顧客周知 / 止血対応 / 原因調査 / 復旧 ### 影響 - 影響を受けたサービス - 影響を受けたユーザー - ダウンタイム ### 寄与要因 Contributing Factors (人間が記入) ### 再発防止策 (人間が記入) ### 教訓 (人間が記入) ``` テンプレートの「根本原因」というセクション名は再考した方がよいかもしれません。複雑な分散システムのインシデントで単一の根本原因が見つかることはまれです。PagerDuty/Jeli の [Howie Guide](https://howie-guide.pagerduty.com/) や Learning from Incidents コミュニティでは **根本原因** ではなく **寄与要因** が推奨されています。たった1つの原因を探すより、複数の寄与要因を列挙する方が実態に合います。 ### 指針5: 人間が担うべき部分を明示的に残す ここが一番大事な指針です。 AI は情報の収集と整理が得意です。散在する情報源から関連データを引っ張ってきて、時系列に並べて構造化する作業は、人間がやるより AI が担う方が圧倒的に速いです。 一方、以下は人間が担うべきです。 - **寄与要因の分析**: なぜこのインシデントが起きたのか、技術的な要因だけでなく、組織やプロセスの要因も含めた分析 - **再発防止策の策定**: 何をすれば再発を防げるか、優先度をつけて計画する意思決定 - **教訓の抽出**: チームとして何を学んだか、文化や仕組みにどう反映するか - **ナラティブの記述**: 対応者がどんな状況で判断を迫られ、何を考えていたか テンプレートにこれらの項目を「(人間が記入)」と残しておけば、AI と人間の役割分担が自然にできます。incident.io もこのアプローチで、AI が構造化された80%を生成し、人間が10〜15分でナラティブと洞察を追加する形をとっています。 ポストモーテムの価値は寄与要因の分析と再発防止策の議論にあります。準備の自動化は、チームがその議論に集中するための手段にすぎません。 ### 指針6: セキュリティを考慮する AI エージェントに本番環境のモニタリングツールやコミュニケーションツールへのアクセスを与えるなら、セキュリティには気を配る必要があります。 2025年の調査では MCP 実装の43%にコマンドインジェクション脆弱性が見つかっています([Equixly Security Assessment](https://www.practical-devsecops.com/mcp-security-vulnerabilities/))。MCP サーバーのツール説明文にユーザーから見えない悪意ある指示を仕込む「ツールポイズニング」攻撃も報告されています。 最低限、以下は守っておきたいところです。 - 最小権限の原則: エージェントに与える権限は読み取りのみに絞る。New Relic の SRE Agent は「本番システムを変更しない、承認フローをバイパスしない、人間の判断を上書きしない」という制約を意図的に設けています - MCP サーバーの検証: 信頼できるソースの MCP サーバーのみ使い、ツール定義が変更されていないか定期的に確認する - 自動承認を無効にする: 本番ツールへのアクセスにはユーザーの明示的な許可を必須にする ## スキルの実装エッセンス `/postmortem` スキルの SKILL.md の骨格を示します。実際の環境に合わせて MCP サーバーの設定やテンプレートを調整してください。 ```yaml --- name: postmortem description: インシデントIDを受け取り、各種データソースから情報を収集してポストモーテムのドラフトを生成する user-invocable: true disable-model-invocation: true allowed-tools: Agent, Read, Write, Bash, Grep, Glob argument-hint: [incident-id] --- ``` ```markdown あなたはポストモーテムの準備を支援するアシスタントです。 インシデントID: $ARGUMENTS ## Step 1: 情報収集(並列実行) 以下のサブエージェントを **並列** で起動してください。 各エージェントは、取得した情報を要約して返してください。 生データをそのまま返さないでください。 1. **Slack ログ収集**: #incident-$ARGUMENTS チャンネルの メッセージを取得し、主要なアクションと判断を時系列で要約 2. **アラート収集**: `datadog-ci` CLI でインシデント期間中の アラートを取得し、発火時刻と内容を一覧化 3. **チケット収集**: MCP 経由で関連する Jira チケットの ステータスと概要を取得 4. **コード変更収集**: `gh` CLI でインシデント前後の PR とデプロイを取得し、変更内容を要約 ## Step 2: 要約・構造化 収集結果をもとに、タイムラインを時系列順に構成し、 影響範囲と対応アクションを整理してください。 タイムラインの各項目には必ず情報源を明記してください。 ## Step 3: ドラフト出力 チームのテンプレートに沿ってポストモーテムのドラフトを出力してください。 「寄与要因」「再発防止策」「教訓」は人間が記入する欄として空けてください。 ``` `disable-model-invocation: true` にしているのは、このスキルが外部データソースにアクセスする副作用を持つためです。ユーザーが明示的に `/postmortem` を実行したときだけ動くようにし、Claude が勝手に呼び出すのを防ぎます。 ## 注意: AI はトイルを増やすこともある 2025年の調査([Runframe: State of Incident Management 2025](https://runframe.io/blog/state-of-incident-management-2025))によると、AI 投資にもかかわらず運用トイルは25%から30%に上昇しました。うまく統合されなければ、AI ツールは既存の作業を減らすどころか検証のオーバーヘッドを新たに生みます。 ポストモーテムの準備スキルを導入しても、「AI の出力を人間が検証する」という新しい作業は発生します。それが手作業での準備より本当に軽いかどうかは、チームの環境と MCP サーバー/CLI の整備状況次第です。 小さく始めて、実際に準備時間が短縮されるか確認しながら改善していくのがよいかと思います。Google Cloud の SRE チームのように、生成されたポストモーテムを次回以降の参考データとして蓄積する「フィードバックループ」を組めれば、スキルの品質は回を重ねるごとに上がっていきます。 ## まとめ この記事で紹介した設計指針は6つです。 1. 情報収集はサブエージェントで並列化する コンテキスト爆発を構造的に防ぐ。 CLI優先、サブエージェントは2〜4 2. 収集と要約を分ける 責務の分離 3. 出力を検証可能にする セルフレビューの限界を踏まえ、情報源を明記する 4. テンプレートはチームに合わせる 「根本原因」より「寄与要因」を 5. 人間が担う部分を残す AI は情報収集、人間は分析・判断・ナラティブ 6. セキュリティを考慮する 最小権限、MCP検証、自動承認の無効化``` 準備の自動化は、チームが寄与要因の分析と再発防止策の議論に集中するための手段にすぎません。 ## 品質要求が相反するシステムをどう分割するか — サービスベースアーキテクチャの実践 マイクロサービスでもモノリスでもない選択肢として、サービスベースアーキテクチャを医療SaaSの共通基盤で採用した経緯と実装、運用の教訓を紹介します。 ## はじめに こんにちは、kosui([@kosui_me](https://x.com/kosui_me))([id:kosui_me](http://blog.hatena.ne.jp/kosui_me/))です。 本記事は [SRE Kaigi 2026](https://srekaigi.jp/) での登壇「開発チームが信頼性向上のためにできること」の内容から、アーキテクチャ選定の部分を抜き出して再構成したものです。登壇ではRLSやドメインイベント、データ連携なども扱いましたが、この記事ではサービスベースアーキテクチャに絞り、登壇では時間の都合で話しきれなかった設計判断の背景や運用の教訓を紹介します。 普段は医療系のスタートアップで認証基盤・ライセンス基盤・組織階層基盤などのプラットフォームシステムを開発・運用するチームのテックリードをしています。 [日本の医療に本気で向き合う。認証・権限管理基盤チームの決意 - KAKEHASHI Tech Blog](https://kakehashi-dev.hatenablog.com/entry/2025/08/29/105510) ## 品質要求が相反する問題 私たちのチームは認証基盤、ID基盤、ライセンス基盤、端末・証明書基盤の4つのシステムを担当しています。社内の複数プロダクトが依存する共通基盤であり、障害が発生すると全プロダクトに影響が波及します。 ![プラットフォームの全体構成。プロダクト群がOIDCやAPIでプラットフォーム内の認証基盤・ライセンス基盤・端末/証明書基盤・ID基盤を参照し、ID基盤がデータ基盤へ反映する](/images/service-based-architecture/platform-overview.svg) 課題は、これらのシステムがそれぞれ異なる品質要求を持っていることです。 認証基盤は「ログインできなければ医療現場が止まる」ため、可用性を最優先にする必要があります。一方、ID基盤は患者データの真正性を保証する必要があるため、整合性とトレーサビリティが最重要です。そして認証基盤はID基盤に依存しています。 ![認証基盤とID基盤の依存関係。認証基盤がID基盤を参照しており、この依存が品質要求の相反を引き起こす](/images/service-based-architecture/auth-id-dependency.svg) この依存関係は、以下のような問題を引き起こします。 - ID基盤で整合性維持のために停止してデータ移行を行いたいが、認証基盤は止められない - ID基盤でデータ不整合が発生すると、認証基盤も巻き込まれて停止せざるを得ない 品質要求が異なるシステムが密結合しており、一方を改善しようとすると他方に悪影響が出ます。この状況を解消する必要がありました。 加えて、チームは非常に小規模で、アプリケーション開発者の採用は進んだもののEmbedded SREを迎える余裕はありませんでした。SREの専門知識がない中でも信頼性を担保していくために、アーキテクチャの選定で運用負荷をできる限り抑える必要がありました。 ## 何を選び、何を選ばなかったか 3つの要件が衝突していました。 1. 基盤はまだ発展途上であり、安易に分割すると障害点が増える 2. しかしデータの整合性は絶対に譲れない 3. それでもシステムごとに独立デプロイしたい この3つを同時に満たすアーキテクチャパターンを探して、4つの候補を検討しました。 ### マイクロサービス ![マイクロサービスアーキテクチャ。サービスごとに独立したDB・デプロイ・API通信を持つ構成](/images/service-based-architecture/arch-microservices.svg) 最初に検討したのはマイクロサービスです。独立デプロイが可能で、スケーリングの自由度も高い構成です。しかし、実際に設計を詰めていくと「どこで分割するか」が定まりませんでした。4つのシステムは本来別の責務ですが、参照関係が絡み合っており、誤った境界で分割した場合の手戻りリスクが大きいと感じました。分散トランザクションの複雑さもあり、当時のチーム規模では現実的ではないと判断しました。 ### イベント駆動アーキテクチャ ![イベント駆動アーキテクチャ。ProducerがEvent Brokerを介してConsumerに非同期でイベントを配信する構成](/images/service-based-architecture/arch-event-driven.svg) 疎結合でスケーラビリティが高い構成です。しかし、結果整合性が前提となるため、私たちが求める強いデータ整合性とは相性がよくありません。Event Brokerが新たな障害点になることに加え、イベント消失時のリカバリなど運用負荷の増加も懸念されました。 なお、イベント駆動アーキテクチャを採用しない場合でも、監査証跡の確保は重要です。特に医療領域では、米国の医療情報に関する法律であるHIPAAが、情報システムに対して技術的セーフガードを要求しています。その一つである [45 C.F.R. § 164.312(b)](https://www.law.cornell.edu/cfr/text/45/164.312) は、システム上の活動記録を保持する仕組みの実装を求めるものです。日本の医療システムでも、この水準の監査証跡を確保しておくことは重要だと考えています。 私たちは、インフラレベルのイベントブローカーではなく、アプリケーション層でドメインイベントを永続化する設計を採用することで、この要件に対応しています。具体的な設計については「[TypeScriptでドメインイベントを容易に記録できるコード設計を考える](https://kosui.me/posts/2025/05/06/142842)」で詳しく解説しています。 サービスベースアーキテクチャであっても、各サービスのユースケース層でイベントをDBに記録すれば、非同期メッセージングの複雑さを避けつつ監査証跡を確保できます。 ### モジュラモノリス ![モジュラモノリスアーキテクチャ。単一デプロイユニット内でモジュール分離しつつ、単一データベースで強い整合性を確保する構成](/images/service-based-architecture/arch-modular-monolith.svg) 単一DBで整合性を保ちつつモジュール分離が可能です。デプロイの独立性を求めなければ、モジュラモノリスはサービスベースアーキテクチャよりもシンプルに運用できます。特にデータベースマイグレーションの仕組みを単一に保てることは大きなメリットです。しかし、私たちのケースではデプロイが一体となる点が問題でした。認証基盤のみに脆弱性パッチを適用したい、といったケースに対応できません。 ### サービスベースアーキテクチャ 上の3つのどれも要件を完全に満たせない中で、カケハシのチーフアーキテクトである[@kimutyam](https://x.com/kimutyam)さんにサービスベースアーキテクチャの存在を教えていただきました。それをきっかけにMark RichardsとNeal Fordによる『[ソフトウェアアーキテクチャの基礎](https://www.oreilly.co.jp/books/9784873119823/)』を改めて読み返し、この方法を試してみようと考えるようになりました。 ## サービスベースアーキテクチャとは ![サービスベースアーキテクチャ。各サービスが独立デプロイ可能で、単一PostgreSQL内のスキーマを所有。サービス間のAPI通信は禁止し、他スキーマへはSELECTのみ許可する構成](/images/service-based-architecture/service-based-architecture.svg) 概要は以下のとおりです。 - 単一のDBを共有しつつ、サービスは独立してデプロイできる - サービス間のAPI通信は禁止 - サービス間でDBの読み取りは許可 - ただし、各テーブルは特定のサービスが所有し、他サービスは書き込めない マイクロサービスの「独立デプロイ」と、モノリスの「単一DBによる強い整合性」を両立させる設計です。分散トランザクションの複雑さを回避しつつ、デプロイの独立性を確保できます。 ### DB共有というアンチパターンとの違い 「DB共有はアンチパターンでは?」という質問をよくいただきます。確かに、マイクロサービスの教科書には「各サービスは自分のDBを持つべき」と書かれています。 しかし、ここで言うDB共有アンチパターンとは「どのサービスもどのテーブルも自由に読み書きできる状態」を指しています。サービスベースアーキテクチャはこれとは異なります。 | | DB共有アンチパターン | サービスベースアーキテクチャ | |---|---|---| | テーブルへのアクセス | どのサービスもどのテーブルも自由に読み書き | テーブルごとに所有権を持つ | | 書き込み権限 | 制限なし | 所有サービスのみ書き込み可能 | | 他サービスのデータ | 自由に変更可能 | 読み取りのみ | | 変更の影響範囲 | 不明 | 所有権により追跡可能 | 重要なのは所有権です。各テーブル、またはスキーマ単位で明確な所有者が存在し、所有者のみが書き込みを行います。他のサービスは読み取りのみです。この点が「何でもありのDB共有」とは決定的に異なります。 ### なぜAPI通信ではなくDB直接参照なのか ```typescript // サービス間API呼び出し const user = await userService.getUser(userId); // DB直接参照 const user = await db.query( 'SELECT * FROM directory.users WHERE id = $1', [userId] ); ``` 以前、サービス間をAPI経由でデータ参照していた時期がありましたが、DNSキャッシュの問題でレスポンスが不安定になった経験があります。API呼び出しはネットワーク障害による連鎖障害のリスクを常に抱えています。 DB直接参照であれば、ネットワーク遅延の影響を受けず、トランザクション内で整合性も保証できます。私たちの目的は「独立デプロイ」であって「ネットワーク分離」ではないため、DB直接参照で十分でした。 ## PostgreSQLでの実装 ### スキーマ分割 ![PostgreSQL内のスキーマ分割。auth/directory/assetスキーマがそれぞれ認証/ID/ライセンスサービスに所有され、サービス間はSELECT参照のみ](/images/service-based-architecture/shared-db-schemas.svg) 単一のPostgreSQL内に、サービスごとのスキーマを作成します。 ``` PostgreSQL ├── auth スキーマ ← 認証サービスが所有 ├── directory スキーマ ← ディレクトリサービスが所有 └── asset スキーマ ← 端末管理サービスが所有 ``` 物理的には1つのDBですが、論理的にスキーマで分離しています。 ### DBユーザーによる権限制御 「自分のスキーマのみ書き込み、他は読み取りのみ」を運用ルールではなくDBの権限として強制します。これが重要なポイントです。 ```sql -- 認証サービス用のロール CREATE ROLE auth_service; GRANT ALL ON ALL TABLES IN SCHEMA auth TO auth_service; GRANT SELECT ON ALL TABLES IN SCHEMA directory TO auth_service; -- ディレクトリサービス用のロール CREATE ROLE directory_service; GRANT ALL ON ALL TABLES IN SCHEMA directory TO directory_service; GRANT SELECT ON ALL TABLES IN SCHEMA auth TO directory_service; GRANT SELECT ON ALL TABLES IN SCHEMA asset TO directory_service; ``` `auth_service` はauthスキーマにフルアクセスできますが、directoryスキーマはSELECTのみです。運用ルールではなくDBレベルで制御しているため、開発者が誤ってINSERTを実行しようとしてもブロックされます。 ## 発展可能性 サービスベースアーキテクチャを選んだ最大の理由は、発展可能性にあります。 ドメイン境界が明確になった段階で、特定のサービスをDBごと分離してマイクロサービス化できます。非同期処理が必要になった箇所にのみ、イベント駆動パターンを部分的に導入することも可能です。逆に「まだ分割しない」という選択を取ることもできます。 発展は分割方向だけではありません。運用を通じて「これらのサービスは実際には密結合で、別々にする必要がなかった」と判明した場合、モジュラモノリスへ統合し直すことも比較的容易です。DB共有のおかげでデータ移行が不要だからです。 一方で、一度マイクロサービスとして完全に分離してしまうと、再統合は大規模な作り直しになります。分離は不可逆に近い操作です。 早すぎる分離は誤った境界での分割を招きます。「現時点で判断できないことを認め、将来の選択肢を残す」ことが、このアーキテクチャの本質だと考えています。 ## 課題 もちろん課題もあります。 ### テーブルの公開/非公開をどう明示するか 現状、私たちは同一チームで複数の基盤を運用しているため、「このテーブルは読み取ってよいのか」という混乱は発生していません。しかし、スキーマ内のすべてのテーブルが他サービスに公開されるべきとは限りません。たとえば、パスワードハッシュを管理するテーブルは認証基盤以外のサービスから参照されるべきではありません。 こうした「内部専用テーブル」と「公開テーブル」の区別を、仕組みとして明示する方法はいくつか考えられます。命名規則でプレフィックスを付与する方法や、DBユーザーの権限をテーブル単位で細かく制御する方法などです。現時点では同一チーム内の暗黙知で運用できていますが、チームの人数が増えたり、他チームがスキーマを参照するようになれば、「どのテーブルを読んでよいのか」という判断を個人の知識に頼るわけにはいきません。組織の拡大に先んじて、構造的な解決策を整備していく必要があると考えています。 ### スキーマ変更にはデプロイ順序の調整が必要 他サービスが参照しているテーブルのカラムを変更するには、影響範囲の調査とデプロイ順序の調整が必要です。カラム削除やリネームの場合は、参照元サービスの更新を先にデプロイしなければなりません。デプロイの柔軟性については、まだ改善の余地が残っています。 ### データベースが単一障害点になるリスク サービスベースアーキテクチャの最大のトレードオフは、単一のデータベースが全サービスのSPOFになることです。マイクロサービスであればDB-Aが停止してもサービスBは影響を受けませんが、DB共有ではそうはいきません。 このリスクを軽減するために、いくつかの対策があります。 **リードレプリカによる読み取り負荷の分散** PostgreSQLのストリーミングレプリケーションでリードレプリカを構成し、参照クエリをレプリカに振り分けることで、プライマリの負荷を軽減できます。サービスベースアーキテクチャでは他サービスのスキーマへのアクセスがSELECTのみであるため、クロススキーマ参照をリードレプリカに向けるのは自然な構成です。ただし、レプリケーション遅延は通常ミリ秒単位ですが、負荷が高い場合は秒単位に達する可能性があります。強い整合性が必要な読み取りはプライマリに向ける必要があります。 **自動フェイルオーバーによるダウンタイムの最小化** Patroniやpg_auto_failoverなどのツールを使用すれば、プライマリ障害時に自動でスタンバイへ昇格できます。Amazon Aurora PostgreSQLであれば、フェイルオーバーは通常30秒以内、[RDS Proxyを併用すれば10秒以内に完了します](https://dev.classmethod.jp/articles/aurora-fast-failover-with-amazon-rds-proxy/)。いずれの場合もアプリケーション側のコネクションプーリングの再接続処理が必要です。 **根本的な限界** これらの対策を講じても、論理的なSPOFが完全に解消されるわけではありません。共有DBに対する破壊的なスキーマ変更やデータ破損は、すべてのサービスに同時に影響します。これはサービスベースアーキテクチャの本質的なトレードオフであり、「独立デプロイ」と「強いデータ整合性」を両立するための代償です。将来的に特定のサービスの可用性要件が極めて高くなった場合は、そのサービスのDB分離を検討する必要があります。これは前述の発展可能性で触れた、マイクロサービス化への自然な移行パスです。 ### 運用で学んだこと - テーブルの所有権を明文化してADRに記録する。新しいメンバーが「このテーブルは誰のものか」と迷わないようにする - SELECT権限の付与は慎重に行う。一度読み取り権限を付与すると依存関係が生まれるため、DB直接参照が本当に必要か、データ基盤経由で十分ではないか、都度検討する - テスト環境でも本番と同じロール設定を再現する。開発環境でスーパーユーザーを使用していると、本番で初めて権限エラーに気づくことになる ## まとめ サービスベースアーキテクチャは、データの強い整合性が必要であるが、デプロイは独立させたい、ドメイン境界もまだ不明確で、チームも小規模、という状況に適したアーキテクチャです。 「マイクロサービスかモノリスか」という二項対立に陥りがちなアーキテクチャ選定ですが、その中間にこのような選択肢があることを知っていただければ幸いです。 設計パターンは選定して終わりではなく、運用の中で課題を発見し、継続的に改善していくものだと実感しています。 ### 他に書いたサーバサイドTypeScriptの記事 [https://kosui.me/posts/2025/05/06/142842](https://kosui.me/posts/2025/05/06/142842) [https://kosui.me/posts/2025/12/10/210415](https://kosui.me/posts/2025/12/10/210415) ## 参考文献 - Mark Richards, Neal Ford『[ソフトウェアアーキテクチャの基礎](https://www.oreilly.co.jp/books/9784873119823/)』(オライリー・ジャパン) - Mark Richards, Neal Ford『[Fundamentals of Software Architecture, 2nd Edition](https://www.oreilly.com/library/view/fundamentals-of-software/9781098175504/)』(O'Reilly Media, 2025)— サービスベースアーキテクチャの章が大幅に加筆されています - Chris Richardson「[Shared database](https://microservices.io/patterns/data/shared-database.html)」「[Database per service](https://microservices.io/patterns/data/database-per-service.html)」— microservices.io - [HIPAA Security Rule - 45 C.F.R. § 164.312(b)](https://www.law.cornell.edu/cfr/text/45/164.312) — 監査証跡に関する技術的セーフガードの要件 - [PostgreSQL GRANT documentation](https://www.postgresql.org/docs/current/sql-grant.html) — スキーマ単位の権限制御の構文リファレンス ## Node.jsパフォーマンスチューニングをDatadog APMとClaude Codeでサクッとやる Datadog APMでNode.jsのプロファイルを取得し、Claude Codeと組み合わせてパフォーマンスのボトルネックを特定・改善する方法を紹介します。 ![](https://cdn-ak.f.st-hatena.com/images/fotolife/k/kosui_me/20251210/20251210210405.png) ## はじめに こんにちは、kosui([@kosui_me](https://x.com/kosui_me))([id:kosui_me](http://blog.hatena.ne.jp/kosui_me/))です。 普段は医療系のスタートアップで認証基盤・ライセンス基盤・組織階層基盤などのプラットフォームシステムを開発・運用するチームのテックリードをしています。 [日本の医療に本気で向き合う。認証・権限管理基盤チームの決意 - KAKEHASHI Tech Blog](https://kakehashi-dev.hatenablog.com/entry/2025/08/29/105510) 今日は、サーバサイドTypeScriptでやっていく上で、いつか向き合うことになるパフォーマンスチューニングの話をします。 私は前職ではGoやPerlやJavaを使っていましたが、現職ではNode.js/TypeScriptを使っています。サーバサイドTypeScriptの知見は多くないと感じているので、こうして記事を書き溜めておいて、将来の自分や同じようにNode.js/TypeScriptでサーバサイド開発をしている人の助けになれば嬉しいです。 ### 他に書いたサーバサイドTypeScriptの記事 [https://kosui.me/posts/2025/05/06/142842](https://kosui.me/posts/2025/05/06/142842) [https://kakehashi-dev.hatenablog.com/entry/2025/08/19/110000](https://kakehashi-dev.hatenablog.com/entry/2025/08/19/110000) ## 本題 Node.jsはシングルスレッドでイベントループを回すアーキテクチャを採用しています。そのおかげで大量の同時接続を効率的に捌いてくれますが、その一方でCPU負荷が高い処理が発生するとそこで詰まってしまい、アプリケーション全体のレスポンスが低下してしまいます。 そんな時、私たちは「推測する前に計測せよ」に従い、まず問題をきちんと発見することに注力すると思います。実際、Goを書いている人でpprofを使ったことがない人は少ない思います。 一方で、Node.jsのプロファイリングにはあまり馴染みがない人も多いのではないでしょうか。 そこで、Datadog APMでNode.jsアプリケーションのパフォーマンスを計測して、Claude Codeでボトルネックを解析し、サクサクとパフォーマンスチューニングを行う方法を紹介します。 ## Node.jsのパフォーマンスチューニングの勘所 多くの場合、Node.jsアプリケーションのパフォーマンスチューニングを行う理由は次のような問題が発生した時です。 - メモリ - メモリリークが発生しGCが頻発している - そもそもヒープサイズが小さすぎる - CPU - 非同期にすべき処理が同期的に実行されている - CPUヘビーな処理が全体のイベントループを阻害している - ネットワーク - ネットワークでKeep-Aliveが効いていない 具体的なハマりどころをいくつか紹介します。 ちなみに、以下の記事が非常に参考になりました。 [https://yosuke-furukawa.hatenablog.com/entry/2017/12/05/125517](https://yosuke-furukawa.hatenablog.com/entry/2017/12/05/125517) ### メモリ Node.jsは世代別GCを採用しています。世代別GCでは、オブジェクトが新しく生成されたときは新世代に配置され、ある程度生存し続けると古い世代に昇格します。GCは新世代を頻繁にスキャンして不要なオブジェクトがドシドシ回収されていく一方で、古い世 代はあまり頻繁にはスキャンしません。Node.jsでは、新世代を管理するNew Spaceと、古い世代を管理するOld Spaceがあり、それぞれ最大サイズを指定できます。 そして、サーバサイドTypeScript/JavaScriptアプリケーションにおいて、リポジトリやHTTPクライアントやロガーなどをシングルトン化せずにリクエストごとに生成してしまうと、オブジェクトがNew spaceにどんどん溜まっていき、GCが頻発する原因になります。 また、利用しているライブラリ側でグローバルなキャッシュが適切にクリアされない場合も、メモリリークの原因になります。 いずれにせよ、メモリリークが発生している場合はGCが頻発し、アプリケーションのレスポンスが低下します。 また、そもそもヒープサイズが小さすぎる場合も、GCが頻発する原因になります。Node.jsアプリケーションのデフォルトのヒープサイズは比較的小さいため、大量のデータを扱うアプリケーションでは `--max-old-space-size` オプションでヒープサイズを増やすことが必要になる場合があります。ただ、あまりにデカいと他のプロセスが使うメモリがなくなりますので、注意して下さい。 ### CPUヘビーな処理 サーバサイドTypeScript/JavaScriptでは、シングルスレッドでイベントループを回しているため、非同期処理が得意な反面、CPUヘビーな処理を苦手としています。 よくあるCPUヘビーな処理の例としては、例えば次のようなケースがあります。一部の処理はどうしても同期的に実行する必要があるため、元気があればWorker Threadsを使って別スレッドで処理する方法を検討したり、CPUヘビーな処理を他のサービスに切り出すことを検討したりします。 - 大量のデータを同期的に処理する - デカい配列を処理する - `argon2id` や `bcrypt` などのCPU負荷が高いハッシュ関数を同期的に実行する - Reactのサーバサイドレンダリングで大規模なコンポーネントツリーを同期的に描画する ## Datadog APM ### `@datadog/pprof` 実は、Node.jsでもGoと同じようにpprofを利用でき、Datadog APMもpprofをサポートしています。 プロファイラは単体で利用することもでき、`@datadog/pprof` ([DataDog/pprof-nodejs](https://github.com/DataDog/pprof-nodejs)) にて公開されています。 このプロファイラは、V8のプロファイラによって収集されたプロファイルをpprof形式にエンコードして保存し、そのプロファイルをDatadog APMに送信する仕組みになっています。 V8のプロファイラをそのまま利用しているため、どの関数でCPU時間が消費されているか、どの関数がヒープを消費しているかなどを詳細に分析できます。具体的には、次の3種類のプロファイルを収集できます。 - CPU時間 (CPU Time) - ヒープサイズ (Heap Size) - 実時間 (Wall Time) なお、`@datadog/pprof` はGoogleの [google/pprof-nodejs](https://github.com/google/pprof-nodejs) をフォークして改良しています。google/pprof-nodejsもメンテナンスされているものの、Datadog版の方が積極的に改善されている印象です。 また、DatadogはPure JavaScriptなpprofエンコーダ・デコーダ[DataDog/pprof-format](https://github.com/DataDog/pprof-format)を開発しています。力が入っていますね。 ### Datadog APMの導入 導入もそれなりに簡単で、`node --require dd-trace/init app.js` のように `--require` オプションを付けて起動すれば計測できるようになります。ただし、得られたプロファイルをDatadog APMへ送信するためにはDatadogエージェントが動いている必要があります。正しい導入手順は [Node.js アプリケーションのトレース](https://docs.datadoghq.com/ja/tracing/trace_collection/automatic_instrumentation/dd_libraries/nodejs/) を参照してください。 ### プロファイル収集の原理 `--require` オプションで渡された `dd-trace/init` モジュールはアプリケーションの起動時に自動でロードされます。 `dd-trace/init` 内の `@datadog/pprof` がロードされた時点でプロファイリングが開始され、同時に `process.on('exit', () => { })` ハンドラが登録されて、プロセス終了時にpprof形式のプロファイルが保存されます ([コード](https://github.com/DataDog/pprof-nodejs/blob/107eb6a9/ts/src/index.ts#L57-L67))。保存されたプロファイルは、おそらくDatadogエージェントが収集してDatadog APMに送信していると思われます。 pprof形式のファイルは、先ほども述べた通りV8のプロファイラによって収集されたプロファイルをエンコードして作られています。具体的には、`v8.getHeapSnapshot()` や `v8.getCPUProfile()` といったV8のAPIを利用してプロファイルを取得し、それをpprof形式に変換しています ([コード](https://github.com/DataDog/pprof-nodejs/blob/107eb6a9/ts/src/profile-serializer.ts#L90-L204))。 ### Datadog APMでのプロファイル確認 Datadog APMにプロファイルが送信されると、DatadogのAPMダッシュボードで確認できます。 1. 対象サービスのAPMダッシュボードに移動する 2. 「Profiles」タブをクリックする 3. 「Visualize as」から「Profile List」を選択する このボタンを見つけられず、迷うことが多い 4. どれか一つプロファイルをクリックする 5. 右上に小さなダウンロードボタンがある マジでこれを見つけられず、本当に苦労した ## Claude Codeでのプロファイル解析 Datadog APMからダウンロードしたpprof形式のプロファイルは、わりとそのままClaude Codeにぶん投げて解析できます。ただ、Claude Codeは `go tool pprof` を使って解析することが多いので、Goをインストールしておくとよいと思います。 また、実際のアプリケーションコードと見比べながら解析してほしいので、アプリケーションのプロジェクトルートからClaude Codeを起動するのがおすすめです。 ### Claude Codeが表面的な解析を繰り返す時は ただ、pprof形式のプロファイルはそれなりにサイズが大きいので、Claude Codeは一部のプロファイルだけを覗き見して、表面的な解析結果を返してくることも多いです。 例えば、メモリリークが発生してGCが頻発している場合はCPUの使用率も高くなりますが、Claude Codeがそれを見て「CPUの負荷を減らそう」と提案してくることもあります。そういう時は、ヒープのプロファイルを重点的に解析するように促すとよいでしょう。 また、Claude Codeはあまり `-tree` オプションを使ってくれません。そのため、ネイティブモジュールにめちゃくちゃ気を取られることもあれば、複数の関数にまたがっているボトルネックを見逃すこともあります。そういう時は、`-tree` オプションを使うように促すとよいでしょう。 ## まとめ Node.jsアプリケーションの知見があまりない中でも、Datadog APMとClaude Codeを組み合わせることで、案外なんとかパフォーマンスの問題を特定できます。ただ、Claude Codeが表面的な解析を繰り返すことも多いので、適宜ヒーププロファイルを重点的に解析するように促したり、`-tree` オプションを使うように促したりして、エージェントが道に迷わないようにサポートしてあげると良いと思います。 ## 求人 日本の医療に本気で向き合うための、強くて研ぎ澄まされた基盤システムを一緒に作ってくれる人を探しています!!!! - [【認証・権限管理基盤】プロダクトマネージャー | 株式会社カケハシ](https://hrmos.co/pages/kakehashi/jobs/1911355054714523735) - [【プラットフォームドメイン】エンジニアリングマネージャー | 株式会社カケハシ](https://hrmos.co/pages/kakehashi/jobs/1911355054714523752) - [【プラットフォームドメイン】Head of Engineering 候補 | 株式会社カケハシ](https://hrmos.co/pages/kakehashi/jobs/1911355054714523755) - [フルスタックエンジニア|全社共通 | 株式会社カケハシ](https://hrmos.co/pages/kakehashi/jobs/1911355054714523766) - [バックエンドエンジニア|全社共通 | 株式会社カケハシ](https://hrmos.co/pages/kakehashi/jobs/1911355054714523765) ## 私がTypeScriptで `interface` よりも `type` を好む理由 interfaceの宣言マージがセキュリティリスクを招く可能性を示しながら、アプリケーション開発でtypeを優先すべき理由を解説します。 ## はじめに TypeScriptで型を定義する際、`interface` と `type` のどちらを使うべきか。これは、多くの開発現場で一度は議論になるテーマではないかと思います。 世の中の多くのドキュメントや記事では、クラスへの `implements` のしやすさや、`interface` が持つ「宣言のマージ(Declaration Merging)」の利便性が紹介されることもあり、`interface` の利用が推奨されるケースもよく見かけます。 しかし、特にサーバサイドアプリケーションや、ある程度規模のあるシステムを開発する上で、私はこの「宣言のマージ」機能が、時として予期せぬ挙動や、場合によってはセキュリティ上のリスクを静かにもたらす要因になると感じています。 今回は、なぜ私がプロダクトコードにおいて `interface` の積極的な使用を避け、`type` エイリアスを好んで使うのか、具体的なシナリオを通してお話ししたいと思います。 ## 一見無害なコードに潜む問題 あるユーザー情報を扱うAPIの開発シーンを想像してみてください。 まず、`User` の型を `interface` で定義します。この時点では、ユーザー情報はIDと名前だけを持つとされています。 ```typescript // src/domain/user.ts // UserはIDと名前しか持たない interface User { id: string name: string } const User = { create: (props: { name: string }): User => { return { id: randomUUID(), name: props.name }; }, } as const; // ユーザー情報を取得するリゾルバのインターフェース interface UserResolver { resolve: (id: string) => Promise; } ``` 次に、この `User` 型を返すAPIハンドラを実装します。DBからユーザー情報を取得し、見つからなければエラーを投げる、というごく一般的な処理です。 ```typescript // src/index.ts import { Hono } from "hono"; import { createUserResolver } from "./adaptor/memory/userResolver.js"; const app = new Hono(); const userResolver = createUserResolver(); app.get("/user/:id", async (c) => { const id = c.req.param("id"); const res = await userResolver.resolve(id); return c.json(res); }); ``` このAPIハンドラの実装者は、`user.ts` の冒頭を見て「`User` は `id` と `name` しか持たない」と認識しているため、DBから取得した `user` オブジェクトをそのままクライアントに返却します。ここまでは、特に問題ないように見えます。 ところが、数ヶ月後、別の開発者が認証機能の実装に伴い、 `src/domain/user.ts` の `User` `interface` に定義を追加したとします。 ```typescript // src/domain/user.ts // ...100行ほど上にあるさまざまな処理や定義... // 認証のために User にパスワードハッシュを追加 interface User { hashedPassword?: string } ``` ここで問題が発生します。TypeScriptの `interface` は、同じ名前で宣言されると、その定義が自動的にマージ(結合)されます。 その結果、`src/index.ts` の実装者が意図していた `User` 型は、`id` と `name` だけでなく、`hashedPassword` も含む型へと静かに変化してしまっています。 もし `userResolver` がDBからパスワードハッシュを含む完全なユーザーオブジェクトを返していた場合(これはORMなどを使っているとよくあるケースです)、APIハンドラ `handler` は、その実装者の意図に反して、パスワードハッシュを含んだオブジェクトをクライアントに返してしまうことになります。 もちろん、これはやや極端な例かもしれません。経験豊富な開発チームであれば、APIのレスポンスを返す前に、Zodなどを使用したバリデーション処理や、機微情報を除外する処理を挟むのが一般的でしょうから、今回のようなケースが実際のプロダクトでそのまま起こることは稀だと考えます。 また、わざわざ既存の型定義を編集せずに同じ名前で定義する人もそうそう居ないでしょう。(**私はそんな現場を目撃したことがありますが**、たまたま運が悪かっただけです)。 しかし、ここで私が指摘したいのは、「型定義ファイルを見に行くだけでは、その型が最終的にどのような形状になるか断定できない」という interface の特性そのものが持つリスクです。プロジェクトが大規模化し、多くの開発者が関わるようになると、このような「暗黙のマージ」がコードの見通しを悪くさせ、意図しない挙動の温床になる可能性は否定できません。 ## 宣言のマージ この問題の根本的な原因は、`interface` の「宣言のマージ」という言語仕様そのものにあります。 この機能は、例えば外部ライブラリの型定義を拡張する際(いわゆる "Module Augmentation")には非常に強力です。例えば、FastifyやHonoの `Request` オブジェクトに、自前のミドルウェアで追加した `user` プロパティの型定義を後から追加する、といった用途には最適です。 しかし、アプリケーション内部のドメインモデルや、APIのレスポンスのような「データの形状(Shape)」を定義する際に、この「どこからでも後から定義を上書き・追加できてしまう」という特性は、裏目に出ることがあります。 型定義の「信頼できる唯一の情報源(Single Source of Truth)」が曖昧になり、知らないうちに型が変更されてしまう。これは、システムの堅牢性やセキュリティを担保する上で、私たちが軽視してはならないリスクだと考えています。 ## `type` エイリアスによる型定義 では、この問題を回避するにはどうすればよいでしょうか。 答えはシンプルで、私は `type` エイリアスを使うことを推奨します。 先ほどの `User` を `type` で定義し直してみましょう。 ```typescript // src/domain/user.ts // typeで定義する type User = { id: string name: string } ``` APIハンドラの実装は先ほどと同じです。 ここで、認証機能の開発者が `User` 型を同名で拡張しようと試みると、どうなるでしょうか。 ```typescript // src/domain/user.ts // 同じ名前の type を定義しようとすると... type User = { // ^ Cannot redeclare block-scoped variable 'User'. ts(2451) hashedPassword: string } ``` このように、`type` エイリアスは宣言のマージを許可しません。同じスコープ内で同じ名前の `type` を定義しようとすると、TypeScriptコンパイラが明確に「重複した識別子」としてエラーを検出してくれます。 これにより、型定義が意図せず変更されることをコンパイル時点で防ぐことができます。`User` 型の定義は `src/domain/user.ts` に書かれたものがすべてである、ということが保証されるわけです。 もし認証機能でパスワードハッシュを含めた型が必要なのであれば、`User` を拡張した新しい型を明示的に定義することになります。 ```typescript // src/domain/auth.ts import { User } from '../users/types' // User を拡張して、認証用の型を明示的に作成する type AuthUser = User & { hashedPassword: string } ``` こうすることで、`User` 型(APIレスポンスに使われる型)は安全なまま保たれ、認証処理という別の関心事のために拡張された型を安全に使い分けることができます。 ## 補足 ### データベースから取得した値をそのまま返すべきではない 今回の例では、データベースから取得した値をそのままHTTPレスポンスボディに含めていました。 本来であれば、Zodなどのスキーマバリデーションライブラリを使用して、レスポンスボディを検証してから返却するべきです。 ```typescript import { z } from 'zod/v4' const ResponseBody = { schema: z.object({ id: z.string(), name: z.string(), }), } as const app.get("/user/:id", async (c) => { const id = c.req.param("id"); const user = await userResolver.resolve(id); const res = ResponseBody.schema.parse(user); return c.json(res); }); ``` > また別の論点ですが、「同名の型定義」は type であっても別ファイルならできてしまうので、「今まで思っていた User 型とは違うものが別ファイルに定義されてそれが渡されていたが、部分型一致で気づかなかった」があり得てしまうので、オブジェクト全返しは即座にやめた方が良い、ですね > — ユーン (@euxn23) [2025年10月24日](https://twitter.com/euxn23/status/1981519606612447443?ref_src=twsrc%5Etfw) > そもそも,リポジトリ層から帰ってきたオブジェクトをそのまま DTO に入れて応答するのがよくないと思っているので,そこを Linter などで検査できると一番良いのではと考えています.Go などと比べると詰め替えの手間を省略できるのは便利ですが,逆に言えば予期しない値もレスポンスしやすいです > — Siketyan (@s6n_jp) [2025年10月24日](https://twitter.com/s6n_jp/status/1981527951956201956?ref_src=twsrc%5Etfw) ### ESLintの設定を見直そう [no-redeclare | typescript-eslint](https://typescript-eslint.io/rules/no-redeclare/) を使用すれば、interfaceを同じ名前で宣言することを禁止できます。 また、[consistent-type-definitions | typescript-eslint](https://typescript-eslint.io/rules/consistent-type-definitions/) を使用すれば、自動でinterfaceまたはtypeのどちらかに寄せることができます。 > これに賛同なのは前提として、 eslint-typescript/no-redeclare を使うとそもそも interface の重複自体を排除できる話が書いてあると嬉しそうです。 > — ユーン (@euxn23) [2025年10月24日](https://twitter.com/euxn23/status/1981519316223975740?ref_src=twsrc%5Etfw) ### エッジケースのパフォーマンスに注意 > Edge case performance > Large, complex logical types can be optimized better with interfaces by TypeScript's type checker. > [https://typescript-eslint.io/rules/consistent-type-definitions/](https://typescript-eslint.io/rules/consistent-type-definitions/) 多くのケースでは型エイリアスとinterfaceのパフォーマンス上の違いはほとんどありません。ただし、エッジケースにおいては、 interfaceの方が型検査のパフォーマンスに優れています。 > 明確なボトルネックがなく型チェックに1分くらいかかるTypeScriptのコードベースで、 1500件ほどのtypeを一括にinterfaceに置換してみた。 とくに型チェック時間は変わらなかった。 現実はそんなもんです(?) > — 🈚️うひょ🤪✒📘 TypeScript本発売🫐 (@uhyo_) [2025年10月22日](https://twitter.com/uhyo_/status/1980824323503059097?ref_src=twsrc%5Etfw) > ということで、interface + extends に機械的に変換できそうなインターセクション型約200件をinterface + extendsに変換してみた。 その結果4〜5秒(7〜8%)の型チェック高速化が認められた。 これも現実です(?) > — 🈚️うひょ🤪✒📘 TypeScript本発売🫐 (@uhyo_) [2025年10月22日](https://twitter.com/uhyo_/status/1980876807948890144?ref_src=twsrc%5Etfw) ## まとめ:トレードオフと私の選択 もちろん、`interface` が悪だと言いたいわけではありません。 先ほども触れたように、外部ライブラリの型定義を拡張する用途では `interface` のマージ機能が不可欠な場面もあります。また、古くから「オブジェクトの形状定義には `interface` を、それ以外(Union型やUtility Typeの結果など)には `type` を」という使い分けの指針も存在します。 しかし、私たちが日々扱う「アプリケーションのデータ構造」である、APIのレスポンス、DBのエンティティ、ドメインオブジェクトなどを定義する際には、その型が「閉じている(closed)」こと、つまり「定義ファイルに書かれているプロパティがすべてである」という状態が、私は何よりも重要だと考えています。 `type` を使うことは、その型定義が意図せず拡張されることを防ぐという、技術的な負債やセキュリティリスクを未然に防ぐための強力な防衛策となります。 半年後にコードを修正する未来の自分やチームメイトが、その型定義を信頼して安全に開発を進められる。そうした保守性の高いコードベースを築くために、私はアプリケーションの型定義の第一選択として、`interface` よりも `type` を選ぶようにしています。 ## ユーザーの内部IDの発行権を他人に握らせてはいけない 外部サービスのIDやユーザーが変更可能な値をシステムの内部IDに使うべきでない理由と、そのリスクを具体例で説明します。 ![](https://cdn-ak.f.st-hatena.com/images/fotolife/k/kosui_me/20250602/20250602225125.png) ## 結論 ユーザーの内部IDを自システム以外に委ねるべきではありません。 ユーザーの内部IDの実装について気をつけるべきことを2つ紹介します。 - 外部サービスが発行したIDを内部IDにするべきではない - ユーザーが変更可能な値を内部IDにするべきではない ## 外部サービスが発行したIDを内部IDにするべきではない 外部サービスが発行したIDを内部IDにすることは避けましょう。 内部IDは、システム内で一意で永続的な識別子であるべきです。 ### 例) 外部IDプロバイダのユーザーID 例えば、GoogleをIDプロバイダとして利用する上で、GoogleアカウントのユーザーIDをそのままユーザーの内部IDとして使用した場合、どんな問題が起こるかを考えてみましょう。 例えば、後からGoogleがユーザーIDの仕様を変更した場合、システム内でのユーザーIDの一意性が保証されなくなります。もちろん、GoogleがユーザーIDを変更することは稀ですが、もしそうなった場合、システム全体に影響が及ぶ可能性があります。 また、後から他のIDプロバイダを追加した場合、ユーザーIDの一意性を保つために、複雑なロジックが必要になるかもしれません。 ```typescript // 複雑になってしまった例 type UserId = { type: 'Google' | 'Facebook' | 'Twitter'; value: string; } ``` ```sql SELECT * FROM companies c LEFT JOIN company_members cm ON cm.company_id = c.company_id LEFT JOIN users u ON u.id = cm.id AND u.id_type = cm.id_type; -- id_typeを指定し忘れた場合、誤ったユーザーIDを結合して障害になりそう ``` ### 例) 外部サービスのIDを内部IDにする場合 先ほどの例は、外部IDプロバイダのユーザーIDをそのまま内部IDに利用してしまう、という非常に特異なケースでした。 しかし、AWS CognitoユーザープールやAuth0などのIDaaSを利用する場合、外部サービスのIDを内部IDとして利用することはよくあります。 そして、そのような場合も、外部サービスのIDをそのまま内部IDとして利用することは避けるべきです。 例えば、AWS CognitoユーザープールのユーザーID (sub) をそのまま内部IDとして利用する場合、以下のような問題が発生する可能性があります。 #### 例) ユーザー名の変更時にIDが変更されてしまう場合 AWS Cognitoユーザープールでは、ユーザー名を変更できません。(別途変更可能な `preferred_username` 属性を利用することはできます。) この問題を解決するために、「ユーザー名を変更する場合、内部的にはCognitoユーザーを削除して再登録する」という実装をすることが考えられます。 しかし、CognitoユーザーのID(sub)をそのまま内部IDとして利用している場合、ユーザーを削除して再登録することで、内部IDが変わってしまいます。 このCognitoユーザープールに単一のシステムが依存している場合はまだしも、複数のシステムが依存しているとしたらどうでしょう。どうやって安全に新しいID体系へ移行するのでしょうか? このような場合、あらかじめ内部IDを別途採番する実装をすることが望ましいです。 ## ユーザーが変更可能な値を内部IDにするべきではない ユーザーが変更可能な値をシステムのためのID(内部ID)にすることは避けましょう。 IDは一意で永続的な識別子であるべきであり、ユーザーが変更できる値はその特性に反します。 ### 例) ユーザーの内部ID ユーザーの内部IDを、ユーザーが変更可能な値にした場合、どんな問題が起こるかを考えてみましょう。 例えば、ユーザーの内部IDをユーザー名にする場合、ユーザーがユーザー名を変更すると内部IDも変わってしまいます。 これにより、以下のような問題が発生します。 - **リレーションシップの破壊** 他のデータベースのテーブルや外部システムとのリレーションシップが、ユーザー名の変更によって壊れてしまいます。 - **監査ログ** ユーザーの行動を追跡するためのログが、ユーザー名の変更によって無効になります。 ### ユーザーがユーザー名を変更しなければ良いのか? ユーザーがユーザー名を変更不可とする実装は現実的ではありませんが、「初期リリースでは変更不可」などの意思決定が取られることはあります。 では、その場合には問題は発生しないのでしょうか? 以下の例を見てみましょう。 1. ユーザーAが `foo` として登録 2. ユーザーAが退会 3. ユーザーBが `foo` として登録 この場合、他のデータベースのテーブルや外部システムとのリレーションシップがきちんと修正されていないと、ユーザーBがユーザーAのデータにアクセスできてしまう可能性があります。 近年では、個人情報の漏洩は企業の大きな信用毀損リスクとなるため、「内部IDを別途採番する実装コスト」と「個人情報の漏洩リスク」を天秤にかけた場合、後者のリスクが圧倒的に大きいです。 もっと分かりやすく言えば、内部IDの実装をするための(多くても)1週間と、個人情報漏洩の問題を顧客に説明し、再発防止策を講じ、徹底的に調査するための1ヶ月を天秤にかけてみてください。 また、サービスがスケールした場合にデータ分析をする場合、過去の差分データがどのユーザーに紐づいているかを正確に把握するためには、内部IDが必要です。せっかくサービスがPMFを達成しても、得られたデータがゴミになってしまい、次の施策が打てなくなってしまうかもしれません。 ## なぜTypeScriptでメソッド記法を避けるべきか?実務に近い事例の紹介 TypeScriptでメソッド記法を使うと引数の型チェックが甘くなる理由を、タスク管理サービスの実例を交えて解説します。 ![](https://cdn-ak.f.st-hatena.com/images/fotolife/k/kosui_me/20250602/20250602230338.png) ## 参考文献 - [プロを目指す人のためのTypeScript入門 安全なコードの書き方から高度な型の使い方まで | 技術評論社](https://gihyo.jp/book/2022/978-4-297-12747-3) 通称ブルーベリー本。コラム17に記載。 - [Method Shorthand Syntax Considered Harmful | Total TypeScript](https://www.totaltypescript.com/method-shorthand-syntax-considered-harmful) - [TypeScript の変性(共変・反変)を 5 分で理解する](https://zenn.dev/jay_es/articles/2024-02-13-typescript-variance) ## はじめに TypeScriptでは、オブジェクトの型エイリアスには主に2通りの方法で関数を含めることができます。 - 「メソッド記法」と呼ばれる方法 - プロパティとして関数を定義する方法 ```typescript type User = Readonly<{ id: string }> type UserRepository = { // メソッド記法 save1(user: User): Promise // プロパティとして関数を定義 save2: (user: User) => Promise } ``` しかし、「[プロを目指す人のためのTypeScript入門](https://gihyo.jp/book/2022/978-4-297-12747-3)」を始め、さまざまな書籍やブログ記事で「メソッド記法」は避けるべきと解説されています。これは、メソッド記法を利用した場合は双変となってしまい、引数がサブタイプでも型検査を通過してしまうためです。この問題の背景については下記の記事が詳しいです。 [https://zenn.dev/jay_es/articles/2024-02-13-typescript-variance](https://zenn.dev/jay_es/articles/2024-02-13-typescript-variance) しかし、「具体的にメソッド記法が問題となるケースが思い当たらない」という方も多いと思います。この記事では、実際のバックエンド開発で発生しうる事例を参考に考えてみましょう。 ## 事例 ### タスク管理サービス Jiraのようなタスク管理サービスを例に考えてみましょう。タスクのステータスは `ToDo` `Doing` `Done` のいずれかを取り、ステータスごとに個別のプロパティを持つとします。 ```typescript type User = { id: number } type Task = | { id: number, status: 'ToDo' } | { id: number, assignee: User, status: 'Doing' } | { id: number, assignee: User, doneAt: Date, status: 'Done' } ``` ここで、`Doing` 状態のタスクを `ToDo` 状態へ戻すユースケース `UnassignTaskUseCase` を実装します。 ```typescript type Ok = { ok: true } type Err = { ok: false, reason: 'NOT_FOUND' | 'BAD_STATUS' } type UnassignTaskUseCase = { run: (taskId: number) => Promise } ``` ### メソッド記法のリポジトリ このユースケースを実装するためには、タスクを取得する処理と、タスクを永続化する処理が必要です。これらを `TaskRepository` が担うとします。ここで注意すべき点は、リポジトリの `save` がメソッド記法で定義されていることです。 ```typescript type TaskRepository { find: (taskId: number) => Promise save(task: Task): Promise // ^^^ メソッド記法 } const newUseCase = (taskRepository: TaskRepository): UnassignTaskUseCase => ({ run: async (taskId: number) => { const task = await taskRepository.find(taskId) if (!task) { return { ok: false, reason: 'NOT_FOUND' } } if (task.status !== 'Doing') { return { ok: false, reason: 'BAD_STATUS' } } await taskRepository.save({ id: task.id, status: 'ToDo' }) return { ok: true } } }) ``` ### サブタイプを引数に取るリポジトリ実装 ユースケースへ渡す `TaskRepository` の実体を実装します。ただし、`store` が特定のサブタイプのみを引数に取るとします。例えば、ある実装者が「`status` が `Doing` の場合は `assignee` の存在チェックをしたい」と考えたとしましょう。 ```typescript // Doingの場合は永続化する時に `assignee` がUserテーブルに存在するか検証する const saveDoing = (task: Task & { status: 'Doing' }) => { console.log(task.assignee.id) // task.assignee.id の存在チェック return Promise.resolve(undefined) } const tasks = [{ id: 1, status: 'ToDo' }] as const const taskRepository = { find: (taskId: number) => Promise.resolve(tasks.find(x => x.id === taskId)), save: saveDoing, } as const ``` ここで、実装したリポジトリを基にユースケースを作成してみましょう。 「プロパティとして関数を定義する方法」であれば、 `newUseCase` にタスクリポジトリを渡した時点でコンパイルが失敗します。 しかし、「メソッド記法」でユースケースの依存するリポジトリを定義した結果、以下のように型検査を通過してしまいました。そして、実行に失敗します。 ```typescript const useCase = newUseCase(taskRepository) // ^^^^^^^^^^^^^^ // 型検査を通ってしまう useCase.run(1) // Cannot read properties of undefined (reading 'name') ``` [Playground Link](https://www.typescriptlang.org/play/?#code/C4TwDgpgBAqgzhATlAvFA3lAlgEwFxQB2ArgLYBGSUAvgFCiRQAqAhnANaq1RQA+G2fETKVEAGihxgLYMTgEA5EwD2AEWUKa3PgNwESFJBLZwsAc0IQIBeEcnTZ8qAvVZCZzXR79Me4YfEoE3NLa1gEQJxlSwBBYAJVGQgJKRk5RXVLT1p6cGgAeU40TGV2AmBEYmg6BmgAUURkYqhSggAzFgAbBAlECDZoxQA5fKYAfQAxfJgh1U1+BQAhGNUxgGUmGKYYNeza2EJgi1YOWwBhNmhi7UrCAgAKaQ4ASSEDUQBKVAA+KAAFRDKUhYBAAHkKOgaiG+tDouUYJ3YACUIGBlKZgMpECBUBhtG03DhHmx2K99CIkB8CACgSCIKDETpiIQcBACZYcDCeAB6blQAB6gqggEOGQC9DIBhhkAkwyADCjAKs22jgLAAbhBiRwCIiqf9AcCwUrlLguVBeQKhWKpXLYTkAMbRKRECAAd3Ol1xauRqPRWEx2I1JJRaIxWJAWpghzgpmOJJdCB+UHu6BuzIIbBAhGt8aepLeFMQXxQv0TPB4tsI9qzuJYjpY3qgWYDXp9IAAdOyiVnXh9tDwsG14wBCLNfIvFnh9WSIQgCVpQDrdZJQPoDO7OEbjKYzOZaUdeYu9zMk5upRxQfsoNAuA3uBTD7vF8fESfTsqzro9Rf9OCDZzLVYbLY7NkO53lWNbAHW-qekG2JHsqqq+EIWbNrgKQOOkzgqOonhdqOD5PiUL4VFU25aNQXa0CarjuIAdgyACwagAQKoA9gyABw2gBZvoAaMqAJoMgDRDIAQmaANYMUAAAZHKEQlQIAMgy2IggBjDIAPwyAGsMgDXDPxgAa2oAFOo8YA0gyADiWgA8UTxtClvaioqlRZhulmfocFAABkAjHuhl5uB4ND5oW2jGconQQM2nTKGY7rNqJVjITgOHGnySEhb5uBQNRGmAIMMgDlDOKgD1DDcEATlONK6r5fRfp0Kr3MyrLshA4VWkZdrgVmcC4gA2ghBAAIyoWkThKGoGg0AAukE9XGcA1VlrVkGBt6wa4iObYPB2OYBO52q0ggzYFd5xV1a2hL3AAHnGu1hag54QS84UfGICpwQQpkQOZl3UANUBDTaNVQHIEAXLGaCWM6CBfaq9ZQZN2Jdh9ANrcy9wtR8QA) から実際に試してみてください。 ### プロパティとして関数を定義した場合 ここで、プロパティとして関数を定義した場合を見てみましょう。 ```typescript type TaskRepository { find: (taskId: number) => Promise save: (task: Task) => Promise // ^^^ 関数プロパティ } ``` この場合、きちんとコンパイルが失敗します。 ```typescript const useCase = newUseCase(taskRepository) // ^^^^^^^^^^^^^ // Argument of type // '{ // readonly find: (taskId: number) => Promise<{ readonly id: 1; readonly status: "ToDo"; } | undefined>; // readonly save: (task: Task & { status: "Doing"; }) => Promise; // }' is not assignable to parameter of type 'TaskRepository'. // Types of property 'save' are incompatible. ``` ## まとめ メソッド記法を避けることで、依存性注入時に不正な実装を注入することを防止できます。 やや冗長な例となってしまいましたが、参考になれば幸いです。 ## 生成AIにMermaid.jsでロバストネス図を描いてもらう Mermaid.jsのフローチャート機能を活用してロバストネス図を描く手法を紹介し、生成AIを用いて図を自動生成する実践例を示した記事です。 ![](https://cdn-ak.f.st-hatena.com/images/fotolife/k/kosui_me/20250508/20250508011714.png) ## 背景 ### 生成AIによる要求分析 要求分析はチームによるシステム開発の要であり、関係者の認識を揃えるために非常に重要なプロセスです。 システム開発のための要求分析の手法としてICONIXやRDRAなどが知られていますが、その過程ではユースケース図やロバストネス図などの図を用いてシステムの要求を視覚化することが一般的です。 近年では生成AIを使用することでより高速に要求分析を行うことができるようになりました。特に、自然言語で記述された抽象的な要望を生成AIに与え、要求を抽出させた上でPlantUMLやMermaid.jsなどで図を執筆させることで、プロジェクトの立ち上げ速度を向上させることができます。 例えば、「この要望から関係しうるアクターと外部システムをすべて列挙し、それぞれのアクターが持つ要望を3つ挙げて」と書くだけで、プロジェクト初期に巻き込むべきチームや関係者をすぐに発見できます。 ### ロバストネス図とは ロバストネス図を通じて、ユースケースとドメインモデルが実際の画面やAPIでどのように用いられるか整理することで、ユースケースやドメインモデルの妥当性を検証できます。 以下は、PlantUMLで生成したロバストネス図の一つです。 ![](https://cdn-ak.f.st-hatena.com/images/fotolife/k/kosui_me/20250508/20250508005200.png) ロバストネス図は、以下の3つの主要な要素で構成されています。 - バウンダリオブジェクト (Boundary Object) 例) 画面やAPIなど ユーザーや外部システムとのインタフェースを表します。 - コントロールオブジェクト (Control Object) 例) 「認証する」「商品の一覧を取得する」などのユースケース バウンダリオブジェクトとエンティティオブジェクトの間の仲介役として、システムの処理ロジックや制御の流れを表します。 - エンティティオブジェクト (Entity Object) 例) 「ユーザー」「商品」などの永続化されたデータ システムが扱う主要なデータや情報を表します。データベースやファイルストレージに永続化されることが一般的です。 ## 目的 ### Mermaid.jsでロバストネス図を描画したい PlantUMLにはロバストネス図の描画機能がありますが、Mermaid.jsにはありません。 しかし、Mermaid.jsはJavaScriptとCSSだけで描画を完結させることができるため、GitHubやNotion、esaなどの様々なサービス上で導入されています。 Mermaid.jsの他の機能を利用して、何が何でもMermaid.jsでロバストネス図を書いてみましょう。 ## 手法 ### Mermaid.jsのフローチャートを用いたロバストネス図 Mermaid.jsの `flowchart` 機能を利用して、ロバストネス図を描画しました。 `class` を活用することで、バウンダリ・コントロール・エンティティの区別もしやすいと思います。 ```mermaid --- config: theme: redux --- flowchart LR classDef boundary fill:#fff59d,stroke:#ffa000,stroke-width:2px; classDef control fill:#e3f2fd,stroke:#2196f3,stroke-width:2px; classDef entity fill:#e6ee9c,stroke:#4caf50,stroke-width:2px; actorUser["👤 ユーザー"] subgraph boundarySg["バウンダリ (画面と要素)"] direction LR subgraph loginView["🖥️ ログイン画面"] direction LR loginBtn["👆 ログインボタン"] loginBtn:::boundary end subgraph articleListView["🖥️ 記事一覧画面"] direction LR articleListSec["🖥️ 記事一覧 セクション"] articleListSec:::boundary postForm["👆 投稿フォーム"] postForm:::boundary end end subgraph controlSg["コントローラ"] direction LR authnCtrl["🔑 認証する"] authnCtrl:::control articleListCtrl["📜 記事一覧を 取得する"] articleListCtrl:::control postCtrl["📤 投稿する"] postCtrl:::control end subgraph entitySg["エンティティ"] direction LR user[("ユーザー")] user:::entity article[("記事")] article:::entity end actorUser --> loginBtn loginBtn --> authnCtrl authnCtrl --> user authnCtrl --> articleListCtrl articleListCtrl --> article articleListSec -.- articleListCtrl actorUser -.- articleListSec actorUser --> postForm postForm --> postCtrl postCtrl --> article ``` ### 生成AIを用いたロバストネス図の生成 作成したロバストネス図を基に、Gemini 2.5 Flashに「記事削除機能」のロバストネス図を作成させます。 > 記事詳細画面から、「削除」ボタンを押して記事を削除する機能のロバストネス図を書いてください。なお、記事を削除できるのは、その記事を投稿したユーザーだけです。まず記事投稿者取得コントロールによって記事を投稿したユーザーを取得し、次に記事削除コントロールによって記事を削除してください。 > なお、ロバストネス図は下記のテンプレートを参考にMermaid.jsで書いてください。 > (後述するテンプレート) その結果、以下のようなロバストネス図を得ることができました。これは概ね成功ではないでしょうか。 ```mermaid flowchart LR classDef boundary fill:#fff59d,stroke:#ffa000,stroke-width:2px; classDef control fill:#e3f2fd,stroke:#2196f3,stroke-width:2px; classDef entity fill:#e6ee9c,stroke:#4caf50,stroke-width:2px; actorUser["👤 ユーザー"] subgraph boundarySg["バウンダリ"] direction LR subgraph articleDetailView["🖥️ 記事詳細画面"] direction LR deleteButton["🗑️ 削除ボタン"] deleteButton:::boundary end end subgraph controlSg["コントローラ"] direction LR articleAuthorCtrl["👤 記事投稿者 取得コントロール"] articleAuthorCtrl:::control articleDeleteCtrl["🗑️ 記事削除 コントロール"] articleDeleteCtrl:::control end subgraph entitySg["エンティティ"] direction LR article[("記事")] article:::entity user[("ユーザー")] user:::entity end actorUser --> deleteButton deleteButton --> articleAuthorCtrl articleAuthorCtrl --> article articleAuthorCtrl --> user articleAuthorCtrl --> articleDeleteCtrl articleDeleteCtrl --> article articleDeleteCtrl --> articleDetailView:::boundary ``` ```mermaid flowchart LR classDef boundary fill:#fff59d,stroke:#ffa000,stroke-width:2px; classDef control fill:#e3f2fd,stroke:#2196f3,stroke-width:2px; classDef entity fill:#e6ee9c,stroke:#4caf50,stroke-width:2px; actorUser["👤 ユーザー"] subgraph boundarySg["バウンダリ (画面と要素)"] direction LR subgraph loginView["🖥️ ログイン画面"] direction LR loginBtn["👆 ログインボタン"] loginBtn:::boundary end subgraph articleListView["🖥️ 記事一覧画面"] direction LR articleListSec["🖥️ 記事一覧 セクション"] articleListSec:::boundary postForm["👆 投稿フォーム"] postForm:::boundary end end subgraph controlSg["コントローラ"] direction LR authnCtrl["🔑 認証する"] authnCtrl:::control articleListCtrl["📜 記事一覧を 取得する"] articleListCtrl:::control postCtrl["📤 投稿する"] postCtrl:::control end subgraph entitySg["エンティティ"] direction LR user[("ユーザー")] user:::entity article[("記事")] article:::entity end actorUser --> loginBtn loginBtn --> authnCtrl authnCtrl --> user authnCtrl --> articleListCtrl articleListCtrl --> article articleListSec -.- articleListCtrl actorUser -.- articleListSec actorUser --> postForm postForm --> postCtrl postCtrl --> article ``` ## 付録 ### テンプレート 以下のテンプレートをコピペして利用してください。 ```mermaid --- config: theme: redux --- flowchart LR classDef boundary fill:#fff59d,stroke:#ffa000,stroke-width:2px; classDef control fill:#e3f2fd,stroke:#2196f3,stroke-width:2px; classDef entity fill:#e6ee9c,stroke:#4caf50,stroke-width:2px; actorUser["👤 ユーザー"] subgraph boundarySg["バウンダリ (画面と要素)"] direction LR subgraph loginView["🖥️ ログイン画面"] direction LR loginBtn["👆 ログインボタン"] loginBtn:::boundary end subgraph articleListView["🖥️ 記事一覧画面"] direction LR articleListSec["🖥️ 記事一覧 セクション"] articleListSec:::boundary postForm["👆 投稿フォーム"] postForm:::boundary end end subgraph controlSg["コントローラ"] direction LR authnCtrl["🔑 認証する"] authnCtrl:::control articleListCtrl["📜 記事一覧を 取得する"] articleListCtrl:::control postCtrl["📤 投稿する"] postCtrl:::control end subgraph entitySg["エンティティ"] direction LR user[("ユーザー")] user:::entity article[("記事")] article:::entity end actorUser --> loginBtn loginBtn --> authnCtrl authnCtrl --> user authnCtrl --> articleListCtrl articleListCtrl --> article articleListSec -.- articleListCtrl actorUser -.- articleListSec actorUser --> postForm postForm --> postCtrl postCtrl --> article ``` ## TypeScriptでドメインイベントを容易に記録できるコード設計を考える データ変更の記録をドメインイベントとして型安全に設計する方法を、リポジトリ設計とテーブル設計の観点から具体的なコード例とともに解説した記事です。 ![](https://cdn-ak.f.st-hatena.com/images/fotolife/k/kosui_me/20250506/20250506155457.png) ## はじめに ### データアナリストの現場の苦しみ 近年、ビジネスの意思決定にはデータの活用が重要だという認識が広まりつつあります。実際、データアナリストに関する求人やデータ分析の発表が増えているのを実感します。 しかし、現場では、異常かつ不十分なデータをデータアナリストが必死に処理しながら分析を試みている状況です。それによって、本来集中したいデータの分析に充分に取り組めていないのが現状だと思います。あっちこっちのシステムに散らばった中途半端なデータの数々を寄せ集め、微妙なフォーマットの違いに気を配りながら整形し、それぞれのデータの法的な契約状態に注意しながら分析を行うのは、非常に大変な作業です。データアナリストの方々は、データの収集と整形に多くの時間を費やしているのではないでしょうか。 > 現在、IT系の仕事の中でデータアナリストは高い人気を博している。大手を含めて日本企業の大多数は情報活用が出来ていないので、データアナリストやその志望者にはブルーオーシャンが広がっているように見えるかもしれない。しかし、情報の分析・活用の現場で欠けているのはデータ分析のスキルではない。豊穣かつ正確なローデータ(分析用の生データ)の供給源となるべき「まともな基幹システム」である。 > ——— [基幹システムが劣化するとデータ分析が栄える - 設計者の発言](https://dbconcept.hatenablog.com/entry/2025/01/27/100337) ### アプリケーション開発者は今すぐデータの変更の記録に取り組もう この問題は、私たちアプリケーション開発者にとっても他人ごとではありません。データの源泉であるアプリケーションが適切に変更を記録すれば、このような問題は避けられるはずです。記録さえしておけば、今は忙しくても、後でデータアナリストへ必要なデータを取り出して配信できます。 しかし、一度失われてしまったデータを復元することは二度とできません。これは将来どうにかするべき問題ではなく、今すぐ取り組むべき問題だということを認識しなければなりません。そして、「今記録できていないから今さらやっても仕方がない」という考えは捨てるべきです。データが記録できていない状態を現在進行形の問題として捉えるべきです。 ### データ変更の記録はアプリケーション開発者をも助ける 実は、データ変更の記録は、アプリケーション開発者にとっても大きなメリットがあります。実際、私は何度もデータ変更の記録に助けられました。 例えば、顧客から監査記録の提出を求められた場合、データ変更の記録があれば、それを整形し適切に匿名化することで顧客のニーズに応えられます。 また、データ変更の記録は、アプリケーションの不具合や障害の調査にも役立ちます。不整合なデータが発生した場合、データ変更の記録を確認することで、いつどのような操作によってそれが発生したのかを特定できます。これにより、問題の原因を迅速に特定し、修正することができます。 ### この記事の目的 そこで本記事では、データ変更の記録方法を型化し、「データ変更の記録が全くない状態」から「整形も配信もしないが、少なくとも記録だけはある状態」を目指します。 具体的には、ドメインイベントの記録を容易にするためのリポジトリ設計を紹介します。 ## ドメインイベント ### ドメインイベントとは イベントとは、過去に発生した出来事を表すものです。ドメインイベントは、ドメインで発生した出来事を指します。 例えば、EC サイトにおけるドメインイベントには以下のようなものがあります。 - ユーザーが作成されたとき - ユーザーが削除されたとき - 注文が作成されたとき - 注文がキャンセルされたとき - 商品が在庫切れになったとき - 商品が再入荷されたとき ### ドメインイベントを記録すべき理由 ドメインイベントを記録することで、様々な恩恵を受けられます。例えば、以下のようなものがあります。 - 監査 - 不具合や障害の調査 - ユーザーの行動分析 - ユーザーの行動に基づいたレコメンデーション - 過去のデータを利用したビジネスの意思決定 ## リポジトリ設計 ### エンティティを受け取るリポジトリ 典型的なリポジトリの設計では、リポジトリはエンティティを受け取ります。例えば、ユーザーのリポジトリは以下のように設計されることが多いです。 - `save` メソッドで User エンティティを受け取り、永続化する - `delete` メソッドで User エンティティを受け取り、削除する ```typescript type User = Readonly<{ id: string; name: string; }>; type UserRepository = Readonly<{ save: (user: User) => Promise; delete: (userId: string) => Promise; }>; ``` この場合、ドメインイベントを記録するためには次の方法があります。 - リポジトリの中でドメインイベントを記録する `save` メソッドや `delete` メソッドの中でドメインイベントを生成して記録する - リポジトリへエンティティとドメインイベントの両方を引数として渡す `save` メソッドや `delete` メソッドへドメインイベントを引数として渡す どちらの方法でも、ドメインイベントを記録することはできますが、以下のような問題があります。 - リポジトリの中でドメインイベントを記録する場合 リポジトリの責務が増えてしまいます。特に、エンティティの状態からドメインイベントを逆算して生成することは難しいです。 例えば、「管理者が他のユーザーを削除した」というドメインイベントを記録する場合、リポジトリは「管理者が誰か」を知る必要があります。 しかし、削除対象のユーザーのエンティティからは「管理者が誰か」を知ることはできません。 - リポジトリへエンティティとドメインイベントの両方を引数として渡す場合 リポジトリの複雑度が増してしまいます。そのエンティティとドメインイベントの関係が正しいか検証する必要が生まれます。 ### ドメインイベントのみを受け取るリポジトリ そこで、ドメインイベントのみを受け取るリポジトリを設計します。 ドメインイベントは、ドメインの状態の変化に加え、変化後の状態を保持します。 以下の例では、ユーザーの作成と削除のドメインイベントを定義し、それを受け取るリポジトリ(ストア)を設計します。 ```typescript type UserCreated = Readonly<{ eventId: string; eventAt: Date; eventName: "UserCreated"; user: User; }>; type UserCreatedStore = Readonly<{ store: (event: UserCreated) => Promise; }>; type UserDeleted = Readonly<{ eventId: string; eventAt: Date; eventName: "UserDeleted"; payload: { /** ユーザーを削除した管理者のID */ deletedBy: string; }; user: Pick; }>; type UserDeletedStore = Readonly<{ store: (event: UserDeleted) => Promise; }>; ``` ### ドメインイベントの一般化 それぞれの集約のドメインイベントについて、別々に定義するのは冗長です。 そこで、ドメインイベントをユーティリティ型として一般化します。 ```typescript type DomainEvent = Readonly<{ eventId: string; eventAt: Date; eventName: TEventName; payload: TPayload; /** * 集約のID。例えばユーザーのID。 */ aggregateId: TState["id"]; /** * 集約の状態。例えば、ユーザーそのもの。 * ユーザーを削除する場合は、ユーザーのIDのみを保持する。 */ aggregateState: TState; }>; type UserEvent< TEventName, TPayload, TState extends User | Pick > = DomainEvent; type UserCreated = UserEvent<"UserCreated", unknown, User>; type UserDeleted = UserEvent< "UserDeleted", { /** ユーザーを削除した管理者のID */ deletedBy: string; }, Pick >; ``` ## テーブル設計 ドメインイベントを記録するためのテーブル設計を考えます。 理想としては、ドメインイベントごとにテーブルを定義することでエンティティとのリレーションシップを表現できるようになります。 しかし、ドメインイベントの数が多くなると、テーブルの数も増えてしまいます。 そのため、ドメインイベントを一般化した型を利用して、ドメインイベントを一つのテーブルにまとめることができます。 ここでは、PostgreSQL向けに以下のようなテーブル設計を提案します。 - `payload` や `aggregate_state` は JSON 型で保存する データの整形や整合性チェックはデータ基盤上でも何とかなるので、ここではとにかく整合性よりもデータを記録することに集中します。 - `event_id` は UUID 型で保存する UUID v7を使用し、`eventId` でソートすれば時系列順のイベントを取得できる状態を目指します。 - `event_at` を保存する `event_id` からタイムスタンプを復元することはできますが、データを利用する側がイベントの発生日時を絞ってクエリしやすいように `event_at` を保存しておきます。 - `aggregate_name` は集約の名前を保存する データを分析する時に「ユーザーに関するイベント」など特定の集約のイベントを抽出するために必要です ```sql CREATE TABLE domain_events ( event_id UUID NOT NULL, event_at TIMESTAMP NOT NULL, event_name VARCHAR(255) NOT NULL, payload JSON NOT NULL, aggregate_id VARCHAR(255) NOT NULL, aggregate_name VARCHAR(255) NOT NULL, aggregate_props JSON NOT NULL, PRIMARY KEY (event_id) ); ``` より具体的なデータモデリングの技法については、私の同僚であり先任のテックリードが書いた以下の記事が参考になります。 [https://kakehashi-dev.hatenablog.com/entry/2024/05/15/090000](https://kakehashi-dev.hatenablog.com/entry/2024/05/15/090000) ## ドメインイベントを中心にしたアプリケーション設計 ユーザーの作成を例にとって、ドメインイベントを中心にしたアプリケーション設計を考えます。 ドメインイベントを中心にしたアプリケーション設計は、以下のような構成になります。 - ドメインイベントを生成する関数 - ドメインイベントを記録するストア - ビジネスロジックに応じてドメインイベントの生成と記録を行うユースケース 具体的な実装例を下記にて公開しています。 [https://github.com/iwasa-kosui/til/tree/main/packages/domain-event](https://github.com/iwasa-kosui/til/tree/main/packages/domain-event) ```typescript import { generator } from "ui7"; const uuid = generator(); const createUser = (props: Omit): UserCreated => { // 注意: UUID v7 をユーザーIDに使用する場合、 // IDからユーザーの作成日時を特定できてしまう。 // ユーザーIDをエンドユーザーに露出すべきでない場合、他の手段を採用すべき。 const userId = uuid(); return { eventId: uuid(), eventAt: new Date(), eventName: "UserCreated", payload: {}, aggregateId: userId, aggregateState: { id: userId, ...props }, }; }; type Result = { ok: true; data: T } | { ok: false; error: E }; const Result = { ok: (data: T): Result => ({ ok: true, data }), err: (error: E): Result => ({ ok: false, error }), } as const; type UserResolver = Readonly<{ resolveByName: (name: string) => Promise; }>; type UserAlreadyExists = Readonly<{ code: "UserAlreadyExists"; message: string; detail: { name: string; }; }>; const UserAlreadyExists = { new: (name: string): UserAlreadyExists => ({ code: "UserAlreadyExists", message: `User with name ${name} already exists`, detail: { name }, }), } as const; const CreateUserUseCase = ( userResolver: UserResolver, userCreatedStore: UserCreatedStore ) => { const run = async ( props: Omit ): Promise> => { const user = await userResolver.resolveByName(props.name); if (user !== undefined) { return Result.err(UserAlreadyExists.new(props.name)); } const event = createUser(props); await userCreatedStore.store(event); return Result.ok(event.aggregateState); }; return { run } as const; }; ``` ## Union型から交差型への変換 TypeScriptのUnion型を交差型へ変換するユーティリティ型の実装を、条件型の分配法則と関数引数の反変性という2つの型システムの性質を用いて詳しく解説した記事です。 ![](https://cdn-ak.f.st-hatena.com/images/fotolife/k/kosui_me/20250505/20250505144841.png) ## はじめに Union 型から交差型へ変換するユーティリティ型を紹介し、その仕組みを解説します。 私はこのユーティリティ型を時々利用しますが、その原理を度々忘れてしまうのでここにメモしておきます。 ## 使い方 以下の例では、`UnionToIntersection` が `Age | Name` を `Age & Name` へ変換しています。 ```typescript type Age = { age: number }; type Name = { name: string }; type User = UnionToIntersection; // ^^^^ // Age & Name ``` ## 実装 ここでは、`UnionToIntersection` をいくつかのユーティリティ型を組み合わせて実装します。 1. `ToFunction` は、`T` を第一引数に取る関数に変換します。 2. `Parameter` は、関数の第一引数の型を取得します。 つまり、最終的には `UnionToIntersection` の結果は `T` と一致するように思えます。なぜこの操作で Union 型が交差型へ変換されるのでしょうか。 ```typescript /** * Tを第一引数に取る関数に変換するユーティリティ型 */ type ToFunction = [T] extends [unknown] ? (x: T) => void : never; /** * 関数の引数の型を取得するユーティリティ型 */ type Parameter = [T] extends [(x: infer I) => void] ? I : never; /** * UnionをIntersectionに変換するユーティリティ型 */ type UnionToIntersection = Parameter>; ``` ## 解説 ### `ToFunction` の役割 #### 条件型の分配法則 - [https://www.typescriptlang.org/docs/handbook/2/conditional-types.html#distributive-conditional-types](https://www.typescriptlang.org/docs/handbook/2/conditional-types.html#distributive-conditional-types) - [TypeScript 条件型の分配法則 - PADAone's Notes - Obsidian Publish](https://publish.obsidian.md/pd1-notes/ts-distributive-conditional-type) 条件型において、型パラメータがユニオン型の場合、各メンバーに対して条件式が分配的に適用されます。 以下の例では、`ToFunctionDistributed` は `Age` と `Name` のそれぞれに対して適用され、結果として 2 つの関数型が得られます。 ```typescript type ToFunctionDistributed = T extends unknown ? (x: T) => void : never; type UserFunction = ToFunctionDistributed; // ^^^^^^^^^^^^ // ((x: Age) => void) | ((x: Name) => void) ``` しかし、`ToFunction` のように `extends` キーワードの各辺を角括弧 `[]` で囲むと、分配法則が適用されず、ユニオン型全体が一つの型として扱われます。 ```typescript type ToFunction = [T] extends [unknown] ? (x: T) => void : never; type UserFunction = ToFunction; // ^^^^^^^^^^^^ // (x: Age | Name) => void ``` ### `Parameter` の役割 #### 反変性の利用 `ToFunction` によって、`(x: Age | Name) => void` という関数の型を得ました。 次に、`Parameter` を使ってこの関数の第一引数の型を取得します。 ```typescript type Parameter = [T] extends [(x: infer I) => void] ? I : never; type User = Parameter>; ``` `(x: Age | Name) => void` という関数は、`Age` または `Name` のいずれかを引数に取ることができます。つまり、この関数は `Age` と `Name` の両方を受け取れる必要があります。 よって、`Parameter` は `Age & Name` という交差型を返します。`(x: Age | Name) => void` の第一引数は `Age` と `Name` の交差型である `Age & Name` となります。 ```typescript type User = Parameter>; // ^^^^ // Age & Name ``` これにより、各ユニオンの要素が個別に関数型に埋め込まれ、最終的に交差型へと変換される仕組みとなります。 ## より短い実装 ### `ToFunction` の簡略化 `ToFunction` は、以下のように簡略化できます。 ```typescript type ToFunction = T extends unknown ? (x: T) => void : never; ``` 条件型の分配法則は `Parameter` の中で止められるので、`ToFunction` の中で分配を止める必要はありません。 ```typescript type ToFunction = T extends unknown ? (x: T) => void : never; type Parameter = [T] extends [(x: infer I) => void] ? I : never; type UnionToIntersection = Parameter>; ``` ## さらにより短い実装 - [TypeScript: UnionToIntersection\ ─ 共用体型を交差型にするユーティリティ型 #型レベルプログラミング - Qiita](https://qiita.com/suin/items/93eb9c328ee404fdfabc#comment-5218b3e9d13d93dfc98f) この記事のuhyoさんのコメントが丁寧で分かりやすいです。 `UnionToIntersection` をより短く実装する方法もあります。 ```typescript type UnionToIntersection = ( T extends unknown ? (x: T) => void : never ) extends (x: infer I) => void ? I : never; ``` このコードでは、以下の 2 つの性質を利用しています。 1. **条件型の分配** TypeScript の条件型は、「naked な(`[]` などで囲っていない)型パラメーター」を直接チェック対象としている場合、自動的にそれぞれのユニオン型の要素に対して展開(分配)されます。 ``` T extends unknown ? (x: T) => void : never ``` この結果、T が "A | B" だとすると、内部で (x: A) => void | (x: B) => void のような形になります。 1. **関数パラメーターの共変性/反変性** 関数の引数は反変性を持ちます。つまり、ある関数型の引数型のユニオンが、1 つの関数型の引数型の交差型として推論される、という性質を利用しています。 型全体で見ると、 `typescript (x: A) => void | (x: B) => void ` のような合併が、 `typescript (x: A & B) => void ` と推論される形に変換され、各メンバーの型の「積」を取り出す(交差型にする)ことが可能になります。 ユニオン型の展開を止めるために通常は [T] のようにラップする必要がありますが、ここでは外側の `extends` の左辺が naked ではないため、分配されません。 ```typescript type UnionToIntersection = ( T extends unknown ? (x: T) => void : never ) extends (x: infer I) => void ? // ^ 左辺が naked ではないので分配されない I : never; ``` ## Discriminated Unionを利用したStateパターンの実現 Discriminated Unionを活用したStateパターンの実装方法を、シンプルな状態遷移から振る舞いの入力が状態ごとに異なるケースまで段階的に紹介した記事です。 ![](https://cdn-ak.f.st-hatena.com/images/fotolife/k/kosui_me/20250505/20250505132720.png) ### この記事について 先日公開した下記の記事について、記事冒頭で紹介した「クラスベースによる状態遷移の実装」があまりに素朴な実装であり、その後Stateパターンへの言及がほとんどなされなかった上、あたかもクラスを用いた実装そのものに問題があるようなタイトルであったことから、様々なご指摘・ご意見を頂くこととなりました。 [https://kosui.me/posts/2025/02/20/005900](https://kosui.me/posts/2025/02/20/005900) この記事ではその反省を活かし、単に「Discriminated Unionを利用してStateパターンを実現する」ということにフォーカスした内容へ再構成いたしました。 ## はじめに アプリケーション開発では、複数の内部状態を持つオブジェクトを取り扱うことがしばしばあります。例えば、タクシー配車アプリの配車リクエストや、CMS(Contents Management System)の記事、ECサイトの注文などが挙げられます。 このようなオブジェクトについて、内部状態に応じて振る舞いを変化させるデザインパターンとして「Stateパターン」が知られています。 この記事では、TypeScriptのクラスを使用したStateパターンを紹介したのち、クラスではなくタグ付きユニオンと関数を使用した実装例を紹介します。 ## 単純な状態遷移 ### 課題 #### タクシー配車アプリ まずは、どの振る舞いも引数を持たない単純な状態遷移について、タクシー配車アプリの配車リクエストを例に見てみましょう。 タクシー配車アプリでは、配車リクエストが「呼び出し(Waiting)」「迎車中(EnRoute)」「乗車中(InTrip)」「完了(Completed)」といった状態を経て処理が進み、キャンセル(Cancelled)などの例外経路も存在します。 「呼び出し (Waiting)」状態の場合、「乗務員の割り当て (assignDriver)」を実行できる一方で、「迎車中 (EnRoute)」のように「走行開始 (startTrip)」は実行できません。 ```mermaid classDiagram direction LR class Waiting["Waiting 呼び出し中"] { state: "Waiting" passengerId: string } class EnRoute["EnRoute 迎車中"] { state: "EnRoute" passengerId: string } class InTrip["InTrip 乗車中"] { state: "InTrip" passengerId: string startTime: Date } class Completed["Completed 完了"] { state: "Completed" passengerId: string startTime: Date endTime: Date } class Cancelled["Cancelled キャンセル済み"] { state: "Cancelled" passengerId: string } Waiting --> EnRoute : assignDriver() 乗務員の割当 EnRoute --> InTrip : startTrip() 走行開始 InTrip --> Completed : completeTrip() 走行完了 Waiting --> Cancelled : cancel() EnRoute --> Cancelled : cancel() InTrip --> Cancelled : cancel() Completed --> Cancelled : cancel() ``` #### 状態遷移を持つオブジェクトの実装 まず、上記のような状態遷移を持つオブジェクトを1つのクラスへ実装する場合を考えます。 下記実装では、`assignDriver` でも `startTrip` でも「望む状態からの遷移か」をわざわざチェックしているため、状態ごとの振る舞いの定義がとても煩雑になります。 ```typescript class TaxiRequest { public state: 'Waiting' | 'EnRoute' | 'InTrip' | 'Completed' | 'Cancelled'; public passengerId: string; constructor(passengerId: string) { this.state = 'Waiting'; this.passengerId = passengerId; } // 乗務員が配車リクエストを受けると EnRoute に状態遷移 public assignDriver() { if (this.state !== 'Waiting') { throw new Error(`Invalid state transition: ${this.state} -> EnRoute`); } this.state = 'EnRoute'; } // 乗客が乗車したら InTrip に状態遷移 public startTrip() { // EnRoute のときだけ実行可能な想定だが… // 実はクラス外部から this.state を書き換え可能で、想定外の状態でも呼ばれるかも if (this.state !== 'EnRoute') { throw new Error(`Invalid state transition: ${this.state} -> InTrip`); } this.state = 'InTrip'; } ``` ### Stateパターン Stateパターンを適用することで、状態ごとの振る舞いを別のクラスとして管理できます。 先ほどの例に挙げた「配車リクエスト」を、Stateパターンに従って実装してみましょう。 [https://refactoring.guru/ja/design-patterns/state](https://refactoring.guru/ja/design-patterns/state) Stateパターンでは、実際の状態と振る舞いをStateクラスとして表現し、変化する状態をContextクラスが保持します。 これによって、「呼び出し(Waiting)時に実行できる振る舞いは乗務員の割り当て (assignDriver)」「迎車中 (EnRoute)時に実行できる振る舞いは走行開始 (startTrip)」のように、それぞれの状態の振る舞いを各Stateクラスの実装として表現できます。 ```mermaid classDiagram direction TD class Context { State state handle() void setState(State state) void } class State { <> handle() void setContext(context: Context) } class StateA { handle() void } class StateB { handle() void } Context o-- State State <|-- StateA State <|-- StateB note for Context "状態をコントロールするクラス" note for State "状態の抽象クラス" note for StateA "実際の状態を表現するクラス" ``` #### 実装例 ```mermaid classDiagram direction LR class Waiting["Waiting 呼び出し中"] { state: "Waiting" passengerId: string } class EnRoute["EnRoute 迎車中"] { state: "EnRoute" passengerId: string } class Cancelled["Cancelled キャンセル済み"] { state: "Cancelled" passengerId: string } Waiting --> EnRoute : handle() 迎車中へ遷移 Waiting --> Cancelled : cancel() Cancelled --> Cancelled : handle() 何も起こらない ``` 「呼び出し(Waiting)」を表現する `WaitingState` クラスでは、 `handle()` を呼び出すと次の状態 「迎車中 (EnRoute)」へ遷移します。一方で、「キャンセル済み(Cancelled)」を表現する `CancelledState` クラスでは、次の状態でもキャンセル済みのままなので、 `handle()` を呼び出しても何も起きません。 ```typescript abstract class State { protected context: Context; constructor(context: Context) { this.context = context; } // 次の状態へ遷移する public abstract handle(): void; public cancel(reason: string): void { this.context.cancelReason = reason; this.context.setState(new CancelledState(this.context)); } } class Context { private currentState: State; passengerId: string; cancelReason?: string; constructor(passengerId: string) { this.passengerId = passengerId; // 初期状態は Waiting とする this.currentState = new WaitingState(this); } // 状態オブジェクトを切り替える public setState(newState: State): void { this.currentState = newState; } public handle() { this.currentState.handle(); } } class WaitingState extends State { protected context: Context; constructor(context: Context) { super(context); this.context = context; } // 次の状態 (迎車中) へ遷移する public handle(): void { this.context.setState(new EnRouteState(this.context)); } } class CancelledState extends State { protected context: Context; constructor(context: Context) { super(context); this.context = context; } public handle(): void { // キャンセル済みの場合は何もしない } } //... ``` ### Discriminated Unionを利用したStateパターン Discriminated Unionを活用することで、Stateパターンのように状態ごとの振る舞いを分けて定義できます。 まず、呼び出し(Waiting)状態について振る舞いを定義します。 `assignDriver` は引数に `Waiting` のみを取るため、この振る舞いは「呼び出し(Waiting)」状態からのみ呼び出せます。 ```typescript type Waiting = Readonly<{ kind: 'Waiting'; passengerId: string; }>; const assignDriver = (state: Waiting): EnRoute => ({ kind: 'EnRoute', passengerId: state.passengerId, }); const Waiting = { handle: assignDriver, } as const; ``` 同様に他の状態についても振る舞いを定義し、これらのDiscriminated Union `TaxiRequest` を定義します。 ```typescript type EnRoute = Readonly<{ kind: 'EnRoute'; passengerId: string; }>; const EnRoute = { handle: //... } as const; type Cancelled = Readonly<{ kind: 'Cancelled'; passengerId: string; reason: string; }>; const Cancelled = { handle: //... } as const; type TaxiRequest = Waiting | EnRoute | Cancelled // | ... ; ``` Stateパターンの「`handle()` を呼び出せば、状態ごとの振る舞いが実行される」という性質を表現する `handle` 関数を定義し、これを `TaxiRequest` オブジェクトに持たせておきましょう。`never` 型のみを引数に取る `assertNever` を用いることで、処理が漏れている状態があれば型検査時に気付けるようにしておきます。 ```typescript const assertNever = (x: never): never => { throw new Error(`Unexpected value: ${x}`); } const handle = (state: TaxiRequest): TaxiRequest => { switch (state.kind) { case 'Waiting': return Waiting.handle(state); case 'EnRoute': // ... case 'Cancelled': return Cancelled.handle(state); default: return assertNever(state); } }; const cancel = (state: TaxiRequest, reason: string): Cancelled => ({ kind: 'Cancelled', passengerId: state.passengerId, reason, }); const TaxiRequest = { handle, cancel, }; ``` ### `handle` は必要? どの状態でも共通して何らかの振る舞いを実行する「 `handle` 」のような操作が不要な場合、単にこれを省略できます。 ```typescript type Waiting = Readonly<{ kind: 'Waiting'; passengerId: string; }>; const assignDriver = (state: Waiting): EnRoute => ({ kind: 'EnRoute', passengerId: state.passengerId, }); const TaxiRequest = { assignDriver, } as const; ``` ## 複雑な状態遷移 ### 状態ごとに振る舞いの入力が異なる場合 ここで、「呼び出し(Waiting)時に実行できる乗務員の割り当て (assignDriver)」や、「迎車中 (EnRoute)時に実行できる走行開始 (startTrip)」のように、状態ごとの振る舞いの入力が異なる場合を見てみましょう。 下記の例では、乗務員の割り当て (assignDriver) の場合は「乗務員ID (driverId)」を、走行開始 (startTrip) の場合は「開始日時 (startTime)」を入力として欲しています。 ```mermaid classDiagram direction LR class Waiting["Waiting 呼び出し中"] { state: "Waiting" passengerId: string } class EnRoute["EnRoute 迎車中"] { state: "EnRoute" passengerId: string driverId: string } class InTrip["InTrip 乗車中"] { state: "InTrip" passengerId: string driverId: string startTime: Date } class Completed["Completed 完了"] { state: "Completed" passengerId: string driverId: string startTime: Date endTime: Date } Waiting --> EnRoute : assignDriver(**driverId**) 乗務員の割当 EnRoute --> InTrip : startTrip(**startTime**) 走行開始 InTrip --> Completed : completeTrip(**endTime**) 走行完了 ``` ### Stateパターンの場合 振る舞いによって入力が異なる場合、`assignDriver` や `startTrip`、`completeTrip` のようにそれぞれの振る舞いを抽象クラス `State` に定義する必要があります。 この場合、例えば `WaitingState` の実装では `startTrip` と `completeTrip` を「例外を投げるメソッド」として実装することになります。よって、誤って「呼び出し(Waiting)」時に `startTrip` を呼び出す操作をした場合、実行時に気付くこととなります。 できれば誤った操作について型検査時に気が付きたいのですが、私はそれを分かりやすく実現する方法を思いつきませんでした。もしご存知の方がいれば教えて下さい。 ```typescript abstract class State { protected context: Context; constructor(context: Context) { this.context = context; } public abstract assignDriver(driverId: string): void; public abstract startTrip(startTime: Date): void; public abstract completeTrip(endTime: Date): void; public cancel(reason: string): void { this.context.cancelReason = reason; this.context.setState(new CancelledState(this.context)); } } class WaitingState extends State { protected context: Context; constructor(context: Context) { super(context); this.context = context; } public assignDriver(driverId: string): void { this.context.setState(new EnRouteState(this.context, driverId)); } public startTrip(): void { throw new Error('乗務員が割り当てられていないため走行を開始できません。'); } public completeTrip(): void { throw new Error('乗車が開始されていないため完了できません。'); } } ``` ### Discriminated Unionを利用したStateパターン Discriminated Unionを利用する場合、それぞれの振る舞いを表現する関数を個別に定義できます。よって、「`WaitingState` の状態で `startTrip` が呼び出されている」といった不正な操作は、型検査にて発見できます。 一方で、`as` を利用して無理矢理に型推論の結果を無視した場合、誤った状態が関数に渡されたとしても、実行時に発見することはできないことに注意が必要です。 ```typescript type Waiting = Readonly<{ kind: 'Waiting'; passengerId: string; }>; type EnRoute = Readonly<{ kind: 'EnRoute'; passengerId: string; driverId: string; }>; type InTrip = Readonly<{ kind: 'InTrip'; passengerId: string; driverId: string; startTime: Date; }>; const assignDriver = ({passengerId}: Waiting, driverId: string): EnRoute => ({ kind: 'EnRoute', passengerId, driverId, }); const startTrip = ({passengerId, driverId}: EnRoute, startTime: Date): InTrip => ({ kind: 'EnRoute', passengerId, driverId, startTime, }); const TaxiRequest = { assignDriver, startTrip, } as const; ``` ## まとめ この記事では、Discriminated Unionを利用したStateパターンの実現方法について、「単純な状態遷移」と「状態ごとに振る舞いの入力が異なるようなケース」の2つのケースを交えて紹介しました。 ## 複雑な状態遷移😭: クラスではなく関数とDiscriminated Unionで状態の定義と遷移を表現する TypeScriptでクラスによる状態管理の課題を示し、Discriminated Unionとコンパニオンオブジェクトパターンを用いて型安全に状態遷移を表現する方法を解説した記事です。 ![](https://cdn-ak.f.st-hatena.com/images/fotolife/k/kosui_me/20250505/20250505132835.png) ### 補足: 2025/02/25 本記事でほとんど紹介されなかった「Stateパターン」を含めて再構成した記事を公開しましたので、今後は下記の記事をご覧ください。 [https://kosui.me/posts/2025/02/25/021320](https://kosui.me/posts/2025/02/25/021320) ### 補足: 2025/02/21 クラスベースでも、Stateパターンを適用し、StateをDiscriminated Unionとして表現することで今回の問題を解決できます。つまり、クラスを利用することに問題があるわけではありません。この記事では、TypeScriptではあえてクラスを利用しなくても状態遷移を表現できることを紹介します。記事を一部修正し、Stateパターンをクラスによって実現する方法を追記しています。 ## 背景 ### サーバサイド実装での状態管理の重要性 サーバサイドのビジネスロジックでは、エンティティ(注文、決済、在庫、タクシー配車リクエストなど)が複数の状態を行き来しながら進行します。 たとえば、タクシー配車アプリでは、配車リクエストが「呼び出し(Waiting)」「迎車中(EnRoute)」「乗車中(InTrip)」「完了(Completed)」といった状態を経て処理が進み、キャンセル(Cancelled)などの例外経路も存在します。 ```mermaid classDiagram direction LR class Waiting["Waiting 呼び出し中"] { state: "Waiting" passengerId: string } class EnRoute["EnRoute 迎車中"] { state: "EnRoute" passengerId: string driverId: string } class InTrip["InTrip 乗車中"] { state: "InTrip" passengerId: string driverId: string startTime: Date } class Completed["Completed 完了"] { state: "Completed" passengerId: string driverId: string startTime: Date endTime: Date } class Cancelled["Cancelled キャンセル済み"] { state: "Cancelled" passengerId: string reason: string } Waiting --> EnRoute : assignDriver() 乗務員の割当 EnRoute --> InTrip : startTrip() 走行開始 InTrip --> Completed : completeTrip() 走行完了 Waiting --> Cancelled : cancel() EnRoute --> Cancelled : cancel() InTrip --> Cancelled : cancel() Completed --> Cancelled : cancel() ``` こうした状態遷移を正しく管理することは、ビジネスロジックを安定して運用する上で欠かせません。 ところが、状態が増えたり分岐が複雑化したりすると、開発チーム全体の理解が追いつかなくなり、不正な状態遷移を引き起こすバグが潜在化しやすくなります。 ### TypeScript の特徴と型システムの恩恵 強力な型推論機能が特徴の一つである TypeScript では、判別可能なユニオン (Discriminated Unions) を利用して、より堅牢に状態遷移のロジックを表現できます。 コンパイル時に誤った状態遷移を検知し、不整合やバグを防ぎやすくなります。 ## 課題 ### クラスベースによる状態遷移の実装 以下のコードでは、タクシーの配車リクエストの状態遷移を実装しています。この実装では、各状態で持ちうるプロパティを`TaxiRequest` クラスにそのまま持たせています。 一見すると、特に何の問題もなく状態遷移を表現できています。しかし、この実装には不正な状態遷移が引き起こされるリスクがあります。 ```typescript class TaxiRequest { // 状態はすべてこのクラスのプロパティで保持する public state: 'Waiting' | 'EnRoute' | 'InTrip' | 'Completed' | 'Cancelled'; public passengerId: string; public driverId?: string; public startTime?: Date; public endTime?: Date; public reason?: string; constructor(passengerId: string) { this.state = 'Waiting'; this.passengerId = passengerId; } // 乗務員が配車リクエストを受けると EnRoute に状態遷移 public assignDriver(driverId: string) { this.driverId = driverId; this.state = 'EnRoute'; } // 乗客が乗車したら InTrip に状態遷移 public startTrip() { // EnRoute のときだけ実行可能な想定だが… // 実はクラス外部から this.state を書き換え可能で、想定外の状態でも呼ばれるかも if (this.state !== 'EnRoute') { throw new Error(`Invalid state transition: ${this.state} -> InTrip`); } this.state = 'InTrip'; this.startTime = new Date(); } // 乗客の移動が完了したら Completed に状態遷移 public completeTrip() { if (this.state !== 'InTrip') { throw new Error(`Invalid state transition: ${this.state} -> Completed`); } this.state = 'Completed'; this.endTime = new Date(); } // キャンセルされたら Cancelled に状態遷移 public cancel(reason: string) { this.state = 'Cancelled'; this.reason = reason; } } // 使用例 const request = new TaxiRequest('passenger123'); request.assignDriver('driver456'); request.startTrip(); request.completeTrip(); ``` ### リスク① 外部からの不正な操作 インスタンスメソッド以外からも状態遷移ができるため、不正な状態遷移が引き起こされる恐れがあります。 ```typescript const request = new TaxiRequest('passenger123'); request.state = 'EnRoute'; // 乗務員が割り当てられていないのに // 走行が開始されてしまう! request.startTrip(); ``` ### リスク② 事前条件の検証不足 状態遷移を担うメソッドは、必ず事前条件を検証する必要があります。 例えば、「迎車中(EnRoute)」へ遷移する「乗務員割り当て(assignDriver)」は、必ず「呼び出し中(Waiting)」の状態から開始される必要があります。 しかし、`assignDriver` で事前条件を十分に検証していない場合、誤った状態遷移を許してしまいます。 ```typescript class TaxiRequest { // (中略) public assignDriver(driverId: string) { this.driverId = driverId; this.state = 'EnRoute'; } } const request = new TaxiRequest('passenger123'); request.cancel(); // キャンセルされたはずの配車リクエストが // 再び開始されてしまう request.assignDriver(); ``` ### リスク③ 全容の把握が難しくなる 状態が肥大化すると、クラス内のメソッド群やプロパティ間の依存関係が複雑化し、テストや保守が困難になります。 - 例: `startTrip()` が成功すると `endTrip()` の呼び出しが有効になるが、同時に `cancel()` もあり得るのか? - 例: `assignDriver()` の後で `state` は必ず `EnRoute` である前提だが、他のメソッドが `state` を書き換えていないか? このように、「どのメソッドがどの状態を前提に動くのか」をクラス設計ですべてカバーしきるのは、手動で管理するには限界があります。さらに追加要件で状態が増えたり、外部APIの連携などで状態遷移パターンが増えると、テストも複雑化し漏れが生じやすくなるのが課題です。 ### リスク④ 特定の状態でのみ参照・変更できるプロパティを扱いにくい 複雑な状態遷移を持つエンティティには、特定の状態でのみ参照・変更できるプロパティがありえます。 例えば、「乗務員ID `driverId`」は「呼び出し中(Waiting)」の状態では参照できず、「迎車中(EnRoute)」以降に参照できます。 しかし、下記の実装では、「乗務員ID `driverId`」が必ず参照できる状態でも型システムの上では `driverId` がnullableとなってしまいます。 この他、「`state` が 'Completed' なのに他のプロパティが足りない」「`driverId` がないのに `state` が 'EnRoute' になる」などを、コンパイルレベルで防ぐのは難しくなってしまいます。 ```typescript class TaxiRequest { public state: 'Waiting' | 'EnRoute' | 'InTrip' | 'Completed' | 'Cancelled'; public passengerId: string; public driverId?: string; public startTime?: Date; public endTime?: Date; public reason?: string; // 中略 } ``` ## 解決策 ### Discriminated Union(判別可能なユニオン)による状態遷移の厳密な管理 TypeScript のユニオン型を使い、状態を表すための `state` プロパティを判別子とすることで、**どの状態にどんなデータが必要か** を型レベルで明示できます。 ```typescript type Waiting = Readonly<{ state: "Waiting"; passengerId: string; }>; type EnRoute = Readonly<{ state: "EnRoute"; driverId: string; passengerId: string; }>; type InTrip = Readonly<{ state: "InTrip"; driverId: string; passengerId: string; startTime: Date; }>; type Completed = Readonly<{ state: "Completed"; driverId: string; passengerId: string; startTime: Date; endTime: Date; }>; type Cancelled = Readonly<{ state: "Cancelled"; passengerId: string; reason: string; }>; type TaxiRequest = Waiting | EnRoute | InTrip | Completed | Cancelled; ``` このように定義しておけば、状態ごとに必須プロパティや使えるプロパティが明確化され、スイッチ文で状態に応じた分岐をする際にもコンパイラが型チェックを行ってくれます。 #### 型検査時に不正を排除できる 例えば、「乗務員ID `driverId`」は「呼び出し中(Waiting)」の状態で参照を試みると型検査時にエラーとなります。また、「迎車中(EnRoute)」の状態では必ず `nullable` とならずに参照できます。このように、実行せずとも型検査時に問題を検出できます。 ```typescript declare const waiting: Waiting; waiting.driverId; // Error: Property 'driverId' does not exist on type 'Waiting'. declare const enRoute: EnRoute; enRoute.driverId; // OK ``` #### 全容を把握しやすい それぞれの状態でどのようなプロパティを持つのか、容易に一覧できます。例えば、「走行開始時間 `startTime`」は「乗車中 (InTrip)」「完了済み (Completed)」の場合に参照できることが分かります。 ### Stateパターンをコンパニオンオブジェクトで実現する 前述のように、状態ごとに属性や振る舞いを `State` として抽出し、それを操作するクラスを `Context` とするデザインパターンは「Stateパターン」として知られています。 [https://refactoring.guru/ja/design-patterns/state](https://refactoring.guru/ja/design-patterns/state) ```typescript class TaxiRequestContext { public state: Waiting | EnRoute | InTrip | Completed | Cancelled; // ... } ``` しかし、TypeScriptでは[コンパニオンオブジェクトパターン](https://typescriptbook.jp/tips/companion-object)を適用することで、クラスを定義せずに同様の要求を実現できます。 ```typescript type TaxiRequest = Waiting | EnRoute | InTrip | Completed | Cancelled; const TaxiRequest = { assignDriver: ... } as const; ``` ここで、状態の遷移を関数で表現してみましょう。この実装における `assignDriver` は、次のような特徴を持っています。 - 「呼び出し中(Waiting)」の配車リクエストのみ引数として受け取ります。誤った状態を渡すと型検査時にエラーが発生するため、安全に利用できます。 - 関数の戻り値は 「迎車中(EnRoute)」であることが型で保証されるので、呼び出し元のコードでも安全に次の処理を実装できます。 ```typescript const assignDriver = ( { passengerId }: Waiting, driverId: string ): EnRoute => ({ state: 'EnRoute', passengerId, driverId, }); export const TaxiRequest = { assignDriver, } as const; ``` また、TypeScriptではメソッドと関数とでは変性が異なっているなど、クラスを利用する場合には型検査が緩くなることに注意が必要です。特にクラスを利用する必要がないケースでは、コンパニオンオブジェクトパターンを適用することをおすすめします。 > オブジェクトの型定義をする際にメソッド記法を使うと双変になります。これは共変か反変のどちらかを満たしていればよい、という関係性です。そのため引数がサブタイプの場合でも型エラーになってくれません。つまり上記の反変のところで説明したようなランタイムエラーが実際に起ってしまう危険性があります。特別な意図がなければ避けるようにしましょう。 > [TypeScript の変性(共変・反変)を 5 分で理解する](https://zenn.dev/jay_es/articles/2024-02-13-typescript-variance) ### Branded typeの活用 「乗務員ID `driverId`」と「乗客ID `passengerId`」を取り違えないように、Branded typeを活用できます。 以下の例では、乗務員ID `driverId` を引数に取る関数へ乗客ID `passengerId` を渡そうとしています。Branded typeを活用することで、型検査時にエラーとして検出できました。 ```typescript type Brand = T & { [key in K]: unknown }; const PassengerIdSymbol = Symbol("PassengerId"); type PassengerId = Brand; const DriverIdSymbol = Symbol("DriverId"); type DriverId = Brand; const assignDriver = ({ passengerId }: Waiting, driverId: DriverId): EnRoute => ({ state: "EnRoute", driverId, passengerId, }); declare const waiting: Waiting; assignDriver(waiting, "badId"); // Error: Argument of type '"badId"' is not assignable to parameter of type 'DriverId'. ``` Branded typeについてより詳しく知りたい方は下記を参照してください。 [https://bufferings.hatenablog.com/entry/2025/01/12/171721](https://bufferings.hatenablog.com/entry/2025/01/12/171721) [https://qiita.com/uhyo/items/de4cb2085fdbdf484b83](https://qiita.com/uhyo/items/de4cb2085fdbdf484b83) ## まとめ ### 背景 - 複雑な状態を管理するサーバサイドロジックでは、状態遷移の安全性や可読性が重要。 - TypeScript には、判別可能なユニオンなどの機能があり、状態遷移を型として表現できる。 ### 課題 クラスに各状態で持ちうるプロパティをそのまま持たせる実装では、内部状態が思わぬタイミングで書き換えられる可能性があり、状態管理の責務が不明瞭になりがち。 ### 解決策 - **ユニオン型による厳密な状態表現** - コンパイル時チェックで不正を防止し、ビジネスロジックの変更にも強い構造を作れる。 - **関数ベースでエンティティを管理** - イミュータブルデータ構造・純粋関数と組み合わせやすく、状態管理ロジックを明確化できる。 - テストしやすく、変更や再利用にも柔軟に対応可能。 タクシー配車アプリのように状態が多く分岐が複雑なケースほど、TypeScript の型システムと関数ベースの実装が大きな威力を発揮します。型検査を味方に付けて、堅牢かつ拡張しやすいサーバサイド開発を目指してみてください。 ## おまけ: Java の Sealed インタフェースを利用した実装 Java 17 以降で利用できる Sealed インタフェースと、Java 16 以降で利用できるレコードを活用することで、状態遷移を TypeScript のユニオン型に近い設計で表現しています。 ```java import java.time.LocalDateTime; public class TaxiRequestExample { // --- Sealedインターフェイス: TaxiRequest --- // どのクラス(レコード)がこれを実装できるかを permits で限定する public sealed interface TaxiRequest permits Waiting, EnRoute, InTrip, Completed, Cancelled { // 状態に共通するメソッドを定義してもOK // 例: String passengerId(); } // --- 各状態を表すレコード --- // それぞれ final にすることでSealedインターフェイスを実装可能 public static final record Waiting(String passengerId) implements TaxiRequest {} public static final record EnRoute(String passengerId, String driverId) implements TaxiRequest {} public static final record InTrip(String passengerId, String driverId, LocalDateTime startTime) implements TaxiRequest {} public static final record Completed(String passengerId, String driverId, LocalDateTime startTime, LocalDateTime endTime) implements TaxiRequest {} public static final record Cancelled(String passengerId, String reason) implements TaxiRequest {} // --- 状態遷移をまとめたユーティリティクラス --- public static final class TaxiRequestTransitions { private TaxiRequestTransitions() {} // Waiting -> EnRoute public static EnRoute assignDriver(Waiting waiting, String driverId) { // 必要に応じてビジネスロジックやバリデーションを追加 return new EnRoute(waiting.passengerId(), driverId); } // EnRoute -> InTrip public static InTrip startTrip(EnRoute enRoute) { return new InTrip( enRoute.passengerId(), enRoute.driverId(), LocalDateTime.now() ); } // InTrip -> Completed public static Completed completeTrip(InTrip inTrip) { return new Completed( inTrip.passengerId(), inTrip.driverId(), inTrip.startTime(), LocalDateTime.now() ); } // どの状態からでもキャンセル可能 (例) public static Cancelled cancel(TaxiRequest request, String reason) { // パターンマッチングswitch (Java 17/19以降でプレビュー機能など必要な場合あり) String passengerId = switch (request) { case Waiting w -> w.passengerId(); case EnRoute e -> e.passengerId(); case InTrip i -> i.passengerId(); case Completed c -> c.passengerId(); case Cancelled c2 -> c2.passengerId(); }; return new Cancelled(passengerId, reason); } } // --- 動作例を示すメインメソッド --- public static void main(String[] args) { // 1. Waiting状態のリクエストを作成 Waiting waiting = new Waiting("passenger123"); // 2. ドライバーを割り当て -> EnRoute EnRoute enRoute = TaxiRequestTransitions.assignDriver(waiting, "driver456"); System.out.println("assignDriver => " + enRoute); // 3. 乗客が乗車した -> InTrip InTrip inTrip = TaxiRequestTransitions.startTrip(enRoute); System.out.println("startTrip => " + inTrip); // 4. 乗車が完了した -> Completed Completed completed = TaxiRequestTransitions.completeTrip(inTrip); System.out.println("completeTrip => " + completed); // 5. キャンセル操作の例 (どの状態からでも可とする仕様) Cancelled cancelledFromCompleted = TaxiRequestTransitions.cancel(completed, "Some issue"); System.out.println("cancel => " + cancelledFromCompleted); // 6. パターンマッチング付きswitchで各状態ごとに処理 handleTaxiRequest(completed); } // --- パターンマッチングswitchのサンプル --- public static void handleTaxiRequest(TaxiRequest request) { // 状態ごとに分岐して固有の情報を参照できる switch (request) { case Waiting w -> System.out.println("状態: Waiting, passenger: " + w.passengerId()); case EnRoute e -> System.out.println("状態: EnRoute, driver: " + e.driverId()); case InTrip i -> System.out.println("状態: InTrip, started at: " + i.startTime()); case Completed c -> System.out.println("状態: Completed, endTime: " + c.endTime()); case Cancelled c2 -> System.out.println("状態: Cancelled, reason: " + c2.reason()); } } } ``` ## 網羅的なPRDやDesign Docを書かなくなった 網羅的なPRD・Design Docを書いてレビューするより、関係者と対話しながら観点とトレードオフを洗い出す方が少ない手数で良い意思決定ができるという主張をまとめた記事です。 ![](https://cdn-ak.f.st-hatena.com/images/fotolife/k/kosui_me/20250505/20250505132917.png) - 2024/06/12 16:16 結論を追記 - 2024/06/12 20:29 より記事の内容を分かりやすく理解頂くため、タイトルを「PRDやDesign Docを書かなくなった」から変更 - 2024/06/13 20:39 結論にフロー情報・ストック情報に関する意見を追記 ## 結論 この記事では、「様々な観点を考慮して網羅的にドキュメントを書いて、それを関係者にレビューしてもらう」のではなく、関係者と同期的に対話しながら、観点や選択肢やそのトレードオフを洗い出すことで、少ない手数でより良い答えが見つけられると主張する。 ただし、対話のために必要なドキュメントは事前に書いておくべきだし、対話した結果はドキュメントに残すことが望ましい。そして、そのドキュメントのフォーマットはPRDやDesign Doc以外でも良い。例えば、ADRはアーキテクチャに関する議論の過程と結果を述べる上で必要十分なフォーマットだと思う。 ちなみに、この記事では「議事録などのフロー情報をそのままドキュメントとして残せば十分だ」という主張はしていない。考古学をしようとしたら「ミーティングの場で書かれた箇条書きの羅列でしかない議事録」ばかりが出てきて閉口したことは誰だってあるだろう。プロダクトや機能ごとに重要な観点を選んだ上で、それに対する選択肢とトレードオフを含め、どんな意思決定がされたのかを後世の人や数年後の自分に伝えられるようなドキュメントは依然として必要である。 ## 課題: ドキュメントの執筆と合意形成のビルドトラップ 私は、PRDもDesign Docも、意思決定とその背景やトレードオフについて関係者間で合意するために書いていた。 - PRD - 意思決定 要件 - 背景・トレードオフ 要求や競合事例などのプロダクトを取り巻く環境 - Design Doc - 意思決定 設計 - 背景・トレードオフ 実現したい要件や品質・性能面のトレードオフやセキュリティ要件など ### 理想: 完璧で網羅的なドキュメントと厳密なレビュー あらゆる観点を網羅的に記載したドキュメントは、一見すると議論の叩き台として完璧に見える。 だが、人間の脳みそにあるワーキングメモリのサイズには限りがある。これらのドキュメントのレビュワーは、その情報量の多さにウッとなるだろう。そして、自分にとって関係がありそうな部分だけピックアップしてコメントしてから、Slackに「ありがとうございます!全体としてはめっちゃ良い感じです!」と書き込むだろう。 結局、彼らが要件や設計に致命的な欠陥を見つけるのは、きっと「これさえマージすればリリースできる!」というプルリクエストをレビューしようと実装を眺めた時か、満を持して行われたデモを見ている時か、何なら顧客からの問い合わせを確認する時だろう。 担当者もレビュワーも、きっと「あれほどたくさんの観点を洗い出したのに!」「あれだけたくさんの選択肢を綿密に洗い出したのに!」と口々に叫ぶだろう。そして次に「もっと綿密に観点と選択肢を洗い出そう!」「レビュープロセスを厳密化しよう!」と言うだろう。 ### 現実: 増大する作業に見合わない成果 しかし、どんなに綿密に準備してレビューしても、結局そんな膨大な情報量をレビューできる人間なんてそもそも存在しない。私も「次回からはこの段階のレビューに私も混ぜて下さい!」と叫んだことはあるが、そのレビューで思うような成果を私が発揮することはついになかった。 たくさん機能をリリースしても顧客は増えないように、たくさんドキュメントを書いてレビューしても関係者間でコンテキストを共有し問題発見へ結びつけることはできない。いずれにしても、私はビルドトラップにハマっていた。 ## 解決策 ### より早期に関係者と対話する RDRAやモブプロなどを活用し、対話しながらトレードオフと選択肢を洗い出していくことで、レビューの段階でSlackのスレッドに嘘みたいな量のコメントを積み重ねなくてもコンテキストを関係者と共有できる。 ### 重要な観点に議論を絞る 競合分析の章に大したことが書いてないPRDが生まれる原因は、その人の怠惰さではなく、あらゆる観点を網羅的に考える必要性の薄さにあるだろう。プロダクトや機能によって重要な観点とそうではない観点がある。PRDやDesign Doc、または非機能要求グレードなど、網羅的な観点が記載されたドキュメントフォーマットは大いに参考になるが、わざわざそれに従って全てを記載する必要は無い。 ### 議論のためのフォーマットを選ぶ [https://github.com/joelparkerhenderson/architecture-decision-record](https://github.com/joelparkerhenderson/architecture-decision-record) 例えば、アーキテクチャに関する意思決定についてはADR(Architecture decision record) が有用だ。議論のための背景、論点、決定された事項、その決定によってどんな影響がある(と当時は考えていた)のかを記すフォーマットだ。タスクのオーナーはあらかじめ「議論のための背景、論点」について列挙できる部分は列挙しておき、Googleカレンダーの予定にそのドキュメントへのリンクを貼ると良い。 ## まとめ つまり、「PRD」や「Design Doc」を"書く"ことや関係者間で"合意形成"することにフォーカスするのではなく、対話的な議論にフォーカスするのが望ましい。そして、対話的な議論を重ねた結果として、意思決定やその背景がドキュメントへ記録されるのだ。 ## TSKaigiにプロポーザルが採択されました🎉 TSKaigi 2024へのプロポーザル採択報告と、サーバサイドTypeScriptでfp-tsを活用した複雑なビジネスロジック検証の取り組みを紹介する記事です。 ![](https://cdn-ak.f.st-hatena.com/images/fotolife/k/kosui_me/20250505/20250505133128.png) ## 2024/03/20 追記 採択されました🎉 聴講者の期待に応えられるように全力で頑張ります💪 また、これに伴い記事のタイトルを「TSKaigiにプロポーザルを提出しました」から変更しています。 ## TSKaigi 2024年5月11日に[TSKaigi 2024](https://tskaigi.org/)が開催されます。 柔軟な型の表現力と強力なエコシステムを持つTypeScriptは、Webフロントエンドアプリケーションの開発のみならず、インフラ構成管理やサーバアプリケーションの開発にも利用されています。 TSKaigiの公式サイトにも下記の記載があるように、様々なバックグラウンドを持つエンジニアと横断的な交流ができそうでとてもわくわくしています。 > 私たちの願いは、フロントエンドからバックエンド、インフラに至るまで、多様な分野のTypeScriptエンジニアたちが集い、普段は交流の少ないエンジニアたちが、それぞれの得意分野や最新の知見を交換し合う交流の場を創り出すことです。 > - [https://tskaigi.org/call-for-proposals](https://tskaigi.org/call-for-proposals) ## プロポーザルを提出した背景 ### サーバサイドTypeScriptのメリット = フロントエンドとの協働? ところで、TypeScriptによるサーバアプリケーション開発のメリットとして「フロントエンドと同じプログラミング言語やツールで開発できるため、設定ファイルやソースコード、知識を共有できる」という点が挙げられることが多いと感じています。確かに、パッケージ管理やLinter/Formatter、言語仕様についての知見はその開発対象を問わず共有できます。 しかし、OpenAPIやProtobufで定義したスキーマから様々なプログラミング言語の型定義やクライアントが生成できるように、今日ではプログラミング言語を揃えなくても十分な開発者体験を得ることができます。また、例えば自動テストに対する戦略がそれぞれで異なるように、各々の専門性や独自性は高く、共有できる知識は限定的です。 ### ビジネスの変化に柔軟に対応するTypeScript では、サーバアプリケーション開発をTypeScriptで開発するメリットは他に無いのでしょうか?冒頭でも述べた通り、私はTypeScriptの魅力を「柔軟な型の表現力と強力なエコシステム」にあると考えています。 事業を取り巻く環境は常に変化しますが、それに応じてシステムに要求される機能や品質も変わり続けます。TypeScriptで開発されたアプリケーションでは、型の運用方法を柔軟に変化させることで、型によってビジネスロジックを厳格に検査する堅牢なシステムへ変化させることも、実行時の入力値や状態に応じて柔軟に対応するシステムへ変化させることもできます。さらに、同じシステムの中でも機能やモジュールによって異なる型の運用方法を選択することもできます。 私は、TSKaigiでの登壇を通じて、サーバアプリケーション開発にTypeScriptを用いることが様々な可能性を持っていることを伝えたいと考えています。例えば、現職 (カケハシ)では、実務における複雑性を解決する鍵として、fp-tsやEffectなどの関数型プログラミングの概念を柔軟に取り入れたライブラリが大きな役割を果たしました。 これは決して全てのサーバアプリケーション開発に当てはまるような選択肢ではありませんが、もしこのプロポーザルが採択された際は、サーバサイドTypeScriptが持つ幅広い可能性の一つを一人でも多くの方にお伝えできれば幸いです。 ## プロポーザル ### タイトル 複雑なビジネスルールに挑む:正確性と効率性を両立するfp-tsのチーム活用術 ### 課題 #### 複数のエンティティが絡み合ったビジネスルールの検証 特にtoBサービスを開発する皆様、こんな悩みを抱えたことはありませんか? 「顧客がExcelやCSVで入稿した複雑な入力データを、正確かつ効率的に検証しなければならない…」 toBサービスでは、数々のエンティティの関係性について、顧客の業界特有のビジネスルールや法令に基づいた検証を実現しなければならないことがあります。もちろん、検証結果が正確であることは必須ですが、顧客に何度も再入力させないためには複数のエラーをなるべく一度に返却しなければなりません。 数多くのビジネスルールの検証結果を、正確かつ効率的に合成する良い方法はないでしょうか? ### 解決策 #### fp-tsの柔軟な関数合成・エラー合成機能 この発表では、複数の医療系SaaSを展開するカケハシにて、エンタープライズな顧客の組織階層を管理・配信する基盤システム「OGAS」の事例を紹介します。「Excel入稿ファイルからツリー形式のデータ構造を組み立てる」という複雑な要件に対し、fp-tsの柔軟な関数合成・エラー合成機能で立ち向かい、検証ロジックの正確さと効率性を両立させることができました。 #### fp-tsをチームで活用するために さらに、この発表ではfp-tsをチームで活用するための工夫も紹介します。fp-tsは日本語の情報が少なく、neverthrowなどと比べて抽象度が高いため、しばしば敬遠されることがあります。しかし、OptionやEitherだけではなく、これらを柔軟に合成できるpipeやflow、Validationなどの仕組みを活用することで、複雑なビジネスロジックに立ち向かうことができます。私達は、一日二回のモブプロや、すぐに使える社内レシピ集などを通じ、幅広い背景を持つメンバーが活躍できる環境を実現しました。 ## for...of 文を使わずに Promise を直列実行するための TypeScript 向けユーティリティ for...of文を使わずにPromiseを直列実行するためのTypeScript向けユーティリティ関数forOfを紹介する記事です。 ![](https://cdn-ak.f.st-hatena.com/images/fotolife/k/kosui_me/20231224/20231224154632.png) ## はじめに 「iterators を使わずに Array の各メソッドや Object.keys を使おうね」とか「Array に対する非同期処理の直列実行は reduce で書けるよね」とか、もう 2017 年ぐらいに十分話され尽くした話だとは思います。 しかし、reduce による Promise の直列実行について、TypeScript 向けに「ジェネリクスで返り値の型もいい感じにしてくれる、よしなに for...of 文っぽく書けるお役立ち関数」として切り出されているケースがあんまり無かったので、それを紹介します。 ## Promise の並列実行 JavaScript/TypeScript では、Promise を利用することでとてもシンプルに非同期処理を記述することができます。 例えば、Promise.all を利用することでかんたんに非同期処理を並列化することができます。 ```typescript const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); // 並列に実行される await Promise.all([ sleep(5000), // 3 番目に完了する sleep(3000), // 2 番目に完了する sleep(1000), // 1 番目に完了する ]); ``` これにより、ある配列に対する非同期処理を並列化したい場合も、下記のようにシンプルに記述することができます。 ```typescript const durations = [5000, 3000, 1000]; await Promise.all(durations.map(sleep)); ``` ## for...of 文による Promise の直列実行 一方で、やんごとなき事情により、非同期処理を直列化したい場合もあると思います。 そんな時、for...of 文を使う方も多いのではないでしょうか。 ```typescript for (const duration of durations) { await sleep(duration); } ``` しかし、[Airbnb JavaScript Style Guide](https://github.com/airbnb/javascript#iterators-and-generators) や [eslint-config-airbnb (v19.0.4 時点)](https://github.com/airbnb/javascript/blob/eslint-config-airbnb-v19.0.4/packages/eslint-config-airbnb-base/rules/style.js#L339-L342) にもあるように、for...of 文を含めた iterator の利用よりも Array や Object の各メソッドの利用が推奨されています。 ## reduce() による Promise の直列実行 では、Array.prototype.reduce() による Promise の直列実行を試してみましょう。特に各 Promise の value を利用しない場合、そこまで複雑なコードにはなりません。 ```typescript await durations.reduce(async (prev, cur) => { await prev; return sleep(cur); }, Promise.resolve()); ``` 一方で、Promise の value を利用する場合はもう少しだけ読みにくくなります。パっと見て「あー、Promise を直列実行してるんだな」とすんなりと理解するには少し時間がかかる場合もあると思います。 ```typescript const asyncSayHello = async (username: string) => { await sleep(Math.random()); return `Hello ${username}`; }; const usernames = ['ken', 'john', 'yuri']; const greetings = await usernames.reduce>( async (prev, cur) => [...(await prev), await asyncSayHello(cur)], Promise.resolve([]), ); // ['Hello, ken!', 'Hello, tom!', 'Hello, yuri!'] console.log(greetings) ``` ## ユーティリティ関数 forOf の紹介 そこで、ちょっとしたユーティリティ関数 forOf があると便利かもしれません。まあ、2023 年にもなってドヤ顔で紹介するようなものではないんですが...。 ```typescript const forOf = (doFn: (entry: T) => Promise, arr: T[]): Promise => arr.reduce>( async (prev, cur) => [...(await prev), await doFn(cur)], Promise.resolve([]), ); ``` forOf のような関数を用意することで、Promise の value を使用する場合もしない場合も、ある程度すっきりします。 ```typescript await forOf(sleep, durations); await forOf(asyncSayHello, usernames); ``` ## おわりに 最近、あまりにブログを書かなかったので、リハビリとして書いてみました。まあちょっと私生活で色々大変だったのを言い訳にしつつ、最近生活が安定してきたのでまた少しずつ頑張るかという気持ちです。 もっと仕事に根ざした記事を書けるように、さらに元気を取り戻していきたいです。 ここまで読んで下さりありがとうございました。 ## BigQuery の課金額で泣かないための UserScript BigQueryで実行前にクエリの課金額をその場で表示するUserScriptの仕組みと実装を解説した記事です。 ## はじめに BigQuery を利用する上で、うっかり高額なクエリを投げてしまったことはありませんか?また、「BigQuery を利用したいけれど課金額が分からないと破産しそうで怖い」という方もいるのではないかと思います。 [https://qiita.com/itkr/items/745d54c781badc148bb9](https://qiita.com/itkr/items/745d54c781badc148bb9) そこで、BigQuery のエクスプローラ画面で、入力したクエリの課金額を実行前にその場で表示してくれる UserScript を作成しました。 ![BigQuery の課金額が表示されている様子![](https://cdn-ak.f.st-hatena.com/images/fotolife/k/kosui_me/20231224/20231224170228.png)BigQuery の課金額が表示されている様子 本記事では、まず前提として Web サービス開発者にとっての BigQuery の有用性を述べた上で、BigQuery の課金体系をおさらいします。その上で、本記事で解決したい課題である「BigQuery のクエリへの課金額を実行前にその場で知りたい」について述べ、その解決策である UserScript について解説します。 ## 前提 まず、前提として、BigQuery がどのようなソリューションで、どのように Web サービス開発者に利用されるか、そして BigQuery を利用する上で注意するべき事項として BigQuery の課金体系を述べます。 ### Web サービス開発者と BigQuery **BigQuery** は Google Cloud で提供されているフルマネージドなデータウェアハウスです。一般には BigQuery は「データサイエンティストがビッグデータを高速に解析するためのソリューション」として紹介されることが多いですが、BigQuery は Web サービス開発者にとっても非常に有用なソリューションです。 例えば、不具合の影響範囲を調査したり、負荷対策のために各エンドポイントへのアクセス傾向を調査したりするために、膨大なアプリケーションログを解析しなければならない時があるかと思います。しかし、ローカル環境に TB 単位のログを持ってくるのは大変ですし、Google Cloud Logging のログエクスプローラは膨大なログ解析に対しては速度の面で不向きです。 そんな時に、BigQuery は非常に便利です。公式ドキュメントに以下の記載があるように、BigQuery では膨大なアプリケーションログの分析も素早く済ませることができます。 > BigQuery のスケーラブルな分散型分析エンジンを使用すると、数テラバイト、数ペタバイトのデータに対し、数秒もしくは数分でクエリを完了できます。 > - [https://cloud.google.com/bigquery/docs/introduction](https://cloud.google.com/bigquery/docs/introduction) ### BigQuery の課金体系 一方で、他のクラウドサービスもそうであるように、BigQuery を利用するためには必ず課金体系を理解する必要があります。 [公式ドキュメント](https://cloud.google.com/bigquery/pricing?hl=ja) によれば、BigQuery の課金体系は以下に分類できます。 - ストレージ料金 ログを保存するために掛かる費用 - 分析料金 保存したログを分析するために掛かる費用 特に、BigQuery をオンデマンドに利用する場合、分析料金は米国リージョンで $5.00/TB、東京リージョンでは $6.00/TB (2022 年 2 月現在) となっています。また、BigQuery では [カラム型ストレージ](https://cloud.google.com/blog/ja/products/data-analytics/bigquery-explained-storage-overview) が採用されているため、スキャンした列のデータの合計容量に応じた課金が行われます。 特に注意するべきことは、「スキャンした列のデータの合計容量に応じた課金が行われる」ということです。たとえば、うかつに `SELECT * FROM ...` をしてしまうと、すべての列をスキャンしてしまい、それだけ課金額が増えてしまいます。さらに、`LIMIT 1000` や `WHERE ...` のように読み出す行数を制限したとしても、指定した列へのスキャンは必ず行われるため、課金額は変わりません。つまり、必要な列だけを指定してクエリを構築することが重要です。 また、[カスタム割り当て機能](https://cloud.google.com/bigquery/docs/custom-quotas) を利用することで、1 日に処理されるクエリデータの量を制限し、想定を超えた課金が発生することを防ぐことができます。 BigQuery のコストを最適化するベストプラクティスとしてより詳しい情報を知りたい場合、[こちら](https://cloud.google.com/blog/ja/products/data-analytics/cost-optimization-best-practices-for-bigquery) の公式ブログ記事を読むことをおすすめします。 ## 課題 私が抱えていた課題は、「これから投げる BigQuery のクエリに対する課金額の概算をパッと知りたい」です。BigQuery のエクスプローラは、以下の図のように処理されるデータサイズを表示してくれますが、私はその場で課金額が自動で計算されて表示されて欲しいと感じていました。 ![](https://cdn-ak.f.st-hatena.com/images/fotolife/k/kosui_me/20231224/20231224170301.png) ## 解決策 ### 要件 そこで、私は以下のような機能を実現したいと考えました。 1. そのクエリで処理されるバイト数を取得する 2. バイト数から課金額を計算し表示する ### UserScript にした理由 ブラウザで表示しているページを手軽に書き換える方法として、たとえば以下があります。 今回は、「手軽に複数のブラウザに向けて公開したい」「ユーザが一度導入したら意識せずに機能が働いて欲しい」「ユーザのリテラシは高いと考えて良い」という要件のため、UserScript として開発しました。 - 各ブラウザの拡張機能 - ユーザが簡単に機能を導入できる - 開発者は提供したい各ブラウザの拡張機能の仕様に合わせる必要がある - UserScript - 開発者は簡単に複数のブラウザに向けて機能を公開できる - ブックマークレット - ユーザが能動的にアクションしないと実行されない ### 処理されるバイト数の取得 また、処理されるバイト数を取得する方法としてすぐ思いつく方法は「DOM からテキストを取得してパースする」があるかと思います。BigQuery ではクエリを書き換えるたびに処理されるバイト数を表示してくれるので、この DOM を MutationObserver で監視すれば取得できるでしょう。しかし、`10 MB` のように整形されたテキストよりも生のバイト数の方が扱いやすいため、この方法は採用しませんでした。 代わりに、以下の方法で実現しました。 まず、`XMLHttpRequest.prototype.send` をオーバーライドして XHR による通信を監視します。 ```javascript (function() { const globalSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.send = function(){ this.addEventListener("readystatechange", function() { // XHR による通信のレスポンスが取得できる console.log(this.responseText) }, false) globalSend.apply(this, arguments) } } ``` 次に、レスポンステキストに `totalBytesProcessed` が含まれている場合は、レスポンステキストを JSON 形式としてパースし、処理されるバイト数を取得します。 ```javascript if (this.responseText.includes("totalBytesProcessed")) { const totalBytesProcessed = JSON.parse(this.responseText)[0]?.data?.response?.totalBytesProcessed } ``` この方法はあまり行儀の良いやり方ではないため、場合によっては利用者を不安にさせるかもしれませんが、今回の UserScript は全体で 50 行に満たない非常に小規模なスクリプトであるため、このスクリプトに危険性が無いことをご理解頂いた上で利用していただけると考えました。 ### スクリプト 以下に該当のスクリプトを公開しています。 今回のスクリプトでは、米国リージョンを基準にした料金を表示するため、他リージョンでの金額を表示したい方はよしなにスクリプトを書き換えていただければと思います。 ## おわりに 課金額に気をつけながら、快適な BigQuery ライフを送りましょう。 ## 免責事項 私は当ページに掲載した情報について可能な限り安全かつ正確であるように努めておりますが、安全性または正確性などについて責任を負うものでは有りません。 当ページに掲載された情報によって生じたあらゆる損害等について、理由の如何に関わらず、私は一切責任を負いません。 ## それでも .env を env したい シェルと互換性のない.envファイルを安全に読み込む方法として、godotenvを使った解決策を紹介する短編記事です。 ![](https://cdn-ak.f.st-hatena.com/images/fotolife/k/kosui_me/20231224/20231224170714.png) ## はじめに 様々なやんごとなき事情によって、手元でささっと `source .env` もしくは `env $(cat .env) foobar` したくなる時はありませんか。 私はあります。 しかし、以下の記事にて指摘されている通り、 `.env` ファイルのシンタックスは、Bash や Zsh などの Bourne Shell 互換のシェルにおける変数の宣言のシンタックスとは異なります。 [https://zenn.dev/red_fat_daruma/articles/7f0ebe9c4d5659](https://zenn.dev/red_fat_daruma/articles/7f0ebe9c4d5659) 実際、上記の記事で挙げられているものを `source .env` にて読み込もうとすると以下のような結果となります。 ``` $ cat .env including_space=hello world push_to_background=hello & pipe_to=hello | world redirect_to=hello > world end_statement=hello; world comment_tailing=hello # world $ source .env .env:1: command not found: world .env:2: parse error near `&' ``` しかし、それでもなお様々なやんごとなき事情によって手元でささっと .env の中身を環境変数として読み込みたい時がきっとあるかもしれません。 ## 解決策 私は [https://github.com/joho/godotenv](https://github.com/joho/godotenv) を利用することにしました。 joho/godotenv は Ruby dotenv プロジェクトの移植実装であり、現在も継続してメンテナンスされている様子が伺えます。 試してみましょう。 ```zsh $ go get -u github.com/joho/godotenv/cmd/godotenv $ godotenv -f .env zsh $ env | grep hello comment_tailing=hello including_space=hello world push_to_background=hello & pipe_to=hello | world redirect_to=hello > world end_statement=hello; world ``` 無事に読み込むことができました。良かったね! ## おまけ 実は oh-my-zsh の dotenv plugin でも `source` で `.env` file を読み込んでいるため、上記のファイルを正しく読み込むことができません。 コントリビューションチャンスかなと思ったのですが、この次にご紹介する gist を見て諦めました。 [https://github.com/ohmyzsh/ohmyzsh/blob/master/plugins/dotenv/dotenv.plugin.zsh#L47](https://github.com/ohmyzsh/ohmyzsh/blob/master/plugins/dotenv/dotenv.plugin.zsh#L47) `.env` ファイルをダイレクトに bash へ読み込ませることについて、以下の gist に先人たちの苦労が記されています。 以下の議論をお読みいただければ、いかにこれが厳しい道程であるかが伝わるかと思われます。 多分 zsh でも同じように辛いと思います。 [https://gist.github.com/judy2k/7656bfe3b322d669ef75364a46327836](https://gist.github.com/judy2k/7656bfe3b322d669ef75364a46327836) [https://zenn.dev/red_fat_daruma/articles/7f0ebe9c4d5659](https://zenn.dev/red_fat_daruma/articles/7f0ebe9c4d5659) ## Go コンパイラのコードを読んでみよう Goの公式コンパイラgcのソースコードを字句解析・構文解析・AST変換の流れに沿って読み解く入門記事です。 ## はじめに 本記事は、 [DeNA Advent Calendar 2020](https://qiita.com/advent-calendar/2020/dena) の 11 日目の記事です。 突然ですが、「コンパイラのコードを読んでみよう」なんて言われても、「どうせ巨大で難解で複雑なロジックを理解しないと読めないんでしょ?」と思いませんか。 コンパイラの構造を理解しようとしても聞いたことのないような専門用語がずらりと並び、コードを読もうとしたらそれらをすべて完全に理解してないと一行も理解できないんじゃないか...。Go のコンパイラ **gc** のソースコードを読むまでは、私もそう思っていました。 しかし、あまりにも暇な休日のある日、思い立って gc のコードを読んでみました。すると、「コンパイル」という難解な響きの処理も、一つひとつを小さなタスクに分解することで、少しずつ読み進めることができると分かったのです! 何よりも感動したことは、 gc そのものが全て Go で書かれていて、コードはシンプルで読みやすく、コメントもかなり丁寧であることです。しかも、gc のコード上では Go の様々な機能が思う存分活用されていて、大変興味深い部分に溢れています。 この記事では、コンパイラについて学んだことがない Gopher 向けに、Go の公式コンパイラの一つである gc のコードを読む楽しさを伝えることを目指します。 ### 対象とする読者 想定している読者は以下の通りです。コンパイラについてよく知らなくても、Go の基礎的な文法を理解している方であれば読み進められるように書いています。 - コンパイラに興味がある方 - コンパイラのコードを読んだことがない方 対象とするバージョンは go1.15.6 です。 #### お断り コンパイラに関する詳細な知識を求めている方へ: 本記事のコードリーディングでは、 AST への変換までを対象としています。 また、本記事では再帰降下構文解析の具体的な実現手段や、SSA・機械語への変換の手順などは紹介していません。 あくまで gc の概観を理解し、より詳細な理解に進むためのステップとしてお読み頂ければ幸いです。 ## コンパイラとは まず、コンパイラとは何かをおさらいしましょう。 コンパイラとは、高水準なプログラミング言語で書かれたプログラムを、機械語やアセンブリ言語やバイトコードなどへ変換するプログラムを指します。 次に、一般的なコンパイラによる処理の流れを見ていきましょう。 ![](https://cdn-ak.f.st-hatena.com/images/fotolife/k/kosui_me/20231224/20231224154057.png) ### 字句解析 (scan) 字句解析 (scan) は、プログラムをトークンの列へ変換します。トークンとは、「意味を持つ最小単位」を指します。例えば、 `x := 1 + 23` という文について見てみましょう。意味を持ったまま `:=` をこれ以上分解することはできませんので、 `:=` はトークンであるといえます。これは `x` や `1` などにも同じことが言えます。よって、 `x := 1 + 23` は `x` `:=` `1` + `23` というトークンの列へ変換されます。 ### 構文解析 (parse) 構文解析 (parse) は、トークン列からトークン同士の関係を表す木構造へ変換します。 例えば、 `x := 2 + 3 * 6` は以下のような木構造で表現することができます。 このように、構文解析によって得られた木構造を **構文木** と呼びます。 ``` := / \ x + / \ 2 * / \ 3 6 ``` また、これらの構文木からコード生成に不要な部分を削除したものを、 **AST** (abstract syntax tree; 抽象構文木) と呼びます。 多くの場合、構文解析ではトークン列から構文木に変換した上で、さらに AST へ変換します。 ### 中間表現生成 プログラムをより効率的に動作させるために、多くのコンパイラはコードの最適化を行います。 そのために、コンパイラが最適化しやすいような形式 **中間表現** に変換します。 例えば、 gc では AST を SSA 形式と呼ばれる中間表現の一種へ変換します。 **SSA 形式** は [静的単一代入形式](https://ja.wikipedia.org/wiki/%E9%9D%99%E7%9A%84%E5%8D%98%E4%B8%80%E4%BB%A3%E5%85%A5) とも呼ばれ、それぞれの変数が一度だけ代入されるように定義される形式です。 例えば、以下のような変換が行われます。 以下の例では、 SSA 形式に変換することによって `a1` への代入が不要な処理であることがより明確になりました。このように、最適化を行う上で SSA 形式への変換は非常に便利です。 ```go // before a := 1 a = 2 b = a + 1 // after a1 := 1 a2 := 2 b1 := a2 + 1 ``` ## gc とは **gc** は `cmd/compile` とも呼ばれ、普段多くの人々が利用している、公式の Go コンパイラの1つです。 gc のコードは[github.com/golang/go/tree/master/src/cmd/compile](https://github.com/golang/go/tree/master/src/cmd/compile)に置かれています。README.md も丁寧に書かれていて、コードにもきちんとコメントが書かれているため、コンパイラに対する知識が少ない状態でも読み進めることができます。 ## gc のパッケージ構成 gc は `cmd/compile` パッケージに実装されています。 それでは `cmd/compile` パッケージの構成を見てみましょう。 gc の実装のほぼ全ては `cmd/compile/internal` パッケージに置かれており、exported な関数や構造体や変数はありません。 コアとなる処理は `cmd/compile/internal/gc` パッケージに格納されており、 `gc.Main` を中心としてコンパイルが行われ、必要に応じて他のパッケージが呼び出されます。 ``` cmd/compile ├── internal │ ├── gc │ │ gc のコアとなるパッケージ │ │ 構文木 -> AST -> SSA(中間表現) へ変換する │ │ │ ├── syntax │ │ 字句解析・構文解析を行うパッケージ │ │ │ ├── ssa │ │ SSA の最適化を行うパッケージ │ │ │ ├── logopt │ │ json オプションを指定時の処理を行うパッケージ │ │ │ ├── test │ │ テスト │ │ │ ├── types │ │ Go の型を表現するパッケージ │ │ │ │ 以下はすべて SSA からそれぞれの │ │ アーキテクチャに向けた機械語を生成するパッケージ │ ├── amd64 │ ├── arm │ ├── arm64 │ ├── mips │ ├── mips64 │ ├── ppc64 │ ├── riscv64 │ ├── s390x │ ├── wasm │ └── x86 └── main.go ``` ## gc によるコンパイルのフロー それでは、gc によるコンパイルのフローを追っていきましょう。 ![](https://cdn-ak.f.st-hatena.com/images/fotolife/k/kosui_me/20231224/20231224154215.png)gc によるコンパイルのフロー 初めに、コンパイル対象となるそれぞれのソースファイルは、字句解析・構文解析を経て構文木となり、さらに AST へ変換されます。 次に、型チェックが完了した AST は SSA 形式の中間表現へ変換されます。その後、中間表現は最適化されたのち、機械語へと変換されます。 ## コードリーディング 最後に、実際のコードを読んでみましょう。本記事では字句解析・構文解析・AST への変換までを対象とします。 ### コンパイルの開始とファイルの読み込み #### `gc.Main` : 初期化処理 gcのメイン処理は [main.Main()](https://github.com/golang/go/blob/go1.15.6/src/cmd/compile/main.go) にはほとんど書かれておらず、実際には 700 行近い関数である [gc.Main()](https://github.com/golang/go/blob/go1.15.6/src/cmd/compile/internal/gc/main.go) が中心となって行われます。`gc.Main` の冒頭の約 200 行はコマンドライン引数やオプションに関する処理で、実際にコンパイルの対象となるファイルを字句解析・構文解析する処理は 570 行目前後から行われます。 それでは、実際にそれぞれのファイルに対する処理が開始される `gc.Main` の[ 561 行目](https://github.com/golang/go/blob/go1.15.6/src/cmd/compile/internal/gc/main.go#L561-L576)から見てみましょう。 まず、 `initUniverse()` が universe ブロックを用意します。 universe ブロックはすべてのソースファイルが展開されるブロックで、これから読み込まれるあらゆるコードがこのブロックの下に展開されていきます。 `initUniverse()` は、 `int`, `bool`, `error` などの基本型や `true`, `false`, `iota` などの定数、ゼロ値 `nil` などを初期化し、これを universe ブロックに宣言します。 cmd/compile/gc/main.go [[開く]](https://github.com/golang/go/blob/go1.15.6/src/cmd/compile/internal/gc/main.go#L561-L576) ``` func Main(archInit func(*Arch)) { // 中略 { /* highlight-range{1} */ } initUniverse() dclcontext = PEXTERN nerrors = 0 autogeneratedPos = makePos(src.NewFileBase("", ""), 1, 0) timings.Start("fe", "loadsys") loadsys() timings.Start("fe", "parse") lines := parseFiles(flag.Args()) timings.Stop() timings.AddEvent(int64(lines), "lines") finishUniverse() ``` ##### ここが面白い `cmd/compile/gc/universe.go` を読むと、実は universe ブロックに `builtinpkg` という偽のパッケージが宣言されていて、そこに基本型や定数が宣言されていくことが分かります。 ```go // builtinpkg is a fake package that declares the universe block. var builtinpkg *types.Pkg ``` ```go // initUniverse initializes the universe block. func initUniverse() { lexinit() typeinit() lexinit1() } ``` 例えば、 `iota` も以下のように初期化されて `builtinpkg` の中に置かれています。 私たちが普段何気なく使っている `nil` や `iota` も、実は架空のパッケージに定義されていると思うと、ちょっと不思議ですね。 ```go // lexinit initializes known symbols and the basic types. func lexinit() { // 中略 s = builtinpkg.Lookup("iota") s.Def = asTypesNode(nod(OIOTA, nil, nil)) asNode(s.Def).Sym = s asNode(s.Def).Name = new(Name) } ``` ちなみに、gc では `builtinpkg` のようなグローバル変数が数多く宣言されています。普段避けがちなグローバル変数も、使い時を間違えなければ便利ですが、これらの大量に宣言されたグローバル変数をうまく整理すれば、 gc へのコントリビューションを達成できるかもしれませんね。 次に、 `loadsys()` が低レベルなランタイム関数をロードします。 例えば、おなじみの定義済み関数 `panic()` の実装である [`gopanic()`](https://github.com/golang/go/blob/go1.15.6/src/runtime/panic.go#L889) もここでロードされます。ランタイム関数の実装は [runtime](https://github.com/golang/go/blob/go1.15.6/src/runtime) に置かれています。 そして、いよいよ `parseFiles()` 関数によってそれぞれのファイルが並列に字句解析・構文解析され、ファイルごとに構文木へ変換されます。 次は、 `parseFiles()` を見ていきましょう。 cmd/compile/gc/main.go [[開く]](https://github.com/golang/go/blob/go1.15.6/src/cmd/compile/internal/gc/main.go#L561-L576) ``` func Main(archInit func(*Arch)) { // 中略 initUniverse() dclcontext = PEXTERN nerrors = 0 autogeneratedPos = makePos(src.NewFileBase("", ""), 1, 0) timings.Start("fe", "loadsys") { /* highlight-range{1} */ } loadsys() timings.Start("fe", "parse") { /* highlight-range{1} */ } lines := parseFiles(flag.Args()) timings.Stop() timings.AddEvent(int64(lines), "lines") finishUniverse() ``` #### `gc.parseFiles` それでは、 `gc.parseFiles()` の中身を見ていきましょう。 1つのソースファイルから得られる情報は `noder` 構造体で表現されます。 まず、 goroutine の中で `syntax.Parse()` によって字句解析・構文解析が行われ、得られた構文木が `noder.file` へ格納されます。 次に、`noder.node()` を呼び出し、この `noder.file` から AST を生成します。 cmd/compile/gc/noder.go [[開く]](https://github.com/golang/go/blob/go1.15.6/src/cmd/compile/internal/gc/noder.go#L23-L76) ``` // parseFiles concurrently parses files into *syntax.File structures. // Each declaration in every *syntax.File is converted to a syntax tree // and its root represented by *Node is appended to xtop. // Returns the total count of parsed lines. func parseFiles(filenames []string) uint { noders := make([]*noder, 0, len(filenames)) // Limit the number of simultaneously open files. sem := make(chan struct{}, runtime.GOMAXPROCS(0)+10) // 補足: [STEP1] ソースファイルから構文木へ for _, filename := range filenames { p := &noder{ basemap: make(map[*syntax.PosBase]*src.PosBase), err: make(chan syntax.Error), } noders = append(noders, p) go func(filename string) { // 補足: セマフォで同時に開くファイル数を `runtime.GOMAXPROCS(0)+10` 個まで制限している sem <- struct{}{} defer func() { <-sem }() defer close(p.err) base := syntax.NewFileBase(filename) f, err := os.Open(filename) if err != nil { p.error(syntax.Error{Msg: err.Error()}) return } defer f.Close() // 補足: 構文木を生成し p.file に格納する // syntax.Parse で発生した error は // p.error 関数で記録される p.file, _ = syntax.Parse(base, f, p.error, p.pragma, syntax.CheckBranches) // errors are tracked via p.error }(filename) } // 補足 [STEP2] 構文木から AST へ var lines uint for _, p := range noders { for e := range p.err { p.yyerrorpos(e.Pos, "%s", e.Msg) } p.node() // 補足: AST への変換はここで行われる lines += p.file.Lines p.file = nil // release memory if nsyntaxerrors != 0 { errorexit() } // Always run testdclstack here, even when debug_dclstack is not set, as a sanity measure. testdclstack() } localpkg.Height = myheight return lines } ``` ##### ここが面白い `gc.parseFiles` では、 channel を利用したセマフォで同時に開くファイル数を `runtime.GOMAXPROCS(0)+10` 個まで制限しています。 これは、 too many open files を防ぐために使われています [(golag/go/#21621)](https://github.com/golang/go/issues/21621)。 訂正: 「channel を利用してエラーの伝搬を行っている」と書いていましたが、誤りでした。 また、 goroutine でそれぞれのファイルごとに字句解析・構文解析までを行っていることから、 CPU を余らせることなく処理を行っています。 Go の高速なコンパイルは、このような地道な工夫によって支えられているのだなあ、と感じました。 ```go noders := make([]*noder, 0, len(filenames)) // Limit the number of simultaneously open files. sem := make(chan struct{}, runtime.GOMAXPROCS(0)+10) for _, filename := range filenames { p := &noder{ basemap: make(map[*syntax.PosBase]*src.PosBase), err: make(chan syntax.Error), } noders = append(noders, p) go func(filename string) { // 補足: セマフォで同時に開くファイル数を `runtime.GOMAXPROCS(0)+10` 個まで制限している sem <- struct{}{} defer func() { <-sem }() defer close(p.err) base := syntax.NewFileBase(filename) f, err := os.Open(filename) if err != nil { p.error(syntax.Error{Msg: err.Error()}) return } defer f.Close() p.file, _ = syntax.Parse(base, f, p.error, p.pragma, syntax.CheckBranches) // errors are tracked via p.error }(filename) } ``` ### 構文解析 #### `syntax.Parse` `gc.parseFiles()` から呼び出される `syntax.Parse()` は、コメントにある通り 1 つのソースファイルから構文木を生成します。 gc は再帰降下構文解析 (recursive descent parsing) という方式で構文解析を行います。構文解析器は `parser` 構造体で表現され、字句解析器である `scanner` 構造体が埋め込まれています。字句解析を進めながら構文解析を行います。 cmd/compile/syntax/parser.go [[開く]](hhttps://github.com/golang/go/blob/go1.15.3/src%2Fcmd%2Fcompile%2Finternal%2Fsyntax%2Fparser.go#L17-L32) ``` type parser struct { file *PosBase errh ErrorHandler mode Mode pragh PragmaHandler { /* highlight-range{1} */ } scanner // 補足: ここに scanner が埋め込まれている base *PosBase // current position base // 中略 } ``` scanner は現在見ているコード上の位置や、現在見ているトークンの情報などを持ちます。 cmd/compile/syntax/scanner.go [[開く]](https://github.com/golang/go/blob/768201729df89a28aae2cc5e41a33ffcb759c113/src/cmd/compile/internal/syntax/scanner.go#L30-L44) ``` type scanner struct { // 中略 // current token, valid after calling next() line, col uint // 補足: 現在見ているコード上の位置 blank bool // line is blank up to col tok token // 補足: 現在見ているトークンの情報 ``` cmd/compile/syntax/syntax.go [[開く]](https://github.com/golang/go/blob/go1.15.6/src/cmd/compile/internal/syntax/syntax.go#L67-L82) ``` // Parse parses a single Go source file from src and returns the corresponding // syntax tree. If there are errors, Parse will return the first error found, // and a possibly partially constructed syntax tree, or nil. // // If errh != nil, it is called with each error encountered, and Parse will // process as much source as possible. In this case, the returned syntax tree // is only nil if no correct package clause was found. // If errh is nil, Parse will terminate immediately upon encountering the first // error, and the returned syntax tree is nil. // // If pragh != nil, it is called with each pragma encountered. // func Parse(base *PosBase, src io.Reader, errh ErrorHandler, pragh PragmaHandler, mode Mode) (_ *File, first error) { defer func() { if p := recover(); p != nil { if err, ok := p.(Error); ok { first = err return } panic(p) } }() var p parser // 補足: 構文解析器(parser) と 字句解析器(scanner) を初期化する p.init(base, src, errh, pragh, mode) // 補足: next() は埋め込まれている scanner がレシーバの関数 // ここでの呼び出しは scanner の現在位置を初期化するために必要 p.next() // 補足: ここで字句解析と構文解析の処理が呼び出され、結果が戻される return p.fileOrNil(), p.first } ``` #### `syntax.fileOrNil` `syntax.fileOrNil()` は、前述した再帰降下構文解析を行う関数です。 `fileOrNil()` は、まず pacakge 句と import 文を構文解析した後、トップレベルスコープに定義された定数、変数、型、関数について構文解析していきます。 ちなみに、以下にあるように syntax パッケージではしばしば関数の頭や関数中のコメントに `// TypeSpec = identifier [ "=" ] Type .` のような文字列が書かれていると思いますが、これは **BNF** (バッカスナウア記法) と呼ばれる記法を拡張した EBNF と呼ばれる記法で、プログラミング言語の文法を定義するためにしばしば用いられます。関数の頭に EBNF のコメントが書かれている場合は、左辺について構文解析している関数だと考えて頂ければ読み進められると思います。 加えて、`{ }` が省略可能で繰り返し可能であること、 `[ ]` が省略可能であることをそれぞれ表していることも覚えておくと読みやすいです。 cmd/compile/syntax/parser.go [[開く]](https://github.com/golang/go/blob/go1.15.6/src/cmd/compile/internal/syntax/parser.go#L374-L450) ``` // SourceFile = PackageClause ";" { ImportDecl ";" } { TopLevelDecl ";" } . func (p *parser) fileOrNil() *File { ``` まずは初期化処理から見ていきましょう。このファイルについての構文解析の結果は `File` 構造体に詰められます。 cmd/compile/syntax/parser.go [[開く]](https://github.com/golang/go/blob/go1.15.6/src/cmd/compile/internal/syntax/parser.go#L374-L450) ``` func (p *parser) fileOrNil() *File { if trace { defer p.trace("file")() } // 補足: 構文解析の結果は File 構造体に詰められる f := new(File) f.pos = p.pos() ``` `File` 構造体の定義を見てみましょう。`PkgName` フィールドはパッケージ名を持ちます。 `DeclList` フィールドはトップレベルスコープに宣言された定数、変数、型、関数と import をすべて持つスライスです。 トップレベルスコープとは、各ファイルの中で最も外側にあるスコープを指します。定数、変数、型、関数 というそれぞれ全く異なる性質を持つ要素の宣言が、同じ `DeclList` というスライスの中に格納されているのは、ちょっと意外ですね。 ```go // package PkgName; DeclList[0], DeclList[1], ... type File struct { Pragma Pragma PkgName *Name DeclList []Decl Lines uint node } ``` ##### ここが面白い 上で少し触れましたが、import、定数、変数、型、関数の宣言は同じ `Decl` というインターフェイスを持つ構造体として扱われています。 Decl は Declaration(宣言) の略語です。 ```go type Decl interface { Node aDecl() } ``` 定数の宣言、型の宣言はそれぞれ、`ConstDecl`, `TypeDecl` という構造体で表現されています。 ところで、いずれの構造体にも `decl` という構造体が埋め込まれていますね。一体これは何でしょう? ```go // NameList // NameList = Values // NameList Type = Values type ConstDecl struct { Group *Group // nil means not part of a group Pragma Pragma NameList []*Name Type Expr // nil means no type Values Expr // nil means no values decl } // Name Type type TypeDecl struct { Group *Group // nil means not part of a group Pragma Pragma Name *Name Alias bool Type Expr decl } ``` 以下が `decl` の宣言です。 `decl` は `node` という構造体を埋め込み、 `node` は `Post` という構造体を埋め込んでいますが、 `Pos` はただコード内の位置を持つだけの構造体です。 では、なぜ `ConstDecl` や `TypeDecl` は `Pos` を直接埋め込まずに `decl` を埋め込んでいるのでしょうか? 答えは `aDecl()` にあります。 `aDecl()` というこの何もしない関数は、実はどこからも呼び出されていませんが、 `Decl` インターフェイスは `aDecl()` を持つことを要求します。つまり、ここではダックタイピングを利用して、それぞれの構造体が「宣言」であるか否かを、 `aDecl()` という関数を持っているかどうかで判定しています。すぐにはパッと理解できないテクニックですが、面白いダックタイピングの使い方ですね。 ```go type decl struct{ node } func (*decl) aDecl() {} type node struct { pos Pos } type Pos struct { base *PosBase line, col uint32 } ``` さて、話を戻します。 Package 句を構文解析する部分について見てみましょう。 `p.got()` と `p.want()` はそれぞれ以下のように現在のトークンを参照して情報を返します。 - `p.got()` トークンを読み進めた上で、現在見ているトークンが引数に与えた種類であるかを返す関数 - `p.want()` トークンを読み進めた上で、現在見ているトークンが引数に与えた種類でなければエラーとする関数 `p.got()` と `p.want()` を使って構文をチェックし、問題がなければ `f.PkgName` にパッケージ名を格納します。 ちなみに、 `p.takePragma()` は `// go:generate ...` などのプラグマが書かれている場合はこれを取得する関数です。この場合は、ファイルの冒頭に書かれたプラグマを取得し、 `File` 構造体に持たせていますね。 cmd/compile/syntax/parser.go [[開く]](https://github.com/golang/go/blob/go1.15.6/src/cmd/compile/internal/syntax/parser.go#L374-L450) ``` func (p *parser) fileOrNil() *File { if trace { defer p.trace("file")() } f := new(File) f.pos = p.pos() // PackageClause if !p.got(_Package) { // 補足: 当然、パッケージ名が無ければ Syntax Error ですね p.syntaxError("package statement must be first") return nil } f.Pragma = p.takePragma() f.PkgName = p.name() p.want(_Semi) ``` 次に、 import 文を構文解析する部分について見ていきます。 現在のトークンが `import` であれば、 `p.appendGroup(f.DeclList, p.importDecl)` で `f.DeclList` を append します。 ```go // don't bother continuing if package clause has errors if p.first != nil { return nil } // 補足: 次にimport 文を構文解析する // { ImportDecl ";" } for p.got(_Import) { f.DeclList = p.appendGroup(f.DeclList, p.importDecl) p.want(_Semi) } ``` `p.appendGroup(f.DeclList, p.importDecl)` と `p.importDecl()` について見てみましょう。 まず `p.importDecl()` は、 `ImportSpec` を構文解析して `ImportDecl` を返します。 この `p.importDecl()` 関数をそのまま `p.appendGroup(f.DeclList, p.importDecl)` に渡しています。 `appendGroup` はコメントによれば `f | "(" { f ";" } ")"` を構文解析するようです。 つまり、 `p.appendGroup(f.DeclList, p.importDecl)` では `"hoge"` または `("hoge"; "fuga"; "piyo")` のような文字列を構文解析し、得られた import 宣言をすべて `f.DeclList` に append しているようです。 cmd/compile/syntax/parser.go [[開く]](https://github.com/golang/go/blob/go1.15.6/src%2Fcmd%2Fcompile%2Finternal%2Fsyntax%2Fparser.go#L519) ```go // ImportSpec = [ "." | PackageName ] ImportPath . // ImportPath = string_lit . func (p *parser) importDecl(group *Group) Decl { ``` cmd/compile/syntax/parser.go [[開く]](https://github.com/golang/go/blob/go1.15.6/src%2Fcmd%2Fcompile%2Finternal%2Fsyntax%2Fparser.go#L493) ```go // appendGroup(f) = f | "(" { f ";" } ")" . // ";" is optional before ")" func (p *parser) appendGroup(list []Decl, f func(*Group) Decl) []Decl { ``` さて、package 句と import 文の構文解析が完了したら、次はトップレベルスコープにある定数、変数、型、関数を構文解析していきます。現在 scanner が見ているトークンが `const` であれば `p.constDecl` 、 `var` であれば `p.varDecl` など、それぞれ対応する関数に構文解析させ、結果を `f.DeclList` に追加します。つまり、この `f.DeclList` の中にトップレベルスコープで宣言された定数、変数、型、関数の情報がスライスの要素として格納されていきます。ファイル内のトップレベルスコープのすべての宣言について構文解析が完了した場合、そのファイルの構文解析は完了となります。 また、関数などの中でさらに変数や型や関数の宣言があれば、再帰的にこれを構文解析します。その辺りの処理が気になる場合は、 `funcDeclOrNil` 関数の実装を見ると良さそうです。 cmd/compile/syntax/parser.go [[開く]](https://github.com/golang/go/blob/go1.15.6/src/cmd/compile/internal/syntax/parser.go#L403-L450) ``` // { TopLevelDecl ";" } for p.tok != _EOF { switch p.tok { case _Const: p.next() f.DeclList = p.appendGroup(f.DeclList, p.constDecl) case _Type: p.next() f.DeclList = p.appendGroup(f.DeclList, p.typeDecl) case _Var: p.next() f.DeclList = p.appendGroup(f.DeclList, p.varDecl) case _Func: p.next() if d := p.funcDeclOrNil(); d != nil { f.DeclList = append(f.DeclList, d) } default: if p.tok == _Lbrace && len(f.DeclList) > 0 && isEmptyFuncDecl(f.DeclList[len(f.DeclList)-1]) { // opening { of function declaration on next line p.syntaxError("unexpected semicolon or newline before {") } else { p.syntaxError("non-declaration statement outside function body") } p.advance(_Const, _Type, _Var, _Func) continue } // Reset p.pragma BEFORE advancing to the next token (consuming ';') // since comments before may set pragmas for the next function decl. p.clearPragma() if p.tok != _EOF && !p.got(_Semi) { p.syntaxError("after top level declaration") p.advance(_Const, _Type, _Var, _Func) } } // p.tok == _EOF p.clearPragma() f.Lines = p.line return f } ``` ### AST への変換 #### `gc.parseFiles` ここまでは、構文解析が `cmd/compile/internal/syntax` で行われ、 `gc.parseFiles()` にファイルごとの構文木が返されることを確認しました。 それでは、 `gc.parseFiles()` に戻り、今度は構文木から AST への変換処理について見ていきましょう。 `gc` パッケージは、`syntax` パッケージによって得られた `File` 構造体を受け取り、 `Node` 構造体で表現される AST へ変換します。 そして、その AST をたどって型チェックし、問題がなければ中間表現を生成します。 cmd/compile/gc/noder.go [[開く]](https://github.com/golang/go/blob/go1.15.6/src/cmd/compile/internal/gc/noder.go#L23-L76) ```go func parseFiles(filenames []string) uint { // 中略 // 補足: [STEP2] 構文木から AST へ var lines uint for _, p := range noders { for e := range p.err { p.yyerrorpos(e.Pos, "%s", e.Msg) } { /* highlight-range{1} */ } p.node() // 補足: AST への変換はここで行われる lines += p.file.Lines p.file = nil // release memory if nsyntaxerrors != 0 { errorexit() } // Always run testdclstack here, even when debug_dclstack is not set, as a sanity measure. testdclstack() } localpkg.Height = myheight return lines } ``` #### `noder.node` それでは最後に、先程触れた `p.node()` でどのように AST への変換を行っているか少しだけ覗いてみましょう。 ちなみに `noder` というのはおそらく AST のノードを生成するための構造体だから node + er で `noder` という命名になっている、と私は推測しています。 まずは初期化処理をしています。 `imported_unsafe` はどこか一つのファイルで `unsafe` パッケージをインポートすると `true` になるグローバル変数です。 めちゃくちゃグローバル変数が多いですが、何かパフォーマンス上の問題があるのかもしれません。残念ながら、私は詳しい経緯や理由を追うことができませんでした。 `mkpackage()` はパッケージ名を取り出しています。 その後、プラグマが利用されているかどうかを記録しています。 cmd/compile/gc/noder.go [[開く]](https://github.com/golang/go/blob/go1.15.6/src/cmd/compile/internal/gc/noder.go#L237-L284) ```go func (p *noder) node() { types.Block = 1 imported_unsafe = false p.setlineno(p.file.PkgName) mkpackage(p.file.PkgName.Value) if pragma, ok := p.file.Pragma.(*Pragma); ok { p.checkUnused(pragma) } ``` ##### ここが面白い gc は、元々 C 言語で書かれていて、構文解析に yacc/bison を利用していました。 その後 gc は Go で書き直されましたが、 yacc/bison が利用されていた痕跡は今も残っています。 例えば、先程少しだけ触れた `mkpackage()` では `yyerror()` という関数を呼び出していますが、この `yyparse` という関数名は yacc/bison でのエラー処理のために定義する必要がある関数名と一致します。 もしコンパイラに興味があれば、 yacc/bison に触れてみるのもアリかもしれません。 ```go func mkpackage(pkgname string) { if localpkg.Name == "" { if pkgname == "_" { yyerror("invalid package name _") } localpkg.Name = pkgname } else { if pkgname != localpkg.Name { yyerror("package %s; expected %s", pkgname, localpkg.Name) } } } ``` そして、次に `File` 構造体の `DeclList` プロパティからトップレベルスコープに定義された定数や関数などを持ってきて、これを `p.decls` で AST へ変換します。 `xtop` はトップレベルスコープにある宣言をすべて格納するグローバル変数です。 ここに格納された宣言は、後で1つずつ順番に型チェックされていきます[[開く]](https://github.com/golang/go/blob/go1.15.6/src%2Fcmd%2Fcompile%2Finternal%2Fgc%2Fmain.go#L582)。 cmd/compile/gc/noder.go [[開く]](https://github.com/golang/go/blob/go1.15.6/src/cmd/compile/internal/gc/noder.go#L237-L284) ```go func (p *noder) node() { types.Block = 1 imported_unsafe = false p.setlineno(p.file.PkgName) mkpackage(p.file.PkgName.Value) if pragma, ok := p.file.Pragma.(*Pragma); ok { p.checkUnused(pragma) } { /* highlight-range{1} */ } xtop = append(xtop, p.decls(p.file.DeclList)...) ``` `p.decls` の中身を見てみましょう。変数・定数・型・関数の宣言はそれぞれのハンドラで AST に変換された後に `xtop` へ格納されていきますが、import については特に型チェックが必要なわけではないので `xtop` には格納されていないことが分かります。 ```go func (p *noder) decls(decls []syntax.Decl) (l []*Node) { var cs constState for _, decl := range decls { p.setlineno(decl) switch decl := decl.(type) { case *syntax.ImportDecl: p.importDecl(decl) case *syntax.VarDecl: l = append(l, p.varDecl(decl)...) case *syntax.ConstDecl: l = append(l, p.constDecl(decl, &cs)...) case *syntax.TypeDecl: l = append(l, p.typeDecl(decl)) case *syntax.FuncDecl: l = append(l, p.funcDecl(decl)) default: panic("unhandled Decl") } } return } ``` ##### ここが面白い 上記のうち、 定数の AST への変換処理 `constDecl()` について見てみましょう。 以下のうち、ハイライト部分について着目して下さい。 なんと、 `iota` のカウンタの管理は、構文木から AST への変換の過程で行われていることが分かります。驚きですね。 1. 直前まで見ていた定数宣言の状態 `constState` と現在見ている定数宣言を比較し、同じ `const ( ... )` 内で宣言された定数でなければ `constState` をリセットする 2. `Node` 構造体を生成し、宣言された定数の名前と値を格納する 3. 宣言された定数に対応する `iota` の値を `n.SetIota(cs.iota)` で格納する 4. `cs.iota++` で `iota` のカウンタをインクリメントする ちなみに、 `iota` の値を使うのか、宣言時に与えらた値を使うのかを決定する処理は型チェック時に行われるようです。 ```go func (p *noder) constDecl(decl *syntax.ConstDecl, cs *constState) []*Node { { /* highlight-range{1-5} */ } if decl.Group == nil || decl.Group != cs.group { *cs = constState{ group: decl.Group, } } if pragma, ok := decl.Pragma.(*Pragma); ok { p.checkUnused(pragma) } names := p.declNames(decl.NameList) typ := p.typeExprOrNil(decl.Type) var values []*Node if decl.Values != nil { values = p.exprList(decl.Values) cs.typ, cs.values = typ, values } else { if typ != nil { yyerror("const declaration cannot have type without expression") } typ, values = cs.typ, cs.values } nn := make([]*Node, 0, len(names)) for i, n := range names { if i >= len(values) { yyerror("missing value in const declaration") break } v := values[i] if decl.Values == nil { v = treecopy(v, n.Pos) } n.Op = OLITERAL declare(n, dclcontext) n.Name.Param.Ntype = typ n.Name.Defn = v { /* highlight-range{1} */ } n.SetIota(cs.iota) nn = append(nn, p.nod(decl, ODCLCONST, n, nil)) } if len(values) > len(names) { yyerror("extra expression in const declaration") } { /* highlight-range{1} */ } cs.iota++ return nn } ``` ## まとめ 本記事では、コンパイラに関する知識をおさらいした上で、公式 Go コンパイラの一つである gc のコードを AST への変換部分まで見ていきました。 この中で、どれか一つでも「お、面白いじゃん」と思うような部分があれば嬉しいです。 ちなみに、Go の型システムに興味がある方は [Featherweight Go を読んでみた](https://matsubara0507.github.io/posts/2020-07-02-read-featherweight-go.html) という記事がおすすめです。 また、この記事をきっかけに gc やコンパイラに興味を持ってくれた方がいらっしゃれば Twitter などで教えて下さい。筆者がとても喜びます。 [DeNA 公式 Twitter アカウント @DeNAxTech](https://twitter.com/DeNAxTech) では、この Advent Calendar や DeNA の技術記事、イベントでの登壇資料などを発信しています。 もし良かったらフォローしてください。 ## 付録 A: gc 以外の Go コンパイラ ### gccgo **gccgo** は GCC のフロントエンドで、もう一つの公式の Go コンパイルツールチェインです。 **GCC** は GNU Compiler Collection の略であり、様々なプログラミング言語に対応したコンパイルツールチェインです。 gccgo については [golang.org/doc/install/gccgo](https://golang.org/doc/install/gccgo) にて解説されています。 ### gollvm **gollvm** は LLVM のフロントエンドで、C++ で書かれた gccgo と共通のフロントエンド **gofrontend** を利用しています。 **LLVM** は特定の言語に依存しない中間言語 **LLVM IR** を用いることで、様々な言語に対応可能なコンパイラフレームワークです。 詳しくは [go.googlesource.com/gollvm](https://go.googlesource.com/gollvm/) をご覧ください。 ![](https://cdn-ak.f.st-hatena.com/images/fotolife/k/kosui_me/20231224/20231224154238.png)gollvm ## ライセンス 本記事に掲載された gc のコードについて ``` Copyright (c) 2009 The Go Authors. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of Google Inc. nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` ## ISUCON10 予選敗退の記録と反省 ISUCON10予選に初参加した振り返りで、事前準備から当日の最適化作業、反省点までを詳しくまとめた記録です。 ## はじめに 2020/09/28 に開催された ISUCON10 で予選敗退。 とても楽しい問題でしたが、無残にも敗れ去りました。 来年に向け、事前準備および当日にやったことを振り返ります。 なお、チームメイト @genya0407 の参加記は [こちら](https://genya0407.github.io/post/isucon10) になります。 ## 記録 「ここにチーム名を入れる」というチーム名で [@genya0407](https://twitter.com/genya0407) と出場。 Go 実装を使用し、結果は 1300 点でした。 ## メンバー - [@ebiebievidence](https://twitter.com/kosui_me) (私) - 初参戦 - デプロイ環境を整える - アプリケーション - [@genya0407](https://twitter.com/genya0407) - ISUCON8, ISUCON9 に続き参戦 - インフラ - スロークエリを見てインデックスを張ったり - アプリケーションのコード修正もしていた - (全部) ## 事前準備 ### 初動 ISUCON7 および ISUCON8 の予選をベースに、主に初動の練習をしました。 私は ISUCON について完全な素人であり、@genya0407 から色々と教わりながら以下のように初動を分担しました。 この初動の訓練はかなり重要でした。適切な分担決めを行うには、それぞれの得意・不得意を適切に知っておく必要があります。 また、デプロイコマンドやログローテートの仕組みなどは、何度か訓練を重ねることである程度汎用的なモノに仕上がってきます。 最終的には、初動は以下の流れで作業することにしました。 1. マニュアル読み合わせ (二人で) 2. アプリを一通り触る(二人で) ここからは作業分担 - @genya0407 1. `/etc/hosts` をチームメンバーに配る ホスト名を揃えておくと何かとコマンドを共有しやすくて便利 - nginx.conf に LTSV のログを出力する設定を加える - my.cnf にスローログを出力する設定を加える - alp をインストールする - @ebiebievidence 2. アプリコードおよびデータベースのマイグレーションファイルを GitHub リポジトリに上げる - デプロイコマンドを作る デプロイ時に必ずログファイルをローテートする - docker-compose で利用可能なローカル開発環境を構築する - アプリコードにプロファイラを仕込む 3. 各種プロファイルの結果を見ながら方針を決定する (二人で) ### ツール 以下のツールを利用することとしました。 今回は New Reric の無料ライセンスが利用できるなど、最新のツールを利用することもできましたが、使い慣れたツールを利用することにしました。 netdata を導入することも検討しましたが、 `htop` と `vmstat` で十分必要な情報が得られると判断し、導入を見送りました。 - alp - pt-query-digest - [fgprof](https://github.com/felixge/fgprof) - Off-CPU な実行時間ベースで計測できる Golang プロファイラ - 標準ライブラリに含まれている pprof は On-CPU な実行時間ベースでのみ計測するため、DB や外部 API など外部とのやりとりで時間を溶かしている部分を見つけられない #### **自作のシナリオ可視化ツール** 「それぞれのユーザがどのような動線を辿っているかを可視化できれば、どのエンドポイントがクリティカルであるか分かるのでは」という @genya0407 の天才的な提案がありました。 nginx の `userid` 機能を利用してトラッキング Cookie を付与することで、それぞれのユーザがエンドポイントを叩く順番を可視化することができます。 それを受けて、私は LTSV 形式の access.log をパースしてユーザごとの辿る経路をサンキー図で出力する CLI を作りました。 以下は、 ISUCON9 予選のログをこの CLI に読ませた結果です。 ほとんどのユーザはログイン後すぐに `POST /sell` または `POST /buy` のいずれかを叩き、その後 `GET /items/id.json` や `GET /new_items/id.json` を回遊していることなどが分かります。 ![](https://cdn-ak.f.st-hatena.com/images/fotolife/k/kosui_me/20231224/20231224154315.png)ユーザごとの辿る経路を示すサンキー図(ISUCON9予選) ## 当日 ### 流れ 初回ベンチで CPU リソースが足りていないことがわかったため、まず CPU への負荷を減らすことが重要だと考えました。 初動が完了したら、以下の流れで改善を行いました。 1. fgprof, pt-query-digest でボトルネックを探す 2. より低いコストで改善できそうなボトルネックから改修を行う 3. ローカル環境で動作確認 4. 問題がなさそうであれば PR を作る 5. PR をもう一人がレビューをし、問題がなければデプロイしてベンチを回す 6. 点数が下がっていなければマージする (1. に戻る) ### コミュニケーション 基本的には Zoom でつなぎっぱなしにしつつ、困ったり詰まったりしたら画面共有をしてペアプロ風に進めました。 レビューして欲しい PR や、プロファイルの結果などは Discord で送った上で、Zoom で一言声を掛けるようにしました。 また、何か行動を開始する時は Zoom で声を掛けてから行うようにしました。 小規模なチームであれば、Zoom を繋ぎっぱなしにしてしまえばかなりスムーズにコミュニケーションが取れることがわかりました。 一方、意識的に記録に残さなければ後から参照することができないという欠点はあります。 特に、何かしら話し合いが行われたら、その話し合いが完了し次第すぐに決まったことと次にやることをきちんと Discord なり Slack なりにメモしておくことが重要です。 ### 実施したこと ほぼ @genya0407 がやってくれました。私、精進します... #### bot からのリクエストが来ているか判断した @genya0407 が access.log を シェル芸でいい感じに解析して、bot からのリクエストが来ているか確認した。 しかし、ほぼ全く来ていなかったため、対策を見送った。 **反省** 実は、ボットはある程度サービスの負荷が減ると登場するという仕組みだったらしいです。しかし、そこに気づかずに最後までここの対策をしませんでした。どうやったら気がつくことができたのか...次回に向けた具体的な対策は浮かびません。 #### ユーザの行動の追跡 トラッキング Cookie を nginx から返すようにした上で、先程紹介した自作のシナリオ可視化ツールを利用して、ユーザの行動の追跡を試しました。 しかし、今回のベンチマーカーはクッキーを保持しなかったため、この方法による追跡は失敗してしまいました。 そこで、慌てて User-Agent ヘッダーの値をユーザ特定の条件として再度追跡を試しました。回遊を見た結果、 `/buy` までにたどり着くのがとても長いことが分かったため、`/buy` までに辿る経路もパフォーマンスを上げなければならないことが分かりました。 ![](https://cdn-ak.f.st-hatena.com/images/fotolife/k/kosui_me/20231224/20231224154332.png)サンキー図にした結果 **反省** 「ベンチマーカーは必ず `GET /api/estate/low_priced` エンドポイントを叩く」ということは競技後に知りました。ここが分かれば、「じゃあまずはそこをよく観察する必要があるよね」という話に持っていけたのですが、なんと上の図をよく見るとそれが読み取れたんですね...。焦りは禁物とはよくいいますが、人間は焦ってしまう生き物ですから、焦っている状態でもすぐ見てわかるような出力ができるツールにしたいと想います。いい感じになったら公開する予定です。 また、実は各リクエストの UA 文字列の末尾には UUID が付与されており、 UA 文字列からユーザをトラッキングできました。現実世界では Google Chrome や Microsoft Edge が UA 文字列を固定化するなど、UA 文字列からユーザを特定できないようにプライバシーが保護されるような仕組みが導入されつつあります。しかし、 ISUCON ならではのこのような仕掛けに気付く発想を持つことができれば、よりよいスコアが目指せたなと反省です。 #### MySQL 8 へのアップグレード (中止) 私の「MySQL 8 なら色々機能増えてるしパフォーマンスも良くなってるっぽいし、MySQL 8 に上げるっしょ!」という雑な思いつきに @genya0407 が付き合ってくれて MySQL 8 へアップグレードしてくれましたが、スコアが爆下がりするという事件が発生したため中止されました。binlog を無効する必要があったようです。 **反省** MySQL 8 の機能をよく知らないのに MySQL 8 へのアップグレードをなんとなくの気持ちで提案してしまった私は、MySQL 8 の機能をきちんとキャッチアップするか、自分が知っている範囲の知識で地に足つけて戦うかするべきです。 #### DB サーバと Web サーバを 1 台ずつの構成に CPU への負荷を下げるために、@genya0407 が DB サーバと Web サーバを分けてくれました。 確かここで 1000 点台に乗った気がします。 #### popularity を逆にする @genya0407 がスローログを見ていく中で、 `GET /api/estate/search` エンドポイントで発行されている以下のクエリについて、 ソート時にインデックスが使われていないことに気が付きました。 ```sql SELECT * FROM estate WHERE ... ORDER BY popularity DESC, id ASC LIMIT ? OFFSET ? ``` MySQL 5.x では降順インデックスが利用できないため、 popularity を初期化時に逆にしてあげることで上記クエリを `ORDER BY popularity ASC, id ASC` に書き換え、インデックスでソートできるようにしました [(#8)](https://github.com/isucon-genya-uni/isucon10y/pull/8)。 #### いい感じにインデックスを貼る @genya0407 がスローログを見ながらいい感じにインデックスを貼っていきました。 ```sql create index e1 on isuumo.estate (popularity); create index estate_rent on isuumo.estate (rent); create index estate_point on isuumo.estate (latitude, longitude); create index i1 on isuumo.chair (price); ``` 例: [#6](https://github.com/isucon-genya-uni/isucon10y/pull/6) ```sql -- 変更前: MySQL [isuumo]> explain SELECT * FROM chair WHERE stock > 0 ORDER BY price ASC, id ASC LIMIT 20; +----+-------------+-------+------------+------+---------------+------+---------+------+-------+----------+-----------------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+------+---------+------+-------+----------+-----------------------------+ | 1 | SIMPLE | chair | NULL | ALL | NULL | NULL | NULL | NULL | 28907 | 33.33 | Using where; Using filesort | +----+-------------+-------+------------+------+---------------+------+---------+------+-------+----------+-----------------------------+ -- 適用したインデックス: create index i1 on isuumo.chair (price); -- 変更後: MySQL [isuumo]> explain SELECT * FROM chair WHERE stock > 0 ORDER BY price ASC, id ASC LIMIT 20; +----+-------------+-------+------------+-------+---------------+------+---------+------+------+----------+-------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+-------+---------------+------+---------+------+------+----------+-------------+ | 1 | SIMPLE | chair | NULL | index | NULL | i1 | 4 | NULL | 20 | 33.33 | Using where | +----+-------------+-------+------------+-------+---------------+------+---------+------+------+----------+-------------+ ``` #### 範囲検索を ID 検索に置き換え たとえば、家賃 (rent) については検索画面では 4 種類の範囲しか選べませんが、DB へのクエリでは `rent >= ? AND rent < ?` のように範囲検索がされていました。これらについて、 DB に `rent_id` を設け、 `rent_id` で検索できるようにしました。 ```go for _, r := range estateSearchCondition.Rent.Ranges { if r.Max > 0 { query := `UPDATE estate SET rent_id = ? WHERE rent >= ? AND rent < ?` _, err := db.Exec(query, r.ID, r.Min, r.Max) if err != nil { panic(err) } } else { query := `UPDATE estate SET rent_id = ? WHERE rent >= ?` _, err := db.Exec(query, r.ID, r.Min) if err != nil { panic(err) } } } ``` この修正を door_width や price などにも横展開し適用しましたが、後述の理由により十分スコアが伸びませんでした。 **反省** インデックスを `rent_id` のみに貼ってしまい、 `popularity` と複合インデックスで貼らなかったことからスコアは大きく伸びませんでした。後々`alp -q` で振り返ると、検索エンドポイントへのアクセスのほとんどは単純な条件による検索だったため、 `rent_id, popularity` とか `price_id, popularity` とか貼っておけばいい感じにスコアが伸びたのかもしれません。ユーザ、もといベンチマーカーの動きは例年必ずクセがあるので、これを見落とさないように alp の結果を見る時はクエリパラメータも含めて見るべきですね。 加えて、境界値を誤る痛恨の不具合を入れてしまい、この原因究明に時間を費やしてしまいました。境界値周りはあまりにも有名な凡ミスである一方で、焦っている時ほど踏みがちですから、気をつけたいですね。 また、コストが高い対応については本当に必要かよく検討し、必要であれば方針を言語化してレビューなどをしてもらうべきだったかもしれません。 #### COUNT(*) の結果をキャッシュ pt-query-digest および fgprof から、物件検索でも椅子検索でも `COUNT(*)` が CPU 時間を食いつぶしていることが分かりました。 初めは DB にキャッシュ用のテーブルを作ってそこにキャッシュさせましたが、あまりスコアが伸びなかったためアプリのメモリに載せました。([#12](https://github.com/isucon-genya-uni/isucon10y/pull/12) および [#16](https://github.com/isucon-genya-uni/isucon10y/pull/16)) ちなみに、 [catatsuy/mercari_go_isucon.md](https://gist.github.com/catatsuy/e627aaf118fbe001f2e7c665fda48146) を参考にさせて頂きました。Golang の `sync.RWMutex` は気軽に使えて便利です。 ```go type countMap struct { sync.RWMutex count map[string]int64 } func (c *countMap) Set(key string, value int64) { c.Lock() c.count[key] = value c.Unlock() } func (c *countMap) Get(key string) (int64, bool) { c.RLock() v, found := c.count[key] c.RUnlock() return v, found } func (c *countMap) Clear() { c.RLock() c.count = make(map[string]int64) c.RUnlock() return } func NewCountMap() *countMap { m := make(map[string]int64) c := &countMap{ count: m, } return c } func searchEstates(c echo.Context) error { // (中略) count, ok := estateCountMap.Get(hash) if ok { res.Count = count } else { err = db.Get(&res.Count, countQuery+searchCondition, params...) if err != nil { c.Logger().Errorf("searchEstates DB execution error : %v", err) return c.NoContent(http.StatusInternalServerError) } chairCountMap.Set(hash, res.Count) } // (中略) } func postEstate(c echo.Context) error { // (中略) estateCountMap.Clear() // invalidate if err := tx.Commit(); err != nil { c.Logger().Errorf("failed to commit tx: %v", err) return c.NoContent(http.StatusInternalServerError) } return c.NoContent(http.StatusCreated) } ``` #### なぞって検索の DB クエリのソートをアプリでやる なぞって検索は検索結果が 50 件前後ですが、ソートが重かったっぽいのでアプリでやるように @genya0407 が改修してくれました[(#15)](https://github.com/isucon-genya-uni/isucon10y/pull/15)。 ## 上記以外の反省 ### デプロイフローの確立は第一に行うべき 今回は fgprof をアプリに仕込んでからデプロイフローの確立を行いました。デプロイフローが確立されるまでに時間がかかったため、インデックスを貼るなどのスキーマを書き換える作業の反映が安全にできませんでした。デプロイフローの確立こそ、最初に行うべきでした。 ### @ebiebievidence がこれから学ぶべきこと 来年に向けて、これからの業務に向けて、私は以下を学ぶべきであると思いました。 - MySQL のインデックス 現在でも多くの業務アプリケーションで利用されている MySQL のパフォーマンスをチューニングするためには、 特にインデックスについてよく勉強する必要があると感じました。 - シェル芸 @genya0407 はシェル芸を駆使し、ログを迅速に分析していました。 時間が限られている ISUCON や障害対応では、シェル芸は非常に役立つことが分かりました。 ### チームの役割編成 初動終了後、@genya0407 が主にインデックス追加やクエリ改善などの DB からのアプローチ、@ebiebievidence が主にアプリケーションからのアプローチで改善に取り組みました。お互いが自分の得意な部分に集中して取り組むことができた一方で、俯瞰的にログやプロファイルを観察して方針を決定する司令官的存在が不在となりました。 また、インフラ的な知識がチームにないため、カーネルパラメータや MySQL のチューニングには取り組みませんでした。来年はインフラに詳しいメンバーも加えて参加できればいいなと思いました。 ### 初動に時間がかかってしまった MySQL 8 へのアップグレードや、ローカル環境の準備などに時間を要し、初動に時間がかかってしまいました。特に、ローカル環境の準備については私が担当でしたが、@genya0407 の方が知見が深かったため、うまくいかないタスクについては早めに相談するべきでした。 ### シャーディング完全に思いつかなかった 一部のチームでは、物件と椅子のテーブルをシャーディングし、別のホストで提供する方法を取っていました。完全に思いつかなかった...。こういう思考が持てたらよかったなと思いました。 ## まとめ 問題を楽しむことができた一方で、来年に向けた課題が多く見つかった ISUCON 10 予選となりました。 来年はよりレベルアップできるように、この一年は精進していきたいです。 ## 二者間交渉ゲームにおける交渉解の比較 ゲーム理論における二者間交渉の代表的な解であるナッシュ交渉解・カライ・スモルディンスキー解・均等解の公理と特性を比較解説します。 ## はじめに 本記事では、二者間交渉において、交渉解として著名な、ナッシュ交渉解、カライ・スモルディンスキー解、均等解(カライ解)を紹介する。 ## 注意書き カライ・スモルディンスキー解および均等解は、二者間交渉の場合と、三者以上による交渉の場合で、満たす公理が異なる。本記事では、 **二者間交渉における** カライ・スモルディンスキー解、均等解について紹介する。 ## 二者間交渉とは 二者間交渉とは、文字通り、二者間で行われる交渉である。 二者間交渉では、合意案候補の集合から、交渉によって合意案が一つ決定される。 例えば、 `太郎` と `花子` がどこで夕食を食べるか交渉する時、合意案候補集合 `サイゼリヤ` ・ `洋麺屋五右衛門` ・ `吉野家` から、交渉によって合意案として どれか一つを選択する。この時、`太郎`と`花子`はそれぞれ、自分自身の効用が最大になるように交渉を進める。 「効用」というのは、例えば、`太郎`から見て「`サイゼリヤ`は80点、`洋麺屋五右衛門`は60点、`吉野家`は90点」などの「合意案候補に対する満足度」である。また、「与えられた合意案候補に対して、ある交渉参加者から見た満足度を返す関数」は「効用関数」と呼ばれる。 ## 交渉解とは 交渉解とは、二者間交渉において最も合理的な合意案を導出する方法・ルールを指す。 つまり、ある特定の交渉解に従って合意案を選択することで、合理的な合意案を決定することができる。 しかし、この3つの交渉解それぞれにおいて「合理的な合意案」の定義が異なる。 つまり、3つの交渉解において、どのような公理を満たすべきかがそれぞれ異なる。 それぞれの交渉解が、二者間交渉においていずれの公理を満たすかを、以下の表に示す。 | | 公理1 | 公理2 | 公理3 | 公理4 | 公理5 | | --- | --- | --- | --- | --- | --- | | ナッシュ交渉解(1950) | ○ | ○ | ○ | ○ | × | | カライ・スモルディンスキー解(1975) | ○ | ○ | × | ○ | ×(※1) | | 均等解(1977) | ×(※2) | × | ○ | ○ | ○ | ※1 カライ・スモルディンスキー解は、単調性は満たさないが、限定単調性を満たす。 ※2 均等解は、パレート最適性は満たさないが、弱パレート最適性を満たす。 ## ナッシュ交渉解が満たす公理1~4と満たさない公理5 ### ナッシュ交渉解(Nash bargaining solution)とは ナッシュ交渉解とは、各交渉参加者の効用の総乗を最大化する合意案候補を合意案とする、ナッシュが導出した交渉解である。 公理1~4を満たす唯一の交渉解である。 かみ砕いて説明すると、AとBが参加する二者間交渉において、AとBの獲得するスコアの積を最大化する合意案候補を合意案とすれば、公理1~公理4は必ず満たされる。また、公理1~4をすべて満たす交渉解はナッシュ交渉解の他に存在しない。 以下の図でいえば、原点と合意案候補を頂点に持ち、X軸とY軸を辺に持つ長方形の面積が最大になるような合意案候補が、合意案として選ばれる。 実際、合意案が成す長方形(赤色)の面積は、それ以外の合意案候補が成す長方形(緑色)の面積より大きい。 ![](https://i.imgur.com/gAvE0g6.png) コラム: 「ナッシュ均衡とは関係があるんですか?」という質問をいただくことがある。ナッシュ均衡とナッシュ交渉解は別々の概念ではある。しかし、「非協力ゲームを何かいい感じに協力ゲームへ近似すると、ナッシュ均衡とナッシュ交渉解は同一になる」ということをナッシュが証明している。サラっと書いたが、めちゃくちゃすごいことである。 > 検索をすると「ナッシュ均衡」に関するtweetは山ほど引っかかるけど、やはり「ナッシュ交渉解」はほとんどない。協力ゲームの代表的な解概念となり、公理論的な特徴付けの走りとなったこちらも、実はノーベル賞級の業績。Google Scholarによると、引用件数はどちらも7000件台。 > — 安田 洋祐 (@yagena) [2015年5月25日](https://twitter.com/yagena/status/602653901662326784?ref_src=twsrc%5Etfw) > 奇跡的な両論文を二十歳そこそこで書いてからわずか数年後に、具体的な交渉ゲームを通じて協力ゲームの「ナッシュ交渉解」を非協力ゲームの「ナッシュ均衡」として説明したーつまり、バラバラのアプローチであった両分野を繋いだー「ナッシュ・プログラム」も特筆すべき業績。やっぱりナッシュすげぇ… > — 安田 洋祐 (@yagena) [2015年5月25日](https://twitter.com/yagena/status/602655708765691904?ref_src=twsrc%5Etfw) すごいことである。 詳しい解説は[金沢大学の半沢英一先生の講演資料](https://mathsoc.jp/publication/tushin/1104/hanzawa.pdf)の18章前後に記載されている。 ### 公理1: パレート最適性 その交渉解が導出する合意案がすべての交渉空間においてパレート最適である場合、その交渉解はパレート最適性を満たす。 かみ砕いて説明すると、「パレート最適性」とは、決定された合意案と比べて、「お互いが損をせずに、少なくとも一方がより得をする」合意案候補が存在しない性質を指す。 ![](https://i.imgur.com/RT2JFS9.png) ### 公理2: 正アフィン変換からの独立性 すべての交渉空間において、交渉空間に対して正アフィン変換を行っても合意案が変わらない場合、その交渉解は正アフィン変換からの独立性を満たす。 例えば、ナッシュ交渉解は公理2を満たすため、以下のように交渉空間に対してアフィン変換を行っても、ナッシュ交渉解は同一の合意案候補を合意案として選択する。 ![](https://i.imgur.com/T5YhvVj.png) ![](https://i.imgur.com/Kbx7JjH.png) ### 公理3: 無関係な他の合意案候補の候補からの独立性 すべての交渉空間において、交渉解によって決定された合意案以外の合意案候補を交渉空間から取り去っても、合意案が変わらない場合、その交渉解は無関係な他の合意案候補の候補からの独立性を満たす。 かみ砕いて説明すると、「無関係な他の合意案候補の候補からの独立性」とは、合意案じゃない合意案候補を交渉空間から消しても合意案が変わらない性質を指す。 ![](https://i.imgur.com/ExXBLWZ.png) ### 公理4: 対称性 すべての交渉空間において、交渉参加者を入れ替えても、ある交渉解が元の合意案に対して対象な合意案候補を合意案として選択する場合、その交渉解は対称性を満たす。 かみ砕いて説明すると、対称性を満たす交渉解は、交渉参加者のAさんとBさんの立場を入れ替えても、対称な合意案候補を合意案として選択する。 ### 公理5: 単調性 すべての交渉空間において、交渉空間に新しい合意案候補が加えられたことで合意案が変更されても、交渉参加者のいずれも損をしない場合、交渉解は単調性を満たす。 ナッシュ交渉解はこれを満たさない。 たとえば、以下の例では、新たに追加された合意案候補が新たに合意案として選択されるが、この場合はBの効用は減少してしまう。 ![](https://i.imgur.com/qhs4m1Q.png) ## カライが目指した単調性の実現 交渉解が単調性を満たさない場合、後出しで追加された合意案候補のせいで、誰かが損をする場合が出てしまう。一般に、後出しで出された合意案候補が採用されて損をした人間が良い気持ちになるとは思えない。絶対に殴り合いになる。少なくとも、私の地元はそういう街だと思う。 カライは、ナッシュ交渉解が単調性を満たさないことを指摘し、1975年にカライ・スモルディンスキー解、1977年に均等解(カライ解)を提案した。 ### カライ・スモルディンスキー解 カライ・スモルディンスキー解は、二者間交渉において、「公理3: 無関係な他の合意案候補の候補からの独立性」「公理5: 単調性」以外の3つの公理と、「限定単調性」を満たす交渉解[2]。個人効用÷最大効用の比率が交渉参加者間で最も均等になる合意案候補を合意案とする。限定単調性を満たす交渉解は、各参加者が交渉中に獲得できる最大の効用の組(理想点)が同一である場合にのみ単調性を満たす。 > [2] Kalai, Ehud, and Meir Smorodinsky. "Other Solutions to Nash's Bargaining Problem." Econometrica 43, no. 3 (1975): 513-18. doi:10.2307/1914280. ### 均等解(カライ解/Egalitarian solution) 均等解は、二者間交渉において、「公理1: パレート最適性」「公理2: 正アフィン変換からの独立性」以外の3つの公理と、「弱パレート最適性」を満たす交渉解[3]。最も得られる効用が低い交渉参加者の効用を最大化する合意案候補を合意案とする。一番損をする参加者ができるだけ損をしないようにしてくれるため、一人負けが発生しにくい平等主義的な解。 > [3] Kalai, Ehud. "Proportional Solutions to Bargaining Situations: Interpersonal Utility Comparisons." Econometrica 45, no. 7 (1977): 1623-630. doi:10.2307/1913954. #### 弱パレート最適性とは ある交渉解が選択した合意案から見て「参加者全員が同時により得をする合意案候補」が必ず存在しない場合、その交渉解は弱パレート最適性を満たす。 ![](https://i.imgur.com/teV4aBb.png) ## ナッシュ交渉解と均等解の比較 - ナッシュ交渉解は当事者の効用の積を最大化する一方で、均等解は当事者の効用の最小値を最大化する。このことから、ナッシュ交渉解はより功利主義的であり、均等解はより平等主義的といえる。 - 交渉空間に新たな合意案候補が次々に追加されていくような状況では、単調性を満たさないナッシュ交渉解による合意案の選択は争いを招きかねない。 - 逆に、交渉空間が変更されない状況下では、単調性は意義を失う。そのため、パレート最適性を満たさない均等解は用いるべきではないといえる。 なお、より詳しい分析・比較は、本記事では扱わない。本記事の位置付けは、あくまでそれらへの足がかりとする。分配的正義(配分的正義ではない)の話とかも出てきてややこしいので... ご不明な点・気になる点・誤っている点があれば[Twitter/@ebiebievidence](https://twitter.com/kosui_me)までご一報ください。 # Talks ## 本当にTypeScriptのclassを使わずにシステムを運用できるの? - Event: TSKaigi 2026 事後勉強会 - Date: 2026-06-24 - Duration: 10min - Themes: typescript - URL: https://kosui.me/talks/2026/tskaigi-afterparty 実際の運用中のシステムを紹介 ## TypeScriptのclassはなぜこうなったのか - Event: TSKaigi 2026 - Date: 2026-05-22 - Duration: 30min - Themes: typescript - URL: https://kosui.me/talks/2026/tskaigi TypeScriptのclassが今の形になった歴史を紐解き、落とし穴を根本原因から整理し、対策を提示します。 ## 開発チームが信頼性向上のためにできること - Event: SRE Kaigi 2026 - Date: 2026-01-31 - Duration: 30min - Themes: sre, architecture, プラットフォーム - URL: https://kosui.me/talks/2026/sre-kaigi-2026 Embedded SRE不在でも開発チームが信頼性に責任を持ち、改善を続けるための具体的な方法論と、運用を通じて学んだ教訓を紹介します。 ## 堅牢な認証基盤の実現:TypeScriptで代数的データ型を活用する - Event: 関数型まつり 2025 - Date: 2025-06-14 - Duration: 20min - Themes: typescript - URL: https://kosui.me/talks/2025/fp-matsuri 医療システムの認証基盤で代数的データ型を活用し、複雑な状態管理を型安全に実現した事例を紹介します。 ## PdMのためのソフトウェアエンジニアリング入門 - Event: 社内LT - Date: 2024-12-08 - Duration: 10min - Themes: team - URL: https://kosui.me/talks/2024/kakehashi-internal-lt プロダクトマネージャー向けに、ソフトウェアエンジニアリングの基礎と、 PdMとエンジニアが協働するためのアプローチを解説する。 ## プロダクト成長に対応するプラットフォーム戦略: Authleteによる共通認証基盤の移行事例 - Event: OAuth & OpenID Connect 勉強会 ー 認可サーバーの作りかた(AWS編) - Date: 2024-10-30 - Duration: 20min - Themes: architecture - URL: https://kosui.me/talks/2024/authlete-study-group 医療SaaS企業カケハシが、複数プロダクト向けに統一認証基盤を構築した事例。 Authleteを活用したアーキテクチャと、プロダクトチームとの移行戦略を紹介。 ## 複雑なビジネスルールに挑む:正確性と効率性を両立するfp-tsのチーム活用術 - Event: TSKaigi 2024 - Date: 2024-05-11 - Duration: 20min - Themes: typescript - URL: https://kosui.me/talks/2024/tskaigi fp-tsの柔軟な関数合成・エラー合成機能を活用し、Excel入稿機能の複雑なバリデーションで正確性と効率性を両立させた事例を紹介します。 ## 品質とスピードを両立: TypeScript の柔軟な型システムをバックエンドで活用する - Event: Findy様 TypeScript 開発言語を統一 〜フロントからバックまで活用〜 Lunch LT - Date: 2024-03-26 - Duration: 10min - Themes: typescript - URL: https://kosui.me/talks/2024/techplay-typescript-lt TypeScriptの柔軟な型システムをバックエンドで活用し、品質とスピードを両立させるための実践的なテクニックを紹介します。 ## 更新"しない"ドキュメント管理 「イミュータブルドキュメントモデル」の実運用 - Event: Findy様 ドキュメント管理を制する 陳腐化を防ぐための実践事例 Lunch LT - Date: 2023-12-06 - Duration: 20min - Themes: team - URL: https://kosui.me/talks/2023/document ドキュメントを更新し続ける代わりに、意思決定を記録する「イミュータブルドキュメントモデル」の実運用について紹介します。 ## 大規模SaaSにおけるプラットフォームシステム開発の進め方 - Event: SmartHR・カケハシ・リクルートのエンジニアが語る「複雑化するプラットフォーム開発をスムーズに進めるための方法」 - Date: 2023-10-24 - Duration: 15min - Themes: architecture - URL: https://kosui.me/talks/2023/techplay 大規模SaaSにおけるプラットフォームシステム開発で、要求分析とアジャイル開発を小さく反復する方法論を紹介します。 ## User-Agent 文字列削減によるサービス影響とその対策 - Event: DeNA TechCon 2021 Winter - Date: 2021-12-27 - Duration: 5min - URL: https://kosui.me/talks/2021/dena-techcon-winter Chrome の User-Agent 文字列削減がウェブサービスに与える影響と、 User-Agent Client Hints を使った対策について解説するライトニングトーク。 ## Goのコンパイラをみてみよう 〜iotaを通じて〜 - Event: MCCMMANCC 2019 - Date: 2019-12-22 - Duration: 15min - URL: https://kosui.me/talks/2019/mccmmancc Go言語の「iota」という言語機能に焦点を当て、 Goコンパイラのソースコードを読み解きながら内部実装を探索する。 # External Articles ## 共通基盤の構築にサーバサイドTypeScriptを選んで嬉しかったこと - Publisher: KAKEHASHI Tech Blog - Date: 2026-07-08 - URL: https://kakehashi-dev.hatenablog.com/entry/2026/07/08/090000 - Themes: typescript, architecture 認証基盤・ID基盤の構築でサーバサイドTypeScriptを採用して得られた利点を紹介。OIDCのSSR実装、Discriminated Unionによる状態遷移管理、構造的型システムを活かした組織単位の設計を論じる。 ## サーバーサイドTypeScriptの型システムをどう教えるか — 他言語経験者に向けたオンボーディング事例 - Publisher: KAKEHASHI Tech Blog - Date: 2026-03-31 - URL: https://kakehashi-dev.hatenablog.com/entry/2026/03/31/110000 - Themes: typescript, team Python出身・Elixir出身のエンジニアにTypeScriptをオンボーディングした事例を紹介。「型検査とランタイムの境界を理解する」ことが言語背景を問わず最重要だと論じる。 ## TypeScriptのテストにはas const satisfiesが便利です - Publisher: KAKEHASHI Tech Blog - Date: 2025-12-14 - URL: https://kakehashi-dev.hatenablog.com/entry/2025/12/14/110000 - Themes: typescript テストでダミーデータを定義する際の型検査エラーを、as const satisfies の組み合わせで解決する方法を紹介。Discriminated Unionやオプショナルプロパティを持つ型でも、型安全性を保ちながらリテラル型推論を活用できる。 ## TypeScriptの宣言的な配列操作 - ビジネスロジックを明確にする - Publisher: KAKEHASHI Tech Blog - Date: 2025-11-19 - URL: https://kakehashi-dev.hatenablog.com/entry/2025/11/19/110000 - Themes: typescript 配列操作を手続き的スタイルから宣言的スタイルへ移行するメリットを解説。filterやmapなどの高階関数とカリー化を組み合わせ、ビジネスロジックの意図を明確にするコーディング手法を提案。 ## カケハシのマルチプロダクト インフラアーキテクチャ - Publisher: Findy Tools - Date: 2025-09-25 - URL: https://findy-tools.io/companies/kakehashi/91/97 - Themes: architecture 医療SaaSを支えるマルチプロダクト基盤のアーキテクチャを紹介。OpenID Connectによる認証基盤、アカウント・アセット管理、Databricksを用いたデータ基盤、API・イベントドリブンによるプロダクト間連携の設計思想を解説。 ## 日本の医療に本気で向き合う。認証・権限管理基盤チームの決意 - Publisher: KAKEHASHI Tech Blog - Date: 2025-08-29 - URL: https://kakehashi-dev.hatenablog.com/entry/2025/08/29/105510 - Themes: team 複数プロダクト間で共通する認証・権限管理を一元化する基盤の役割や、厚生労働省ガイドラインに基づく二要素認証必須化への対応など、医療DXを支える基盤チームの取り組みと展望を紹介。 ## 他言語経験者が知っておきたいTypeScriptのクラスの注意点 - Publisher: KAKEHASHI Tech Blog - Date: 2025-08-19 - URL: https://kakehashi-dev.hatenablog.com/entry/2025/08/19/110000 - Themes: typescript Java/C#経験者がTypeScriptのクラスを使う際の4つの落とし穴を解説。構造的部分型によるドメインオブジェクトの混同、thisの動的束縛、private修飾子の実行時の限界などについて代替アプローチとともに紹介。 ## 目的別データベースの実践: PostgreSQL 行レベルセキュリティと DynamoDB Outboxパターン - Publisher: KAKEHASHI Tech Blog - Date: 2024-09-19 - URL: https://kakehashi-dev.hatenablog.com/entry/2024/09/19/110000 - Themes: architecture PostgreSQLの行レベルセキュリティによるテナント間データ分離と、DynamoDBのOutboxパターンによるイベント配信を紹介。医療SaaSにおける個人情報保護と高可用性の要件に応じた目的別データベース選定の実践例。 ## 社内ドキュメントはなぜ更新されないのか?情報の鮮度を最小限の運用負荷で維持する「イミュータブルドキュメントモデル」のススメ - Publisher: KAKEHASHI Tech Blog - Date: 2023-10-16 - URL: https://kakehashi-dev.hatenablog.com/entry/2023/10/16/100000 - Themes: team 情報を可変的なリソース情報と不変的なイベント情報に分類し、イベントの積み重ねとしてリソースを表現することで、最小限の運用負荷でドキュメントの鮮度を維持するアプローチを提案。 ## AWS ECS on Fargate + FireLens で大きなログが扱いやすくなった話 - Publisher: DeNA Engineering Blog - Date: 2022-08-12 - URL: https://engineering.dena.com/blog/2022/08/firelens/ - Themes: sre ECS on Fargate環境でFireLensを使用した際に、16KB以上のログが分割されてJSONパースが失敗する問題の原因と、FluentBitのMultilineフィルタによるログ結合の解決策を解説。