Event Sourcing dans une app mobile : retour d'expérience
J'ai intégré Event Sourcing et CQRS dans Coachy, mon app de musculation. Avantages concrets, pièges à éviter et exemples d'implémentation Flutter+NestJS.
Event Sourcing sur mobile résout 3 problèmes d'un coup : historique complet (chaque modification est un événement), sync offline naturelle (les événements sont des messages), et analytics gratuites (replay des événements). Le piège : la complexité de la projection côté client.
Coachy track les séances de musculation. Avec un CRUD classique, modifier une séance efface l'ancien état. Synchroniser des modifications concurrentes devient un cauchemar. Analyser l'évolution d'un exercice sur 6 mois ? Bonjour les requêtes SQL complexes.
L'Event Sourcing résout ces trois problèmes d'un coup.
J'ai donc fait le choix d'Event Sourcing (ES) couplé à CQRS. Pas par effet de mode, mais parce que traquer des entraînements, c'est fondamentalement une succession d'événements : "séance démarrée", "série ajoutée", "poids augmenté", "repos terminé".
Pourquoi Event Sourcing pour une app mobile
Event Sourcing, c'est simple : au lieu de stocker l'état actuel, je stocke tous les événements qui ont mené à cet état. Chaque action de l'utilisateur devient un événement immuable.
Dans Coachy, une séance n'est plus une ligne en base avec "exercices: JSON". C'est une séquence d'événements :
// Au lieu de ça (CRUD){
id: "session_123",
exercises: [
{ name: "Squat", sets: [100, 100, 105] }
],
updatedAt: "2025-11-20T10:30:00Z"
}
// Je stocke ça (Event Sourcing)
[
{ type: "SessionStarted", sessionId: "123", timestamp: "10:00:00Z" },
{ type: "ExerciseAdded", sessionId: "123", exercise: "Squat", timestamp: "10:02:00Z" },
{ type: "SetCompleted", sessionId: "123", exercise: "Squat", weight: 100, reps: 10 },
{ type: "SetCompleted", sessionId: "123", exercise: "Squat", weight: 100, reps: 8 },
{ type: "WeightIncreased", sessionId: "123", exercise: "Squat", newWeight: 105 },
{ type: "SetCompleted", sessionId: "123", exercise: "Squat", weight: 105, reps: 6 }
]
Cette approche m'apporte trois avantages majeurs :
Historique complet : Je peux répondre à "combien de temps entre mes séries de squat le 15 octobre ?" ou "quand ai-je augmenté mes charges sur le développé couché ?". Les données sont là, dans les événements.
Replay naturel : Pour débugger ou analyser, je peux reconstituer n'importe quel état passé. Si un utilisateur signale un bug sur une séance spécifique, je replay ses événements et je vois exactement ce qui s'est passé.
Offline-first : Ma source de vérité locale accumule les événements. Quand je synchronise, j'envoie juste les nouveaux événements au serveur. Pas de merge complexe, pas de conflit sur les objets complets.
Event Sourcing brille particulièrement sur mobile : la nature déconnectée des apps mobiles s'aligne parfaitement avec l'accumulation locale d'événements et leur sync différée.
Architecture Flutter + NestJS
Côté Flutter, j'utilise une architecture en couches inspirée de Clean Architecture :
// Event Store local (SQLite)class EventStore {
Future<void> append(String streamId, List<Event> events) async {
// Stockage des événements avec offset séquentiel
for (var event in events) {
await db.insert('events', {
'stream_id': streamId,
'event_type': event.type,
'data': jsonEncode(event.data),
'timestamp': event.timestamp.toIso8601String(),
'version': await getNextVersion(streamId)
});
}
}
Stream<Event> getEvents(String streamId) {
return db.select('events')
.where('stream_id = ?', [streamId])
.orderBy('version')
.map((row) => Event.fromJson(row));
}
}
// Projection pour les vues (CQRS)
class SessionProjection {
void handle(Event event) {
switch (event.type) {
case 'SessionStarted':
sessions[event.sessionId] = Session(
id: event.sessionId,
startTime: event.timestamp,
exercises: []
);
break;
case 'SetCompleted':
sessions[event.sessionId]?.addSet(
event.exercise,
event.weight,
event.reps
);
break;
}
}
}
Côté NestJS, j'ai un Event Store centralisé avec PostgreSQL et EventStore DB pour des performances optimales :
@Injectable()export class EventSourcingService {
async appendEvents(streamId: string, events: Event[]): Promise<void> {
const stream = await this.eventStore.readStream(streamId);
const expectedVersion = stream.length;
await this.eventStore.appendToStream(streamId, events, {
expectedVersion
});
// Projections mises à jour de manière asynchrone
await this.projectionService.updateProjections(streamId, events);
}
}
Les pièges concrets rencontrés
Taille du store : Avec une utilisation intensive, on peut facilement atteindre 50k+ événements en quelques mois. Sur mobile, ça pèse. D'où l'implémentation de snapshots : tous les 1000 événements, je sauvegarde l'état agrégé pour éviter de rejouer depuis le début.
class SnapshotStore { Future<Snapshot?> getLatestSnapshot(String streamId) async {
return db.select('snapshots')
.where('stream_id = ?', [streamId])
.orderBy('version DESC')
.limit(1)
.firstOrNull();
}
Future<void> saveSnapshot(String streamId, int version, Map<String, dynamic> state) async {
await db.insert('snapshots', {
'stream_id': streamId,
'version': version,
'state': jsonEncode(state),
'timestamp': DateTime.now().toIso8601String()
});
}
}
Synchronisation : Gérer les événements concurrents entre mobile et serveur m'a pris plus de temps que prévu. J'ai opté pour une stratégie "last writer wins" sur les événements non-conflictuels et une résolution manuelle pour les vrais conflits (rare en pratique sur une app personnelle).
Migrations de schéma : Faire évoluer le format des événements sans casser l'historique existant demande de la discipline. J'utilise un système de versioning des événements :
interface Event { type: string;
version: number;
data: unknown;
timestamp: Date;
}
// Gestion des anciennes versions
class EventMigrator {
migrate(event: Event): Event {
if (event.type === 'SetCompleted' && event.version < 2) {
// Migration v1 -> v2 : ajout du field "restTime"
return {
...event,
version: 2,
data: {
...event.data,
restTime: null // Valeur par défaut
}
};
}
return event;
}
}
La migration d'événements est critique : une fois en production, tu ne peux plus changer le passé. Prévois tes évolutions de schéma dès le début.
Performance et optimisations
Event Sourcing peut devenir lent si mal implémenté. Quelques optimisations qui m'ont sauvé :
Indexation intelligente : Index sur stream_id + version pour un replay rapide, et sur timestamp pour les requêtes analytiques.
Projections matérialisées : Les vues complexes (statistiques, graphiques) sont précalculées dans des tables dédiées, mises à jour en arrière-plan.
Compression d'événements : Pour les séquences répétitives (10 séries identiques), j'ai un événement BulkSetsCompleted au lieu de 10 SetCompleted.
Les résultats en dev sur Coachy : temps de démarrage <200ms, sync complète <2s pour 30 jours d'historique, et zero perte de données dans mes tests.
Retour d'expérience en développement
Event Sourcing dans Coachy, c'est un pari qui tient ses promesses. L'offline-first fonctionne naturellement, aucune séance n'est perdue même en cas de crash, et je peux implémenter des fonctionnalités analytiques poussées sans requêtes SQL monstrueuses.
Le prix à payer ? Complexité cognitive plus élevée (penser en événements prend du temps), et debugging plus subtil (il faut comprendre la séquence d'événements, pas juste l'état final).
Mais pour une app mobile où la donnée utilisateur est critique et l'offline fréquent, Event Sourcing + CQRS reste mon choix d'architecture. Surtout quand tu développes seul et que tu as besoin d'une base solide pour évoluer.
À lire aussi
- Architecture offline-first : patterns et pièges
- NestJS pour dev solo : pourquoi j'ai choisi ce stack
- Construire des produits à côté de son travail
Sources :