·6 min read

Event Sourcing in a Mobile App: Lessons Learned

I integrated Event Sourcing and CQRS into Coachy, my strength-training app. Concrete benefits, pitfalls to avoid, and Flutter+NestJS implementation examples.

TL;DR

Event Sourcing on mobile solves 3 problems at once: complete history (every change is an event), natural offline sync (events are messages), and free analytics (event replay). The catch: the complexity of client-side projections.

Coachy tracks strength-training sessions. With a classic CRUD approach, editing a session erases the previous state. Synchronizing concurrent modifications becomes a nightmare. Analyzing the progression of an exercise over 6 months? Good luck with complex SQL queries.

Event Sourcing solves all three of these problems at once.

So I chose Event Sourcing (ES) coupled with CQRS. Not to follow a trend, but because tracking workouts is fundamentally a succession of events: "session started," "set added," "weight increased," "rest completed."

Why Event Sourcing for a mobile app

Event Sourcing is simple: instead of storing the current state, I store all the events that led to that state. Every user action becomes an immutable event.

In Coachy, a session is no longer a database row with "exercises: JSON." It's a sequence of events:

// Instead of this (CRUD)

{

id: "session_123",

exercises: [

{ name: "Squat", sets: [100, 100, 105] }

],

updatedAt: "2025-11-20T10:30:00Z"

}

// I store this (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 }

]

This approach gives me three major advantages:

Complete history: I can answer "how long between my squat sets on October 15?" or "when did I increase my bench press weight?" The data is right there, in the events.

Natural replay: For debugging or analysis, I can reconstruct any past state. If a user reports a bug on a specific session, I replay their events and see exactly what happened.

Offline-first: My local source of truth accumulates events. When I sync, I just send the new events to the server. No complex merging, no conflicts on entire objects.

Event Sourcing shines particularly on mobile: the disconnected nature of mobile apps aligns perfectly with local event accumulation and deferred sync.

Flutter + NestJS architecture

On the Flutter side, I use a layered architecture inspired by Clean Architecture:

// Local Event Store (SQLite)

class EventStore {

Future<void> append(String streamId, List<Event> events) async {

// Store events with sequential offset

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 for views (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;

}

}

}

On the NestJS side, I have a centralized Event Store with PostgreSQL and EventStore DB for optimal performance:

@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 updated asynchronously

await this.projectionService.updateProjections(streamId, events);

}

}

Concrete pitfalls encountered

Store size: With intensive use, you can easily hit 50k+ events in a few months. On mobile, that adds up. Hence the implementation of snapshots: every 1,000 events, I save the aggregated state to avoid replaying from the beginning.

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()

});

}

}

Synchronization: Handling concurrent events between mobile and server took longer than expected. I opted for a "last writer wins" strategy on non-conflicting events and manual resolution for real conflicts (rare in practice on a personal app).

Schema migrations: Evolving event formats without breaking the existing history requires discipline. I use an event versioning system:

interface Event {

type: string;

version: number;

data: unknown;

timestamp: Date;

}

// Handling old versions

class EventMigrator {

migrate(event: Event): Event {

if (event.type === 'SetCompleted' && event.version < 2) {

// Migration v1 -> v2: added "restTime" field

return {

...event,

version: 2,

data: {

...event.data,

restTime: null // Default value

}

};

}

return event;

}

}

Event migration is critical: once in production, you can't change the past. Plan your schema evolutions from the start.

Performance and optimizations

Event Sourcing can become slow if poorly implemented. A few optimizations that saved me:

Smart indexing: Index on stream_id + version for fast replay, and on timestamp for analytical queries.

Materialized projections: Complex views (statistics, charts) are precomputed in dedicated tables, updated in the background.

Event compression: For repetitive sequences (10 identical sets), I have a BulkSetsCompleted event instead of 10 SetCompleted events.

Results in dev on Coachy: startup time <200ms, full sync <2s for 30 days of history, and zero data loss in my tests.

Development retrospective

Event Sourcing in Coachy is a bet that's paying off. Offline-first works naturally, no session is ever lost even in a crash, and I can implement advanced analytics features without monstrous SQL queries.

The trade-off? Higher cognitive complexity (thinking in events takes time) and more subtle debugging (you need to understand the sequence of events, not just the final state).

But for a mobile app where user data is critical and offline is frequent, Event Sourcing + CQRS remains my architecture of choice. Especially when you're developing solo and need a solid foundation to build on.

Event Sourcing is a game-changer for domains rich in temporal events (fitness, finance, gaming). The learning curve is steep, but the long-term benefits are worth the investment.

Also worth reading


Sources:

event sourcingCQRSFluttermobilearchitectureNestJS