The Enum That Became a Production Incident

Enums are one of those C# features that feel too small to deserve much architectural attention. You use them for status. You use them for type. You use them for source, category, reason, mode, provider, direction, level, permission, and every other value that seems to belong to a neat fixed list. Thats exactly why they are dangerous.
An enum is simple when the whole system lives inside one process, one deployment, one database schema, and one version of the code. Real systems rarely stay that clean. The moment an enum crosses a boundary, it stops being just a C# convenience. It becomes part of your contract. That contract can leak everywhere. Once that happens, changing the enum is no longer a small refactor. Its a compatibility decision.
This post is about the enum bugs that do not look serious in code review, but can quietly become production incidents.
The simple enum that caused the damage
Start with something ordinary.
public enum PaymentStatus
{
Created = 1,
Authorised = 2,
Settled = 3,
Failed = 4
}
This looks fine. It is readable. It removes magic strings. It makes the domain easier to talk about. Then the system grows. The API accepts payments. SQL stores the status. A worker sends settlement files. Another worker sends notifications. A reporting job builds dashboards. A support tool allows manual updates. Another service consumes payment events. The enum is now everywhere.
At this point, PaymentStatus is not just code. It is stored data. It is API data. It is message data. It is operational data. That changes the rules.
C# enums are numbers underneath
C# enum types are value types backed by an integral numeric type. If you dont specify the underlying type, the default is int.
That means this enum:
public enum PaymentStatus
{
Created = 1,
Authorised = 2,
Settled = 3,
Failed = 4
}
is also a set of numbers. That seems obvious, but it leads to a nasty mistake. Developers often treat enums as if the type system guarantees the value must be one of the named members. It doesnt.
You can do this:
var status = (PaymentStatus)999;
Console.WriteLine(status);
The compiler allows it. The runtime allows it. Your enum variable can hold a numeric value that has no named member. That alone surprises people. Now imagine 999 comes from JSON, a database row, a queue message, or a bad import. If the code assumes every enum value is named, the bug can travel a long way before anyone notices.
Parsing can accept numeric values
This is one of the easiest places to get caught. You might write this at an API boundary:
if (!Enum.TryParse<PaymentStatus>(value, ignoreCase: true, out var status))
{
return Results.BadRequest("Invalid payment status.");
}
It looks reasonable.
The problem is that Enum.TryParse converts the string representation of either the name or the numeric value of enum constants. In practice, that means a string like "Settled" can parse, but so can "3". More importantly, "999" can also parse into (PaymentStatus)999.
So this can happen:
Enum.TryParse<PaymentStatus>("999", out var status);
Console.WriteLine(status);
TryParse tells you the text was convertible to the enum type. It does not prove the value is one of the named values you intended to allow. If you need that check, you need a second step.
public static bool TryParseDefinedPaymentStatus(
string? value,
out PaymentStatus status)
{
status = default;
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
if (!Enum.TryParse(value, ignoreCase: true, out status))
{
return false;
}
return Enum.IsDefined(status);
}
Thats better for normal enums. Its not automatically right for [Flags] enums, because a valid combined value may not be defined as a single named enum member. Flags need separate handling. More on that later.
Default enum values are often accidental states
Now look at this DTO.
public sealed record CreatePaymentRequest(
decimal Amount,
string Currency,
PaymentStatus Status);
What is the default value of PaymentStatus?
Its 0.
But the enum starts at 1.
public enum PaymentStatus
{
Created = 1,
Authorised = 2,
Settled = 3,
Failed = 4
}
That means default(PaymentStatus) is not a named value.
A missing value can become 0.
If your code does not handle that clearly, you get a ghost status. It exists in memory. It can be written to the database. It may appear in logs as 0. It may not appear in dashboards because nobody expected it.
The safer pattern is usually to make the zero value explicit.
public enum PaymentStatus
{
Unknown = 0,
Created = 1,
Authorised = 2,
Settled = 3,
Failed = 4
}
This does not magically fix the design. It does make the accidental state visible.
Now you can reject it at the boundary.
if (request.Status is PaymentStatus.Unknown)
{
return Results.BadRequest("Payment status is required.");
}
Or you can allow it internally only where it has a clear meaning.
The key point is simple: never let 0 become an unnamed accident.
JSON can turn enum design into API design
Enums become more dangerous once they appear in JSON. You have two broad choices. You can serialise them as numbers:
{
"status": 3
}
Or as strings:
{
"status": "settled"
}
Numbers are compact, but they are a poor public contract. They force every consumer to know your internal numeric mapping. They also make logs, traces, payload captures, and support tickets harder to read. Strings are easier to read and safer across systems, but they still need versioning discipline. If you rename Authorised to Authorized, you have changed the wire contract unless you handle the old value. With System.Text.Json, JsonStringEnumConverter<TEnum> can convert enum values to and from strings.
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.Converters.Add(
new JsonStringEnumConverter<PaymentStatus>(
namingPolicy: JsonNamingPolicy.CamelCase,
allowIntegerValues: false));
});
The allowIntegerValues: false part is important. The default constructor allows integer values. That can mean your API accepts "status": 999 even when you intended to expose string enum names only. For public APIs, I would usually reject integer enum values unless there is a specific compatibility reason to allow them.
The detail here is not about being precious over JSON style. It is about avoiding accidental contracts. Once clients start sending 3, they depend on 3 meaning the same thing forever.
Changing enum order can corrupt meaning
This is the classic enum mistake.
A developer starts with this:
public enum PaymentStatus
{
Created,
Authorised,
Settled,
Failed
}
The implicit values are:
Created = 0
Authorised = 1
Settled = 2
Failed = 3
Later, someone adds a new value in the middle.
public enum PaymentStatus
{
Created,
Validating,
Authorised,
Settled,
Failed
}
Now the values are:
Created = 0
Validating = 1
Authorised = 2
Settled = 3
Failed = 4
If the enum is only used inside the same version of the same process, that is not usually a problem. If the numeric values are stored in SQL, sent over queues, written into JSON, exported to data lakes, or consumed by another service, you may have changed the meaning of historical data.
Yesterday, 2 meant Settled.
Today, 2 means Authorised.
That is not a refactor. That is data corruption by redeployment.
Always assign explicit values to enums that cross a boundary.
public enum PaymentStatus
{
Unknown = 0,
Created = 1,
Authorised = 2,
Settled = 3,
Failed = 4,
Validating = 5
}
New values go at the end unless you are deliberately reserving ranges. You do not need to love this style. You only need to remember that the number may outlive the code that created it.
Old services do not know your new enum value
This is where enum bugs become distributed system bugs.
Imagine you add Reversed.
public enum PaymentStatus
{
Unknown = 0,
Created = 1,
Authorised = 2,
Settled = 3,
Failed = 4,
Reversed = 5
}
The API deploys first and starts publishing events.
{
"paymentId": "pay_123",
"status": "reversed"
}
But the notification worker is still running the old code.
It only knows:
public enum PaymentStatus
{
Unknown = 0,
Created = 1,
Authorised = 2,
Settled = 3,
Failed = 4
}
What happens?
That depends on how the worker deserialises, validates, and handles unknown values. It might fail the message. It might dead letter it. It might map it to Unknown. It might fall into a default branch. It might treat it as a status that should never happen.
The dangerous version is this:
var message = JsonSerializer.Deserialize<PaymentEvent>(json);
var template = message.Status switch
{
PaymentStatus.Created => "payment-created",
PaymentStatus.Authorised => "payment-authorised",
PaymentStatus.Settled => "payment-settled",
PaymentStatus.Failed => "payment-failed",
_ => "payment-created"
};
That fallback looks harmless. Its not. A reversed payment could send a created-payment notification.
Thats how a small enum change becomes a customer-facing incident.
The fix is not always to reject unknown values. Sometimes rejecting is right. Sometimes preserving and ignoring is right. Sometimes mapping to Unknown is right.
The real fix is to make that decision explicit.
var template = message.Status switch
{
PaymentStatus.Created => "payment-created",
PaymentStatus.Authorised => "payment-authorised",
PaymentStatus.Settled => "payment-settled",
PaymentStatus.Failed => "payment-failed",
PaymentStatus.Reversed => "payment-reversed",
PaymentStatus.Unknown => throw new InvalidOperationException(
"Cannot send notification for unknown payment status."),
_ => throw new InvalidOperationException(
$"Unsupported payment status: {message.Status}")
};
Do not quietly guess when the system sees a value it does not understand.
Databases make enum mistakes permanent
Storing enum values as integers is common.
public sealed class Payment
{
public Guid Id { get; set; }
public PaymentStatus Status { get; set; }
}
With EF Core, enum values are commonly mapped to their underlying numeric values by convention. EF Core also supports value converters, including converting enum values to strings in the database. Numeric storage has benefits. It is compact. It is stable if you never change existing numeric assignments. It is easy to index. String storage has different benefits. It is readable. It avoids meaning drift when enum member order changes. It makes manual investigation easier. It can also make external reporting clearer.
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Payment>()
.Property(x => x.Status)
.HasConversion<string>();
}
This stores values such as "Settled" rather than 3.
That is not automatically better in every system. Renaming enum members becomes a data migration problem. Case changes can matter depending on collation and converter behaviour. Strings take more space. Reporting systems may still want normalised reference data. The point isnt "always store enums as strings". The point is that storage is part of the contract.
You need to choose intentionally.
For short-lived internal technical states, an int enum can be fine. For business statuses that appear in support tools, reports, integrations, and audit trails, I would seriously consider a string column or a lookup table.
Switch expressions can hide unsafe defaults
Switch expressions are clean.
public static bool CanSettle(PaymentStatus status)
{
return status switch
{
PaymentStatus.Authorised => true,
PaymentStatus.Created => false,
PaymentStatus.Settled => false,
PaymentStatus.Failed => false,
_ => false
};
}
That _ => false looks safe. Sometimes it is. But it also hides unknown values. If the business wants unknown statuses to stop processing loudly, this fallback does the opposite. It quietly suppresses the issue. For operational code, I often prefer this:
public static bool CanSettle(PaymentStatus status)
{
return status switch
{
PaymentStatus.Authorised => true,
PaymentStatus.Created => false,
PaymentStatus.Settled => false,
PaymentStatus.Failed => false,
PaymentStatus.Unknown => false,
_ => throw new ArgumentOutOfRangeException(
nameof(status),
status,
"Unsupported payment status.")
};
}
That may feel noisy, but it turns unknown values into visible failures. You can then decide where to catch them. Maybe the API returns a 400. Maybe the queue message goes to a dead-letter queue. Maybe the worker logs a structured error and skips the record. What you should not do is accidentally let unknown values take a random business path.
Enum values are not workflow rules
Payment statuses are tempting to model as a single enum.
public enum PaymentStatus
{
Unknown = 0,
Created = 1,
Authorised = 2,
Settled = 3,
Failed = 4,
Reversed = 5
}
That describes possible states. It doesnt describe valid transitions.
Can Created go straight to Settled?
Can Failed become Authorised?
Can Settled become Failed?
Can Reversed become Settled again?
The enum cannot answer those questions. If you scatter transition rules across handlers, controllers, validators, and workers, you eventually get contradictory behaviour.
A state transition should be explicit.
The code can be simple.
public static class PaymentStatusTransitions
{
private static readonly IReadOnlyDictionary<PaymentStatus, PaymentStatus[]> Allowed =
new Dictionary<PaymentStatus, PaymentStatus[]>
{
[PaymentStatus.Created] =
[
PaymentStatus.Authorised,
PaymentStatus.Failed
],
[PaymentStatus.Authorised] =
[
PaymentStatus.Settled,
PaymentStatus.Failed
],
[PaymentStatus.Settled] =
[
PaymentStatus.Reversed
],
[PaymentStatus.Failed] = [],
[PaymentStatus.Reversed] = []
};
public static bool CanMoveTo(
PaymentStatus current,
PaymentStatus next)
{
return Allowed.TryGetValue(current, out var allowed)
&& allowed.Contains(next);
}
}
For a simple workflow, that is enough.
For a serious financial workflow, I would usually go further. The transition would have a command, actor, timestamp, reason, correlation ID, idempotency key, and audit entry. The enum is only the state label. It is not the workflow engine.
Flags enums are a different kind of sharp edge
Flags enums are useful for combinations.
[Flags]
public enum UserPermission
{
None = 0,
Read = 1,
Write = 2,
Approve = 4,
Admin = 8
}
This allows:
var permissions = UserPermission.Read | UserPermission.Write;
That is fine.
The problem starts when flags are treated like normal enums.
Enum.IsDefined(UserPermission.Read | UserPermission.Write);
The combination is valid as a bit pattern, but it may not be a named enum member.
You also need to reject impossible bits.
public static bool HasOnlyDefinedFlags(UserPermission value)
{
const UserPermission all =
UserPermission.Read |
UserPermission.Write |
UserPermission.Approve |
UserPermission.Admin;
return (value & ~all) == 0;
}
Without that kind of check, (UserPermission)1024 can exist. It can be stored. It can travel through your system. It can fail in some places and be ignored in others. Flags are not bad. They are just not a good fit for everything. They work well for low-level permissions or options where combinations are truly independent. They work badly for business states where combinations have meaning, ordering, lifecycle, or audit requirements. If a payment is Created | Settled, that is not a clever enum. That is a broken model.
OpenAPI can make your enum contract official
Once your enum appears in an API schema, clients start generating code from it. ASP.NET Core OpenAPI support can describe enum values in generated OpenAPI metadata. If an enum is represented as a string in JSON, the schema can expose those string values. That is useful, but it also makes the enum part of the client contract. A frontend may generate TypeScript types. A partner may generate a Java client. A mobile app may bake the values into a release that will stay in the wild for months. That means adding a value is not always harmless. From the server side, adding Reversed feels backward compatible. Existing values still work. The database still works. Your new tests pass. From the client side, the generated enum may not know reversed. Some clients will fail deserialisation. Some will map it to unknown. Some will crash when a switch has no default handling. Some will render a blank label.
This is why API enums deserve versioning thought. For public APIs, consider whether the field should be an enum at all. Sometimes a string with documented known values and clear unknown handling is more honest. Sometimes you expose both a machine code and a display label. Sometimes the status deserves its own resource. The more external the contract, the less casual the enum should be.
A better way to use enums
Enums are still useful. The goal is not to ban them. The goal is to stop treating them like harmless local details when they are actually system contracts. For internal code, Im comfortable with enums when the value is genuinely closed, local, and owned by the same deployment. For external contracts, stored data, workflow state, or integration values, I want more discipline.
Here is the version I would rather see.
[JsonConverter(typeof(JsonStringEnumConverter<PaymentStatus>))]
public enum PaymentStatus
{
Unknown = 0,
Created = 1,
Authorised = 2,
Settled = 3,
Failed = 4,
Reversed = 5
}
Then I would configure JSON input to reject numeric enum values.
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.Converters.Add(
new JsonStringEnumConverter<PaymentStatus>(
JsonNamingPolicy.CamelCase,
allowIntegerValues: false));
});
I would validate incoming values at the boundary.
public static IResult ValidateStatus(PaymentStatus status)
{
if (status is PaymentStatus.Unknown)
{
return Results.BadRequest("Payment status is required.");
}
if (!Enum.IsDefined(status))
{
return Results.BadRequest("Unsupported payment status.");
}
return Results.Ok();
}
I would avoid silent switch fallbacks in business logic.
public static string ToProviderCode(PaymentStatus status)
{
return status switch
{
PaymentStatus.Created => "CREATED",
PaymentStatus.Authorised => "AUTHORISED",
PaymentStatus.Settled => "SETTLED",
PaymentStatus.Failed => "FAILED",
PaymentStatus.Reversed => "REVERSED",
PaymentStatus.Unknown => throw new InvalidOperationException(
"Unknown status cannot be sent to provider."),
_ => throw new ArgumentOutOfRangeException(
nameof(status),
status,
"Unsupported payment status.")
};
}
I would put workflow transitions in one place.
public sealed class PaymentStatusPolicy
{
public bool CanTransition(
PaymentStatus current,
PaymentStatus next)
{
return (current, next) switch
{
(PaymentStatus.Created, PaymentStatus.Authorised) => true,
(PaymentStatus.Created, PaymentStatus.Failed) => true,
(PaymentStatus.Authorised, PaymentStatus.Settled) => true,
(PaymentStatus.Authorised, PaymentStatus.Failed) => true,
(PaymentStatus.Settled, PaymentStatus.Reversed) => true,
_ => false
};
}
}
And I would treat storage as a long-term contract.
modelBuilder.Entity<Payment>()
.Property(x => x.Status)
.HasConversion<string>()
.HasMaxLength(32);
That is not much code. Its just code that admits what the enum has become.
When an enum should become something else
The hard part is knowing when the enum has outgrown itself. An enum is usually fine when the values are few, stable, local, and not user managed. It starts to smell when values need display names, translations, sort order, effective dates, audit history, permissions, lifecycle rules, tenant specific behaviour, external mappings, or frequent additions without deployment.
At that point, you may want a lookup table.
Or you may want a richer domain type.
public sealed record PaymentStatusCode
{
public static readonly PaymentStatusCode Created = new("created");
public static readonly PaymentStatusCode Authorised = new("authorised");
public static readonly PaymentStatusCode Settled = new("settled");
public static readonly PaymentStatusCode Failed = new("failed");
public static readonly PaymentStatusCode Reversed = new("reversed");
public string Value { get; }
private PaymentStatusCode(string value)
{
Value = value;
}
public static bool TryCreate(
string? value,
out PaymentStatusCode? status)
{
status = value?.Trim().ToLowerInvariant() switch
{
"created" => Created,
"authorised" => Authorised,
"settled" => Settled,
"failed" => Failed,
"reversed" => Reversed,
_ => null
};
return status is not null;
}
public override string ToString() => Value;
}
Thats more ceremony than an enum, so dont reach for it automatically. But for values that live outside your process, ceremony can be cheaper than ambiguity.
The production lesson
The production bug is rarely "someone used an enum". The bug is usually one of these........
The enum had implicit numeric values and those numbers escaped.
A new enum value reached an old service.
A fallback branch guessed instead of failing.
A default 0 value became a real state.
A database stored numbers whose meaning changed later.
A public API exposed internal enum names.
A flags enum allowed impossible combinations.
A workflow used an enum as if it also contained the transition rules.
The fix is not complicated. It is mostly discipline. Name the zero value. Assign explicit numbers. Reject numeric JSON values for public contracts. Validate parsed values. Be careful with [Flags]. Avoid silent switch defaults. Keep transitions explicit. Treat stored enum values as data, not implementation details.
Microsoft Learn - Enumeration types, C# referencees,
Microsoft Learn - C# language specification,
Microsoft Learn - Enum.TryParseParse
Microsoft Learn - Enum.IsDefinedIsDefined
Microsoft Learn - JsonStringEnumConverter
Microsoft Learn - EF Core value conversionsalue conversions
Microsoft Learn - ASP.NET Core OpenAPI metadata for enum schemasoft Learn - ASP.NET Core OpenAPI metadata for enum schemas




