Replace Primitive with Object: refactor sencillo pero con grandes beneficios

0
355

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 👋!

4. Referencias

DEJA UNA RESPUESTA

Por favor ingrese su comentario!

He leído y acepto la política de privacidad

Por favor ingrese su nombre aquí

Información básica acerca de la protección de datos

  • Responsable:
  • Finalidad:
  • Legitimación:
  • Destinatarios:
  • Derechos:
  • Más información: Puedes ampliar información acerca de la protección de datos en el siguiente enlace:política de privacidad