バーバラ・リスコフのデータ抽象原則を学び、明確で安定したAPI設計、破壊的変更の削減、保守しやすいシステム構築の方法を身につけましょう。

バーバラ・リスコフは、現代のソフトウェアチームが“壊れない”ものを作る方法に静かに影響を与えた計算機科学者です。彼女のデータ抽象や情報隠蔽、そして後の**リスコフの置換原則(LSP)**に関する研究は、プログラミング言語から日常的なAPI設計に至るまで幅広く影響を与えました:振る舞いを明確に定義し、内部を保護し、他者がそのインターフェースに安全に依存できるようにすることです。
信頼できるAPIは単に理論的に「正しい」だけではありません。それはプロダクトの速度を高めるインターフェースです:
その信頼性は体験です:APIを呼ぶ開発者にとって、保守するチームにとって、間接的にそれに依存するユーザーにとっての体験です。
データ抽象とは、呼び出し側が保存や計算の細かい実装ではなく、概念(アカウント、キュー、サブスクリプション)に対して小さな操作セットでやり取りするという考え方です。
表現の詳細を隠すと、誤りのカテゴリをまるごと取り除けます:誰も公開されるべきでないデータベースのフィールドに「偶然」依存したり、システムが扱えない方法で共有状態を変更したりできません。同じく重要なのは、抽象化は調整のコストを下げることです:公開振る舞いが一貫していれば、チームは内部をリファクタリングするための許可を求める必要がありません。
この記事の終わりまでに、実践的に以下ができるようになります:
後で手早く要点を見たい場合は /blog/a-practical-checklist-for-designing-reliable-apis にジャンプしてください。
データ抽象は単純な考え方です:何かをどう「作られているか」ではなく、それが何をするかで扱います。
自動販売機を思い浮かべてください。モーターの回り方や硬貨の数え方を知らなくても良い。必要なのは操作(“商品を選ぶ”、“支払う”、“商品を受け取る”)とルール(“十分に支払えば商品が出る。売り切れなら返金される”)だけです。これが抽象化です。
ソフトウェアでは、インターフェースが「何をするか」です:操作名、受け取る入力、出力、期待されるエラー。実装は「どう動くか」です:データベースのテーブル、キャッシュ戦略、内部クラス、パフォーマンスの工夫。
これらを切り離すことで、システムが進化してもAPIが安定します。内部を書き直したり、ライブラリを差し替えたり、ストレージを最適化しても、ユーザー向けのインターフェースは変わりません。
抽象データ型は「コンテナ + 許される操作 + ルール」を、特定の内部構造にコミットせずに記述したものです。
例: Stack(後入れ先出し)。
push(item): アイテムを追加するpop(): 最後に追加したアイテムを取り出して返すpeek(): 取り出さずにトップのアイテムを見る重要なのは約束です: pop() は最新の push() を返す。内部が配列であれリンクリストであれ、これは公開された契約です。
同じ分離はあらゆるところに当てはまります:
POST /payments がインターフェースで、詐欺チェック、リトライ、DB書き込みが実装です。client.upload(file) がインターフェースで、チャンク分割、圧縮、並列リクエストが実装です。抽象化で設計すると、ユーザーが頼る契約に注力でき、裏側を自由に変えても壊さない自由を得られます。
インバリアントは抽象化の内部で常に真であるべきルールです。APIを設計しているなら、インバリアントはデータが不可能な状態に陥らないようにするガードレールです—例えば二通貨が混在した口座や、アイテムがないのに「完了」となった注文など。
インバリアントはタイプの「現実の形」です:
Cart に負の数量を含めてはいけない。UserEmail は常に有効なメールアドレスである(「後で検証」ではない)。Reservation は start < end で、両方とも同じタイムゾーンである。これらが成り立たなくなると、システムは予測不可能になります。というのも、各機能が「壊れた」データをどう扱うか推測しなければならなくなるからです。
良いAPIは境界でインバリアントを強制します:
これによりエラーハンドリングが自然に改善します。「何かが壊れた」という曖昧な失敗ではなく、APIがどのルールに違反したかを説明できるようになります(例: “end must be after start”)。
呼び出し側が「まず normalize() を呼ばないと動かない」など内部ルールを覚えなければならないのは避けるべきです。もしインバリアントが特別な手順に依存しているなら、それはインバリアントではなく“足を撃つ銃”です。
インターフェースは次のように設計しましょう:
APIの型を文書化するときは次を書き出してください:
良いAPIは単なる関数群ではなく、約束です。契約はその約束を明確にし、呼び出し側が振る舞いを信頼でき、保守者が内部を変えても誰も驚かないようにします。
最低限、次を文書化してください:
この明確さにより振る舞いは予測可能になります:呼び出し側はどの入力が安全か、どの結果を扱うべきかを知り、テストは意図をチェックできます。
契約がないとチームは記憶や暗黙の慣習に頼ります:「ここには null を渡すな」「その呼び出しは時々リトライする」「エラー時は空を返すことがある」など。これらはオンボーディングやリファクタで失われがちです。
文書化された契約はそれらを共有知識に変えます。コードレビューの対象も「この変更は契約を満たすか?」という議論に変わり、挙動の推測ではなく契約に基づく議論になります。
曖昧: “ユーザーを作成する。”
良い例: “一意な email でユーザーを作成する。\n\n- 事前条件: email は有効なアドレスであること。呼び出し側は users:create 権限を持つこと。\n- 事後条件: 新しい userId を返す。ユーザーは永続化され直ちに取得可能である。\n- 失敗モード: email が既に存在する場合は 409、無効フィールドは 400。部分的にユーザーが作られることはない。”
曖昧: “素早くアイテムを取得する。”
良い例: “limit 件までを createdAt 降順で返す。\n\n- 副作用: なし。\n- 一貫性: 最大で 60 秒古い可能性がある。\n- ページング: 次ページは nextCursor を使う。カーソルは 15 分で失効する。”
情報隠蔽はデータ抽象の実践面です:呼び出し側は APIが何をするか を頼りにし、どのようにするか を頼りにしないでください。内部が見えなければ、自由に変更しても破壊的なリリースになりません。
良いインターフェースは作成・取得・更新・一覧・検証のような操作の小さな集合を公開し、表現(テーブル、キャッシュ、キュー、ファイルレイアウト、サービス境界)は非公開にします。
例: 「カートにアイテムを追加する」は操作です。「CartRowId」は実装の詳細です。実装の詳細を公開すると、利用者がそれに基づくロジックを組んでしまい、変更が難しくなります。
クライアントが安定した振る舞いにだけ依存すると、あなたは自由に:
…それでもAPIは互換性を保ちます。これが本当の利得です:ユーザーには安定性、保守者には自由が与えられます。
内部が意図せず漏れる例:
status=3 のような数値を受け入れる)。意味を返す形を優先してください:
"userId": "usr_…")し、DB行番号を避ける。変更する可能性がある詳細は公開しないでください。もし利用者が本当に必要なら、それを意図的にインターフェースの一部として昇格させ、文書化します。
LSPを一言で言えば:「あるコードがインターフェースで動作するなら、そのインターフェースの有効な実装に差し替えても、特別なケースを要せず動作を保つべきだ」ということです。
LSPは継承の話だけではなく、信頼 の話です。インターフェースを公開するとき、あなたは振る舞いについて約束をしています。LSPはすべての実装がその約束を守るべきだと言っています。
呼び出し側はAPIの記述を信頼します—今日の偶然の振る舞いではありません。もしインターフェースが「有効なレコードなら save() に渡せる」と言うなら、すべての実装はその有効なレコードを受け入れなければなりません。インターフェースが「get() は値か明確な ‘not found’ を返す」と言うなら、実装はランダムに新しいエラーを投げたり部分的なデータを返したりできません。
安全な拡張は、新しい実装やプロバイダを追加してもユーザーがコードを書き換える必要を生じさせません。これがLSPの実際的な利点です:インターフェースを差し替え可能に保つこと。
APIが約束を破るよくある例は:
入力の狭窄(厳しい事前条件): 新実装がインターフェースで許容されていた入力を拒否する。例: インターフェースは UTF-8 全文字列を ID として受け入れるのに、新実装は数値のみ受け入れる。
出力の弱化(事後条件の緩み): 新実装が約束したより少ない情報を返す。例: インターフェースで結果はソート・一意・完全とされていたが、ある実装は未ソート・重複・抜け落ちがある。
微妙な違反は障害時の振る舞いを変えることです:ある実装は「not found」を返し別の実装は例外を投げると、呼び出し側は差し替えられなくなります。
“プラグイン”をサポートするには、インターフェースを契約のように書きます:
もし実装が本当に厳しいルールを必要とするなら、同じインターフェースに隠さず別のインターフェースを作るか、機能として明示(例: supportsNumericIds())してクライアントが自発的に選べるようにします。
良い設計のインターフェースは“使いやすそう”に感じられます。なぜなら、呼び出し側に必要なものだけを公開して、それ以上は公開しないからです。リスコフのデータ抽象の観点は、狭く安定して読みやすいインターフェースへ導きます。
大きなAPIは無関係な責務を混ぜがちです:設定、状態変更、集計、トラブルシューティングが一箇所にあると、安全に呼べる操作がわかりにくくなります。
凝集したインターフェースは同じ抽象に属する操作だけをグループ化します。キューなら enqueue/dequeue/peek/size にフォーカスする、という具合です。概念が少ないほど誤用の道は減ります。
「柔軟性」はしばしば「不明瞭さ」を生みます。options: any、mode: string、多数のブール値(force、skipCache、silent)は定義されていない組み合わせを作ります。
代替策:
publish() と publishDraft())。パラメータのためにソースを読まないと何が起きるか分からないなら、それは良い抽象の一部ではありません。
名前は契約を伝えます。観測可能な振る舞いを表す動詞を選んでください:reserve、release、validate、list、get。比喩や意味のあいまいな語は避けてください。同じように聞こえる2つのメソッドがあれば、呼び出し側は類似した振る舞いを期待します—だからそれを真にすべきです。
次に気づいたら分割を検討してください:
分割でコアの約束を保ちながら内部を進化させやすくなります。成長を見越すなら、スリムな“コア”パッケージとアドオンの構成を検討してください。詳しくは /blog/evolving-apis-without-breaking-users を参照してください。
APIはめったに停滞しません。新機能、エッジケース、そして「小さな改善」が実は実アプリケーションを壊すことがあります。目標はインターフェースを凍結することではなく、既存のユーザーが依存する約束を壊さずに進化させることです。
セマンティックバージョニングはコミュニケーションの手段です:
しかし判断力が必要です。ある「バグ修正」が呼び出し側が依存していた挙動を変えるなら、実際には破壊的変更です—たとえ元の挙動が偶発的だったとしても。
多くの破壊的変更はコンパイラでは検出されません:
事前条件と事後条件の観点で考えることが重要です:呼び出し側が提供すべきもの、戻り値として期待できるもの。
非推奨は明示的で期限付きであると有効です:
リスコフ流のデータ抽象は、ユーザーが依存できるものを狭めるので進化を楽にします。呼び出し側がインターフェース契約にしか依存しなければ、ストレージ形式やアルゴリズム、最適化を自由に変えられます。
実務では強力なツールも助けになります。例えば、内部APIを素早く反復しながら React フロントや Go + PostgreSQL バックエンドを作るとき、vibe-coding のようなワークフロー(記事では Koder.ai を例示)は実装を加速します。ただし基本は変わりません:明確な契約、安定した識別子、後方互換的な進化が必要です。スピードは乗数なので、正しいインターフェース習慣に乗数をかける価値があります。
信頼できるAPIは「決して失敗しない」ものではなく、呼び出し側が理解し、処理し、テストできる方法で失敗するものです。エラーハンドリングは抽象化の一部であり、「正しい使い方」と「世界(ネットワーク、ディスク、権限、時間)が異なるときどうなるか」を定義します。
まず2つのカテゴリを分けます:
この区分でインターフェースは正直になります:呼び出し側がコードで直せるものとランタイムで対処すべきものが分かります。
契約は失敗の扱いを暗示します:
Ok | Error)は失敗が予期され、呼び出し側に明示的に扱わせたい場合に有効。選択する仕組みは何でも構いませんが、API全体で一貫させて、利用者が推測しないようにしてください。
各操作について発生し得る失敗を意味で列挙してください:"バージョンが古くて衝突した"、"見つからない"、"権限がない"、"レート制限"。安定したエラーコードと構造化フィールドを提供し、テストが文字列に依存しなくて済むようにします。
操作がリトライして安全か、どんな条件で安全か、冪等性をどう達成するか(冪等キー、自然なリクエストID)を文書化します。部分成功が起こりうる(バッチ処理)場合、成功と失敗の報告方法、タイムアウト後に呼び出し側が想定すべき状態を定義してください。
抽象は約束です:「有効な入力でこれらの操作を呼べば、これらの結果が得られ、これらのルールが常に成り立つ」。テストはコード変更に対してその約束を守らせる手段です。
契約を自動化可能なチェックに翻訳して始めてください。
ユニットテストは各操作の事後条件とエッジケースを検証します:返り値、状態変化、エラー挙動。インターフェースが「存在しないアイテムの削除は false を返し何も変えない」と言うなら、そのテストを書きます。
結合テストは実際の境界(DB、ネットワーク、シリアライズ、認可)をまたいで契約を検証します。多くの契約違反は型のエンコード/デコードやリトライ/タイムアウトでのみ現れます。
不変条件は任意の有効な操作列に対して成立しなければなりません(例: "残高は負にならない"、"IDは一意である"、"list() で返したアイテムは get(id) で取得できる")。
プロパティベーステストは大量のランダムだが有効な入力と操作列を生成して反例を探します。人がテストに書かないような奇妙なコーナーケースを見つけるのに有効です。
公開または共有APIでは、消費者が実際に行うリクエストと依存するレスポンスの例を公開させます。プロバイダはCIでこれらの契約を実行し、実際の利用を壊さないことを確認します—プロバイダ側が想定していなかった利用でも検出できます。
すべてをテストで網羅することはできないので、本番で契約が変わっていないかを示すシグナルを監視してください:レスポンス形状の変化、4xx/5xx の増加、新しいエラーコード、レイテンシの急増、未知フィールドやデシリアライズ失敗。エンドポイントとバージョンごとに追跡すると早期にドリフトを検出できます。
スナップショットやロールバックを配信パイプラインでサポートしているなら、これらはこの考え方と相性が良いです:早期にドリフトを検出し、クライアントに適応を強いることなくロールバックできます。(例えば Koder.ai はワークフローの一部としてスナップショットとロールバックを含めており、"契約を最初に、変更はその後" のアプローチと整合します。)
抽象を重視するチームでも、目先の“実用的”なやり方に流されて徐々にAPIが特例の寄せ集めになることがあります。繰り返し出る罠とその対策をいくつか紹介します。
フィーチャーフラグはローアウトに有用ですが、?useNewPricing=true、mode=legacy、v2=true のようにフラグが公開・長期化すると、利用者はそれらを組み合わせて予期しない方法で使い、結果的に複数の振る舞いを永続的にサポートする羽目になります。
安全なアプローチ:
テーブルIDやジョインキー、"SQL 風" フィルタ(例: where=...)を公開すると、クライアントは保存モデルを学ばされ、リファクタが難しくなります。
代わりにドメイン概念と安定した識別子でモデリングしてください。クライアントには「どう格納されているか」ではなく「何を求めているか」を聞かせるべきです(例: "ある顧客の指定期間の注文")。
フィールドを追加するのは一見無害ですが、繰り返すと責務が曖昧になり、不変条件が弱まり、クライアントが偶発的な詳細に依存するようになります。
長期コストを避けるには:
過度な抽象化は本当のニーズを阻むことがあります—例: "このカーソル後から始める" を表現できないページング、"完全一致" を指定できない検索。クライアントは回避策を取って複数回呼び出したりローカルでフィルタしたりし、結果的に性能やエラーが悪化します。
対策は制御された柔軟性を用意することです:サポートするフィルタ演算子の小さな集合のような明確な拡張点を提供し、オープンエンドな逃げ道を避けます。
単純化は能力を奪うことを意味しません。混乱するオプションを非推奨にしても、基礎の能力はより明確な形で残します:重複する複数のパラメータを1つの構造化リクエストオブジェクトに置き換える、あるいは1つの"何でもやる"エンドポイントを2つの凝集したエンドポイントに分ける。その後、バージョン付きドキュメントと明確な非推奨タイムラインで移行を導きます(/blog/evolving-apis-without-breaking-users を参照)。
リスコフのデータ抽象の考えを適用するためのシンプルで繰り返せるチェックリストです。目標は完璧さではなく、APIの約束を明示し、テスト可能にし、進化させやすくすることです。
短い一貫したブロックを使います:
transfer(from, to, amount)amount > 0 と口座が存在することInsufficientFunds, AccountNotFound, Timeout深掘りするなら 抽象データ型(ADT), 契約による設計(Design by Contract), リスコフの置換原則(LSP) を調べてください。
チームが内部ノートを保っているなら /docs/api-guidelines のようなページにリンクしてレビューワークフローを再利用しやすくしてください。そして、手で作るにせよチャット駆動のビルダー(Koder.ai のような)で高速にサービスを作るにせよ、これらのガイドラインは「速く出す」ための非妥協の一部として扱ってください。信頼できるインターフェースは速度が複利的に効くための基盤です。
彼女はデータ抽象と情報隠蔽を広め、これらは現代のAPI設計に直接結びつきます:小さく安定した契約を公開し、実装は柔軟に保つ。実利としては、破壊的変更が減り、安全なリファクタリングが可能になり、統合が予測しやすくなります。
呼び出し側が時間を超えて依存できるAPIのことです:
信頼性は「決して失敗しない」ことではなく、むしろ予測可能に失敗することと契約を守ることにあります。
振る舞いを契約として書き出します:
空結果・重複・順序などエッジケースも含めて明記すると、呼び出し側は契約に基づいて実装・テストできます。
不変条件とは、抽象化の内部で常に成立しているべきルールです(例:「数量は負にならない」)。不変条件は境界で強制します:
normalize() のような儀式を呼び出し側に要求しない。こうすることでシステムの下流で「起こり得ない状態」を扱う必要が減ります。
情報隠蔽は操作や意味を公開し、内部表現を公開しないことです。将来変更する可能性のあるもの(テーブル、キャッシュ、シャードキー、内部ステータス)に消費者を結びつけないでください。
実践的な方法:
usr_...)して、データベースの行IDは出さない。status=3 を避ける)。それは実装を固定してしまうからです。クライアントがテーブルIDやジョインキー、SQLライクなフィルタに依存すると、スキーマを変えるたびにAPI互換性が壊れます。
「保存方法」ではなくドメインの問いをモデル化してください(例: 「ある顧客の指定期間の注文」)。契約の背後で実装は隠すべきです。
LSPは「あるコードがインターフェースで動くなら、そのインターフェースのどの実装に入れ替えても動き続けるべきだ」という原則です。APIでは「呼び出し側を驚かせない」ことを意味します。
代替可能な実装をサポートするには:
注意する違反例:
実装に追加の制約が必要なら、別インターフェースを定義するか、supportsNumericIds() のような明示的機能として提示して、クライアントが能動的に選べるようにします。
インターフェースは小さく一貫性があり読みやすいべきです:
options: any や多数のブール値を避け、曖昧さを減らす。reserve、release、list、validate のように観測可能な振る舞いを表す名前を使う。エラー処理を契約の一部として設計します:
重要なのは一貫性で、呼び出し側が予測して処理できることです。
役割や変更頻度が異なる部分があればモジュールを分けます(進化に関しては /blog/evolving-apis-without-breaking-users を参照)。