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.