A multi-brand retailer wants one booking Mini App across two banners. Both services appear to follow the same user journey: choose a store, identify the member, select a time window, and receive a booking reference.
The tempting design is one shared UI connected to one “group” membership service and one booking backend. That design often centralizes data and rules that are not actually shared. Banner A and Banner B may use different CRM systems, price books, cancellation policies, store identifiers, and fulfillment workflows. A common screen does not make those dependencies common.
The safer target is smaller: reuse the stable interaction contract while keeping changeable brand decisions behind explicit ports. This article builds that boundary in TypeScript and shows how a Host App or Mini App runtime can carry brand context without becoming the source of retail truth.
The snippets are reference code for the architecture. They are not drop-in FinClip SDK calls and have not been executed against a production retailer.
** Start with the decisions that must remain local**
David Parnas’s information-hiding criterion is useful here: a module should conceal a design decision likely to change. For multi-brand retail, the likely changes are not the steps drawn in the journey map. They are the policies behind those steps.
Before defining a shared service, list the volatile decisions:
- which CRM recognizes membership for each banner;
- how stores are identified and whether franchise locations participate;
- which system owns availability;
- how prices, fees, taxes, and legal copy are calculated;
- who can confirm, cancel, or reject a booking;
- what happens when a banner leaves the shared service.
If the shared module knows those answers directly, it owns brand policy. If it asks a banner adapter through a stable contract, it owns orchestration.
** Make brand context explicit and immutable**
Do not infer the banner from a color theme, hostname, store-number prefix, or the first CRM record returned. The Host App should provide a signed, validated context when it opens the module.
export interface RetailContext {
hostAppId: string;
bannerId: string;
storeId?: string;
locale: string;
currency: string;
sessionId: string;
}
export interface HostAssertion {
token: string;
audience: "retail-booking";
expiresAt: string;
}
export interface BootstrapRequest {
context: RetailContext;
assertion: HostAssertion;
}
Treat RetailContext as immutable for one service session. If the user switches banner or store, create a new context and repeat eligibility checks. Silent context mutation makes logs ambiguous and creates opportunities for a response from Banner A to be displayed under Banner B.
The assertion should be short-lived and audience-bound. The Mini App does not need the Host App’s refresh token or an unrestricted user profile. The backend validates the assertion and maps the subject to a banner-specific member identifier through the appropriate adapter.
Define the shared contract in retail language
Avoid a generic interface such as execute(action, payload). It hides nothing from callers because every new rule leaks into the payload. Define small ports using concepts that remain meaningful across banners.
export interface StorePort {
search(input: {
bannerId: string;
query: string;
service: "pickup";
}): Promise<StoreSummary[]>;
getServiceStatus(input: {
bannerId: string;
storeId: string;
service: "pickup";
}): Promise<"available" | "unavailable" | "temporarily_paused">;
}
export interface MembershipPort {
resolve(input: {
bannerId: string;
hostSubject: string;
}): Promise<MemberIdentity | null>;
}
export interface BookingPort {
listWindows(input: WindowQuery): Promise<PickupWindow[]>;
quote(input: BookingDraft): Promise<BrandQuote>;
confirm(input: ConfirmRequest): Promise<BookingReceipt>;
cancel(input: CancelRequest): Promise<CancellationReceipt>;
}
Notice what is absent. There is no shared points balance, tier table, price list, or store roster. The contract exposes the result the journey needs while allowing each adapter to obtain that result differently.
BrandQuote should return customer-facing price and policy text as data owned by the banner backend. The shared UI renders it but does not calculate it.
export interface BrandQuote {
quoteId: string;
amount: { value: string; currency: string };
feeLines: Array<{ code: string; label: string; value: string }>;
cancellationText: string;
expiresAt: string;
}
This prevents a shared client release from being required when Banner B changes a fee label or cancellation rule. It also prevents Banner A’s copy from becoming the accidental group default.
**
Compose banner adapters at the edge**
The orchestration service should select adapters from validated banner context. Keep selection in one composition root rather than scattering if (bannerId === ...) branches through the workflow.
interface BannerServices {
stores: StorePort;
membership: MembershipPort;
booking: BookingPort;
}
const servicesByBanner: Readonly<Record<string, BannerServices>> = {
bannerA: {
stores: new BannerAStoreAdapter(),
membership: new BannerACrmAdapter(),
booking: new BannerABookingAdapter(),
},
bannerB: {
stores: new BannerBStoreAdapter(),
membership: new BannerBCrmAdapter(),
booking: new BannerBBookingAdapter(),
},
};
export function servicesFor(bannerId: string): BannerServices {
const services = servicesByBanner[bannerId];
if (!services) throw new UnsupportedBannerError(bannerId);
return services;
}
Each adapter is an anti-corruption layer. It maps a brand system’s identifiers, status codes, and retry behavior into the shared contract. Banner A may query a modern booking API. Banner B may call a legacy service and wait for store confirmation. The shared orchestration must not pretend these consistency models are identical.
For example, confirm() can return a receipt status of confirmed, pending_store, or rejected. The UI can support those states without exposing vendor-specific codes.
Keep Host App APIs narrow
A Mini App running inside an existing retail app may need Host-owned capabilities: the signed-in identity, the selected store, a native payment flow, or a route back to the Host App. Register these as named APIs with small request and response schemas.
type HostCapability =
| "retail.context.read"
| "identity.assertion.create"
| "payment.checkout.open"
| "host.navigation.return";
interface CapabilityPolicy {
miniAppId: string;
allowed: ReadonlySet<HostCapability>;
}
async function invokeHostApi(
caller: { miniAppId: string; runId: string },
capability: HostCapability,
payload: unknown,
) {
capabilityGuard.require(caller, capability);
const input = schemas[capability].parse(payload);
return handlers[capability](caller, input);
}
Authorize by Mini App identity and active run, not only by API name. Validate input on the Host side even if the Mini App already validates it. Return the smallest result required. A payment API should return a payment result or reference, not card details or a reusable credential.
If the Mini App contains an embedded H5 page, review its API surface separately. Do not assume that an API permitted to the Mini App logic layer should automatically be available to every WebView frame.
Orchestrate the booking without absorbing brand rules
The application service coordinates stable steps. It does not decide membership eligibility, pricing, or whether the store can honor the booking.
export async function prepareBooking(
context: RetailContext,
hostSubject: string,
requestedWindow: string,
) {
const services = servicesFor(context.bannerId);
if (!context.storeId) throw new StoreRequiredError();
const serviceStatus = await services.stores.getServiceStatus({
bannerId: context.bannerId,
storeId: context.storeId,
service: "pickup",
});
if (serviceStatus !== "available") {
return { status: serviceStatus } as const;
}
const member = await services.membership.resolve({
bannerId: context.bannerId,
hostSubject,
});
if (!member) return { status: "membership_not_found" } as const;
const quote = await services.booking.quote({
bannerId: context.bannerId,
storeId: context.storeId,
memberId: member.memberId,
requestedWindow,
});
return { status: "ready", quote } as const;
}
The function asks the owning systems for decisions. It never checks a local bannerBFees.json, assumes that every member has a group ID, or labels a request confirmed before the store system returns a receipt.
Keep quote and confirm separate. Send quoteId back during confirmation so the banner backend can reject an expired or altered quote. Use an idempotency key for confirmation and cancellation. Mobile retries, network changes, and repeated taps must not create duplicate store work.
Contract-test every banner against the shared behavior
Reuse is credible only when each adapter satisfies the same observable contract. Write one test suite and run it against every banner implementation.
export function bookingContract(
name: string,
create: () => Promise<BookingPort>,
) {
describe(`${name} booking adapter`, () => {
it("returns banner-owned price and policy copy", async () => {
const port = await create();
const quote = await port.quote(validDraft());
expect(quote.quoteId).toBeTruthy();
expect(quote.amount.currency).toMatch(/^[A-Z]{3}$/);
expect(quote.cancellationText.length).toBeGreaterThan(0);
});
it("makes confirmation idempotent", async () => {
const port = await create();
const request = validConfirmRequest({ idempotencyKey: "same-key" });
const first = await port.confirm(request);
const second = await port.confirm(request);
expect(second.reference).toBe(first.reference);
});
});
}
Add adapter-specific tests for legacy quirks, but do not weaken the shared contract silently. If Banner B cannot provide immediate confirmation, model pending_store explicitly and update the user journey. A type that lies about certainty is worse than a type with one more state.
Consumer-driven contract tests can also protect the Host App API. Pin request and response schemas by version. Reject a Mini App package that asks for an undeclared capability or breaks the current contract.
Choose a deployment shape that preserves exit paths
One codebase does not require one deployed package. Three common shapes are valid:
- One Mini App package receives
bannerIdand uses brand adapters behind a backend gateway. - Each banner deploys a separate Mini App package built from shared components.
- A shared shell opens smaller banner-specific modules.
Choose based on release ownership, legal separation, outage containment, and how independently the brands change. If Banner B must release or withdraw without Banner A, a separate package may be safer even when most source code is shared.
Record package ID, Host App ID, banner ID, contract version, and backend environment in every launch event. Never derive production routing from visual branding.
The release process should prove four operations before launch: promote a tested package, refuse an incompatible contract version, roll back to a known package, and disable one banner without disabling the others.
Observe boundary failures, not only conversion
Business analytics will track searches, selected windows, and completed bookings. Architecture telemetry should answer different questions:
- Did a Host App provide an unknown banner or store?
- Which adapter timed out or returned an unmapped status?
- Did the Mini App request an undeclared Host capability?
- Did quote currency match the banner context?
- Did a confirmation retry return the same reference?
- Could operations disable one banner and leave the other available?
Include correlation IDs across Host App, Mini App, gateway, adapter, and banner backend. Redact assertions, membership identifiers, and customer data. A shared module should make ownership clearer during an incident, not create a group log containing every banner’s sensitive payloads.
Where FinClip fits
FinClip provides a Mini App runtime and management capabilities that can support modular services inside an existing or purpose-built Host App. Its documentation describes Host-side custom APIs and the association of Mini Apps with applications. Those mechanisms can carry a validated context, expose narrow native capabilities, and manage module availability.
They do not merge CRM, pricing, inventory, POS, or fulfillment. The retailer still designs the shared contract, builds the adapters, defines package ownership, and operates each brand’s backend. SDK integration is also a native Host App change; later Mini App updates do not remove the need to govern new custom APIs or SDK upgrades.
That boundary is the implementation lesson. Reuse the interface that remains stable across the retail family. Hide the brand decisions behind adapters owned by the systems that can answer them. A shared booking door is useful. A fictional group CRM is not.
Top comments (0)