hexcore 2.0.6__tar.gz → 2.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. {hexcore-2.0.6/hexcore.egg-info → hexcore-2.1.0}/PKG-INFO +191 -1
  2. {hexcore-2.0.6 → hexcore-2.1.0}/README.md +188 -0
  3. hexcore-2.1.0/hexcore/application/cqrs/__init__.py +24 -0
  4. hexcore-2.1.0/hexcore/application/cqrs/adapters.py +33 -0
  5. hexcore-2.1.0/hexcore/application/cqrs/config.py +69 -0
  6. hexcore-2.1.0/hexcore/application/cqrs/factory.py +131 -0
  7. hexcore-2.1.0/hexcore/application/cqrs/in_memory_buses.py +99 -0
  8. hexcore-2.1.0/hexcore/application/cqrs/pipeline.py +58 -0
  9. hexcore-2.1.0/hexcore/application/cqrs/registry.py +115 -0
  10. {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/config.py +38 -5
  11. hexcore-2.1.0/hexcore/domain/cqrs/__init__.py +50 -0
  12. hexcore-2.1.0/hexcore/domain/cqrs/buses.py +80 -0
  13. hexcore-2.1.0/hexcore/domain/cqrs/commands.py +30 -0
  14. hexcore-2.1.0/hexcore/domain/cqrs/exceptions.py +34 -0
  15. hexcore-2.1.0/hexcore/domain/cqrs/handlers.py +52 -0
  16. hexcore-2.1.0/hexcore/domain/cqrs/middleware.py +51 -0
  17. hexcore-2.1.0/hexcore/domain/cqrs/queries.py +27 -0
  18. hexcore-2.1.0/hexcore/domain/cqrs/serializer.py +39 -0
  19. {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/domain/events.py +36 -4
  20. {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/domain/services.py +3 -3
  21. {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/domain/uow.py +1 -1
  22. hexcore-2.1.0/hexcore/infrastructure/cqrs/__init__.py +24 -0
  23. hexcore-2.1.0/hexcore/infrastructure/cqrs/middlewares.py +135 -0
  24. hexcore-2.1.0/hexcore/infrastructure/cqrs/procrastinate.py +99 -0
  25. hexcore-2.1.0/hexcore/infrastructure/cqrs/pydantic_serializer.py +56 -0
  26. hexcore-2.1.0/hexcore/infrastructure/events/events_backends/memory.py +32 -0
  27. {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/infrastructure/uow/__init__.py +4 -4
  28. {hexcore-2.0.6 → hexcore-2.1.0/hexcore.egg-info}/PKG-INFO +191 -1
  29. {hexcore-2.0.6 → hexcore-2.1.0}/hexcore.egg-info/SOURCES.txt +20 -0
  30. {hexcore-2.0.6 → hexcore-2.1.0}/hexcore.egg-info/requires.txt +3 -0
  31. {hexcore-2.0.6 → hexcore-2.1.0}/pyproject.toml +4 -1
  32. hexcore-2.1.0/tests/test_cqrs.py +602 -0
  33. {hexcore-2.0.6 → hexcore-2.1.0}/tests/test_uow_session_regression.py +6 -6
  34. hexcore-2.0.6/hexcore/infrastructure/events/events_backends/memory.py +0 -20
  35. {hexcore-2.0.6 → hexcore-2.1.0}/LICENSE +0 -0
  36. {hexcore-2.0.6 → hexcore-2.1.0}/MANIFEST.in +0 -0
  37. {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/__init__.py +0 -0
  38. {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/__main__.py +0 -0
  39. {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/application/__init__.py +0 -0
  40. {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/application/dtos/__init__.py +0 -0
  41. {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/application/dtos/base.py +0 -0
  42. {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/application/dtos/query.py +0 -0
  43. {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/application/use_cases/__init__.py +0 -0
  44. {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/application/use_cases/base.py +0 -0
  45. {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/application/use_cases/query.py +0 -0
  46. {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/domain/__init__.py +0 -0
  47. {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/domain/auth/__init__.py +0 -0
  48. {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/domain/auth/permissions.py +0 -0
  49. {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/domain/auth/value_objects.py +0 -0
  50. {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/domain/base.py +0 -0
  51. {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/domain/exceptions.py +0 -0
  52. {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/domain/repositories.py +0 -0
  53. {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/infrastructure/__init__.py +0 -0
  54. {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/infrastructure/api/__init__.py +0 -0
  55. {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/infrastructure/api/utils.py +0 -0
  56. {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/infrastructure/cache/__init__.py +0 -0
  57. {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/infrastructure/cache/cache_backends/__init__.py +0 -0
  58. {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/infrastructure/cache/cache_backends/memory.py +0 -0
  59. {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/infrastructure/cache/cache_backends/redis.py +0 -0
  60. {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/infrastructure/cli.py +0 -0
  61. {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/infrastructure/events/__init__.py +0 -0
  62. {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/infrastructure/events/events_backends/__init__.py +0 -0
  63. {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/infrastructure/repositories/__init__.py +0 -0
  64. {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/infrastructure/repositories/base.py +0 -0
  65. {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/infrastructure/repositories/decorators.py +0 -0
  66. {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/infrastructure/repositories/implementations.py +0 -0
  67. {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/infrastructure/repositories/orms/__init__.py +0 -0
  68. {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/infrastructure/repositories/orms/beanie/__init__.py +0 -0
  69. {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/infrastructure/repositories/orms/beanie/utils.py +0 -0
  70. {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/__init__.py +0 -0
  71. {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/session.py +0 -0
  72. {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/utils.py +0 -0
  73. {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/infrastructure/repositories/utils.py +0 -0
  74. {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/infrastructure/uow/decorators.py +0 -0
  75. {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/infrastructure/uow/helpers.py +0 -0
  76. {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/py.typed +0 -0
  77. {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/types.py +0 -0
  78. {hexcore-2.0.6 → hexcore-2.1.0}/hexcore.egg-info/dependency_links.txt +0 -0
  79. {hexcore-2.0.6 → hexcore-2.1.0}/hexcore.egg-info/entry_points.txt +0 -0
  80. {hexcore-2.0.6 → hexcore-2.1.0}/hexcore.egg-info/top_level.txt +0 -0
  81. {hexcore-2.0.6 → hexcore-2.1.0}/scripts/__init__.py +0 -0
  82. {hexcore-2.0.6 → hexcore-2.1.0}/scripts/main.py +0 -0
  83. {hexcore-2.0.6 → hexcore-2.1.0}/setup.cfg +0 -0
  84. {hexcore-2.0.6 → hexcore-2.1.0}/tests/conftest.py +0 -0
  85. {hexcore-2.0.6 → hexcore-2.1.0}/tests/test_basic.py +0 -0
  86. {hexcore-2.0.6 → hexcore-2.1.0}/tests/test_beanie_query_utils.py +0 -0
  87. {hexcore-2.0.6 → hexcore-2.1.0}/tests/test_config_loading.py +0 -0
  88. {hexcore-2.0.6 → hexcore-2.1.0}/tests/test_domain_service_query.py +0 -0
  89. {hexcore-2.0.6 → hexcore-2.1.0}/tests/test_infrastructure_query_path.py +0 -0
  90. {hexcore-2.0.6 → hexcore-2.1.0}/tests/test_query_field_validation.py +0 -0
  91. {hexcore-2.0.6 → hexcore-2.1.0}/tests/test_repositories_utils.py +0 -0
  92. {hexcore-2.0.6 → hexcore-2.1.0}/tests/test_use_cases_query.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hexcore
3
- Version: 2.0.6
3
+ Version: 2.1.0
4
4
  Summary: Núcleo reutilizable para proyectos Python con arquitectura hexagonal y event handling. Provee abstracciones, utilidades y contratos para DDD, eventos y desacoplamiento de infraestructura.
5
5
  Author-email: "David Latosefki (Indroic)" <indroic@outlook.com>
6
6
  License-Expression: MIT
@@ -18,6 +18,8 @@ Requires-Dist: redis>=6.4.0
18
18
  Requires-Dist: sqlalchemy>=2.0.43
19
19
  Requires-Dist: typer>=0.17.3
20
20
  Requires-Dist: ruff>=0.12.11
21
+ Provides-Extra: procrastinate
22
+ Requires-Dist: procrastinate>=3.0.0; extra == "procrastinate"
21
23
  Dynamic: license-file
22
24
 
23
25
  # HexCore [![PyPI Downloads](https://static.pepy.tech/personalized-badge/hexcore?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/hexcore)
@@ -264,6 +266,194 @@ Ambos repositorios utilizan `to_entity_from_model_or_document` para convertir mo
264
266
 
265
267
  ---
266
268
 
269
+ ---
270
+
271
+ ## Arquitectura CQRS en HexCore
272
+
273
+ HexCore v2 integra de forma nativa soporte para el patrón **CQRS (Command Query Responsibility Segregation)**, permitiendo separar conceptual y técnicamente las operaciones de escritura (Commands) de las de lectura (Queries).
274
+
275
+ ### ¿Cómo funciona el CQRS en HexCore?
276
+
277
+ El sistema se basa en 3 buses principales, configurables e independientes:
278
+
279
+ 1. **`AbstractCommandBus`**: Despacha inteniones de mutación (`Command`) a un único `AbstractCommandHandler`. Los commands modifican el estado del sistema y se ejecutan (por defecto) dentro de una transacción de base de datos (Unit of Work).
280
+ 2. **`AbstractQueryBus`**: Despacha intenciones de lectura (`Query`) a un único `AbstractQueryHandler`. Retornan un resultado sin mutar el estado.
281
+ 3. **`EventBus`**: Distribuye eventos de dominio (`DomainEvent`) a múltiples suscriptores asíncronamente (vía `subscribe`/`publish`).
282
+
283
+ La configuración de CQRS se activa mediante el `CQRSConfig` en tu `ServerConfig`:
284
+
285
+ ```python
286
+ from hexcore.config import ServerConfig
287
+ from hexcore.application.cqrs.config import CQRSConfig, BusConfig
288
+
289
+ config = ServerConfig(
290
+ cqrs=CQRSConfig(
291
+ command_bus=BusConfig(
292
+ # Por defecto incluye TransactionMiddleware
293
+ middlewares=["hexcore.infrastructure.cqrs.middlewares.TransactionMiddleware"]
294
+ ),
295
+ # Puedes sustituir el backend en memoria por uno distribuido (Ej: Celery, Procrastinate)
296
+ # backend="mi_app.infrastructure.ProcrastinateCommandBus"
297
+ )
298
+ )
299
+ ```
300
+
301
+ ---
302
+
303
+ ### Guía de Migración: De Casos de Uso Clásicos a CQRS
304
+
305
+ Si ya tienes una aplicación escrita con la abstracción `UseCase` de HexCore, puedes migrar progresivamente a CQRS sin reescribir todo tu código, utilizando los adaptadores incluidos.
306
+
307
+ #### Paso 1: Usar el adaptador `UseCaseCommandHandler`
308
+
309
+ En lugar de instanciar un UseCase directamente en tu endpoint, envuélvelo en un comando:
310
+
311
+ ```python
312
+ from hexcore.application.cqrs.adapters import UseCaseCommandHandler
313
+ from hexcore.application.cqrs.registry import HandlerRegistry
314
+
315
+ # 1. Tienes tu UseCase legado
316
+ class CreateUserUseCase(UseCase[CreateUserCommand, UserDTO]):
317
+ async def execute(self, request: CreateUserCommand) -> UserDTO:
318
+ # logica legacy
319
+ pass
320
+
321
+ # 2. Lo registras en el registry de CQRS utilizando el adaptador
322
+ registry = HandlerRegistry()
323
+ registry.register_command(
324
+ CreateUserCommand,
325
+ UseCaseCommandHandler(CreateUserUseCase())
326
+ )
327
+ ```
328
+
329
+ #### Paso 2: Consumirlo desde el endpoint usando el CommandBus
330
+
331
+ ```python
332
+ @router.post("/users")
333
+ async def create_user(
334
+ cmd: CreateUserCommand,
335
+ # factory inyectado por dependencias
336
+ bus: AbstractCommandBus = Depends(get_command_bus)
337
+ ):
338
+ # El bus despacha el comando al UseCase legacy de forma transparente
339
+ result = await bus.dispatch(cmd)
340
+ return result
341
+ ```
342
+
343
+ #### Paso 3 (Final): Refactor a Handler Puro
344
+
345
+ Cuando estés listo, convierte tu UseCase directamente en un `AbstractCommandHandler`:
346
+
347
+ ```python
348
+ from hexcore.domain.cqrs import AbstractCommandHandler
349
+
350
+ class CreateUserHandler(AbstractCommandHandler[CreateUserCommand, UserDTO]):
351
+ def __init__(self, uow: IUnitOfWork):
352
+ self.uow = uow
353
+
354
+ async def handle(self, command: CreateUserCommand) -> UserDTO:
355
+ # Lógica refactorizada
356
+ return dto
357
+ ```
358
+
359
+ ---
360
+
361
+ ### Guía: Almacenamiento Híbrido para Queries (Mongo, Redis, SQL)
362
+
363
+ La mayor ventaja de CQRS es optimizar las lecturas. HexCore permite que tus **Commands** escriban en una base de datos relacional (SQLAlchemy) fuertemente normalizada, mientras que los **Queries** leen de vistas desnormalizadas súper rápidas en MongoDB o Redis.
364
+
365
+ #### 1. Sincronización a través del EventBus (La Proyección)
366
+
367
+ Cuando un Command modifica SQL, dispara un Evento de Dominio. Un handler de eventos intercepta este evento y actualiza el "Read Model" en MongoDB o Redis.
368
+
369
+ ```python
370
+ from hexcore.domain.events import EventBus, DomainEvent
371
+
372
+ class UserCreatedEvent(DomainEvent):
373
+ user_id: str
374
+ full_name: str
375
+ email: str
376
+
377
+ async def project_user_to_mongodb(event: UserCreatedEvent):
378
+ """Proyecta el evento en la BD de lectura (MongoDB)"""
379
+ doc = UserReadDocument(
380
+ id=event.user_id,
381
+ name=event.full_name,
382
+ email=event.email
383
+ )
384
+ await doc.insert() # usando Beanie (Mongo)
385
+
386
+ # Registrar la proyección
387
+ event_bus.subscribe(UserCreatedEvent, project_user_to_mongodb)
388
+ ```
389
+
390
+ #### 2. Query Handler leyendo del Read Model
391
+
392
+ Tu QueryHandler nunca toca SQL, simplemente ataca directamente a Mongo o Redis para máxima velocidad.
393
+
394
+ ```python
395
+ from hexcore.domain.cqrs import AbstractQueryHandler, Query
396
+
397
+ class GetUserQuery(Query[UserReadDTO]):
398
+ user_id: str
399
+
400
+ class GetUserQueryHandler(AbstractQueryHandler[GetUserQuery, UserReadDTO]):
401
+ async def handle(self, query: GetUserQuery) -> UserReadDTO:
402
+ # Consulta ultra rápida a la colección de lectura en MongoDB
403
+ doc = await UserReadDocument.get(query.user_id)
404
+
405
+ # O desde Redis:
406
+ # data = await redis_client.get(f"user:{query.user_id}")
407
+
408
+ if not doc:
409
+ raise UserNotFoundException()
410
+ return UserReadDTO(**doc.dict())
411
+ ```
412
+
413
+ Con este esquema, alcanzas una alta escalabilidad: tus endpoints GET son despachados por el `QueryBus` respondiendo en milisegundos desde Mongo/Redis, y tus operaciones POST/PUT/DELETE van por el `CommandBus` transaccionando con ACID en SQL.
414
+
415
+ #### 3. Definición de Modelos de Lectura (Proyecciones)
416
+
417
+ Una pregunta frecuente es: **¿HexCore genera automáticamente estos modelos de lectura?**
418
+ La respuesta es **No**. El patrón CQRS sugiere que tus modelos de lectura estén diseñados *específicamente* para lo que tus interfaces visuales (UI) o APIs van a consultar. Por lo tanto, debes definir estos modelos manualmente.
419
+
420
+ **Si usas MongoDB (Beanie) para lecturas:**
421
+ Debes crear un documento manual optimizado. Por ejemplo, en lugar de tener joins, puedes embeber datos:
422
+ ```python
423
+ from beanie import Document
424
+
425
+ # Modelo desnormalizado optimizado para la lectura
426
+ class UserReadDocument(Document):
427
+ id: str # ID referenciado de la tabla SQL
428
+ name: str
429
+ email: str
430
+ total_purchases_cache: int = 0 # Dato pre-calculado por eventos
431
+
432
+ class Settings:
433
+ name = "users_read_projections"
434
+ ```
435
+
436
+ **Si usas PostgreSQL/MySQL (SQLAlchemy) para lecturas:**
437
+ Si prefieres mantenerte 100% en SQL pero aislando lecturas, puedes crear tablas específicas para proyecciones (Materialized Views o tablas planas):
438
+ ```python
439
+ from sqlalchemy.orm import declarative_base
440
+ from sqlalchemy import Column, String, Integer
441
+
442
+ Base = declarative_base()
443
+
444
+ class UserReadProjection(Base):
445
+ __tablename__ = 'users_read_projection'
446
+
447
+ # Modelo totalmente plano sin relaciones ForeignKey complejas
448
+ id = Column(String, primary_key=True)
449
+ full_name = Column(String)
450
+ email = Column(String)
451
+ total_purchases_cache = Column(Integer, default=0)
452
+ ```
453
+ En ambos casos, es tu **EventBus** (o un consumidor como Procrastinate) el encargado de instanciar estos modelos manuales y persistirlos cada vez que se detecte un cambio en los modelos de escritura.
454
+
455
+ ---
456
+
267
457
  ## Referencias
268
458
 
269
459
  - [CONTRIBUTING.md](./CONTRIBUTING.md): Pautas de colaboración.
@@ -242,6 +242,194 @@ Ambos repositorios utilizan `to_entity_from_model_or_document` para convertir mo
242
242
 
243
243
  ---
244
244
 
245
+ ---
246
+
247
+ ## Arquitectura CQRS en HexCore
248
+
249
+ HexCore v2 integra de forma nativa soporte para el patrón **CQRS (Command Query Responsibility Segregation)**, permitiendo separar conceptual y técnicamente las operaciones de escritura (Commands) de las de lectura (Queries).
250
+
251
+ ### ¿Cómo funciona el CQRS en HexCore?
252
+
253
+ El sistema se basa en 3 buses principales, configurables e independientes:
254
+
255
+ 1. **`AbstractCommandBus`**: Despacha inteniones de mutación (`Command`) a un único `AbstractCommandHandler`. Los commands modifican el estado del sistema y se ejecutan (por defecto) dentro de una transacción de base de datos (Unit of Work).
256
+ 2. **`AbstractQueryBus`**: Despacha intenciones de lectura (`Query`) a un único `AbstractQueryHandler`. Retornan un resultado sin mutar el estado.
257
+ 3. **`EventBus`**: Distribuye eventos de dominio (`DomainEvent`) a múltiples suscriptores asíncronamente (vía `subscribe`/`publish`).
258
+
259
+ La configuración de CQRS se activa mediante el `CQRSConfig` en tu `ServerConfig`:
260
+
261
+ ```python
262
+ from hexcore.config import ServerConfig
263
+ from hexcore.application.cqrs.config import CQRSConfig, BusConfig
264
+
265
+ config = ServerConfig(
266
+ cqrs=CQRSConfig(
267
+ command_bus=BusConfig(
268
+ # Por defecto incluye TransactionMiddleware
269
+ middlewares=["hexcore.infrastructure.cqrs.middlewares.TransactionMiddleware"]
270
+ ),
271
+ # Puedes sustituir el backend en memoria por uno distribuido (Ej: Celery, Procrastinate)
272
+ # backend="mi_app.infrastructure.ProcrastinateCommandBus"
273
+ )
274
+ )
275
+ ```
276
+
277
+ ---
278
+
279
+ ### Guía de Migración: De Casos de Uso Clásicos a CQRS
280
+
281
+ Si ya tienes una aplicación escrita con la abstracción `UseCase` de HexCore, puedes migrar progresivamente a CQRS sin reescribir todo tu código, utilizando los adaptadores incluidos.
282
+
283
+ #### Paso 1: Usar el adaptador `UseCaseCommandHandler`
284
+
285
+ En lugar de instanciar un UseCase directamente en tu endpoint, envuélvelo en un comando:
286
+
287
+ ```python
288
+ from hexcore.application.cqrs.adapters import UseCaseCommandHandler
289
+ from hexcore.application.cqrs.registry import HandlerRegistry
290
+
291
+ # 1. Tienes tu UseCase legado
292
+ class CreateUserUseCase(UseCase[CreateUserCommand, UserDTO]):
293
+ async def execute(self, request: CreateUserCommand) -> UserDTO:
294
+ # logica legacy
295
+ pass
296
+
297
+ # 2. Lo registras en el registry de CQRS utilizando el adaptador
298
+ registry = HandlerRegistry()
299
+ registry.register_command(
300
+ CreateUserCommand,
301
+ UseCaseCommandHandler(CreateUserUseCase())
302
+ )
303
+ ```
304
+
305
+ #### Paso 2: Consumirlo desde el endpoint usando el CommandBus
306
+
307
+ ```python
308
+ @router.post("/users")
309
+ async def create_user(
310
+ cmd: CreateUserCommand,
311
+ # factory inyectado por dependencias
312
+ bus: AbstractCommandBus = Depends(get_command_bus)
313
+ ):
314
+ # El bus despacha el comando al UseCase legacy de forma transparente
315
+ result = await bus.dispatch(cmd)
316
+ return result
317
+ ```
318
+
319
+ #### Paso 3 (Final): Refactor a Handler Puro
320
+
321
+ Cuando estés listo, convierte tu UseCase directamente en un `AbstractCommandHandler`:
322
+
323
+ ```python
324
+ from hexcore.domain.cqrs import AbstractCommandHandler
325
+
326
+ class CreateUserHandler(AbstractCommandHandler[CreateUserCommand, UserDTO]):
327
+ def __init__(self, uow: IUnitOfWork):
328
+ self.uow = uow
329
+
330
+ async def handle(self, command: CreateUserCommand) -> UserDTO:
331
+ # Lógica refactorizada
332
+ return dto
333
+ ```
334
+
335
+ ---
336
+
337
+ ### Guía: Almacenamiento Híbrido para Queries (Mongo, Redis, SQL)
338
+
339
+ La mayor ventaja de CQRS es optimizar las lecturas. HexCore permite que tus **Commands** escriban en una base de datos relacional (SQLAlchemy) fuertemente normalizada, mientras que los **Queries** leen de vistas desnormalizadas súper rápidas en MongoDB o Redis.
340
+
341
+ #### 1. Sincronización a través del EventBus (La Proyección)
342
+
343
+ Cuando un Command modifica SQL, dispara un Evento de Dominio. Un handler de eventos intercepta este evento y actualiza el "Read Model" en MongoDB o Redis.
344
+
345
+ ```python
346
+ from hexcore.domain.events import EventBus, DomainEvent
347
+
348
+ class UserCreatedEvent(DomainEvent):
349
+ user_id: str
350
+ full_name: str
351
+ email: str
352
+
353
+ async def project_user_to_mongodb(event: UserCreatedEvent):
354
+ """Proyecta el evento en la BD de lectura (MongoDB)"""
355
+ doc = UserReadDocument(
356
+ id=event.user_id,
357
+ name=event.full_name,
358
+ email=event.email
359
+ )
360
+ await doc.insert() # usando Beanie (Mongo)
361
+
362
+ # Registrar la proyección
363
+ event_bus.subscribe(UserCreatedEvent, project_user_to_mongodb)
364
+ ```
365
+
366
+ #### 2. Query Handler leyendo del Read Model
367
+
368
+ Tu QueryHandler nunca toca SQL, simplemente ataca directamente a Mongo o Redis para máxima velocidad.
369
+
370
+ ```python
371
+ from hexcore.domain.cqrs import AbstractQueryHandler, Query
372
+
373
+ class GetUserQuery(Query[UserReadDTO]):
374
+ user_id: str
375
+
376
+ class GetUserQueryHandler(AbstractQueryHandler[GetUserQuery, UserReadDTO]):
377
+ async def handle(self, query: GetUserQuery) -> UserReadDTO:
378
+ # Consulta ultra rápida a la colección de lectura en MongoDB
379
+ doc = await UserReadDocument.get(query.user_id)
380
+
381
+ # O desde Redis:
382
+ # data = await redis_client.get(f"user:{query.user_id}")
383
+
384
+ if not doc:
385
+ raise UserNotFoundException()
386
+ return UserReadDTO(**doc.dict())
387
+ ```
388
+
389
+ Con este esquema, alcanzas una alta escalabilidad: tus endpoints GET son despachados por el `QueryBus` respondiendo en milisegundos desde Mongo/Redis, y tus operaciones POST/PUT/DELETE van por el `CommandBus` transaccionando con ACID en SQL.
390
+
391
+ #### 3. Definición de Modelos de Lectura (Proyecciones)
392
+
393
+ Una pregunta frecuente es: **¿HexCore genera automáticamente estos modelos de lectura?**
394
+ La respuesta es **No**. El patrón CQRS sugiere que tus modelos de lectura estén diseñados *específicamente* para lo que tus interfaces visuales (UI) o APIs van a consultar. Por lo tanto, debes definir estos modelos manualmente.
395
+
396
+ **Si usas MongoDB (Beanie) para lecturas:**
397
+ Debes crear un documento manual optimizado. Por ejemplo, en lugar de tener joins, puedes embeber datos:
398
+ ```python
399
+ from beanie import Document
400
+
401
+ # Modelo desnormalizado optimizado para la lectura
402
+ class UserReadDocument(Document):
403
+ id: str # ID referenciado de la tabla SQL
404
+ name: str
405
+ email: str
406
+ total_purchases_cache: int = 0 # Dato pre-calculado por eventos
407
+
408
+ class Settings:
409
+ name = "users_read_projections"
410
+ ```
411
+
412
+ **Si usas PostgreSQL/MySQL (SQLAlchemy) para lecturas:**
413
+ Si prefieres mantenerte 100% en SQL pero aislando lecturas, puedes crear tablas específicas para proyecciones (Materialized Views o tablas planas):
414
+ ```python
415
+ from sqlalchemy.orm import declarative_base
416
+ from sqlalchemy import Column, String, Integer
417
+
418
+ Base = declarative_base()
419
+
420
+ class UserReadProjection(Base):
421
+ __tablename__ = 'users_read_projection'
422
+
423
+ # Modelo totalmente plano sin relaciones ForeignKey complejas
424
+ id = Column(String, primary_key=True)
425
+ full_name = Column(String)
426
+ email = Column(String)
427
+ total_purchases_cache = Column(Integer, default=0)
428
+ ```
429
+ En ambos casos, es tu **EventBus** (o un consumidor como Procrastinate) el encargado de instanciar estos modelos manuales y persistirlos cada vez que se detecte un cambio en los modelos de escritura.
430
+
431
+ ---
432
+
245
433
  ## Referencias
246
434
 
247
435
  - [CONTRIBUTING.md](./CONTRIBUTING.md): Pautas de colaboración.
@@ -0,0 +1,24 @@
1
+ """
2
+ hexcore.application.cqrs — Capa de aplicación CQRS.
3
+ Registry, Pipeline, Buses In-Memory, Adaptadores y Factory.
4
+ """
5
+
6
+ from .registry import HandlerRegistry
7
+ from .config import CQRSConfig, BusConfig, MiddlewareConfig
8
+ from .pipeline import MiddlewarePipeline
9
+ from .in_memory_buses import InMemoryCommandBus, InMemoryQueryBus, InMemoryEventBus
10
+ from .adapters import UseCaseCommandHandler
11
+ from .factory import CQRSFactory
12
+
13
+ __all__ = [
14
+ "HandlerRegistry",
15
+ "CQRSConfig",
16
+ "BusConfig",
17
+ "MiddlewareConfig",
18
+ "MiddlewarePipeline",
19
+ "InMemoryCommandBus",
20
+ "InMemoryQueryBus",
21
+ "InMemoryEventBus",
22
+ "UseCaseCommandHandler",
23
+ "CQRSFactory",
24
+ ]
@@ -0,0 +1,33 @@
1
+ """
2
+ Adaptadores de compatibilidad entre UseCase existente y el sistema CQRS.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import typing as t
7
+
8
+ from hexcore.application.use_cases.base import UseCase
9
+ from hexcore.domain.cqrs.handlers import ICommandHandler
10
+ from hexcore.domain.cqrs.commands import Command
11
+
12
+
13
+ T = t.TypeVar("T", bound=Command)
14
+ R = t.TypeVar("R")
15
+
16
+
17
+ class UseCaseCommandHandler(ICommandHandler[T, R]):
18
+ """
19
+ Adaptador que envuelve un UseCase existente como un ICommandHandler.
20
+ Permite migrar progresivamente sin reescribir use cases.
21
+
22
+ Uso::
23
+
24
+ existing_use_case = CreateUserUseCase(repository)
25
+ handler = UseCaseCommandHandler(existing_use_case)
26
+ registry.register_command_handler(CreateUserCommand, handler)
27
+ """
28
+
29
+ def __init__(self, use_case: UseCase[t.Any, R]) -> None:
30
+ self._use_case = use_case
31
+
32
+ async def handle(self, command: T) -> R:
33
+ return await self._use_case.execute(command)
@@ -0,0 +1,69 @@
1
+ """
2
+ Configuración declarativa de CQRS integrada en ServerConfig.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import typing as t
7
+
8
+ from pydantic import BaseModel, ConfigDict, Field
9
+
10
+
11
+ class MiddlewareConfig(BaseModel):
12
+ """Configuración de un middleware individual."""
13
+
14
+ enabled: bool = True
15
+ order: int = 0 # Menor = se ejecuta primero
16
+ options: dict[str, t.Any] = Field(default_factory=dict)
17
+
18
+ model_config = ConfigDict(frozen=True)
19
+
20
+
21
+ class BusConfig(BaseModel):
22
+ """Configuración de un bus individual (command, query o event)."""
23
+
24
+ # Clase del bus a instanciar (dotted path o referencia directa)
25
+ # Si es None, usa el bus in-memory por defecto
26
+ backend: t.Optional[str] = None
27
+ middlewares: list[str] = Field(default_factory=list)
28
+ options: dict[str, t.Any] = Field(default_factory=dict)
29
+
30
+ model_config = ConfigDict(frozen=True)
31
+
32
+
33
+ class CQRSConfig(BaseModel):
34
+ """
35
+ Configuración global de CQRS para Hexcore.
36
+ Se integra como campo opcional en ServerConfig.
37
+
38
+ Ejemplo en config.py del proyecto::
39
+
40
+ from hexcore.config import ServerConfig
41
+ from hexcore.application.cqrs.config import CQRSConfig, BusConfig
42
+
43
+ config = ServerConfig(
44
+ cqrs=CQRSConfig(
45
+ command_bus=BusConfig(
46
+ backend="hexcore.infrastructure.cqrs.procrastinate.ProcrastinateCommandBus",
47
+ middlewares=[
48
+ "hexcore.infrastructure.cqrs.middlewares.LoggingMiddleware",
49
+ "hexcore.infrastructure.cqrs.middlewares.RetryMiddleware",
50
+ ],
51
+ options={"max_retries": 3},
52
+ ),
53
+ ),
54
+ )
55
+ """
56
+
57
+ enabled: bool = True
58
+ command_bus: BusConfig = Field(
59
+ default_factory=lambda: BusConfig(
60
+ middlewares=[
61
+ "hexcore.infrastructure.cqrs.middlewares.TransactionMiddleware"
62
+ ]
63
+ )
64
+ )
65
+ query_bus: BusConfig = Field(default_factory=BusConfig)
66
+ event_bus: BusConfig = Field(default_factory=BusConfig)
67
+ serializer: t.Optional[str] = None # None = PydanticSerializer por defecto
68
+
69
+ model_config = ConfigDict(arbitrary_types_allowed=True)
@@ -0,0 +1,131 @@
1
+ """
2
+ Factory para construir buses CQRS a partir de la configuración.
3
+ Resuelve backends, serializers y middlewares por dotted path.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import importlib
8
+ import typing as t
9
+
10
+ from hexcore.domain.cqrs.buses import ICommandBus, IQueryBus, IEventBus
11
+ from hexcore.domain.cqrs.middleware import IMiddleware
12
+ from hexcore.domain.cqrs.serializer import ISerializer
13
+
14
+ from .config import CQRSConfig, BusConfig
15
+ from .registry import HandlerRegistry
16
+ from .pipeline import MiddlewarePipeline
17
+ from .in_memory_buses import InMemoryCommandBus, InMemoryQueryBus, InMemoryEventBus
18
+
19
+
20
+ def _import_class(dotted_path: str) -> type:
21
+ """Importa una clase a partir de su dotted path."""
22
+ module_path, class_name = dotted_path.rsplit(".", 1)
23
+ module = importlib.import_module(module_path)
24
+ return getattr(module, class_name)
25
+
26
+
27
+ def _build_middlewares(dotted_paths: list[str]) -> list[IMiddleware]:
28
+ """Instancia middlewares a partir de sus dotted paths."""
29
+ middlewares: list[IMiddleware] = []
30
+ for path in dotted_paths:
31
+ cls = _import_class(path)
32
+ middlewares.append(cls())
33
+ return middlewares
34
+
35
+
36
+ def _build_pipeline(bus_config: BusConfig) -> MiddlewarePipeline:
37
+ """Construye un MiddlewarePipeline desde la configuración de un bus."""
38
+ middlewares = _build_middlewares(bus_config.middlewares)
39
+ return MiddlewarePipeline(middlewares)
40
+
41
+
42
+ class CQRSFactory:
43
+ """
44
+ Factory que construye las instancias de buses CQRS.
45
+
46
+ Usa CQRSConfig para determinar qué implementación de bus instanciar,
47
+ qué middlewares configurar y qué serializer utilizar.
48
+
49
+ Uso::
50
+
51
+ config = CQRSConfig(...)
52
+ registry = HandlerRegistry()
53
+ # ...registrar handlers...
54
+
55
+ factory = CQRSFactory(config, registry)
56
+ command_bus = factory.create_command_bus()
57
+ query_bus = factory.create_query_bus()
58
+ """
59
+
60
+ def __init__(
61
+ self,
62
+ config: CQRSConfig,
63
+ registry: HandlerRegistry,
64
+ ) -> None:
65
+ self._config = config
66
+ self._registry = registry
67
+
68
+ def create_serializer(self) -> ISerializer:
69
+ """Crea el serializer configurado (PydanticSerializer por defecto)."""
70
+ if self._config.serializer:
71
+ cls = _import_class(self._config.serializer)
72
+ return cls()
73
+ from hexcore.infrastructure.cqrs.pydantic_serializer import PydanticSerializer
74
+
75
+ return PydanticSerializer()
76
+
77
+ def create_command_bus(self, **extra_kwargs: t.Any) -> ICommandBus:
78
+ """
79
+ Crea el CommandBus configurado.
80
+ Si no hay backend explícito, retorna InMemoryCommandBus.
81
+ """
82
+ bus_config = self._config.command_bus
83
+ pipeline = _build_pipeline(bus_config)
84
+
85
+ if bus_config.backend is None:
86
+ return InMemoryCommandBus(
87
+ registry=self._registry,
88
+ pipeline=pipeline,
89
+ )
90
+
91
+ # Backend personalizado (ej. ProcrastinateCommandBus)
92
+ cls = _import_class(bus_config.backend)
93
+ return cls(
94
+ registry=self._registry,
95
+ serializer=self.create_serializer(),
96
+ pipeline=pipeline,
97
+ **bus_config.options,
98
+ **extra_kwargs,
99
+ )
100
+
101
+ def create_query_bus(self) -> IQueryBus:
102
+ """
103
+ Crea el QueryBus configurado.
104
+ Nota: Las queries siempre son síncronas en CQRS puro.
105
+ """
106
+ bus_config = self._config.query_bus
107
+ pipeline = _build_pipeline(bus_config)
108
+
109
+ if bus_config.backend is None:
110
+ return InMemoryQueryBus(
111
+ registry=self._registry,
112
+ pipeline=pipeline,
113
+ )
114
+
115
+ cls = _import_class(bus_config.backend)
116
+ return cls(
117
+ registry=self._registry,
118
+ pipeline=pipeline,
119
+ **bus_config.options,
120
+ )
121
+
122
+ def create_event_bus(self) -> IEventBus:
123
+ """Crea el EventBus configurado."""
124
+ bus_config = self._config.event_bus
125
+ pipeline = _build_pipeline(bus_config)
126
+
127
+ if bus_config.backend is None:
128
+ return InMemoryEventBus(pipeline=pipeline)
129
+
130
+ cls = _import_class(bus_config.backend)
131
+ return cls(pipeline=pipeline, **bus_config.options)