Part 5 · 2 chapters · ~20 min

Shared Core, Country Adapters

Extension points as contracts in three kinds (declarations, implementations, slots), what a country may never touch, versioning that keeps a core change from breaking twelve adapters, the rule of two, and the boundary on disk: core, adapter and country packages, import rules, selection by config, contract suites and ownership.

11

Extension points

code
// an extension point: the interface the core calls, the contract suite the adapter must pass, and the registry that binds by config
// packages/core/extension-points/payment-rail.ts
export interface PaymentRail {
  readonly id: string; readonly version: 2
  quote(intent: TransferIntent): Promise<Quote>                     // fee, eta, expiry; the core shows it on review (Trust course part 0)
  submit(intent: TransferIntent, key: IdempotencyKey): Promise<Submission>   // the core mints the key (Trust course part 3); the rail honours it
  status(ref: string): Promise<RailStatus>                           // pending sub-states the core renders (Trust course part 3)
  cancel?(ref: string): Promise<CancelResult>                        // optional: a rail that cannot cancel omits it; the core hides the action
}
// packages/core/extension-points/payment-rail.contract.ts  (the core tests the adapter)
export function paymentRailContract(make: () => PaymentRail) {
  test('quote has fee, eta and an expiry in the future', async () => { const q = await make().quote(fx.intent); expect(q.fee.currency).toBe(fx.intent.amount.currency); expect(q.expiresAt).toBeGreaterThan(Date.now()) })
  test('submit is idempotent: the same key returns the same submission', async () => { const r = make(); const a = await r.submit(fx.intent, fx.key); const b = await r.submit(fx.intent, fx.key); expect(b.ref).toBe(a.ref) })
  test('status reaches a terminal state and names a reason on failure', async () => { const s = await pollUntilTerminal(make(), fx.failingRef); if (s.kind === 'failed') expect(s.reason).toMatch(/\w/) })
  test('a network timeout surfaces as RailTimeout, never as success', async () => { await expect(make().submit(fx.intent, fx.key, { simulate: 'timeout' })).rejects.toBeInstanceOf(RailTimeout) })
}
// packages/adapters/rail-nibss/index.ts: export const nibss: PaymentRail = { id: 'nibss', version: 2, quote, submit, status }   // no cancel: NIBSS cannot
// packages/adapters/rail-nibss/rail.test.ts: paymentRailContract(() => nibssWithMockedHttp())
// packages/countries/ng/index.ts: registry.register('rail.transfer', () => import('@adapters/rail-nibss').then(m => m.nibss))   // split; bound by config.rails.transfer.adapter
// the core: const rail = await registry.get<PaymentRail>('rail.transfer'); const quote = await rail.quote(intent)   // never imports nibss
three kinds, one promise
  1. Declarations: data the country supplies in a schema the core owns and renders (steps, documents, rails, disclosures, tiers: part 2's config). The country chooses which and with what parameters, never how a step renders. The schema is the contract, with defaults and dated removals.
  2. Implementations: interfaces the core defines and calls (IdentityProvider, PaymentRail, ReportingFormatter, AddressFormat: part 4's adapters), typed, tested against a contract suite the core ships (the Architecture course part 2's contract tests, inverted), with new methods shipping with defaults.
  3. Slots: constrained insertion into a core flow or screen (an optional stage, a regulatory banner, a country field), accepting only components from the design-system set with props the slot defines; no arbitrary markup, no reordering, no removing core stages. The escape valve that keeps a country from forking for one banner.
  4. Never touched: ledger semantics and money arithmetic, the session and authentication model, the design system (theme through tokens only), the telemetry schema, the shape of degraded states (copy varies; states do not), the release process. A request to vary these is a request to fork; the only route is a design conversation about changing the core for everyone.
  5. Versioned: each point has a version; the core declares what it supports; adapters declare what they implement; a breaking change is a new version with a deprecation period, a guide and a codemod; the contract suites run against every adapter on every core change, so a core PR that breaks an adapter fails before landing.
  6. The discipline: a point only when a second country needs the variance (one country's need is a slot or a flag); designed for the general case, not the requester's; the platform team owns the interface and the country team the implementation; the count of points is a metric, because each is surface area maintained forever.
EXTENSION POINTS
what a country may declare, what it may implement, what it may never touch, and how the core promises not to break it
swipe the figure sideways, or tap expand for full screen
1/6
declarations
Declarations: the country supplies data in a schema the core owns; the core renders it. The KYC step list, the document types, the rails and their parameters, the disclosures, the tiers. The country cannot change how a step renders, only which steps and with what parameters. The schema is the contract; validation (part 2) enforces it; the core team evolves it with defaults and dated removals.
12

The repo: core, adapters, countries

the boundary on disk
  1. The layout: packages/core (shell, flows, design system, money, i18n, telemetry, extension points with contract suites); packages/adapters/* per concern per provider (identity-smile, rail-nibss, reporting-nfiu, address-ke); packages/countries/* binding config, catalogues and adapters; apps/web taking one country package. The Architecture course part 0's layers with countries on top.
  2. Import rules, enforced by dependency-cruiser: the core imports no adapter or country; adapters import only core/extension-points (never the shell or flows); countries import the core and their adapters; nothing imports a country. The one that matters most: the core never imports an adapter.
  3. Selection by config: the country index reads its config (part 2), imports the adapters it names and registers them; the core calls through the registry; each adapter is its own chunk (the Architecture course part 1), so a Nigerian user never downloads the Kenyan rail.
  4. Contract suites per extension point, run in every adapter's CI and on every core change to the interface (affected-only from the graph: the Big-company FE course part 1). An adapter that passes is trusted.
  5. Ownership on disk (CODEOWNERS): core to the platform team; an identity adapter jointly to the country team and the identity sub-team; the country package to the country team. A PR touching core and an adapter needs both; the boundary on disk is the boundary in the org (part 7).
  6. A launch in the repo (part 6): a new country package with a config and catalogues that validate, adapter choices from existing packages (new adapter packages for new providers), a CODEOWNERS entry, a flag. No core change if the points were designed for the general case; a core change is the signal that they were not.
the exercise
Draw your repo's import graph between what would be core, adapters and countries. Every edge from core into a country-specific module is a fork edge; count them, and that is the migration in part 6.
THE REPO: CORE, ADAPTERS, COUNTRIES
how the boundary looks on disk, what imports what, and the CI that keeps it so
swipe the figure sideways, or tap expand for full screen
1/6
the layout
The layout: packages/core/{shell, flows, design-system, money, i18n, telemetry, extension-points}; packages/adapters/{identity-smile, identity-youverify, rail-nibss, rail-mpesa, reporting-nfiu, reporting-frc, address-ng, address-ke}; packages/countries/{ng, ke, gh, eg} each with config.json, catalogues/, and an index that binds adapters; apps/web that takes a country package. The Architecture course part 0's boundaries, with countries as the top layer.