Why Flutter Architecture Matters from Day One
Most Flutter tutorial apps use a single file with setState() — fine for demos, catastrophic for production. When clients come to me to rescue poorly-structured Flutter projects, the most common symptoms are:
- Business logic mixed with UI widgets
- No clear data layer or API abstraction
- Impossible to write unit tests
-
setState()causing full-screen rebuilds - Shared state spaghetti across widgets
The solution is Clean Architecture — a layered approach that separates concerns so strictly that you can swap your backend, state manager, or UI framework without touching business logic.
The 3-Layer Flutter Clean Architecture
┌────────────────────────────────────┐
│ Presentation Layer │
│ (BLoC + Widgets + Pages) │
├────────────────────────────────────┤
│ Domain Layer │
│ (Use Cases + Entities + Repos) │
├────────────────────────────────────┤
│ Data Layer │
│ (APIs + Local DB + DTOs) │
└────────────────────────────────────┘
Each layer only communicates downward — the Presentation layer calls Domain, Domain calls Data, never the reverse.
Project Directory Structure
lib/
├── core/
│ ├── error/ # Failures & Exceptions
│ ├── network/ # Dio HTTP client setup
│ └── usecases/ # Base UseCase abstract class
├── features/
│ └── orders/
│ ├── data/
│ │ ├── datasources/ # Remote API & Local Hive datasources
│ │ ├── models/ # DTO models with fromJson/toJson
│ │ └── repositories/ # Repository implementations
│ ├── domain/
│ │ ├── entities/ # Pure business objects (no JSON)
│ │ ├── repositories/ # Abstract repository interfaces
│ │ └── usecases/ # GetOrders, CreateOrder, etc.
│ └── presentation/
│ ├── bloc/ # OrdersBloc, OrdersState, OrdersEvent
│ ├── pages/ # OrdersPage, OrderDetailPage
│ └── widgets/ # OrderCard, OrderStatusChip
1. Domain Layer: Entities & Use Cases
Entities are pure Dart classes with no Flutter or JSON dependencies:
// domain/entities/order.dart
class Order {
final String id;
final String customerId;
final List<OrderItem> items;
final OrderStatus status;
final DateTime createdAt;
const Order({
required this.id,
required this.customerId,
required this.items,
required this.status,
required this.createdAt,
});
}
Use Cases encapsulate a single business operation:
// domain/usecases/get_orders.dart
class GetOrders implements UseCase<List<Order>, GetOrdersParams> {
final OrderRepository repository;
GetOrders(this.repository);
@override
Future<Either<Failure, List<Order>>> call(GetOrdersParams params) {
return repository.getOrders(customerId: params.customerId);
}
}
2. Data Layer: Repository Implementation & DTOs
The Repository Implementation bridges Domain contracts with real API calls:
// data/repositories/order_repository_impl.dart
class OrderRepositoryImpl implements OrderRepository {
final OrderRemoteDataSource remoteDataSource;
final OrderLocalDataSource localDataSource;
final NetworkInfo networkInfo;
@override
Future<Either<Failure, List<Order>>> getOrders({required String customerId}) async {
if (await networkInfo.isConnected) {
try {
final remoteOrders = await remoteDataSource.getOrders(customerId);
await localDataSource.cacheOrders(remoteOrders);
return Right(remoteOrders.map((dto) => dto.toEntity()).toList());
} on ServerException {
return Left(ServerFailure());
}
} else {
final cachedOrders = await localDataSource.getCachedOrders(customerId);
return Right(cachedOrders.map((dto) => dto.toEntity()).toList());
}
}
}
3. Presentation Layer: BLoC State Management
// presentation/bloc/orders_bloc.dart
class OrdersBloc extends Bloc<OrdersEvent, OrdersState> {
final GetOrders getOrders;
OrdersBloc({required this.getOrders}) : super(OrdersInitial()) {
on<FetchOrders>(_onFetchOrders);
}
Future<void> _onFetchOrders(FetchOrders event, Emitter<OrdersState> emit) async {
emit(OrdersLoading());
final result = await getOrders(GetOrdersParams(customerId: event.customerId));
result.fold(
(failure) => emit(OrdersError(message: failure.message)),
(orders) => emit(OrdersLoaded(orders: orders)),
);
}
}
4. Dependency Injection with get_it
Wire everything together using get_it for testable, decoupled dependency injection:
// core/injection_container.dart
final sl = GetIt.instance;
Future<void> initDependencies() async {
// BLoCs
sl.registerFactory(() => OrdersBloc(getOrders: sl()));
// Use Cases
sl.registerLazySingleton(() => GetOrders(sl()));
// Repositories
sl.registerLazySingleton<OrderRepository>(
() => OrderRepositoryImpl(
remoteDataSource: sl(),
localDataSource: sl(),
networkInfo: sl(),
),
);
// Data Sources
sl.registerLazySingleton<OrderRemoteDataSource>(
() => OrderRemoteDataSourceImpl(client: sl()),
);
}
When to Use This Architecture
| App Scale | Recommended Architecture |
|---|---|
| Side project / prototype | Simple setState() or Provider
|
| 3–10 features, team of 1–2 |
Provider or Riverpod with service layer |
| Enterprise / team / long-term | Clean Architecture + BLoC (this guide) |
Need a Flutter Architect?
Setting up Flutter Clean Architecture correctly from the start saves thousands of hours of refactoring later. If you need an experienced Flutter Solution Architect based in Kerala, India (available worldwide remotely) to architect your mobile application, start a conversation today.
🏛️ About the Author & Original Publication
This architectural guide was originally published on abinschandran.in.
Abin S Chandran is a Senior Freelance Software Developer & Solution Architect serving clients in Kochi & Infopark, Kerala, and worldwide. He specializes in high-velocity Next.js 15 SaaS platforms, 60fps Flutter mobile applications, sub-10ms Node.js enterprise APIs, and production AI/RAG integrations.
👉 Planning a custom software project or SaaS MVP? Hire Abin or Request an Architecture Consultation ↗
Top comments (0)