DEV Community

Cover image for Turn a Mobile App Backlog into Delivery Lanes with TypeScript
FinClip Super-App
FinClip Super-App

Posted on

Turn a Mobile App Backlog into Delivery Lanes with TypeScript

Turn a Mobile App Backlog into Delivery Lanes with TypeScript

A mobile app backlog often mixes security work, core journeys, campaigns, regional requests, experiments, and partner integrations. Giving every item the same release path is easy to administer, but it can force a time-sensitive, bounded service to wait behind a high-risk change that correctly requires deeper review.

The answer is not an algorithm that automatically decides product strategy. A useful triage tool makes assumptions visible, enforces non-negotiable constraints, and recommends a delivery lane with reasons that humans can review.

In this tutorial, we will build that tool in TypeScript. It routes a backlog item to one of three lanes:

  • core_native: part of the main app and its coordinated release.
  • modular_in_app: a bounded service loaded inside the host under explicit controls.
  • external_or_standalone: a web handoff or separate app when the audience or boundary is independent.

The model also estimates urgency signals without pretending that uncertain forecasts are exact financial values.

Model the work item

Start with fields that a product, risk, and engineering review can reasonably supply:

export type DeliveryLane =
  | "core_native"
  | "modular_in_app"
  | "external_or_standalone"
  | "needs_discovery";

export type RiskLevel = "low" | "medium" | "high" | "critical";

export interface BacklogItem {
  id: string;
  title: string;
  owner?: string;

  // Demand and timing
  affectedUsersPerMonth?: number;
  evidenceConfidence: "none" | "weak" | "moderate" | "strong";
  valueWindowEndsAt?: string;
  recurringManualCostPerMonth?: number;
  blocksCommittedLaunch: boolean;

  // Customer journey
  startsInHostApp: boolean;
  repeatedLoginOnHandoff: boolean;
  repeatedDataEntryOnHandoff: boolean;
  audienceOverlapWithHost: number; // 0..1

  // Delivery characteristics
  expectedChangesPerQuarter: number;
  hostReleaseDependency: boolean;
  independentlyReversible: boolean;
  independentBusinessOwner: boolean;

  // Integration and risk
  requiredCapabilities: string[];
  capabilityAllowlistDefined: boolean;
  risk: RiskLevel;
  regulatedCoreJourney: boolean;
  broadNativeSurfaceChange: boolean;
  separateBrandOrLegalBoundary: boolean;
}
Enter fullscreen mode Exit fullscreen mode

Several inputs are optional because backlog data is usually incomplete. Missing information should lead to discovery, not a conveniently low score.

audienceOverlapWithHost is an estimate between zero and one. It represents how much of the service's intended audience already uses the host app. It is more useful than asking whether a service is “strategic,” a label that tends to expand during prioritisation meetings.

The data model should be versioned. If teams change the meaning of high risk or strong evidence, old decisions must remain interpretable.

Validate input at the boundary

TypeScript types disappear at runtime, so an API cannot trust incoming JSON merely because it is assigned to BacklogItem. A production service should validate identifiers, ranges, dates, enum values, and currency units before triage. Reject an audienceOverlapWithHost above one, an invalid date, or a negative manual cost. Decide whether missing optional fields are allowed for early discovery and ensure the user interface explains the consequence.

Schema validation libraries can make this concise, but the schema should remain aligned with the policy version. Store the validated snapshot with the result. Recomputing an old decision from data that has since changed weakens the audit trail.

Currency requires special care. If several markets submit costs, store the amount, currency, conversion date, and rate source. The normalised urgency score can compare items only after the organisation agrees on that treatment. Avoid silently treating every numeric amount as the same currency.

Separate hard constraints from preferences

Some conditions should block modular delivery regardless of commercial urgency. A critical service without an owner should not become acceptable because it has a short market window.

export interface TriageResult {
  lane: DeliveryLane;
  confidence: "low" | "medium" | "high";
  urgencyScore: number;
  fitScores: Record<Exclude<DeliveryLane, "needs_discovery">, number>;
  blockers: string[];
  reasons: string[];
  missingEvidence: string[];
}

function findModularBlockers(item: BacklogItem): string[] {
  const blockers: string[] = [];

  if (!item.owner || !item.independentBusinessOwner) {
    blockers.push("A modular service requires a named business owner.");
  }

  if (item.risk === "critical") {
    blockers.push("Critical-risk work stays in the coordinated core lane.");
  }

  if (item.regulatedCoreJourney) {
    blockers.push("The item is part of a regulated core journey.");
  }

  if (item.requiredCapabilities.length > 0 && !item.capabilityAllowlistDefined) {
    blockers.push("Required host capabilities do not have an allowlist.");
  }

  if (!item.independentlyReversible) {
    blockers.push("The service cannot be restricted or removed independently.");
  }

  return blockers;
}
Enter fullscreen mode Exit fullscreen mode

These are example policies, not universal rules. A bank, retailer, airline, and public agency will classify risk differently. The design principle is the important part: keep blockers explicit so a weighted score cannot average them away.

An organisation may support high-risk modular services after building stronger controls. In that case, create a separate policy profile with additional evidence instead of silently weakening the general rule.

Capture missing discovery

The triage engine should resist false certainty. Add a function that identifies information required for a useful decision:

function findMissingEvidence(item: BacklogItem): string[] {
  const missing: string[] = [];

  if (!item.owner) missing.push("service owner");
  if (item.evidenceConfidence === "none") missing.push("demand evidence");
  if (item.affectedUsersPerMonth === undefined) missing.push("affected users");

  if (
    item.recurringManualCostPerMonth === undefined &&
    !item.valueWindowEndsAt &&
    !item.blocksCommittedLaunch
  ) {
    missing.push("cost or timing consequence of delay");
  }

  return missing;
}
Enter fullscreen mode Exit fullscreen mode

Missing evidence does not always mean the item should be rejected. An experiment may exist precisely because demand is uncertain. It should still have a hypothesis, an owner, a limited audience, and a decision that observed usage will inform.

You can distinguish “unknown because this is an experiment” from “unknown because nobody prepared the request” by adding a work type and an experiment plan. Keep this example compact for now.

Estimate urgency without inventing precision

Cost of delay can include a market window, continuing manual work, a committed dependency, and affected users. We can combine normalised signals while retaining the raw evidence for human review.

const DAY_MS = 86_400_000;

function clamp(value: number, min = 0, max = 1): number {
  return Math.min(max, Math.max(min, value));
}

function daysUntil(date: string, now: Date): number {
  return Math.ceil((new Date(date).getTime() - now.getTime()) / DAY_MS);
}

function urgencyScore(item: BacklogItem, now = new Date()): number {
  const windowSignal = item.valueWindowEndsAt
    ? 1 - clamp(daysUntil(item.valueWindowEndsAt, now) / 120)
    : 0;

  const manualCostSignal = clamp(
    (item.recurringManualCostPerMonth ?? 0) / 50_000,
  );

  const reachSignal = clamp((item.affectedUsersPerMonth ?? 0) / 100_000);
  const commitmentSignal = item.blocksCommittedLaunch ? 1 : 0;

  return Number(
    (
      windowSignal * 0.35 +
      manualCostSignal * 0.20 +
      reachSignal * 0.20 +
      commitmentSignal * 0.25
    ).toFixed(3),
  );
}
Enter fullscreen mode Exit fullscreen mode

The thresholds are calibration constants, not benchmarks. A regional organisation may consider 10,000 affected users material. A global consumer platform may not. Put these values in a versioned policy configuration rather than leaving them embedded in application code.

Do not present 0.74 as a financial return. It is a relative urgency signal used to compare similarly evidenced items. Show the underlying market date, cost estimate, and reach alongside it.

The score should also expose evidence confidence. Two requests may receive the same urgency value even though one uses observed call-centre cost and the other uses a campaign forecast. Product reviewers should see that difference. One option is to display a confidence badge beside the score and prevent low-confidence items from outranking committed work without an explicit review.

Do not reward teams for inflating reach or declaring every date a hard deadline. Compare forecasts with actual outcomes after release and report systematic estimation bias by request type, not as an individual performance ranking. The purpose is to improve portfolio decisions, not to create a contest for the largest number.

Score delivery-lane fit

Next, evaluate characteristics that favour each lane:

type ScoredLane = Exclude<DeliveryLane, "needs_discovery">;

function scoreLaneFit(item: BacklogItem): Record<ScoredLane, number> {
  let core = 0;
  let modular = 0;
  let external = 0;

  // Core-native signals
  if (item.regulatedCoreJourney) core += 4;
  if (item.broadNativeSurfaceChange) core += 3;
  if (item.risk === "critical") core += 4;
  if (item.risk === "high") core += 2;
  if (item.audienceOverlapWithHost >= 0.8) core += 1;

  // Modular in-app signals
  if (item.startsInHostApp) modular += 2;
  if (item.repeatedLoginOnHandoff) modular += 2;
  if (item.repeatedDataEntryOnHandoff) modular += 2;
  if (item.expectedChangesPerQuarter >= 3) modular += 2;
  if (item.hostReleaseDependency) modular += 2;
  if (item.independentlyReversible) modular += 2;
  if (item.audienceOverlapWithHost >= 0.5) modular += 1;
  if (item.capabilityAllowlistDefined) modular += 1;

  // External or standalone signals
  if (item.separateBrandOrLegalBoundary) external += 4;
  if (item.audienceOverlapWithHost < 0.25) external += 3;
  if (!item.startsInHostApp) external += 1;
  if (item.requiredCapabilities.length === 0) external += 1;

  return {
    core_native: core,
    modular_in_app: modular,
    external_or_standalone: external,
  };
}
Enter fullscreen mode Exit fullscreen mode

The score explains affinity, while the blockers enforce policy. A campaign may have strong modular characteristics and still remain blocked until its data capability is declared.

Consider negative evidence as well. If a service would add navigation clutter for a tiny unrelated audience, subtract from modular fit. If a web handoff has excellent single sign-on and preserves context, the customer-continuity benefit of embedding may be small.

Make an explainable recommendation

Combine the signals into a result:

function bestLane(scores: Record<ScoredLane, number>): ScoredLane {
  return (Object.entries(scores) as Array<[ScoredLane, number]>).sort(
    (a, b) => b[1] - a[1],
  )[0][0];
}

function scoreGap(scores: Record<ScoredLane, number>): number {
  const ordered = Object.values(scores).sort((a, b) => b - a);
  return ordered[0] - ordered[1];
}

export function triageBacklogItem(
  item: BacklogItem,
  now = new Date(),
): TriageResult {
  const missingEvidence = findMissingEvidence(item);
  const fitScores = scoreLaneFit(item);
  const blockers = findModularBlockers(item);
  const preferred = bestLane(fitScores);
  const reasons: string[] = [];

  if (item.valueWindowEndsAt) reasons.push("The item has a time-bound value window.");
  if (item.hostReleaseDependency) reasons.push("It currently waits for host releases.");
  if (item.repeatedLoginOnHandoff) reasons.push("Handoff repeats authentication.");
  if (item.repeatedDataEntryOnHandoff) reasons.push("Handoff repeats customer input.");
  if (item.separateBrandOrLegalBoundary) {
    reasons.push("The service has an independent brand or legal boundary.");
  }

  let lane: DeliveryLane = preferred;

  if (missingEvidence.length >= 2) {
    lane = "needs_discovery";
  } else if (preferred === "modular_in_app" && blockers.length > 0) {
    lane = item.regulatedCoreJourney || item.risk === "critical"
      ? "core_native"
      : "needs_discovery";
  }

  const gap = scoreGap(fitScores);
  const confidence =
    missingEvidence.length > 0 ? "low" : gap >= 4 ? "high" : "medium";

  return {
    lane,
    confidence,
    urgencyScore: urgencyScore(item, now),
    fitScores,
    blockers,
    reasons,
    missingEvidence,
  };
}
Enter fullscreen mode Exit fullscreen mode

The recommendation can now say “needs discovery” instead of forcing every item into a delivery architecture. That output is useful when it names the missing decision evidence.

The confidence calculation is intentionally simple. A production model could use evidence freshness, source quality, and reviewer agreement, but it should remain understandable to the people approving the work.

Test the policy, especially the boundaries

Policy code needs tests because a small scoring change can alter delivery recommendations. Here are examples using Vitest:

import { describe, expect, it } from "vitest";
import { BacklogItem, triageBacklogItem } from "./triage";

const campaign: BacklogItem = {
  id: "campaign-42",
  title: "Regional renewal campaign",
  owner: "growth-region-a",
  affectedUsersPerMonth: 28_000,
  evidenceConfidence: "strong",
  valueWindowEndsAt: "2026-10-15",
  recurringManualCostPerMonth: 8_000,
  blocksCommittedLaunch: true,
  startsInHostApp: true,
  repeatedLoginOnHandoff: true,
  repeatedDataEntryOnHandoff: false,
  audienceOverlapWithHost: 0.88,
  expectedChangesPerQuarter: 5,
  hostReleaseDependency: true,
  independentlyReversible: true,
  independentBusinessOwner: true,
  requiredCapabilities: ["customer.basic", "analytics.event"],
  capabilityAllowlistDefined: true,
  risk: "medium",
  regulatedCoreJourney: false,
  broadNativeSurfaceChange: false,
  separateBrandOrLegalBoundary: false,
};

describe("mobile backlog triage", () => {
  it("recommends a modular lane for a bounded campaign", () => {
    const result = triageBacklogItem(campaign, new Date("2026-09-02"));
    expect(result.lane).toBe("modular_in_app");
    expect(result.blockers).toEqual([]);
  });

  it("does not average away a missing capability allowlist", () => {
    const result = triageBacklogItem({
      ...campaign,
      capabilityAllowlistDefined: false,
    });

    expect(result.lane).toBe("needs_discovery");
    expect(result.blockers).toContain(
      "Required host capabilities do not have an allowlist.",
    );
  });

  it("keeps a regulated core journey in the core lane", () => {
    const result = triageBacklogItem({
      ...campaign,
      title: "Payment authorization change",
      risk: "critical",
      regulatedCoreJourney: true,
    });

    expect(result.lane).toBe("core_native");
  });

  it("requests discovery when demand and consequence are unknown", () => {
    const result = triageBacklogItem({
      ...campaign,
      owner: undefined,
      evidenceConfidence: "none",
      affectedUsersPerMonth: undefined,
      valueWindowEndsAt: undefined,
      recurringManualCostPerMonth: undefined,
      blocksCommittedLaunch: false,
    });

    expect(result.lane).toBe("needs_discovery");
    expect(result.missingEvidence).toContain("demand evidence");
  });
});
Enter fullscreen mode Exit fullscreen mode

Add regression tests for previously reviewed items before changing weights. If a policy update moves them to another lane, reviewers should understand why.

Run triage as a portfolio conversation

Do not let the tool become a form that teams complete in isolation. Bring product, mobile engineering, security, operations, and the requesting business owner together for the first reviews.

Compare items that have similar evidence and timing. Record the chosen lane, policy version, reviewer, exceptions, and next review date. For rejected or deferred items, record the reason. A backlog becomes easier to manage when “later” has a stated condition rather than functioning as a permanent status.

Run the review at a predictable cadence, while allowing genuinely time-critical items to enter through a documented exception path. A weekly or fortnightly session is often frequent enough for new service requests. Core security incidents should follow their existing emergency process rather than wait for portfolio triage.

Review the distribution of decisions as well as individual results. If nearly every item lands in needs_discovery, the intake form or product discovery process may be weak. If everything is recommended for modular delivery, the scoring weights may be too permissive. If no item can pass the modular blockers, the organisation may lack the controls required for that lane or may be applying core-app rules unchanged.

For the first modular pilot, capture a baseline from the conventional route:

  • Request-to-release elapsed time.
  • Active implementation time versus waiting.
  • Number of handoffs and manual approvals.
  • Host-app engineering effort.
  • Change failures, recovery, and rework.
  • Customer completion and support demand.

Measure the same outcomes after the pilot. A faster release with poor completion or higher incident cost is not a successful result. A slightly faster release that frees core mobile capacity and makes rollback easier may still be valuable.

Treat the first decisions as calibration data. Ask which recommendations reviewers overruled and why. Add a missing policy field only when the same concern recurs; otherwise the intake will grow into a questionnaire that delays the work it was designed to route.

Evolve the lanes carefully

The first version of this engine should be small. Use it to make decisions inspectable and collect disagreements. Those disagreements reveal missing fields, unclear policies, or categories that are too broad.

Over time, add policy profiles for different markets or risk classes, evidence expiry, capability sensitivity, and integration with service manifests. Keep the business inputs visible. An increasingly sophisticated technical score should not conceal uncertain demand.

Most importantly, let the backlog determine whether a new lane is justified. If suitable items appear only once a year, a modular platform may cost more than it saves. If bounded, valuable services repeatedly miss their window because every change depends on the host release, a supported modular path can remove a recurring constraint.

The TypeScript is the easy part. The lasting value comes from agreeing on what each delivery lane is for, which risks cannot be averaged away, and which evidence turns a waiting request into a credible opportunity.

Top comments (0)