Clean Framework is a toolkit of classes and implementations that help any developer create a layered architecture on any app, following the principles of Clean Architecture from Uncle Bob (Robert Martin).
To understand the components, first we have to talk about the layers, which are just a way to group your code to avoid interdependencies and to separate concerns.
The following diagram explains how the Clean Architecture proposes the implementation of the layers.
The idea of layering the architecture to separate the domain logic from the implementation details is not recent, and some other approaches have also been proposed (like the Hexagonal Architecture). Bob Martin took good ideas from the existing proposals, so some of the terms may seem familiar.
The core of your app should exist within this layer. Here we have Entity instances that hold the state of all your features. These entities are immutable and should be free of any external code, they should not care about databases, UI, or services. If you are familiar with Domain Driven Design, this is considered your Domain data.
The Use Case is an object that handles the data in the Entities and redirects the flows of data. Use Cases will hold most of the business logic of your features.
Use Cases handle two classes, Input and Output, which move data inside or outside respectively, they are very similar to DDD Events. The next layer can only use these components to send and receive data from the Entities. Since they are simple PODOs (Plain Old Dart Objects), they are completely agnostic from the implementation of the outside layer, and this means the Use Case will usually interact with any type of object without worrying about the details.
To interact with the Outputs and Inputs, Use Cases use requests and filters, and these interactions can be synchronous or subscriptions.
The goal of this layer is to translate the Inputs and Outputs from the Use Case into more specific messages for specific destinations. These components have a similar function than the BDD Adapter. We have to main components, the Presenter and the Gateway
It's job is to translate Outputs into ViewModels, which are contain data and behavior (in the form of callbacks). This class will hold most of your UI logic that is not business related, like navigation.
Presenters will interact with providers of Use Cases to subscribe to a specific Output, so when that output gets updated, we can schedule a refresh on the UI side. Once the Presenter receives the updated Output, it will create a new View Model to be processed by the UI.
When you need external data from sources like REST servers, databases, hardware, cache, etc. Use Cases will send requests with an specific Output. This message will be listened by a Gateway, which translates the Output data into a request that can be processed by the next layer.
There are two types of Gateway, depending on how you need the response to be delivered. The base Gateway class handles requests and waits for a response on the same interaction, blocking the execution until a response or an error is received.
The other type is the WatcherGateway, which will create a subscription. Once the result is received and sent back to the UseCase, it will keep listening for subsequent responses, which are sent to the Use Case through the Input listener.
This is where code from libraries and dependencies interacts with your features. Waits for Requests to happen and then process them depending on its type. Clean Framework include some ready-to-use default implementations to work with Firebase, GraphQL and REST services.
The UI layer is considered a type of External Interface layer since it also relies on messages to an adapter (the Presenter) to send and receive state changes from the entities.
To start using the Clean Framework components, you need to add the library on the pubspec.yaml of the project. Use the latest version available.
dependencies:
clean_framework: ^1.1.0
We suggest you organize your app into Features, with the assumption that features don't depend on each other. The goal should be to be able to delete a feature completely and don't break any code.
Each feature could be organized in this way:
lib
providers_loader.dart
features
my_new_feature
domain
my_new_feature_usecase.dart
my_new_feature_entity.dart
my_new_feature_outputs.dart
my_new_feature_inputs.
presentation
my_new_feature_presenter.dart
my_new_feature_view_model.dart
my_new_feature_ui.dart
external_interfaces
my_new_feature_gateway.dart
Notice that the name of the feature is a prefix for all the files inside. We prefer this naming convention so they are easier to idenfiy on searches, but you are free to follow any convention that suits your need.
The folder structure is also a suggestion, you can add multiple layers if the feature begins to grow and have multiple screens and interactions.
Use Cases, Gateways and External Interfaces are instances of classes that are not Flutter Widgets, so they are not dependant on the Flutter Context. To have access to them, you can "publish" them using the Providers pattern.
If you notice on the files list shown above, outside the features folder we have a file where we list all the providers used on the app. For large projects this is probably not the best idea, since this file can be long and bloated, so probably splitting the providers by feature could work better.
This is an example on how this file can be coded:
final myNewFeatureUseCaseProvider =
UseCaseProvider<MyNewFeatureEntity, MyNewFeatureUseCase>(
(_) => LastLoginUseCase(),
);
final myNewFeatureGatewayProvider = GatewayProvider<MyNewFeatureGateway>(
(_) => MyNewFeatureGateway(),
);
void loadProviders() {
myNewFeatureUseCaseProvider.getUseCaseFromContext(providersContext);
MyNewFeatureGatewayProvider.getGateway(providersContext);
restExternalInterface.getExternalInterface(providersContext);
}
Clean Framework uses Riverpod for the Providers behavior, so you can understand why the providers are global instances. For anyone not familiar to how Riverpod works, this might seem innapropiate, specially comming from a strict OO formation. Justifying why this is useful and desirable, please refer to the Riverpod documentation, since the creator already did a great job explaining this approach.
Providers create instances lazyly, but some of the listeners need to be connected before use cases make any request. That is why we use a global function to "touch" all gateway and external interfaces providers to ensure they are created when the app starts.
The last consideration is to remember to use the function on the main function:
void main() {
loadProviders();
runApp(MyApp());
}
Lets discuss in more detail the components of the UI Layer
As mentioned on the previous topic, the UI component lives on the most external layer of the architecture. It means that it is related to specific libraries that conform the frontend of the application, in our case, the Flutter widgets libraries.
When building an app using the Clean Framework classes, we try to separate as much as possible any code that is not related to pure UI logic and put that on the Presenter (to send and receive data from internal layers) and the Use Case (the normal location for business logic).
UI is a class that behaves like a Stateless Widget. It will be very rare that a Stateful Widget is needed, since the state usage for important data breaks the layer rules. Try to always think on ways the UI widgets without the need for Stateful Widgets.
All UI implementations require at least one View Model to fetch data from the entities. This data comes from Use Case Outputs, which Presenters receive and translate as needed.
The feature you code can be expresed into multiple screens presented to the user, and even include small widgets that are inserted in other screens. These are your entry points to the feature, and as such, will require for the UI to listen to the state changes of the feature's Use Case through its Outputs. In other words, Use Cases can have multiple Outputs, that can have relationships with many View Models through the Presenters.
View Models are immutable classes, almost pure PODO's (Plain Old Dart Objects). We try to make them as lean as possible, because its only responsibility is the passing of digested data fields into the UI object.
They tend to have only Strings. This is intentional since the Presenter has the responsibility of any formating and parsing done to the data.
Finally, the Presenters purpose is to connect and listen to Use Case Providers to interact with the Use Case instance and pass messages for user actions done on the UI (through callbacks on the View Model) and also to trigger rebuilds on the UI when the state changes causes a new Output to be generated. This will be explained in detail on the following sessions, so for now just asume the Presenters associate with only one type of Output.
The most important job of the Presenter is to translate an Output instance and create a new View Model everytime the Output is received.
After a feature folder is created, any developer will probably try to start adding Flutter Widgets to build up the code requirements. This framework is flexible enough to allow you to start coding components that don't require to have any access or even knowledge of any possible dependency (databases, services, cache, etc), because those concerns belong to other layers.
The simplest way to start working on a new feature is to first decide how many UI elements will be required to complete the implementation of the feature. For the sake of simplicity we are going to considering only one widget for the single screen of the new feature.
We are going to code a very simple feature which can be explained in a few Gherkin scenarios:
Given I have navigated to the Add Machine feature
Then I will see the Add Machine screen
And the total shown will be 0.
Given I opened the Add Machine feature
When I write a number on the number field
And I press the "Add" button
Then the total shown will be the entered number.
Given I have entered a number on the Add Machine feature
When I write another number and press "Add"
Then the total shown will be the sum of both numbers.
Given I have added one or more numbers on the Add Machine feature
When I navigate away and open the feature again
Then the total shown is 0.
And this is the design of the page, which we have as reference, but the scope of the codelab won't be to focus on completing the code to reflect exactly the appearance, it will be up to you to finish the implementation.
UI components are extensions of Flutter Widgets, so this means the we have to use a Widget Tester. Our goal is to confirm that the data is retrieved correctly from the view model.
This is how our basic test looks like:
void main() {
uiTest(
'AddMachineUI unit test',
context: ProvidersContext(),
builder: () => AddMachineUI(),
verify: (tester) async {
expect(find.text('Add Machine'), findsOneWidget);
final sumTotalWidget = find.byKey(Key('SumTotalWidget'));
expect(sumTotalWidget, findsOneWidget);
expect(find.descendant(of: sumTotalWidget, matching: find.text('0')), findsOneWidget);
},
);
}
After creating the initial blank project (using ‘flutter create' for instance), you can add this test under the suggested path (features/add_machine/presentation).
Now, to explain the code:
The test is confirming that the first Gherkin scenario happens correctly, but of course the test cannot pass until we have coded the actual UI class. The first piece of code we have to provide is precisely this UI implementation.
But in practice, we not only need that. UI is coupled to a valid ViewModel, which gets translated from a specific Output inside a Presenter. So lets create the minimal code on these classes to make the test pass:
class AddMachineUI extends UI<AddMachineViewModel> {
AddMachineUI({required PresenterCreator<AddMachineViewModel> create})
: super(create: create);
@override
Widget build(BuildContext context, AddMachineViewModel viewModel) {
return Column(children: [
Text('Add Machine'),
Container(
key: Key('SumTotalWidget'),
child: Text(viewModel.total),
),
]);
}
@override
create(PresenterBuilder<AddMachineViewModel> builder) {
throw UnimplementedError();
}
}
class AddMachineViewModel extends ViewModel {
final String total;
AddMachineViewModel({required this.total});
@override
List<Object?> get props => [total];
}
Let's review the code so far:
Now let's look at the necessary changes to the test itself:
void main() {
uiTest(
'AddMachineUI unit test',
context: ProvidersContext(),
builder: () => AddMachineUI(
create: (builder) => AddMachinePresenter(builder: builder),
),
verify: (tester) async {
expect(find.text('Add Machine'), findsOneWidget);
final sumTotalWidget = find.byKey(Key('SumTotalWidget'));
expect(sumTotalWidget, findsOneWidget);
expect(find.descendant(of: sumTotalWidget, matching: find.text('0')),
findsOneWidget);
},
);
}
class AddMachinePresenter
extends Presenter<AddMachineViewModel, AddMachineUIOutput, UseCase> {
AddMachinePresenter({
required PresenterBuilder<AddMachineViewModel> builder,
}) : super(provider: addMachineUseCaseProvider, builder: builder);
@override
AddMachineViewModel createViewModel(UseCase<Entity> useCase, output) =>
AddMachineViewModel(total: output.total.toString());
AddMachineUIOutput subscribe(_) => AddMachineUIOutput(total: 0);
}
class AddMachineUIOutput extends Output {
final int total;
AddMachineUIOutput({required this.total});
@override
List<Object?> get props => [total];
}
final addMachineUseCaseProvider = UseCaseProvider((_) => UseCaseFake());
The Presenter, Output and UseCaseProvider are using as much fake data as possible to control the outcome of the test.
Now lets evolve our current code so we can test the second scenario. This is the test for it:
/// Given I opened the Add Machine feature
/// When I write a number on the number field
/// And I press the "Add" button
/// Then the total shown will be the entered number.
uiTest(
'AddMachineUI unit test - Scenario 2',
context: ProvidersContext(),
builder: () => AddMachineUI(
create: (builder) => AddMachinePresenter(builder: builder),
),
verify: (tester) async {
final numberField = find.byKey(Key('NumberField'));
expect(numberField, findsOneWidget);
await tester.enterText(numberField, '15');
final addButton = find.byKey(Key('AddButton'));
expect(addButton, findsOneWidget);
await tester.tap(addButton);
final sumTotalWidget = find.byKey(Key('SumTotalWidget'));
expect(sumTotalWidget, findsOneWidget);
expect(find.descendant(of: sumTotalWidget, matching: find.text('15')),
findsOneWidget);
},
);
To make this test work, we will need to first move the Presenter code into its corresponding place inside the production features code, complete the implementation, and make the test use a fake Use Case that publishes a single static Output.
class AddMachinePresenter
extends Presenter<AddMachineViewModel, AddMachineUIOutput, UseCase> {
AddMachinePresenter({
required UseCaseProvider provider,
required PresenterBuilder<AddMachineViewModel> builder,
}) : super(provider: provider, builder: builder);
@override
AddMachineViewModel createViewModel(useCase, output) => AddMachineViewModel(
total: output.total.toString(),
onAddNumber: (number) => _onAddNumber(useCase, number));
void _onAddNumber(useCase, String number) {
useCase.setInput<AddMachineAddNumberInput>(
AddMachineAddNumberInput(int.parse(number)));
}
}
class AddMachineViewModel extends ViewModel {
final String total;
final ValueChanged<String> onAddNumber;
AddMachineViewModel({required this.total, required this.onAddNumber});
@override
List<Object?> get props => [total];
}
class AddMachineUIOutput extends Output {
final int total;
AddMachineUIOutput({required this.total});
@override
List<Object?> get props => [total];
}
class AddMachineAddNumberInput extends Input {
final int number;
AddMachineAddNumberInput(this.number);
}
class AddMachineViewModel extends ViewModel {
final String total;
final ValueChanged<String> onAddNumber;
AddMachineViewModel({required this.total, required this.onAddNumber});
@override
List<Object?> get props => [total];
}
About the code so far:
We have to make fixes on the UI to add the new widgets:
class AddMachineUI extends UI<AddMachineViewModel> {
final UseCaseProvider provider;
AddMachineUI({required this.provider});
@override
Widget build(BuildContext context, AddMachineViewModel viewModel) {
final fieldController = TextEditingController();
return Scaffold(
body: Column(children: [
Text('Add Machine'),
Container(
key: Key('SumTotalWidget'),
child: Text(viewModel.total),
),
TextFormField(
key: Key('NumberField'),
controller: fieldController,
decoration: const InputDecoration(
border: UnderlineInputBorder(), labelText: 'Write a number'),
),
ElevatedButton(
key: Key('AddButton'),
onPressed: () => viewModel.onAddNumber(fieldController.value.text),
child: Text('Add'),
),
]),
);
}
@override
create(PresenterBuilder<AddMachineViewModel> builder) =>
AddMachinePresenter(provider: provider, builder: builder);
}
Now that we have a full presenter implementation, the test can stop relying on the test presenter we coded previously, and change the mocks, now we need to mock the Use Case, as follows:
void main() {
uiTest(
'AddMachineUI unit test - Scenario 2',
context: ProvidersContext(),
builder: () => AddMachineUI(provider: addMachineUseCaseProvider),
verify: (tester) async {
final numberField = find.byKey(Key('NumberField'));
expect(numberField, findsOneWidget);
await tester.enterText(numberField, '15');
final addButton = find.byKey(Key('AddButton'));
expect(addButton, findsOneWidget);
await tester.tap(addButton);
await tester.pumpAndSettle();
final sumTotalWidget = find.byKey(Key('SumTotalWidget'));
expect(sumTotalWidget, findsOneWidget);
expect(find.descendant(of: sumTotalWidget, matching: find.text('15')),
findsOneWidget);
},
);
}
final addMachineUseCaseProvider = UseCaseProvider((_) => StaticUseCase([
AddMachineUIOutput(total: 0),
AddMachineUIOutput(total: 15),
]));
class StaticUseCase extends UseCase<EmptyEntity> {
static int _index = 0;
final List<Output> outputs;
StaticUseCase(this.outputs) : super(entity: EmptyEntity());
@override
void setInput<I extends Input>(I input) {
_index++;
entity = EmptyEntity();
}
@override
O getOutput<O extends Output>() {
return outputs[_index] as O;
}
}
class EmptyEntity extends Entity {
@override
List<Object?> get props => [];
}
Hopefuly by now you can appreciate the capacity of the Clean Framework components to help developers work with the UI layer without the need to first finish the Domain Layer code. You can even work in paralel with another developer that is doing it, while also having a high coverage on your code.
It has to be noted that this is very helpful to create MVP builds and have a working prototype that can be reviewed by stakeholders and QA teams, saving the development team a lot of headaches, since the feedback can be received sooner.
Congratulations, at this point we are ready to start exploring the Domain Layer, the heart of anything important for the project.
Let's start by understanding the Entities. If you are familiar with Domain Driven Design (DDD), you already know how important are the Domain components to an app. When the design is robust, there is a zero chance that the state of the app failes due to validation or null errors. Domain models have strict rules so it is very hard to create instances with inconsistent states.
The sum of all your Entities is the state of the whole feature. This state will be kept alive as long as its Use Case exists. Since we create it when the app is executed (using a provider), this reference is alive until the app is removed from memory.
So it is important to understand that this state needs initial values and rules governing how those values chage. When writing an Entity, try to follow these rules:
It is OK to add methods to validate the consistency of the data. For example:
class AccountEntity extends Entity{
final bool isRegistered;
final UserNameEntity userName;
AccountEntity({required this.isRegistered, this.userName});
}
class UserNameEntity extends Entity{
final String firstName;
final String lastName;
UserNameEntity({required this.firstName, this.lastName}) : assert(firstName.isNotEmpty() && lastName.isNotEmpty);
String get fullName => firstName + ' ' + lastName;
}
See how it is virtually impossible to create an inconsistent user name with null or empty first and last name, and we have a dynamic getter that builds the full name.
This has two main advantages:
Try to delegate similar helper methods to the Entity, where they only rely on the data, such as form validations, math calculations, derivatives, etc.
Use Cases live outside the Entities, on its own layer. Use Cases will create and manipulate Entities internally, while transfering data from Inputs and into Outputs. Lets look at one simple example to understand the class:
class MyUseCase extends UseCase<MyEntity> {
MyUseCase()
: super(entity: MyEntity(), outputFilters: {
MyUIOutput: (MyEntity e) => MyUIOutput(data: e.data),
}, inputFilters: {
MyInput: (MyInput i, MyEntity e) => e.copyWith(data: i.data),
});
}
A typical Use Case will need to create an Entity. The output filters attribute lets you set up a list of possible "channels" that Presenters can use to subscribe to.
Here, MyUseCase has only one output, so the Presenter only needs to listen to MyUIOutput instances, which will be generated when the Presenter is created and any time the Entity data field changes.
Notice that the filter is a Map of the type of the Output and a function that receives the current Entity instance. It is intended to do it this way so its easier to isolate the code and help the developer think on simple terms and avoid having complex method calls.
Outputs are meant to only hold a subset of the data available in the Entity, and the way the Presenter and UseCase communication works internally, a new Output is only generated if the fields used for its construction chage. In this example, the Use Case can alter the Entity, but if the data field remains the same, no new Output is created.
Input filters work in a similar way. If a Gateway is attached to a Use Case, it produces a specific type of Input. This class allows a Gateway to send a MyInput instance, which will be used by the input filter anonymous method to create a new version of the Entity based on the data received.
So this means that a MyInput instance is received, it will trigger a Entity change on the data field, and thus generate a new MyUIOuput.
Entities can be changed at any time in other methods inside the Use Case, as in here:
// Method inside the Use Case
void updateAmount(double newAmount){
if (entity.isAmountValid(newAmount))
entity = entity.merge(amount: newAmount);
else
entity = entity.merge(error: Errors.invalidAmount);
}
The entity attribute is available in any UseCase. Each time we need to change at least one field, we need to replace the whole instance. If this is not done, the Use Case will not generate any Output, since it behaves like a ValueNotifier
Use Cases have no knowledge of the world of the ouside layers. They only create Outputs that can be listened by anything. That is why you have to keep the implementation independant from any assumption about the data.
For example, an Output can contain data that will be stored in a database, visualized on a screen, or sent to a service. Only the external layers will determine where the data goes and how it is used.
There are two ways the Use Case sends out Outputs. We already reviewed the output filters, which generate them after the entity changes.
But to create outputs on demand and wait for some kind of response from the outside layers, we use the following:
void fetchUserData(){
await request(FetchUserDataOutput(), onSuccess: (UserDataInput input) {
return entity.merge(
name: input.name);
}, onFailure: (_) {
return entity.merge(error: Error.dataFetchError);
});
}
The request method creates a Future where the instance of FetchUserDataOuput is published. If no one is listening to this specific type of output, an error is thrown. During development you might attach dummy Gateways to help you complete the Use Case behavior without the need to write any outside code.
The request has two callbacks, for success and failures respectively.
Notice how the onSuccess callback is receiving an Input. Remember UseCase communicates with the external layer only with Inputs and Outputs. When outside data needs to come inside the class, it has to be through an Input.
We have already done the Presenter implementation, and now you have a bit more understanding on how it connects to the Use Case. As long as you plan correctly which Outputs will be used on the output filter and by the Presenter, then everything will be handled internally.
Gateways connections will be explained on the next section of the Codelab.
When Gateways and Presenters need to send Inputs to the Use Case, both can use this method:
useCase.setInput<MyInput>(MyInput('foo'));
Gateways do this for you internally, but Presenters are free to use this method at anytime instead of calling a specific method on the UseCase.
Now we are ready to continue the feature implementation we started on the previous section. Let's start with the test for the third Gherkin scenario:
/// Given I have entered a number on the Add Machine feature
/// When I write another number and press "Add"
/// Then the total shown will be the sum of both numbers.
uiTest(
'AddMachineUI unit test - Scenario 3',
context: ProvidersContext(),
builder: () => AddMachineUI(provider: addMachineUseCaseProvider),
verify: (tester) async {
final numberField = find.byKey(Key('NumberField'));
expect(numberField, findsOneWidget);
await tester.enterText(numberField, '15');
final addButton = find.byKey(Key('AddButton'));
expect(addButton, findsOneWidget);
await tester.tap(addButton);
await tester.pumpAndSettle();
final sumTotalWidget = find.byKey(Key('SumTotalWidget'));
expect(sumTotalWidget, findsOneWidget);
expect(find.descendant(of: sumTotalWidget, matching: find.text('15')),
findsOneWidget);
await tester.enterText(numberField, '7');
await tester.tap(addButton);
await tester.pumpAndSettle();
expect(find.descendant(of: sumTotalWidget, matching: find.text('22')),
findsOneWidget);
},
);
// Replace the provider with these lines:
final addMachineUseCaseProvider = UseCaseProvider((_) => StaticUseCase([
AddMachineUIOutput(total: 0),
AddMachineUIOutput(total: 15),
AddMachineUIOutput(total: 22),
]));
This is basically a copy/paste of the previous test, the only needed change is the use case fake now returning an additional output.
Once we have this test coded and passing, its time for some major refactoring on all three tests, since now we want to use a production-worthy use case. Let's add the new Entity and Use Case into their corresponding place inside the domain folder:
class AddMachineEntity extends Entity {
final int total;
AddMachineEntity(this.total);
@override
List<Object?> get props => [total];
}
class AddMachineUseCase extends UseCase<AddMachineEntity> {
AddMachineUseCase()
: super(entity: AddMachineEntity(0), outputFilters: {
AddMachineUIOutput: (AddMachineEntity e) =>
AddMachineUIOutput(total: e.total),
}, inputFilters: {
AddMachineAddNumberInput:
(AddMachineAddNumberInput i, AddMachineEntity e) =>
AddMachineEntity(i.number + e.total),
});
}
// rest of code above, this is the only change:
final addMachineUseCaseProvider = UseCaseProvider((_) => AddMachineUseCase());
After these changes, all 3 tests pass as normal, very easy refactor, right?
Congratulations if you made it until this point, on the next section we will plug-in a Gateway,
We already learned part of this layer componets with the Presenter and View Model. The only thing left to learn here are the Gateways, which handle Outputs used as requests, and create Inputs to be processed by the Use Case.
Let's look at a simple example first:
class MyGateway extends Gateway<MyOutput,
MyRequest, MyResponse, MyInput> {
LastLoginDateGateway({ProvidersContext? context, UseCaseProvider? provider})
: super(
context: context ?? providersContext,
provider: provider ?? lastLoginUseCaseProvider);
@override
MyRequest buildRequest(MyOutput output) {
return MyRequest(data: output.data);
}
@override
MyInput onSuccess(covariant FirebaseSuccessResponse response) {
return MyInput(data: response.data);
}
@override
FailureInput onFailure(FailureResponse failureResponse) {
return FailureInput();
}
}
final myGatewayProvider = GatewayProvider<MyGateway>(
(_) => MyGateway(),
);
In a very similar role to a Presenter, the Gateways are translators, take Outputs and create Requests, passing the data, and when the data is received as a Response, then translate it into a valid Input.
This is the way we create a bridge between very specific libraries and dependencies and the agnostic Domain layer. Gateways exist on a 1 to 1 relationship for every type of Output that is lauched as part of a request from the Use Case.
Since they are created at the start of the execution through a Provider, keep in mind that a loader of providers help you ensure an instance of the Gateway exists before attempting to create requests.
The implementation makes the intent very clear: when the Output is launched, it triggers the onSuccess method to create a Request, which in turns gets launched to any External Interface that is listening to those types of requests.
When the Response is launched by the External Interface, it could come back as a succesful or failed response. On each case, the Gateway generates the proper Input, which is pushed into the Use Case immediately.
These Gateways create a circuit that is thread-blocking. For when you want to create a request that doesn't require an immediate response, you can use another type of Gateway:
class MyGateway extends WatcherGateway<MyOutput,
MyRequest, MyResponse, MyInput> {
// rest of the code is the same
}
When extending the WatcherGateway, the External Interface connected to this Gateway will be able to send a stream of Responses. Each time a Response is received, the onSuccess method will be invoked, so a new Input gets created.
The Use Case in this case will need to setup a proper input filter to allow the Inputs to change the Entity multiple times.
For WatcherGateways, the onFailure method happens when the subscription could not be set for some reason. For example, for Firebase style dependencies, it could happen when attempting to create the connection for the stream of data.
Let's go back to the code of the Add Machine app we used on the previous section. The only scenario we have to code is the one that confirms the number is reset everytime you open the app.
Creating a test for that is trivial, since we can either add an integration test that does the steps, or create a setup idential to a state where the app has closed the feature.
But none of this will require us to write a Gateway or External Interface, so we will need to modify the requirements. Let's assume that the stakeholders found it was more helpful if we retrieved the previous calculated total each time with opened the feature.
This change will require that the apps "remembers" the last total in some way, which will easily require an External Interface. We don't have to decide right now how we are going to store the number. It is more important to finish the implementation the simplest way possible, which is to keep the number in memory inside the External Interface.
Right now we will only care about our Gateway, and how the Use Case will talk to it. So before we jump into the code, lets code the test that needs to pass:
/// Given I have added one or more numbers on the Add Machine feature
/// When I navigate away and open the feature again
/// Then the total shown is the previous total that was shown.
uiTest(
'AddMachineUI unit test - Scenario 4',
context: context,
builder: () => AddMachineUI(provider: addMachineUseCaseProvider),
setup: () {
final gateway = AddMachineGetTotalGateway(
context: context, provider: addMachineUseCaseProvider);
gateway.transport =
(request) async => Right(AddMachineGetTotalResponse(740));
final gatewayProvider = GatewayProvider((_) => gateway);
gatewayProvider.getGateway(context);
},
verify: (tester) async {
expect(find.descendant(of: sumTotalWidget, matching: find.text('740')),
findsOneWidget);
},
);
//...
final context = ProvidersContext();
final addMachineUseCaseProvider = UseCaseProvider((_) => AddMachineUseCase());
This time we use another type of helper, ProviderTester is a bit more flexible, since it can be used to test components that are not UI objects, while still providing a providers context.
Here we are assuming we will have a Home widget that loads our feature UI, and shows us the total. We have to make the app now show that number instead of a 0. This number will be created by the Gateway for now, later we will move it to the External Interface.
Now, lets jump into the Gateway code:
class AddMachineGetTotalGateway extends Gateway<
AddMachineGetTotalOutput,
AddMachineGetTotalRequest,
AddMachineGetTotalResponse,
AddMachineGetTotalInput> {
AddMachineGetTotalGateway({
ProvidersContext? context,
UseCaseProvider? provider,
UseCase? useCase,
}) : super(context: context, provider: provider, useCase: useCase);
@override
buildRequest(AddMachineGetTotalOutput output) {
return AddMachineGetTotalRequest();
}
@override
FailureInput onFailure(covariant FailureResponse failureResponse) {
throw UnimplementedError();
}
@override
onSuccess(covariant AddMachineGetTotalResponse response) {
return AddMachineGetTotalInput(response.number);
}
}
class AddMachineGetTotalRequest extends Request {}
class AddMachineGetTotalResponse extends SuccessResponse {
final int number;
AddMachineGetTotalResponse(this.number);
}
As we learned previously, our Gateway will be associated only with a AddMachineGetTotalOutput, which will get translated into a AddMachineGetTotalRequest object. The output doesn't send any extra data, so our Request is also empty.
The AddMachineGetTotalResponse will hold the preserved number that we retrieve on the External Interface, so the Gateway needs to get it on a successful response and produce a valid AddMachineGetTotalInput that the Use Case can process.
And with this code, the only thing we need to do is make the UseCase do a request to actually retrieve the number:
class AddMachineUseCase extends UseCase<AddMachineEntity> {
AddMachineUseCase()
: super(entity: AddMachineEntity(0), outputFilters: {
AddMachineUIOutput: (AddMachineEntity e) =>
AddMachineUIOutput(total: e.total),
}, inputFilters: {
AddMachineAddNumberInput:
(AddMachineAddNumberInput i, AddMachineEntity e) =>
AddMachineEntity(i.number + e.total),
}) {
onCreate();
}
void onCreate() {
request(AddMachineGetTotalOutput(),
onSuccess: (AddMachineGetTotalInput input) {
return AddMachineEntity(input.number);
},
onFailure: (_) => entity);
}
}
Here we are adding a way to trigger a request. This onCreate method will be used by the Presenter once the UI is built, as follows:
class AddMachinePresenter extends Presenter<AddMachineViewModel,
AddMachineUIOutput, AddMachineUseCase> {
AddMachinePresenter({
required UseCaseProvider provider,
required PresenterBuilder<AddMachineViewModel> builder,
}) : super(provider: provider, builder: builder);
@override
AddMachineViewModel createViewModel(useCase, output) => AddMachineViewModel(
total: output.total.toString(),
onAddNumber: (number) => _onAddNumber(useCase, number));
void _onAddNumber(useCase, String number) {
useCase.setInput<AddMachineAddNumberInput>(
AddMachineAddNumberInput(int.parse(number)));
}
@override
void onLayoutReady(context, AddMachineUseCase useCase) => useCase.onCreate();
}
To be able to use a specific Use Case, we had to include the name of the class in the generics declaration.
With the onLayoutReady override we are able to call any method on the use case the first time the UI is built.
If all these changes are correct and the new test passes, congratulations! You now have attached a custom Gateway to your feature!
The final piece of the Framework is the most flexible one, since it work as a wrapper for any external dependency code from libraries and modules. If coded properly, they will protect you from dependencies migrations and version upgrades.
As usual, let's study the example first:
class TestInterface extends ExternalInterface<TestRequest, TestResponse> {
TestInterface(GatewayProvider provider)
: super([() => provider.getGateway(context)]);
@override
void handleRequest() {
// For normal Gateways
on<FutureTestRequest>(
(request, send) async {
await Future.delayed(Duration(milliseconds: 100));
send(Right(TestResponse('success')));
},
);
// For WatcherGateways
on<StreamTestRequest>(
(request, send) async {
final stream = Stream.periodic(
Duration(milliseconds: 100),
(count) => count,
);
final subscription = stream.listen(
(count) => send(Right(TestResponse(count.toString()))),
);
await Future.delayed(Duration(milliseconds: 500));
subscription.cancel();
},
);
}
}
First let's understand the constructor. It requires a list of Gateway references, which are normally retrieved from providers. During tests, you can add the object reference direcly.
When the External Interface gets created by its Provider, this connection will attach the object to the mechanism that the Gateway uses to send Requests.
The handleRequest method will have one or multiple calls of the on method, each one associated to a Request Type. These types must extend from the Response type specified on the generics class declaration.
Each of the on calls will send back an Either instance, where the Right value is a SuccessResponse, and the Left is a FailureResponse.
External Interfaces are meant to listen to groups of Requests that use the same dependency. Clean Framework has default implementations of external interfaces for Firebase, GraphQL and REST services, ready to be used in any application, you just need to create the providers using them.
For the final changes on our Add Machine app, we will move the code for the static number in the Gateway to the External Interface. There are no further chages on the current tests, but as an exercise you can add an integration test that confirms the last scenario by adding a way to navigate to the feature, pop out, then open it again to confirm the number is preserved.
This is the remaining code:
class AddMachineExternalInterface
extends ExternalInterface<Request, AddMachineGetTotalResponse> {
int _savedNumber;
AddMachineExternalInterface({
required List<GatewayConnection> gatewayConnections,
int number = 0,
}) : _savedNumber = number,
super(gatewayConnections);
@override
void handleRequest() {
on<AddMachineGetTotalRequest>((request, send) {
send(AddMachineGetTotalResponse(_savedNumber));
});
on<AddMachineSetTotalRequest>((request, send) {
_savedNumber = request.number;
send(AddMachineGetTotalResponse(_savedNumber));
});
}
@override
FailureResponse onError(Object error) {
// left empty, enhance as an exercise later
return UnknownFailureResponse();
}
}
See how now we handle two types of request, one to just get the saved total, and the other to modify the total before sending the current value. This requires the creation of another Gateway and request, as follows:
class AddMachineSetTotalGateway extends Gateway<
AddMachineSetTotalOutput,
AddMachineSetTotalRequest,
AddMachineGetTotalResponse,
AddMachineGetTotalInput> {
AddMachineSetTotalGateway({
ProvidersContext? context,
UseCaseProvider? provider,
UseCase? useCase,
}) : super(context: context, provider: provider, useCase: useCase);
@override
buildRequest(AddMachineSetTotalOutput output) {
return AddMachineSetTotalRequest(output.number);
}
@override
FailureInput onFailure(covariant FailureResponse failureResponse) {
throw UnimplementedError();
}
@override
onSuccess(covariant AddMachineGetTotalResponse response) {
return AddMachineGetTotalInput(response.number);
}
}
class AddMachineSetTotalRequest extends Request {
final int number;
AddMachineSetTotalRequest(this.number);
}
And here are the changes for the rest of components:
class AddMachineUseCase extends UseCase<AddMachineEntity> {
AddMachineUseCase()
: super(entity: AddMachineEntity(0), outputFilters: {
AddMachineUIOutput: (AddMachineEntity e) =>
AddMachineUIOutput(total: e.total),
}) {
onCreate();
}
void onAddNumber(int number) async {
await request(AddMachineSetTotalOutput(number + entity.total),
onSuccess: (AddMachineGetTotalInput input) {
return AddMachineEntity(input.number);
}, onFailure: (_) {
return entity;
});
}
void onCreate() async {
await request(AddMachineGetTotalOutput(),
onSuccess: (AddMachineGetTotalInput input) {
return AddMachineEntity(input.number);
},
onFailure: (_) => entity);
}
}
class AddMachinePresenter extends Presenter<AddMachineViewModel,
AddMachineUIOutput, AddMachineUseCase> {
AddMachinePresenter({
required UseCaseProvider provider,
required PresenterBuilder<AddMachineViewModel> builder,
}) : super(provider: provider, builder: builder);
@override
AddMachineViewModel createViewModel(useCase, output) => AddMachineViewModel(
total: output.total.toString(),
onAddNumber: (number) => _onAddNumber(useCase, number));
void _onAddNumber(AddMachineUseCase useCase, String number) {
useCase.onAddNumber(int.parse(number));
}
@override
void onLayoutReady(context, AddMachineUseCase useCase) => useCase.onCreate();
}
The main change is that now the Use Case uses a specific method to handle the request to change the saved number, instead of using an input filter.
And finally, some minor corrections to all the tests, just to enable all the providers:
final context = ProvidersContext();
late UseCaseProvider addMachineUseCaseProvider;
late GatewayProvider getTotalGatewayProvider;
late GatewayProvider setTotalGatewayProvider;
late ExternalInterfaceProvider externalInterfaceProvider;
void main() {
final sumTotalWidget = find.byKey(Key('SumTotalWidget'));
void setup() {
addMachineUseCaseProvider = UseCaseProvider((_) => AddMachineUseCase());
getTotalGatewayProvider = GatewayProvider<AddMachineGetTotalGateway>((_) =>
AddMachineGetTotalGateway(
context: context, provider: addMachineUseCaseProvider));
setTotalGatewayProvider = GatewayProvider<AddMachineSetTotalGateway>((_) =>
AddMachineSetTotalGateway(
context: context, provider: addMachineUseCaseProvider));
externalInterfaceProvider = ExternalInterfaceProvider((_) =>
AddMachineExternalInterface(
gatewayConnections: <GatewayConnection<Gateway>>[
() => getTotalGatewayProvider.getGateway(context),
() => setTotalGatewayProvider.getGateway(context),
]));
getTotalGatewayProvider.getGateway(context);
setTotalGatewayProvider.getGateway(context);
externalInterfaceProvider.getExternalInterface(context);
}
/// Given I have navigated to the Add Machine feature
/// Then I will see the Add Machine screen
/// And the total shown will be 0.
uiTest(
'AddMachineUI unit test - Scenario 1',
context: context,
builder: () => AddMachineUI(provider: addMachineUseCaseProvider),
setup: setup,
verify: (tester) async {
expect(find.text('Add Machine'), findsOneWidget);
expect(sumTotalWidget, findsOneWidget);
expect(find.descendant(of: sumTotalWidget, matching: find.text('0')),
findsOneWidget);
},
);
/// Given I opened the Add Machine feature
/// When I write a number on the number field
/// And I press the "Add" button
/// Then the total shown will be the entered number.
uiTest(
'AddMachineUI unit test - Scenario 2',
context: context,
builder: () => AddMachineUI(provider: addMachineUseCaseProvider),
setup: setup,
verify: (tester) async {
final numberField = find.byKey(Key('NumberField'));
expect(numberField, findsOneWidget);
await tester.enterText(numberField, '15');
final addButton = find.byKey(Key('AddButton'));
expect(addButton, findsOneWidget);
await tester.tap(addButton);
await tester.pumpAndSettle();
await tester.pumpAndSettle();
expect(sumTotalWidget, findsOneWidget);
expect(find.descendant(of: sumTotalWidget, matching: find.text('15')),
findsOneWidget);
},
);
/// Given I have entered a number on the Add Machine feature
/// When I write another number and press "Add"
/// Then the total shown will be the sum of both numbers.
uiTest(
'AddMachineUI unit test - Scenario 3',
context: context,
builder: () => AddMachineUI(provider: addMachineUseCaseProvider),
setup: setup,
verify: (tester) async {
final numberField = find.byKey(Key('NumberField'));
expect(numberField, findsOneWidget);
await tester.enterText(numberField, '15');
final addButton = find.byKey(Key('AddButton'));
expect(addButton, findsOneWidget);
await tester.tap(addButton);
await tester.pumpAndSettle();
expect(sumTotalWidget, findsOneWidget);
expect(find.descendant(of: sumTotalWidget, matching: find.text('15')),
findsOneWidget);
await tester.enterText(numberField, '7');
await tester.tap(addButton);
await tester.pumpAndSettle();
expect(find.descendant(of: sumTotalWidget, matching: find.text('22')),
findsOneWidget);
},
);
/// Given I have added one or more numbers on the Add Machine feature
/// When I navigate away and open the feature again
/// Then the total shown is the previous total that was shown.
uiTest(
'AddMachineUI unit test - Scenario 4',
context: context,
builder: () => AddMachineUI(provider: addMachineUseCaseProvider),
setup: () {
setup();
final gateway = setTotalGatewayProvider.getGateway(context);
// We add a pre-existent request, so by the time the UI is build,
// the use case already has this value
gateway.transport(AddMachineSetTotalRequest(740));
},
verify: (tester) async {
expect(find.descendant(of: sumTotalWidget, matching: find.text('740')),
findsOneWidget);
},
);
}