Overview

This guide covers the practical, day-to-day workflow for building features in the FieldForce Flutter app. It uses the existing Tasks feature (apps/fieldforce/lib/features/tasks/) as a concrete worked example throughout.

How a feature is organized

Every feature in the app follows the same three-folder structure. Here is the real Tasks feature:
features/tasks/
  domain/
    task.dart               ← The Task entity (what a task IS)
    task_status.dart        ← TaskStatus enum
    task_repository.dart    ← Interface: what operations exist on tasks
  data/
    powersync_task_repository.dart  ← How those operations actually work
    task_mapper.dart                ← Convert SQLite row → Task entity
    task_crud_uploader.dart         ← How writes upload to the server
  presentation/
    tasks_screen.dart       ← The UI widget
    tasks_controller.dart   ← State management (Riverpod)
  tasks_providers.dart      ← Wires the repository into Riverpod
Three layers, clear responsibilities. Each layer only knows about the one directly below it:
presentation  →  domain  ←  data
Presentation calls the domain interfaces. Data implements them. They never import each other.

Adding a new feature: step by step

Here is the process for adding a new feature, using “add a notes section to tasks” as an example.

Step 1 — Domain: define the data and operations

Start with the entity. Use @freezed for immutability and copy-with support:
// lib/features/notes/domain/note.dart
import 'package:freezed_annotation/freezed_annotation.dart';
part 'note.freezed.dart';

@freezed
abstract class Note with _$Note {
  const factory Note({
    required String id,
    required String taskId,
    required String body,
    required DateTime createdAt,
  }) = _Note;
}
Then define the repository interface — the list of operations the feature supports:
// lib/features/notes/domain/note_repository.dart
import 'package:core_models/core_models.dart';
import 'note.dart';

abstract interface class NoteRepository {
  // Reactive read: returns a stream, not a Result — there's no real failure mode for local reads
  Stream<List<Note>> watchNotesForTask(String taskId);

  // Write: returns Result<void> because the server might reject it
  Future<Result<void>> addNote({required String taskId, required String body});
}
Rule: Reads that come from the local SQLite database return a Stream. Writes that go to the server return a Future<Result<void>>. This distinction is described in Core Functions — Error Handling.

Step 2 — Run code generation

After defining the entity with @freezed, generate the .freezed.dart file:
melos run generate
You will see a note.freezed.dart file appear next to note.dart. Do not edit it — it is auto-generated. Commit it to Git along with note.dart.

Step 3 — Data: implement the repository

Create the concrete implementation that reads from SQLite and writes via PowerSync:
// lib/features/notes/data/powersync_note_repository.dart
import 'package:core_database/core_database.dart';
import 'package:core_models/core_models.dart';

import '../domain/note.dart';
import '../domain/note_repository.dart';

class PowerSyncNoteRepository implements NoteRepository {
  final PowerSyncDatabase _db;
  PowerSyncNoteRepository(this._db);

  @override
  Stream<List<Note>> watchNotesForTask(String taskId) =>
      _db.watch('SELECT * FROM notes WHERE task_id = ? ORDER BY created_at', [taskId])
         .map((rs) => rs.map(_noteFromRow).toList());

  @override
  Future<Result<void>> addNote({required String taskId, required String body}) async {
    try {
      await _db.execute(
        'INSERT INTO notes(id, task_id, body, created_at) VALUES(?, ?, ?, ?)',
        [uuidV7(), taskId, body, DateTime.now().toUtc().toIso8601String()],
      );
      return const Success(null);
    } catch (e) {
      return Failure(UnexpectedFailure('Failed to add note: $e'));
    }
  }

  Note _noteFromRow(Map<String, dynamic> row) => Note(
    id: row['id'] as String,
    taskId: row['task_id'] as String,
    body: row['body'] as String,
    createdAt: DateTime.parse(row['created_at'] as String),
  );
}
Key things to notice:
  • _db.watch() returns a live stream — the UI updates automatically when data changes.
  • try/catch only lives here, in the data layer. Never in domain or presentation.
  • uuidV7() generates the ID on the client so the row has an ID even before the server sees it.
  • All timestamps are UTC (DateTime.now().toUtc()). The presentation layer converts to local time for display.

Step 4 — Wire it into Riverpod

Create a providers file that connects the repository to the DI system:
// lib/features/notes/notes_providers.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:core_database/core_database.dart';

import 'data/powersync_note_repository.dart';
import 'domain/note_repository.dart';

part 'notes_providers.g.dart';

@riverpod
NoteRepository noteRepository(Ref ref) =>
    PowerSyncNoteRepository(ref.watch(powerSyncDatabaseProvider));
Run generation again to create notes_providers.g.dart:
melos run generate

Step 5 — Presentation: build the controller and screen

// lib/features/notes/presentation/notes_controller.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../domain/note.dart';
import '../notes_providers.dart';

part 'notes_controller.g.dart';

@riverpod
Stream<List<Note>> notesList(Ref ref, String taskId) =>
    ref.watch(noteRepositoryProvider).watchNotesForTask(taskId);
In your widget, read the provider:
// lib/features/notes/presentation/notes_screen.dart
class NotesScreen extends ConsumerWidget {
  final String taskId;
  const NotesScreen({required this.taskId, super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final notes = ref.watch(notesListProvider(taskId));
    return notes.when(
      loading: () => const CircularProgressIndicator(),
      error:   (e, _) => Text('Error: $e'),
      data:    (list) => ListView(
        children: list.map((n) => Text(n.body)).toList(),
      ),
    );
  }
}

Step 6 — Add to routing

Register the new screen in lib/routing/ (the central go_router route table).

When to run code generation

You changed…Run…
A @freezed classmelos run generate
A @riverpod provider or controllermelos run generate
A slang locale file (*.i18n.json)melos run generate
A plain Dart class (no annotations)Nothing — no generation needed
A PowerSync schema definitionNothing — PowerSync schema is plain Dart, no generation
During active development, run the watcher so generation is instant:
melos run generate:watch
Commit generated files. Files like *.freezed.dart and *.g.dart are checked in to Git. This means anyone checking out the repo can build immediately without running generation themselves.

How offline writes actually reach the server

When you write to SQLite (as in addNote() above), PowerSync doesn’t immediately call the server. Here is the full journey:
1. _db.execute(INSERT ...)


2. Row saved to local SQLite
   + operation added to PowerSync's internal upload queue

         ▼  (when online)
3. PowerSync calls uploadData() in core_database


4. uploadData() sends the operation to POST /api/mobile/sync/upload


5. Go backend validates, writes to Postgres, returns per-op result


6. PowerSync marks the op as done; the queue advances

         ▼  (via logical replication back down)
7. The authoritative server row syncs back to local SQLite
8. Your _db.watch() stream emits the updated list
What happens offline? Steps 1–2 always happen immediately. Steps 3–8 are deferred until connectivity returns. The user sees their change in the UI right away (from local SQLite), and it syncs to the server when they are back online. What if the server rejects the write? See Rejected Writes below.

Rejected writes

Sometimes the server permanently rejects an operation — for example, a validation rule says the note body can’t be empty, or the user lost permission to edit that task before the offline write synced. When this happens:
  • PowerSync’s upload queue does not get stuck — the rejected operation is cleared.
  • The Go backend writes a record to a rejected_mutations table.
  • That record syncs back down to the device via PowerSync.
  • The app surfaces it as a notification: “One change couldn’t be applied.”
Think of it like a post office marking a package “Return to sender” and sending you a notification, rather than blocking all future deliveries. As a developer, you don’t need to handle this manually per-feature. The uploadData() implementation in core_database handles the acknowledgement. You only need to build UI if you want to show users their rejected mutations (which come in through a rejected_mutations reactive query).

Working with the package DAG

The project enforces strict rules about which packages can import which others. The short version:
  • core_models is imported by everything — it has no imports itself.
  • Data/domain packages (core_network, core_auth, etc.) never import core_ui.
  • Nothing imports from an apps/ folder.
  • core_location is only used by the fieldforce app, not digital_worker.
The rules are automatically checked every time you run:
melos run analyze
If you accidentally import the wrong package, the analyzer will fail with a clear message pointing to the violation. Fix it before committing — the CI pipeline runs the same check.

Troubleshooting

Build errors after pulling new code? Generated files might be out of date:
melos run generate
The app shows stale data that doesn’t update? PowerSync’s reactive queries only work with _db.watch(), not _db.get(). Check that your data layer uses watch() for live data. Sync isn’t uploading my offline writes? Open the hidden diagnostics screen (shake the device in dev mode, or navigate to the diagnostics route). It shows the upload queue depth and last sync time. melos run analyze fails with a DAG error? The error message will name the file and the forbidden import. Remove the import and restructure: move shared logic to the appropriate core_* package, or pass data through the domain layer interfaces instead of importing the concrete implementation.