api-design について
ライブラリの開発者やレビュー担当者向けに、公開 API とワイヤ形式の設計・変更を扱うスキルです。互換性を保つ拡張型の変更、検証、バージョン管理の指針を示します。
主な機能
- 01ソース・バイナリ・ワイヤ互換性
- 02拡張型 API の進化
- 03命名と引数の規約
- 04エラーと戻り値の指針
- 05非推奨化とバージョン管理
- 06API 承認テストの指針
活用シーン
- 01公開ライブラリ API の設計
- 02プルリクエストの破壊的変更レビュー
- 03分散システムのワイヤ形式更新計画
- 04非推奨化とリリース方針の選択
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ wshaddix/ dotnet-skills --skill api-designインストール後、Claude Code で/api-designを入力して読み込まれたか確認します。
詳細
パブリック API の設計と互換性
このスキルを使用する場面
次のような場合にこのスキルを使用します。
- NuGet パッケージやライブラリのパブリック API を設計するとき
- 既存のパブリック API に変更を加えるとき
- 分散システムのワイヤ形式の変更を計画するとき
- バージョン管理戦略を実装するとき
- 破壊的変更がないかプルリクエストをレビューするとき
3 種類の互換性
| 種類 | 定義 | 範囲 |
|---|---|---|
| API/ソース | 新しいバージョンに対してコードをコンパイルできる | パブリックメソッドのシグネチャ、型 |
| バイナリ | コンパイル済みコードが新しいバージョンで実行できる | アセンブリのレイアウト、メソッドトークン |
| ワイヤ | シリアル化されたデータを他のバージョンで読み取れる | ネットワークプロトコル、永続化形式 |
これらのいずれかを破ると、ユーザーのアップグレードに支障が生じます。
拡張のみの設計
安定した API の基盤は、削除も変更もせず、拡張のみを行うことです。
3 つの柱
- 以前の機能は不変です - リリース後は、動作とシグネチャを変更しません
- 新しい構成要素で機能を追加します - オーバーロード、新しい型、オプトイン機能を追加します
- 非推奨期間を経てから削除します - リリース単位ではなく、年単位で設けます
メリット
- 古いコードは新しいバージョンでも引き続き動作します
- 新旧の方法が共存します
- 既定では、破壊的変更なしにアップグレードできます
- ユーザーは自分の都合に合わせてアップグレードできます
API サーフェスの命名規則
型の命名
| 型の種類 | 接尾辞のパターン | 例 |
|---|---|---|
| 基底クラス | 抽象基底型に限り Base を付ける | ValidatorBase |
| インターフェイス | I を先頭に付ける | IWidgetFactory |
| 例外 | Exception を末尾に付ける | WidgetNotFoundException |
| 属性 | Attribute を末尾に付ける | RequiredPermissionAttribute |
| イベント引数 | EventArgs を末尾に付ける | WidgetCreatedEventArgs |
| オプション/構成 | Options を末尾に付ける | WidgetServiceOptions |
| ビルダー | Builder を末尾に付ける | WidgetBuilder |
メソッドの命名
| パターン | 規則 | 例 |
|---|---|---|
| 同期 | 動詞または動詞句 | Calculate()、GetWidget() |
| 非同期 | Async を末尾に付ける | CalculateAsync()、GetWidgetAsync() |
| ブール値の問い合わせ | Is/Has/Can を先頭に付ける | IsValid()、HasPermission() |
| Try パターン | Try を先頭に付け、out パラメーターを使用する | TryGetWidget(int id, out Widget widget) |
| ファクトリー | Create を先頭に付ける | CreateWidget()、CreateWidgetAsync() |
| 変換 | To/From を先頭に付ける | ToDto()、FromEntity() |
パブリック API では略語を避ける
// WRONG -- abbreviations in public surface
public IReadOnlyList<TxnResult> GetRecentTxns(int cnt);
// CORRECT -- spelled out for clarity
public IReadOnlyList<TransactionResult> GetRecentTransactions(int count);
パラメーターの順序
パラメーターの順序を統一すると、認知負荷を軽減できます。
標準的な順序
- 対象/主題 -- 操作の対象となる主要なエンティティ
- 必須パラメーター -- 既定値のない必須の入力
- オプションのパラメーター -- 適切な既定値を持つ入力
- キャンセルトークン -- 常に最後に配置します(CA1068 によって規則が適用されます)
public Task<Widget> GetWidgetAsync(
int widgetId, // 1. Target
WidgetOptions options, // 2. Required
bool includeHistory = false, // 3. Optional
CancellationToken cancellationToken = default); // 4. Always last
オーバーロードの展開順序
// Simple -- sensible defaults
public Task<Widget> GetWidgetAsync(int widgetId,
CancellationToken cancellationToken = default)
=> GetWidgetAsync(widgetId, WidgetOptions.Default, cancellationToken);
// Detailed -- full control
public Task<Widget> GetWidgetAsync(int widgetId,
WidgetOptions options,
CancellationToken cancellationToken = default);
戻り値の型の選択
戻り値の選び方
| シナリオ | 戻り値の型 | 理由 |
|---|---|---|
| 単一のエンティティが常に存在する | Widget | 見つからない場合は例外をスローします |
| 単一のエンティティが存在しない可能性がある | Widget? | Nullable によって省略可能であることを示します |
| 空の場合もあるコレクション | IReadOnlyList<Widget> | 不変でインデックスアクセスが可能であり、変更不可であることを示します |
| ストリーミング結果 | IAsyncEnumerable<Widget> | 結果セット全体のバッファリングを回避します |
| 詳細を伴う操作結果 | Result<Widget>/判別可能なユニオン | 例外を使わず、詳細なエラー情報を提供します |
| 非同期処理で値を返さない | Task | イベントハンドラーを除き、async void は使用しません |
| 同期的に完了することが多い | ValueTask<Widget> | キャッシュヒット時の Task の割り当てを回避します |
IEnumerable より IReadOnlyList を優先する
// WRONG -- caller does not know if result is materialized or lazy
public IEnumerable<Widget> GetWidgets();
// CORRECT -- signals materialized, indexable collection
public IReadOnlyList<Widget> GetWidgets();
// CORRECT -- signals streaming/lazy evaluation explicitly
public IAsyncEnumerable<Widget> GetWidgetsStreamAsync(
CancellationToken cancellationToken = default);
Try パターン
public bool TryGetWidget(int widgetId, [NotNullWhen(true)] out Widget? widget);
public Task<Widget?> TryGetWidgetAsync(int widgetId,
CancellationToken cancellationToken = default);
エラー報告の戦略
例外の階層
public class WidgetServiceException : Exception
{
public WidgetServiceException(string message) : base(message) { }
public WidgetServiceException(string message, Exception inner) : base(message, inner) { }
}
public class WidgetNotFoundException : WidgetServiceException
{
public int WidgetId { get; }
public WidgetNotFoundException(int widgetId)
: base($"Widget {widgetId} not found.") => WidgetId = widgetId;
}
public class WidgetValidationException : WidgetServiceException
{
public IReadOnlyList<string> Errors { get; }
public WidgetValidationException(IReadOnlyList<string> errors)
: base("Widget validation failed.") => Errors = errors;
}
例外と戻り値を使い分ける場面
| アプローチ | 使用する場面 |
|---|---|
| 例外をスローする | 想定外の失敗、プログラミングエラー、インフラストラクチャの障害 |
null / default を返す | 「見つからない」が通常かつ想定される結果である場合 |
Try パターン(bool + out) | 失敗がよく発生し、同期処理である解析や検証 |
| Result オブジェクト | 呼び出し側が区別する必要のある複数の失敗ケース |
引数の検証
public Widget CreateWidget(string name, decimal price)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(price);
return new Widget(name, price);
}
API 変更のガイドライン
安全な変更(すべてのリリースで可能)
// ADD new overloads with default parameters
public void Process(Order order, CancellationToken ct = default);
// ADD new optional parameters to existing methods
public void Send(Message msg, Priority priority = Priority.Normal);
// ADD new types, interfaces, enums
public interface IOrderValidator { }
public enum OrderStatus { Pending, Complete, Cancelled }
// ADD new members to existing types
public class Order
{
public DateTimeOffset? ShippedAt { get; init; } // NEW
}
安全でない変更(原則禁止、またはメジャーバージョンのみ)
// REMOVE or RENAME public members
public void ProcessOrder(Order order); // Was: Process()
// CHANGE parameter types or order
public void Process(int orderId); // Was: Process(Order order)
// CHANGE return types
public Order? GetOrder(string id); // Was: public Order GetOrder()
// CHANGE access modifiers
internal class OrderProcessor { } // Was: public
// ADD required parameters without defaults
public void Process(Order order, ILogger logger); // Breaks callers!
非推奨化のパターン
// Step 1: Mark as obsolete with version
[Obsolete("Obsolete since v1.5.0. Use ProcessAsync instead.")]
public void Process(Order order) { }
// Step 2: Add new recommended API
public Task ProcessAsync(Order order, CancellationToken ct = default);
// Step 3: Remove in next major version
拡張ポイント
インターフェイスベースの拡張
// GOOD -- interface-based extension point
public interface IWidgetValidator
{
ValueTask<bool> ValidateAsync(Widget widget, CancellationToken ct = default);
}
// GOOD -- delegate-based extension for simple hooks
public class WidgetServiceOptions
{
public Func<Widget, CancellationToken, ValueTask>? OnWidgetCreated { get; set; }
}
拡張メソッドのガイドライン
| ガイドライン | 理由 |
|---|---|
| 型と同じ名前空間に拡張メソッドを配置する | 追加の using ステートメントなしで見つけやすくなります |
拡張メソッドを System または System.Linq に配置しない | 名前空間の汚染を防ぎます |
| 型を所有している場合は、拡張メソッドよりインスタンスメソッドを優先する | 拡張メソッドは最後の手段です |
this パラメーターには、使用可能な中で最も具体的な型を指定する | IntelliSense の汚染を防ぎます |
ワイヤー互換性
分散システムでは、シリアル化されたデータをバージョン間で読み取れる必要があります。
要件
| 方向 | 要件 |
|---|---|
| 後方互換性 | 古い書き込み側 → 新しい読み取り側 |
| 前方互換性 | 新しい書き込み側 → 古い読み取り側 |
ダウンタイムなしのローリングアップグレードには、両方が必要です。
ワイヤーフォーマットを安全に進化させる
フェーズ 1:読み取り側のサポートを追加する
public sealed record HeartbeatV2(
Address From,
long SequenceNr,
long CreationTimeMs); // NEW field
public object Deserialize(byte[] data, string manifest) => manifest switch
{
"Heartbeat" => DeserializeHeartbeatV1(data),
"HeartbeatV2" => DeserializeHeartbeatV2(data),
_ => throw new NotSupportedException()
};
フェーズ 2:書き込み側を有効にする(次のマイナーバージョン)
akka.cluster.use-heartbeat-v2 = on
防御的なシリアル化設計
public sealed class WidgetDto
{
[JsonPropertyName("id")]
public int Id { get; init; }
[JsonPropertyName("name")]
public required string Name { get; init; }
[JsonPropertyName("category")]
public string? Category { get; init; }
[JsonPropertyName("priority")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public int Priority { get; init; }
}
Enum のシリアル化戦略
// GOOD -- string serialization is rename-safe
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum WidgetStatus
{
Draft,
Active,
Archived
}
// RISKY -- integer serialization breaks when members are reordered
public enum WidgetPriority
{
Low = 0,
Medium = 1,
High = 2
}
API 承認テスト
自動化された API サーフェステストで、意図しない破壊的変更を防ぎます。
[Fact]
public Task ApprovePublicApi()
{
var api = typeof(MyLibrary.PublicClass).Assembly.GeneratePublicApi();
return Verify(api);
}
PR レビュープロセス
- PR に
*.verified.txtファイルの変更を含める - レビュアーは差分で API サーフェスの変更を正確に確認できる
- 破壊的変更がすぐにわかる
- 承認には意識的な判断が必要
バージョン管理戦略
セマンティックバージョニング(実践的な運用)
| バージョン | 許容される変更 |
|---|---|
| パッチ(1.0.x) | バグ修正、セキュリティパッチ |
| マイナー(1.x.0) | 新機能、非推奨化、obsolete の削除 |
| メジャー(x.0.0) | 破壊的変更、古い API の削除 |
主な原則
- 予告なしの破壊的変更を避ける - メジャーバージョンであっても告知が必要です
- 拡張はいつでも可能 - 新しい API はどのリリースでも提供できます
- 削除前に非推奨化する - 少なくとも 1 つのマイナーバージョンで
[Obsolete]を使用します - スケジュールを伝える - ユーザーがアップグレードを計画できるようにします
プルリクエストのチェックリスト
- 公開メンバーを削除していない(代わりに
[Obsolete]を使用する) - シグネチャを変更していない(代わりにオーバーロードを追加する)
- 新しい必須パラメーターを追加していない(デフォルト値を使用する)
- API 承認テストを更新した(
.verified.txtの変更をレビュー済み) - ワイヤーフォーマットの変更はオプトイン方式である(読み取り側を先に対応する)
- 破壊的変更を文書化した(リリースノート、移行ガイド)
アンチパターン
修正を装った破壊的変更
// "Bug fix" that breaks users
public async Task<Order> GetOrderAsync(OrderId id) // Was sync!
{
}
// Correct: Add new method, deprecate old
[Obsolete("Use GetOrderAsync instead")]
public Order GetOrder(OrderId id) => GetOrderAsync(id).Result;
public async Task<Order> GetOrderAsync(OrderId id) { }
サイレントな動作変更
// Changing defaults breaks users
public void Configure(bool enableCaching = true) // Was: false!
// Correct: New parameter with new name
public void Configure(
bool enableCaching = false,
bool enableNewCaching = true)
ポリモーフィックなシリアル化
// AVOID: Type names in wire format
{ "$type": "MyApp.Order, MyApp", "Id": 123 }
// PREFER: Explicit discriminators
{ "type": "order", "id": 123 }
エージェントが注意すべき点
- 公開 API 名に略語を使用しないでください -- 単語を省略せずに記述します。
- CancellationToken を省略可能なパラメーターより前に配置しないでください -- CA1068 により最後に配置する必要があります。
- 変更可能なコレクションを公開 API から返さないでください --
IReadOnlyList<T>を返します。 [JsonPropertyName]属性を付けずに、シリアル化されたプロパティ名を変更しないでください。- 既存の公開メソッドに必須パラメーターを追加しないでください -- オーバーロードを追加するか、デフォルト値を使用します。
- API サーフェスで
async voidを使用しないでください --TaskまたはValueTaskを返します。 - 基底となるライブラリ例外なしに例外階層を設計しないでください。
- 拡張メソッドを
System名前空間に配置しないでください。
リソース
コメント
Markdown 対応