홈 / Skills / 개발 생산성 / API 및 인터페이스 설계 API and Interface Design
개발 생산성

API 및 인터페이스 설계 API and Interface Design

Addy OsmaniMITSkill 언어: 영어
라이선스 확인됨스크립트 없음낮은 위험
실행 예시실제 실행 · claude-sonnet-5-5 · 2026년 10월
Design the HTTP API for a small invoicing service (fictional): customers, invoices with line items, and a "pay invoice" action that charges a card through a payment provider and must be safe to retry because mobile clients have flaky networks. We expect more fields to be added later and several mobile app versions in the wild. Give me the endpoints, the error format, the pagination and the retry-safety design, with the main TypeScript types.
Skill: api-and-interface-design

Invoicing API design

Principles

  • One version, extended additively. Everything lives under /v1. New fields are optional and added to responses, and nothing is removed or retyped. A /v2 is a last resort.
  • Old app versions must survive. Clients ignore unknown fields and treat enums as open: an unknown value falls back to a default. This is a documented rule for client authors. Server responses always include every field, using null rather than omitting.
  • Money is { amount: integer minor units, currency: ISO-4217 }. Never use floats.
  • Naming: plural nouns, camelCase fields, UPPER_SNAKE enum values, and IDs that are opaque prefixed strings (inv_…, cus_…).

Endpoints

POST   /v1/customers
GET    /v1/customers                      ?limit&cursor&email&createdAfter
GET    /v1/customers/{customerId}
PATCH  /v1/customers/{customerId}         (partial; JSON merge-patch)

POST   /v1/invoices                       (creates DRAFT, line items inline)
GET    /v1/invoices                       ?limit&cursor&customerId&status&issuedAfter
GET    /v1/invoices/{invoiceId}
PATCH  /v1/invoices/{invoiceId}           (DRAFT only; If-Match required)
POST   /v1/invoices/{invoiceId}/finalization   (DRAFT → OPEN, immutable after)
POST   /v1/invoices/{invoiceId}/void           (OPEN → VOID)

POST   /v1/invoices/{invoiceId}/payments  (the pay action; Idempotency-Key required)
GET    /v1/invoices/{invoiceId}/payments
GET    /v1/payments/{paymentId}           (poll target for pending/unknown outcomes)
  • Pay is modeled as creating a payment attempt. That gives the client something to poll and gives us an audit trail.
  • Line items are part of the invoice. They are replaced as a whole via PATCH on a DRAFT, so there is no separate line-item endpoint to keep consistent. After finalization the invoice is immutable.
  • Concurrent edits use ETag / If-Match. A mismatch returns 412 PRECONDITION_FAILED.

Types

type Id<P extends string> = `${P}_${string}`;
type CustomerId = Id<'cus'>; type InvoiceId = Id<'inv'>; type PaymentId = Id<'pay'>;

interface Money { amount: number; currency: string }   // amount: integer minor units

interface Customer {
  id: CustomerId;
  name: string;
  email: string;
  metadata: Record<string, string>;     // escape hatch for future fields
  createdAt: string;                    // RFC 3339 UTC
  updatedAt: string;
}
interface CreateCustomerInput { name: string; email: string; metadata?: Record<string, string> }
type UpdateCustomerInput = Partial<CreateCustomerInput>;

type InvoiceStatus = 'DRAFT' | 'OPEN' | 'PAID' | 'VOID';   // clients: unknown → treat as OPEN-like/readonly

interface LineItem {
  id: string;
  description: string;
  quantity: number;                     // integer
  unitPrice: Money;
  total: Money;                         // server-computed
}
interface LineItemInput { description: string; quantity: number; unitPrice: Money }

interface Invoice {
  id: InvoiceId;
  customerId: CustomerId;
  status: InvoiceStatus;
  lineItems: LineItem[];
  total: Money;                         // server-computed
  amountDue: Money;
  dueDate: string | null;
  createdAt: string;
  updatedAt: string;
}
interface CreateInvoiceInput { customerId: CustomerId; lineItems: LineItemInput[]; dueDate?: string }
type UpdateInvoiceInput = Partial<Omit<CreateInvoiceInput, 'customerId'>>;

// Payment
interface PaymentMethodInput { token: string }   // provider-issued token; raw card data never touches us

type PaymentStatus = 'PROCESSING' | 'SUCCEEDED' | 'FAILED' | 'REQUIRES_ACTION' | 'UNKNOWN';

interface Payment {
  id: PaymentId;
  invoiceId: InvoiceId;
  status: PaymentStatus;
  amount: Money;
  failure: { code: string; message: string } | null;  // e.g. CARD_DECLINED
  nextAction: { type: string; url?: string } | null;  // e.g. 3-D Secure; open-ended
  createdAt: string;
  updatedAt: string;
}
interface CreatePaymentInput { paymentMethod: PaymentMethodInput }  // amount is NOT client-supplied: it's the invoice's amountDue

// Envelopes
interface Page<T> { data: T[]; page: { nextCursor: string | null; hasMore: boolean } }

The client doesn't send the amount to pay. The server charges amountDue, so a stale or tampered client can't over- or under-charge.

Error format

One shape for every non-2xx response, including 5xx from gateways where we control them:

interface ApiError {
  error: {
    code: string;          // stable, machine-readable; clients switch on this, never on message
    message: string;       // human-readable, English, may change, not a contract
    requestId: string;     // also in the X-Request-Id header
    retryable: boolean;    // tells the client whether retrying the same request can help
    details?: FieldViolation[] | Record<string, unknown>;
  };
}
interface FieldViolation { field: string; code: string; message: string }  // e.g. "lineItems[0].quantity"
Status Code examples Notes
400 MALFORMED_REQUEST Unparseable body, bad header
401 / 403 UNAUTHENTICATED / FORBIDDEN
404 NOT_FOUND
409 INVOICE_NOT_PAYABLE, REQUEST_IN_PROGRESS State conflict, or in-flight duplicate
412 PRECONDITION_FAILED ETag mismatch
422 VALIDATION_FAILED, IDEMPOTENCY_KEY_REUSED Semantically invalid
429 RATE_LIMITED Retry-After header
502/503/504 PROVIDER_UNAVAILABLE, UNAVAILABLE retryable: true
500 INTERNAL No internals leaked

A declined card is a business outcome, not an HTTP error. Pay returns 200 with a Payment whose status is FAILED and failure.code is CARD_DECLINED. A replay then returns exactly the same thing instead of an error that looks like a transport failure.

Pagination

Use cursor pagination, not page numbers. Mobile lists are infinite scroll, and offset pages duplicate or skip rows when invoices are created between fetches.

  • Request: GET /v1/invoices?limit=25&cursor=<opaque>. The default limit is 25 and the maximum is 100.
  • Response: Page<Invoice>. Pass page.nextCursor back verbatim.
  • The cursor is opaque base64 that encodes the sort key (createdAt, id) plus a filter hash. Clients must not parse it. That lets us change the internals without breaking old apps.
  • Sort is fixed at createdAt desc, id desc. Add a sort param later if needed.
  • There is no totalItems. Counts are expensive and drift. If needed later, add it as an opt-in ?include=totalCount.
  • A cursor reused with different filters returns 400 INVALID_CURSOR.

Retry-safe payments

Contract

POST /v1/invoices/{id}/payments requires an Idempotency-Key header, and returns 400 without one.

  • The client generates the key once per user intent, when the user taps "Pay". It persists the key (in memory plus local storage) and reuses it on every retry of that tap. A new tap after a definitive failure gets a new key.
  • The key is never generated inside the HTTP retry layer. If it were, each retry would be a new charge.

Server algorithm

  1. Claim atomically. Insert into idempotency_keys(scope, key, request_hash, payment_id, state, response, created_at) with a unique constraint on (accountId, key). The constraint picks the winner. Never do SELECT then INSERT.
  2. If the insert hits the unique violation, load the existing row:
    • request_hash differs (hash of invoiceId + body): 422 IDEMPOTENCY_KEY_REUSED.
    • State is in_progress: 409 REQUEST_IN_PROGRESS with Retry-After: 2 and retryable: true. Never let a second caller through because the first "seems stuck".
    • State is completed: replay the stored status and body, plus the header Idempotent-Replayed: true.
  3. Record intent before calling out. In the same transaction as the claim, create the Payment row as PROCESSING and lock the invoice. Enforce a partial unique index: at most one PROCESSING or SUCCEEDED payment per invoice. This is a second line of defense if a client uses two different keys for the same invoice.
  4. Call the provider with a derived provider-side idempotency key, for example pay:v1:{paymentId}. It is stable across our own retries and crash recovery, so even our own re-attempts can't double charge.
  5. Persist the outcome. Update the payment to SUCCEEDED or FAILED, mark the invoice PAID in the same transaction, and store the response against the key.
  6. Treat timeouts as a third outcome, UNKNOWN. A provider timeout or a crash after the call tells us nothing about whether the charge applied.
    • The payment stays PROCESSING/UNKNOWN, and the key stays claimed.
    • A reconciler resolves it by querying the provider with the same provider key, or via provider webhooks.
    • Clients that get 409 or an UNKNOWN payment poll GET /v1/payments/{id}. They never re-pay with a new key until the status is definitive.
  7. Validate the provider's responses (shape, amount, currency) before trusting them. They are untrusted input.

Client rules

Response Action
2xx with SUCCEEDED Done
2xx with FAILED Show the reason; a new attempt uses a new key
2xx with REQUIRES_ACTION Run the next action, then poll
Network error, timeout, 5xx, 429 Retry with the same key, with exponential backoff and jitter
409 REQUEST_IN_PROGRESS Wait for Retry-After, then retry with the same key
422 IDEMPOTENCY_KEY_REUSED Client bug; don't retry

Retention

Keep keys at least as long as the longest path that can redeliver a request. Our recommendation is 30 days minimum, and never shorter than the provider's dispute window or any dead-letter replay horizon. The Payment rows themselves are kept permanently, so the partial unique index on invoices still guards after the key expires. Expiry is a retention job and is never an on-read check.

Evolving safely

  • Add, don't change. New fields are optional on input and always present on output. Adding an enum value counts as a change, so the open-enum client rule is what makes it safe.
  • metadata maps on customers and invoices absorb ad-hoc fields without a schema change.
  • Send X-App-Version (or the User-Agent) from clients and log it. Before deprecating anything, you can then see which versions still use it. Deprecations are announced with Deprecation and Sunset headers.
  • Minimum supported version: an unsupported client gets 426 UPGRADE_REQUIRED with the standard error body, which gives a graceful "please update" path.
  • Publish an OpenAPI spec generated from these types, and run contract tests that replay recorded requests from old app versions on every release.

Open questions

  • Partial payments and refunds: I assumed one full-amount payment per invoice. Partial payments would change the "one active payment" index into a balance check.
  • Auth model: I assumed a per-account bearer token, with idempotency keys scoped to the account. Tell me if it's multi-tenant in a different way.
  • Currency: one currency per invoice is assumed.

Test run: an invoicing API for a fictional service. The model produced endpoints, types, one error format, cursor pagination and a retry-safe payment design with an atomic idempotency key. Design only; nothing was implemented or run, and some choices (for example returning 200 with a FAILED payment) are opinions.

하는 일

REST나 GraphQL 엔드포인트, 모듈 경계, 컴포넌트 props의 설계를 안내합니다. 계약에서 시작해(구현 전에 타입과 엔드포인트를 먼저 쓴다) 다음 원칙을 적용합니다. 하이럼의 법칙(관찰 가능한 모든 동작이 의존성이 된다), 단일 버전 규칙, 클라이언트가 분기에 쓸 수 있는 통일된 오류 응답, 검증은 시스템 경계에서만 하기, 기존 필드를 바꾸기보다 선택 필드를 추가하기. REST 패턴(리소스는 명사, 알맞은 동사, 부분 업데이트, 커서 페이지네이션, 필터링)과 TypeScript 패턴(입력·출력 타입 분리, 상태에는 판별 유니온, 브랜드 ID, 옛 클라이언트를 위한 열린 열거형)도 다룹니다. 두 번 실행되면 안 되는 작업의 멱등성도 다룹니다.

결과물

엔드포인트, 타입, 오류 형식, 페이지네이션, 진화 계획으로 쓴 설계와 가정 및 미해결 질문 목록.

이런 때 좋습니다

새 공개·내부 API를 설계하거나 기존 API를 검토할 때, 바로 업데이트할 수 없는 클라이언트(모바일 앱 등)에 깨지는 변경을 주지 않으려 할 때.

참고 사항 및 위험

낮은 위험:지침만 있는 파일로 스크립트가 없습니다. 설계 글과 코드 스케치를 만들 뿐 외부 서비스를 호출하지 않습니다. 재시도해도 안전한 결제가 있는 가상의 청구서 API로 한 번 시험했고 엔드포인트, 타입, 통일된 오류 형식, 커서 페이지네이션, 멱등 키 설계를 냈습니다. 설계일 뿐 구현이나 실행은 하지 않았고, 일부 선택(예: 카드 거절 시 200과 FAILED 결제를 돌려주기)은 의견이라 동의하지 않을 수 있습니다. 작성자 Addy Osmani, MIT.

SKILL.md 원본패키지에 파일 2개