Tipos de datos algebraicos en .NET Framework / .NET — Diseño que representa estados y resultados mediante tipos
· Actualizado el: · Go Komura · .NET, .NETFramework, CSharp, FSharp, AlgebraicDataTypes, DiscriminatedUnion, DomainModeling, Aprovechamiento de activos existentes
1. Lo primero que conviene entender
Al escribir aplicaciones de negocio en .NET, es habitual encontrarse con valores de retorno o estados como este.
public class CreateUserResult
{
public bool IsSuccess { get; set; }
public User User { get; set; }
public string ErrorCode { get; set; }
public string ErrorMessage { get; set; }
}
A primera vista parece un diseño claro, pero este tipo permite que se cuelen muchos “estados que no deberían existir”.
Por ejemplo, se pueden construir valores como estos.
IsSuccess == trueperoUser == nullIsSuccess == trueperoErrorCodetiene un valorIsSuccess == falseperoUsertiene un valorErrorCode == "DuplicateEmail"peroErrorMessage == null- Se agrega un nuevo código de error, pero el código que lo invoca no se actualiza para tratarlo
Este tipo de diseño resulta cómodo al principio, pero a medida que el sistema crece impone una carga cada vez mayor sobre quien lo lee y quien lo mantiene.
Aquí es donde conviene aplicar el concepto de tipo de datos algebraico.
El nombre “tipo de datos algebraico” suena un poco formal, pero en la práctica resulta fácil de entender si se piensa así:
Representar mediante el tipo —y no mediante comentarios ni convenciones de nombres— el hecho de que “este valor solo puede adoptar formas predeterminadas”.
Por ejemplo, el resultado de crear un usuario se puede representar como exactamente una de estas opciones.
CreateUserResult =
Created(User)
o DuplicateEmail(email)
o WeakPassword(reason)
o SystemFailure(message)
En caso de éxito existe User.
En caso de correo duplicado existe email.
En caso de contraseña débil existe reason.
En caso de error de sistema existe message.
Cada caso contiene únicamente los datos que necesita.
El éxito y el fallo nunca se dan a la vez.
Tampoco es posible construir un estado de éxito sin User.
En .NET, esta idea se puede implementar con uniones discriminadas en F#, con una jerarquía de clases sealed, una jerarquía de record, una biblioteca como OneOf en C#, o con el futuro tipo union de C#.
En este artículo se explica cómo usar los tipos de datos algebraicos tanto en .NET Framework como en el .NET actual, junto con sus ventajas prácticas y los puntos a tener en cuenta.
El código que aparece en este artículo está publicado en GitHub como un conjunto de muestras que se puede compilar y ejecutar (una biblioteca, demostraciones de cada patrón de implementación y pruebas unitarias que verifican la exhaustividad de Match, las transiciones de estado y la conversión a DTO).
dotnet-algebraic-data-types - komurasoft-blog-samples (GitHub)
Cómo leer este artículo
Es un artículo largo, así que no hace falta leerlo de principio a fin. Vaya directamente al capítulo que corresponda a su objetivo.
| Objetivo | Capítulos a leer |
|---|---|
| Quiero saber primero qué es un ADT y por qué hace falta | 1-3 |
| Quiero comparar cómo implementarlo en C# / F# y elegir | 4-10 |
| Quiero usar Option, Result, transiciones de estado y límites de API en la práctica | 11-14 |
| Quiero explicar las ventajas de adoptarlo a mi equipo o a mi jefe | 15-19 |
| Quiero introducirlo en un sistema existente que incluye .NET Framework | 20-21 |
| Tengo dudas sobre cuándo usar enum, bool o herencia | 22-25 |
| Quiero conocer antes las trampas de diseño | 26-29 |
| Quiero un procedimiento concreto para corregir código existente | 30-31 |
Conocimientos previos que se asumen
En los ejemplos de código a partir del capítulo 6 se usa la coincidencia de patrones (pattern matching) de C# y la expresión switch. A continuación se resume la terminología.
| Término | Significado | Ejemplo de sintaxis |
|---|---|---|
Expresión switch |
Forma de escribir en la que la propia bifurcación devuelve un valor. A diferencia de la sentencia switch, el lado derecho de cada rama es el valor del resultado |
result switch { ... } |
| Patrón de tipo | Bifurca según si el valor es de ese tipo y lo enlaza a una variable | Created x => ... |
| Patrón de propiedad | Además del tipo, examina el valor de una propiedad. Con var se puede extraer el contenido |
Created { User: var user } => ... |
| Patrón de descarte | Rama para cuando el valor no coincide con ningún patrón | _ => throw ... |
Cláusula when |
Agrega una condición adicional al patrón | OutOfStock x when x.Available == 0 => ... |
La fuente primaria es la documentación de Microsoft Learn: Pattern matching overview y switch expression.
Los capítulos 4 y 5, para mostrar una forma de escribir compatible incluso con versiones antiguas de C#, usan as e if en lugar de la expresión switch. Se pueden leer sin conocer pattern matching.
2. Qué es un tipo de datos algebraico
En inglés se llama Algebraic Data Type, abreviado ADT.
A grandes rasgos, un ADT es la combinación de dos tipos de tipos.
- Tipo producto: un tipo que tiene A y B a la vez
- Tipo suma: un tipo que es A o B
En .NET, las clases, structs y records se usan, en la mayoría de los casos, como “tipos producto”.
public sealed class Address
{
public string PostalCode { get; }
public string Prefecture { get; }
public string City { get; }
public string Street { get; }
public Address(string postalCode, string prefecture, string city, string street)
{
PostalCode = postalCode;
Prefecture = prefecture;
City = city;
Street = street;
}
}
Su significado es el siguiente.
Address = PostalCode y Prefecture y City y Street
En cambio, el tipo suma es “uno de ellos”.
PaymentResult =
Succeeded(receiptNo)
o InsufficientFunds(shortage)
o Rejected(reason)
o NetworkFailure(message)
Su significado es el siguiente.
PaymentResult = Succeeded o InsufficientFunds o Rejected o NetworkFailure
Representar este “o” mediante el tipo es la parte de los tipos de datos algebraicos que más se usa en la práctica.
En F#, esto se puede escribir de forma natural como una característica del lenguaje.
type PaymentResult =
| Succeeded of receiptNo: string
| InsufficientFunds of shortage: decimal
| Rejected of reason: string
| NetworkFailure of message: string
Durante mucho tiempo, C# no contó con una unión discriminada como característica estándar, al estilo de F#. Por eso, en C# se ha representado mediante jerarquías de clases o bibliotecas.
Sin embargo, la idea en sí se puede aplicar perfectamente en C#.
Lo importante no es usar una sintaxis concreta, sino este único punto:
Hacer que sea imposible construir un estado inválido desde el principio.
3. Por qué bool o enum por sí solos no bastan
En procesos pequeños, bool o enum pueden parecer suficientes.
Por ejemplo, un valor de retorno como este.
public enum PaymentStatus
{
Succeeded,
InsufficientFunds,
Rejected,
NetworkFailure
}
public sealed class PaymentResponse
{
public PaymentStatus Status { get; set; }
public string ReceiptNo { get; set; }
public decimal? Shortage { get; set; }
public string Reason { get; set; }
public string Message { get; set; }
}
Sin embargo, en este diseño la relación entre Status y cada propiedad no está expresada mediante el tipo.
ReceiptNo solo es necesario cuando Status == Succeeded.
Shortage solo es necesario cuando Status == InsufficientFunds.
Reason solo es necesario cuando Status == Rejected.
Message solo es necesario cuando Status == NetworkFailure.
Esta regla vive fuera del código.
Depende de comentarios, especificaciones, pruebas, acuerdos implícitos y la memoria de quien implementó el código.
Como resultado, aumenta este tipo de código defensivo.
if (response.Status == PaymentStatus.Succeeded)
{
if (string.IsNullOrEmpty(response.ReceiptNo))
{
throw new InvalidOperationException("ReceiptNo is required.");
}
return response.ReceiptNo;
}
Este tipo de código defensivo es necesario en algunos casos, pero en realidad muchas veces se puede evitar mediante el “diseño del tipo”.
Si se representa como un tipo de datos algebraico, cada caso puede contener únicamente los datos que necesita.
Succeeded tiene receiptNo
InsufficientFunds tiene shortage
Rejected tiene reason
NetworkFailure tiene message
Con este diseño no se puede construir un valor Succeeded sin receiptNo.
Es decir, en lugar de esforzarse en comprobar el estado después, se impide desde el principio construir un estado inválido.
Resumen de los métodos de implementación (capítulos 4 a 10)
Desde aquí hasta el capítulo 10 se detalla cómo implementar este tipo suma en .NET. Primero se presenta el panorama general.
| Método de implementación | Capítulo | Entorno objetivo | Cantidad de código | Dependencia de bibliotecas | Detección de casos omitidos |
|---|---|---|---|---|---|
Jerarquía de clases (constructor privado + clases sealed anidadas + Match) |
4 y 5 | Funciona tanto en .NET Framework como en el .NET actual | Mucha. Hay que escribir a mano la clase, la fábrica y el Match de cada caso |
Ninguna | Como el Match gana un argumento, agregar un caso provoca un error de compilación en el código que lo invoca |
| Jerarquía de record | 6 | record es una característica de C# 9 en adelante. Este artículo asume .NET 5 o posterior | Poca. Cada caso se escribe en una línea | Ninguna | La expresión switch sola es débil. Si se define un Match propio, se puede forzar |
| Unión discriminada de F# | 7 | Proyectos F#. Se puede usar tanto para .NET Framework como para el .NET actual | Mínima. La propia definición del tipo es la lista de casos | Ninguna (característica del lenguaje) | El compilador comprueba la exhaustividad de match y avisa si falta algo |
| OneOf | 8 | Amplia variedad de destinos, incluidos .NET Framework y .NET Standard | Poca. No hace falta crear una clase base propia | Sí (paquete NuGet) | Como Match exige un delegado por cada caso, agregar un parámetro de tipo provoca un error de compilación en el código que lo invoca |
| Bibliotecas basadas en Source Generator | 9 | Centradas en el .NET actual. Hay que verificar la compatibilidad con .NET Framework en cada biblioteca | Poca. Basta con agregar un atributo | Sí (paquete + entorno de compilación) | Algunas, combinadas con un analizador, pueden avisar de casos no tratados |
| Tipo union de C# 15 | 10 | Función en vista previa. No apta para código de producción | Mínima | Ninguna (característica del lenguaje) | Aún no está definida, porque la especificación no es definitiva |
El punto de partida para elegir es el siguiente.
- Si se va a introducir hoy mismo en un sistema existente que incluye .NET Framework: la jerarquía de clases de los capítulos 4 y 5
- Si el objetivo puede limitarse al .NET actual: la jerarquía de record del capítulo 6
- Si solo se quiere representar un valor de retorno puntual: OneOf, del capítulo 8
4. Una implementación válida también para .NET Framework: jerarquía de clases sealed
En sistemas existentes, incluidos los que usan .NET Framework, lo más fácil de introducir es una clase base abstracta + clases sealed anidadas + un método Match.
Es fácil de usar incluso en versiones antiguas de C# y no requiere ninguna característica especial del runtime.
Como ejemplo, representemos el resultado de crear un usuario.
public abstract class CreateUserResult
{
private CreateUserResult()
{
}
public sealed class Created : CreateUserResult
{
internal Created(User user)
{
if (user == null) throw new ArgumentNullException(nameof(user));
User = user;
}
public User User { get; }
}
public sealed class DuplicateEmail : CreateUserResult
{
internal DuplicateEmail(string email)
{
if (email == null) throw new ArgumentNullException(nameof(email));
Email = email;
}
public string Email { get; }
}
public sealed class WeakPassword : CreateUserResult
{
internal WeakPassword(string reason)
{
if (reason == null) throw new ArgumentNullException(nameof(reason));
Reason = reason;
}
public string Reason { get; }
}
public sealed class SystemFailure : CreateUserResult
{
internal SystemFailure(string message)
{
if (message == null) throw new ArgumentNullException(nameof(message));
Message = message;
}
public string Message { get; }
}
public static CreateUserResult Ok(User user)
=> new Created(user);
public static CreateUserResult EmailAlreadyUsed(string email)
=> new DuplicateEmail(email);
public static CreateUserResult PasswordIsWeak(string reason)
=> new WeakPassword(reason);
public static CreateUserResult Failed(string message)
=> new SystemFailure(message);
public T Match<T>(
Func<Created, T> created,
Func<DuplicateEmail, T> duplicateEmail,
Func<WeakPassword, T> weakPassword,
Func<SystemFailure, T> systemFailure)
{
if (created == null) throw new ArgumentNullException(nameof(created));
if (duplicateEmail == null) throw new ArgumentNullException(nameof(duplicateEmail));
if (weakPassword == null) throw new ArgumentNullException(nameof(weakPassword));
if (systemFailure == null) throw new ArgumentNullException(nameof(systemFailure));
var c = this as Created;
if (c != null) return created(c);
var d = this as DuplicateEmail;
if (d != null) return duplicateEmail(d);
var w = this as WeakPassword;
if (w != null) return weakPassword(w);
var f = this as SystemFailure;
if (f != null) return systemFailure(f);
throw new InvalidOperationException("Unknown result type: " + GetType().FullName);
}
}
El código que lo consume puede escribirse así.
CreateUserResult result = service.CreateUser(command);
string message = result.Match(
created => "Usuario creado: " + created.User.Id,
duplicate => "Esta dirección de correo ya está en uso: " + duplicate.Email,
weak => "La contraseña es demasiado débil: " + weak.Reason,
failure => "No se pudo crear el usuario: " + failure.Message);
La ventaja de este enfoque es que funciona tanto en .NET Framework como en el .NET actual.
Created, DuplicateEmail, WeakPassword y SystemFailure son todos CreateUserResult, pero cada uno contiene datos distintos.
Solo Created tiene User.
Solo DuplicateEmail tiene Email.
Solo WeakPassword tiene Reason.
Solo SystemFailure tiene Message.
No se puede construir un valor que represente éxito y fallo a la vez.
Además, si se hace que el código consumidor use Match, se puede forzar a que se traten todos los casos.
Supongamos, por ejemplo, que se agrega un nuevo caso llamado TemporaryBlocked.
public sealed class TemporaryBlocked : CreateUserResult
{
internal TemporaryBlocked(DateTimeOffset until)
{
Until = until;
}
public DateTimeOffset Until { get; }
}
En ese momento, también se agrega Func<TemporaryBlocked, T> a los parámetros del método Match.
Como consecuencia, las llamadas existentes a result.Match(...) dejan de compilar. Este es un error deseable: permite detectar en tiempo de compilación que “se agregó un caso nuevo, pero el código que lo invoca no lo contempla”.
5. Cerrar el conjunto de casos con un constructor privado
Al representar un tipo suma en C#, lo importante es cerrar el conjunto de casos en la mayor medida posible.
Si el constructor de la clase base es protected, queda margen para que se herede desde fuera.
public abstract class PaymentResult
{
protected PaymentResult()
{
}
}
Con este diseño, se puede crear un tipo como este desde otro ensamblado o desde otro punto del código.
public sealed class UnknownPaymentResult : PaymentResult
{
}
Entonces el conjunto de casos de PaymentResult deja de estar cerrado.
Se quería decir “este tipo es uno de Succeeded, InsufficientFunds, Rejected o NetworkFailure”, pero termina apareciendo un caso adicional.
Una solución práctica que también funciona en .NET Framework es hacer private el constructor de la clase base y definir los tipos de caso como tipos anidados de la clase base.
public abstract class PaymentResult
{
private PaymentResult()
{
}
public sealed class Succeeded : PaymentResult
{
internal Succeeded(string receiptNo)
{
ReceiptNo = receiptNo;
}
public string ReceiptNo { get; }
}
public sealed class InsufficientFunds : PaymentResult
{
internal InsufficientFunds(decimal shortage)
{
Shortage = shortage;
}
public decimal Shortage { get; }
}
public static PaymentResult Success(string receiptNo)
=> new Succeeded(receiptNo);
public static PaymentResult Insufficient(decimal shortage)
=> new InsufficientFunds(shortage);
}
Un tipo anidado puede acceder a los miembros private del tipo contenedor.
Por eso, solo los tipos de caso anidados pueden heredar de PaymentResult.
Con este patrón, en C# también se puede lograr algo cercano a un “conjunto de casos cerrado”.
Sin embargo, el compilador de C# no realiza una comprobación de exhaustividad completa como la de F#.
Por eso, cuando se usa este patrón en C#, se recomienda concentrar el procesamiento en el método Match, en lugar de dispersar switch por todo el código.
6. En el .NET actual, la jerarquía de record permite escribir de forma concisa
Si se puede asumir .NET 5 o posterior, usar record en C# permite escribir tipos de caso centrados en datos de forma mucho más breve.
public abstract record CreateUserResult
{
private CreateUserResult()
{
}
public sealed record Created(User User) : CreateUserResult;
public sealed record DuplicateEmail(string Email) : CreateUserResult;
public sealed record WeakPassword(string Reason) : CreateUserResult;
public sealed record SystemFailure(string Message) : CreateUserResult;
}
El código consumidor puede usar pattern matching y la expresión switch.
static string ToMessage(CreateUserResult result)
{
return result switch
{
CreateUserResult.Created { User: var user }
=> $"Usuario creado: {user.Id}",
CreateUserResult.DuplicateEmail { Email: var email }
=> $"Esta dirección de correo ya está en uso: {email}",
CreateUserResult.WeakPassword { Reason: var reason }
=> $"La contraseña es demasiado débil: {reason}",
CreateUserResult.SystemFailure { Message: var message }
=> $"No se pudo crear el usuario: {message}",
_ => throw new InvalidOperationException("Resultado desconocido.")
};
}
Esta forma de escribir es idiomática en C# y fácil de leer.
Sin embargo, también hay puntos a tener en cuenta.
La jerarquía de record es útil para reducir el código repetitivo de comparación de valores y de presentación. Sin embargo, es más seguro no asumir que cierra el conjunto de casos con la misma fuerza que la “clase normal + constructor privado + casos sealed anidados” del capítulo anterior.
En particular, en un record class que no es sealed intervienen miembros generados propios de record, como el constructor de copia. Si el objetivo es “que nunca se pueda derivar desde fuera” o “cerrar estrictamente el conjunto de casos”, es más sólido elegir la jerarquía de clases del capítulo anterior, la unión discriminada de F#, o una biblioteca de union / Source Generator con trayectoria probada.
Además, incluir _ en esta expresión switch da la impresión de que se pueden aceptar tipos derivados desconocidos.
Pero si el diseño trata el conjunto de casos como cerrado, _ es, en realidad, una rama a la que “no debería llegarse nunca”.
En las versiones estables actuales de C#, no se puede esperar una comprobación de exhaustividad tan estricta como la de las uniones discriminadas de F#. Por eso, incluso al usar una jerarquía de record en C#, es más seguro inclinarse hacia una de estas dos opciones.
- Definir un método
Matchque obligue al código consumidor a tratar todos los casos - Localizar el
switchy no dispersarlo por todo el código
Por ejemplo, también se puede agregar Match a una jerarquía de record.
public abstract record CreateUserResult
{
private CreateUserResult()
{
}
public sealed record Created(User User) : CreateUserResult;
public sealed record DuplicateEmail(string Email) : CreateUserResult;
public sealed record WeakPassword(string Reason) : CreateUserResult;
public sealed record SystemFailure(string Message) : CreateUserResult;
public T Match<T>(
Func<Created, T> created,
Func<DuplicateEmail, T> duplicateEmail,
Func<WeakPassword, T> weakPassword,
Func<SystemFailure, T> systemFailure)
{
return this switch
{
Created x => created(x),
DuplicateEmail x => duplicateEmail(x),
WeakPassword x => weakPassword(x),
SystemFailure x => systemFailure(x),
_ => throw new InvalidOperationException("Resultado desconocido.")
};
}
}
Esto permite que el código consumidor siempre tenga en cuenta todos los casos al procesar.
var message = result.Match(
created => $"Creado: {created.User.Id}",
duplicate => $"Duplicado: {duplicate.Email}",
weak => $"Contraseña débil: {weak.Reason}",
failure => $"Fallo: {failure.Message}");
La ventaja de usar record es que se reduce el código repetitivo relacionado con la comparación, la presentación y la copia de valores. Sin embargo, en una biblioteca compartida que también debe funcionar en .NET Framework, a veces resulta más manejable escribir con una clase normal que forzar el uso de record o de propiedades init-only.
Conviene priorizar “encerrar en el tipo el estado que se quiere representar” por encima de “usar la sintaxis más nueva”.
7. Usar la unión discriminada de F#
El lenguaje que trata los tipos de datos algebraicos de forma más natural en .NET es F#.
F# incorpora la unión discriminada como característica del lenguaje.
type CreateUserResult =
| Created of user: User
| DuplicateEmail of email: string
| WeakPassword of reason: string
| SystemFailure of message: string
El código consumidor también resulta natural.
let toMessage result =
match result with
| Created user -> $"Usuario creado: {user.Id}"
| DuplicateEmail email -> $"Esta dirección de correo ya está en uso: {email}"
| WeakPassword reason -> $"La contraseña es demasiado débil: {reason}"
| SystemFailure message -> $"No se pudo crear el usuario: {message}"
Lo bueno de F# es que la enumeración de casos y el pattern matching están integrados en el lenguaje.
Al agregar un caso, resulta fácil detectar los casos que faltan tratar en el match.
Además, tipos como Option<'T>, que representan si un valor existe o no, se pueden usar de forma natural como una unión discriminada.
let tryFindUser id : User option =
// Si se encuentra, Some user; si no, None
failwith "sample"
Al devolver option en lugar de null, el hecho de que “puede no existir” queda reflejado en el tipo.
Como la unión discriminada de F# se compila como un tipo de .NET, se puede usar tanto en proyectos F# dirigidos a .NET Framework como en proyectos F# dirigidos al .NET actual.
Sin embargo, cuando se manipula una unión discriminada de F# directamente desde C#, a veces no resulta tan natural como dentro de F#.
Por eso, la siguiente distribución de responsabilidades resulta realista.
- En la lógica de dominio interna de F#, usar activamente la unión discriminada de F#
- En las API públicas que se invocan con frecuencia desde C#, convertir a un DTO o a una jerarquía de clases fáciles de manejar desde C#
- En los límites, mapear a otra representación adaptada a las necesidades de JSON o de la base de datos
Si se logra la separación “tipos fuertes dentro del dominio, tipos manejables en los límites externos”, resulta más fácil trabajar incluso con una combinación de F# y C#.
8. Usar una biblioteca como OneOf
Cuando se quiere representar un tipo suma de forma sencilla en C#, una biblioteca como OneOf también es una opción.
Por ejemplo, el valor de retorno se puede representar así.
using OneOf;
public sealed class DuplicateEmail
{
public DuplicateEmail(string email)
{
Email = email;
}
public string Email { get; }
}
public sealed class WeakPassword
{
public WeakPassword(string reason)
{
Reason = reason;
}
public string Reason { get; }
}
public OneOf<User, DuplicateEmail, WeakPassword> CreateUser(CreateUserCommand command)
{
if (EmailExists(command.Email))
{
return new DuplicateEmail(command.Email);
}
if (!IsStrongPassword(command.Password))
{
return new WeakPassword("Use 12 caracteres o más.");
}
return CreateUserCore(command);
}
El código que lo invoca puede procesarlo con Match.
var result = service.CreateUser(command);
var message = result.Match(
user => $"Creado: {user.Id}",
duplicate => $"Duplicado: {duplicate.Email}",
weak => $"Contraseña débil: {weak.Reason}");
OneOf<User, DuplicateEmail, WeakPassword> significa “este valor es uno de User, DuplicateEmail o WeakPassword”.
La ventaja de este método es que resulta fácil de usar como valor de retorno puntual sin necesidad de crear una clase base propia.
Es especialmente adecuado para representar este tipo de valores de retorno en la capa de servicios de aplicación o de casos de uso.
Resultado de creación de usuario = User o DuplicateEmail o WeakPassword
Resultado de obtención de producto = Product o NotFound o AccessDenied
Resultado de pago = Receipt o InsufficientFunds o PaymentRejected
Sin embargo, también hay puntos a tener en cuenta.
Si un tipo como OneOf<A, B, C> se expone tal cual en una API pública, el nombre en el dominio puede perder fuerza.
Por ejemplo, si solo se miran los argumentos de tipo, las dos firmas siguientes tienen una estructura similar.
OneOf<User, NotFound, AccessDenied> GetUser(...)
OneOf<Order, NotFound, AccessDenied> GetOrder(...)
En un ámbito reducido resulta cómodo, pero si se quiere dejar claro el significado en el dominio, es más legible crear un tipo propio.
public abstract class GetUserResult
{
// Found / NotFound / AccessDenied
}
La pauta para decidir es la siguiente.
- Para un valor de retorno puntual,
OneOfresulta cómodo - Para un concepto que aparece repetidamente en el dominio, crear un tipo propio
- Si se prioriza la estabilidad de la API pública, usar un tipo de resultado con nombre propio
Cabe señalar que OneOf se puede usar en una amplia variedad de destinos, incluidos .NET Framework y .NET Standard, por lo que es una opción fácil de introducir también en activos existentes de .NET Framework.
9. Usar bibliotecas basadas en Source Generator
En el .NET actual también existen bibliotecas que usan Source Generator para generar tipos con aspecto de unión discriminada.
Por ejemplo, algunas generan código para Switch, Map, validación e integración con serialización con solo agregar un atributo.
A modo de ejemplo, tendría este aspecto.
[Union]
public partial record Result<T>
{
public sealed record Success(T Value) : Result<T>;
public sealed record Failure(string Error) : Result<T>;
}
Este tipo de bibliotecas puede reducir el código repetitivo de Match o Switch escrito a mano.
Algunas, además, combinadas con un analizador, avisan de los casos no tratados.
Sin embargo, si se van a usar en un sistema existente que incluye .NET Framework, hay que verificar lo siguiente.
- Si el TFM objetivo es compatible con .NET Framework
- Si se dispone del SDK, Visual Studio y el entorno MSBuild necesarios para usar Source Generator
- Si el entorno de CI produce el mismo resultado generado
- Si el código generado se puede depurar
- Si la integración con JSON, base de datos y OpenAPI en los límites de la aplicación funciona como se espera
En particular, en proyectos antiguos de .NET Framework, un paquete que asume Source Generator a veces no se puede usar tal cual.
Si se quiere dar un buen soporte a .NET Framework, es más seguro empezar con una jerarquía de clases escrita a mano o con OneOf.
10. Sobre el tipo union de C# 15
Este capítulo trata contenido en fase de propuesta y vista previa. Es información vigente a junio de 2026, y la sintaxis, los tipos generados y el tratamiento del pattern matching que se describen a continuación pueden cambiar antes del lanzamiento oficial. Incluso podrían descartarse. Lea el código de este capítulo no como “así se puede escribir hoy”, sino como “esta es la dirección que se está debatiendo”. La base de esta implementación son la propuesta de especificación de la característica de C# (Unions - C# feature specifications) y el artículo del .NET Blog, ambos documentos en fase de propuesta.
Este artículo reescribirá este capítulo conforme a la especificación definitiva en cuanto se publique oficialmente el tipo union de C# 15. Hasta entonces, no use el contenido de este capítulo como base para decisiones de diseño. Las opciones estables en las que sí puede apoyarse son las de los capítulos 4 a 9.
En la dirección que plantea la vista previa, se puede declarar que “este tipo es uno de los tipos especificados”.
public record class Cat(string Name);
public record class Dog(string Name);
public record class Bird(string Name);
public union Pet(Cat, Dog, Bird);
El código consumidor trata cada caso mediante pattern matching.
static string Describe(Pet pet)
{
return pet switch
{
Cat cat => $"Cat: {cat.Name}",
Dog dog => $"Dog: {dog.Name}",
Bird bird => $"Bird: {bird.Name}",
Pet { Value: null } => "Unknown pet"
};
}
Si esta característica se estabiliza, en C# también será posible manejar de forma más natural un “conjunto cerrado de tipos” junto con un “pattern matching exhaustivo”.
Si el tipo generado en la etapa de vista previa es un struct, también puede llegar un valor como default(Pet), con el Value interno en null.
Cuando un método público recibe un valor union, hay que tratar este tipo de valor predeterminado de forma defensiva.
Aun así, una característica en vista previa debe evaluarse con cautela antes de incorporarla al código de producción.
La especificación del lenguaje, el soporte del IDE, los tipos auxiliares del runtime, los analizadores y la integración con serializadores pueden cambiar antes del lanzamiento oficial.
Por eso, en la práctica actual resulta realista situarlo así.
- Para validaciones nuevas o investigación técnica, vale la pena probar el union de C#
- Para código de producción que se mantendrá a largo plazo, usar opciones estables como las uniones discriminadas de F#, las jerarquías de class/record, OneOf o Source Generator
- Organizar desde ahora los valores de retorno y los estados como tipos de “uno entre varios”, para facilitar una futura migración al union de C#
En otras palabras, no hace falta esperar al union de C# para aplicar hoy mismo un diseño de estilo ADT.
Al contrario, organizar desde ahora los tipos Result, Option, los tipos de estado y los tipos de evento de dominio facilitará la migración futura a la característica del lenguaje.
11. Tipo Option: representar la “ausencia” en lugar de null
Un ejemplo representativo de tipo de datos algebraico es Option<T>.
Option<T> representa una de estas dos opciones.
Some(value)
None
En C# es habitual representar “ausencia” con null, pero null tiene el problema de no ser visible desde el tipo.
User user = repository.FindById(id);
// El código que llama debe recordar si user puede ser null
Console.WriteLine(user.Name);
Al usar Option<User>, el hecho de que “puede no encontrarse” queda reflejado en el tipo.
A continuación se muestra una implementación sencilla, también válida en .NET Framework.
public abstract class Option<T>
{
private Option()
{
}
public sealed class Some : Option<T>
{
internal Some(T value)
{
Value = value;
}
public T Value { get; }
}
public sealed class None : Option<T>
{
internal None()
{
}
}
private static readonly None NoneValue = new None();
public static Option<T> Of(T value)
{
if (object.Equals(value, null))
{
return NoneValue;
}
return new Some(value);
}
public static Option<T> Empty()
{
return NoneValue;
}
public TResult Match<TResult>(Func<T, TResult> some, Func<TResult> none)
{
if (some == null) throw new ArgumentNullException(nameof(some));
if (none == null) throw new ArgumentNullException(nameof(none));
var s = this as Some;
if (s != null) return some(s.Value);
return none();
}
}
El código consumidor queda así.
Option<User> user = repository.FindById(id);
string displayName = user.Match(
some: u => u.Name,
none: () => "Invitado");
No hace falta eliminar null por completo.
Las API existentes de .NET, las bases de datos y JSON siguen produciendo null.
Sin embargo, dentro de la lógica de dominio, en muchos casos Option<T> deja la intención más clara que null.
Option<T> resulta especialmente adecuado en métodos como estos.
Option<User> TryFindUser(UserId id);
Option<Customer> FindCustomerByEmail(Email email);
Option<Discount> GetApplicableDiscount(Order order);
El punto clave no es solo anteponer Try al nombre del método, sino también reflejar la “posibilidad de ausencia” en el propio tipo de retorno.
12. Tipo Result: devolver los fallos previstos mediante el tipo
Otro tipo muy usado es Result<TSuccess, TError>.
Representa una de estas dos opciones.
Success(value)
Failure(error)
Las excepciones son adecuadas para fallos inesperados o para fallos que no se quieren incorporar al flujo de control habitual. Por otro lado, los fallos que ocurren con frecuencia en el negocio a veces resultan más legibles si se devuelven mediante el tipo.
Por ejemplo, en el proceso de inicio de sesión estos fallos son previsibles.
- El usuario no existe
- La contraseña es incorrecta
- La cuenta está bloqueada
- Se requiere autenticación multifactor
Si esto se representa únicamente con excepciones, el código que llama termina escribiendo la lógica de negocio dentro de los catch.
try
{
var session = auth.Login(userName, password);
return Ok(session);
}
catch (InvalidPasswordException)
{
return Unauthorized();
}
catch (AccountLockedException)
{
return Forbid();
}
Escribirlo con excepciones funciona, pero la lógica de negocio tiende a quedar enterrada en el manejo de excepciones.
Representado al estilo ADT, queda así.
public abstract class LoginResult
{
private LoginResult()
{
}
public sealed class Succeeded : LoginResult
{
internal Succeeded(Session session)
{
Session = session;
}
public Session Session { get; }
}
public sealed class InvalidPassword : LoginResult
{
internal InvalidPassword()
{
}
}
public sealed class AccountLocked : LoginResult
{
internal AccountLocked(DateTimeOffset until)
{
Until = until;
}
public DateTimeOffset Until { get; }
}
public sealed class MfaRequired : LoginResult
{
internal MfaRequired(string challengeId)
{
ChallengeId = challengeId;
}
public string ChallengeId { get; }
}
public static LoginResult Success(Session session)
=> new Succeeded(session);
public static LoginResult WrongPassword()
=> new InvalidPassword();
public static LoginResult Locked(DateTimeOffset until)
=> new AccountLocked(until);
public static LoginResult RequireMfa(string challengeId)
=> new MfaRequired(challengeId);
public T Match<T>(
Func<Succeeded, T> succeeded,
Func<InvalidPassword, T> invalidPassword,
Func<AccountLocked, T> accountLocked,
Func<MfaRequired, T> mfaRequired)
{
if (succeeded == null) throw new ArgumentNullException(nameof(succeeded));
if (invalidPassword == null) throw new ArgumentNullException(nameof(invalidPassword));
if (accountLocked == null) throw new ArgumentNullException(nameof(accountLocked));
if (mfaRequired == null) throw new ArgumentNullException(nameof(mfaRequired));
var s = this as Succeeded;
if (s != null) return succeeded(s);
var i = this as InvalidPassword;
if (i != null) return invalidPassword(i);
var l = this as AccountLocked;
if (l != null) return accountLocked(l);
var m = this as MfaRequired;
if (m != null) return mfaRequired(m);
throw new InvalidOperationException("Unknown result type: " + GetType().FullName);
}
}
Con este diseño, el código que llama puede implementarse viendo “los resultados posibles del proceso de inicio de sesión”.
var result = auth.Login(userName, password);
return result.Match(
succeeded => Ok(succeeded.Session),
invalidPassword => Unauthorized(),
accountLocked => StatusCode(423),
mfaRequired => Accepted(new { mfaRequired.ChallengeId }));
El punto clave no es dejar de usar excepciones.
Repartir los roles así: Result para las bifurcaciones de negocio previstas, excepciones para las anomalías inesperadas.
Solo con esto, la claridad de la capa de servicios de aplicación o de la capa de API mejora notablemente.
13. Representar las transiciones de estado mediante tipos
Los ADT no solo sirven para valores de retorno, también son adecuados para representar estados.
Pensemos, por ejemplo, en el estado de un pedido.
public enum OrderStatus
{
Draft,
Submitted,
Paid,
Shipped,
Cancelled
}
Con solo un enum resulta difícil representar los datos que necesita cada estado.
Draftnecesita el creadorSubmittednecesita la fecha y hora de envíoPaidnecesita el número de pagoShippednecesita el número de envíoCancellednecesita el motivo de la cancelación
Si se intenta representarlo con OrderStatus y propiedades separadas, vuelven a acumularse propiedades nullable.
public sealed class Order
{
public OrderStatus Status { get; set; }
public DateTimeOffset? SubmittedAt { get; set; }
public string PaymentNo { get; set; }
public string TrackingNo { get; set; }
public string CancelReason { get; set; }
}
Con este diseño se puede construir un estado en el que Status == Draft pero TrackingNo tiene un valor.
Representado al estilo ADT, el propio estado se convierte en un tipo.
public abstract class OrderState
{
private OrderState()
{
}
public sealed class Draft : OrderState
{
internal Draft(UserId createdBy)
{
CreatedBy = createdBy;
}
public UserId CreatedBy { get; }
}
public sealed class Submitted : OrderState
{
internal Submitted(DateTimeOffset submittedAt)
{
SubmittedAt = submittedAt;
}
public DateTimeOffset SubmittedAt { get; }
}
public sealed class Paid : OrderState
{
internal Paid(string paymentNo)
{
PaymentNo = paymentNo;
}
public string PaymentNo { get; }
}
public sealed class Shipped : OrderState
{
internal Shipped(string trackingNo)
{
TrackingNo = trackingNo;
}
public string TrackingNo { get; }
}
public sealed class Cancelled : OrderState
{
internal Cancelled(string reason)
{
Reason = reason;
}
public string Reason { get; }
}
}
El pedido tiene un OrderState.
public sealed class Order
{
public OrderId Id { get; }
public OrderState State { get; private set; }
public Order(OrderId id, UserId createdBy)
{
Id = id;
State = new OrderState.Draft(createdBy);
}
}
Además, las transiciones de estado se encierran en métodos.
public void Submit(IClock clock)
{
if (!(State is OrderState.Draft))
{
throw new InvalidOperationException("Solo se pueden enviar los pedidos en estado de borrador.");
}
State = new OrderState.Submitted(clock.Now);
}
public void MarkAsPaid(string paymentNo)
{
if (!(State is OrderState.Submitted))
{
throw new InvalidOperationException("Solo los pedidos enviados se pueden marcar como pagados.");
}
State = new OrderState.Paid(paymentNo);
}
Con este diseño, resultan más legibles tanto los datos de cada estado como las reglas de transición entre estados.
Por supuesto, al persistir los datos, a veces se guardan separados en OrderStatus y columnas auxiliares.
Aun así, dentro del dominio se puede tratar como OrderState y convertir en el límite con la base de datos.
Representación en la base de datos
status = "Paid"
payment_no = "PAY-001"
Representación interna en el dominio
OrderState.Paid("PAY-001")
No hace falta debilitar el modelo de dominio para adaptarlo al esquema de la base de datos.
14. Convertir a DTO en los límites de la API
Los tipos de estilo ADT resultan muy útiles dentro del dominio.
Por otro lado, en las API JSON, la base de datos, las colas de mensajes, OpenAPI y las integraciones externas hace falta un poco de cuidado.
Supongamos, por ejemplo, que este ADT se expone tal cual en JSON.
public abstract record PaymentResult
{
public sealed record Succeeded(string ReceiptNo) : PaymentResult;
public sealed record Rejected(string Reason) : PaymentResult;
public sealed record NetworkFailure(string Message) : PaymentResult;
}
En JSON, quizá se quiera un formato como este.
{
"type": "succeeded",
"receiptNo": "R-001"
}
En caso de fallo, tendría este formato.
{
"type": "rejected",
"reason": "card_expired"
}
Este type es el discriminador del lado de JSON.
El ADT del dominio y la representación JSON se parecen, pero no son lo mismo.
Por eso, es más seguro diseñar una conversión a DTO en los límites externos.
public sealed class PaymentResultDto
{
public string Type { get; set; }
public string ReceiptNo { get; set; }
public string Reason { get; set; }
public string Message { get; set; }
}
En el proceso de conversión, se crea un DTO para cada caso del ADT.
public static PaymentResultDto ToDto(PaymentResult result)
{
return result switch
{
PaymentResult.Succeeded x => new PaymentResultDto
{
Type = "succeeded",
ReceiptNo = x.ReceiptNo
},
PaymentResult.Rejected x => new PaymentResultDto
{
Type = "rejected",
Reason = x.Reason
},
PaymentResult.NetworkFailure x => new PaymentResultDto
{
Type = "network_failure",
Message = x.Message
},
_ => throw new InvalidOperationException("Resultado de pago desconocido.")
};
}
Por supuesto, también existe la opción de usar la serialización polimórfica de System.Text.Json o convertidores personalizados.
Sin embargo, en una API que se mantiene a largo plazo, en muchos casos es más seguro no acoplar estrechamente el formato JSON a la estructura interna del tipo de dominio.
Se recomienda esta separación.
Dentro del dominio
PaymentResult.Succeeded
PaymentResult.Rejected
PaymentResult.NetworkFailure
Límite de la API
PaymentResultDto
type: "succeeded" | "rejected" | "network_failure"
El tipo de dominio se concentra en representar el negocio, y la representación externa se estabiliza mediante el DTO.
Con esta separación, resulta más fácil mantener la compatibilidad de la API aunque se mejore el interior del dominio.
15. Ventaja 1: resulta más difícil construir estados inválidos
La mayor ventaja de los ADT es que dificultan la construcción de estados inválidos.
Por ejemplo, un tipo como este permite crear fácilmente combinaciones inválidas.
public sealed class Reservation
{
public bool IsCancelled { get; set; }
public DateTimeOffset? CancelledAt { get; set; }
public string CancelReason { get; set; }
public DateTimeOffset? ConfirmedAt { get; set; }
}
Con este tipo se pueden construir estados como estos.
CancelledAttiene un valor aunque no se haya cancelado- Está cancelada pero
CancelReasonno tiene valor ConfirmedAttiene un valor aunque ya esté cancelada- Existe una fecha de confirmación aunque todavía no se haya confirmado
Representado al estilo ADT, se pueden separar los datos que necesita cada estado.
public abstract class ReservationState
{
private ReservationState()
{
}
public sealed class Requested : ReservationState
{
internal Requested(DateTimeOffset requestedAt)
{
RequestedAt = requestedAt;
}
public DateTimeOffset RequestedAt { get; }
}
public sealed class Confirmed : ReservationState
{
internal Confirmed(DateTimeOffset confirmedAt)
{
ConfirmedAt = confirmedAt;
}
public DateTimeOffset ConfirmedAt { get; }
}
public sealed class Cancelled : ReservationState
{
internal Cancelled(DateTimeOffset cancelledAt, string reason)
{
CancelledAt = cancelledAt;
Reason = reason;
}
public DateTimeOffset CancelledAt { get; }
public string Reason { get; }
}
}
Así, solo el estado cancelado tiene la fecha de cancelación y el motivo.
En lugar de comprobar después las combinaciones inválidas, se reducen desde el momento del diseño.
Esto también es importante desde el punto de vista de las pruebas.
Cuando aumentan los bool y las propiedades nullable, el número de combinaciones se dispara.
Con un ADT, los casos que hay que probar se organizan como “los casos definidos”.
16. Ventaja 2: hace que el código consumidor sea consciente de los casos que faltan tratar
Un ADT muestra al código que lo invoca “qué casos puede tener este valor”.
Por ejemplo, al ver el siguiente valor de retorno, el código que lo invoca sabe que debe tratar Found, NotFound y Forbidden.
public abstract class GetDocumentResult
{
private GetDocumentResult()
{
}
public sealed class Found : GetDocumentResult
{
internal Found(Document document)
{
Document = document;
}
public Document Document { get; }
}
public sealed class NotFound : GetDocumentResult
{
internal NotFound(DocumentId id)
{
Id = id;
}
public DocumentId Id { get; }
}
public sealed class Forbidden : GetDocumentResult
{
internal Forbidden(UserId userId)
{
UserId = userId;
}
public UserId UserId { get; }
}
public static GetDocumentResult DocumentFound(Document document)
=> new Found(document);
public static GetDocumentResult DocumentNotFound(DocumentId id)
=> new NotFound(id);
public static GetDocumentResult AccessForbidden(UserId userId)
=> new Forbidden(userId);
public T Match<T>(
Func<Found, T> found,
Func<NotFound, T> notFound,
Func<Forbidden, T> forbidden)
{
if (found == null) throw new ArgumentNullException(nameof(found));
if (notFound == null) throw new ArgumentNullException(nameof(notFound));
if (forbidden == null) throw new ArgumentNullException(nameof(forbidden));
var f = this as Found;
if (f != null) return found(f);
var n = this as NotFound;
if (n != null) return notFound(n);
var d = this as Forbidden;
if (d != null) return forbidden(d);
throw new InvalidOperationException("Unknown result type: " + GetType().FullName);
}
}
Si solo se devuelve null, no se sabe si es porque “no existe”, porque “no hay permiso” o porque “falló el proceso de obtención”.
Si solo se usan excepciones, resulta difícil saber cuáles están previstas desde el punto de vista del negocio.
Al representarlo como GetDocumentResult, la firma del método se convierte en la especificación.
GetDocumentResult GetDocument(UserId userId, DocumentId documentId);
Este método no se limita a devolver un documento.
Tiene el contrato de API de devolver uno de estos tres resultados: “encontrado”, “no encontrado” o “sin permiso”.
Además, usar Match facilita detectar los casos no tratados.
return result.Match(
found => Ok(found.Document),
notFound => NotFound(),
forbidden => Forbid());
Cuando se agrega un caso nuevo y aumentan los parámetros de Match, resulta fácil detectar en tiempo de compilación que el código consumidor no se actualizó.
Esto resulta muy efectivo en el mantenimiento a largo plazo.
17. Ventaja 3: los términos del dominio permanecen en el código
Si el estado se representa solo con bool, int, string o null, el significado de negocio desaparece del código.
return false;
¿Qué significa este false?
- No se encontró
- La entrada era inválida
- No había permiso
- El servicio externo estaba caído
- Ya se había procesado
El código que llama no puede saberlo si no conoce el contexto.
Al usar un ADT, las palabras de negocio quedan reflejadas como tipo.
return GetDocumentResult.DocumentNotFound(documentId);
return GetDocumentResult.AccessForbidden(userId);
return SubmitOrderResult.AlreadySubmitted(orderId);
return SubmitOrderResult.CreditLimitExceeded(limit);
Esta diferencia es enorme.
Los términos del dominio pasan a ser visibles tanto en las revisiones de código como en los registros y en las pruebas.
Por ejemplo, hasta los nombres de las pruebas resultan naturales.
[Fact]
public void Reenviar_un_pedido_ya_enviado_devuelve_AlreadySubmitted()
{
var result = service.Submit(orderId);
Assert.IsType<SubmitOrderResult.AlreadySubmitted>(result);
}
Esto no es solo una técnica de implementación: es una forma de dejar la especificación de negocio en el código.
18. Ventaja 4: reduce el uso excesivo de excepciones
Las excepciones de .NET son potentes.
Sin embargo, si hasta las bifurcaciones que ocurren con frecuencia en el negocio se convierten en excepciones, el flujo puede volverse difícil de seguir.
Pensemos, por ejemplo, en la asignación de stock.
La falta de stock no es una anomalía desde el punto de vista del sistema. Es un resultado que ocurre con normalidad en el negocio.
public abstract class ReserveStockResult
{
private ReserveStockResult()
{
}
public sealed class Reserved : ReserveStockResult
{
internal Reserved(ReservationId reservationId)
{
ReservationId = reservationId;
}
public ReservationId ReservationId { get; }
}
public sealed class OutOfStock : ReserveStockResult
{
internal OutOfStock(Sku sku, int requested, int available)
{
Sku = sku;
Requested = requested;
Available = available;
}
public Sku Sku { get; }
public int Requested { get; }
public int Available { get; }
}
public T Match<T>(
Func<Reserved, T> reserved,
Func<OutOfStock, T> outOfStock)
{
if (reserved == null) throw new ArgumentNullException(nameof(reserved));
if (outOfStock == null) throw new ArgumentNullException(nameof(outOfStock));
var r = this as Reserved;
if (r != null) return reserved(r);
var o = this as OutOfStock;
if (o != null) return outOfStock(o);
throw new InvalidOperationException("Unknown result type: " + GetType().FullName);
}
}
Representado así, la falta de stock se convierte en el resultado normal OutOfStock.
var result = stock.Reserve(sku, quantity);
return result.Match(
reserved => Ok(reserved.ReservationId),
outOfStock => Conflict(new
{
sku = outOfStock.Sku.Value,
requested = outOfStock.Requested,
available = outOfStock.Available
}));
Por otro lado, situaciones como que se corte la conexión a la base de datos, que el archivo de configuración esté corrupto o que ocurra una inconsistencia inesperada sí pueden representarse con excepciones.
Como criterio de decisión, la siguiente línea divisoria resulta práctica.
Lo que el código que llama debe tratar como una bifurcación normal
=> se devuelve con Result / ADT
Lo de lo que el procesamiento normal no puede recuperarse
=> se convierte en excepción
Con este reparto se evita que try-catch termine sustituyendo a la lógica de negocio.
19. Ventaja 5: facilita escribir pruebas
Al usar un ADT, los casos que hay que probar quedan claros.
Supongamos, por ejemplo, que existe el siguiente tipo de resultado.
SubmitOrderResult =
Submitted(orderId)
o AlreadySubmitted(orderId)
o InvalidOrder(reason)
o CreditLimitExceeded(limit)
En este caso, las pruebas se dividen de forma natural según cada caso.
Si el pedido es válido, devuelve Submitted
Si ya se había enviado, devuelve AlreadySubmitted
Si el pedido es inválido, devuelve InvalidOrder
Si supera el límite de crédito, devuelve CreditLimitExceeded
Si el estado se representa con combinaciones de propiedades nullable, la propia prueba también tiene que entender “qué combinación es válida”.
Con un ADT, el propio caso se convierte en el criterio de prueba.
Además, resulta más fácil crear datos de prueba.
var result = SubmitOrderResult.CreditLimitExceeded(limit);
Con esta única línea se puede crear un dato con el significado de “exceso de crédito”.
La intención queda más clara que combinando Status, ErrorCode, Message y Limit para construir un objeto que solo se parece al caso deseado.
20. Estrategia de introducción para .NET Framework
Al introducir un diseño de estilo ADT en un sistema existente de .NET Framework, es mejor no hacer cambios grandes de golpe.
Se recomienda empezar por los valores de retorno. Busque en el código existente elementos como estos.
bool TryXxx(...)que también necesita indicar el motivo del fallo- Se devuelve
null, pero hay varios motivos posibles de no encontrar el valor - Un
enum Statuscon un número creciente de propiedades auxiliares nullable - Se representan bifurcaciones de negocio mediante excepciones
- Se extiende la comparación de cadenas de
ErrorCode
En este tipo de lugares, el efecto de migrar a ADT se nota con facilidad.
A continuación, se crea un tipo de resultado propio.
public abstract class RegisterMemberResult
{
private RegisterMemberResult()
{
}
public sealed class Registered : RegisterMemberResult
{
internal Registered(MemberId memberId)
{
MemberId = memberId;
}
public MemberId MemberId { get; }
}
public sealed class DuplicateEmail : RegisterMemberResult
{
internal DuplicateEmail(string email)
{
Email = email;
}
public string Email { get; }
}
public sealed class InvalidInvitationCode : RegisterMemberResult
{
internal InvalidInvitationCode(string code)
{
Code = code;
}
public string Code { get; }
}
public T Match<T>(
Func<Registered, T> registered,
Func<DuplicateEmail, T> duplicateEmail,
Func<InvalidInvitationCode, T> invalidInvitationCode)
{
if (registered == null) throw new ArgumentNullException(nameof(registered));
if (duplicateEmail == null) throw new ArgumentNullException(nameof(duplicateEmail));
if (invalidInvitationCode == null) throw new ArgumentNullException(nameof(invalidInvitationCode));
var r = this as Registered;
if (r != null) return registered(r);
var d = this as DuplicateEmail;
if (d != null) return duplicateEmail(d);
var i = this as InvalidInvitationCode;
if (i != null) return invalidInvitationCode(i);
throw new InvalidOperationException("Unknown result type: " + GetType().FullName);
}
}
Después, en el límite de la API existente se convierte de inmediato al DTO o al formato anterior.
var result = service.Register(command);
return result.Match(
registered => new RegisterMemberResponse
{
Success = true,
MemberId = registered.MemberId.Value
},
duplicate => new RegisterMemberResponse
{
Success = false,
ErrorCode = "DuplicateEmail",
ErrorMessage = duplicate.Email + " ya está en uso."
},
invalidCode => new RegisterMemberResponse
{
Success = false,
ErrorCode = "InvalidInvitationCode",
ErrorMessage = "El código de invitación no es válido."
});
Aunque no se cambie de inmediato la interfaz externa, se puede fortalecer primero solo la lógica interna.
Esto es muy importante en un sistema existente.
Restricciones de la API externa o de la pantalla
se mantiene el formato de respuesta existente
Lógica de dominio interna
se maneja de forma segura con tipos de estilo ADT
Con solo convertir en el límite, la bifurcación interna se puede ordenar considerablemente.
21. Crear una biblioteca compartida con .NET Standard
Para una biblioteca que se usa tanto desde .NET Framework como desde el .NET actual, existe la opción de usar .NET Standard.
En particular, si se prioriza una amplia compatibilidad, .NET Standard 2.0 es un candidato realista.
Por ejemplo, se puede colocar el modelo de dominio y los tipos de resultado en una biblioteca con esta estructura.
MyApp.Domain
TargetFramework: netstandard2.0
MyApp.LegacyWeb
TargetFramework: net472
hace referencia a MyApp.Domain
MyApp.Api
TargetFramework: net8.0
hace referencia a MyApp.Domain
Con esta estructura resulta fácil compartir los mismos tipos de dominio entre una aplicación antigua de .NET Framework y una aplicación nueva de .NET.
Ahora bien, si el destino es .NET Standard 2.0, hay que evitar depender demasiado de las API nuevas de C# o de .NET.
Por ejemplo, en una biblioteca compartida suele ser prudente evitar diseños como estos.
- Depender fuertemente de
recordo deinit - Usar directamente API de .NET 6 en adelante
- Exponer ampliamente código que asume Source Generator
- Introducir tipos propios de ASP.NET Core en la capa de dominio
En una biblioteca compartida, centrarse en clases simples, objetos de valor y tipos de resultado de estilo ADT hace que resulte fácil de usar durante mucho tiempo.
public abstract class PaymentResult
{
private PaymentResult()
{
}
// Se representa con una clase normal, fácil de usar tanto en .NET Framework como en .NET
}
En la capa de aplicación dedicada exclusivamente al nuevo .NET, se pueden usar record y la expresión switch.
Capa de dominio compartida
tipos normales, legibles incluso en entornos antiguos
Capa de aplicación nueva
aprovecha record / pattern matching / minimal API, entre otros
Con esta separación resulta más fácil equilibrar los activos existentes con el desarrollo nuevo.
22. Hasta dónde conviene llegar con los ADT
Los ADT son útiles, pero no todo debe convertirse en un ADT.
Son adecuados para aquello cuyo conjunto de casos está prácticamente cerrado desde el punto de vista del negocio. Por ejemplo, en casos como estos.
- Resultados de procesamiento
- Resultados de validación de entradas
- Estado de un pedido
- Resultado de un pago
- Resultado de autenticación
- Resultado de llamadas a servicios externos
- Eventos de dominio
- Tipos de comando
- Estado de una pantalla
Por el contrario, hay casos que requieren precaución.
- Aquello cuyos tipos aumentan desde fuera mediante plugins
- Aquello cuyos tipos aumentan por definición del usuario
- Aquello que aumenta durante la operación como datos maestros en la base de datos
- Tipos de integración con frameworks que asumen extensión por herencia
- DTO de un CRUD simple
Si el diseño permite que los casos aumenten desde fuera, una interfaz o una jerarquía de herencia normal es más adecuada que un ADT cerrado.
Por ejemplo, si los formatos de salida de informes aumentan mediante plugins, un diseño como este resulta natural.
public interface IReportExporter
{
string FormatName { get; }
void Export(Report report, Stream output);
}
En este caso, si se convierte en un tipo suma cerrado como PdfExporter | ExcelExporter | CsvExporter, la extensión externa se vuelve difícil.
Los ADT son un diseño que funciona bien en un “mundo cerrado”.
¿Está realmente cerrado desde el punto de vista del negocio? ¿Existe la posibilidad de que aumente desde fuera en el futuro?
Es importante evaluar bien ese punto.
23. Cuándo usar enum
enum no es malo.
enum es adecuado cuando ningún caso necesita datos adicionales y basta con una simple etiqueta. Por ejemplo, en casos como este.
public enum Gender
{
Unknown,
Male,
Female,
Other
}
O algo como el nivel de registro.
public enum LogLevel
{
Trace,
Debug,
Information,
Warning,
Error,
Critical
}
Por otro lado, si cada caso necesita datos distintos, conviene considerar un tipo de estilo ADT.
PaymentStatus enum
Succeeded
Rejected
Failed
PaymentResult ADT
Succeeded(receiptNo)
Rejected(reason)
Failed(message)
El criterio para distinguirlos es simple.
Basta con saber cuál es el caso
=> enum
Cada caso tiene datos distintos
=> ADT
Cada caso tiene un comportamiento o restricciones distintas
=> ADT o jerarquía de clases
Cuando empieza a crecer la combinación de enum + grupo de propiedades nullable, es una señal de que conviene migrar a ADT.
24. Cuándo usar bool
bool tampoco es malo.
Si el significado realmente se agota en sí/no, bool es suficiente.
bool IsEnabled { get; }
bool IsDeleted { get; }
Sin embargo, si hay varios motivos de fallo posibles, bool se queda corto.
bool TryCreateUser(CreateUserCommand command);
Este método no indica el motivo cuando falla.
Se puede complementar con un parámetro out.
bool TryCreateUser(CreateUserCommand command, out User user, out string errorCode);
Pero esto se vuelve cada vez más complejo.
En este caso, resulta más legible usar un tipo de resultado.
CreateUserResult CreateUser(CreateUserCommand command);
El código que llama también puede tratar, mediante el tipo, no solo el éxito o el fallo, sino también el tipo de fallo.
return result.Match(
created => Ok(created.User),
duplicate => Conflict(),
weak => BadRequest(),
failure => StatusCode(500));
El criterio de decisión es el siguiente.
Realmente es binario y no necesita información adicional
=> bool
Es binario pero necesita un valor de éxito o un motivo de fallo
=> Result
Tres o más opciones, o cada caso tiene datos distintos
=> ADT
25. Diferencia entre herencia y ADT
Al crear un tipo de estilo ADT en C#, el aspecto se parece al de una herencia normal.
public abstract class PaymentResult
{
}
public sealed class Succeeded : PaymentResult
{
}
public sealed class Rejected : PaymentResult
{
}
Sin embargo, el propósito es un poco distinto.
La herencia orientada a objetos habitual se usa, en muchos casos, para sustituir comportamiento.
public abstract class Shape
{
public abstract double Area();
}
public sealed class Circle : Shape
{
public override double Area() => ...;
}
En cambio, la herencia de estilo ADT se usa para representar “las formas posibles de los datos”.
public abstract class PaymentResult
{
public sealed class Succeeded : PaymentResult
{
public string ReceiptNo { get; }
}
public sealed class Rejected : PaymentResult
{
public string Reason { get; }
}
}
No se trata de que una sea correcta y la otra no.
Si se quiere ubicar el procesamiento en cada caso, el polimorfismo habitual es la opción adecuada.
public abstract class Notification
{
public abstract void Send();
}
Si se quiere bifurcar en el código que llama viendo todos los casos, ADT junto con pattern matching o Match es la opción adecuada.
return notification.Match(
email => SendEmail(email),
sms => SendSms(sms),
push => SendPush(push));
En las aplicaciones de negocio resulta claro repartir así: ADT para los valores de retorno y los estados, e interfaces para sustituir comportamiento.
26. No dispersar demasiado el pattern matching
Al empezar a usar ADT, dan ganas de escribir switch o Match por todas partes.
Sin embargo, si la misma bifurcación se dispersa en varios lugares, aumentan los puntos que hay que modificar al agregar un caso.
Supongamos, por ejemplo, que PaymentResult se procesa con switch en varios lugares.
Conversión de la respuesta de la API
Registro de logs
Generación de mensajes de pantalla
Registro de métricas
Generación del registro de auditoría
Al agregar un caso, hay que corregir todos los switch.
A veces esto es inevitable, pero agrupar en la medida de lo posible la responsabilidad de la bifurcación facilita el mantenimiento.
public static class PaymentResultMapper
{
public static PaymentResultDto ToDto(PaymentResult result)
{
return result.Match(
succeeded => ...,
rejected => ...,
failure => ...);
}
public static string ToLogMessage(PaymentResult result)
{
return result.Match(
succeeded => ...,
rejected => ...,
failure => ...);
}
}
También hay casos en los que conviene que el propio caso contenga el procesamiento, en lugar de bifurcar.
public abstract class PaymentResult
{
public abstract bool IsSuccess { get; }
}
Sin embargo, si se hace que el caso contenga demasiado procesamiento, el tipo de dominio empieza a conocer las necesidades de la API o de la interfaz de usuario.
En muchos casos es mejor no incorporar directamente este tipo de procesamiento en el tipo de dominio.
- Conversión a código de estado HTTP
- Conversión a un DTO JSON
- Mensajes para mostrar en pantalla
- Formato de los registros
- Representación para OpenAPI
El tipo de dominio representa el significado del negocio. La conversión en los límites se ubica en un Mapper.
Si se tiene en cuenta esta separación, el ADT resulta más fácil de mantener a largo plazo.
27. Cómo nombrar los tipos
En los tipos de estilo ADT, el nombre es importante.
Con nombres genéricos como Result, Error o Response, el significado se diluye.
Los nombres que se usan con frecuencia son estos.
CreateUserResult
RegisterMemberResult
SubmitOrderResult
ReserveStockResult
PaymentResult
LoginResult
GetDocumentResult
OrderState
ReservationState
Los nombres de los casos se acercan a los términos de negocio.
Created
DuplicateEmail
WeakPassword
SystemFailure
AlreadySubmitted
CreditLimitExceeded
OutOfStock
MfaRequired
AccountLocked
Con solo Error1, Error2 o Failed, al código que llama le resulta difícil entender el significado.
Además, en la medida de lo posible, los datos que contiene cada caso también deben ser tipos de negocio.
public sealed class CreditLimitExceeded : SubmitOrderResult
{
public Money Limit { get; }
public Money RequestedAmount { get; }
}
Funciona igual dejándolo como decimal o string, pero al combinarlo con objetos de valor como Money, Email, UserId u OrderId, la intención queda aún más clara.
Los ADT y los objetos de valor se combinan bien.
Objeto de valor
representa el significado y las restricciones de un único valor
ADT
representa las distintas formas posibles
Al combinar ambos, resulta más fácil encerrar las reglas de negocio en el tipo.
28. Tener cuidado con el versionado
Como el ADT explicita el conjunto de casos, agregar un caso afecta al código que lo invoca.
Esto es a la vez una ventaja y un punto de atención.
En código interno, que aparezca un error de compilación al agregar un caso es algo que hay que recibir con agrado, porque permite encontrar los casos no tratados.
Por otro lado, en un tipo que se ofrece externamente como paquete NuGet o API pública, agregar un caso puede tener un significado cercano al de un cambio disruptivo.
Supongamos, por ejemplo, que quien usa la biblioteca ha escrito el tratamiento de todos los casos así.
var text = result.Match(
success => ...,
validationError => ...,
permissionDenied => ...);
Si la biblioteca agrega el caso RateLimited y también cambia la firma de Match, el código del usuario deja de compilar.
Esto es seguro, pero tiene impacto en términos de compatibilidad de la API pública.
Por eso, en una biblioteca pública conviene pensarlo así.
- Si se permite agregar casos, subir la versión y tratarlo como un cambio disruptivo
- Si se quiere permitir a los usuarios externos un tratamiento tipo
default, optar por otro diseño en lugar de un ADT cerrado - Ser estricto en el dominio interno, y en la API externa usar un DTO con un contrato versionado
Dentro de una aplicación de negocio, que aparezca un error de compilación al agregar un caso es preferible.
En una API pública, también hay que pensar en el diseño de compatibilidad.
29. Sobre el rendimiento
Un diseño de estilo ADT, a cambio de expresividad, a veces aumenta el número de objetos.
Al usar una jerarquía de clases en .NET Framework, se genera un objeto por cada caso.
return PaymentResult.Success(receiptNo);
En una aplicación de negocio habitual, esto en muchos casos no supone un problema importante.
Sin embargo, hay que prestar atención en lugares como estos.
- Procesamiento de bajo nivel invocado con alta frecuencia
- Procesamiento de streams que maneja un gran volumen de eventos
- Videojuegos o procesamiento en tiempo real
- Procesamiento en el que se quiere reducir al mínimo las asignaciones de memoria
- Procesamiento que almacena grandes cantidades de ADT en colecciones enormes
Cuando el rendimiento es importante, existen varias opciones.
- Usar un tipo Result basado en struct
- Considerar el struct discriminated union de F#
- Reducir las asignaciones con Source Generator
- Usar enum más campos dedicados en la ruta crítica, y convertir a ADT en el límite
- Medir antes de optimizar
No hace falta optimizar en exceso desde el principio.
En muchos sistemas de negocio, la claridad de diseño que aporta el ADT tiene más valor que el pequeño costo de generar objetos.
Sin embargo, en los lugares con requisitos de rendimiento exigentes, hay que pensar el diseño y la medición juntos.
30. Ejemplo de refactorización de código existente
Por último, veamos el proceso de transformar al estilo ADT un fragmento de código existente habitual.
El código original es este. Para facilitar la comparación posterior, se numeran los puntos que se van a corregir.
public bool TryReserveStock(string sku, int quantity, out string errorCode)
{
// (1) El éxito o fallo es bool, y el motivo es out string. La relación entre ambos no queda reflejada en el tipo
errorCode = null;
var stock = stockRepository.Find(sku);
if (stock == null)
{
errorCode = "SKU_NOT_FOUND"; // (2) El motivo del fallo es un literal de cadena
return false;
}
if (stock.Available < quantity)
{
errorCode = "OUT_OF_STOCK"; // (3) El código que llama no recibe cuántas unidades faltan
return false;
}
stock.Reserve(quantity);
return true; // (4) Aunque tenga éxito, no se devuelve qué asignación se realizó
}
En este código, el motivo del fallo se representa con string.
El código que llama tiene que comparar cadenas.
string errorCode;
if (!service.TryReserveStock(sku, quantity, out errorCode))
{
if (errorCode == "SKU_NOT_FOUND")
{
...
}
else if (errorCode == "OUT_OF_STOCK")
{
...
}
}
Convirtamos esto en un tipo de resultado.
public abstract class ReserveStockResult
{
private ReserveStockResult()
{
}
public sealed class Reserved : ReserveStockResult
{
internal Reserved(ReservationId reservationId)
{
ReservationId = reservationId;
}
public ReservationId ReservationId { get; }
}
public sealed class SkuNotFound : ReserveStockResult
{
internal SkuNotFound(Sku sku)
{
Sku = sku;
}
public Sku Sku { get; }
}
public sealed class OutOfStock : ReserveStockResult
{
internal OutOfStock(Sku sku, int requested, int available)
{
Sku = sku;
Requested = requested;
Available = available;
}
public Sku Sku { get; }
public int Requested { get; }
public int Available { get; }
}
public static ReserveStockResult Success(ReservationId reservationId)
=> new Reserved(reservationId);
public static ReserveStockResult NotFound(Sku sku)
=> new SkuNotFound(sku);
public static ReserveStockResult NotEnough(Sku sku, int requested, int available)
=> new OutOfStock(sku, requested, available);
public T Match<T>(
Func<Reserved, T> reserved,
Func<SkuNotFound, T> skuNotFound,
Func<OutOfStock, T> outOfStock)
{
var r = this as Reserved;
if (r != null) return reserved(r);
var n = this as SkuNotFound;
if (n != null) return skuNotFound(n);
var o = this as OutOfStock;
if (o != null) return outOfStock(o);
throw new InvalidOperationException("Resultado de asignación de stock desconocido.") ;
}
}
El método de servicio queda así. Los números se corresponden con los del código original.
public ReserveStockResult ReserveStock(Sku sku, int quantity)
{
// (1) Un único valor de retorno representa todos los casos posibles. No hace falta el parámetro out
var stock = stockRepository.Find(sku);
if (stock == null)
{
return ReserveStockResult.NotFound(sku); // (2) Un tipo de caso, no una cadena
}
if (stock.Available < quantity)
{
// (3) El caso también contiene la situación del faltante (cantidad solicitada y disponible)
return ReserveStockResult.NotEnough(sku, quantity, stock.Available);
}
var reservationId = stock.Reserve(quantity);
return ReserveStockResult.Success(reservationId); // (4) Solo el caso de éxito tiene el ID de asignación
}
Si se enumeran los cambios, quedan así.
| # | Antes | Después |
|---|---|---|
| (1) | Valor de retorno bool + out string errorCode |
Un único tipo de resultado ReserveStockResult |
| (2) | La cadena "SKU_NOT_FOUND" |
Caso SkuNotFound (tiene Sku) |
| (3) | Solo "OUT_OF_STOCK", sin saber cuánto falta |
Caso OutOfStock (tiene Requested y Available) |
| (4) | Al tener éxito solo se devuelve true |
El caso Reserved tiene ReservationId |
El código que llama puede dejar de comparar cadenas.
var result = service.ReserveStock(sku, quantity);
return result.Match(
reserved => Ok(new { reserved.ReservationId }),
notFound => NotFound(new { sku = notFound.Sku.Value }),
outOfStock => Conflict(new
{
sku = outOfStock.Sku.Value,
requested = outOfStock.Requested,
available = outOfStock.Available
}));
El punto clave de esta refactorización es que se puede trasladar el significado interno al tipo sin cambiar el comportamiento externo.
Primero se fortalece el valor de retorno.
Después se acerca el código que llama a Match.
Por último, se van reduciendo los códigos de error de tipo string y las propiedades auxiliares nullable.
Con este orden, incluso en un sistema existente se puede introducir de forma gradual.
31. Lista de verificación al introducirlo
Al crear un tipo de estilo ADT, conviene verificar lo siguiente.
¿Ese tipo representa "uno entre varios"?
¿El conjunto de casos está cerrado desde el punto de vista del negocio?
¿Cada caso necesita datos distintos?
¿Se pierde el significado si se usa bool / enum / null / un código de error string?
¿Se quiere que el código que llama sea consciente de tratar todos los casos?
¿No afecta a la compatibilidad de la API pública?
¿Existe una política de conversión con JSON / la base de datos / los DTO de pantalla?
Si también se usa en .NET Framework, ¿basta con una clase normal?
Si es exclusivo del .NET actual, ¿vale la pena usar record o Source Generator?
La estrategia de implementación se puede elegir así.
Proyecto F#
usar la unión discriminada de F#
C# en .NET Framework
abstract class + constructor privado + clases sealed anidadas + Match
C# en .NET 5 o posterior
abstract record + casos sealed record + pattern matching
Valor de retorno puntual
una biblioteca como OneOf
Reducir código repetitivo en el .NET actual
bibliotecas basadas en Source Generator
Validación de cara al futuro
vista previa del union de C# 15
Cualquiera que sea el método elegido, el objetivo es el mismo.
Proteger con el tipo la regla que antes se protegía con un comentario.
Este es el mayor sentido de usar ADT.
32. Resumen
Los tipos de datos algebraicos no son exclusivos de los lenguajes funcionales.
Incluso en C# sobre .NET Framework, se pueden usar de forma perfectamente práctica combinando clases abstractas y clases sealed.
En C# sobre el .NET actual, se pueden escribir de forma más concisa con record y pattern matching.
En F#, se puede usar directamente la unión discriminada como característica del lenguaje.
Y usando una biblioteca, en C# también se puede manejar OneOf o Result con facilidad.
Lo importante no es la sintaxis, sino la forma de pensar el diseño.
Conviene revisar lo que se representaba con bool, null, enum + propiedades nullable o string ErrorCode, y volver a preguntarse esto.
¿De qué caso, entre cuáles, es este valor?
¿Qué datos necesita cada caso?
¿Qué datos no deberían existir fuera de ese caso?
¿Qué es lo que se quiere que el código que llama trate obligatoriamente?
Si se crea el tipo respondiendo a estas preguntas, se reducen los estados inválidos, mejora la claridad de las bifurcaciones y los términos de negocio permanecen en el código.
En un sistema existente, se recomienda empezar por los valores de retorno.
Intente reemplazar por un tipo de resultado propio los lugares donde se acumulan TryXxx, null, ErrorCode o bifurcaciones de negocio mediante excepciones.
Solo con eso, la legibilidad y la seguridad del código cambian notablemente.
Referencias
- El conjunto de código de muestra de este artículo (biblioteca, demostraciones y pruebas unitarias) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/dotnet-algebraic-data-types
- Discriminated Unions - F# | Microsoft Learn
- Pattern matching overview - C# | Microsoft Learn
- switch expression - C# reference | Microsoft Learn
- Records - C# reference | Microsoft Learn
- .NET Standard - .NET | Microsoft Learn
- Explore union types in C# 15 - .NET Blog
- Unions - C# feature specifications | Microsoft Learn
- OneOf - NuGet
- Thinktecture.Runtime.Extensions - NuGet
Artículos relacionados
Artículos recientes con las mismas etiquetas para profundizar en temas cercanos.
¿Qué es un PDB (Program Database)? — Cómo entender la información de depuración, los símbolos y Source Link
Qué es un PDB: qué contiene, qué no, y su relación con Debug/Release, Portable PDB, Source Link, servidores de símbolos y el análisis de ...
¿Qué es Roslyn? ── Leer, corregir y generar código C# desde la perspectiva del compilador
Resumen de Roslyn (.NET Compiler Platform): Syntax Tree, SemanticModel, Workspace, Analyzer, Source Generator, y sus usos y precauciones ...
Cómo manejar correctamente los tokens de suplantación de Windows — préstamo de privilegios por hilo y una forma segura de revertirlos
Guía práctica sobre los tokens de suplantación de Windows: tokens de acceso, primarios y de hilo, niveles de suplantación, RevertToSelf y...
El malentendido de que TCP permite recibir por cada unidad enviada con Send ── diseño de recepción para tratarlo como flujo de bytes
En TCP, suponer que se recibe por cada unidad enviada con Send o Write provoca fragmentación, uniones, texto corrupto y protocolos rotos....
Cómo distinguir la espera de GC de una fuga de memoria en .NET — Procedimiento práctico para observar, comparar y demostrar el crecimiento de memoria
Cómo distinguir, en aplicaciones .NET, si la memoria crece por espera de GC o por una fuga real, usando dotnet-counters, dotnet-gcdump y ...
Temas relacionados
Estas páginas sitúan el tema en un contexto más amplio de servicios y decisiones.
Temas técnicos de Windows
Portal sobre desarrollo de Windows, investigación de fallos y aprovechamiento de activos existentes.
Servicios relacionados con este tema
El artículo está directamente relacionado con los siguientes servicios.
Desarrollo de aplicaciones para Windows
Aplicaciones empresariales, integración de dispositivos y herramientas de comunicación, de los requisitos al desarrollo.
Preguntas frecuentes
Preguntas habituales en las consultas sobre el tema del artículo.
- ¿Qué es un tipo de datos algebraico (ADT)?
- Un tipo de datos algebraico es la combinación de un tipo producto (un tipo que tiene A y B a la vez) y un tipo suma (un tipo que es A o B). En la práctica, la idea es representar mediante el tipo, y no mediante comentarios o convenciones de nombres, el hecho de que "este valor solo puede adoptar formas predeterminadas". El caso más usado en la práctica es el tipo suma: por ejemplo, el resultado de un pago se puede representar como "una de estas opciones: Succeeded(receiptNo), Rejected(reason) o NetworkFailure(message)", de modo que resulte imposible construir un estado inválido.
- ¿Cómo se implementa una unión discriminada (tipo suma) en C#?
- En sistemas existentes, incluidos los que usan .NET Framework, la forma más fácil de introducirlo es el patrón de clase base abstracta con constructor privado, más clases sealed anidadas, más un método Match. Como solo los tipos anidados pueden heredar de la clase base, se obtiene un conjunto de casos cerrado. A partir de .NET 5 se puede escribir de forma más concisa con una jerarquía de abstract record y sealed record. Para un valor de retorno puntual, una biblioteca como OneOf también es una opción, y en F# las uniones discriminadas se usan de forma natural como característica propia del lenguaje.
- ¿En qué casos conviene usar un tipo de datos algebraico en lugar de enum o bool?
- Si basta con saber cuál es el caso, enum es suficiente; si de verdad es una disyuntiva binaria sin información adicional, bool es suficiente. En cambio, cuando cada caso tiene datos distintos (por ejemplo, receiptNo en caso de éxito y shortage cuando falta saldo), un ADT es la opción adecuada. Si empiezan a acumularse combinaciones de enum con grupos de propiedades nullable, o si se empieza a comparar el motivo del fallo mediante códigos de error de tipo string, son señales de que conviene migrar a un ADT. Ahora bien, si el diseño permite que un plugin u otro elemento externo agregue casos nuevos, una interfaz es más adecuada que un ADT cerrado.
- ¿Los fallos de negocio deben representarse con excepciones o con un tipo Result?
- En la práctica funciona bien repartir los roles así: los fallos previstos que el código que llama debe tratar como una bifurcación normal (falta de stock, correo duplicado, contraseña incorrecta, etc.) se devuelven con Result/ADT, y las anomalías inesperadas de las que el procesamiento normal no puede recuperarse (corte de conexión con la base de datos, archivo de configuración corrupto, etc.) se representan con excepciones. Si hasta las bifurcaciones que ocurren habitualmente en el negocio se convierten en excepciones, el try-catch termina sustituyendo a la lógica de negocio y el flujo se vuelve difícil de seguir. Solo con este reparto de roles, la claridad de la capa de servicios de aplicación o de la capa de API mejora notablemente.
Perfil del autor
Página de presentación del autor del artículo.
Go Komura
Representante de KomuraSoft LLC
Especializado en desarrollo de software para Windows, consultoría técnica e investigación de fallos, sobre todo en proyectos con sistemas existentes y errores difíciles de reproducir.