A Clean Architecture is a layered software design pattern, introduced by Robert C. Martin, that is used to separate business rules from frameworks, UI, and external concerns. In Flutter, where widgets, state management, and platform plugins can easily tangle into a single file, Clean Architecture brings order, testability, and long-term maintainability. This guide walks through Clean Architecture for Flutter from the ground up, with practical Dart examples drawn from real enterprise scenarios.
Why Clean Architecture Matters for Mobile Developers?
The majority of Flutter apps begin with a few screens, a few API calls, and a few setState calls. But the moment the project grows into a real product with multiple modules, offline support, role-based access, and integrations with ERPs like Odoo, the absence of architecture becomes painfully obvious. Files balloon, business logic leaks into widgets, and refactoring becomes a risky operation. Adopting Clean Architecture helps you:
- Decouple business rules from Flutter and third-party packages.
- Test business logic in isolation without spinning up widgets or HTTP clients.
- Swap data sources (REST, GraphQL, local cache, Odoo JSON-RPC) without rewriting use cases.
- Onboard new developers faster by giving each layer a clear responsibility.
- Scale to large codebases exactly the kind of structure Mobo, Cybrosys's Flutter-based mobile suite for Odoo, relies on to keep dozens of feature modules manageable.
Prerequisites
Before applying Clean Architecture to a Flutter project, make sure you have:
- Flutter SDK 3.x or later installed
- Familiarity with async/await and Future/Stream
- Basic understanding of dependency injection (e.g., get_it, injectable, or Riverpod providers)
- A state management solution of choice (Bloc, Riverpod, Provider pattern is agnostic)
1. The Three Layers of Clean Architecture
Clean Architecture in Flutter is most commonly organized into three layers: Data, Domain, and Presentation. Dependencies always point inward, the outer layers know about the inner ones, never the reverse.
| Layer | Responsibility | Depends On |
| Presentation | Widgets, blocs, view models, navigation | Domain |
| Domain | Entities, use cases, repository contracts | Nothing |
| Data | Repository implementations, data sources, DTOs | Domain |
The Domain layer is where the application's core logic lives. It contains pure Dart, no Flutter imports, no http, no isar. This is what makes business logic portable and trivially testable.
Layer Responsibilities at a Glance
- Domain: Defines what the app does. Entities like Customer, SaleOrder, and use cases like FetchPendingDeliveries live here.
- Data: Defines how the app gets and persists data. REST clients, Odoo RPC services, local databases (ISAR, Hive), and DTO mapping.
- Presentation: Defines how the app looks and reacts. Widgets, blocs/cubits, controllers, navigation.
2. Recommended Folder Structure
A predictable folder layout pays for itself within weeks. The structure below scales from a small app to a multi-module suite like Mobo, where each Odoo app (Sales, Inventory, HR, Field Service) lives as a self-contained feature.
lib/
+-- core/
¦ +-- error/
¦ ¦ +-- failures.dart
¦ ¦ +-- exceptions.dart
¦ +-- network/
¦ ¦ +-- network_info.dart
¦ +-- usecase/
¦ +-- usecase.dart
+-- features/
¦ +-- sales/
¦ +-- data/
¦ ¦ +-- datasources/
¦ ¦ ¦ +-- sale_remote_data_source.dart
¦ ¦ ¦ +-- sale_local_data_source.dart
¦ ¦ +-- models/
¦ ¦ ¦ +-- sale_order_model.dart
¦ ¦ +-- repositories/
¦ ¦ +-- sale_repository_impl.dart
¦ +-- domain/
¦ ¦ +-- entities/
¦ ¦ ¦ +-- sale_order.dart
¦ ¦ +-- repositories/
¦ ¦ ¦ +-- sale_repository.dart
¦ ¦ +-- usecases/
¦ ¦ +-- get_sale_orders.dart
¦ ¦ +-- confirm_sale_order.dart
¦ +-- presentation/
¦ +-- bloc/
¦ ¦ +-- sale_bloc.dart
¦ ¦ +-- sale_event.dart
¦ ¦ +-- sale_state.dart
¦ +-- pages/
¦ ¦ +-- sale_list_page.dart
¦ +-- widgets/
¦ +-- sale_order_tile.dart
+-- main.dart
Each feature folder is a vertical slice but everything the Sales module needs lives inside features/sales. This isolation is what allows large teams to work on independent modules without stepping on each other.
3. The Domain Layer (Entities and Use Cases)
Entities are plain Dart classes representing business concepts. They contain no JSON parsing, no database annotations, and no Flutter references.
// features/sales/domain/entities/sale_order.dart
import 'package:equatable/equatable.dart';
class SaleOrder extends Equatable {
final int id;
final String name;
final String customerName;
final double totalAmount;
final String state;
final DateTime orderDate;
const SaleOrder({
required this.id,
required this.name,
required this.customerName,
required this.totalAmount,
required this.state,
required this.orderDate,
});
@override
List<Object?> get props => [id, name, customerName, totalAmount, state, orderDate];
}
A use case is a single, well-named action. One use case = one business rule. They make intent visible at a glance.
// core/error/failures.dart
abstract class Failure {
final String message;
const Failure(this.message);
}
class ServerFailure extends Failure {
const ServerFailure(super.message);
}
class CacheFailure extends Failure {
const CacheFailure(super.message);
}
// core/error/exceptions.dart
class ServerException implements Exception {}
class CacheException implements Exception {}
// core/usecase/usecase.dart
import 'package:dartz/dartz.dart';
import '../error/failures.dart';
abstract class UseCase<Type, Params> {
Future<Either<Failure, Type>> call(Params params);
}
class NoParams {
const NoParams();
}
// features/sales/domain/usecases/get_sale_orders.dart
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../../../../core/usecase/usecase.dart';
import '../entities/sale_order.dart';
import '../repositories/sale_repository.dart';
class GetSaleOrders implements UseCase<List<SaleOrder>, NoParams> {
final SaleRepository repository;
GetSaleOrders(this.repository);
@override
Future<Either<Failure, List<SaleOrder>>> call(NoParams params) {
return repository.getSaleOrders();
}
}
Repository contracts also live in the domain layer. They establish the contract for data operations, defining what is needed without dictating how it gets done:
// features/sales/domain/repositories/sale_repository.dart
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../entities/sale_order.dart';
abstract interface class SaleRepository {
Future<Either<Failure, List<SaleOrder>>> getSaleOrders();
Future<Either<Failure, SaleOrder>> confirmSaleOrder(int id);
}
4. The Data Layer (Models, Data Sources, Repository Implementations)
Models extend entities and add serialization. Keeping this concern in the data layer means the domain stays free of JSON noise.
// core/network/network_info.dart
abstract interface class NetworkInfo {
Future<bool> get isConnected;
}
// sale_local_data_source.dart
abstract interface class SaleLocalDataSource {
Future<void> cacheSaleOrders(List<SaleOrderModel> orders);
Future<List<SaleOrderModel>> getCachedSaleOrders();
}
// features/sales/data/models/sale_order_model.dart
import '../../domain/entities/sale_order.dart';
class SaleOrderModel extends SaleOrder {
const SaleOrderModel({
required super.id,
required super.name,
required super.customerName,
required super.totalAmount,
required super.state,
required super.orderDate,
});
factory SaleOrderModel.fromJson(Map<String, dynamic> json) {
final partner = json['partner_id'];
return SaleOrderModel(
id: json['id'] as int,
name: json['name'] as String,
customerName: partner is List ? partner[1] as String : '',
totalAmount: (json['amount_total'] as num).toDouble(),
state: json['state'] as String,
orderDate: DateTime.parse(json['date_order'] as String),
);
}
}
Data sources are thin wrappers around a single technology, one for the remote backend (e.g., Odoo JSON-RPC), one for local storage (e.g., ISAR or SharedPreferences):
// features/sales/data/datasources/sale_remote_data_source.dart
import 'package:odoo_rpc/odoo_rpc.dart';
import '../models/sale_order_model.dart';
abstract class SaleRemoteDataSource {
Future<List<SaleOrderModel>> fetchSaleOrders();
}
class SaleRemoteDataSourceImpl implements SaleRemoteDataSource {
final OdooClient client;
SaleRemoteDataSourceImpl(this.client);
@override
Future<List<SaleOrderModel>> fetchSaleOrders() async {
final response = await client.callKw({
'model': 'sale.order',
'method': 'search_read',
'args': [
[['state', 'in', ['sale', 'done']]],
],
'kwargs': {
'fields': ['id', 'name', 'partner_id', 'amount_total', 'state', 'date_order'],
'limit': 100,
'order': 'date_order DESC',
},
});
return (response as List)
.map((json) => SaleOrderModel.fromJson(json as Map<String, dynamic>))
.toList();
}
}
The repository implementation glues data sources together, handles caching, and translates exceptions into Failure objects:
// features/sales/data/repositories/sale_repository_impl.dart
import 'package:dartz/dartz.dart';
import '../../../../core/error/exceptions.dart';
import '../../../../core/error/failures.dart';
import '../../../../core/network/network_info.dart';
import '../../domain/entities/sale_order.dart';
import '../../domain/repositories/sale_repository.dart';
import '../datasources/sale_local_data_source.dart';
import '../datasources/sale_remote_data_source.dart';
class SaleRepositoryImpl implements SaleRepository {
final SaleRemoteDataSource remote;
final SaleLocalDataSource local;
final NetworkInfo networkInfo;
SaleRepositoryImpl({
required this.remote,
required this.local,
required this.networkInfo,
});
@override
Future<Either<Failure, List<SaleOrder>>> getSaleOrders() async {
if (await networkInfo.isConnected) {
try {
final orders = await remote.fetchSaleOrders();
await local.cacheSaleOrders(orders);
return Right(orders);
} on ServerException {
return Left(ServerFailure('Failed to load sale orders'));
}
} else {
try {
final cached = await local.getCachedSaleOrders();
return Right(cached);
} on CacheException {
return Left(CacheFailure('No cached data available'));
}
}
}
}
This offline-first pattern which is remote when online, cached otherwise is exactly the strategy Mobo applies across its delivery and field-service modules, where field agents often work in low-connectivity environments.
5. The Presentation Layer (Bloc and Widgets)
The presentation layer consumes use cases and exposes state to widgets. With flutter_bloc, it looks like this:
// sale_event.dart
abstract class SaleEvent {}
class LoadSaleOrders extends SaleEvent {}
// sale_state.dart
abstract class SaleState {}
class SaleInitial extends SaleState {}
class SaleLoading extends SaleState {}
class SaleLoaded extends SaleState {
final List<SaleOrder> orders;
SaleLoaded(this.orders);
}
class SaleError extends SaleState {
final String message;
SaleError(this.message);
}
// features/sales/presentation/bloc/sale_bloc.dart
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/usecase/usecase.dart';
import '../../domain/usecases/get_sale_orders.dart';
import 'sale_event.dart';
import 'sale_state.dart';
class SaleBloc extends Bloc<SaleEvent, SaleState> {
final GetSaleOrders getSaleOrders;
SaleBloc({required this.getSaleOrders}) : super(SaleInitial()) {
on<LoadSaleOrders>((event, emit) async {
emit(SaleLoading());
final result = await getSaleOrders(const NoParams());
result.fold(
(failure) => emit(SaleError(failure.message)),
(orders) => emit(SaleLoaded(orders)),
);
});
}
}
The widget never touches OdooClient, http, or ISAR because it only reacts to bloc states:
// features/sales/presentation/pages/sale_list_page.dart
class SaleListPage extends StatelessWidget {
const SaleListPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Sale Orders')),
body: BlocBuilder<SaleBloc, SaleState>(
builder: (context, state) {
if (state is SaleLoading) {
return const Center(child: CircularProgressIndicator());
} else if (state is SaleLoaded) {
return ListView.builder(
itemCount: state.orders.length,
itemBuilder: (_, i) => SaleOrderTile(order: state.orders[i]),
);
} else if (state is SaleError) {
return Center(child: Text(state.message));
}
return const SizedBox.shrink();
},
),
);
}
}
//To wire the bloc into the widget tree, wrap the page with a BlocProvider when navigating to it:
BlocProvider(
create: (_) => sl<SaleBloc>()..add(LoadSaleOrders()),
child: const SaleListPage(),
)
6. Dependency Injection
To keep layers decoupled at runtime, register dependencies in a single place using get_it:
// injection_container.dart
import 'package:get_it/get_it.dart';
final sl = GetIt.instance;
Future<void> init() async {
// Bloc
sl.registerFactory(() => SaleBloc(getSaleOrders: sl()));
// Use cases
sl.registerLazySingleton(() => GetSaleOrders(sl()));
// Repository
sl.registerLazySingleton<SaleRepository>(
() => SaleRepositoryImpl(remote: sl(), local: sl(), networkInfo: sl()),
);
// Data sources
sl.registerLazySingleton<SaleRemoteDataSource>(
() => SaleRemoteDataSourceImpl(sl()),
);
sl.registerLazySingleton<SaleLocalDataSource>(
() => SaleLocalDataSourceImpl(sl()),
);
// External
sl.registerLazySingleton(() => OdooClient('https://your-instance.com'));
sl.registerLazySingleton<NetworkInfo>(() => NetworkInfoImpl(sl()));
}
7. When to Use Each Layer
| Layer | Use For | Avoid |
| Domain | Business rules, validation, pure logic | Flutter widgets, HTTP, JSON |
| Data | API clients, caching, parsing, mapping | Business decisions |
| Presentation | UI rendering, navigation, user input | Direct data source calls |
A common mistake is calling a repository directly from a widget. It works, but it bypasses the use case layer where business intent is documented and tested. Always go through use cases.
In conclusion, a clean Architecture turns a Flutter codebase into a modular, testable, and long-lived system. By separating concerns into Domain, Data, and Presentation layers, you gain the freedom to evolve each part independently:
- Entities and use cases capture business intent in pure Dart.
- Repositories isolate data sources behind a contract.
- Blocs and widgets stay thin and focused on UI behavior.
- Dependency injection wires it all together at the edges of the app.
Whether you're building an internal HR tool, a customer-facing CRM, or a full-scale Odoo companion suite like Mobo, Clean Architecture is what keeps the code shippable years after the first release. Start small but pick one feature, slice it into three layers, and the rest of the codebase will follow naturally.
To read more about How to Authenticate Mobo Apps with Odoo: Login, Sessions & Token Security Explained, refer to our blog How to Authenticate Mobo Apps with Odoo: Login, Sessions & Token Security Explained.