Enable Dark Mode!
implementing-clean-architecture-for-mobo-flutter-applications.jpg
By: Muhammed Shehzad K

Implementing Clean Architecture for Mobo Flutter Applications

Technical mobo

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.

LayerResponsibilityDepends On
PresentationWidgets, blocs, view models, navigationDomain
DomainEntities, use cases, repository contractsNothing
DataRepository implementations, data sources, DTOsDomain

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

LayerUse ForAvoid
DomainBusiness rules, validation, pure logicFlutter widgets, HTTP, JSON
DataAPI clients, caching, parsing, mappingBusiness decisions
PresentationUI rendering, navigation, user inputDirect 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.


If you need any assistance in odoo, we are online, please chat with us.



0
Comments



Leave a comment



Recent Posts

whatsapp_icon
location

Calicut

Cybrosys Technologies Pvt. Ltd.
Neospace, Kinfra Techno Park
Kakkancherry, Calicut
Kerala, India - 673635

location

Kochi

Cybrosys Technologies Pvt. Ltd.
1st Floor, Thapasya Building,
Infopark, Kakkanad,
Kochi, India - 682030.

location

Bangalore

Cybrosys Techno Solutions
The Estate, 8th Floor,
Dickenson Road,
Bangalore, India - 560042

Send Us A Message