Flutter

How to handle breaking changes in your Dart SDK’s dependencies?

Building an SDK is a great way to share your ideas with the Dart community, and to enable developers to access products that empowers their application. However, it comes with a few challenges.

When it reaches a certain age (which means a few months in the fast Dart ecosystem) a SDK will start to be exposed to breaking changes in its dependencies. This is something that is bound to happen as every pub package out there relies at least on the Dart SDK (which is not immune) to breaking changes. To deal with this issue, this article explores three strategies you may follow to ensure a minimal impact on your users, but also on the implementation cost and maintainability of your SDK.

Context

Useful reminders

Dart pub uses semantic versioning. It means among other things that all packages must be versioned using (at least) the X.Y.Z format, where X is called the major version, Y the minor version and Z the patch version. Semantic versioning also states that each new version that increases the minor or patch version number should be backward compatible with all the other versions.

It means that when using the caret syntax in your pubspec.yaml (like collection: ^1.15.0) pub will accept any version between 1.15.0 and 2.0.0 (not included) because semantic versioning guarantees that any of these versions will work with the API defined in the version 1.15.0. This obviously relies on the fact that the collection (or any other package you would want to use) follows the semantic versioning principles, so do not hesitate to pay attention that your dependencies follow them and to warn their maintainers when they don’t. Note that introducing breaking changes without respecting this convention will result in bugs for your users.

The super_example package

With this in mind, let’s imagine that you’ve built the 1.0.0 version of the ++code>super_example++/code> package. This package is built on top of another package, called ++code>super_dependency++/code>. Your package starts to have quite an impact on the community as you see more and more issues and articles about it. Everything is good under the sun, until one day, ++code>super_dependency++/code> releases version 4.0.0 with a bunch of breaking changes.

Dependency graph of the super_example package

Your users start to update their ++code>super_dependency++/code> version and see that your package creates conflicts with it, so they have two possible choice at this point:

  1. Keep the old version of ++code>super_dependency++/code> to be able to continue using your package. It means they won’t get the new API and optimizations of the ++code>super_dependency++/code> package, so they are mad.
  2. Update ++code>super_dependency++/code> and drop your ++code>super_example++/code> package. It means they won’t be able to use your great work anymore, so they are mad.

This is not ideal for you because either way your package creates unintended frustration, whereas it should just be a useful addition to your user’s applications. You need to release at least a new version of ++code>super_example++/code> to handle this breaking change.

Ideally, you would want all these combinations to work:

  • ++code>super_dependency: ^4.0.0++/code> and ++code>super_example: ^new_version++/code>. It’s the “everyone updates” scenario where your user updates everything.
  • ++code>super_dependency: ^3.0.0++/code> and ++code>super_example: ^new_version++/code>. It’s a scenario where your user has the choice to update it’s ++code>super_dependency++/code> or not. It’s exceptionally relevant when your SDK is associated to a paid service, and you don’t want to create an entry barrier of updates for your potential customers. Not only that, but it also guarantees that even the users that don’t update their ++code>super_dependency++/code> version still gets your new features.

Let’s look at 3 strategies you could follow to handle this breaking dependency.

Strategy 1: Hack your way through

In certain cases, we can use some hacks to fix the breaking change while still supporting the old versions of the dependency. Here is a practical example: Let’s say the ++code>super_dependency++/code> have an abstract Dependency class as below:

Let’s say you implement this class in the 1.0.0 version of your ++code>super_example++/code> like this :

Version 4.0.0 of ++code>super_dependency++/code> is out, and now the Dependency class looks like this:

Notice that the param is now typed with int.

Now let’s say you update your implementation like this:

This will ensure that the new version of the ++code>super_example++/code> package is compatible with the version 4.0.0 of the ++code>super_dependency++/code> package. However, it won’t be backwards compatible with the ^3.0.0 versions because in these versions, the Dependency class method must be overridden with a String param.

It seems impossible to have a backwards compatible way of doing this using a legit way.

But we can hack our way through by doing this :

Here, we removed the type annotation. It’s possible because Dart is smart enough to understand that the type of param is the same as the parent method. That way we can support all versions.

This approach is the only one that gives complete freedom to your users, but there is a catch. By following this strategy, you would introduce one or multiple code smells in your SDK, and hit its maintainability. Each hack you add will increase the difficulty to keep the library up, especially in the future. In the example above, you must make sure that no devs will add the int annotation in front of param, at any point in the close future. This rule will not “natively” be caught by your IDE, as your local dev environment will probably depend on the last version of ++code>super_dependency++/code> which will indicate that the type of param is int, so you have to test and document this piece of code cautiously

Finally, this method is not always possible. A good example is what if the Dependency class is simply renamed? You would then be implementing a class that doesn’t exist in the previous versions of ++code>super_dependency++/code>, or one that does not exist in the newer versions.

“Strategy 1: Hack you way out” resulting dependency graph

Strategy 2: Bump the dependency

Here is a quote from the Dart documentation:

"It’s important to actively manage your dependencies and ensure that your packages use the freshest versions possible."

So the idea here is to bump the ++code>super_dependency++/code> package version to 4.0.0, correct your implementation and release a new version of the ++code>super_example++/code> package that does not support the prior versions of ++code>super_dependency++/code>. This is what 99% of pub packages do, including Flutter if you consider Dart as its main dependency. When a new Flutter version is released, you can be sure that it will use the last stable Dart version. This is by far the best strategy to ensure that everyone is trying to go forward with using the latest versions of every library.

This is also the easiest solution for you, because you don’t have to maintain more than the usual. After the update, you can completely forget about the older versions of the ++code>super_dependency++/code> package and if anybody complains about it, they are in the wrong for not trying to update their dependencies, right? Well, it’s not really about who is in the wrong or not, but about offering the best experience to your users. Choosing this path still means that if someone doesn’t want to update its version of ++code>super_dependency++/code> for any reason, then they won’t get your updates neither. So this is actually not the ideal situation for your users because you force them to update the ++code>super_dependency++/code> package to get your new features.

As mentioned in the introduction, it can become problematic if your users are actually customers of a service your SDK provides. In this scenario, bumping the dependency version would mean two things. First, they would be forced to update by a service they pay for, which is not good publicity, and second, it would mean reducing the range of potential new customers your service might attract. It will be easy to convince your current users to update, as they are probably happy with your package. However, it might create situations where you would speak to a potential new client and need to say: “You want our service? You need to update first.”. This could close the door for some deals in the coming months, as a lot of devs tend to wait before updating their dependency.

An alternative to this is to wait a few months before bumping the version. The advantage is you keep the ease of maintainability, and you give more time to your users to consider an update. But this also means that they cannot update before you do, which is not ideal.

“Strategy 2: Bump the dependency” resulting dependency graph

Strategy 3: Maintain both versions

This strategy is a last resort if the second strategy does not work for you. The idea is to double your releases during a short period of time after the release of version 4.0.0 of ++code>super_dependency++/code>. The way it would work is you would split the ++code>super_example++/code> package into two branches. On the first one, you would release the 2.0.0 version of ++code>super_example++/code> which depends on ++code>super_dependency++/code> 4.0.0 and higher. Then a few days or weeks later, when you need to release a new feature, you release it twice: one as the 1.1.0 version which still relies on version 3.0.0 of ++code>super_dependency++/code> and one as the 2.1.0 version which depends on version 4.0.0 and higher.

In this scenario you would have to maintain two versions of ++code>super_example++/code> but most of the extra work could be automated using release scripts that creates the release branch for you. Still, this solution is not suited for every project. Because of the heavy maintenance cost it adds, it requires a very strict behavior concerning tests and documentation, from each member of your team. If you are your own team, then this solution might not be suited for you as it adds a lot work on your plate. That is why most of pub packages uses the second strategy instead of this one, among the fact that this strategy does not motivate your users to update, but rather give them the choice. It’s the best solution for them, because they would have complete freedom on the versions they want to use.

Unfortunately, as you probably understood, it’s the worst solution for you as it greatly increases the maintenance cost of your SDK. The main issue with this strategy is that it works well with one breaking change, but as soon as there’s another one, it doubles. If you rely on another dependency which also breaks, then you have 4 versions to manage. If you only do that for a few months though, it would become very unlikely that two unrelated dependencies break at the same time. But if it were to happen, your ++code>super_example++/code> package would become impossible to maintain. Supporting two versions of the same package can already lead to much more issues even with automation of the release process, but four would be an actual nightmare.

“Strategy 3: Maintain both versions” resulting dependency graph

Conclusion

Each of these strategies have their cons, so none of them can be recommended with eyes closed. With what we saw, my advice would be to first consider the second strategy, bumping your dependency version, with a short delay for the update (around 3 months). The Dart ecosystem has been designed this way for a reason : it changes constantly and very fast. Making sure that each package uses the latest versions of its dependencies ensure that the ecosystem doesn’t get stuck into legacy code, which is a pain for every developer that has to handle this code. This is a commonly known principal in the Flutter/Dart community, as most of Flutter updates depends on the latest Dart SDKs so that the framework always enables the developers to use the latest features of the language.

All three strategies displayed in the same dependency graph

However, if this strategy is impossible, then I would consider using the third strategy and never the first. The maintenance cost is high but the risk is not as high as adding code smells in your codebase.

Finally, some other strategies might exist, a good candidate I didn’t talk about being dropping the conflicting dependency. In any case you should now have some ideas on how to make this ++code>super_example++/code> package thrive along its ++code>super_dependency++/code>! If you want to improve your code quality, make sure to check out our article on how to create custom lints and add them to your project.

Sources

Dart changelogs: https://github.com/dart-lang/sdk/blob/main/CHANGELOG.md

Package versioning (Dart documentation): https://dart.dev/tools/pub/versioning

Semantic versioning: https://semver.org/spec/v2.0.0-rc.1.html

Développeur mobile ?

Rejoins nos équipes