ehsan.blog
~/blog/global-module-percall-level-configuration-system-developerehsan-api-client-v1 — zsh
cat global-module-percall-level-configuration-system-developerehsan-api-client-v1.md

Global, module, per-call: the config system that stops settings drift

·5 min read

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:

plaintext
library defaults


global config        (createClient)


module config         (defineModule)


per-call config        (ctx.request(spec, perCall))


final, merged config for THIS request only

Arrays — like header sets — are merged, not replaced. Everything else, the more specific value wins outright.

ts
// 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.

ts
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.

ts
// 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.

ts
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'
}
ts
// 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.

ts
// 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
ls ./related
cat ./comments

Comments are not configured yet. Enable GitHub Discussions and paste the giscus repo-id / category-id into src/consts.ts.