Tipos de repositorio: Separación de responsabilidades

Tras quejarme del repositorio genérico, del repositorio concreto y del no-repositorio, ya empiezo a estar un poco cansado. Ha llegado el momento de ver cómo me gustan a mi los repositorios (ahora).

La parte transaccional

Para mi, el repositorio ideal es así:

public interface IProductRepository
{
   Product FindById(Guid id);
}

Ya está. Si pudiera tener todos mis repositorios así, con un único FindById, sería genial porque querría decir que he conseguido crear un modelo rico, enlazado y definido que me permite ejecutar todas las operaciones a partir del aggregate root que las va a realizar. En todo caso, no me parece mal tener otros FindByXXX que permitan buscar por alguna clave natural.

¿Y para añadir nuevas entidades? Intento nunca crear nuevos aggregate roots. Si puedo, hago que la creación de entidades se realiza a partir de otras entidades para aplicar así persistencia por alcance. Hay veces que no me queda más remedio y en ese caso añado un método void Add() al repositorio. Además el nombre es ese, Add, para reforzar la idea de que estoy añadiendo una cosa nueva, no actualizando una que ya existe. De hecho, si el ORM lo permite, prefiero que ese método lance una excepción si se intenta guardar una entidad que no sea transient.

¿Y para actualizar entidades que han sido modificadas en memoria? Eso no te hace falta. Si estás usando un ORM de verdad, su implementación interna del patrón UoW junto con su sistema de seguimiento de cambios (change tracking) ya se encargarán de almacenar los cambios que hayas hecho en las entidades que cargadas dentro de ese UoW cuando hagas el commit del UoW. Pero, ¿qué pasa con las entidades desconectadas del UoW? No deberías tener entidades desconectadas del UoW. Nunca. Cuando lleguemos a la segunda parte del post y veamos como se hacen las lecturas, esto quedará más claro.

¿Y el borrado de entidades? Antes que nada habría que preguntarse si realmente tiene sentido borrar entidades. Si después de pensarlo mucho hace falta borrarlas, acabo añadiendo el método void Delete(Entity entity) al repositorio.

Con todo esto, en el peor de los casos acabaríamos con un repositorio así:

public interface IProductRepository
{
    // Este método está siempre
    Product FindById(Guid id);

    // Este puede ser necesario: búsqueda de UNA entidad 
    // usando una clave natural
    Product FindByBarcode(string barcode);

    // Este método hay que intentar evitarlo usando
    // persistencia por alcance
    void Add(Product product);

    // Antes de añadir este método, hay que responder la
    // pregunta: ¿realmente tiene sentido borrar esta entidad?
    void Delete(Product product);
}

Una de las cosas que más me gustan de este repositorio es que minimiza la contaminación del dominio de la que me quejaba en posts anteriores. Tener que plantearse si realmente son necesarios los métodos Add/Delete también ayuda a pensar más a fondo sobre el dominio que estamos modelando y tratar de descubrir alternativas a la hora de modelarlo.

La parte de lectura

El repositorio que acabamos de ver se encarga únicamente de la parte OLTP de la aplicación. Es decir, nos sirve para cargar una entidad cuyo ID ya conocemos (o alguna clave natural) y ejecutar acciones sobre ella. Por suerte o por desgracia, para implementar la mayoría de aplicaciones necesitamos exponer información hacia el exterior, ya sea en forma de interfaz de usuario o de servicios interoperables por otros sistemas, y para eso suele ser necesario leer más de una entidad a la vez.

A la hora de leer los datos es fundamental tener en cuenta es el uso que le vamos a dar. Es algo de lo que me he ido quejando con las anteriores implementaciones de repositorio, y es que si hace falta cargar entidades completas para luego convertirlas a DTOs o Models, podemos acabar teniendo problema de eficiencia.

La solución que me gusta es leer directamente de la base de datos los DTOs o los Models. Para ello se pueden usar varias alternativas, como escribir sentencias sql a mano o usar un Micro ORM, ahora que están tan de moda.

Personalmente, si puedo, aprovecho las posibilidades del ORM que suelo usar, NHibernate. NHibernate permite utilizar proyecciones para realizar consultas específicas en las que, por ejemplo, sólo estamos interesados en cargar parte de una entidad o queremos realizar algún cálculo en la base de datos (por ejemplo, un sum o un max). Estas proyecciones se pueden hacer con cualquiera de los mecanismos de consulta de NHibernate: HQL, Linq, Criteria, QueryOver o incluso SQL nativo. La ventaja es que la mayoría de las veces es fácil apañarse con Linq, con lo que la cosa queda muy legible:

var products = session.Query<Product>()
                      .Where(x => x.AvailableInStock)
                      .Select(x => new ProductDTO
                      {
                          Id = x.Id,
                          Name = x.Name,
                          Family = x.Family.Name,
                      }).ToArray();

La ventaja de esto es que permite lanzar cada consulta de la manera más eficiente posible y tratarlos como lo que son, casos de uso individuales y específicos. Puesto que estas lecturas devuelven DTOs o Models, está claro que el interface de estos servicios de lectura de datos no pertenece al dominio de la aplicación. Estos servicios como parte de la infraestructura y se tratan como tales.

Una ventaja inmediata de esta arquitectura es que desaparece la capa de mappers que suele existir para convertir de entidades a objetos que se exponen hacia el exterior, ya sean DTOs para comunicaciones o Models para las vistas.

Es importante ser disciplinado y mantener cada DTO/Model para un caso de uso concreto y con la granularidad adecuada. En el caso ideal, cada vista de la aplicación o cada servicio expuesto hacia el exterior debería poder cargarse en una sola consulta de BD, aunque esto no siempre es posible. Si empezamos a reutilizar DTOs en distintos casos de uso, acabamos por tener objetos demasiado grandes que son complicados de cargar desde la base de datos. Mi opinión ahí es clara: si el DTO que existe no te sirve, no luches mucho por modificarlo para cubrir 2 casos, crea un segundo DTO. Esto permite mantener las consultas optimizadas y focalizadas.

Ahora ya queda claro porque no existe un método Update en el repositorio. Las entidades nunca viajan hasta la capa de presentación o servicios externos y, por tanto, nunca vamos a tener entidades desconectadas del UoW.

Algunas consideraciones finales

Esto NO es CQRS. Según los expertos en el tema, si no hay modelos de datos separados de lectura y escritura no se puede considerar CQRS. En este caso estamos usando LA MISMA base de datos para ambas cosas.

La primera vez que leí sobre este tipo de arquitectura fue en este post de Greg Young, que acabó convirtiéndose en el germen de lo que luego evolucionarían entre él y Udi Dahan hasta convertirlo en CQRS. Tal vez yo simplemente sea más lento y dentro de un tiempo me dé cuenta de que esto no sirve y llegue a la conclusión de que la solución es CQRS. Ya veremos.

9 comentarios en “Tipos de repositorio: Separación de responsabilidades

  1. Oscar Montesinos @2Flores dijo:

    Me ha gustado mucho Juanma, el Delete me ha quedado claro excepto una duda, no debería ser void Delete(int id) en vez void Delete (Product producto)?

    Un saludo
    Oscar

  2. Gracias, Oscar.

    Se podría pasar sólo el Id al Delete, pero teniendo en cuenta que la clase ProductRepository ya está muy acoplada con la clase Product, creo que queda más claro así y además ganas seguridad de tipos.

    Si te preocupa tener que cargar IIIel producto de la BD para poder eliminarlo, hay ORMs que lo pueden hacer sin tocar la BD, por ejemplo en NHibernate con un session.Load(id) crea un proxy del producto que sólo tiene inicializado el Id, y hasta que no se accede a otras propiedades no se carga la información de la BD, por lo que para este caso es una salida ideal.

  3. Oscar Montesinos @2Flores dijo:

    Gracias Juanma, lo del Load algo he visto de pasada, le pegaré un vistazo, gracias de nuevo.

  4. Hola Juanma

    Tengo un proyecto orientado al dominio y estoy ahora buscando la mejor solución al problema que planteas en este post, buscando por internet he llegado hasta tu blog, algo que normalmente me suele pasar así que enhorabuena ;-).

    Tengo algunas dudas, inicialmente había pensado tirar por CQRS pero creo que de momento me voy a inclinar por una solución más sencilla, el proyecto no requiere de demasiada complejidad de momento. La opción es atacando a la misma base de datos y tener una clase de acceso a datos que usara sql directamente o usando Select para devolver DTO como planteas.

    La duda que me surge es donde ubicar estas clase de acceso a datos y los DTOs que devuelven. Creo que estoy demasiado acostumbrado a mapear de entidades a dto en la capa de aplicación, que es donde suelo definir los DTO, y me cuesta ver esta nueva perspectiva pare mi.

    Gracias

  5. Hola Jorge,

    Me alegro de que mi blog te resulte útil :-)

    Sobre la duda que planteas, yo suelo separar la parte transaccional de la parte de lecturas.

    En la parte transaccional se recibe un command (o algo parecido) que contiene los Ids de las entidades necesarias y los datos con los que debe realizarse la operación. Quien maneja ese command usa repositorios para cargar entidades y realizar la operación necesaria. Algo parecido a lo que explica en esta serie de posts: https://blog.koalite.com/2014/02/crear-modelos-mas-ricos-quitando-logica-de-los-servicios/

    La parte de lectura la suelo implementar con queries, a veces agrupando varias en un servicio (técnicamente sería un servicio de aplicación, pero podrías desplegarlo de forma que sea compartido por varias aplicaciones, por ejemplo con un API Rest). Estas queries reciben los parámetros de búsqueda (por ejemplo un texto para filtrar productos, o una categoría de clientes) y devuelven directamente los DTOs desde la base de datos.

    Con esto, podrías tener algo así:

    En el dominio, un IProductRepository para buscar un producto por Id y hacer algo con él (por ejemplo descontinuarlo).

    En cada de aplicación, un ICatalogueService que busca productos por categoría y devuelve DTOs optimizados para mostrar en un grid (id, nombre, nombre de la familia, nombre de la categoría, stock actual y precio, por ejemplo).

    No sé si te he aclarado algo o te he liado más.

    Un saludo,

    Juanma.

  6. Muchas gracias juanma.

    Creo que me ha quedado claro lo que me planteas.

    La idea sería tener en la capa de aplicación un servicio para la parte trasaccional usando repositorios, entidades etc.. para operar y un servicio distinto para lecturas saltándonos la parte de respositorios y entidades, que se encargue de hacer queries directamente a la base de datos y devolver DTOs. De esta forma estaría usando por ejemplo el contexto de base de datos de EF desde este servicio para lectura. Y en la capa de aplicación es donde se definirian los DTOs a devolver.

    Espero haberlo entendido bien.

    Saludos

  7. Efectivamente, Jorge. Esa es la idea.

    El interface del repositorio pertenecería al dominio y devolvería entidades (que son parte del dominio).

    El interface del servicio de lectura pertenecería a la capa de aplicación y devolvería DTOs (que no son parte del dominio, sino de la aplicación).

    Las implementaciones de ambos estarían en el paquete de persistencia/integración (aplicando dependency inversion) y podrían acceder los dos a la base de datos usando EF, NH, Dapper o lo que más te guste (incluso cosas distintos cada uno).

  8. Ivan Montilla dijo:

    Me queda una duda importante acerca de como salvar datos en las entidades.

    Teniendo en cuenta que el repositorio no tiene ningún método llamado Commit o SaveChanges, que llame al UoW y salve los datos, ¿cómo puedo realizar esta tarea desde una acción del controlador?

    Imaginando el siguiente caso:

    «`
    [HttpPut(«/customers/{id}»)]
    public IActionResult UpdateCustomer([FromRoute] Id id, [FromBody] UpdateModel model) // model = DTO
    {
    var customer = customerRepository.FindById(id);
    model.UpdateCustomer(customer); // Actualiza las propiedades de la entidad con las del modelo.

    uow.Commit(); // ¿Debería de inyectar además del repositorio también una abstracción de UoW? ¿esta abstracción quizás deba tener únicamente el método Commit()?

    return Ok(customer);
    }
    «`

    Un saludo

Comentarios cerrados.