Adapter Layer: Gateways
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.
Testing and Coding the Gateway
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:
test/features/add_machine/presentation/add_machine_ui_test.dart
/// 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:
lib/features/add_machine/external_interface/add_machine_get_total_gateway.dart
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:
lib/features/add_machine/domain/add_machine_use_case.dart
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:
lib/features/add_machine/presentation/add_machine_presenter.dart
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!