Skip to content
All notes

Architecture· 8 min read

Micro-frontends without the chaos: shared contracts in a monorepo

What kept five independently deployed portals coherent for a year: versioned UI packages, typed API contracts, and treating architecture as a social agreement.

Micro-frontends have a reputation problem, and it's earned. Most write-ups either sell you the dream — independent teams shipping independently, forever — or the horror story: five design systems, three React versions, and a shell app nobody wants to touch. I spent a year leading the frontend of a platform that ran visa pipelines for an international consultancy, split across independently deployed portals: CRM, visa operations, workforce management. It worked. Not because we picked the right framework, but because we treated the architecture as a set of contracts — and enforced them where drift actually starts.

This is what survived a year of weekly requirement changes, and what I'd keep doing.

Split by business domain, not by page

The first decision that mattered: each app in the monorepo maps to a business domain with its own release cadence and its own operators. The CRM team's counselors never touch visa stage gating; visa officers never see lead capture. Splitting there means a deploy of one portal is genuinely low-risk to the others — which is the entire point of micro-frontends. If your split doesn't follow real organizational seams, you inherit all the coordination cost with none of the independence.

We kept everything in one monorepo. That sounds like it defeats the purpose — isn't the dream separate repos? In practice the monorepo is what made independence safe. Every portal builds against the same commit of the shared packages, so 'works on my branch' and 'works in production' are the same statement. Independent deployment, shared history.

Three packages carry all the coherence

Coherence across the portals came from exactly three shared packages, each owning one kind of drift:

The UI package owns visual drift. Buttons, tables, form fields, layout primitives — every portal renders from the same components, so the platform reads as one product even though it deploys as several. The rule that made it work: portals never style shared components, they compose them. The moment a portal forks a button 'just this once', you have two design systems with extra steps.

The API-contract package owns data drift. Every request and response type that crosses a portal boundary lives here, generated into TypeScript that all apps import. When the backend renamed a field on the visa-stage payload, the compiler broke every consumer at build time — in the monorepo, before any deploy. That single property — cross-portal breakage is a compile error, not a production incident — paid for the whole architecture.

The auth package owns session drift. Login, token refresh, role checks: one implementation, because the fastest way to create a security bug is to have three teams each interpret 'role-based access' slightly differently.

The dependency rule, in one direction
apps/crm        ──┐
apps/visa-ops   ──┼──▶  packages/ui
apps/workforce  ──┘     packages/api-contracts
                        packages/auth

// packages never import from apps.
// apps never import from each other.

Versioning inside a monorepo still matters

The subtle failure mode of shared packages is the forced march: a breaking change to the UI package that makes every portal upgrade today, whether their release schedule likes it or not. We versioned shared packages inside the monorepo and let portals adopt majors on their own cadence — a portal shipping a client demo this week could pin the previous major while the others moved.

That flexibility has a cost: someone has to burn down the version spread, or you're maintaining three majors forever. We capped it — no portal more than one major behind — and made the upgrade part of normal sprint work, not a special project. Boring, explicit, effective.

The architecture is a social agreement

Here's the part no framework gives you: every rule above is trivially breakable by one developer having a bad Friday. Nothing technically stops a portal importing from another portal, or copy-pasting a shared component to 'move fast'. The architecture holds because the team agrees it holds — the tooling just makes the agreement visible. Lint rules blocked cross-app imports, CI failed on contract mismatches, and code review treated a fork of a shared component as a design discussion, not a diff.

I sat in weekly SRS meetings with the client, and half the value of the shared-contract setup showed up there: when a requirement touched two portals, the contract package told us exactly what would break and where. Scoping stopped being guesswork. That's the honest sales pitch for this architecture — not that it makes frontends independent, but that it makes their dependencies impossible to ignore.

What to take away

  • Split micro-frontends along business domains with genuinely different release cadences — or don't split at all.
  • Centralize exactly the things that drift: visual language, API types, auth. Everything else stays local to the portal.
  • Make cross-portal breakage a compile error. Typed contracts in a monorepo are the cheapest insurance you can buy.
  • Version shared packages even in a monorepo, and cap how far portals can lag.
  • Enforce the rules socially and mechanically — lint rules and CI make the agreement visible, code review keeps it alive.