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, orPayment. - 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 $refs, while anonymous shapes stay inline at their point of use.
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 likeDataorPayload. - Distinguish representations that genuinely differ. A create request and a full resource are not the same shape;
CreateOrderRequestcarries noidorstatus, whileOrderdoes. - 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:
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:
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
$refthrough 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
Addresscomponents 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; rendering them into modern docs is covered in modern API documentation from OpenAPI.
Top comments (0)