In C#, some problems seem to ask for an enum immediately:
public enum Grade
{
A,
B,
C,
D,
F
}
The representation is compact, fast, familiar, and works very well with the language.
But the same concept can also be modeled as a class with static instances:
public sealed class Grade
{
private Grade(string label, bool isPassing)
{
Label = label;
IsPassing = isPassing;
}
public static Grade A { get; } = new("A", true);
public static Grade B { get; } = new("B", true);
public static Grade C { get; } = new("C", true);
public static Grade D { get; } = new("D", true);
public static Grade F { get; } = new("F", false);
public string Label { get; }
public bool IsPassing { get; }
}
This pattern is commonly called an Enumeration Class, Smart Enum, or more broadly a small domain type with a closed set of instances.
At first glance, it can look like an unnecessary reinvention of enum. Sometimes it is.
Other times it avoids exactly the kind of scattered logic, invalid states, and repeated conditionals that make a domain model fragile.
The important decision is not “which syntax do I prefer?” but what guarantees does the type need to represent?
The essential difference: value vs domain concept
An enum is excellent for representing a set of names backed by integral values.
public enum SortDirection
{
Ascending,
Descending
}
In this case, nothing else is probably needed.
But Grade may stop being just a name when the domain starts asking questions about it:
- does it pass?
- what GPA does it represent?
- what label should be displayed?
- can it transition to another state?
- does it carry special rules?
- how is it serialized to an external system?
When those answers start to grow, the value stops looking like a simple constant and starts looking like an object.
That is the inflection point.
What an enum gives you
The biggest advantage of enum is that the language knows it is an enum.
That unlocks a lot of ergonomics almost for free.
Grade grade = Grade.A;
string text = grade.ToString();
foreach (var value in Enum.GetValues<Grade>())
{
Console.WriteLine(value);
}
Parsing is built in as well:
if (Enum.TryParse<Grade>("A", ignoreCase: true, out var grade))
{
Console.WriteLine(grade);
}
And the language works naturally with switch and pattern matching:
string Describe(Grade grade) =>
grade switch
{
Grade.A => "Excellent",
Grade.B => "Very good",
Grade.C => "Good",
Grade.D => "Passing",
Grade.F => "Failing",
_ => throw new ArgumentOutOfRangeException(nameof(grade))
};
Much of the ecosystem understands enums directly too:
- System.Text.Json;
- ASP.NET Core model binding;
- OpenAPI/Swagger;
- Entity Framework Core;
- reflection;
- serializers;
- configuration;
- interoperability with APIs that expect numbers or strings;
- bitwise operations when using
[Flags].
For small, closed sets, all of that is a major advantage.
The uncomfortable detail: an enum can hold a value you never declared
Consider:
public enum Grade
{
A = 1,
B = 2,
C = 3,
D = 4,
F = 5
}
This is still legal:
Grade grade = (Grade)999;
The explicit cast does not require 999 to correspond to a declared member.
That is why values crossing an untrusted boundary may need validation:
if (!Enum.IsDefined<Grade>(grade))
{
throw new ArgumentOutOfRangeException(nameof(grade));
}
This creates an important distinction:
enum Grade
declared set: A, B, C, D, F
values representable by the type:
any value of the underlying integral type
In other words, “this is a Grade” does not necessarily mean “this is one of the Grade values I declared.”
Inside controlled code, that may not matter. With deserialization, persistence, casts, interoperability, or historical data, it can.
An Enumeration Class changes the guarantee
With a class and a private constructor:
public sealed class Grade
{
private Grade(string label)
{
Label = label;
}
public static Grade A { get; } = new("A");
public static Grade B { get; } = new("B");
public static Grade C { get; } = new("C");
public static Grade D { get; } = new("D");
public static Grade F { get; } = new("F");
public string Label { get; }
}
the consumer cannot write:
new Grade("Z");
If the API exposes no other construction path, the only available instances are the ones the type chose to publish.
The invariant changes from:
"I hope nobody manufactures a strange value"
to:
"the type controls which instances can exist"
That is much closer to the idea of making invalid states difficult or impossible to represent.
There is an obvious caveat: if Grade is a class, null still exists.
Nullable Reference Types let us express that distinction:
Grade grade = Grade.A; // non-null expected
Grade? maybeGrade = null;
The type improves one invariant, but it does not magically eliminate every invalid state.
Where the class starts to justify its cost: metadata
Suppose each grade carries more information:
public sealed class Grade
{
private Grade(
string label,
double gpa,
bool isPassing)
{
Label = label;
Gpa = gpa;
IsPassing = isPassing;
}
public static Grade A { get; } = new("A", 4.0, true);
public static Grade B { get; } = new("B", 3.0, true);
public static Grade C { get; } = new("C", 2.0, true);
public static Grade D { get; } = new("D", 1.0, true);
public static Grade F { get; } = new("F", 0.0, false);
public string Label { get; }
public double Gpa { get; }
public bool IsPassing { get; }
}
Consumer code can now say:
if (grade.IsPassing)
{
Console.WriteLine(grade.Gpa);
}
With a plain enum, that metadata usually lives somewhere else:
bool IsPassing(Grade grade) =>
grade switch
{
Grade.A => true,
Grade.B => true,
Grade.C => true,
Grade.D => true,
Grade.F => false,
_ => throw new ArgumentOutOfRangeException(nameof(grade))
};
That is not inherently bad.
For one property, it may be the preferable design.
The problem appears when five, ten, or twenty mapping tables accumulate across the codebase.
Grade -> IsPassing
Grade -> Gpa
Grade -> Label
Grade -> ExternalCode
Grade -> SortOrder
Grade -> Color
...
At that point the model is telling us those data probably belong to the Grade concept itself.
And where it can justify even more: behavior
The class does not have to be only a bag of properties.
It can encapsulate rules:
public bool CanAdvanceTo(Grade next)
{
// domain rule
}
or:
public decimal CalculateWeight(decimal credits)
{
return (decimal)Gpa * credits;
}
The architectural difference matters.
With an enum, the natural tendency is often:
data = enum
rules = scattered switches
With a domain type, we can move toward:
data + invariants + behavior
inside the concept
That is not always better. But when the rules truly belong to the value, it can reduce primitive obsession and duplicated logic.
The biggest Smart Enum cost: now you are part of the runtime
With an enum, many capabilities are already solved.
With a class, you must decide and implement some of them.
For example:
Grade.Parse("A");
Grade.TryParse("A", out var grade);
Grade.All;
None of those exist automatically.
You might add:
public static IReadOnlyList<Grade> All { get; } =
new[] { A, B, C, D, F };
public static Grade Parse(string value) =>
All.Single(x =>
string.Equals(
x.Label,
value,
StringComparison.OrdinalIgnoreCase));
But now you own decisions about:
- duplicates;
- casing;
- exceptions;
- performance;
- ordering;
- backward compatibility;
- external codes;
- evolution of the set.
The class buys expressive power by taking on infrastructure.
Serialization: simple with enum, explicit with class
An enum usually has a direct path through serializers.
For example, System.Text.Json can be configured to represent enums as strings.
An Enumeration Class usually needs an explicit conversion from:
{
"grade": "A"
}
to:
Grade.A
That may require a JsonConverter<Grade>.
The same issue appears with:
- ASP.NET Core model binding;
- query parameters;
- configuration;
- messages;
- events;
- caches;
- external contracts.
If the type lives only inside the domain core, that cost can be fine.
If it crosses ten integrations, an enum may be much cheaper.
Persistence and EF Core
An enum usually maps cleanly to a number or string.
A domain class may need a ValueConverter:
builder.Property(x => x.Grade)
.HasConversion(
grade => grade.Label,
value => Grade.Parse(value));
That does not make the class a bad option.
But it does change the operational cost of the design.
The right question is not only “which model is more elegant?” but also:
How many boundaries need to know how to reconstruct this type?
A rich domain can become awkward if every layer needs a custom adapter and the benefit is small.
Equality: one of the easiest bugs to introduce
With an enum:
Grade.A == Grade.A
has clear semantics.
With a normal class, == compares references unless we define something else.
If every instance is a static singleton:
public static Grade A { get; } = new(...);
reference identity may work while no reconstruction path creates equivalent instances.
Serializers, ORMs, factories, or tests can break that assumption.
A conceptual value type usually needs explicit value semantics.
One modern option is a record with controlled construction:
public sealed record Grade
{
private Grade(
string Label,
double Gpa,
bool IsPassing)
{
this.Label = Label;
this.Gpa = Gpa;
this.IsPassing = IsPassing;
}
public string Label { get; }
public double Gpa { get; }
public bool IsPassing { get; }
public static Grade A { get; } = new("A", 4.0, true);
public static Grade B { get; } = new("B", 3.0, true);
public static Grade C { get; } = new("C", 2.0, true);
public static Grade D { get; } = new("D", 1.0, true);
public static Grade F { get; } = new("F", 0.0, false);
}
Records provide compiler-generated value equality.
But one detail matters:
record does not automatically mean “closed set.”
If the constructor is public, callers can still create new values.
The closed set comes from controlling construction paths, not from the record keyword itself.
What about pattern matching?
Enums are especially convenient:
return grade switch
{
Grade.A => 4,
Grade.B => 3,
Grade.C => 2,
Grade.D => 1,
Grade.F => 0,
_ => throw new ArgumentOutOfRangeException()
};
A Smart Enum based on static instances does not have exactly the same ergonomics.
You can compare:
if (grade == Grade.A)
{
// ...
}
but static instances are not enum members in every pattern-matching context.
If the behavior already lives inside the object, you may stop needing the switch at all.
That can be an advantage:
int points = grade.Points;
instead of:
int points = grade switch { ... };
But if your code constantly needs exhaustive branching over the set, an enum is probably still the more natural representation.
A practical comparison
| Question | enum | Enumeration Class / Smart Enum |
|---|---|---|
| Small set of constants? | Excellent | Often overkill |
| Per-value metadata? | External mapping | Natural |
| Per-value behavior? | Often becomes switches | Natural |
| Prevent undeclared values? | Not completely | Yes, by controlling construction |
Need [Flags]? | Excellent | Not its natural use case |
Heavy use of switch? | Excellent | Less ergonomic |
| Simple serialization? | Excellent | Often needs a converter |
| Straightforward EF Core mapping? | Excellent | May need conversion |
| Equality? | Built in | Must be designed |
| Reflection/Enum APIs? | Built in | Must be recreated |
| Rich domain rules? | Limited | Much more expressive |
| External API interoperability? | Very convenient | More adaptation |
The criterion that helps most: where does the complexity live?
A bad reason to abandon enum is:
“Classes are more object-oriented.”
A bad reason to keep enum is:
“We have always done it this way.”
The decision should follow actual domain complexity.
Case 1: enum is clearly enough
public enum SortDirection
{
Ascending,
Descending
}
There is no interesting metadata.
There are no rules.
There is no behavior.
There is no reason to invent mini-infrastructure.
Case 2: enum is still enough with one helper
public enum LogLevel
{
Debug,
Info,
Warning,
Error
}
Maybe all we need is:
bool ShouldAlert(LogLevel level) =>
level >= LogLevel.Error;
A class would probably be excess design.
Case 3: the concept has become a value object
OrderStatus
PaymentMethod
SubscriptionTier
RiskLevel
Grade
ShippingService
If each value accumulates external codes, rules, transitions, metadata, and behavior, the enum may become little more than the key to a large distributed lookup table.
That is where an Enumeration Class becomes much more attractive.
A useful signal: count the switches
switch statements are not bad.
But if the same enum repeatedly appears as:
switch (status) { ... } // pricing
switch (status) { ... } // permissions
switch (status) { ... } // UI
switch (status) { ... } // validation
switch (status) { ... } // transitions
it is worth asking whether some of that logic belongs to the concept itself.
Sometimes the answer is no: each switch belongs to a different consumer.
But if all of them encode intrinsic rules of status, we are probably looking at scattered domain behavior.
Another signal: does the value cross boundaries or live in the core?
This distinction changes the trade-off significantly.
JSON / HTTP / DB / config
|
v
boundary type
|
v
domain type
You do not have to use the same type on both sides.
You can receive a string or enum at the boundary:
public sealed record CreateStudentRequest(string Grade);
validate it once:
Grade grade = Grade.Parse(request.Grade);
and then work with the domain type internally.
That avoids forcing ASP.NET, JSON, EF, and every external system to understand your internal abstraction directly.
The general pattern is:
simple at the boundaries, rich in the core.
The final question
Before creating a Smart Enum, ask:
- Do the values carry their own data?
- Do they have intrinsic rules or behavior?
- Am I repeating switches or dictionaries to answer the same questions?
- Do I need strict control over which values can be constructed?
- Is the benefit worth the extra serializers, persistence mapping, and parsing?
If almost every answer is “no,” use an enum.
If several are “yes,” you may no longer be modeling an enumeration at all. You are modeling a domain type.
The difference looks small on a slide.
In a large system, it can determine whether rules stay concentrated in one place or end up scattered across controllers, services, mappers, serializers, and validators.
Pocket rule
names / constants only
|
v
enum
metadata + invariants + behavior
|
v
Enumeration Class / Smart Enum
The point is not to replace every enum.
It is to recognize the moment when a value stops being a constant and becomes an idea in the domain.
Complete implementation with sealed record
If you have decided the concept deserves a Smart Enum, the next step is to get construction, equality, parsing, All, and persistence right. The complete implementation is in Smart Enum with sealed record in C#: a complete Grade implementation.
References
- Microsoft Learn — Enumeration types
- Microsoft Learn — System.Enum.IsDefined
- Microsoft Learn — Records
- Microsoft Learn — Value conversions in EF Core