What “Platform-Agnostic” Means in an Offline-First App
A platform-agnostic implementation blueprint is a set of design decisions, abstractions, and interfaces that let you build the same offline-first behavior across iOS, Android, web, desktop, or cross-platform frameworks without rewriting the core logic each time. The goal is not “one codebase everywhere” (that is a tooling choice), but “one set of invariants and contracts everywhere.”
In practice, this means you define a small number of stable boundaries—storage, network transport, time, background execution, and cryptography—and implement them per platform. Everything else (domain logic, sync orchestration, data transformations, and UI-facing state) depends only on those boundaries. When you switch stacks (e.g., Kotlin to Swift, React Native to Flutter, SQLite to IndexedDB), you swap adapters rather than redesigning the system.
Core idea: Separate “policy” from “mechanism”
Policy is what must happen (e.g., “persist operations before acknowledging UI success,” “never lose user intent,” “apply server patches deterministically”). Mechanism is how it happens on a given platform (e.g., Room vs Core Data vs IndexedDB, WorkManager vs BGTaskScheduler). A platform-agnostic blueprint defines policy in platform-neutral code and delegates mechanism to adapters.
The Blueprint: Layers and Contracts You Can Port to Any Stack
1) Domain Layer (pure, portable)
This layer contains your business rules and domain types. It should be deterministic and side-effect free where possible. It should not import platform libraries. It should not know about HTTP, SQL, background schedulers, or UI frameworks.
- Entities/value objects (e.g., Task, Comment, InventoryItem)
- Use cases (e.g., CreateTask, CompleteTask, AddComment)
- Validation and invariants (e.g., “quantity cannot be negative”)
- Pure transformations (e.g., mapping server DTOs to domain models)
2) Application Layer (orchestration, still portable)
This layer coordinates domain logic with infrastructure via interfaces. It is where you place “offline-first workflows” as use-case orchestrators. It can be written in a shared module (if you do multi-platform) or duplicated with minimal changes (if you do native per platform) because it depends only on interfaces.
- Listen to the audio with the screen off.
- Earn a certificate upon completion.
- Over 5000 courses for you to explore!
Download the app
- Repository interfaces (e.g., TaskRepository)
- Sync coordinator interface usage (e.g., SyncService)
- Transaction boundaries (e.g., “write local + enqueue operation atomically”)
- Mapping between domain and persistence models
3) Infrastructure Layer (platform-specific adapters)
This is where you implement the interfaces using platform tools. Each platform provides its own adapter implementations, but the interface contracts remain identical.
- Database adapter (SQLite/Room, Core Data, Realm, IndexedDB, file system)
- HTTP client adapter (URLSession, OkHttp, fetch)
- Background execution adapter (WorkManager, BGTaskScheduler, Service Worker)
- Crypto/keystore adapter (Keychain, Android Keystore, WebCrypto)
- Clock and monotonic time adapter
4) Presentation Layer (UI, platform-specific)
The UI binds to application-layer state and triggers use cases. The blueprint here is about consistent state contracts (loading/queued/error) rather than shared UI code.
- View models / presenters expose stable state shapes
- UI never calls infrastructure directly; it calls use cases
- UI reacts to repository streams/observables
Define the “Port” Interfaces: The Minimum Set You Must Standardize
To adapt to any stack, define a small set of ports (interfaces) that represent all side effects. Keep them narrow and explicit. Below is a practical set that tends to cover offline-first apps without leaking platform details.
Storage ports
KeyValueStore: small settings, feature flags, last sync markers.
Database: structured data with queries and transactions.
FileStore: blobs, attachments, cached media.
// Pseudocode interfaces (language-agnostic style) interface KeyValueStore { getString(key): String? putString(key, value): void getLong(key): Long? putLong(key, value): void remove(key): void } interface Database { runInTransaction(block): Result query(sql, args): Rows execute(sql, args): void } interface FileStore { write(path, bytes): void read(path): Bytes exists(path): Boolean delete(path): void }Network and time ports
HttpClient: request/response with cancellation and timeouts.
Clock: wall-clock time for timestamps; optionally monotonic time for durations.
Connectivity: current status and change stream (but do not embed platform enums; normalize them).
interface HttpClient { request(method, url, headers, body): HttpResponse } interface Clock { nowEpochMillis(): Long } enum ConnectivityState { OFFLINE, CAPTIVE, METERED, ONLINE } interface Connectivity { current(): ConnectivityState observe(listener): Unsubscribe }Execution ports
Scheduler: schedule background sync attempts with constraints.
Mutex/Lock: cross-thread/process coordination if needed.
Logger: structured logs with correlation IDs.
interface Scheduler { scheduleOneOff(name, earliestTimeMillis, constraints): void schedulePeriodic(name, intervalMillis, constraints): void cancel(name): void } interface Logger { info(event, fields): void warn(event, fields): void error(event, fields): void }Step-by-Step: Build a Stack-Neutral “Reference Flow” for a Single Use Case
A good way to ensure platform-agnostic design is to pick one representative use case (e.g., “create item,” “edit profile,” “submit form”) and implement it end-to-end using only ports. Then replicate the same shape for other use cases.
Step 1: Define the domain command and result
Keep it free of platform types. Avoid Date objects tied to a platform; use epoch milliseconds or ISO strings.
type CreateTaskCommand = { title: String, notes: String?, createdAtMillis: Long } type CreateTaskResult = { localId: String }Step 2: Define repository contract used by UI/application
The repository is the stable API your UI depends on. It should expose reads as streams/observables if your stack supports it, but the contract can be expressed as “subscribe + callback” to stay portable.
interface TaskRepository { createTask(cmd: CreateTaskCommand): CreateTaskResult observeTask(id: String, listener): Unsubscribe observeTaskList(listener): Unsubscribe }Step 3: Implement repository using ports (no platform imports)
The repository implementation coordinates local persistence and operation enqueueing. The key is to keep the transaction boundary consistent across platforms: either both writes happen or neither happens.
class TaskRepositoryImpl(db: Database, clock: Clock, opQueue: OperationQueue) : TaskRepository { createTask(cmd) { val localId = generateLocalId() db.runInTransaction { db.execute("INSERT INTO tasks(id,title,notes,createdAt) VALUES(?,?,?,?)", [localId, cmd.title, cmd.notes, cmd.createdAtMillis]) opQueue.enqueue({ type: "CreateTask", localId: localId, payload: { title: cmd.title, notes: cmd.notes }, createdAtMillis: clock.nowEpochMillis() }) } return { localId } } }Step 4: Provide platform adapters for ports
On Android, Database might be Room/SQLite; on iOS, Core Data/SQLite; on web, IndexedDB. The repository code does not change; only the adapter does.
- Android: DatabaseAdapter(RoomDatabase), SchedulerAdapter(WorkManager), HttpClientAdapter(OkHttp)
- iOS: DatabaseAdapter(SQLite/Core Data), SchedulerAdapter(BGTaskScheduler), HttpClientAdapter(URLSession)
- Web: DatabaseAdapter(IndexedDB), SchedulerAdapter(Service Worker + periodic sync where available), HttpClientAdapter(fetch)
Adaptation Playbook: Mapping the Blueprint to Common Stacks
Native Android (Kotlin)
- Ports as Kotlin interfaces in a core module
- Adapters implemented in Android module
- Database adapter wraps Room; expose runInTransaction
- Scheduler adapter wraps WorkManager; normalize constraints (network, charging, idle)
Native iOS (Swift)
- Ports as Swift protocols in a core framework
- Adapters implemented using URLSession, SQLite/Core Data, BGTaskScheduler
- Be explicit about threading: adapters should marshal callbacks onto a chosen dispatcher/queue
Flutter (Dart)
- Ports as abstract classes; implementations use sqflite/isar/hive, dio/http, and background_fetch/workmanager plugins
- Keep platform channel usage inside adapters only
React Native (TypeScript)
- Ports as TypeScript interfaces; implementations use SQLite/WatermelonDB/Realm, fetch/axios
- Background execution differs widely; keep a minimal Scheduler port and degrade gracefully where unsupported
Web (PWA)
- Database adapter uses IndexedDB (via idb library or custom wrapper)
- Scheduler adapter uses Service Worker events; periodic background sync may not be available on all browsers
- Connectivity adapter uses navigator.onLine plus fetch probes if needed; normalize to your ConnectivityState
Standardize Data Shapes at the Boundaries (So You Can Swap Libraries)
Platform-agnostic systems fail when platform-specific types leak into shared logic. Standardize boundary shapes:
- IDs: use strings (UUID/ULID) rather than platform UUID types.
- Timestamps: use epoch millis or ISO-8601 strings; parse at edges.
- Binary: represent as byte arrays in core; convert to platform buffers in adapters.
- Errors: define a small error taxonomy (e.g., NetworkUnavailable, Unauthorized, Timeout, ServerRejected, CorruptLocalState) and map platform exceptions into it.
enum AppErrorType { NETWORK_UNAVAILABLE, UNAUTHORIZED, TIMEOUT, SERVER_REJECTED, UNKNOWN } type AppError = { type: AppErrorType, message: String?, cause: Any? }Portability Patterns That Prevent Rewrites
Pattern: “Repository + DataSource” split
Define repositories as the stable API for the app. Internally, repositories can use a LocalDataSource and RemoteDataSource, each behind interfaces. When you change database technology, you only rewrite LocalDataSource.
- TaskRepository (stable)
- TaskLocalDataSource (swap Room/Core Data/IndexedDB)
- TaskRemoteDataSource (swap OkHttp/URLSession/fetch)
Pattern: “Capability detection” instead of “platform branching”
Instead of checking “if iOS then …”, check whether a capability exists. For example, background periodic scheduling may be unavailable on some web contexts. Your Scheduler adapter can expose capabilities so the application layer can choose a fallback behavior.
type SchedulerCapabilities = { periodicSupported: Boolean, requiresUserGesture: Boolean } interface Scheduler { capabilities(): SchedulerCapabilities schedulePeriodic(name, intervalMillis, constraints): void }Pattern: “Deterministic serialization” for stored records
When you store queued operations or cached server payloads, use a deterministic serialization format (e.g., canonical JSON) and version it. This makes migrations and cross-platform parity easier, especially if multiple clients share the same local schema concepts.
- Include schemaVersion on stored envelopes
- Use explicit field names; avoid relying on reflection ordering
- Keep backward-compatible readers for at least one previous version
Cross-Platform Consistency Checklist (What Must Match Everywhere)
To ensure the same behavior across stacks, define a checklist of invariants and verify them per platform implementation:
- Atomicity: local write and operation enqueue happen together.
- Ordering: operations are processed in a defined order (e.g., FIFO per entity).
- Deduplication rules: identical operations are coalesced the same way (if you do coalescing).
- Serialization format: operation envelopes and metadata fields match.
- Error mapping: the same network/server errors map to the same AppErrorType.
- Clock usage: timestamps are generated in the same layer (preferably via Clock port).
- Threading model: repository callbacks/streams deliver on a consistent dispatcher/queue.
Practical Step-by-Step: Porting the Blueprint to a New Stack
Step 1: Freeze the contracts
Before writing any platform code, write the port interfaces and repository APIs as if they were a public SDK. Treat them as stable. Add comments that specify behavior (transactionality, ordering, error mapping) rather than implementation hints.
- Define Database.runInTransaction semantics
- Define HttpClient timeout and cancellation semantics
- Define Scheduler constraints vocabulary
Step 2: Build a “thin vertical slice”
Implement one use case end-to-end with real persistence and a stubbed network. Verify that the UI can create and read entities entirely offline. This slice should include:
- Local schema for one entity
- Operation queue persistence
- Repository create + observe
- Minimal sync trigger (manual button or app start)
Step 3: Implement adapters one by one
Start with the database adapter because it influences transaction boundaries and performance. Then implement HttpClient, then Scheduler. Keep adapters small and testable.
- DatabaseAdapter: ensure transactions truly roll back on failure
- HttpClientAdapter: normalize errors into AppError
- SchedulerAdapter: ensure jobs are uniquely named and deduplicated
Step 4: Add contract tests for adapters
Create a shared suite of “port contract tests” that each platform adapter must pass. Even if the tests are implemented separately per language, the scenarios should match.
- Database contract: nested transaction behavior (if supported), rollback, concurrent reads
- KeyValueStore contract: persistence across app restarts
- HttpClient contract: timeout mapping, cancellation mapping
// Example contract scenario description (not platform-specific) Test: Database transaction is atomic Given: empty tasks table When: runInTransaction { insert task A; throw error } Then: tasks table remains emptyStep 5: Validate parity with “golden traces”
Define a canonical sequence of actions and expected state transitions, then record logs/metrics in a comparable format across platforms. For example:
- Create task offline
- Restart app
- Go online
- Trigger sync
- Observe task becomes “synced”
Each platform should emit the same sequence of structured events (event names and key fields), even if the underlying logging library differs.
Handling Platform Differences Without Breaking the Blueprint
Background execution variability
Some platforms will not guarantee periodic background work. Your blueprint should allow the application layer to request scheduling, but not assume it will run. Design the Scheduler port so it can return “scheduled” vs “not supported,” and ensure the app has foreground triggers (app resume, user action) that also call into the same sync entrypoint.
Database feature gaps
SQLite supports complex queries and transactions; IndexedDB is different; some mobile databases have limited joins. Keep the core logic independent of query sophistication by:
- Using repository methods that express intent (e.g., getTasksByProject) rather than exposing raw SQL
- Allowing adapters to implement queries differently (indexes, denormalization)
- Keeping migrations and schema versions explicit per platform but aligned conceptually
Threading and concurrency models
Kotlin coroutines, Swift concurrency, JavaScript event loop, and Dart isolates differ. The blueprint should standardize:
- Whether repository methods are synchronous or async
- Where callbacks are delivered (main thread vs background)
- How cancellation is represented
If you cannot share code, you can still share the contract: write it down and enforce it with tests and code reviews.
Blueprint Artifacts You Should Produce (Reusable Across Teams)
1) Interface specification document
A short spec that lists each port and repository interface, with behavioral notes and examples. This becomes the “constitution” of your offline-first implementation across platforms.
2) Reference schemas and envelopes
Define canonical JSON envelopes for stored operations and cached responses, including versioning fields. Keep them in a shared repo or as a language-neutral schema file.
{ "schemaVersion": 1, "operationId": "...", "type": "CreateTask", "entityId": "local:...", "createdAtMillis": 1730000000000, "payload": { "title": "...", "notes": "..." } }3) Adapter checklist per platform
- Which database library is used and why
- How transactions are implemented
- How background scheduling is triggered and constrained
- How secure storage is accessed for secrets (if needed by adapters)
- Known limitations and fallbacks
4) Sample app or “kitchen sink” module
A minimal app that exercises the ports and repositories without product UI complexity. This is invaluable when onboarding a new platform (e.g., adding desktop or web later) because it provides a known-good target behavior.
Concrete Example: Translating One Blueprint to Three Implementations
Assume your core module defines TaskRepositoryImpl that depends on Database, Clock, OperationQueue, HttpClient, Scheduler. Here is how the adaptation looks:
- Android: DatabaseAdapter wraps Room DAO calls; runInTransaction uses RoomDatabase.withTransaction; SchedulerAdapter uses unique WorkManager work; HttpClientAdapter uses OkHttp interceptors to attach headers.
- iOS: DatabaseAdapter wraps SQLite with explicit BEGIN/COMMIT/ROLLBACK (or Core Data performAndWait with save/rollback); SchedulerAdapter registers BGAppRefreshTask; HttpClientAdapter uses URLSession with URLRequest timeouts.
- Web: DatabaseAdapter wraps IndexedDB transactions; SchedulerAdapter uses Service Worker sync when available, otherwise no-op plus foreground triggers; HttpClientAdapter uses fetch with AbortController for cancellation.
The repository and domain code remain the same shape; only adapters change. If you later replace Room with SQLDelight, or URLSession with a different networking layer, you still keep the same ports and tests.