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.ts — platform 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).
Navigation — shared structure, platform-specific service¶
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 outsidebrand.config.ts. It reads the platform's own name frombrand.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'sSSO_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 howpublic/languagesended 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'sglobals.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'sclassNamevalues 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.
Related¶
- Technology stack
- Platform conventions
- Authentication & authorization — the
AUTH_MODEsetting