Your SDK has one function per operationId. A caller who wants the documents in a folder scans a
flat list of names and learns your URL layout to guess which ones apply. Nothing in the list says
that moveDocument and createDocument belong together. A generator promises resource classes
instead, and you have nothing to check its classes against.
What you get
You will end up with a map from each operationId to its home entity and verb, clients built from it in Node and Go, and tests for three call chains. This is for you if you decide an SDK’s shape before its generator.
Short answer
Write down the home entity and verb for every operationId in a small map, then build the client
surface from it. Pin the surface with tests that run the three call chains your documentation leads
with. A generator is then judged against the map rather than trusted: sdkgen files an action such
as move under create, and OpenAPI Generator files it under whichever tag the operation carries.
The map, not the guess, decides where a two-entity operation lives.
You will need
Node 22 or later, Go 1.24 or later, an OpenAPI 3 document, and the SDK you have or the generator you
are evaluating. Verified 2026-10-21 against Node 22.22.0, Go 1.24.7, @voxgig/sdkgen 4.35.0 with
@voxgig/apidef 8.25.3, @voxgig/create-sdkgen 0.30.8, and OpenAPI Generator 7.26.0. The document
here, openapi.json, describes folders that hold documents: eleven operations, two entities, and
one operation, moveDocument, that touches both. The sample runs against an in-memory server on
port 0 and calls no real API.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
OpenAPI Generator Api classes per tag | The document’s tags already group operations the way callers think | A home decided by whoever wrote the tag, so a mis-tagged operation lands in the wrong class without any warning | Your tags are a documentation grouping rather than a caller’s mental model |
| sdkgen semantic model | You want the same entity classes in every language from one model | A classification made from path shape and method, so a POST action outside the six verbs is filed under create behind an $action key until you edit the guide | The API is a set of actions rather than resources |
| Stainless resource config | You want resource classes and will name the mapping of every endpoint yourself | A stainless.yml that names every method, kept in step with the document by you, and a vendor’s generator in the release path | You need generation to run entirely in your own pipeline |
| Stripe resource services | You hand-write the SDK and want the pattern most developers already recognize | Every resource class written and kept current by a person, in every language you ship | More than two languages, or an API that changes every month |
Voxgig maintains sdkgen. It is one of four options here, not the recommendation.
Stripe’s services are the shape most developers recognize, and Stripe generates them: nearly every
resource file in stripe-node
opens with the comment // File generated from our OpenAPI spec. Copying that shape without a
generator means every class is written and kept current by a person. Stainless reaches the same
shape from a configuration file that names every method, which is a second artifact to keep in step
with the document. OpenAPI
Generator and sdkgen both guess from the document, by tag and by path shape, and this page is about
what to do with the guess.
Write the home of every operation down
Decide, per operationId, which entity a caller will look under and which verb they will type. Write it in a file beside the document, where a review can see it and a test can read it.
{
"folder": {
"listFolders": "folders.list()",
"createFolder": "folders.create(data)",
"getFolder": "folder(id).get()",
"updateFolder": "folder(id).update(data)",
"deleteFolder": "folder(id).remove()"
},
"document": {
"listDocuments": "folder(id).documents.list()",
"createDocument": "folder(id).documents.create(data)",
"getDocument": "document(id).get()",
"updateDocument": "document(id).update(data)",
"deleteDocument": "document(id).remove()",
"moveDocument": "document(id).move({ folderId })"
}
}
Two kinds of line in that file carry decisions a generator cannot make. listDocuments and
createDocument are document operations reached through a folder, so the chain is
folder(id).documents and the parent is an argument rather than a second home. moveDocument
touches two entities. The document is the thing that changes, so the document is its home and the
folder is its argument. A path-shaped rule has no verb called move, and a tag puts the operation
wherever the author of the tag was thinking that day. Decide the two-entity cases first, because
every generator guesses at exactly those.1
Build the surface from the map
The client follows the map line by line. Paths and methods come from openapi.json, looked up by
operationId, so the document keeps the wire and the map keeps the surface.
return {
folders: {
list: () => call('listFolders'),
create: (data) => call('createFolder', {}, data),
},
folder: (folderId) => ({
get: () => call('getFolder', { folderId }),
update: (data) => call('updateFolder', { folderId }, data),
remove: () => call('deleteFolder', { folderId }),
documents: {
list: () => call('listDocuments', { folderId }),
create: (data) => call('createDocument', { folderId }, data),
},
}),
document: (documentId) => ({
get: () => call('getDocument', { documentId }),
update: (data) => call('updateDocument', { documentId }, data),
remove: () => call('deleteDocument', { documentId }),
// A move touches two entities. The document is the one that changes,
// so it is the home, and the folder is the argument.
move: ({ folderId }) => call('moveDocument', { documentId }, { folderId }),
}),
}
node demo.mjs
home entity and call, for every operation in openapi.json
folder listFolders folders.list()
folder createFolder folders.create(data)
folder getFolder folder(id).get()
folder updateFolder folder(id).update(data)
folder deleteFolder folder(id).remove()
document listDocuments folder(id).documents.list()
document createDocument folder(id).documents.create(data)
document getDocument document(id).get()
document updateDocument document(id).update(data)
document deleteDocument document(id).remove()
document moveDocument document(id).move({ folderId })
11 operations in the document, 11 homes in the map
create then read back f1 holds d1; d1 is in f1
list then retitle f1 lists 2 documents; d1 is "Q3 plan, final"
move between folders d1 is in f2; f1 holds 0, f2 holds 1
The map has a home for each of the eleven operations, and the three chains run against the in-memory
server in server.mjs. Its ids are sequential, so f1 and d1 read the same on every run.
Write the three chains as tests
The chains are the test. Take the three sequences your quickstart shows first, write each one against the client, and keep them when you switch generators. A generated client that cannot express a chain fails here before it reaches a user.
test('move a document between folders and both folders agree', () =>
withClient(async (client, state) => {
const { from, to, moved, left, arrived } = await moveBetweenFolders(client)
assert.equal(moved.folderId, to.id)
assert.deepEqual(left, [])
assert.deepEqual(arrived.map((d) => d.id), [moved.id])
assert.equal(state.documents.get(moved.id).folderId, to.id, 'the server agrees')
assert.equal(state.folders.has(from.id), true, 'the source folder still exists')
}))
The move test asserts on both folders and on the server’s own record. A move that updates the
document and leaves the source folder listing it is the bug a two-entity operation invites. Two
more tests pin the map: every operationId in the document has exactly one home, and client.mjs
calls each operationId from exactly one place.
Keep the decision in Go
Go has no fluent idiom for addressing one instance, so the same decision takes the shape go-github and stripe-go use. One service per entity hangs off the client, and the parent id is the first argument where the Node client nests a call.
// Move lives on Documents: the document is what changes, the folder is where
// it goes. The decision is the same one entities.json records for the Node
// client, made once and applied in every language.
func (s *DocumentsService) Move(ctx context.Context, id, folderID string) (*Document, error) {
out := &Document{}
return out, s.c.do(ctx, "POST", "/documents/"+url.PathEscape(id)+"/move", map[string]string{"folderId": folderID}, out)
}
go test -v
=== RUN TestChains
=== RUN TestChains/create_then_read_back
create then read back: f1 holds d1; d1 is in f1
=== RUN TestChains/list_then_retitle
list then retitle: f1 lists 2 documents; d1 is "Q3 plan, final"
=== RUN TestChains/move_between_folders
move between folders: d1 is in f2; f1 holds 0, f2 holds 1
Documents.Move sits beside Documents.Get, and Documents.List(ctx, folderID) takes the folder
the way go-github’s Repositories.ListByOrg takes the organization. The three lines are printed by
the test after its assertions pass, against an httptest server that serves the same eleven routes.
Every id passes through url.PathEscape, as every Node path parameter passes through
encodeURIComponent, so an id holding ? or ../ stays one path segment. An id that is empty,
., or .. survives escaping unchanged and points the URL at another path:
folder('..').get() would ask for /. Both clients refuse such an id before sending anything.
Check it worked
node --test chains.test.mjs
1..7
# tests 7
# suites 0
# pass 7
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 387.831032
# pass 7 is the three chains, the two map tests, and two more. One checks that operations()
skips the keys of a path item that are not methods, such as summary and servers. The other
hands an empty id, ., and .. to a client whose stand-in server records every path it receives,
and fails if one arrives. Delete the move line from client.mjs and two tests fail: the move
chain, and the count of places that call moveDocument.
When it goes wrong
The guess goes wrong where the map is most needed. guess.mjs applies the rule a path-shaped
classifier uses, compares it with the map, and then reads what two generators did with the same
document, from output committed under generated/.
node guess.mjs
operation guessed from the path decided in entities.json
listFolders folder.list folder: folders.list()
createFolder folder.create folder: folders.create(data)
getFolder folder.get folder: folder(id).get()
updateFolder folder.update folder: folder(id).update(data)
deleteFolder folder.remove folder: folder(id).remove()
listDocuments document.list document: folder(id).documents.list()
createDocument document.create document: folder(id).documents.create(data)
getDocument document.get document: document(id).get()
updateDocument document.update document: document(id).update(data)
deleteDocument document.remove document: document(id).remove()
! moveDocument document, no verb named move document: document(id).move({ folderId })
what two generators did with moveDocument, read from generated/
sdkgen 4.35.0 document.create, a second point of create selected with $action: "move"
points of document.create: POST /folders/{folderId}/documents and POST /documents/{documentId}/move
openapi-generator 7.26.0 FoldersApi.moveDocument, placed by the operation's tag
Ten of eleven guesses agree with the map. The eleventh is the move, and the two generators place it
differently. sdkgen’s classifier, @voxgig/apidef, keeps six verbs and files the move as a second
point of document.create, reached by passing $action: 'move' in the create data. The generated
type says so in a comment:
export interface DocumentCreateData {
folder_id: string
title: string
// Selects a custom action instead of the plain create:
// 'move'
// The remaining keys are that action's own payload.
$action?: string
[action: string]: any
}
A caller reading DocumentEntity finds no move, and the generate run printed no warning, because
the operation was placed rather than dropped. The
model reference says that no
bundled target generates an operation under a name outside the six, and that an operation is
reclassified in the guide file, .sdk/model/guide/guide.aontu. That edit is the map, applied to
sdkgen’s model.
OpenAPI Generator put moveDocument on FoldersApi, because the document tags it folders, and
its customization page says APIs generate per
tag. The fix is in the document: tag the operation with its home entity. In both cases the move
chain is the test that fails, because document(id).move is the call the documentation shows.
The second failure is a home split in two. A team that cannot agree puts move on the document and
on the folder. The SDK then has two methods for one operation, two places to document it, and two
behaviors to keep identical. The fifth test exists for this: each operationId is called from exactly
one place.
When not to do this
Do not force this shape onto an API that is a set of actions. A payments API whose operations are authorize, capture, and refund has no entity those three are methods of, and a resource layout invents one. Write the actions as top-level methods and let the map say so.
Do not adopt sdkgen or any other model-first generator for the shape and then skip the map. The
classifier reads path shape and method, as guess.mjs does, and it placed moveDocument under
create here without a warning. The map and the chain tests are what turn that guess into a decision you
made.2
Do not decide the home of a two-entity operation by which team owns the endpoint. Callers do not know your org chart. They know which object they hold when they want the operation, and that object is the home.
Do not keep the three chains in a notebook. They belong in the test suite, because the day you switch generators is the day they earn their keep.3
Related how-tos
Last verified
Verified 2026-10-21 against Node 22.22.0, Go 1.24.7, @voxgig/sdkgen 4.35.0, @voxgig/apidef
8.25.3, @voxgig/create-sdkgen 0.30.8, and OpenAPI Generator 7.26.0. Every output block is what the
command preceding it printed. The two generators ran once, with the network, through generate.sh;
the entity model, the generated types, and the Api classes they wrote are committed under
generated/ and read by guess.mjs. Stripe’s and Stainless’s shapes are read from their
documentation and were not generated here.
Footnotes
-
Google numbers the verbs. AIP-121 sets out resource-oriented design, and each of the five standard methods has a proposal of its own, AIP-131 for Get through AIP-135 for Delete. Custom methods, which are everything else, share AIP-136. The map on this page uses the same five, with
removestanding in for Delete, and adds a sixth. A vocabulary that gives each verb its own numbered document has thought about the sixth one too, and filed it under everything else. ↩︎ Back to text -
The stripe-node README opens its usage with
stripeClient.customers.create(...), and the API reference it mirrors lists resources with their operations beneath them. The library and the reference share a table of contents, a property a flat list of operationIds does not have. ↩︎ Back to text -
Stainless keeps its mapping in a file,
stainless.yml, which its reference describes as separate from the spec because it captures structure that does not belong in the API definition itself. The reference’s own example names a methodlinkon anaccountsresource forpost /accounts/{account_id}/link, a custom verb assigned by hand, beneath the standard four. The file is the same decision asentities.json, written in a vendor’s grammar. ↩︎ Back to text