DEV Community

Cover image for Enums & APIs
Timothy McGrath
Timothy McGrath

Posted on

Enums & APIs

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.

Top comments (5)

Collapse
 
mweel1 profile image
Mardo

I believe enums work great in a single service boundary.

However, once you start exposing them in API's what is the consumer to do with an integer?

It most likely will end up in a database where they have no reference to what the enum is, where a string is always self-documented. They could create a cross-reference and map it to their own enums, but that is just another place something can break or get crossed up.

I'm sticking with strings where I am exposing outside of my service boundary because they are self-documented, and not up for interpretation.

Collapse
 
costinmanda profile image
Costin Manda

I don't actually suggest it, but if the API is versioned, doesn't that mean that the V2 version could use an extended enum that is different from the first by the new value?

Collapse
 
ayyappa99 profile image
Ayyappa • Edited

We usually have a mixed approach for the enums which are likely to change in the future.
let's say WalletTransactionType is an enum which holds Credit, Debit. It may change in future versions when we support transactions with digital wallet or EMI.

Client : Client sdk is generated as if WalletTransactionType properties as a string. We generate the allowed types in the code documentation tags so that as frontend dev tries to set the value, it shows up the documented code to assist him what are allowed.
Server : Server holds the enum type so that its easy to validate for the values passed from client.

Case 1 : Lets say if EMI is added in new version,
V1 : Client sends only Credit, Debit and server validates it fine.
V2 : Client sends all three and server validates it fine.

Case 2 : Let's say we removed Debit option and added EMI.
V1 : Client sends Credit or Debit, server takes the values, takes necessary steps in its v1 version services.
V2 : Works as normal.

Basically we maintain different services, controllers and routers for each version and so does the DTO objects. For a new version, we just clone the existing code and work on it.
If anything breaks, like Case 2, we get a compilation error for V1 versions and we adapt them to match the domain functionality accordingly.

PS : I liked the Unknown extra value though as it can be helpful in some scenarios. I can easily generate unknown for every enum listed in the spec automatically through code generator.

Hope it makes sense.

Collapse
 
gayanper profile image
gayanper

I came across the same problem recently and there were some suggestions to include this unknown literal for all enums at code generation level. One concern i have with that is it introduce unwanted code complexity for all enum handlings. And developers end up in implementing undefined behavior in consumer code for all enums. Do you see the same concerns ?

I like the version API for enum values solution and also if you really want to use enum for something potential change, then define the unknown as part of the API contract and describe it. So API designers can take that decision than a code generator does.

Another option i consider is representing the new value as a attribute in current version of the API provided that this new value can be optional for current consumers.

Collapse
 
smartcodinghub profile image
Oscar

I have enums. And I usually expose a versioned endpoint to get the valid values. But for that master data that can change in some form, I use the database.