Adapter Layer: Gateways

We already learned part of this layer components with the Presenter and View Model.

The Gateway is the last piece of the puzzle. It is the one that connects the Use Case to the External Interface. It is the one that translates the Output into a Request, and the Response into an Input, for Use Case.

Adaptive Layer

Let's look at a simple example first:

class MyGateway extends Gateway<MyOutput, MyRequest, MyResponse, MyInput> {
  @override
  MyRequest buildRequest(MyOutput output) {
    return MyRequest(data: output.data);
  }

  @override
  MyInput onSuccess(FirebaseSuccessResponse response) {
    return MyInput(data: response.data);
  }

  @override
  FailureInput onFailure(FailureResponse failureResponse) {
    return FailureInput();
  }
}

final myGatewayProvider = GatewayProvider(MyGateway.new);

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 launched 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 successful 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.

Coding the Gateway

Now let's create a Gateway that takes output from the previously created HomeUseCase and creates appropriate input from the data received from PokemonExternalInterface.

lib/features/home/external_interface/pokemon_collection_gateway.dart

class PokemonCollectionGateway extends Gateway<PokemonCollectionGatewayOutput, PokemonCollectionRequest, PokemonSuccessResponse, PokemonCollectionSuccessInput> {
  @override
  PokemonCollectionRequest buildRequest(PokemonCollectionGatewayOutput output) {
    return PokemonCollectionRequest();
  }

  @override
  FailureInput onFailure(FailureResponse failureResponse) {
    return FailureInput(message: failureResponse.message);
  }

  @override
  PokemonCollectionSuccessInput onSuccess(PokemonSuccessResponse response) {
    final deserializer = Deserializer(response.data);

    return PokemonCollectionSuccessInput(
      pokemonIdentities: deserializer.getList(
        'results',
        converter: PokemonIdentity.fromJson,
      ),
    );
  }
}

class PokemonCollectionGatewayOutput extends Output {
  @override
  List<Object?> get props => [];
}

class PokemonCollectionSuccessInput extends SuccessInput {
  PokemonCollectionSuccessInput({required this.pokemonIdentities});

  final List<PokemonIdentity> pokemonIdentities;
}

final _pokemonResUrlRegex = RegExp(r'https://pokeapi.co/api/v2/pokemon/(\d+)/');

class PokemonCollectionRequest extends GetPokemonRequest {
  @override
  String get resource => 'pokemon';

  @override
  Map<String, dynamic> get queryParams => {'limit': 1000};
}

class PokemonIdentity {
  PokemonIdentity({required this.name, required this.id});

  final String name;
  final String id;

  factory PokemonIdentity.fromJson(Map<String, dynamic> json) {
    final deserializer = Deserializer(json);

    final match = _pokemonResUrlRegex.firstMatch(deserializer.getString('url'));

    return PokemonIdentity(
      name: deserializer.getString('name'),
      id: match?.group(1) ?? '0',
    );
  }
}

Now that we have all the necessary pieces, we can now attach them to each other. This is done through providers. Let's get back to where we create our first provider i.e. lib/providers.dart and add two more providers as below.

lib/providers.dart

final pokemonCollectionGateway = GatewayProvider(
  PokemonCollectionGateway.new,
  useCases: [
    homeUseCaseProvider,
  ],
);

final pokemonExternalInterfaceProvider = ExternalInterfaceProvider(
  PokemonExternalInterface.new,
  gateways: [
    pokemonCollectionGateway,
  ],
);

Note: Here we are attaching the PokemonCollectionGateway to the PokemonExternalInterface and the HomeUseCase to the PokemonCollectionGateway.

After this, let's add the pokemonExternalInterfaceProvider to the AppProviderScope, so that it's initialized beforehand any requests is made to it.

lib/main.dart

return AppProviderScope(
  externalInterfaceProviders: [
    pokemonExternalInterfaceProvider,
  ,
  ...,
);

Let's try running the app now. Is it working fine? No, right? We're still getting the static data from the Presenter.

Remember that, Presenters are also part of the adapter layer as it bridges between the use case and the UI. Let's fill in the gaps now. Head back to the HomePresenter and update it as below.

lib/features/home/presentation/home_presenter.dart

class HomePresenter
    extends Presenter<HomeViewModel, HomeUIOutput, HomeUseCase> {
  HomePresenter({
    super.key,
    required super.builder,
  }) : super(provider: homeUseCaseProvider);

  @override
  void onLayoutReady(BuildContext context, HomeUseCase useCase) {
    useCase.fetchPokemons();
  }

  @override
  HomeViewModel createViewModel(HomeUseCase useCase, HomeUIOutput output) {
    return HomeViewModel(
      pokemons: output.pokemons.map((pokemon) {
        return PokemonViewModel(name: pokemon.name, imageUrl: pokemon.imageUrl);
      }).toList(growable: false),
      onSearch: (query) => useCase.setInput(PokemonSearchInput(name: query)),
      onRefresh: () => useCase.fetchPokemons(isRefresh: true),
      onRetry: useCase.fetchPokemons,
      isLoading: output.status == HomeStatus.loading,
      hasFailedLoading: output.status == HomeStatus.failed,
    );
  }

  @override
  void onOutputUpdate(BuildContext context, HomeUIOutput output) {
    if (output.isRefresh) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(
          content: Text(
            output.status == HomeStatus.failed
                ? 'Sorry, failed refreshing pokemons!'
                : 'Refreshed pokemons successfully!',
          ),
        ),
      );
    }
  }
}

Now let's make a request from Use Case. Head back to HomeUseCase and update the todo as below.

await request<PokemonCollectionGatewayOutput, PokemonCollectionSuccessInput>(
  PokemonCollectionGatewayOutput(),
  onSuccess: (success) {
    final pokemons = success.pokemonIdentities.map(_resolvePokemon);

    return entity.copyWith(
      pokemons: pokemons.toList(growable: false),
      status: HomeStatus.loaded,
      isRefresh: isRefresh,
    );
  },
  onFailure: (failure) {
    return entity.copyWith(
       status: HomeStatus.failed,
       isRefresh: isRefresh,
    );
  },
);

...

PokemonData _resolvePokemon(PokemonIdentity pokemon) {
  return PokemonData(
    name: pokemon.name.toUpperCase(),
    imageUrl: 'https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/dream-world/${pokemon.id}.svg',
  );
}

Try running the app now. It should be working fine now.

The full example with the detail page implementation can be found here.