This project is a refactored version of the default Flutter counter app, demonstrating how it could be implemented in a production environment using Clean Architecture, originally introduced by Robert Cecil Martin in his articles Clean Architecture and The Clean Architecture.
The default counter app is often criticized for its simplicity and lack of real-world applicability. This example showcases a more maintainable and scalable approach, incorporating immutability, dependency injection, and reactive state management using streams.
The project follows the four main layers of Clean Architecture:
- Enterprise Business Rules: Core business logic and entities.
- Application Business Rules: Business rules and operations.
- Interface Adapters: Application-specific logic and orchestration, including the User Interface.
- Frameworks & Drivers: Technical details like the database and web frameworks, and the main entry point of the application.
Contains the Counter
entity. This layer does not depend on anything else,
which is evident from the imports in the class.
Contains the IncrementCounter
interface and its implementation
IncrementCounterFakeImpl
. This layer depends only on the Entities layer.
It also includes the CounterGateway
interface to allow use cases to
communicate with the outer layers.
Contains the CounterPresenter
which manages the state and business logic.
This layer depends on both Use Cases and Entities layers.
It also includes the User Interface components like the MyHomePage
widget
and the implementation of the Gateway (FakeCounterGateway
).
Contains technical details like the database and web frameworks. This layer also includes the main entry point of the application, where dependencies are injected and the application is initialized.
The Frameworks & Drivers components have access to all inner layers.
For the sake of simplicity, the inner layers are not decoupled into separate packages in this example. In a production environment, it is essential to enforce the dependency flow by separating these layers into different packages. This ensures that, for example, the Entities layer cannot access the Frameworks & Drivers Layer.
The simplified structure of the project is as follows:
lib/
├── main.dart
├── interface_adapters/
├── use_cases/
└── entities/
To get started with this project, clone the repository and run the following commands:
flutter pub get
flutter run
You can also test the implementation directly on DartPad: https://dartpad.dev/?id=e4faa4d8c6fd26106d566af076795917
To run the tests, use the following command:
flutter test
The tests are adjusted for Clean Architecture, demonstrating how to test each layer independently and ensuring the overall integrity of the application.
This implementation is so simple that it can even fit in the README:
import 'dart:async';
import 'package:flutter/material.dart';
// Frameworks & Drivers Layer
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
final CounterGateway gateway = FakeCounterGateway();
final IncrementCounter incrementCounter = IncrementCounterFakeImpl(
gateway,
);
final CounterPresenter presenter = CounterPresenter(incrementCounter);
return MaterialApp(
title: 'Flutter Demo',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorSchemeSeed: Colors.blue,
),
home: MyHomePage(title: 'Flutter Demo Home Page', presenter: presenter),
);
}
}
// Interface Adapters Layer (Presenter)
class CounterPresenter {
CounterPresenter(this.incrementCounter) {
incrementCounter.counterStream.listen(_updateCounter);
}
final IncrementCounter incrementCounter;
Counter? _counter;
final StreamController<Counter> _controller = StreamController<Counter>();
Stream<Counter> get counterStream => _controller.stream;
void increment() {
if (_counter != null) {
incrementCounter.increment(_counter!);
}
}
void _updateCounter(Counter counter) {
_counter = counter;
_controller.add(_counter!);
}
void dispose() => _controller.close();
}
// Interface Adapters Layer (UI)
class MyHomePage extends StatefulWidget {
const MyHomePage({
required this.title,
required this.presenter,
super.key,
});
final String title;
final CounterPresenter presenter;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
StreamBuilder<Counter>(
stream: widget.presenter.counterStream,
initialData: const Counter(0),
builder: (BuildContext context, AsyncSnapshot<Counter> snapshot) {
return Text(
'${snapshot.data?.value ?? 0}',
style: Theme.of(context).textTheme.headlineMedium,
);
},
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: widget.presenter.increment,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
@override
void dispose() {
widget.presenter.dispose();
super.dispose();
}
}
// Interface Adapters Layer (Fake Gateway Implementation)
class FakeCounterGateway implements CounterGateway {
FakeCounterGateway() {
_controller.add(_counter);
}
Counter _counter = const Counter(0);
final StreamController<Counter> _controller = StreamController<Counter>();
@override
Stream<Counter> watch() => _controller.stream;
@override
Future<void> saveCounter(Counter counter) async {
await Future<void>.delayed(Duration.zero);
_counter = counter;
_controller.add(_counter);
}
}
// UseCases Layer
abstract interface class IncrementCounter {
const IncrementCounter();
void increment(Counter counter);
Stream<Counter> get counterStream;
}
abstract interface class CounterGateway {
const CounterGateway();
Stream<Counter> watch();
Future<void> saveCounter(Counter counter);
}
class IncrementCounterFakeImpl implements IncrementCounter {
IncrementCounterFakeImpl(this.gateway) {
_init();
}
final CounterGateway gateway;
final StreamController<Counter> _controller = StreamController<Counter>();
Future<void> _init() async {
gateway.watch().listen((Counter counter) {
_controller.add(counter);
});
}
@override
void increment(Counter counter) async {
final Counter newCounter = counter.copyWith(value: counter.value + 1);
await gateway.saveCounter(newCounter);
}
@override
Stream<Counter> get counterStream => _controller.stream;
}
// Entities Layer
class Counter {
const Counter(this.value);
final int value;
Counter copyWith({int? value}) {
return Counter(value ?? this.value);
}
}