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.

Resource boundary design: flat vs over-nested URIs Two columns comparing an anti-pattern with a deeply nested URI against the recommended flat URI with query parameters, with domain ownership labels. Anti-pattern: over-nested path Recommended: flat path + query /users/{id}/orders/{id}/payments /payments?account_id={id} User domain Order domain Payment domain 3 domain owners, tight coupling Breaks when any domain is split Payment domain only Single owner, cross-domain filter via query parameter URI reveals infrastructure Hard to version independently Authorization spans 3 services URI hides infrastructure Independent versioning possible Authorization at one boundary

Implementation checklist:

  1. Map entities to URIs. Assign one primary resource per URI path (/v1/accounts, /v1/invoices). Avoid composite paths that leak internal service boundaries.
  2. 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.
  3. 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.