Error Handling in Layered Architectures with Dart Patterns

Alejandro Ferrero
10 min readMay 15, 2023

--

Error handling might be one of the most overlooked aspects of a maintainable and scalable codebase, especially when building applications for hundreds, thousands, or even millions of users. A solid and well-structured software architecture, a predictable state management solution, and proper testing practices should be at the core of any world-class, production-ready App, as I mentioned in a previous blog post. On this occasion, I’ll be discussing the importance of a consistent and thorough approach to error handling in these types of codebases and how Dart’s new feature, Patterns, can help us implement it.

The Problem

First up, let’s analyze the problem we’re trying to solve.

In a layered architecture, each layer and its respective components have specific responsibilities and perform concrete actions while encouraging proper dependency injection patterns and separation of concerns, avoiding code coupling. However, exceptions can occur at any given point across these layers and components, and it’s in our best interest to handle these exceptions gracefully and prevent them from disrupting the user experience or, worse yet, crashing the App altogether.

So, how can we ensure the implementation of exception-resistant code in these situations? Where are these exceptions going to be thrown? And where should we be handling them? Should the code fail silently, or should we always alert the end user about these problems? These are common questions we face as our App continues to scale, more functionality is added across the different layers, and the codebase only grows larger. Therefore, a consistent and precise error-handling approach is fundamental to address them all, as it will minimize the probability of unexpected behaviors. On the other hand, failing to do so can lead to numerous problems, such as silent or non-reported errors, App crashes in production, or poor usability, among other issues.

The Solution

To tackle the above problem from the root, here’s a three-step recipe:

  1. As a general rule, only throw exceptions at the client level.
  2. Repositories should never throw exceptions. Instead, they should handle the bubbled-up client exceptions gracefully and return informative objects that can be handled appropriately by the upper layers.
  3. Always have your state management solution take care of the problem allowing the presentation layer (UI) to react accordingly.

That being said, let’s take a look at what this advice looks like in Dart code in a bottom-up fashion.

The Client

Let’s pretend we’re working with an enhanced version of the famous Flutter counter App where, every time the user increases/decreases the counter, a complex computation needs to take place. This computation is carried out by the performComputation method under the 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);
}
}
}

In this case, we’re wrapping the entire performComputation’s function body in a try-catch block to ensure no exceptions slip through. Additionally, we can observe that unless the response.statusCode is within the [200, 300) range, an exception will be thrown. It’s important to know that this method will only throw a type of exception, PerformComputationException, allowing us to trace the issue’s root back to this method when digging into the error logs.

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});
}

Notice how CounterClientException is an abstract class defining the bare minimum properties all exceptions thrown by the CounterClient must include. Thus, we could create as many subclasses of this abstract class as necessary as we add new methods and functionality to this client — just like we did with PerformComputationException, which extends CounterClientException.

The Repository

Here comes the most important piece of this error-handling approach and where Dart Patterns come into place. As we mentioned before, repositories should never throw exceptions. Instead, they should return an informative object that allows upper layers to react to a failed operation. Hence, let’s start by defining what such an object should look like.

typedef CounterRepoPattern = (CounterRepoFailure?, int?);

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

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

First of all, let me correct myself. What we will be returning is not quite an object, but rather a Dart Pattern. CounterRepoPattern is a pattern whose first value is a nested pattern representing an informative set of values about the thrown exception, while the second value is the actual value we’re hoping to return if the request is successful. Taking a closer look at CounterRepoFailure, we notice that this pattern is composed of 4 different values that will help the upper layer identify what happened and how to react to it.

Lastly, we also leverage one of my favorite functionalities from the Dart language, extensions.

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 and CounterRepoFailureX will make these patterns much more consumable to the upper layers as we avoid using the not-so-intuitive $ syntax.

Moreover, let’s dig into the CounterRepo and its performComputation method.

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);
}
}
}

Much like we did at the client level, we wrap the entire method’s body with a try-catch bloc to make sure any potential exceptions from the client are caught. If the computation succeeds, we return (null, result). However, if something fails, we’re handling it and being very specific and detailed about what happened. Notice how we use the on PerformComputationException block followed by a swallow-all catch block to make sure even the strangest of unexpected exceptions are handled properly. Diving into the PerformComputationException catch block, we can observe that pattern matching allows us to be very intentional about the exceptions we want to handle while keeping our code extremely readable. Ultimately, we’re returning a CounterRepoPattern that matches the failure signature, meaning that its first piece, the CounterRepoFailure, will include all the expected values, while the second piece, the expected successful value, will be null.

The State Management Solution

Although many state management solutions may fit this approach, I opted for the BLoC pattern and its flutter implementation, flutter_bloc.

For this example, I refactored the starter counter application created with very_good_cli and used a bloc instead of a cubit. Let’s start with the event and state files file.

@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 is a standard BlocEvent file including the only two events we need for this example, CounterAppIncremented and 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 includes a single immutable class, CounterState. It features a helper method, copyWith, that will allow us to emit new CounterState instances, which include properties from the previous state while keeping the bloc’s state fully immutable.

Lastly, let’s take a look at the core bloc file, 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),
);
}
}
}

This CounterBloc is responsible for reacting to the two previously explained events as soon as they’re triggered and emitting an updated state. The fundamental structure and logic required to handle both events are essentially the same, so we’ll only be reviewing the _incremented method — notice the explanations below can be easily extrapolated to the _decremented method.

We first emit a new state with the status CounterStatus.loading to communicate to the UI an asynchronous task is taking place, allowing us to handle this wait time by, for example, showing a loading spinner or some other cool animation. Then, we execute the async computation and await the response. Remember, there’s no need to wrap this code in a try-catch block because the CounterRepo will never throw an error. Therefore, once we get the returned pattern back, we can, once again, perform pattern matching to determine the outcome of the computation. Notice that when following this approach, we need to be very intentional and conscious about what we’re returning from the repository and what it looks like. By doing so, we identify that two different cases with adequate matching patterns should suffice to cover all the potentially returned patterns by the performComputation method. Moreover, it’s worth noticing that even though CounterRepoPattern consists of two nullable values, we can confidently unwrap them as non-nullable in the switch statement making the syntax clearer and more concise.

Additionally, I took the liberty to include one of my favorite use cases for this error-handling approach, which is reporting non-fatal errors to Crashlytics. To do so, we need to leverage the values we included in the CounterRepoFailure pattern and record a new Crashlytics error with _crashlytics.recordError.

Lastly, notice that in both cases, we end up emitting a new state:

  • If it fails, we emit a new state, which includes a CounterStatus.failure status and the returned failure.type.
  • If it succeeds, we emit a state with a CounterStatus.success status and the result of the computation as the new counter value.

Bonus: The UI

It’s all good and fine to use this approach to prevent the App from crashing or to keep us informed about any trending errors. However, users most often need some visual feedback about an error that just occurred. So, let’s see a pretty simple approach to do just that by leveraging the system we just implemented.

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),
),
],
),
),
);
}
}

Since we’re using flutter_bloc to handle our App’s state, we can wrap our CounterView’s Scaffold inside a BlocListener and listen for changes to the state’s didFail getter. Thus, every time an error occurs, the CounterBloc will notify the UI, and it will be able to show a simple yet informative SnackBar based on the given CounterRepoFailureType value. It’s worth mentioning that enums are a valuable tool for error handling when used in combination with switch statements because they force developers to be deliberate and purposeful when defining potential error types.

Conclusion

Dart Patterns are an incredibly useful instrument that can help us when implementing a thorough, consistent, and predictable error-handling system in our codebases. Furthermore, we’ve seen how we can get better insights about what may be wrong with our implementation while preventing potential App crashes in a layered architecture with bloc state management. Ultimately, it all comes down to the three-step recipe mentioned in the solution section with a pinch of intentional, conscious, and prepare-for-the-worst mentality.

And that’s it! I would love to hear your feedback about this blog or discuss any related topics, so feel free to drop a comment or reach out to me on Twitter.

--

--

Alejandro Ferrero

🤤 Founder of Foodium 💙 Lead Mobile Developer at iVisa 💻 Former Google DSC Lead at PoliMi 🔗 Blockchain enthusiast 🇪🇸 🇺🇸 🇮🇹 🇦🇩 Been there, done that