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

  1. 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.

  2. 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 with resolver.extraNodeModules. Zero drama, solid payoff 🚀

  3. 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 🙂

  4. Extract business as a package
    We created packages/business with a dedicated tsconfig.build.json, moved pure TS (state machines, reducers, policies), and exposed an explicit index.ts public API. Everything else stayed internal.

    Simple guardrail: if it needs react, react-native, or navigation, it’s not business. Dont negotiate that.

  5. 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 💆‍♂️).

  6. 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.

  7. 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 😅

  8. 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.

  9. Governance
    SemVer across packages, deprecation windows documented in PR templates, CODEOWNERS per package. Type gates: no any 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 to es2019+ 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 with tsconfig 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 🏋️‍♂️).

Last updated
© 2025, Devpulsion.
Exposio 1.0.0#82a06ca | About
Extracting Business Logic From a React Native Monolith: From App To Packages With Verdaccio | Devpulsion