Resource Modeling Best Practices for REST APIs
Part of the API Design Fundamentals & Architecture reference, this page covers the specific failure modes that emerge when resource boundaries, schema validation, and client-generation pipelines are designed without a contract-first discipline. The contract breaks manifest at runtime: SDK deserialization crashes, N+1 query storms, stale cache reads, and duplicate mutations from clients that retry without idempotency key implementation.
The problem this topic addresses
Resource modeling drift is the gap between what your OpenAPI specification says a resource looks like and what your service actually returns. It accumulates silently — a field renamed during a sprint, a required property made optional without a version bump, a nested path added that couples two previously independent domains. By the time the drift surfaces it has already broken generated clients in staging or, worse, in production.
The patterns below close that gap at the boundary where it is cheapest to fix: the spec itself, the CI pipeline, and the code-generation toolchain.
Spec definition: the resource contract anchor
Every resource modeling decision should start with a minimal, precise OpenAPI 3.1.0 schema. The snippet below captures the three primitives that appear across all the patterns on this page: a typed resource with a discriminated union, cursor pagination, and an idempotency key parameter.
openapi: "3.1.0"
info:
title: Payments API
version: "1.0.0"
paths:
/v1/payments:
get:
operationId: listPayments
parameters:
- name: cursor
in: query
schema: { type: string }
description: "Opaque cursor from the previous page's meta.next_cursor"
- name: limit
in: query
schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
responses:
"200":
content:
application/json:
schema: { $ref: "#/components/schemas/PaginatedPayments" }
post:
operationId: createPayment
parameters:
- name: Idempotency-Key
in: header
required: true
schema: { type: string, format: uuid }
description: "Client-generated UUID; deduplicated for 24 h"
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/PaymentCreate" }
responses:
"201":
content:
application/json:
schema: { $ref: "#/components/schemas/Payment" }
components:
schemas:
Payment:
type: object
required: [id, type, created_at]
discriminator:
propertyName: type
mapping:
card: "#/components/schemas/CardPayment"
wire: "#/components/schemas/WirePayment"
oneOf:
- $ref: "#/components/schemas/CardPayment"
- $ref: "#/components/schemas/WirePayment"
CardPayment:
type: object
required: [id, type, last4, created_at]
properties:
id: { type: string, pattern: "^pay_[A-Za-z0-9]{16}$" }
type: { type: string, const: "card" }
last4: { type: string, pattern: "^[0-9]{4}$" }
created_at: { type: string, format: date-time }
WirePayment:
type: object
required: [id, type, routing_number, created_at]
properties:
id: { type: string, pattern: "^pay_[A-Za-z0-9]{16}$" }
type: { type: string, const: "wire" }
routing_number: { type: string, pattern: "^[0-9]{9}$" }
created_at: { type: string, format: date-time }
PaymentCreate:
oneOf:
- $ref: "#/components/schemas/CardPayment"
- $ref: "#/components/schemas/WirePayment"
PaginatedPayments:
type: object
required: [data, meta, links]
properties:
data:
type: array
items: { $ref: "#/components/schemas/Payment" }
meta:
type: object
required: [total_count]
properties:
total_count: { type: integer }
next_cursor: { type: string, nullable: true }
links:
type: object
properties:
self: { type: string, format: uri }
next: { type: string, format: uri, nullable: true }
RFC and standard alignment
| Decision | Governing standard | Key clause |
|---|---|---|
| Opaque resource IDs in URIs | RFC 3986 §3.3 | Path segments are opaque; internal PKs must not be exposed |
ETag / If-None-Match conditional GET |
RFC 9110 §8.8.3 | Servers MUST return 304 Not Modified when ETag matches |
| Idempotency key on POST | RFC 9110 §9.2.2 | POST is not inherently idempotent; client key makes it safe to retry |
| Nested vs flat URI design | RFC 3986 §3.3, REST constraint | Sub-resources allowed; cross-domain nesting violates uniform interface |
discriminator / polymorphic schema |
OpenAPI 3.1.0 §4.8.25 | oneOf + discriminator.propertyName required for type narrowing in codegen |
| Cursor pagination | IETF RFC 5988 (Web Linking) | links.next as a URI relation, not a raw offset |
Step 1 — Defining resource boundaries (server-side)
Establish clear domain boundaries by mapping business entities to discrete, self-contained endpoints. Resource boundaries should mirror bounded contexts in your domain: each endpoint owns exactly one aggregate root. When path nesting spans multiple bounded contexts it leaks internal service topology to every client.
Implementation checklist:
- Map entities to URIs. Assign one primary resource per URI path (
/v1/accounts,/v1/invoices). Avoid composite paths that leak internal service boundaries. - Define an ownership matrix. Document which team or service owns the schema, lifecycle, and mutation contracts for each resource. Review this matrix when a cross-resource join is proposed.
- Enforce via PR review. Require architectural sign-off when introducing cross-resource paths that span multiple bounded contexts or when nesting depth exceeds one level.
Use flat paths with query parameters for cross-domain filtering (/payments?account_id={id}) rather than encoding the cross-domain relationship in the path itself.
Step 2 — Spec-first schema validation in CI
Automated OpenAPI linting and JSON Schema validation gates catch modeling drift before deployment. Enforce naming conventions, required fields, and response contracts on every pull request targeting the main branch. This is the counterpart to statelessness and caching strategies — stateless design decisions only hold in production if the contracts encoding them survive CI.
GitHub Actions pipeline:
name: OpenAPI Contract Validation
on: [pull_request]
jobs:
validate-spec:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install linters
run: npm install -g @redocly/cli @stoplight/spectral-cli
- name: Lint OpenAPI spec
run: spectral lint openapi.yaml --ruleset .spectral.yaml
- name: Validate OpenAPI structure
run: redocly lint openapi.yaml
- name: Check backward compatibility
run: redocly diff main:openapi.yaml HEAD:openapi.yaml --fail-on breaking
Key Spectral rules (.spectral.yaml):
rules:
operation-operationId-unique: true
operation-parameters: true
schema-type-enum: true
path-params-camel-case:
description: "All path parameters must use camelCase"
severity: error
given: "$.paths[*].parameters[?(@.in=='path')]"
then:
field: name
function: pattern
functionOptions:
match: "^[a-z][A-Za-z0-9]*$"
require-idempotency-key-on-post:
description: "POST endpoints must declare an Idempotency-Key header"
severity: error
given: "$.paths[*].post"
then:
field: parameters
function: schema
functionOptions:
schema:
type: array
contains:
properties:
name: { const: "Idempotency-Key" }
in: { const: "header" }
no-internal-db-ids:
description: "Path parameter names must not expose internal PK conventions"
severity: warn
given: "$.paths[*].parameters[?(@.in=='path')]"
then:
field: name
function: pattern
functionOptions:
notMatch: "^(pk|row_id|internal_id)$"
This pipeline blocks merges if required fields are removed, naming conventions are violated, or breaking changes are introduced without an explicit version bump.
Step 3 — Type-safe client generation
Configure codegen toolchains to produce strongly-typed SDKs. The discriminator in the spec above maps directly to an exhaustive TypeScript union, eliminating runtime typeof guards and the runtime errors that occur when a new payment type is added without updating clients. Pair this with the HTTP method mapping guidelines to ensure generated clients emit the correct verb for each mutation.
TypeScript SDK generation:
npx @openapitools/openapi-generator-cli generate \
-i openapi.yaml \
-g typescript-axios \
--additional-properties=stringEnums=true,supportsES6=true,enumPropertyNaming=original \
-o ./clients/typescript-sdk
Generated discriminated union — exhaustive narrowing in TypeScript:
import { PaymentApi, Payment } from "./generated";
const api = new PaymentApi();
async function describePayment(id: string): Promise<string> {
const { data } = await api.getPayment(id);
// TypeScript narrows the union via the discriminator
switch (data.type) {
case "card":
return `Card ending ${data.last4}`;
case "wire":
return `Wire to routing ${data.routing_number}`;
default:
// Compile-time exhaustiveness check
const _exhaustive: never = data;
throw new Error(`Unknown payment type`);
}
}
Python async client generation:
npx @openapitools/openapi-generator-cli generate \
-i openapi.yaml \
-g python \
--additional-properties=asyncio=true,packageName=payments_client \
-o ./clients/python-sdk
# Generated client with async support and type hints
from payments_client import PaymentsApi, ApiClient, Configuration
from payments_client.models import CardPayment, WirePayment
async def describe_payment(payment_id: str) -> str:
async with ApiClient(Configuration(host="https://api.example.com")) as client:
api = PaymentsApi(client)
payment = await api.get_payment(payment_id)
if isinstance(payment, CardPayment):
return f"Card ending {payment.last4}"
elif isinstance(payment, WirePayment):
return f"Wire to routing {payment.routing_number}"
raise ValueError(f"Unhandled payment type: {payment.type}")
Edge-case handling
Bulk / batch endpoints. When clients need multiple resources in one round-trip, expose a POST /v1/resources/batch endpoint that accepts an ids array rather than allowing N parallel GETs. Define the batch response contract in OpenAPI so codegen can produce a typed batch method:
paths:
/v1/payments/batch:
post:
operationId: batchGetPayments
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [ids]
properties:
ids:
type: array
items: { type: string }
maxItems: 100
responses:
"200":
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/Payment" }
errors:
type: array
items:
type: object
properties:
id: { type: string }
message: { type: string }
Conditional updates with ETag. Resource versioning via ETag prevents lost-update races on concurrent PUT/PATCH requests. The server returns an ETag on every GET; subsequent mutations must supply If-Match. A 412 Precondition Failed signals a version conflict without masking it as a generic 409.
Polymorphic schema evolution. When adding a new payment type, add a new $ref to the oneOf array and add a new mapping entry in the discriminator. Old clients that deserialize the union will fail at the switch/isinstance check rather than silently coercing the wrong type — making the gap visible and drivable by a runtime feature flag.
Validation and testing patterns
Contract tests verify the live service against the spec, not just the spec against itself. Run Prism in proxy mode to assert that every production response matches the declared schema:
# Start Prism in validation proxy mode
npx @stoplight/prism-cli proxy openapi.yaml https://api.example.com --errors
# Run contract test suite against the proxied endpoint
npx jest --testPathPattern=contract
Vitest contract test example:
import { describe, it, expect } from "vitest";
import { PaymentApi } from "./generated";
const api = new PaymentApi({ basePath: "http://localhost:4010" }); // Prism port
describe("GET /v1/payments", () => {
it("returns a paginated response matching the spec schema", async () => {
const res = await api.listPayments({ limit: 5 });
expect(res.data.data).toBeInstanceOf(Array);
expect(res.data.meta).toHaveProperty("total_count");
expect(res.data.links).toHaveProperty("self");
});
it("includes a next_cursor when more pages exist", async () => {
// Arrange: seed > 5 payments in test fixture
const res = await api.listPayments({ limit: 5 });
if (res.data.meta.next_cursor) {
const page2 = await api.listPayments({ cursor: res.data.meta.next_cursor });
expect(page2.data.data.length).toBeGreaterThan(0);
}
});
});
SDK generation impact
| Spec decision | TypeScript effect | Python effect |
|---|---|---|
discriminator + oneOf |
Generates a tagged union type; narrowing is exhaustive and compile-time checked | Generates isinstance checks; missing branches raise ValueError at runtime |
required: [id, type] on every variant |
Missing fields cause a type error at the assignment site | pydantic model raises ValidationError on construction |
format: uuid on Idempotency-Key |
Type is string; no UUID validation in the generated client |
Same — format hints are advisory unless a custom serializer is configured |
maxItems: 100 on batch ids array |
OpenAPI Generator emits a validation comment, not a runtime check | Same — enforce server-side; do not rely on generated client validation |
nullable: true on next_cursor |
Union type string | null; caller must null-check before paginating |
Optional[str]; if page.meta.next_cursor: guard required |
Anti-patterns quick reference
| Anti-pattern | Impact | Fix |
|---|---|---|
Leaking database PKs in public URIs (e.g. /users/42) |
Exposes infrastructure, enables enumeration attacks | Use opaque IDs: pay_A3f9kLmQ2wXr8pNv |
Over-nested paths (/orgs/{id}/teams/{id}/members/{id}/roles) |
Tight coupling, brittle client routing, hard to version independently | Flatten to /roles?member_id={id} |
Missing ETag / Last-Modified headers on mutable resources |
Cache inconsistency, stale reads, wasted bandwidth | Compute version hash in middleware; return 304 Not Modified |
| Spec drift between OpenAPI and runtime payloads | SDK generation failures, client deserialization crashes | CI validation gate + mock server contract tests on every PR |
No Idempotency-Key on POST mutations |
Duplicate charges or state corruption on network retries | Require the header in the spec; store key + response for 24 h |
anyOf without a discriminator |
Codegen produces any or object instead of a typed union |
Always pair oneOf with discriminator.propertyName |
FAQ
How do I enforce resource modeling consistency across multiple platform teams?
Centralize OpenAPI specifications in a version-controlled monorepo or dedicated spec registry. Mandate shared linting rules via .spectral.yaml, enforce PR templates that require schema validation artifacts, and block merges if spectral lint or backward-compatibility checks fail. Establish an API Review Board for cross-domain boundary changes.
Should I use nested or flat resource paths?
Use nested paths (/parents/{id}/children) only when the child’s lifecycle is strictly owned by the parent and authorization boundaries align. Prefer flat paths (/children?parent_id={id}) for independent scalability, simpler client routing, and easier pagination. Nested paths increase coupling and complicate versioning when child resources evolve independently.
How does spec-first validation prevent client SDK generation failures?
Strict JSON Schema validation, type resolution checks, and OpenAPI compliance gates catch breaking changes — missing required fields, invalid enum values, mismatched discriminator mappings — before codegen runs. Failing early in CI prevents downstream SDKs from generating invalid type unions or broken method signatures, eliminating runtime deserialization crashes.
What CI/CD gates should block PRs with resource schema drift?
Three mandatory gates: (1) automated diff checks using redocly diff to flag removed paths, changed types, or altered required fields; (2) backward compatibility validators that enforce additive-only changes; (3) mock server contract tests using Prism or WireMock spun from the PR spec. Fail the build if any endpoint returns a mismatched status code or payload shape.
Related
- API Design Fundamentals & Architecture — parent reference covering the full contract-first discipline
- How to design RESTful resource hierarchies for microservices — cross-service aggregation and relationship traversal patterns
- Idempotency Key Implementation — deduplication store design and key lifecycle management
- HTTP Method Mapping Guidelines — safe vs unsafe method semantics and their effect on generated clients
- Statelessness & Caching Strategies — ETag,
Cache-Control, and conditional request patterns that depend on stable resource contracts