Skip to content

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 in src/clients/, over the endpoint maps in src/core/endpoints/ and the Zod schemas in src/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 *Meta supply paths, scopes, schemas and coverage. They never replace a custom client.
  • createEsi() from @lgriffin/esi.ts/client is 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.

Surface12.1.012.2.0
EsiClient, getDefaultClient, EsiClientBuilder, CustomEsiClientDeprecated, removed in 13.0.0Supported
EsiApiFactory and EsiApiFactory.createClient(type)Deprecated, removed in 13.0.0Supported
EsiApiFactory's nine named factories (createMarketClient and the rest)Deprecated since 11.0.0Still deprecated, in favour of createClient(type) or EsiClientBuilder (createEsi() as the spec-shaped alternative). Removal needs a future major release; none is scheduled.
EsiTokenManager.createClientDeprecated since 11.0.0Still 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, ApiClientBuilderDeprecated, removed in 13.0.0Supported
ApiClient, configureApiClient, the page, cursor and batch helpers, diagnostics, and 25 in allDeprecated re-exportsExported from their declarations again; every name and type unchanged
The 194 hand-written response types in src/types, TestDataFactory, the ./schemas entryDeprecated, removed in 13.0.0Supported
DeprecationInfo on endpoint definitionsUnchanged: per-call warnings from ESIUnchanged

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.

  1. Lift the deprecations (12.2.0, this page). Posture 1 and ARCH-14 go into the charter.
  2. 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 for addContacts, comma-joined ids, read rather than is_read), kept in one overlay. spec:coverage then also proves every custom method points at a real operation. A spike on two domains comes before converting all 39.
  3. 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.
  4. 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.
  5. 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, buildResponseMeta in src/core/responseMeta.ts, serves each domain client's withMetadata(), the new EsiClient.withMetadata() / withSafeMode() and CustomEsiClient.withMetadata() / withSafeMode() (every domain from one call), and esi.withMetadata() / esi.withSafeMode().
    • Diagnostics (12.x): EsiDiagnostics backs EsiClient.diagnostics, the new CustomEsiClient.diagnostics and its shortcuts, and esi.diagnostics.
    • Swappable middleware (12.x): cache, rateLimiter, circuitBreaker, deduplicator and clock are EsiClientConfig fields that configureApiClient installs, so EsiClient, the builder, EsiApiFactory.createClient and createEsi (whose EsiOptions carry the same five) take them the same way.

Shared already ​

A feature the generated layer gains reaches the custom clients through the same pipeline code, not a copy of it:

FeatureCustom clientscreateEsi()One implementation
Deprecation warningsDeprecationInfo 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 YAMLmeta.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 contractgetPublicContractBids/Items, fetchAllPublicContractBids/Items, streamPublicContractBids/Items, and streamEndpoint/fetchAllEndpoint by namecontracts.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 ​

LayerWhereRole
Custom clientssrc/EsiClient.ts, src/EsiClientBuilder.ts, src/clients/The product: named methods, curated arguments, helpers
Endpoint maps, schemassrc/core/endpoints/*Endpoints.ts, src/schemas/What each method calls and how its answer is validated
Request pipelinesrc/core/Cache, retry, rate limiter, breaker, deduplicator, pagination; shared
Generated operationssrc/generated/, src/adapters/One typed function and *Meta per spec operation; the foundation
Spec-shaped surfacesrc/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.

Released under the GPL-3.0-or-later license.