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
- Presentation — widgets and their state controllers. They render state and send intents; nothing else.
- Domain — entities and use cases in plain Dart, no Flutter imports. In a small app this layer can be thin.
- 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.