Arquitetura de software · Estrutura do código · 8 min de leitura
Offline-first e sincronização
O app lê e grava num banco local e sincroniza com o servidor quando dá: funciona no metrô, abre na hora e não perde o que a pessoa fez — desde que os conflitos tenham regra.
Arraste para o lado para ver o diagrama inteiro.
A ideia
Em vez de “buscar na API e mostrar”, a tela observa o banco local (Room com Flow no Android, SwiftData ou Core Data no iOS). Ler é instantâneo; gravar também — a mudança vai para o banco e para uma fila de pendências (a outbox). Um trabalho em segundo plano (WorkManager, BGTaskScheduler) envia as pendências e traz as novidades quando há rede, com retry e backoff.
Trazer novidades (pull)
- Sincronização incremental: o servidor devolve só o que mudou depois de um cursor (
updated_atou número de versão), não a base inteira. - Exclusões: apagar de verdade no servidor faz o item “ressuscitar” em quem estava offline; use tombstones (marca de apagado) por um tempo.
- IDs gerados no aparelho (UUID v7) permitem criar itens offline sem colisão.
Conflitos
Duas pessoas (ou dois aparelhos) alteraram o mesmo item offline. Estratégias, da mais simples à mais rica:
- Última escrita vence (por horário do servidor): simples, mas descarta uma das edições.
- Por campo: mescla quando cada um mudou campos diferentes.
- Versão + rejeição: o servidor recusa a mudança baseada em versão velha e o app pede para a pessoa escolher.
- CRDTs: estruturas que se mesclam sempre sem conflito (contadores, listas, texto colaborativo) — base do local-first.
Cuidados
- Mostre o estado: “salvo no aparelho”, “sincronizado”, “erro ao enviar”.
- Operações idempotentes no servidor (a outbox pode reenviar).
- Migrações do banco local testadas — o dado do usuário mora ali.
- Dados sensíveis no aparelho pedem criptografia e cuidado no logout.
No código
@Entity data class Note(@PrimaryKey val id: String, val text: String, val updatedAt: Long, val pending: Boolean)
class NotesRepository(private val dao: NoteDao, private val work: WorkManager) {
/** A tela observa isto: abre na hora, com ou sem rede. */
val notes: Flow<List<Note>> = dao.observeAll()
suspend fun save(text: String, id: String = UUID.randomUUID().toString()) {
dao.upsert(Note(id, text, System.currentTimeMillis(), pending = true))
// Um único trabalho de sincronização na fila, que roda quando houver rede.
work.enqueueUniqueWork(
"sync-notes",
ExistingWorkPolicy.KEEP,
OneTimeWorkRequestBuilder<SyncNotesWorker>()
.setConstraints(Constraints(requiredNetworkType = NetworkType.CONNECTED))
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build(),
)
}
}
class SyncNotesWorker(ctx: Context, params: WorkerParameters, private val dao: NoteDao, private val api: NotesApi) :
CoroutineWorker(ctx, params) {
override suspend fun doWork(): Result = try {
dao.pending().forEach { api.put(it); dao.markSynced(it.id, it.updatedAt) } // PUT por id: idempotente
api.changesSince(dao.lastCursor()).let { dao.applyRemote(it) }
Result.success()
} catch (e: IOException) {
Result.retry()
}
} Gravar marca o item como pendente e agenda um trabalho único com rede obrigatória e backoff; o worker envia as pendências com PUT por id (repetir não duplica) e depois puxa só o que mudou.
Em resumo
- A tela observa o banco local.
- Gravações vão para uma outbox enviada em segundo plano.
- Sincronização incremental com cursor e tombstones.
- Conflitos precisam de regra explícita.
- Servidor idempotente: a outbox reenvia.
Vantagens e custos
- App instantâneo e útil sem rede.
- Nada se perde quando a conexão cai.
- Menos carga no servidor com sincronização incremental.
- Conflitos e migrações locais dão trabalho.
- Dois modelos de dados (local e remoto) para manter.
- Estado “pendente” precisa aparecer na interface.
Perguntar à IA
Monte um prompt pronto sobre Offline-first e sincronização na sua linguagem e no seu caso, e abra num chat gratuito.
O prompt vai para o site que você escolher. Não cole senhas, tokens nem dados reais de clientes. Para Gemini e outros, copie e cole.