APIに破壊的変更を加えたいけれど、既存クライアントを壊したくない——こういう場面、開発が続くプロジェクトでは必ずといっていいほど出てきますよね。

REST APIのバージョニングには主に3つの方式があります。URIパス、カスタムリクエストヘッダー、そしてAcceptヘッダーを使うコンテントネゴシエーション。どれも「正解」ではなく、それぞれにトレードオフがあります。この記事ではSpring Bootでの実装コードを交えながら、各方式の特性と選び方を整理していきます。

なお、基本的なCRUD実装についてはSpring Boot REST APIのCRUDチュートリアルを、例外ハンドリングについてはSpring Boot REST APIの例外処理もあわせてご覧ください。

方式1:URIパスバージョニング

最もよく見かける方式です。/api/v1/users/api/v2/users のようにパスにバージョン番号を埋め込みます。

@RestController
@RequestMapping("/api/v1/users")
public class UserV1Controller {

    @GetMapping("/{id}")
    public UserV1Response getUser(@PathVariable Long id) {
        // v1のレスポンス形式
        return new UserV1Response(id, "山田太郎");
    }
}

@RestController
@RequestMapping("/api/v2/users")
public class UserV2Controller {

    @GetMapping("/{id}")
    public UserV2Response getUser(@PathVariable Long id) {
        // v2ではfullNameをfirstName/lastNameに分割
        return new UserV2Response(id, "山田", "太郎");
    }
}

長所 はシンプルさです。ブラウザのアドレスバーに直接打ち込めるし、curl でのテストも楽です。プロキシやCDNのキャッシュも効きやすく、ログでバージョンがひと目でわかります。

短所 はURIにバージョンが混入することです。REST原則では「同一リソースは同一URI」が理想とされており、/users/1/api/v2/users/1 が同じリソースを指すのは厳密には違和感があります。とはいえ実務では許容されているケースがほとんどです。

方式2:カスタムリクエストヘッダーによるバージョニング

X-API-Version: 2 のようなカスタムヘッダーでバージョンを指定する方式です。@RequestMappingheaders 属性で振り分けられます。

@RestController
@RequestMapping("/api/users")
public class UserController {

    @GetMapping(value = "/{id}", headers = "X-API-Version=1")
    public UserV1Response getUserV1(@PathVariable Long id) {
        return new UserV1Response(id, "山田太郎");
    }

    @GetMapping(value = "/{id}", headers = "X-API-Version=2")
    public UserV2Response getUserV2(@PathVariable Long id) {
        return new UserV2Response(id, "山田", "太郎");
    }
}

URIが /api/users/{id} のまま変わらないのが 長所 です。サービス間通信や社内APIのように、クライアントがヘッダーを自由に制御できる環境では扱いやすいです。

一方、ブラウザから直接リクエストを送るのが難しく、テストにはcurlやPostmanが必要になります。カスタムヘッダーを使う場合はCORSのプリフライトリクエスト(OPTIONS)で Access-Control-Allow-Headers に忘れず追加しましょう。CORSの設定方法も参考にしてください。

方式3:Acceptヘッダー/コンテントネゴシエーション

HTTPの仕様に最も忠実な方式です。Accept: application/vnd.myapp.v2+json のようなベンダーメディアタイプでバージョンを表現します。

@RestController
@RequestMapping("/api/users")
public class UserController {

    @GetMapping(
        value = "/{id}",
        produces = "application/vnd.myapp.v1+json"
    )
    public UserV1Response getUserV1(@PathVariable Long id) {
        return new UserV1Response(id, "山田太郎");
    }

    @GetMapping(
        value = "/{id}",
        produces = "application/vnd.myapp.v2+json"
    )
    public UserV2Response getUserV2(@PathVariable Long id) {
        return new UserV2Response(id, "山田", "太郎");
    }
}

HTTPの設計思想に沿っており、メディアタイプとバージョンを同時に宣言できるのが 長所 です。ただし、クライアント側でAcceptヘッダーを正確に指定しなければならないため、実装コストは高めです。Swagger UIでの表示も工夫が必要になります。

3方式の比較

観点URIパスカスタムヘッダーAcceptヘッダー
可読性
キャッシュ
クライアントコスト
REST原則準拠
ブラウザテスト
Swagger UI対応

どの方式を選ぶか

迷ったときの判断軸を整理するとこんな感じです。

  • パブリックAPIやブラウザからのアクセスがある場合 → URIパスが安全。可読性とテストのしやすさが最優先になります。
  • 社内APIや機械間通信がメインの場合 → カスタムヘッダーも選択肢に入ります。URIをシンプルに保ちながら、ヘッダーでバージョンを制御できます。
  • HTTPの仕様に厳密に従いたい場合 → Acceptヘッダー方式が理想ですが、クライアント側の実装負担を十分考慮してください。

正直なところ、多くのプロジェクトでは URIパス方式が現実的な第一選択 です。デメリットより運用のシンプルさが勝ることが多いです。

SpringDoc OpenAPIで複数バージョンをSwagger UIに表示する

SpringDoc OpenAPIの導入手順で基本設定が済んでいる場合、GroupedOpenApi を使うとv1とv2を別グループとして表示できます。

@Configuration
public class OpenApiConfig {

    @Bean
    public GroupedOpenApi v1Api() {
        return GroupedOpenApi.builder()
            .group("v1")
            .pathsToMatch("/api/v1/**")
            .build();
    }

    @Bean
    public GroupedOpenApi v2Api() {
        return GroupedOpenApi.builder()
            .group("v2")
            .pathsToMatch("/api/v2/**")
            .build();
    }
}

これでSwagger UIのドロップダウンから v1v2 を切り替えて確認できるようになります。カスタムヘッダー方式やAcceptヘッダー方式の場合は、addOperationCustomizer を使ってドキュメントに追記する方法もありますが、URIパス方式が最もシンプルに統合できます。

非推奨バージョンのDeprecation通知

v1を廃止予定にする場合、クライアントにレスポンスヘッダーで通知するのが親切です。RFC 8594で定義された Sunset ヘッダーが使えます。

@Component
public class DeprecationFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain chain) throws ServletException, IOException {

        chain.doFilter(request, response);

        if (request.getRequestURI().startsWith("/api/v1/")) {
            response.setHeader("Deprecation", "true");
            response.setHeader("Sunset", "Sat, 31 Dec 2026 23:59:59 GMT");
            response.setHeader("Link", "</api/v2/>; rel=\"successor-version\"");
        }
    }
}

これでv1へのリクエストに廃止予定日が自動的に付与されます。クライアントチームに余裕を持って移行を促せるので、廃止の半年〜1年前には入れておきたいですね。

実装時の落とし穴

AmbiguousMappingが発生するケース

複数のControllerが同一パスにマッピングされていると起動時に例外が出ます。URIパス方式でも、@RequestMapping のパス定義が重複しないよう注意してください。

CORSとカスタムヘッダー

カスタムヘッダー方式を採用した場合、CORSの allowedHeadersX-API-Version を追加しないとプリフライトが失敗します。

@Bean
public CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration config = new CorsConfiguration();
    config.setAllowedHeaders(List.of("Content-Type", "X-API-Version"));
    // ...
}

まとめ

バージョニング方式に「これが正解」はありません。URIパスはシンプルで広く通用し、カスタムヘッダーはURIをきれいに保ちたい内部APIに向いていて、Acceptヘッダーは仕様への忠実さを重視するときに選択肢になります。

まずはチームのクライアント種別と運用方針を確認してから方式を決めると、後から「やっぱり変えたい」となりにくいです。迷ったらURIパスから始めて、必要に応じてDeprecation通知を整備していくのが現実的なアプローチだと思います。