# Design: Slim @suaveplan/currency-providers — foundation-with-sibling-plugins > Non-normative. The normative requirements live in > `./specs/currency-providers/spec.md`. This document explains the > architecture the slim refactor establishes, and is the canonical > cross-cutting reference for the nine sibling `@suaveplan/currency-provider-` > packages landing in the follow-up wave. Each sibling's own `design.md` > SHALL link back to this file rather than duplicating its contents > (per AGENTS.md §0c). ## Shape ```text @suaveplan/currency-providers (this package, slim core) ├── CurrencyProvider contract (re-exported from @suaveplan/currency) ├── CurrencyProviderRegistry — name → instance lookup ├── defineCurrencyProvider — factory helper for object-literal providers ├── httpRequest / composeAbortSignal — shared HTTP utilities ├── ApiKeyConfig / NoKeyConfig / HttpFetchOptions / ProviderConfigBase — config types └── CURRENCY_PROVIDER_CODES (8) + CurrencyProviderError @suaveplan/currency-provider-ecb ──┐ @suaveplan/currency-provider-fixer ──┤ @suaveplan/currency-provider-oxr ──┤ @suaveplan/currency-provider-binance ──┤ each depends on the slim @suaveplan/currency-provider-coingecko ──┤ core for the registry + @suaveplan/currency-provider-coinmarketcap─┤ factory + httpRequest + @suaveplan/currency-provider-cryptocompare ┤ error + configs @suaveplan/currency-provider-kraken ──┤ @suaveplan/currency-provider-virtual ──┘ ``` The slim core has **zero knowledge** of any concrete provider. Adding a tenth provider (say Yahoo Finance) requires zero change to the core — the new sibling package implements the `CurrencyProvider` contract using `defineCurrencyProvider` + `httpRequest`, declares the core as a peer dep, and ships independently. This shape mirrors: - `@suaveplan/network` (core) + `@suaveplan/network-plugin-*` (transports) - `@suaveplan/drive` (core) + `@suaveplan/drive-plugin-*` (chunks, etc.) - `@suaveplan/i18n` (core) + `@suaveplan/i18n-backend-*` (backends) ## Why a registry at all The `CurrencyConverter` from `@suaveplan/currency` already accepts a `provider` and a `fallbackProviders` array — callers can wire providers directly without the registry. The registry exists for callers that: 1. Wire many providers from configuration (e.g. an admin UI that toggles providers on/off). 2. Need a single point that owns the canonical instance of each provider (avoiding the "constructed twice with different `fetch` closures" bug). 3. Want a uniform "look up by name" surface that the future `AggregatorProvider` and `RateLimitedProvider` wrappers can consume. The registry is therefore **opt-in convenience**, not a required indirection. `new CurrencyConverter({ provider: new EcbProvider() })` continues to work once `@suaveplan/currency-provider-ecb` is installed. ## CurrencyProviderRegistry contract ```ts class CurrencyProviderRegistry { /** * Register a provider under its own `provider.name`. Throws * CurrencyProviderError(PROVIDER_ALREADY_REGISTERED) when the name is * already in use — explicit replacement requires unregister + register. */ register(provider: CurrencyProvider): void; /** * Look up a registered provider by name. Throws * CurrencyProviderError(PROVIDER_NOT_REGISTERED) when no provider has * been registered under that name. This is the contract every * caller-facing surface (CurrencyConverter wiring, admin UIs, * aggregator wrappers) uses to resolve providers. */ get(name: string): CurrencyProvider; /** Non-throwing probe — `true` when `get(name)` would succeed. */ has(name: string): boolean; /** Sorted snapshot of currently registered provider names. */ list(): readonly string[]; /** Remove a registration; returns `true` when something was removed. */ unregister(name: string): boolean; } ``` Determinism notes: - `list()` returns a sorted snapshot (not insertion order) so two registries with the same set of providers list them identically — useful for deterministic golden-file tests in downstream packages. - `register` is idempotent only at the identity level: registering the same instance under the same name twice throws `PROVIDER_ALREADY_REGISTERED`, exactly as registering two different instances under the same name throws. Idempotent "replace if newer" semantics belong in a wrapper, not the core. - The registry stores `CurrencyProvider` instances by reference — there is no cloning, no freezing, no defensive copy. Providers are expected to be immutable shells around their config. ## defineCurrencyProvider factory ```ts interface DefineCurrencyProviderInput { readonly name: string; readonly supportsCrypto: boolean; readonly supportsFiat: boolean; readonly supportsVirtual?: boolean; readonly fetchRates: ( options?: RateFetchOptions ) => Promise; } function defineCurrencyProvider( input: DefineCurrencyProviderInput ): CurrencyProvider; ``` Returns an `Object.freeze`-d implementation of the `CurrencyProvider` contract. The factory exists so sibling packages can declare a provider in the "config-driven" idiom: ```ts // In @suaveplan/currency-provider-ecb export function createEcbProvider(config?: NoKeyConfig): CurrencyProvider { const baseUrl = config?.baseUrl ?? DEFAULT_URL; const fetchImpl = config?.fetch ?? globalThis.fetch; return defineCurrencyProvider({ name: "ecb", supportsCrypto: false, supportsFiat: true, async fetchRates(options) { const xml = await httpRequest({ url: baseUrl, fetch: fetchImpl, providerName: "ecb", signal: options?.signal, }).then((r) => r.text()); // … parse, build result … }, }); } ``` Class-based providers (`class EcbProvider implements CurrencyProvider`) remain equally supported — `defineCurrencyProvider` is the lower-ceremony path, not a mandate. ## httpRequest / composeAbortSignal The nine deleted provider implementations all repeated this block: ```ts const response = await this.fetchImpl(url); if (!response.ok) { throw new CurrencyProviderError( CURRENCY_PROVIDER_CODES.HTTP_ERROR, `${PROVIDER_NAME} API error: ${response.status} ${response.statusText}`, { providerName: PROVIDER_NAME, status: response.status } ); } ``` That repetition is the strongest signal that an `httpRequest` helper belongs in the core. The slimmed core ships: ```ts interface HttpRequestInput { readonly url: string; readonly fetch: FetchLike; readonly providerName: string; readonly init?: RequestInit; readonly signal?: AbortSignal; readonly timeoutMs?: number; } /** * Issues the request, composes any caller signal with the timeout signal, * raises CurrencyProviderError(HTTP_ERROR) on non-2xx. Returns the raw * Response so callers handle .json() / .text() / .arrayBuffer() themselves. */ async function httpRequest(input: HttpRequestInput): Promise; /** * Compose two optional AbortSignals into one. Returns: * - `caller` when only the caller signal is present * - `timeout` when only the timeout signal is present * - a combined signal that aborts when EITHER aborts, when both are present * - `undefined` when neither is present */ function composeAbortSignal( caller: AbortSignal | undefined, timeout: AbortSignal | undefined ): AbortSignal | undefined; ``` `composeAbortSignal` uses `AbortSignal.any([...])` when available (Node 20+ / Bun / Chromium 117+) and falls back to a manual `AbortController` wiring for older runtimes — both code paths are exercised by tests so the universal-tier guarantee holds. ## Code registry shape ```ts CURRENCY_PROVIDER_CODES = defineCodes("CURRENCY_PROVIDER", [ // Existing 6 codes (carried forward from the 2026-05-17 archive) "HTTP_ERROR", "PARSE_ERROR", "UPSTREAM_ERROR", "UNSUPPORTED_BASE", "MISSING_CONFIG", "UNKNOWN_CURRENCY", // New in this refactor (registry-related) "PROVIDER_NOT_REGISTERED", "PROVIDER_ALREADY_REGISTERED", ] as const); ``` Both registry codes are reachable from real call sites in this package's tests (`registry.get("nope")` and double-`register` respectively). The six pre-existing codes remain reachable through the sibling packages' tests — this package's own tests no longer trigger HTTP_ERROR / PARSE_ERROR / etc. because the code paths that raised them are now in the siblings. The codes themselves stay declared here because (a) `httpRequest` raises HTTP_ERROR from the shared helper, and (b) every sibling consumes the same registry and asserting "code is defined here, raised there" is the canonical foundation-with-plugins shape. Per AGENTS.md §2c, this is not silent retraction: the codes stay in the registry; the call sites move to sibling packages where they remain exercised. The §13 step-0 spec reconciliation will confirm each code is either raised in this package's `src/` or referenced as a contract that siblings consume. ## File layout (slim) ```text src/ ├── error-codes/ │ ├── error-codes.ts # 8-code registry + CurrencyProviderError │ ├── error-codes.md │ └── error-codes.test.ts ├── types/ │ ├── configs.ts # ApiKeyConfig / NoKeyConfig / HttpFetchOptions / ProviderConfigBase │ └── configs.md ├── registry/ │ ├── registry.ts # CurrencyProviderRegistry │ ├── registry.md │ └── registry.test.ts ├── factory/ │ ├── factory.ts # defineCurrencyProvider │ ├── factory.md │ └── factory.test.ts ├── http/ │ ├── http.ts # httpRequest + composeAbortSignal │ ├── http.md │ └── http.test.ts └── index.ts # explicit named barrel ``` Five feature modules total (down from twelve), matching the §10 no-prefix rule. Five subpath exports in `package.json` (down from eleven). ## Tier purity Universal-tier — same as the just-archived shape. No `node:`/`bun:`/DOM imports anywhere. The shared `httpRequest` helper uses only WHATWG `fetch`, `AbortController`, and `AbortSignal.any` (with manual fallback). No `setTimeout`-based timeout: `AbortSignal.timeout(ms)` is the universal primitive, available in Node 17.3+, Bun, and modern browsers. ## Test strategy - `registry.test.ts` — register / get / has / list / unregister, both registry error codes raised, `list()` sort determinism, idempotent-bystander semantics. - `factory.test.ts` — `defineCurrencyProvider` returns a frozen object satisfying `CurrencyProvider`, preserves all four optional flags, forwards `fetchRates` call-through. - `http.test.ts` — `httpRequest` happy path, HTTP_ERROR on 5xx, header pass through, timeout signal abort, caller signal abort, both signals abort, `composeAbortSignal` for each of the four combinations (neither / caller / timeout / both). - `error-codes.test.ts` — every code defined, `SuaveplanError` contract preserved, all discriminants accessible. All tests use the `@suaveplan/testing/runner` wrapper and a recording `FetchLike` closure. No `vi.stubGlobal`, no `vi.mock`. ## Why this is not a breaking-by-design move The contract every consumer cares about (the `CurrencyProvider` interface, the `CurrencyProviderError` shape, the eight error codes, the four config types) is **identical** before and after this refactor. What moves is *which package owns the concrete classes*. Consumers who installed `@suaveplan/currency-providers` for the registry / configs / error surface see the package shrink, not change. Consumers who installed it for the provider classes need to add the matching `@suaveplan/currency-provider-` package — a one-line `package.json` change per provider — and update their import path. The provider classes themselves keep the same constructor signatures and the same `fetchRates` behaviour, exactly so this move doesn't compound into a contract change.