Sincronizzazione tra un'app online e una offline con CloudEvents: l'implementazione in Java Spring Boot

Sincronizzazione tra un'app online e una offline con CloudEvents: l'implementazione in Java Spring Boot

Sesto articolo della serie sulla sincronizzazione tra un'app online e una offline (CloudEvents 1.0, pattern outbox/inbox, push/pull/ack). Qui la implementiamo con due applicazioni Spring Boot: il server online espone gli endpoint REST con Spring MVC e Spring Data JPA, il client offline è un'applicazione Spring Boot senza web server, con un task schedulato via @Scheduled al posto del loop manuale visto negli articoli precedenti.

Riepilogo dell'architettura

L'evento resta CloudEvents 1.0: specversion, id univoco (idempotenza), type, source, time, data. Tre operazioni sul server — push, pull, ack — e le stesse due tabelle viste finora: l'event store append-only lato server, outbox e registro degli eventi applicati lato client.

L'app online: entità JPA

A differenza degli articoli con SQL scritto a mano, qui lasciamo che sia Hibernate a creare lo schema dalle entità (spring.jpa.hibernate.ddl-auto=update), con SQLite come motore tramite il driver sqlite-jdbc e il dialetto SQLiteDialect del modulo hibernate-community-dialects:

package com.gabrieleromanato.onlinesync.model;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;

import java.time.Instant;

// Rappresenta l'event store append-only: una riga per ogni evento CloudEvents ricevuto
@Entity
@Table(name = "events", uniqueConstraints = @UniqueConstraint(columnNames = "eventId"))
public class SyncEvent {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long seq;

    @Column(nullable = false)
    private String eventId;

    @Column(nullable = false)
    private String specversion;

    @Column(nullable = false)
    private String type;

    @Column(nullable = false)
    private String source;

    @Column(nullable = false)
    private String time;

    private String datacontenttype;

    // JSON serializzato del campo "data" dell'evento CloudEvents
    @Column(nullable = false)
    private String data;

    @Column(nullable = false)
    private Instant receivedAt = Instant.now();

    public Long getSeq() {
        return seq;
    }

    public String getEventId() {
        return eventId;
    }

    public void setEventId(String eventId) {
        this.eventId = eventId;
    }

    public String getSpecversion() {
        return specversion;
    }

    public void setSpecversion(String specversion) {
        this.specversion = specversion;
    }

    public String getType() {
        return type;
    }

    public void setType(String type) {
        this.type = type;
    }

    public String getSource() {
        return source;
    }

    public void setSource(String source) {
        this.source = source;
    }

    public String getTime() {
        return time;
    }

    public void setTime(String time) {
        this.time = time;
    }

    public String getDatacontenttype() {
        return datacontenttype;
    }

    public void setDatacontenttype(String datacontenttype) {
        this.datacontenttype = datacontenttype;
    }

    public String getData() {
        return data;
    }

    public void setData(String data) {
        this.data = data;
    }

    public Instant getReceivedAt() {
        return receivedAt;
    }
}
package com.gabrieleromanato.onlinesync.model;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

@Entity
@Table(name = "client_cursors")
public class ClientCursor {

    @Id
    private String clientId;

    @Column(nullable = false)
    private long lastSeq;

    public ClientCursor() {
    }

    public ClientCursor(String clientId, long lastSeq) {
        this.clientId = clientId;
        this.lastSeq = lastSeq;
    }

    public String getClientId() {
        return clientId;
    }

    public long getLastSeq() {
        return lastSeq;
    }

    public void setLastSeq(long lastSeq) {
        this.lastSeq = lastSeq;
    }
}

I repository Spring Data, con una query derivata per l'idempotenza e una per la lettura incrementale:

package com.gabrieleromanato.onlinesync.repository;

import com.gabrieleromanato.onlinesync.model.SyncEvent;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface SyncEventRepository extends JpaRepository<SyncEvent, Long> {

    boolean existsByEventId(String eventId);

    List<SyncEvent> findBySeqGreaterThanOrderBySeqAsc(Long seq, Pageable pageable);
}
package com.gabrieleromanato.onlinesync.repository;

import com.gabrieleromanato.onlinesync.model.ClientCursor;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ClientCursorRepository extends JpaRepository<ClientCursor, String> {
}

L'app online: filtro di autenticazione e controller

Invece di tirare in ballo Spring Security per una sola API key, un OncePerRequestFilter registrato automaticamente come bean è sufficiente (in produzione: JWT o mTLS):

package com.gabrieleromanato.onlinesync.security;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

// Autenticazione minimale via API key statica (in produzione: JWT o mTLS)
@Component
public class ApiKeyFilter extends OncePerRequestFilter {

    @Value("${sync.api-key}")
    private String apiKey;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {

        if (!request.getRequestURI().startsWith("/api/sync/")) {
            chain.doFilter(request, response);
            return;
        }

        String providedKey = request.getHeader("X-Api-Key");
        if (!apiKey.equals(providedKey)) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.setContentType("application/json");
            response.getWriter().write("{\"error\":\"unauthorized\"}");
            return;
        }

        chain.doFilter(request, response);
    }
}

Le DTO per il binding JSON delle richieste:

package com.gabrieleromanato.onlinesync.dto;

import java.util.Map;

// Rappresentazione JSON di un evento CloudEvents 1.0
public class CloudEventDto {

    private String specversion;
    private String id;
    private String type;
    private String source;
    private String time;
    private String datacontenttype = "application/json";
    private Map<String, Object> data;

    public boolean isValid() {
        return "1.0".equals(specversion)
                && id != null && !id.isBlank()
                && type != null && !type.isBlank()
                && source != null && !source.isBlank()
                && time != null && !time.isBlank();
    }

    public String getSpecversion() { return specversion; }
    public void setSpecversion(String specversion) { this.specversion = specversion; }
    public String getId() { return id; }
    public void setId(String id) { this.id = id; }
    public String getType() { return type; }
    public void setType(String type) { this.type = type; }
    public String getSource() { return source; }
    public void setSource(String source) { this.source = source; }
    public String getTime() { return time; }
    public void setTime(String time) { this.time = time; }
    public String getDatacontenttype() { return datacontenttype; }
    public void setDatacontenttype(String datacontenttype) { this.datacontenttype = datacontenttype; }
    public Map<String, Object> getData() { return data; }
    public void setData(Map<String, Object> data) { this.data = data; }
}
package com.gabrieleromanato.onlinesync.dto;

import java.util.List;

public class PushRequestDto {

    private String clientId;
    private List<CloudEventDto> events;

    public String getClientId() { return clientId; }
    public void setClientId(String clientId) { this.clientId = clientId; }
    public List<CloudEventDto> getEvents() { return events; }
    public void setEvents(List<CloudEventDto> events) { this.events = events; }
}
package com.gabrieleromanato.onlinesync.dto;

public class AckRequestDto {

    private String clientId;
    private long cursor;

    public String getClientId() { return clientId; }
    public void setClientId(String clientId) { this.clientId = clientId; }
    public long getCursor() { return cursor; }
    public void setCursor(long cursor) { this.cursor = cursor; }
}

E il controller con i tre endpoint. Come negli articoli precedenti, gli eventi non conformi vengono segnalati singolarmente in rejected invece di far fallire l'intera richiesta:

package com.gabrieleromanato.onlinesync.controller;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.gabrieleromanato.onlinesync.dto.AckRequestDto;
import com.gabrieleromanato.onlinesync.dto.CloudEventDto;
import com.gabrieleromanato.onlinesync.dto.PushRequestDto;
import com.gabrieleromanato.onlinesync.model.ClientCursor;
import com.gabrieleromanato.onlinesync.model.SyncEvent;
import com.gabrieleromanato.onlinesync.repository.ClientCursorRepository;
import com.gabrieleromanato.onlinesync.repository.SyncEventRepository;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/api/sync")
public class SyncController {

    private final SyncEventRepository eventRepository;
    private final ClientCursorRepository cursorRepository;
    private final ObjectMapper objectMapper;

    public SyncController(SyncEventRepository eventRepository,
                           ClientCursorRepository cursorRepository,
                           ObjectMapper objectMapper) {
        this.eventRepository = eventRepository;
        this.cursorRepository = cursorRepository;
        this.objectMapper = objectMapper;
    }

    // Il client offline invia gli eventi accumulati mentre non era connesso
    @PostMapping("/push")
    public ResponseEntity<Map<String, Object>> push(@RequestBody PushRequestDto request) throws JsonProcessingException {
        if (request.getClientId() == null || request.getEvents() == null) {
            Map<String, Object> error = new LinkedHashMap<>();
            error.put("error", "clientId ed events[] sono obbligatori");
            return ResponseEntity.badRequest().body(error);
        }

        List<Map<String, Object>> accepted = new ArrayList<>();
        List<Map<String, Object>> rejected = new ArrayList<>();

        for (CloudEventDto event : request.getEvents()) {
            if (!event.isValid()) {
                Map<String, Object> rejection = new LinkedHashMap<>();
                rejection.put("id", event.getId());
                rejection.put("reason", "evento non conforme a CloudEvents 1.0");
                rejected.add(rejection);
                continue;
            }

            // Se eventId esiste già è un duplicato (retry di rete): non va reinserito
            boolean exists = eventRepository.existsByEventId(event.getId());

            if (!exists) {
                SyncEvent entity = new SyncEvent();
                entity.setEventId(event.getId());
                entity.setSpecversion(event.getSpecversion());
                entity.setType(event.getType());
                entity.setSource(event.getSource());
                entity.setTime(event.getTime());
                entity.setDatacontenttype(event.getDatacontenttype());
                entity.setData(objectMapper.writeValueAsString(event.getData()));
                eventRepository.save(entity);
            }

            Map<String, Object> acceptance = new LinkedHashMap<>();
            acceptance.put("id", event.getId());
            acceptance.put("inserted", !exists);
            accepted.add(acceptance);
        }

        Map<String, Object> body = new LinkedHashMap<>();
        body.put("accepted", accepted);
        body.put("rejected", rejected);
        body.put("serverTime", Instant.now().toString());
        return ResponseEntity.ok(body);
    }

    // Il client offline scarica gli eventi generati altrove dopo l'ultima sync
    @GetMapping("/pull")
    public ResponseEntity<Map<String, Object>> pull(@RequestParam String clientId) {
        long since = cursorRepository.findById(clientId).map(ClientCursor::getLastSeq).orElse(0L);

        List<SyncEvent> events = eventRepository.findBySeqGreaterThanOrderBySeqAsc(since, PageRequest.of(0, 200));

        long cursor = since;
        List<Map<String, Object>> out = new ArrayList<>();

        for (SyncEvent ev : events) {
            cursor = ev.getSeq();

            Map<String, Object> dto = new LinkedHashMap<>();
            dto.put("specversion", ev.getSpecversion());
            dto.put("id", ev.getEventId());
            dto.put("type", ev.getType());
            dto.put("source", ev.getSource());
            dto.put("time", ev.getTime());
            dto.put("datacontenttype", ev.getDatacontenttype());
            try {
                dto.put("data", objectMapper.readValue(ev.getData(), Map.class));
            } catch (JsonProcessingException e) {
                throw new IllegalStateException("evento con data JSON non valido: " + ev.getEventId(), e);
            }
            out.add(dto);
        }

        Map<String, Object> body = new LinkedHashMap<>();
        body.put("events", out);
        body.put("cursor", cursor);
        return ResponseEntity.ok(body);
    }

    // Il client conferma fino a dove ha applicato gli eventi ricevuti
    @PostMapping("/ack")
    public ResponseEntity<Map<String, Object>> ack(@RequestBody AckRequestDto request) {
        if (request.getClientId() == null) {
            Map<String, Object> error = new LinkedHashMap<>();
            error.put("error", "clientId e cursor sono obbligatori");
            return ResponseEntity.badRequest().body(error);
        }

        ClientCursor cursor = cursorRepository.findById(request.getClientId())
                .orElse(new ClientCursor(request.getClientId(), 0));
        cursor.setLastSeq(request.getCursor());
        cursorRepository.save(cursor);

        Map<String, Object> body = new LinkedHashMap<>();
        body.put("ok", true);
        return ResponseEntity.ok(body);
    }
}
spring.application.name=online-spring
server.port=3000

spring.datasource.url=jdbc:sqlite:events.db
spring.datasource.driver-class-name=org.sqlite.JDBC

spring.jpa.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.dialect=org.hibernate.community.dialect.SQLiteDialect
spring.jpa.open-in-view=false

sync.api-key=${SYNC_API_KEY:dev-secret}

L'app offline: entità, repository e servizio schedulato

Il client è un'applicazione Spring Boot senza web server (spring.main.web-application-type=none): niente Tomcat, solo persistenza JPA e un bean schedulato.

package com.gabrieleromanato.offlinesync.model;

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

@Entity
@Table(name = "outbox")
public class OutboxEvent {

    @Id
    private String eventId;

    private String type;
    private String source;
    private String time;

    // JSON serializzato del campo "data" dell'evento CloudEvents
    private String data;

    private boolean sent = false;

    public OutboxEvent() {
    }

    public OutboxEvent(String eventId, String type, String source, String time, String data) {
        this.eventId = eventId;
        this.type = type;
        this.source = source;
        this.time = time;
        this.data = data;
    }

    public String getEventId() { return eventId; }
    public String getType() { return type; }
    public String getSource() { return source; }
    public String getTime() { return time; }
    public String getData() { return data; }
    public boolean isSent() { return sent; }
    public void setSent(boolean sent) { this.sent = sent; }
}
package com.gabrieleromanato.offlinesync.model;

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

import java.time.Instant;

@Entity
@Table(name = "applied_events")
public class AppliedEvent {

    @Id
    private String eventId;

    private String type;

    // JSON serializzato del campo "data" dell'evento CloudEvents
    private String data;

    private Instant appliedAt = Instant.now();

    public AppliedEvent() {
    }

    public AppliedEvent(String eventId, String type, String data) {
        this.eventId = eventId;
        this.type = type;
        this.data = data;
    }

    public String getEventId() { return eventId; }
    public String getType() { return type; }
    public String getData() { return data; }
    public Instant getAppliedAt() { return appliedAt; }
}
package com.gabrieleromanato.offlinesync.repository;

import com.gabrieleromanato.offlinesync.model.OutboxEvent;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface OutboxEventRepository extends JpaRepository<OutboxEvent, String> {

    List<OutboxEvent> findBySentFalse(Pageable pageable);
}
package com.gabrieleromanato.offlinesync.repository;

import com.gabrieleromanato.offlinesync.model.AppliedEvent;
import org.springframework.data.jpa.repository.JpaRepository;

public interface AppliedEventRepository extends JpaRepository<AppliedEvent, String> {
}

Il cuore del client è questo servizio: @Scheduled(fixedDelay = 30_000) sostituisce il loop manuale con sleep() visto negli articoli precedenti, e le eccezioni di rete vengono loggate senza propagarsi, esattamente come i blocchi try/catch degli altri linguaggi:

package com.gabrieleromanato.offlinesync.service;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.gabrieleromanato.offlinesync.model.AppliedEvent;
import com.gabrieleromanato.offlinesync.model.OutboxEvent;
import com.gabrieleromanato.offlinesync.repository.AppliedEventRepository;
import com.gabrieleromanato.offlinesync.repository.OutboxEventRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestTemplate;

import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

@Service
public class SyncService {

    private static final Logger log = LoggerFactory.getLogger(SyncService.class);

    private final OutboxEventRepository outboxRepository;
    private final AppliedEventRepository appliedRepository;
    private final RestTemplate restTemplate;
    private final ObjectMapper objectMapper;

    @Value("${sync.server-url}")
    private String baseUrl;

    @Value("${sync.api-key}")
    private String apiKey;

    @Value("${sync.client-id}")
    private String clientId;

    public SyncService(OutboxEventRepository outboxRepository,
                        AppliedEventRepository appliedRepository,
                        RestTemplate restTemplate,
                        ObjectMapper objectMapper) {
        this.outboxRepository = outboxRepository;
        this.appliedRepository = appliedRepository;
        this.restTemplate = restTemplate;
        this.objectMapper = objectMapper;
    }

    // Ogni 30 secondi prova a sincronizzarsi; se la rete manca, l'eccezione
    // viene loggata e si riprova al giro successivo (comportamento offline-first).
    @Scheduled(fixedDelay = 30_000)
    public void trySync() {
        try {
            HttpHeaders headers = new HttpHeaders();
            headers.set("X-Api-Key", apiKey);
            headers.set("Content-Type", "application/json");

            // 1) PUSH: invia gli eventi locali accumulati mentre si era offline
            List<OutboxEvent> pending = outboxRepository.findBySentFalse(PageRequest.of(0, 200));

            if (!pending.isEmpty()) {
                List<Map<String, Object>> events = pending.stream().map(this::toCloudEvent).toList();

                Map<String, Object> body = new LinkedHashMap<>();
                body.put("clientId", clientId);
                body.put("events", events);

                restTemplate.exchange(baseUrl + "/api/sync/push", HttpMethod.POST,
                        new HttpEntity<>(body, headers), Map.class);

                pending.forEach(ev -> ev.setSent(true));
                outboxRepository.saveAll(pending);
                log.info("Inviati {} eventi al server", pending.size());
            }

            // 2) PULL: scarica gli eventi generati altrove dopo l'ultima sync
            String pullUrl = baseUrl + "/api/sync/pull?clientId=" + clientId;
            HttpEntity<Void> pullRequest = new HttpEntity<>(headers);
            Map<?, ?> payload = restTemplate.exchange(pullUrl, HttpMethod.GET, pullRequest, Map.class).getBody();

            @SuppressWarnings("unchecked")
            List<Map<String, Object>> incoming = (List<Map<String, Object>>) payload.get("events");

            for (Map<String, Object> event : incoming) {
                String eventId = (String) event.get("id");

                // findById + save è idempotente: un evento con lo stesso id sovrascrive se stesso
                if (appliedRepository.findById(eventId).isEmpty()) {
                    String type = (String) event.get("type");
                    String data = objectMapper.writeValueAsString(event.get("data"));
                    appliedRepository.save(new AppliedEvent(eventId, type, data));

                    // Qui va la logica reale: deserializzare "data" in base a "type" e
                    // aggiornare l'entità corrispondente nel modello di dominio offline.
                }
            }

            // 3) ACK: conferma al server fino a dove si è applicato
            if (!incoming.isEmpty()) {
                Map<String, Object> ackBody = new LinkedHashMap<>();
                ackBody.put("clientId", clientId);
                ackBody.put("cursor", payload.get("cursor"));

                restTemplate.exchange(baseUrl + "/api/sync/ack", HttpMethod.POST,
                        new HttpEntity<>(ackBody, headers), Map.class);
                log.info("Applicati {} eventi ricevuti dal server", incoming.size());
            }
        } catch (ResourceAccessException e) {
            // Rete assente o server irraggiungibile: comportamento atteso per
            // un client offline-first. Si riprova al ciclo successivo.
            log.warn("Sync non riuscita (rete assente?): {}", e.getMessage());
        } catch (Exception e) {
            log.error("Errore inatteso durante la sincronizzazione", e);
        }
    }

    private Map<String, Object> toCloudEvent(OutboxEvent ev) {
        try {
            Map<String, Object> map = new LinkedHashMap<>();
            map.put("specversion", "1.0");
            map.put("id", ev.getEventId());
            map.put("type", ev.getType());
            map.put("source", ev.getSource());
            map.put("time", ev.getTime());
            map.put("datacontenttype", "application/json");
            map.put("data", objectMapper.readValue(ev.getData(), Map.class));
            return map;
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

E il punto d'ingresso, con @EnableScheduling e un CommandLineRunner che accoda l'evento di esempio all'avvio, come negli altri articoli:

package com.gabrieleromanato.offlinesync;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.gabrieleromanato.offlinesync.model.OutboxEvent;
import com.gabrieleromanato.offlinesync.repository.OutboxEventRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.web.client.RestTemplate;

import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;

@SpringBootApplication
@EnableScheduling
public class SyncClientApplication {

    public static void main(String[] args) {
        SpringApplication.run(SyncClientApplication.class, args);
    }

    @Bean
    public RestTemplate restTemplate() {
        SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
        factory.setConnectTimeout(10_000);
        factory.setReadTimeout(10_000);
        return new RestTemplate(factory);
    }

    // Esempio: simula un cambiamento avvenuto nel dominio offline all'avvio.
    // Nella tua app reale questa chiamata va fatta subito dopo ogni
    // scrittura locale rilevante, non solo allo startup.
    @Bean
    public CommandLineRunner seedExampleEvent(OutboxEventRepository outboxRepository, ObjectMapper objectMapper) {
        return args -> {
            if (outboxRepository.count() == 0) {
                Map<String, Object> data = new LinkedHashMap<>();
                data.put("recordId", 42);
                data.put("note", "Creato mentre offline");

                outboxRepository.save(new OutboxEvent(
                        UUID.randomUUID().toString(),
                        "com.gabrieleromanato.offlineapp.record.created",
                        "urn:client:offline-client-01",
                        Instant.now().toString(),
                        objectMapper.writeValueAsString(data)
                ));
            }
        };
    }
}
spring.application.name=offline-spring
spring.main.web-application-type=none

spring.datasource.url=jdbc:sqlite:offline.db
spring.datasource.driver-class-name=org.sqlite.JDBC

spring.jpa.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.dialect=org.hibernate.community.dialect.SQLiteDialect
spring.jpa.open-in-view=false

sync.server-url=${SYNC_SERVER_URL:http://localhost:3000}
sync.api-key=${SYNC_API_KEY:dev-secret}
sync.client-id=${SYNC_CLIENT_ID:offline-client-01}

Provarlo

cd online-spring && mvn spring-boot:run
# in un altro terminale
cd offline-spring && mvn spring-boot:run

Le dipendenze necessarie sono spring-boot-starter-web e spring-boot-starter-data-jpa lato server (più spring-web lato client per RestTemplate), hibernate-community-dialects per il dialetto SQLite, e org.xerial:sqlite-jdbc come driver JDBC. Il codice di questo articolo è stato scritto e rivisto con cura ma non compilato in questo ambiente, perché qui l'accesso a Maven Central non è disponibile: prova la build nel tuo ambiente di sviluppo con i comandi sopra.

Cosa manca per la produzione

Le stesse lacune viste negli articoli precedenti restano valide: autenticazione più robusta di una API key statica (Spring Security con JWT, o mTLS), retry con backoff esponenziale invece dell'intervallo fisso di @Scheduled, un filtro lato client sugli eventi con source uguale al proprio, validazione dello schema di data per ogni type, e una strategia esplicita di conflict resolution per le modifiche concorrenti. In un progetto reale vale anche la pena spostare l'URL del server, l'API key e l'intervallo di sync fuori da application.properties e dentro un secret manager, e sostituire ddl-auto=update con migrazioni versionate (Flyway o Liquibase) prima di andare in produzione.

Nell'ultimo articolo della serie chiudiamo con C#: server ASP.NET Core Minimal API e worker service .NET per il client, riprendendo e completando l'esempio con cui questa serie di conversazioni era partita.