ソフトウェアエンジニアが知っておくべきAPI概念:詳細解説

この記事には短い動画があります:TikTok

1. 概要

APIを呼び出すのは簡単だ――リクエストを送り、JSONを読み、終わり。しかし、他のチームや、6ヶ月後のあなた自身が、コードを読まなくても使い続けられるAPIを設計するのは、話が全然違う。この記事では、どのバックエンドエンジニアも知っておくべきAPI設計の核となる概念をまとめた。HTTPの基礎から、各種アーキテクチャパターン(REST、GraphQL、gRPC、webhook、WebSocket)、さらには堅牢性、セキュリティ、信頼性、ドキュメント化まで。読み終わったあなたは、APIを外に公開する前に、自分のAPIを見直すための具体的なチェックリストを手に入れるだろう。

2. HTTP基礎:メソッド、ステータスコード、リクエスト/レスポンス

RESTやGraphQLの話に入る前に、基本的なHTTPセマンティクスを正しく使う必要がある。多くのAPIが「一見RESTに見えるが、実は完全に間違っている」というのは、ここが原因だ。

@RestController
@RequestMapping("/api/orders")
class OrderController {

    private final OrderService orderService;

    OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @GetMapping("/{id}")
    ResponseEntity<Order> getOrder(@PathVariable Long id) {
        return orderService.findById(id)
                .map(ResponseEntity::ok)
                .orElseGet(() -> ResponseEntity.notFound().build());
    }

    @PostMapping
    ResponseEntity<Order> createOrder(@RequestBody CreateOrderRequest request) {
        Order created = orderService.create(request);
        return ResponseEntity.created(URI.create("/api/orders/" + created.id())).body(created);
    }

    @PutMapping("/{id}")
    ResponseEntity<Order> replaceOrder(@PathVariable Long id, @RequestBody Order order) {
        return ResponseEntity.ok(orderService.replace(id, order));
    }

    @DeleteMapping("/{id}")
    ResponseEntity<Void> cancelOrder(@PathVariable Long id) {
        orderService.cancel(id);
        return ResponseEntity.noContent().build();
    }
}

よくある誤りをいくつか挙げる:

  • GETは安全性(状態変化なし)とべき等性を持つ必要がある。GETエンドポイントがカウンターを増やしたり、重要なログを書き込んだりするなら、クライアント(とプロキシ/CDNキャッシュ)との暗黙の契約に違反している。
  • PUTはリソース全体を置換し、PATCHは変更部分だけを更新する。1つのフィールド更新にPUTを使うのはセマンティクスとして間違っているが、コードは動いてしまう。
  • ステータスコードは正確な意味を反映すべき:201 Createdは新規作成時(Locationヘッダ付き)、204 No Contentは削除成功時でボディなし、409 Conflictは状態矛盾時――エラーだからといって単に400や500ではない。

注意: 全てのレスポンスに200 OKを返し、ボディに"success": falseフィールドを入れるのは、よくあるアンチパターンだ。クライアント、ロードバランサー、APMツール、いずれもステータスコードに基づいてリトライかアラートを判断する――エラーをボディに隠すと、この全体的なチェーンが壊れてしまう。

3. APIスタイルの選択:REST、GraphQL、gRPC、webhook、WebSocket

各アーキテクチャパターンは異なる問題を解決する。間違った選択は技術的な過ちではなく、ユースケースの誤解だ。

3.1. REST — HTTP経由のCRUDにおけるデフォルト

リソースが明確(Order、Customer、Invoice)で、クライアントが多様な場合(Web、モバイル、サードパーティ)に適切だ。セクション2の例がまさに標準的なRESTだ。

3.2. GraphQL — クライアントがフィールド選択の柔軟性を必要とする場合

type Order {
  id: ID!
  customer: Customer!
  total: Float!
  items: [OrderItem!]!
}

type Query {
  order(id: ID!): Order
}
@Controller
class OrderGraphQlController {

    private final OrderService orderService;

    OrderGraphQlController(OrderService orderService) {
        this.orderService = orderService;
    }

    @QueryMapping
    Order order(@Argument Long id) {
        return orderService.findById(id).orElse(null);
    }
}

クライアントが必要なフィールドを選択できる(3つのRESTエンドポイントを呼ぶ代わりに{ order(id: 1) { total } })。代わりに、URLベースのHTTPキャッシュが失われ、N+1クエリに自分で対処する必要がある(DataLoaderを使用)。GraphQLは複雑なUI、異なるデータ要件を持つ多数のクライアントがあるアプリに向いている――シンプルなAPI、少数のクライアント向きではない。

3.3. gRPC — 内部サービス間で高パフォーマンスが必要な場合

service PaymentService {
  rpc Charge (ChargeRequest) returns (ChargeResponse);
}

message ChargeRequest {
  string order_id = 1;
  int64 amount_cents = 2;
}

gRPCはHTTP/2 + Protobuf(バイナリ、JSONより小さい)を使用し、双方向ストリーミングをサポートする。マイクロサービス内のサービス間通信に非常に適しているが、手動デバッグが困難(通常のcurlでは読めない)で、ブラウザ向けの公開APIには向かない。

3.4. Webhook — 受信側が能動的な通知を必要とする場合

@PostMapping("/webhooks/payment-status")
ResponseEntity<Void> handlePaymentWebhook(@RequestBody PaymentStatusEvent event,
                                           @RequestHeader("X-Signature") String signature) {
    if (!signatureValidator.isValid(event, signature)) {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
    }
    paymentEventHandler.handle(event);
    return ResponseEntity.ok().build();
}

Webhookは呼び出し方向を逆にする:PaymentServiceのサーバーがイベント発生時にあなたのサーバーにコールバックする。署名を常に検証する(HMAC、偽造リクエスト防止)、常に200を素早く返して非同期で処理する――送信側はタイムアウトまたはエラー時に通常リトライする。

3.5. WebSocket — リアルタイム双方向通信が必要な場合

チャット、ライブトラッキング、ダッシュボードのリアルタイム更新に使用する。単純なリクエスト-レスポンスには不向き、接続を維持するオーバーヘッドがREST比で大きい。

4. 堅牢設計:命名、ページネーション、バージョニング、エラーレスポンス

4.1. 命名の一貫性

コレクションには複数形の名詞を使う(/orders、/orderや/getOrdersではなく)、階層的な名詞は関係を表現する(/customers/{id}/orders)。同じAPI内でcamelCaseとsnake_caseを混在させるのを避ける。

4.2. ページネーション

@GetMapping
Page<Order> listOrders(@RequestParam(defaultValue = "0") int page,
                        @RequestParam(defaultValue = "20") int size) {
    return orderService.findAll(PageRequest.of(page, size));
}
GET /api/orders?page=1&size=20

オフセットベース(page/size)は分かりやすいが、大規模テーブルでは遅く、ページング中に挿入/削除があるとレコード重複/漏れが発生しやすい。カーソルベース(?after=order_1234)は大規模データセットでより安定しているが、「ページ10にジャンプ」の実装は複雑だ。

4.3. バージョニング

@RestController
@RequestMapping("/api/v2/orders")
class OrderControllerV2 { /* ... */ }

URIパスでのバージョニングは読みやすく、ゲートウェイでのルーティングが容易だ。ヘッダでのバージョニング(Accept: application/vnd.myapp.v2+json)はURLは綺麗だが、ブラウザでのデバッグは難しい。どちらを選ぶかより重要なのは、明確な廃止予定戦略だ:事前に通知し、古いバージョンを十分な期間実行し、DeprecationとSunsetヘッダを返す。

4.4. エラーレスポンスの標準化

@ExceptionHandler(OrderNotFoundException.class)
ResponseEntity<ProblemDetail> handleNotFound(OrderNotFoundException ex) {
    ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
    problem.setProperty("orderId", ex.getOrderId());
    return ResponseEntity.status(HttpStatus.NOT_FOUND).body(problem);
}
{
  "type": "about:blank",
  "title": "Not Found",
  "status": 404,
  "detail": "Order 99 not found",
  "orderId": 99
}

ProblemDetail(Spring Boot 3.x対応、RFC 7807準拠)は、システム全体で統一されたエラー形式を提供する――クライアントは1つのスキーマだけをパースする必要があり、どのフィールドにエラーメッセージが含まれているかを推測する必要がない。

5. セキュリティ:API key、OAuth2、JWT、scope

5.1. API key — シンプル、低リスク環境のサービス間通信に適している

@Component
class ApiKeyFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
            throws ServletException, IOException {
        String apiKey = req.getHeader("X-Api-Key");
        if (apiKey == null || !apiKeyRepository.isValid(apiKey)) {
            res.setStatus(HttpStatus.UNAUTHORIZED.value());
            return;
        }
        chain.doFilter(req, res);
    }
}

APIキーは誰が代理で誰を呼び出しているのかを区別できない――アプリを認証するだけで、ユーザーを認証しない。エンドユーザーの個人データ保護に使用すべきではない。

5.2. OAuth2 + JWT — スコープに基づく権限付与が必要な場合

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://auth.nonladev.com
@PreAuthorize("hasAuthority('SCOPE_orders:write')")
@PostMapping
ResponseEntity<Order> createOrder(@RequestBody CreateOrderRequest request) {
    return ResponseEntity.ok(orderService.create(request));
}

JWTは(ユーザーID、スコープ、exp)を含み、公開鍵で検証可能で、毎回認可サーバーに問い合わせる必要がない――従来のセッションより拡張性が優れている理由だ。しかし、JWTは期限切れ前に容易に失効させられない:ユーザーアカウントがロックされても、古いトークンはexpまで有効のまま。一般的な解決策:短いexp(5〜15分)とリフレッシュトークン、またはゲートウェイレイヤーでのブラックリスト確認。

注意: スコープは「このトークンが何をできるか」を指定するだけで、ビジネスルール検証(例えば:ユーザーAはorders:writeを持つが、ユーザーBの注文は編集できない)に代わるものではない。常にサービスレイヤーでオーナーシップを確認する。

6. 信頼性:タイムアウト、リトライ、べき等性、レート制限、キャッシュ

6.1. タイムアウトとリトライ

@Bean
RestClient paymentClient() {
    return RestClient.builder()
            .baseUrl("https://payment.internal")
            .requestFactory(ClientHttpRequestFactorySettings.DEFAULTS
                    .withConnectTimeout(Duration.ofSeconds(2))
                    .withReadTimeout(Duration.ofSeconds(3))
                    .buildRequestFactory())
            .build();
}
<dependency>
    <groupId>io.github.resilience4j</groupId>
    <artifactId>resilience4j-spring-boot3</artifactId>
    <version>2.2.0</version>
</dependency>
@Retry(name = "paymentService", fallbackMethod = "chargeFallback")
Charge charge(ChargeRequest request) {
    return paymentClient.post().uri("/charge").body(request).retrieve().body(Charge.class);
}

タイムアウトを設定しないと、ダウンストリームが停止した場合にスレッドが無限に待機し、ドミノ効果であなたのサービスが崩壊する。無責任なリトライはリクエスト嵐を引き起こし、ダウンストリームをさらに悪く落とす――リトライは常にエクスポーネンシャルバックオフとサーキットブレーカーと組み合わせる。

6.2. 金銭操作のためのIdempotency key

@PostMapping("/charges")
ResponseEntity<Charge> charge(@RequestHeader("Idempotency-Key") String key,
                               @RequestBody ChargeRequest request) {
    return idempotencyStore.findByKey(key)
            .map(existing -> ResponseEntity.ok(existing))
            .orElseGet(() -> {
                Charge charge = paymentService.charge(request);
                idempotencyStore.save(key, charge);
                return ResponseEntity.ok(charge);
            });
}

クライアントが同じリクエスト(タイムアウト、ネットワークの不具合によるもの)を同じIdempotency-Keyで再送信すると、古い結果を受け取り、二重課金されない。これは財務的な副作用を持つあらゆるAPIで最も重要な概念だ。

@Test
void shouldReturnSameChargeForDuplicateIdempotencyKey() {
    ChargeRequest request = new ChargeRequest("order-1", 10_000L);
    Charge first = client.charge("idem-key-1", request);
    Charge second = client.charge("idem-key-1", request);

    assertThat(second).isEqualTo(first);
    assertThat(paymentGateway.chargeCallCount()).isEqualTo(1);
}

6.3. レート制限とキャッシュ

@Bean
Bucket4jConfiguration rateLimitConfig() {
    return Bucket4jConfiguration.builder()
            .capacity(100)
            .refillGreedy(100, Duration.ofMinutes(1))
            .build();
}
@Cacheable(value = "orders", key = "#id")
Order findById(Long id) {
    return orderRepository.findById(id).orElseThrow();
}

レート制限は、故意または誤りによるクライアントの過負荷からシステムを保護する。キャッシュはダウンストリームの負荷を軽減するが、TTLとキャッシュ無効化を検討する必要がある――処理中の注文データのstaleキャッシュは、キャッシュがない場合より深刻な状態の誤解を招く可能性がある。

7. ドキュメント化、可観測性、コントラクトテスト

<dependency>
    <groupId>org.springdoc</groupId>
    <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
    <version>2.6.0</version>
</dependency>
@Operation(summary = "新規注文を作成する", responses = {
        @ApiResponse(responseCode = "201", description = "注文が正常に作成されました")
})
@PostMapping
ResponseEntity<Order> createOrder(@RequestBody CreateOrderRequest request) { /* ... */ }

OpenAPIスペックはアノテーションから自動生成され、スペックが実装と「ズレ」ることを防ぐ――ドキュメントを手書きして、コードが変更されたのに更新を忘れるという古典的な問題を回避する。

コントラクトテスト(Spring Cloud Contract、Pact)は、コンシューマーとプロバイダーが独立して展開するときに契約を破らないことを保証する:

@Test
void providerShouldHonorOrderContract() {
    Order order = orderService.findById(1L).orElseThrow();

    assertThat(order.id()).isEqualTo(1L);
    assertThat(order.status()).isIn("PENDING", "PAID", "CANCELLED");
}

可観測性(構造化ログ、ヘッダtraceparent経由のサービス間トレースID、エンドポイント別のレイテンシ/エラーレートメトリクス)と組み合わせることで、APIオペレーションチームは本番環境をデバッグするための十分な情報を得られ、断片的なログから推測する必要がなくなる。

8. 結論

API設計はフレームワークの正しい選択ではなく、小さな決定の連鎖にある:どのメソッド、どのステータスコード、どのようなページネーション、誰がどのスコープで呼ぶのか、何回リトライしたら止めるか。個々の決定は単純に見えるが、まとめるとあなたのAPIが使いやすいのか、統合するすべてのチームの頭痛の種かを決める。上記の概念をしっかり把握する――HTTPセマンティクス、ユースケースに適したアーキテクチャ選択、層状セキュリティ、信頼性パターン、コードと同期されたドキュメント――は、他の人が本当に長期間信頼して使用できるAPIを設計するための基盤だ。

system-designapispring-bootrestsecurity