Seguramente te suena familiar la siguiente situación:
Al diseñar los componentes que intervienen en una funcionalidad, decidiste representar un concepto simple, con un tipo de dato primitivo o con un string. A medida que el software fue evolucionando, añadiste nuevos comportamientos que operaban sobre el valor, algunos de los cuales los duplicaste en varios puntos del código. El concepto o atributo, que en un principio era simple, ya no lo era tanto.
En este punto es cuando nos podemos plantear darle más entidad al valor, creando un tipo de dato nuevo, con semántica propia, donde estén las operaciones que le afectan. Los pasos para hacerlo de manera segura, sin alterar la funcionalidad, es lo que veremos a continuación al aplicar el refactor Replace Primitive with Object. Pero primero, recordemos el significado de refactoring:
Refactoring significa hacer cambios en el diseño del código, con la intención de mejorarlo, pero sin alterar la funcionalidad, sin cambiar el comportamiento externo del software. Para garantizar que al refactorizar, no añadimos bugs o “rompemos” la funcionalidad, es importante tener tests automáticos que comprueben el caso de uso. Al refactorizar periódicamente, consigues que el diseño siga siendo bueno a lo largo del tiempo.
¡Veamos Replace Primitive with Object con un ejemplo!
2. Ejemplo
La funcionalidad a refactorizar es la siguiente: como cliente de un banco quiero recibir una notificación de que he pagado con mi tarjeta de débito/crédito en un comercio para saber que el pago se ha realizado.
Para simplificar el ejemplo, vamos a obviar la vía (SMS, email, etc.) por la que se envía la notificación. Sólo nos centraremos en la parte de la implementación donde se construye el contenido del mensaje.
Código Actual
Empecemos por el test unitario, que nos ayuda además, a entender lo que se espera del componente:
import org.junit.jupiter.api.Test; import java.math.BigDecimal; import static org.junit.jupiter.api.Assertions.assertEquals; class NotificationMessageProviderTest { @Test public void shouldGetTheNotificationMessage() { final BigDecimal purchaseAmount = BigDecimal.valueOf(99); final String merchantName = "Amazon"; final Card card = new Card("9676652857929306"); final String message = new NotificationMessageProvider().getMessage(purchaseAmount, merchantName, card); assertEquals("Pago con tu tarjeta terminada en 9306 por 99.00 EUR en Amazon", message); } }
La implementación:
import java.math.BigDecimal; import java.math.RoundingMode; public class NotificationMessageProvider { public String getMessage(final BigDecimal amount, final String merchantName, final Card card) { return "Pago con tu tarjeta terminada en " + card.getNumber().substring(12) + " por " + amount.setScale(2, RoundingMode.HALF_UP) + " EUR en " + merchantName; } }
El modelo Card
:
public class Card { private final String cardNumber; public Card(final String cardNumber) { this.cardNumber = cardNumber; } public String getNumber() { return cardNumber; } }
Para construir el mensaje necesitamos, además de la cantidad de dinero de la compra y del nombre del comercio, la terminación (últimos cuatro dígitos) del número de la tarjeta con la que se realizó el pago. El número de la tarjeta está modelado como String, lo cual hace que para obtener la terminación, el código cliente tenga que operar directamente sobre el String.
Lo que haremos es crear un nuevo tipo de dato CardNumber que contenga el valor del número de la tarjeta y las operaciones que le afectan.
¡Comencemos a refactorizar!
Paso 1: Encapsular.
El primer paso es verificar que el valor a reemplazar está encapsulado. En este caso sí lo está. Vemos que el atributo cardNumber es privado y tiene su correspondiente getter:
public class Card { private final String cardNumber; public Card(final String cardNumber) { this.cardNumber = cardNumber; } public String getNumber() { return cardNumber; } }
Paso 2: Crear la nueva clase.
A continuación, creamos la nueva clase que contiene el valor:
public class CardNumber { private final String value; public CardNumber(final String value) { this.value = value; } @Override public String toString() { return value; } }
El número de la tarjeta “en crudo”, se devuelve a través del método toString()
para que sea más natural para los clientes de la nueva clase obtener su representación como String
.
Paso 3: Cambiar el modelo para que use la nueva clase
Cambiemos la clase Card
internamente, sin cambiar su API, para que use el tipo CardNumber
, esto sería, el tipo del atributo y el contenido del getter:
public class Card { private final CardNumber cardNumber; public Card(final String cardNumber) { this.cardNumber = new CardNumber(cardNumber); } public String getNumber() { return cardNumber.toString(); } }
Paso 4 (Importante): Ejecutar el test
Ejecutamos el test para ver que no hemos roto nada.
Paso 5: Renombrar el método getNumber para que refleje mejor lo que devuelve
Para indicarle mejor a los cliente de Card::getNumber
lo que devuelve, lo renombramos por getNumberAsString
. Además añadimos un nuevo getter
que devuelve el nuevo tipo. La entidad Card
quedaría así:
public class Card { private final CardNumber cardNumber; public Card(final String cardNumber) { this.cardNumber = new CardNumber(cardNumber); } public String getNumberAsString() { return cardNumber.toString(); } public CardNumber getNumber() { return cardNumber; } }
En este punto ya hemos terminado formalmente el refactor. Hemos creado una nueva abstracción, encapsulado el valor y tenemos un lugar común donde poner el comportamiento relacionado con éste.
A partir de ahora es cuando le vamos a sacar provecho a tener el nuevo tipo.
Analicemos si existe algún comportamiento que podamos mover a la clase CardNumber
. Por ejemplo, el número de la tarjeta puede devolver por sí mismo su terminación.
public class CardNumber { private final String value; public CardNumber(final String value) { this.value = value; } public String ending() { return value.substring(12); // 12 porque la longitud total es 16, más adelante añadiremos la validación } @Override public String toString() { return value; } }
Refactorizamos ahora la clase NotificationMessageProvider
para que use el nuevo tipo CardNumber
y obtenga a través de éste la terminación del número de la tarjeta, olvidándose de cómo está representado internamente:
public class NotificationMessageProvider { public String getMessage(final BigDecimal amount, final String merchantName, final Card card) { return "Pago con tu tarjeta terminada en " + card.getNumber().ending() + " por " + amount.setScale(2, RoundingMode.HALF_UP) + " EUR en " + merchantName; } }
Podemos ver que el código cliente ha quedado un poco más legible.
Ejecutamos el test de nuevo. ¡Seguro que todo fue OK ?!
Para mostrar otra de las ventajas que nos aporta el haber creado una nueva clase, añadiremos una validación a la creación del CardNumber
.
Requisito: El número de la tarjeta está formado por 16 dígitos
import java.util.regex.Pattern; public class CardNumber { private final String value; public CardNumber(final String value) { if (!Pattern.matches("\\d{16}", value)) { throw new IllegalArgumentException("Card number must contains 16 digits"); } this.value = value; } public String ending() { return value.substring(12); } @Override public String toString() { return value; } }
Mejoras en el diseño que hemos obtenido con la nueva clase CardNumber
:
- Mayor encapsulamiento: Se oculta la representación interna del número de la tarjeta.
- Mayor abstracción.
- Mayor cohesión: Tienes un lugar común (la clase nueva) para poner las cosas relacionadas (el valor y los métodos que trabajan sobre él).
- Se elimina la duplicidad. El comportamiento está en un solo sitio, no en cada cliente que quiera obtener la terminación del número de la tarjeta.
- Se evitan conflictos al sobrecargar un método que reciba como argumento el nuevo tipo. Por ejemplo, de no tener el nuevo tipo con su semántica, esto daría error de compilación:
// Mismo tipo de datos de los parámetros, pero diferente semántica
createCard(String cardNumber)
createCard(String cardType)// Con la nueva clase se puede sobrecargar sin problemas
createCard(CardNumber cardNumber)
createCard(String cardType) - Organización: Cuando quieras buscar las operaciones que existen sobre el número de la tarjetas, ya sabes donde buscar: en la clase CardNumber.
- Puedes añadir validaciones.
- Mayor legibilidad en el código cliente.
Como ves, con relativamente poco esfuerzo, hemos obtenido muchos beneficios.
Si quieres practicar un poco más, puedes introducir un nuevo tipo de dato Money
que reemplace al parámetro amount
en el método NotificationMessageProvider::getMessage
. Antes, te recomiendo cambiarle la signatura, para que reciba un parámetro de tipo Purchase
, que contenga el amount, el nombre del comercio y la tarjeta. La nueva clase Money
se puede encargar, entre otras cosas, de devolver el amount como String
con un formato y una moneda por defecto.
3. Conclusiones
Tener este refactor en nuestra caja de herramientas no quiere decir que lo apliquemos a la primera. Inicialmente podemos modelar con un tipo primitivo y si surge algún comportamiento duplicado sobre el valor o alguna validación, aplicar el refactor.
Espero que el tutorial te haya servido y que en un futuro, el diseño de tu código se beneficie de lo que hemos visto. ¡Hasta la próxima ?!