DEV Community

Cover image for Flutter BLoC Architecture
djibril mugisho
djibril mugisho

Posted on

Flutter BLoC Architecture

Introduction

Have you ever wondered how companies create and manage large-scale Flutter applications with hundreds of features, countless screens, and tons of API requests, without ending up with a messy codebase? In this article, I will guide you through one of the most effective architectures (the BLoC design pattern) used for building large-scale Flutter products, along with industry best practices and standards.

What is the BLoC design pattern?

BLoC is a design pattern introduced at the Google I/O in 2018, primarily used in Flutter applications to separate business logic from the user interface. It allows developers to manage state and events in a scalable, testable, and maintainable way. The BLoC pattern architecture helps developers separate concerns by dividing each feature into three parts: the presentation layer, the domain layer (business layer), and the data layer for handling networking and data fetching.

Presentation layer πŸ–ŒοΈ

In the BLoC architecture, the presentation layer is responsible for everything UI-related β€” how things look and how they respond to state changes.

It's commonly divided into three parts:

  1. Screens (or Views) – Full pages or routes, usually composed of many widgets and tied to specific features (e.g., LoginScreen, ProfileScreen).
  2. Widgets – Reusable UI components like SearchBar, CustomInput, or Button, which are smaller building blocks.
  3. State Management – This includes the BLoC or Cubit itself, which manages the screen's state through:
    • Events (inputs to BLoC),
    • States (outputs from BLoC),
    • and the BLoC class itself (where logic is handled).

Domain layer or business layer

The domain layer defines the business rules and logic of your app. It contains entities, use cases, and abstract repositories β€” the core contracts of what the app should do, without worrying about how it's done (no HTTP, no UI, no database details).

This layer defines what operations should exist (like "GetUserProfile", "UpdateCart", etc.), but not how they're implemented. for more on domain driven design(DDD) please use this material

Element of the domain layer

  1. Entities - define the schematization of data within the domain layer.
  2. Repository - Defines a blueprint for the actual repository implementation in the data layer.
  3. usecase - In this folder, you find the core contract of what the application should do(Login, Signup, FetchUserProfile…), each file corresponds to a specific use case or business requirement.

Data layer 🌐 

The data layer handles everything related to networking and databases, it doesn’t worry about how the returned data shall be consumed, the core mission of this component, is to get data from the specified destination(localdatabase, HTTP request) and return it to the repository, no UI manupilation, no state changes only data fetching.

The data layer is also divided into three major parts. Yes πŸ˜™, I know - another layer with three parts! But don't worry, there's a reason behind these divisions.

Elements of the data layer

  1. Models πŸ“Β - Models πŸ“ - This part defines the structure of the fetched data. Every model corresponds to an entity from the use case. This organization is essential as it enables a smooth data flow from repository implementation to use case.
  2. Repository πŸ”ŒΒ Β - The repository implementation acts as a bridge between the domain layer and the data source layer, it handles the conversion of row JSON data into a usable domain model(User, Account…)
  3. Data source πŸ—ΊοΈΒ - This is where the actual data fetching takes place. This layer is responsible for querying data either remotely or locally through local data storage like SQLite, this layer does not handle the conversion of data into usable use case models.

We are going to build a simple application that fetches posts. This mini example will help us put into practice everything we have learned so far.

Quickly create a new Flutter project flutter create my_project , and install all the below dependencies.

Dependencies installation.


flutter pub add flutter_bloc // State management 
flutter pub add fpdart  //handles async operations and error management using types like Either, Option, and Task, promoting safer and more predictable code.
flutter pub add get_it // Handles dependency injection and service location. 
flutter pub dio //Handles HTTP requests and everything networking related(please choose what fits well for you)

Enter fullscreen mode Exit fullscreen mode

Now that we have all the required dependencies in place, let’s organize the folder structure based on the recommended standards.

Flutter recommended folder structure

For the scope of this article, I will create only one custom exception.

class Failure implements Exception {
  final String message;
  Failure({this.message = "Unexpected error occurred."});
}

Enter fullscreen mode Exit fullscreen mode

Most tutorials start with the presentation layer and end with the data source. However, we'll take a different approach. We'll begin with the domain layer, move to the data layer, and finish with the presentation layer.

πŸ“‚Β Domain layer

domain/
    β”œβ”€β”€ entities/
    β”‚   └── post_entity.dart
    β”œβ”€β”€ repository/
    β”‚   └── post_repository.dart
    └── usecase/
        β”œβ”€β”€ create_post_usecase.dart
        └── get_post_usecase.dart
Enter fullscreen mode Exit fullscreen mode

post_entity.dart

This file contains the structure of the data that will be consumed by the UI, it’s pure dart, no libray, just a pure model


class PostEntity {
  final String title;
  final String body;
  final int id;

  PostEntity({required this.title, required this.body, required this.id});
}

Enter fullscreen mode Exit fullscreen mode

post_repository.dart

post_repository.dart is an abstract class that acts as a blueprint of the actual repository(repository implementation), it defines all methods that the implementation version should have and the type of data it should return. Please note that repositories in the domain layer do not interact with the datasource, rather, they define how the repository implementation should look.

import 'package:bloc_patter_course/core/errors/failure.dart';
import 'package:bloc_patter_course/features/post/domain/entities/post_entity.dart';
import 'package:fpdart/fpdart.dart';

abstract interface class PostRepository {
  Future<Either<Failure, List<PostEntity>>> getPosts();
  Future createPost({required String title, required String body});
}

Enter fullscreen mode Exit fullscreen mode

Usecases(Business requirements)

In our case, we only have one use case, the one for getting the posts. For example, if we were working on an authentication feature, we would have use cases like signup_usecase or login_usecase and so on…

get_post_usecase.dart

class GetPostUsecase {
  final PostRepository repository;

  GetPostUsecase({required this.repository});
  Future<Either<Failure, List<PostEntity>>> call() async {
    return await repository.getPosts();
  }
}

Enter fullscreen mode Exit fullscreen mode

The use case does not interact directly with the datasource, it needs a bridge, and that bridge is the repository(repository implementation).

A clear end-to-end data flow illustrating Clean Architecture principles, ensuring separation of concerns from UI to internet data retrieval.

🌐 Data layer

data/
    β”œβ”€β”€ models /
    β”‚   └── post_model.dart
    β”œβ”€β”€ repository/
    β”‚   └── post_repository_imp.dart
    └── datasource/
        └── post_remote_datasource.dart
Enter fullscreen mode Exit fullscreen mode

Time to explain how this folder structure brings stuff together 😚.

The data layer also includes data schematization. The models folder contains files that describe the structure of the data received from the data source. These models implement their corresponding entities from the domain layer, enabling a seamless flow of data between the data and domain layers(as mentioned earlier).

In our case, we do have only one model, and it’s the post_model.dart

import 'dart:convert';
import 'package:bloc_patter_course/features/post/domain/entities/post_entity.dart';

class PostModel extends PostEntity {
  PostModel({required super.title, required super.body, required super.id});

  Map<String, dynamic> toMap() {
    return <String, dynamic>{
      'title': title,
      'body': body,
      'id': id,
    };
  }

  factory PostModel.fromMap(Map<String, dynamic> map) {
    return PostModel(
      title: map['title'] as String,
      body: map['body'] as String,
      id: map['id'] is int ? map['id'] : int.parse(map['id'].toString()),
    );
  }

  String toJson() => json.encode(toMap());

  factory PostModel.fromJson(String source) =>
      PostModel.fromMap(json.decode(source) as Map<String, dynamic>);
}

Enter fullscreen mode Exit fullscreen mode

In the repository folder, we find the concrete implementations of the repositories, not the interface abstractions defined in the domain layer. Each repository file implements the methods declared in the corresponding repository interface from the domain layer. This is important because it ensures the data layer aligns with the expectations and contracts defined by the domain layer.

post_repository_imp.dart β†’ (post_repository_implementation)


import 'package:fpdart/fpdart.dart';
import 'package:bloc_patter_course/core/errors/failure.dart';
import 'package:bloc_patter_course/features/posts/data/datasource/post_remote_datasource.dart';
import 'package:bloc_patter_course/features/posts/data/models/post_model.dart';
import 'package:bloc_patter_course/features/posts/domain/entities/post_entity.dart';
import 'package:bloc_patter_course/features/posts/domain/repository/post_repository.dart';

class PostRepositoryImplementation implements PostRepository {
  PostRemoteDatasource postRemoteDatasource;
  PostRepositoryImplementation({required this.postRemoteDatasource});

  @override
  Future<Either<Failure, List<PostEntity>>> getPosts() async {
    try {
      final posts = await postRemoteDatasource.getPosts();

      final data = posts.map((post) => PostModel.fromMap(post)).toList();

      return Right(data);
    } on Failure catch (e) {
      return Left(Failure(message: e.message));
    } catch (e) {
      return Left(Failure());
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

And at the end we have the datasource folder, this is the part in charge of getting data from the actual source, it can be from the device local store(offline use cases) or remote,like in our scenario.

In this article i am using Dio for API requests but feel free to use any library of your choice.

Dio’s configurations

lib/core/network/dio_client.dart. Feel free to update this file to match your requirements.

import 'package:dio/dio.dart';
import 'package:dio_cache_interceptor/dio_cache_interceptor.dart';

final options = CacheOptions(
  store: MemCacheStore(),
  hitCacheOnNetworkFailure: true,
  maxStale: const Duration(days: 7),
  priority: CachePriority.normal,
);

class DioClient {
  static Dio instance() {
    final dio = Dio(BaseOptions(
      baseUrl: "https://jsonplaceholder.typicode.com", //Jsonplaceholder is a great place for getting dummy data.
    ));

    //Optional cashing, 
    // dio.interceptors.add(DioCacheInterceptor(options: options));
    return dio;
  }
}

Enter fullscreen mode Exit fullscreen mode

Datasource

post_remote_datasource.dart


import 'package:bloc_patter_course/core/errors/failure.dart';
import 'package:bloc_patter_course/core/network/dio_client.dart';
import 'package:dio/dio.dart';

Dio http = DioClient.instance();

class PostRemoteDatasource {
  Future<List<dynamic>> getPosts() async {
    try {
      final Response<dynamic> response = await http.get("/posts");

      return response.data;
    } on DioException catch (e) {
      throw Failure(message: e.message as String);
    } catch (e) {
      throw Exception();
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

πŸ’‰Β Dependency injecttion

with all the pieces in place, it’s time to put them all together. Here is a schema of our dependency injection.

                 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                 β”‚        Presentation Layer    β”‚
                 β”‚  (e.g. UI + Bloc/Cubit/View) β”‚
                 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–²β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                              β”‚
                              β”‚ depends on
                              β”‚
                 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                 β”‚        Domain Layer       β”‚
                 β”‚  (Entities + Use Cases +  β”‚
                 β”‚   Repository Interfaces)  β”‚
                 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–²β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                              β”‚
                              β”‚ implemented by
                              β”‚
                 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                 β”‚         Data Layer        β”‚
                 β”‚  (Models + Repositories + β”‚
                 β”‚    Data Sources + Dio)    β”‚
                 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                              β”‚
                              β”‚ initialized in
                              β–Ό
                 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                 β”‚   Dependency Injection File   β”‚
                 β”‚ (e.g. service_locator.dart)   β”‚
                 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Enter fullscreen mode Exit fullscreen mode

While I won’t go into detail about service locators, as that goes beyond the scope of this article, here’s a simple example of how you can use get_it to manage dependency injection effectively in a large-scale Flutter application:

First start by initializing the base class or entry point.

lib/core/injections/service_locator.dart

import 'package:bloc_patter_course/core/injections/post/post_service_locator.dart';
import 'package:get_it/get_it.dart';

final GetIt slInstance = GetIt.instance;

class ServiceLocator {
  Future<void> init() async {

  }
}

Enter fullscreen mode Exit fullscreen mode

Now you can use the above class πŸ‘†πŸ½ as based class, and separate injections based on features.

lib/core/injections/posts/post_service_locator.dart. Dependency injection for the posts feature.

import 'package:bloc_patter_course/features/posts/data/datasource/post_remote_datasource.dart';
import 'package:bloc_patter_course/features/posts/data/repository/post_repository_imp.dart';
import 'package:bloc_patter_course/features/posts/domain/repository/post_repository.dart';
import 'package:bloc_patter_course/features/posts/domain/usecase/get_post_usecase.dart';
import 'package:bloc_patter_course/features/posts/presentation/cubit/posts_cubit.dart';
import 'package:get_it/get_it.dart';

class PostServiceLocator {
  final GetIt sl;
  PostServiceLocator(this.sl);

  void init() {
    sl.registerSingleton<PostRemoteDatasource>(PostRemoteDatasource(),
        instanceName: 'postRemoteDatasource');

    sl.registerSingleton<PostRepository>(
        PostRepositoryImplementation(
            postRemoteDatasource: sl(instanceName: "postRemoteDatasource")),
        instanceName: 'postRepositoryImplementation');

    sl.registerSingleton<GetPostUsecase>(
        GetPostUsecase(
            repository: sl(instanceName: "postRepositoryImplementation")),
        instanceName: "getPostUsecase");

    sl.registerSingleton<PostsCubit>(
        PostsCubit(
          getPostUsecase: sl(instanceName: "getPostUsecase"),
        ),
        instanceName: "postsCubit");
  }
}

Enter fullscreen mode Exit fullscreen mode

Initialize lib/core/injections/posts/post_service_locator.dart. like this


final GetIt slInstance = GetIt.instance;

class ServiceLocator {
  Future<void> init() async {
    final authServiceLocator = PostServiceLocator(slInstance);
    authServiceLocator.init();
  }
}

Enter fullscreen mode Exit fullscreen mode

Diagram

A modular dependency injection structure that cleanly separates core initialization from feature-specific service wiring for scalability and maintainability.

Now that our infrastructure is in place, let's transition into the presentation layer β€” starting with state management setup, and then moving on to how the UI consumes and displays the data.

🌐 Presentation layer

.
└── lib/features/posts/presentation/
    β”œβ”€β”€ cubit/
    β”‚   β”œβ”€β”€ posts_cubit.dart
    β”‚   └── posts_state.dart
    β”œβ”€β”€ screens /
    β”‚   └── posts_screen.dart
    └── widgets /
        └── post.dart 
Enter fullscreen mode Exit fullscreen mode

I prefer cubit over bloc due to it simplicity and non-even-driven approach, if you are more comfortable with bloc please feel free to use it.

posts_state.dart defines all available states.

import 'package:bloc_patter_course/features/posts/domain/entities/post_entity.dart';

//In Dart, a sealed class is a special type of class that restricts inheritance
//to only specific subclasses. The subclasses can only be defined within the same
//library or file where the sealed class itself is defined.
sealed class PostsState {
  PostsState();
}

class PostInitialState extends PostsState {
  PostInitialState();
}

class PostsLoading extends PostsState {
  PostsLoading();
}

class LoadedPosts extends PostsState {
  List<PostEntity> posts;
  LoadedPosts({required this.posts});
}

class PostError extends PostsState {
  PostError();
}

Enter fullscreen mode Exit fullscreen mode

posts_cubit.dart. Cubit implemation

import 'package:bloc_patter_course/features/posts/domain/usecase/get_post_usecase.dart';
import 'package:bloc_patter_course/features/posts/presentation/cubit/posts_state.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

class PostsCubit extends Cubit<PostsState> {
  GetPostUsecase getPostUsecase;

  PostsCubit({required this.getPostUsecase}) : super(PostInitialState());

  Future<void> getPosts() async {
    emit(PostsLoading());
    final posts = await getPostUsecase();

    posts.fold(
        (err) => emit(PostError()), (posts) => emit(LoadedPosts(posts: posts)));
  }
}

Enter fullscreen mode Exit fullscreen mode

widgets/post.dart

import 'package:flutter/material.dart';

class Post extends StatelessWidget {
  final String title;
  final String body;
  const Post({super.key, required this.title, required this.body});

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Card(
        child: Container(
          constraints: BoxConstraints(maxWidth: 500),
          padding: const EdgeInsets.all(16),
          decoration: BoxDecoration(
            borderRadius: BorderRadius.circular(8),
            border: Border.all(
              color: const Color.fromARGB(33, 158, 158, 158),
              width: 1,
            ),
          ),
          width: double.infinity,
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(
                title,
                style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
              ),
              const SizedBox(height: 8),
              Text(
                body,
                style: TextStyle(fontSize: 16, color: Colors.black54),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

Enter fullscreen mode Exit fullscreen mode

Not the most beatiful widget but it shoul do it 😁

screens/posts_screen.dart

import 'package:bloc_patter_course/features/posts/presentation/cubit/posts_cubit.dart';
import 'package:bloc_patter_course/features/posts/presentation/cubit/posts_state.dart';
import 'package:bloc_patter_course/features/posts/presentation/widgets/post.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

class Posts extends StatefulWidget {
  const Posts({super.key});

  @override
  State<Posts> createState() => _PostsState();
}

class _PostsState extends State<Posts> {
  @override
  void initState() {
    context.read<PostsCubit>().getPosts();
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return BlocConsumer<PostsCubit, PostsState>(
      listener: (context, state) {},
      builder: (context, state) {
        return Scaffold(
            appBar: AppBar(
              title: Text('Posts'),
            ),
            body: Builder(builder: (_) {
              if (state is PostsLoading) {
                return Center(
                    child: Container(
                  padding: EdgeInsets.all(20),
                  child: CircularProgressIndicator(),
                ));
              }

              if (state is LoadedPosts) {
                return RefreshIndicator(
                  onRefresh: () async {
                    context.read<PostsCubit>().getPosts();
                  },
                  child: ListView.builder(
                      itemCount: state.posts.length,
                      itemBuilder: (_, index) {
                        return Post(
                            key: Key(state.posts[index].id.toString()),
                            title: state.posts[index].title,
                            body: state.posts[index].body);
                      }),
                );
              }

              return Center(
                  child: Container(
                padding: EdgeInsets.all(20),
                child: const CircularProgressIndicator(),
              ));
            }));
      },
    );
  }
}

Enter fullscreen mode Exit fullscreen mode

Register the Cubit store(posts_cubit.dart) like this πŸ‘‡πŸΎ

import 'package:bloc_patter_course/core/injections/service_locator.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

void main() async {
  ServiceLocator serviceLocator = ServiceLocator();
  WidgetsFlutterBinding.ensureInitialized();
  await serviceLocator.init();

  runApp(MultiBlocProvider(
    providers: [
      BlocProvider(
          create: (_) => slInstance<PostsCubit>(instanceName: "postsCubit"))
    ],
    child: const MyApp(),
  ));
}
Enter fullscreen mode Exit fullscreen mode

That was quite a lot to unpack, wasn’t it? In short, this is how you implement the powerful BLoC design pattern in your Flutter application β€” enabling a scalable architecture and maintainable code as your project grows. So, who is this for? This is for teams working on large-scale applications; implementing this architecture on a small application will be shooting a rabbit with a cannon, and adding unnecessary complexities to a small application.

Feel free to leave your comments, thoughts, ideas and improvements. Don’t forget to like and share .

Thanks for reading πŸ«±πŸΎβ€πŸ«²πŸΌ

source code : https://github.com/DjibrilM/bloc-pattern-architecture-code

Top comments (0)