DEV Community

Cover image for Add Modular Services Without Rewriting Your React Native Navigation
FinClip Super-App
FinClip Super-App

Posted on

Add Modular Services Without Rewriting Your React Native Navigation

A mobile platform project often begins with an unnecessary assumption: if the delivery architecture changes, the customer navigation must change with it.

That coupling creates a large first release. The team integrates a runtime, invents a new service catalogue, redesigns the home screen, moves existing routes, and introduces several services at once. Production feedback then becomes difficult to interpret because every layer changed together.

A safer approach keeps the public route contract stable. Existing menu items and deep links continue to represent customer tasks. A gateway decides whether each task should open the original native screen, a modular service, or a web fallback. The implementation can evolve without teaching customers a new information architecture on day one.

This article builds that boundary in TypeScript for a React Native application.

Begin with customer-task routes

Avoid exposing runtime terminology in navigation. A customer should open appointments, not miniapp-host with a collection of technical parameters.

Create a small set of route keys representing durable customer intentions:

export type CustomerTask =
  | "support.appointments"
  | "rewards.seasonal-benefit"
  | "services.document-submission";

export type ExistingNativeRoute =
  | "SupportAppointments"
  | "RewardsHome"
  | "DocumentUpload";

export const nativeFallbacks: Record<CustomerTask, ExistingNativeRoute> = {
  "support.appointments": "SupportAppointments",
  "rewards.seasonal-benefit": "RewardsHome",
  "services.document-submission": "DocumentUpload",
};
Enter fullscreen mode Exit fullscreen mode

These identifiers should change slowly. They can be used by navigation, analytics, support documentation, and deep-link handling even when the destination implementation changes.

The fallback map also makes the first integration reversible. Suspending a module does not need to leave the menu item broken; the gateway can return the customer to the previous native journey.

Describe modular destinations with a manifest

Keep service configuration outside navigation components. A manifest makes ownership, compatibility, and permissions reviewable before a module is allowed to launch.

export type HostCapability =
  | "identity.basic"
  | "analytics.event"
  | "location.approximate"
  | "files.select";

export type ServiceManifest = {
  serviceId: string;
  task: CustomerTask;
  owner: string;
  status: "draft" | "active" | "suspended";
  moduleUri: `miniapp://${string}`;
  webFallback?: `https://${string}`;
  minimumHostVersion: string;
  markets: string[];
  capabilities: HostCapability[];
};

export const appointmentManifest: ServiceManifest = {
  serviceId: "appointments.branch.v2",
  task: "support.appointments",
  owner: "customer-operations",
  status: "active",
  moduleUri: "miniapp://appointments.branch/2.1.0",
  webFallback: "https://services.example.com/appointments",
  minimumHostVersion: "8.4.0",
  markets: ["GB", "SG"],
  capabilities: ["identity.basic", "analytics.event"],
};
Enter fullscreen mode Exit fullscreen mode

Production manifests should be signed, schema-validated, versioned, and served through a controlled publication process. The mobile client should never accept an arbitrary URI or capability list from an untrusted response.

The owner field is operational, not decorative. Monitoring and support systems should be able to turn it into a current escalation route. If a team changes, the registry must change before the service becomes orphaned.

Make the destination decision explainable

The gateway needs enough context to select a route without embedding business logic throughout the UI.

export type LaunchContext = {
  task: CustomerTask;
  market: string;
  hostVersion: string;
  modularServicesEnabled: boolean;
  audienceBucket: number; // 0..99
};

export type LaunchDecision =
  | { kind: "native"; route: ExistingNativeRoute; reason: string }
  | { kind: "module"; uri: string; serviceId: string; reason: string }
  | { kind: "web"; url: string; serviceId: string; reason: string };

function compareVersions(left: string, right: string): number {
  const a = left.split(".").map(Number);
  const b = right.split(".").map(Number);
  const length = Math.max(a.length, b.length);

  for (let index = 0; index < length; index += 1) {
    const difference = (a[index] ?? 0) - (b[index] ?? 0);
    if (difference !== 0) return difference;
  }

  return 0;
}

export function decideDestination(
  context: LaunchContext,
  manifest?: ServiceManifest,
): LaunchDecision {
  const native = nativeFallbacks[context.task];

  if (!context.modularServicesEnabled) {
    return { kind: "native", route: native, reason: "platform_disabled" };
  }

  if (!manifest || manifest.task !== context.task) {
    return { kind: "native", route: native, reason: "manifest_missing" };
  }

  if (manifest.status !== "active") {
    return { kind: "native", route: native, reason: "service_inactive" };
  }

  if (!manifest.markets.includes(context.market)) {
    return { kind: "native", route: native, reason: "market_not_allowed" };
  }

  if (context.audienceBucket >= 10) {
    return { kind: "native", route: native, reason: "outside_rollout" };
  }

  if (compareVersions(context.hostVersion, manifest.minimumHostVersion) < 0) {
    if (manifest.webFallback) {
      return {
        kind: "web",
        url: manifest.webFallback,
        serviceId: manifest.serviceId,
        reason: "host_too_old",
      };
    }

    return { kind: "native", route: native, reason: "host_too_old" };
  }

  return {
    kind: "module",
    uri: manifest.moduleUri,
    serviceId: manifest.serviceId,
    reason: "eligible",
  };
}
Enter fullscreen mode Exit fullscreen mode

The returned reason is important. A fallback caused by staged rollout is healthy behaviour; a missing manifest may indicate a publication failure. If both are logged as generic navigation events, operations cannot distinguish control decisions from defects.

Replace the example version parser with a well-tested semantic-version implementation if your versions include prerelease labels or build metadata.

Adapt the decision to existing navigation

The current menu does not need to know how a modular runtime works. It asks the gateway to open a customer task.

type Navigator = {
  navigate: (screen: string, params?: Record<string, unknown>) => void;
};

type Runtime = {
  open: (uri: string, options: { serviceId: string }) => Promise<void>;
};

type Browser = {
  openSecure: (url: string) => Promise<void>;
};

export async function openCustomerTask(
  decision: LaunchDecision,
  dependencies: {
    navigator: Navigator;
    runtime: Runtime;
    browser: Browser;
  },
): Promise<void> {
  switch (decision.kind) {
    case "native":
      dependencies.navigator.navigate(decision.route);
      return;

    case "module":
      await dependencies.runtime.open(decision.uri, {
        serviceId: decision.serviceId,
      });
      return;

    case "web":
      await dependencies.browser.openSecure(decision.url);
  }
}
Enter fullscreen mode Exit fullscreen mode

The adapter should receive only validated destinations. The runtime must reject unknown URI schemes, unapproved service IDs, and capabilities that are absent from the published manifest. The browser adapter should enforce an HTTPS hostname allowlist and prevent arbitrary redirects.

Do not place personal information in module URIs or query parameters. Pass a short-lived, audience-bound launch token containing an opaque subject reference, then let the service retrieve authorised context from its backend. URLs frequently reach logs, screenshots, browser history, and analytics systems.

Preserve deep-link meaning across implementations

Existing links from email, push notifications, websites, and support messages may be more durable than the screen currently serving them. Keep those links expressed in customer-task language as well.

For example, exampleapp://support/appointments should resolve to the same task key used by the in-app menu. The deep-link parser can validate the external input, discard unknown parameters, and then call decideDestination. It should never accept a raw module URI from outside the app.

When route ownership changes, test old links against every supported host version. Customers may open a months-old notification after the modular service has been upgraded, suspended, or replaced. The gateway needs a valid result for each state: current module, maintained web route, or existing native journey.

Preserve analytics meaning too. A historical dashboard may measure appointment_started and appointment_completed; changing the renderer should not create a second business definition. Add technical dimensions such as delivery_kind, service_id, and manifest_version while keeping customer-task events stable. This allows product teams to compare the original journey with the modular one without stitching together unrelated event names.

Be careful with attribution during staged rollout. The server-assigned cohort, route-decision reason, and service version should travel through the journey as controlled metadata. Do not let individual modules redefine these fields. A shared event envelope makes route comparisons possible while preventing each service team from inventing a separate measurement model.

Finally, document the supported lifetime of task routes. Removing a service may retire the customer proposition, but old links should fail in a deliberate way. Show a clear message, provide the next useful destination, and record the retirement outcome so the organisation can find channels that still publish obsolete links.

Keep visual continuity as a contract

Stable navigation is only part of a familiar experience. Independently delivered services can still introduce inconsistent typography, gestures, loading states, terminology, and error behaviour.

Create a host shell that owns shared chrome and exposes approved design tokens:

export type HostDesignContract = {
  themeVersion: "3";
  colorMode: "light" | "dark";
  textScale: number;
  locale: string;
  safeArea: { top: number; right: number; bottom: number; left: number };
  closeBehavior: "return_to_origin";
};
Enter fullscreen mode Exit fullscreen mode

Version this contract independently from individual services. A module can declare the versions it supports, and the host can refuse or fall back when no compatible contract exists.

The contract should cover behaviour as well as colours. Define how the back button works, where the service title appears, how consent is requested, how support is reached, and how a fatal error returns the customer to a safe screen. Accessibility settings from the host should continue into the module whenever the runtime permits it.

Avoid giving every service unrestricted control over the entire screen. Shared chrome helps customers understand that they remain inside the host app and gives the host a reliable place for navigation and safety controls.

Roll out by stable customer identity

Randomising eligibility on every launch can make the same customer alternate between native and modular journeys. Derive the audience bucket from a stable, non-PII identifier on the server, and keep the percentage in policy configuration.

Start with employees or test accounts, then move to a small customer cohort. Measure at least:

  • route decision and reason;
  • module load success and time;
  • task completion;
  • fallback activation;
  • customer exits and retries;
  • support contacts associated with the service;
  • host crashes and runtime errors.

A platform metric such as module load time cannot tell you whether the customer completed an appointment. Join runtime telemetry to a privacy-conscious business outcome using a correlation identifier with limited retention.

Design rollback before launch

Rollback should be a policy change, not an emergency mobile release.

Suspending the manifest or setting the rollout percentage to zero should route new sessions to the previous native screen or an approved web fallback. Decide what happens to sessions already open in the module. Financial or transactional tasks may need to complete, fail safely, or resume through a server-side operation identifier.

The fallback must be maintained while it remains part of the recovery plan. A native screen that has silently broken because the team assumed the module would always be available provides false reassurance.

Use separate controls for platform-wide shutdown and individual-service suspension. One faulty appointment module should not remove unrelated services, while a runtime vulnerability may require the host to disable all modular execution immediately.

Test the policy boundaries

Unit tests should focus on routing conditions and fail-safe behaviour:

import { describe, expect, it } from "vitest";
import { appointmentManifest, decideDestination } from "./routing";

const baseContext = {
  task: "support.appointments" as const,
  market: "GB",
  hostVersion: "8.5.0",
  modularServicesEnabled: true,
  audienceBucket: 4,
};

describe("decideDestination", () => {
  it("opens the module for an eligible customer", () => {
    expect(decideDestination(baseContext, appointmentManifest).kind)
      .toBe("module");
  });

  it("keeps most customers on the native route during rollout", () => {
    const result = decideDestination(
      { ...baseContext, audienceBucket: 41 },
      appointmentManifest,
    );
    expect(result).toMatchObject({
      kind: "native",
      reason: "outside_rollout",
    });
  });

  it("uses the maintained fallback when the service is suspended", () => {
    const result = decideDestination(baseContext, {
      ...appointmentManifest,
      status: "suspended",
    });
    expect(result).toMatchObject({
      kind: "native",
      route: "SupportAppointments",
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

Add integration tests for signed manifest validation, expired launch tokens, incompatible design-contract versions, unknown capabilities, runtime load failure, deep links, analytics correlation, and returning from a web fallback.

Test accessibility and navigation on real devices. A module that renders correctly can still trap focus, break the system back gesture, ignore text scaling, or announce unclear screen titles to assistive technology.

Change the implementation before changing the mental model

The gateway creates a narrow seam between what customers ask the app to do and how the app fulfils the request.

That seam lets the organisation add one modular service behind a route customers already understand. It preserves a native fallback, limits exposure, keeps decisions observable, and makes withdrawal possible without waiting for an app-store release.

Once several services demonstrate value, navigation may need to evolve. That decision can use real evidence: which tasks customers seek, which entries become crowded, and whether discovery is failing. The platform architecture prepares the app for that future without forcing a speculative redesign into the first release.

Customers can keep using the app they recognise while the delivery model underneath it becomes more adaptable.

Top comments (0)