Skip to content

Monorepo Setup

Contractual works well in monorepos. Each service or domain area can have its own contractual.yaml, its own .contractual/ directory, and its own independent versioning lifecycle. The GitHub Action paths filter keeps CI efficient; only services whose specs changed run the check.

Use a per-service setup (multiple contractual.yaml files) when:

  • Services have different release cadences
  • Different teams own different services and want separate changelogs
  • Contracts are colocated with service code in subdirectories

Use a single root contractual.yaml when:

  • All contracts are released together as a bundle
  • A single team owns all contracts
  • A single unified changelog is desired
my-monorepo/
├── services/
│ ├── orders/
│ │ ├── contractual.yaml # Orders service config
│ │ ├── .contractual/ # Orders versioning state
│ │ │ ├── versions.json
│ │ │ ├── changesets/
│ │ │ └── snapshots/
│ │ └── specs/
│ │ └── orders.openapi.yaml
│ ├── inventory/
│ │ ├── contractual.yaml # Inventory service config
│ │ ├── .contractual/
│ │ │ ├── versions.json
│ │ │ ├── changesets/
│ │ │ └── snapshots/
│ │ └── specs/
│ │ └── inventory.openapi.yaml
│ └── notifications/
│ ├── contractual.yaml
│ ├── .contractual/
│ └── schemas/
│ └── notification-event.json
├── .github/
│ └── workflows/
│ ├── contractual-orders-pr.yml
│ ├── contractual-inventory-pr.yml
│ ├── contractual-orders-release.yml
│ └── contractual-inventory-release.yml
└── package.json

Each service config is self-contained. Paths in the config are relative to the config file itself.

services/orders/contractual.yaml
# yaml-language-server: $schema=https://contractual.dev/schema.json
contracts:
- name: orders-api
type: openapi
path: ./specs/orders.openapi.yaml
services/inventory/contractual.yaml
# yaml-language-server: $schema=https://contractual.dev/schema.json
contracts:
- name: inventory-api
type: openapi
path: ./specs/inventory.openapi.yaml
- name: inventory-events
type: json-schema
path: ./schemas/inventory-event.schema.json

Initialize each service separately:

Terminal window
contractual init --config services/orders/contractual.yaml
contractual init --config services/inventory/contractual.yaml

Create one pair of workflows per service. Use paths to trigger the workflow only when that service’s files change.

.github/workflows/contractual-orders-pr.yml
name: Contractual PR Check - Orders
on:
pull_request:
types: [opened, synchronize, reopened]
paths:
- 'services/orders/specs/**'
- 'services/orders/schemas/**'
- 'services/orders/contractual.yaml'
- 'services/orders/.contractual/**'
permissions:
contents: write
pull-requests: write
jobs:
check:
name: Check orders contracts
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.head_ref }}
- uses: contractual-dev/action@v1
with:
mode: pr-check
github-token: ${{ secrets.GITHUB_TOKEN }}
config-path: services/orders/contractual.yaml
.github/workflows/contractual-inventory-pr.yml
name: Contractual PR Check - Inventory
on:
pull_request:
types: [opened, synchronize, reopened]
paths:
- 'services/inventory/specs/**'
- 'services/inventory/schemas/**'
- 'services/inventory/contractual.yaml'
- 'services/inventory/.contractual/**'
permissions:
contents: write
pull-requests: write
jobs:
check:
name: Check inventory contracts
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.head_ref }}
- uses: contractual-dev/action@v1
with:
mode: pr-check
github-token: ${{ secrets.GITHUB_TOKEN }}
config-path: services/inventory/contractual.yaml
.github/workflows/contractual-orders-release.yml
name: Contractual Release - Orders
on:
push:
branches: [main]
paths:
- 'services/orders/.contractual/changesets/**'
permissions:
contents: write
pull-requests: write
jobs:
release:
name: Version orders contracts
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: contractual-dev/action@v1
with:
mode: release
github-token: ${{ secrets.GITHUB_TOKEN }}
config-path: services/orders/contractual.yaml
version-pr-title: "Version Contracts - Orders"
version-pr-branch: "contractual/version-orders"

Each service has its own .contractual/ directory. Versions are completely independent. orders-api can be at 3.1.0 while inventory-api is at 1.0.0.

This is the setup shown above, one contractual.yaml per service and one .contractual/ per service.

All contracts share one contractual.yaml at the repo root and one .contractual/ directory. All contracts are versioned together. A change to any one of them produces a single Version Contracts PR that bumps all affected contracts.

# contractual.yaml (at repo root)
# yaml-language-server: $schema=https://contractual.dev/schema.json
contracts:
- name: orders-api
type: openapi
path: ./services/orders/specs/orders.openapi.yaml
- name: inventory-api
type: openapi
path: ./services/inventory/specs/inventory.openapi.yaml
- name: inventory-events
type: json-schema
path: ./services/inventory/schemas/inventory-event.schema.json

Use shared versioning when contracts are tightly coupled and consumers expect them to be released as a bundle.

Pass --config to any CLI command to target a specific service:

Terminal window
# Lint only the orders service
contractual lint --config services/orders/contractual.yaml
# Check breaking changes for inventory
contractual breaking --config services/inventory/contractual.yaml
# Version the notifications service
contractual version --config services/notifications/contractual.yaml

For repositories with many services that follow the same pattern, a matrix job reduces workflow duplication:

.github/workflows/contractual-pr.yml
name: Contractual PR Check
on:
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: write
pull-requests: write
jobs:
check:
name: Check ${{ matrix.service }} contracts
runs-on: ubuntu-latest
strategy:
matrix:
include:
- service: orders
paths: 'services/orders'
- service: inventory
paths: 'services/inventory'
- service: notifications
paths: 'services/notifications'
# Only run for services whose files changed.
# Requires actions/changed-files or similar.
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.head_ref }}
- name: Check for changed files
id: changed
uses: tj-actions/changed-files@v44
with:
files: ${{ matrix.paths }}/**
- name: Run Contractual PR check
if: steps.changed.outputs.any_changed == 'true'
uses: contractual-dev/action@v1
with:
mode: pr-check
github-token: ${{ secrets.GITHUB_TOKEN }}
config-path: ${{ matrix.paths }}/contractual.yaml