When I started adding a second in-app purchase provider to a React Native app, the application-level design looked straightforward.
Both stores needed almost the same things:
- load products
- start a purchase
- restore purchases
- check ownership
The SDKs were different, but the business flow was not. So the first step was the obvious one: hide the store-specific code behind a shared interface and let the rest of the app depend on that interface.
That part worked.
The problem appeared one layer lower.
A clean TypeScript provider abstraction does not tell React Native which native modules should exist in the binary. If both store SDKs are installed, autolinking can still discover both of them. At that point the problem is no longer only about choosing the right provider at runtime. It is also about deciding which native billing implementation is allowed into each build.
That distinction changed the way I structured the integration.
The provider abstraction worked until the native build
I still wanted the application to see one commerce contract.
A screen should be able to call purchase() without knowing which marketplace is underneath it. Entitlement logic should not contain store-specific branches. Product UI should not care which native SDK returned the data.
A small interface is enough to define that boundary:
export type Product = {
id: string;
title: string;
price: string;
};
export type Purchase = {
productId: string;
transactionId: string;
};
export interface IAPProvider {
connect(): Promise<void>;
getProducts(productIds: string[]): Promise<Product[]>;
purchase(productId: string): Promise<Purchase>;
restorePurchases(): Promise<Purchase[]>;
}
Each store can adapt its own SDK to that contract:
export class StoreAProvider implements IAPProvider {
async connect(): Promise<void> {
// Connect to Store A billing.
}
async getProducts(productIds: string[]): Promise<Product[]> {
// Map Store A product models to the app's Product shape.
return [];
}
async purchase(productId: string): Promise<Purchase> {
// Execute the native Store A purchase flow.
return {
productId,
transactionId: "store-a-transaction",
};
}
async restorePurchases(): Promise<Purchase[]> {
return [];
}
}
The second provider implements the same interface, and a mock provider can cover development:
export class MockIAPProvider implements IAPProvider {
async connect(): Promise<void> {}
async getProducts(productIds: string[]): Promise<Product[]> {
return productIds.map((id) => ({
id,
title: `Mock product ${id}`,
price: "0",
}));
}
async purchase(productId: string): Promise<Purchase> {
return {
productId,
transactionId: `mock-${Date.now()}`,
};
}
async restorePurchases(): Promise<Purchase[]> {
return [];
}
}
This kept the application code clean, and I would use the same abstraction again.
What it did not solve was native dependency isolation.
Autolinking changes what "selected provider" means
At the JavaScript level, selecting a provider is simple:
export const COMMERCE_TARGETS = ["mock", "storeA", "storeB"] as const;
export type CommerceTarget = (typeof COMMERCE_TARGETS)[number];
export function createIAPProvider(
target: CommerceTarget,
): IAPProvider {
switch (target) {
case "storeA":
return new StoreAProvider();
case "storeB":
return new StoreBProvider();
case "mock":
return new MockIAPProvider();
}
}
That answers which implementation the application should call.
React Native still has another decision to make during the native build: which modules should be linked into the binary.
If both billing SDKs are installed in node_modules, both are candidates for native discovery. Choosing StoreAProvider in TypeScript does not automatically remove Store B's package from Gradle, the Android manifest, or native module registration.
The dependency tree can still look like this:
node_modules
├── store-a-iap-sdk
└── store-b-iap-sdk
That was the part my first design did not account for.
I had treated provider selection as a runtime concern, but native billing capability is also a build-time concern.
Runtime provider selection is not the same as native dependency isolation.
Two SDKs that look almost identical from JavaScript can still have very different native behavior. They may bring different Gradle dependencies, initialization code, manifest changes, lifecycle assumptions, or React Native compatibility constraints.
Once both exist in the same binary, all of those differences become part of the release surface.
Make the store a build-time concern
The model that worked better was to treat each marketplace build as a native variant of the same application.
Store A build
└── Store A native billing SDK
Store B build
└── Store B native billing SDK
Development build
└── Mock commerce provider
The source code stays shared, but the binaries do not need to expose the same native capabilities.
I keep the build target explicit:
export type CommerceTarget = "mock" | "storeA" | "storeB";
export function resolveCommerceTarget(
value: string | undefined,
): CommerceTarget {
switch (value) {
case "storeA":
case "storeB":
return value;
default:
return "mock";
}
}
The application uses that value to select the TypeScript provider.
The native build configuration uses the same target to decide which marketplace SDK is allowed to autolink.
The exact configuration depends on the React Native and Expo versions in the project, so the useful lesson is not one copy-paste configuration file. The useful part is where the decision is made.
A Store A build should not simply avoid calling Store B's SDK.
It should avoid linking Store B's SDK.
That keeps the binary easier to reason about:
- no unused billing SDK in the final artifact
- fewer Gradle dependency interactions
- fewer store-specific manifest changes
- less native initialization surface
- clearer release debugging
- fewer questions about which billing lifecycle owns the app
It also gives failures a smaller search space. If a Store A build breaks, I want to inspect Store A's native integration, not Store A plus an unrelated billing SDK that should never have been there.
The architecture after separating those two decisions
Once runtime provider selection and native build isolation are treated separately, the overall shape becomes much clearer:
React Native UI
│
▼
Commerce Service
│
▼
IAP Provider Contract
/ \
/ \
Store A Provider Store B Provider
│ │
▼ ▼
Native SDK A Native SDK B
│ │
▼ ▼
Store A Build Store B Build
The shared contract belongs to the application.
The native SDK belongs to the binary.
That separation is the important part.
One repository can still produce different native runtimes
This became more important once OTA updates entered the picture.
If two store builds contain different native billing modules, they are not equivalent runtimes just because they were produced from the same Git repository.
Consider these two binaries:
Binary A
└── Native Billing SDK A
Binary B
└── Native Billing SDK B
Now publish JavaScript that assumes SDK A exists.
That JavaScript is valid for Binary A. It may be invalid for Binary B.
This is where runtime fingerprinting starts to matter beyond being another release setting. Native dependency differences are part of compatibility.
If a native module changes, or if a build links a different native module entirely, the OTA boundary should reflect that.
For a multi-store app, I prefer separate update channels, or equivalent release boundaries, for each marketplace build, backed by a runtime policy that includes native changes.
The rule I use is straightforward:
If two binaries do not have the same native capabilities, they should not automatically receive the same OTA update.
That also changes how I think about "one app."
From the product side, it is one app.
From the repository side, it is one codebase.
From the native release side, it can still be two different binaries with different capabilities and different compatibility boundaries.
Those statements can all be true at the same time.
Native patches are part of the build, not a local workaround
The second place where this became obvious was native compatibility patching.
Sometimes a billing package is close to what the current React Native or Expo version expects, but not quite. A small patch can make the integration work.
The unsafe version of that approach is a postinstall script that searches for some text and rewrites whatever package version happens to be installed.
That is convenient until the dependency publishes a new patch version and the same script edits code it has never been reviewed against.
For native patches, I prefer to be deliberately strict.
A patch should know which package version it was written for and should refuse to run when that assumption changes.
Conceptually:
type PatchContract = {
packageName: string;
expectedVersion: string;
expectedSourceHash: string;
};
export function assertPatchContract(
contract: PatchContract,
actualVersion: string,
actualSourceHash: string,
): void {
if (actualVersion !== contract.expectedVersion) {
throw new Error(
`Review the patch before using ${contract.packageName}@${actualVersion}`,
);
}
if (actualSourceHash !== contract.expectedSourceHash) {
throw new Error(
`Source changed for ${contract.packageName}; refusing to patch it blindly`,
);
}
}
I also want the patch to be idempotent.
Running install twice should not produce two different native trees. A clean local install and a clean cloud build should reach the same result.
This is one of those cases where a failed build is better than a "successful" build with an unreviewed native modification.
There is another release consequence here: a native patch can change the runtime fingerprint.
Changing one file inside a native dependency may look tiny in Git, especially if the patch is applied after install. From the binary's point of view, it is still native code changing underneath the JavaScript bundle.
So I treat native patches as part of the release architecture, not as a developer-machine trick.
Keep local and cloud builds boring
Once multiple store variants and native patches are involved, reproducibility matters more than clever build logic.
I want the following inputs to be explicit and repeatable:
lockfile
build target
store configuration
native patch version
environment variables
update channel
runtime fingerprint
A clean install should be meaningful.
If npm ci on my machine and the dependency install inside the cloud builder produce different native trees, that is a release problem even if the JavaScript bundle is identical.
This is also where dependency version ranges deserve more attention.
For most packages, a compatible patch update is exactly what semver is supposed to make easy.
For a native package coupled to a reviewed compatibility patch, an automatic patch update can be the opposite of what I want. The package manager may consider the new version compatible while the patch script correctly considers it unknown.
In that situation, pinning the reviewed version is not about being afraid of upgrades. It is about making the upgrade explicit:
- install the new version
- inspect the native source
- review or remove the patch
- run the integration tests again
- move the release forward
That is much easier to debug than discovering the change halfway through a store build.
Test the binary, not only the TypeScript abstraction
The provider interface makes unit tests straightforward, but a passing interface test does not prove that the correct native SDK ended up in the APK.
I treat the release matrix itself as something worth testing:
| Build | Native billing capability | TypeScript provider |
|---|---|---|
| Development | None / mock | MockIAPProvider |
| Store A | SDK A only | StoreAProvider |
| Store B | SDK B only | StoreBProvider |
The commerce contract should behave consistently across providers:
export async function getOwnedProductIds(
provider: IAPProvider,
): Promise<Set<string>> {
const purchases = await provider.restorePurchases();
return new Set(
purchases.map((purchase) => purchase.productId),
);
}
The build target should select the expected provider.
The native build should contain the expected SDK and exclude the other one.
And finally, the real purchase flow still needs a release-like device test.
For each marketplace build, I want to verify at least:
launch
→ connect to billing
→ load products
→ start a purchase
→ handle cancel or success
→ restore ownership
→ relaunch
Mocks are useful for application behavior.
They cannot prove that Gradle, autolinking, the marketplace client, and the native billing lifecycle agree with each other.
That proof has to happen on the actual binary.
Closing
The provider abstraction was still the right first step. It kept marketplace-specific billing code out of the rest of the application and gave the product one stable commerce API.
The part I had underestimated was the boundary below it.
Selecting a provider in TypeScript is an application decision. Deciding which billing SDK exists in the binary is a build decision. OTA compatibility, native patches, and release testing all follow from that difference.
For the next multi-store React Native app, I would structure it the same way: keep the purchase contract shared, make the marketplace an explicit build target, and let each binary contain only the native billing capability it actually needs.
That keeps the cross-platform part genuinely shared without pretending the native part is identical when it is not.
Top comments (0)