Your API is described by one OpenAPI document per service, and the generator turns each document
into its own client. A caller who places an order and then takes a payment installs two packages,
configures two clients, and pins two version numbers that move on different days. Both documents
declare a schema named Error, each with its own fields.
What you get
You will end up with one package generated from two service documents, each schema named for the service that owns it, and a test that fails when a name moves. This is for you if several teams describe one API and you publish one SDK.
Short answer
Run redocly join over the service documents with --prefix-components-with-info-prop naming a
property each document declares for the purpose, then generate the SDK from the joined document
with OpenAPI Generator. Every component is prefixed with its service, so two Error schemas become
orders_Error and payments_Error and the generator emits one class for each. Fix the property
before the first join and never change it, because the generated class names follow it.
You will need
Node 22 or later, two or more OpenAPI 3 documents whose operationIds do not collide, and a Java
runtime for OpenAPI Generator. Verified 2026-10-21 against Node 22.22.0, @redocly/cli 2.64.1,
@openapitools/openapi-generator-cli 2.41.0, and OpenAPI Generator 7.26.0. Here the orders service
is orders.json and the payments service is payments.json, two documents for one shop. Each
declares a schema named Error with its own fields, and both declare the same Money. The sample
joins the two, reads the generated package, and tests the names. It calls neither service.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| Kiota, one client per description | Services release on their own schedules and most callers use one of them | A facade you write and version yourself, two kiota-lock.json files to update, and a runtime dependency per language | Callers want one package, one changelog, and one version to pin |
| Redocly join, then OpenAPI Generator | You own every document and want one coherent package with one version number | A join step on a command Redocly marks experimental, a prefix on every component, and a full regeneration when any one service changes | A service you do not own changes its document without telling you |
| Speakeasy source with several inputs | You generate with Speakeasy already and want the merge inside its workflow file | An account and a vendor-owned generator in the release path, and last-wins merging unless every input carries a namespace | You need generation to run entirely in your own pipeline |
Joining first gives callers the package they asked for, and gives you a build in which any
service’s change regenerates everything, so a payments fix ships an orders release too. Kiota’s
route keeps each client on its own release train, and the facade that makes two clients read as one
SDK is code you own, outside any generator. Speakeasy folds the merge into its workflow file, with
modelNamespace as the prefix rule, in exchange for running your release through its generator.
Fix the prefix rule in one place
Redocly’s join refuses two components that share a name and differ in content. It merges identical
duplicates once, and it writes nothing at all while a conflict stands, so the first join of these
two documents fails on Error. The
--prefix-components-with-info-prop option resolves
that by prefixing every component with a value read from the info object of the document it came
from. Which property to read is the decision this page is about.
The documents here each declare x-service, a short lowercase name, for this one purpose. The
title is display text: the join turns each space in it into an underscore, but a parenthesis
passes through into a name the component grammar does not allow.1 The version works until
the next release, which is the pitfall shown below. join.mjs
holds the rule as one constant and runs the join:
export const INPUTS = ['orders.json', 'payments.json']
export const OUTPUT = 'merged.json'
export const RULE = ['--prefix-components-with-info-prop', 'x-service']
const HERE = fileURLToPath(new URL('.', import.meta.url))
const REDOCLY = fileURLToPath(new URL('./node_modules/@redocly/cli/bin/cli.js', import.meta.url))
/** Run redocly join. Returns what it printed and whether it wrote the file. */
export function join(output = OUTPUT, rule = RULE, inputs = INPUTS) {
const args = [REDOCLY, 'join', ...inputs, '-o', output, ...rule]
const options = {
cwd: HERE,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, REDOCLY_TELEMETRY: 'off', REDOCLY_SUPPRESS_UPDATE_NOTICE: 'true' },
}
try {
const stdout = execFileSync(process.execPath, args, options)
return { ok: true, status: 0, output: stdout }
} catch (err) {
return { ok: false, status: err.status, output: `${err.stdout ?? ''}${err.stderr ?? ''}` }
}
}
node join.mjs
@redocly/cli 2.64.1
join orders.json payments.json -o merged.json --prefix-components-with-info-prop x-service
info: Orders 1.4.0, taken from orders.json
tags: orders, payments
operations: 6
createOrder createPayment getOrder getPayment listOrders refundPayment
schemas, by prefix:
orders_ Order NewOrder Money Error
payments_ Payment NewPayment Refund Money Error
Error is two schemas: orders_Error (code, message) and payments_Error (type, title, status, detail)
Money is two schemas as well, orders_Money and payments_Money, identical: true
The output has six operations, two tags, and eleven prefixed components. The two Error schemas
stay two schemas. Money is two schemas as well, which is the price of the option. It prefixes
every component, the ones that never clashed included, so an identical schema in two documents
becomes two classes in the package. The info block is the first document’s, with its title and
its version, so the joined document calls itself Orders 1.4.0. A package version belongs to the
package rather than to any one service, and the generator has a setting for it.
Generate one package from the joined document
OpenAPI Generator reads the joined document like any other. The run is recorded in generate.sh,
and it needs Java and, the first time, the network, because the npm wrapper downloads the generator
jar pinned in openapitools.json.
set -euo pipefail
node join.mjs
npx --yes @openapitools/openapi-generator-cli@2.41.0 generate -g typescript-fetch -i merged.json -o sdk
rm -rf generated
mkdir generated
cp -r sdk/apis sdk/models generated/
cp sdk/.openapi-generator/VERSION generated/VERSION
surface.mjs reads that output and prints what a caller sees:
node surface.mjs
openapi-generator 7.26.0, typescript-fetch, one package from merged.json
api classes: 2
OrdersApi createOrder getOrder listOrders
PaymentsApi createPayment getPayment refundPayment
models: 9
OrdersError OrdersMoney OrdersNewOrder OrdersOrder PaymentsError PaymentsMoney PaymentsNewPayment PaymentsPayment PaymentsRefund
one error type per service:
OrdersError code, message
PaymentsError type, title, status, detail?
OrdersMoney and PaymentsMoney carry the same fields: true
One package, two Api classes. The
typescript-fetch generator
groups operations by tag, and the tags came through the join unchanged: tags are prefixed only on
request, with --prefix-tags-with-info-prop, and these two never clashed. The join also wrote an
x-tagGroups block with one group per source document, which the generator ignores.2
OrdersError and PaymentsError are two interfaces with different fields, which is what the
prefix bought. OrdersMoney and PaymentsMoney are the same interface twice, which is what it
cost.
Make a rename fail the build
The names in the package follow the prefix rule. A change to the rule, or to the value it reads, is
a breaking change for every caller even when no path or field changed. The test pins both ends:
the committed merged.json must be what the join produces today, and the generated model names
must be the joined schema names camel-cased.
test('the join reproduces the committed document byte for byte', () => {
const out = joinPath(dir, 'again.json')
const result = join(out)
assert.equal(result.ok, true, result.output)
assert.equal(readFileSync(out, 'utf8'), readFileSync(new URL(`./${OUTPUT}`, import.meta.url), 'utf8'))
})
test('the generated models are the joined schemas, camel-cased', () => {
const index = readFileSync(new URL('./generated/models/index.ts', import.meta.url), 'utf8')
const generated = [...index.matchAll(/from '\.\/(\w+)'/g)].map((m) => m[1]).sort()
const camel = (name) => name.split('_').map((w) => w[0].toUpperCase() + w.slice(1)).join('')
assert.deepEqual(generated, Object.keys(merged.components.schemas).map(camel).sort())
})
Run the join in CI on every change to a service document and let the first test fail when the
output drifts from the committed file; the diff is the review. A service that edits its
x-service fails the same test, which is the point of pinning the rule outside any one document.
Switching the rule to title or version in join.mjs fails that test alone, because no other
test joins under the rule.
Check it worked
node --test join.test.mjs
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 1431.140226
# pass 6 covers the reproduction of the committed document, the generated names, every operation
from both inputs, the two Error schemas, the prefix on every component, and the refusal without a
rule.
When it goes wrong
Two failures are shown by pitfall.mjs, which joins into a temporary directory and reads only the
component names back.
node pitfall.mjs
1. no prefix rule
exit 1, output file written: false
Conflict on components => responses : ErrorResponse in files: orders.json,payments.json
Conflict on components => schemas : Error in files: orders.json,payments.json
Please fix conflicts before running join.
2. the rule names a property whose value moves
x-service orders_Error payments_Error
title Orders_Error Payments_Error
version 1.4.0_Error 2.0.1_Error
payments 2.0.1 to 2.1.0, no path or field changed: 5 of 5 payments schemas renamed under the version rule
The first is the join without a rule. Redocly names both conflicts, ErrorResponse and Error,
exits with status 1, and writes no file, so a build that forgot the option stops at this step.
Speakeasy’s merge takes the opposite
position: with no namespace, the later document’s component replaces the earlier one, and a
structural difference is reported as a warning. Under that rule the payments Error replaces the
orders Error and the build continues.
The second is a rule whose value moves. The option takes any info property, and Redocly’s own
example uses version.3 Under that rule the two schemas are 1.4.0_Error and 2.0.1_Error,
and a payments release that moves to 2.1.0 renames every payments type in the package without
touching a path or a field. The title holds still for longer and breaks the day a service is
renamed in its document. A property declared for the purpose moves only when someone edits it to
move.
The third shows in the published package rather than in the join. The joined info is the first
document’s, so a package that takes its version from the document ships as 1.4.0 whatever the
payments service is at. The typescript-fetch page documents npmVersion as defaulting to the
document’s version. Pass it, or set the version in the manifest you publish from, and treat the
document’s version as the orders service’s number.
When not to do this
Do not join documents you do not own. A team that renames a schema or edits its x-service without
telling you breaks your package on their schedule, and that test turns their change into your red
build. Generate a client per document instead and compose the two behind a facade you version
yourself, which is the Kiota route in the table and works with any generator.
Do not prefix with version, whatever the documentation’s example does. That example joins two
versions of one API, where a name that changes with the version is wanted. Two services are not two
versions.
Do not reach for a join when the services ship on different cadences and most callers use one of them. One package means one changelog, and a caller of the orders service reads about every payments change, then upgrades for fixes in calls they never make.
Do not change the rule after the first release. Every prefixed name is public API, and the test exists so that a change to it is a decision rather than an accident.
Related how-tos
Last verified
Verified 2026-10-21 against Node 22.22.0, @redocly/cli 2.64.1, @openapitools/openapi-generator-cli
2.41.0, and OpenAPI Generator 7.26.0. Every output block is what the command preceding it printed.
The generator ran once, with the network, through generate.sh; its apis/ and models/ output
is committed under generated/ and read by surface.mjs and the test. The Speakeasy workflow file
in the sample was written from the workflow reference and the merge guide and was not run. Kiota’s
behavior is taken from its documentation. Neither tool was installed.
Footnotes
-
The grammar has not moved across the 3.x line. OpenAPI 3.0.3, the version these documents declare, and 3.2.1 both say that component keys MUST match
^[a-zA-Z0-9\.\-_]+$, which admits letters, digits, and three punctuation marks: the dot, the hyphen, and the underscore. Redocly’s join meets the grammar partway. ItsgetInfoPrefixreplaces every whitespace character with an underscore and leaves the rest alone, so a title ofOrders (EU)yieldsOrders_(EU)_Error, a key that Redocly’s ownlintrejects. The underscore the join puts between prefix and name is one of the three. ↩︎ Back to text -
x-tagGroupsis a Redoc extension, and Redocly’s page on it says what it is for: a layer of navigation over the tags in rendered reference documentation. The same page adds that any tag not placed in a group is left out of the output. A joined document groups every tag by construction, one group per source document named after itsinfo.title. A tag added by hand afterwards is shown nowhere until somebody gives it a group. ↩︎ Back to text -
The example in the join command’s documentation is
redocly join museum_v1.yaml museum_v2.json --prefix-components-with-info-prop version, which joins two versions of a museum API into one document. There the version is the prefix that fits, because the two documents describe the same things at two points in time and the names are meant to differ by exactly that. The same option applied to two services carries the example’s mechanism into a situation it was not written for. ↩︎ Back to text