Skip to content

Shared code

The platforms in this ecosystem are different on the outside, one on the inside. To users each one has its own brand, domain and services — yet well over 90% of the code comes from a single source.

This page explains how that shared code is distributed.

Why it became necessary

In the beginning every platform was copied out of the template. The result was that a single bug fix had to be repeated by hand across 8 repositories: miss one and that platform silently falls behind.

Measurement showed that ~95% of the frontend was genuinely shared; the real divergence was about 40 lines carrying the brand name. In other words the duplication was not a technical requirement — it was an inheritance of copying.

Three mechanisms

Shared code travels differently depending on the layer. None of them is "copy and paste" — all are versioned and reversible.

Layer Form Mechanism
Backend core Go module go.mod dependency
Frontend layer npm package package.json dependency
Platform skeleton git history git merge + daily autosync

1. Backend core — a Go module

Authentication, role-based access control, the API gateway, audit, the AI pipeline, the eID/SSO integration — all of it lives in one Go module. A platform's main.go is typically around 30 lines: start the core, then add whatever routes are specific to that platform.

The core has one direct layer:

  • open-gerege-core — the open foundation consumed directly by every government and Gerege backend.

Until 2026-08-02, private-gerege-core sat in between. It contained no added logic or migrations, so it was removed from the chain and archived. Commercial application logic remains in each product repository.

2. Frontend layer — @gerege/ui-core

The same problem the core solved on the backend, solved again on the frontend. The package contains:

  • lib/** — API client, BFF helpers, the i18n dictionary, theme, session,
  • components/** — shell, admin, user area, eID, gateway,
  • api/** — the logic behind 158 BFF routes.

The package ships as TypeScript source (not built), so the consuming app compiles it through Next.js transpilePackages. Distribution is an open HTTPS tarball — no authentication required, works inside a Docker build.

Why BFF routes keep a wrapper

Next.js registers routes through the file system, so each app keeps a one-line re-export per path:

// src/app/api/org/[id]/route.ts
export { GET, PUT, DELETE } from '@gerege/ui-core/api/org/[id]';
export const dynamic = 'force-dynamic';

All 158 files could collapse into a single [...path] catch-all — but that would destroy a security allow-list: the route listing defines which backend paths the browser can reach at all. The wrapper is a deliberate price.

3. Platform skeleton — git inheritance

Whatever does not belong in the package (page structure, globals.css, deploy configuration) is inherited from the template by git merge. A daily autosync pulls changes from the upstream template and opens a pull request in the app repositories — every change that reaches production passes human review.

Files that must stay each platform's own (brand, deploy, CI, docs) are protected by merge=ours in .gitattributes.

merge=ours does not protect against one-sided changes

That driver only resolves conflicts. If the upstream template deletes a file, the merge follows it — the driver is never invoked. Real protection is for each brand/config file to hold different content on both sides.

What stays platform-owned

In the package / core Owned by the platform
lib/**, components/**, BFF logic brand.config.ts — name, domain, colours, docs URL
Auth, RBAC, gateway, audit components/landing/** — marketing copy
eID / SSO integration app/**/page.tsx — route registration (thin wrappers)
The shared i18n dictionary (846 keys × 7 languages) lib/<platform>I18n.tsplatform terminology
Menu structure (AppShell) nav.config.ts — which sections the platform serves
app/globals.css — brand colour tokens
deploy/**, .github/** — deployment, CI

Why platform terminology stays in the app

The rule: the shared dictionary only knows the shared surface. Words that belong to one platform alone — the wallet's IBAN/statement vocabulary, the developer portal's API catalogue, Ring's business-process terminology — live in the app's own dictionary.

The reason is cost: put Ring's 1,104 terms into the shared dictionary and kiosk, POS and the wallet all carry them — and every language added multiplies that cost sevenfold.

The implementation follows the same pattern in every repository:

// lib/walletI18n.ts — the wallet's 15 terms × 4 languages
export function useWalletT() {  }   // falls back to English where untranslated

Where a component passes T down as a prop to its children, splitting it into two functions (T + wt) would mean splitting every prop as well. In that case write a single resolver: if the key is the platform's own, take it from the platform dictionary, otherwise from the package (ring-dgov's lib/lang.ts).

AppShell has the same layout on every platform (Super admin · Admin · Manager · Citizen), but each platform implements only a subset of it: the wallet has no gateway, relay or registry modules.

Setting Purpose
navRoutes The routes the app actually serves; the menu is filtered by this
navSystemLabels System names on the rail (me → "Wallet")
navExtra Menu entries that exist only on that platform (Ring's 21 BPM entries)

navExtra comes from a CLIENT component

UiCoreProvider is a client component called from the server root layout. Menu icons (React components) and label functions do not cross the server→client boundary. So the app creates a thin client wrapper and passes them from inside it:

// src/nav.config.tsx
'use client';
export default function AppNav({ children }) {
  return <UiCoreProvider navExtra={NAV_EXTRA}>{children}</UiCoreProvider>;
}

Marking a menu entry that only a few platforms serve with optIn: true in the package makes it appear only on the platforms that list it explicitly in navRoutes.

Three automated gates

Shared code creates three different kinds of dependency. Each one shows up differently when it breaks, so there are three gates as well:

Dependency Gate What breakage looks like
Package code ← app code tsc Compilation fails — immediately visible
Package route ← app BFF wrapper check-routes The endpoint silently disappears
Package class ← app CSS check-styles The screen silently loses its styling
  • check-brand — the build fails if a platform name appears in code outside brand.config.ts. It reads the platform's own name from brand.config.ts, so the list never falls out of date by hand.

    What the gate caught

    The sign-in page offered login through "Gerege SSO (sso.gerege.mn)" even though the government-line platforms actually redirect to sso.dgov.mn. The host is now read from the backend's SSO_ISSUER.

  • check-routes — requires an app wrapper for every route in the package. Without it, a new package endpoint would silently disappear on that platform (the logic is not visible inside the app, so nothing looks wrong).

    The gate cannot prove the EXCLUDE list is right

    A route that is deliberately not exposed goes into EXCLUDE — the divergence becomes explicit. But the gate cannot catch an entry that is simply wrong. That is how public/languages ended up excluded on one platform and left the language switcher empty: every gate green, the screen broken.

  • check-styles — the package contains no CSS: the styling lives in each repository's globals.css. When the package names a new class, or a repository's CSS goes stale, the component silently loses its styling — buttons fall back to the browser's default grey chrome, tables lose their borders. This gate matches the package's className values against the repository's CSS.

Versioning

All three mechanisms follow semver. When a new version is published, Dependabot opens a pull request in the consuming repositories; the update itself is a one-line change in go.mod or package.json.

Bumping the template is not enough

Since every platform inherits from the template, it is tempting to think that "bump the template and it spreads to everyone". In reality the two kinds of dependency behave differently:

File merge=ours? Spreads from the template?
backend/go.mod yes ❌ never
frontend/package.json no ✅ yes

The protection on go.mod is a structural requirement: the module line differs in every repository (…/gerege-app-mn/backend vs …/wallet-gerege-mn/backend), so every merge would conflict on the very first line. Bumping the backend core version in the template therefore spreads nothing — each repository needs its own pull request.

The inheritance tree is also three levels deep (public template → private template → app), so even a file that can spread takes several autosync cycles to reach the leaves.

A breaking change stalls the dependency pull requests

Growing the dictionary from four languages to seven broke every place that said Record<Lang, …>. As a result every Dependabot pull request failed tsc, nobody merged them, and the next one piled on top — the fleet drifted apart from v0.4.0 to v0.10.2.

So when making a breaking change in a package: (a) put the migration instructions in the release notes, (b) ship the fix in the consuming repositories at the same time. Automated updates need a manual step for breaking changes.

Falling behind is silent

If dependency pull requests pile up, platforms drift onto different versions and the promise that "one fix reaches everyone" breaks. Keeping those pull requests closed is an operating condition of this structure, not an optional nicety.