Usando DTOs con Java Spring Boot

 Los DTOs son un patrón de diseño que tiene como finalidad crear objetos planos con una serie de atributos de interés, estos puedan ser enviados o recuperados del servidor en una sola invocación. Estos no representan entidades como lo hace un modelo por lo que podemos obtener información de distintas fuentes o tablas y concentrarlas en una única clase. Esto es bastante útil cuando necesitamos pasar mucha información entre distintas funciones o clases y no queremos enviar más de tres atributos a una función pudiendo crear un DTO que nos permita transferir la información o devolver múltiples objetos en una sola función, todos encapsulados dentro de un solo objeto.

Ventajas de usar DTOs

 Como mencionamos, los DTOs son objetos que contienen atributos de interés y que, al no ser representativos de una entidad, pueden ser bastante flexibles con los datos que contienen. Este concepto es super conveniente cuando pensamos en trabajar con APIs ya que muchas veces al buscar acceder a cierta información nos encontramos con la sorpresa de necesitar información de varias entidades o fuentes, por ejemplo, si quisiéramos acceder a la información de un cliente de una tienda pero también quisiéramos obtener toda la información de las ultimas diez compras que ha realizado para desplegarlo en nuestro front end tendríamos que llamar dos veces a la API para obtener la información que necesitamos pero también podríamos crear un objeto que resuma esta información y permita que en una sola llamada a nuestra API se responda con toda la información necesaria realizando todo el proceso.

 La respuesta es bastante obvia, llamar una sola vez es la forma más conveniente para el front end pues reduce la carga sobre el sistema no solo por los tiempos de espera sino también porque el proceso de obtener y procesar la información necesaria lo realiza back end reduciendo así el procesamiento de datos que realiza el front end.

 Otra gran ventaja que nos ofrece el uso de DTOs es el pequeño plus de seguridad al no revelar información de la estructura de nuestra base de datos. Si bien es cierto una solución más fácil y rápida es devolver las entidades directamente, nos olvidamos que muchas veces estas entidades necesitan pasar por un procesamiento previo, necesitan información complementaria de otras fuentes y también pueden revelar el nombre que asignamos a nuestros campos de una tabla, exponiendo así información que se podría utilizar para un ataque a nuestra aplicación.

 Para finalizar, otra gran ventaja es que nos permite facilitar la transferencia de información entre funciones. Imaginemos que una función necesita de cinco parámetros para funcionar, lo normal sería pensar en pasar esos parámetros directamente, pero esto hace más difícil recordar que parámetros se necesitan pasar, que tipo de datos son y sobre todo hace más difícil de leer nuestro código. Otro caso de uso es cuando tenemos que devolver varios objetos de una función, por ejemplo, una petición a una API fue exitosa o no y un mensaje si no lo fue. Utilizar un DTO en estos casos nos ayuda a solventar ambos problemas facilitando así el proceso de desarrollo del software, además de generar un código mucho más fácil de leer.

En resumen, las ventajas de los DTOs son las siguientes:

  • Nos permite reducir la cantidad de llamadas requeridas a una API al devolver toda la información necesaria en un solo recurso.
  • Reduce la probabilidad de exponer la estructura de nuestras fuentes de información al no devolver directamente una entidad.
  • Permite crear código más fácil de entender y reutilizar.
  • Permite solucionar el problema que representa pasar muchos parámetros a la vez.
  • Permite devolver «varios objetos» en una misma respuesta al estar contenidos dentro del DTO.

Creando los DTOs

 Para este caso utilizaremos los DTOs para crear el cuerpo de la respuesta de nuestros endpoints. Lo primero será crear una carpeta llamada dto dentro de la cual crearemos dos carpetas para mantener el orden. La primera será modelsDtos que contendrá DTOs para la información que normalmente contiene nuestros modelos de nuestro proyecto, esto se separará ya que podría ser útiles para otros fines y no solo para las request o las response. Luego crearemos una carpeta llamada response donde crearemos una clase base para evitar repetir código. Ya que uno de los objetivos dentro de nuestra API será agregar un mensaje y un código dentro de la respuesta heredaremos esta clase base que contendrá tanto el mensaje como el código. Nuestra clase base quedara de la siguiente forma.

@Data
@EqualsAndHashCode(callSuper = false)
public class BankResponse extends GeneralResponse {

    @JsonProperty("BancoInfo")
    private BankDbDto bankDbResponse;

}

src/main/java/org/marcos/ApiDbExample/dto/response/bancoDto/BankResponse.java

@Data
@EqualsAndHashCode(callSuper = false)
@JsonInclude(Include.NON_NULL)
public class ListBankNamesResponse extends GeneralResponse{
    @JsonProperty("bankNames")
    private List<String> bankNames;
}

src/main/java/org/marcos/ApiDbExample/dto/response/bancoDto/ListBankNamesResponse.java

@Data
@EqualsAndHashCode(callSuper = false)
public class ListOfBanksResponse extends GeneralResponse{
    
    @JsonProperty("Bancos")
    private List<BankDbDto> listaBancos;

}

src/main/java/org/marcos/ApiDbExample/dto/response/bancoDto/ListOfBanksResponse.java

 Finalmente vamos a crear el DTO para nuestro modelo que incluirá los datos que vamos a desplegar como respuesta de nuestros endpoints.

@Data
@AllArgsConstructor
@NoArgsConstructor
public class BankDbDto implements Serializable{
    
    @JsonProperty("BankId")
    private String bankId;

    @JsonProperty("BankName")
    private String bankName;

    @JsonProperty("BankDescription")
    private String bankDescription;
    

    public Banco toModel(){
        var model = new Banco();


        model.setId(Long.valueOf(this.getBankId()));
        model.setName(this.getBankName());
        model.setDescription(this.getBankDescription());

        return model;
    }

    public BankDbDto(Banco model){
        this.setBankId(model.getId().toString());
        this.setBankDescription(model.getDescription());
        this.setBankName(model.getName());
    }
}

src/main/java/org/marcos/ApiDbExample/dto/modelsDtos/BankDbDto.java

 Adicional a nuestro DTO del modelo vamos a crear un método que nos permita convertir fácilmente de nuestro DTO al modelo y vamos a crear un constructor que facilite el crear un DTO a partir de un modelo. La anotación @JsonProperty nos permitirá cambiar el nombre con el que se agregara al JSON de respuesta los campos dentro de los DTOs al momento de ser enviados.

Aplicando los DTOs

 Lo primero que haremos será actualizar nuestra interfaz para el servicio, actualizaremos los tipos de dato de retorno de nuestras funciones.


public interface BancoService {

    public GeneralResponse bankExists(String id);
    
    public BankResponse createBanco(Banco banco);

    public BankResponse getBancoById(String id);

    public BankResponse updateBanco(Banco banco);

    public BankResponse parcialUpdateBanco(Banco banco);

    public ListBankNamesResponse nameBancos();

    public ListOfBanksResponse getAllBancos();

    public BankResponse deleteBanco(String id);
}

src/main/java/org/marcos/ApiDbExample/service/BancoService.java

 Luego de esto tendremos que actualizar también la implementación de estos métodos. Lo primero que podemos notar es que tenemos más opciones que hacer únicamente la petición de datos a nuestra fuente, podemos hacer validaciones sobre esta y generar así otros tipos de respuesta muchísimo más descriptivas para el cliente de nuestro servicio.


    @Override
    @Transactional
    public BankResponse createBanco(Banco banco) {

        var response = new BankResponse();
        try {
            var dbResponse = bancoRepository.save(banco);
            response.setBankDbResponse(new BankDbDto(dbResponse));

            response.setCode(1);
            response.setMessage("ok");
        } catch (Exception e) {
            response.setCode("2");
            response.setMessage("error");
        }

        return response;
    }

src/main/java/org/marcos/ApiDbExample/service/BancoServiceImp.java

 Como vemos en el código anterior, al tener un objeto mucho más versátil en cuanto a su contenido podemos darnos el lujo de tener distintos tipos de respuesta, y poder tomar decisiones con esta información pudiendo por ejemplo no tener únicamente respuestas 200 si encontramos un objeto o 404 si no, podemos tener también respuesta tipo 500 en caso tener un error en alguna parte del proceso al asignar un código dentro de un *try catch* que permita ver al Controller que algo no salió bien. En el código que tenemos arriba vemos que guardamos el objeto, obtenemos el objeto de respuesta y luego creamos una respuesta indicando el código de respuesta y su mensaje. Estos dos últimos son un añadido que hemos puesto a partir de migrar al uso de DTOs enriqueciendo la respuesta que damos al cliente al ofrecer más información, cambiar la estructura y nombres de los datos que ofrecemos como respuesta. Otro caso interesante de analizar es el del método de PATCH ya que al tener que hacer validaciones locales pueden surgir otras respuestas.

@Override
    public BankResponse parcialUpdateBanco(Banco banco) {
        var response = new BankResponse();

        if (Objects.nonNull(banco.getId())) {
            try {
                Banco oldBanco = bancoRepository.findById(banco.getId()).orElse(null);

                if (Objects.isNull(oldBanco)) {

                    response.setCode(3);
                    response.setMessage("not found");
                    return response;
                }

                if (Objects.nonNull(banco.getName()) && !banco.getName().equals(oldBanco.getName())) {
                    oldBanco.setName(banco.getName());
                }

                if (Objects.nonNull(banco.getDescription())
                        && !banco.getDescription().equals(oldBanco.getDescription())) {
                    oldBanco.setDescription(banco.getDescription());
                }

                var updatedBanco = bancoRepository.save(oldBanco);

                response.setBankDbResponse(new BankDbDto(updatedBanco));

                response.setCode(4);
                response.setMessage("actualizado");

            } catch (Exception e) {

                response.setCode(2);
                response.setMessage("error!");
            }

        }

        return response;
    }

src/main/java/org/marcos/ApiDbExample/service/BancoServiceImp.java

 Como podemos ver en el ejemplo anterior ahora generamos tres tipos de respuesta, la primera que se origina cuando no encontramos un objeto y por ende no podemos hacer una actualización parcial del mismo. La segunda cuando se ha actualizado correctamente el objeto y la tercera si surge algún tipo de problema durante el proceso lo que provoca que se notifique de un error. Esto nos permite mayor control del lado del controlador acerca del tipo de respuesta que vamos a devolver.

 Finalmente, los DTOs no varían tanto en su implementación de los modelos tradicionales, ni mucho menos los sustituye ya que estos tienen por fin ser una representación de los datos ofrecidos por alguna fuente como pueden ser una REST API, SOAP API, o una base de datos permitiendo tomar esa información y poder manipularla de forma sencilla en nuestra aplicación mientras los DTOs nos permiten personalizar como transportamos y presentamos esa información. Otro ejemplo útil es aplicarlo cuando estamos generando una lista.


    @Override
    public ListOfBanksResponse getAllBancos() {
        var response = new ListOfBanksResponse();

        try {
            var dataList = bancoRepository.findAll();

            response.setListaBancos(new ArrayList<>());

            if (dataList.isEmpty()) {
                response.setCode(6);
                response.setMessage("vacio");
            } else {

                BankDbDto data;
                for (var detalle : dataList) {
                    data = new BankDbDto(detalle);
                    response.getListaBancos().add(data);
                }
                response.setCode(1);
                response.setMessage("ok");

            }
        } catch (Exception e) {
            response.setCode("2");
            response.setMessage("errores");
        }
        return response;

    }

src/main/java/org/marcos/ApiDbExample/service/BancoServiceImp.java

 Como vemos en este caso tenemos de nuevo múltiples respuestas que se dan cuando no se han encontrado objetos, si se han encontrado objetos y si ha ocurrido algún error. Fuera de esto la implementación viene a ser muy parecida a con un modelo. Finalmente, solo quedaría ver la parte del controller. Para mantener la coherencia tanto en las peticiones que recibimos como en las respuestas que damos vamos a aplicar los DTOs en ambos casos por lo que deberemos actualizar los tipos de dato de respuesta como los tipos de dato de petición para que podamos convertir las respuestas en objetos.


@RestController
@CrossOrigin
@RequestMapping(value = "api/db/banco")
public class BancoController {

    @Autowired
    private BancoService bancoService;

    @Autowired
    private CodeService codeService;

    // @GetMapping("/{idbanco}")
    @RequestMapping(method = RequestMethod.HEAD, path = "/{idbanco}")
    public ResponseEntity<GeneralResponse> headBancoId(@PathVariable(name = "idbanco") String id) {
        GeneralResponse bankExists = bancoService.bankExists(id);
        if (bankExists.getCode().equals(1)) {
            return ResponseEntity.ok(bankExists);
        }
        return ResponseEntity.notFound().build();
    }

    @GetMapping("/")
    public ResponseEntity<ListOfBanksResponse> listBancos() {
        return ResponseEntity.ok(bancoService.getAllBancos());
    }

    @GetMapping("/getBanco")
    public ResponseEntity<BankResponse> getBancoById(@RequestParam String id) {
        var banco = bancoService.getBancoById(id);
        if (banco.getCode().equals(1)) {
            return ResponseEntity.ok(banco);
        }
        return ResponseEntity.notFound().build();
    }

    @PostMapping("/createBanco")
    public ResponseEntity<BankResponse> createBanco(@RequestBody BankDbDto banco) {
        var created = bancoService.createBanco(banco.toModel());

        return ResponseEntity.created(
                ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}").buildAndExpand(created.getBankDbResponse().getBankId()).toUri())
                .body(created);
    }

    @PutMapping("/updateBanco")
    public ResponseEntity<BankResponse> updateBanco(@RequestBody BankDbDto banco) {
        var updated = bancoService.updateBanco(banco.toModel());
        return ResponseEntity.created(
            ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}").buildAndExpand(updated.getBankDbResponse().getBankId()).toUri())
            .body(updated);
    }

    @PatchMapping("/updateBanco")
    public ResponseEntity<BankResponse> parcialUpdateBanco(@RequestBody BankDbDto banco) {
        var updated = bancoService.parcialUpdateBanco(banco.toModel());
        if (!updated.getCode().equals(4)) {
            return ResponseEntity.noContent().build();
        }
        return ResponseEntity.created(
                ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}").buildAndExpand(updated.getBankDbResponse().getBankId()).toUri())
                .body(updated);
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<BankResponse> deleteBanco(@PathVariable(name = "id") String id){
        BankResponse deleted = bancoService.deleteBanco(id);
        if(!deleted.getCode().equals(7)){
            return ResponseEntity.noContent().build();
        }

        return ResponseEntity.ok(deleted);
    }

}

src/main/java/org/marcos/ApiDbExample/controller/BancoController.java

 Como podemos ver en los casos anteriores el paso de modelos a DTOs es sencillo, y se facilita aún más utilizando los métodos que hemos creado para convertir de modelo a DTO y de DTO a modelo. Aparte de esto en el Controller se concreta la maniobrabilidad extra que nos permiten los DTOs al momento de dar una respuesta en un endpoint, al permitir utilizar esta información extra para decidir qué tipo de respuesta es la más conveniente.

Referencias

Deja un comentario