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-apibefore merge.
Project-agnostic: code samples use placeholder package
com.example.platform, service nameplatform-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-api → arch-api-contract-validator (detects breaking changes vs released spec).
Table of Contents
- Design Principles
- Resource Modeling
- URL Conventions
- HTTP Methods & Status Codes
- Versioning Strategy
- Request & Response Shape
- Error Envelope (RFC 9457)
- Pagination
- Filtering, Sorting, Sparse Fieldsets
- Idempotency
- Caching
- Rate Limiting Headers
- Content Negotiation
- HATEOAS
- Async & Long-Running Operations
- Bulk Operations
- File Upload & Download
- Health, Info, Metrics
- OpenAPI Contract-First
- Deprecation Lifecycle
- Webhooks (Outbound)
- Anti-patterns
- 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
Authorizationheader. - 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.htmlin 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 /payInvoicePOST /accounts/{id}:close # Google-style colon syntax acceptablePOST /reports/{id}/exports # creates an export jobAvoid /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 timedata class CreateInvoiceRequest( val customerId: UUID, val amountCents: Long, val currency: String, val dueDate: LocalDate)
// Maps to a commandfun 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 DTOfun 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/purchaseOrdersor/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}servicesegment 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 withPathPatternParserand 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.
- JSON Merge Patch (RFC 7396) — simpler, default recommendation.
- 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/invoicesWhy 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-ndjsonfor streaming,text/csvfor export,application/octet-streamfor 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,Zsuffix). - Local date:
2026-06-10. - Local time:
15:30:00. - Duration:
PT1H30M(ISO 8601 duration).
- Instant:
- 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
nullmeans “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 ContentContent-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
@RestControllerAdviceclass 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=eyJpZCI6IjEyMyJ9Response:
{ "items": [ ... ], "page": { "limit": 50, "nextCursor": "eyJpZCI6IjE3MyJ9", "prevCursor": null }}Why cursor over offset:
- Stable under concurrent inserts (offset shifts).
- Cheaper at scale —
WHERE id > last_iduses an index;OFFSET 100000scans 100k rows. - Opaque cursor encodes sort + position; clients can’t tamper.
8.2 Offset-based (acceptable for small datasets)
GET /invoices?page=3&size=50Response 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
limitdefault: 50. Max: 200. Reject larger with 400.- Page size MUST be enforced server-side; never trust client to ask for “all”.
8.4 Link header (alternative to in-body)
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-01For 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-01Document 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,ascValidate 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,amountCentsValidate 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/paymentsIdempotency-Key: 9b8c7d6e-5f4a-3b2c-1d0e-9f8a7b6c5d4e10.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 ModifiedETag 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 OKRateLimit-Limit: 100RateLimit-Remaining: 73RateLimit-Reset: 47On exceed:
HTTP/1.1 429 Too Many RequestsRateLimit-Limit: 100RateLimit-Remaining: 0RateLimit-Reset: 47Retry-After: 47Use 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
RateLimitheader (e.g.RateLimit: limit=100, remaining=0, reset=47). The separateRateLimit-Limit/RateLimit-Remaining/RateLimit-Resetshown 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 Acceptableif 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.5Server 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: PENDING → RUNNING → SUCCEEDED | 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:batchCreateContent-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 /attachmentsContent-Type: multipart/form-data; boundary=...
--boundaryContent-Disposition: form-data; name="file"; filename="invoice.pdf"Content-Type: application/pdf
<binary>--boundary--Presigned-URL upload (large files):
POST /attachments→ returns presigned S3/GCS URL.- Client
PUTs file directly to object storage. - Client
POST /attachments/{id}/finalizeconfirms 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: 12345For 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
- Edit
src/main/resources/openapi/{service}.yaml. - Generate DTO + interfaces with the OpenAPI Generator Gradle plugin (
org.openapitools.generator). - Implement the interface in a
@RestController. - CI runs
/arch-apito verify spec ↔ controller ↔ DTO consistency. - Publish the spec at
/v3/api-docs(springdoc-openapi).
19.2 Spec hygiene
info.versionfollows 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
- Mark deprecated in OpenAPI:
deprecated: trueon operation/field. - Announce to consumers via changelog, status page, email — at least N months before sunset (default N=6 for public, 3 for internal).
- Serve with deprecation headers:
Deprecation: Wed, 01 Jul 2026 00:00:00 GMTSunset: Wed, 01 Jan 2027 00:00:00 GMTLink: <https://docs.example.com/api/migration-v1-v2>; rel="deprecation",<https://api.example.com/api/v2/invoices>; rel="successor-version"
- Sunset — return
410 Gonewith 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-apipasses — 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
@Transactionalon 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,ETagif 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.