Timothy McGrath
Posted on September 1, 2019
Enums are a double-edged sword. They are extremely useful to create a set of possible values, but they can be a versioning problem if you ever add a value to that enum.
In a perfect world, an enum represents a closed set of values, so versioning is never a problem because you never add a value to an enum. However, we live in the real, non-perfect world and what seemed like a closed set of values often turns out to be open.
So, let's dive in.
Beer API
My example API is a Beer API!
I have a GET that returns a Beer, and a POST that accepts a Beer.
[HttpGet]
public ActionResult<Models.Beer> GetBeer()
{
return new ActionResult<Models.Beer>(new Models.Beer()
{
Name = "Hop Drop",
PourType = Beer.Common.PourType.Draft
});
}
[HttpPost]
public ActionResult PostBeer(Models.Beer beer)
{
return Ok();
}
The Beer class:
public class Beer
{
public string Name { get; set; }
public PourType PourType { get; set; }
}
And the PourType enum:
public enum PourType
{
Draft = 1,
Bottle = 2
}
The API also converts all enums to strings, instead of integers which I recommend as a best practice.
services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2)
.AddJsonOptions(options =>
{
options.SerializerSettings.Converters.Add(new Newtonsoft.Json.Converters.StringEnumConverter());
});
So, the big question comes down to this definition of PourType in the Beer class.
public PourType PourType { get; set; }
Should it be this insted?
public string PourType { get; set; }
We're going to investigate this question by considering what happens if we add a new value to PourType, Can = 3.
Let's look at the pros/cons.
Define As Enum
Pros
When you define PourType as an Enum on Beer, you create discoverability and validation by default. When you add Swagger (as you should do), it defines the possible values of PourType as part of your API. Even better, when you generate client code off of the Swagger, it defines the Enum on the client-side, so they can easily send you the correct value.
Cons
Backwards compatibility is now an issue. When we add Can to the PourType, we have created a new value that the client does not know about. So, if the client requests a Beer, and we return a Beer with the PourType of Can, it will error on deserialization.
Define As String
Pros
This allows new values to be backwards compatible with clients as far as deserialization goes. This will work great in cases where the client doesn't actually care about the value or the client never uses it as an enum.
However, from the API's perspective, you have no idea if that is true or not. It could easily cause a runtime error anyway. If the client attempts to convert it to an enum it will error. If the client is using the value in an IF or SWITCH statement, it will lead to unexpected behavior and possibly error.
Cons
The biggest issue is discoverability is gone. The client has no idea what the possible set of values are, it has to pass a string, but has no idea what string.
This could be handled with documentation, but documentation is notoriously out of date and defining it on the API is a much easier process for a client.
So What Do We Do?
Here's what I've settled on.
Enum!
The API should describe itself as completely as possible, including the possible values for an enum value. Without these values, the client has no idea what the possible values are.
So, a new enum should be considered a version change to the API.
There are a couple ways to handle this version change.
Filter
The V1 controller could now filter the Beer list to remove any Beer's that have a PourType of Can. This may be okay if the Beer only makes sense to clients if they can understand the PourType.
Unknown Value
The Filter method will work in some cases, but in other cases you may still want to return the results because that enum value is not a critical part of the resource.
In this case, make sure your enum has an Unknown value. It will need to be there at V1 for this to work. When the V1 controller gets a Beer with a Can PourType, it can change it to Unknown.
Here's the enum for PourType:
public enum PourType
{
/// <summary>
/// Represents an undefined PourType, could be a new PourType that is not yet supported.
/// </summary>
Unknown = 0,
Draft = 1,
Bottle = 2
}
Because Unknown was listed in the V1 API contract, all clients should have anticipated Unknown as a possibility and handled it. The client can determine how to handle this situation... it could have no impact, it could have a UI to show the specific feature is unavailable, or it could choose to error. The important thing is that the client should already expect this as a possibility.
Resource Solution
One thing that should be considered in this situation is that the enum is actually a resource.
PourType is a set of values that could expand as more ways to drink Beer are invented (Hooray!). It may make more sense to expose the list of PourType values from the API. This prevents any version changes when the PourType adds a new value.
This works well when the client only cares about the list of values (e.g. displaying the values in a combobox). But if the client needs to write logic based on the value it can still have issues with new values, as they will land in the default case.
Exposing the enum as a resource also allows additional behavior to be added to the value, which can help with client logic. For example, we could add a property to PourType for RequiresBottleOpener, so the client could make logic decisions without relying on the "Bottle" value, but just on the RequiresBottleOpener property.
The PourType resource definition:
public class PourType
{
public string Name { get; set; }
public bool RequiresBottleOpener { get; set; }
}
The PourType controller:
[HttpGet]
public ActionResult<IEnumerable<PourType>> GetPourTypes()
{
// In real life, store these values in a database.
return new ActionResult<IEnumerable<PourType>>(
new List<PourType>{
new PourType {Name = "Draft"},
new PourType {Name = "Bottle", RequiresBottleOpener = true},
new PourType {Name = "Can"}
});
}
However, this path does increase complexity at the API and client, so I do not recommend this for every enum. Use the resource approach when you have a clear case of an enum that will have additional values over time.
Conclusion
I have spent a lot of time thinking about this and I believe this is the best path forward for my specific needs.
If you have tackled this issue in a different way, please discuss in the comments. I don't believe there is a perfect solution to this, so it'd be interesting to see other's solutions.
Posted on September 1, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.