- Published on
Extracting Business Logic From a React Native Monolith: From App To Packages With Verdaccio
Thesis
When white‑label scope explodes (brands, features, compliance variants), coupling domain logic to the React Native shell kills iteration time. The fix is boring and powerful: pull the business out, invert dependencies, and let a plain TypeScript package decide while the app only wires.
Think of it this way: the app touches the world, the package shapes the rules. No React/Native imports, just ports, data, and decisions.
Audience & constraints
- RN iOS/Android teams juggling multiple brands/markets
- Need to share business with a TV box, without forks or heroic syncs
- Private Verdaccio, CI gates, reproducible local dev, no long‑lived branches
Context from mission
Our RN monolith had grown fast and sideways. Screens owned decisions, hooks talked to device services, and analytics/auth/storage were tangled inside navigation. Refactors felt like pulling a thread from a sweater.
We split into three lanes: business
(pure TS domain), app
(RN shell, screens, adapters), tools
(lint, tsconfig, scripts). The immediate win: reuse business
in a new TV box repo and keep UI/OS specifics separate.
Detailed plan
-
Boundary mapping and inventory
We started with a graph, not a hunch. Modules were tagged by responsibility; cycles and back‑edges popped up instantly.Cross‑cutting infra (analytics, auth, storage, DRM) became small facades with explicit contracts. A map we could debate and improve.
-
Simulate isolation with path aliases
We rehearsed the split before touching files.tsconfig
paths named the layers; ESLint banned back‑imports from business to app.{ "compilerOptions": { "baseUrl": ".", "paths": { "@business/*": ["src/business/*"], "@app/*": ["src/app/*"], "@tools/*": ["tools/*"] } } }
// .eslintrc.js (excerpt) module.exports = { rules: { 'no-restricted-imports': [ 'error', { paths: [], patterns: [ { group: ['@app/*'], message: 'Business must not import app.' } ] } ] } }
Jest mirrored with
moduleNameMapper
, Metro withresolver.extraNodeModules
. Zero drama, solid payoff 🚀 -
Kill cycles and define stable contracts
We removed incidental singletons, injected dependencies at the app boundary, and wrote tiny ports for time, storage, crypto/DRM, networking, logging. Domain code depends on ports; the app provides adapters. No React context in business, no RN APIs in core.Example ports:
export interface Clock { now(): Date } export interface SecureStorage { get(key: string): Promise<string | null>; set(key: string, v: string): Promise<void> } export interface DRMKeyProvider { getLicense(contentId: string): Promise<string> }
The mental model clicked quickly—less cleverness, more clarity 🙂
-
Extract
business
as a package
We createdpackages/business
with a dedicatedtsconfig.build.json
, moved pure TS (state machines, reducers, policies), and exposed an explicitindex.ts
public API. Everything else stayed internal.Simple guardrail: if it needs
react
,react-native
, or navigation, it’s not business. Dont negotiate that. -
Extract
tools
as a package
ESLint presets, TS bases, commit tooling, scripts—centralized into@company/tools
. Consumers extend, not override. One place to evolve compiler options and rules.It cut drift across repos and shaved yak‑time later (nice 💆♂️).
-
Establish local registry with Verdaccio
We set up scoped packages and CI publish to a self‑hosted Verdaccio.# verdaccio.yaml (essentials) storage: ./storage packages: '@company/*': access: $all publish: $authenticated uplinks: {}
CI ran
pnpm publish --registry <verdaccio-url>
with a robot token; versions via Changesets. Feedback loop was fast and consistant. -
Migrate the app
We swapped alias imports for real package imports, added an anti‑corruption layer for lingering device concerns, and injected ports at the root. Feature toggles/analytics stayed outside business; only decisions crossed the boundary.Canary builds confirmed parity under prod flags. Tedious but safe 😅
-
Share with TV box project
The TV box imported@company/business
; adapters implemented TV‑specific playback, storage, DRM. Only adapters changed per platform—the decision engine stayed identical, which de‑risked launch windows.Having one truth for entitlement, content windows, error policy simplified on‑call.
-
Governance
SemVer across packages, deprecation windows documented in PR templates, CODEOWNERS per package. Type gates: noany
in public API, exhaustive domain enums,--noUncheckedIndexedAccess
on.Releases ship from main with a
changeset
and green typecheck. Boring on purpose.
A few focus on specific
-
tsconfig paths, ESLint import restrictions
Treat paths as architectural boundaries—enforce with lint and CI. Keep business compiled toes2019
+ with no JSX. Surfaces transpile as they need. -
Metro resolver and Jest mappers
Keep Metro in lockstep:// metro.config.js (excerpt) module.exports = { resolver: { extraNodeModules: { '@business': path.resolve(__dirname, 'node_modules/@company/business') } } }
And keep Jest’s
moduleNameMapper
aligned withtsconfig
to avoid phantom greens. -
Verdaccio config and publish workflow
Authenticate in CI with a scoped token and an explicit.npmrc
:@company:registry=https://verdaccio.internal/ //verdaccio.internal/:_authToken=${VERDACCIO_TOKEN}
Batch version bumps with Changesets—less cognitive load in review.
-
Ports and adapters in practice
Keep ports tiny and boring; adapters can grow per platform without polluting core. Resist leaking telemetry or navigation concerns into business; those belong to the shell.
What worked and what we’d change next
- The alias rehearsal exposed cycles early; the actual extraction felt almost mechanical.
- Ports forced discipline around side‑effects; business tests became data‑in/data‑out, fast and trustworthy.
- Next time we’d add stricter import groups sooner to catch back‑slides in day‑to‑day PRs.
Net‑net: the real game is boundaries and contracts. Verdaccio and packages make distribution trivial; keeping the seams honest is where teams earn compounding speed. The payoff shows up quickly across products (felt great 🏋️♂️).