Steven's Knowledge

Architecture & Dependency Injection

Flutter architecture — layering, the dependency rule, domain/data/presentation responsibilities, DI with get_it/injectable and Riverpod, modularization

Architecture & Dependency Injection

How you draw the lines between layers decides how much of your codebase you can change without fear. The guiding principle: dependencies point inward — the domain depends on nothing, and everything depends on the domain.

Why Architecture

A folder structure is not an architecture. Architecture is the set of rules about who is allowed to depend on whom. Get those rules right and you gain:

  • Separation of concerns — UI, business rules, and I/O change for different reasons and at different rates. Isolate them so a change to your HTTP client never forces you to touch a use case.
  • Testability — pure business logic with no Flutter or network imports can be unit-tested in milliseconds, without a widget tester or a running device.
  • Replaceability — swap Dio for http, REST for GraphQL, or hive for isar by editing one layer, because the rest of the app depends on an interface, not the implementation.

This page is about layering and wiring. For which state library drives the presentation layer, see State Management; for the feature-first folder layout these layers live in, see Best Practices.

The Dependency Rule

The single rule that makes layered architecture work:

Source-code dependencies may only point inward, toward higher-level policy. Inner layers know nothing about outer layers.

        ┌─────────────────────────────────────┐
        │           Presentation              │   Flutter, Widgets, ViewModels
        │   (depends on Domain)               │
        │        │                            │
        │        ▼                            │
        │   ┌───────────────────────────┐     │
        │   │         Domain            │     │   Pure Dart — no framework
        │   │  Entities · UseCases ·    │     │   Depends on NOTHING
        │   │  Repository interfaces    │     │
        │   └───────────────────────────┘     │
        │        ▲                            │
        │        │                            │
        │   ┌────┴──────────────────────┐     │
        │   │          Data             │     │   Dio, Hive, DTOs
        │   │  Repository impls ·       │     │   (depends on Domain)
        │   │  Data sources · Mappers   │     │
        │   └───────────────────────────┘     │
        └─────────────────────────────────────┘

  Presentation ──▶ Domain ◀── Data

Note the arrows: both Presentation and Data point at Domain. Data does not sit "below" the domain as a dependency — it implements interfaces the domain declares. This inversion is what lets the domain stay pure.

Layered Architecture Overview

LayerOwnsImports allowedNever imports
PresentationWidgets, ViewModels/Notifiers, routingFlutter, DomainData, Dio, DTOs
DomainEntities, use cases, repository interfacesPure Dart onlyFlutter, Dio, any package with I/O
DataRepo implementations, data sources, DTOsDomain, Dio, Hive, driftFlutter widgets, Presentation

The arrow always crosses one boundary inward. Presentation never reaches into Data directly — it goes through the domain's use cases and interfaces.

Domain Layer

The heart of the app, and the part most worth protecting. Pure Dart — no package:flutter, no package:dio, nothing with I/O.

Entities

Plain immutable objects expressing business concepts. No JSON, no fromJson — serialization is a data-layer concern.

// domain/entities/order.dart
class Order {
  final String id;
  final OrderStatus status;
  final Money total;
  const Order({required this.id, required this.status, required this.total});

  bool get isRefundable => status == OrderStatus.delivered;
}

enum OrderStatus { pending, shipped, delivered, cancelled }

Repository Interfaces

The domain declares what it needs as an abstract contract. It never knows the implementation.

// domain/repositories/order_repository.dart
abstract interface class OrderRepository {
  Future<Result<Order, Failure>> getOrder(String id);
  Future<Result<void, Failure>> cancelOrder(String id);
}

Use Cases

One use case = one business operation. Thin objects that orchestrate repositories and enforce business rules. Use a call method so they invoke like functions.

// domain/usecases/cancel_order.dart
class CancelOrder {
  final OrderRepository _repo;
  const CancelOrder(this._repo);

  Future<Result<void, Failure>> call(String id) async {
    final order = await _repo.getOrder(id);
    return order.fold(
      (o) => o.status == OrderStatus.shipped
          ? const Failure.invalidState('shipped orders cannot be cancelled').toErr()
          : _repo.cancelOrder(id),
      (f) => f.toErr(),
    );
  }
}

Use cases are optional for trivial flows. If a use case only forwards a single call to the repository with no logic, calling the repository straight from the ViewModel is fine. Add use cases when there is genuine orchestration or business rules to house.

Data Layer

Implements the domain's interfaces and talks to the outside world.

Repository Implementations

// data/repositories/order_repository_impl.dart
class OrderRepositoryImpl implements OrderRepository {
  final OrderRemoteDataSource _remote;
  final OrderLocalDataSource _local;
  const OrderRepositoryImpl(this._remote, this._local);

  @override
  Future<Result<Order, Failure>> getOrder(String id) async {
    try {
      final dto = await _remote.fetchOrder(id);
      await _local.cache(dto);
      return Ok(dto.toEntity());          // DTO → entity at the boundary
    } on DioException catch (e) {
      return Err(e.toFailure());          // map transport errors to domain Failures
    }
  }

  @override
  Future<Result<void, Failure>> cancelOrder(String id) async { /* ... */ }
}

Data Sources

Each data source owns one protocol. The remote source knows about Dio; the local source knows about Hive. Neither knows about business rules.

// data/datasources/order_remote_data_source.dart
class OrderRemoteDataSource {
  final Dio _dio;
  OrderRemoteDataSource(this._dio);

  Future<OrderDto> fetchOrder(String id) async {
    final res = await _dio.get('/orders/$id');
    return OrderDto.fromJson(res.data);
  }
}

DTO ↔ Entity Mapping

DTOs carry serialization concerns; entities carry business meaning. Keep them separate so a backend field rename never ripples into your domain.

// data/models/order_dto.dart
class OrderDto {
  final String id;
  final String status;
  final int totalCents;
  OrderDto({required this.id, required this.status, required this.totalCents});

  factory OrderDto.fromJson(Map<String, dynamic> json) => OrderDto(
    id: json['id'] as String,
    status: json['status'] as String,
    totalCents: json['total_cents'] as int,
  );

  Order toEntity() => Order(
    id: id,
    status: OrderStatus.values.byName(status),
    total: Money.fromCents(totalCents),
  );
}

Where the serialization itself lives (codegen with json_serializable/freezed, error mapping, interceptors) is covered in Networking.

Presentation Layer

Widgets stay dumb. A ViewModel / Notifier holds page state and calls use cases; the widget only renders state and forwards user events.

// presentation/order/order_view_model.dart  (Riverpod AsyncNotifier)
@riverpod
class OrderViewModel extends _$OrderViewModel {
  @override
  Future<Order> build(String id) =>
      ref.read(getOrderProvider)(id).then((r) => r.getOrEthrow());

  Future<void> cancel() async {
    state = const AsyncLoading();
    final result = await ref.read(cancelOrderProvider)(state.requireValue.id);
    state = result.toAsyncValue(orElse: () => state);
  }
}

The widget subscribes and dispatches — nothing more:

class OrderPage extends ConsumerWidget {
  const OrderPage({super.key, required this.id});
  final String id;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final state = ref.watch(orderViewModelProvider(id));
    return state.when(
      data: (order) => OrderView(order: order),
      loading: () => const CircularProgressIndicator(),
      error: (e, _) => ErrorView(failure: e as Failure),
    );
  }
}

Navigation and routing sit in the presentation layer too — keep route definitions out of ViewModels and trigger them via callbacks or a router service. See Routing.

Error Modeling Across Layers

Transport-level exceptions (DioException, SocketException) must not leak past the data layer. Map them to domain Failure types at the boundary and flow a sealed Result upward.

// domain/failures/failure.dart
sealed class Failure {
  const Failure();
  const factory Failure.network(String message) = NetworkFailure;
  const factory Failure.notFound() = NotFoundFailure;
  const factory Failure.invalidState(String message) = InvalidStateFailure;
}

The presentation layer pattern-matches on the sealed type to render the right message — no try/catch scattered through widgets. Full error-mapping strategy lives in Networking.

Dependency Injection

Layers depend on interfaces; something must supply the concrete implementations at runtime. That wiring is dependency injection. Two mainstream approaches in Flutter: a service locator (get_it + injectable) and Riverpod providers.

Service Locator: get_it + injectable

get_it (8.x) is a simple service locator; injectable generates the registration boilerplate from annotations.

// di/injection.dart
final getIt = GetIt.instance;

@InjectableInit()
void configureDependencies() => getIt.init();   // generated extension

Annotate implementations; injectable wires constructor dependencies automatically:

@LazySingleton(as: OrderRepository)              // bind impl to the interface
class OrderRepositoryImpl implements OrderRepository {
  final OrderRemoteDataSource _remote;
  final OrderLocalDataSource _local;
  OrderRepositoryImpl(this._remote, this._local);   // deps auto-resolved
}

@lazySingleton
class OrderRemoteDataSource {
  final Dio _dio;
  OrderRemoteDataSource(this._dio);
}

Provide third-party objects you do not own via a @module:

@module
abstract class NetworkModule {
  @lazySingleton
  Dio dio() => Dio(BaseOptions(baseUrl: Env.apiUrl));

  @preResolve                                   // awaited during init()
  Future<SharedPreferences> get prefs => SharedPreferences.getInstance();
}

Resolve where needed: getIt<OrderRepository>().

Registration Scopes

ScopeAnnotationLifetimeUse for
Singleton@singletonCreated at init(), lives foreverEagerly-needed, cheap, always used
Lazy singleton@lazySingletonCreated on first resolve, then cachedMost repositories / services (default choice)
Factory@injectableNew instance on every resolveStateful, short-lived objects
Async singleton@preResolve / @singleton(...)Awaited dependencySharedPreferences, DB connections

Prefer @lazySingleton as the default. It avoids paying construction cost at startup for services that may never be touched in a given session, while still sharing one instance. Reserve eager @singleton for things that must exist before the first frame.

Riverpod as DI

Riverpod is a state-management library, but its providers are also a fully-typed DI container — often you do not need get_it at all. Dependencies are resolved by ref.watch / ref.read, and the graph is checked at compile time.

final dioProvider = Provider<Dio>((ref) => Dio(BaseOptions(baseUrl: Env.apiUrl)));

final orderRemoteProvider = Provider(
  (ref) => OrderRemoteDataSource(ref.watch(dioProvider)),
);

final orderRepositoryProvider = Provider<OrderRepository>(
  (ref) => OrderRepositoryImpl(
    ref.watch(orderRemoteProvider),
    ref.watch(orderLocalProvider),
  ),
);

get_it vs Riverpod for DI

Aspectget_it + injectableRiverpod providers
Compile-time safetyNo — missing registration fails at runtimeYes — unresolved deps are compile errors
BoilerplateLow (codegen)Low–medium
Coupling to state libraryNone — pure locatorTies DI to Riverpod
Scoping / disposalManual scopes (pushNewScope)autoDispose, ProviderScope overrides
Access from pure DartAnywhere (getIt<T>())Needs a ref / ProviderContainer
Test overridesgetIt.registerSingleton after reset()ProviderScope(overrides: [...])

Rule of thumb: if the project already uses Riverpod, use it for DI too and skip get_it. Reach for get_it when you want DI decoupled from your state library, or need to resolve dependencies from places that have no ref.

DI Enables Test Overrides

The payoff of programming to interfaces: swap in fakes without touching production code.

// get_it
getIt.reset();
getIt.registerSingleton<OrderRepository>(FakeOrderRepository());

// Riverpod
ProviderScope(
  overrides: [
    orderRepositoryProvider.overrideWithValue(FakeOrderRepository()),
  ],
  child: const MyApp(),
);

When Full Clean Architecture Is Overkill

Layers are a cost, not a virtue. A four-layer Clean Architecture with use cases, DTOs, mappers, and repository interfaces pays off in large, long-lived, multi-team apps. In a small app it is pure ceremony: you write three files to fetch one number.

For small / prototype apps, collapse the layers:

  • Skip use cases — call repositories directly from Notifiers.
  • Skip the DTO/entity split — if the API shape is your model and is stable, use one class with freezed.
  • Keep one boundary — a repository interface between UI and I/O is the highest-value line to draw, and the cheapest to keep. Add the rest only when the app grows into it.

Do not adopt the full pattern to "look professional" — see the same caution in State Management.

Modularization

Once a single lib/ grows past a few features, split into packages. This turns the dependency rule from a convention into something the compiler enforces — a package simply cannot import what it does not declare in pubspec.yaml.

Feature & Layer Packages

my_app/
├── app/                     The runnable Flutter app (wires everything)
├── packages/
│   ├── core/                Shared utilities, Result, Failure, DI setup
│   ├── networking/          Dio client, interceptors
│   ├── feature_order/       A self-contained feature (data/domain/presentation)
│   │   ├── lib/
│   │   └── pubspec.yaml     Declares deps on core only — NOT on other features
│   └── feature_cart/
└── melos.yaml

Melos Monorepo

melos manages multiple packages in one repo — bootstrapping local path links, running scripts across all packages, and versioning together.

# melos.yaml
name: my_app
packages:
  - app
  - packages/**
scripts:
  analyze: melos exec -- dart analyze .
  test: melos exec --dir-exists=test -- flutter test

Enforcing Boundaries

  • A feature package depends only on core / shared packages — never on another feature. Cross-feature needs go through an interface in core.
  • Keep the public surface small: expose intent via a barrel file (feature_order.dart) and keep everything else out of lib/src/ reach.
  • The app package is the only place that wires features together and registers DI.

Testing Strategy Per Layer

Clean layering exists largely to make this table possible:

LayerTest typeWhat to fake
Domain (use cases)UnitRepository interfaces (hand-written or mocktail fakes)
Data (repo impl)UnitData sources / Dio (http_mock_adapter)
Presentation (ViewModel)UnitUse cases / repositories via DI overrides
Presentation (Widget)WidgetWhole graph via ProviderScope / getIt override

Because the domain has no Flutter import, its tests run as plain Dart with no widget binding — fast and deterministic. Full tooling and mock strategy in Testing.

Anti-Patterns

1. God / do-everything repositories

A single DataRepository with thirty unrelated methods becomes a merge-conflict magnet and an untestable blob. Split by bounded context: OrderRepository, CartRepository, AuthRepository.

2. Leaking framework types into the domain

The moment a BuildContext, Dio, Response, or a Flutter Widget appears in a domain file, the domain is no longer pure — it can no longer be unit-tested without Flutter, nor shared across platforms. Map to entities and Failure types at the data boundary.

3. Service-locator overuse / hidden globals

Calling getIt<T>() from deep inside widgets and business logic turns the locator into a global variable. Dependencies become invisible at the call site and impossible to follow. Inject through constructors; resolve from the locator only at composition roots.

4. Anemic pass-through layers

A use case that only calls repo.getX(), a ViewModel that only forwards to a use case — layers that add no logic are over-engineering. Collapse them. A layer must justify its existence with real responsibility.

5. Business logic in widgets

Pricing rules, validation, and retry policy inside onPressed or build cannot be tested without pumping a widget and cannot be reused. Push logic down into use cases / ViewModels; keep build declarative.

6. Circular dependencies between features

feature_order importing feature_cart which imports feature_order back is a design smell that blocks independent compilation and testing. Break the cycle by lifting the shared contract into core and depending on the interface, not the feature.

On this page