En C# hay problemas que parecen pedir un enum de inmediato:
public enum Grade
{
A,
B,
C,
D,
F
}
La representación es compacta, rápida, familiar y funciona muy bien con el lenguaje.
Pero el mismo concepto también puede modelarse como una clase con instancias estáticas:
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; }
}
A este patrón se le suele llamar Enumeration Class, Smart Enum o, más ampliamente, un pequeño tipo de dominio con un conjunto cerrado de instancias.
A primera vista parece una reinvención innecesaria del enum. A veces lo es.
Otras veces evita exactamente el tipo de lógica dispersa, estados inválidos y condicionales repetidos que terminan haciendo frágil un modelo de dominio.
La decisión importante no es “qué sintaxis me gusta más”, sino qué garantías necesito que represente el tipo.
La diferencia esencial: valor vs concepto de dominio
Un enum es excelente para representar un conjunto de nombres asociados a valores integrales.
public enum SortDirection
{
Ascending,
Descending
}
En este caso probablemente no hace falta nada más.
Pero Grade puede dejar de ser solo un nombre si el dominio empieza a preguntarle cosas:
- ¿aprueba?
- ¿qué GPA representa?
- ¿qué etiqueta debe mostrarse?
- ¿puede convertirse a otro estado?
- ¿qué reglas especiales tiene?
- ¿cómo se serializa hacia un sistema externo?
Cuando esas respuestas empiezan a crecer, el valor deja de ser una simple constante y empieza a parecerse a un objeto.
Ese es el punto de inflexión.
Qué gana un enum
La principal ventaja de enum es que el lenguaje sabe que es un enum.
Eso desbloquea una gran cantidad de ergonomía casi gratis.
Grade grade = Grade.A;
string text = grade.ToString();
foreach (var value in Enum.GetValues<Grade>())
{
Console.WriteLine(value);
}
También tenemos parsing:
if (Enum.TryParse<Grade>("A", ignoreCase: true, out var grade))
{
Console.WriteLine(grade);
}
Y el lenguaje funciona de manera natural con switch y 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))
};
Además, buena parte del ecosistema entiende enums directamente:
- System.Text.Json;
- ASP.NET Core model binding;
- OpenAPI/Swagger;
- Entity Framework Core;
- reflection;
- serializers;
- configuración;
- interoperabilidad con APIs que esperan números o strings;
- operaciones bitwise cuando usamos
[Flags].
Para conjuntos simples y cerrados, todo eso es una ventaja enorme.
El detalle incómodo: un enum puede contener un valor que nunca declaraste
Supongamos:
public enum Grade
{
A = 1,
B = 2,
C = 3,
D = 4,
F = 5
}
Esto sigue siendo legal:
Grade grade = (Grade)999;
El cast explícito no exige que 999 corresponda a un miembro declarado.
Por eso, cuando el valor cruza una frontera no confiable, puede ser necesario validar:
if (!Enum.IsDefined<Grade>(grade))
{
throw new ArgumentOutOfRangeException(nameof(grade));
}
Esto produce una distinción muy importante:
enum Grade
conjunto declarado: A, B, C, D, F
valor representable por el tipo:
cualquier valor del tipo integral subyacente
En otras palabras, “es un Grade” no implica necesariamente “es uno de los Grade que declaré”.
En código interno controlado esto puede no ser un problema. En deserialización, persistencia, casts, interoperabilidad o datos históricos sí puede convertirse en uno.
Una Enumeration Class cambia la garantía
Con una clase y constructor privado:
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; }
}
el consumidor ya no puede hacer:
new Grade("Z");
Si la API no expone otra ruta de construcción, las únicas instancias disponibles son las que el tipo decidió publicar.
La invariante pasa de ser:
"espero que nadie fabrique un valor raro"
a:
"el tipo controla qué instancias pueden existir"
Eso es mucho más cercano a la idea de hacer estados inválidos difíciles o imposibles de representar.
Hay una excepción obvia: si Grade es una clase, sigue existiendo null.
Con Nullable Reference Types podemos expresarlo:
Grade grade = Grade.A; // no-null esperado
Grade? maybeGrade = null;
El tipo mejora una invariante, pero no elimina mágicamente todas las demás.
Donde la clase empieza a justificar su coste: metadata
Supongamos que cada nota tiene más información:
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; }
}
Ahora el código consumidor puede decir:
if (grade.IsPassing)
{
Console.WriteLine(grade.Gpa);
}
Con un enum puro, esa metadata normalmente termina en otro lugar:
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))
};
Eso no está mal.
De hecho, para una sola propiedad puede ser perfectamente preferible.
El problema aparece cuando empiezan a existir cinco, diez o veinte tablas de correspondencia repartidas por el sistema.
Grade -> IsPassing
Grade -> Gpa
Grade -> Label
Grade -> ExternalCode
Grade -> SortOrder
Grade -> Color
...
Entonces el modelo nos está diciendo que esos datos probablemente pertenecen al concepto Grade.
Y donde puede justificarlo todavía más: comportamiento
La clase no tiene que limitarse a guardar propiedades.
Puede encapsular reglas:
public bool CanAdvanceTo(Grade next)
{
// regla del dominio
}
o:
public decimal CalculateWeight(decimal credits)
{
return (decimal)Gpa * credits;
}
La diferencia arquitectónica es importante.
Con enum, la tendencia natural es:
datos = enum
reglas = switch dispersos
Con un tipo de dominio, podemos acercarnos a:
datos + invariantes + comportamiento
dentro del mismo concepto
No siempre es mejor. Pero cuando las reglas pertenecen claramente al valor, puede reducir primitive obsession y lógica duplicada.
El gran coste del Smart Enum: ahora tú eres el runtime
Con un enum, muchas capacidades ya vienen resueltas.
Con una clase tienes que decidir e implementar parte de ellas.
Por ejemplo:
Grade.Parse("A");
Grade.TryParse("A", out var grade);
Grade.All;
Nada de eso existe automáticamente.
Podríamos añadir:
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));
Pero ahora somos responsables de:
- duplicados;
- casing;
- excepciones;
- performance;
- orden;
- compatibilidad hacia atrás;
- códigos externos;
- evolución del conjunto.
La clase compra poder a cambio de infraestructura.
Serialización: simple con enum, explícita con clase
Un enum suele tener un camino directo con serializers.
Por ejemplo, podemos configurar System.Text.Json para representar enums como strings.
Una Enumeration Class normalmente requiere definir cómo convertir:
{
"grade": "A"
}
en:
Grade.A
Eso puede implicar un JsonConverter<Grade>.
La misma pregunta aparece con:
- ASP.NET Core model binding;
- parámetros de query;
- configuración;
- mensajes;
- eventos;
- caches;
- contratos externos.
Si el tipo vive solo dentro del core del dominio, este coste puede estar bien.
Si el tipo cruza diez integraciones diferentes, el enum puede ser mucho más económico.
Persistencia y EF Core
Un enum suele mapearse fácilmente a un número o string.
Una clase de dominio puede requerir un ValueConverter:
builder.Property(x => x.Grade)
.HasConversion(
grade => grade.Label,
value => Grade.Parse(value));
Eso no convierte la clase en mala opción.
Pero sí cambia el coste operacional del diseño.
La pregunta correcta no es solamente “¿qué modelo es más elegante?”, sino también:
¿cuántas fronteras tendrán que saber reconstruir este tipo?
Un buen dominio puede volverse incómodo si cada capa necesita un adaptador distinto y el beneficio real es pequeño.
Igualdad: uno de los bugs más fáciles de introducir
Con enum:
Grade.A == Grade.A
tiene semántica clara.
Con una clase normal, == compara referencias salvo que implementemos otra cosa.
Si todas las instancias son singletons estáticos:
public static Grade A { get; } = new(...);
la identidad de referencia puede funcionar mientras ninguna ruta de reconstrucción cree nuevas instancias equivalentes.
Pero serializers, ORMs, factories o tests pueden cambiar esa suposición.
Por eso un tipo de valor conceptual suele necesitar semántica de valor explícita.
Una opción moderna es usar un record con construcción controlada:
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);
}
Los records aportan igualdad por valor generada por el compilador.
Pero hay una precisión importante:
record no significa automáticamente “conjunto cerrado”.
Si exponemos un constructor público, cualquiera podrá crear nuevos valores.
El cierre del conjunto proviene de controlar las rutas de construcción, no de la palabra clave record.
¿Y el pattern matching?
Los enums son particularmente cómodos:
return grade switch
{
Grade.A => 4,
Grade.B => 3,
Grade.C => 2,
Grade.D => 1,
Grade.F => 0,
_ => throw new ArgumentOutOfRangeException()
};
Con una Smart Enum basada en instancias estáticas, no tenemos exactamente la misma ergonomía.
Podemos comparar:
if (grade == Grade.A)
{
// ...
}
pero los casos estáticos no funcionan como miembros de enum en todos los contextos de pattern matching.
Si el comportamiento ya vive dentro del objeto, muchas veces dejamos de necesitar ese switch.
Eso puede ser una ventaja:
int points = grade.Points;
en lugar de:
int points = grade switch { ... };
Pero si nuestro código necesita hacer branching exhaustivo sobre el conjunto constantemente, probablemente el enum siga siendo una representación más natural.
Una tabla práctica
| Pregunta | enum | Enumeration Class / Smart Enum |
|---|---|---|
| ¿Conjunto pequeño de constantes? | Excelente | Posible exceso |
| ¿Necesito metadata por valor? | Requiere mapping externo | Natural |
| ¿Necesito comportamiento por valor? | Suele terminar en switches | Natural |
| ¿Quiero impedir valores no declarados? | No completamente | Sí, controlando construcción |
¿Necesito [Flags]? | Excelente | No es su caso natural |
¿Uso intensivo de switch? | Excelente | Menos ergonómico |
| ¿Serialización simple? | Excelente | Suele requerir converter |
| ¿EF Core directo? | Excelente | Puede requerir conversion |
| ¿Igualdad? | Incorporada | Hay que diseñarla |
| ¿Reflection/Enum APIs? | Incorporadas | Hay que construir equivalentes |
| ¿Reglas ricas de dominio? | Limitado | Mucho más expresivo |
| ¿Interop con APIs externas? | Muy cómodo | Más adaptación |
El criterio que más ayuda: dónde vive la complejidad
Una mala razón para abandonar enum es:
“Las clases son más orientadas a objetos.”
Una mala razón para conservar enum es:
“Siempre lo hemos hecho así.”
La decisión debería seguir la complejidad real del dominio.
Caso 1: el enum es claramente suficiente
public enum SortDirection
{
Ascending,
Descending
}
No hay metadata interesante.
No hay reglas.
No hay comportamiento.
No hay motivo para inventar una mini infraestructura.
Caso 2: el enum sigue siendo suficiente con una función auxiliar
public enum LogLevel
{
Debug,
Info,
Warning,
Error
}
Quizá solo necesitamos:
bool ShouldAlert(LogLevel level) =>
level >= LogLevel.Error;
Una clase sería probablemente exceso.
Caso 3: el concepto ya es un value object
OrderStatus
PaymentMethod
SubscriptionTier
RiskLevel
Grade
ShippingService
Si cada valor acumula códigos externos, reglas, transiciones, metadata y comportamiento, el enum puede convertirse en apenas la llave de una gran tabla distribuida.
Ahí una Enumeration Class empieza a ser mucho más atractiva.
Una señal muy útil: contar los switches
Los switch no son malos.
Pero si el mismo enum aparece repetidamente en código como:
switch (status) { ... } // pricing
switch (status) { ... } // permissions
switch (status) { ... } // UI
switch (status) { ... } // validation
switch (status) { ... } // transitions
conviene preguntar si parte de esa lógica debería moverse hacia el propio concepto.
A veces la respuesta es no: cada switch pertenece a un consumidor diferente.
Pero si todos implementan reglas intrínsecas de status, probablemente estamos viendo comportamiento de dominio disperso.
Otra señal: ¿el valor cruza fronteras o vive en el core?
Esta distinción cambia mucho la decisión.
JSON / HTTP / DB / config
|
v
boundary type
|
v
domain type
No es obligatorio usar el mismo tipo en ambos lados.
Podemos recibir un string o enum en la frontera:
public sealed record CreateStudentRequest(string Grade);
validarlo una vez:
Grade grade = Grade.Parse(request.Grade);
y desde ahí trabajar con el tipo de dominio.
Eso evita exigir a ASP.NET, JSON, EF y cada sistema externo que comprendan directamente nuestra abstracción interna.
El patrón general es:
simple en las fronteras, rico en el core.
La pregunta final
Antes de crear una Smart Enum, preguntaría:
- ¿Los valores tienen datos propios?
- ¿Tienen reglas o comportamiento intrínseco?
- ¿Estoy repitiendo switches o dictionaries para responder siempre las mismas preguntas?
- ¿Necesito controlar estrictamente qué valores pueden construirse?
- ¿El beneficio compensa serializers, persistence mapping y parsing adicionales?
Si casi todas las respuestas son “no”, usaría un enum.
Si varias son “sí”, probablemente ya no estamos modelando una enumeración: estamos modelando un tipo de dominio.
La diferencia parece pequeña en una diapositiva.
En un sistema grande puede determinar si las reglas permanecen concentradas en un lugar o terminan repartidas entre controllers, services, mappers, serializers y validadores.
Regla de bolsillo
solo nombres / constantes
|
v
enum
metadata + invariantes + comportamiento
|
v
Enumeration Class / Smart Enum
No se trata de reemplazar todos los enums.
Se trata de reconocer el momento en que un valor dejó de ser una constante y se convirtió en una idea del dominio.
Implementación completa con sealed record
Si ya decidiste que el concepto merece un Smart Enum, el siguiente paso es resolver bien la construcción, igualdad, parsing, All y persistencia. Lo desarrollamos con código completo en Smart Enum con sealed record en C#: implementación completa de Grade.
Referencias
- Microsoft Learn — Enumeration types
- Microsoft Learn — System.Enum.IsDefined
- Microsoft Learn — Records
- Microsoft Learn — Value conversions in EF Core