If you keep your OpenAPI description in one file and customize it with overlays, you’ve probably seen this happen. The overlay starts small: a summary here, an extension there. Then someone needs the same 404 response on every operation that reads or deletes a single resource. The easiest thing to do is to copy the action and change the target. A month later, the overlay contains the same block six times. Then the description of the response changes, five copies get updated, and one doesn’t. Nobody notices, because the overlay still applies cleanly.
If you’re one of the people who maintain overlays like this, this article was written just for you. We’ll go through three ways to stop pasting the same change into an overlay, show where each of them breaks, and look at what the new Overlay 1.2.0, released on 22 September 2026, changes about it.
What is an Overlay?
Let’s first define it. I took the following description directly from the Overlay specification:
An Overlay is represented in JSON or YAML and contains actions that are applied in order to an OpenAPI description. Each action uses a target expressed as a RFC9535 JSONPath query expression to identify where changes apply.
In practice, an overlay is how you keep one source description and still publish different versions of it: one for public docs, one for an SDK generator, one for a partner. Instead of maintaining copies of the description, you maintain a list of changes. Each action has a target that says where, and update, copy, or remove that says what.
The copy-paste problem
Here is a small OpenAPI description I’ll use for the rest of the article:
openapi: 3.1.0
info:
title: Accounts API
version: 1.0.0
paths:
/accounts:
get:
operationId: listAccounts
responses:
'200':
description: A page of accounts.
/accounts/{id}:
get:
operationId: getAccount
responses:
'200':
description: One account.
delete:
operationId: deleteAccount
responses:
'204':
description: Account deleted.
/transfers/{id}:
get:
operationId: getTransfer
responses:
'200':
description: One transfer.
Every operation on /accounts/{id} and /transfers/{id} can return 404; listAccounts can’t. This is the overlay most people write first:
overlay: 1.1.0
info:
title: Add error responses
version: 1.0.0
actions:
- target: $.paths['/accounts/{id}'].*.responses
update:
'404':
description: The resource does not exist.
content:
application/problem+json:
schema:
$ref: '#/components/schemas/Problem'
- target: $.paths['/transfers/{id}'].*.responses
update:
'404':
description: The resource does not exist.
content:
application/problem+json:
schema:
$ref: '#/components/schemas/Problem'
It’s correct, and with two paths it’s even readable. But the duplication grows with every path you add, and it’s the worst kind of duplication: the copies are supposed to stay identical, nothing checks that they do, and an outdated copy still applies without any error.
There’s one thing this naive version gets right, though, and it’s worth keeping in mind for the rest of the article: every target is its own action. When you apply it with a strict tool, a target that matches nothing fails on its own. Not every shortcut below keeps that.
A word on union selectors
RFC 9535 JSONPath allows several names in one bracket, so a single action can reach both paths:
- target: $.paths['/accounts/{id}','/transfers/{id}'].*.responses
The copy is gone, and for a short, stable list of paths, this is perfectly fine. But let’s see what happens when somebody misspells /transfers/{id} as /transfer/{id}. I applied both versions with SpecLynx CLI and --strict, which fails when an action’s target matches nothing.
The union selector:
$ npx @speclynx/cli overlay apply union.overlay.yaml openapi.yaml --strict --verbose
Overlay: union.overlay.yaml -> openapi.yaml
[ok] update $.paths['/accounts/{id}','/transfer/{id}'].*.responses (2 matches)
Overlay was successfully applied
One action per target:
$ npx @speclynx/cli overlay apply errors.overlay.yaml openapi.yaml --strict --verbose
Overlay: errors.overlay.yaml -> openapi.yaml
[ok] update $.paths['/accounts/{id}'].*.responses (2 matches)
[FAIL] noop $.paths['/transfer/{id}'].*.responses (0 matches)
Action target "$.paths['/transfer/{id}'].*.responses" matched zero nodes (strict mode)
Error: Action target "$.paths['/transfer/{id}'].*.responses" matched zero nodes (strict mode)
The union still matched two nodes, so as far as strict mode is concerned, the action worked, and the missing 404 on transfers went completely unnoticed. This isn’t a bug: the target as a whole did match something (apidom#688 has the reasoning). A union selector removes the copy by merging targets, and merging targets is exactly what hides a typo.
A word on YAML anchors
If your overlay is written in YAML, you might reach for anchors and aliases (¬Found and *notFound) to reuse the update block. In a YAML-only workflow, this works: you maintain one copy, and the parser replaces every alias with a copy of the anchored value. But the reuse lives in the YAML syntax, not in the overlay, and that has consequences:
- JSON overlays can’t use it. JSON has no anchors.
- It doesn’t survive conversion. RFC 9512, which Overlay 1.2 refers to for how YAML maps to JSON, treats aliases as a serialization detail that can be discarded. Convert the overlay to JSON once, and the copies are back for good.
- The anchor lives inside an action. YAML requires the anchor to come before its aliases, so the shared block sits in whichever action uses it first. Delete or move that action, and every alias after it stops parsing.
A word on generating overlays
The last DIY option is to stop writing the overlay by hand: keep a list of targets and a template, and generate the overlay with a small script (jq, yq, or a few lines of JavaScript). This works with any overlay version, in JSON and YAML, and with one action per target.
The cost is that you now maintain two things instead of one: the generator and its output. Reviewers review a generated file, and somebody eventually edits the output by hand instead of the input. For a team that already generates its OpenAPI description, this can be fine. For everybody else, it’s a lot of machinery for avoiding a copy.
Overlay 1.2: reusable actions
Overlay 1.2 adds what the workarounds above were reaching for: a named, reusable action that every target references on its own. The shared change lives in components.actions, and every entry in actions only says where it goes:
overlay: 1.2.0
info:
title: Add error responses
version: 1.0.0
components:
actions:
notFound:
description: The 404 response every single-resource operation returns.
fields:
update:
'404':
description: The resource does not exist.
content:
application/problem+json:
schema:
$ref: '#/components/schemas/Problem'
actions:
- $ref: '#/components/actions/notFound'
target: $.paths['/accounts/{id}'].*.responses
- $ref: '#/components/actions/notFound'
target: $.paths['/transfers/{id}'].*.responses
description: Transfers share the 404 of accounts.
Please take a moment here and look at the shape. If you’ve ever written an OpenAPI description, it will look familiar: components holds the reusable parts and $ref points to them, the same way schemas and responses work in OpenAPI itself.
Let’s apply it:
$ npx @speclynx/cli overlay apply errors.overlay.yaml openapi.yaml -o result.yaml --verbose
Overlay: errors.overlay.yaml -> openapi.yaml
[ok] update $.paths['/accounts/{id}'].*.responses (2 matches)
[ok] update $.paths['/transfers/{id}'].*.responses (1 matches)
Overlay was successfully applied
The result is byte for byte identical to the result of the copy-pasted 1.1 overlay. This is how it differs from the input:
@@ -15,14 +15,32 @@
responses:
'200':
description: One account.
+ '404':
+ description: The resource does not exist.
+ content:
+ application/problem+json:
+ schema:
+ $ref: '#/components/schemas/Problem'
delete:
operationId: deleteAccount
responses:
'204':
description: Account deleted.
+ '404':
+ description: The resource does not exist.
+ content:
+ application/problem+json:
+ schema:
+ $ref: '#/components/schemas/Problem'
/transfers/{id}:
get:
operationId: getTransfer
responses:
'200':
description: One transfer.
+ '404':
+ description: The resource does not exist.
+ content:
+ application/problem+json:
+ schema:
+ $ref: '#/components/schemas/Problem'
A few rules are worth knowing before you write your first reusable action:
- The target always comes from the reference. A reusable action must not contain a
targetinside itsfields, and a reference must always have one. A reusable action that nobody references does nothing. - Only the description can be overridden. A reference has three fixed fields:
$ref,target, anddescription. If both define a description, the reference’s description wins. You can’t change theupdateper use; for a different change, define a second reusable action. - References are local.
$refmust point into#/components/actions/of the same overlay file. Keys are JSON Pointer tokens, so a key likea/bis referenced as#/components/actions/a~1b.
Let’s put all the approaches side by side:
| Approach | No copies | Works in JSON | Every target checked on its own | Shared change has a name and a description |
|---|---|---|---|---|
| Copy and paste | no | yes | yes | no |
| Union selector | yes | yes | no | no |
| YAML anchors | in the YAML file only | no | yes | no |
| Generator script | yes (in the input) | yes | yes | in the generator |
| Overlay 1.2 reusable actions | yes | yes | yes | yes |
The one column where reusable actions lose today is one I didn’t put in the table: tool support. Overlay 1.2 is a month old, and not every overlay tool reads it yet.
The overlay that only works on your machine
There’s a second problem Overlay 1.2 fixes, and it’s a quieter one. An overlay can say which OpenAPI description it was written for, using extends. Overlay 1.0 and 1.1 allowed extends to be a relative reference like ../openapi.yaml, but they never said what it’s relative to. Relative to the overlay file? To the directory you run the tool from? Every tool had to decide on its own, so an overlay that worked from the repository root wasn’t guaranteed to work in CI, from a different directory or with a different tool.
Overlay 1.2 settles it: a relative extends is resolved against the overlay’s $self field if it has one, and against the location the overlay was loaded from if it doesn’t. Never against your working directory.
Without $self, the overlay file’s location is what counts:
overlay: 1.2.0
extends: ../openapi.yaml
info:
title: Add error responses
version: 1.0.0
# ...
$ npx @speclynx/cli overlay apply demo/overlays/extends.overlay.yaml --verbose
Overlay: extends.overlay.yaml -> (extends)
[ok] update $.paths['/accounts/{id}'].*.responses (2 matches)
[ok] update $.paths['/transfers/{id}'].*.responses (1 matches)
Overlay was successfully applied
The target argument is omitted here, so the overlay is applied to its extends, and you get the same result from whichever directory you run it.
With $self, the overlay carries its own identity, wherever the file happens to be. The following overlay lives on my disk, but it says it belongs next to the sample description from the SpecLynx getting-started guide:
overlay: 1.2.0
$self: https://speclynx.com/assets/samples/getting-started/petstore.overlay.yaml
extends: openapi.yaml
info:
title: Add a server
version: 1.0.0
actions:
- target: $
update:
servers:
- url: https://api.petstore.example.com
$ npx @speclynx/cli overlay apply petstore.overlay.yaml --verbose
Overlay: petstore.overlay.yaml -> (extends)
[ok] update $ (1 matches)
Overlay was successfully applied
openapi: 3.1.0
info:
title: Petstore API
...
extends: openapi.yaml was resolved against $self to https://speclynx.com/assets/samples/getting-started/openapi.yaml, not to a file next to the overlay on my disk.
The specification goes one step further and treats extends as a name rather than an address:
URIs used in an Overlay document, including $self and extends, are identifiers, and not necessarily locators (URLs).
If an OpenAPI 3.2 description declares its own $self, tools are expected to match extends against it. An overlay written for https://apis.example.com/approved/petstore.yaml then still applies to a copy of that description served from a mirror. Appendix B of the specification walks through all the cases, and I highly recommend reading it.
Catching mistakes before CI applies them
New fields mean new ways to get things wrong: a $self with a fragment, a target left inside a reusable action, a reference to notfound instead of notFound, a reference without a target. All of these are mistakes a linter can catch before anything is applied. Here is an overlay with four of them:
overlay: 1.2.0
$self: https://example.com/overlays/errors.overlay.yaml#v1
info:
title: Add error responses
version: 1.0.0
components:
actions:
notFound:
fields:
target: $.paths.*.*.responses
update:
'404':
description: The resource does not exist.
actions:
- $ref: '#/components/actions/notfound'
target: $.paths['/accounts/{id}'].*.responses
- $ref: '#/components/actions/notFound'
$ npx @speclynx/cli validate broken.overlay.yaml
broken.overlay.yaml
2:8-2:59 error 10010602 $self must not contain a fragment identifier
10:9-10:15 error 10050201 target must not be defined in Reusable Action fields; it is supplied by the Reusable Action Reference Object
15:11-15:42 error 321-31-1792587509947 local reference not found
17:5-18:1 error 10060200 should always have a 'target'
✖ 4 problems (4 errors)
Whatever tool you use, run a validation step like this in CI before the step that applies your overlays. A broken reference is much cheaper to fix in a pull request than in a published description.
What Overlay 1.2 doesn’t solve
Reusable actions remove the copy, but not everything around it:
- You still list every target. RFC 9535 JSONPath can’t select keys by pattern, so there’s no single expression for “every path with an
{id}in it”. Each target still needs its own reference. It’s just one line now instead of a whole block. - Reuse ends at the file. A reusable action can’t be shared across overlay files. If five overlays need the same
404, each of them defines it. - No per-use variations. Apart from the description, a reference can’t change what the action does. Two variations of a change are two reusable actions.
Trying it today
Overlay 1.2 support is still new across the ecosystem. On the SpecLynx side, since version 1.5, SpecLynx CLI applies, validates, and generates 1.2 overlays (overlay diff now generates 1.2 by default, and --overlay-version 1.1.0 targets tools that don’t read 1.2 yet). Since version 2.14 (the latest is 3.0), the Language Service provides linting, completion, and hover documentation for 1.2. Both are built on ApiDOM, which supports 1.2 since version 6.0 and which you can use directly if you apply overlays from your own code. The OpenAPI Toolkit for VS Code and the browser Editor don’t support 1.2 yet. All examples in this article were run with SpecLynx CLI 1.5.2, the latest version at the time of writing.
Closing words
Copy-pasted actions are the most common way overlays rot: they apply cleanly while their copies drift apart. Every workaround available before 1.2 trades something away: union selectors hide typos, anchors don’t survive JSON, and generators add code to maintain. If your tools support it, go for Overlay 1.2: move the shared change into components.actions, give every target its own reference, and apply your overlays with strict mode on. Sounds pretty easy, right?
Have you found another way to keep overlays free of copies, or a case where reusable actions don’t fit? I’d be happy to add it to this article. And if your Overlay 1.2 document doesn’t apply the way the specification says it should, please open an issue with an example. I’d highly appreciate it!
Further reading: Overlay Specification 1.2.0 · RFC 9535: JSONPath · One Overlay, three JSONPaths · Your overlay changed one field. Why does the diff touch half the file?