"Any fool can write code that a computer can understand. Good programmers write code that humans can understand", Martin Fowler.

Tanto si estás empezando en el desarrollo de software como si tienes muchos años de experiencia, Clean Code es un término que has debido escuchar y escucharás constantemente (aquí puedes encontrar más info sobre Clean Code en este ebook). El código limpio está relacionado con la aplicación de buenas prácticas al momento de escribir código, tales como los nombre de variables, evitar los side effects y la duplicación de código o crear funciones que hagan una sola cosa.

En este artículo comentaremos algunos tips utilizados en Java que aplican algunas de las buenas prácticas de Clean Code, que seguramente te serán muy útiles y que puedes poner en práctica fácilmente. Además, haremos uso de lo que se conoce como programación declarativa, es decir, escribir código indicando qué se quiere hacer” en vez de “cómo se quiere hacer”, lo que se llama programación imperativa.

Algunos de los beneficios de programar declarativamente son:

Nos enfocaremos principalmente en tips con código escrito de una manera declarativa, para que sea entendible, mantenible y fácil de probar.

5 Tips (💡)

1 Principio de Responsabilidad única

A continuación vamos a refactorizar el siguiente extracto de código Java, aplicando utilidades incorporadas a partir de la versión 8 en adelante, tales como el uso de lambdas, Collections, streams, entre otros.

El extracto es el siguiente:

public List<PetDto> getPets() {
    List<Pet> pets = petRepository.findAll();
    List<PetDto> petDtos = new ArrayList<>();
    for (Pet pet : pets) {
        PetDto petDto = new PetDto();
        String sound;
        switch (pet.getType()) {
            case "CAT":
                sound = "MIAU!!";
                break;
            case "DOG":
                sound = "GUAU!!";
                break;
            default:
                sound = "----";
        }
        petDto.setName(pet.getName());
        petDto.setSound(sound);
        petDto.setAdopted(pet.getFamilyName() == null);
        petDtos.add(dto);
    }
    return petDtos;
}

Tenemos un método que internamente busca en base de datos todas las mascotas, y luego transforma dichas entidades en DTOs. Además, para esa transformación es requerido devolver dependiendo del tipo de mascota el sonido, en caso de que no se soporte el tipo, tendremos un valor por defecto.

El tamaño en líneas de un método o clase nos puede decir mucho. El extracto anterior, por ejemplo, nos habla de una violación al principio de responsabilidad única. Evidentemente, tenemos un servicio que debe retornar un listado de mascotas transformado, no la entidad de base de datos directamente, pero esa transformación bien puede ser responsabilidad de otro método o clase, para ello empecemos haciendo uso de nuestro IDE, y extraigamos bloques de código a métodos:

public List<PetDto> getPets() {
    List<Pet> pets = petRepository.findAll();
    List<PetDto> petDtos = new ArrayList<>();
    for (Pet pet : pets) {
        petDtos.add(toPetDto(pet));
    }
    return petDtos;
}

private PetDto toPetDto(Pet pet) {
    PetDto petDto = new PetDto();
    petDto.setName(pet.getName());
    petDto.setSound(getSoundByType(pet.getType()));
    petDto.setAdopted(pet.getFamilyName() == null);
    return petDto;
}

private String getSoundByType(String petType) {
    String sound;
    switch (petType) {
        case "CAT":
            sound = "MIAU!!";
            break;
        case "DOG":
            sound = "GUAU!!";
            break;
        default:
            sound = "----";
    }
    return sound;
}

Por una parte, hemos extraído la lógica de transformar un Pet a PetDto. Por otra, la lógica para obtener el sonido por el tipo de mascota. De momento hemos reducido el tamaño en líneas de nuestro método inicial, y los métodos extraídos se encargan cada uno de una lógica particular. Ahora bien, algo que podemos hacer como optimización es reemplazar ese for, por un stream().map, ya que básicamente lo que estamos haciendo es transformar a partir del listado de mascotas, otro listado con PetDto, y cada vez que escuchemos la palabra transformar podemos pensar en map, y de esta manera quedará más declarativo:

public List<PetDto> getPets() {
    List<Pet> pets = petRepository.findAll();
    return pets.stream()
            .map(this::toPetDto)
            .collect(Collectors.toList());
}

private PetDto toPetDto(Pet pet) {
    PetDto petDto = new PetDto();
    petDto.setName(pet.getName());
    petDto.setSound(getSoundByType(pet.getType()));
    petDto.setAdopted(pet.getFamilyName() == null);
    return petDto;
}

private String getSoundByType(String petType) {
    /** Switch feo que luego veremos **/
    return sound;
}

Con lo anterior, hemos separado las responsabilidades. Esa transformación a PetDto podría delegarse a otra clase separada (PetMapper.java), e inyectada como dependencia de esta:

private final PetMapper mapper;
private final PetRepository petRepository;

public List<PetDto> getPets() {
        List<Pet> pets = petRepository.findAll();
        return pets.stream()
                .map(mapper::toPetDto)
                .collect(Collectors.toList());
}

O, incluso, podríamos delegar esa lógica al constructor del DTO, ya que el DTO depende de la entidad y puede ser responsable además de construirse a partir de Pet:

*
  /** PetService.java **/
  private final PetRepository petRepository;

  public List<PetDto> getPets() {
      List<Pet> pets = petRepository.findAll();
      return pets.stream()
              .map(PetDto::new)
              .collect(Collectors.toList())
  }
*
}

/** file: PetDto.java **/
class PetDto {
    private final String name;
    private final String sound;
    private final boolean adopted;

    private PetDto(Pet pet) {
        name = pet.getName();
        sound = getSoundByType(pet.getType());
        adopted = pet.getFamilyName() == null;
    }

    private String getSoundByType(String petType) {
        /** Switch feo, que luego veremos **/
        return sound;
    }
}

Con lo anterior tenemos un código limpio y buenas prácticas.

En conclusión:

1. La cantidad de líneas en un método, o clase, son un buen indicador de oportunidades de mejoras y refactorización.

2. Simplificar en métodos usando el poder del IDE, (Extraer a un método) permite visualizar las lógicas que pueden o no ser responsabilidad del mismo.

3. Ni qué hablar de las pruebas, al separar responsabilidades resultará mucho más sencillo probar cada pieza.

2 La importancia de los nombres

Del extracto anterior, podemos pensar directamente en aplicar lambdas (las funciones anónimas o lambdas solo están disponibles a partir de Java 8).

public List<PetDto> getPets() {
    List<Pet> pets = petRepository.findAll();
    return pets.stream()
        .map(pet -> {
            PetDto petDto = new PetDto();
            String sound;
            switch (pet.getType()) {
                case "CAT":
                    sound = "MIAU!!";
                    break;
                case "DOG":
                    sound = "GUAU!!";
                    break;
                default:
                    sound = "----";
            }
            petDto.setName(pet.getName());
            petDto.setSound(sound); 
            petDto.setAdopted(pet.getFamilyName() == null);
            return petDto; 
        }).collect(Collectors.toList());
    }
}

Sin embargo, hacerlo no implica un cambio significativo, el código sigue estando saturado, además de que continúa rompiendo el principio de responsabilidad única.

Para que se vea mejor, lo suyo es siempre, a las funciones anónimas o lambdas (aunque pueden ser anónimas), darles un nombre descriptivo:

public List<PetDto> getPets() {
    List<Pet> pets = petRepository.findAll();
    return pets.stream()
        .map(pet -> toPetDto(pet))
        .collect(Collectors.toList());
    }
}

private PetDto toPetDto(Pet pet) {
    PetDto petDto = new PetDto();
    String sound;
    switch (pet.getType()) {
        case "CAT":
            sound = "MIAU!!";
            break;
        case "DOG":
            sound = "GUAU!!";
            break;
        default:
            sound = "----";
    }
    petDto.setName(pet.getName());
    petDto.setSound(sound); 
    petDto.setAdopted(pet.getFamilyName() == null);
    return petDto;
}

De esa manera se identifica más rápidamente qué hace la función anónima, además podemos escribirlo de una manera más bonita usando el operador ::

public List<PetDto> getPets() {
    List<Pet> pets = petRepository.findAll();
    return pets.stream()
        .map(this::toPetDto)
        .collect(Collectors.toList());
    }
}

Adicionalmente tenemos los beneficios anteriores, podemos pensar en sacar el método toPetDto de esta clase, o bien refactorizar un poco para que haga una sola cosa y no las dos que está haciendo.

En resumen, al usar lambdas siempre deberíamos extraer las más complejas en métodos o en otras clases:

1. Procurar representarlas usando el operador (::)
Ejemplos: this::, mapper:: , otherService::,OtherClass::

2. Usar pet -> -> solo si se puede representar en una línea.
Ejemplo: pet -> validateBy(pet, store)

3 Condiciones de Guarda

En muchas ocasiones, somos redundantes al condicionar la lógica en nuestros métodos. Aplicar condiciones de guarda permite simplificar el código, de manera que si tenemos un if como el que se muestra a continuación:

private PetDto toPetDto(Pet pet) {
    PetDto petDto = new PetDto();
    String sound;
    String petType = pet.getType();
    if(nonNull(petType)) { 
        switch (petType) {
            case "CAT":
                sound = "MIAU!!";
                break;
            case "DOG":
                sound = "GUAU!!";
                break;
            default:
                sound = "----";
        }
        petDto.setName(pet.getName());
        petDto.setSound(sound);
        petDto.setAdopted(pet.getFamilyName() == null);
    } else {
        throw new IllegalArgumentException("Pet type not supported");
    }
    return petDto;
}

Este if se encarga de evitar que llegue unnullal swicth , puesto que este lanzaría un NPE.

Pues bien en ese extracto de código quizás no sea tan claro, pero podemos hacer uso de las condiciones de guarda, de esa manera nos ahorramos un bloque de código.

Lo primero es invertir la condición del if de modo que cuando sea null lancemos la excepción, y de lo contrario continúe las siguientes acciones, así:

private PetDto toPetDto(Pet pet) {
    PetDto petDto = new PetDto();
    String sound;
    String petType = pet.getType();
    if(isNull(petType)) { 
        throw new IllegalArgumentException("Pet type not supported");
    }
    switch (petType) {
        case "CAT":
            sound = "MIAU!!";
            break;
        case "DOG":
            sound = "GUAU!!";
            break;
        default:
            sound = "----";
    }
    petDto.setName(pet.getName());
    petDto.setSound(sound);
    petDto.setAdopted(pet.getFamilyName() == null);  
    return petDto;
}

Ahora organizar un poco extrayendo en métodos, y retocando el switch:

private PetDto toPetDto(Pet pet) {
    PetDto petDto= new PetDto();
    petDto.setName(pet.getName());
    petDto.setSound(getSoundByType(pet.getType()));
    petDto.setAdopted(pet.getFamilyName() == null);  
    return petDto;
}

private String getSoundByType(String petType) {
    if(isNull(petType)) { 
        throw new IllegalArgumentException("Pet type not supported");
    }
    switch (petType) {
        case "CAT":
            return "MIAU!!";
        case "DOG":
            return "GUAU!!";
        default:
            return "----";
    }      
}

Las condiciones de guarda permiten organizar el código, en realidad a nivel de pruebas debemos seguir cubriendo ambos escenarios, pero se ve mucho más ordenado y por ende es mucho más legible y mantenible.

Aprovechamos para comentar algunas recomendaciones con los switch (no soy muy fan de ellos, al menos hasta Java 17 donde son más flexibles, en otro post podemos hablar de ello), pero me parecen aceptables siempre que:

1. Tengan una implementación default, ya sea lanzar excepción o ejecutar una lógica por defecto.

2. Contenga máximo una línea en cada case; si tienes un bloque, puedes usar el IDE para extraer en métodos, poniendo un nombre descriptivo.

3. En caso que puedas retornar directamente el valor, no usar el break, usar el return.

4. Ni hablar de las pruebas: en ellas debemos continuar cubriendo ambos escenarios, no solo el happy path, también la lógica del default.

4 Evitar NPE usando Optional

El NullPointerException se puede evitar de varias maneras. Una es la acostumbrada programación defensiva, como la que hemos aplicado en el ejemplo anterior, verificando que haya o no un null y retornando un valor por defecto o una excepción.

Sin embargo, siempre hemos escuchado que la mejor defensa es un buen ataque, así que a partir de Java 8 podemos cambiar de dinámica e intentemos usar el Optional. Si lo hacemos de primera tendríamos algo así:

private PetDto toPetDto(Pet pet) {
    PetDto dto = new PetDto();
    String sound = pet.getType()
       .map(this::getSoundByType)
       .orElseThrow(() -> new IllegalArgumentException("message"));

    dto.setName(pet.getName());
    dto.setSound(sound);
    dto.setAdopted(pet.getFamilyName() == null);  
    return dto;
}

private String getSoundByType(@NotNull String petType) {
    switch (petType) {
        case "CAT":
            return "MIAU!!";
        case "DOG":
            return "GUAU!!";
        default:
            return "----";
    }      
}

Con ello, tampoco es que hayamos logrado mucho, no dista mucho de lo que teníamos al retornar un null.

Así que una mejor opción, es pensar en nuestra entidad, de la siguiente manera:

class Pet {
    private String name;
    private String familyName;
    private String type;
    Optional<String> getType() {
        return Optional.ofNullable(type);
    }
    /** Other getters and stuff **/
}

Con ello ahora podemos, atacar el problema usando el map(...), y lanzar excepción o devolver un valor por defecto, orElse(DEFAULT_VALUE).

private PetDto toPetDto(Pet pet) {
    PetDto dto = new PetDto();
    String sound = pet.getType()
       .map(this::getSoundByType)
       .orElseThrow(() -> new IllegalArgumentException("message"));

    dto.setName(pet.getName());
    dto.setSound(sound);
    dto.setAdopted(pet.getFamilyName() == null);  
    return dto;
}

private String getSoundByType(@NotNull String petType) {
    switch (petType) {
        case "CAT":
            return "MIAU!!";
        case "DOG":
            return "GUAU!!";
        default:
            return "----";
    }      
}

Los Optionals no debemos usarlos como reemplazo del null. Ellos nos ofrecen la posibilidad de filtrar (filter), transformar (map) y manipular de una manera más flexible los valores recibidos.

Consideraciones:

1. Evitar reemplazar null con Optional.empty(), no dista de hacer lo mismo que usar el null y no trae ningún beneficio.

2. Poner Optional en los Getters de las entidades puede ser útil para atacar los problemas, además de ser coherentes porque reflejan la realidad de que el valor puede o no venir.

3. Usar orElse() únicamente cuando lo que devolvemos sea un valor estático… en caso diferente usar el orElseGet(() -> {}), esto por temas de memoria y rendimiento. El primero da por hecho que el valor se necesita y lo crea aunque no hagamos uso de este; el segundo lo crea cuando lo necesitemos.

5 Usa las interfaces funcionales

Por último, las interfaces funcionales han sido introducidas en Java 8, y tienen gran importancia en el uso de lambdas. Quizás ya hayas hecho uso de ellas, pero no las reconoces por este nombre, ni por el que has usado, pero, por lo general en los streams, habremos usado algún filter y por ende una interfaz funcional.

Ejemplo de algunas interfaces funcionales proporcionadas por Java 8:

Predicate<String> isEmpty = String::isEmpty;
Consumer<String> println = System.out::println;
Supplier<List<?>> emptyList = Collections::emptyList;
Function<String, Object> stringValueOf = String::valueOf;

Básicamente, es una interface de Java que solo tiene un único método abstracto, y que está anotada con @FunctionalInterface, aunque el hecho de que tenga un único método no significa que no pueda tener más métodos default, o static, como lo veremos a continuación, creando nuestra propia interfaz funcional.

Planteemos una situación real, con el siguiente ejemplo:

public DeleteObjectsResult delete(DeleteObjectsRequest deleteObjectRequest) {
  try {
    return s3Client.deleteObjects(deleteObjectRequest);
  } catch (AmazonClientException ex) {
    String message = "Internal exception using AWS Sdk: " + ex.getMessage();
    throw new CustomSdkClientException(message, ex);
  } catch (Exception ex) {
    String message = "Unexpected exception using AWS Sdk: " + ex.getMessage();
    throw new CustomUnexpectedException(message, ex);
  }
}

private PutObjectResult upload(PutObjectRequest putObjectRequest) {
  try {
    return s3Client.putObject(putObjectRequest);
  } catch (AmazonClientException ex) {
    LOG.error(ex.getMessage());
    throw new CustomSdkClientException("Internal exception using AWS Sdk", ex);
  } catch (Exception ex) {
    LOG.error(ex.getMessage());
    throw new CustomUnexpectedException("Unexpected exception using AWS Sdk", ex);
  }
}

Tenemos un cliente s3 de amazon, que se encarga de subir y borrar objetos a un bucket. Para ambos métodos requerimos gestionar las posibles excepciones, pero como vemos, estamos duplicando código, y aunque hay muchas formas de solucionar el problema, lo vamos a hacer creando nuestra propia interfaz funcional. Para ello, lo primero es identificar la firma que queremos representar; por lo tanto, de los dos métodos anteriores la única diferencia son las siguientes líneas:

return s3Client.putObject(putObjectRequest);
return s3Client.deleteObjects(deleteObjectRequest);

Además de los tipos de parámetros que recibimos y devolvemos, claro está… Y en lo que coinciden estos dos métodos del s3Client, es que ambos pueden lanzar la excepción AmazonClientException,y podemos ver que la firma en la librería es algo como:

PutObjectResult putObject(PutObjectRequest r) throws AmazonServiceException;
DeleteObjectsResult deleteObjects(DeleteObjectsRequest r) throws AmazonServiceException;

Con lo cual nuestra firma para ambos métodos usando Generics sería algo así:

R methodName(T t) throws AmazonServiceException;

Con lo anterior, una interfaz funcional que recibe un parámetro y retorna otro es una Function<T, R>, pero la básica de Java no lanza excepciones, por lo que no podemos usarla. Entonces crearemos una interfaz funcional que extienda de esta base y jugaremos con ello:

package com.bbva.extended.s3.integrator.service.handlers;

import ...; // more imports staff
import java.util.function.Function;

@FunctionalInterface
public interface SdkFunction<T, R> extends Function<T, R> {
 R applyThrows(T elem) throws AmazonClientException;

 @Override
 default R apply(final T elem) {
   try {
     return applyThrows(elem);
   } catch (AmazonClientException ex) {
     String message = "Internal exception using AWS Sdk: " + ex.getMessage();
     throw new CustomSdkClientException(message, ex);
   } catch (Exception ex) {
     String message = "Unexpected exception using AWS Sdk: " + ex.getMessage();
     throw new CustomUnexpectedException(message, ex);
   }
 }
}

La anotación @FunctionalInterface se encarga de marcar nuestra interfaz como una interfaz funcional, y no presenta ningún error de compilación porque efectivamente tenemos un único método abstracto aunque, como se puede observar, tenemos otro implementado como default (los métodos por default en las interfaces fueron introducidos a partir de Java 8 también).

Como ya lo explicamos, hemos creado una interfaz SdkFunction<T, R>, que extiende de Function<T, R>, y cuyo único método abstracto es R applyThrows(T elem) throws AmazonClientException;, (la firma que queremos representar), y, como podemos ver, hemos sobreescrito el único método de Function<T, R>, R apply(final T elem).

Con ello lo que estamos haciendo es redefiniendo la firma de nuestra interfaz funcional, y garantizando que los métodos que la “implementan”, cumplen con la nueva firma, que lanza AmazonClientException.

Ahora, ¿cómo la usamos? Podemos crear una SdkFunction que reciba la función del s3Client:

public PutObjectResult upload(PutObjectRequest putObjectRequest) {
 SdkFunction<PutObjectRequest, PutObjectResult> sdkUpload = s3Client::putObject;
 return sdkUpload.apply(putObjectRequest);
}

public DeleteObjectsResult delete(DeleteObjectsRequest deleteObjectRequest) {
 SdkFunction<DeleteObjectsRequest, DeleteObjectsResult> sdkDelete = s3Client::deleteObjects;
 return sdkDelete.apply(deleteObjectRequest);
}

Y, de esta manera, los métodos cumplen con la firma de nuestra interfaz funcional y podemos hacer uso del método default apply para garantizar que controlamos las excepciones como esperábamos. Con ello, se ha centralizado el manejo de excepciones en un único punto, de modo que también las pruebas unitarias pueden verse beneficiadas.

Las interfaces funcionales ofrecen muchísimas más bondades que las mostradas en aquí. Quizás, la más importante es su forma declarativa de las funcionalidades que queremos conseguir; en contraposición a la imperatividad hereditaria que tenemos de decir el cómo debe hacerse, ampliando las posibilidades. Seguramente ahondemos más sobre ellas en próximas publicaciones.

Conclusiones

Algunas conclusiones que hemos extraído:

Y por último y no menos importante, no dejemos para mañana lo que podemos hacer hoy, ¡el código mola ya! ¡disfrutémoslo diariamente!

Cuéntanos qué te parece.

Los comentarios serán moderados. Serán visibles si aportan un argumento constructivo. Si no estás de acuerdo con algún punto, por favor, muestra tus opiniones de manera educada.

Suscríbete

Estamos comprometidos.

Tecnología, personas e impacto positivo.