Dentro del mundo del DDD hay una pieza que hace que todo encaje: Las Proyecciones.
Qué son las proyecciones
Uno de los problemas más comunes cuando escalamos nuestro código es que diversos equipos necesitan acceder a la misma entidad. Por ejemplo, el equipo de backoffice necesita acceder a los usuarios, pero no queremos crear endpoints específicos ni darles acceso directo a nuestra base de datos.
Las proyecciones resuelven este problema permitiendo que cada contexto mantenga su propia versión de los datos que necesita.
Por ejemplo, en una plataforma de cursos online, un usuario tiene una representación en la plataforma y otra diferente en el backoffice.
En la plataforma, el usuario generará todas las mutaciones de su entidad (registrarse, cambiar su nombre, su email, etc.). Cada una de estas mutaciones publicará un evento de dominio:
📂 platform
└📂 users
├📂 application
│ ├📁 change_email
│ ├📁 register
│ ├…
│ └📁 rename
├📂 domain
│ ├📄 User
│ ├📄 UserEmailChanged
│ ├📄 UserRegistered
│ ├…
│ ├📄 UserRenamed
│ └📄 UserRepository
└📂 infrastructure
└📄 PostgresUserRepository
Luego, el módulo de usuarios dentro del contexto del backoffice, escuchará estos eventos y actualizará su propia proyección:
📂 backoffice
└📂 users
├📂 application
│ ├📂 create
│ │ └📄 CreateUserOnUserRegistered
│ ├📂 update_email
│ │ └📄 UpdateBackofficeUserEmailOnUserEmailChanged
│ ├…
│ └📂 rename
│ └📄 RenameBackofficeUserOnUserRenamed
├📂 domain
│ ├📄 BackofficeUser
│ └📄 BackofficeUserRepository
└📂 infrastructure
└📄 PostgresBackofficeUserRepository
El backoffice también podría escuchar otros eventos como course_progress.finished
para mantener un listado de los
cursos que cada usuario ha completado.
Esta arquitectura permite que ambos servicios escalen independientemente y tengan su propia representación de la entidad. Además, otorga autonomía a los equipos para modelar solo los datos que realmente necesitan.
El (gran) problema de las proyecciones
Asumiendo que ya contamos con la infraestructura para publicar y escuchar eventos de dominio, el mayor problema de las proyecciones es la cantidad de código repetitivo necesario para crearlas, mantenerlas y realizar la primera importación de datos.
Por ejemplo, para proyectar usuarios (un caso muy común), cada equipo debe implementar código similar para:
- Escuchar los mismos eventos de dominio.
- Actualizar su proyección con una lógica similar.
- Mantener la coherencia cuando se añaden nuevos campos.
Cuando se añade un campo nuevo a la entidad usuario, todos los equipos que proyectan esta entidad deben actualizar su código para incorporar este cambio (si les interesa).
Esto, en una empresa mediana/grande, implica muchas horas de desarrollo que no aportan valor directo a los usuarios. Y, además de que es un trabajo muy repetitivo, también es un trabajo caro dado todas las colas que hay que crear y mantener.
Pero por suerte, gracias a Kafka (u otros servicios de streaming de datos), podemos simplificar mucho este proceso.
Proyecciones con Kafka
A diferencia de las colas de mensajería tradicionales, Kafka funciona con streams de datos. Esto significa que los mensajes ya consumido no se borran, sino que se mantienen en el stream.
Esta característica nos permite implementar una solución elegante: cada vez que se produce una mutación en la plataforma, además de publicar el evento de dominio específico, también publicamos un snapshot completo y actualizado de la entidad en su propio topic. Así, siempre tenemos disponible la última versión de cada entidad.
Ahora te podrías preguntar ¿esto no es lo mismo que compartir la tabla de users
? Sí, pero no.
La diferencia clave es que, al tenerlo en un stream de datos, los consumidores pueden reaccionar a estos cambios en tiempo real. Con una tabla compartida, tendríamos que implementar algún mecanismo adicional para detectar cambios.
Con esta aproximación, el contexto de backoffice se simplifica notablemente:
📂 backoffice
└📂 users
├📂 application
│ └📂 project
│ └📄 BackofficeUserSnapshotProjector
├📂 domain
│ ├📄 BackofficeUser
│ └📄 BackofficeUserRepository
└📂 infrastructure
└📄 PostgresBackofficeUserRepository
El BackofficeUserSnapshotProjector
se encarga de leer los snapshots del topic de users
y actualizar la
proyección. Lo que antes requería múltiples archivos y manejadores de eventos, ahora se reduce a un
único componente.
¿Y la primera importación?
Esta solución también resuelve elegantemente el problema de la primera importación de datos. En el enfoque tradicional basado en eventos, necesitaríamos:
- Crear una nueva cola para consumir los eventos de las mutaciones a proyectar.
- Empezar a publicar en esa cola.
- Importar manualmente (vía script, dump de BD, etc.) todo el histórico de datos. Muchas veces esto implica montar una nueva réplica de la base de datos de origen para no tumbar la principal.
- Empezar a consumir los eventos de la cola descartando los que ya hemos importado previamente.
Este proceso requiere código específico para la importación inicial y una coordinación para crear colas y enrutar eventos correctamente.
Con Kafka, gracias a que tenemos los snapshots en el stream, podemos hacer una primera importación de la siguiente manera:
- Desplegar el nuevo servicio con el projector.
Y ya está. El projector leerá automáticamente todo el histórico de snapshots desde el principio del stream y actualizará la proyección local con todos los datos existentes.
Qué pasa si tengo millones de datos
Tener todo el histórico de snapshots en un stream de datos puede ser un problema si tenemos millones de datos. Ya que estos streams empezarán a pesar Teras de datos. Además, de que si hemos de procesar todo el histórico cuando solo nos interesa la última versión, estamos perdiendo tiempo y recursos.
Para ello Kafka nos ofrece los compacted topics. Estos topics se encargan de que solo se mantenga la última versión de la entidad que le pasemos identificada por su clave.
Por ejemplo, si publicamos múltiples snapshots del usuario con identificador "123", un compacted topic mantendrá solo el último, eliminando automáticamente las versiones anteriores durante el proceso de compactación.
De esta forma, el stream de datos no crece indefinidamente y las lecturas son mucho más eficientes, especialmente durante la primera importación o cuando se necesita reconstruir una proyección desde cero.
¿Cómo aplicarlo en tu proyecto?
Uno de los grandes retos de esta arquitectura es ver cómo implementarlo:
- ¿Cada vez que se muta una entidad publicamos un snapshot?
- ¿Lo hacemos escuchando los eventos de dominio y reaccionando a ellos?
- ¿Y por qué no en el repository si es donde se producen sí o sí las mutaciones?
Además, de que en código legacy puede ser complicado de implementar, ya que no siempre hay un sólo sitio donde se produzcan las mutaciones. Allí un sistema CDC nos puede venir muy bien.
Todo esto, más la experiencia de cómo Wallapop lo hace en producción con Teras de datos en sus compacted topics, lo vemos en el curso de Proyecciones con Kafka que acabamos de lanzar.