# 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](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](ARCHITECTURE.md); how to specify and test the change is covered in [TESTING.md](TESTING.md).

---

## 1 · Conventions

| Thing          | Convention                                                               | Example                                                |
| -------------- | ------------------------------------------------------------------------ | ------------------------------------------------------ |
| Interface      | `I` prefix                                                               | `ICache`, `IRetryStrategy`, `ILogger`                  |
| Zod schema     | `Schema` suffix, `z.looseObject`, one file per domain in `src/schemas/`  | `MarketOrderSchema` in `src/schemas/market.ts`         |
| Response type  | Same name without the suffix, `z.infer` of the schema, in `src/types/`   | `type MarketOrder = z.infer<typeof MarketOrderSchema>` |
| Endpoint map   | `Endpoints` suffix, `as const satisfies EndpointMap`                     | `allianceEndpoints`                                    |
| Domain client  | `Client` suffix, extends `BaseEsiClient<typeof xEndpoints>`              | `AllianceClient`                                       |
| Registry key   | camelCase, equal to the `EsiClient` getter name                          | `'paragonHub'` → `client.paragonHub`                   |
| Generated file | `.generated.ts` suffix, header comment, never hand-edited                | `esi-scopes.generated.ts`                              |
| Config object  | `Config` suffix, every field optional                                    | `CircuitBreakerConfig`, `RetryConfig`                  |
| Wire format    | snake_case in types and schemas; camelCase in method and path parameters | `alliance_id` in the body, `allianceId` in the call    |
| Enum from ESI  | `esiEnum([...])`: known members plus an open string                      | `esiEnum(['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:

<!-- doc-example: no-check contributor example: code inside src/, not a consumer import -->

```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](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`:

<!-- doc-example: no-check contributor example: code inside src/, not a consumer import -->

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

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

---

## 2 · The endpoint definition

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

| Field              | Type                                   | Purpose                                                                              |
| ------------------ | -------------------------------------- | ------------------------------------------------------------------------------------ |
| `path`             | `string`                               | Template with camelCase placeholders, e.g. `'alliances/{allianceId}/icons/'`         |
| `method`           | `'GET' \| 'POST' \| 'PUT' \| 'DELETE'` | HTTP method                                                                          |
| `requiresAuth`     | `boolean`                              | Attach the bearer token. Must agree with the generated scope map (DES-04)            |
| `pathParams`       | `readonly string[]`                    | Placeholder names in method-argument order                                           |
| `queryParams`      | `Record<string, string>`               | Method parameter name → query key, e.g. `{ orderType: 'order_type' }`                |
| `hasBody`          | `boolean`                              | The argument after the path and query parameters is sent as the body                 |
| `bodyBuilder`      | `(...args) => unknown`                 | Build the body from the remaining arguments; its parameter types become the method's |
| `cursorPagination` | `boolean`                              | Use `before`/`after` cursors; the return type becomes `CursorResult<T>`              |
| `deprecated`       | `DeprecationInfo`                      | `message`, `replacedBy`, `sunsetDate`; `createClient` logs a warning on each call    |
| `responseSchema`   | `z.ZodType`                            | Validates the response and drives the inferred return type                           |
| `requestSchema`    | `z.ZodType`                            | Validates 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](PAGINATION.md).

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

<!-- doc-example: no-check contributor example: code inside src/, not a consumer import -->

```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](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](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`:

| Definition            | Scope map entry | Result                                                       |
| --------------------- | --------------- | ------------------------------------------------------------ |
| `requiresAuth: false` | present         | **Error**, exit 1. The token is never sent and ESI refuses.  |
| `requiresAuth: true`  | absent          | **Error**, 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](QUALITY-GATES.md#schema-drift).

### 3.6 Deprecation (DES-05)

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

<!-- doc-example: no-check contributor example: code inside src/, not a consumer import -->

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

<!-- doc-example: no-check contributor example: code inside src/, not a consumer import -->

```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](PAGINATION.md).

### 3.8 Prove it

| Check                   | Command                                    |
| ----------------------- | ------------------------------------------ |
| Scenario and unit tests | `npm run bdd:<domain>`, `npm test`         |
| Path and method vs spec | `npm run validate:esi`, `npm run contract` |
| Inferred types          | `npm run test:types`                       |
| Public surface          | `npm 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`:

   <!-- doc-example: no-check contributor example: code inside src/, not a consumer import -->

   ```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 folder                                          | Regenerate with              | Source                              | Freshness check                                |
| ------------------------------------------------------- | ---------------------------- | ----------------------------------- | ---------------------------------------------- |
| `src/types/generated/esi-spec.generated.ts`             | `npm run generate:types`     | OpenAPI schemas                     | CI and release `git diff --exit-code`          |
| `src/core/endpoints/esi-cache-ttls.generated.ts`        | `npm run generate:types`     | `x-cached-seconds`                  | CI and release `git diff --exit-code`          |
| `src/core/endpoints/esi-rate-limit-groups.generated.ts` | `npm run generate:types`     | Rate-limit extensions               | CI and release `git diff --exit-code`          |
| `src/core/endpoints/esi-scopes.generated.ts`            | `npm run generate:types`     | Operation `security` blocks         | CI and release; read by `validate:auth-scopes` |
| `etc/endpoint-scaffold.generated.reference.ts`          | `npm run generate:endpoints` | Operations                          | None; reference only                           |
| `src/generated/operations.generated.ts`                 | `npm run spec:generate`      | Vendored contract snapshot          | CI `spec:generate:check` and `spec:coverage`   |
| `tests/contract/snapshots/esi-openapi.snapshot.json`    | `npm run contract:snapshot`  | Whole document                      | Contract tests fall back to it                 |
| `okf/`                                                  | `npm run generate:okf`       | Operations and schemas              | None                                           |
| `etc/esi.ts.api.md`                                     | `npm run api-report`         | `dist/index.d.ts` via api-extractor | CI 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](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](ARCHITECTURE.md#1a-ports-adapters-and-the-layer-rule).

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](PAGINATION.md#incomplete-walks).

---

## 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`:

| Directory                      | May 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](SDE.md#layers-inside-the-module) |

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.
