Building Blocks: The Power of Composition

You've defined resource schemas with properties and validation. But what if you have common fields across multiple resources? Or multiple valid structures for the same field?

JSON Schema provides composition keywords that let you combine schemas like building blocks:

  • allOf - Must match ALL of the schemas (intersection)
  • anyOf - Must match AT LEAST ONE schema (union)
  • oneOf - Must match EXACTLY ONE schema (exclusive union)

These patterns let you write DRY (Don't Repeat Yourself) schemas and model complex validation logic elegantly.

allOf: Combining Schemas (Intersection)

allOf means "this must match ALL of these schemas." It's perfect for:

  • Extending a base schema with additional fields
  • Combining common properties
  • Adding constraints to existing schemas

Example: Base + Extensions

Imagine you have a base Person schema and want to extend it for different types:

# Common base properties
definitions:
  BasePerson:
    type: object
    properties:
      id:
        type: string
        format: uuid
      name:
        type: string
        minLength: 1
      email:
        type: string
        format: email
    required: [id, name, email]

# Employee extends BasePerson
kind: employees
apiVersion: v1
schema:
  type: array
  key:
    name: employee_id
    schema:
      type: string

  items:
    allOf:
      - $ref: '#/definitions/BasePerson'  # Include all base fields
      - type: object                      # Add employee-specific fields
        properties:
          employee_number:
            type: string
          department:
            type: string
          hire_date:
            type: string
            format: date
        required:
          - employee_number
          - department

What this means:

An employee must have:

  • id, name, email (from BasePerson)
  • employee_number, department (employee-specific)

The resulting schema is the intersection of all schemas in the allOf array.

Example: Adding Validation Constraints

You can use allOf to layer additional validation on top of a base type:

properties:
  username:
    allOf:
      - type: string
      - minLength: 3
        maxLength: 20
      - pattern: '^[a-z0-9_]+$'  # Lowercase alphanumeric + underscore
      - not:
          enum: [admin, root, system]  # Forbidden values

Each schema in allOf adds a constraint. The username must satisfy ALL of them.

anyOf: At Least One Match (Union)

anyOf means "this must match AT LEAST ONE of these schemas." Use it when:

  • A field can have multiple valid formats
  • You want flexible validation
  • You're modeling unions of types

Example: Multiple Contact Methods

A contact field can be either an email OR a phone number (or both):

properties:
  contact:
    anyOf:
      - type: object
        properties:
          email:
            type: string
            format: email
        required: [email]

      - type: object
        properties:
          phone:
            type: string
            pattern: '^\+?[1-9]\d{1,14}$'
        required: [phone]

      - type: object
        properties:
          email:
            type: string
            format: email
          phone:
            type: string
            pattern: '^\+?[1-9]\d{1,14}$'
        required: [email, phone]

Valid inputs:

{"contact": {"email": "user@example.com"}}
{"contact": {"phone": "+12345678901"}}
{"contact": {"email": "user@example.com", "phone": "+12345678901"}}

All three are valid because each matches at least one schema.

Example: Flexible Value Types

Allow a field to be either a string or a number:

properties:
  quantity:
    anyOf:
      - type: integer
        minimum: 0
      - type: string
        pattern: '^\d+$'

Valid inputs:

{"quantity": 42}
{"quantity": "42"}

Both validate successfully.

oneOf: Exactly One Match (Exclusive Union)

oneOf means "this must match EXACTLY ONE of these schemas." It's stricter than anyOf—if multiple schemas match, validation fails.

Use oneOf when:

  • You have mutually exclusive options
  • You want to enforce polymorphic types
  • You need a discriminated union

Example: Payment Methods

A payment can be EITHER credit card OR bank transfer, but not both:

kind: payments
apiVersion: v1
schema:
  type: array
  key:
    name: payment_id
    schema:
      type: string

  items:
    type: object
    properties:
      amount:
        type: number
        minimum: 0

      method:
        type: string
        enum: [credit_card, bank_transfer]

      payment_details:
        oneOf:
          - type: object  # Credit card
            properties:
              card_number:
                type: string
                pattern: '^\d{16}$'
              expiry_month:
                type: integer
                minimum: 1
                maximum: 12
              expiry_year:
                type: integer
              cvv:
                type: string
                pattern: '^\d{3,4}$'
            required: [card_number, expiry_month, expiry_year, cvv]

          - type: object  # Bank transfer
            properties:
              account_number:
                type: string
              routing_number:
                type: string
              account_holder:
                type: string
            required: [account_number, routing_number, account_holder]

    required: [amount, method, payment_details]

Valid:

{
  "amount": 100.00,
  "method": "credit_card",
  "payment_details": {
    "card_number": "1234567812345678",
    "expiry_month": 12,
    "expiry_year": 2025,
    "cvv": "123"
  }
}

Invalid:

{
  "amount": 100.00,
  "method": "credit_card",
  "payment_details": {
    "card_number": "1234567812345678",
    "account_number": "9876543210"  // ❌ Matches BOTH schemas
  }
}

Example: Discriminated Unions with discriminator

For better API documentation, combine oneOf with OpenAPI's discriminator:

properties:
  shape:
    oneOf:
      - type: object
        properties:
          type:
            const: circle
          radius:
            type: number
        required: [type, radius]

      - type: object
        properties:
          type:
            const: rectangle
          width:
            type: number
          height:
            type: number
        required: [type, width, height]

    discriminator:
      propertyName: type
      mapping:
        circle: '#/definitions/Circle'
        rectangle: '#/definitions/Rectangle'

The discriminator tells API clients: "Look at the type field to determine which schema applies."

Combining Composition Keywords

You can nest and combine these keywords for sophisticated validation:

# A user must be either an admin OR (a regular user with an email)
allOf:
  - type: object
    properties:
      id: {type: string}
      name: {type: string}
    required: [id, name]

  - oneOf:
      - type: object
        properties:
          role:
            const: admin
        required: [role]

      - type: object
        properties:
          role:
            const: user
          email:
            type: string
            format: email
        required: [role, email]

Valid:

{"id": "1", "name": "Alice", "role": "admin"}
{"id": "2", "name": "Bob", "role": "user", "email": "bob@example.com"}

Invalid:

{"id": "3", "name": "Charlie", "role": "user"}  // ❌ Missing email

Real-World Pattern: API Versioning

Use composition to support multiple API versions:

kind: resources
apiVersion: v1
definitions:
  ResourceV1:
    type: object
    properties:
      name: {type: string}
      value: {type: string}

  ResourceV2:
    type: object
    properties:
      name: {type: string}
      value: {type: number}
      unit: {type: string}

schema:
  type: array
  key:
    name: resource_id
    schema:
      type: string

  items:
    oneOf:
      - allOf:
          - $ref: '#/definitions/ResourceV1'
          - type: object
            properties:
              api_version: {const: "v1"}
      - allOf:
          - $ref: '#/definitions/ResourceV2'
          - type: object
            properties:
              api_version: {const: "v2"}

Clients can send either v1 or v2 format, identified by api_version.

Common Pitfalls and Solutions

Pitfall 1: oneOf Ambiguity

Problem: Multiple schemas match, causing validation to fail.

oneOf:
  - type: object
    properties:
      email: {type: string}
  - type: object
    properties:
      email: {type: string}
      phone: {type: string}

Sending {"email": "test@example.com"} matches BOTH schemas!

Solution: Make schemas mutually exclusive:

oneOf:
  - type: object
    properties:
      email: {type: string}
    required: [email]
    additionalProperties: false  # No other properties allowed

  - type: object
    properties:
      email: {type: string}
      phone: {type: string}
    required: [email, phone]
    additionalProperties: false

Pitfall 2: allOf Contradictions

Problem: Schemas in allOf contradict each other.

allOf:
  - type: string
  - type: number

Nothing can be both a string AND a number. This schema is impossible to satisfy.

Solution: Ensure all schemas in allOf are compatible. Use allOf to add constraints, not to change types.

Pitfall 3: Performance with Deep Nesting

Problem: Deeply nested composition can slow down validation.

allOf:
  - allOf:
      - allOf:
          - type: object

Solution: Flatten when possible:

allOf:
  - type: object
  - properties: {...}
  - required: [...]

Best Practices

1. Use allOf for Extension

When you have a base schema and want to add fields:

allOf:
  - $ref: '#/definitions/BaseResource'
  - type: object
    properties:
      extra_field: {type: string}

2. Use oneOf for Polymorphism

When you have mutually exclusive types:

oneOf:
  - $ref: '#/definitions/TypeA'
  - $ref: '#/definitions/TypeB'

3. Use anyOf Sparingly

anyOf is flexible but can be confusing. Prefer oneOf when possible for clarity.

4. Add Discriminators

For oneOf, use discriminator to improve generated API docs:

oneOf:
  - ...
discriminator:
  propertyName: type

5. Document Your Intent

Add descriptions explaining WHY you're using composition:

payment_details:
  description: |
    Payment details. Must be either credit card OR bank transfer.
    Use the 'method' field to indicate which type.
  oneOf:
    - $ref: '#/definitions/CreditCardDetails'
    - $ref: '#/definitions/BankTransferDetails'

Testing Composition Schemas

Always test edge cases:

# Test that ALL schemas in allOf are required
# Test that ONLY ONE schema in oneOf matches
# Test that AT LEAST ONE schema in anyOf matches
# Test invalid combinations

Use Firestone's Swagger UI server to interactively test:

firestone generate \
  --resources resource.yaml \
  openapi \
  --ui-server

Try submitting various payloads and see which ones validate.

Next Steps

You've mastered schema composition. Explore:


Remember: Composition is powerful, but keep it simple. If your composition logic requires a PhD to understand, you've gone too far. Aim for clarity over cleverness.