Clean Architecture

Overview

The Gremlin Mobile workspace is designed with a strict Clean Architecture layout. This layout separates our business logic from data sources and user interfaces, ensuring that the codebase is easy to test, modular, and resilient to change.

🏛 Clean Architecture Layers

Inside every application feature or shared package, code is split into three distinct layers: three distinct layers

1. Domain Layer (The Core)

  • What it contains: Business models (entities), abstract repository interfaces (ports), and use cases.
  • Rules:
    • It must be pure Dart. No imports of package:flutter, network clients, or databases.
    • It depends only on core_models.
    • Pragmatic Use Case Rule: For simple CRUD operations (e.g., loading a list of simple items with no business logic), the presentation controller can call the repository interface directly. We only create explicit use-case classes for complex business logic.

2. Data Layer (The Infrastructure)

  • What it contains: Data Transfer Objects (DTOs), API datasources (GraphQL/REST clients), local databases (PowerSync/SQLite), and repository implementations (adapters).
  • Rules:
    • It implements the abstract repository interfaces defined in the domain layer.
    • This is the only layer allowed to use try/catch. It catches low-level exceptions (socket timeouts, HTTP errors, database locks) and translates them into clean, typed AppFailure models.

3. Presentation Layer (The UI)

  • What it contains: Flutter widgets, UI pages, and Riverpod controllers (notifiers).
  • Rules:
    • It coordinates UI state and reacts to user input.
    • It depends on the domain layer (repository interfaces and use cases) to retrieve or mutate data.
    • It must never import the data layer directly. This ensures the UI is entirely decoupled from how data is loaded or synchronized.

Real example: the Tasks feature

The existing tasks feature inside the FieldForce app shows exactly how this maps to files:
apps/fieldforce/lib/features/tasks/
  domain/
    task.dart               ← @freezed entity (what a Task is)
    task_repository.dart    ← abstract interface (what you can do with tasks)
    task_status.dart        ← enum: assigned / in_progress / completed
  data/
    powersync_task_repository.dart  ← implements TaskRepository using SQLite
    task_mapper.dart                ← converts a SQLite row → Task entity
    task_crud_uploader.dart         ← handles the write path to the Go backend
  presentation/
    tasks_screen.dart       ← Flutter widget
    tasks_controller.dart   ← @riverpod provider (reads from domain)
  tasks_providers.dart      ← wires PowerSyncTaskRepository into Riverpod
The controller is four lines — it reads through the domain interface and never touches SQLite directly:
// presentation/tasks_controller.dart
@riverpod
Stream<List<Task>> tasksList(Ref ref) =>
    ref.watch(taskRepositoryProvider).watchTasks();
This means the UI never knows whether data came from SQLite, a REST API, or a test mock. See the Development Workflow guide for a full walkthrough of adding a new feature using this structure.

📐 Package Dependency DAG (Inward-Pointing Rule)

Modularity is enforced at the package level. Dependencies point inward toward core_models, which has zero dependencies and is written in pure Dart. Package Dependency DAG

The Innermost Core: core_models

  • Written in pure Dart with no Flutter dependencies.
  • Contains shared entities, error definitions (AppFailure), and operation outcomes (Result).
  • Because it depends on nothing, it compiles instantly and is safe to use in any shared context.

Machine-Checked DAG

The dependency graph is machine-checked to prevent architectural erosion. Running melos run analyze executes tool/check_dag.dart, which automatically fails the build/check if any workspace package depends on a package violating the DAG rules, or if Flutter packages are imported in pure-Dart packages (like core_models). New packages must be registered in the DAG script and documented.

Sideways & Outward Imports are Forbidden

  • A package must never import code from an apps/* folder.
  • Modulating code into packages enforces these rules automatically at compile-time: the build tool will reject circular dependencies or incorrect imports.

What the DAG rule looks like in practice

Allowedcore_database importing core_models:
// packages/core_database/lib/src/powersync_database.dart
import 'package:core_models/core_models.dart'; // ✅ inward import
Forbiddencore_models importing anything:
// packages/core_models/lib/src/result.dart
import 'package:flutter/material.dart'; // ❌ DAG violation: core_models must be pure Dart
import 'package:core_network/core_network.dart'; // ❌ DAG violation: nothing imports inward into core_models
Forbidden — a data package importing the UI package:
// packages/core_network/lib/src/api_client.dart
import 'package:core_ui/core_ui.dart'; // ❌ DAG violation: data layer must not import presentation
If you add a forbidden import, melos run analyze will fail immediately with a message naming the bad edge. Fix it before committing.

⚙️ Code Generation Discipline

We use code generation (build_runner) to automate repetitive boilerplate for state management, JSON parsing, and immutability.
  • Tools: Freezed (unions/models), json_serializable (JSON DTOs), riverpod_generator (type-safe DI), and slang (i18n translations).
  • Freezed Exception: Use Freezed only for standard domain models and DTOs. The Result and AppFailure sealed classes must remain hand-written sealed classes; do not convert them to Freezed as its generated machinery is not needed there.
  • Command: Run melos run generate from the workspace root to regenerate all generated files across packages.
  • Repository Practice: Always commit generated files (*.g.dart, *.freezed.dart, etc.) to the Git repository. This ensures that the codebase compiles immediately upon checking it out without requiring a setup build step.

⚙️ Configuration & Secrets

We keep configuration clean and secure by separating environment values from secrets:
  • Environment Configurations: Non-secret configurations (like the Go API URL or PowerSync endpoint) are declared in JSON files under apps/<app>/config/ (e.g., dev.json, prod.json). These are loaded using the --dart-define-from-file compile flag.
  • Composition Root Injection: Flutter applications read these configuration values at their entry points (e.g., main_dev.dart) and inject them down into packages via constructors or Riverpod providers. A shared package must never call String.fromEnvironment directly. It must receive its config dynamically.
  • Secrets Management: True secrets (like signing certificates or API private keys) are never stored in the repository. They are stored securely in Codemagic environment variables and injected during the build/compile stage in the CI pipeline.