Skip to content

How to work with use cases

Frank Pepermans edited this page Nov 4, 2021 · 8 revisions

Building Stream with a grain of salt

DJ Shadow reference

This guide focuses on use cases, starting with the most straightforward example, and then continuing into advanced behavior.

Use case as a Future

The most basic use case handling is done via a simple Future interface. A use case can simply be called with an incoming parameter, and then provide all output as a Future<List<UseCaseResult<Out>>>.

We use a list, because a single use case can potentially emit multiple events for every incoming parameter. A single UseCaseResult will contain either a value, or an error, should the use case have emitted an error at some point.

First, let's create a very basic use case. We create one that takes an incoming parameter of type int, and then outputs 3 int values:

  • first the incoming value as it is
  • then the incoming value +1
  • then the incoming value +2
class MyUseCase extends UseCase<int, int> {
  @override
  Stream<int> transaction(int param) async* {
    yield param;
    yield param + 1;
    yield param + 2;
  }
}

Next, we need to setup a manager (Cubit) which simply exposes a handler. Inside that handler, the use case is invoked as a Future:

class Manager extends Cubit<State> {
  final MyUseCase _useCase;

  Manager(this._useCase) : super(const State.initial());

  Future<void> handleUseCase(int param) async {
    final result = await _useCase(param);

    for (var entry in result) {
      if (entry.hasData) {
        // emit a state
      } else if (entry.hasError) {
        // handle an error
      }
    }
  }
}

This approach, while fairly simple, does have some considerations:

  • what if the handler is invoked many times in sequence?
    • we cannot cancel the previous use case invocation, as it runs as a Future
    • we would need to add logic inside our manager to mitigate

Use case as a Stream

A different approach is to consume the use case as a Stream instead. While more complex, it gives us fine-grained control on how and when events are processed, we can also transform events if we want to, or even chain multiple use cases in sequence!

Currently, when using use cases as a Stream, they also process events using switchMap, this means that, whenever a new input is added via the handler from the Widget, then the current pipeline will stop and restart from the top using the freshest data.

To facilitate stream consumption, a helper class is provided for usage with Cubits or Blocs. This helper is a mixin called UseCaseBlocHelper, in order to use it, we have to update our manager example:

class Manager extends Cubit<State> with UseCaseBlocHelper<State> {
  final MyUseCase _useCase;

  Manager(this._useCase) : super(const State.initial());
}

Now, we can start using use cases as Streams instead. There are 2 ways to do this, some use cases require input from a Widget, in that case we still need a handler to expose, another type is a use case which doesn't expect any new input, but instead emits whenever it needs to. An example of the latter is the discovery engine, which internally decides when to emit new results, without expecting input.

Pipe and consume

Both situations work with a handler from the UseCaseBlocHelper mixin:

  • pipe: takes a use case, and returns a handler
  • consume: takes a use case, with a starting value, and returns no handler

With our custom use case from above, we will chose the pipe version:

class Manager extends Cubit<State> with UseCaseBlocHelper<State> {
  final MyUseCase _useCase;
  final void Function(int param) _handleUseCase;

  Manager(this._useCase) : super(const State.initial());

  Future<void> handleUseCase(int param) async {
    _handleUseCase(param);
  }

  @override
  Future<void> initHandlers() async {
    _handleUseCase = pipe(_useCase).fold(
      onSuccess: (data) {
        // emit a state
      },
      onFailure: HandleFailure((e, st) {
        // handle an error
      }),
    );
  }
}

onSucces will be invoked with every successful output, and onFailure with every exception.

We can also opt for consume instead, this way, we won't get a handler which can be invoked, instead the use case is subscribed to once only, with initialData.

In both cases, subscriptions will stop, once the Cubit is being disposed. So there is no need for custom code that cancels for example:

class Manager extends Cubit<State> with UseCaseBlocHelper<State> {
  final MyUseCase _useCase;

  Manager(this._useCase) : super(const State.initial());

  @override
  Future<void> initHandlers() async {
    consume(_useCase, initialData: 1).fold(
      onSuccess: (data) {
        // emit a state
      },
      onFailure: HandleFailure((e, st) {
        // handle an error
      }),
    );
  }
}

Error handling

By default, onFailure is very generic, but we can improve it by using matchers. Assume we want to specifically match for ArgumentErrors:

onFailure: HandleFailure((e, st) {
  // handle an error
}, matchers: {
  On<ArgumentError>((e, st) {
    // handle an ArgumentError
  })
}),

Guard

onSuccess and onFailure can all return new states, but there are scenarios where we want tighter control over our state updates.

To achieve this, we can create a single state update handler, where we can compare a potential next state to the current state, and decide if we proceed with emitting the new one, or instead stay with the current one. An example here would be the loading state:

  • if the current state is in a loading state, then any next state update should either be a filled state, or an error state

This state-machine behavior can be added via guard:

pipe(_useCase).fold(
  onSuccess: (data) {
    // emit a state
  },
  onFailure: HandleFailure((e, st) {
    // handle an error
  }),
  guard: (nextState) {
    // compare nextState to this.state, and finally return
    // true if we want to pass nextState,
    // false if we need to remain with this.state
    return true;
  }
);

Transform

Usually, we either pipe or consume a use case directly into a fold with onSuccess and onFailure.

We can however also choose to apply extra transformations along the way, say we want to log something, we can add a LoggerUseCase step, or a CountlyUseCase step even. Or we wish to discard any loading events from a previous use case, then we can simply use a where Stream transformer.

If we wish to transform over another use case, then we can use followedBy as a transformer. It takes the output from a previous use case as the input of a next use case.

pipe(_useCase)
  .transform(
    (out) => out
      .followedBy(LoggerUseCase((it) => 'I have emitted $it')) // logs 1, 2, 3
      .where((it) => it >= 3)
      .followedBy(LoggerUseCase((it) => 'I have emitted $it')) // logs 3 only
  .fold(
    onSuccess: (data) {
      // emit a state
    },
    onFailure: HandleFailure((e, st) {
      // handle an error
    }),
  );

Resolve to a state early

Once transform and use case chaining takes place, we may not want every single event which traverses the transform pipeline to hit onSuccess or onFailure at the end. What if we follow up with another use case, but that use case can only run on the final event of the previous use case? Take a use case which fetches data from an external end point, followed by another one which deserializes that data into Objects. The first use case may emit progress updates, which are useless for the second one, but those progress events to require a state update to loading state.

Therefore, an extra transformer can be used, which swallows events and emits a new state directly:

pipe(_useCase)
  .transform(
    (out) => out
      .followedBy(LoggerUseCase((it) => 'I have emitted $it')) // logs 1, 2, 3
      .maybeResolveEarly(
        condition: (it) => it < 3, 
        stateBuilder: (it) => const State.loading())
      .followedBy(LoggerUseCase((it) => 'I have emitted $it')) // logs 3 only
  .fold(
    onSuccess: (data) {
      // emit a state
    },
    onFailure: HandleFailure((e, st) {
      // handle an error
    }),
  );

This transformer replaces the where transformer from before, it still swallows the first 2 events [1, 2], but instead of just ignoring them, it instead triggers a next state (State.loading()), which of course also passes guard, if provided.