Source: ddx-documentation/adr/0006-factory.md
- **Status:** Accepted (2026-10-23) - **Binds:** G3.3, G6.3, G6.4, invariant 1 (no browser → ddx-api call) - **Related:** [0007](/en/docs/adr/0007-di-container), [0008](/en/docs/adr/0008-singleton-scope), [RQ6](/en/docs/research/rq6-di-scope) ## Context A Server Component and a Client Component reach ddx-api in two different ways. The server goes to ddx-api directly with gateway headers (server-only). The browser goes to the same-origin BFF `/api/v1/*` only. If components build their own clients, the base URL, headers, error envelope and auth handling get copied many times — and one copy will call ddx-api from the browser. ## Decision 1. One factory: `createApiClient({ side: 'server' | 'client', ... })`. - `side: 'server'` — lives in a module that starts with `import 'server-only'`. Base URL = internal ddx-api URL from the server env. Adds the session JWT and gateway headers. Never importable from a `'use client'` file. - `side: 'client'` — base URL = same-origin `/api/v1`. Sends cookies only. No env read. 2. Both sides return the **same typed interface** (`ApiClient`), built from the `@ddx/contracts` schemas. Entity clients (`createEntityClient(api, 'patients')`) wrap it. 3. Responses are parsed with the contract Zod schema and returned as a `Result` (`neverthrow`); errors use the house error envelope. 4. **Components never construct a client.** No `createApiClient`, no `fetch` to an API path, no `new` of a client inside a component or hook body. They receive services from the container (ADR 0007): `getServerContainer().get('entities')` on the server, `useService('entities')` on the client. 5. The factory is the only place that knows the base URL. `NEXT_PUBLIC_API_URL` must not exist. ## Rejected alternative - **Per-component `fetch` helpers** (HMS style): rejected — duplicates headers and envelopes, and makes invariant 1 unverifiable. - **One isomorphic client that checks `typeof window`**: rejected — it would pull server env and gateway code into the client bundle; `server-only` cannot guard it. ## Consequences - One file to audit for auth headers and base URL per side. - Tests inject a fake `ApiClient` through the container, not through module mocks. ## Check ```sh ! rg -n 'NEXT_PUBLIC_API_URL|localhost:16310' ddx-web/src ! rg -n 'createApiClient\(' ddx-web/src/components ddx-web/src/app --glob '*.tsx' rg -l "import 'server-only'" ddx-web/src/lib/api # server factory module carries the marker ```
adr/
deploy/
research/
A Server Component and a Client Component reach ddx-api in two different ways. The server goes
to ddx-api directly with gateway headers (server-only). The browser goes to the same-origin BFF
/api/v1/* only. If components build their own clients, the base URL, headers, error envelope
and auth handling get copied many times — and one copy will call ddx-api from the browser.
createApiClient({ side: 'server' | 'client', ... }).
side: 'server' — lives in a module that starts with import 'server-only'. Base URL =
internal ddx-api URL from the server env. Adds the session JWT and gateway headers. Never
importable from a 'use client' file.side: 'client' — base URL = same-origin /api/v1. Sends cookies only. No env read.ApiClient), built from the
@ddx/contracts schemas. Entity clients (createEntityClient(api, 'patients')) wrap it.Result
(neverthrow); errors use the house error envelope.createApiClient, no fetch to an API path,
no new of a client inside a component or hook body. They receive services from the
container (ADR 0007): getServerContainer().get('entities') on the server,
useService('entities') on the client.NEXT_PUBLIC_API_URL must not exist.fetch helpers (HMS style): rejected — duplicates headers and envelopes,
and makes invariant 1 unverifiable.typeof window: rejected — it would pull server env
and gateway code into the client bundle; server-only cannot guard it.ApiClient through the container, not through module mocks.! rg -n 'NEXT_PUBLIC_API_URL|localhost:16310' ddx-web/src! rg -n 'createApiClient\(' ddx-web/src/components ddx-web/src/app --glob '*.tsx'rg -l "import 'server-only'" ddx-web/src/lib/api # server factory module carries the marker