Offline-first isn't a feature, it's an architecture
3 min read#android #kotlin #sync #architecture
Exequ-Jobs is the technician app for our field-service platform at Exequtech. Its users work on roofs, in ceilings, and in parts of South Africa where mobile signal simply stops existing. I built it AI-assisted — Claude Code typing, me deciding and reviewing — and the single decision that shaped everything else was this: the local database is the source of truth, and the cloud is something we synchronize with when we can.
Local write, then queue
Every mutation in the app — completing a job card, capturing a photo, logging a trip — commits to the local store first and succeeds immediately. Alongside the domain write, a pending operation lands in a queue table:
write → Room (SQLCipher-encrypted) → PendingOperationEntity → done (UI updates)
↓ later, when network is healthy
SyncOrchestratorWorker drains the queueThe user never waits on the network, because the network isn't part of the write path at all. WorkManager workers drain the queue when connectivity returns, download newly assigned jobs, and reconcile cloud state in a three-phase merge transaction — apply remote, replay local, resolve conflicts by domain rules rather than timestamps.
Photos are their own problem
A job's photos and receipts can be tens of megabytes captured in a dead zone. Uploading them through the API server would double the transfer and tie up the backend, so the app requests presigned S3 URLs and uploads directly. The subtle failure mode: an upload that dies halfway leaves a database row pointing at an object that never arrived. The photo worker therefore heals orphans — on every run it re-verifies rows against storage and re-enqueues what's missing. Self-healing beats never-failing; never-failing isn't on the menu in a basement.
One binary, many tenants
The platform is multi-tenant, and technicians can work for more than one company. Login flows through our central console, which issues short-lived tenant JWTs; a single OkHttp interceptor then retargets every request — Apollo GraphQL and REST alike — to the selected tenant at runtime:
override fun intercept(chain: Interceptor.Chain): Response {
val tenantUrl = cloudTokenManager.getTenantUrl() ?: BuildConfig.API_BASE_URL
val targetUrl = tenantUrl.toHttpUrlOrNull()
?: return chain.proceed(chain.request())
val newUrl = chain.request().url.newBuilder()
.scheme(targetUrl.scheme).host(targetUrl.host).port(targetUrl.port)
.build()
return chain.proceed(chain.request().newBuilder().url(newUrl).build())
}No client rebuilds, no app restarts, no per-tenant APKs.
Boring choices that paid off
- Room with
exportSchemaon, schema JSON committed: every migration is reviewable in a PR and covered by an instrumentation test before it ever meets a technician's data. - SQLCipher at rest, because the device carries customer names, addresses and signatures.
- UUIDv7 everywhere, so IDs generated offline sort correctly and never collide with the server.
- Server-sent events for the reverse direction — job updates stream in and fan out into the local store, so two technicians on one job see each other's progress.
Offline-first costs you: every feature is designed twice, once for the happy path and once for reconciliation. But the alternative is an app that lies — one that says "saved" while silently holding your work hostage until the bars come back. Field software has to be built for the basement.
A note on how it's built, because I'd rather you hear it from me: a lot of the Kotlin in this app was typed by Claude Code under my direction. The architecture above, the decisions, the debugging at 22:00 when sync eats a photo — those are mine. The app doesn't care whose fingers typed it; the technicians care that it works with zero bars.
The full case study — 66k lines, the three-phase merge, the lot: Exequ-Jobs.