Servicios e inyección de dependencia

Esta lección será fácil para personas que ya hayan hecho en Spring u otros frameworks al uso. En esta lección veremos el patrón Service Object, para desacoplar la lógica de negocio del controlador HTTP. Mediante inyección de depednencia instanciaremos automáticamente ese servicio en el controlador.

Una muy buena práctica cuando fabriquemos controladores en una aplicación web será mantenerlos lo más ligeros de código que se pueda. Es decir, que tener toda la lógica de negocio de un endpoint dentro del método de endpoint no es muy manejable y muy limpio.

Uno de los patrones que nos va a permitir llevar a cabo esta práctica es el patrón Objeto de Servicio (o Service Object), donde movemos la lógica de negocio a clases independientes denominadas servicios que no estén acopladas al controlador HTTP. Con esto podemos desarrollar la lógica de negocio de nuestra aplicación como un conjunto de clases básicas que aplican la lógica de negocio de forma independiente. De este modo, además, son más fáciles de testear.

Es decir, que en vez de tener un controlador lleno de acceso a fuentes de datos o de manipulación de la lógica de negocio de la aplicación, todo lo moveremos a una clase separada denominada Servicio, y delegaremos en sus métodos desde el controlador. De este modo, el controlador queda únicamente para acciones propias del protocolo HTTP como extraer parámetros, validar autenticación y autorización, y generar respuestas, pero la lógica de negocio se queda fuera.

Otra de las principales ventajas de este sistema es que ahora podemos fabricar tests que prueben que la lógica de negocio funciona correctamente, y esos tests pueden comportarse como tests unitarios (o de integración, si van a trabajar con base de datos), pero que no requieren hacer un end to end de todo el stack HTTP para ser testeados.

Lo que no queremos hacer

Lo que no deberíamos querer hacer con nuestros controladores serios es tener toda la lógica de negocio dentro del controlador:

public class TemperaturaResource {

    private Map<String, Temperatura> temperaturas = new HashMap<>();

    @POST
    public Temperatura insertar(Temperatura t) {
        if (temperaturas.containsKey(t.ciudad())) {
            var msg = "Ya existe medición para " + t.ciudad();
            throw new TemperaturaDuplicadaException(msg);
        }
        temperaturas.put(t.ciudad(), t);
        return t;
    }

    @GET
    public Stream<Temperatura> listar() {
        return temperaturas.values().stream();
    }

    @GET
    @Path("/maxima")
    public Temperatura maxima() {
        return temperaturas.values().stream()
                .max(Comparator.comparingInt(Temperatura::maxima))
                .orElseThrow(() -> new NoSuchElementException("No hay medición"));
    }
}

Si bien esto parece limpio, en la práctica esto hace que el controlador tenga demasiado código y que a la larga, si hace falta evolucionar para incorporar más comportamientos, sea dificil aplicar refactorizaciones. Esas anotaciones pueden confundir. A la larga, resultaría más beneficioso separar en un servicio la lógica de negocio de la aplicación.

La interfaz de un servicio

Una buena forma de comenzar a crear un service object es declarar una interfaz. Es una buena manera de especificar el contrato formal del servicio, puede ayudar a fabricar objetos mock, y en equipos de trabajo compuestos por varias personas, también es una buena forma de compartir las interfaces para que otros módulos no dependan en implementaciones concretas sino en especificaciones generales.

La especificación de la interfaz de temperaturas estará basada en la carta de servicios que tiene que exponer el endpoint:

  • Listar las temperaturas dadas de alta en el sistema.
  • Insertar una nueva temperatura en el sistema.
  • No permitir insertar la medición de una ciudad dos veces.
  • Permitir obtener la medición que tenga la temperatura máxima.
  • Generar un error en caso de que se pida la máxima si no hay mediciones.

En definitiva, el servicio puede tener este aspecto:

public interface TemperaturaService {
    void insertar(Temperatura t);
    Stream<Temperatura> obtener();
    Temperatura maxima();
}

Con tres métodos podemos tratarlo todo. Las excepciones que se tirarán en caso de errores a la hora de insertar temperaturas u obtener las máximas serán de tipo RuntimeException y por ello no las vemos declaradas aquí, pero se puede imaginar que son lanzadas en caso de problemas con la llamada.

La implementación de nuestra interfaz

Con todo, podemos crear la implementación de la interfaz. En mi caso, voy a fabricar una clase que se llame igual que la interfaz pero que termine en Impl. No es la única forma de separar interfaz de implementación. Si lo prefieres, puedes ponerle una I al principio de tu interfaz, como ITemperaturaService y llamar a la implementación como TemperaturaService a secas. Depende mucho de los gustos de cada persona o de la guía de estilo de cada proyecto.

public class TemperaturaServiceImpl implements TemperaturaService {

    private Map<String, Temperatura> temperaturas = new HashMap<>();

    @Override
    public void insertar(Temperatura t) {
        if (temperaturas.containsKey(t.ciudad())) {
            var msg = "Ya existe medición para " + t.ciudad();
            throw new TemperaturaDuplicadaException(msg);
        }
        temperaturas.put(t.ciudad(), t);
    }

    @Override
    public Stream<Temperatura> obtener() {
        return temperaturas.values().stream();
    }

    @Override
    public Temperatura maxima() {
        return temperaturas.values().stream()
                .max(Comparator.comparingInt(Temperatura::maxima))
                .orElseThrow(() -> new NoSuchElementException("No hay medición"));
    }
}

Como ves, es el mismo código que el que tenía antes en el controlador. La única diferencia es que ahora esta es una clase de datos que puedo testear por separado. Este sería el código de un test para este servicio, que se puede hacer instanciando directamente la clase por no tener más dependencias que sí misma.

class TemperaturaServiceImplTest {

    private TemperaturaServiceImpl servicio;

    private Temperatura soria, burgos;

    @BeforeEach
    public void setUp() {
        servicio = new TemperaturaServiceImpl();
        soria = new Temperatura("Soria", 10, 20);
        burgos = new Temperatura("Burgos", 15, 25);
    }

    @Test
    public void testObtenerTemperaturaVacia() {
        assertTrue(servicio.obtener().toList().isEmpty());
    }

    @Test
    public void testObtenerTemperaturaNoVacia() {
        servicio.insertar(soria);
        assertFalse(servicio.obtener().toList().isEmpty());
    }

    @Test
    public void testObtenerTemperaturas() {
        servicio.insertar(soria);
        servicio.insertar(burgos);
        List<Temperatura> lista = servicio.obtener().toList();
        assertTrue(lista.stream().anyMatch(t -> t.ciudad().equals("Soria") && t.minima() == 10));
        assertTrue(lista.stream().anyMatch(t -> t.ciudad().equals("Burgos") && t.minima() == 15));
        assertFalse(lista.stream().anyMatch(t -> t.ciudad().equals("Mérida")));
    }

    @Test
    public void testTemperaturaDuplicada() {
        servicio.insertar(soria);
        assertThrows(TemperaturaDuplicadaException.class, () -> {
            servicio.insertar(new Temperatura(soria.ciudad(), 28, 38));
        });
    }

    @Test
    public void testMaximaSinTemperaturas() {
        assertThrows(NoSuchElementException.class, () -> servicio.maxima());
    }

    @Test
    public void temperaturaMaxima() {
        servicio.insertar(soria);
        servicio.insertar(burgos);
        assertEquals(burgos, servicio.maxima());
    }
}

Utilizar el servicio en nuestro controlador

Ahora que tenemos la lógica dentro del servicio, vamos a refactorizar el controlador para que use los métodos del servicio en lugar de tener toda la lógica de negocio dentro. Fíjate que corta se queda la clase del recurso una vez que externalizamos toda la lógica:

@Path("/temperaturas")
public class TemperaturaResource {

    private TemperaturaService temperaturas = new TemperaturaServiceImpl();

    @POST
    public Temperatura insertar(Temperatura t) {
        temperaturas.insertar(t);
        return t;
    }

    @GET
    public Stream<Temperatura> listar() {
        return temperaturas.obtener();
    }

    @GET
    @Path("/maxima")
    public Temperatura maxima() {
        return temperaturas.maxima();
    }
}

Como nuestro servicio tiene la lógica, sólo tenemos que fabricar una instancia y en los endpoints nos basta con llamar a los métodos del servicio para que nos la haga. El controlador todavía puede tener lógica de negocio propia. Por ejemplo, imaginemos que tenemos que envolver la respuesta que nos ofrece el método .obtener() de nuestro TemperaturaService en otro objeto envolvente para así poder generar un JSON un poco más específico. El controlador sería el lugar ideal. Si nuestros endpoints usasen autenticación y tuviésemos que conectar previamente con un proveedor de acceso, podríamos hacerlo también. En el siguiente ejemplo ficticio tengo otro servicio para validar al usuario actual y devolver sus temperaturas:

private TemperaturaService temperaturas;

private UsuariosService usuarios;

@GET
public List<Temperatura> listar(SecurityContext context) {
  // Obtenemos el usuario actual a partir del JWT que viene en el SecurityContext.
  var currentUser = usuarios.decodificarUsuario(context);

  // Recuperamos las temperaturas que creó este este usuario. Este método podría
  // filtrar para sólo devolver las creadas por el usuario pero no las creadas por
  // otros usuarios.
  return temperaturas.obtenerTemperaturasPrivadas(currentUser);
}

Inyección de dependencia

La manera en la que estamos usando el TemperaturaService en nuestro controlador, de todos modos, no es óptima. Rara vez querremos instanciar directamente el servicio en nuestro controlador. Esto es en parte porque en muchas ocasiones nosotros ni siquiera sabremos cómo instanciar las clases.

Pongo un ejemplo práctico: en el módulo de acceso a datos describo cómo Quarkus hace uso de una biblioteca denominada Panache ORM que sirve para facilitar el acceso a bases de datos. Este acceso a bases de datos se dirige mediante unas clases llamadas repositorios, que se comportan como servicios que permiten insertar y eliminar elementos de una base de datos. Imagínalo como lo siguiente:

// Nuestro repositorio guarda la base de datos de temperaturas.
TemperaturaRepository baseDeDatos;

// Podemos insertar una temperatura...
baseDeDatos.insertar(temperatura);

// O podemos recuperar una temperatura.
Temperatura result = baseDeDatos.findById(temperaturaId);

Pero instanciar esta clase es más complejo y hacerlo a mano puede acarrear problemas de conexión con la base de datos. Por ello sale a cuenta dejar que sea Quarkus quien use su framework de inyección de dependencia para hacer esto por nosotros. Para personas que ya hayan trabajado con frameworks como Spring esta parte se la van a saber bien así que pueden saltarse el siguiente párrafo. Para quienes nunca hayan usado este framework, recomendaría prestar atención a lo siguiente.

Un framework de inyección de dependencia se ocupa de instanciar las clases por nosotros. Es decir, nosotros no ponemos new TemperaturaServiceImpl(), sino que dejamos que el framework lo instancie. En sí, esto es algo que llevamos haciendo desde el primer capítulo. ¿Quién instancia los controladores HTTP que atienden a las peticiones? Porque tú no has hecho new TemperaturaResource() ni new EcoResource(), únicamente has creado la clase y has visto que funcionaba. Quarkus siempre se ha ocupado de instanciar copias de esa clase. Con los servicios va a pasar algo parecido: nosotros le pediremos a Quarkus que los instancie. Esto tiene varias ventajas:

  • Si nuestro servicio depende de otros servicios, Quarkus se ocupa de componer los constructores automáticamente. Por ejemplo, si nuestro TemperaturaService dependiese de otro MySqlService para la persistencia de datos, Quarkus podría instanciar las dos clases y pasarle una a la otra como parámetro de constructor (a la gente de Spring le sonará aquí la anotación @Autowired).
  • Quarkus es incluso capaz de establecer ciclos de vida para esas clases que instancia. Por ejemplo, podemos pedirle a Quarkus que nos ofrezca clases frescas y recién declaradas con cada petición HTTP. Así nos podemos asegurar de que nunca hay dos peticiones que comparten el mismo objeto. O si lo preferimos, podemos señalizarle a Quarkus que un servicio debe ser usado como un singleton y ser global para todas las peticiones. (La gente que haya usado Spring, por ejemplo, conocerá aquí la anotación @Singleton).

En definitiva, ¿qué tenemos que hacer ahora?

Primero, señalizaremos a Quarkus que la clase TemperaturaServiceImpl es una clase que debe ser inyectable. Eso lo hacemos poniéndole la anotación @ApplicationScoped a la clase (import jakarta.enterprise.context.ApplicationScoped):

@ApplicationScoped
public class TemperaturaServiceImpl implements TemperaturaService {
  ...
}

ApplicationScoped sirve para indicar que la clase se tiene que comportar como un singleton: su scope es la aplicación así que todas las peticiones la van a compartir. También se puede usar RequestScoped para indicar que cada petición debería instanciar su propia copia de un TemperaturaServiceImpl. Sin embargo, como ahora mismo estamos guardando en memoria las temperaturas, esto haría que cada petición empiece con una lista vacía. Puedes hacer la prueba y verás que por mucho que hagas un POST de una nueva temperatura, el GET de la lista siempre te va a venir vacío.

Finalmente, en el controlador HTTP le pediremos al framework de inyección de dependencia que nos instancie ese servicio. La forma que actualmente se considera más elegante y fácil de seguir es la siguiente:

import jakarta.inject.Inject;

public class TemperaturaResource {

  private TemperaturaService temperaturas;

  @Inject
  public TemperaturaResource(TemperaturaService temperaturas) {
    this.temperaturas = temperaturas;
  }

  // El resto de métodos del resource van aquí.
}

Como ves, le he puesto a la clase TemperaturaResource un constructor con un parámetro de tipo TemperaturaService. El constructor guarda ese parámetro como campo para poder usarlo en el resto de métodos. Le he puesto al constructor de la clase la anotación @Inject, que puedes importar con un import jakarta.inject.Inject.

Lo que ahora ocurrirá es que cuando se levante la aplicación web, Quarkus tratará de instanciar nuestro TemperaturaResource para conectarlo al servidor web (porque lleva una anotación @Path a nivel de clase). Pero verá que para hacer eso, necesita antes tener un TemperaturaService. Como hay una implementación de esa interfaz que tiene la anotación ApplicationScoped, instanciará esa clase y se la pasará como parámetro. La anotación @Inject sirve para indicarle a Quarkus que debe hacer esto.

Seguramente también te encuentres código que está hecho sin constructores. Incluso en este mismo curso, en muchas de las lecciones siguientes instancio el servicio sin usar el constructor. En su lugar, el campo lleva directamente la anotación Inject.

public class TemperaturaResource {

  @Inject
  private TemperaturaService temperaturas;

}

Tienes que saber que ambas formas son equivalentes. Cuando el framework de inyección de dependencia de Quarkus está inyectando una clase, si se encuentra campos que llevan la anotación @Inject también les enchufa esa dependencia si la tiene a mano, o la instancia si le hace falta. El resultado será el mismo.

Sin embargo, es recomendable inyectar a nivel constructor. Si tuviésemos un servicio que depende de otro, esto podría facilitar las pruebas. Míralo con el siguiente ejemplo:

@ApplicationScoped
public class TemperaturaService implements TemperaturaService {

  private DatabaseService db;

  @Inject
  public TemperaturaService(DatabaseService db) {
    this.db = db;
  }

}

Cuando ahora Quarkus trate de instanciar la TemperaturaService para poder inyectarla en un controlador, verá que para hacer eso necesita antes inyectarle un DatabaseService. Estas cadenas de inyectado nos permiten fabricar clases simples que cumplen las buenas prácticas de orientación a objetos y que solo hacen una cosa. En cuanto una clase necesita un comportamiento ajeno, puede depender de otro servicio para hacerlo. En este caso, mi TemperaturaService tirará de un servicio externo para guardar las temperaturas y por ello lo necesita como parámetro.

Sin embargo, como lo he puesto como parámetro de constructor, si ahora quisiese ponerle a mano un servicio de database y pasárselo como parámetro, puedo hacerlo. El framework de inyección de dependencia siempre respeta las dependencias que ponemos a mano. Dicho de otro modo, si hago algo como esto:

DatabaseService db = new MockDatabaseService();
TemperaturaService temps = new TemperaturaService(db);

Mi servicio de temperaturas estará usando la instancia de MockDatabaseService que le he pasado. Con esto tengo más fácil pasar mocks en los tests de servicio para poder usar colaboradores falsos cuando esté testeando mis servicios. La anotación @Inject será ignorada porque en este caso me estoy ocupando yo de instanciar el objeto.

La inyección de dependencia es un concepto separado que puede requerir más tiempo de estudio. Comprenderla al principio es complicado porque algunas personas podrían sentirse extrañas ante la idea de que de forma mágica alguien se ocupe de instanciar las cosas en vez de obligar todo el rato a escribir new, pero es lo habitual en este tipo de proyectos. Dominarlo y aplicar el patrón Service Object te permitirá fabricar mejor código.

Tienes todo el código que hemos visto en este vídeo aquí recopilado: https://gist.github.com/danirod/bd4bc8413f74e889e86fefe29b0f08a6

Lista de reproducción
  1. 1
    Qué es Quarkus y cómo crear un proyecto
    8 minutos
  2. 2
    ¿Cómo crear endpoints en Quarkus?
    7 minutos
  3. 3
    Paso de parámetros con PathParam y QueryParam
    8 minutos
  4. 4
    Retorno de objetos JSON
    7 minutos
  5. 5
    Definir un endpoint POST
    7 minutos
  6. 6
    Servicios e inyección de dependencia
    8 minutos
  7. 7
    Response y ResponseBuilder
    8 minutos
  8. 8
    ExceptionMapper y tratamiento de errores
    9 minutos