Platform-Agnostic Implementation Blueprint and Adaptation to Any Stack

Capítulo 19

Estimated reading time: 12 minutes

+ Exercise

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.

Continue in our app.
  • Listen to the audio with the screen off.
  • Earn a certificate upon completion.
  • Over 5000 courses for you to explore!
Or continue reading below...
Download App

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 empty

Step 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.

Now answer the exercise about the content:

In a platform-agnostic offline-first architecture, what is the main benefit of defining narrow port interfaces (for storage, network, time, scheduling) and implementing them with platform-specific adapters?

You are right! Congratulations, now go to the next page

You missed! Try again.

Ports capture all side effects behind stable contracts, so domain and application logic depend on interfaces, not platform tools. When changing platforms or libraries, you replace the adapters while preserving the same offline-first invariants and workflows.

Free Ebook cover Offline-First Mobile Apps: Sync, Storage, and Resilient UX Across Platforms
100%

Offline-First Mobile Apps: Sync, Storage, and Resilient UX Across Platforms

New course

19 pages

Download the app to earn free Certification and listen to the courses in the background, even with the screen off.