Satu Kontrak, Dua Backend: Paritas Offline untuk Web dan Mobile

Foto oleh hackNY via Openverse (CC BY-SA 2.0)
Ini adalah desain di mana setiap domain data mendeklarasikan satu kontrak bertipe, dan dua atau lebih implementasi yang dapat dipertukarkan berada di baliknya. Komponen UI hanya bergantung pada kontrak, sehingga Anda bisa menukar implementasi HTTP langsung dengan mock berbasis fixture tanpa menyentuh layar apa pun. Pilihannya diselesaikan sekali di composition root.
Dengan satu flag konfigurasi yang diselesaikan di composition root, bukan tersebar di seluruh kode. Di web ia berupa variabel lingkungan yang dibaca saat build; di Flutter ia berupa field pada config provider yang diatur dari build flavor. Setiap komponen mengimpor satu nama netral, jadi membalik flag menukar seluruh lapisan data sekaligus.
Secara perilaku, ya. Kegagalan berbahaya adalah mock drift, di mana fixture berhenti cocok dengan respons API nyata dan tes hijau menyembunyikan layar rusak. Mock harus menegakkan invarian yang sama seperti server, seperti pembatasan cabang dan idempotensi, dan kedua implementasi harus memenuhi kontrak identik sehingga field yang diganti nama merusak kompilasi di kedua sisi.
Backend staging butuh jaringan, kredensial, dan infrastruktur yang berjalan, yang menggagalkan demo offline, menghambat pekerjaan frontend sebelum endpoint ada, dan membuat tes lambat serta rapuh. Mock berbasis fixture tidak punya ketergantungan itu. Ia berjalan di CI tanpa database dan membuat kontributor baru menyalakan aplikasi yang berfungsi dalam satu perintah.
Ya, dan di situlah ia paling menguntungkan. Di ERP carwash kami, POS Next.js memakai pasangan httpClient dan mockClient yang berbagi satu kontrak TypeScript, sementara aplikasi Flutter memakai ApiRepository dan MockRepository di balik antarmuka abstrak. Idenya agnostik bahasa; hanya alat perangkaiannya yang berbeda.

Foto oleh hackNY via Openverse (CC BY-SA 2.0)
Ringkasan Utama
Beri setiap layar satu kontrak repository bertipe, lalu kirim dua implementasi di baliknya: ApiRepository yang memanggil backend dan MockRepository yang didukung fixture. Tukar keduanya dengan satu flag konfigurasi. Anda mendapat demo langsung tanpa server, tes deterministik, dan pekerjaan frontend yang tidak pernah terhambat menunggu backend siap.
JID Carwash ERP berjalan di tiga permukaan: API NestJS 10 plus Prisma 5, POS dan panel admin Next.js 15, serta aplikasi lapangan Flutter 3.22 yang dibawa kru ke sekitar area cuci. Sejak awal saya menemui masalah yang membosankan tapi mahal. Pemilik ingin melihat POS di laptopnya saat rapat tanpa Wi-Fi. Rekan frontend saya ingin membangun layar payroll sebelum endpoint payroll ada. Dan saya ingin tes Flutter saya berjalan di CI tanpa menyalakan database Postgres. Ketiganya adalah masalah yang sama dengan topeng berbeda: UI seharusnya tidak peduli apakah backend nyata ada di ujung sana.
Solusinya adalah pola repository adapter yang diterapkan konsisten di kedua frontend. Setiap domain punya satu kontrak, dan dua implementasi yang dapat dipertukarkan berada di baliknya. Ini adalah abstraksi yang sama yang dipakai aplikasi Flutter clean-architecture untuk membuat lapisan domain tidak tahu-menahu soal sumber data, dan yang dipakai klien API TypeScript bertipe agar UI dikompilasi terhadap spesifikasi alih-alih endpoint langsung. Berikut persis cara kerjanya di basis kode yang sudah dikirim, termasuk jebakan yang membuat sebagian besar lapisan mock berbohong.
Semuanya dimulai dari satu berkas tipe. Ia mendeklarasikan bentuk setiap entitas dan metode yang harus disediakan setiap repository. Kontrak adalah hukum yang dipatuhi kedua implementasi, dan yang terpenting, ia menyandikan invarian domain langsung di dalam tipe. Uang adalah bilangan bulat IDR, bukan angka pecahan, sehingga field-nya berupa number biasa yang dinamai agar satuannya jelas. Setiap panggilan daftar dan detail menerima branchId, karena di sisi server setiap query Prisma dibatasi per cabang untuk mencegah satu cabang membaca order cabang lain.
// 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;
}Di web, kedua implementasi adalah objek biasa yang sama-sama memenuhi antarmuka OrdersRepository. Yang live membungkus klien HTTP kami dan memanggil rute NestJS. Yang mock membaca dari modul fixture di memori. Aturan yang saya tegakkan sejak hari pertama: mock harus mereproduksi perilaku yang dijamin API, bukan sekadar mengembalikan data. Jadi mock memfilter fixture berdasarkan branchId persis seperti server membatasi query-nya, dan metode settle-nya melakukan deduplikasi pada idempotency key persis seperti layanan settlement asli. Jika mock melewatkan itu, demo akan berperilaku berbeda dari produksi dan seluruh tujuannya runtuh.
// 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
// ...
};Aplikasi lapangan mencerminkan ide yang persis sama di Dart, hanya dengan alat yang sesuai bahasanya. Setiap fitur mendeklarasikan abstract interface class, lalu mengirim dua kelas konkret. ApiOrdersRepository yang live berbicara ke NestJS dan, ketika perangkat offline, mengantrekan tulisannya ke outbox yang dikuras connectivity_plus begitu sinyal kembali. MockOrdersRepository menyimpan fixture di memori dan menjalankan baik demo maupun widget test. Riverpod merangkai pilihan itu di level provider, sehingga tidak ada widget yang pernah mengimpor repository konkret secara langsung; semuanya memantau provider dan menerima implementasi mana pun yang dipilih konfigurasi.
// 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));
});Pertukarannya adalah satu boolean, diselesaikan sekali di composition root. Di web ia berupa variabel lingkungan yang dibaca saat build; di Flutter ia berupa field pada provider konfigurasi aplikasi yang diatur dari build flavor. Setiap komponen dan setiap layar mengimpor nama netral, bukan varian http atau mock. Satu lapisan tak langsung itulah yang membuat semuanya tanpa rasa sakit: untuk menjalankan demo tanpa Wi-Fi saya mengatur flag ke mock dan UI yang persis sama berjalan terhadap fixture; untuk mengirim produksi saya biarkan live. Tidak ada yang berubah di kode fitur.
// 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.Mode kegagalannya adalah mock drift: fixture perlahan berhenti cocok dengan apa yang sebenarnya dikembalikan API, dan tes hijau menyembunyikan layar yang rusak. Dua aturan menjaga mock tetap jujur. Pertama, kedua implementasi harus memenuhi kontrak yang identik, sehingga field yang diganti nama merusak kompilasi di kedua sisi sekaligus. Kedua, mock harus menegakkan invarian yang sama seperti server, termasuk pembatasan cabang dan idempotensi, atau demo Anda berbohong kepada orang yang menontonnya.
Hasilkan fixture Anda dari skrip seed yang sama yang mengisi cabang nyata, bukan secara manual. Milik kami memancarkan data fixture dalam bentuk persis seperti yang dikembalikan Prisma, sehingga data demo tak bisa dibedakan dari catatan cabang langsung. Fixture yang ditulis tangan adalah jalur tercepat menuju drift karena tidak ada yang ingat memperbaruinya.
| Aspek | ApiRepository | MockRepository |
|---|---|---|
| Sumber data | NestJS plus Prisma via HTTP | Fixture di memori, JSON hasil seed |
| Jaringan | Diperlukan, atau outbox offline | Tidak ada sama sekali |
| Tulisan | Dipersistkan, diantrekan di outbox jika offline | Bermutasi di memori, reset saat muat ulang |
| Cakupan cabang | Ditegakkan oleh query Prisma | Ditegakkan oleh filter fixture |
| Kegunaan utama | Produksi | Demo, tes, dev tanpa backend |
Pola ini tidak gratis. Anda memelihara implementasi kedua dari setiap repository, dan disiplin invarian di atas adalah pekerjaan nyata. Namun untuk produk dengan tiga permukaan yang berbagi satu domain, alternatifnya lebih buruk: UI yang terkopel ke backend langsung tidak bisa didemokan offline, tidak bisa dibangun mendahului API-nya, dan tidak bisa dites tanpa infrastruktur. Satu kontrak dan dua adapter membeli ketiganya, dan flag konfigurasi yang menukarnya adalah baris kode termurah di seluruh basis kode.