Skip to content

ESI.ts Design Rules ​

Implements: DES-01 · DES-02 · DES-03 · DES-04 · DES-05 · DES-06 · DES-07 · DES-08. The requirements themselves, their status and how each is verified live in CHARTER.md Part 3; this guide explains how to meet them.

The conventions that make every domain client read as if one author wrote it, and the two walkthroughs a contributor needs most: adding an endpoint and adding a domain client. How the request pipeline executes a definition is covered in ARCHITECTURE.md; how to specify and test the change is covered in TESTING.md.


1 · Conventions ​

ThingConventionExample
InterfaceI prefixICache, IRetryStrategy, ILogger
Zod schemaSchema suffix, z.looseObject, one file per domain in src/schemas/MarketOrderSchema in src/schemas/market.ts
Response typeSame name without the suffix, z.infer of the schema, in src/types/type MarketOrder = z.infer<typeof MarketOrderSchema>
Endpoint mapEndpoints suffix, as const satisfies EndpointMapallianceEndpoints
Domain clientClient suffix, extends BaseEsiClient<typeof xEndpoints>AllianceClient
Registry keycamelCase, equal to the EsiClient getter name'paragonHub' → client.paragonHub
Generated file.generated.ts suffix, header comment, never hand-editedesi-scopes.generated.ts
Config objectConfig suffix, every field optionalCircuitBreakerConfig, RetryConfig
Wire formatsnake_case in types and schemas; camelCase in method and path parametersalliance_id in the body, allianceId in the call
Enum from ESIesiEnum([...]): known members plus an open stringesiEnum(['character', 'corporation'])

Two long-standing exceptions are kept for compatibility: the skills registry key maps to CharacterSkillsClient, and endpoint map files use the singular domain (assetEndpoints.ts) while the client uses the plural (AssetsClient). Do not copy either into new code.

Schemas tolerate additive change (DES-01) ​

Response validation replaces the body with safeParse().data. A strict z.object strips any field it does not name, so a field CCP adds tomorrow would silently disappear from every consumer. Hand-written response schemas therefore use z.looseObject, which keeps unknown keys:

ts
import { z } from 'zod';
import { esiEnum } from './esiEnum';

export const StandingSchema = z.looseObject({
  from_id: z.number(),
  from_type: esiEnum(['agent', 'npc_corp', 'faction']),
  standing: z.number(),
});

The same reasoning applies to enums. esiEnum is a union of z.enum(values) and z.string(), so the inferred type still autocompletes the known members while a new member from ESI validates instead of throwing. Use a closed z.enum only where the library itself branches on the value.

The only z.object calls in src/ are internal helpers in src/schemas/common.ts that never wrap an ESI body. See RUNTIME-VALIDATION.md for the validation switches and the error a failure produces.

Types come from schemas (DES-02) ​

createClient infers each method's return type from responseSchema. A definition without one compiles, but the method returns Promise<unknown>, which is a silent regression for every caller. Every definition that returns a body ships a schema; only an operation whose success is 204 No Content, or a textResponse document, may omit it. npm run spec:response-schemas enforces this in CI against the vendored spec. The matching exported type is a z.infer alias rather than a second hand-written interface. The spec-derived interfaces in src/types/generated/ are exported separately under the EsiSpec namespace for reference; they are not what the clients return.

Immutability (DES-06) ​

Error classes declare their fields readonly. Merges copy: EsiClientBuilder.withConfig spreads into a new object rather than assigning into the caller's. Bucket and circuit records inside the rate limiter and breaker are the intentional exception because they are counters. When you add a config type, keep its fields optional and treat a received config as read-only.

Compiler settings (DES-07) ​

tsconfig.json sets strict, noUncheckedIndexedAccess, noImplicitReturns, noImplicitOverride and noFallthroughCasesInSwitch. The tuple arithmetic in EndpointArgs and InferEndpointResult only holds under these settings. tsconfig.test.json relaxes noUncheckedIndexedAccess for tests and nothing else. Index access in src/ therefore needs a guard or a justified non-null assertion, as BaseEsiClient does with this._endpoints[endpointName]!.

Logging from a client ​

Clients log through the per-client helpers in src/core/logger/clientLog.ts, passing this._client so the line is attributed to the owning ApiClient:

ts
logWarn(this._client, 'AllianceClient.getContacts() is deprecated.', {
  allianceId,
});

Do not import the global logger in a client. LOGGING.md covers resolution order and custom loggers.


2 · The endpoint definition ​

src/core/endpoints/EndpointDefinition.ts is the whole contract:

FieldTypePurpose
pathstringTemplate with camelCase placeholders, e.g. 'alliances/{allianceId}/icons/'
method'GET' | 'POST' | 'PUT' | 'DELETE'HTTP method
requiresAuthbooleanAttach the bearer token. Must agree with the generated scope map (DES-04)
pathParamsreadonly string[]Placeholder names in method-argument order
queryParamsRecord<string, string>Method parameter name → query key, e.g. { orderType: 'order_type' }
hasBodybooleanThe argument after the path and query parameters is sent as the body
bodyBuilder(...args) => unknownBuild the body from the remaining arguments; its parameter types become the method's
cursorPaginationbooleanUse before/after cursors; the return type becomes CursorResult<T>
deprecatedDeprecationInfomessage, replacedBy, sunsetDate; createClient logs a warning on each call
responseSchemaz.ZodTypeValidates the response and drives the inferred return type
requestSchemaz.ZodTypeValidates the body when validateRequest is on

Arguments are consumed positionally by buildEndpointPath: path parameters, then query parameters in declaration order (an undefined query argument is omitted), then the body. Offset pagination is not declared; the pipeline follows x-pages whenever ESI returns it. See PAGINATION.md.

A representative map, adapted from src/core/endpoints/marketEndpoints.ts and contactEndpoints.ts:

ts
import { z } from 'zod';
import { EndpointMap } from './EndpointDefinition';
import { MarketOrderSchema } from '../../schemas/market';

export const exampleEndpoints = {
  getMarketOrders: {
    path: 'markets/{regionId}/orders/',
    method: 'GET',
    requiresAuth: false,
    pathParams: ['regionId'],
    queryParams: { orderType: 'order_type' },
    responseSchema: z.array(MarketOrderSchema),
  },
  addContacts: {
    path: 'characters/{characterId}/contacts',
    method: 'POST',
    requiresAuth: true,
    pathParams: ['characterId'],
    queryParams: { standing: 'standing' },
    bodyBuilder: (contactIds: number[]) => contactIds,
    requestSchema: z.array(z.number().int()),
    responseSchema: z.array(z.number()),
  },
} as const satisfies EndpointMap;

as const preserves the literal pathParams tuple so EndpointArgs can compute the argument list; satisfies checks the shape without widening it. Dropping either one degrades every method on the map to loose types.

Reference a named *Schema export in responseSchema, optionally wrapped in z.array(...). npm run schema:drift compares inline schemas too, but can only name them (inline).


3 · Walkthrough: add an endpoint ​

The unit of work is one ESI operation wired end to end. Behaviour comes first: follow the ears-gherkin-dev skill and write the EARS rule and a failing scenario before the definition exists (TEST-01, see TESTING.md).

3.1 Find the operation ​

bash
npm run generate:endpoints

scripts/spec/generate-endpoint-scaffold.ts downloads the OpenAPI document and writes etc/endpoint-scaffold.generated.reference.ts: one scaffold map per ESI tag with path, method, requiresAuth (from the operation's security block) and pathParams filled in, and a // responseSchema: TODO placeholder. It is a reference to diff against, not a file to import; it never overwrites src/core/endpoints/.

The scaffold is pinned to the compatibility date in the script (ESI_OPENAPI_URL). An operation newer than that date does not appear; read it from the live spec, or run the nightly drift check (scripts/spec/check-spec-drift.ts, described in QUALITY-GATES.md) which lists what is missing against the latest date.

3.2 Write or reuse the schema ​

Add the response schema to the domain file in src/schemas/, using z.looseObject and esiEnum. Every domain file is re-exported from src/schemas/index.ts, which is the @lgriffin/esi.ts/schemas entry point. Add the z.infer alias beside the others in src/types/<domain>.ts; src/types/api-responses.ts re-exports it.

3.3 Add the definition ​

Copy the scaffold entry into the domain's *Endpoints.ts map, then:

  1. Rename the key to the method name the client will expose (getMarketOrders, not GetMarketsRegionIdOrders).
  2. Keep pathParams in camelCase and in URL order.
  3. Add queryParams, hasBody or bodyBuilder as the operation needs.
  4. Add responseSchema, and requestSchema for a body.
  5. Set cursorPagination: true if the operation takes before/after.

3.4 Check authentication against the scope map (DES-04) ​

bash
npm run validate:auth-scopes

scripts/spec/validate-auth-scopes.ts normalises each definition to METHOD:snake_case_path and compares it with src/core/endpoints/esi-scopes.generated.ts:

DefinitionScope map entryResult
requiresAuth: falsepresentError, exit 1. The token is never sent and ESI refuses.
requiresAuth: trueabsentError, exit 1. The token is sent but no scope is listed.

A genuine mismatch in the generated map (a path ESI spells differently, or a scope newer than the generated file) is recorded in scripts/spec/auth-scope-exceptions.json with a reason. An entry that no longer excuses a mismatch also fails the run, so remove it once the generated map catches up. The file is empty today. Never flip requiresAuth to make the script pass; the bearer header is attached only when that flag is set.

3.5 Check the schema against the spec ​

bash
npm run schema:drift

Reports fields missing on either side, required/optional disagreements and type mismatches, at any depth, between the endpoint's schema and the OpenAPI response. CI runs the --ci variant, which fails on drift not listed in the shrink-only scripts/spec/schema-drift-baseline.json, so a new endpoint must add none. A permanent accepted deviation goes in scripts/spec/schema-drift-exceptions.json keyed by schema name. See QUALITY-GATES.md.

3.6 Deprecation (DES-05) ​

When ESI deprecates an operation, keep the definition and add the field rather than a comment:

ts
getOldThing: {
  path: 'old/thing/',
  method: 'GET',
  requiresAuth: false,
  responseSchema: OldThingSchema,
  deprecated: {
    message: 'ESI is retiring this route.',
    replacedBy: 'getNewThing',
    sunsetDate: '2027-01-01',
  },
},

createClient logs one warning per call through the owning client's logger. When a hand-written client method is itself deprecated (a method that moved to another client), mark it @deprecated in JSDoc and call logWarn(this._client, …), as AllianceClient.getContacts() does.

3.7 Expose it on the client ​

Every key in the map is already callable through this.api. Add a named, documented method on the domain client so it appears in the public surface with camelCase arguments and a JSDoc block:

ts
/**
 * Retrieves all active market orders in a region.
 *
 * @param regionId - The ID of the region to fetch orders for
 * @returns A list of all active market orders in the region
 */
getMarketOrders(regionId: number): Promise<MarketOrder[]> {
  return this.api.getMarketOrders(regionId, 'all');
}

For an offset-paginated operation, also add the stream* and fetchAll* wrappers over streamEndpoint and fetchAllEndpoint, and mark @requires Authentication in the JSDoc where requiresAuth is set. Wrapper semantics are in PAGINATION.md.

3.8 Prove it ​

CheckCommand
Scenario and unit testsnpm run bdd:<domain>, npm test
Path and method vs specnpm run validate:esi, npm run contract
Inferred typesnpm run test:types
Public surfacenpm run build && npm run api-report

Unit tests live in tests/tdd/<domain>/; tests/tdd/helpers/clientErrorTests.ts provides the shared HTTP-error cases. Scenarios live in tests/bdd/features/core/NNNN-<domain>.feature with one step per file in tests/bdd/steps/ and a spec entry in tests/bdd/specs/core/, and mock at the transport seam (TEST-03). Commit the updated etc/esi.ts.api.md; CI fails if it is stale (GATE-03).


4 · Walkthrough: add a domain client ​

Needed only when ESI adds a tag that no existing client covers. Every step below is required; the compiler catches some omissions and a unit test catches others.

  1. Schemas. src/schemas/<domain>.ts, re-exported from src/schemas/index.ts.

  2. Types. src/types/<domain>.ts with z.infer aliases, re-exported from src/types/api-responses.ts.

  3. Endpoint map. src/core/endpoints/<domain>Endpoints.ts, following section 3.

  4. Client class. src/clients/<Domain>Client.ts:

    ts
    import { ApiClient } from '../core/ApiClient';
    import { BaseEsiClient } from './BaseEsiClient';
    import { allianceEndpoints } from '../core/endpoints/allianceEndpoints';
    import { AllianceInfo } from '../types/api-responses';
    
    export class AllianceClient extends BaseEsiClient<typeof allianceEndpoints> {
      constructor(client: ApiClient) {
        super(client, allianceEndpoints);
      }
    
      getAllianceById(allianceId: number): Promise<AllianceInfo> {
        return this.api.getAllianceById(allianceId);
      }
    }

    BaseEsiClient supplies this.api (built by createClient), streamEndpoint, fetchAllEndpoint, withMetadata() and withSafeMode(). A client that also fronts another map (as AllianceClient fronts contact routes) builds a second createClient(client, otherEndpoints) in its constructor. The client holds no HTTP knowledge of its own.

  5. Registry. In src/clients/ClientRegistry.ts add the key to ApiClientType, the class to ClientInstance, an entry to clientFactories, and the class to the re-export list. tests/tdd/core/ClientRegistry.test.ts types its expectations as Record<ApiClientType, …>, so it stops compiling until you add the class there too.

  6. EsiClient getter. In src/EsiClient.ts, get <key>(): <Domain>Client { return this.getClient('<key>'); }. Clients are created lazily on first access.

  7. CustomEsiClient getter. In src/EsiClientBuilder.ts, the same getter returning <Domain>Client | undefined. tests/tdd/core/customClientGetters.test.ts fails to compile when a registered client has no getter (ARCH-08).

  8. EsiApiFactory. EsiApiFactory.createClient('<key>', config) works from the registry with no change. Named factory methods (createMarketClient and so on) are deprecated since 11.0.0 in favour of createClient(type) and EsiClientBuilder; do not add one. A new domain client is never deprecated (ARCH-14).

  9. Root export. export { <Domain>Client } from './clients/<Domain>Client'; in src/index.ts.

  10. Specification and tests. A new numbered feature file, step definitions, unit tests and a tests/typetests/domain-responses.test-d.ts assertion for at least one method, as in section 3.8.

  11. API report. npm run build && npm run api-report, and commit etc/esi.ts.api.md.

The README's client table is still maintained by hand. The client, route and operation counts are generated: run npm run docs:metrics after adding a client (DOC-04).


5 · Generated files (DES-03) ​

Generated output is committed so a diff shows exactly what CCP changed. It is never edited by hand: the next run overwrites the edit and hides the real change. Prettier ignores *.generated.ts and etc/*.api.md.

File or folderRegenerate withSourceFreshness check
src/types/generated/esi-spec.generated.tsnpm run generate:typesOpenAPI schemasCI and release git diff --exit-code
src/core/endpoints/esi-cache-ttls.generated.tsnpm run generate:typesx-cached-secondsCI and release git diff --exit-code
src/core/endpoints/esi-rate-limit-groups.generated.tsnpm run generate:typesRate-limit extensionsCI and release git diff --exit-code
src/core/endpoints/esi-scopes.generated.tsnpm run generate:typesOperation security blocksCI and release; read by validate:auth-scopes
etc/endpoint-scaffold.generated.reference.tsnpm run generate:endpointsOperationsNone; reference only
src/generated/operations.generated.tsnpm run spec:generateVendored contract snapshotCI spec:generate:check and spec:coverage
tests/contract/snapshots/esi-openapi.snapshot.jsonnpm run contract:snapshotWhole documentContract tests fall back to it
okf/npm run generate:okfOperations and schemasNone
etc/esi.ts.api.mdnpm run api-reportdist/index.d.ts via api-extractorCI API Surface Check

npm run generate:all runs types, OKF, the contract snapshot, schema drift and validate:esi in sequence. generate:types defaults to the compatibility date in scripts/spec/generate-esi-types.ts and accepts --latest or --compatibility-date=YYYY-MM-DD. The OKF bundle is described in OKF.md.

src/generated/operations.generated.ts holds one typed function and one *Meta constant per spec operation, all calling the OperationTransport port in src/core/ports/. Unlike the rows above it reads the vendored snapshot, never the live spec, so CI can check it offline. It is generated by scripts/spec/spec-generate.ts, with the ScopeTree, PublicScopeTree and createScopeTree section written by scripts/spec/spec-scope-tree.ts, and spec-refresh.yml is the one workflow that re-vendors the snapshot and regenerates it. src/adapters/PipelineTransport already backs it with the request pipeline, so an operation shares the ApiClient's rate limiter, cache and retry with the hand-written clients. It is not exported from any package entry yet: ROADMAP Phase 2 PR 11 exposes it through the builder. See ARCHITECTURE.md.

The hand-written counterparts are not generated and are not exempt from review: endpoint maps, Zod schemas, clients and the exception files under scripts/ are product decisions, and the drift reports exist to keep them honest.


6 · Pagination rules (DES-08) ​

Pagination helpers take the caller's HTTP method and must not return a truncated result as if it were complete. New pagination code passes def.method through to the retry context, as BaseEsiClient.streamEndpoint and fetchAllEndpoint do, and throws EsiPaginationError with reason fetch-failed when a page fails with a non-ESI error. When it cannot prove the walk complete (a reported count above MAX_PAGES, page-cap; an empty page after the first, empty-page; a cursor still set at the page limit, cursor-cap) it calls incompleteWalk (src/core/pagination/incompleteWalk.ts), which throws under incompletePages: 'throw' and otherwise logs a warning naming the option and lets the walk keep what it has. Put the items collected so far on partial. The default truncate is the one remaining exception and flips to throw in a future major. See PAGINATION.md.


7 · Layers ​

Imports in src/ point inward. npm run lint:layers enforces seven rules with a local ESLint rule, layers/inward-imports in config/eslint/layers.rules.cjs, in CI and in check:local:

DirectoryMay not import
src/core/ports/Anything outside src/core/ports/, packages included. A port is an interface.
src/generated/Anything except src/core/ports/. Change the generator, not its output.
src/core/ (the pipeline)The layers built on it: clients/, EsiClient, EsiClientBuilder, index, generated/, auth/, sde/, testing/, client/, adapters/
src/client/, src/adapters/The custom domain clients and entry points: clients/, EsiClient, EsiClientBuilder, index
src/sde/ (side module)Anything in src/ outside src/sde/ except src/core/ports/; any package other than node:*, zod, js-yaml, adm-zip
Everything else in src/sde/. The SDE shares no code with the pipeline (ARCH-10); a bridge belongs in a separate package
Inside src/sde/Across its own layers: domain/ imports domain/ and zod only, ports/ the domain and the root files, ingestion/ never a port or a provider, providers/ never testing/, testing/ only from the entry points, and nothing imports index.ts or memory.ts. The full table is in SDE.md

The rule resolves each specifier against the importing file, so it judges an import by where it lands, at any depth: .././clients is clients. It reads static and type-only imports, re-exports, import('...') types and expressions, and require(). The lint runs with --no-inline-config, so an eslint-disable comment does not get round it. When core needs something from an outer layer, add a port in src/core/ports/ and have the outer layer implement it.

No file breaks the core rule. BASELINE in config/eslint/layers.rules.cjs once exempted ClientRegistry.ts (now in src/clients/) and configureApiClient.ts (its EsiClientConfig type now lives in src/core/EsiClientConfig.ts); it is empty, and tests/tdd/layers/layers-lint.test.ts fails if an entry is added, so a new violation is fixed in the code, not exempted.

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