How-to › Ship an SDK

How to shape an SDK around entities instead of endpoint wrappers#

Decide the home entity of every operation before you pick a generator, test the three documented call chains, and see what two generators guess.

Audience
API producer
Level
intermediate
Topic
Design SDK ergonomics per language
Languages
TypeScript, JavaScript and Go
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
OpenAPI Generator Api classes per tagThe document’s tags already group operations the way callers thinkA home decided by whoever wrote the tag, so a mis-tagged operation lands in the wrong class without any warningYour tags are a documentation grouping rather than a caller’s mental model
sdkgen semantic modelYou want the same entity classes in every language from one modelA 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 guideThe API is a set of actions rather than resources
Stainless resource configYou want resource classes and will name the mapping of every endpoint yourselfA stainless.yml that names every method, kept in step with the document by you, and a vendor’s generator in the release pathYou need generation to run entirely in your own pipeline
Stripe resource servicesYou hand-write the SDK and want the pattern most developers already recognizeEvery resource class written and kept current by a person, in every language you shipMore 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

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

  1. 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 remove standing 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

  2. 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

  3. 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 method link on an accounts resource for post /accounts/{account_id}/link, a custom verb assigned by hand, beneath the standard four. The file is the same decision as entities.json, written in a vendor’s grammar. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.