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.
- {hexcore-2.0.6/hexcore.egg-info → hexcore-2.1.0}/PKG-INFO +191 -1
- {hexcore-2.0.6 → hexcore-2.1.0}/README.md +188 -0
- hexcore-2.1.0/hexcore/application/cqrs/__init__.py +24 -0
- hexcore-2.1.0/hexcore/application/cqrs/adapters.py +33 -0
- hexcore-2.1.0/hexcore/application/cqrs/config.py +69 -0
- hexcore-2.1.0/hexcore/application/cqrs/factory.py +131 -0
- hexcore-2.1.0/hexcore/application/cqrs/in_memory_buses.py +99 -0
- hexcore-2.1.0/hexcore/application/cqrs/pipeline.py +58 -0
- hexcore-2.1.0/hexcore/application/cqrs/registry.py +115 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/config.py +38 -5
- hexcore-2.1.0/hexcore/domain/cqrs/__init__.py +50 -0
- hexcore-2.1.0/hexcore/domain/cqrs/buses.py +80 -0
- hexcore-2.1.0/hexcore/domain/cqrs/commands.py +30 -0
- hexcore-2.1.0/hexcore/domain/cqrs/exceptions.py +34 -0
- hexcore-2.1.0/hexcore/domain/cqrs/handlers.py +52 -0
- hexcore-2.1.0/hexcore/domain/cqrs/middleware.py +51 -0
- hexcore-2.1.0/hexcore/domain/cqrs/queries.py +27 -0
- hexcore-2.1.0/hexcore/domain/cqrs/serializer.py +39 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/domain/events.py +36 -4
- {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/domain/services.py +3 -3
- {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/domain/uow.py +1 -1
- hexcore-2.1.0/hexcore/infrastructure/cqrs/__init__.py +24 -0
- hexcore-2.1.0/hexcore/infrastructure/cqrs/middlewares.py +135 -0
- hexcore-2.1.0/hexcore/infrastructure/cqrs/procrastinate.py +99 -0
- hexcore-2.1.0/hexcore/infrastructure/cqrs/pydantic_serializer.py +56 -0
- hexcore-2.1.0/hexcore/infrastructure/events/events_backends/memory.py +32 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/infrastructure/uow/__init__.py +4 -4
- {hexcore-2.0.6 → hexcore-2.1.0/hexcore.egg-info}/PKG-INFO +191 -1
- {hexcore-2.0.6 → hexcore-2.1.0}/hexcore.egg-info/SOURCES.txt +20 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/hexcore.egg-info/requires.txt +3 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/pyproject.toml +4 -1
- hexcore-2.1.0/tests/test_cqrs.py +602 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/tests/test_uow_session_regression.py +6 -6
- hexcore-2.0.6/hexcore/infrastructure/events/events_backends/memory.py +0 -20
- {hexcore-2.0.6 → hexcore-2.1.0}/LICENSE +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/MANIFEST.in +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/__init__.py +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/__main__.py +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/application/__init__.py +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/application/dtos/__init__.py +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/application/dtos/base.py +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/application/dtos/query.py +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/application/use_cases/__init__.py +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/application/use_cases/base.py +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/application/use_cases/query.py +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/domain/__init__.py +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/domain/auth/__init__.py +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/domain/auth/permissions.py +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/domain/auth/value_objects.py +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/domain/base.py +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/domain/exceptions.py +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/domain/repositories.py +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/infrastructure/__init__.py +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/infrastructure/api/__init__.py +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/infrastructure/api/utils.py +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/infrastructure/cache/__init__.py +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/infrastructure/cache/cache_backends/__init__.py +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/infrastructure/cache/cache_backends/memory.py +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/infrastructure/cache/cache_backends/redis.py +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/infrastructure/cli.py +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/infrastructure/events/__init__.py +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/infrastructure/events/events_backends/__init__.py +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/infrastructure/repositories/__init__.py +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/infrastructure/repositories/base.py +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/infrastructure/repositories/decorators.py +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/infrastructure/repositories/implementations.py +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/infrastructure/repositories/orms/__init__.py +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/infrastructure/repositories/orms/beanie/__init__.py +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/infrastructure/repositories/orms/beanie/utils.py +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/__init__.py +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/session.py +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/utils.py +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/infrastructure/repositories/utils.py +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/infrastructure/uow/decorators.py +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/infrastructure/uow/helpers.py +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/py.typed +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/hexcore/types.py +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/hexcore.egg-info/dependency_links.txt +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/hexcore.egg-info/entry_points.txt +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/hexcore.egg-info/top_level.txt +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/scripts/__init__.py +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/scripts/main.py +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/setup.cfg +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/tests/conftest.py +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/tests/test_basic.py +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/tests/test_beanie_query_utils.py +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/tests/test_config_loading.py +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/tests/test_domain_service_query.py +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/tests/test_infrastructure_query_path.py +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/tests/test_query_field_validation.py +0 -0
- {hexcore-2.0.6 → hexcore-2.1.0}/tests/test_repositories_utils.py +0 -0
- {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
|
|
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 [](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)
|