Por un cúmulo de casualidades llevo unos cuantos días hablando en el Mundo Real™ sobre diseño de modelos con distintas personas y una pregunta recurrente es cómo hacer que el modelo, y sobre todo las entidades, «ganen peso» dentro de la aplicación y encapsulen más lógica en lugar de repartirla en servicios varios.
Si esto es una buena idea o no, es otro tema, pero sí es cierto que encapsular la lógica en un modelo basado en objetos «inteligentes» tiene algunas ventajas, como que puede ayudar a escribir tests más claros o dar lugar a un código más compacto y fácil de consumir.
Miguel Ángel Martín, MookieFumi en Twitter, está escribiendo una serie de post recordando conceptos básicos de C# y en uno de ellos habla sobre tipos enumerados, y me parece un buen sitio por el que podemos empezar a añadir lógica a nuestro modelo.
Tipos enumerados
Los tipos enumerados se utilizan para representar conjuntos discretos, generalmente pequeños, de valores. Son muy útiles cuando tenemos que distinguir entre unos cuantos casos para clasificar objetos, por ejemplo, podríamos tener un tipo enumerado para representar distintas forma de pago en una tienda online:
public enum PaymentMethod { CreditCard, PayPal, BankTransfer }
La principal limitación que tienen los tipos enumerados en C# es que no podemos añadirles más información, y mucho menos, lógica. En el fondo no son más que una forma de encapsular un valor de tipo int
o byte
y restringirlo a una serie de valores (bueno, al menos en parte porque en C# se pueden hacer cosas un poco raras con ellos).
Es muy frecuente que, donde tengamos un tipo enumerado, en algún momento acabe apareciendo un switch
en otra clase para realizar un proceso de forma distinta en función del valor:
public decimal GetMaxAmountToPayWith(PaymentMethod method) { switch (method) { case PaymentMethod.CreditCard: return 1000m; case PaymentMethod.PayPal: return 100m; case PaymentMethod.BankTransfer: return 10000m; default: throw new ArgumentOutOfRangeException("method"); } }
Estamos separando la lógica, GetMaxAmountAmountToPayWith
, de los datos, PaymentMethod
, algo que podríamos considerar una mala práctica dentro del paradigma de programación orientada a objetos (en otros paradigmas, como la programación funcional, seguramente ésta fuese la forma más natural de hacerlo).
Clases
Una forma de evitar esto es refactorizar nuestro tipo enumerado a una clase:
public sealed class PaymentMethod { public static readonly PaymentMethod CreditCard = new PaymentMethod("Tarjeta de Crédito", 1000m); public static readonly PaymentMethod PayPal = new PaymentMethod("PayPal", 100m); public static readonly PaymentMethod BankTransfer = new PaymentMethod("Transferencia Bancaria", 10000m); private readonly string name; private readonly decimal maxAmount; private PaymentMethod(string name, decimal maxAmount) { this.name = name; this.maxAmount = maxAmount; } public string Name { get { return name; } } public decimal MaxAmount { get { return maxAmount; } } }
Lo que tenemos es una clase con un constructor privado, por lo que no se pueden crear nuevas instancias desde fuera de la clase, que expone a través de atributos estáticos de sólo lectura los valores permitidos. De esta forma, el código que teníamos antes con el switch
desaparece y para obtener la cantidad máxima que se puede pagar con una forma de pago determinada podemos escribir el siguiente código:
var maxAmount = PaymentMethod.CreditCard.MaxAmount;
Es fundamental que los objetos que usamos sean inmutables, ya que sólo existirá una instancia de PaymentMethod
que represente cada valor en toda la aplicación, por lo que será compartida por todos aquellos objetos que la necesiten y no debería poder modificada por ninguno de ellos.
Polimorfismo
En el ejemplo anterior realmente lo único que hacíamos era añadir más información al tipo enumerado, pero esta técnica podemos usarla para añadir también comportamiento.
Imaginemos que tenemos el siguiente código para procesar un pago a través de un IPaymentGateway
que es capaz de tratar con servicios externos para hacer el cargo en la tarjeta, contactar con PayPal o lo que sea necesario:
public void ProcessPayment(PaymentMethod method, decimal amount) { if (method.MaxAmount < amount) throw new InvalidOperationException(string.Format("No se puede pagar tanto dinero con {0}", method.Name)); if (method == PaymentMethod.CreditCard) paymentGateway.HandleCreditCardPayment(amount); else if (method == PaymentMethod.PayPal) paymentGateway.HandlePayPalPayment(amount); else if (method == PaymentMethod.BankTransfer) paymentGateway.HandleBankTransferPayment(amount); }
Nuevamente estamos alejando la lógica de los datos. Con el código que escribimos antes, podríamos traspasar el método ProcessPayment
a la clase PaymentMethod
, pero seguiríamos teniendo un if
bastante feo y alguien podría sentirse mal por estar violando el Open/Closed Principle, ya que si añadimos nuevas formas de pago tenemos que andar tocando el método ProcessPayment
.
Para mejorar esto, existe una herramienta básica en programación orientada a objetos: el polimorfismo. Podemos cambiar nuestra clase PaymentMethod
para usar distintas clases para representar cada forma de pago:
public abstract class PaymentMethod { public static readonly PaymentMethod CreditCard = new CreditCard(); public static readonly PaymentMethod PayPal = new PayPal(); public static readonly PaymentMethod BankTransfer = new BankTransfer(); private readonly string name; private readonly decimal maxAmount; protected PaymentMethod(string name, decimal maxAmount) { this.name = name; this.maxAmount = maxAmount; } public string Name { get { return name; } } public decimal MaxAmount { get { return maxAmount; } } public void ProcessPayment(IPaymentGateway gateway, decimal amount) { if (maxAmount < amount) throw new InvalidOperationException(string.Format("No se puede pagar tanto dinero con {0}", name)); HandleValidPayment(gateway, amount); } protected abstract void HandleValidPayment(IPaymentGateway gateway, decimal amount); private class CreditCard : PaymentMethod { public CreditCard() : base("Tarjeta de Crédito", 1000m) {} public override void HandleValidPayment(IPaymentGateway gateway, decimal amount) { gateway.HandleCreditCardPayment(amount); } } private class PayPal : PaymentMethod { public PayPal() : base("PayPal", 100m) {} public override void HandleValidPayment(IPaymentGateway gateway, decimal amount) { gateway.HandlePayPalPayment(amount); } } private class BankTransfer : PaymentMethod { public BankTransfer() : base("Transferencia Bancaria", 1000m) {} public override void HandleValidPayment(IPaymentGateway gateway, decimal amount) { gateway.HandleBankTransferPayment(amount); } } }
Ahora tenemos clases privadas para representar cada forma de pago y en cada una de ellas se redefine la forma de gestionar un pago válido. Por el camino, podemos ver otras dos técnicas que a veces resultan útiles, el uso de patrón Template Method (del que no soy gran fan, por cierto), para no repetir la validación de que el importe se puede pagar con la forma de pago, y una especie de double dispatch para interactuar con el servicio IPaymentGateway
desde un ValueObject
.
Persistencia
Una preocupación muy frecuente cuando se pasa a este tipo de diseños es qué va a ocurrir con la persistencia. Lo que antes era un enum
que mi ORM y mi base de datos se tragaban sin problemas, ahora se ha convertido en una jerarquía de clases privadas actuando como constantes.
En NHibernate es muy fácil resolver el problema usando un IUserType, y supongo que cualquier ORM medianamente decente ofrecerá algún punto de extensión parecido para resolver este caso (por ejemplo, LLBLGen se basa en TypeConverters).
De todas formas, siempre se pueden aplicar soluciones «imaginativas» (o sea, ñapas) para resolverlo si tu ORM es muy limitado, pero voy a intentar salvaguardar mi imagen no poniéndolas aquí.
Conclusión
Lo que hemos visto en este post podríamos considerarlo como una forma de evitar un antipatrón que se conoce como primitive obsession. Hemos cambiando un tipo que era poco más que un entero, por una clase (o un conjunto de clases) que nos permite encapsular datos y comportamiento.
Si esto merece la pena o no es algo que deberás considerar en cada caso. El código inicial con el enumerado y los switch
es bastante más simple que el código final, pero también es menos flexible y puede ser más complicado de mantener a la larga.
Mi consejo en esto es, como casi siempre, aplicar YAGNI, empezar con el tipo enumerado y refactorizar hacia el diseño más elaborado cuando empieza a ser necesario.
Lo importante es saber que existe la posibilidad y cómo podemos llegar a tener un modelo más inteligente, más completo, más sencillo de testear y más fácil de mantener.
Juanma, buen post y excelente código.
Gracias por compartirlo.
Una pequeña observación casi sin importancia: la versión enum/switch es *mucho* más flexible que PaymentMethod; el motivo es que el primero no impone ninguna estructura a funcionalidades futuras (incluyendo modificaciones), mientras que el segundo sí.
Es el precio por tener un modelo que los humanos podamos comprender y manejar.
Entiendo el argumento, pero no sé si realmente podemos considerar flexible algo que los humanos no podamos comprender y manejar.
No me he explicado con claridad, has asumido que ese otro «algo» no lo podemos comprender ni manejar.
Yo todo lo que he dicho, es que usando OOP (para comprenderlo y manejarlo) pagas un precio, no que no puedas comprender ni manejar soluciones basadas en enum/switch.
De echo, a mi me atraen más ese otro tipo de soluciones.
—–
«Es el precio por tener un modelo que los humanos podamos comprender y manejar.» == «Es el precio de usar OOP.»
Estoy de acuerdo en que aplicando OOP pagas un precio (lo digo al final del post) y que hay otras formas de modelar esto (también lo menciono en el post), pero creo que la flexibilidad no sólo consiste en «imponer menos cosas».
Para mi la flexibilidad debe permitirme adaptar el software nuevos requisitos, y a veces tener menos restricciones no hace que sea más fácil hacer esos cambios.
Conozco algunos (y no miro a nadie) que piensan que el software desarrollado con lenguajes de tipado estático, por ejemplo, qué se yo… Haskell, acaba siendo más fácil de evolucionar que el software desarrollado con lenguajes dinámicos como, por decir algo… Javascript.
En ese escenario la implementación basada en Haskell sería más restrictiva (impone tipos), pero puede que esas restricciones hagan que sea más fácil razonar sobre el programa y permitan adaptarlo mejor a nuevos requisitos.
En cualquier caso, son temas interesantes para tratar y divagar un rato (rigidez de implementación y su impacto en la evolución del diseño -algo así como «menos es mas»-, y diseño de modelos OOP vs FP).
Juan, confundes las cosas, en concreto, ser más/menos estricto con ser más/menos flexible, más que nada que es obvio que javascript (y todos los «vale todo») son más flexibles (dudo que yo haya dicho tal cosa, de ser así, me rectifico ahora mismo).
Pero ahí no he entrado yo que conste, únicamente enum/switch es más flexible (precisamente por ser menos estricto…).
¿Ves?, al final parece que nunca estamos de acuerdo porque sencillamente destaca ese 1% del que no :P
Mira, en ese punto estoy de acuerdo: seguramente estemos más de acuerdo de lo que queremos dar a entender.
Aun así, no veo tan clara la implicación «menos estricto es más flexible». La jerarquía de clases (que tiene el claro inconveniente de ser bastante más compleja a priori), puedes seguir manejándola como un switch (bueno, con un if/else if/else…) pero además puedes aprovechar el polimorfismo para añadir comportamiento sin tocar código que ya existe.
«no veo tan clara la implicación “menos estricto es más flexible”»
supongamos un sistema A, si creas otro B tomando A pero añadiendo una restricción (no nula) cualquiera, es obvio que B es estrictamente menos flexible que A (más que nada que A puede hacer todo lo que B pero además aquello en lo que B está restringido).
Tu estructura de clases añade el código que resuelve el problema concreto (exactamente igual que e/s), pero además está añadiendo una estructura abstracta que clasifica ese código, esa estructura adicional, restringe acciones posteriores en el código (justificas el polimorfismo buscando un ejemplo ¡que entra dentro de tu restricción! y eso es tautología :) ).
Si ahora vamos a lo práctico, tu argumento es que, como te sientes cómodo con esa estructura y muy incómodo con otras, *para ti*, te da más «»»»flexibilidad»»»» la versión restringida, ¡pero eso *no es* más flexible!, es otra cosa (¿»productividad subjetiva»?).
En cuanto a la definición de flexible, creo que las acepciones 3 y 4 de la rae lo dejan claro (http://lema.rae.es/drae/?val=flexible):
«Que no se sujeta a normas estrictas, a dogmas o a trabas.»
«Susceptible de cambios o variaciones según las circunstancias o necesidades.»
Que a ti te resulte más cómodo refactorizar todo un árbol de clases antes que buscar «el par» de sitios donde tocar en un código spaguetti (por poner un ejemplo visual) no quiere decir que venga otro señor (ej. un Haskeller :P ) y él sí sepa donde «tocar».
Por tanto, eliminando cualquier elemento subjetivo, creo que queda claro que la versión enum/sw es *más* flexible.
Pero repito, mi comentario inicial venía a decir que en un sentido OOP estaba de acuerdo en todo, pero que en mi opinión era incorrecto (y peligroso) pensar que esa solución es más flexible.
Buen post y código.
Saludos
El link en el que haces referencia para: «(bueno, al menos en parte porque en C# se pueden hacer cosas un poco raras con ellos)», no funciona.
Gracias, Edgar. Ya he corregido el enlace.
Hola Juanma,
Coincido con casi todo, agregaría algo importante y es que en caso de devolver tipos primitivos en ocasiones movemos cierta información al lugar incorrecto, por ejemplo si una utilidad que hace POST remoto devuelve true o false quien consuma ese objeto tiene que ocuparse de resolver el mensaje de error o «mejorar» la respuesta, es un ejemplo burdo pero creo que es claro.
Además crear tipos propios nos permite (como vos decís) que esos mismos tipos sepan operar sobre sus datos, por ejemplo con el método de POST que dije anteriormente, si le paso un objeto «PostParams» en lugar de un string, PostParams puede saber cómo convertirse en un json, un form data o lo que sea dependiendo del caso de uso y podemos testearlo independientemente.
Una cosa adicional es que toda esa lógica la podemos ir agregando o mejorando sin necesidad de alterar el método POST.
Una última cosita, donde decís «seguiríamos teniendo un if bastante feo y alguien podría sentirse mal por estar violando el Open/Closed Principle» debería ser el principio de sustitución de Liskov el que estaríamos violando.
Un saludo. Leonardo.
Hola Juanma,
este será mi primer comentario publico con respecto a ser «critico constructivo» con código ajeno, espero no ofender y que se me entienda lo que quiero transmitir.
Me choca ver el codigo de
public static readonly PaymentMethod CreditCard = new CreditCard();
public static readonly PaymentMethod PayPal = new PayPal();
public static readonly PaymentMethod BankTransfer = new BankTransfer();
Cuando luego creas clases privadas con el naming identico a los nombre de las propiedades estáticas públicas, yo si que soy partidario de los tipos enumeradores porque creo que nos da mucha versatilidad a la hora de añadir nuevos conceptos y sobretodo creo que aporta gran valor para el open closed principle, yo creo honestamente que el patron estrategy iria de coña en el problema que intentas solventar
Espero poder haberme explicado bien y genial blog
Un saludo
Luis
Hola Luis,
No te preocupes, que toda crítica constructiva es bienvenida :-)
Sobre tener propiedades estáticas con el mismo nombre que clases privadas, bueno, es cuestión de gusto. Hay a quien tampoco le convence que la clase base, PaymentMethod, conozca las clases hijas a través de esas propiedades y prefiere separarlo, teniendo en algún sitio un PaymentRegistry o similar que es el que contiene las referencias a las distintas implementaciones.
En cuanto al patrón strategy, el diseño propuesto no deja de ser una versión de ese patrón. Si lo ves desde el punto de vista de la clase que consume los PaymentMethod, estos son estrategias que se le inyectan en algún punto determinado para hacer algo.
Un saludo y gracias por tu aportación.
Juanma.