Global, module, per-call: the config system that stops settings drift
Every feature I’ll cover in the rest of this series — caching, retries, auth, tenancy — can be set globally, overridden per module, and overridden again per call. This post is about that layering system itself, because once it clicks, every later config example makes sense without re-explaining it.
The problem: one setting, applied everywhere, forever
Problem: you set timeout: 10_000 globally because most of your API is
fast. Then one report-generation endpoint genuinely needs 60 seconds. The
naive fix is a special case at the call site — some if (isReportEndpoint)
branch, or worse, bumping the global timeout to 60 seconds so that one slow
endpoint stops timing out, which now means every genuinely-broken fast
endpoint also gets 60 seconds to fail instead of 10.
Solution: three layers of config, deep-merged, where the more specific layer always wins:
library defaults
│
▼
global config (createClient)
│
▼
module config (defineModule)
│
▼
per-call config (ctx.request(spec, perCall))
│
▼
final, merged config for THIS request onlyArrays — like header sets — are merged, not replaced. Everything else, the more specific value wins outright.
// Global: fast default
createClient({ http: { timeout: 10_000 } })
// This one call: override, nothing else changes
await api.reports.generate(input, { timeout: 60_000 })That report call gets 60 seconds. Every other call in the app still times out at 10. No global change, no special-case branching, no copy-pasted config object.
The three layers, with a concrete example each
Layer 1 — global (createClient)
This is your app-wide default. Set it once, thoughtfully, and most of your app never needs to override it.
createClient({
baseURL: 'https://api.example.com',
http: { timeout: 10_000, retry: { attempts: 3 } },
cache: { strategy: 'stale-while-revalidate', ttl: 60_000 },
})Layer 2 — module (defineModule({ config: {...} }))
Overrides the global value for every method inside that one module. This is
for “this whole group of endpoints behaves differently,” not one-off calls.
Supported keys: baseURL, timeout, headers, auth, cache, retry,
tenancy, validation.
// Every 'search' method gets a shorter cache TTL — search results
// go stale fast, unlike most of the app's data.
const search = defineModule({
config: { cache: { ttl: 5_000 } },
methods: { /* ... */ },
})Problem this solves: without module-level config, you’d have to pass
{ cache: { ttl: 5_000 } } as a per-call override on every single search
method call, and someone would eventually forget one.
Layer 3 — per-call (ctx.request(spec, perCall))
The narrowest override, scoped to exactly one call.
interface PerCallConfig {
signal?: AbortSignal
headers?: Record<string, string>
tenantId?: string
cache?: { enabled?: boolean; ttl?: number; bust?: boolean }
retry?: { attempts?: number }
timeout?: number
skipAuth?: boolean
skipDedup?: boolean
responseType?: 'json' | 'blob' | 'text' | 'arraybuffer'
}// Force-refresh one call, ignoring whatever's cached
await api.users.get('42', { timeout: 2000, cache: { bust: true } })Why “arrays merge, not replace” actually matters
Problem: you set a global header (say, X-Client-Version) and then add a
per-call header for one request. If per-call config replaced the global
config instead of merging, that one call would silently lose
X-Client-Version — a header some backend logging or analytics pipeline
might depend on.
Solution: header sets specifically merge across layers, so your per-call
headers object adds to the global one instead of wiping it out. This is
the kind of default that only matters once, in production, when you notice a
header missing from exactly one endpoint’s logs and spend twenty minutes
confused about why.
A worked example: three layers, one request
Let’s trace an actual request through all three layers to make the merge order concrete.
// Layer 1: global
createClient({
http: { timeout: 10_000, headers: { 'X-Client-Version': '2.4.0' } },
})
// Layer 2: module
const invoices = defineModule({
config: { timeout: 20_000 }, // invoices module is generally slower
methods: {
get: async (ctx, id: string, perCall?) =>
(await ctx.request(
{ method: 'GET', path: '/invoices/{id}', pathParams: { id } },
perCall,
)).data,
},
})
// Layer 3: per-call, at the actual call site
await api.invoices.get('inv_1', { timeout: 45_000, headers: { 'X-Trace-Id': 'abc' } })The request that actually goes out has: timeout: 45_000 (per-call wins
over module’s 20,000 and global’s 10,000), and headers containing both
X-Client-Version: 2.4.0 and X-Trace-Id: abc — because headers merge
instead of overwrite.
When to reach for which layer
- Global — anything true for 80%+ of your app: base URL, default timeout, default cache strategy, auth strategy.
- Module — a whole group of endpoints has different needs: a payroll module on a different host, a search module with a short cache TTL, an admin module with longer timeouts.
- Per-call — a genuine one-off: this specific button click needs to bypass the cache, this specific request needs a longer timeout because the user uploaded a big file.
If you find yourself passing the same per-call override to every call in a module, that’s a signal it belongs at the module layer instead. If you find yourself passing the same module override to every module, it belongs at the global layer. The layering system isn’t just a technical mechanism — it’s also a decent forcing function for noticing when a “one-off” isn’t actually one-off anymore.
What sticks from this post
- Three layers: global → module → per-call, deep-merged, more specific wins.
- Arrays (like headers) merge across layers instead of replacing — so a per-call header addition doesn’t silently drop global ones.
- Use the layer that matches the actual scope of the setting — global for app-wide defaults, module for a whole group of endpoints, per-call for genuine one-offs.
Next: authentication — all four strategies, and the OAuth2 refresh flow in enough detail to actually trust it in production.
- developerehsan