Skip to content

Repository Pattern

The repository pattern is how you connect your local storage (database) to Locorda’s sync engine. This guide shows you how to implement repositories for your app.

A repository is a class that:

  • Manages your local database (Drift, Hive, Isar, or even a simple Map)
  • Connects to the sync engine via callbacks
  • Provides a clean API to your UI (save, delete, query)

Here’s a complete repository implementation:

import 'dart:async';
import 'package:locorda/locorda.dart';
/// Repository integrating local storage with Locorda sync.
///
/// This example uses a simple Map as a mock database. In a real app,
/// you'd use your preferred storage solution (Drift, Hive, Isar, etc.)
/// and connect it to sync via the same callback pattern.
class TaskRepository {
final ObjectSyncEngine _syncEngine;
final Map<String, Task> _tasks = {}; // Mock DB - use Drift/Hive/etc. in real apps
final StreamController<List<Task>> _controller = StreamController.broadcast();
StreamSubscription? _hydrationSubscription;
TaskRepository._(this._syncEngine);
/// Create and initialize repository with sync.
static Future<TaskRepository> create(ObjectSyncEngine syncEngine) async {
final repo = TaskRepository._(syncEngine);
// Connect your local storage to sync via callbacks.
//
// IMPORTANT: These callbacks are your "single source of truth" for data updates.
// ALL changes (local saves, remote sync, conflict resolution) flow through these
// callbacks. This ensures your UI always shows the merged, conflict-free state.
repo._hydrationSubscription = await syncEngine.hydrateWithCallbacks<Task>(
getCurrentCursor: () async => null, // Simple: no cursor persistence
onUpdate: (task) async {
repo._tasks[task.id] = task; // In real app: await db.upsert(task)
repo._notifyListeners();
},
onDelete: (id) async {
repo._tasks.remove(id); // In real app: await db.delete(id)
repo._notifyListeners();
},
onCursorUpdate: (cursor) async {}, // Skipped for simplicity
);
return repo;
}
/// Watch all tasks reactively
Stream<List<Task>> watchAll() => _controller.stream;
/// Get all tasks (snapshot)
List<Task> getAll() => _tasks.values.toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
/// Save task (create or update) - queued for sync to other devices.
///
/// IMPORTANT: Do NOT update _tasks directly here!
/// The sync engine will call our onUpdate callback, which updates _tasks.
Future<void> save(Task task) async {
await _syncEngine.save<Task>(task);
}
/// Delete task - queued for sync to other devices.
Future<void> delete(String id) async {
await _syncEngine.deleteDocument<Task>(id);
}
void _notifyListeners() {
_controller.add(getAll());
}
void dispose() {
_hydrationSubscription?.cancel();
_controller.close();
}
}

The key to understanding the repository is the callback pattern:

This method connects your local storage to sync:

await syncEngine.hydrateWithCallbacks<Task>(
getCurrentCursor: () async => null,
onUpdate: (task) async { /* save to your DB */ },
onDelete: (id) async { /* delete from your DB */ },
onCursorUpdate: (cursor) async { /* save sync cursor */ },
);

Parameters:

  • getCurrentCursor - Returns the last sync position (for incremental sync)
  • onUpdate - Called when a task is created or updated (from any source)
  • onDelete - Called when a task is deleted (from any source)
  • onCursorUpdate - Called to persist sync progress

The callback pattern works with any storage solution:

With Drift:

onUpdate: (task) async {
await database.into(database.tasks).insertOnConflictUpdate(
TasksCompanion.insert(
id: task.id,
title: task.title,
completed: task.completed,
createdAt: task.createdAt,
),
);
},
onDelete: (id) async {
await (database.delete(database.tasks)..where((t) => t.id.equals(id))).go();
}

With Hive:

onUpdate: (task) async {
final box = await Hive.openBox<Task>('tasks');
await box.put(task.id, task);
_notifyListeners();
},
onDelete: (id) async {
final box = await Hive.openBox<Task>('tasks');
await box.delete(id);
_notifyListeners();
}

With Isar:

onUpdate: (task) async {
await isar.writeTxn(() async {
await isar.tasks.put(task);
});
_notifyListeners();
},
onDelete: (id) async {
await isar.writeTxn(() async {
await isar.tasks.delete(id);
});
_notifyListeners();
}

For efficient incremental sync, persist the cursor:

getCurrentCursor: () async {
// Load from your preferences/database
final prefs = await SharedPreferences.getInstance();
return prefs.getString('sync_cursor_tasks');
},
onCursorUpdate: (cursor) async {
// Save to your preferences/database
final prefs = await SharedPreferences.getInstance();
await prefs.setString('sync_cursor_tasks', cursor);
}

The cursor tracks “what you’ve already synced” so future syncs only fetch new changes.

Locorda is state management agnostic - use whatever fits your app. The sync engine doesn’t care how you manage UI state; it just provides callbacks for data changes.

If you use Drift, you get reactive queries for free. No StreamController, no manual notifyListeners() - Drift’s watch() automatically emits when data changes:

class NoteRepository {
final Database db;
final ObjectSyncEngine _syncEngine;
NoteRepository(this.db, this._syncEngine) {
// Connect sync callbacks to Drift operations
_syncEngine.hydrateWithCallbacks<Note>(
onUpdate: (note) async {
await db.into(db.notes).insertOnConflictUpdate(note);
// No need to call notifyListeners() - Drift handles it!
},
onDelete: (id) async {
await (db.delete(db.notes)..where((n) => n.id.equals(id))).go();
},
// ... other callbacks
);
}
// Reactive query - automatically updates when data changes
Stream<List<NoteIndexEntry>> watchAllNotes() {
return (db.select(db.notes)
..orderBy([
(n) => OrderingTerm(
expression: n.dateModified,
mode: OrderingMode.desc,
)
])).watch();
}
// Filtered queries work the same way
Stream<List<NoteIndexEntry>> watchByCategory(String category) {
return (db.select(db.noteIndexEntries)
..where((n) => n.category.equals(category))
..orderBy([(n) => OrderingTerm(expression: n.dateModified)])
).watch();
}
}

Why Drift is great with Locorda:

  • Reactive queries out of the box
  • Type-safe SQL queries
  • Efficient indexing and filtering
  • No manual stream management

If you’re not using Drift, you can use:

StreamController (simple but manual):

final _controller = StreamController<List<Task>>.broadcast();
Stream<List<Task>> watchAll() => _controller.stream;
void _notifyListeners() {
_controller.add(getAll()); // Call this in onUpdate/onDelete callbacks
}

State management libraries: Provider, Riverpod, BLoC, etc. all work - you know your framework better than we do. Just connect the sync callbacks to your state updates however your framework expects it.

  • Use callbacks for ALL database updates (local and remote)
  • Let the sync engine handle conflict resolution
  • Persist cursors for efficient incremental sync
  • Use streams for reactive UI updates
  • Dispose subscriptions properly
  • Don’t bypass callbacks and update your database directly in save()
  • Don’t ignore onUpdate - it’s essential for showing merged data
  • Don’t mix sync-managed data with non-sync data in the same table (use separate repositories)

Create one repository per resource type. Each repository manages its own sync callbacks and storage:

class NoteRepository {
// Handles Note objects via syncEngine.hydrateWithCallbacks<Note>(...)
}
class CategoryRepository {
// Handles Category objects via syncEngine.hydrateWithCallbacks<Category>(...)
}

This keeps sync state isolated - each type has its own cursor, callbacks, and storage table.

The repository pattern in Locorda:

  1. Callbacks = Single Source of Truth - All changes flow through onUpdate/onDelete
  2. You control storage - Use any database you want
  3. Sync is automatic - Local changes are queued, remote changes are merged
  4. Framework handles conflicts - CRDT algorithms ensure consistency
  5. Works with any state management - Streams, Provider, Riverpod, BLoC, etc.