Creating APIs for consumers can be a challenging skill to master. We have to balance APIs that are flexible for reasonable changes, but not overly verbose or complicated in order to cater for all possibilities rather than focusing on the here-and-now. Sometimes, you'll get it wrong and realise that you need to change the way an API works - maybe it needs new arguments in order to take advantage of a new feature. Or perhaps you've realised that the current design isn't optimal and want to replace it with a new design. And sometimes, you'll find the rug pulled from underneath you by a library on which you depend, which forces you to react with a ripple-through breaking change.
Let's assume that we have a library in which we don't want to force breaking changes on the user; these breaking changes might be compile-time (which is in some ways good - at least your users will know immediately that something has broken!) or they could be runtime (in which case, you probably want your users to know in advance and explicitly opt-in). Instead, we would like to warn them that the code they're currently using is currently valid, but give them a migration path to a newer API. How can we (a) provide a new API, (b) not break existing users, yet (c) give users an opt-in migration path to using the new API?
The Obsolete attribute
The [<Obsolete>]
attribute in .NET is a lesser-known but highly useful tool that you can use to solve this challenge. It's an attribute that can be applied to many language elements and provides a compiler warning whenever a caller attempts to access the element that it is applied to. For example, it can be applied to methods, properties or even entire types. This also includes F# types types such as Modules, Records and Discriminated Unions. You can also provide a textual description to the nature of the Obsolete warning - ideally, this should provide guidance on how to mitigate the issue by migrating to the newer alternative.
Here's an example of a standard F# record which has been marked as obsolete:
[Obsolete "This type is obsolete; use Building instead."]
type Hotel = {
Name: string
Address: string
Rating: int
}
Code such as the following will give the appropriate warning for the type annotation as well as once for each field access of the record (so four times in total!):
let describe (hotel:Hotel) =
$"Hotel {hotel.Name} is located at {hotel.Address} and has a rating of {hotel.Rating}."
// warning FS0044: This construct is deprecated. This type is obsolete; use Building instead.
Armed with this, we can provide an intermediate version of our application which supports both the older and newer version of the API; at some point in the future, we can then completely remove the older version, safe in the knowledge that we've given our users time and guidance as to how to migrate to the new API.
You can also supply a boolean argument after the message, which indicates to the compiler that this obsolete violation should be surfaced as an error, not a warning.
Working around Obsolete warnings
One issue you may have with Obsolete warnings is that your own library code will undoubtedly need to use old types whilst the existing code is still valid. How can you prevent these warnings showing in your own code in the "migration" phase of your API (this is especially important if you are treating warnings as errors)?
It turns out that there are a few options at your disposal:
- Do not mark types as Obsolete. Instead, only decorate external-facing functions and methods which you know are not called from your own internal codebase.
- Add the
<NoWarn>
element to your project file e.g.
<NoWarn>FS0044</NoWarn>
However, this will disable all Obsolete warnings across all files in the project - even "genuine" Obsolete warnings that you may want to receive from other libraries that you yourself are relying upon!
- Add the
#nowarn
element inline in your code:
#nowarn "0044"
This will disable all Obsolete warnings, but only for the file that #nowarn is declared in..
A real-world example - Farmer
In Farmer, our ARM template F# DSL, we often face migration challenges as Azure resources change and / or are deprecated. One such example of this is the Postgres Single Server, which has been superceded with the Flexible Server. In Farmer, although we now have support for both types, we want to let users know that the Single Server version will be decommissioned next year.
Therefore, whilst we've now provided overloads for builder keywords for users to take advantage of the new Flexible Server, we've also marked the "old" ones obsolete, therefore letting users know that their current infrastructure will be deprecated at some point in the future, allowing them to put in place a migration plan for moving to the new Flexible Server model. This screenshot shows the typical development experience:
Custom keywords in F# Computation Expressions (the basis for the Farmer DSL experience) are actually just methods on plain classes, decorated with an attribute. Therefore, simply by decorating the associated method with Obsolete, we get the above warnings.
/// Sets the tier of the Single Server instance.
[Obsolete("Use a FlexibleTier instead. " + SingleServerObsoleteMessage)]
[CustomOperation "tier"]
member _.SetTier(state, tier) =
// implementation omitted
Conclusion
APIs always evolve over time, and occasionally we might need to make a breaking change. If that's the case, it's important to help guide users and ideally give them a migration path between old and new API surfaces. The Obsolete
attribute is one such mechanism that you have for doing this.
You can find a simple example of the techniques shown in this blog post here.