Skip to content
My Guidelines
Guides
In this guide (81 items)

API Design

API Design Guidelines

MANDATORY FOR ALL AI AGENTS: Every public REST endpoint MUST follow the conventions below. Controllers belong in the infrastructure / entry-points layer as inbound adapters — they MUST NOT contain business logic. DTOs MUST stay separate from domain entities. Breaking changes to a released API MUST go through a new version + deprecation lifecycle. Validate spec ↔ controller ↔ DTO consistency with /arch-api before merge.

Project-agnostic: code samples use placeholder package com.example.platform, service name platform-service, base path /api/v1. Replace with your project’s actual values when applying patterns.

Companion guides:

  • hexagonal-architecture.md — controllers as inbound adapters, DTO ↔ command mapping.
  • security.md — AuthN/Z, rate limiting, CORS, error info disclosure.
  • adr.md — capture API decisions (versioning strategy, error format, pagination style).
  • arch-doc-spec.md §API Contract — board deliverable section.
  • testing.md — contract tests, consumer-driven contracts (Pact, Karate).
  • observability.md — HTTP metrics, request tracing.

Companion command/agent: /arch-apiarch-api-contract-validator (detects breaking changes vs released spec).


Table of Contents

  1. Design Principles
  2. Resource Modeling
  3. URL Conventions
  4. HTTP Methods & Status Codes
  5. Versioning Strategy
  6. Request & Response Shape
  7. Error Envelope (RFC 9457)
  8. Pagination
  9. Filtering, Sorting, Sparse Fieldsets
  10. Idempotency
  11. Caching
  12. Rate Limiting Headers
  13. Content Negotiation
  14. HATEOAS
  15. Async & Long-Running Operations
  16. Bulk Operations
  17. File Upload & Download
  18. Health, Info, Metrics
  19. OpenAPI Contract-First
  20. Deprecation Lifecycle
  21. Webhooks (Outbound)
  22. Anti-patterns
  23. Pre-Merge Checklist

1. Design Principles

  • Contract-first. Author OpenAPI YAML BEFORE implementation. Controllers and DTOs implement the contract, not the other way around.
  • Boring & consistent. Predictable beats clever. Same pattern across every endpoint.
  • Resource-oriented. URLs are nouns; HTTP methods are verbs. Avoid RPC-style /doSomething.
  • Stateless. No server session. JWT in Authorization header.
  • Stable. A v1 endpoint released to clients is immutable on the wire. Add fields → fine. Remove or change semantics → new version.
  • Discoverable. OpenAPI spec published at /v3/api-docs; Swagger UI at /swagger-ui.html in non-prod only.
  • Documented at the spec. Description, examples, error responses live in OpenAPI — not in a wiki.

2. Resource Modeling

2.1 Identify resources

  • A resource is a noun the client cares about: invoices, users, orders.
  • Resources have identifiers (UUID preferred, opaque strings acceptable). Sequential integers leak business volume — avoid for public APIs.
  • Sub-resources express containment: /organizations/{orgId}/invoices/{invoiceId}.

2.2 Action endpoints (when REST doesn’t fit)

When the operation isn’t a CRUD verb on a resource (transition, computation, command), use a sub-resource named for the action:

POST /invoices/{id}/payments # not POST /payInvoice
POST /accounts/{id}:close # Google-style colon syntax acceptable
POST /reports/{id}/exports # creates an export job

Avoid /getInvoice, /createInvoice, /findUserByEmail — these are RPC, not REST.

2.3 Domain vs DTO

  • Domain entities live in domain/. NEVER expose them directly.
  • DTOs live in the entry adapter: infrastructure/entry-points/rest-controller/.../{request|response}/.
  • Mapper translates DTO ↔ command and aggregate ↔ response in dedicated mapper classes.
  • Hexagonal rule: the controller depends on the application port, not on the domain. The mapper bridges them.
// DTO — adapter layer, mutable in shape over time
data class CreateInvoiceRequest(
val customerId: UUID,
val amountCents: Long,
val currency: String,
val dueDate: LocalDate
)
// Maps to a command
fun CreateInvoiceRequest.toCommand(actor: UserId): CreateInvoiceCommand =
CreateInvoiceCommand(
customerId = CustomerId(customerId),
amount = Money(amountCents, Currency.of(currency)),
dueDate = dueDate,
actor = actor
)
// Domain aggregate maps to a response DTO
fun Invoice.toResponse(): InvoiceResponse = InvoiceResponse(
id = id.value,
status = status.name,
amount = AmountResponse(amount.amountCents, amount.currency.code),
dueDate = dueDate,
createdAt = createdAt
)

3. URL Conventions

3.1 Path

  • Plural nouns for collections: /invoices, not /invoice.
  • kebab-case for multi-word resources: /purchase-orders, not /purchaseOrders or /purchase_orders.
  • Lowercase only. No mixed case.
  • Hierarchy at most 3 levels deep: /organizations/{orgId}/invoices/{invoiceId}/payments. Beyond that, flatten and use query params.

3.2 Base path

https://api.example.com/{service}/v{N}/{resource}
  • service segment is optional — only when the gateway hosts multiple services on one domain.
  • v{N} is the major version (see §5).
  • No trailing slashes — canonicalize on /invoices. Spring Boot 3 matches paths with PathPatternParser and disables trailing-slash matching by default, so /invoices/ returns 404 unless you add an explicit redirect/alias. Normalize at the gateway rather than relying on framework trailing-slash tolerance.

3.3 Query parameters

  • snake_case OR camelCase — pick one per service and stick with it. Default recommendation: camelCase (matches JSON body conventions).
  • Boolean: ?includeDeleted=true, never ?delete=1.
  • Multi-value: repeat key (?status=PAID&status=PENDING) or comma-delimited (?status=PAID,PENDING). Document one and validate.

3.4 Identifiers in URL

  • Always UUIDs (or opaque strings) for public APIs.
  • Never email, username, or other PII in path — they change and leak.
  • Slugs OK for SEO-bound public resources (/articles/why-we-chose-hexagonal); pair with a canonical UUID for stability.

4. HTTP Methods & Status Codes

4.1 Method semantics

Method Use Idempotent Safe
GET Read yes yes
HEAD Headers only, existence check yes yes
POST Create OR non-idempotent action no no
PUT Full replacement of a known-id resource yes no
PATCH Partial update usually no
DELETE Remove yes no
OPTIONS CORS preflight yes yes

4.2 Status code matrix

Code When
200 OK Successful read or update returning the resource
201 Created Successful create; include Location header pointing to the new resource
202 Accepted Async operation started (see §15)
204 No Content Successful action without response body (DELETE, idempotent PUT)
301 / 308 Permanent redirect (versioning migration)
304 Not Modified Conditional GET with matching If-None-Match (see §11)
400 Bad Request Malformed payload, validation failure
401 Unauthorized Missing or invalid auth credentials
403 Forbidden Authenticated but not authorized; uniform 404 preferred for sensitive resources
404 Not Found Resource doesn’t exist (or masked for authorization — see security.md authz section)
405 Method Not Allowed Wrong verb for the URL
406 Not Acceptable Client Accept not satisfiable
409 Conflict Concurrent update, version mismatch, duplicate-resource
410 Gone Permanently removed (post-deprecation)
412 Precondition Failed If-Match / If-Unmodified-Since failed
415 Unsupported Media Type Request Content-Type not supported
422 Unprocessable Content Syntactically valid, semantically invalid (business rule violation)
423 Locked Resource locked for modification
428 Precondition Required Optimistic concurrency required but no If-Match sent
429 Too Many Requests Rate limit exceeded; include Retry-After
500 Internal Server Error Unhandled server fault — never leak details
502 / 503 / 504 Upstream / unavailable / timeout

4.3 PUT vs PATCH

  • PUT: client sends the full representation. Missing fields are interpreted as “set to default / null”. Idempotent.
  • PATCH: partial update. Two options — pick one per service:
    • JSON Merge Patch (RFC 7396) — simpler, default recommendation. Content-Type: application/merge-patch+json.
    • JSON Patch (RFC 6902) — operation array, supports add/remove/replace/move/copy. Content-Type: application/json-patch+json.
  • Avoid “PATCH that takes a partial JSON body without specifying which format” — that’s ambiguous.

5. Versioning Strategy

5.1 Default: URL path versioning

/api/v1/invoices
/api/v2/invoices

Why URL over headers/query:

  • Discoverable from logs and access traces.
  • Easy to route at the gateway / load balancer.
  • Cache keys naturally include version.

Reject Accept-header-only versioning (Accept: application/vnd.example.invoices.v2+json) unless integrating with a partner that mandates it — it complicates caching and routing.

5.2 When to bump major version

A new major version (v2) is required when ANY of:

  • A required request field is added.
  • A response field is removed or renamed.
  • The semantic of an existing field changes.
  • An enum value is removed or its meaning narrowed.
  • HTTP method or status code semantics change.
  • An endpoint URL is moved.
  • A required authentication scope changes meaning.

NOT a new version (additive, safe):

  • New optional request field with a default.
  • New response field (clients must ignore unknown fields — document this).
  • New endpoint.
  • New optional query parameter.
  • Performance, error message wording, response time changes.

5.3 Coexistence

  • v1 and v2 SHOULD coexist for the full deprecation window (see §20).
  • Implement v2 as separate controllers; share the application use case if domain semantics are stable. Versioning is an adapter concern, not a domain concern.
  • Avoid if (version == 2) branches inside one controller — duplicate the controller, share the use case.

5.4 Minor versions

  • Track minor changes in OpenAPI info.version (e.g., 1.4.2) for documentation purposes only.
  • Do NOT expose minors in the URL path.

6. Request & Response Shape

6.1 Content type

  • Default: application/json; charset=utf-8.
  • Other formats only when justified: application/x-ndjson for streaming, text/csv for export, application/octet-stream for binary.

6.2 JSON conventions

  • camelCase for field names. Pick once per service and never mix.
  • ISO 8601 for dates and timestamps:
    • Instant: 2026-06-10T12:00:00Z (UTC, Z suffix).
    • Local date: 2026-06-10.
    • Local time: 15:30:00.
    • Duration: PT1H30M (ISO 8601 duration).
  • Currency amounts: integer minor units (amountCents: 12345) + ISO 4217 code (currency: "USD"). Never floats for money.
  • Enums: UPPER_SNAKE_CASE strings (status: "PENDING_PAYMENT"). Never numeric codes.
  • Booleans: explicit field name (isActive, hasPermission). Never tri-state booleans — use a nullable type with documented meaning OR an enum.
  • IDs: strings (even when stored as UUID/long). JS clients lose precision on 64-bit ints.
  • Null vs absent: a field set to null means “explicitly cleared”. An absent field means “no change” (in PATCH) or “not provided” (in POST). Document the difference per endpoint.

6.3 Envelope?

Two valid patterns — pick one per service:

A. Bare resource (recommended):

{ "id": "...", "status": "PAID", "amountCents": 12345 }

B. Wrapped envelope:

{
"data": { "id": "...", "status": "PAID" },
"meta": { "requestId": "...", "version": "1.4.2" }
}

Bare is simpler, matches Spring serialization defaults, easier to consume. Wrapped is useful when you always need top-level metadata. Do not mix — every endpoint in a service uses the same shape.

6.4 Request limits

Limit Default
Body size 256 KB (JSON); larger only for file upload endpoints
URL length 2048 chars
Header value 8 KB
Query param count 50
Array element count 1000 (then paginate or bulk endpoint)

Enforced at API gateway AND application. Reject early with 413 Content Too Large or 414 URI Too Long.


7. Error Envelope (RFC 9457)

7.1 Default: Problem Details for HTTP APIs

Use RFC 9457 (obsoletes RFC 7807) Problem Details — the JSON shape is unchanged from RFC 7807; only the spec reference is updated.

HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
{
"type": "https://errors.example.com/invoice/invalid-amount",
"title": "Invalid invoice amount",
"status": 422,
"detail": "Amount must be greater than zero.",
"instance": "/api/v1/invoices",
"code": "INVOICE_INVALID_AMOUNT",
"traceId": "abc-123",
"errors": [
{ "field": "amountCents", "code": "MUST_BE_POSITIVE", "message": "must be > 0" }
]
}

7.2 Field meaning

Field Required Purpose
type yes URI identifying the error class (dereferenceable doc page is a plus)
title yes Short human summary, stable per type
status yes Mirrors HTTP status; redundant but explicit
detail no Human description specific to this occurrence
instance no URI of the specific resource/request that failed
code yes Stable machine-readable code (UPPER_SNAKE_CASE); clients branch on this
traceId yes Correlation ID for support
errors[] no Field-level validation errors

code is the stable contract for client logic. title and detail may be translated/changed; code MUST NOT.

7.3 Centralized exception handler

@RestControllerAdvice
class GlobalExceptionHandler {
@ExceptionHandler(InvoiceNotFoundException::class)
fun handleNotFound(ex: InvoiceNotFoundException, req: HttpServletRequest): ResponseEntity<ProblemDetail> {
val pd = ProblemDetail.forStatus(HttpStatus.NOT_FOUND).apply {
type = URI("https://errors.example.com/invoice/not-found")
title = "Invoice not found"
detail = "No invoice with id ${ex.id}"
instance = URI(req.requestURI)
setProperty("code", "INVOICE_NOT_FOUND")
setProperty("traceId", MDC.get("traceId"))
}
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(pd)
}
@ExceptionHandler(MethodArgumentNotValidException::class)
fun handleValidation(ex: MethodArgumentNotValidException): ResponseEntity<ProblemDetail> { ... }
}

7.4 Don’t leak

  • No stack traces in prod (server.error.include-stacktrace: never).
  • No SQL fragments, file paths, framework class names.
  • Uniform 404 for sensitive resources (don’t differentiate “doesn’t exist” from “exists-but-forbidden” — see security.md §17).

8. Pagination

8.1 Default: cursor-based

GET /invoices?limit=50&cursor=eyJpZCI6IjEyMyJ9

Response:

{
"items": [ ... ],
"page": {
"limit": 50,
"nextCursor": "eyJpZCI6IjE3MyJ9",
"prevCursor": null
}
}

Why cursor over offset:

  • Stable under concurrent inserts (offset shifts).
  • Cheaper at scale — WHERE id > last_id uses an index; OFFSET 100000 scans 100k rows.
  • Opaque cursor encodes sort + position; clients can’t tamper.

8.2 Offset-based (acceptable for small datasets)

GET /invoices?page=3&size=50

Response includes total:

{
"items": [ ... ],
"page": { "number": 3, "size": 50, "totalElements": 1247, "totalPages": 25 }
}

Use offset ONLY when:

  • The dataset is small (< 10k rows).
  • Users genuinely need to “jump to page 5”.
  • Total count is cheap (indexed, cached).

8.3 Limits

  • limit default: 50. Max: 200. Reject larger with 400.
  • Page size MUST be enforced server-side; never trust client to ask for “all”.
Link: <https://api.example.com/api/v1/invoices?cursor=abc>; rel="next",
<https://api.example.com/api/v1/invoices?cursor=xyz>; rel="prev"

Either in-body page object OR Link header — not both. Pick per service.


9. Filtering, Sorting, Sparse Fieldsets

9.1 Filtering

Default simple filters as query params:

GET /invoices?status=PAID&customerId=...&createdAfter=2026-01-01

For complex filtering, support a single filter param with a documented DSL (RSQL or Spring Querydsl predicate syntax):

GET /invoices?filter=status==PAID;amountCents=gt=1000;createdAt=ge=2026-01-01

Document the operators (==, !=, gt, lt, in, like). Validate aggressively — never pass user filter strings to JPQL/SQL without parsing.

9.2 Sorting

GET /invoices?sort=createdAt,desc&sort=amountCents,asc

Validate against an allowlist of sortable fields. Reject unknown fields with 400.

9.3 Sparse fieldsets

When clients need only specific fields (mobile, bandwidth-sensitive):

GET /invoices?fields=id,status,amountCents

Validate against an allowlist. Document per endpoint.


10. Idempotency

10.1 Idempotency-Key header

Required on every non-idempotent write (POST that creates resources, POST that triggers side effects). Optional on PUT/PATCH/DELETE (HTTP semantics already make them idempotent on the URL).

POST /api/v1/payments
Idempotency-Key: 9b8c7d6e-5f4a-3b2c-1d0e-9f8a7b6c5d4e

10.2 Server contract

  • Store (idempotency_key, request_hash, response, status) in a dedicated table with a TTL (24h–7d).
  • Same key + same request hash → return the cached response, status 200/201 as originally.
  • Same key + different request hash → return 422 with code: IDEMPOTENCY_KEY_REUSED.
  • New key → process and store.

10.3 Scope

Idempotency keys are scoped per (tenant, endpoint, key). Different tenants can use the same key.

10.4 Why required for payments / orders

A network blip causes the client to retry. Without idempotency, you double-charge. With idempotency, the retry resolves cleanly.


11. Caching

11.1 Cache-Control

Endpoint Default Cache-Control
Authenticated read of mutable data private, no-cache (revalidate)
Authenticated read of immutable data (versioned) private, max-age=86400, immutable
Public read (catalog, static) public, max-age=300
Any write no-store
Sensitive (auth tokens, secrets) no-store, no-cache, must-revalidate, private

11.2 ETags

For resources clients re-read often, support conditional requests:

GET /invoices/123
→ 200 OK
ETag: "abc123"
Last-Modified: Mon, 10 Jun 2026 12:00:00 GMT
GET /invoices/123
If-None-Match: "abc123"
→ 304 Not Modified

ETag generation strategies:

  • Strong: hash of canonical representation (deterministic).
  • Weak (W/"..."): version counter — cheaper, sufficient for most.

11.3 Optimistic concurrency

For updates, require If-Match to prevent lost updates:

PUT /invoices/123
If-Match: "abc123"
Content-Type: application/json
{ ... }
200 OK (matched, applied)
412 Precondition Failed (current ETag differs — refresh + retry)
428 Precondition Required (caller didn't send If-Match on a write that requires it)

11.4 Vary

Include Vary: Accept, Accept-Encoding, Authorization when caching diverges by header.


12. Rate Limiting Headers

When rate limiting applies (see security.md §11):

HTTP/1.1 200 OK
RateLimit-Limit: 100
RateLimit-Remaining: 73
RateLimit-Reset: 47

On exceed:

HTTP/1.1 429 Too Many Requests
RateLimit-Limit: 100
RateLimit-Remaining: 0
RateLimit-Reset: 47
Retry-After: 47

Use the IETF RateLimit-* headers (Internet-Draft), not the legacy X-RateLimit-* — though many gateways still send legacy; document which.

Note: the newer IETF draft consolidates these into a single structured-field RateLimit header (e.g. RateLimit: limit=100, remaining=0, reset=47). The separate RateLimit-Limit / RateLimit-Remaining / RateLimit-Reset shown above are the earlier draft — pick the form your gateway emits and document it.


13. Content Negotiation

13.1 Accept

  • Default response: application/json.
  • When supporting alternatives, the client picks via Accept:
    • Accept: application/json → JSON.
    • Accept: text/csv → CSV export.
  • Server returns 406 Not Acceptable if it can’t satisfy.

13.2 Compression

  • Honor Accept-Encoding: gzip, br.
  • Compress JSON ≥1 KB. Set Content-Encoding.
  • Disable compression for already-compressed payloads (images, video).

13.3 Localization

For human-facing messages (when applicable):

Accept-Language: es-CO, es;q=0.9, en;q=0.5

Server returns Content-Language header. Error detail and title may be localized; code MUST NOT.


14. HATEOAS

Default position: skip hypermedia links unless you have a concrete need.

Use HATEOAS only when:

  • Clients are dynamic and benefit from server-driven workflow (rare in mobile/SPA where flows are hardcoded).
  • You’re building a public platform with many third-party consumers and want to evolve workflows without coordinating client releases.

If you do, follow HAL or JSON:API consistently across endpoints:

{
"id": "123",
"status": "PENDING_APPROVAL",
"_links": {
"self": { "href": "/invoices/123" },
"approve": { "href": "/invoices/123/approvals", "method": "POST" },
"void": { "href": "/invoices/123/void", "method": "POST" }
}
}

Available links reflect current state (state machine — only valid transitions appear).


15. Async & Long-Running Operations

15.1 Pattern: status resource

POST /reports/{id}/exports
→ 202 Accepted
Location: /report-exports/{exportId}
{ "exportId": "...", "status": "PENDING" }
GET /report-exports/{exportId}
→ 200 OK
{ "exportId": "...", "status": "RUNNING", "progress": 0.42 }
GET /report-exports/{exportId}
→ 200 OK
{ "exportId": "...", "status": "SUCCEEDED", "result": { "url": "https://..." } }

States: PENDINGRUNNINGSUCCEEDED | FAILED | CANCELED.

15.2 Polling vs callbacks vs SSE/WebSocket

Pattern When
Polling Default for ≤1 min duration. Suggest interval via Retry-After.
Webhook callback Job duration >1 min, partner is server-side. See §21.
Server-Sent Events Browser client wants real-time updates.
WebSocket Full-duplex, low-latency (chat, collaboration).

15.3 Cancellation

DELETE /report-exports/{exportId}
→ 202 Accepted (cancellation in progress)
→ 409 Conflict (already SUCCEEDED — can't cancel)

16. Bulk Operations

16.1 Bulk read

Built into pagination + filtering. No special endpoint.

16.2 Bulk write

When clients need to create/update many resources atomically:

POST /invoices:batchCreate
Content-Type: application/json
{
"items": [
{ "customerId": "...", "amountCents": 100 },
{ "customerId": "...", "amountCents": 200 }
]
}

Response:

{
"results": [
{ "status": 201, "id": "..." },
{ "status": 422, "error": { "code": "INVALID_AMOUNT", ... } }
]
}
  • Use HTTP 207 Multi-Status OR 200 with per-item status.
  • Document atomicity: “all-or-nothing” vs “partial success”.
  • Cap batch size (e.g., ≤100 items) and reject larger.

16.3 Bulk delete

POST /invoices:batchDelete
{ "ids": ["...", "...", "..."] }

Prefer POST over DELETE with body — many proxies strip DELETE bodies.


17. File Upload & Download

17.1 Upload

Direct upload (small files, ≤10 MB):

POST /attachments
Content-Type: multipart/form-data; boundary=...
--boundary
Content-Disposition: form-data; name="file"; filename="invoice.pdf"
Content-Type: application/pdf
<binary>
--boundary--

Presigned-URL upload (large files):

  1. POST /attachments → returns presigned S3/GCS URL.
  2. Client PUTs file directly to object storage.
  3. Client POST /attachments/{id}/finalize confirms completion.

17.2 Validate

  • Content-Type allowlist AND magic-byte sniffing.
  • Max size enforced at gateway + Spring (spring.servlet.multipart.max-file-size).
  • Antivirus scan before exposing to other users.
  • Generate server-side filename — never echo user input as path.

17.3 Download

GET /attachments/{id}/content
→ 200 OK
Content-Type: application/pdf
Content-Disposition: attachment; filename="invoice-123.pdf"
Content-Length: 12345

For large files, return a presigned URL instead:

GET /attachments/{id}
→ 200 OK
{ "id": "...", "downloadUrl": "https://...presigned...", "expiresAt": "..." }

18. Health, Info, Metrics

Spring Boot Actuator defaults:

Endpoint Purpose Auth Exposure
/actuator/health Liveness + readiness none public
/actuator/health/liveness K8s liveness probe none public
/actuator/health/readiness K8s readiness probe none public
/actuator/info Build info none public
/actuator/metrics Micrometer metrics scope internal
/actuator/prometheus Prometheus scrape scope internal
/v3/api-docs OpenAPI spec none non-prod, optional in prod
/swagger-ui.html Swagger UI none non-prod only

Other actuator endpoints (env, beans, mappings, heapdump) MUST be disabled in prod or restricted to an internal network.


19. OpenAPI Contract-First

19.1 Workflow

  1. Edit src/main/resources/openapi/{service}.yaml.
  2. Generate DTO + interfaces with the OpenAPI Generator Gradle plugin (org.openapitools.generator).
  3. Implement the interface in a @RestController.
  4. CI runs /arch-api to verify spec ↔ controller ↔ DTO consistency.
  5. Publish the spec at /v3/api-docs (springdoc-openapi).

19.2 Spec hygiene

  • info.version follows SemVer.
  • Every operation has summary, description, operationId, request/response examples, all error responses.
  • Schema components reused — no inline duplicates.
  • Enum values listed explicitly.
  • Pagination, error envelope, idempotency headers defined as reusable components.
  • Security schemes (bearerAuth, oauth2) referenced per operation.

19.3 Breaking change detection

/arch-api / arch-api-contract-validator agent compares the working tree’s OpenAPI YAML against the last released tag and flags:

  • Removed endpoints, methods, fields.
  • Type changes.
  • Enum narrowing.
  • Required-flag flipping.
  • Status code semantic changes.

Block PRs with breaking changes unless a major version bump is in the same PR.


20. Deprecation Lifecycle

20.1 Stages

  1. Mark deprecated in OpenAPI: deprecated: true on operation/field.
  2. Announce to consumers via changelog, status page, email — at least N months before sunset (default N=6 for public, 3 for internal).
  3. Serve with deprecation headers:
    Deprecation: Wed, 01 Jul 2026 00:00:00 GMT
    Sunset: Wed, 01 Jan 2027 00:00:00 GMT
    Link: <https://docs.example.com/api/migration-v1-v2>; rel="deprecation",
    <https://api.example.com/api/v2/invoices>; rel="successor-version"
  4. Sunset — return 410 Gone with a Problem Details body pointing to the successor.

20.2 Don’t

  • Don’t silently remove a v1 endpoint without 410. Clients deserve a clear signal.
  • Don’t break v1 to “push” clients to v2. Coexistence > coercion.
  • Don’t bump major version for additive changes; reserve majors for actual breakage.

21. Webhooks (Outbound)

When this service notifies external consumers via HTTP callback:

21.1 Contract

  • Method: POST.
  • Body: same Problem-Details-style envelope as your APIs, or a domain event payload.
  • Signed via HMAC-SHA256:
    X-Webhook-Signature: t=1719456000,v1=base64(hmac_sha256(secret, t + "." + body))
    X-Webhook-Id: <uuid>
    X-Webhook-Topic: invoice.created
  • Consumer verifies signature; rejects on mismatch.

21.2 Delivery

  • At-least-once. Consumers MUST be idempotent (use X-Webhook-Id).
  • Retry with exponential backoff (e.g., 1m, 5m, 15m, 1h, 6h, 24h — abandon after 24h).
  • Dead-letter queue for permanently failing deliveries.
  • Allow consumer to configure their endpoint URL + secret via your API.

21.3 Backed by outbox

The publisher reads from the transactional outbox — never from a raw “send-and-forget” call inline with the business write.


22. Anti-patterns

Anti-pattern Why it’s wrong Use instead
GET /getInvoices RPC, not REST GET /invoices
POST /invoices/delete/{id} Method-in-URL DELETE /invoices/{id}
Returning 200 OK with {"success": false, ...} Hides errors from HTTP layer Use proper status + Problem Details
Sequential integer IDs in public URLs Leaks volume UUID
Floats for money Precision loss Integer minor units + currency code
boolean flag growing into tri-state Ambiguous semantics Enum
Exposing the JPA entity as a response Couples API to schema; over-fetches DTO + mapper
@Transactional on the controller Spring leaks into orchestration layer Use case + Transaction port
Two endpoints differing only by query flag (?bulk=true) Hidden behavior Separate endpoint
Version in Accept header only Hard to log, cache, route URL path
Returning 200 OK for create Lose Location and 201 semantics 201 Created + Location
Body on GET / DELETE Many proxies strip it Move to POST or query params
Returning a snapshot count without pagination Memory explosion at scale Paginate from day 1
Endpoint name changes between minor versions Breaks clients Major bump or coexist
Catching all exceptions and returning 500 Hides bugs; clients can’t recover Map domain exceptions to specific statuses
Auth via custom header X-User-Id Trivially spoofable OAuth2/JWT in Authorization

23. Pre-Merge Checklist

For any PR that adds or modifies a REST endpoint:

  • OpenAPI YAML updated. Schema components reused, not duplicated.
  • /arch-api passes — no breaking change against last released tag (or major version bumped if intentional).
  • DTO is a separate class from domain entity; mapper handles translation.
  • Controller depends on application port, not domain directly.
  • No @Transactional on controller; transaction port used inside use case.
  • Method + status code match §4 matrix.
  • Error responses use Problem Details (RFC 9457) with stable code.
  • Bean Validation on DTO; domain invariants in constructors.
  • Pagination defined (cursor preferred) if endpoint returns a collection.
  • Idempotency-Key honored if endpoint is non-idempotent write with side effects.
  • Rate limit defined (see security.md §11).
  • AuthN/Z applied at adapter, not use case (see security.md §3).
  • Sensitive endpoints return uniform 404 instead of 403.
  • Caching headers set (Cache-Control, ETag if applicable).
  • No PII / secrets in logs; structured logging includes traceId.
  • OpenAPI examples added for request + each response status.
  • Contract tests (Karate, Pact, or MockMvc) cover happy path + key error cases.
  • Negative auth tests cover anonymous, wrong role, cross-tenant.
  • Deprecation headers added if replacing a prior version of an existing endpoint.
  • ADR drafted if the change introduces a new versioning policy, error format, or pagination style.