DEV Community

Cover image for Build a Partner-Service Placement Decision Engine in TypeScript
FinClip Super-App
FinClip Super-App

Posted on

Build a Partner-Service Placement Decision Engine in TypeScript

Build a Partner-Service Placement Decision Engine in TypeScript

Partner ecosystems create an architectural choice before they create an integration task. Should a service run inside the host app, open through a controlled deep link, or remain a separate application?

Treating every useful partner as an embedding candidate creates a crowded product and a growing third-party risk surface. Treating every partner as an external link preserves isolation but can leave customers repeating authentication, context, and payment steps.

This tutorial builds a small TypeScript decision engine for comparing the three models. It uses hard gates for conditions that must not be averaged away, followed by explainable signals for the candidates that pass. The policy is illustrative; adapt the thresholds and evidence requirements to your organization, market, and regulatory obligations.

Model the three outcomes

The engine returns one of three placements:

  • embed: run the partner service within the host experience, for example as a mini app;
  • handoff: use a verified app link, universal link, or secure browser flow;
  • separate: keep a dedicated app because the audience or operating boundary is distinct.
export type Placement = "embed" | "handoff" | "separate";

export type Score = 0 | 1 | 2 | 3 | 4 | 5;

export interface PartnerServiceCandidate {
  id: string;
  name: string;

  // Product signals
  journeyContinuity: Score;
  hostCapabilityValue: Score;
  usageFrequency: Score;
  consequenceWhenNeeded: Score;
  distinctAudience: Score;
  specialistExperienceDepth: Score;

  // Operational readiness
  dueDiligenceComplete: boolean;
  supportOwnerAssigned: boolean;
  dataPurposeApproved: boolean;
  exitPlanTested: boolean;
  runtimeMonitoringAvailable: boolean;

  requestedCapabilities: string[];
  allowedCapabilities: string[];
}

export interface PlacementDecision {
  candidateId: string;
  placement: Placement;
  eligibleToEmbed: boolean;
  blockers: string[];
  reasons: string[];
  scores: {
    embed: number;
    handoff: number;
    separate: number;
  };
  policyVersion: string;
  evaluatedAt: string;
}
Enter fullscreen mode Exit fullscreen mode

Keeping product signals separate from operational readiness matters. A service can be highly relevant and still be unfit for embedding because its data purpose is unclear or nobody owns customer complaints.

Add categorical embedding gates

Start with conditions that block the embedded model. Do not represent them as negative points in a weighted average.

function embeddingBlockers(
  candidate: PartnerServiceCandidate,
): string[] {
  const blockers: string[] = [];

  if (!candidate.dueDiligenceComplete) {
    blockers.push("Third-party due diligence is incomplete");
  }

  if (!candidate.supportOwnerAssigned) {
    blockers.push("No owner is assigned for customer support and complaints");
  }

  if (!candidate.dataPurposeApproved) {
    blockers.push("The purpose for partner data access is not approved");
  }

  if (!candidate.exitPlanTested) {
    blockers.push("The service exit and withdrawal plan has not been tested");
  }

  const unapproved = candidate.requestedCapabilities.filter(
    (capability) => !candidate.allowedCapabilities.includes(capability),
  );

  if (unapproved.length > 0) {
    blockers.push(`Unapproved host capabilities: ${unapproved.join(", ")}`);
  }

  return blockers;
}
Enter fullscreen mode Exit fullscreen mode

These rules answer a narrow question: can the service be considered for embedding today? A blocker does not necessarily reject the partnership. It may route the first release to a handoff while the teams complete due diligence or redesign the permission request.

Score the placement signals

For eligible candidates, calculate three explainable scores. The weights below express a policy choice: journey continuity and host capability value matter most for embedding; a distinct audience and deep specialist workflow favor a separate app.

function placementScores(candidate: PartnerServiceCandidate) {
  const embed =
    candidate.journeyContinuity * 3 +
    candidate.hostCapabilityValue * 3 +
    candidate.usageFrequency * 2 +
    candidate.consequenceWhenNeeded +
    (candidate.runtimeMonitoringAvailable ? 3 : 0) -
    candidate.distinctAudience * 2;

  const handoff =
    (5 - candidate.hostCapabilityValue) * 2 +
    (5 - candidate.usageFrequency) +
    candidate.specialistExperienceDepth * 2 +
    (5 - candidate.journeyContinuity);

  const separate =
    candidate.distinctAudience * 3 +
    candidate.specialistExperienceDepth * 3 +
    (5 - candidate.journeyContinuity) * 2;

  return { embed, handoff, separate };
}
Enter fullscreen mode Exit fullscreen mode

The score is not a universal truth. Its purpose is to make tradeoffs visible. Product, architecture, operations, and risk teams can challenge a weight or an input instead of arguing from different unstated assumptions.

Build an explainable evaluator

If embedding is blocked, choose between handoff and separate using the remaining scores. If it is eligible, select the highest score and record the main reasons.

const POLICY_VERSION = "2026-08-31.1";

function maxPlacement(
  scores: PlacementDecision["scores"],
  options: Placement[],
): Placement {
  return options.reduce((best, current) =>
    scores[current] > scores[best] ? current : best,
  );
}

function explain(
  candidate: PartnerServiceCandidate,
  placement: Placement,
): string[] {
  const reasons: string[] = [];

  if (candidate.journeyContinuity >= 4) {
    reasons.push("The service continues a task already underway in the host");
  }

  if (candidate.hostCapabilityValue >= 4) {
    reasons.push("Host identity, payment, or context materially improves the task");
  }

  if (candidate.specialistExperienceDepth >= 4) {
    reasons.push("The partner owns a deep specialist workflow");
  }

  if (candidate.distinctAudience >= 4) {
    reasons.push("The service has a substantially different primary audience");
  }

  if (placement === "handoff") {
    reasons.push("A controlled link preserves the partner experience with less coupling");
  }

  if (placement === "embed" && candidate.runtimeMonitoringAvailable) {
    reasons.push("The host can monitor and withdraw the embedded service");
  }

  return reasons;
}

export function decidePlacement(
  candidate: PartnerServiceCandidate,
  now = new Date(),
): PlacementDecision {
  const blockers = embeddingBlockers(candidate);
  const scores = placementScores(candidate);
  const eligibleToEmbed = blockers.length === 0;

  const options: Placement[] = eligibleToEmbed
    ? ["embed", "handoff", "separate"]
    : ["handoff", "separate"];

  const placement = maxPlacement(scores, options);

  return {
    candidateId: candidate.id,
    placement,
    eligibleToEmbed,
    blockers,
    reasons: explain(candidate, placement),
    scores,
    policyVersion: POLICY_VERSION,
    evaluatedAt: now.toISOString(),
  };
}
Enter fullscreen mode Exit fullscreen mode

Tie-breaking deserves an explicit policy. The implementation above keeps the first option when scores tie, which favors embedding only after all gates pass. A more conservative organization may prefer handoff on every tie.

Evaluate three services

An in-journey assistance service uses host context, has high consequence, and meets the operational gates.

const cardTravelHelp: PartnerServiceCandidate = {
  id: "svc-card-travel-help",
  name: "Card Travel Assistance",
  journeyContinuity: 5,
  hostCapabilityValue: 5,
  usageFrequency: 2,
  consequenceWhenNeeded: 5,
  distinctAudience: 1,
  specialistExperienceDepth: 2,
  dueDiligenceComplete: true,
  supportOwnerAssigned: true,
  dataPurposeApproved: true,
  exitPlanTested: true,
  runtimeMonitoringAvailable: true,
  requestedCapabilities: ["identity.basic", "card.event.read"],
  allowedCapabilities: ["identity.basic", "card.event.read"],
};

console.log(decidePlacement(cardTravelHelp));
// placement: "embed"
Enter fullscreen mode Exit fullscreen mode

A specialist booking provider has a deep experience but receives limited value from host capabilities.

const holidayBooking: PartnerServiceCandidate = {
  id: "svc-holiday-booking",
  name: "Holiday Booking",
  journeyContinuity: 1,
  hostCapabilityValue: 1,
  usageFrequency: 1,
  consequenceWhenNeeded: 2,
  distinctAudience: 2,
  specialistExperienceDepth: 5,
  dueDiligenceComplete: true,
  supportOwnerAssigned: true,
  dataPurposeApproved: true,
  exitPlanTested: true,
  runtimeMonitoringAvailable: false,
  requestedCapabilities: [],
  allowedCapabilities: [],
};

console.log(decidePlacement(holidayBooking));
// placement: "handoff"
Enter fullscreen mode Exit fullscreen mode

A service with a separate professional audience and sustained specialist workflow belongs in its own app.

const merchantOperations: PartnerServiceCandidate = {
  id: "svc-merchant-operations",
  name: "Merchant Operations Suite",
  journeyContinuity: 1,
  hostCapabilityValue: 2,
  usageFrequency: 5,
  consequenceWhenNeeded: 4,
  distinctAudience: 5,
  specialistExperienceDepth: 5,
  dueDiligenceComplete: true,
  supportOwnerAssigned: true,
  dataPurposeApproved: true,
  exitPlanTested: true,
  runtimeMonitoringAvailable: true,
  requestedCapabilities: ["merchant.identity"],
  allowedCapabilities: ["merchant.identity"],
};

console.log(decidePlacement(merchantOperations));
// placement: "separate"
Enter fullscreen mode Exit fullscreen mode

Test blockers, not only happy paths

Use Vitest or another test runner to protect the policy’s important behavior.

import { describe, expect, it } from "vitest";
import { decidePlacement } from "./partner-placement";

describe("partner-service placement", () => {
  it("embeds a ready service that continues a host journey", () => {
    const result = decidePlacement(cardTravelHelp);
    expect(result.placement).toBe("embed");
    expect(result.eligibleToEmbed).toBe(true);
  });

  it("blocks embedding when a capability is not approved", () => {
    const candidate = {
      ...cardTravelHelp,
      requestedCapabilities: ["identity.basic", "location.precise"],
    };

    const result = decidePlacement(candidate);
    expect(result.eligibleToEmbed).toBe(false);
    expect(result.blockers.join(" ")).toContain("location.precise");
    expect(result.placement).not.toBe("embed");
  });

  it("does not embed without a customer-support owner", () => {
    const candidate = {
      ...cardTravelHelp,
      supportOwnerAssigned: false,
    };

    const result = decidePlacement(candidate);
    expect(result.eligibleToEmbed).toBe(false);
  });

  it("records the policy version", () => {
    const result = decidePlacement(holidayBooking);
    expect(result.policyVersion).toBe("2026-08-31.1");
  });
});
Enter fullscreen mode Exit fullscreen mode

Add boundary tests for every score, ties, unknown capabilities, missing fields, expired due diligence, unavailable monitoring, and changes in policy version. Validate incoming JSON before it reaches the evaluator so an unexpected value such as 6 cannot bypass the TypeScript type at runtime.

Keep the decision attached to evidence

A boolean field called dueDiligenceComplete is enough for the example, but production systems should store evidence references. Record who approved the assessment, what activity and data flow it covers, when it expires, and which provider version was assessed.

Do the same for support ownership and exit testing. An exit plan is meaningful only if the platform can identify active versions, stop new sessions, handle customers already in the journey, revoke capabilities, preserve records, and display an appropriate replacement or unavailable state.

Store each placement decision with the service manifest, policy version, capability list, scores, blockers, and evidence digests. Re-evaluate when any material input changes. A previously approved provider should not inherit approval for a new data category or a newly acquired subcontractor.

Separate identity federation from data permission

Single sign-on can improve a partner journey, but successful authentication should not grant the partner access to the customer's host profile. Treat identity federation and data authorization as separate decisions.

Issue a token with the smallest audience and scope the partner needs. Keep it short-lived, bind it to the intended service, and avoid placing reusable credentials in URLs or client-side storage. If the partner needs a customer attribute, expose a purpose-specific claim or API instead of sharing the host session or a complete profile object.

The service manifest should declare:

  • the identity subject it needs;
  • every requested data field and capability;
  • the business purpose and retention period;
  • whether the partner or host is the system of record;
  • the user-consent and revocation experience;
  • where audit and customer-support records will live.

These declarations can feed both the placement evaluator and runtime enforcement. A handoff can still use federated identity, while an embedded mini app may receive no customer data at all until the user starts a specific action.

Model provider failure as part of the placement

Partner availability is part of the host experience once a service is promoted or embedded. Decide what the customer sees when the provider is slow, unavailable, returning incomplete data, or operating in only some regions.

For embedded services, use timeouts, circuit breakers, health signals, and a host-controlled unavailable state. Avoid letting a partner error expose internal messages or leave the host navigation blocked. The host should be able to disable entry points for affected audiences without waiting for the provider to publish a fix.

For handoffs, validate the destination before promoting it and monitor completion callbacks where the relationship permits them. If the host cannot observe task completion, state that limitation in the measurement model rather than treating a click as success.

Separate applications still need coordinated incident communication when the host brand promotes them. The placement boundary reduces technical coupling; it does not make customer confusion disappear.

Add contract and geography events to re-evaluation

Technical inputs are only part of the lifecycle. A commercial change can alter the appropriate placement. Re-run the policy when pricing, liability, service levels, data-processing locations, subcontractors, supported countries, or termination rights change.

For multi-market hosts, evaluate placement per market. One provider may qualify for embedding in a country where it is licensed, supported, and locally monitored, while the same service requires a handoff or must remain unavailable elsewhere. Store the market and policy jurisdiction in the decision record so a global approval cannot silently override local restrictions.

An automated expiry date is useful. When evidence or a contract reaches its review date, block new expansion and notify the owner. Existing customers may need a managed transition rather than immediate shutdown, so the policy should distinguish distribution to new users from continued service for active cases.

Implement each model deliberately

The evaluator chooses a product boundary; it does not implement the boundary.

For an embedded mini app, enforce a capability allowlist at runtime. Give each service an owner, signed package, version, rollout audience, health status, and remote suspension control. Capture consent at the point where the partner requests data or a device permission.

For a handoff, allowlist destinations and use verified links. Send the customer to the specific task, show that the destination is changing, minimize data in URL parameters, and provide a return path. Measure whether customers complete the partner task rather than counting outbound clicks alone.

For a separate app, make the value proposition and audience explicit. Avoid silent account coupling. If the host promotes or pre-fills the separate service, declare the data transfer and support boundary with the same care used for an embedded journey.

Treat placement as a reversible product decision

Do not freeze the outcome in a slide deck. Begin with the least complex model that can test the customer value. A handoff may reveal genuine demand but high abandonment at repeated authentication. That evidence can justify an embedded pilot. An embedded service may develop a distinct audience and operational model that later supports a dedicated app.

Track task completion, abandonment, repeated authentication, time to complete, support contacts, complaints, service availability, incident rate, and cost per completed task. Review these alongside the partner’s commercial performance.

The decision engine gives teams a shared language for that review. It prevents “seamless” from becoming an unsupported architectural preference and prevents risk concerns from turning every partner into a generic external link. Each service earns its place through the customer task it improves, the capabilities it needs, and the responsibility the host is ready to sustain.

Top comments (0)