Software local-first: app rodando offline com CRDT

1. Visão Geral

A maioria das aplicações atualmente considera o servidor como o guardião dos dados “reais”, enquanto o cliente apenas os exibe. Sem rede, o app fica praticamente inutilizável. Software local-first inverte essa abordagem: os dados residem primeiro no dispositivo do usuário, e o servidor apenas ajuda na sincronização.

Este artigo explica o que é local-first, por que está ganhando atenção, e o papel do CRDT (Conflict-free Replicated Data Type) na fusão de dados sem necessidade de locks ou um “único vencedor”. Ao final, você será capaz de implementar alguns CRDTs básicos em Java 21, construir um endpoint de sincronização com Spring Boot 3, e saber quando não deve escolher essa abordagem.

2. O que é local-first e por que está ganhando atenção

Uma aplicação local-first geralmente tem as seguintes características:

  • Leitura/escrita instantânea no dispositivo: toda operação de escrita vai para armazenamento local (SQLite, IndexedDB, arquivo…), sem esperar por round-trip de rede.
  • Funciona offline: sem rede, você ainda pode editar dados. Quando a rede volta, sincroniza.
  • Colaboração entre múltiplos dispositivos e pessoas: réplicas são editadas simultaneamente e convergem para o mesmo estado automaticamente.
  • O usuário é dono dos dados: se o servidor desaparecer, os dados ainda existem no dispositivo.

Existem alguns motivos pelos quais essa abordagem está sendo discutida mais:

  1. Expectativas de experiência: usuários esperam respostas instantâneas do app, sem spinners após cada clique.
  2. Dispositivos mais poderosos: smartphones e laptops conseguem rodar bancos de dados locais e lógica de fusão de dados.
  3. Ecossistema mais maduro: bibliotecas CRDT como Automerge, Yjs, e vários engines de sincronização para SQLite tornaram a adoção prática mais fácil do que escrever tudo do zero.
  4. Privacidade e soberania de dados: manter dados no dispositivo do usuário reduz a dependência de um servidor central.

O maior desafio é justamente a sincronização. Quando dois dispositivos editam offline e se encontram depois, quem está certo?

3. Como CRDT resolve conflitos

CRDT é uma estrutura de dados projetada de forma que todas as réplicas, após receberem as atualizações umas das outras, sempre convergem para o mesmo estado, independentemente da ordem de recebimento. Nenhum servidor precisa ser árbitro, sem locks necessários.

Existem duas famílias principais:

  • State-based (CvRDT): réplicas enviam o estado completo umas para as outras e chamam merge.
  • Operation-based (CmRDT): réplicas enviam cada operação. O canal de transmissão deve garantir que cada operação seja entregue exatamente uma vez.

Este artigo usa state-based porque é mais fácil de entender e demonstrar. Para convergir, a função merge deve satisfazer três propriedades:

Comutatividade : merge(a, b) == merge(b, a)
Associatividade: merge(merge(a, b), c) == merge(a, merge(b, c))
Idempotência   : merge(a, a) == a

Graças a essas três propriedades, mensagens que chegam atrasadas, fora de ordem ou duplicadas não afetam o resultado final.

4. Implementando alguns CRDTs básicos

Os exemplos abaixo usam apenas JDK. A parte Spring Boot na seção 5 precisará de dependências adicionais.

4.1. G-Counter: contador que apenas incrementa

Suponha que você queira contar as visualizações de um Order em múltiplos dispositivos. Cada réplica apenas incrementa sua própria célula. Ao fazer merge, usamos o máximo de cada célula.

import java.util.HashMap;
import java.util.Map;

public final class GCounter {
    private final Map<String, Long> counts = new HashMap<>();

    public void increment(String replicaId) {
        counts.merge(replicaId, 1L, Long::sum);
    }

    public long value() {
        return counts.values().stream().mapToLong(Long::longValue).sum();
    }

    public void merge(GCounter other) {
        other.counts.forEach((id, n) -> counts.merge(id, n, Math::max));
    }
}

increment toca apenas na célula de replicaId, então duas réplicas nunca sobrescrevem uma à outra. merge usa Math::max, e max é comutativa, associativa e idempotente. Teste rápido:

GCounter phone = new GCounter();
GCounter laptop = new GCounter();
phone.increment("phone");
phone.increment("phone");
laptop.increment("laptop");

phone.merge(laptop);
laptop.merge(phone);
phone.merge(laptop); // fazer merge novamente não prejudica o resultado
System.out.println(phone.value() + " " + laptop.value());
3 3

Para suportar também decremento, use PN-Counter: dois G-Counter, um para incrementos e outro para decrementos, o valor é a diferença entre eles.

4.2. LWW-Register: “last-write-wins”

Para um campo simples como Customer.displayName, a maneira mais simples é manter o valor com o timestamp mais recente. Se os timestamps forem iguais, use replicaId como desempate para garantir um resultado determinístico.

public record LwwRegister<T>(T value, long timestamp, String replicaId) {

    public LwwRegister<T> merge(LwwRegister<T> other) {
        if (other.timestamp != timestamp) {
            return other.timestamp > timestamp ? other : this;
        }
        return other.replicaId.compareTo(replicaId) > 0 ? other : this;
    }
}
var a = new LwwRegister<>("Lan", 100, "phone");
var b = new LwwRegister<>("Lan Nguyễn", 105, "laptop");
System.out.println(a.merge(b).value() + " | " + b.merge(a).value());
Lan Nguyễn | Lan Nguyễn

Nota: Relógios em diferentes dispositivos geralmente não estão sincronizados. Uma máquina que roda alguns minutos adiantada vai “vencer” toda escrita. Na prática, é comum usar Hybrid Logical Clock ou Lamport clock em vez de timestamps físicos puros.

LWW é simples, mas silenciosamente descarta uma das duas edições. Para documentos colaborativos, isso não é aceitável. Nesse caso, você precisa de um CRDT para strings, como RGA ou os algoritmos que Automerge e Yjs usam.

4.3. OR-Set: conjunto com adição/remoção

Suponha uma lista de compras compartilhada. Uma implementação ingênua falhará quando um dispositivo deleta “Leite” enquanto outro acabou de adicioná-lo novamente. Observed-Remove Set resolve isso anexando um tag único a cada add. O comando remove apenas deleta tags que essa réplica já viu.

import java.util.*;

public final class ORSet {
    private final Map<String, Set<String>> adds = new HashMap<>();
    private final Map<String, Set<String>> removes = new HashMap<>();

    public void add(String item) {
        adds.computeIfAbsent(item, k -> new HashSet<>()).add(UUID.randomUUID().toString());
    }

    public void remove(String item) {
        removes.computeIfAbsent(item, k -> new HashSet<>())
               .addAll(adds.getOrDefault(item, Set.of()));
    }

    public Set<String> elements() {
        Set<String> result = new TreeSet<>();
        adds.forEach((item, tags) -> {
            Set<String> alive = new HashSet<>(tags);
            alive.removeAll(removes.getOrDefault(item, Set.of()));
            if (!alive.isEmpty()) result.add(item);
        });
        return result;
    }

    public void merge(ORSet other) {
        union(adds, other.adds);
        union(removes, other.removes);
    }

    private static void union(Map<String, Set<String>> target, Map<String, Set<String>> source) {
        source.forEach((k, v) -> target.computeIfAbsent(k, x -> new HashSet<>()).addAll(v));
    }

    public ORSetState toState() {
        return new ORSetState(adds, removes);
    }

    public static ORSet fromState(ORSetState state) {
        ORSet set = new ORSet();
        union(set.adds, state.adds());
        union(set.removes, state.removes());
        return set;
    }
}
import java.util.Map;
import java.util.Set;

public record ORSetState(Map<String, Set<String>> adds, Map<String, Set<String>> removes) {}

Análise de como funciona:

  • add gera um novo UUID cada vez, mesmo ao readicionar um elemento.
  • remove copia os tags atuais do elemento para removes. Tags adicionados por outras réplicas que esta réplica ainda não viu permanecem vivos.
  • elements retorna elementos que têm pelo menos um tag não deletado. Resultado: quando adição e exclusão acontecem simultaneamente, adição vence (add-wins).
  • merge é apenas uma união de conjuntos, então satisfaz as três propriedades da seção 3.
  • ORSetState é um record simples para Jackson serializar como JSON durante sincronização.

5. Sincronização via servidor com Spring Boot

No modelo local-first, o servidor não decide quem está certo. É apenas uma réplica sempre online, ajudando dispositivos a trocar estado. Adicione a dependência web (versão gerenciada por spring-boot-starter-parent):

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

O controller recebe o estado do cliente, faz merge com a cópia do servidor e retorna o estado mesclado:

import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@RestController
@RequestMapping("/lists/{listId}")
public class ShoppingListSyncController {

    private final Map<String, ORSet> store = new ConcurrentHashMap<>();

    @PostMapping("/sync")
    public synchronized ORSetState sync(@PathVariable String listId,
                                        @RequestBody ORSetState clientState) {
        ORSet serverCopy = store.computeIfAbsent(listId, id -> new ORSet());
        serverCopy.merge(ORSet.fromState(clientState));
        return serverCopy.toState();
    }
}

O cliente faz o oposto: envia o estado, recebe o estado de volta e chama merge na sua cópia local. Como merge é idempotente, o cliente pode reenviar várias vezes, então retry em caso de falha de rede é seguro. Teste com curl:

curl -s -X POST localhost:8080/lists/family/sync \
  -H "Content-Type: application/json" \
  -d '{"adds":{"Leite":["t1"],"Ovos":["t2"]},"removes":{"Ovos":["t2"]}}'
{"adds":{"Leite":["t1"],"Ovos":["t2"]},"removes":{"Ovos":["t2"]}}

O cliente calcula elements() do estado retornado e obtém [Leite].

Nota: synchronized e ConcurrentHashMap são adequados apenas para demonstração. Em produção, você deve persistir o estado em um banco de dados e enviar apenas as mudanças (delta) em vez do estado completo. Você também precisa de autenticação e autorização para cada listId.

6. Testando o comportamento de convergência

O mais importante a testar é: duas réplicas editam simultaneamente, fazem merge em qualquer ordem, mas devem ter o mesmo resultado.

import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;

class ORSetTest {

    @Test
    void concurrentAddAndRemove_convergeWithAddWins() {
        ORSet phone = new ORSet();
        phone.add("Leite");
        ORSet laptop = ORSet.fromState(phone.toState()); // sincronização inicial

        phone.remove("Leite");   // offline: telefone deleta
        laptop.add("Leite");     // offline: laptop re-adiciona
        laptop.add("Pão");

        ORSet a = ORSet.fromState(phone.toState());
        a.merge(laptop);
        ORSet b = ORSet.fromState(laptop.toState());
        b.merge(phone);

        assertThat(a.elements()).isEqualTo(b.elements())
                                .containsExactly("Pão", "Leite");
    }

    @Test
    void merge_isIdempotent() {
        ORSet list = new ORSet();
        list.add("Ovos");
        list.merge(ORSet.fromState(list.toState()));
        assertThat(list.elements()).containsExactly("Ovos");
    }
}

O primeiro teste valida comutatividade e a regra add-wins. “Leite” permanece porque o laptop o adicionou com uma tag que o telefone nunca viu. O segundo teste valida idempotência. Você deve adicionar testes baseados em propriedades, gerando aleatoriamente sequências de operações e ordens de merge para detectar falhas de convergência obscuras.

7. Erros comuns e quando não usar

Erros comuns:

  • Usar relógio do sistema para LWW sem lidar com sincronização. Resultado: dados “novos” são sobrescritos por dados “antigos”.
  • Metadata crescendo: OR-Set mantém tags de elementos deletados (tombstones) para sempre. Você precisa de um mecanismo de garbage collection, que geralmente requer saber com certeza que todas as réplicas receberam a operação de exclusão.
  • Assumir que convergência significa negócio correto: CRDT garante apenas que todas as réplicas são iguais, não que o resultado agrada o usuário. Se dois usuários editam o título, LWW ainda perde uma versão.
  • Sem migrations de schema: dados vivem por longo tempo no dispositivo, então versões antigas e novas do app precisam ler o estado umas das outras.

Quando não deve usar local-first e CRDT:

  • Restrições globais obrigatórias: saldo de conta não pode ser negativo, estoque não pode ser vendido em excesso, cada assento pode ser reservado por apenas uma pessoa. Esses invariantes precisam de coordenação centralizada (transactions, locks), CRDTs não os garantem.
  • Dados precisam de controle rigoroso: regulamentações impedem armazenar dados no dispositivo ou você precisa revogar acesso instantaneamente.
  • App CRUD simples que está sempre online: o custo de complexidade supera os benefícios. Uma REST API com optimistic locking é suficiente.
  • Dados imensamente grandes ou compartilhados por muitas pessoas: sincronizar o estado completo para cada dispositivo não é viável.

A abordagem prática geralmente é híbrida: colaboração como notas, listas, rascunhos usa local-first. Transações como pagamentos via PaymentService ainda passam pelo servidor com constraints rígidas.

8. Conclusão

Local-first coloca dados no dispositivo do usuário primeiro, permitindo respostas instantâneas e funcionamento offline. CRDT é a base técnica que permite múltiplas réplicas editar simultaneamente e convergirem automaticamente, graças a uma função merge que é comutativa, associativa e idempotente.

Neste artigo, você implementou G-Counter, LWW-Register e OR-Set em Java 21, construiu um endpoint de sincronização com Spring Boot e escreveu testes de convergência. Para um produto real, considere usar bibliotecas CRDT já estabelecidas em vez de escrever suas próprias. Aplique local-first apenas às partes de dados que realmente se beneficiam dele, e mantenha processos que exigem restrições globais no servidor.

local-firstcrdtoffline-firstdistributed-systemsspring-boot