Understanding the Problems with Dogmatic Programming Advice
Rethinking Immutability with Proxy Objects: A Practical Guide
Okay, this will be a pretty opinionated article, but it's a topic I feel strongly about. I grew up with Object-Oriented Programming (OOP), and since then, I've thought of objects when designing software systems. At the core of OOP is the idea that objects hold data, which is modified by the methods and functions of the class interface. This paradigm fits well with Flutter's pattern of using a Listenable
interface together with a ListenableBuilder
, which updates the UI displaying the properties of an object every time that object changes.
In fact, if you remove the ability to change the state of an object by calling its functions, you lose much of the beauty and elegance of OOP. To achieve the same functionality with immutable data structures, you have to write a lot more code, which is harder to understand and less efficient.
But…, but mutable shared data is dangerous
I can already hear half of Reddit shouting this when arguing for mutable objects.
Yes, having mutable objects in your system can lead to potential problems that are hard to debug, especially if you're working in a larger team with less experienced developers.
It can be even more problematic in a multithreaded environment where concurrent changes to the same object could occur. However, in Dart and Flutter, we don't have to worry about this since we don't share data across isolates.
When working with async code, you might encounter situations where your data changes in an unexpected order.
I'm not a big fan of Uncle Bob in general, especially his claim on the term "clean" for his monstrosity of architecture. However, he makes an interesting point in one of his YouTube talks. He notes that the real problem in the software industry is its rapid growth, which requires more developers each year. This leads to a large percentage of inexperienced developers in our field, which inevitably affects the quality of the code produced.
Because of this, we often see new best practices introduced and recommended to reduce potential pitfalls while programming.
The best-known examples in recent years are Test Driven Development (TDD) and immutability. Yes, following these concepts can help improve the quality of your software, but sometimes at a significant cost. I'll save TDD for another article and focus only on immutability.
Coming from purely functional programming languages, where immutability makes a lot of sense because every function always returns only copies of the data, the idea was promoted as the ultimate solution to the worst programming nightmares. Unfortunately, as with almost all dogmatic movements, people stopped asking if it makes sense to follow the new rule at all costs and in every situation.
Although Flutter follows the MVU principle from Elm, which can be seen as a function where new data produces a new UI, it is actually a hybrid system designed to update only the minimum part of the UI for top performance. Specifically, the observer pattern that you can implement using ChangeNotifiers
doesn't really make sense with immutable objects because if nothing changes, what should be notified? How do you implement minimal rebuilding of the UI when using immutable objects? How do you implement automatic updates of different parts of your UI that observe the same data?
One solution is to use Streams
or ValueNotifiers
(as a Stream replacement) to send immutable message objects to the widgets you want to change. However, in many cases, the same goal can be achieved with simpler and more readable code by using mutable objects.
So no immutability?
No, don't misunderstand me. I see the benefits of immutable data. But sometimes the goal isn't immutability; it's about having control over who can change the data and when it can be changed.
Make only the members of an object public if they truly need to be public. Often, you can use an abstract interface to hide internal behavior, so users are less tempted to change the state directly from the outside.
Using
final
variables in methods and properties that should not be changed is beneficial. Besides preventing accidental changes, it communicates that this variable won't change.Expose properties of objects only through public getters and modify internal object state only via methods or commands. This way, you can add logging or set breakpoints to see who is changing the object. I'm not a big fan of setters because they can hide additional actions beyond just assigning a value.
Use the Proxy pattern, which the rest of this article will explore.
Using Proxy objects to get the best of both worlds
Very often, we receive Data Transfer Objects (DTOs) from remote APIs in the form of JSON responses. These class definitions are usually generated from something like OpenAPI, so adding extra functionality directly to these DTOs is impractical. I recommend treating DTOs from your backend as immutable data, regardless of whether you enforce that immutability at the code level or not. (I never really understood why a language needs explicit Data classes...)
Let's assume we are writing a social media app that allows users to scroll through feeds of various posts. In the example project for this article, the DTO looks like this:
class PostDto {
final int id;
final String title;
final String imageUrl;
final bool isLiked;
PostDto({
required this.id,
required this.title,
required this.imageUrl,
required this.isLiked,
});
factory PostDto.fromJson(int id, Map<String, dynamic> json) {
return PostDto(
id: id,
title: json['title'],
imageUrl: json['imageUrl'],
isLiked: json['isLiked'],
);
}
}
Instead of directly interacting with this object we wrap it inside a PostProxy
class PostProxy extends ChangeNotifier {
PostDto? _target;
PostProxy(this._target);
String get title => _target!.title;
String get imageUrl => _target!.imageUrl;
bool get isLiked => _target!.isLiked;
}
This is the simplest version. Even though it is not immutable, the proxy's interface prevents any modification of the DTO.
Making the proxy a living object
To fully utilize the power of the proxy, we need to add a bit more logic to it:
class PostProxy extends ChangeNotifier {
PostDto? _target;
PostProxy(this._target);
String get title => _target!.title;
String get imageUrl => _target!.imageUrl;
bool get isLiked => _likeOverride ?? _target!.isLiked;
bool? _likeOverride;
void updateFromApi() {
di<ApiClient>().getPost(_target!.id).then((postDto) {
_target = postDto;
_likeOverride = null;
notifyListeners();
});
}
/// optimistic UI update
Future<void> like(BuildContext context) async {
_likeOverride = true;
notifyListeners();
try {
await di<ApiClient>().likePost(_target!.id);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Failed to like post'),
),
);
}
_likeOverride = null;
notifyListeners();
}
}
Future<void> unlike(BuildContext context) async {
_likeOverride = false;
notifyListeners();
try {
await di<ApiClient>().unlikePost(_target!.id);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Failed to unlike post'),
),
);
}
_likeOverride = null;
notifyListeners();
}
}
}
With this, the Proxy can respond to user input with an immediate UI update and gracefully recover from network issues during like/unlike operations.
(Please excuse the simple way I show the SnackBar)
In the UI, this Proxy can be used with a ListenableBuilder
or, in the case of the Demo App, by using my watch_it
package:
class PostCard extends WatchingWidget {
const PostCard({
super.key,
required this.post,
});
final PostProxy post;
@override
Widget build(BuildContext context) {
/// watch the post to rebuild the widget when the post changes
watch(post);
return Card.outlined(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AspectRatio(aspectRatio: 16 / 9, child: Image.network(post.imageUrl)),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(post.title),
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (post.isLiked)
IconButton(
icon: const Icon(Icons.favorite),
onPressed: () => post.unlike(context),
)
else
IconButton(
icon: const Icon(Icons.favorite_border),
onPressed: () => post.like(context),
),
const SizedBox(width: 8),
IconButton(
onPressed: post.updateFromApi,
icon: const Icon(Icons.refresh)),
],
),
],
),
);
}
}
The use of a nullable override
version of a property to store a data update by the user until we refresh data from the backend can be applied to any property of your business objects.
You can find the full source code for the example app here: https://github.com/escamoteur/proxy_pattern_demo.
In the second part of this article, I will show you how to use this pattern to propagate changes to proxy objects that are observed from different parts of the UI and need to have different lifetimes depending on where they are displayed. Also, we will replace the proxies functions with Commands.
I’m curious how the same demo app would look if written with pure immutable objects. If anyone would like to demonstrate this with a similar small amount of code, please post your fork in the comments.