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

Source: https://voxgig.com/howto/entity-clients-instead-of-endpoint-wrappers

- Audience: api-producer
- Level: intermediate
- Languages: typescript, javascript, go
- Verified: 2026-10-21
- Published: 2026-10-18

## 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](https://openapi-generator.tech/docs/customization/) | 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](https://voxgig.com/sdk) | 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](https://www.stainless.com/docs/reference/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](https://docs.stripe.com/api) | 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](https://github.com/stripe/stripe-node/blob/master/src/resources/Customers.ts)
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.

```json title="entities.json"
{
  "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.

```js title="client.mjs"
  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 }),
    }),
  }
```

```bash
node demo.mjs
```

```text output
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.

```js title="chains.test.mjs"
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](https://pkg.go.dev/github.com/google/go-github/v74/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.

```go title="client.go"
// 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)
}
```

```bash
go test -v
```

```text output
=== 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

```bash
node --test chains.test.mjs
```

```text output
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/`.

```bash
node guess.mjs
```

```text output
  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:

```ts title="generated/sdkgen/DocsTypes.ts"
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](https://github.com/voxgig/sdkgen/blob/main/docs/reference/model.md) 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](https://openapi-generator.tech/docs/customization/) 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

- [Tell an entity-shaped SDK from an endpoint-shaped one](/howto/measure-entity-versus-endpoint-shaped-output)

- [Decide if an endpoint is an entity operation or an action](/howto/entity-operation-or-standalone-action)

- [Generate your first SDK with sdkgen](/howto/first-sdk-with-sdkgen)

- [Write operationIds that generate clean SDK method names](/howto/operationids-that-generate-clean-sdk-method-names)

- [Expose typed SDK errors callers match without parsing text](/howto/typed-sdk-errors-callers-can-match)

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

[^1]: Google numbers the verbs. [AIP-121](https://google.aip.dev/121) sets out resource-oriented
design, and each of the five standard methods has a proposal of its own,
[AIP-131](https://google.aip.dev/131) for Get through AIP-135 for Delete. Custom methods, which are
everything else, share [AIP-136](https://google.aip.dev/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.

[^2]: The [stripe-node README](https://github.com/stripe/stripe-node) opens its usage with
`stripeClient.customers.create(...)`, and the [API reference](https://docs.stripe.com/api) 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.

[^3]: Stainless keeps its mapping in a file,
[`stainless.yml`](https://www.stainless.com/docs/reference/config/), 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.