DEV Community

Cover image for Is Your Mobile App Ready for Mini Apps? Build an Evidence-Based Check in TypeScript
FinClip Super-App
FinClip Super-App

Posted on

Is Your Mobile App Ready for Mini Apps? Build an Evidence-Based Check in TypeScript

Is Your Mobile App Ready for Mini Apps? Build an Evidence-Based Check in TypeScript

Platform decisions often begin with architecture diagrams. A more reliable starting point is the work an organisation is already doing.

Are several teams rebuilding identity and payment integrations? Are campaigns waiting for the next native release? Do customers leave the host app and authenticate again in adjacent services? Is there a team prepared to own shared capabilities after the first launch?

Those questions can be answered with evidence. In this tutorial, we will build a small TypeScript tool that records that evidence, distinguishes positive signals from hard blockers, and produces one of three recommendations:

  • start_thin_platform: there is enough repeated demand and operating readiness for a narrow pilot;
  • run_discovery: the pattern may be real, but important evidence or ownership is missing;
  • stay_modular: the current needs are better handled inside a conventional modular app.

The tool will not “calculate” a strategic decision. Its purpose is to make assumptions visible and prevent a single enthusiastic score from hiding a serious gap.

What readiness looks like in data

A platform becomes more plausible when several independent services need common host capabilities. The first service can always be a special case. A likely second service is important because it tests whether the proposed foundation is actually reusable.

We will represent six areas:

  1. Repeated demand — at least two committed service candidates.
  2. Reusable capabilities — identity, payment, permissions, analytics, or similar needs occur across teams.
  3. Coordination cost — lead time, release queues, and repeated reviews are measurable.
  4. Customer continuity — adjacent journeys contain external handoffs or repeated authentication.
  5. Operating ownership — the platform and services have named owners and support paths.
  6. Evidence quality — important inputs come from records, not guesses.

We also need blockers. A missing platform owner, no second use case, or undefined identity boundary should not be averaged away by several positive signals.

Model the assessment

Create readiness.ts and define the input types:

export type EvidenceSource = "measured" | "estimated" | "unknown";

export interface CandidateService {
  name: string;
  committedTeam: boolean;
  requiredCapabilities: string[];
  externalHandoffsPerJourney: number;
  owner?: string;
}

export interface DeliveryBaseline {
  medianLeadTimeDays: number;
  nativeReleaseWaitDays: number;
  repeatedSecurityReviewsLast12Months: number;
  source: EvidenceSource;
}

export interface OperatingModel {
  platformOwner?: string;
  supportRouteDefined: boolean;
  serviceReviewDefined: boolean;
  retirementPathDefined: boolean;
  identityBoundaryDefined: boolean;
  dataAccessBoundaryDefined: boolean;
}

export interface ReadinessInput {
  hostAppMonthlyActiveUsers: number;
  services: CandidateService[];
  delivery: DeliveryBaseline;
  operatingModel: OperatingModel;
}

export type Decision =
  | "start_thin_platform"
  | "run_discovery"
  | "stay_modular";

export interface Signal {
  id: string;
  passed: boolean;
  evidence: string;
}

export interface Assessment {
  decision: Decision;
  confidence: "high" | "medium" | "low";
  signals: Signal[];
  blockers: string[];
  nextActions: string[];
}
Enter fullscreen mode Exit fullscreen mode

Notice what is absent: a feature-count field. A large app can remain a single well-run product. We care about how many independent services need the same foundations and how much friction exists in the current delivery path.

Monthly active users are included for context, not as a gate. A smaller trusted app can have a strong platform case, while a very large app can have no reason to expose shared capabilities.

Find repeated capabilities

The next function counts how many committed teams need each capability:

function repeatedCapabilities(
  services: CandidateService[]
): Array<{ capability: string; consumers: number }> {
  const counts = new Map<string, number>();

  for (const service of services.filter((item) => item.committedTeam)) {
    for (const capability of new Set(service.requiredCapabilities)) {
      counts.set(capability, (counts.get(capability) ?? 0) + 1);
    }
  }

  return [...counts.entries()]
    .filter(([, consumers]) => consumers >= 2)
    .map(([capability, consumers]) => ({ capability, consumers }))
    .sort((a, b) => b.consumers - a.consumers);
}
Enter fullscreen mode Exit fullscreen mode

We deduplicate capabilities inside each service because one service mentioning analytics twice should not create extra evidence. We count committed teams only. A long list of hypothetical partners can be useful for exploration, but it should not carry the same weight as teams that have an owner and a real delivery need.

Separate signals from blockers

Now create the assessment function:

export function assessReadiness(input: ReadinessInput): Assessment {
  const committed = input.services.filter((service) => service.committedTeam);
  const reusable = repeatedCapabilities(input.services);
  const handoffs = committed.reduce(
    (total, service) => total + service.externalHandoffsPerJourney,
    0
  );

  const signals: Signal[] = [
    {
      id: "second-use-case",
      passed: committed.length >= 2,
      evidence: `${committed.length} committed service team(s)`,
    },
    {
      id: "reusable-capabilities",
      passed: reusable.length >= 2,
      evidence:
        reusable.length > 0
          ? reusable.map((item) => `${item.capability}:${item.consumers}`).join(", ")
          : "No capability has two committed consumers",
    },
    {
      id: "delivery-friction",
      passed:
        input.delivery.medianLeadTimeDays >= 30 ||
        input.delivery.nativeReleaseWaitDays >= 14 ||
        input.delivery.repeatedSecurityReviewsLast12Months >= 3,
      evidence: `${input.delivery.medianLeadTimeDays}d median lead time; ` +
        `${input.delivery.nativeReleaseWaitDays}d release wait; ` +
        `${input.delivery.repeatedSecurityReviewsLast12Months} repeated reviews`,
    },
    {
      id: "customer-continuity",
      passed: handoffs >= 2,
      evidence: `${handoffs} external handoff(s) across committed journeys`,
    },
    {
      id: "operating-readiness",
      passed:
        Boolean(input.operatingModel.platformOwner) &&
        input.operatingModel.supportRouteDefined &&
        input.operatingModel.serviceReviewDefined,
      evidence: input.operatingModel.platformOwner
        ? `Platform owner: ${input.operatingModel.platformOwner}`
        : "No platform owner",
    },
  ];

  const blockers: string[] = [];

  if (committed.length < 2) {
    blockers.push("No committed second service to test reuse");
  }
  if (!input.operatingModel.platformOwner) {
    blockers.push("No named owner for shared platform capabilities");
  }
  if (!input.operatingModel.identityBoundaryDefined) {
    blockers.push("Identity boundary is undefined");
  }
  if (!input.operatingModel.dataAccessBoundaryDefined) {
    blockers.push("Data-access boundary is undefined");
  }

  const passed = signals.filter((signal) => signal.passed).length;
  const hasCriticalBoundaryBlocker = blockers.some((blocker) =>
    blocker.includes("boundary")
  );

  let decision: Decision;
  if (committed.length < 2 && passed <= 2) {
    decision = "stay_modular";
  } else if (blockers.length === 0 && passed >= 4) {
    decision = "start_thin_platform";
  } else {
    decision = "run_discovery";
  }

  const confidence =
    input.delivery.source === "measured"
      ? "high"
      : input.delivery.source === "estimated"
        ? "medium"
        : "low";

  const nextActions: string[] = [];
  if (input.delivery.source !== "measured") {
    nextActions.push("Measure lead time and release waiting time from delivery records");
  }
  if (committed.length < 2) {
    nextActions.push("Find a second committed service before designing shared APIs");
  }
  if (hasCriticalBoundaryBlocker) {
    nextActions.push("Define identity and data-access boundaries with security owners");
  }
  if (decision === "start_thin_platform") {
    nextActions.push("Pilot only the capabilities shared by the first two services");
    nextActions.push("Compare second-service lead time with the baseline");
  }

  return { decision, confidence, signals, blockers, nextActions };
}
Enter fullscreen mode Exit fullscreen mode

The thresholds are examples, not universal standards. A regulated bank and a seasonal retail campaign operate on different timelines. Put thresholds in configuration when this moves beyond a workshop tool, and record why each value was chosen.

The decision logic deliberately treats boundary failures as blockers. Four green signals cannot compensate for an undefined identity model. At the same time, a blocker does not always mean “stop forever.” It usually turns the next step into discovery rather than implementation.

Run it against a realistic case

Add an example input:

const example: ReadinessInput = {
  hostAppMonthlyActiveUsers: 2_400_000,
  services: [
    {
      name: "merchant-offers",
      committedTeam: true,
      requiredCapabilities: ["identity", "analytics", "payments", "messaging"],
      externalHandoffsPerJourney: 1,
      owner: "Growth Services",
    },
    {
      name: "travel-insurance",
      committedTeam: true,
      requiredCapabilities: ["identity", "analytics", "payments", "consent"],
      externalHandoffsPerJourney: 2,
      owner: "Protection Products",
    },
    {
      name: "local-events",
      committedTeam: false,
      requiredCapabilities: ["identity", "analytics", "location"],
      externalHandoffsPerJourney: 1,
    },
  ],
  delivery: {
    medianLeadTimeDays: 48,
    nativeReleaseWaitDays: 18,
    repeatedSecurityReviewsLast12Months: 5,
    source: "measured",
  },
  operatingModel: {
    platformOwner: "Mobile Platform",
    supportRouteDefined: true,
    serviceReviewDefined: true,
    retirementPathDefined: false,
    identityBoundaryDefined: true,
    dataAccessBoundaryDefined: true,
  },
};

console.dir(assessReadiness(example), { depth: null });
Enter fullscreen mode Exit fullscreen mode

The two committed services share identity, analytics, and payments. Delivery friction is visible, and customer journeys contain three handoffs. The host has defined critical boundaries and assigned a platform owner. The result should recommend a thin pilot rather than a full ecosystem build.

The uncommitted local-events idea does not strengthen the decision. It may become a useful third case later, but the tool keeps forecasts separate from current demand.

Test the uncomfortable cases

A readiness tool is most useful when it can say “not yet.” Add Vitest tests in readiness.test.ts:

import { describe, expect, it } from "vitest";
import { assessReadiness, ReadinessInput } from "./readiness";

const base: ReadinessInput = {
  hostAppMonthlyActiveUsers: 500_000,
  services: [],
  delivery: {
    medianLeadTimeDays: 10,
    nativeReleaseWaitDays: 3,
    repeatedSecurityReviewsLast12Months: 0,
    source: "measured",
  },
  operatingModel: {
    supportRouteDefined: false,
    serviceReviewDefined: false,
    retirementPathDefined: false,
    identityBoundaryDefined: true,
    dataAccessBoundaryDefined: true,
  },
};

describe("assessReadiness", () => {
  it("keeps one isolated service in the modular app", () => {
    const result = assessReadiness({
      ...base,
      services: [{
        name: "one-campaign",
        committedTeam: true,
        requiredCapabilities: ["analytics"],
        externalHandoffsPerJourney: 0,
        owner: "Campaigns",
      }],
    });

    expect(result.decision).toBe("stay_modular");
    expect(result.blockers).toContain(
      "No committed second service to test reuse"
    );
  });

  it("does not average away an undefined identity boundary", () => {
    const services = ["offers", "insurance"].map((name) => ({
      name,
      committedTeam: true,
      requiredCapabilities: ["identity", "analytics", "payments"],
      externalHandoffsPerJourney: 2,
      owner: name,
    }));

    const result = assessReadiness({
      ...base,
      services,
      delivery: {
        medianLeadTimeDays: 50,
        nativeReleaseWaitDays: 20,
        repeatedSecurityReviewsLast12Months: 6,
        source: "measured",
      },
      operatingModel: {
        platformOwner: "Mobile Platform",
        supportRouteDefined: true,
        serviceReviewDefined: true,
        retirementPathDefined: true,
        identityBoundaryDefined: false,
        dataAccessBoundaryDefined: true,
      },
    });

    expect(result.decision).toBe("run_discovery");
    expect(result.blockers).toContain("Identity boundary is undefined");
  });
});
Enter fullscreen mode Exit fullscreen mode

These cases protect the intent of the model. High user volume does not create readiness. Several positive signals do not neutralise a governance gap. A single campaign does not justify a reusable runtime.

Improve the evidence before improving the score

Teams can easily game a readiness score by changing estimates. The tool should therefore expose evidence quality and preserve the raw observations behind every signal.

In a production version, replace the single source field with provenance for every input:

  • delivery-system query and date range for lead time;
  • app analytics query for external handoffs and completion;
  • repository or architecture review for duplicated capabilities;
  • named accountable owner for every service;
  • decision record for identity and data boundaries.

Store assessments over time rather than overwriting them. A change from run_discovery to start_thin_platform should be explained by new evidence: a second committed service, an approved boundary, or measured coordination cost.

Avoid a leaderboard across business units. The output supports a local decision; it is not a maturity contest. One team may correctly stay modular while another pilots a shared mini-app runtime.

Define the thin pilot

If the decision is start_thin_platform, use the repeated-capability output to limit scope. In the example, the first two services share identity, analytics, and payments. That is the candidate boundary. The unshared messaging and consent needs can remain service-specific until another consumer appears or governance requires standardisation.

The pilot should also have exit criteria:

  • the second service integrates with fewer custom changes than the first;
  • its delivery lead time improves against the recorded baseline;
  • customer handoffs or repeated authentication decline;
  • support and security workload remain acceptable;
  • every shared interface has an owner and a versioning path.

If these conditions are not met, the answer may be to revise the boundary, keep the app modular, or delay further platform investment. Learning that early is part of the value of a thin pilot.

A readiness check should create a better conversation

No TypeScript function can decide whether an organisation should become a platform. It can make the decision more inspectable.

By recording committed services, repeated capabilities, delivery friction, customer handoffs, ownership, and evidence quality, teams can discuss the same facts. Hard blockers remain visible. Hypothetical demand stays separate from committed use cases. The second service becomes a real test of reuse rather than a promise in a roadmap.

That is enough for a useful first step. Build the smallest shared layer that serves two genuine needs, measure what changes, and let the evidence determine whether a broader platform should follow.

Top comments (0)