# Reusable JSON Schema components in OpenAPI: DRY models without $ref spaghetti

The first OpenAPI document most teams write inlines every request and response schema next to its operation. It is fast to start and painful by month three: the same customer object is shaped three different ways across create, retrieve, and update, and fixing a field means hunting through dozens of paths. The second document over-corrects and extracts every trivial shape into `components`, producing a maze where reading one operation requires chasing ten references. Good component design sits between those two, guided by reuse and by what code generation needs.

## Extract for reuse, not for tidiness

Promote a schema to `components.schemas` when one of these is true:

-   The same structure appears in two or more operations, such as a customer representation returned by several endpoints.
-   It is a domain concept with a stable name and lifecycle, such as an `Order`, `Invoice`, or `Payment`.
-   It participates in composition, for example a discriminated union of event payloads.
-   It is referenced by another component.

Keep a shape inline when it is used exactly once and has no independent identity. A one-off query envelope that only one endpoint accepts does not deserve a global name. This is the same heuristic a code-to-spec scanner applies: named declarations in the source become components with `$ref`s, while anonymous shapes stay inline at their point of use.

``` yaml
components:
  schemas:
    Money:
      type: object
      required: [amount, currency]
      properties:
        amount: { type: string, description: "Decimal amount as a string" }
        currency: { type: string, pattern: "^[A-Z]{3}$" }

    Order:
      type: object
      required: [id, status, total]
      properties:
        id: { type: string, format: uuid }
        status: { $ref: "#/components/schemas/OrderStatus" }
        total: { $ref: "#/components/schemas/Money" }
        createdAt: { type: string, format: date-time }

    OrderStatus:
      type: string
      enum: [pending, paid, shipped, cancelled, refunded]
```

`Money` is extracted because it is reused across orders, invoices, and refunds. An endpoint-specific filter object unique to one report stays inline.

## Name by domain concept and role

Component names are a public vocabulary; clients generate types from them. A few conventions prevent the usual sprawl:

-   Use the domain noun, optionally qualified by role: `Order`, `CreateOrderRequest`, `OrderResponse`. Avoid generic names like `Data` or `Payload`.
-   Distinguish representations that genuinely differ. A create request and a full resource are not the same shape; `CreateOrderRequest` carries no `id` or `status`, while `Order` does.
-   Keep enums as named components when they are shared or semantically meaningful, so generated code produces one union type instead of duplicated string literals.
-   Never let two different structures share a name. If a scan or merge encounters a collision, the newcomer is renamed and its references rewritten rather than silently overwriting the original.

The goal is that a consumer reading `$ref: "#/components/schemas/OrderResponse"` already knows what they are getting.

## Compose instead of copy

OpenAPI 3.1 and 3.2 align with JSON Schema 2020-12, which makes composition cleaner than in 3.0. Model variants with `allOf` and tagged unions with `discriminator`:

``` yaml
    CreateOrderRequest:
      type: object
      required: [items, currency]
      properties:
        items:
          type: array
          minItems: 1
          items: { $ref: "#/components/schemas/CreateOrderItem" }
        currency: { type: string, pattern: "^[A-Z]{3}$" }
        note: { type: string, maxLength: 500 }

    NotificationEvent:
      type: object
      discriminator:
        propertyName: type
      required: [type]
      properties:
        type: { type: string }
      oneOf:
        - $ref: "#/components/schemas/OrderPaidEvent"
        - $ref: "#/components/schemas/OrderRefundedEvent"
```

Since 3.1, `$ref` is a normal JSON Schema keyword and can sit alongside annotations such as `description` and `nullable` wrappers rather than being wrapped in an `allOf` purely to attach text. Use that freedom sparingly; a reference with a short clarifying description is fine, but overriding the referenced schema next to the reference usually signals a missing, properly named component.

## Reuse parameters and responses too

Components are not only schemas. Pagination and sorting repeat across collections and belong in `components.parameters`; common error envelopes belong in `components.responses`:

``` yaml
components:
  parameters:
    PageParam:
      name: page
      in: query
      schema: { type: integer, minimum: 1, default: 1 }
    PageSizeParam:
      name: "page_size"
      in: query
      schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
  responses:
    Unauthorized:
      description: Authentication missing or invalid
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
```

This keeps page-size limits consistent in one place instead of drifting to `50` on one endpoint and `1000` on the next.

## The failure modes to design out

-   **The god entity.** Reusing the full database model for every response leaks internal columns and makes every field look writable. Define response-specific components that expose only what the operation returns.
-   **Deep reference chains.** If understanding a field requires following `$ref` through four files, the abstraction has gone too far; flatten where the indirection adds no reuse.
-   **Dead components.** A component referenced by nothing is dead weight that readers assume is public. Bundle the document and prune unreferenced definitions, or at least list them in review.
-   **Circular references used carelessly.** Parent-child recursion is legitimate and well supported, but cycles across unrelated domain objects usually indicate a modeling mistake.
-   **Copy-paste divergence.** Two `Address` components that differ by one forgotten field are worse than one shared component, because consumers cannot tell which is authoritative.

## Keep it machine-verifiable

Treat component hygiene like any other contract check: bundle multi-file specs into one document for review, run a linter for unused or duplicated components, and confirm that generated TypeScript or Python types compile against the spec. When the spec is reverse-engineered from code, a three-way merge on rescan should add newly discovered components additively, rename collisions, and preserve the components and descriptions you authored by hand rather than regenerating over them.

A small, named, genuinely reused component library is what makes an OpenAPI document a useful source for documentation, typed clients, mocks, and agent tools; a pile of inline duplicates or a forest of one-off references is what makes teams stop trusting it. The Powerduck workspace includes visual schema and component design on top of the same local spec, and the code-to-spec path that turns named source declarations into these components is summarized in the [code-to-OpenAPI overview](https://www.powerduck.com/blog/generate-openapi-from-existing-code/); rendering them into modern docs is covered in [modern API documentation from OpenAPI](https://www.powerduck.com/blog/modern-openapi-documentation-2026/).

