·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