[{"data":1,"prerenderedAt":28},["ShallowReactive",2],{"articolo-architettura-offline-first-in-flutter-outbox-sincronizzazione-e-conflitti":3,"comments-article-architettura-offline-first-in-flutter-outbox-sincronizzazione-e-conflitti":27},{"id":4,"title":5,"slug":6,"excerpt":7,"body":8,"cover_image":9,"cover_remote_url":10,"cover_credit":11,"video_url":15,"status":16,"published_at":17,"meta_title":18,"meta_description":19,"category":20,"author":24},103,"Architettura offline-first in Flutter: outbox, sincronizzazione e conflitti","architettura-offline-first-in-flutter-outbox-sincronizzazione-e-conflitti","Come progettare un'app Flutter che funziona anche senza rete: cache locale come sorgente di verità, pattern Outbox per le scritture, motore di sincronizzazione e strategie di risoluzione dei conflitti.","## Perché offline-first non è solo \"gestire l'errore di rete\"\n\nMolte app Flutter trattano l'assenza di connessione come un caso eccezionale: si mostra uno snackbar, si disabilita un pulsante e si spera che l'utente riprovi. Un'architettura **offline-first** ribalta la prospettiva: la rete è un dettaglio implementativo, mentre la **sorgente di verità per la UI è il database locale**. L'app legge e scrive sempre in locale, e un componente separato si occupa di riconciliare lo stato con il server quando è possibile.\n\nI vantaggi sono concreti:\n\n- **Latenza percepita quasi nulla**: la UI si aggiorna prima ancora che parta la richiesta HTTP.\n- **Resilienza**: metropolitana, ascensori, connessioni instabili non bloccano l'utente.\n- **Meno stati di caricamento**: spinner solo al primo avvio o quando i dati mancano davvero.\n\nIn cambio, bisogna affrontare tre problemi seri: come rappresentare le modifiche non ancora inviate, come sincronizzarle in modo affidabile, come gestire i conflitti. Vediamoli uno alla volta.\n\n## I tre livelli dell'architettura\n\n```\nUI (widget)\n   ↕ stream reattivi\nRepository  ──►  Local store (SQLite \u002F Isar \u002F Drift)\n   │                     ▲\n   │                     │\n   └──► Outbox ──► SyncEngine ──► API remota\n```\n\n- **Local store**: contiene le entità di dominio più alcuni metadati di sincronizzazione.\n- **Outbox**: tabella di \"intenzioni di scrittura\" da inviare al server, in ordine.\n- **SyncEngine**: processo in background che svuota l'outbox e scarica gli aggiornamenti remoti.\n\nIl Repository espone alla UI solo stream sul database locale: non restituisce mai `Future` che dipendono dalla rete.\n\n## Modellare l'entità con i metadati di sync\n\nOgni record locale ha bisogno di informazioni aggiuntive rispetto al modello remoto:\n\n```dart\nenum SyncStatus { synced, pending, conflict }\n\nclass Note {\n  final String id;            \u002F\u002F UUID generato sul client\n  final String title;\n  final String body;\n  final DateTime updatedAt;   \u002F\u002F timestamp locale della modifica\n  final int remoteVersion;    \u002F\u002F versione conosciuta lato server\n  final SyncStatus status;\n  final bool deleted;         \u002F\u002F tombstone: cancellazione logica\n\n  const Note({\n    required this.id,\n    required this.title,\n    required this.body,\n    required this.updatedAt,\n    this.remoteVersion = 0,\n    this.status = SyncStatus.pending,\n    this.deleted = false,\n  });\n}\n```\n\nTre scelte da sottolineare:\n\n1. **ID generati dal client** (UUID v4 o v7): evitano il problema del \"record senza id\" finché il server non risponde, e rendono le operazioni idempotenti.\n2. **Tombstone** invece di `DELETE` fisico: senza un record che testimonia la cancellazione, la sincronizzazione successiva riporterebbe in vita il dato.\n3. **`remoteVersion`**: serve per il controllo di concorrenza ottimistica (vedi più avanti).\n\n## Il pattern Outbox\n\nL'errore più comune è tentare la chiamata HTTP dentro il repository e, in caso di fallimento, \"ricordarsi\" di riprovare. Il pattern **Outbox** rende invece la scrittura locale e la registrazione dell'intenzione di sincronizzare un'unica transazione atomica.\n\n```dart\nenum OpType { create, update, delete }\n\nclass OutboxEntry {\n  final int id;              \u002F\u002F autoincrement: definisce l'ordine\n  final String entityType;   \u002F\u002F 'note', 'task', ...\n  final String entityId;\n  final OpType op;\n  final String payload;      \u002F\u002F JSON dello stato da inviare\n  final int attempts;\n  final DateTime? nextAttemptAt;\n\n  const OutboxEntry({\n    required this.id,\n    required this.entityType,\n    required this.entityId,\n    required this.op,\n    required this.payload,\n    this.attempts = 0,\n    this.nextAttemptAt,\n  });\n}\n```\n\nIl repository diventa quindi molto semplice e completamente sincrono rispetto alla rete:\n\n```dart\nclass NoteRepository {\n  NoteRepository(this._db, this._sync);\n\n  final AppDatabase _db;\n  final SyncEngine _sync;\n\n  Stream\u003CList\u003CNote>> watchAll() => _db.watchNotes(includeDeleted: false);\n\n  Future\u003Cvoid> save(Note note) async {\n    final updated = note.copyWith(\n      updatedAt: DateTime.now().toUtc(),\n      status: SyncStatus.pending,\n    );\n\n    await _db.transaction(() async {\n      await _db.upsertNote(updated);\n      await _db.enqueueOutbox(\n        entityType: 'note',\n        entityId: updated.id,\n        op: updated.remoteVersion == 0 ? OpType.create : OpType.update,\n        payload: jsonEncode(updated.toJson()),\n      );\n    });\n\n    _sync.requestSync(); \u002F\u002F best-effort, non attendiamo il risultato\n  }\n\n  Future\u003Cvoid> delete(String id) async {\n    await _db.transaction(() async {\n      await _db.markDeleted(id);\n      await _db.enqueueOutbox(\n        entityType: 'note',\n        entityId: id,\n        op: OpType.delete,\n        payload: '{}',\n      );\n    });\n    _sync.requestSync();\n  }\n}\n```\n\nLa UI riceve immediatamente il nuovo valore attraverso lo stream: è il cosiddetto **optimistic update**, ma senza l'acrobazia del rollback manuale, perché la modifica è già persistita.\n\n### Coalescing delle operazioni\n\nSe l'utente modifica la stessa nota dieci volte offline, non ha senso inviare dieci PUT. Quando si accoda un `update` per un'entità che ha già un `update` pending non ancora in volo, conviene **sostituire il payload** della entry esistente. Analogamente, un `delete` su un'entità che ha ancora un `create` pending può cancellare entrambe le entry: il server non ha mai saputo dell'esistenza del record.\n\n## Il motore di sincronizzazione\n\nIl SyncEngine ha un ciclo di vita chiaro: viene svegliato da un evento (avvio app, ritorno in foreground, ripristino della connettività, nuova scrittura) e processa la coda in modo seriale.\n\n```dart\nclass SyncEngine {\n  SyncEngine(this._db, this._api, this._connectivity);\n\n  final AppDatabase _db;\n  final ApiClient _api;\n  final Connectivity _connectivity;\n\n  bool _running = false;\n  bool _pendingRequest = false;\n\n  void start() {\n    _connectivity.onConnectivityChanged.listen((results) {\n      if (!results.contains(ConnectivityResult.none)) requestSync();\n    });\n    requestSync();\n  }\n\n  Future\u003Cvoid> requestSync() async {\n    if (_running) {\n      _pendingRequest = true; \u002F\u002F non accodiamo run paralleli\n      return;\n    }\n    _running = true;\n    try {\n      await _pushOutbox();\n      await _pullChanges();\n    } catch (e, s) {\n      \u002F\u002F logging \u002F crash reporting: non rilanciare, il retry è programmato\n    } finally {\n      _running = false;\n      if (_pendingRequest) {\n        _pendingRequest = false;\n        unawaited(requestSync());\n      }\n    }\n  }\n}\n```\n\n### Push: svuotare l'outbox\n\n```dart\nFuture\u003Cvoid> _pushOutbox() async {\n  final entries = await _db.pendingOutbox(limit: 50);\n\n  for (final entry in entries) {\n    try {\n      final result = await _api.apply(entry); \u002F\u002F POST\u002FPUT\u002FDELETE\n      await _db.transaction(() async {\n        await _db.applyServerVersion(entry.entityId, result.version);\n        await _db.markSynced(entry.entityId);\n        await _db.removeOutbox(entry.id);\n      });\n    } on ConflictException catch (e) {\n      await _resolveConflict(entry, e.serverState);\n      await _db.removeOutbox(entry.id);\n    } on NetworkException {\n      await _db.scheduleRetry(entry, _backoff(entry.attempts));\n      break; \u002F\u002F ordine garantito: fermiamoci alla prima entry non inviabile\n    } on ApiException catch (e) {\n      if (e.statusCode >= 400 && e.statusCode \u003C 500) {\n        \u002F\u002F errore permanente: non ha senso riprovare all'infinito\n        await _db.moveToDeadLetter(entry);\n      } else {\n        await _db.scheduleRetry(entry, _backoff(entry.attempts));\n        break;\n      }\n    }\n  }\n}\n\nDuration _backoff(int attempts) {\n  final seconds = math.min(300, math.pow(2, attempts).toInt());\n  final jitter = math.Random().nextInt(1000);\n  return Duration(seconds: seconds, milliseconds: jitter);\n}\n```\n\nDue dettagli spesso trascurati:\n\n- **Backoff esponenziale con jitter**: senza jitter, migliaia di client che tornano online dopo un disservizio colpiscono il backend nello stesso istante.\n- **Dead letter**: un `422` causato da un payload non valido non si risolve riprovando. Meglio spostare l'operazione in una tabella separata, notificare l'utente e permettere di scartare o correggere.\n\n### Pull: scaricare gli aggiornamenti remoti\n\nLa sincronizzazione discendente dovrebbe essere **incrementale**, non un download completo. Il pattern classico è il *delta sync* basato su cursore:\n\n```dart\nFuture\u003Cvoid> _pullChanges() async {\n  var cursor = await _db.readSyncCursor('note');\n\n  while (true) {\n    final page = await _api.changesSince(cursor, limit: 200);\n\n    await _db.transaction(() async {\n      for (final remote in page.items) {\n        final local = await _db.findNote(remote.id);\n        if (local == null || local.status == SyncStatus.synced) {\n          await _db.upsertFromRemote(remote);\n        } else {\n          \u002F\u002F esiste una modifica locale non ancora inviata\n          await _resolveConflict(local, remote);\n        }\n      }\n      await _db.writeSyncCursor('note', page.nextCursor);\n    });\n\n    if (!page.hasMore) break;\n    cursor = page.nextCursor;\n  }\n}\n```\n\nIl cursore può essere un timestamp server oppure, meglio, un numero di sequenza monotono: i timestamp soffrono di clock skew e di record modificati nello stesso millisecondo.\n\n## Gestire i conflitti\n\nUn conflitto si verifica quando lo stesso record è stato modificato sia in locale sia sul server dall'ultima sincronizzazione. Le strategie principali:\n\n| Strategia | Quando usarla | Rischio |\n|---|---|---|\n| **Last Write Wins** | Dati poco critici, singolo utente su più device | Perdita silenziosa di modifiche |\n| **Server wins** | Dati autoritativi (prezzi, permessi) | Frustrazione dell'utente |\n| **Merge per campo** | Entità con campi indipendenti | Complessità, stati incoerenti |\n| **Prompt all'utente** | Contenuti editoriali importanti | Attrito nella UX |\n| **CRDT** | Editing collaborativo realtime | Molto complesso, payload pesanti |\n\nIl **controllo di concorrenza ottimistica** è il prerequisito per rilevarli: il client invia la `remoteVersion` che conosce (tipicamente in un header `If-Match` con un ETag), e il server risponde `409 Conflict` se nel frattempo la versione è cambiata.\n\n```dart\nFuture\u003Cvoid> _resolveConflict(Note local, Note server) async {\n  switch (conflictPolicy) {\n    case ConflictPolicy.serverWins:\n      await _db.upsertFromRemote(server);\n    case ConflictPolicy.lastWriteWins:\n      final winner = local.updatedAt.isAfter(server.updatedAt) ? local : server;\n      await _db.upsertNote(winner.copyWith(remoteVersion: server.remoteVersion));\n    case ConflictPolicy.manual:\n      await _db.upsertNote(local.copyWith(status: SyncStatus.conflict));\n      await _db.saveConflictSnapshot(local.id, server); \u002F\u002F per la UI di merge\n  }\n}\n```\n\nSuggerimento pratico: anche se si adotta Last Write Wins, conviene **archiviare la versione perdente** in una tabella di storico. Costa poco e salva da segnalazioni di \"dati spariti\" impossibili da diagnosticare.\n\n## Comunicare lo stato all'utente\n\nUn'app offline-first deve essere onesta. Tre elementi di UI fanno la differenza:\n\n1. **Badge per elemento**: una piccola icona di \"in attesa di sincronizzazione\" sui record con `status != synced`.\n2. **Indicatore globale**: numero di operazioni pending e timestamp dell'ultima sincronizzazione riuscita.\n3. **Azione manuale**: pull-to-refresh che chiama `requestSync()`, perché gli utenti si aspettano di poter forzare.\n\n```dart\nclass SyncBanner extends StatelessWidget {\n  const SyncBanner({super.key, required this.pendingCount});\n\n  final int pendingCount;\n\n  @override\n  Widget build(BuildContext context) {\n    if (pendingCount == 0) return const SizedBox.shrink();\n    return MaterialBanner(\n      leading: const Icon(Icons.cloud_upload_outlined),\n      content: Text('$pendingCount modifiche in attesa di sincronizzazione'),\n      actions: [\n        TextButton(\n          onPressed: () => context.read\u003CSyncEngine>().requestSync(),\n          child: const Text('Sincronizza ora'),\n        ),\n      ],\n    );\n  }\n}\n```\n\n## Testare la sincronizzazione\n\nLa logica di sync è il punto in cui si annidano i bug più costosi, ed è per fortuna facilmente testabile perché non dipende dai widget. Casi da coprire con unit test:\n\n- Scrittura offline → outbox contiene esattamente una entry.\n- Dieci update consecutivi → una sola entry dopo il coalescing.\n- Errore di rete a metà coda → le entry successive non vengono saltate.\n- `409` dal server → il conflitto viene applicato secondo la policy.\n- Doppio invio della stessa operazione → il server, grazie all'ID client, non crea duplicati.\n- Riavvio dell'app con outbox non vuoto → la coda riparte.\n\nUn `FakeApiClient` con un flag `isOnline` e una lista di risposte programmate permette di simulare l'intero ciclo in millisecondi.\n\n## Errori da evitare\n\n- **Usare solo `connectivity_plus` per decidere se si è online**: il pacchetto dice che esiste un'interfaccia attiva, non che il server è raggiungibile (captive portal, DNS bloccato). Trattate sempre il fallimento della richiesta come fonte di verità.\n- **Eseguire il sync in parallelo su più isolate senza lock**: si ottengono scritture duplicate. Serializzate la coda.\n- **Ignorare l'ordine delle operazioni**: creare un figlio prima del padre genera errori di integrità referenziale lato server.\n- **Sincronizzare tutto a ogni avvio**: con dataset grandi diventa insostenibile. Investite subito in un endpoint di delta sync.\n- **Non versionare lo schema locale**: le migrazioni del database offline sono inevitabili; pianificatele dall'inizio.\n\n## Conclusioni\n\nL'offline-first non è una funzionalità da aggiungere alla fine, ma una decisione architetturale che condiziona modelli, repository e API. Il costo iniziale è reale — un database locale, una coda persistente, una policy di conflitti — ma il risultato è un'app che risponde istantaneamente, sopravvive alle connessioni scadenti e riduce drasticamente il carico sul backend.\n\nSe state partendo da zero, implementate nell'ordine: database locale come sorgente di verità, poi l'outbox, poi il delta sync, e solo alla fine la gestione fine dei conflitti. Ogni passo porta valore anche da solo.","https:\u002F\u002Fflutter.it\u002Fstorage\u002Farticles\u002Fadc73ce3-0606-4f86-ad18-919ca7b5cf9f.jpg","https:\u002F\u002Fimages.unsplash.com\u002Fphoto-1655204903983-73007f15cb3e?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w5NzA2NTJ8MHwxfHJhbmRvbXx8fHx8fHx8fDE3ODk4NzY5MDB8&ixlib=rb-4.1.0&q=80&w=1080",{"name":12,"author_url":13,"photo_url":14},"iSawRed","https:\u002F\u002Funsplash.com\u002F@isawred","https:\u002F\u002Funsplash.com\u002Fphotos\u002Fa-close-up-of-a-control-panel-AGQZprOABgQ",null,"published","2026-09-20T04:01:40+00:00","Offline-first in Flutter: outbox e sincronizzazione","Guida all'architettura offline-first in Flutter: database locale come sorgente di verità, pattern Outbox, delta sync, retry con backoff e conflitti.",{"id":21,"name":22,"slug":23},3,"Best practice","best-practice",{"id":25,"name":26},1,"Flutter Bot",[],1792316909953]