Client architecture
Implements: posture 1, ARCH-14 and ARCH-08 · see CHARTER.md Parts 1 and 2. Checked by: tests/tdd/scripts/custom-client-identity.test.ts. Decided: 2026-10-17 in #664, option D.
ESI.ts is a library of hand-written, curated custom clients. The code that is generated from the ESI OpenAPI document is the foundation underneath them, not a replacement for them. This page records that decision and the plan that follows from it.
The decision
"D please. The core identity of the project is the custom approach and we should never give that up. This needs enshrined in the charter" (the maintainer, on #664)
- The custom clients are the product.
EsiClient,EsiClientBuilder→CustomEsiClient,EsiApiFactory.createClient(type)and the 39 domain clients insrc/clients/, over the endpoint maps insrc/core/endpoints/and the Zod schemas insrc/schemas/. Method names, argument order, body builders, wire fixes and validation strictness are product decisions, made by people. - Generation serves them. The generated operations (
src/generated/operations.generated.ts) and their*Metasupply paths, scopes, schemas and coverage. They never replace a custom client. createEsi()from@lgriffin/esi.ts/clientis the spec-shaped low-level surface. It stays documented for anyone who wants calls that mirror the URL and the spec's parameters exactly (esi.public,esi.as(identity), MULTI-CHARACTER.md), and it becomes the engine under the custom clients. It needs no feature parity with them.- Changing this is a charter amendment first. ARCH-14 fails a change that deprecates or removes a custom-client construction surface, or replaces one with the generated surface.
What 12.2.0 changes
12.1.0 marked the custom clients @deprecated for removal in 13.0.0, following a plan to make createEsi() the only surface. 12.2.0 lifts those deprecations. That is a compatible change: no name, signature or behaviour changes.
| Surface | 12.1.0 | 12.2.0 |
|---|---|---|
EsiClient, getDefaultClient, EsiClientBuilder, CustomEsiClient | Deprecated, removed in 13.0.0 | Supported |
EsiApiFactory and EsiApiFactory.createClient(type) | Deprecated, removed in 13.0.0 | Supported |
EsiApiFactory's nine named factories (createMarketClient and the rest) | Deprecated since 11.0.0 | Still deprecated, in favour of createClient(type) or EsiClientBuilder (createEsi() as the spec-shaped alternative). Removal needs a future major release; none is scheduled. |
EsiTokenManager.createClient | Deprecated since 11.0.0 | Still deprecated, in favour of esi.as(tokens.identity(id)). Removal needs a future major release; none is scheduled. |
BaseEsiClient, the 39 domain clients, ApiClientType, RouteOptions, ApiClientBuilder | Deprecated, removed in 13.0.0 | Supported |
ApiClient, configureApiClient, the page, cursor and batch helpers, diagnostics, and 25 in all | Deprecated re-exports | Exported from their declarations again; every name and type unchanged |
The 194 hand-written response types in src/types, TestDataFactory, the ./schemas entry | Deprecated, removed in 13.0.0 | Supported |
DeprecationInfo on endpoint definitions | Unchanged: per-call warnings from ESI | Unchanged |
No 13.0 is planned.
The plan
Each step is additive or internal, so each can ship in a 12.x minor release. A future major would only be for clean-up chosen later, such as dropping setters that identity views make redundant.
- Lift the deprecations (12.2.0, this page). Posture 1 and ARCH-14 go into the charter.
- Derive the endpoint maps from the generated
*Meta. Path, method, scopes, pagination kind and headers come from the spec. The hand-written part shrinks to what a custom client adds: the method name, argument order,bodyBuilder, and the wire fixes the spec gets wrong (query versus body foraddContacts, comma-joined ids,readrather thanis_read), kept in one overlay.spec:coveragethen also proves every custom method points at a real operation. A spike on two domains comes before converting all 39. - Generate the baseline schemas, hand-write the overrides. The generated response schemas become the baseline, and a hand-written schema stays only where ESI's wire differs from the spec. Schema drift becomes a generator diff instead of an audit.
- Identity views for the custom clients. A shared runtime with per-identity views, along the lines of
client.as(identity)returning the same custom clients bound to one character over one cache, rate-limit budget and breaker, and a public-only builder whose types omit authenticated methods. Custom-client users get the multi-character model without learning a new call style. - Close runtime gaps once, in the shared runtime. Parallel page collection, response metadata and safe mode, diagnostics, swappable middleware and nameable response types are built where both surfaces see them, not twice.
- Response metadata and safe mode (12.x): one reader,
buildResponseMetainsrc/core/responseMeta.ts, serves each domain client'swithMetadata(), the newEsiClient.withMetadata()/withSafeMode()andCustomEsiClient.withMetadata()/withSafeMode()(every domain from one call), andesi.withMetadata()/esi.withSafeMode(). - Diagnostics (12.x):
EsiDiagnosticsbacksEsiClient.diagnostics, the newCustomEsiClient.diagnosticsand its shortcuts, andesi.diagnostics. - Swappable middleware (12.x):
cache,rateLimiter,circuitBreaker,deduplicatorandclockareEsiClientConfigfields thatconfigureApiClientinstalls, soEsiClient, the builder,EsiApiFactory.createClientandcreateEsi(whoseEsiOptionscarry the same five) take them the same way.
- Response metadata and safe mode (12.x): one reader,
Shared already
A feature the generated layer gains reaches the custom clients through the same pipeline code, not a copy of it:
| Feature | Custom clients | createEsi() | One implementation |
|---|---|---|---|
| Deprecation warnings | DeprecationInfo on the endpoint definition: Endpoint '<method>' is deprecated. … | OperationMeta.deprecation: Operation '<operationId>' is deprecated. … | src/core/endpoints/deprecationWarning.ts |
| The OpenAPI document, as JSON and YAML | meta.getOpenApiJson(), meta.getOpenApiYaml() | meta.openapiJson.get(), meta.openapiYaml.get() | textResponse through handleRequest; the same Accept header and the same JSON schema |
| An empty answer on a public contract | getPublicContractBids/Items, fetchAllPublicContractBids/Items, streamPublicContractBids/Items, and streamEndpoint/fetchAllEndpoint by name | contracts.public.bids(id).get(), contracts.public.items(id).get() | answeredWithNoContent and emptyListWhenNoContent in src/core/requestPipeline/statusHandling.ts |
The endpoint maps stay hand-written and src/core may not import the generated layer, so tests/tdd/core/endpointOperationParity.test.ts holds the two in step: it fails when an endpoint definition and its generated operation disagree about deprecation (with the message and sunset date from scripts/spec/operation-overlay.json), a text response and its Accept header, or reading no content as an empty list. Step 2 of the plan derives these facts instead of checking them.
Where things live
| Layer | Where | Role |
|---|---|---|
| Custom clients | src/EsiClient.ts, src/EsiClientBuilder.ts, src/clients/ | The product: named methods, curated arguments, helpers |
| Endpoint maps, schemas | src/core/endpoints/*Endpoints.ts, src/schemas/ | What each method calls and how its answer is validated |
| Request pipeline | src/core/ | Cache, retry, rate limiter, breaker, deduplicator, pagination; shared |
| Generated operations | src/generated/, src/adapters/ | One typed function and *Meta per spec operation; the foundation |
| Spec-shaped surface | src/client/ (./client) | createEsi(), esi.public, esi.as(identity) over the generated layer |
ARCHITECTURE.md walks a request through the pipeline, and SEMVER.md says how each kind of change is released.
