← Blog
Architecture · 8 min read

Mobile app architecture in Flutter: layers, state and offline sync

Flutter makes the first screen easy. Screen thirty is where structure starts to matter — when the same data appears in five places, the network drops on a train, and several people (or agents) work in the codebase at once.

Feature-first folders

Organise code by feature, not by type. Everything for orders lives together; shared plumbing lives in core. Deleting or rewriting a feature becomes one folder, not a hunt across the project.

lib/
  features/
    orders/
      presentation/   # screens, widgets, controllers
      domain/         # Order, PlaceOrder
      data/           # OrdersRepository, OrdersApi, OrdersDao
    profile/
  core/               # theme, routing, HTTP client, database, errors
  main.dart

Three layers, one direction

  1. Presentation — widgets and their state controllers. They render state and send intents; nothing else.
  2. Domain — entities and use cases in plain Dart, no Flutter imports. In a small app this layer can be thin.
  3. Data — repositories, API clients and the local database. The only place that knows about JSON, HTTP or SQL.

Dependencies point one way: presentation → domain ← data. The rule that saves the most pain: widgets never call HTTP.

Pick one state approach and stick to it

Riverpod and Bloc both work well. Consistency matters more than the choice — two state libraries in one app is worse than either. Keep state immutable and model it explicitly (loading, data, error) with sealed classes, so every screen handles every case.

Offline-first repositories

Treat the local database as the source of truth. The UI watches it; the network only refreshes it. Writes are saved locally first and go into an outbox that a sync worker sends when the connection is back.

class OrdersRepository {
  OrdersRepository(this._api, this._db, this._outbox);

  final OrdersApi _api;
  final AppDatabase _db;
  final Outbox _outbox;

  // The UI only ever watches the local database.
  Stream<List<Order>> watchOrders() => _db.ordersDao.watchAll();

  Future<void> refresh() async {
    final remote = await _api.fetchOrders();
    await _db.ordersDao.upsertAll(remote.map(Order.fromDto));
  }

  Future<void> placeOrder(Order order) async {
    await _db.ordersDao.insert(order.copyWith(syncState: SyncState.pending));
    await _outbox.enqueue(PlaceOrderCommand(order.id));
  }
}
  • Use SQLite through Drift for anything relational; it gives you typed queries and streams.
  • Make every outbox command idempotent with a client-generated ID, so a retry never creates a second order.
  • Decide the conflict rule up front: the server wins, last write wins by server timestamp, or a field-level merge.

A clean boundary with the backend

  • Generate the API client from an OpenAPI schema instead of writing it by hand.
  • Map transfer objects (DTOs) to domain models in the data layer, so an API change touches one file.
  • Version the API and keep a minimum-supported-version flag, because old app versions stay installed for months.

Test each layer where it is cheap

Unit tests for domain logic and repositories with fake APIs and an in-memory database; widget tests for screens and their states; a few integration tests for the critical flows. Run them all in CI with one command — the same pipeline that builds and ships the app, as in this fastlane setup for AI agents.

Few spots left Free AI audit