Tratamento de erros em arquiteturas em camadas com padrões Dart

Tempo de leitura: 8 minutes

O tratamento de erros pode ser um dos aspectos mais negligenciados de uma base de código escalável e sustentável, especialmente ao criar aplicativos para centenas, milhares ou até milhões de usuários. Uma arquitetura de software sólida e bem estruturada, uma solução de gerenciamento de estado previsível e práticas de teste adequadas devem estar no centro de qualquer aplicativo pronto para produção de classe mundial, como mencionei em uma postagem anterior no blog. Nesta ocasião, discutirei a importância de uma abordagem consistente e completa para o tratamento de erros nesses tipos de bases de código e como o novo recurso do Dart, Patterns, pode nos ajudar a implementá-lo.

 

O problema

Primeiro, vamos analisar o problema que estamos tentando resolver.

Em uma arquitetura em camadas, cada camada e seus respectivos componentes têm responsabilidades específicas e executam ações concretas, incentivando padrões adequados de injeção de dependência e separação de preocupações, evitando o acoplamento de código. No entanto, exceções podem ocorrer em qualquer ponto dessas camadas e componentes, e é de nosso interesse lidar com essas exceções normalmente e evitar que elas interrompam a experiência do usuário ou, pior ainda, travem o aplicativo completamente.

Então, como podemos garantir a implementação de código resistente a exceções nessas situações? Onde essas exceções serão lançadas? E onde devemos lidar com eles? O código deve falhar silenciosamente ou devemos sempre alertar o usuário final sobre esses problemas? Essas são perguntas comuns que enfrentamos à medida que nosso aplicativo continua a escalar, mais funcionalidades são adicionadas nas diferentes camadas e a base de código só cresce. Portanto, uma abordagem consistente e precisa de tratamento de erros é fundamental para lidar com todos eles, pois minimizará a probabilidade de comportamentos inesperados. Por outro lado, não fazer isso pode levar a inúmeros problemas, como erros silenciosos ou não relatados, falhas do App em produção ou usabilidade ruim, entre outros problemas.

 

A solução

Para resolver o problema acima desde a raiz, aqui está uma receita de três etapas:

  1. Como regra geral, lance exceções apenas no nível do cliente.
  2. Os repositórios nunca devem lançar exceções. Em vez disso, eles devem lidar com as exceções do cliente em bolhas graciosamente e retornar objetos informativos que possam ser tratados adequadamente pelas camadas superiores.
  3. Sempre tenha sua solução de gerenciamento de estado cuidando do problema, permitindo que a camada de apresentação (IU) reaja de acordo.

Dito isso, vamos dar uma olhada em como esse conselho se parece no código Dart de baixo para cima.

 

O cliente

Vamos fingir que estamos trabalhando com uma versão aprimorada do famoso aplicativo contador Flutter onde, toda vez que o usuário aumenta/diminui o contador, uma computação complexa precisa ocorrer. Essa computação é realizada pelo método performComputation no CounterClient.

class CounterClient {
  CounterClient({http.Client? client}) : httpClient = client ?? http.Client();

  final http.Client httpClient;
  static const String _url = 'https://example.com/';

  Future<int> performComputation({
    required int current,
    required int computation,
  }) async {
    try {
      final response = await httpClient.get(Uri.parse(_url));
      final requestInfo =
          'Request Method: ${response.request?.method ?? 'Unknown'}'
          '\n'
          'Request URL: ${response.request?.url ?? 'Unknown'}'
          '\n'
          'Request Headers: ${response.headers}';
      if (response.statusCode >= 200 && response.statusCode <= 300) {
        return current + computation;
      } else if (response.statusCode >= 400 && response.statusCode <= 500) {
        Error.throwWithStackTrace(
          PerformComputationException('Client Error', message: requestInfo),
          StackTrace.current,
        );
      } else if (response.statusCode >= 500 && response.statusCode <= 500) {
        Error.throwWithStackTrace(
          PerformComputationException('Server Error', message: requestInfo),
          StackTrace.current,
        );
      } else {
        Error.throwWithStackTrace(
          PerformComputationException('Unknown Error', message: requestInfo),
          StackTrace.current,
        );
      }
    } catch (error, stackTrace) {
      Error.throwWithStackTrace(PerformComputationException(error), stackTrace);
    }
  }
}

Nesse caso, estamos envolvendo todo o corpo da função performComputation em um bloco try-catch para garantir que nenhuma exceção passe. Além disso, podemos observar que, a menos que response.statusCode esteja dentro do intervalo [200, 300], uma exceção será lançada. É importante saber que esse método lançará apenas um tipo de exceção, PerformComputationException, permitindo rastrear a raiz do problema até esse método ao pesquisar os logs de erro.

abstract class CounterClientException implements Exception {
  const CounterClientException(this.error, {this.message});

  final Object error;
  final String? message;
}

class PerformComputationException extends CounterClientException {
  PerformComputationException(super.error, {super.message});
}

Observe como CounterClientException é uma classe abstrata que define as propriedades mínimas que todas as exceções lançadas pelo CounterClient devem incluir. Assim, poderíamos criar quantas subclasses dessa classe abstrata forem necessárias à medida que adicionarmos novos métodos e funcionalidades a esse cliente — assim como fizemos com PerformComputationException, que estende CounterClientException.

 

O Repositório

Aqui vem a parte mais importante dessa abordagem de tratamento de erros e onde os Dart Patterns entram em vigor. Como mencionamos antes, os repositórios nunca devem lançar exceções. Em vez disso, eles devem retornar um objeto informativo que permita que as camadas superiores reajam a uma operação com falha. Portanto, vamos começar definindo como esse objeto deve ser.

typedef CounterRepoPattern = (CounterRepoFailure?, int?);

typedef CounterRepoFailure = (
  Object error,
  StackTrace stackTrace,
  CounterRepoFailureType type,
  String? message,
);

enum CounterRepoFailureType {
  clientError,
  formatError,
  httpError,
  serverError,
  socketError,
  timeoutError,
  unknown,
}

Em primeiro lugar, deixe-me corrigir-me. O que retornaremos não é exatamente um objeto, mas sim um padrão de dardo. CounterRepoPattern é um padrão cujo primeiro valor é um padrão aninhado que representa um conjunto informativo de valores sobre a exceção lançada, enquanto o segundo valor é o valor real que esperamos retornar se a solicitação for bem-sucedida. Observando mais de perto o CounterRepoFailure, notamos que esse padrão é composto por 4 valores diferentes que ajudarão a camada superior a identificar o que aconteceu e como reagir a isso.

Por fim, também aproveitamos uma das minhas funcionalidades favoritas da linguagem Dart, as extensões.

extension CounterRepoPatternX on CounterRepoPattern {
  CounterRepoFailure? get failure => $1;

  int? get value => $2;
}

extension CounterRepoFailureX on CounterRepoFailure {
  Object get error => $1;

  StackTrace get stackTrace => $2;

  CounterRepoFailureType get type => $3;

  String? get message => $4;
}

CounterRepoPatternX e CounterRepoFailureX tornarão esses padrões muito mais consumíveis para as camadas superiores, pois evitamos usar a sintaxe $ não tão intuitiva.

Além disso, vamos nos aprofundar no CounterRepo e seu método performComputation.

class CounterRepo {
  const CounterRepo({
    required CounterClient counterClient,
  }) : _counterClient = counterClient;

  final CounterClient _counterClient;

  Future<CounterRepoPattern> performComputation({
    required int current,
    required int computation,
  }) async {
    try {
      final result = await _counterClient.performComputation(
        current: current,
        computation: computation,
      );
      return (null, result);
    } on PerformComputationException catch (exception, stackTrace) {
      final error = exception.error;
      final errorParams = switch (error) {
        ClientException => (
            CounterRepoFailureType.clientError,
            (error as ClientException).message,
          ),
        FormatException => (
            CounterRepoFailureType.formatError,
            (error as FormatException).message,
          ),
        HttpException => (
            CounterRepoFailureType.httpError,
            (error as HttpException).message,
          ),
        SocketException => (
            CounterRepoFailureType.socketError,
            (error as SocketException).message,
          ),
        TimeoutException => (
            CounterRepoFailureType.timeoutError,
            (error as TimeoutException).message ?? exception.message,
          ),
        _ => (CounterRepoFailureType.unknown, exception.message),
      };
      return ((error, stackTrace, errorParams.$1, errorParams.$2), null);
    } catch (error, stackTrace) {
      return ((error, stackTrace, CounterRepoFailureType.unknown, null), null);
    }
  }
}

Assim como fizemos no nível do cliente, envolvemos todo o corpo do método com um bloco try-catch para garantir que quaisquer possíveis exceções do cliente sejam capturadas. Se o cálculo for bem-sucedido, retornamos (null, result). No entanto, se algo falhar, estamos lidando com isso e sendo muito específicos e detalhados sobre o que aconteceu. Observe como usamos o bloco on PerformComputationException seguido por um bloco catch engolir tudo para garantir que até mesmo as exceções inesperadas mais estranhas sejam tratadas corretamente. Mergulhando no bloco catch PerformComputationException, podemos observar que a correspondência de padrões nos permite ser muito intencionais sobre as exceções que queremos tratar, mantendo nosso código extremamente legível. Por fim, estamos retornando um CounterRepoPattern que corresponde à assinatura de falha, o que significa que sua primeira parte, o CounterRepoFailure, incluirá todos os valores esperados, enquanto a segunda parte, o valor esperado de sucesso, será nulo.

 

A solução de gerenciamento de estado

Embora muitas soluções de gerenciamento de estado possam se adequar a essa abordagem, optei pelo padrão BLoC e sua implementação flutter, flutter_bloc.

Para este exemplo, refatorei o aplicativo contador inicial criado com very_good_cli e usei um bloc em vez de um cubit. Vamos começar com o arquivo de arquivos de evento e estado.

@immutable
abstract class CounterEvent extends Equatable {
  const CounterEvent();
}

class CounterAppIncremented extends CounterEvent {
  const CounterAppIncremented();

  @override
  List<Object?> get props => [];
}

class CounterAppDecremented extends CounterEvent {
  const CounterAppDecremented();

  @override
  List<Object?> get props => [];
}

counter_event.dart é um arquivo BlocEvent padrão que inclui os dois únicos eventos necessários para este exemplo, CounterAppIncremented e CounterAppDecremented.

enum CounterStatus { initial, loading, success, failure }

@immutable
class CounterState extends Equatable {
  const CounterState({
    this.value = 0,
    this.status = CounterStatus.initial,
    this.failureType,
  });

  final int value;
  final CounterStatus status;
  final CounterRepoFailureType? failureType;

  bool get didFail => status == CounterStatus.failure;

  @override
  List<Object?> get props => [value, status, failureType];

  CounterState copyWith({
    int? value,
    CounterStatus? status,
    CounterRepoFailureType? failureType,
  }) {
    return CounterState(
      value: value ?? this.value,
      status: status ?? this.status,
      failureType: failureType ?? this.failureType,
    );
  }
}

counter_state.dart inclui uma única classe imutável, CounterState. Possui um método auxiliar, copyWith, que nos permitirá emitir novas instâncias de CounterState, que incluem propriedades do estado anterior, mantendo o estado do bloc totalmente imutável.

Por fim, vamos dar uma olhada no arquivo do bloco principal, counter_bloc.dart.

class CounterBloc extends Bloc<CounterEvent, CounterState> {
  CounterBloc({
    required CounterRepo counterRepo,
    required FirebaseCrashlytics crashlytics,
  })  : _counterRepo = counterRepo,
        _crashlytics = crashlytics,
        super(const CounterState()) {
    on<CounterAppIncremented>(_incremented);
    on<CounterAppDecremented>(_decremented);
  }

  final CounterRepo _counterRepo;
  final FirebaseCrashlytics _crashlytics;

  FutureOr<void> _incremented(
    CounterAppIncremented event,
    Emitter<CounterState> emit,
  ) async {
    emit(state.copyWith(status: CounterStatus.loading));
    final pattern = await _counterRepo.performComputation(
      current: state.value,
      computation: 1,
    );
    switch (pattern) {
      case (final CounterRepoFailure failure, null):
        unawaited(
          _crashlytics.recordError(
            failure.error,
            failure.stackTrace,
            reason: failure.message,
          ),
        );
        return emit(
          state.copyWith(
            failureType: failure.type,
            status: CounterStatus.failure,
          ),
        );
      case (null, final int result):
        return emit(
          state.copyWith(value: result, status: CounterStatus.success),
        );
    }
  }

  FutureOr<void> _decremented(
    CounterAppDecremented event,
    Emitter<CounterState> emit,
  ) async {
    emit(state.copyWith(status: CounterStatus.loading));
    final pattern = await _counterRepo.performComputation(
      current: state.value,
      computation: -1,
    );
    switch (pattern) {
      case (final CounterRepoFailure failure, null):
        unawaited(
          _crashlytics.recordError(
            failure.error,
            failure.stackTrace,
          ),
        );
        return emit(state.copyWith(status: CounterStatus.failure));
      case (null, final int result):
        return emit(
          state.copyWith(value: result, status: CounterStatus.success),
        );
    }
  }
}

Este CounterBloc é responsável por reagir aos dois eventos explicados anteriormente assim que são acionados e emitir um estado atualizado. A estrutura fundamental e a lógica necessárias para lidar com ambos os eventos são essencialmente as mesmas, portanto, revisaremos apenas o método _incrementado — observe que as explicações abaixo podem ser facilmente extrapoladas para o método _decrementado.

Primeiro, emitimos um novo estado com o status CounterStatus.loading para comunicar à IU que uma tarefa assíncrona está ocorrendo, permitindo-nos lidar com esse tempo de espera, por exemplo, mostrando um botão giratório de carregamento ou alguma outra animação bacana. Em seguida, executamos o cálculo assíncrono e aguardamos a resposta. Lembre-se, não há necessidade de agrupar este código em um bloco try-catch porque o CounterRepo nunca lançará um erro. Portanto, assim que obtivermos o padrão retornado, podemos, mais uma vez, realizar a correspondência de padrões para determinar o resultado da computação. Observe que, ao seguir essa abordagem, precisamos ser muito intencionais e conscientes sobre o que estamos retornando do repositório e como ele se parece. Ao fazer isso, identificamos que dois casos diferentes com padrões correspondentes adequados devem ser suficientes para cobrir todos os padrões potencialmente retornados pelo método performComputation. Além disso, vale a pena notar que, embora CounterRepoPattern consista em dois valores anuláveis, podemos desempacotá-los com segurança como não anuláveis na instrução switch, tornando a sintaxe mais clara e concisa.

 

Por fim, observe que em ambos os casos, acabamos emitindo um novo estado:

  • Se falhar, emitimos um novo estado, que inclui um status CounterStatus.failure e o failed.type retornado.
  • Se for bem-sucedido, emitimos um estado com o status CounterStatus.success e o resultado do cálculo como o novo valor do contador.

Bônus: a IU

É muito bom usar essa abordagem para evitar que o aplicativo falhe ou para nos manter informados sobre quaisquer erros de tendência. No entanto, os usuários geralmente precisam de algum feedback visual sobre um erro que acabou de ocorrer. Então, vamos ver uma abordagem bastante simples para fazer exatamente isso, aproveitando o sistema que acabamos de implementar.

class CounterPage extends StatelessWidget {
  const CounterPage({super.key});

  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (_) => CounterBloc(
        counterRepo: context.read<CounterRepo>(),
        crashlytics: FirebaseCrashlytics.instance,
      ),
      child: const CounterView(),
    );
  }
}

class CounterView extends StatelessWidget {
  const CounterView({super.key});

  @override
  Widget build(BuildContext context) {
    final l10n = context.l10n;
    return BlocListener<CounterBloc, CounterState>(
      listenWhen: (pre, cur) => pre.didFail && cur.didFail,
      listener: (context, state) {
        late final String errorMessage;
        switch (state.failureType!) {
          case CounterRepoFailureType.clientError:
          case CounterRepoFailureType.formatError:
          case CounterRepoFailureType.httpError:
          case CounterRepoFailureType.serverError:
            errorMessage =
                'Sorry, there was an error on our side. Try again later';
            break;
          case CounterRepoFailureType.socketError:
            errorMessage =
                'Uh oh! There seems to be an error with your connection.';
            break;
          case CounterRepoFailureType.timeoutError:
            errorMessage = 'That request took too long...';
            break;
          case CounterRepoFailureType.unknown:
            errorMessage = 'Oops! Something is not working.';
            break;
        }
        ScaffoldMessenger.of(context)
          ..clearSnackBars()
          ..showSnackBar(SnackBar(content: Text(errorMessage)));
      },
      child: Scaffold(
        appBar: AppBar(title: Text(l10n.counterAppBarTitle)),
        body: const Center(child: CounterText()),
        floatingActionButton: Column(
          mainAxisAlignment: MainAxisAlignment.end,
          crossAxisAlignment: CrossAxisAlignment.end,
          children: [
            FloatingActionButton(
              onPressed: () => context.read<CounterBloc>().add(
                    const CounterAppIncremented(),
                  ),
              child: const Icon(Icons.add),
            ),
            const SizedBox(height: 8),
            FloatingActionButton(
              onPressed: () => context.read<CounterBloc>().add(
                    const CounterAppDecremented(),
                  ),
              child: const Icon(Icons.remove),
            ),
          ],
        ),
      ),
    );
  }
}

 

Como estamos usando flutter_bloc para lidar com o estado de nosso aplicativo, podemos agrupar o Scaffold de nosso CounterView dentro de um BlocListener e ouvir as alterações no getter didFail do estado. Assim, toda vez que ocorrer um erro, o CounterBloc notificará a interface do usuário e poderá mostrar um SnackBar simples, mas informativo, com base no valor CounterRepoFailureType fornecido. Vale a pena mencionar que os enums são uma ferramenta valiosa para tratamento de erros quando usados em combinação com instruções switch porque forçam os desenvolvedores a serem deliberados e propositais ao definir possíveis tipos de erro.

 

Conclusão

Dart Patterns são um instrumento incrivelmente útil que pode nos ajudar na implementação de um sistema de tratamento de erros completo, consistente e previsível em nossas bases de código. Além disso, vimos como podemos obter melhores insights sobre o que pode estar errado com nossa implementação, evitando possíveis falhas de aplicativos em uma arquitetura em camadas com gerenciamento de estado de bloco. Em última análise, tudo se resume à receita de três etapas mencionada na seção de soluções com uma pitada de mentalidade intencional, consciente e de preparação para o pior.