One Contract, Two Backends: Offline Parity for Web and Mobile

Photo by hackNY via Openverse (CC BY-SA 2.0)
It is a design where each data domain declares one typed contract, and two or more interchangeable implementations sit behind it. A UI component depends only on the contract, so you can swap a live HTTP implementation for a fixture-backed mock without touching any screen. The choice is resolved once at a composition root.
With a single config flag resolved at the composition root, not scattered through the code. On the web it is an environment variable read at build time; in Flutter it is a field on a config provider set from a build flavor. Every component imports one neutral name, so flipping the flag swaps the entire data layer at once.
Behaviorally, yes. The dangerous failure is mock drift, where fixtures stop matching real API responses and green tests hide broken screens. The mock must enforce the same invariants as the server, such as branch scoping and idempotency, and both implementations must satisfy the identical contract so a renamed field breaks the compile on both sides.
A staging backend needs a network, credentials, and running infrastructure, which defeats offline demos, blocks frontend work before endpoints exist, and makes tests slow and flaky. A fixture-backed mock has none of those dependencies. It runs in CI with no database and lets a new contributor boot a working app in one command.
Yes, and that is where it pays off most. In our carwash ERP the Next.js POS uses an httpClient and mockClient pair sharing one TypeScript contract, while the Flutter app uses an ApiRepository and MockRepository behind an abstract interface. The idea is language-agnostic; only the wiring tools differ.

Photo by hackNY via Openverse (CC BY-SA 2.0)
Key Takeaway
Give every screen one typed repository contract, then ship two implementations behind it: an ApiRepository that calls your backend and a MockRepository backed by fixtures. Swap them with a single config flag. You get live demos with no server, deterministic tests, and frontend work that never blocks on a backend being ready.
JID Carwash ERP runs on three surfaces: a NestJS 10 plus Prisma 5 API, a Next.js 15 POS and admin panel, and a Flutter 3.22 field app the crew carries around the wash bays. Early on I hit a boring but expensive problem. The owner wanted to see the POS on his laptop during a meeting with no Wi-Fi. My frontend teammate wanted to build the payroll screen before the payroll endpoints existed. And I wanted my Flutter tests to run in CI without spinning up a Postgres database. All three are the same problem wearing different hats: the UI should not care whether a real backend is on the other end.
The fix was the repository adapter pattern applied consistently across both frontends. Each domain gets one contract, and two interchangeable implementations sit behind it. This is the same abstraction that clean-architecture Flutter apps use to keep the domain layer ignorant of data sources, and that typed TypeScript API clients use so the UI compiles against a spec rather than a live endpoint. Here is exactly how it works in a shipped codebase, including the trap that makes most mock layers lie.
Everything starts from a single types file. It declares the shape of each entity and the methods every repository must provide. The contract is the law both implementations obey, and critically, it encodes the domain invariants directly in the types. Money is an integer count of IDR, never a floating-point number, so the field is a plain number named to make its unit obvious. Every list and detail call takes a branchId, because on the server side every Prisma query is scoped by branch to prevent one branch from reading another's orders.
// apps/pos-web/src/api/types.ts — the single contract both clients honor
export interface OrdersRepository {
list(params: { branchId: string; status?: OrderStatus }): Promise<Order[]>;
getById(branchId: string, orderId: string): Promise<Order>;
create(input: CreateOrderInput): Promise<Order>;
settle(
orderId: string,
payment: PaymentInput,
idempotencyKey: string,
): Promise<Order>;
}
export interface Order {
id: string;
branchId: string; // every read is scoped by this
pelangganId: string | null;
totalIdr: number; // integer IDR — never a float
status: OrderStatus;
}On the web, the two implementations are plain objects that both satisfy the OrdersRepository interface. The live one wraps our HTTP client and hits the NestJS routes. The mock one reads from an in-memory fixtures module. The rule I enforced from day one: the mock must reproduce the same behavior the API guarantees, not merely return data. So the mock filters fixtures by branchId exactly like the server scopes its queries, and its settle method dedupes on the idempotency key exactly like the real settlement service does. If the mock skipped those, the demo would behave differently from production and the whole point would collapse.
// apps/pos-web/src/api/http/orders.http.ts — live implementation
export const httpOrders: OrdersRepository = {
list: ({ branchId, status }) =>
api.get(`/branches/${branchId}/orders`, { params: { status } }),
settle: (orderId, payment, idempotencyKey) =>
api.post(`/orders/${orderId}/settle`, payment, {
headers: { "Idempotency-Key": idempotencyKey },
}),
// ...
};
// apps/pos-web/src/api/mock/orders.mock.ts — fixture implementation
export const mockOrders: OrdersRepository = {
list: async ({ branchId, status }) =>
fixtures.orders
.filter((o) => o.branchId === branchId) // same branch scope as the API
.filter((o) => !status || o.status === status),
settle: async (orderId, payment, idempotencyKey) =>
settleInMemory(orderId, payment, idempotencyKey), // dedupes on the key, like prod
// ...
};The field app mirrors the exact same idea in Dart, just with language-appropriate tools. Each feature declares an abstract interface class, then ships two concrete classes. The live ApiOrdersRepository talks to NestJS and, when the device is offline, queues its writes into an outbox that connectivity_plus drains once the signal returns. The MockOrdersRepository holds fixtures in memory and powers both demos and widget tests. Riverpod wires the choice at the provider level, so no widget ever imports a concrete repository directly; they all watch the provider and receive whichever implementation the config selected.
// apps/field-app/lib/features/orders/data/orders_repository.dart
abstract interface class OrdersRepository {
Future<List<Order>> list({required String branchId, OrderStatus? status});
Future<Order> settle(String orderId, PaymentInput payment, String idempotencyKey);
}
// Live: talks to NestJS; queues writes in the outbox when offline
final class ApiOrdersRepository implements OrdersRepository { /* ... */ }
// Fixtures: powers demos and widget tests, no network at all
final class MockOrdersRepository implements OrdersRepository { /* ... */ }
final ordersRepositoryProvider = Provider<OrdersRepository>((ref) {
return ref.watch(appConfigProvider).useMock
? MockOrdersRepository()
: ApiOrdersRepository(ref.watch(httpClientProvider));
});The swap is one boolean, resolved once at the composition root. On the web it is an environment variable read at build time; in Flutter it is a field on the app config provider set from a build flavor. Every component and every screen imports the neutral name, never the http or mock variant. That single indirection is what makes the whole thing painless: to run a no-Wi-Fi demo I set the flag to mock and the exact same UI runs against fixtures; to ship production I leave it live. Nothing in the feature code changes.
// apps/pos-web/src/api/index.ts — the composition root
const useMock = process.env.NEXT_PUBLIC_API_MODE === "mock";
export const orders: OrdersRepository = useMock ? mockOrders : httpOrders;
export const payments: PaymentsRepository = useMock ? mockPayments : httpPayments;
// Components import `orders` — never `httpOrders` or `mockOrders` directly.The failure mode is mock drift: the fixtures slowly stop matching what the API actually returns, and green tests hide broken screens. Two rules keep mocks honest. First, both implementations must satisfy the identical contract, so a renamed field breaks the compile on both sides at once. Second, the mock must enforce the same invariants as the server, branch scoping and idempotency included, or your demo is lying to the person watching it.
Generate your fixtures from the same seed script that populates a real branch, not by hand. Ours emits fixture data in the exact shape Prisma returns, so demo data is indistinguishable from a live branch's records. Hand-written fixtures are the fastest route to drift because nobody remembers to update them.
| Aspect | ApiRepository | MockRepository |
|---|---|---|
| Data source | NestJS plus Prisma over HTTP | In-memory fixtures, seeded JSON |
| Network | Required, or the offline outbox | None at all |
| Writes | Persisted, queued in the outbox if offline | Mutate in memory, reset on reload |
| Branch scope | Enforced by the Prisma query | Enforced by the fixture filter |
| Primary use | Production | Demos, tests, dev without a backend |
The pattern is not free. You maintain a second implementation of every repository, and the invariant discipline above is real work. But for a product with three surfaces sharing one domain, the alternative is worse: a UI coupled to a live backend cannot be demoed offline, cannot be built ahead of its API, and cannot be tested without infrastructure. One contract and two adapters bought all three, and the config flag that switches them is the cheapest line of code in the whole codebase.