Skip to main content

🏗️ Arquitectura de Sincronización Offline-First entre App Móvil y Backend

🎯 Objetivo

Diseñar un proceso completo y robusto para la sincronización de datos (catálogos y tablas transaccionales) entre una aplicación móvil y un sistema backend con soporte Offline-First.


🧩 Componentes de la Arquitectura

📱 App Móvil

  • DB Local: (SQLite/Realm) con catálogos, transacciones y tablas de control (pending_outbox, sync_state, oplog).
  • Outbox: registra todas las operaciones CRUD realizadas sin conexión.
  • Motor de Sync: detecta conectividad, maneja colas y aplica deltas desde el backend.
  • Conflict Resolver: gestiona conflictos (LWW o cliente-gana).
  • Manejador de Push: escucha notificaciones silenciosas y dispara sincronizaciones automáticas.

🌐 API Backend

  • Endpoints de sincronización (batch e idempotentes).
  • Módulo de catálogos con deltas y versionado.
  • Resolución de conflictos y validación de negocio.
  • Control de dispositivos (tokens, versiones, sincronización remota).

☁️ Servicio de Push

  • Usa FCM (Android) o APNs (iOS).
  • Envía notificaciones silenciosas (content-available=1) para iniciar sincronizaciones sin alertar al usuario.

🗄️ Base de Datos Servidor

  • Tablas maestras y operacionales con campos updated_at, version, o etag.
  • Control de versiones para catálogos y journaling para generar deltas.

🖥️ Servicio de Gestión de Dispositivos

  • Almacena device_id, push_token, app_version, last_seen, platform.
  • Permite sincronizaciones dirigidas a segmentos de usuarios o zonas específicas.

⚙️ Estrategias Técnicas

1️⃣ Detección de Cambios (Cliente → Servidor)

Tabla pending_outbox con:

  • id (UUID), op (CREATE/UPDATE/DELETE), entity, entity_id, payload, idempotency_key, status, retry_count, created_at_local.
  • La app siempre escribe localmente primero, luego registra en pending_outbox.
  • El motor de sync procesa los registros PENDING en lotes.

2️⃣ Actualización de Catálogos (Servidor → Cliente)

  • Modelo “delta by timestamp/version”:
    • El cliente guarda last_catalog_sync_at por catálogo.
    • El backend expone /catalogs/{name}/delta?since=timestamp.
    • Devuelve upserts[] y tombstones[] con updated_at o version.

3️⃣ Manejo de Lotes

  • Tamaños configurables (50–200 items).
  • Reintentos con backoff exponencial y jitter.
  • Endpoint batch idempotente con idempotency_key persistido en backend (TTL 14 días).

4️⃣ Resolución de Conflictos

Tipo de datoEstrategiaRegla
CatálogosLWW (Last Write Wins)El servidor gana
Transacciones nuevasCliente ganaSe aceptan si no violan reglas de negocio
Updates sobre misma entidadOptimistic ConcurrencyIf-Match: <etag> o version

5️⃣ Atomicidad

  • Transacción por item dentro del batch.
  • Éxito parcial permitido, respuesta con matriz de resultados.
  • Modo opcional atomic=true para lotes que deben fallar todos o ninguno.

6️⃣ Seguridad

  • Autenticación OAuth2.0/JWT.
  • Conexión segura TLS.
  • Límite de payload (512KB–2MB) y compresión GZIP.
  • Trazas con idempotency_key, device_id, sync_session_id.

🧠 Diagrama de Flujo – Lógica del Cliente (Offline-First)


🔁 Diagrama de Secuencia – Sincronización de Transacciones


📡 Diagrama de Secuencia – Sincronización Forzada (Push Silenciosa)


🗃️ Esquema de Base de Datos Local

CREATE TABLE pending_outbox (
id TEXT PRIMARY KEY,
op TEXT NOT NULL,
entity TEXT NOT NULL,
entity_id TEXT NOT NULL,
payload TEXT NOT NULL,
idempotency_key TEXT NOT NULL,
created_at_local TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'PENDING',
retry_count INTEGER NOT NULL DEFAULT 0
);

CREATE TABLE sync_state (
key TEXT PRIMARY KEY,
last_sync_at TEXT,
last_version INTEGER
);

CREATE TABLE cat_products (
id TEXT PRIMARY KEY,
sku TEXT,
name TEXT,
price REAL,
updated_at TEXT,
version INTEGER
);

🧩 Endpoints REST Recomendados

Transacciones

POST /transactions/batch

Request:

{
"items": [
{
"idempotency_key": "uuid",
"op": "CREATE",
"entity": "order",
"client_id": "uuid",
"payload": { "field": "value" }
}
],
"device": { "id": "dev123", "app_version": "1.2.0" }
}

Response:

{
"results": [
{
"idempotency_key": "uuid",
"status": "OK",
"server_id": "srv-001",
"etag": "v2",
"message": "Success"
}
]
}

Catálogos

GET /catalogs/{name}/delta?since=2025-01-01T00:00:00Z

Response:

{
"upserts": [{ "id": "p1", "name": "Leche Alpura", "price": 22.5 }],
"tombstones": [{ "id": "p2", "deleted_at": "2025-10-01T00:00:00Z" }],
"server_time": "2025-10-22T10:00:00Z",
"version": 15
}

Catálogos

GET /catalogs/{name}/delta?since=2025-01-01T00:00:00Z

Response:

{
"upserts": [{ "id": "p1", "name": "Leche Alpura", "price": 22.5 }],
"tombstones": [{ "id": "p2", "deleted_at": "2025-10-01T00:00:00Z" }],
"server_time": "2025-10-22T10:00:00Z",
"version": 15
}

Sincronización

POST /sync/ack

Body:

{ "device_id": "dev123", "catalog": "products", "version": 15 }

🔄 Pseudocódigo de Ciclo de Sincronización

function syncNow():
if !isNetworkAvailable(): return

// 1) Transacciones -> Servidor
items = db.outbox.fetch(limit = adaptiveBatchSize())
if items.isEmpty(): goto catalogs

resp = api.post("/transactions/batch", {items})
for each r in resp.results:
if r.status == "OK":
db.outbox.markSent(r.idempotency_key)
else if r.status == "CONFLICT":
db.outbox.markFailed(r.idempotency_key)
else:
db.outbox.incrementRetry(r.idempotency_key)

// 2) Catálogos <- Servidor
catalogs:
for cat in enabledCatalogs():
since = db.sync_state.get(cat).last_sync_at
do:
delta = api.get("/catalogs/"+cat+"/delta?since="+since)
db.tx {
applyUpserts(cat, delta.upserts)
applyDeletes(cat, delta.tombstones)
db.sync_state.update(cat, delta.server_time)
}
while delta.next_cursor != null

📈 Políticas de Reintento

  • Errores 5xx / red: backoff exponencial (2ⁿ segundos, máx 5 min).
  • Errores 4xx (409, 412): marcar FAILED, posible resolución manual.
  • Timeouts: reducir tamaño de lote dinámicamente.

✅ Decisiones de Diseño por Defecto

AspectoPolítica
Conflictos CatálogosServidor gana (LWW)
Conflictos TransaccionesCliente gana (validación posterior)
AtomicidadPor item (no todo-o-nada)
Tamaño de lote50–200 items
Idempotencia (server)TTL 14 días
Sync forzadaSilent push segmentada por device_tag

📊 Métricas y Observabilidad

  • Métricas: tamaño de lote, tasa de conflicto, duración de sync.
  • Logs: idempotency_key, device_id, sync_session_id.
  • Alertas: spikes de CONFLICT o latencias elevadas.

🕒 Sincronización de Catálogos Programada (una vez al día)

Esta sección agrega un planificador diario para refrescar catálogos aunque no haya actividad del usuario, complementando la sincronización por eventos y por push silenciosa.

Objetivos

  • Garantizar que los catálogos estén actualizados al menos 1 vez al día.
  • Desacoplar del uso activo (si el usuario no abre la app).
  • Respetar batería y datos: ventana con backoff, solo en red no medida si es posible, y tamaño de delta paginado.

Política sugerida

  • Horario sugerido: ventana entre 02:00–04:00 hora local (configurable por feature flag).
  • Estrategia: “delta by timestamp/version” por catálogo (products, clients, prices, routes, etc.).
  • Stagger por dispositivo**:** aplicar un jitter aleatorio (±30 min) para evitar picos en el backend.
  • Condiciones: preferir Wi‑Fi, batería > 20% y dispositivo en reposo (configurable por plataforma).
  • Failover: si falla la corrida diaria, reintentar con backoff y, al tercer fallo, forzar un full sync del catálogo afectado fuera de pico (con límites de página).

Diagrama de Flujo — Planificador Diario (cliente)

Secuencia — Corrida Programada (diaria)

Ajustes de Endpoint sugeridos

  • GET /catalogs/{name}/delta?since=ISO8601&cursor=...&pageSize=...
  • Respuestas con server_time, version, next_cursor, hints de paginación.

📈 Políticas de Reintento

  • Errores 5xx / red: backoff exponencial (2ⁿ segundos, máx 5 min).
  • Errores 4xx (409, 412): marcar FAILED, posible resolución manual.
  • Timeouts: reducir tamaño de lote dinámicamente.

✅ Decisiones de Diseño por Defecto

AspectoPolítica
Conflictos CatálogosServidor gana (LWW)
Conflictos TransaccionesCliente gana (validación posterior)
AtomicidadPor item (no todo-o-nada)
Tamaño de lote50–200 items
Idempotencia (server)TTL 14 días
Sync forzadaSilent push segmentada por device_tag

📊 Métricas y Observabilidad

  • Métricas: tamaño de lote, tasa de conflicto, duración de sync.
  • Logs: idempotency_key, device_id, sync_session_id.
  • Alertas: spikes de CONFLICT o latencias elevadas.