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 sustentável e escaloná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 de classe mundial pronto para produção. Nesta artigo, 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, ao mesmo tempo que incentivam padrões adequados de injeção de dependência e separação de interesses, evitando o acoplamento de código. No entanto, exceções podem ocorrer em qualquer ponto dessas camadas e componentes, e é do nosso interesse lidar com essas exceções normalmente e evitar que 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 deveria falhar silenciosamente ou deveríamos sempre alertar o usuário final sobre esses problemas? Essas são questões comuns que enfrentamos à medida que nosso aplicativo continua a crescer, 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 resolver 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, travamentos do aplicativo em produção ou má usabilidade, entre outros problemas.

 

A solução

Para resolver o problema acima pela 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 geradas normalmente e retornar objetos informativos que possam ser tratados adequadamente pelas camadas superiores.
  3. Sempre faça com que sua solução de gerenciamento de estado cuide do problema, permitindo que a camada de apresentação (IU) reaja de acordo.

Dito isso, vamos dar uma olhada na aparência desse conselho no código Dart de baixo para cima.

 

O cliente

Vamos fingir que estamos trabalhando com uma versão aprimorada do famoso aplicativo Flutter counter onde, toda vez que o usuário aumenta/diminui o contador, um cálculo complexo precisa ocorrer. Este cálculo é realizado 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 agrupando 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 este método lançará apenas um tipo de exceção, PerformComputationException, permitindo-nos rastrear a raiz do problema até este método ao pesquisar nos logs de erros.

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 desta classe abstrata fossem necessárias à medida que adicionamos novos métodos e funcionalidades a este cliente – assim como fizemos com PerformComputationException, que estende CounterClientException.

 

O Repositório

Aí vem a parte mais importante dessa abordagem de tratamento de erros e onde os padrões Dart 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 a aparência de tal objeto.

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 Dart Pattern. 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 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 último, 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 em 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 (nulo, resultado). Porém, 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 mais estranhas e inesperadas 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. Em última análise, estamos retornando um CounterRepoPattern que corresponde à assinatura de falha, o que significa que sua primeira parte, 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 de flutter, flutter_bloc.

Para este exemplo, refatorei o aplicativo de contador inicial criado com very_good_cli e usei um bloco em vez de um cubit. Vamos começar com o arquivo de eventos e arquivos de 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 únicos dois eventos que precisamos 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. Ele apresenta 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 último, vamos dar uma olhada no arquivo principal do bloc, 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 _incremented – observe que as explicações abaixo podem ser facilmente extrapoladas para o método _decremented.

Primeiro emitimos um novo estado com o status CounterStatus.loading para comunicar à UI 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 interessante. Em seguida, executamos o cálculo assíncrono e aguardamos a resposta. Lembre-se de que não há necessidade de agrupar esse código em um bloco try-catch porque o CounterRepo nunca gerará um erro. Portanto, uma vez recuperado o padrão retornado, podemos, mais uma vez, realizar a correspondência de padrões para determinar o resultado do cálculo. Observe que ao seguir essa abordagem, precisamos ser muito intencionais e conscientes sobre o que estamos retornando do repositório e sua aparência. Ao fazer isso, identificamos que dois casos diferentes com padrões de correspondência adequados devem ser suficientes para cobrir todos os padrões potencialmente retornados pelo método performComputation. Além disso, é importante notar que, embora CounterRepoPattern consista em dois valores anuláveis, podemos desembrulhá-los com segurança como não anuláveis na instrução switch, tornando a sintaxe mais clara e concisa.

Além disso, tomei a liberdade de incluir um dos meus casos de uso favoritos para essa abordagem de tratamento de erros, que é relatar erros não fatais ao Crashlytics. Para fazer isso, precisamos aproveitar os valores que incluímos no padrão CounterRepoFailure e registrar um novo erro do Crashlytics com _crashlytics.recordError.

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 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 trave ou para nos manter informados sobre quaisquer erros de tendência. No entanto, na maioria das vezes, os usuários 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, sempre que ocorrer um erro, o CounterBloc notificará a UI e poderá mostrar um SnackBar simples, mas informativo, com base no valor CounterRepoFailureType fornecido. Vale a pena mencionar que 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 determinados ao definir possíveis tipos de erros.

 

Conclusão

Os 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 e, ao mesmo tempo, evitar possíveis travamentos do aplicativo 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.