Part 2 · 2 chapters · ~20 min

Configuration Over Code

The country config as a typed, versioned, validated declaration loaded per tenant through accessors, what it must not contain, config as a product with review, release, rollback and audit, and the two things a value cannot express: rules that change on a regulator's clock, evaluated on the server and previewed on the client, and flags targeted by geography with a kill switch per country.

5

The country config

code
// config.ts: the country config as a typed, validated, versioned object with accessors; loaded per tenant; never read ad hoc
import { z } from 'zod'
export const CountryConfig = z.object({
  country: z.enum(['NG', 'KE', 'GH', 'EG']), version: z.string(),
  currency: z.object({ code: z.string().length(3), exponent: z.number().int().min(0).max(8) }),
  locales: z.object({ default: z.string(), supported: z.array(z.string()).min(1) }),
  identity: z.object({ provider: z.enum(['smile', 'youverify', 'onfido']), documents: z.array(z.string()).min(1), tiers: z.array(Tier) }),
  rails: z.object({ transfer: Rail.optional(), card: Rail.optional(), mobileMoney: Rail.optional() }),   // optional: a missing rail renders "unavailable"
  rules: z.string(),                                                                                  // the rule set id; the rules themselves live in the engine
  features: z.record(z.boolean()),                                                                    // licence facts; experiments and ramps are flags, not here
  legal: z.object({ entity: z.string(), disclosures: z.array(z.string()) }),
}).strict()                                                                                           // an unknown key fails: no smuggling logic in
export type CountryConfig = z.infer<typeof CountryConfig>

let current: CountryConfig | null = null
export async function loadConfig(tenant: string, region: Region) {
  const res = await fetch(`${region.api}/config/${tenant}`, { headers: { 'If-None-Match': etagFor(tenant) ?? '' } })
  const json = res.status === 304 ? cached(tenant) : await res.json()
  const parsed = CountryConfig.safeParse(json)
  if (!parsed.success) throw new ConfigInvalid(tenant, parsed.error)     // a designed load-time state, with the schema version and the tenant; never a crash in a component
  current = parsed.data; telemetry.setDimension('configVersion', parsed.data.version); return parsed.data
}
export const config = {                                                   // accessors: a missing key is a type error here, not an undefined in Ghana
  get currency() { return must().currency },
  rail(kind: keyof CountryConfig['rails']) { return must().rails[kind] ?? null },   // null → the UI's "unavailable in your country" state
  feature(name: string) { return must().features[name] ?? false },
}
const must = () => { if (!current) throw new Error('config not loaded'); return current }
a typed, versioned declaration per country
  1. The shape is the variance inventory (part 0): currency and exponent, locales, identity provider and documents and tiers, rails naming their adapters, the rule set id, features by licence, legal entity and disclosures, support. Every field on the inventory; nothing that is not.
  2. The schema is owned by the core team (JSON Schema, or a type with runtime validation); an invalid config cannot be published; a new field ships with a default; a removal is a dated deprecation (part 5). The contract between the core and every country.
  3. Loading: the client fetches its tenant's config from its region at startup with an ETag and a short TTL, validates against the bundled schema (unparseable is a designed load-time state, never a crash in a component), and attaches the config version to telemetry; the server does the same per request.
  4. Accessors, not lookups: a missing or mistyped key is a type error at build and a validation error at load, not an undefined in one country at runtime; defaults (an omitted rail renders "unavailable in your country") live in the accessor layer.
  5. What it must not contain: secrets (the server's secret store, referenced by name), logic (a condition is a rule), per-user state, copy (the message catalogue per locale, part 3). A config with an if in it is a program without tests; a strict schema keeps logic from being smuggled in.
  6. Config as a product: an owner (the country team authors; the core owns the schema), a review (a PR with CI validation and a human-read diff), a release (a train with rings), a rollback (the previous ETag), an audit a regulator can read. A console edit without review is a production change without a process.
THE COUNTRY CONFIG
what a country declares, how the core reads it, and the schema that keeps it honest
swipe the figure sideways, or tap expand for full screen
1/6
the shape
The shape: { country: "NG", currency: { code: "NGN", exponent: 2 }, locales: { default: "en-NG", supported: ["en-NG", "yo-NG", "ha-NG"] }, identity: { provider: "smile", documents: ["nin", "passport", "drivers"], tiers: [...] }, rails: { transfer: { adapter: "nibss", eta: "PT5M" }, card: { adapter: "paystack" } }, rules: "ng-2026-10", features: { savings: true, loans: false }, legal: { entity: "…", disclosures: [...] }, support: {...} }. Every field on the inventory; nothing that is not.
6

Rules engines and flags by geography

three layers, three release processes
  1. A rule is a condition over a fixed vocabulary (action, amount, tier, counterparty, time) with an effect from a fixed set (require a factor, apply a limit, block with a reason, flag for reporting), in a country rule set the config references. Data a lawyer can read; no arbitrary code.
  2. Evaluation in two places: the server at the authoritative point (the Trust course part 3's submit); the client with the same rule set as a preview (the Trust course part 5: "this will need a strong factor", the limit inline as the user types). The client's result is advisory; the server's is binding.
  3. Authoring and release: the country team writes rules with a compliance reviewer; a change is a PR against the rule set with a table of test cases; CI validates; a config train with rings per country rolls it out; an audit log; an effectiveFrom so a regulatory deadline is met at midnight without a deploy.
  4. Flags by geography: the flag service (the Big-company FE course part 5) with country as a targeting dimension (savings: Nigeria 100%, Kenya 10% ramping, Ghana off for licence); a kill switch per country (one regulator's demand does not touch the others); evaluated on the client with the tenant as context.
  5. Which layer: a constant value is config; a condition with an effect is a rule; an on-or-off by population is a flag. Crossing them (an if in a config; a rule that says "if Ghana, block") loses a release process or an audit.
  6. The client renders all three: config decides what exists on screen; rules decide what the user is told before acting and the reason in words when blocked (the Trust course part 7); flags decide what renders and which experiments assign; each version rides on telemetry so a change in any is attributable.
the exercise
Find every country-specific if in your codebase. Each is a config value, a rule, or a flag in disguise; sort them into the three layers and the extension points write themselves.
RULES ENGINES AND FLAGS BY GEOGRAPHY
logic that changes on a regulator's clock, and features that exist only where licensed
swipe the figure sideways, or tap expand for full screen
1/6
a rule
A rule: { id: "ng.transfer.sca", when: { action: "transfer", amount_gte: 500000000 }, then: { requireFactor: "strong", coolingPeriod: "PT30M" } }, in a rule set ng-2026-10 referenced by the country config. Conditions over a known vocabulary (action, amount, tier, counterparty type, time); effects from a known set (require a factor, apply a limit, block with a reason, flag for reporting). No arbitrary code: a rule is data a lawyer can read.