diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 93e8478b..d9b01843 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -75,12 +75,12 @@ jobs:
run: |
set -euo pipefail
if [ "${USE_NOTES_FILE}" = "1" ]; then
- gh release create "$TAG_NAME" dist/* \
+ gh release create "$TAG_NAME" dist/* book/dist/*.epub book/dist/*.pdf \
--repo "$REPO" \
--title "$TAG_NAME" \
--notes-file RELEASE_NOTES.md
else
- gh release create "$TAG_NAME" dist/* \
+ gh release create "$TAG_NAME" dist/* book/dist/*.epub book/dist/*.pdf \
--repo "$REPO" \
--title "$TAG_NAME" \
--generate-notes
diff --git a/.gitignore b/.gitignore
index c60eeed6..a183ad8f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -41,3 +41,5 @@ docs/superpowers/
book/dist/**
!book/dist/pyfly-by-example.pdf
!book/dist/pyfly-by-example.epub
+!book/dist/pyfly-by-example-es.pdf
+!book/dist/pyfly-by-example-es.epub
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f771d7f5..b1caf700 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,31 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
---
+## v26.06.111 (2026-06-16)
+
+### Added
+
+- **"PyFly by Example" — Spanish edition.** The book is now published in Spanish
+ (`book/dist/pyfly-by-example-es.{epub,pdf}`) alongside the English edition,
+ built from a parallel `book/manuscript-es/` manuscript via `book.es.yaml`. The
+ book build (`book/build/build.py`) is now language-parameterized (`--config`,
+ per-manifest `manuscript_dir` / `output_basename` / localized labels). Both
+ editions are attached to the GitHub release.
+- **Quick Start tutorial + step-by-step depth.** A new "Build Lumen Step by Step"
+ walkthrough takes the reader from an empty folder to a running, tested wallet
+ feature; every chapter was deepened into a more granular, beginner-friendly
+ tutorial. A dedication was added (EN + ES).
+
+### Fixed
+
+- **`pyfly new` no longer scaffolds the removed `pyfly.web.port` key.** The project
+ template (`pyfly.yaml.j2`) now emits the port under `server:` as
+ `pyfly.server.port` (Spring `server.port` parity); the legacy `pyfly.web.port`
+ was removed in v26.06.102 and had been left as a dead key in freshly scaffolded
+ applications.
+
+---
+
## v26.06.110 (2026-06-16)
### Fixed
diff --git a/book/book.es.yaml b/book/book.es.yaml
new file mode 100644
index 00000000..af871378
--- /dev/null
+++ b/book/book.es.yaml
@@ -0,0 +1,61 @@
+title: "PyFly by Example"
+subtitle: "Microservicios Python Orientados a Eventos con el Firefly Framework"
+author: "Firefly Software Foundation"
+publisher: "Firefly Software Foundation"
+language: "es"
+identifier: "urn:uuid:5e2d9b41-7a3c-4f8e-b1d6-c0ffeeab1e5e"
+rights: "Copyright (c) 2026 Firefly Software Foundation. Bajo licencia Apache-2.0."
+cover_svg: "art/cover.svg"
+cover_png: "art/cover.png"
+trim_width: "7.5in"
+trim_height: "9.25in"
+manuscript_dir: "manuscript-es"
+output_basename: "pyfly-by-example-es"
+labels:
+ contents: "Contenido"
+front:
+ - {id: title, file: 00-front/00-title.md, nav: false}
+ - {id: copyright, file: 00-front/00-copyright.md, nav: false}
+ - {id: dedication, file: 00-front/00-dedication.md, nav: false}
+ - {id: preface, file: 00-front/00-preface.md, title: "Prefacio"}
+ - {id: conventions, file: 00-front/00-conventions.md, title: "Convenciones"}
+parts:
+ - title: "Inicio Rápido"
+ chapters:
+ - {id: quickstart, file: 00-quickstart.md, num: "", title: "Construye Lumen Paso a Paso", opener: art/openers/ch01.svg}
+ - title: "Parte I — Fundamentos"
+ chapters:
+ - {id: ch01, file: 01-why-pyfly.md, num: 1, title: "¿Por qué PyFly?", opener: art/openers/ch01.svg}
+ - {id: ch02, file: 02-dependency-injection.md, num: 2, title: "Inyección de Dependencias y el Contexto de Aplicación", opener: art/openers/ch02.svg}
+ - {id: ch03, file: 03-configuration.md, num: 3, title: "Configuración, Perfiles y Secretos", opener: art/openers/ch03.svg}
+ - {id: ch04, file: 04-first-http-api.md, num: 4, title: "Tu Primera API HTTP", opener: art/openers/ch04.svg}
+ - title: "Parte II — Modelar y Persistir el Dominio"
+ chapters:
+ - {id: ch05, file: 05-persistence.md, num: 5, title: "Persistencia y el Patrón Repositorio", opener: art/openers/ch05.svg}
+ - {id: ch06, file: 06-domain-driven-design.md, num: 6, title: "Diseño Guiado por el Dominio", opener: art/openers/ch06.svg}
+ - {id: ch07, file: 07-cqrs.md, num: 7, title: "CQRS: Comandos y Consultas", opener: art/openers/ch07.svg}
+ - title: "Parte III — Arquitectura Orientada a Eventos"
+ chapters:
+ - {id: ch08, file: 08-eda.md, num: 8, title: "Eventos de Dominio y Arquitectura Orientada a Eventos", opener: art/openers/ch08.svg}
+ - {id: ch09, file: 09-event-sourcing.md, num: 9, title: "Event Sourcing del Libro Mayor", opener: art/openers/ch09.svg}
+ - {id: ch10, file: 10-messaging.md, num: 10, title: "Mensajería con Kafka y RabbitMQ", opener: art/openers/ch10.svg}
+ - title: "Parte IV — Hacia los Microservicios"
+ chapters:
+ - {id: ch11, file: 11-http-clients.md, num: 11, title: "Dividir el Monolito: Clientes HTTP y el BFF", opener: art/openers/ch11.svg}
+ - {id: ch12, file: 12-sagas.md, num: 12, title: "Transacciones Distribuidas: Sagas, Workflows y TCC", opener: art/openers/ch12.svg}
+ - {id: ch13, file: 13-caching-resilience.md, num: 13, title: "Caché y Resiliencia", opener: art/openers/ch13.svg}
+ - title: "Parte V — Asegurar, Observar y Desplegar"
+ chapters:
+ - {id: ch14, file: 14-security.md, num: 14, title: "Seguridad, Sesiones e Identidad", opener: art/openers/ch14.svg}
+ - {id: ch15, file: 15-observability.md, num: 15, title: "Observabilidad y el Panel de Administración", opener: art/openers/ch15.svg}
+ - {id: ch16, file: 16-testing.md, num: 16, title: "Pruebas de Aplicaciones PyFly", opener: art/openers/ch16.svg}
+ - {id: ch17, file: 17-scheduling-notifications.md, num: 17, title: "Programación, Notificaciones, Webhooks y Callbacks", opener: art/openers/ch17.svg}
+ - {id: ch18, file: 18-production.md, num: 18, title: "Extender PyFly y Llevarlo a Producción", opener: art/openers/ch18.svg}
+ - title: "Apéndices"
+ chapters:
+ - {id: appa, file: 90-appendix-a-spring.md, num: "A", title: "Chuleta de Spring Boot → PyFly"}
+ - {id: appb, file: 91-appendix-b-mongodb.md, num: "B", title: "MongoDB y Datos Documentales"}
+ - {id: appc, file: 92-appendix-c-ecm.md, num: "C", title: "ECM: Contenido y Firma Electrónica"}
+ - {id: appd, file: 93-appendix-d-cli.md, num: "D", title: "CLI y Resolución de Problemas"}
+ - {id: glossary, file: 94-glossary.md, num: "", title: "Glosario"}
+back: []
diff --git a/book/book.yaml b/book/book.yaml
index b5dd6a16..4ad15dc1 100644
--- a/book/book.yaml
+++ b/book/book.yaml
@@ -9,12 +9,20 @@ cover_svg: "art/cover.svg"
cover_png: "art/cover.png"
trim_width: "7.5in"
trim_height: "9.25in"
+manuscript_dir: "manuscript"
+output_basename: "pyfly-by-example"
+labels:
+ contents: "Contents"
front:
- {id: title, file: 00-front/00-title.md, nav: false}
- {id: copyright, file: 00-front/00-copyright.md, nav: false}
+ - {id: dedication, file: 00-front/00-dedication.md, nav: false}
- {id: preface, file: 00-front/00-preface.md, title: "Preface"}
- {id: conventions, file: 00-front/00-conventions.md, title: "Conventions"}
parts:
+ - title: "Quick Start"
+ chapters:
+ - {id: quickstart, file: 00-quickstart.md, num: "", title: "Build Lumen Step by Step", opener: art/openers/ch01.svg}
- title: "Part I — Foundations"
chapters:
- {id: ch01, file: 01-why-pyfly.md, num: 1, title: "Why PyFly?", opener: art/openers/ch01.svg}
diff --git a/book/build/build.py b/book/build/build.py
index dbce5828..9d9a5d60 100644
--- a/book/build/build.py
+++ b/book/build/build.py
@@ -1,5 +1,14 @@
-"""Build *PyFly by Example* into EPUB + PDF from book.yaml."""
+"""Build *PyFly by Example* into EPUB + PDF from a book manifest.
+
+Defaults to ``book.yaml`` (English). Pass ``--config book.es.yaml`` to build the
+Spanish edition; each manifest names its own ``manuscript_dir``, ``language``,
+localized ``labels`` (e.g. the Contents heading) and ``output_basename``.
+
+ book/build/run.sh # English -> pyfly-by-example.{epub,pdf}
+ book/build/run.sh --config book.es.yaml # Spanish -> pyfly-by-example-es.{epub,pdf}
+"""
from __future__ import annotations
+import argparse
import re
import sys
from pathlib import Path
@@ -12,7 +21,6 @@
from pdf import render_pdf # noqa: E402
BOOK = Path(__file__).resolve().parents[1]
-MAN = BOOK / "manuscript"
THEME = BOOK / "theme"
DIST = BOOK / "dist"
@@ -29,7 +37,7 @@ def _split_part(part_title: str) -> tuple[str, str]:
return "", part_title.strip()
-def _items_from_manifest(cfg: dict) -> list[dict]:
+def _items_from_manifest(cfg: dict, man: Path, *, contents_label: str) -> list[dict]:
"""Ordered build items, each tagged ``kind`` and carrying the metadata that
kind needs. Consumed by BOTH the EPUB and PDF assemblers.
@@ -42,7 +50,7 @@ def _items_from_manifest(cfg: dict) -> list[dict]:
items: list[dict] = []
# 1) front matter
for fm in cfg.get("front", []):
- p = MAN / fm["file"]
+ p = man / fm["file"]
if not p.exists():
continue
items.append({
@@ -53,12 +61,12 @@ def _items_from_manifest(cfg: dict) -> list[dict]:
"in_nav": bool(fm.get("nav", True)) and "title" in fm,
})
# 2) Contents page — after front matter, before Part I
- items.append({"kind": "toc", "id": "toc", "title": "Contents"})
+ items.append({"kind": "toc", "id": "toc", "title": contents_label})
# 3) parts: a divider then each chapter
for part in cfg.get("parts", []):
ptitle_full = part["title"]
eyebrow, ptitle = _split_part(ptitle_full)
- chapters = [ch for ch in part["chapters"] if (MAN / ch["file"]).exists()]
+ chapters = [ch for ch in part["chapters"] if (man / ch["file"]).exists()]
if not chapters:
continue
# stable divider id from the eyebrow, e.g. "Part I" -> "part-i"
@@ -77,13 +85,13 @@ def _items_from_manifest(cfg: dict) -> list[dict]:
"id": ch["id"],
"title": (f'{ch["num"]}. {ch["title"]}' if ch.get("num") not in (None, "") else ch["title"]),
"num": ch["num"],
- "path": str(MAN / ch["file"]),
+ "path": str(man / ch["file"]),
"part": ptitle_full,
})
return items
-def _toc_html(items: list[dict], *, href_fmt: str) -> str:
+def _toc_html(items: list[dict], *, href_fmt: str, label: str) -> str:
"""Generate the Contents body. ``href_fmt`` formats a chapter id into a link
target: '#{cid}' for the single-document PDF, '{cid}.xhtml' for the EPUB."""
parts: list[str] = []
@@ -107,7 +115,7 @@ def _toc_html(items: list[dict], *, href_fmt: str) -> str:
if open_group:
parts.append("")
body = "".join(parts)
- return f'
Contents
{body}'
+ return f'{escape(label)}
{body}'
def _divider_html(eyebrow: str, ptitle: str) -> str:
@@ -116,11 +124,22 @@ def _divider_html(eyebrow: str, ptitle: str) -> str:
f'{escape(ptitle)}
')
-def main() -> int:
- cfg = yaml.safe_load((BOOK / "book.yaml").read_text())
+def main(argv: list[str] | None = None) -> int:
+ ap = argparse.ArgumentParser(description="Build PyFly by Example (EPUB + PDF).")
+ ap.add_argument("--config", default="book.yaml",
+ help="Manifest file under book/ (default: book.yaml).")
+ ap.add_argument("--out", default=None,
+ help="Output basename (default: manifest 'output_basename' or 'pyfly-by-example').")
+ args = ap.parse_args(argv)
+
+ cfg = yaml.safe_load((BOOK / args.config).read_text())
+ man = BOOK / cfg.get("manuscript_dir", "manuscript")
+ contents_label = cfg.get("labels", {}).get("contents", "Contents")
+ out_base = args.out or cfg.get("output_basename") or "pyfly-by-example"
+
css_text = [(THEME / "book.css").read_text(), (THEME / "tokens.css").read_text(),
(THEME / "pygments.css").read_text()]
- items = _items_from_manifest(cfg)
+ items = _items_from_manifest(cfg, man, contents_label=contents_label)
# ---- EPUB ----
epub = EpubBuilder(title=cfg["title"], author=cfg["author"], language=cfg["language"],
@@ -130,7 +149,7 @@ def main() -> int:
epub.add_file(cover_png, "art/cover.png", "cover-img", properties="cover-image")
for it in items:
if it["kind"] == "toc":
- body = _toc_html(items, href_fmt="{cid}.xhtml")
+ body = _toc_html(items, href_fmt="{cid}.xhtml", label=contents_label)
epub.add_doc(Doc(id=it["id"], title=it["title"], xhtml_body=body,
in_nav=True, kind="toc"))
elif it["kind"] == "divider":
@@ -143,7 +162,7 @@ def main() -> int:
in_nav=it.get("in_nav", True), kind=it["kind"],
part=it.get("part"), num=it.get("num")))
DIST.mkdir(exist_ok=True)
- epub.build(DIST / "pyfly-by-example.epub")
+ epub.build(DIST / f"{out_base}.epub")
# ---- PDF (single concatenated document) ----
parts_html: list[str] = []
@@ -151,7 +170,7 @@ def main() -> int:
parts_html.append(f'
')
for it in items:
if it["kind"] == "toc":
- body = _toc_html(items, href_fmt="#{cid}")
+ body = _toc_html(items, href_fmt="#{cid}", label=contents_label)
parts_html.append(f'')
elif it["kind"] == "divider":
body = _divider_html(it["eyebrow"], it["ptitle"])
@@ -164,10 +183,11 @@ def main() -> int:
render_pdf(full, base_url=BOOK,
css_paths=[THEME / "tokens.css", THEME / "pygments.css",
THEME / "book.css", THEME / "print.css"],
- out=DIST / "pyfly-by-example.pdf")
+ out=DIST / f"{out_base}.pdf")
n = sum(1 for it in items if it["kind"] in ("front", "chapter"))
- print(f"Built {n} document(s) + TOC + {sum(1 for it in items if it['kind']=='divider')} "
- f"part divider(s) -> EPUB + PDF in {DIST}")
+ print(f"[{cfg['language']}] Built {n} document(s) + TOC + "
+ f"{sum(1 for it in items if it['kind']=='divider')} part divider(s) "
+ f"-> {out_base}.epub + {out_base}.pdf in {DIST}")
return 0
diff --git a/book/dist/pyfly-by-example-es.epub b/book/dist/pyfly-by-example-es.epub
new file mode 100644
index 00000000..a12314a3
Binary files /dev/null and b/book/dist/pyfly-by-example-es.epub differ
diff --git a/book/dist/pyfly-by-example-es.pdf b/book/dist/pyfly-by-example-es.pdf
new file mode 100644
index 00000000..73adcd20
Binary files /dev/null and b/book/dist/pyfly-by-example-es.pdf differ
diff --git a/book/dist/pyfly-by-example.epub b/book/dist/pyfly-by-example.epub
index 8729d71b..663ec5f0 100644
Binary files a/book/dist/pyfly-by-example.epub and b/book/dist/pyfly-by-example.epub differ
diff --git a/book/dist/pyfly-by-example.pdf b/book/dist/pyfly-by-example.pdf
index 623926ce..e3b63631 100644
Binary files a/book/dist/pyfly-by-example.pdf and b/book/dist/pyfly-by-example.pdf differ
diff --git a/book/manuscript-es/00-front/00-conventions.md b/book/manuscript-es/00-front/00-conventions.md
new file mode 100644
index 00000000..2a1f272c
--- /dev/null
+++ b/book/manuscript-es/00-front/00-conventions.md
@@ -0,0 +1,46 @@
+## Convenciones
+
+Esta página explica las convenciones tipográficas y estructurales que se usan a lo largo del libro.
+
+### Listados de código
+
+Cada ejemplo de código de varias líneas tiene una **pestaña con el nombre del archivo** en la esquina superior izquierda que muestra el archivo al que pertenece, y un pie **"Listado N.N"** debajo del bloque que lo identifica por capítulo y número de secuencia. Por ejemplo:
+
+::: listing wallet/domain/wallet.py | Listado 5.1 — Raíz del agregado Wallet
+from pyfly.core import component
+from dataclasses import dataclass, field
+from decimal import Decimal
+
+@component
+@dataclass
+class Wallet:
+ id: str
+ balance: Decimal = field(default=Decimal("0.00"))
+
+ def deposit(self, amount: Decimal) -> None:
+ if amount <= 0:
+ raise ValueError("Deposit amount must be positive")
+ self.balance += amount
+:::
+
+Las referencias de código en línea dentro de la prosa usan fuente `monoespaciada`, como en "el decorador `@component` registra la clase en el contenedor de PyFly".
+
+### Notas al margen
+
+En los márgenes y en el cuerpo aparecen cuatro estilos de notas al margen:
+
+!!! note "Nota"
+ Las notas aportan contexto complementario o aclaran una sutileza del texto principal: merece la pena leerlas, pero no son bloqueantes.
+
+!!! tip "Consejo"
+ Los consejos comparten un atajo, un idiom o una buena práctica que te ahorrará tiempo en proyectos reales.
+
+!!! warning "Advertencia"
+ Las advertencias señalan un error habitual o un punto delicado que puede provocar problemas difíciles de depurar si se ignora.
+
+!!! spring "Equivalencia con Spring"
+ Las notas de equivalencia con Spring relacionan un concepto de PyFly directamente con su equivalente en Spring Boot: ideales para quienes migran desde el ecosistema de la JVM.
+
+### Figuras
+
+Los diagramas se numeran como **Figura N.N** y llevan un pie debajo de la imagen. Se incrustan como SVG en línea, de modo que se renderizan con nitidez a cualquier nivel de zoom tanto en la edición en pantalla como en la impresa. Te encontrarás con la primera en la página inicial del Capítulo 1.
diff --git a/book/manuscript-es/00-front/00-copyright.md b/book/manuscript-es/00-front/00-copyright.md
new file mode 100644
index 00000000..a6c31724
--- /dev/null
+++ b/book/manuscript-es/00-front/00-copyright.md
@@ -0,0 +1,13 @@
+Copyright © 2026 Firefly Software Foundation.
+
+Publicado bajo la Licencia Apache, Versión 2.0 (la "Licencia"); no puedes utilizar este material salvo en cumplimiento de la Licencia. Puedes obtener una copia de la Licencia en .
+
+Salvo que lo exija la legislación aplicable o se acuerde por escrito, el software y la documentación distribuidos bajo la Licencia se distribuyen "TAL CUAL", SIN GARANTÍAS NI CONDICIONES DE NINGÚN TIPO, ya sean expresas o implícitas. Consulta la Licencia para conocer el régimen específico de permisos y limitaciones que la rigen.
+
+---
+
+**Primera edición, 2026.**
+
+Todos los listados de código de este libro se escribieron y verificaron con la versión 26.6.x del framework PyFly.
+
+Publicado por la Firefly Software Foundation.
diff --git a/book/manuscript-es/00-front/00-dedication.md b/book/manuscript-es/00-front/00-dedication.md
new file mode 100644
index 00000000..a6acca09
--- /dev/null
+++ b/book/manuscript-es/00-front/00-dedication.md
@@ -0,0 +1,7 @@
+*Para **Jacinto Arias** y el equipo de **Taidy** —*
+
+*por empujarnos, una y otra vez, a construir un framework de verdad para Python.*
+
+*Gracias a vosotros, ahora por fin sí se harán las cosas de una única forma.*
+
+*Una. Sola. Forma. (Le damos una semana.)*
diff --git a/book/manuscript-es/00-front/00-preface.md b/book/manuscript-es/00-front/00-preface.md
new file mode 100644
index 00000000..d11b04f3
--- /dev/null
+++ b/book/manuscript-es/00-front/00-preface.md
@@ -0,0 +1,42 @@
+## Prefacio
+
+El Python empresarial ha significado durante mucho tiempo ensamblar una docena de bibliotecas independientes —una para inyección de dependencias, otra para enrutamiento, otra más para acceso asíncrono a la base de datos— sin un idioma común que las una. **PyFly** cambia eso. Ofrece la experiencia cohesionada de convención sobre configuración que Spring Boot dio al mundo Java, reconstruida desde cero para Python 3.12+ y `async`/`await`.
+
+Este libro enseña PyFly **haciendo**. Construyes una aplicación real desde una carpeta vacía hasta un servicio seguro, observable y orientado a eventos, haciendo concreto cada concepto antes de pasar al siguiente. Y lo más importante: el código de estas páginas no es pseudocódigo ilustrativo, sino que está tomado de un **proyecto real que compila, arranca y supera sus pruebas** con PyFly v26.6.110. Cada listado se verificó contra el ejemplo en ejecución, de modo que lo que lees es lo que realmente funciona.
+
+### Para quién es este libro
+
+Este libro es para desarrolladores de Python de nivel intermedio que se sienten cómodos con `async`/`await`, las anotaciones de tipos y los fundamentos de los servicios HTTP. No necesitas experiencia previa con frameworks: si has construido algo con FastAPI, Flask o SQLAlchemy, estás bien preparado.
+
+Los desarrolladores de Spring Boot se sentirán especialmente como en casa. Allí donde PyFly refleja un concepto de Spring —beans, estereotipos, transacciones declarativas, eventos de aplicación— una llamada de **Equivalencia con Spring** traza el paralelismo de forma explícita, para que mapees lo que ya sabes en lugar de aprender desde cero.
+
+### Lo que vas a construir
+
+Cada capítulo hace avanzar **Lumen**, un servicio de monedero (wallet) digital y libro mayor. El recorrido sigue un arco deliberado, una parte cada vez:
+
+- **Inicio rápido — Construye Lumen paso a paso.** Antes de la inmersión profunda, un único recorrido guiado te lleva desde una carpeta vacía hasta una funcionalidad de monedero en ejecución y probada —abrir un monedero, depositar, leer el saldo por HTTP— para que veas la forma completa de una aplicación PyFly antes de centrarte en cualquier parte concreta. Cada capítulo posterior expande luego una porción de lo que construiste aquí.
+- **Parte I — Fundamentos (Capítulos 1–4).** Generas el andamiaje del primer servicio Lumen con `pyfly new`, lo ejecutas bajo un servidor ASGI, conectas el contenedor de inyección de dependencias de PyFly, vinculas configuración tipada y perfiles, y expones tus primeros endpoints REST validados.
+- **Parte II — Modelar y persistir (Capítulos 5–7).** Introduces el patrón repositorio sobre un puerto, persistes monederos con SQLAlchemy asíncrono (SQLite, sin necesidad de infraestructura), modelas el dominio con un objeto de valor `Money` y una raíz de agregado `Wallet`, y separas las lecturas de las escrituras con manejadores de comando y consulta de CQRS despachados a través de un bus.
+- **Parte III — Orientada a eventos (Capítulos 8–10).** El agregado emite eventos de dominio; un escuchador los proyecta; un **libro mayor con event sourcing** (un patrón en el que el estado se reconstruye a partir de un flujo de eventos) reconstruye cada saldo reproduciendo su flujo de eventos; y esos mismos eventos fluyen hacia Kafka o RabbitMQ para otros servicios.
+- **Parte IV — Hacia los microservicios (Capítulos 11–13).** Lumen va más allá de su propio proceso: un cliente HTTP tipado llama a un servicio externo de Pagos, una **saga de transferencia** orquestada mueve dinero entre monederos y *compensa* cuando un paso falla, y los patrones de caché y resiliencia mantienen el sistema rápido y tolerante a fallos.
+- **Parte V — Asegurar · Observar · Publicar (Capítulos 14–18).** Aseguras los endpoints con JWT y `@secure`, haces el servicio observable con métricas, trazas, comprobaciones de salud y el panel de administración, pruebas toda la pila, lo conectas con el mundo exterior mediante programación, notificaciones y webhooks, y finalmente lo extiendes y lo despliegas a producción.
+
+Para la última página tendrás un servicio funcional, probado, observable y seguro, y el modelo mental para extenderlo.
+
+### Cómo usar este libro
+
+**Lee de forma secuencial.** Cada capítulo se apoya en el anterior, y la base de código de Lumen crece de forma incremental; saltarte capítulos deja huecos.
+
+**Escribe tú mismo cada listado.** Leer y teclear el código a la vez es la manera de que los patrones se asienten. Resiste la tentación de copiar y pegar hasta que hayas escrito cada listado al menos una vez.
+
+**Ejecútalo.** Lumen funciona de verdad: `uv run pyfly run` arranca el servicio y `uv run --extra dev pytest` lo ejercita. Cada vez que un capítulo añada una funcionalidad, arranca la aplicación o las pruebas y míralo funcionar. Ver JSON real que vuelve de un endpoint real vale por cien diagramas.
+
+Cada capítulo cierra con un **Resumen** de lo que cambió en la base de código de Lumen y un conjunto de **Ejercicios** que dan un paso más allá. Los ejercicios son opcionales, pero recomendables para cualquier cosa que pretendas aplicar de inmediato.
+
+### Convenciones en breve
+
+Las convenciones tipográficas y estructurales —los pies de los listados de código, los tipos de llamada y la numeración de las figuras— se demuestran, con ejemplos en vivo, en la sección **Convenciones** que viene a continuación.
+
+### El código de acompañamiento
+
+El proyecto Lumen completo y ejecutable vive en el directorio `samples/lumen` del framework. Es un único proyecto PyFly por capas —`interfaces`, `models`, `core`, `web`— que haces crecer capítulo a capítulo; el código terminado que hay allí es el destino al que este libro te conduce. Configúralo una sola vez con `uv sync` y úsalo para comparar tu trabajo, ponerte al día si te quedas atrás o, simplemente, ejecutar las partes sobre las que estás leyendo.
diff --git a/book/manuscript-es/00-front/00-title.md b/book/manuscript-es/00-front/00-title.md
new file mode 100644
index 00000000..dd14cdd9
--- /dev/null
+++ b/book/manuscript-es/00-front/00-title.md
@@ -0,0 +1,5 @@
+# PyFly by Example {.chtitle}
+
+### Microservicios Python orientados a eventos con el framework Firefly
+
+**Firefly Software Foundation**
diff --git a/book/manuscript-es/00-quickstart.md b/book/manuscript-es/00-quickstart.md
new file mode 100644
index 00000000..74965081
--- /dev/null
+++ b/book/manuscript-es/00-quickstart.md
@@ -0,0 +1,965 @@
+Inicio rápido
+
+# Construye Lumen paso a paso {.chtitle}
+
+::: figure art/openers/ch01.svg |
+
+Bienvenido. Esto es lo primerísimo que vas a construir con PyFly, y lo vamos a hacer con calma. Al final de este capítulo habrás pasado de una *carpeta vacía* a una porción *en ejecución y probada* de un servicio real de monedero digital: abrir un monedero, persistirlo en una base de datos, leer su saldo de vuelta por HTTP y reaccionar a un evento de dominio. Cada concepto recibe un fragmento de código pequeño y completo y un punto de control "Ejecútalo" para que lo veas funcionar antes de seguir adelante.
+
+Esto es un *recorrido*, no la inmersión profunda. Cada paso adelanta un tema que la Parte I y la Parte II tratan a fondo más tarde. El objetivo aquí es ganar impulso: para cuando llegues al Capítulo 1 ya habrás conocido la inyección de dependencias, la configuración, HTTP, la persistencia, CQRS y los eventos —en pequeño— y el resto del libro completará el *porqué*.
+
+La aplicación que construyes se llama **Lumen**: un monedero digital con sabor a DDD. Un monedero puede abrirse, recibir depósitos y permitir retiradas, protegiendo una regla central —**el saldo nunca se vuelve negativo**— y modelando el dinero con aritmética entera exacta para que nunca haya desviaciones de coma flotante. Es la misma aplicación que construye el libro entero, así que nada de lo que aprendas aquí es desechable.
+
+!!! note "Nota"
+ Este capítulo está escrito sobre PyFly **v26.6.110**. Cada listado está tomado del proyecto real y en ejecución `samples/lumen` que acompaña al libro: el código compila, arranca y pasa sus pruebas. Reconocerás estos mismos archivos de nuevo, con más profundidad, en capítulos posteriores.
+
+---
+
+## Paso 1 — Requisitos previos e instalación
+
+PyFly es un framework de Python, y la forma más fluida de trabajar con él es a través de [**uv**](https://docs.astral.sh/uv/), el rápido gestor de paquetes y proyectos de Python de Astral. uv se encarga de tu versión de Python, de tu entorno virtual y de tus dependencias en una sola herramienta, y la herramienta de línea de comandos `pyfly` se ejecuta a través de él.
+
+Necesitas dos cosas:
+
+* **Python 3.12 o más reciente.** PyFly usa funciones de tipado modernas (`StrEnum`, uniones `X | None`, genéricos de PEP 695).
+* **uv.** Instálalo una vez, en todo el sistema.
+
+Instala uv con la orden oficial de una línea:
+
+```bash
+curl -LsSf https://astral.sh/uv/install.sh | sh # macOS / Linux
+# Windows (PowerShell):
+# powershell -c "irm https://astral.sh/uv/install.ps1 | iex"
+```
+
+### Ejecútalo
+
+Confirma que ambas herramientas están disponibles:
+
+```bash
+uv --version
+uv python install 3.12 # ensures a 3.12+ interpreter is available
+```
+
+Deberías ver impresa una versión de uv, y uv informará de que Python 3.12 está instalado (o ya presente).
+
+!!! tip "Siguiendo el ejemplo terminado"
+ Todo lo que hay en este capítulo existe, ya terminado, en el repositorio del libro bajo `samples/lumen`. Si en algún momento quieres comparar tu código con el original —o simplemente ejecutarlo—, clona el repositorio y haz:
+
+ ```bash
+ cd samples/lumen
+ uv sync --extra dev # framework + pytest
+ uv run pyfly run --server uvicorn
+ ```
+
+ Puedes construir junto a él, copiando un archivo a la vez, o leer la versión terminada cuando un paso no esté claro. Ambas cosas funcionan.
+
+---
+
+## Paso 2 — Andamiaje del proyecto
+
+PyFly incluye un generador de proyectos, `pyfly new`, igual que Spring tiene el Spring Initializr. Escribe una distribución de proyecto convencional para que no empieces desde una página en blanco. Como `pyfly` vive dentro del paquete del framework, lo primerísimo que hacemos es crear un directorio de proyecto y añadirle PyFly.
+
+Para este recorrido construiremos los directorios a mano a medida que avancemos —así cada archivo queda visible y nada se esconde detrás de un generador—, pero el generador está ahí cuando quieras tomar ventaja:
+
+```bash
+pyfly new lumen --archetype hexagonal --features web,data-relational
+```
+
+Esa orden crea una carpeta `lumen/`, un `pyproject.toml`, un `pyfly.yaml` y un árbol de fuentes por capas. Creemos la misma forma nosotros mismos para que veas exactamente para qué sirve cada pieza. Empieza con el proyecto y sus dependencias:
+
+```bash
+mkdir lumen && cd lumen
+uv init --package --name lumen
+uv add "pyfly[cli,web,data-relational]" "pydantic>=2.5"
+uv add --dev "pytest>=8" "pytest-asyncio>=0.24" "httpx>=0.27"
+```
+
+Los tres extras de PyFly que acabas de añadir se corresponden con las tres cosas que Lumen necesita: `cli` aporta la propia orden `pyfly`, `web` aporta el servidor ASGI y `data-relational` aporta SQLAlchemy 2 (async) más `aiosqlite` para que podamos persistir en un archivo SQLite sin ninguna base de datos externa que instalar.
+
+### La distribución del proyecto
+
+Las aplicaciones PyFly siguen una estructura por capas que separa el contrato público, el modelo de dominio, la lógica de aplicación y el borde web. Crea estos paquetes bajo `src/lumen`:
+
+```
+lumen/
+├── pyproject.toml
+├── pyfly.yaml # framework configuration
+└── src/lumen/
+ ├── interfaces/ # the public contract: DTOs + enums
+ │ ├── dtos/v1/
+ │ └── enums/v1/
+ ├── models/ # the domain model + persistence
+ │ ├── entities/v1/
+ │ └── repositories/
+ ├── core/ # application logic: commands, queries, handlers
+ │ ├── services/
+ │ └── mappers/
+ ├── web/ # the HTTP edge: controllers
+ │ └── controllers/
+ ├── app.py # the application class
+ └── main.py # the ASGI entry point
+```
+
+Cada capa tiene un único cometido. `interfaces` es la frontera con la que habla el resto del código (y otros servicios). `models` contiene los ricos objetos de dominio y las filas en que se persisten. `core` contiene las operaciones de negocio. `web` las expone por HTTP. Las rellenaremos capa por capa.
+
+!!! spring "Equivalencia con Spring"
+ `pyfly new` es el equivalente del Spring Initializr (`start.spring.io`). Las funcionalidades `web` y `data-relational` son las contrapartes en PyFly de los starters `spring-boot-starter-web` y `spring-boot-starter-data-jpa`: nombrar una funcionalidad arrastra exactamente las dependencias y la autoconfiguración que esa funcionalidad necesita, y nada más.
+
+### La clase de aplicación
+
+Dos archivos convierten un paquete en una aplicación PyFly. El primero, `app.py`, declara la propia aplicación: qué paquetes escanear en busca de componentes y qué niveles del framework activar.
+
+::: listing lumen/app.py | Listado 0.1 — La clase de aplicación
+from __future__ import annotations
+
+from pyfly.core import pyfly_application
+from pyfly.starters.domain import enable_domain_stack
+
+
+@enable_domain_stack
+@pyfly_application(
+ name="lumen",
+ version="1.0.0",
+ description="Lumen — a DDD digital-wallet service built on the PyFly framework.",
+ scan_packages=[
+ "lumen.models.repositories",
+ "lumen.core.services.wallets",
+ "lumen.web.controllers",
+ ],
+)
+class LumenApplication:
+ pass
+:::
+
+`@pyfly_application` marca la clase como una aplicación PyFly y `scan_packages` le dice al contenedor de inyección de dependencias dónde buscar los componentes que declararás: tus repositorios, servicios, manejadores de comandos/consultas y controladores. `@enable_domain_stack` activa los niveles de dominio en los que nos apoyaremos más tarde: CQRS, el motor transaccional, la capa de datos relacional y los eventos.
+
+### El punto de entrada ASGI
+
+El segundo archivo, `main.py`, es lo que un servidor ASGI importa y sirve realmente. Arranca PyFly —carga la configuración, escanea tus paquetes y construye el contexto de aplicación— y luego entrega la aplicación web resultante a Starlette.
+
+::: listing lumen/main.py | Listado 0.2 — El punto de entrada ASGI
+from __future__ import annotations
+
+from lumen.app import LumenApplication
+from pyfly.core import PyFlyApplication
+from pyfly.web.adapters.starlette import create_app
+
+# Bootstrap: load config, scan packages, build the DI context.
+_pyfly = PyFlyApplication(LumenApplication)
+
+app = create_app(
+ title="lumen",
+ version="1.0.0",
+ description="Lumen — a DDD digital-wallet service built on the PyFly framework.",
+ context=_pyfly.context,
+)
+:::
+
+!!! note "Nota"
+ El `samples/lumen/main.py` real añade un gancho `lifespan` y un montaje `/static`. Esos son refinamientos que conocerás en el Capítulo 4; los dos elementos esenciales —`PyFlyApplication(LumenApplication)` para arrancar y `create_app(...)` para construir la aplicación web— son exactamente lo que ves aquí.
+
+### Configuración
+
+PyFly lee `pyfly.yaml` desde la raíz del proyecto. Crea uno que nombre la aplicación, establezca el puerto HTTP y active los niveles que necesitamos. Todo está anidado bajo una clave de nivel superior `pyfly`.
+
+::: listing pyfly.yaml | Listado 0.3 — pyfly.yaml
+pyfly:
+ app:
+ name: lumen
+ version: 1.0.0
+ server:
+ # App on 8080; the actuator + admin default to the management port 9090.
+ port: 8080
+ cqrs:
+ enabled: true
+ transactional:
+ enabled: true
+ eda:
+ provider: memory # in-memory event bus, no broker needed
+ data:
+ relational:
+ enabled: true
+ url: "sqlite+aiosqlite:///./lumen.db"
+ ddl-auto: create # create tables on startup
+:::
+
+Vale la pena dedicar un momento ahora —y un capítulo más adelante— a unas pocas claves. `pyfly.server.port` es el puerto HTTP de la aplicación —`8080` por defecto, exactamente como el `server.port` de Spring—. `data.relational` apunta a un archivo SQLite (`lumen.db`) y `ddl-auto: create` le dice al framework que cree el esquema de la base de datos al arrancar, así que no hay ningún paso de migración que ejecutar para este recorrido. `eda.provider: memory` nos da un bus de eventos en proceso.
+
+!!! warning "Advertencia"
+ Si vienes de un PyFly más antiguo, ten en cuenta que la clave del puerto es `pyfly.server.port` (sobreescritura por entorno `PYFLY_SERVER_PORT`). Las antiguas `pyfly.web.port` / `PYFLY_WEB_PORT` se eliminaron: a partir de ahora establece el puerto bajo `pyfly.server`.
+
+### Ejecútalo
+
+Aun sin endpoints todavía, la aplicación arranca. Iníciala:
+
+```bash
+uv run pyfly run --server uvicorn
+```
+
+Verás el banner de PyFly, registros de arranque estructurados y una línea que te dice que el servidor está escuchando en `http://0.0.0.0:8080`. La opción `--server uvicorn` selecciona el servidor Uvicorn (viene con `pyfly[web]`); para desarrollo, añade `--reload` para reiniciar automáticamente cuando edites un archivo.
+
+PyFly también expone un **endpoint de salud** para que los orquestadores puedan saber que la aplicación está viva. Los endpoints del actuator y el panel de administración se ejecutan en un *puerto de gestión separado*, `9090` por defecto, lo que mantiene los endpoints operativos fuera de tu puerto público de aplicación. En otra terminal:
+
+```bash
+curl -s localhost:9090/actuator/health
+```
+
+```json
+{"status":"UP"}
+```
+
+!!! note "Dos puertos, a propósito"
+ La aplicación sirve tu API en `8080`; el **puerto de gestión** `9090` sirve `/actuator/health`, `/actuator/info` y el panel de administración. Este es el comportamiento de `management.server.port` de Spring Boot. El puerto de gestión está *abierto y sin autenticación por defecto* —está bien en una red privada, pero en producción establecerías `pyfly.management.security.enabled: true` para protegerlo, o `pyfly.management.server.port: -1` para deshabilitar por completo los endpoints de gestión—. Por defecto, solo `health` e `info` se exponen por HTTP; expón más (métricas, entorno, …) con `pyfly.management.endpoints.web.exposure.include`.
+
+Detén el servidor con `Ctrl-C`. El shell está vacío, pero los cimientos están vivos: el contenedor de inyección de dependencias se construye, el servidor arranca y el informe de salud funciona. Ahora le damos algo que hacer.
+
+---
+
+## Paso 3 — La primera porción del dominio
+
+Empezamos donde DDD dice que hay que empezar: por el modelo. Dos objetos cargan con todo el dominio: `Money`, un objeto de valor para importes exactos, y `Wallet`, el agregado que posee el saldo.
+
+### Money — un objeto de valor
+
+`Money` es el *objeto de valor* de manual: no tiene identidad, dos instancias con el mismo importe y moneda son intercambiables, y nunca cambia. Almacenamos los importes como **unidades menores** enteras (céntimos) más un código de moneda ISO-4217, de modo que la aritmética es exacta: `Money(1050, EUR)` son 10,50 € y no hay redondeo de coma flotante del que preocuparse.
+
+Primero, la diminuta enumeración de moneda de la que depende, bajo `interfaces/enums/v1/`:
+
+::: listing lumen/interfaces/enums/v1/currency.py | Listado 0.4 — La enumeración Currency
+from __future__ import annotations
+
+from enum import StrEnum
+
+
+class Currency(StrEnum):
+ """ISO-4217 currency codes Lumen wallets can hold."""
+
+ EUR = "EUR"
+ USD = "USD"
+ GBP = "GBP"
+:::
+
+Ahora `Money` en sí, bajo `models/entities/v1/`. Se construye sobre `pyfly.domain.ValueObject` y es una dataclass congelada, así que la igualdad es estructural y las instancias son inmutables. La aritmética devuelve objetos `Money` *nuevos* y se niega a mezclar monedas.
+
+::: listing lumen/models/entities/v1/money.py | Listado 0.5 — El objeto de valor Money
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from lumen.interfaces.enums.v1.currency import Currency
+from pyfly.domain import BusinessRuleViolation, ValueObject
+
+
+@dataclass(frozen=True)
+class Money(ValueObject):
+ """An exact monetary amount in a single currency (minor units)."""
+
+ amount: int
+ currency: Currency
+
+ def __post_init__(self) -> None:
+ if not isinstance(self.amount, int) or isinstance(self.amount, bool):
+ raise BusinessRuleViolation(
+ "money-amount-integer", "amount must be an integer number of minor units"
+ )
+
+ @classmethod
+ def zero(cls, currency: Currency) -> Money:
+ """The additive identity for *currency* (a zero balance)."""
+ return cls(amount=0, currency=currency)
+
+ def add(self, other: Money) -> Money:
+ self._assert_same_currency(other)
+ return Money(amount=self.amount + other.amount, currency=self.currency)
+
+ def subtract(self, other: Money) -> Money:
+ self._assert_same_currency(other)
+ return Money(amount=self.amount - other.amount, currency=self.currency)
+
+ @property
+ def is_positive(self) -> bool:
+ return self.amount > 0
+
+ @property
+ def is_negative(self) -> bool:
+ return self.amount < 0
+
+ @property
+ def major_units(self) -> float:
+ """The amount as a major-unit decimal (cents / 100)."""
+ return round(self.amount / 100, 2)
+
+ def _assert_same_currency(self, other: Money) -> None:
+ if self.currency is not other.currency:
+ raise BusinessRuleViolation(
+ "money-currency-mismatch",
+ f"cannot combine {self.currency.value} with {other.currency.value}",
+ )
+:::
+
+`BusinessRuleViolation` es la señal del framework de que se ha quebrantado una regla de dominio —aquí, "los importes son unidades menores enteras" y "no puedes sumar euros a dólares"—. Fíjate en que no hay HTTP, ni base de datos, ni cableado del framework: un objeto de valor es dominio puro.
+
+### Wallet — el agregado
+
+El `Wallet` es la *raíz de agregado*: el objeto que posee la invariante. El estado solo cambia a través de métodos que revelan la intención (`open`, `deposit`, `withdraw`), cada uno de los cuales protege la regla **el saldo nunca se vuelve negativo** y registra un *evento de dominio* que describe lo que ocurrió. Construido sobre `pyfly.domain.AggregateRoot`, lanza eventos con `raise_event(...)`, que drenaremos y publicaremos en el Paso 9.
+
+::: listing lumen/models/entities/v1/wallet_entity.py | Listado 0.6 — El agregado Wallet y sus eventos de dominio
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import UTC, datetime
+
+from lumen.interfaces.enums.v1.currency import Currency
+from lumen.models.entities.v1.money import Money
+from pyfly.domain import AggregateRoot, BusinessRuleViolation, DomainEvent
+
+
+@dataclass(frozen=True)
+class WalletOpened(DomainEvent):
+ wallet_id: str = ""
+ owner_id: str = ""
+ currency: str = ""
+
+
+@dataclass(frozen=True)
+class FundsDeposited(DomainEvent):
+ wallet_id: str = ""
+ amount: int = 0
+ currency: str = ""
+ balance: int = 0
+
+
+class Wallet(AggregateRoot[str]):
+ """Wallet aggregate root — owns the ``balance >= 0`` invariant."""
+
+ __slots__ = ("owner_id", "balance", "created_at")
+
+ def __init__(
+ self,
+ id: str,
+ owner_id: str,
+ balance: Money,
+ created_at: datetime | None = None,
+ ) -> None:
+ super().__init__(id)
+ self.owner_id = owner_id
+ self.balance = balance
+ self.created_at = created_at or datetime.now(UTC)
+
+ @property
+ def currency(self) -> Currency:
+ return self.balance.currency
+
+ @classmethod
+ def open(cls, wallet_id: str, owner_id: str, currency: Currency) -> Wallet:
+ """Open a new, empty wallet; raises :class:`WalletOpened`."""
+ if not owner_id.strip():
+ raise BusinessRuleViolation("wallet-owner-required", "owner_id is required")
+ wallet = cls(id=wallet_id, owner_id=owner_id, balance=Money.zero(currency))
+ wallet.raise_event(
+ WalletOpened(wallet_id=wallet_id, owner_id=owner_id, currency=currency.value)
+ )
+ return wallet
+
+ def deposit(self, amount: Money) -> None:
+ """Credit *amount* to the balance; raises :class:`FundsDeposited`."""
+ self._assert_currency(amount)
+ if not amount.is_positive:
+ raise BusinessRuleViolation("wallet-deposit-positive", "deposit amount must be > 0")
+ self.balance = self.balance.add(amount)
+ assert self.id is not None
+ self.raise_event(
+ FundsDeposited(
+ wallet_id=self.id,
+ amount=amount.amount,
+ currency=amount.currency.value,
+ balance=self.balance.amount,
+ )
+ )
+
+ def _assert_currency(self, amount: Money) -> None:
+ if amount.currency is not self.balance.currency:
+ raise BusinessRuleViolation(
+ "wallet-currency-mismatch",
+ f"wallet holds {self.balance.currency.value}, got {amount.currency.value}",
+ )
+:::
+
+::: figure art/figures/06-aggregate.svg | Figura 0.1 — El agregado Wallet posee su invariante; todos los cambios de estado pasan por sus métodos.
+
+!!! note "Nota"
+ El `Wallet` terminado en `samples/lumen` también tiene un método `withdraw` y un evento `FundsWithdrawn`, que siguen la misma forma —los hemos dejado fuera aquí para que este primer listado sea corto—. El Capítulo 6 construye el agregado completo.
+
+### Ejecútalo
+
+No necesitas el servidor en ejecución para ejercitar el dominio. Abre un REPL de Python dentro del proyecto y maneja el modelo directamente:
+
+```bash
+uv run python
+```
+
+```python
+>>> from lumen.interfaces.enums.v1.currency import Currency
+>>> from lumen.models.entities.v1.money import Money
+>>> from lumen.models.entities.v1.wallet_entity import Wallet
+>>> w = Wallet.open("wlt-1", "alice", Currency.EUR)
+>>> w.deposit(Money(1500, Currency.EUR))
+>>> w.balance.amount, w.balance.currency.value
+(1500, 'EUR')
+>>> w.deposit(Money(100, Currency.USD)) # wrong currency → rejected
+pyfly.domain.exceptions.BusinessRuleViolation: wallet holds EUR, got USD
+```
+
+El agregado impone sus propias reglas en Python puro, sin necesidad de infraestructura.
+
+---
+
+## Paso 4 — Persístelo
+
+Un monedero que vive solo en memoria no sirve de mucho. Necesitamos guardarlo. La capa de datos de PyFly te da un *repositorio al estilo de Spring Data* sobre SQLAlchemy, y como configuramos SQLite no hay ninguna base de datos que instalar.
+
+### La fila de persistencia
+
+El agregado es rico; la fila en la que se persiste es plana. Mapeamos `Wallet` a un `WalletEntity` —una fila por monedero, con el saldo dividido en una columna entera (`balance_minor`) y una columna de moneda—. Hereda la `Base` declarativa del framework, lo que le permite conservar como clave primaria el propio id de cadena del agregado.
+
+::: listing lumen/models/entities/v1/wallet_orm.py | Listado 0.7 — La fila de persistencia WalletEntity
+from __future__ import annotations
+
+from datetime import UTC, datetime
+
+from sqlalchemy import String
+from sqlalchemy.orm import Mapped, mapped_column
+
+from pyfly.data.relational.sqlalchemy import Base
+
+
+class WalletEntity(Base):
+ """One persisted wallet row, keyed by the aggregate's own string id."""
+
+ __tablename__ = "wallets"
+
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
+ owner_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
+ currency: Mapped[str] = mapped_column(String(3), nullable=False)
+ balance_minor: Mapped[int] = mapped_column(nullable=False, default=0)
+ created_at: Mapped[datetime] = mapped_column(default=lambda: datetime.now(UTC))
+:::
+
+Como la clase es subclase de `Base`, importarla registra la tabla `wallets`; con `ddl-auto: create` el framework crea esa tabla al arrancar.
+
+### El repositorio
+
+En lugar de escribir SQL a mano, creas una subclase del `Repository[Entity, IdType]` genérico del framework. Esa única declaración le dice al framework el tipo de la entidad y el tipo de la clave primaria, y a cambio obtienes gratis toda la superficie del repositorio asíncrono: `save`, `find_by_id`, `find_all`, `count`, `delete`, paginación y más, con la sesión de base de datos inyectada por ti.
+
+::: listing lumen/models/repositories/wallet_repository.py | Listado 0.8 — Un repositorio al estilo de Spring Data
+from __future__ import annotations
+
+from lumen.models.entities.v1.wallet_orm import WalletEntity
+from pyfly.container import repository
+from pyfly.data.relational.sqlalchemy import Repository
+
+
+@repository
+class WalletRepository(Repository[WalletEntity, str]):
+ """CRUD for :class:`WalletEntity`, plus a convenience upsert."""
+
+ async def find_by_owner_id(self, owner_id: str) -> list[WalletEntity]:
+ """All wallets owned by *owner_id* (derived query stub)."""
+ ...
+
+ async def upsert(self, entity: WalletEntity) -> WalletEntity:
+ """Insert *entity*, or update the row with the same id."""
+ session = self._require_session()
+ merged = await session.merge(entity)
+ await session.flush()
+ return merged
+:::
+
+Dos cosas aquí son Spring Data puro. `find_by_owner_id` es una **consulta derivada**: su cuerpo es un esbozo elidido (`...`), y al arrancar el framework analiza el *nombre* del método y compila por ti un `SELECT … WHERE owner_id = :owner_id` real. `upsert` es una pequeña comodidad sobre `session.merge` para que un manejador pueda persistir un monedero tanto si es nuevo como si ya existe, con una sola llamada.
+
+El decorador `@repository` registra la clase como un componente gestionado —un *bean* en el contenedor de inyección de dependencias— para que pueda inyectarse en los manejadores que escribimos a continuación.
+
+::: figure art/figures/05-repository.svg | Figura 0.2 — Un Repository del framework convierte una declaración tipada en una superficie CRUD completa.
+
+!!! spring "Equivalencia con Spring"
+ `Repository[WalletEntity, str]` es el análogo directo del `JpaRepository` de Spring Data. Tú declaras la interfaz; el framework proporciona la implementación. Las consultas derivadas (`findByOwnerId` en Spring, `find_by_owner_id` aquí) se analizan a partir del nombre del método exactamente de la misma manera.
+
+---
+
+## Paso 5 — Un camino de escritura con CQRS
+
+Ahora cableamos el modelo al repositorio a través de un *comando*. PyFly usa **CQRS** —Command Query Responsibility Segregation (segregación de responsabilidad entre comandos y consultas)—, lo que significa que las escrituras fluyen por un camino (los comandos) y las lecturas por otro (las consultas). Un comando es un objeto pequeño e inmutable que describe la intención; un manejador (handler) lo ejecuta.
+
+### El comando
+
+`OpenWallet` lleva los datos necesarios para abrir un monedero y se valida a sí mismo antes de que nada se ejecute. Es una dataclass congelada que extiende `Command[str]` —el `str` dice "este comando, cuando se maneja, produce un id de monedero"—.
+
+::: listing lumen/core/services/wallets/open_wallet_command.py | Listado 0.9 — El comando OpenWallet
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from lumen.interfaces.enums.v1.currency import Currency
+from pyfly.cqrs import Command, ValidationResult
+
+
+@dataclass(frozen=True)
+class OpenWallet(Command[str]):
+ """Open a new wallet. Returns the generated wallet id."""
+
+ owner_id: str
+ currency: Currency
+
+ async def validate(self) -> ValidationResult: # type: ignore[override]
+ if not self.owner_id.strip():
+ return ValidationResult.failure("owner_id", "Owner id is required")
+ return ValidationResult.success()
+:::
+
+### El manejador
+
+El manejador es donde ocurre el trabajo: generar un id, crear el agregado `Wallet`, persistirlo a través del repositorio y luego drenar y publicar los eventos del agregado. Se ejecuta dentro de `@transactional()`, que abre una unidad de trabajo, confirma en caso de éxito y revierte en caso de fallo. El repositorio y el publicador de eventos los inyecta el contenedor —tú solo los declaras en `__init__`—.
+
+::: listing lumen/core/services/wallets/open_wallet_handler.py | Listado 0.10 — El manejador de OpenWallet
+from __future__ import annotations
+
+from uuid import uuid4
+
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+from lumen.core.mappers.wallet_mapper import to_entity
+from lumen.core.services.wallets.event_publishing import publish_domain_events
+from lumen.core.services.wallets.open_wallet_command import OpenWallet
+from lumen.models.entities.v1.wallet_entity import Wallet
+from lumen.models.repositories.wallet_repository import WalletRepository
+from pyfly.container import service
+from pyfly.cqrs import CommandHandler, command_handler
+from pyfly.data.relational.sqlalchemy import transactional
+from pyfly.eda import EventPublisher
+
+
+@command_handler
+@service
+class OpenWalletHandler(CommandHandler[OpenWallet, str]):
+ """Open a new, empty wallet."""
+
+ def __init__(
+ self,
+ repository: WalletRepository,
+ events: EventPublisher,
+ session_factory: async_sessionmaker[AsyncSession],
+ ) -> None:
+ super().__init__()
+ self._repository = repository
+ self._events = events
+ self._session_factory = session_factory
+
+ @transactional()
+ async def do_handle(self, command: OpenWallet) -> str: # type: ignore[override]
+ wallet_id = f"wlt-{uuid4()}"
+ wallet = Wallet.open(
+ wallet_id=wallet_id,
+ owner_id=command.owner_id,
+ currency=command.currency,
+ )
+ await self._repository.upsert(to_entity(wallet))
+ await publish_domain_events(self._events, wallet.clear_events())
+ return wallet_id
+:::
+
+El manejador llama a `to_entity(wallet)` —un pequeño mapeador que aplana el agregado en una fila `WalletEntity`—. Créalo bajo `core/mappers/`. (Añadiremos la proyección del lado de lectura que también necesita en el siguiente paso.)
+
+::: listing lumen/core/mappers/wallet_mapper.py | Listado 0.11 — Mapeando el agregado a su fila
+from __future__ import annotations
+
+from lumen.models.entities.v1.wallet_entity import Wallet
+from lumen.models.entities.v1.wallet_orm import WalletEntity
+
+
+def to_entity(wallet: Wallet) -> WalletEntity:
+ """Flatten a :class:`Wallet` aggregate into a persistable row."""
+ assert wallet.id is not None
+ return WalletEntity(
+ id=wallet.id,
+ owner_id=wallet.owner_id,
+ currency=wallet.currency.value,
+ balance_minor=wallet.balance.amount,
+ created_at=wallet.created_at,
+ )
+:::
+
+::: figure art/figures/07-cqrs.svg | Figura 0.3 — Un comando fluye por el bus hasta su manejador; las consultas toman un camino separado.
+
+!!! spring "Equivalencia con Spring"
+ `@command_handler` + `@service` registra un manejador al que el bus de comandos despacha —muy parecido a un `@Service` de Spring cuyo método maneja una petición—. `@transactional()` es la contraparte en PyFly del `@Transactional` de Spring: gestiona la unidad de trabajo para que la persistencia se confirme por completo o se revierta por completo.
+
+---
+
+## Paso 6 — Un camino de lectura
+
+Las lecturas toman el otro carril. Una *consulta* hace una pregunta; un *manejador de consultas* la responde, normalmente proyectando una fila de base de datos sobre un DTO pequeño y hecho a propósito. Leeremos solo el saldo.
+
+### El DTO y la consulta
+
+La respuesta del saldo es un diminuto modelo Pydantic bajo `interfaces/dtos/v1/`:
+
+::: listing lumen/interfaces/dtos/v1/balance_dto.py | Listado 0.12 — El modelo de respuesta BalanceDto
+from __future__ import annotations
+
+from pydantic import BaseModel
+
+from lumen.interfaces.enums.v1.currency import Currency
+
+
+class BalanceDto(BaseModel):
+ """Lightweight balance projection for the balance endpoint."""
+
+ id: str
+ currency: Currency
+ balance_minor: int
+ balance: float
+:::
+
+La consulta lleva solo el id del monedero y declara que devuelve un `BalanceDto` o `None`:
+
+::: listing lumen/core/services/wallets/get_balance_query.py | Listado 0.13 — La consulta GetBalance
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from lumen.interfaces.dtos.v1.balance_dto import BalanceDto
+from pyfly.cqrs import Query
+
+
+@dataclass(frozen=True)
+class GetBalance(Query[BalanceDto | None]):
+ """Look up just the balance of a wallet by its identifier."""
+
+ wallet_id: str
+:::
+
+### El manejador y la proyección
+
+Añade el mapeador del lado de lectura a `wallet_mapper.py` —una pequeña función que proyecta una fila sobre el DTO, calculando el saldo en unidades mayores—:
+
+::: listing lumen/core/mappers/wallet_mapper.py | Listado 0.14 — Proyectando una fila sobre el DTO de saldo
+from __future__ import annotations
+
+from lumen.interfaces.dtos.v1.balance_dto import BalanceDto
+from lumen.interfaces.enums.v1.currency import Currency
+from lumen.models.entities.v1.wallet_orm import WalletEntity
+
+
+def entity_to_balance_dto(entity: WalletEntity) -> BalanceDto:
+ """Project a persisted row onto the lightweight balance DTO."""
+ return BalanceDto(
+ id=entity.id,
+ currency=Currency(entity.currency),
+ balance_minor=entity.balance_minor,
+ balance=round(entity.balance_minor / 100, 2),
+ )
+:::
+
+El manejador de la consulta carga la fila por id y la proyecta —devolviendo `None` cuando no existe tal monedero—:
+
+::: listing lumen/core/services/wallets/get_balance_handler.py | Listado 0.15 — El manejador de GetBalance
+from __future__ import annotations
+
+from lumen.core.mappers.wallet_mapper import entity_to_balance_dto
+from lumen.core.services.wallets.get_balance_query import GetBalance
+from lumen.interfaces.dtos.v1.balance_dto import BalanceDto
+from lumen.models.repositories.wallet_repository import WalletRepository
+from pyfly.container import service
+from pyfly.cqrs import QueryHandler, query_handler
+
+
+@query_handler
+@service
+class GetBalanceHandler(QueryHandler[GetBalance, BalanceDto | None]):
+ def __init__(self, repository: WalletRepository) -> None:
+ super().__init__()
+ self._repository = repository
+
+ async def do_handle(self, query: GetBalance) -> BalanceDto | None: # type: ignore[override]
+ entity = await self._repository.find_by_id(query.wallet_id)
+ return entity_to_balance_dto(entity) if entity is not None else None
+:::
+
+Fíjate en la asimetría que te da CQRS: el lado de escritura rehidrata el agregado completo para proteger las invariantes; el lado de lectura toca solo las columnas que necesita la vista del saldo y nunca construye un agregado. Cada lado está moldeado para su propio cometido.
+
+---
+
+## Paso 7 — Exponlo por HTTP
+
+El dominio funciona, persiste y tiene caminos de escritura y de lectura. Ahora le ponemos un borde web. Un **controlador** mapea las peticiones HTTP sobre comandos y consultas y las despacha a través del bus —no contiene lógica de negocio propia—.
+
+Primero el DTO de petición para abrir un monedero, bajo `interfaces/dtos/v1/`:
+
+::: listing lumen/interfaces/dtos/v1/open_wallet_request.py | Listado 0.16 — La carga útil OpenWalletRequest
+from __future__ import annotations
+
+from pydantic import BaseModel, Field
+
+from lumen.interfaces.enums.v1.currency import Currency
+
+
+class OpenWalletRequest(BaseModel):
+ """Wallet-opening request payload."""
+
+ owner_id: str = Field(min_length=1, max_length=64, description="Identifier of the wallet owner")
+ currency: Currency = Field(default=Currency.EUR, description="ISO-4217 currency the wallet holds")
+:::
+
+Ahora el controlador, bajo `web/controllers/`. El contenedor de inyección de dependencias inyecta los buses de comandos y de consultas; cada manejador construye un comando o una consulta y lo espera con `await` sobre el bus. Las anotaciones de parámetros declaran de dónde provienen los datos: `Valid[Body[...]]` vincula y valida el cuerpo JSON, `PathVar[str]` vincula un segmento de la URL.
+
+::: listing lumen/web/controllers/wallet_controller.py | Listado 0.17 — El controlador de monederos
+from __future__ import annotations
+
+from lumen.core.services.wallets.get_balance_query import GetBalance
+from lumen.core.services.wallets.open_wallet_command import OpenWallet
+from lumen.interfaces.dtos.v1.balance_dto import BalanceDto
+from lumen.interfaces.dtos.v1.open_wallet_request import OpenWalletRequest
+from pyfly.container import rest_controller
+from pyfly.cqrs import DefaultCommandBus, DefaultQueryBus
+from pyfly.kernel import ResourceNotFoundException
+from pyfly.web import Body, PathVar, Valid, get_mapping, post_mapping, request_mapping
+
+
+@rest_controller
+@request_mapping("/api/v1/wallets")
+class WalletController:
+ """Digital-wallet REST API: open a wallet, read its balance."""
+
+ def __init__(self, commands: DefaultCommandBus, queries: DefaultQueryBus) -> None:
+ self._commands = commands
+ self._queries = queries
+
+ @post_mapping("", status_code=201)
+ async def open_wallet(self, request: Valid[Body[OpenWalletRequest]]) -> dict[str, str]:
+ wallet_id = await self._commands.send(
+ OpenWallet(owner_id=request.owner_id, currency=request.currency)
+ )
+ return {"wallet_id": wallet_id}
+
+ @get_mapping("/{wallet_id}/balance")
+ async def wallet_balance(self, wallet_id: PathVar[str]) -> BalanceDto:
+ result = await self._queries.query(GetBalance(wallet_id=wallet_id))
+ if result is None:
+ raise ResourceNotFoundException(
+ f"Wallet {wallet_id!r} not found",
+ code="WALLET_NOT_FOUND",
+ context={"wallet_id": wallet_id},
+ )
+ return result
+:::
+
+::: figure art/figures/04-request.svg | Figura 0.4 — Una petición se vincula a un manejador, que despacha un comando o una consulta a través del bus.
+
+### Ejecútalo
+
+Inicia el servidor:
+
+```bash
+uv run pyfly run --server uvicorn
+```
+
+En otra terminal, abre un monedero:
+
+```bash
+curl -s -X POST localhost:8080/api/v1/wallets \
+ -H 'content-type: application/json' \
+ -d '{"owner_id":"alice","currency":"EUR"}'
+```
+
+```json
+{"wallet_id":"wlt-7d2c1a9e-..."}
+```
+
+Lee el saldo de vuelta (sustituye el id que obtuviste arriba):
+
+```bash
+curl -s localhost:8080/api/v1/wallets/wlt-7d2c1a9e-.../balance
+```
+
+```json
+{"id":"wlt-7d2c1a9e-...","currency":"EUR","balance_minor":0,"balance":0.0}
+```
+
+Un monedero recién abierto tiene saldo cero —exactamente lo que `Money.zero(EUR)` produjo allá en la fábrica `open` del agregado—. La petición viajó desde HTTP, a través del bus de comandos, hasta el manejador, a través del repositorio, hasta SQLite, y de vuelta por el camino de lectura. Ese es todo el arco, de extremo a extremo.
+
+!!! tip "Documentación interactiva gratis"
+ Mientras el servidor se ejecuta, abre `http://localhost:8080/docs` en un navegador. PyFly generó un documento OpenAPI y una interfaz Swagger UI a partir de tu controlador y tus DTOs —puedes probar los endpoints ahí mismo, sin código adicional—.
+
+---
+
+## Paso 8 — Demuéstralo con una prueba
+
+Ejecutar `curl` a mano está bien una vez; una prueba lo demuestra para siempre. PyFly está diseñado para ser testeable sin un servidor en ejecución —puedes despachar comandos y consultas directamente a través de los buses—. Escribe una prueba bajo `tests/`:
+
+::: listing tests/test_quickstart.py | Listado 0.18 — Una prueba de extremo a extremo a través de los buses
+from __future__ import annotations
+
+import pytest
+from lumen.core.services.wallets.get_balance_query import GetBalance
+from lumen.core.services.wallets.open_wallet_command import OpenWallet
+from lumen.interfaces.enums.v1.currency import Currency
+
+from pyfly.cqrs import DefaultCommandBus, DefaultQueryBus
+
+
+@pytest.mark.asyncio
+async def test_open_wallet_then_read_balance(
+ command_bus: DefaultCommandBus,
+ query_bus: DefaultQueryBus,
+) -> None:
+ wallet_id = await command_bus.send(OpenWallet(owner_id="alice", currency=Currency.EUR))
+ assert wallet_id.startswith("wlt-")
+
+ balance = await query_bus.query(GetBalance(wallet_id=wallet_id))
+ assert balance is not None
+ assert balance.balance_minor == 0
+ assert balance.currency is Currency.EUR
+:::
+
+Los parámetros `command_bus` y `query_bus` son *fixtures*: arrancan el contexto de aplicación una vez y te entregan buses cableados, los mismos componentes que el controlador usa en producción. (El `samples/lumen/tests/conftest.py` terminado define estas fixtures; cópialo cuando construyas tu propia batería de pruebas —el Capítulo 16 lo explica al completo—.)
+
+### Ejecútalo
+
+```bash
+uv run --extra dev pytest -q
+```
+
+```
+. [100%]
+1 passed in 0.42s
+```
+
+Verde. Ahora tienes una funcionalidad de monedero que no solo se ejecuta, sino que está *verificada* —la misma prueba se ejecuta en CI en cada cambio—.
+
+!!! spring "Equivalencia con Spring"
+ Despachar a través de los buses en una prueba refleja las pruebas de porción (slice tests) de Spring Boot: ejercitas beans reales cableados sin levantar el servidor HTTP. Las fixtures `command_bus` / `query_bus` son el equivalente en PyFly de un `ApplicationContext` de Spring inyectado en un `@SpringBootTest`.
+
+---
+
+## Paso 9 — Una probada de eventos
+
+El agregado ha estado registrando eventos de dominio todo este tiempo —`WalletOpened`, `FundsDeposited`— y el manejador los drena con `wallet.clear_events()` y los publica. Hasta ahora nada ha *escuchado*. Añadamos un pequeño oyente que reaccione.
+
+Primero, el puente de publicación que el manejador ya importa. Convierte cada evento de dominio drenado en una carga útil y lo publica en el bus de eventos, bajo `core/services/wallets/`:
+
+::: listing lumen/core/services/wallets/event_publishing.py | Listado 0.19 — Publicando los eventos de dominio drenados
+from __future__ import annotations
+
+import dataclasses
+from collections.abc import Iterable
+from typing import Any
+
+from lumen.core.services.listeners.wallet_audit_listener import WALLET_EVENTS_DESTINATION
+from pyfly.domain import DomainEvent
+from pyfly.eda import EventPublisher
+
+
+def _to_payload(event: DomainEvent) -> dict[str, Any]:
+ """Flatten a frozen-dataclass domain event into a JSON-friendly dict."""
+ payload: dict[str, Any] = dataclasses.asdict(event)
+ payload.setdefault("event_type", event.event_type)
+ return payload
+
+
+async def publish_domain_events(publisher: EventPublisher, events: Iterable[DomainEvent]) -> None:
+ """Publish each drained domain event on the wallet events channel."""
+ for event in events:
+ await publisher.publish(
+ destination=WALLET_EVENTS_DESTINATION,
+ event_type=event.event_type,
+ payload=_to_payload(event),
+ )
+:::
+
+Ahora el oyente, bajo `core/services/listeners/`. Es un simple `@service` cuyo método está marcado con `@event_listener`; al arrancar, PyFly lo descubre y lo suscribe al bus —sin cableado a mano—. Aquí mantiene un diminuto registro de auditoría en memoria y un total acumulado por monedero.
+
+::: listing lumen/core/services/listeners/wallet_audit_listener.py | Listado 0.20 — Un oyente de eventos de dominio
+from __future__ import annotations
+
+from pyfly.container import service
+from pyfly.eda import EventEnvelope, event_listener
+
+# The logical channel the wallet handlers publish domain events to.
+WALLET_EVENTS_DESTINATION = "wallet.events"
+
+
+@service
+class WalletAuditListener:
+ """In-memory audit log + running-total projection over wallet events."""
+
+ def __init__(self) -> None:
+ self._running_totals: dict[str, int] = {}
+
+ @event_listener(event_types=["WalletOpened", "FundsDeposited"])
+ async def on_wallet_event(self, envelope: EventEnvelope) -> None:
+ payload = dict(envelope.payload)
+ wallet_id = str(payload.get("wallet_id", ""))
+ if envelope.event_type == "WalletOpened":
+ self._running_totals.setdefault(wallet_id, 0)
+ elif envelope.event_type == "FundsDeposited":
+ amount = int(payload.get("amount", 0))
+ self._running_totals[wallet_id] = self._running_totals.get(wallet_id, 0) + amount
+
+ def running_total(self, wallet_id: str) -> int:
+ """Net funds for *wallet_id*, in minor units."""
+ return self._running_totals.get(wallet_id, 0)
+:::
+
+Añade el paquete de oyentes a `scan_packages` en `app.py` para que el contenedor lo descubra:
+
+```python
+scan_packages=[
+ "lumen.models.repositories",
+ "lumen.core.services.wallets",
+ "lumen.core.services.listeners", # <-- add this
+ "lumen.web.controllers",
+],
+```
+
+::: figure art/figures/08-eda.svg | Figura 0.5 — Un manejador publica eventos de dominio; los oyentes se suscriben y reaccionan, desacoplados del comando.
+
+### Ejecútalo
+
+Abre un monedero, luego haz un depósito, y el total acumulado del oyente se actualiza como efecto secundario de esos comandos —sin que el comando sepa que el oyente existe—. Ese desacoplamiento es el sentido mismo de los eventos: añades reacciones (registros de auditoría, notificaciones, proyecciones) sin tocar el código que las disparó.
+
+!!! spring "Equivalencia con Spring"
+ `@event_listener` es la contraparte en PyFly del `@EventListener` de Spring. Publicar a través de un `EventPublisher` y suscribirse con un método marcado es el mismo modelo de publicación/suscripción que el `ApplicationEventPublisher` de Spring y los beans anotados con `@EventListener`.
+
+---
+
+## Lo que construiste {.recap}
+
+Acabas de construir —y probar— una porción vertical real de un servicio: un modelo de dominio, una base de datos, un camino de escritura, un camino de lectura, un borde HTTP y una reacción a eventos. Cada una de esas cosas fue un *adelanto*. El resto del libro desarma cada una y la reconstruye como es debido, con el razonamiento, las alternativas y los detalles de producción.
+
+Aquí tienes el mapa de lo que acabas de hacer al capítulo que profundiza:
+
+| En este Inicio rápido… | Se profundiza en |
+|---|---|
+| Viste el contenedor construir e inyectar tus beans (`@repository`, `@service`) | **Capítulo 2** — Inyección de dependencias y el contexto de aplicación |
+| Configuraste la aplicación con `pyfly.yaml` y el puerto de gestión | **Capítulo 3** — Configuración, perfiles y secretos |
+| Expusiste una API HTTP con `@rest_controller`, vinculación y validación | **Capítulo 4** — Tu primera API HTTP |
+| Persististe con un `Repository` del framework sobre SQLAlchemy/SQLite | **Capítulo 5** — Persistencia y el patrón Repositorio |
+| Modelaste `Money`, `Wallet`, invariantes y eventos de dominio | **Capítulo 6** — Diseño orientado al dominio |
+| Separaste escrituras y lecturas con comandos, consultas y el bus | **Capítulo 7** — CQRS: comandos y consultas |
+| Publicaste y reaccionaste a eventos de dominio con `@event_listener` | **Capítulo 8** — Eventos de dominio y arquitectura orientada a eventos |
+| (Próximamente) reconstruiste el estado a partir de un registro de eventos | **Capítulo 9** — Event sourcing del libro mayor |
+| (Próximamente) llamaste a otros servicios y dividiste el monolito | **Capítulos 11–12** — Clientes HTTP, el BFF y las sagas |
+| (Próximamente) lo aseguraste, observaste y desplegaste | **Capítulos 14–18** — Seguridad, observabilidad, pruebas y producción |
+
+Cuando estés listo para el *porqué* de todo esto, pasa la página al Capítulo 1.
+
+---
+
+## Pruébalo tú mismo {.exercises}
+
+Si quieres seguir avanzando por tu cuenta primero, tres pequeñas extensiones se construyen directamente sobre lo que ya tienes:
+
+1. **Añade un endpoint de depósito.** Ya tienes el evento `FundsDeposited` y el método `deposit` del agregado. Añade un comando `DepositFunds` + manejador (modélalos sobre `OpenWallet`), una ruta `POST /{wallet_id}/deposit`, y observa cómo trepa el total acumulado del oyente.
+2. **Añade un camino de `withdraw`** que se niegue a sobregirar —la invariante `balance >= 0` del agregado debería rechazarlo, y tu manejador debería exponerlo como un error limpio—. Añade un evento de dominio `FundsWithdrawn` que refleje el evento `FundsDeposited` mostrado en el Listado 0.6.
+3. **Escribe una prueba para el oyente**, comprobando que abrir un monedero y depositar deja el total acumulado esperado —demostrando el camino de eventos de extremo a extremo—.
diff --git a/book/manuscript-es/01-why-pyfly.md b/book/manuscript-es/01-why-pyfly.md
new file mode 100644
index 00000000..c60d48cd
--- /dev/null
+++ b/book/manuscript-es/01-why-pyfly.md
@@ -0,0 +1,456 @@
+Capítulo 1
+
+# ¿Por qué PyFly? {.chtitle}
+
+::: figure art/openers/ch01.svg |
+
+Al final de este capítulo habrás instalado PyFly, generado el esqueleto del servicio de monedero **Lumen** y lo habrás ejecutado en local, con registro estructurado, un endpoint de salud en vivo y documentación interactiva de la API ya funcionando, todo ello sin una sola línea de código repetitivo.
+
+---
+
+## El problema de la cohesión
+
+Imagina tu primer día en un nuevo microservicio en Python. Antes de escribir una sola línea de lógica de negocio, te enfrentas a dos semanas de decisiones de arquitectura.
+
+¿A qué framework web recurres? FastAPI, Flask, Starlette, Django: cada uno es razonable, cada uno introduce sus propios modismos. ¿Qué ORM? ¿SQLAlchemy (síncrono o asíncrono?), Tortoise, Beanie? ¿Cómo conectas las dependencias? ¿Con dependency-injector, python-inject o un módulo de fábrica hecho a mano? ¿Cómo gestionas la configuración? ¿Con pydantic-settings, python-dotenv, dynaconf? ¿Y cómo debería organizarse el proyecto? Cada equipo inventa su propia respuesta.
+
+Con el tiempo ensamblas una pila a medida, la unes con buenas intenciones y la pones en producción. Seis meses después, un segundo equipo empieza un nuevo servicio y toma decisiones completamente distintas. Ahora tienes dos bases de código con convenciones incompatibles, estrategias de pruebas diferentes, patrones de despliegue distintos y ningún entendimiento compartido de cómo funciona nada.
+
+**Python te da una elección infinita. Lo que no te da es cohesión.**
+
+::: figure art/figures/01-choice.svg | Figura 1.1 — Elección infinita, ninguna cohesión.
+
+El problema del ensamblaje de la pila no es un fallo de habilidades: es una carencia de herramientas. Los desarrolladores de Java lo resolvieron hace años con Spring Boot: un único framework con criterio propio que toma decisiones sensatas por ti, te deja sobrescribir lo que importa y aplica un modismo coherente en cada servicio. PyFly aporta esa misma disciplina a Python.
+
+---
+
+## ¿Qué es PyFly?
+
+La solución al problema de la cohesión no es prohibir la elección, sino tomar un buen conjunto de decisiones y empaquetarlas como un framework. Eso es precisamente lo que hace PyFly.
+
+PyFly es un **framework cohesivo, de pila completa y nativamente asíncrono** para construir aplicaciones Python de nivel de producción: tanto microservicios como monolitos y bibliotecas. Toma las decisiones de la pila por ti: inyección de dependencias, enrutamiento HTTP, acceso a base de datos, mensajería, caché, seguridad, observabilidad, todo integrado, todo coherente, todo con valores por defecto listos para producción desde el primerísimo `pyfly run`.
+
+Por dentro, PyFly delega en bibliotecas asíncronas probadas en batalla del ecosistema de Python (Starlette para HTTP, SQLAlchemy (async) para datos relacionales, structlog para el registro, Pydantic para la validación), pero tú nunca las importas directamente. Dependes de **los puertos de PyFly** (clases `Protocol` de Python) y el contenedor de inyección de dependencias conecta los adaptadores concretos en el arranque. Cambia PostgreSQL por MongoDB, o Kafka por RabbitMQ, sin tocar una sola línea de lógica de negocio.
+
+PyFly es la **implementación oficial en Python del Firefly Framework**, una plataforma empresarial probada en batalla construida originalmente para Java (más de 40 módulos en producción). Aporta el mismo modelo de programación a Python 3.12+, no como una conversión sino como una reimplementación nativa diseñada en torno a `async/await` y las anotaciones de tipo.
+
+Su arquitectura se asienta sobre cuatro capas (fundamento, aplicación, infraestructura e integración), cada una compuesta por módulos enfocados que encajan limpiamente entre sí.
+
+::: figure art/figures/01-layers.svg | Figura 1.2 — Las cuatro capas de módulos de PyFly.
+
+Cada capa sigue el mismo principio de **arquitectura hexagonal**: tu código vive en el centro y depende solo de los puertos; los adaptadores viven en los bordes y pueden intercambiarse sin perturbar el núcleo. Este diseño permite que el framework crezca contigo: empieza con un sencillo servicio REST y gradúate a CQRS, mensajería orientada a eventos u orquestación de sagas sin reescribir el fundamento que construiste el primer día.
+
+!!! spring "Equivalencia con Spring"
+ Si vienes de Spring Boot, PyFly te resultará familiar casi de inmediato. `@pyfly_application` es tu `@SpringBootApplication`. `@service`, `@rest_controller` y `@repository` son exactamente los estereotipos que conoces. La inyección por constructor a partir de las anotaciones de tipo refleja `@Autowired` sin XML ni magia de reflexión. La jerarquía de configuración de `pyfly.yaml` (valores por defecto → perfil → variables de entorno) se corresponde directamente con `application.yaml` + perfiles. Una llamada de atención de **Equivalencia con Spring** como esta aparece a lo largo del libro allí donde los conceptos se alinean lo suficiente como para ahorrarte el trabajo mental de traducción.
+
+---
+
+## Instalar PyFly
+
+Para concretar estas ideas, a lo largo del libro construirás **Lumen**, una plataforma fintech de monederos. Lumen empieza de forma sencilla: una API REST que registra asientos en el libro mayor e informa de los saldos de los monederos. Para el capítulo final abarcará varios servicios que se comunican mediante eventos, con sagas que coordinan transferencias entre servicios. Empezar con un esqueleto bien estructurado te ahorra el dolor de adaptar la arquitectura más tarde, así que el generador de esqueletos de PyFly crea esa estructura por ti desde el principio.
+
+Iremos paso a paso, sin prisa. Aquí nada presupone experiencia previa con PyFly: si sabes ejecutar un comando en una terminal, podrás seguir el ritmo.
+
+!!! note "Término nuevo: uv"
+ [uv](https://docs.astral.sh/uv/) es un gestor de paquetes y ejecutor de proyectos de Python muy rápido (piensa en `pip` + `virtualenv` + un ejecutor de tareas, todo en un único binario). PyFly lo recomienda, y cada comando de este libro que empieza con `uv run` significa simplemente "ejecuta esto dentro del entorno virtual del proyecto". Si solo has usado `pip`, no pierdes nada: uv lee el mismo `pyproject.toml` estándar.
+
+**Paso 1 — Comprueba los prerrequisitos.** PyFly tiene como objetivo Python 3.12+ y usa uv. Confirma que ambos están instalados:
+
+::: listing terminal | Listado 1.1 — Verificar los prerrequisitos
+python --version
+# Python 3.12.0 or later
+
+uv --version
+# uv 0.5.0 or later
+:::
+
+Si alguno de los comandos falta o informa de una versión más antigua, instálalo antes de continuar (las instrucciones de instalación de uv son un breve script en su sitio web). Todo lo demás que PyFly necesita, lo trae él por ti.
+
+**Paso 2 — Genera el esqueleto del proyecto.** Un único comando genera un directorio de proyecto completo y con forma de producción:
+
+::: listing terminal | Listado 1.2 — Generar el esqueleto del servicio de monedero Lumen
+# web-api archetype, web feature
+uv run pyfly new lumen --archetype web-api --features web
+:::
+
+!!! note "Términos nuevos: esqueleto (scaffold) / arquetipo"
+ *Generar el esqueleto* (scaffold) es crear un esqueleto de proyecto ya hecho para empezar a partir de código que funciona en lugar de una carpeta vacía. Un *arquetipo* es la plantilla que usa la generación de esqueletos: `web-api` es la plantilla de servicio REST. La opción `--features web` añade la dependencia del servidor ASGI (la pieza que realmente acepta las peticiones HTTP). `uv run pyfly new` llama al registro de arquetipos de PyFly y escribe el directorio completo de una sola vez.
+
+**Paso 3 — Entra en el proyecto e instala las dependencias.**
+
+::: listing terminal | Listado 1.3 — Entrar en el proyecto y sincronizar las dependencias
+cd lumen
+
+# Install dependencies (including the pyfly CLI and ASGI server)
+uv sync
+:::
+
+`uv sync` lee las dependencias declaradas en `pyproject.toml` y las instala en un entorno virtual local del proyecto, incluido el propio comando `pyfly` (disponible mediante el extra `cli`). La primera sincronización descarga los paquetes; las sincronizaciones posteriores son casi instantáneas porque uv cachea todo.
+
+!!! tip "Pruébalo: confirma que la CLI funciona"
+ Desde la raíz del proyecto, ejecuta `uv run pyfly --help`. Deberías ver la lista de comandos de PyFly (`new`, `run` y compañía). Si eso se imprime, tu cadena de herramientas está sana y estás listo para inspeccionar el proyecto generado.
+
+!!! tip "Consejo"
+ Ejecuta `pyfly new` **sin argumentos** para entrar en el modo interactivo. Te guía por la selección de arquetipo y características con navegación mediante las teclas de flecha, algo práctico cuando quieres preseleccionar extras como soporte de datos relacionales o de mensajería.
+
+El generador de esqueletos imprime la disposición generada en forma de árbol:
+
+```
+╭──────────────── Created web-api project ─────────────────╮
+│ lumen-demo/ │
+│ ├── .env.example │
+│ ├── .gitignore │
+│ ├── Dockerfile │
+│ ├── README.md │
+│ ├── pyfly.yaml │
+│ ├── pyproject.toml │
+│ ├── src/lumen_demo/__init__.py │
+│ ├── src/lumen_demo/app.py │
+│ ├── src/lumen_demo/controllers/__init__.py │
+│ ├── src/lumen_demo/controllers/health_controller.py │
+│ ├── src/lumen_demo/controllers/todo_controller.py │
+│ ├── src/lumen_demo/main.py │
+│ ├── src/lumen_demo/models/__init__.py │
+│ ├── src/lumen_demo/models/todo.py │
+│ ├── src/lumen_demo/repositories/__init__.py │
+│ ├── src/lumen_demo/repositories/todo_repository.py │
+│ ├── src/lumen_demo/services/__init__.py │
+│ ├── src/lumen_demo/services/todo_service.py │
+│ ├── tests/__init__.py │
+│ ├── tests/conftest.py │
+│ └── tests/test_todo_service.py │
+╰───────────────────────────────────────────────────────────╯
+
+ Next steps:
+ cd lumen-demo
+ uv sync --group dev
+ pyfly run --reload
+```
+
+La aparente sencillez es deliberada. `pyproject.toml` te da un proyecto conforme a los estándares desde el primer día, sin la deuda heredada de `setup.py`. `pyfly.yaml` es la única fuente de verdad para cada ajuste de tiempo de ejecución, desde el nivel de registro hasta la URL de la base de datos, de modo que la configuración nunca se dispersa por una docena de archivos. La disposición `src/` evita que el paquete `lumen` sea importable accidentalmente sin una instalación explícita, detectando temprano los errores de ruta de importación. El generador de esqueletos también crea controladores, servicios y repositorios de muestra ejecutables, de modo que tengas código que funciona para estudiar de inmediato en lugar de un cascarón vacío.
+
+---
+
+## Dos archivos que importan
+
+Comprender pronto la estructura de los puntos de entrada del esqueleto te ahorrará confusión más adelante. El esqueleto genera dos archivos que trabajan juntos: `app.py` declara la aplicación y `main.py` expone el `app` ASGI que importa el servidor. Cada uno tiene una responsabilidad distinta.
+
+!!! note "Término nuevo: ASGI"
+ *ASGI* (Asynchronous Server Gateway Interface) es el contrato estándar entre un servidor web de Python y una aplicación web asíncrona: el sucesor moderno y asíncrono de WSGI. No tienes que implementarlo; PyFly le entrega al servidor un objeto `app` ASGI ya hecho. Recuerda solo esto: el *servidor* (Uvicorn o Granian) le habla ASGI a tu *app*.
+
+**Paso 1 — Abre la declaración de la aplicación.** Mira `src/lumen/app.py`:
+
+::: listing lumen/app.py | Listado 1.4 — Declaración de la aplicación (@pyfly_application)
+from pyfly.core import pyfly_application
+
+
+@pyfly_application(
+ name="lumen-demo",
+ scan_packages=[
+ "lumen_demo.controllers",
+ "lumen_demo.services",
+ "lumen_demo.repositories",
+ ],
+)
+class Application:
+ pass
+:::
+
+Por corto que sea, cada parámetro tiene su peso:
+
+- **`name`** — la identidad del servicio. Aparece en cada evento de registro estructurado, en el título de OpenAPI y en las cargas útiles de las comprobaciones de salud: un identificador inequívoco cuando estás leyendo registros agregados de una docena de servicios.
+- **`scan_packages`** — la instrucción que impulsa todo el sistema de inyección de dependencias. PyFly recorre cada módulo de la lista y registra en el `ApplicationContext` cualquier clase decorada con `@service`, `@rest_controller`, `@repository` o `@configuration`. Añade una nueva clase de servicio en cualquier punto de esos paquetes y se conecta automáticamente; no se requiere código de registro explícito.
+
+El cuerpo de la clase `Application` está intencionadamente vacío. Nunca la instancias tú mismo: lo hace `PyFlyApplication` durante el arranque: carga la configuración desde `pyfly.yaml`, configura el registro estructurado, imprime el banner de arranque, escanea los paquetes, inicializa el `ApplicationContext` y registra los tiempos de arranque.
+
+!!! note "Términos nuevos: la inyección de dependencias y el ApplicationContext"
+ La *inyección de dependencias* significa que una clase declara lo que necesita en su constructor y el framework suministra esos colaboradores, en lugar de que la clase los construya ella misma. El *ApplicationContext* (a menudo simplemente "el contenedor") es el registro que contiene cada objeto conectado; PyFly llama a cada uno un *bean*, el mismo término que usa Spring. `scan_packages` es lo que lo puebla.
+
+**Paso 2 — Abre el punto de entrada ASGI.** Ahora mira `src/lumen/main.py`:
+
+::: listing lumen/main.py | Listado 1.5 — Punto de entrada ASGI (main.py)
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager
+
+from starlette.applications import Starlette
+
+from pyfly.core import PyFlyApplication
+from pyfly.web.adapters.starlette import create_app
+
+from lumen_demo.app import Application
+
+# Bootstrap: load config, scan packages, build DI context
+_pyfly = PyFlyApplication(Application)
+
+
+@asynccontextmanager
+async def _lifespan(app: Starlette) -> AsyncIterator[None]:
+ """Manage application startup and shutdown lifecycle."""
+ _pyfly._route_metadata = getattr(
+ app.state, "pyfly_route_metadata", []
+ )
+ _pyfly._docs_enabled = getattr(
+ app.state, "pyfly_docs_enabled", False
+ )
+ _pyfly._host = str(_pyfly.config.get("pyfly.server.host", "0.0.0.0"))
+ _pyfly._port = int(_pyfly.config.get("pyfly.server.port", 8080))
+ await _pyfly.startup()
+ yield
+ await _pyfly.shutdown()
+
+
+app = create_app(
+ title="lumen-demo",
+ version="0.1.0",
+ context=_pyfly.context,
+ lifespan=_lifespan,
+)
+:::
+
+Este es el archivo que descubre `pyfly run` (y cualquier servidor ASGI como Uvicorn). El objeto `app` a nivel de módulo es una aplicación Starlette: `create_app` monta cada `@rest_controller` encontrado en el contexto de inyección de dependencias y conecta el gancho del ciclo de vida (lifespan) para que el arranque y el apagado ocurran limpiamente. El decorador `@pyfly_application` por sí solo **no** crea el `app` ASGI: `main.py` es el puente explícito entre el mundo de la inyección de dependencias (`app.py`) y el mundo HTTP.
+
+!!! note "Lo que acaba de pasar"
+ Dos archivos pequeños dividen un trabajo en dos. `app.py` responde a *qué es esta aplicación y dónde vive su código* (nombre, versión, paquetes a escanear). `main.py` responde a *cómo la alcanza un servidor web*: arranca PyFly en un `ApplicationContext` y luego expone un único objeto `app` ASGI. Cuando más adelante añadas un controlador o un servicio, no editarás ninguno de los dos archivos: solo dejas la nueva clase en uno de los paquetes escaneados y PyFly la conecta en el siguiente arranque.
+
+---
+
+## Archivos del proyecto
+
+Los otros dos archivos que editarás con más frecuencia son `pyproject.toml` y `pyfly.yaml`. Cada uno desempeña un papel distinto: `pyproject.toml` gestiona las dependencias, mientras que `pyfly.yaml` es dueño de cada ajuste de tiempo de ejecución.
+
+**Paso 1 — Lee el manifiesto de dependencias.** `pyproject.toml` registra las dependencias del proyecto. Fíjate en los extras:
+
+::: listing lumen/pyproject.toml | Listado 1.6 — pyproject.toml (secciones clave)
+[project]
+name = "lumen-demo"
+version = "0.1.0"
+description = "lumen-demo — built with PyFly"
+requires-python = ">=3.12"
+dependencies = [
+ "pyfly[web]",
+]
+
+[dependency-groups]
+dev = [
+ "pytest>=8.0",
+ "pytest-asyncio>=0.23",
+ "pytest-cov>=5.0",
+ "ruff>=0.3",
+ "mypy>=1.8",
+]
+:::
+
+!!! note "Término nuevo: extras"
+ Un *extra* es un paquete opcional de dependencias nombrado entre corchetes: `pyfly[web]` significa "instala PyFly más su grupo opcional `web`". Los extras mantienen tu instalación ligera: incorporas un controlador de base de datos, una CLI o un servidor más rápido solo cuando realmente lo usas.
+
+El extra `web` en `pyfly[web]` agrupa Uvicorn junto con el adaptador ASGI. El extra `cli` (añádelo cuando necesites el comando `pyfly` fuera de `uv run`) proporciona las herramientas de línea de comandos. Otros extras (`data-relational`, `granian`) son opcionales; los añades solo cuando una característica los necesita. La muestra del monedero Lumen, por ejemplo, declara `pyfly[cli,web,data-relational]` porque se conecta a SQLite.
+
+**Paso 2 — Lee la configuración de tiempo de ejecución.** `pyfly.yaml` es donde viven todos los ajustes de tiempo de ejecución:
+
+::: listing lumen/pyfly.yaml | Listado 1.7 — pyfly.yaml (valor por defecto del esqueleto)
+pyfly:
+ app:
+ name: lumen-demo
+ version: 0.1.0
+ module: lumen_demo.main:app
+
+ server:
+ port: 8080
+ type: "auto"
+ event-loop: "auto"
+ workers: 0
+
+ actuator:
+ endpoints:
+ enabled: true
+
+ admin:
+ enabled: true
+
+ logging:
+ level:
+ root: INFO
+:::
+
+La clave `module` le dice a `pyfly run` exactamente dónde vive el `app` ASGI: `lumen_demo.main:app`. La aplicación escucha en `pyfly.server.port` (por defecto `8080`); el tipo de servidor, los endpoints del actuator y el nivel de registro también tienen valores por defecto sensatos, así que solo sobrescribes lo que realmente necesitas cambiar.
+
+!!! spring "Equivalencia con Spring"
+ `pyfly.server.port` es el equivalente directo de `server.port` de Spring Boot, hasta el mismo valor por defecto `8080`. La variable de entorno de sobrescritura correspondiente es `PYFLY_SERVER_PORT` (el `SERVER_PORT` de Spring). La identidad de la aplicación sigue el mismo patrón: `pyfly.app.name` y `pyfly.app.version` reflejan `spring.application.name`.
+
+!!! warning "Clave renombrada: usa server.port, no web.port"
+ Versiones anteriores de PyFly usaban `pyfly.web.port` (y la variable de entorno `PYFLY_WEB_PORT`) para el puerto de escucha. Ambas fueron **eliminadas** en la v26.06.102 en favor de `pyfly.server.port` / `PYFLY_SERVER_PORT`. Si copias una configuración antigua y el puerto parece ignorado, casi siempre es por esto: renombra la clave bajo `server:` y la sobrescritura surte efecto.
+
+!!! note "Servidor por defecto: Granian frente a Uvicorn"
+ El servidor ASGI por defecto de PyFly es **Granian**, un servidor de alto rendimiento basado en Rust. La dependencia `pyfly[web]` del esqueleto agrupa **Uvicorn** en su lugar (instalación más ligera). Los dos son intercambiables: pasa `--server uvicorn` en la línea de comandos, o añade `pyfly[granian]` a tus dependencias para usar Granian. Por esta razón, la muestra del monedero Lumen se ejecuta con `--server uvicorn`.
+
+---
+
+## Tu primera ejecución
+
+Con el proyecto en su sitio, arranca el servidor.
+
+**Paso 1 — Arranca el servidor.** Desde la raíz del proyecto, ejecuta:
+
+::: listing terminal | Listado 1.8 — Ejecutar el servidor con uv
+uv run pyfly run --server uvicorn
+:::
+
+!!! note "Por qué `--server uvicorn`"
+ El servidor por defecto de PyFly es Granian, pero el extra `pyfly[web]` trae Uvicorn (una instalación más ligera). Pasar `--server uvicorn` le dice a PyFly que use el servidor que realmente tienes. Si alguna vez ves un error sobre que Granian no está instalado, esta opción es la solución, o bien añade `pyfly[granian]` y omite la opción.
+
+Tras un momento verás el banner ASCII de PyFly seguido de un flujo de eventos estructurados de arranque:
+
+```
+ _____.__
+______ ___.__._/ ____\ | ___.__.
+\____ < | |\ __\| |< | |
+| |_> >___ | | | | |_\___ |
+| __// ____| |__| |____/ ____|
+|__| \/ \/
+
+:: PyFly Framework :: (v26.06.110) (Python 3.13.13)
+Copyright 2026 Firefly Software Foundation. | Apache License 2.0
+2026-06-07 20:34:32,442 [INFO] pyfly.core: starting_application |
+ app=lumen-demo version=0.1.0 python=3.13.13 pid=72300
+2026-06-07 20:34:32,442 [INFO] pyfly.core: runtime_environment |
+ os=Darwin os_version=25.5.0 arch=arm64 cpus=11
+2026-06-07 20:34:32,442 [INFO] pyfly.core: loaded_config |
+ source=pyfly-defaults.yaml (framework defaults)
+2026-06-07 20:34:32,442 [INFO] pyfly.core: loaded_config |
+ source=pyfly.yaml
+2026-06-07 20:34:32,442 [INFO] pyfly.core: scanned_package |
+ package=lumen_demo.controllers beans_found=1
+2026-06-07 20:34:32,580 [INFO] pyfly.core: bean_summary |
+ total=127 services=6 repositories=2 controllers=4
+2026-06-07 20:34:32,580 [INFO] pyfly.core: mapped_endpoints | count=5
+2026-06-07 20:34:32,580 [INFO] pyfly.core: request_mapping |
+ method=POST path=/api/v1/wallets handler=open_wallet
+2026-06-07 20:34:32,580 [INFO] pyfly.core: api_documentation |
+ swagger_ui=http://0.0.0.0:8080/docs
+2026-06-07 20:34:32,580 [INFO] pyfly.core: management_server |
+ url=http://0.0.0.0:9090 endpoints=actuator + admin
+2026-06-07 20:34:32,580 [INFO] pyfly.core: admin_dashboard |
+ url=http://0.0.0.0:9090/admin
+2026-06-07 20:34:32,581 [INFO] pyfly.core: server_started |
+ server=uvicorn host=0.0.0.0 port=8080 workers=1
+```
+
+Lee estas líneas de registro como la historia de lo que el framework hizo en tu nombre. Cada evento tiene un nombre en lugar de un mensaje en bruto, de modo que los agregadores de registros puedan filtrar por clave:
+
+1. **`starting_application`** — PyFly se anuncia con el nombre y la versión del servicio que declaraste, dando a cada agregador de registros un filtro limpio desde el primerísimo evento.
+2. **`runtime_environment`** — el sistema operativo, la arquitectura y el número de CPU se emiten una vez en el arranque, lo que facilita correlacionar el comportamiento con el entorno más adelante.
+3. **`loaded_config`** (×2) — la configuración está en capas: el framework trae `pyfly-defaults.yaml` con valores por defecto seguros para producción; tu `pyfly.yaml` sobrescribe solo lo que necesitas. Activa un perfil (p. ej., `--profile prod`) y verás una tercera entrada.
+4. **`scanned_package`** — el contenedor de inyección de dependencias encontró un `@rest_controller` en el paquete de controladores web. `beans_found` crece a medida que añades servicios y repositorios.
+5. **`bean_summary`** — el tamaño total del contexto de inyección de dependencias, incluidos los beans internos del framework. Incluso con 127 beans registrados, PyFly arranca en mucho menos de un segundo.
+6. **`management_server`** — los endpoints del actuator y el panel de administración se sirven en un puerto de gestión *separado* (por defecto `9090`), independiente del `8080` de la aplicación.
+7. **`admin_dashboard`** — la URL exacta de la interfaz de administración en vivo, `http://0.0.0.0:9090/admin`.
+8. **`server_started`** — Uvicorn está aceptando conexiones en el puerto de la aplicación.
+
+!!! note "Lo que acaba de pasar"
+ Ejecutaste un solo comando y PyFly hizo el trabajo de toda una jornada de código repetitivo: cargó la configuración en capas, escaneó tus paquetes, conectó el contenedor de inyección de dependencias, mapeó tus rutas HTTP, publicó documentación interactiva de la API, levantó un servidor de gestión separado con comprobaciones de salud y un panel de administración, y empezó a aceptar peticiones, todo registrado como eventos con nombre y filtrables por máquina. Deja este servidor ejecutándose en su terminal; lo llamarás desde una segunda terminal en los siguientes pasos. Pulsa `Ctrl+C` cuando quieras detenerlo.
+
+!!! spring "Equivalencia con Spring"
+ Servir el actuator y el panel de administración en un puerto dedicado refleja el `management.server.port` de Spring Boot. En PyFly la clave es `pyfly.management.server.port` (por defecto `9090`). Iguálala a `pyfly.server.port` para un comportamiento de puerto único, o ponla a `-1` para deshabilitar por completo los endpoints de gestión. Por defecto, este puerto está **abierto y sin autenticación** (también equivalencia con Spring): bien para el desarrollo local, pero en producción asegúralo con `pyfly.management.security.enabled: true`.
+
+Dos endpoints ya están en vivo antes de que hayas escrito una sola línea de lógica de aplicación. Abre una *segunda* terminal (deja el servidor ejecutándose en la primera) y prueba la comprobación de salud.
+
+**Paso 2 — Comprueba el endpoint de salud en vivo.** Vive en el puerto de gestión, `9090`:
+
+::: listing terminal | Listado 1.9 — Comprobación de salud (puerto de gestión 9090)
+curl -s localhost:9090/actuator/health
+:::
+
+!!! note "Término nuevo: actuator"
+ El *actuator* es la capa de operaciones incorporada de PyFly: un pequeño conjunto de endpoints HTTP que informan sobre la aplicación en ejecución: `/actuator/health`, `/actuator/info` y (cuando los expones) métricas, entorno y más. Es el mismo concepto, y la misma ruta base `/actuator`, que Spring Boot Actuator. Por defecto solo se exponen por HTTP `health` e `info`; amplía eso con `pyfly.management.endpoints.web.exposure.include`.
+
+Deberías ver un documento JSON con un `"status": "UP"` de nivel superior y un mapa `components`, una entrada por cada indicador de salud que el framework registró:
+
+```json
+{
+ "status": "UP",
+ "components": {
+ "cache_health_indicator": {
+ "status": "UP",
+ "details": {"adapter": "InMemoryCache", "latencyMs": 0.01}
+ },
+ "cqrs_health_indicator": {
+ "status": "UP",
+ "details": {"command_handlers": 3, "query_handlers": 2}
+ },
+ "eda_health": {
+ "status": "UP",
+ "details": {"adapter": "InMemoryEventBus"}
+ },
+ "db_health_indicator": {
+ "status": "UP",
+ "details": {"database": "sqlite"}
+ }
+ }
+}
+```
+
+**Paso 3 — Abre un monedero.** Ahora llama al primer endpoint de negocio. A diferencia del actuator, este vive en el puerto de la *aplicación*, `8080`:
+
+::: listing terminal | Listado 1.10 — Abrir un monedero (puerto de aplicación 8080)
+curl -s -X POST localhost:8080/api/v1/wallets \
+ -H 'content-type: application/json' \
+ -d '{"owner_id":"u-1","currency":"EUR"}'
+:::
+
+La respuesta es el identificador del nuevo monedero (tu UUID será distinto):
+
+```json
+{"wallet_id": "wlt-c5bbb2a7-dd49-4321-932e-e4c6bfa5cc2c"}
+```
+
+!!! note "Lo que acaba de pasar"
+ Ese único `curl` viajó por toda la pila que el framework construyó para ti: el cuerpo JSON se validó contra el modelo `OpenWalletRequest`, el `WalletController` lo convirtió en un comando `OpenWallet`, el bus de comandos lo despachó a su manejador (handler), se creó y se persistió una raíz de agregado `Wallet`, y el nuevo id volvió como JSON. Construirás cada una de esas capas por ti mismo en capítulos posteriores; por ahora, fíjate en que ya funciona de extremo a extremo.
+
+**Paso 4 — Explora la documentación interactiva.** Abre `http://localhost:8080/docs` en tu navegador para la interfaz interactiva de Swagger; `http://localhost:8080/redoc` te da ReDoc; la especificación OpenAPI en bruto está en `http://localhost:8080/openapi.json`. La documentación vive en el puerto de la aplicación junto a tu API.
+
+!!! note "Nota"
+ Las tres URL de documentación se emiten en el registro de arranque (entradas `api_documentation`), así que nunca tienes que recordarlas. El Panel de Administración de PyFly (una vista en vivo de los beans, los endpoints, los indicadores de salud y los eventos de registro recientes) se abre en `http://localhost:9090/admin` en el puerto de gestión (su URL exacta es la línea `admin_dashboard` del registro de arranque).
+
+**Paso 5 — Ejecuta las pruebas generadas.** El esqueleto trae una batería de pruebas que funciona para que puedas confirmar la cadena de herramientas de extremo a extremo. Instala las herramientas de desarrollo y luego ejecuta pytest:
+
+::: listing terminal | Listado 1.11 — Ejecutar la batería de pruebas
+uv sync --group dev
+uv run --group dev pytest -q
+:::
+
+!!! note "Salida esperada"
+ Deberías ver una breve sucesión de puntos (o líneas `PASSED`) y un resumen verde como `3 passed in 0.42s`; el número exacto depende del arquetipo. Cualquier resumen verde significa que PyFly, tu entorno virtual y pytest están todos conectados correctamente. (La muestra del monedero Lumen que estudias a lo largo del libro trae una batería mucho mayor, pero se ejecuta del mismo modo: `uv run --group dev pytest -q`.)
+
+Eso completa el ciclo: instalar, generar el esqueleto, ejecutar, llamar y probar, todo antes de escribir una línea de tu propia lógica.
+
+---
+
+## Lo que construiste {.recap}
+
+En menos de cinco minutos instalaste PyFly, generaste el esqueleto de un servicio con forma de producción y lo ejecutaste en local. La tabla siguiente resume lo que Lumen ya entrega (enteramente desde el framework) antes de que hayas escrito una sola línea de lógica de aplicación.
+
+| Capacidad | Cómo la obtuviste |
+|---|---|
+| Registro JSON estructurado con metadatos de tiempo de ejecución | Valor por defecto del framework: emitido en cada arranque |
+| Documentación interactiva de la API (Swagger UI + ReDoc) | Incorporada; siempre activa, desactívala mediante `pyfly.yaml` |
+| Endpoint de salud (`/actuator/health` en el puerto 9090) | `actuator.endpoints.enabled: true` en el esqueleto |
+| Configuración en capas consciente de los perfiles | `pyfly.yaml` + la opción `--profile` |
+| Contenedor de inyección de dependencias con autodescubrimiento | `scan_packages` en `@pyfly_application` |
+| Tiempos de arranque, resumen de beans, mapa de endpoints | Emitidos como eventos de registro estructurado en cada arranque |
+| Panel de Administración (`/admin` en el puerto de gestión 9090) | Habilitado en el esqueleto por defecto |
+
+Esa es la promesa de PyFly: decisiones sensatas tomadas por ti, convenciones coherentes en cada servicio y valores por defecto listos para producción desde la primerísima ejecución.
+
+---
+
+## Pruébalo tú mismo {.exercises}
+
+1. **Renombra el servicio.** Abre `pyfly.yaml` y cambia `app.name` de `lumen-demo` a `lumen-wallet`. Actualiza también el parámetro `name` en `@pyfly_application`. Vuelve a ejecutar con `uv run pyfly run --server uvicorn` y confirma que el nuevo nombre aparece en la línea de registro `starting_application`.
+
+2. **Explora la documentación en vivo.** Con el servidor en ejecución, abre `http://localhost:8080/docs`. Fíjate en el nombre y la versión del servicio en la parte superior de la interfaz de Swagger. Después navega a `http://localhost:8080/redoc` y compara los dos renderizadores de documentación. Comprueba la respuesta de salud en `http://localhost:9090/actuator/health` (recuerda: el actuator vive en el puerto de gestión, no en el puerto de la aplicación) y anota qué indicadores de salud ya están informando. Abre `http://localhost:9090/admin` para ver la misma información en el panel en vivo.
+
+3. **Cambia a Granian.** Añade `pyfly[granian]` a la lista `dependencies` en `pyproject.toml`, ejecuta `uv sync` y luego arranca el servidor sin la opción `--server uvicorn`. Compara la línea de registro `server_started`: el campo `server` ahora debería decir `granian`.
+
+4. **Asigna archivos a responsabilidades.** Mira los dos archivos de punto de entrada `app.py` y `main.py`. Escribe una descripción de una sola frase de lo que cada uno es responsable. ¿De dónde viene el contenedor de inyección de dependencias? ¿Dónde vive el objeto `app` ASGI? ¿Cuál editarías para cambiar los paquetes a escanear?
+
+5. **Colapsa a un único puerto.** Añade `pyfly.management.server.port: 8080` bajo la sección `management:` hermana de `server` en `pyfly.yaml` (de modo que sea igual a `pyfly.server.port`). Vuelve a ejecutar el servidor y observa el registro de arranque: la línea `management_server` desaparece y `http://localhost:8080/actuator/health` ahora responde en el puerto de la aplicación. Después prueba `pyfly.management.server.port: -1`, vuelve a ejecutar y confirma que las rutas del actuator y de administración han desaparecido por completo.
diff --git a/book/manuscript-es/02-dependency-injection.md b/book/manuscript-es/02-dependency-injection.md
new file mode 100644
index 00000000..c75e0765
--- /dev/null
+++ b/book/manuscript-es/02-dependency-injection.md
@@ -0,0 +1,1251 @@
+Capítulo 2
+
+# Inyección de dependencias y el contexto de aplicación {.chtitle}
+
+::: figure art/openers/ch02.svg |
+
+En el capítulo anterior le diste a Lumen su punto de entrada de
+aplicación y observaste cómo arrancaba el contenedor. Ahora vas a
+declarar los primeros componentes reales de Lumen — un
+`WalletRepository` que hereda del `Repository` estilo Spring Data del
+framework, un `WalletEntity` que mapea la fila de persistencia y un
+manejador CQRS que depende de ambos — y dejarás que PyFly los conecte
+entre sí a partir de nada más que anotaciones de tipos.
+Sin factorías, sin `new` manual, sin código de pegamento.
+
+Este capítulo es práctico. Construiremos cada pieza en pasos
+pequeños y numerados, y después de cada hito hay un punto de control
+**Ejecútalo** que muestra el comando exacto que debes escribir y la
+salida que deberías ver en pantalla. Si tu salida coincide, vas por
+buen camino; si no, la diferencia te indica con precisión qué
+corregir. Vas siguiendo el ejercicio dentro de `samples/lumen` con
+PyFly **v26.6.110** instalado (`uv sync` desde el directorio de Lumen
+lo descarga).
+
+!!! note "Nuevo término: contenedor"
+ A lo largo del capítulo hablamos de *el contenedor*. Un
+ **contenedor** es simplemente el objeto que PyFly crea al arrancar
+ y que sabe cómo construir, conectar y entregar todos los objetos de
+ tu aplicación. Nunca lo construyes tú mismo — cuando ejecutas la
+ aplicación, PyFly levanta el contenedor, lo llena con tus beans y
+ lo mantiene vivo hasta el apagado. Piénsalo como un almacén
+ inteligente que tanto guarda tus objetos como los ensambla bajo
+ demanda.
+
+Antes de que aparezca una sola línea de código de Lumen, conviene
+detenerse en *por qué* eso importa. En un proyecto Python
+convencional escribirías algo como:
+
+```python
+handler = DepositFundsHandler(
+ repository=InMemoryWalletRepository(),
+ events=InMemoryEventBus(),
+)
+```
+
+en algún punto cercano a la ruta de arranque. Esa línea parece
+inofensiva, pero fija cada decisión — qué clase de repositorio, qué
+bus de eventos — en el punto de construcción. Cambia el repositorio
+por un adaptador de Postgres y tendrás que encontrar cada lugar de
+construcción. Añade un doble de prueba y necesitarás reestructurar el
+cableado. La **inyección de dependencias** invierte esta relación: las
+clases *declaran* lo que necesitan, y el contenedor *decide* qué
+proporcionar. El resultado es código abierto a la extensión pero
+cerrado a la modificación — el `DepositFundsHandler` que escribes hoy
+aceptará un adaptador de base de datos de producción en la Parte II
+sin un solo cambio en su código fuente.
+
+---
+
+## Estereotipos: declarar tus beans
+
+Antes de que el contenedor pueda conectar nada, necesita saber qué
+clases gestionar. Un **bean** es cualquier objeto que el contenedor
+crea, conecta y posee. Haces que una clase sea visible para el
+contenedor aplicando un **decorador de estereotipo** — una anotación
+ligera que registra la clase y señala su rol arquitectónico.
+
+!!! note "Nuevos términos: bean y estereotipo"
+ Un **bean** no es más que una instancia que el contenedor posee —
+ la construye, le suministra sus dependencias y (normalmente)
+ mantiene una única copia compartida. Un **estereotipo** es el
+ decorador que pones en una clase para decir "contenedor, esto es
+ tuyo, por favor gestiónalo". La palabra *estereotipo* está tomada
+ de Spring; simplemente significa "un rol etiquetado". Poner
+ `@service` en una clase es todo el acto de registro — no hay un
+ fichero de configuración aparte que editar.
+
+PyFly incluye cinco estereotipos:
+
+| Decorador | Significado |
+|---|---|
+| `@service` | Capa de lógica de negocio: operaciones de dominio, orquestación de casos de uso. |
+| `@component` | Bean gestionado genérico sin un rol arquitectónico específico. |
+| `@repository` | Capa de acceso a datos: bases de datos, almacenamiento externo, puertos. |
+| `@configuration` | Clase de configuración que puede contener métodos factoría `@bean`. |
+| `@rest_controller` | Capa HTTP: gestiona peticiones y devuelve respuestas JSON. |
+
+Los cinco estereotipos son **equivalentes a nivel de contenedor**:
+comparten la misma factoría interna `_make_stereotype()` y aceptan los
+mismos argumentos de palabra clave opcionales (`name`, `scope`,
+`profile`, `condition`). Las diferencias significativas son la
+etiqueta `__pyfly_stereotype__` — usada por la capa web para descubrir
+controladores y por el contexto para encontrar clases
+`@configuration` — y la claridad arquitectónica que cada nombre aporta
+a quienes leen tu código. Elegir `@repository` en lugar de
+`@component` no cuesta nada técnicamente, pero le dice a todo futuro
+lector exactamente para qué sirve la clase.
+
+Funcionan tanto la forma simple como la forma con paréntesis:
+
+```python
+@service # bare — all defaults
+class SimpleService:
+ pass
+
+@service(name="wallet_svc") # with keyword args
+class NamedService:
+ pass
+```
+
+### El arranque con scan_packages
+
+El contenedor solo descubre beans en los paquetes que se le ha
+indicado escanear. En `lumen/app.py`, `@pyfly_application` lista cada
+subpaquete que el contenedor debe inspeccionar en busca de
+declaraciones de estereotipo:
+
+::: listing lumen/app.py | Listado 2.1 — Punto de entrada de la aplicación con scan_packages
+from pyfly.core import pyfly_application
+from pyfly.starters.domain import enable_domain_stack
+
+
+@enable_domain_stack
+@pyfly_application(
+ name="lumen",
+ version="1.0.0",
+ description=(
+ "Lumen — a DDD digital-wallet service"
+ " built on the PyFly framework."
+ ),
+ scan_packages=[
+ "lumen.models.repositories",
+ "lumen.core.services.wallets",
+ "lumen.core.services.transfers",
+ "lumen.core.services.listeners",
+ "lumen.web.controllers",
+ ],
+)
+class LumenApplication:
+ pass
+:::
+
+**Cómo funciona.** `@pyfly_application` registra `LumenApplication`
+como la raíz de la aplicación y siembra el contenedor con las
+autoconfiguraciones del framework. `scan_packages` es la lista exacta
+de rutas de paquetes Python que el contenedor recorre al arrancar,
+recolectando cada clase decorada con un estereotipo. Cualquier paquete
+que no esté listado aquí es invisible para el contenedor — la fuente
+más habitual de confusión del tipo "¿por qué no se encuentra mi bean?"
+al añadir nuevos subpaquetes. `@enable_domain_stack` activa en una sola
+línea las autoconfiguraciones de CQRS, el motor transaccional, event
+sourcing, datos relacionales y el motor de reglas.
+
+Lee ese listado como cuatro decisiones:
+
+- **Paso 1 — nombrar la aplicación.** `name="lumen"` y
+ `version="1.0.0"` se convierten en la identidad que reportan el
+ banner de arranque y el endpoint `/actuator/info`. (Estos son el
+ nombre y la versión de la *aplicación*; la versión del framework es
+ aparte — aquí, v26.6.110.)
+- **Paso 2 — describirla.** `description=...` son metadatos orientados
+ a personas que se muestran en la documentación de API generada.
+- **Paso 3 — listar los paquetes a escanear.** Cada entrada de
+ `scan_packages` es una ruta Python con puntos que el contenedor
+ importará e inspeccionará. Si una clase con un estereotipo vive en un
+ paquete que *no* está en esta lista, el contenedor nunca la verá.
+- **Paso 4 — habilitar el stack.** `@enable_domain_stack` enciende las
+ autoconfiguraciones de la capa de dominio para que los buses de
+ CQRS, la sesión transaccional y la capa de datos relacional existan
+ todos antes de que tus beans se conecten.
+
+!!! tip "Consejo: escanea el paquete, no la clase"
+ Las entradas de `scan_packages` son rutas de *paquete*
+ (`lumen.web.controllers`), nunca rutas de módulo o clase
+ individuales. El contenedor recorre el paquete y descubre cada
+ clase decorada con un estereotipo dentro de él. Cuando añades un
+ nuevo manejador bajo `lumen.core.services.wallets`, se recoge
+ automáticamente — sin necesidad de editar `scan_packages`. Solo
+ tocas esta lista cuando introduces un subpaquete completamente
+ nuevo.
+
+**Ejecútalo.** Desde la raíz del proyecto Lumen, arranca la aplicación
+y observa cómo el contenedor se ensambla a sí mismo:
+
+```bash
+cd samples/lumen
+uv run pyfly run --server uvicorn
+```
+
+Deberías ver el banner seguido de líneas estructuradas de arranque —
+el contenedor reportando exactamente lo que escaneó y conectó:
+
+```text
+:: PyFly Framework :: (v26.6.110) (Python 3.12.13)
+
+pyfly.core: starting_application | app=lumen version=1.0.0
+pyfly.core: scanned_package | package=lumen.models.repositories beans_found=1
+pyfly.core: scanned_package | package=lumen.core.services.wallets beans_found=7
+pyfly.core: scanned_package | package=lumen.core.services.transfers beans_found=2
+pyfly.core: scanned_package | package=lumen.core.services.listeners beans_found=1
+pyfly.core: scanned_package | package=lumen.web.controllers beans_found=1
+pyfly.core: bean_summary | total=137 services=10 repositories=1 controllers=4 configurations=19
+pyfly.core: server_started | server=uvicorn host=0.0.0.0 port=8080 workers=1
+pyfly.core: application_started | app=lumen startup_time_s=0.143 beans_initialized=137
+```
+
+Las líneas `scanned_package` son `scan_packages` haciendo su trabajo:
+una línea por entrada, cada una reportando cuántos beans encontró. La
+línea final `application_started` — el equivalente del "Started
+Application in N seconds" de Spring Boot — es tu señal de que el
+contexto arrancó limpiamente. Pulsa `Ctrl-C` para detener el servidor.
+
+!!! note "Nuevo término: el puerto de gestión"
+ Aparecen dos puertos al arrancar. La API HTTP de tu aplicación
+ escucha en `pyfly.server.port` (por defecto **8080**). Los
+ endpoints operativos — la comprobación de salud, info y el panel de
+ administración — escuchan por separado en el **puerto de gestión**
+ `pyfly.management.server.port` (por defecto **9090**), que está
+ abierto y sin autenticación por defecto. No tocarás ninguno de los
+ dos en este capítulo, pero vale la pena saber por qué se encienden
+ dos puertos. (La clave antigua `pyfly.web.port` se eliminó en
+ v26.6.102; usa siempre `pyfly.server.port` ahora.)
+
+**Qué acaba de pasar.** Un solo comando hizo mucho. PyFly cargó
+`pyfly.yaml`, importó cada paquete de `scan_packages`, encontró cada
+clase decorada con un estereotipo, le pidió al contenedor que las
+construyera en orden de dependencias y reportó los totales — 137
+beans, de los cuales la mayoría son beans de autoconfiguración del
+framework y solo un puñado son tuyos por ahora. A partir de aquí, el
+resto del capítulo trata de *añadir* a ese recuento de beans: una
+entidad, un repositorio y un manejador de comandos, cada uno
+descubierto exactamente por este mecanismo.
+
+!!! spring "Equivalencia con Spring"
+ `scan_packages` es el equivalente de `@ComponentScan(basePackages =
+ {...})` de Spring. La semántica es idéntica: lista cada subpaquete
+ que quieras que el framework inspeccione, y registrará todo lo que
+ encuentre. La línea de log `application_started` refleja el resumen
+ de arranque "Started Application in N seconds (process running for
+ M)" de Spring Boot.
+
+### La entidad y el repositorio
+
+Lumen almacena los monederos en una base de datos relacional. Dos
+clases asumen esta responsabilidad: `WalletEntity` (la fila de
+persistencia) y `WalletRepository` (el bean de acceso a datos). Las
+construiremos en orden: primero la forma de la fila, luego el bean de
+acceso a datos que la lee y la escribe.
+
+!!! note "Nuevo término: entidad"
+ Una **entidad** es la forma en base de datos de un registro — aquí,
+ un monedero, almacenado como una fila en una tabla `wallets`. Es
+ deliberadamente simple: solo columnas tipadas, sin comportamiento.
+ (Lumen mantiene aparte el objeto de dominio *rico*, la raíz de
+ agregado `Wallet`; lo conocerás en el Capítulo 6. Por ahora, la
+ entidad es simplemente la forma en que un monedero se escribe en la
+ base de datos y se lee de ella.)
+
+**La entidad.** `WalletEntity` es una clase mapeada con SQLAlchemy que
+hereda el `Base` del framework. Constrúyela campo a campo:
+
+- **Paso 1 — heredar `Base`.** Heredar del `Base` declarativo de PyFly
+ es lo que inscribe la clase en los metadatos del ORM para que el
+ framework pueda crear su tabla.
+- **Paso 2 — nombrar la tabla.** `__tablename__ = "wallets"` es la
+ tabla SQL a la que esta clase mapea.
+- **Paso 3 — declarar la clave primaria.** `id` es una columna `str`
+ marcada `primary_key=True` — el monedero conserva su propio id de
+ dominio (`wlt-…`) en lugar de un número generado.
+- **Paso 4 — declarar las columnas restantes.** `owner_id`,
+ `currency`, `balance_minor` (el saldo en unidades menores —
+ céntimos — de modo que nunca haya un error de redondeo de coma
+ flotante) y una marca de tiempo `created_at`.
+
+::: listing lumen/models/entities/v1/wallet_orm.py | Listado 2.2a — WalletEntity: la fila de persistencia
+from __future__ import annotations
+
+from datetime import UTC, datetime
+
+from sqlalchemy import String
+from sqlalchemy.orm import Mapped, mapped_column
+
+from pyfly.data.relational.sqlalchemy import Base
+
+
+class WalletEntity(Base):
+ """One persisted wallet row, keyed by the aggregate's own id."""
+
+ __tablename__ = "wallets"
+
+ id: Mapped[str] = mapped_column(
+ String(64), primary_key=True
+ )
+ owner_id: Mapped[str] = mapped_column(
+ String(255), nullable=False, index=True
+ )
+ currency: Mapped[str] = mapped_column(String(3), nullable=False)
+ balance_minor: Mapped[int] = mapped_column(
+ nullable=False, default=0
+ )
+ created_at: Mapped[datetime] = mapped_column(
+ default=lambda: datetime.now(UTC)
+ )
+:::
+
+Heredar `Base` (la base declarativa de PyFly) registra la tabla
+`wallets` en `Base.metadata`; el ciclo de vida del motor del framework
+la crea al arrancar. No se necesita más cableado.
+
+**Ejecútalo.** Con `ddl-auto: create` establecido en `pyfly.yaml`, el
+framework construye el esquema a partir de tus entidades mapeadas en el
+momento en que arranca la aplicación. Vuelve a arrancar la aplicación y
+busca las líneas de la capa de datos:
+
+```bash
+uv run pyfly run --server uvicorn
+```
+
+```text
+pyfly.data.relational.auto_configuration: Initializing database schema (ddl-auto=create)
+pyfly.data.relational.auto_configuration: Database schema initialized (1 tables)
+```
+
+`1 tables` es tu tabla `wallets` — creada puramente porque
+`WalletEntity` hereda `Base`. No hay script de migración que ejecutar
+ni `CREATE TABLE` que escribir a mano. (Lumen usa SQLite por defecto,
+así que la base de datos no es más que un fichero `lumen.db` en el
+directorio del proyecto.)
+
+!!! note "Nuevo término: repositorio"
+ Un **repositorio** es el objeto con el que habla tu código cuando
+ quiere cargar o guardar entidades. En lugar de escribir SQL, llamas
+ a métodos como `find_by_id` o `save`. La clase base `Repository` de
+ PyFly *genera* esos métodos por ti a partir de los tipos de la
+ entidad y de la clave, así que el repositorio que escribes está
+ casi vacío — tú lo declaras, y el framework rellena la
+ implementación. (CRUD, usado más abajo, no es más que el acrónimo
+ de Create, Read, Update, Delete — las cuatro operaciones básicas de
+ datos.)
+
+**El repositorio.** `WalletRepository` hereda del `Repository`
+genérico del framework `Repository[WalletEntity, str]`. Los dos
+argumentos de tipo le dicen al framework el *tipo de entidad*
+(`WalletEntity`) y el *tipo de la clave primaria* (`str`); a partir de
+ahí genera e inyecta una superficie CRUD asíncrona completa —
+`find_by_id`, `save`, `find_all`, `find_all(pageable)`, `delete`,
+`delete_by_id`, `count` y más — respaldada por la `AsyncSession`
+transaccional de la autoconfiguración relacional:
+
+::: listing lumen/models/repositories/wallet_repository.py | Listado 2.2b — WalletRepository: subclase del Repository del framework
+from __future__ import annotations
+
+from lumen.models.entities.v1.wallet_orm import WalletEntity
+from pyfly.container import repository
+from pyfly.data import Page, Pageable
+from pyfly.data.relational.sqlalchemy import (
+ Repository,
+ Specification,
+)
+
+
+def balance_at_least(min_minor: int) -> Specification[WalletEntity]:
+ """Reusable predicate: wallets with balance >= min_minor."""
+ return Specification(
+ lambda root, q: q.where(root.balance_minor >= min_minor)
+ )
+
+
+@repository
+class WalletRepository(Repository[WalletEntity, str]):
+ """CRUD + derived + specification queries for WalletEntity."""
+
+ # Derived query — compiled from the name by the post-processor
+ async def find_by_owner_id(
+ self, owner_id: str
+ ) -> list[WalletEntity]:
+ """All wallets owned by owner_id (derived query stub)."""
+ ...
+
+ # Specification query — composable predicate + pagination
+ async def find_rich(
+ self, min_minor: int, pageable: Pageable
+ ) -> Page[WalletEntity]:
+ """Page of wallets with balance >= min_minor."""
+ return await self.find_all_by_spec_paged(
+ balance_at_least(min_minor), pageable
+ )
+
+ # Upsert: one call for INSERT or UPDATE
+ async def upsert(self, entity: WalletEntity) -> WalletEntity:
+ """Persist entity whether the row is new or already exists."""
+ session = self._require_session()
+ merged = await session.merge(entity)
+ await session.flush()
+ return merged
+:::
+
+**Cómo funciona.** `@repository` le dice al contenedor que gestione
+`WalletRepository` como un bean de DI. El framework lee
+`Repository[WalletEntity, str]` al arrancar, genera internamente la
+implementación CRUD y registra la clase — inyectas `WalletRepository`
+directamente por tipo en cualquier parte de la aplicación. No hay
+interfaz de puerto escrita a mano ni adaptador aparte que mantener:
+**el framework suministra e inyecta la implementación; tú dependes de
+la propia clase del repositorio por tipo.**
+
+Los tres métodos extra muestran los puntos de extensión que el
+framework expone por encima del CRUD heredado. Míralos de uno en uno:
+
+- **Paso 1 — una consulta derivada.** `find_by_owner_id` es una
+ **consulta derivada**: el `RepositoryBeanPostProcessor` (un
+ componente de arranque que edita los beans después de que se
+ construyan) analiza el *nombre* del método y compila un
+ `SELECT … WHERE owner_id = :owner_id` real. Tú escribes solo el
+ cuerpo vacío (`...`); el framework suministra el SQL. La convención
+ de nombres es la API — `find_by_` se convierte en
+ `WHERE = ?`.
+- **Paso 2 — una consulta con Specification.** `find_rich` compone un
+ predicado `Specification` reutilizable (aquí, `balance_at_least`) y
+ lo ejecuta con paginación y ordenación mediante el heredado
+ `find_all_by_spec_paged`. Las Specifications son la forma de
+ construir una cláusula `WHERE` componible y con seguridad de tipos
+ cuando un nombre de método resultaría inmanejable.
+- **Paso 3 — un upsert.** `upsert` es una conveniencia ligera sobre
+ `session.merge` para que un manejador de comandos pueda persistir una
+ entidad tanto si es nueva (INSERT) como si ya existe (UPDATE) con una
+ sola llamada. Como el monedero posee su propio id, ambos casos se
+ basan en la misma clave primaria.
+
+**Qué acaba de pasar.** Declaraste un repositorio cuyo cuerpo está casi
+enteramente vacío y, sin embargo, ahora expone una superficie CRUD
+asíncrona completa más una consulta derivada, una consulta de
+specification y un upsert. El estereotipo `@repository` lo registró
+como bean; el framework leyó la base `Repository[WalletEntity, str]`,
+generó la implementación y la hizo inyectable por tipo. Tú escribiste
+la intención; PyFly escribió la fontanería.
+
+!!! tip "Consejo: confirma que el repositorio está registrado"
+ Vuelve a ejecutar `uv run pyfly run --server uvicorn` y mira la
+ línea `bean_summary`: `repositories=1`. Ese único repositorio
+ registrado es tu `WalletRepository`. Si alguna vez añades un segundo
+ repositorio y no aparece en este recuento, la causa habitual es que
+ su paquete falta en `scan_packages`.
+
+!!! spring "Equivalencia con Spring"
+ `@service`, `@component`, `@repository` y `@configuration` mapean
+ directamente a `@Service`, `@Component`, `@Repository` y
+ `@Configuration` de Spring. `@rest_controller` refleja
+ `@RestController`. `Repository[E, ID]` refleja el
+ `JpaRepository` de Spring Data: declara los tipos de entidad
+ y de clave; el framework genera e inyecta la implementación
+ completa. Los métodos de consulta derivada (con nombres como
+ `find_by_owner_id`) se compilan a SQL al arrancar — el mismo
+ mecanismo que la derivación de consultas de Spring Data a partir de
+ los nombres de los métodos.
+
+---
+
+## Inyección por constructor
+
+Con el repositorio declarado, necesitas un manejador que lo use. Ahí es
+donde se hace visible la capacidad más importante del contenedor: nunca
+llamas tú mismo a los constructores. Declaras lo que una clase
+*necesita* como parámetros de `__init__` con anotaciones de tipo, y el
+contenedor los rellena automáticamente. Esto es la **inyección por
+constructor**, y es el enfoque recomendado para todas las dependencias
+obligatorias.
+
+!!! note "Nuevo término: inyección"
+ La **inyección** es el acto del contenedor de *entregar* a una clase
+ los objetos de los que depende, en lugar de que la clase los
+ construya por sí misma. Con la inyección *por constructor*,
+ simplemente listas las dependencias como parámetros tipados de
+ `__init__`; el contenedor lee esas anotaciones de tipo y pasa los
+ beans correspondientes cuando construye el objeto. La clase nunca
+ dice *cómo* obtener sus dependencias — solo *qué* necesita.
+
+El modelo mental es una simple lista de deseos: lista los tipos que
+necesitas; el contenedor entrega las instancias correctas. Si una
+dependencia no existe al arrancar, obtienes de inmediato un claro
+`NoSuchBeanError` — no un críptico `AttributeError` tres marcos de pila
+más adentro en tiempo de ejecución.
+
+### Apilar decoradores de manejador sobre @service
+
+En el diseño CQRS de Lumen, cada manejador del lado de escritura lleva
+dos decoradores: `@command_handler` (o `@query_handler`) **apilado
+sobre `@service`**. El patrón es innegociable: `@service` registra la
+clase como un bean; el decorador CQRS añade únicamente metadatos de
+enrutado (`__pyfly_command_type__` o `__pyfly_query_type__`) para que
+el bus de comandos/consultas pueda despachar al manejador correcto. Sin
+`@service`, el contenedor nunca ve la clase y el bus de comandos lanza
+`CommandHandlerNotFoundException` en el momento del despacho (el bus de
+consultas lanza `QueryHandlerNotFoundException` cuando falta un
+manejador de consultas).
+
+Antes de leer el listado, esta es la forma de lo que estás a punto de
+escribir, paso a paso:
+
+- **Paso 1 — registrar el bean.** Pon `@service` directamente sobre la
+ clase. Esta es la línea que hace que el contenedor lo gestione.
+- **Paso 2 — añadir metadatos de enrutado.** Apila `@command_handler`
+ *encima* de `@service`. Lee `CommandHandler[DepositFunds, int]` y
+ registra "este bean maneja comandos `DepositFunds`".
+- **Paso 3 — declarar dependencias en `__init__`.** Lista el
+ repositorio, el publicador de eventos y la factoría de sesiones como
+ parámetros tipados. Esta única firma es la especificación completa de
+ cableado — el contenedor la lee y suministra los tres.
+- **Paso 4 — escribir la lógica de negocio en `do_handle`.** Envuélvela
+ en `@transactional()` para que toda la secuencia cargar-mutar-guardar
+ sea una única unidad de trabajo confirmada.
+
+El `DepositFundsHandler` muestra el patrón completo:
+
+::: listing lumen/core/services/wallets/deposit_funds_handler.py | Listado 2.3 — DepositFundsHandler: @command_handler + @service apilados
+from __future__ import annotations
+
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+from lumen.core.mappers.wallet_mapper import to_aggregate, to_entity
+from lumen.core.services.wallets.deposit_funds_command import (
+ DepositFunds,
+)
+from lumen.core.services.wallets.event_publishing import (
+ publish_domain_events,
+)
+from lumen.models.entities.v1.money import Money
+from lumen.models.repositories.wallet_repository import WalletRepository
+from pyfly.container import service
+from pyfly.cqrs import CommandHandler, command_handler
+from pyfly.data.relational.sqlalchemy import transactional
+from pyfly.domain import AggregateNotFound
+from pyfly.eda import EventPublisher
+
+
+@command_handler
+@service
+class DepositFundsHandler(CommandHandler[DepositFunds, int]):
+ """Credit funds to an existing wallet; returns new balance."""
+
+ def __init__(
+ self,
+ repository: WalletRepository,
+ events: EventPublisher,
+ session_factory: async_sessionmaker[AsyncSession],
+ ) -> None:
+ super().__init__()
+ self._repository = repository
+ self._events = events
+ self._session_factory = session_factory
+
+ @transactional()
+ async def do_handle( # type: ignore[override]
+ self, command: DepositFunds
+ ) -> int:
+ entity = await self._repository.find_by_id(
+ command.wallet_id
+ )
+ if entity is None:
+ raise AggregateNotFound("Wallet", command.wallet_id)
+
+ wallet = to_aggregate(entity)
+ wallet.deposit(
+ Money(amount=command.amount, currency=wallet.currency)
+ )
+ await self._repository.upsert(to_entity(wallet))
+
+ await publish_domain_events(
+ self._events, wallet.clear_events()
+ )
+ return wallet.balance.amount
+:::
+
+**Cómo funciona.** Cinco decisiones son visibles en este listado:
+
+- `@service` registra la clase como un bean singleton. Sin él, el
+ contenedor nunca ve la clase.
+- `@command_handler` (aplicado encima de `@service`, así que se ejecuta
+ *después* del registro) lee el primer argumento genérico de
+ `CommandHandler[DepositFunds, int]` y registra que este bean maneja
+ comandos `DepositFunds`.
+- La firma de `__init__` es la especificación completa de cableado:
+ `repository: WalletRepository` — el bean CRUD generado por el
+ framework; `events: EventPublisher` — resuelto por la
+ autoconfiguración de CQRS; `session_factory:
+ async_sessionmaker[AsyncSession]` — la factoría de conexiones
+ compartida proporcionada por la autoconfiguración relacional. Los
+ tres se resuelven por tipo; `DepositFundsHandler` nunca importa una
+ clase concreta.
+- `@transactional()` sobre `do_handle` envuelve todo el cuerpo en una
+ única unidad de trabajo confirmada. El decorador abre una sesión
+ desde `session_factory`, la vincula al repositorio durante la
+ llamada y confirma en caso de éxito (o revierte en caso de error).
+- La lógica de negocio sigue la secuencia estándar CQRS/DDD: cargar la
+ entidad, rehidratar el agregado mediante el mapeador, mutar a través
+ de métodos de dominio que imponen invariantes, persistir mediante
+ `upsert`, drenar y publicar los eventos. El monedero se guarda
+ *antes* de que se publiquen los eventos, de modo que cualquier
+ oyente que consulte el repositorio encuentre el registro
+ actualizado.
+
+Un manejador del lado de lectura usa el mismo patrón de apilado, solo
+que con `@query_handler` y `QueryHandler`:
+
+```python
+from pyfly.container import service
+from pyfly.cqrs import QueryHandler, query_handler
+from lumen.models.repositories.wallet_repository import WalletRepository
+
+
+@query_handler
+@service
+class GetWalletHandler(QueryHandler[GetWallet, WalletDto | None]):
+ def __init__(self, repository: WalletRepository) -> None:
+ super().__init__()
+ self._repository = repository
+
+ async def do_handle(
+ self, query: GetWallet
+ ) -> WalletDto | None:
+ entity = await self._repository.find_by_id(query.wallet_id)
+ return entity_to_dto(entity) if entity is not None else None
+```
+
+El contenedor resuelve las dependencias de forma **recursiva**. Cuando
+construye `DepositFundsHandler` también construye `WalletRepository`
+(el bean CRUD generado por el framework), el `EventPublisher` y el
+`async_sessionmaker` — ninguno de los cuales el manejador necesita
+conocer.
+
+**Qué acaba de pasar.** Declaraste un manejador que necesita tres
+colaboradores y no escribiste ni una línea de código de cableado. El
+contenedor leyó las anotaciones de tipo de `__init__`, construyó cada
+dependencia (y *sus* dependencias, de forma recursiva) y entregó el
+objeto terminado a quien pida `DepositFundsHandler`. Cambiar el bus de
+eventos en memoria por Kafka más adelante no tocará esta clase en
+absoluto — pide un `EventPublisher` y se conforma con lo que sea que el
+contenedor proporcione.
+
+**Ejecútalo.** La forma más segura de confirmar que todo el grafo se
+conecta es el test de integración que arranca el contexto de
+aplicación *real* y conduce un depósito a través del bus de comandos.
+Desde la raíz del proyecto Lumen:
+
+```bash
+uv run --extra dev pytest tests/test_app_context_integration.py -q
+```
+
+```text
+. [100%]
+1 passed in 0.19s
+```
+
+Ese único punto es el contenedor demostrándose a sí mismo: escaneó los
+paquetes, generó el repositorio, resolvió el `EventPublisher` y la
+factoría de sesiones, construyó `DepositFundsHandler` con los tres
+inyectados y ejecutó de extremo a extremo un ciclo de vida `open →
+deposit → withdraw → reload`. Si faltara una dependencia, este test
+fallaría en el *arranque* con un `NoSuchBeanError` mucho antes de que
+se ejecutara cualquier aserción — que es exactamente el fallo
+temprano y ruidoso que el contenedor está diseñado para darte.
+
+::: figure art/figures/02-di.svg | Figura 2.1 — El contenedor inyecta dependencias a partir de las anotaciones de tipo.
+
+!!! spring "Equivalencia con Spring"
+ La inyección por constructor en PyFly es funcionalmente idéntica a
+ la inyección por constructor con `@Autowired` de Spring. En el
+ Spring moderno ni siquiera escribes `@Autowired` — el framework
+ infiere la inyección a partir del único constructor, igual que
+ PyFly lee las anotaciones de tipo de `__init__`. El modelo mental es
+ el mismo: declara lo que necesitas, deja que el contenedor lo
+ proporcione.
+
+!!! tip "Consejo"
+ Prefiere la inyección por constructor para las dependencias
+ obligatorias. Las hace visibles en la firma de la clase, te permite
+ escribir tests unitarios de Python puro sin contenedor
+ (`handler = DepositFundsHandler(repo=MockRepo(), events=MockBus())`)
+ y previene errores accidentales de dependencias faltantes en el
+ arranque en lugar de en tiempo de ejecución.
+
+---
+
+## El contenedor y el ApplicationContext
+
+El sistema de DI de PyFly tiene dos capas, y entender la frontera entre
+ellas te ahorrará tiempo real de depuración. Una capa gestiona grafos
+de objetos; la otra gestiona el ciclo de vida completo de la
+aplicación. Confundirlas es una fuente habitual de confusión.
+
+**`Container`** (de `pyfly.container`) es el motor de DI de bajo nivel.
+Almacena objetos `Registration`, resuelve tipos por las anotaciones del
+constructor, gestiona scopes, aplica la desambiguación con `@primary` y
+maneja las búsquedas por nombre basadas en `Qualifier`. No tiene
+conciencia del ciclo de vida — es una máquina pura de "dame un `T`".
+
+**`ApplicationContext`** (de `pyfly.context`) es el orquestador de alto
+nivel. Envuelve a `Container` y añade toda la secuencia de arranque:
+filtrado de perfiles, evaluación de condiciones, procesamiento de
+`@configuration`/`@bean`, tejido de `BeanPostProcessor`, hooks de
+`@post_construct` / `@pre_destroy`, publicación de eventos y
+autoconfiguración. Interactúas con el `ApplicationContext` en el código
+de aplicación; el `Container` en crudo es un detalle de
+implementación, accesible mediante `ctx.container` como vía de escape.
+
+Piénsalo así: `Container` es la planta de fabricación — sabe cómo
+construir cosas. `ApplicationContext` es el jefe de producción —
+decide qué se construye, en qué orden y qué pasa cuando la fábrica abre
+o cierra.
+
+### Reglas de resolución
+
+Cuando el contenedor necesita resolver un tipo `T`, aplica cuatro
+reglas en estricto orden de prioridad:
+
+1. **Registro directo** — si `T` está registrado directamente,
+ resuélvelo.
+2. **Vínculo de interfaz** — si `T` es un `Protocol` o ABC con
+ exactamente una implementación vinculada, resuelve esa
+ implementación.
+3. **Desambiguación con `@primary`** — si hay múltiples
+ implementaciones vinculadas, gana la decorada con `@primary`.
+4. **Error** — `NoSuchBeanError` cuando nada coincide;
+ `NoUniqueBeanError` cuando existen múltiples candidatos sin
+ `@primary`.
+
+El paso 4 es deliberadamente ruidoso. Una dependencia faltante o
+ambigua es un error de configuración, y sacarlo a la luz en el arranque
+en lugar de enterrarlo en una traza de ejecución es una de las
+garantías clave del contenedor.
+
+### @primary
+
+`@primary` resuelve la ambigüedad cuando varios beans satisfacen la
+misma interfaz. Ponlo en la implementación que quieres como
+predeterminada. Esto surge siempre que tienes un puerto
+`@runtime_checkable Protocol` con más de un adaptador registrado — un
+patrón habitual para infraestructura intercambiable (almacén de caché,
+bus de mensajes, canal de notificación).
+
+Por ejemplo, supón que tu aplicación define un protocolo `CacheStore`
+e incluye dos adaptadores — uno en proceso para el desarrollo local y
+uno de Redis para producción:
+
+```python
+from pyfly.container import repository, primary
+
+
+@primary
+@repository
+class InMemoryCacheStore(CacheStore):
+ """Default: active in development and tests."""
+ ...
+
+
+@repository
+class RedisCacheStore(CacheStore):
+ """Production cache — activated by profile or condition."""
+ ...
+```
+
+Sin `@primary`, resolver `CacheStore` con dos implementaciones
+registradas lanza:
+
+```
+NoUniqueBeanError: Multiple beans of type 'CacheStore' found
+ but none is marked @primary
+ Candidates: ['InMemoryCacheStore', 'RedisCacheStore']
+```
+
+El mensaje nombra a cada candidato en competencia para que puedas tomar
+una decisión deliberada en lugar de adivinar cuál habría elegido el
+contenedor. Mover `@primary` de un adaptador al otro es el único cambio
+necesario para conmutar el almacén de respaldo de la aplicación — nada
+en el código de servicio cambia.
+
+Ten en cuenta que el propio `WalletRepository` de Lumen es una subclase
+del `Repository` del framework, así que solo se registra un bean y no
+se necesita `@primary`. `@primary` es relevante siempre que construyas a
+mano un par puerto/adaptador con múltiples implementaciones de
+adaptador.
+
+### @order
+
+El contenedor inicializa los beans singleton de forma anticipada
+durante el arranque, pero algunos beans realmente deben estar listos
+antes que otros — un filtro de seguridad que debe envolver cada
+petición entrante, o un migrador de esquema que debe ejecutarse antes
+de tocar cualquier repositorio. `@order` te da control explícito sobre
+la secuencia de inicialización.
+
+Los valores más bajos se resuelven primero durante la pasada de
+arranque anticipado. Las constantes `HIGHEST_PRECEDENCE` (`-(2**31)`) y
+`LOWEST_PRECEDENCE` (`2**31 - 1`) marcan los extremos:
+
+```python
+from pyfly.container import order, HIGHEST_PRECEDENCE, service
+
+
+@order(HIGHEST_PRECEDENCE)
+@service
+class SecurityInitializer:
+ """Must be ready before any other service."""
+ ...
+```
+
+`@order` afecta a la resolución de singletons durante el arranque, a la
+secuencia en la que se ejecutan las instancias de `BeanPostProcessor` y
+a la ordenación de los resultados de `get_beans_of_type()`.
+
+### Qualifier — resolución de beans por nombre
+
+La inyección basada en tipos cubre la mayoría de los escenarios. No
+obstante, de vez en cuando necesitas realmente una *instancia*
+concreta en lugar de cualquier implementación que satisfaga el tipo —
+el caso clásico es una clase `@configuration` que produce dos beans del
+mismo tipo (digamos, una conexión de base de datos primaria y una de
+réplica de lectura) donde un servicio aguas abajo debe recibir una
+específica.
+
+Selecciona un bean concreto por nombre con `Annotated[T,
+Qualifier("name")]`:
+
+```python
+from typing import Annotated
+from pyfly.container import Qualifier, service
+
+
+@service
+class ReportService:
+ def __init__(
+ self,
+ db: Annotated[object, Qualifier("analytics_db")],
+ ) -> None:
+ self.db = db # receives the bean named "analytics_db"
+```
+
+El contenedor llama a `resolve_by_name("analytics_db",
+expected_type=T)` y verifica la asignabilidad — un nombre mal escrito
+que apunte al tipo equivocado lanza `NoSuchBeanError` con un mensaje
+claro en lugar de inyectar silenciosamente el objeto incorrecto.
+
+---
+
+## Factorías de beans: @configuration y @bean
+
+Los decoradores de estereotipo funcionan de maravilla para las clases
+que posees, pero no toda dependencia es una clase que controlas. Los
+clientes de terceros necesitan argumentos de constructor conocidos solo
+en tiempo de ejecución; beans relacionados comparten estado de
+configuración; algunas familias de beans se expresan con mayor claridad
+como una única factoría. Para todas estas situaciones, PyFly
+proporciona el patrón `@configuration` / `@bean` — código de factoría
+explícito que aun así participa plenamente en la maquinaria de
+resolución y de ciclo de vida del contenedor.
+
+!!! note "Nuevos términos: @configuration y @bean"
+ Una clase `@configuration` es un lugar donde poner **métodos
+ factoría**. Un método `@bean` es una de esas factorías: el
+ contenedor lo *llama* durante el arranque y registra como bean lo
+ que sea que devuelva. Recurres a este patrón cuando una dependencia
+ no puede simplemente estereotiparse — por ejemplo, un objeto de
+ terceros que no posees, o uno que necesita una construcción a
+ medida. La **anotación del tipo de retorno** del método es lo que
+ el contenedor usa para registrar el resultado, así que es
+ obligatoria.
+
+Una clase `@configuration` actúa como una factoría. Sus métodos `@bean`
+se llaman durante la secuencia de arranque, y el valor de retorno de
+cada método se registra como un bean cuyo tipo proviene de la anotación
+de retorno del método. Lee el listado de abajo en dos pasos:
+
+- **Paso 1 — marcar la clase `@configuration`.** Esto le dice al
+ contexto que la escanee en busca de métodos `@bean` antes de
+ construir cualquier bean de estereotipo.
+- **Paso 2 — escribir un método `@bean` con una anotación de retorno.**
+ `event_publisher(self) -> EventPublisher` construye un
+ `InMemoryEventBus` y — porque la anotación dice `EventPublisher` — lo
+ registra *como* un `EventPublisher`. Cualquier cosa que pida un
+ `EventPublisher` recibe ahora esta instancia.
+
+::: listing lumen/infra_config.py | Listado 2.4 — Producir un bean EventPublisher mediante @configuration
+from pyfly.container import configuration, bean
+from pyfly.eda import EventPublisher, InMemoryEventBus
+
+
+@configuration
+class LumenInfraConfig:
+ """Wires infrastructure beans that require explicit construction."""
+
+ @bean
+ def event_publisher(self) -> EventPublisher:
+ """In-memory event bus — replace with Kafka adapter in production."""
+ return InMemoryEventBus()
+:::
+
+**Cómo funciona.** `@configuration` le dice al contexto que escanee
+`LumenInfraConfig` en busca de métodos `@bean` durante el arranque,
+antes de construir cualquier bean de estereotipo. La anotación de
+retorno `EventPublisher` es la clave: el contexto la lee y registra la
+instancia `InMemoryEventBus` producida *como* un `EventPublisher`, no
+como un `InMemoryEventBus`. Esa distinción importa — cuando
+`DepositFundsHandler` pide después un `EventPublisher`, recibe la
+instancia `InMemoryEventBus` sin saber ni importarle el tipo concreto.
+
+Cambiar a un adaptador de Kafka para producción significa reemplazar
+`InMemoryEventBus()` por `KafkaEventPublisher(settings.kafka_url)` en
+un solo método. El resto del código queda intacto.
+
+!!! note "Nota: cómo obtiene Lumen realmente su EventPublisher"
+ El listado de arriba muestra el patrón `@configuration` / `@bean`
+ que escribirías para construir a mano un bean. Lumen en sí *no*
+ necesita esto para su bus de eventos: establecer `eda.provider:
+ memory` en `pyfly.yaml` pide a la autoconfiguración de EDA del
+ framework que registre por ti un bean `EventPublisher` (el mismo
+ `InMemoryEventBus` que ves en el cableado `events_is
+ InMemoryEventBus` al arrancar). Por eso `DepositFundsHandler` puede
+ simplemente pedir un `EventPublisher` — la autoconfiguración ya
+ suministró uno. Recurre a `@bean` cuando necesites un bean que el
+ framework *no* proporciona de fábrica.
+
+Los métodos `@bean` también pueden declarar parámetros; el contenedor
+los resuelve automáticamente:
+
+```python
+@configuration
+class MessagingConfig:
+
+ @bean
+ def audited_publisher(self, base: EventPublisher) -> EventPublisher:
+ """Wrap the base publisher with audit logging."""
+ return AuditingEventPublisher(base)
+```
+
+### Parámetros de @bean
+
+| Parámetro | Por defecto | Descripción |
+|---|---|---|
+| `name` | nombre del método | Nombre del bean para la resolución por nombre. |
+| `scope` | `Scope.SINGLETON` | Scope de ciclo de vida del bean producido. |
+| `primary` | `False` | Marca este como el candidato primario para su interfaz. |
+| `profile` | `""` | Crea el bean solo cuando la expresión de perfil coincide. |
+
+!!! note "Nota"
+ La anotación del tipo de retorno en un método `@bean` es
+ **obligatoria**. El contexto la lee para saber bajo qué tipo de
+ interfaz registrar el bean producido. Omitirla hará que el bean sea
+ inalcanzable por tipo.
+
+---
+
+## Scopes
+
+Cada bean tiene un **scope** que controla cuánto vive su instancia.
+Acertar con el scope tiene menos que ver con el rendimiento y más con
+la corrección: compartir un objeto con estado diseñado para un solo uso
+produce condiciones de carrera; recrear un singleton en cada resolución
+desperdicia recursos y anula la caché. El enum `Scope` define tres
+valores que cubren la inmensa mayoría de las necesidades del mundo
+real.
+
+**`Scope.SINGLETON`** (por defecto) — se crea una instancia en la
+primera resolución y se reutiliza durante toda la vida de la
+aplicación. Los singletons se instancian de forma anticipada durante
+`ApplicationContext.start()`, ordenados por `@order`. Casi todos los
+beans de aplicación deberían ser singletons.
+
+**`Scope.TRANSIENT`** — se crea una instancia nueva en cada resolución.
+Úsalo para objetos con estado, no compartibles:
+
+::: listing lumen/contexts.py | Listado 2.5 — Un bean transitorio para contexto por operación
+from pyfly.container import component, Scope
+
+
+@component(scope=Scope.TRANSIENT)
+class TransferContext:
+ """Carries state for a single wallet transfer operation."""
+
+ def __init__(self) -> None:
+ self.steps: list[str] = []
+ self.rolled_back: bool = False
+:::
+
+**Cómo funciona.** `TransferContext` acumula los pasos de una
+transferencia de varios saltos para que una saga pueda revertirlos en
+orden inverso si algo falla. Compartir una única instancia entre
+peticiones concurrentes mezclaría su estado; `Scope.TRANSIENT`
+garantiza que cada resolución produzca un `TransferContext` fresco y
+vacío. El contenedor sigue gestionando la clase — inyectándola,
+filtrándola por perfil, postprocesándola — pero nunca cachea el
+resultado.
+
+**`Scope.REQUEST`** — acotado a una sola petición HTTP. Se crea una
+instancia nueva cuando llega una petición y se descarta cuando
+termina. Úsalo para beans de la capa web que llevan estado específico
+de la petición, como el usuario autenticado actual.
+
+```python
+from pyfly.container import component, Scope
+
+
+@component(scope=Scope.REQUEST)
+class CurrentUser:
+ user_id: str = ""
+ roles: list[str] = []
+```
+
+Una regla práctica rápida:
+
+- **SINGLETON** — el bean no tiene estado, o su estado es seguro de
+ compartir entre todos los llamadores (pools de conexiones, cachés,
+ objetos de servicio).
+- **TRANSIENT** — el bean acumula estado por operación que no debe
+ filtrarse entre operaciones (sagas, builders, portadores de
+ contexto).
+- **REQUEST** — el bean lleva estado por petición HTTP que debe estar
+ aislado entre peticiones concurrentes (usuario autenticado, ID de
+ traza acotado a la petición).
+
+---
+
+## Ciclo de vida y condiciones
+
+La construcción y el cableado son solo la mitad de la historia. Los
+beans de infraestructura reales necesitan *actuar* después de
+construirse — reservar un pool de hilos, precargar una caché,
+suscribirse a una cola de mensajes — y necesitan *deshacer* esas
+acciones limpiamente al apagarse. PyFly te da dos hooks de ciclo de
+vida para esto, además de una familia de decoradores condicionales que
+controlan si un bean participa o no en el contenedor en absoluto.
+
+### @post_construct y @pre_destroy
+
+Una vez que el contenedor construye un bean e inyecta todas sus
+dependencias, a menudo necesitas una inicialización de una sola vez —
+abrir un pool de conexiones, calentar una caché, registrar un oyente.
+Marca un método con `@post_construct` y el contexto lo llamará después
+de que se complete la construcción. Se admiten tanto métodos síncronos
+como `async`:
+
+::: listing lumen/wallet_audit_listener.py | Listado 2.6 — Hooks de ciclo de vida en un bean @service
+from pyfly.container import service
+from pyfly.context import post_construct, pre_destroy
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+@service
+class WalletAuditListenerWithLifecycle:
+ def __init__(self) -> None:
+ self._entries: list[dict] = []
+
+ @post_construct
+ async def on_start(self) -> None:
+ logger.info("wallet_audit_listener_ready")
+
+ @pre_destroy
+ async def on_stop(self) -> None:
+ logger.info("wallet_audit_listener_shutting_down")
+:::
+
+**Cómo funciona.** `on_start` se dispara *después* de que el
+constructor retorne y todas las dependencias inyectadas estén
+establecidas — lo que hace seguro emitir consultas al repositorio,
+abrir conexiones o publicar un evento de aplicación. La palabra clave
+`async` funciona sin ninguna configuración extra: el contexto llama a
+`await on_start()` cuando detecta una corrutina, y recurre a una
+llamada directa para los métodos síncronos.
+
+`@pre_destroy` es la contraparte, llamada durante
+`ApplicationContext.stop()` antes de descartar el bean. Los beans se
+destruyen en orden **inverso** al de inicialización, de modo que un
+oyente arrancado después del repositorio se detiene antes que él.
+
+**Ejecútalo.** Añade el bean de arriba a un paquete escaneado, luego
+arranca y detén la aplicación para ver cómo se disparan ambos hooks. La
+línea de `@post_construct` aparece durante la pasada de arranque;
+pulsar `Ctrl-C` dispara la línea de `@pre_destroy`:
+
+```bash
+uv run pyfly run --server uvicorn
+```
+
+```text
+... wallet_audit_listener_ready # @post_construct, during startup
+^C
+... shutting_down | app=lumen
+... wallet_audit_listener_shutting_down # @pre_destroy, during shutdown
+```
+
+Ver `..._ready` *antes* de `application_started` confirma que el hook
+se ejecuta como parte de la pasada de arranque anticipado; ver
+`..._shutting_down` después del `Ctrl-C` confirma el desmontaje
+simétrico.
+
+::: figure art/figures/02-lifecycle.svg | Figura 2.2 — El ciclo de vida de un bean.
+
+### Beans condicionales
+
+Las condiciones responden a una pregunta poderosa: *¿debería existir
+este bean en absoluto, dado el entorno actual?* Son la forma en que la
+misma base de código funciona en desarrollo (adaptadores baratos en
+memoria), en CI (Testcontainers) y en producción (infraestructura
+real) — sin una sola sentencia `if` en tu código de servicio.
+
+Los decoradores condicionales se evalúan con una estrategia de dos
+pasadas durante `ApplicationContext.start()`:
+
+**Pasada 1** (antes de procesar el `@configuration` del usuario)
+evalúa:
+- `@conditional_on_property(key, having_value="...")` — la clave de
+ configuración debe existir y, opcionalmente, coincidir con un valor.
+- `@conditional_on_class("module.name")` — el módulo Python debe ser
+ importable.
+- El invocable `condition` en un decorador de estereotipo.
+
+**Pasada 2** (después de procesar el `@configuration` del usuario)
+evalúa:
+- `@conditional_on_bean(SomeType)` — registra solo si ya existe otro
+ bean de ese tipo.
+- `@conditional_on_missing_bean(SomeType)` — registra solo si todavía
+ no existe ningún bean de ese tipo.
+
+El diseño de dos pasadas es deliberado. Las condiciones de la Pasada 1
+dependen de hechos externos — ficheros de configuración y paquetes
+instalados — que se conocen antes de construir cualquier bean. Las
+condiciones de la Pasada 2 dependen de *qué beans se registraron*,
+información disponible solo después de que la Pasada 1 se estabilice.
+Procesarlas en orden garantiza que cada condición se evalúe contra una
+vista estable y predecible del contenedor.
+
+El patrón más poderoso es **"predeterminado con anulación"** —
+proporciona un respaldo que cede automáticamente ante cualquier
+implementación proporcionada por el usuario:
+
+::: listing lumen/notifications.py | Listado 2.7 — Predeterminado con anulación usando @conditional_on_missing_bean
+from pyfly.container import service
+from pyfly.context import conditional_on_missing_bean, conditional_on_property
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class NotificationPort:
+ async def send(self, owner_id: str, message: str) -> None:
+ ...
+
+
+@conditional_on_property("lumen.smtp.host")
+@service
+class SmtpNotificationAdapter:
+ """Real email sender — only active when SMTP is configured."""
+
+ async def send(self, owner_id: str, message: str) -> None:
+ logger.info("smtp_send owner=%s", owner_id)
+
+
+@conditional_on_missing_bean(NotificationPort)
+@service
+class LoggingNotificationFallback:
+ """Log-only fallback — active whenever no real sender is wired."""
+
+ async def send(self, owner_id: str, message: str) -> None:
+ logger.info("notification_fallback owner=%s", owner_id)
+:::
+
+**Cómo funciona.** Lee las dos clases como una cadena de intención.
+`SmtpNotificationAdapter` se activa solo cuando `lumen.smtp.host` está
+presente en la configuración, manteniendo los entornos de desarrollo
+libres de clientes de correo a medio configurar.
+`LoggingNotificationFallback` se activa siempre que no haya registrado
+ningún `NotificationPort` real — en la práctica, cualquier entorno
+donde SMTP no esté configurado. El respaldo no comprueba *por qué* el
+adaptador real está ausente; simplemente llena el hueco.
+
+Por tanto, cualquier manejador que inyecte `NotificationPort` siempre
+recibe *algo* — sin `NoSuchBeanError`, sin guarda de `None`. En
+desarrollo y en CI obtienes salida de log estructurada; en producción
+obtienes correo real. La elección se hace enteramente en la
+configuración, sin cambio de código y sin ninguna ramificación en la
+lógica de servicio.
+
+!!! tip "Consejo"
+ El par `@conditional_on_missing_bean` / `@conditional_on_property`
+ es la forma en que funciona toda la autoconfiguración propia de
+ PyFly. Cada subsistema (caché, mensajería, cliente HTTP) incluye un
+ bean predeterminado que se aparta automáticamente en el momento en
+ que registras tu propia implementación.
+
+---
+
+## Lo que construiste {.recap}
+
+Lumen tiene ahora un `WalletEntity` mapeado a la tabla `wallets`, un
+`WalletRepository` que hereda del `Repository[WalletEntity, str]` del
+framework (lo que aporta una superficie CRUD asíncrona completa, una
+consulta derivada y una consulta de specification), y un
+`DepositFundsHandler` conectado al repositorio, al publicador de
+eventos y a la factoría de sesiones — todo solo mediante anotaciones de
+tipo. Viste por qué `@command_handler` y `@query_handler` **deben
+apilarse sobre `@service`** — los decoradores CQRS añaden metadatos de
+enrutado, pero `@service` es lo que registra el bean. Viste que el
+framework autogenera e inyecta la implementación de `Repository` para
+que dependas de la propia clase del repositorio por tipo, sin necesidad
+de un par puerto/adaptador escrito a mano. También viste cómo
+`@primary` resuelve la ambigüedad cuando dos adaptadores compiten por
+un puerto construido a mano, cómo `@post_construct` / `@pre_destroy`
+delimitan la vida de un bean, y cómo `@conditional_on_missing_bean`
+habilita predeterminados que ceden automáticamente ante
+implementaciones reales.
+
+El hilo conductor es consistente: tú declaras la intención con
+decoradores y anotaciones de tipo; PyFly proporciona las instancias.
+Esa separación te permite probar cada clase de forma aislada,
+intercambiar adaptadores sin tocar la lógica de negocio y conducir toda
+la configuración de la aplicación desde un fichero YAML — todo lo cual
+se vuelve esencial a medida que Lumen crece a lo largo del resto del
+libro.
+
+---
+
+## Pruébalo tú mismo {.exercises}
+
+1. **Practica `@primary` con un puerto construido a mano.**
+ Define un protocolo `CacheStore` con un único método `async def
+ get(key)`. Registra dos adaptadores `@repository` — uno en proceso
+ y uno "remoto" simulado — ambos heredando `CacheStore`. Arranca la
+ aplicación y observa el `NoUniqueBeanError`. Luego añade `@primary`
+ al adaptador en proceso y observa cómo el arranque tiene éxito. A
+ continuación, prueba a inyectar el simulado por nombre: anota un
+ parámetro del constructor como `Annotated[CacheStore,
+ Qualifier("remote_cache")]` después de registrarlo con
+ `@repository(name="remote_cache")`.
+
+2. **Añade un `@post_construct` que registre metadatos de arranque.**
+ Extiende `WalletAuditListener` con un método `async def
+ on_ready(self)` decorado con `@post_construct`. Dentro de él,
+ registra el nombre de clase de cualquier dependencia inyectada.
+ Ejecuta `pyfly run --reload`, arranca el servidor y confirma que la
+ línea de log aparece después de los propios mensajes de arranque del
+ framework.
+
+3. **Haz un bean condicional a una propiedad.** Añade un
+ `WalletAuditService` decorado con
+ `@conditional_on_property("lumen.audit.enabled", having_value="true")`.
+ Abre `pyfly.yaml` y omite la clave. Verifica que el servicio está
+ ausente de la lista de beans al arrancar. Luego añade
+ `lumen.audit.enabled: "true"` a `pyfly.yaml` y vuelve a ejecutar —
+ confirma que aparece. Así es exactamente como controlas subsistemas
+ opcionales sin tocar el código de servicio.
diff --git a/book/manuscript-es/03-configuration.md b/book/manuscript-es/03-configuration.md
new file mode 100644
index 00000000..8e1534cf
--- /dev/null
+++ b/book/manuscript-es/03-configuration.md
@@ -0,0 +1,655 @@
+Capítulo 3
+
+# Configuración, perfiles y secretos {.chtitle}
+
+::: figure art/openers/ch03.svg |
+
+Lumen ya tiene servicios cableados: un `WalletService` respaldado por un repositorio, un publicador de eventos y un ciclo de vida de inyección de dependencias completo. El problema es que cada entorno donde se ejecuta Lumen —tu portátil, un clúster de staging compartido, un despliegue de producción reforzado— necesita ajustes diferentes: puertos distintos, URL de base de datos distintas, verbosidad de log distinta, secretos distintos. Codificar esas diferencias a fuego en el código es frágil; repartirlas entre una docena de llamadas a `os.environ` resulta ilegible e imposible de auditar.
+
+Este capítulo muestra cómo PyFly resuelve eso con un único `pyfly.yaml`, un sistema de precedencia de cuatro capas, ficheros de superposición específicos por entorno y clases de configuración fuertemente tipadas. Al terminar, Lumen tendrá una historia de configuración limpia que escala desde `pyfly run` en tu portátil hasta un despliegue de producción en contenedores, sin tocar una línea de lógica de negocio.
+
+---
+
+## pyfly.yaml: tu única fuente de ajustes
+
+Toda aplicación no trivial tiene al menos dos audiencias para su configuración: un desarrollador que quiere logs detallados y una base de datos local relajada, y un sistema de producción que exige logs JSON estructurados, un pool de conexiones real y ningún modo de depuración. La solución ingenua —`if os.getenv("ENV") == "prod":` repartido entre una docena de ficheros— se vuelve rápidamente imposible de auditar. La respuesta de PyFly es un único fichero YAML (o TOML) canónico que contiene todo lo que tu aplicación sabe de sí misma, con mecanismos separados para lo que cambia entre entornos.
+
+PyFly autodescubre este fichero en la raíz de tu proyecto. El framework comprueba los candidatos en orden —`pyfly.yaml`, `pyfly.toml`, `config/pyfly.yaml`, `config/pyfly.toml`— y carga el primero que encuentra.
+
+!!! note "Nuevo término: autodescubrimiento"
+ "Autodescubrimiento" simplemente significa que no tienes que decirle a PyFly dónde está el fichero de configuración. Dejas `pyfly.yaml` en la raíz de tu proyecto y el framework lo encuentra al arrancar. Sin argumento de ruta, sin llamada de registro.
+
+Veamos el `pyfly.yaml` base de Lumen una sección cada vez, y luego leámoslo en su conjunto.
+
+**Paso 1 — Identifica el servicio.** El primer bloque nombra la aplicación y le da una versión. Estos dos valores fluyen hacia el banner de arranque, el endpoint de salud y los metadatos de trazas, de modo que cada parte del sistema reporta la misma identidad:
+
+```yaml
+pyfly:
+ app:
+ name: lumen
+ version: 1.0.0
+```
+
+**Paso 2 — Elige el puerto de escucha.** La API de negocio de PyFly escucha en `pyfly.server.port`. El valor por defecto es `8080`, así que esta línea es técnicamente redundante: se escribe por claridad. (Si has leído material más antiguo de PyFly que mencionaba `pyfly.web.port`, ten en cuenta que esa clave se eliminó en la v26.06.102; usa ahora `pyfly.server.port`.)
+
+```yaml
+ server:
+ port: 8080
+```
+
+**Paso 3 — Activa las características de dominio que usa Lumen.** Los bloques restantes activan la observabilidad, CQRS, el motor transaccional, event sourcing, la caché, el bus de eventos en memoria y la capa de datos relacional. Cada bloque es una característica; activas lo que necesitas y dejas el resto en los valores por defecto del framework.
+
+Aquí está el fichero completo. Fíjate en que `pyfly:` es la única clave de nivel superior: todo lo que Lumen le dice al framework vive bajo ella:
+
+::: listing pyfly.yaml | Listado 3.1 — Fichero de configuración base de Lumen
+pyfly:
+ app:
+ name: lumen
+ version: 1.0.0
+ banner:
+ mode: console
+ server:
+ # App on 8080; actuator + admin default to the management port 9090.
+ port: 8080
+ observability:
+ metrics:
+ enabled: true
+ tracing:
+ enabled: false
+ cqrs:
+ enabled: true
+ transactional:
+ enabled: true
+ persistence:
+ provider: in-memory
+ eventsourcing:
+ enabled: true
+ cache:
+ provider: in-memory
+ # Event-Driven Architecture: the in-memory bus (no broker needed).
+ # Setting the provider registers the EventPublisher bean that the
+ # wallet command handlers publish domain events through and that the
+ # @event_listener audit projection auto-subscribes to.
+ eda:
+ provider: memory
+ # Relational data layer (SQLAlchemy + SQLite via aiosqlite). The
+ # framework creates the schema on startup (ddl-auto=create) and backs
+ # the WalletRepository (a framework Repository[WalletEntity, str]) that
+ # the command handlers persist through inside @transactional().
+ data:
+ relational:
+ enabled: true
+ url: "sqlite+aiosqlite:///./lumen.db"
+ ddl-auto: create
+:::
+
+!!! note "Ejecútalo"
+ Desde la raíz del proyecto Lumen, arranca la aplicación y observa qué fichero carga el framework:
+
+ ```bash
+ cd samples/lumen
+ uv sync
+ uv run pyfly run
+ ```
+
+ El banner de arranque imprime la versión del framework y, a continuación, PyFly registra cada
+ fuente de configuración que ha fusionado (una línea `loaded_config` por capa) antes de que la
+ aplicación se enlace al puerto `8080`:
+
+ ```
+ :: PyFly Framework :: (v26.06.110) (Python 3.12.13)
+ Copyright 2026 Firefly Software Foundation. | Apache License 2.0
+ no_active_profiles message=No active profiles set, falling back to default
+ loaded_config source=pyfly-defaults.yaml (framework defaults)
+ loaded_config source=.../samples/lumen/pyfly.yaml
+ Uvicorn running on http://0.0.0.0:8080
+ ```
+
+ Déjala en marcha; pronto la golpearás con `curl`. Pulsa `Ctrl+C` para detenerla.
+
+Tres cosas merecen mención. Primera, la clave de nivel superior `pyfly:` está reservada exclusivamente para los ajustes del framework: servidor web, observabilidad, CQRS, EDA, acceso a datos y perfiles viven todos ahí. Tus propias claves de aplicación van bajo un nombre de nivel superior diferente (como `lumen:`). Segunda, `pyfly.app.name` y `pyfly.app.version` identifican el servicio en todas partes: el banner de arranque, los endpoints de salud y los metadatos de trazas leen estos valores. Tercera, el bloque `pyfly.data.relational.*` configura la capa SQLAlchemy/aiosqlite; `url`, `ddl-auto` y `enabled` son sus tres claves centrales.
+
+Las claves anidadas se corresponden directamente con el acceso por notación de puntos a través de `Config.get()`. Para leer un valor anidado, unes la ruta de la clave con puntos: `pyfly.server.port` recorre `pyfly:`, luego `server:` y luego `port:`:
+
+```python
+config.get("pyfly.app.name") # "lumen"
+config.get("pyfly.server.port") # 8080
+config.get("pyfly.data.relational.url") # "sqlite+aiosqlite:///./lumen.db"
+config.get("pyfly.eda.provider") # "memory"
+```
+
+`Config.get()` usa **coincidencia relajada de segmentos**: `ddl-auto` y `ddl_auto` se resuelven a la misma clave. Tu YAML puede usar kebab-case (el estilo YAML convencional) y tu código Python puede usar snake_case; no hace falta recordar qué forma usaste en el fichero.
+
+PyFly usa `PyYAML` (`yaml.safe_load`) para el análisis de YAML; los tipos nativos de YAML se conservan. El entero `8080` en YAML llega como un `int` de Python, sin necesidad de analizar cadenas.
+
+!!! note "Lo que acaba de pasar"
+ Escribiste un único fichero YAML y PyFly lo convirtió en un objeto de configuración tipado y consultable. El bloque `pyfly:` le dijo al framework qué características activar (capa de datos, CQRS, bus de eventos); `Config.get("…")` vuelve a leer cualquier valor por su ruta con puntos; y la coincidencia relajada significa que nunca tienes que recordar si escribiste `ddl-auto` o `ddl_auto`. Ese único objeto es la fuente de la verdad sobre la que se construye el resto de este capítulo.
+
+!!! tip "Consejo"
+ También puedes usar TOML si tu equipo prefiere una sintaxis estilo INI con tipado estricto. Renombra el fichero a `pyfly.toml` y usa la sintaxis de tablas de TOML —`[pyfly.web]`, `[pyfly.data.relational]`— en lugar del anidamiento de YAML. Cada característica descrita en este capítulo funciona de forma idéntica con ambos formatos.
+
+---
+
+## Cómo se organiza la configuración por capas
+
+Un único fichero funciona bien con un solo entorno. Los proyectos reales tienen tres o cuatro —desarrollo, test, staging, producción— y las diferencias entre ellos suelen ser pequeñas: una URL de base de datos aquí, un nivel de log allá. Duplicar el fichero entero para cada entorno es una carga de mantenimiento; la primera vez que alguien actualice el puerto en un fichero y olvide los demás, tendrás un bug de deriva de configuración.
+
+PyFly evita esto superponiendo cuatro fuentes de configuración, cada una fusionada en profundidad sobre la anterior. Las capas posteriores siempre ganan:
+
+::: figure art/figures/03-config.svg | Figura 3.1 — Precedencia de configuración (las capas posteriores ganan).
+
+**Capa 1 — Valores por defecto del framework.** El `pyfly-defaults.yaml` empaquetado dentro del paquete `pyfly.resources` proporciona un valor por defecto sensato para cada clave que lee el framework. Nunca editas este fichero: se carga vía `importlib.resources` y funciona correctamente en distribuciones empaquetadas. El framework siempre parte de una línea base completa y funcional.
+
+**Capa 2 — Fichero de configuración del usuario.** Tu `pyfly.yaml` (o `pyfly.toml`). Incluye solo las claves que difieren de los valores por defecto del framework. En el Listado 3.1, `pyfly.server.port: 8080` coincide con el valor por defecto: se incluye por claridad, no por necesidad.
+
+**Capa 3 — Ficheros de superposición de perfil.** Para cada perfil activo, PyFly busca un fichero llamado `pyfly-{profile}.yaml` junto al fichero base y lo fusiona en profundidad. Las superposiciones de perfil contienen solo las claves que cambian.
+
+**Capa 4 — Variables de entorno.** Se comprueban en el **momento de lectura** en cada llamada a `Config.get()`, no se fijan al arrancar. Esto significa que una variable de entorno establecida después de que la aplicación arranque sigue ganando: el comportamiento correcto para despliegues en contenedores donde los secretos se inyectan en tiempo de ejecución. Las variables de entorno siempre anulan todo lo demás.
+
+### Fusión en profundidad, no reemplazo
+
+Las capas se combinan mediante una fusión en profundidad recursiva (`Config._deep_merge()`). Los diccionarios anidados se fusionan clave por clave; los valores escalares se reemplazan. La distinción importa en la práctica: sin fusión en profundidad, una superposición de producción que cambiara solo `pyfly.server.port` borraría la clave `host` que está junto a ella en la misma sección `server:`, y el bloque `web.docs` no relacionado en una sección hermana. Con la fusión en profundidad, escribes solo lo que pretendes cambiar.
+
+Para concretarlo, considera un fichero base y una superposición de producción:
+
+```yaml
+# pyfly.yaml (base)
+pyfly:
+ server:
+ port: 8080
+ host: "0.0.0.0"
+ web:
+ docs:
+ enabled: true
+```
+
+```yaml
+# pyfly-prod.yaml (overlay)
+pyfly:
+ server:
+ port: 443
+```
+
+Tras la fusión, la configuración efectiva es:
+
+```yaml
+pyfly:
+ server:
+ port: 443 # overridden by prod overlay
+ host: "0.0.0.0" # preserved from base
+ web:
+ docs:
+ enabled: true # preserved from base (sibling section untouched)
+```
+
+Solo las claves que difieren aparecen en la superposición. Todo lo demás se conserva de la capa inferior.
+
+!!! spring "Equivalencia con Spring"
+ Este modelo de cuatro capas se corresponde directamente con la jerarquía de configuración de Spring Boot: `application.yaml` (valores por defecto embebidos en el jar) → tu `application.yaml` → `application-{profile}.yaml` → variables de entorno. El comportamiento de fusión en profundidad, la regla de que la variable de entorno siempre gana y el paso temprano de resolución de perfiles son todas decisiones deliberadas de equivalencia.
+
+---
+
+## Perfiles
+
+El sistema de capas proporciona el mecanismo para variar la configuración entre entornos. Los perfiles proporcionan el vocabulario para nombrar esos entornos y activarlos limpiamente, sin ninguna lógica `if/else` en el código de tu aplicación.
+
+Un **perfil** es una variante de entorno con nombre: `dev`, `test`, `staging`, `prod`. Activar un perfil carga un fichero de superposición y puede incluir o excluir beans condicionalmente.
+
+!!! note "Nuevo término: fichero de superposición"
+ Una "superposición" es un pequeño fichero YAML que contiene *solo las claves que cambian* para un entorno. PyFly lo fusiona sobre el `pyfly.yaml` base. Nunca repites la configuración completa: simplemente declaras las diferencias, y la fusión en profundidad de la sección anterior rellena todo lo demás.
+
+### Activar perfiles
+
+PyFly debe saber qué perfiles están activos *antes* de cargar la configuración completa, porque necesita saber qué ficheros de superposición fusionar. Esta **resolución temprana de perfiles** sigue un orden de prioridad deliberado:
+
+1. **Variable de entorno `PYFLY_PROFILES_ACTIVE`** — máxima prioridad; separada por comas para múltiples perfiles.
+2. **`pyfly.profiles.active` en el fichero de configuración base** — alternativa cuando la variable de entorno no está establecida.
+3. **Pasada programáticamente** — vía `Config.from_file("pyfly.yaml", active_profiles=["prod"])`.
+
+En producción, anula con una variable de entorno: sin cambio de código, sin edición de fichero:
+
+```bash
+PYFLY_PROFILES_ACTIVE=prod uv run pyfly run
+```
+
+### Ficheros de superposición de perfil
+
+Para cada perfil activo `{name}`, PyFly busca `pyfly-{name}.yaml` junto al fichero base. Añadiremos tres superposiciones a Lumen, cada una conteniendo solo las claves que difieren de la base. Constrúyelas una a una.
+
+**Paso 1 — La superposición de desarrollo.** Crea `pyfly-dev.yaml` junto a `pyfly.yaml`. Dev quiere el bucle de retroalimentación más ruidoso posible: trazas completas, cada consulta SQL volcada a la terminal y los internos del framework visibles en `DEBUG`.
+
+::: listing pyfly-dev.yaml | Listado 3.2 — Superposición de desarrollo: logging detallado, modo de depuración
+pyfly:
+ web:
+ debug: true
+ data:
+ relational:
+ echo: true
+ logging:
+ level:
+ root: "DEBUG"
+:::
+
+Tres claves cubren todo lo que el entorno de desarrollo necesita: modo de depuración para trazas detalladas, eco de SQL para que cada consulta aparezca en la terminal y nivel de log `DEBUG` para que los internos del framework sean visibles. Todo lo demás llega sin cambios desde el fichero base. Fíjate en que `echo` vive bajo `pyfly.data.relational.*`, de forma coherente con la estructura del fichero base.
+
+**Paso 2 — La superposición de test.** Crea `pyfly-test.yaml`. El entorno de test quiere lo opuesto a dev: silencio. Silencia el banner para que la salida de las pruebas siga siendo legible, desactiva la persistencia real (las pruebas unitarias simulan el repositorio) y eleva el umbral de log para que las pruebas que pasan no impriman nada.
+
+::: listing pyfly-test.yaml | Listado 3.3 — Superposición de test: SQLite en memoria, banner silenciado
+pyfly:
+ banner:
+ mode: "OFF"
+ data:
+ relational:
+ enabled: false
+ logging:
+ level:
+ root: "WARNING"
+:::
+
+La superposición de test silencia el banner de arranque para que la salida de las pruebas se mantenga limpia, desactiva la persistencia de datos (las pruebas unitarias simulan la capa de repositorio) y eleva el umbral de log a `WARNING` para que las pruebas que pasan no produzcan ruido.
+
+**Paso 3 — La superposición de producción.** Crea `pyfly-prod.yaml`. Producción cambia muchos interruptores a la vez: una URL real de PostgreSQL, logs JSON estructurados, los docs interactivos desactivados y un banner silencioso.
+
+::: listing pyfly-prod.yaml | Listado 3.4 — Superposición de producción: base de datos real, logging JSON, docs desactivados
+pyfly:
+ server:
+ port: 443
+ web:
+ debug: false
+ docs:
+ enabled: false
+ data:
+ relational:
+ enabled: true
+ url: "postgresql+asyncpg://prod-db:5432/lumen"
+ logging:
+ level:
+ root: "WARNING"
+ format: "json"
+ banner:
+ mode: "OFF"
+:::
+
+La superposición de producción toma varias decisiones deliberadas. Desactiva los docs interactivos de la API: no quieres una interfaz de Swagger en vivo en un endpoint de producción. Cambia el logging a formato `json` para que agregadores como Datadog o CloudWatch puedan analizar campos estructurados en lugar de raspar texto legible por humanos. Apunta `pyfly.data.relational.url` a la instancia real de PostgreSQL. Establece `pyfly.server.port: 443`, aunque en la práctica anularás esto con `PYFLY_SERVER_PORT` desde tu pipeline de despliegue para que ningún detalle de topología entre en el repositorio.
+
+!!! note "Ejecútalo"
+ Con los tres ficheros de superposición en su sitio, activa el perfil dev y observa cómo ocurre la fusión. La variable de entorno `PYFLY_PROFILES_ACTIVE` le dice a PyFly qué superposiciones cargar antes de que lea el resto de la configuración:
+
+ ```bash
+ PYFLY_PROFILES_ACTIVE=dev uv run pyfly run
+ ```
+
+ El log de arranque ahora lista la superposición dev entre las fuentes fusionadas y, como dev establece `pyfly.data.relational.echo: true`, cada sentencia SQL aparece en la terminal en cuanto la aplicación toca la base de datos:
+
+ ```
+ active_profiles profiles=['dev']
+ loaded_config source=pyfly-defaults.yaml (framework defaults)
+ loaded_config source=.../samples/lumen/pyfly.yaml
+ loaded_config source=.../samples/lumen/pyfly-dev.yaml (profile: dev)
+ INFO sqlalchemy.engine.Engine BEGIN (implicit)
+ ```
+
+ Detén la aplicación, ejecútala de nuevo *sin* la variable de entorno y la superposición dev desaparece de la lista de fuentes: los valores por defecto más silenciosos del fichero base toman el control. Ese es todo el mecanismo de perfiles en un experimento: una variable de entorno introduce y retira una capa entera.
+
+!!! tip "Consejo"
+ Múltiples perfiles se separan por comas en la variable de entorno y se aplican en orden, de modo que el último perfil gana en los conflictos: `PYFLY_PROFILES_ACTIVE=prod,metrics` aplica primero `pyfly-prod.yaml` y luego `pyfly-metrics.yaml`. Usa esto para componer aspectos transversales: un perfil `metrics` puede activar el raspado de Prometheus sin duplicar toda tu configuración de prod.
+
+### Beans con ámbito de perfil
+
+A veces la diferencia entre entornos no es un valor sino si un componente existe siquiera. Un cargador de semillas que rellena monederos de prueba nunca debe ejecutarse en producción. Un registrador de auditoría detallado que graba cada campo de la petición es útil en desarrollo pero un riesgo de cumplimiento en prod.
+
+El parámetro `profile` de cualquier estereotipo controla cuándo participa un bean en el contenedor. La expresión admite negación y OR separado por comas:
+
+```python
+from pyfly.container import service
+
+
+@service(profile="dev")
+class DevSeedLoader:
+ """Seeds the database with test wallets — only in dev."""
+ ...
+
+
+@service(profile="!prod")
+class VerboseAuditLogger:
+ """Detailed audit logging — active everywhere except prod."""
+ ...
+```
+
+!!! note "Nuevo término: bean"
+ Un "bean" es cualquier objeto que el contenedor de inyección de dependencias crea y gestiona por ti: un `@service`, `@repository`, `@command_handler`, etc. (el término viene directamente de Spring). "Con ámbito de perfil" significa que el contenedor solo crea el bean cuando un perfil que coincide está activo.
+
+`Environment.accepts_profiles()` evalúa las expresiones de perfil durante la primera pasada de `ApplicationContext.start()`. Los beans cuya expresión no coincide con el conjunto activo se eliminan antes de que tenga lugar cualquier resolución: nunca se instancian, nunca se cablean, nunca están presentes en el contenedor. El resultado es un contenedor estructuralmente diferente por entorno, sin ninguna sentencia `if` en el código de tu aplicación.
+
+!!! note "Lo que acaba de pasar"
+ Los perfiles te dieron dos herramientas independientes. Los *ficheros de superposición* (`pyfly-{name}.yaml`) cambian los *valores* de configuración por entorno. El parámetro `profile=` de un estereotipo cambia qué *beans existen* por entorno. Ambos están gobernados por el mismo conjunto de perfiles activos —establecido una sola vez vía `PYFLY_PROFILES_ACTIVE`—, de modo que una única variable de entorno remodela tanto tus ajustes como tu grafo de objetos, con cero lógica condicional en tu código de negocio.
+
+---
+
+## Ajustes con tipos seguros mediante @config_properties
+
+Las búsquedas por clave de cadena como `config.get("pyfly.data.relational.url")` funcionan para lecturas ocasionales, pero no escalan. Cada llamada es una lectura aislada sin información de tipos: debes acordarte de llamar a `float()` sobre el resultado, y una errata en una clave aflora en la primera petición en producción, no al arrancar. Para cualquier cosa más allá de un puñado de valores dispersos, el enfoque correcto es agrupar los ajustes relacionados en una clase Python tipada que se rellena una vez al arrancar y se inyecta donde haga falta.
+
+`@config_properties` resuelve exactamente esto enlazando una sección de configuración a una dataclass de Python tipada.
+
+!!! note "Nuevo término: enlace (binding)"
+ "Enlazar" (binding) significa copiar valores del árbol de configuración a los campos de un objeto tipado. Una `@dataclass` de Python es una clase cuyos campos se declaran con anotaciones de tipo (`url: str`, `pool_size: int`); tras el enlace, lees `props.pool_size` y obtienes un `int` de verdad, no una cadena que tienes que convertir tú mismo.
+
+### Declarar una clase de propiedades
+
+Decora una `@dataclass` con `@config_properties(prefix="...")`. El `prefix` identifica la sección de configuración que se va a enlazar; los nombres de los campos deben coincidir con las claves bajo esa sección (kebab/snake intercambiables).
+
+**Paso 1 — Escribe la clase.** Aquí está la propia `RelationalProperties` del framework, que enlaza el bloque `pyfly.data.relational.*`:
+
+::: listing pyfly/config/properties/data.py | Listado 3.5 — RelationalProperties: ajustes tipados para la capa de datos
+from dataclasses import dataclass
+
+from pyfly.core.config import config_properties
+
+
+@config_properties(prefix="pyfly.data.relational")
+@dataclass
+class RelationalProperties:
+ """Typed binding for pyfly.data.relational.*"""
+
+ enabled: bool = False
+ url: str = "sqlite+aiosqlite:///pyfly.db"
+ echo: bool = False
+ pool_size: int = 5
+:::
+
+El decorador establece `__pyfly_config_prefix__` en la clase y la marca como un bean inyectable. Los tipos de los campos deben ser `int`, `float`, `bool` o `str` para la coerción automática; los tipos más complejos se dejan tal cual.
+
+Fíjate en que cada campo lleva un valor por defecto que coincide con el `pyfly-defaults.yaml` integrado del framework. Esto es intencionado: la clase es autodocumentada y puede construirse y usarse en pruebas unitarias sin ningún fichero YAML en disco; basta con instanciar `RelationalProperties()` y obtienes los valores por defecto de desarrollo.
+
+**Paso 2 — Aplica el patrón a tus propios ajustes.** El mismo decorador funciona para la configuración a nivel de aplicación. Así sería una clase `WalletProperties` para las reglas de negocio de Lumen:
+
+```python
+from dataclasses import dataclass
+from pyfly.core import config_properties
+
+
+@config_properties(prefix="lumen.wallet")
+@dataclass
+class WalletProperties:
+ daily_transfer_limit: float = 10_000.0
+ default_currency: str = "USD"
+```
+
+**Paso 3 — Añade el YAML correspondiente.** Pon el bloque bajo la clave de nivel superior `lumen:` (fuera de `pyfly:`, ya que este es el espacio de nombres propio de tu aplicación, no el del framework) y el framework lo enlaza automáticamente, sin necesidad de ningún registro especial:
+
+```yaml
+lumen:
+ wallet:
+ daily-transfer-limit: 10000.0
+ default-currency: USD
+```
+
+### Enlazar e inyectar
+
+Llama a `config.bind(PropertiesClass)` para producir una instancia tipada y rellenada. `Config` está registrado como un bean singleton, así que puedes inyectarlo en cualquier servicio y enlazar desde ahí:
+
+::: listing lumen/wallet_service.py | Listado 3.6 — Inyectar RelationalProperties vía config.bind()
+from pyfly.container import service
+from pyfly.core import Config
+from pyfly.config.properties import RelationalProperties
+from lumen.models.repositories.wallet_repository import WalletRepository
+from pyfly.eda import EventPublisher
+
+
+@service
+class WalletService:
+ def __init__(
+ self,
+ repo: WalletRepository,
+ events: EventPublisher,
+ config: Config,
+ ) -> None:
+ self.repo = repo
+ self.events = events
+ self.db: RelationalProperties = config.bind(
+ RelationalProperties
+ )
+
+ async def transfer(
+ self, from_id: str, to_id: str, amount: float
+ ) -> dict:
+ if not self.db.enabled:
+ raise RuntimeError("Relational layer not enabled")
+ # ... perform transfer using self.db.url for diagnostics ...
+ return {"from": from_id, "to": to_id, "amount": amount}
+:::
+
+Cuando el contenedor de inyección de dependencias arranca Lumen, `WalletService.__init__` recibe el singleton `Config` compartido e inmediatamente llama a `config.bind(RelationalProperties)`. Esa llamada resuelve los valores enlazados una sola vez, al arrancar, y los almacena en `self.db`: una dataclass de Python sencilla con tipos reales. A partir de ese momento, `transfer()` lee `self.db.enabled` como un `bool`, con autocompletado completo del IDE y sin código de análisis en ninguna parte.
+
+`config.bind()` funciona en cinco pasos:
+
+1. Lee `__pyfly_config_prefix__` de la clase.
+2. Llama a `effective_section(prefix)` — una copia resuelta del subárbol con los marcadores `${...}` expandidos, las anulaciones de variables de entorno aplicadas y las claves solo de entorno inyectadas.
+3. Hace coincidir las claves de la sección con los campos de la dataclass mediante búsqueda relajada (kebab/snake intercambiables).
+4. Aplica coerción de tipos a los campos cuyos valores llegaron como cadenas (por ejemplo, desde variables de entorno).
+5. Construye la dataclass con los kwargs recopilados; los campos ausentes de la configuración usan los valores por defecto de la dataclass.
+
+El detalle crítico en el paso 2 es que `effective_section()` aplica la pila completa de cuatro capas —valores por defecto, fichero, superposición de perfil, variables de entorno— antes de que se construya la dataclass. Para cuando `bind()` termina, `RelationalProperties` refleja lo que sea que diga la superposición de producción o una variable de entorno en tiempo de ejecución, no solo el YAML base.
+
+!!! note "Lo que acaba de pasar"
+ Reemplazaste las búsquedas dispersas de cadena `config.get("…")` por un único objeto tipado. `config.bind(RelationalProperties)` lee toda la sección `pyfly.data.relational.*` una sola vez, aplica la precedencia de cuatro capas, fuerza las cadenas a los tipos correctos y te devuelve una dataclass sencilla. A partir de entonces tu servicio lee `self.db.enabled` como un `bool` con autocompletado del IDE, y una errata en el nombre de un campo se detecta al arrancar, no en el momento de la petición en producción.
+
+!!! note "Ejecútalo"
+ Puedes demostrar que la capa de variables de entorno alcanza una propiedad enlazada con una pequeña comprobación. El `pyfly.yaml` base establece `pyfly.data.relational.enabled: true`; aquí lo anulamos desde el entorno. Desde la raíz del proyecto Lumen:
+
+ ```bash
+ PYFLY_DATA_RELATIONAL_ENABLED=false uv run python -c "
+ from pyfly.core import Config
+ from pyfly.config.properties import RelationalProperties
+ db = Config.from_file('pyfly.yaml').bind(RelationalProperties)
+ print('enabled =', db.enabled, type(db.enabled).__name__)
+ "
+ ```
+
+ Salida esperada: la variable de entorno gana sobre el YAML y la cadena `"false"` se fuerza a un `bool`:
+
+ ```
+ enabled = False bool
+ ```
+
+### Inyectar valores individuales con Value
+
+Para ajustes aislados que no justifican una clase de propiedades completa, PyFly proporciona un descriptor `Value`. Decláralo como un campo a nivel de clase y el contenedor de inyección de dependencias lo resuelve en el momento de creación del bean, exactamente como el `@Value("${...}")` de Spring Boot:
+
+```python
+from pyfly.container import service
+from pyfly.core import Value
+
+
+@service
+class WalletService:
+ # Resolved from pyfly.app.name in the merged config.
+ app_name: str = Value("${pyfly.app.name}")
+ # Falls back to 10000 when the key is absent.
+ transfer_limit: float = Value(
+ "${lumen.wallet.daily-transfer-limit:10000}"
+ )
+```
+
+`Value("${key}")` lanza `KeyError` al arrancar cuando la clave falta y no se proporciona un valor por defecto: una garantía de fallo rápido que mantiene los bugs de configuración faltante fuera de producción. `Value("${key:default}")` usa el valor por defecto delimitado por dos puntos cuando la clave está ausente.
+
+### Coerción de tipos
+
+Los tipos nativos de YAML llegan correctamente tipados: los enteros, booleanos y flotantes no necesitan coerción. Cuando un valor llega de una variable de entorno (siempre una cadena) y el campo de destino tiene un tipo no de cadena, `bind()` aplica la coerción automáticamente:
+
+| Tipo de destino | Regla de coerción |
+|---|---|
+| `int` | `int(value)` |
+| `float` | `float(value)` |
+| `bool` | `value.lower() in ("true", "1", "yes")` |
+| `str` | no se necesita coerción |
+
+La ruta de dataclass de `bind()` trata `"true"`, `"1"` y `"yes"` como `True`; la ruta de anulación de `get()`/variable de entorno en tiempo de lectura admite además `"on"`.
+
+Llamar a `bind()` sobre una clase no decorada con `@config_properties` lanza `ValueError` inmediatamente: una señal clara de fallo rápido al arrancar en lugar de un bug silencioso de valor incorrecto en el momento de la petición.
+
+!!! spring "Equivalencia con Spring"
+ `@config_properties` es la respuesta de PyFly al `@ConfigurationProperties` de Spring Boot. El modelo mental es idéntico: anota un POJO (aquí, una dataclass) con un prefijo y el framework le enlaza la sección de configuración correspondiente con coerción de tipos completa. `Value("${...}")` se corresponde con el `@Value("${...}")` de Spring: misma sintaxis de expresión, misma garantía de fallo rápido ante valores faltantes. La combinación de `pyfly.yaml` + superposiciones de perfil + `@config_properties` + `Value` se corresponde con `application.yaml` + `application-{profile}.yaml` + `@ConfigurationProperties` + `@Value`: los mismos conceptos, con modismos pythónicos.
+
+---
+
+## Variables de entorno y secretos
+
+Los ficheros son el hogar adecuado para la configuración que varía por entorno pero que es seguro versionar: puertos, niveles de log, nombres de host de bases de datos. Son el hogar equivocado para los secretos: las contraseñas, las claves de API, los tokens de firma y las credenciales de bases de datos nunca deben entrar en el control de versiones. La cuarta capa de la pila de configuración existe específicamente para recibir estos valores en el momento del despliegue, desde un gestor de secretos o un pipeline de CI/CD, sin que ninguno de ellos toque el sistema de ficheros.
+
+Las variables de entorno son la cuarta capa y la de mayor prioridad. PyFly las comprueba en cada llamada a `Config.get()` —en el momento de lectura, no al arrancar—, de modo que siempre ganan, incluso cuando se establecen después de que el proceso empiece.
+
+### Convención de nombres
+
+Cada clave de configuración con notación de puntos se corresponde con una variable de entorno con prefijo `PYFLY_` mediante una transformación mecánica de tres pasos:
+
+1. Elimina el prefijo `pyfly.` (si está presente).
+2. Reemplaza los puntos (`.`) y guiones (`-`) por guiones bajos (`_`).
+3. Pon el resultado en mayúsculas y antepón `PYFLY_`.
+
+| Clave de configuración | Variable de entorno |
+|---|---|
+| `pyfly.app.name` | `PYFLY_APP_NAME` |
+| `pyfly.server.port` | `PYFLY_SERVER_PORT` |
+| `pyfly.management.server.port` | `PYFLY_MANAGEMENT_SERVER_PORT` |
+| `pyfly.web.debug` | `PYFLY_WEB_DEBUG` |
+| `pyfly.data.relational.url` | `PYFLY_DATA_RELATIONAL_URL` |
+| `pyfly.data.relational.pool-size` | `PYFLY_DATA_RELATIONAL_POOL_SIZE` |
+| `pyfly.logging.level.root` | `PYFLY_LOGGING_LEVEL_ROOT` |
+| `pyfly.eda.provider` | `PYFLY_EDA_PROVIDER` |
+| `pyfly.profiles.active` | `PYFLY_PROFILES_ACTIVE` |
+
+Para las claves específicas de la aplicación que no empiezan por `pyfly.`, la ruta completa con puntos se transforma de la misma manera (sin eliminar prefijo):
+
+```
+lumen.wallet.daily-transfer-limit
+ → PYFLY_LUMEN_WALLET_DAILY_TRANSFER_LIMIT
+```
+
+La regla es consistente: cuando necesitas decirle a un operador de Kubernetes qué variable de entorno controla un ajuste dado, la respuesta es siempre "aplica la transformación de tres pasos" en lugar de rebuscar en el código fuente del framework.
+
+### Las variables de entorno siempre ganan
+
+Activar el perfil de producción y anular la URL de la base de datos para una instancia de contenedor concreta es un único comando:
+
+```bash
+PYFLY_PROFILES_ACTIVE=prod \
+ PYFLY_DATA_RELATIONAL_URL="postgresql+asyncpg://rds-prod:5432/lumen" \
+ PYFLY_SERVER_PORT=8080 \
+ uv run pyfly run
+```
+
+Aquí, `PYFLY_SERVER_PORT=8080` anula el `port: 443` de la superposición de prod. La pila de precedencia se resuelve así:
+
+1. Valores por defecto del framework → `port: 8080`
+2. Configuración base → `port: 8080` (sin cambios)
+3. Superposición de prod → `port: 443`
+4. Variable de entorno → `port: 8080` (gana)
+
+El puerto efectivo es `8080`. Este es un patrón útil durante una migración por fases: mantén `port: 443` en la superposición como el valor por defecto de producción previsto y luego usa una variable de entorno temporal para mantener el servicio en `8080` para un experimento de división de tráfico. Cuando el experimento termina, elimina la variable de entorno y la superposición toma el control: sin necesidad de editar ficheros.
+
+### Mantener los secretos fuera de los ficheros
+
+Los ficheros de configuración nunca deben contener credenciales, claves de API o secretos de firma. El `pyfly-defaults.yaml` se distribuye con un secreto JWT de marcador de posición (`"change-me-in-production"`) que existe solo para mantener el framework ejecutable nada más sacarlo de la caja. Reemplázalo antes de pasar a producción:
+
+```bash
+PYFLY_SECURITY_JWT_SECRET="$(vault kv get -field=jwt_secret secret/lumen)"
+```
+
+!!! warning "Nunca versiones secretos"
+ No pongas contraseñas, claves de API, credenciales de bases de datos ni secretos JWT en `pyfly.yaml`, `pyfly-prod.yaml` ni en ningún fichero que entre en el control de versiones. Usa variables de entorno provenientes de un gestor de secretos (HashiCorp Vault, AWS Secrets Manager, Kubernetes Secrets o similar). La capa de variables de entorno existe precisamente para recibir estos valores en el momento del despliegue, no en el momento del desarrollo.
+
+### Una nota sobre las claves solo de entorno
+
+`Config.bind()` también gestiona los valores que existen *solo* como variables de entorno, sin entrada correspondiente en ningún fichero YAML. `effective_section()` inyecta estas claves solo de entorno en la sección enlazada para que `bind()` vea el mismo valor que vería `get()`. Añade un nuevo campo a una clase `@config_properties`, establécelo exclusivamente vía una variable de entorno en tu pipeline de despliegue, y se rellena correctamente incluso cuando los ficheros YAML aún no se han actualizado:
+
+```bash
+# No YAML entry for pyfly.data.relational.echo?
+# Set it exclusively via env var — bind() still picks it up.
+PYFLY_DATA_RELATIONAL_ECHO=true uv run pyfly run
+```
+
+Esta es una escotilla de escape práctica durante despliegues incrementales: el equipo que despliega puede inyectar un nuevo valor antes de que el fichero YAML se actualice y se revise, y la aplicación lo toma sin un cambio de código.
+
+!!! warning "Nombres de campo con varias palabras e inyección solo de entorno"
+ La inyección solo de entorno trata cada guion bajo en un nombre `PYFLY_*` como un separador de ruta, de modo que `PYFLY_DATA_RELATIONAL_POOL_SIZE` se lee como la ruta anidada `pool` → `size`, no como el campo plano `pool_size`. Para un campo de una sola palabra como `echo` esto es inequívoco y `bind()` lo inyecta limpiamente. Para un campo de varias palabras como `pool_size`, dale a la clave un hogar real en tu YAML (aunque sea solo `pool-size: 5`) para que la variable de entorno anule una hoja existente en lugar de depender de la inyección solo de entorno. La lectura en tiempo de lectura `config.get("pyfly.data.relational.pool-size")` siempre devuelve el valor de entorno de todos modos, porque `get()` mapea la clave completa con puntos en un solo paso.
+
+---
+
+## Lo que construiste {.recap}
+
+Lumen ahora tiene una historia de configuración limpia a través de tres entornos. Un `pyfly.yaml` contiene la línea base compartida —`pyfly.app.name`, `pyfly.eda.provider`, `pyfly.data.relational.*` y cualquier otra perilla del framework que use Lumen—. `pyfly-dev.yaml`, `pyfly-test.yaml` y `pyfly-prod.yaml` contienen solo los deltas por entorno. Activar un perfil requiere una única variable de entorno (`PYFLY_PROFILES_ACTIVE=prod`). Los ajustes tipados viven en dataclasses `@config_properties`, enlazadas al arrancar con coerción de tipos completa, de modo que los servicios leen campos tipados en lugar de llamar a `float(os.environ.get(...))` en código de servicio disperso. Los valores individuales se inyectan limpiamente vía `Value("${key}")`, que falla rápido al arrancar cuando la clave falta. Los secretos se quedan en las variables de entorno, nunca en los ficheros.
+
+La pila de cuatro capas —valores por defecto → fichero → superposición de perfil → variables de entorno— te da un único modelo mental que funciona desde `pyfly run` en tu portátil hasta un contenedor blindado con secretos inyectados en el momento del despliegue, sin tocar una línea de lógica de negocio.
+
+---
+
+## Puertos de aplicación y de gestión
+
+PyFly separa el puerto de **aplicación** del puerto de **gestión**, reflejando
+los `server.port` / `management.server.port` de Spring Boot. Nada más sacarlo de la caja, la
+API de negocio escucha en `pyfly.server.port` (**8080**) mientras que los endpoints
+del actuator (`/actuator/*`) y el panel de administración (`/admin`) se sirven en un
+`pyfly.management.server.port` dedicado (**9090**). Esto mantiene las comprobaciones de salud,
+el raspado de Prometheus y la consola de administración fuera del puerto público: expones solo
+el 8080 a internet y accedes al 9090 desde dentro del clúster.
+
+| Clave | Variable de entorno | Por defecto | Propósito |
+|---|---|---|---|
+| `pyfly.server.port` | `PYFLY_SERVER_PORT` | `8080` | Puerto HTTP de la aplicación |
+| `pyfly.server.host` | `PYFLY_SERVER_HOST` | `0.0.0.0` | Dirección de enlace de la aplicación |
+| `pyfly.management.server.port` | `PYFLY_MANAGEMENT_SERVER_PORT` | `9090` | Puerto de gestión (actuator + admin) |
+| `pyfly.management.server.address` | `PYFLY_MANAGEMENT_SERVER_ADDRESS` | host de la app | Dirección de enlace de gestión |
+
+El puerto de gestión es un segundo escuchador **en proceso** —no procesos de worker
+adicionales—, que comparte el mismo bucle de eventos y los mismos beans, así que funciona con cualquier adaptador de
+servidor (Granian, Uvicorn, Hypercorn). Dos valores cambian la topología: establece
+`pyfly.management.server.port` **igual** al puerto de la app para servir todo en un
+único puerto (el comportamiento previo a la `v26.06.102`), o establécelo en **`-1`** para desactivar por completo los
+endpoints web de gestión.
+
+!!! warning "El puerto de gestión está abierto por defecto"
+ Por equivalencia con Spring Boot, el puerto de gestión (9090) está **abierto y
+ sin autenticación** por defecto: está pensado para vivir en una red interna
+ protegida por aislamiento de red, no por los filtros de login de la app. Antes de
+ exponerlo a cualquier lugar alcanzable, asegúralo con
+ `pyfly.management.security.enabled: true`, que aplica los filtros de seguridad,
+ sesión y CSRF de la app también al puerto de gestión. Por defecto, solo los
+ endpoints del actuator `health` e `info` se exponen por HTTP; amplíalos con
+ `pyfly.management.endpoints.web.exposure.include` (por ejemplo
+ `"health,info,metrics,prometheus"`).
+
+!!! note "Ejecútalo"
+ Arranca Lumen y golpea el puerto de gestión directamente. El endpoint de salud vive en el
+ 9090, no en el 8080 de la aplicación:
+
+ ```bash
+ uv run pyfly run # in one terminal
+ curl http://localhost:9090/actuator/health # in another
+ ```
+
+ Salida esperada: un pequeño documento JSON que informa de que el servicio está activo:
+
+ ```json
+ {"status":"UP"}
+ ```
+
+ La misma ruta en el puerto de la app (`curl http://localhost:8080/actuator/health`)
+ no responderá, porque el actuator escucha solo en el puerto de gestión.
+
+!!! spring "Equivalencia con Spring"
+ `pyfly.server.port` ≡ `server.port` de Spring, `pyfly.server.host` ≡
+ `server.address`, y `pyfly.management.server.port` ≡
+ `management.server.port`. Establecer un puerto de gestión distinto ejecuta el actuator
+ en su propio conector, exactamente como hace Spring Boot. `pyfly.management.security.enabled`
+ y `pyfly.management.endpoints.web.exposure.include` reflejan los ajustes de seguridad de
+ gestión de Spring y `management.endpoints.web.exposure.include`.
+
+## Pruébalo tú mismo {.exercises}
+
+1. **Añade una superposición de staging.** Crea `pyfly-staging.yaml` con una URL de PostgreSQL para una base de datos de pruebas compartida bajo `pyfly.data.relational.url`, `pyfly.data.relational.enabled: true` y logging en `INFO`. Actívala con `PYFLY_PROFILES_ACTIVE=staging uv run pyfly run` y verifica desde el log de arranque que la fuente de staging se cargó. Compara la configuración efectiva con la que produciría la superposición de prod.
+
+2. **Enlaza una nueva propiedad tipada y úsala.** Añade un campo `max_wallets_per_owner: int = 5` a una nueva clase `WalletProperties` decorada con `@config_properties(prefix="lumen.wallet")`, y una clave correspondiente `lumen.wallet.max-wallets-per-owner: 5` en `pyfly.yaml` (fuera del bloque `pyfly:`). Inyecta `Config` en `WalletService`, llama a `config.bind(WalletProperties)` y añade una guarda en `open_wallet` que lance `ValueError` cuando el propietario ya posee el número máximo de monederos. Escribe una prueba rápida que anule el límite a `1` estableciendo `PYFLY_LUMEN_WALLET_MAX_WALLETS_PER_OWNER=1` y verificando que el error se dispara en el segundo monedero.
+
+3. **Anula un valor vía una variable de entorno y observa la precedencia.** Establece `PYFLY_SERVER_PORT=9090` antes de arrancar Lumen. Comprueba el log de arranque y confirma que el servidor se enlaza al `9090`, no al `8080` de `pyfly.yaml`. Luego desestablece la variable de entorno y reinicia: el puerto debería revertir a `8080`. Este ejercicio hace concreta la naturaleza en tiempo de lectura de la resolución de variables de entorno: la variable de entorno siempre gana, y eliminarla restaura inmediatamente el valor del fichero sin ningún cambio de código.
diff --git a/book/manuscript-es/04-first-http-api.md b/book/manuscript-es/04-first-http-api.md
new file mode 100644
index 00000000..b1e00666
--- /dev/null
+++ b/book/manuscript-es/04-first-http-api.md
@@ -0,0 +1,1031 @@
+Capítulo 4
+
+# Tu primera API HTTP {.chtitle}
+
+::: figure art/openers/ch04.svg |
+
+Lumen tiene los servicios cableados, una historia de configuración limpia y un
+ciclo de vida que abarca desde el desarrollo hasta la producción. Lo único que
+falta es una forma de que el mundo exterior hable con él. Este capítulo cierra
+la Parte I convirtiendo el dominio del monedero en una API REST limpia y
+validada: una con documentación OpenAPI automática, respuestas de error
+estructuradas en las que los clientes pueden confiar y las convenciones
+gestionadas por el framework que ya esperas del resto de PyFly.
+
+---
+
+## Controladores y mapeos de rutas
+
+Todo framework web debe responder a dos preguntas: ¿cómo encuentra una petición
+el manejador correcto, y cómo obtiene ese manejador las dependencias que
+necesita? Los frameworks que responden a estas preguntas con mecanismos
+separados te obligan a mantener un fichero de router, un fichero de pegamento de
+DI y un andamiaje de documentación en tres sitios distintos. PyFly colapsa las
+tres preocupaciones en una sola clase.
+
+Un **controlador** en PyFly es una clase Python corriente que el contenedor de
+DI gestiona y a la que la capa web enruta las peticiones. Dos decoradores lo
+marcan: `@rest_controller`, de `pyfly.container`, lo registra como bean y fija su
+estereotipo; `@request_mapping`, de `pyfly.web`, fija el prefijo de URL que
+hereda cada manejador de la clase.
+
+Algunos términos de ese párrafo reaparecerán a lo largo del capítulo, así que
+fijémoslos de una vez. Un **bean** es simplemente un objeto que el contenedor de
+DI crea y te entrega: tú nunca llamas a `WalletController()`; el framework lo
+construye y conserva una única instancia compartida. Un **estereotipo** es una
+etiqueta que el framework estampa en una clase para saber *qué tipo* de bean es:
+`@rest_controller` estampa «esto es un controlador web», que es la señal que la
+maquinaria de arranque utiliza para ir a buscar rutas dentro de él. Un
+**manejador** (handler) es un método `async def` del controlador que responde a
+un tipo de petición. Con esas tres palabras en la mano, el resto del capítulo se
+lee como prosa llana.
+
+Los manejadores de ruta son simples métodos `async def`, cada uno decorado con
+`@get_mapping`, `@post_mapping`, `@put_mapping`, `@patch_mapping` o
+`@delete_mapping`. Cada decorador de mapeo acepta una ruta relativa opcional y un
+`status_code` opcional. La URL completa es la ruta base de `@request_mapping`
+concatenada con la ruta relativa del decorador del método.
+
+### Un ejemplo de lectura previo a CQRS
+
+El Capítulo 7 introduce el bus completo de comandos/consultas CQRS que Lumen usa
+en producción. Aquí, en la Parte I, la mecánica de la capa web se enseña sobre el
+mismo dominio del monedero, respaldado por un almacén en memoria simple en lugar
+del bus. La estructura del controlador, los imports y las formas de los
+decoradores son *idénticos* a los del Capítulo 7; solo cambia el destino del
+despacho.
+
+::: listing lumen/web/controllers/wallet_controller.py | Listado 4.1 — WalletController usando los decoradores web reales de PyFly
+from __future__ import annotations
+
+from pyfly.container import rest_controller
+from pyfly.kernel import ResourceNotFoundException
+from pyfly.web import (
+ Body,
+ PathVar,
+ QueryParam,
+ Valid,
+ get_mapping,
+ post_mapping,
+ request_mapping,
+)
+
+from lumen.interfaces.dtos.v1.balance_dto import BalanceDto
+from lumen.interfaces.dtos.v1.deposit_request import DepositRequest
+from lumen.interfaces.dtos.v1.open_wallet_request import OpenWalletRequest
+from lumen.interfaces.dtos.v1.wallet_dto import WalletDto
+
+
+# ---------------------------------------------------------------------------
+# In-memory store (replaced by a database repository in Chapter 5)
+# ---------------------------------------------------------------------------
+_wallets: dict[str, WalletDto] = {}
+
+
+@rest_controller
+@request_mapping("/api/v1/wallets")
+class WalletController:
+ """Digital-wallet REST API: open, deposit, inspect.
+
+ In Part I the controller holds a minimal in-memory store so you can
+ focus on the web-layer mechanics — decorators, binding, validation, and
+ error handling — without persistence or CQRS machinery. Chapter 7
+ replaces the store with DefaultCommandBus / DefaultQueryBus dispatching.
+ """
+
+ @post_mapping("", status_code=201)
+ async def open_wallet(
+ self, request: Valid[Body[OpenWalletRequest]]
+ ) -> dict[str, str]:
+ import uuid
+ wallet_id = str(uuid.uuid4())
+ wallet = WalletDto(
+ id=wallet_id,
+ owner_id=request.owner_id,
+ currency=request.currency,
+ balance_minor=0,
+ balance=0.0,
+ created_at=__import__("datetime").datetime.now(
+ __import__("datetime").timezone.utc
+ ),
+ )
+ _wallets[wallet_id] = wallet
+ return {"wallet_id": wallet_id}
+
+ @get_mapping("/{wallet_id}")
+ async def get_wallet(self, wallet_id: PathVar[str]) -> WalletDto:
+ result = _wallets.get(wallet_id)
+ if result is None:
+ raise ResourceNotFoundException(
+ f"Wallet {wallet_id!r} not found",
+ code="WALLET_NOT_FOUND",
+ context={"wallet_id": wallet_id},
+ )
+ return result
+
+ @get_mapping("/{wallet_id}/balance")
+ async def get_balance(self, wallet_id: PathVar[str]) -> BalanceDto:
+ wallet = _wallets.get(wallet_id)
+ if wallet is None:
+ raise ResourceNotFoundException(
+ f"Wallet {wallet_id!r} not found",
+ code="WALLET_NOT_FOUND",
+ context={"wallet_id": wallet_id},
+ )
+ return BalanceDto(
+ id=wallet.id,
+ currency=wallet.currency,
+ balance_minor=wallet.balance_minor,
+ balance=wallet.balance,
+ )
+
+ @post_mapping("/{wallet_id}/deposit")
+ async def deposit(
+ self,
+ wallet_id: PathVar[str],
+ request: Valid[Body[DepositRequest]],
+ ) -> dict[str, int | str]:
+ wallet = _wallets.get(wallet_id)
+ if wallet is None:
+ raise ResourceNotFoundException(
+ f"Wallet {wallet_id!r} not found",
+ code="WALLET_NOT_FOUND",
+ context={"wallet_id": wallet_id},
+ )
+ new_balance = wallet.balance_minor + request.amount
+ _wallets[wallet_id] = wallet.model_copy(
+ update={
+ "balance_minor": new_balance,
+ "balance": new_balance / 100,
+ }
+ )
+ return {"wallet_id": wallet_id, "balance_minor": new_balance}
+
+ @get_mapping("")
+ async def list_wallets(
+ self,
+ owner_id: QueryParam[str] = None,
+ ) -> list[WalletDto]:
+ wallets = list(_wallets.values())
+ if owner_id is not None:
+ wallets = [w for w in wallets if w.owner_id == owner_id]
+ return wallets
+:::
+
+Vale la pena examinar cuatro decisiones de diseño de este listado.
+
+`@rest_controller` hace dos cosas a la vez: registra `WalletController` como bean
+singleton en el contenedor de DI y fija el marcador `__pyfly_stereotype__` que
+`ControllerRegistrar` utiliza para descubrir y montar rutas en el arranque.
+Emparejarlo con `@request_mapping("/api/v1/wallets")` significa que cada decorador
+a nivel de método hereda ese prefijo: escribes la ruta base una sola vez.
+
+Esta versión no tiene ningún `__init__` con colaboradores inyectados. El
+diccionario en memoria `_wallets` es un almacén a nivel de módulo que basta para
+la Parte I. El Capítulo 5 introduce los repositorios; el Capítulo 7 muestra el
+patrón de producción: un constructor que recibe `DefaultCommandBus` y
+`DefaultQueryBus` del contenedor de DI, despachando comandos y consultas a través
+del bus en lugar de leer `_wallets` directamente.
+
+Cada manejador devuelve un modelo Pydantic (`WalletDto`, `BalanceDto`) o un
+`dict` corriente. El framework serializa el valor de retorno a JSON y fija la
+cabecera `Content-Type`: el manejador nunca construye un objeto de respuesta. El
+argumento `status_code=201` de `@post_mapping` produce un 201 Created en caso de
+éxito; todos los demás manejadores usan 200 por defecto.
+
+Los cinco decoradores de mapeo aceptan los mismos dos parámetros:
+
+| Parámetro | Por defecto | Descripción |
+|---|---|---|
+| `path` | `""` | Ruta relativa añadida a la base. Usa `{name}` para variables de ruta. |
+| `status_code` | `200` | Código de estado HTTP para una respuesta correcta. |
+
+`@post_mapping("", status_code=201)` mapea `POST /api/v1/wallets` y devuelve 201
+en caso de éxito. `@get_mapping("/{wallet_id}")` mapea
+`GET /api/v1/wallets/{wallet_id}`. Las rutas se concatenan en el arranque; las
+barras duplicadas o finales se normalizan automáticamente.
+
+### Construir el controlador, paso a paso
+
+Si estás escribiendo esto desde cero, el listado anterior aterriza todo de golpe.
+Aquí tienes el mismo controlador ensamblado en el orden en que realmente lo
+construirías, para que cada decorador tenga un trabajo que hacer antes de que
+llegue el siguiente.
+
+**Paso 1 — Crea el fichero y la clase.** Crea
+`src/lumen/web/controllers/wallet_controller.py` y define una clase vacía
+decorada con los dos decoradores a nivel de clase. Esto basta para que PyFly
+descubra el controlador en el arranque, incluso antes de que tenga una sola ruta.
+
+```python
+from pyfly.container import rest_controller
+from pyfly.web import request_mapping
+
+
+@rest_controller
+@request_mapping("/api/v1/wallets")
+class WalletController:
+ """Digital-wallet REST API: open, deposit, inspect."""
+```
+
+**Paso 2 — Añade el primer manejador.** Dale a la clase un método `async def` y
+márcalo con un decorador de mapeo. `@post_mapping("", status_code=201)` mapea
+`POST /api/v1/wallets` —la ruta vacía significa «la ruta base sin nada
+añadido»— y promete un `201 Created` en caso de éxito.
+
+**Paso 3 — Añade los manejadores restantes.** Repite el patrón: un `async def`
+por ruta, cada uno con su propio decorador de mapeo y ruta relativa. El conjunto
+completo del Listado 4.1 te da abrir, obtener, saldo, depósito y listar.
+
+**Paso 4 — Conecta el almacén.** El diccionario `_wallets` a nivel de módulo es
+la única «base de datos» que necesita la Parte I. Cada manejador lee de él y
+escribe en él directamente; el Capítulo 5 lo cambia por un repositorio real sin
+tocar un solo decorador.
+
+!!! note "Nota"
+ Fíjate en lo que *no* escribiste: ningún fichero de router que mapee URLs a
+ funciones, ninguna llamada de registro en `main.py`, ninguna entrada manual
+ de OpenAPI. Los decoradores son el registro. En el arranque,
+ `ControllerRegistrar` encuentra cada bean `@rest_controller` y monta sus
+ rutas por ti.
+
+!!! tip "Pruébalo"
+ Arranca el servidor y confirma que las rutas están activas. Desde la raíz
+ del proyecto:
+
+ ```bash
+ uv run pyfly run --server uvicorn
+ ```
+
+ El banner de arranque informa de la versión del framework y del puerto al
+ que está enlazado:
+
+ ```
+ :: PyFly Framework :: (v26.06.110) (Python 3.13.13)
+ ```
+
+ En una segunda terminal, abre un monedero y vuelve a leerlo:
+
+ ```bash
+ curl -s -X POST localhost:8080/api/v1/wallets \
+ -H 'Content-Type: application/json' \
+ -d '{"owner_id": "alice", "currency": "EUR"}'
+ ```
+
+ Deberías ver un cuerpo `201` con el id generado:
+
+ ```json
+ {"wallet_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"}
+ ```
+
+ Copia ese id y obtén el monedero:
+
+ ```bash
+ curl -s localhost:8080/api/v1/wallets/a1b2c3d4-e5f6-7890-abcd-ef1234567890
+ ```
+
+ ```json
+ {"id": "a1b2c3d4-...", "owner_id": "alice", "currency": "EUR",
+ "balance_minor": 0, "balance": 0.0, "created_at": "2026-06-15T10:30:00+00:00"}
+ ```
+
+**Qué acaba de pasar.** Dos decoradores a nivel de clase registraron el
+controlador y fijaron su prefijo de URL; un decorador a nivel de método convirtió
+un `async def` en una ruta activa; el framework hizo el enrutamiento, la
+serialización JSON y la gestión del código de estado. Escribiste la intención de
+negocio, no la fontanería.
+
+::: figure art/figures/04-request.svg | Figura 4.1 — Cómo fluye una petición hasta tu manejador.
+
+!!! spring "Equivalencia con Spring"
+ `@rest_controller` + `@request_mapping` + `@get_mapping` / `@post_mapping`
+ es una traducción directa de `@RestController` + `@RequestMapping` +
+ `@GetMapping` / `@PostMapping` de Spring. Los métodos manejadores devuelven
+ valores directamente (no `ResponseEntity`) y el framework los convierte a
+ JSON: exactamente el patrón que Spring fomenta con `@ResponseBody` en
+ `@RestController`.
+
+---
+
+## Vincular los datos de la petición
+
+Una petición transporta datos en varios sitios a la vez: la ruta de la URL
+identifica el recurso, la cadena de consulta lleva filtros y paginación, el
+cuerpo lleva la carga útil y las cabeceras llevan metadatos. La mayoría de los
+frameworks abordan esto con mecanismos separados, cada uno con sus propias
+convenciones. PyFly los unifica bajo una sola idea: **las anotaciones de tipo
+genéricas en los parámetros del manejador declaran de dónde vienen los datos**.
+
+Este enfoque hace que las firmas de los manejadores se documenten a sí mismas. La
+lista de parámetros de cualquier manejador te dice exactamente qué partes de la
+petición lee y qué tipos espera, sin abrir un fichero de router ni consultar la
+documentación.
+
+En términos llanos, **vincular** (binding) es que el framework copia una pieza de
+la petición entrante en uno de los parámetros de tu manejador, convirtiéndola al
+tipo que pediste por el camino. Tú declaras *qué quieres y de dónde viene* con una
+anotación de tipo; PyFly hace la extracción, el parseo y la coerción de tipos
+antes de que se ejecute el cuerpo de tu método.
+
+El `ParameterResolver` inspecciona la firma de cada manejador en el arranque y
+construye un plan de resolución, de modo que no hay sobrecarga de introspección
+por petición. Cinco tipos de vinculación cubren cada parte de una petición HTTP:
+
+### PathVar[T] — variables de ruta
+
+Extrae un segmento con nombre de la ruta de la URL. El nombre del parámetro debe
+coincidir con un `{placeholder}` de la ruta.
+
+```python
+@get_mapping("/{wallet_id}")
+async def get_wallet(self, wallet_id: PathVar[str]) -> WalletDto:
+ ...
+
+@get_mapping("/{wallet_id}/transactions/{txn_id}")
+async def get_transaction(
+ self,
+ wallet_id: PathVar[str],
+ txn_id: PathVar[str],
+) -> dict:
+ ...
+```
+
+`PathVar` coacciona el segmento de cadena en bruto a `T` automáticamente.
+`PathVar[int]`, `PathVar[float]` y `PathVar[UUID]` funcionan todos: la coerción
+llama a `int(value)`, `float(value)` y `UUID(value)` respectivamente.
+
+### QueryParam[T] — parámetros de consulta
+
+Extrae un valor de la cadena de consulta, con soporte para valores por defecto y
+valores opcionales.
+
+```python
+@get_mapping("")
+async def list_wallets(
+ self,
+ owner_id: QueryParam[str] = None,
+ page: QueryParam[int] = 1,
+ size: QueryParam[int] = 20,
+) -> list[WalletDto]:
+ ...
+```
+
+Un parámetro es **obligatorio** cuando no tiene un valor por defecto en Python y
+su tipo no admite `None`. Un `QueryParam` obligatorio ausente lanza
+`InvalidRequestException` (HTTP 400). Para hacer un parámetro opcional, dale un
+valor por defecto o anótalo como `QueryParam[str | None]`.
+
+!!! tip "Pruébalo"
+ Con el servidor en marcha y al menos un monedero abierto, ejercita el
+ manejador `list_wallets`: primero sin filtro, luego con el parámetro de
+ consulta opcional `owner_id`:
+
+ ```bash
+ curl -s 'localhost:8080/api/v1/wallets'
+ curl -s 'localhost:8080/api/v1/wallets?owner_id=alice'
+ ```
+
+ El primero devuelve todos los monederos; el segundo devuelve solo los de
+ Alice. Como `owner_id` tiene un valor por defecto de `None`, omitirlo es
+ perfectamente válido: ningún 400. La variable de ruta se comporta igual a la
+ inversa: pide un id de monedero que no existe y obtendrás un `404` limpio,
+ que la siguiente sección disecciona.
+
+### Body[T] — cuerpo de la petición
+
+Deserializa el cuerpo de la petición JSON (o XML). Cuando `T` es un `BaseModel`
+de Pydantic, se llama a `model_validate_json()` automáticamente.
+
+```python
+@post_mapping("", status_code=201)
+async def open_wallet(
+ self, request: Valid[Body[OpenWalletRequest]]
+) -> dict[str, str]:
+ ...
+```
+
+### Header[T] y Cookie[T]
+
+Extraen valores de las cabeceras y cookies de la petición. Para las cabeceras, el
+nombre del parámetro se convierte de `snake_case` a `kebab-case` automáticamente:
+
+```python
+@get_mapping("/me")
+async def get_my_wallets(
+ self,
+ x_api_key: Header[str],
+ session_id: Cookie[str | None],
+) -> list[WalletDto]:
+ ...
+```
+
+`x_api_key: Header[str]` lee la cabecera `x-api-key`. Una cabecera o cookie
+obligatoria ausente lanza `InvalidRequestException` (HTTP 400), igual que un
+parámetro de consulta ausente.
+
+!!! tip "Consejo"
+ Los cinco tipos de vinculación siguen la misma regla de **obligatorio frente
+ a opcional**: sin valor por defecto + tipo que no admite `None` = obligatorio
+ (HTTP 400 cuando está ausente); cualquier valor por defecto o `T | None` =
+ opcional. La regla es uniforme en `QueryParam`, `Header` y `Cookie`: la
+ aprendes una vez y se aplica en todas partes.
+
+**Qué acaba de pasar.** Aprendiste todo el vocabulario de vinculación como cinco
+anotaciones paralelas —`PathVar`, `QueryParam`, `Body`, `Header`, `Cookie`—
+que se leen como prosa en la firma de un manejador y comparten una única regla de
+obligatorio-frente-a-opcional. El framework lee la anotación, saca el valor del
+sitio correcto, lo coacciona a tu tipo y te entrega un argumento listo para usar.
+No hay nada más que cablear.
+
+---
+
+## Validación con Valid[T]
+
+La vinculación le dice al framework *de dónde* vienen los datos. La validación le
+dice *qué aspecto deben tener esos datos* antes de que tu manejador los vea
+siquiera. Sin una capa que intercepte la entrada incorrecta pronto, la lógica de
+validación se dispersa por los métodos de servicio, bloques `if` manuales
+ensucian el código de negocio y distintos manejadores producen respuestas de
+error inconsistentes según dónde resulte que capturen el problema.
+
+PyFly resuelve esto a nivel de tipo. El `BaseModel` de Pydantic te da
+restricciones a nivel de campo gratis. `Valid[T]` es el marcador de PyFly que
+convierte un `ValidationError` de Pydantic en una **respuesta 422 estructurada**
+en lugar de dejar que escale a un 500.
+
+Una breve glosa antes del código. Un **DTO** —Data Transfer Object, objeto de
+transferencia de datos— es una clase pequeña que describe la *forma* de los datos
+que cruzan el cable: qué campos debe llevar una petición, o qué campos devolverá
+una respuesta. Los DTO de Lumen son modelos Pydantic corrientes, de modo que las
+declaraciones de campo hacen también de reglas de validación. La **validación** es
+el acto de comprobar los datos entrantes contra esas reglas y rechazarlos
+limpiamente si no encajan, antes de que se ejecute nada del código de tu
+manejador.
+
+### DTO de Pydantic para Lumen
+
+Los DTO de petición y respuesta usados en la API del monedero de Lumen viven bajo
+`lumen/interfaces/dtos/v1/`: un fichero por DTO. El nombre del directorio codifica
+una convención que vale la pena señalar: `interfaces` contiene los contratos que
+ve el mundo exterior, y `v1` los versiona para que una futura forma de carga útil
+`v2` pueda convivir junto a la antigua sin romper a los clientes existentes. Aquí
+los tienes completos.
+
+::: listing lumen/interfaces/dtos/v1/open_wallet_request.py | Listado 4.2a — OpenWalletRequest: carga útil de apertura de monedero
+from __future__ import annotations
+
+from pydantic import BaseModel, Field
+
+from lumen.interfaces.enums.v1.currency import Currency
+
+
+class OpenWalletRequest(BaseModel):
+ """Wallet-opening request payload."""
+
+ owner_id: str = Field(
+ min_length=1,
+ max_length=64,
+ description="Identifier of the wallet owner",
+ )
+ currency: Currency = Field(
+ default=Currency.EUR,
+ description="ISO-4217 currency the wallet holds",
+ )
+:::
+
+::: listing lumen/interfaces/dtos/v1/deposit_request.py | Listado 4.2b — DepositRequest: carga útil de depósito/retirada
+from __future__ import annotations
+
+from pydantic import BaseModel, Field
+
+
+class DepositRequest(BaseModel):
+ """Deposit/withdrawal request payload.
+
+ Shared by POST /{id}/deposit and POST /{id}/withdraw — both move a
+ positive amount of money in the wallet's own currency.
+ """
+
+ amount: int = Field(
+ gt=0,
+ description="Amount in minor units (cents); must be positive",
+ )
+:::
+
+::: listing lumen/interfaces/dtos/v1/wallet_dto.py | Listado 4.2c — WalletDto: respuesta completa del monedero
+from __future__ import annotations
+
+from datetime import datetime
+
+from pydantic import BaseModel
+
+from lumen.interfaces.enums.v1.currency import Currency
+
+
+class WalletDto(BaseModel):
+ """Full wallet representation returned to clients.
+
+ ``balance_minor`` is in minor units (cents); ``balance`` is the same
+ value rendered as a major-unit decimal for human-friendly display.
+ """
+
+ id: str
+ owner_id: str
+ currency: Currency
+ balance_minor: int
+ balance: float
+ created_at: datetime
+:::
+
+::: listing lumen/interfaces/dtos/v1/balance_dto.py | Listado 4.2d — BalanceDto: proyección ligera del saldo
+from __future__ import annotations
+
+from pydantic import BaseModel
+
+from lumen.interfaces.enums.v1.currency import Currency
+
+
+class BalanceDto(BaseModel):
+ """Lightweight balance projection for the balance endpoint."""
+
+ id: str
+ currency: Currency
+ balance_minor: int
+ balance: float
+:::
+
+Estos son modelos Pydantic puros: PyFly no les añade nada. Vale la pena
+desglosar cuatro decisiones de diseño.
+
+`OpenWalletRequest.owner_id` usa `Field(min_length=1, max_length=64)`. El límite
+inferior evita monederos fantasma a partir de IDs de propietario en cadena vacía
+que contaminarían silenciosamente tus datos; el límite superior mantiene los
+identificadores dentro de un ancho de columna razonable cuando llegue la capa de
+base de datos en el Capítulo 5.
+
+`currency` es un enum `Currency` (un `StrEnum` con `EUR`, `USD`, `GBP`). Usar un
+enum en lugar de una cadena en bruto significa que Pydantic rechaza `"XYZ"` en el
+momento de la deserialización: nunca validas el código de divisa tú mismo.
+`Field(default=Currency.EUR)` proporciona un valor por defecto razonable para que
+los llamantes puedan omitir el campo en monederos en EUR.
+
+`DepositRequest.amount` es un `int` con `Field(gt=0)`. Almacenar el dinero en
+unidades menores evita el redondeo de coma flotante: `1050` significa 10,50 € en
+un monedero en EUR. La restricción `gt=0` convierte un depósito cero o negativo
+en un error de cliente 422, no en una decisión de lógica de negocio: la
+restricción vive en el tipo, y Pydantic la aplica antes de que se ejecute tu
+manejador.
+
+`WalletDto` y `BalanceDto` son modelos de respuesta. Devolver un modelo Pydantic
+tipado en lugar de un `dict` corriente permite al framework generar esquemas de
+respuesta OpenAPI precisos y da a los clientes un contrato legible por máquina.
+
+### Usar Valid[T] en un manejador
+
+Envuelve `Body[T]` en `Valid` para optar por errores 422 estructurados cuando
+falle la validación:
+
+```python
+@post_mapping("", status_code=201)
+async def open_wallet(
+ self, request: Valid[Body[OpenWalletRequest]]
+) -> dict[str, str]:
+ ...
+
+@post_mapping("/{wallet_id}/deposit")
+async def deposit(
+ self,
+ wallet_id: PathVar[str],
+ request: Valid[Body[DepositRequest]],
+) -> dict[str, int | str]:
+ ...
+```
+
+`Valid[Body[OpenWalletRequest]]` le dice dos cosas al resolver: vincula desde el
+cuerpo de la petición (`Body`) y ejecuta la validación de Pydantic antes de que
+se ejecute el manejador (`Valid`). Cuando el cuerpo falla la validación, el
+resolver captura el `ValidationError` y lanza una `ValidationException` con
+`code="VALIDATION_ERROR"` y un array `context.errors` que contiene cada detalle a
+nivel de campo.
+
+### Lo que ve el cliente en caso de fallo
+
+Para ver la validación en acción, envía un `POST /api/v1/wallets` con un
+`owner_id` vacío:
+
+```
+POST /api/v1/wallets
+Content-Type: application/json
+
+{"owner_id": ""}
+```
+
+!!! tip "Pruébalo"
+ Con el servidor en marcha, envía la carga útil incorrecta y observa el `422`:
+
+ ```bash
+ curl -s -w '\nHTTP %{http_code}\n' -X POST localhost:8080/api/v1/wallets \
+ -H 'Content-Type: application/json' \
+ -d '{"owner_id": ""}'
+ ```
+
+ La opción `-w '\nHTTP %{http_code}\n'` imprime la línea de estado después del
+ cuerpo, para que puedas confirmar que es `HTTP 422`: no el `201` que devuelve
+ una petición válida, ni un `500`. El cuerpo es el sobre estructurado que se
+ muestra a continuación. Prueba una segunda variante —
+ `-d '{"owner_id": "alice", "currency": "XYZ"}'`— para ver cómo el enum
+ `Currency` rechaza un código desconocido con la misma forma de sobre.
+
+La respuesta es HTTP 422:
+
+```json
+{
+ "error": {
+ "message": "Validation failed: owner_id: String should have at least 1 character",
+ "code": "VALIDATION_ERROR",
+ "status": 422,
+ "path": "/api/v1/wallets",
+ "timestamp": "2026-06-07T10:30:00+00:00",
+ "transaction_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
+ "context": {
+ "errors": [
+ {
+ "type": "string_too_short",
+ "loc": ["owner_id"],
+ "msg": "String should have at least 1 character",
+ "input": "",
+ "ctx": {"min_length": 1}
+ }
+ ]
+ }
+ }
+}
+```
+
+Cada error de campo lleva un `type` (legible por máquina), un `loc` (ruta hasta el
+campo que falla), un `msg` (legible por humanos) y un `input` (el valor
+rechazado). Los consumidores de la API parsean este array de forma determinista:
+sin rascar cadenas de error.
+
+La diferencia entre `Body[T]` a secas y `Valid[Body[T]]` es exactamente esta:
+
+| Anotación | Cuando falla la validación |
+|---|---|
+| `Body[T]` | Se propaga el `ValidationError` de Pydantic en bruto: puede convertirse en un 500 sin gestión adicional |
+| `Valid[Body[T]]` | Capturado, convertido en `ValidationException`, produce siempre un 422 estructurado |
+
+Usa `Valid[Body[T]]` en cada endpoint que acepte entrada del usuario.
+
+!!! spring "Equivalencia con Spring"
+ `Valid[Body[T]]` mapea directamente a la combinación de `@Valid` +
+ `@RequestBody` de Spring en un método `@RestController`. En Spring escribes
+ `@PostMapping public ResponseEntity create(@Valid @RequestBody OpenWalletRequest body)`;
+ en PyFly escribes
+ `async def open_wallet(self, request: Valid[Body[OpenWalletRequest]])`. La
+ forma de la respuesta 422 (errores a nivel de campo con rutas de ubicación)
+ refleja la carga útil de `MethodArgumentNotValidException` de Spring Boot 3.
+
+**Qué acaba de pasar.** Las reglas de validación nunca salieron del DTO.
+`Field(min_length=1)` en `owner_id`, el enum `Currency` y `Field(gt=0)` en
+`amount` son toda la especificación, y envolver el cuerpo en `Valid` convirtió
+cualquier infracción de esas reglas en un `422` predecible y legible por máquina
+antes de que se ejecutara tu manejador. Escribiste las restricciones una vez,
+sobre los datos; el framework las aplicó en todas partes donde llegan los datos.
+
+---
+
+## Errores en los que los clientes pueden confiar
+
+Una API bien diseñada falla de forma ruidosa, consistente e informativa. Los
+clientes nunca deben parsear trazas de pila de excepciones ni adivinar qué fue
+mal a partir de un 500 genérico. El reto es lograr esto sin dispersar lógica
+específica de HTTP por todo tu código de servicio: el código de estado HTTP es
+una preocupación de infraestructura, no de negocio.
+
+La jerarquía de excepciones de PyFly es la columna vertebral de su historia de
+errores. Cada excepción del árbol lleva tres cosas: un `message` legible por
+humanos, un `code` legible por máquina y un dict `context` opcional para detalle
+de depuración. El manejador global de excepciones de la capa web mapea cada
+subclase al código de estado HTTP correcto automáticamente: tú haces `raise`, el
+framework responde.
+
+### El árbol de excepciones
+
+```
+PyFlyException
+├── BusinessException → 400 (catch-all)
+│ ├── ValidationException → 422
+│ ├── ResourceNotFoundException → 404
+│ ├── ConflictException → 409
+│ ├── InvalidRequestException → 400
+│ └── ...
+├── SecurityException → 403
+│ ├── UnauthorizedException → 401
+│ └── ForbiddenException → 403
+└── InfrastructureException → 502 (catch-all)
+ ├── ServiceUnavailableException → 503
+ ├── CircuitBreakerException → 503
+ └── ...
+```
+
+La jerarquía es deliberadamente plana. `BusinessException` cubre cualquier cosa
+que sea culpa del llamante; `InfrastructureException` cubre cualquier cosa que sea
+culpa del sistema. Las subclases fijan el código de estado. Cuando un nuevo error
+de dominio no encaja en una subclase existente, extiende el padre más cercano y el
+código de estado viene gratis.
+
+Impórtalas de `pyfly.kernel`:
+
+```python
+from pyfly.kernel import (
+ ResourceNotFoundException,
+ ConflictException,
+ ValidationException,
+ InvalidRequestException,
+)
+```
+
+Lánzalas desde el código del manejador sin preocuparte por HTTP:
+
+```python
+raise ResourceNotFoundException(
+ f"Wallet {wallet_id!r} not found",
+ code="WALLET_NOT_FOUND",
+ context={"wallet_id": wallet_id},
+)
+```
+
+El manejador global la captura, la mapea a 404 y emite una respuesta JSON
+estructurada:
+
+```json
+{
+ "error": {
+ "message": "Wallet 'w-999' not found",
+ "code": "WALLET_NOT_FOUND",
+ "status": 404,
+ "path": "/api/v1/wallets/w-999",
+ "timestamp": "2026-06-07T10:30:00+00:00",
+ "transaction_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
+ "context": {
+ "wallet_id": "w-999"
+ }
+ }
+}
+```
+
+!!! tip "Pruébalo"
+ Pide un monedero que nunca se abrió y observa cómo el framework convierte tu
+ `raise` en un `404` limpio:
+
+ ```bash
+ curl -s -w '\nHTTP %{http_code}\n' localhost:8080/api/v1/wallets/w-999
+ ```
+
+ La línea de estado dice `HTTP 404` y el cuerpo es el sobre de arriba, con
+ `"code": "WALLET_NOT_FOUND"` y tu `context` trasladado al pie de la letra.
+ Nunca escribiste un código de estado en `get_wallet`:
+ `ResourceNotFoundException` se mapea a 404 por ti. Fíjate en el
+ `transaction_id` de la respuesta; cópialo y haz grep en el log de tu servidor
+ para encontrar la petición exacta.
+
+El `transaction_id` es gratis: el `TransactionIdFilter` asigna un UUID a cada
+petición y lo enhebra por todas las respuestas de error. Los clientes lo
+registran; el soporte lo usa para encontrar la entrada de log de servidor
+correspondiente. Un solo ID es todo lo que se necesita para reconstruir lo que
+pasó.
+
+**Qué acaba de pasar.** Tu manejador expresó un hecho de dominio —«este monedero
+no existe»— lanzando una excepción tipada con un mensaje, un código y algo de
+contexto. El manejador global de la capa web hizo la traducción a HTTP: eligió el
+código de estado a partir de la clase de la excepción, lo envolvió todo en el
+sobre de error estándar y estampó un `transaction_id`. Las preocupaciones de HTTP
+se mantuvieron completamente fuera de tu código de negocio.
+
+!!! note "RFC 7807"
+ El sobre de error por defecto —`{"error": {...}}`— es el formato propio de
+ PyFly. Si tu equipo prefiere el estándar del IETF, fija
+ `pyfly.web.problem-details.enabled: true` en `pyfly.yaml`. Con esa opción
+ activada, la misma `ResourceNotFoundException` produce una respuesta
+ `application/problem+json` con `type`, `title`, `status`, `detail` e
+ `instance` como los miembros estándar de RFC 7807, más `code` y
+ `transactionId` como miembros de extensión de PyFly. Ambos modos usan la
+ misma jerarquía de excepciones y el mismo mapeo de estados.
+
+---
+
+## Negociación de contenido y OpenAPI
+
+### JSON y XML
+
+Devolver un `dict` o un modelo Pydantic no es del todo el final de la historia.
+En algún punto entre la sentencia `return` de tu manejador y los bytes que recibe
+el cliente, el framework decide un formato de cable. En lugar de codificar JSON a
+fuego, PyFly pasa el valor de retorno por una cadena ordenada de
+`HttpMessageConverter`: importante para APIs empresariales que deben servir a
+socios de XML o negociar el formato más ligero para clientes móviles.
+
+JSON es el predeterminado. Cuando no se envía ninguna cabecera `Accept`, la
+respuesta es `application/json`. Cuando el cliente envía
+`Accept: application/xml`, el conversor de XML toma el relevo y serializa el mismo
+valor de retorno como XML, sin cambio alguno en el código de tu manejador:
+
+```
+GET /api/v1/wallets/w-001 Accept: application/json
+ → {"id": "w-001", ...}
+
+GET /api/v1/wallets/w-001 Accept: application/xml
+ → w-001...
+```
+
+La misma negociación se aplica a los datos entrantes: un parámetro `Body[T]` o
+`Valid[Body[T]]` acepta cuerpos de petición tanto con
+`Content-Type: application/json` como con `Content-Type: application/xml`. JSON es
+el recurso de reserva cuando no hay ninguna cabecera `Content-Type` presente.
+
+### Documentación autogenerada
+
+Las especificaciones de API mantenidas manualmente se desvían. A medida que
+cambian las rutas, se renombran los parámetros y se añaden nuevos modelos, las
+especificaciones escritas a mano se quedan atrás respecto al código. PyFly elimina
+esto por completo generando la documentación a partir de los mismos metadatos que
+impulsan el enrutamiento: la especificación está siempre sincronizada porque es la
+misma fuente.
+
+Tan pronto como Lumen arranca, tres endpoints de documentación quedan activos sin
+coste alguno:
+
+| Endpoint | Propósito |
+|---|---|
+| `/docs` | Swagger UI: documentación interactiva, pruébalo-ya |
+| `/redoc` | ReDoc: documentación de referencia limpia, de dos paneles |
+| `/openapi.json` | Especificación OpenAPI 3.0 en bruto |
+
+El `OpenAPIGenerator` introspecciona los metadatos de ruta de
+`ControllerRegistrar` —cada ruta, método, variable de ruta, parámetro de consulta
+y esquema de petición/respuesta (a partir de la introspección de modelos
+Pydantic)— y ensambla la especificación en el arranque. Nunca escribes la
+especificación a mano. Los endpoints de documentación viven en el puerto de la
+**aplicación** (8080) junto a tu API; están activados por defecto
+(`pyfly.web.docs.enabled: true`). Desactívalos en producción con
+`pyfly.web.docs.enabled: false` en `pyfly.yaml`.
+
+!!! note "Nota"
+ No confundas los endpoints de documentación con el **panel de
+ administración**. `/docs`, `/redoc` y `/openapi.json` describen *tu* API y se
+ sirven en el puerto de la app (8080). El Panel de Administración de PyFly
+ (`/admin`) y los endpoints de salud del actuator (`/actuator/*`) describen el
+ *proceso en ejecución* y se sirven en el puerto de **gestión** separado
+ (`pyfly.management.server.port`, por defecto 9090), introducido en el
+ Capítulo 3. Son dos listeners distintos con dos audiencias distintas.
+
+!!! tip "Pruébalo"
+ Con el servidor en marcha, obtén la especificación en bruto y confirma que
+ tus rutas están en ella:
+
+ ```bash
+ curl -s localhost:8080/openapi.json | head -c 200
+ ```
+
+ Verás la cabecera de OpenAPI 3.0 y el comienzo del mapa `paths`. Luego abre
+ `http://localhost:8080/docs` en un navegador. Verás
+ `POST /api/v1/wallets`, `GET /api/v1/wallets/{wallet_id}`,
+ `POST /api/v1/wallets/{wallet_id}/deposit` y los demás: cada uno con los
+ esquemas de petición y respuesta correctos derivados de tus modelos Pydantic,
+ y el parámetro de consulta `owner_id` de `list_wallets` ya documentado con su
+ tipo y valor por defecto. Haz clic en «Try it out» en `POST /api/v1/wallets`
+ para abrir un monedero real directamente desde el navegador.
+
+**Qué acaba de pasar.** No escribiste ni una línea de documentación de API y, sin
+embargo, apareció una especificación completa, interactiva y siempre exacta. Los
+mismos metadatos de ruta y modelo que impulsan la gestión de peticiones impulsan
+también la documentación, de modo que las dos nunca pueden desviarse una de otra.
+
+---
+
+## El servidor por debajo
+
+Lumen ya tiene rutas, vinculaciones, validación y documentación. La última
+pregunta es qué escucha realmente en el puerto 8080. La respuesta importa:
+distintos servidores hacen distintos compromisos en rendimiento, soporte de
+versión HTTP, compatibilidad con el SO y herramientas del ecosistema. Atar una
+aplicación a un único servidor a nivel de framework te obliga a aceptar esos
+compromisos de forma permanente.
+
+Un **servidor ASGI** es el proceso que realmente acepta conexiones TCP, parsea
+HTTP y llama a tu aplicación: la capa entre el socket del sistema operativo y tus
+manejadores. PyFly no codifica uno a fuego. En el arranque,
+`ServerAutoConfiguration` ejecuta una selección en cascada basada en lo que está
+instalado:
+
+| Prioridad | Servidor | Característica |
+|---|---|---|
+| 1.ª | **Granian** | Impulsado por Rust/tokio; el mayor rendimiento con un solo worker |
+| 2.ª | **Uvicorn** | Estándar del ecosistema; el mejor soporte de herramientas |
+| 3.ª | **Hypercorn** | HTTP/2 y HTTP/3 nativos |
+
+Los tres arrancan a través del mismo protocolo `ApplicationServerPort`, de modo
+que tu código es completamente ajeno a cuál se está ejecutando. Sobrescríbelo con
+`pyfly.server.type: uvicorn` en `pyfly.yaml` o con la opción de CLI `--server`:
+
+```bash
+pyfly run --server uvicorn --reload # development: auto-reload
+pyfly run --server granian --workers 4 # production: multi-worker
+```
+
+!!! tip "Pruébalo"
+ Para el desarrollo del día a día, ejecuta con auto-reload para que el
+ servidor se reinicie en cada guardado:
+
+ ```bash
+ uv run pyfly run --reload
+ ```
+
+ PyFly registra el servidor elegido y el puerto enlazado en el arranque. Como
+ `--reload` requiere un vigilante de ficheros integrado, PyFly selecciona
+ **Uvicorn** para el modo reload independientemente del orden de la cascada.
+ Edita un manejador, guarda y observa cómo el log informa del reinicio; luego
+ vuelve a ejecutar cualquier `curl` de antes y ve tu cambio en vivo sin
+ detener el proceso.
+
+El bucle de eventos también es enchufable: `uvloop` (Linux/macOS) y `winloop`
+(Windows) se seleccionan automáticamente cuando están instalados, entregando una
+mejora de rendimiento de 2 a 4× sobre el asyncio por defecto. Instálalos con
+`uv add "pyfly[web-fast]"`.
+
+!!! tip "Consejo"
+ Para desarrollo, `pyfly run --reload` es todo lo que necesitas: elige el
+ mejor servidor y bucle de eventos disponibles automáticamente. Para
+ producción, pasa un recuento de workers positivo y explícito para escalar a
+ través de los núcleos: `pyfly run --server granian --workers 4`, como en el
+ ejemplo de arriba. Un valor de `--workers` igual a `0` o negativo se resuelve
+ a un solo worker, de modo que el multi-worker es siempre una opción explícita.
+ Las opciones de la CLI siempre sobrescriben `pyfly.yaml`.
+
+---
+
+## Lo que construiste {.recap}
+
+La Parte I está completa.
+
+En cuatro capítulos pasaste de un andamiaje vacío a un servicio con forma de
+producción. Lumen ahora **arranca** (`@pyfly_application`, banner de arranque,
+logging estructurado), está **cableado** (servicios y repositorios conectados
+mediante inyección por constructor sin código de pegamento), **configurado**
+(`pyfly.yaml` de cuatro capas + superposiciones de perfil + secretos por variable
+de entorno, `WalletProperties` tipado) y **sirve**: una API REST validada en
+`/api/v1/wallets` con vinculación de cuerpo `PathVar`, `QueryParam` y
+`Valid[Body[T]]`; errores 422 estructurados a partir de restricciones de
+Pydantic; mapeo de error-de-dominio-a-estado desde la jerarquía de excepciones;
+modelos de respuesta tipados (`WalletDto`, `BalanceDto`) que impulsan la
+generación de esquemas OpenAPI; y un servidor ASGI enchufable corriendo por
+debajo.
+
+Cada parte de esta pila sigue el mismo principio hexagonal que has visto a lo
+largo del libro: tu código depende de puertos y decoradores; el framework cablea
+los adaptadores. Cambia el almacén en memoria por un adaptador de PostgreSQL en el
+Capítulo 5, sustituye el despacho directo por un bus CQRS completo en el Capítulo
+7, o habilita respuestas XML: nada de eso requiere tocar la estructura de
+decoradores del controlador ni las formas de los DTO.
+
+La Parte II lleva a Lumen más lejos: datos persistentes con SQLAlchemy, eventos de
+dominio, resiliencia con cortacircuitos (circuit breakers) y seguridad con JWT.
+Los cimientos que construiste aquí se llevan adelante intactos.
+
+---
+
+## Pruébalo tú mismo {.exercises}
+
+Cada ejercicio es pequeño y autocontenido. Después de cada cambio, reinicia con
+`uv run pyfly run --reload` y vuelve a ejecutar el `curl` sugerido para confirmar
+el comportamiento. Si tienes instaladas las dependencias de desarrollo, también
+puedes ejecutar la suite de pruebas del proyecto en cualquier momento para
+asegurarte de que nada ha regresionado:
+
+```bash
+uv run --extra dev pytest
+```
+
+Deberías ver una fila de puntos que pasan y una línea de resumen `passed`.
+
+1. **Añade un endpoint `DELETE /api/v1/wallets/{wallet_id}`.** Elimina el
+ monedero de `_wallets` y devuelve 204 No Content. Lanza
+ `ResourceNotFoundException` si el monedero no existe. Decóralo con
+ `@delete_mapping("/{wallet_id}", status_code=204)`: PyFly convierte un retorno
+ `None` con `status_code=204` en una respuesta 204 sin cuerpo. Verifícalo con
+ `curl -X DELETE http://localhost:8080/api/v1/wallets/{id}`.
+
+2. **Añade filtrado por divisa a `list_wallets`.** Añade un parámetro
+ `currency: QueryParam[str] = None` y filtra `_wallets.values()` cuando no sea
+ `None`. Pruébalo con `GET /api/v1/wallets?currency=EUR` y confirma que solo se
+ devuelven los monederos en EUR; confirma que `GET /api/v1/wallets` sin el
+ parámetro devuelve todos los monederos. Luego haz que el parámetro sea
+ obligatorio eliminando el valor por defecto: observa la respuesta 400 cuando lo
+ omites de la petición.
+
+3. **Lanza una subclase específica de dominio de `ResourceNotFoundException`.**
+ Crea un `WalletNotFoundError` que herede de `ResourceNotFoundException` y lleve
+ un campo `currency` extra en `context`. Lánzalo desde `get_wallet` y
+ `get_balance` en lugar de `ResourceNotFoundException` a secas. Verifica que la
+ respuesta de error JSON incluye el campo de contexto extra sin ningún cambio en
+ el manejador global: la jerarquía lo mapea a 404 automáticamente.
diff --git a/book/manuscript-es/05-persistence.md b/book/manuscript-es/05-persistence.md
new file mode 100644
index 00000000..c7e603b8
--- /dev/null
+++ b/book/manuscript-es/05-persistence.md
@@ -0,0 +1,811 @@
+Capítulo 5
+
+# Persistencia y el patrón Repositorio {.chtitle}
+
+::: figure art/openers/ch05.svg |
+
+Lumen tiene una API de monederos (wallets) que funciona, pero cada monedero desaparece en el momento en que reinicias el proceso. Ha llegado la hora de hacer que los monederos sean duraderos.
+
+El enfoque ingenuo es esparcir llamadas a `select()` y `session.commit()` de SQLAlchemy por los manejadores de comandos. PyFly ofrece algo mucho mejor: una **capa de repositorios al estilo de Spring Data**. Declaras una interfaz —`class WalletRepository(Repository[WalletEntity, str])`— y el framework *la implementa por ti*. El CRUD asíncrono completo viene gratis. Los métodos de consulta se derivan de sus **nombres**. La paginación, la ordenación, los filtros componibles y las proyecciones de lectura son ciudadanos de primera clase. No hay ningún adaptador escrito a mano ni SQL en el código de la aplicación.
+
+Este capítulo reconstruye la persistencia de Lumen sobre esa capa, exactamente como lo hace el ejemplo en ejecución: la entidad de SQLAlchemy, el repositorio con sus consultas derivadas y por especificación, `Page`/`Pageable`/`Sort`, las proyecciones para las vistas de lectura y la junta transaccional que mantiene íntegro el agregado `Wallet`. Todo lo que aparece aquí se ejecuta contra un fichero SQLite real con cero infraestructura externa: los 41 tests del ejemplo están en verde sobre él. Este capítulo se dirige a PyFly **v26.6.110**.
+
+Construiremos la capa de persistencia pieza a pieza, y en cada hito hay un recuadro **Ejecútalo** con el comando exacto que debes escribir y la salida que deberías ver. Si estás siguiendo el ejemplo Lumen, trabaja desde la raíz del proyecto (`samples/lumen`), donde viven `pyfly.yaml` y `pyproject.toml`; todos los comandos de abajo dan por hecho ese directorio.
+
+!!! note "Ejecútalo: ve el problema primero"
+ Antes de añadir la persistencia, vale la pena sentir el hueco que cierra. Abre un monedero, luego detén y reinicia el proceso: con el almacén en memoria de la Parte I el monedero ha desaparecido. El resto de este capítulo hace que sobreviva a ese reinicio.
+
+ ```bash
+ # Terminal 1 — start the app
+ uv run pyfly run --server uvicorn
+ # ... startup banner, then: Uvicorn running on http://0.0.0.0:8080
+
+ # Terminal 2 — open a wallet, then read it back
+ curl -s -X POST localhost:8080/api/v1/wallets \
+ -H 'content-type: application/json' \
+ -d '{"owner_id": "alice", "currency": "EUR"}'
+ ```
+
+ Recuperas el nuevo id, y luego la lectura del saldo confirma que existe:
+
+ ```
+ {"wallet_id": "wlt-7f3c..."}
+ ```
+
+ Ahora pulsa `Ctrl+C` en la Terminal 1, arranca la aplicación de nuevo y pide el saldo de ese monedero. Antes de este capítulo, la fila nunca se escribió en disco, así que ha desaparecido:
+
+ ```
+ {"detail": "Wallet 'wlt-7f3c...' not found", "code": "WALLET_NOT_FOUND"}
+ ```
+
+ Al final de este capítulo la misma secuencia devuelve el saldo después de un reinicio.
+
+---
+
+## El repositorio, en una frase
+
+::: figure art/figures/05-repository.svg | Figura 5.1 — Tu código depende del repositorio; el framework suministra la implementación de SQLAlchemy que hay detrás.
+
+Un repositorio de PyFly es una clase que hereda del genérico `Repository[Entity, ID]` y va marcada con el estereotipo `@repository`. Esa es toda la declaración. A partir de los dos parámetros de tipo el framework aprende el **tipo de entidad** y el **tipo de la clave primaria**, y desde ahí proporciona una superficie completa de acceso a datos asíncrono —`save`, `find_by_id`, `find_all`, `delete`/`delete_by_id`, `count`, `exists_by_id`, además de paginación y consultas por especificación— con la `AsyncSession` de la base de datos inyectada por ti.
+
+Este es el patrón Repositorio tal como lo popularizó Spring Data, trasladado a un Python asíncrono idiomático. Tú escribes *qué* quieres (el método) y el framework escribe *cómo* (el SQL).
+
+!!! spring "Equivalencia con Spring"
+ `Repository[T, ID]` es el `JpaRepository` de PyFly. Heredar de él para obtener el CRUD, derivar consultas a partir de los nombres de método, `Pageable`/`Page`, `Specification` y las proyecciones por interfaz están todos trasladados casi nombre por nombre desde Spring Data JPA. Si has escrito un `interface OrderRepository extends JpaRepository` de Spring, ya conoces la forma de este capítulo.
+
+---
+
+## La entidad: una fila por monedero
+
+Antes de que un repositorio pueda almacenar algo, necesita una **entidad**: la forma en disco de un monedero, una fila plana por agregado. (Una *entidad*, en esta capa, es simplemente una clase de Python que se mapea a una tabla de la base de datos; cada instancia es una fila.) Las entidades de PyFly son modelos ordinarios de SQLAlchemy 2.0 construidos sobre una base declarativa que el framework exporta.
+
+La construiremos campo a campo. Crea el fichero `src/lumen/models/entities/v1/wallet_orm.py` y añade las piezas por orden.
+
+**Paso 1 — importa la base declarativa del framework.** Cada entidad hereda de una *base declarativa*: una clase de SQLAlchemy que registra cada tabla que defines para que el framework pueda crearlas todas al arrancar. PyFly exporta una como `Base`:
+
+```python
+from pyfly.data.relational.sqlalchemy import Base
+```
+
+**Paso 2 — nombra la tabla y declara las columnas.** Hereda de `Base`, fija `__tablename__` y escribe un atributo tipado por columna. La entidad completa es corta:
+
+::: listing lumen/models/entities/v1/wallet_orm.py | Listado 5.1 — WalletEntity: la fila de persistencia de SQLAlchemy
+from __future__ import annotations
+
+from datetime import UTC, datetime
+
+from sqlalchemy import String
+from sqlalchemy.orm import Mapped, mapped_column
+
+from pyfly.data.relational.sqlalchemy import Base
+
+
+class WalletEntity(Base):
+ """One persisted wallet row, keyed by the aggregate's string id."""
+
+ __tablename__ = "wallets"
+
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
+ owner_id: Mapped[str] = mapped_column(
+ String(255), nullable=False, index=True
+ )
+ currency: Mapped[str] = mapped_column(String(3), nullable=False)
+ balance_minor: Mapped[int] = mapped_column(nullable=False, default=0)
+ created_at: Mapped[datetime] = mapped_column(
+ default=lambda: datetime.now(UTC)
+ )
+:::
+
+La sintaxis `Mapped[T]` / `mapped_column(...)` es el estilo de SQLAlchemy 2.0: cada anotación de tipo dirige tanto el tipo del atributo de Python como el DDL de columna generado (la sentencia `CREATE TABLE`), de modo que cada columna tiene una única fuente de verdad. Como `WalletEntity` hereda de `Base`, importar este módulo registra la tabla `wallets` en `Base.metadata` —el registro de todas las tablas que conoce la base—, y el ciclo de vida del motor del framework la crea entonces al arrancar.
+
+!!! note "Qué acaba de pasar"
+ Escribiste una clase, sin SQL. Los cinco atributos tipados se convirtieron en cinco columnas; `primary_key=True` marcó `id` como la clave; `index=True` en `owner_id` acelerará la consulta de "monederos propiedad de X" que construyes más adelante; `nullable=False` y `default=...` fijan las restricciones. Importar este módulo basta para que el framework sepa que la tabla existe: nunca llamas a `CREATE TABLE` tú mismo.
+
+Hay dos decisiones de diseño que conviene destacar.
+
+**Base, no BaseEntity.** PyFly trae dos bases declarativas. `BaseEntity` te da una clave primaria sustituta de tipo **UUID** más cuatro columnas de auditoría (`created_at`, `updated_at`, `created_by`, `updated_by`) rellenadas automáticamente: el valor por defecto adecuado para la mayoría de las tablas. Lumen usa deliberadamente la `Base` simple en su lugar, porque el agregado `Wallet` ya es dueño de su identidad: un id de cadena con la forma `wlt-…`. Heredar de `Base` deja que la fila conserve esa clave primaria de **cadena**, de modo que la fila y el agregado comparten una identidad en lugar de que la fila invente una segunda, sustituta.
+
+**Unidades menores en enteros.** Los importes viven en `balance_minor` como céntimos enteros, nunca como un número en coma flotante. Las columnas de coma flotante acumulan error de redondeo a lo largo de millones de transacciones; la aritmética entera se mantiene exacta. Un saldo de `2500` significa 25,00 €: el decimal en unidad mayor se calcula solo en los bordes, para mostrarlo.
+
+!!! tip "Recurre a BaseEntity por defecto"
+ A menos que tu agregado sea dueño de una clave natural como lo es `Wallet`, prefiere `class Order(BaseEntity)`. Obtienes una PK de tipo UUID y columnas de auditoría gratis, y el `AuditingEntityListener` rellena `created_by`/`updated_by` a partir del contexto de seguridad en cada inserción y actualización. Lumen es la excepción, no la regla.
+
+---
+
+## El repositorio: CRUD gratis
+
+Ahora, la pieza central. El `WalletRepository` de Lumen hereda de `Repository[WalletEntity, str]` —tipo de entidad `WalletEntity`, tipo de clave primaria `str`— y se registra con `@repository`. (Un *estereotipo* como `@repository` es un decorador de clase que le dice al contenedor del framework: "gestióname una instancia de esto". Esa instancia gestionada es un **bean**: un objeto que el contenedor crea una vez y entrega a cualquiera que lo pida. La *inyección de dependencias*, o *DI*, es que el contenedor haga esa entrega por ti, de modo que un manejador nunca construye su propio repositorio.) Esa única declaración basta para que el framework suministre toda la superficie CRUD —siendo *CRUD* las cuatro operaciones básicas de tabla: Crear, Leer, Actualizar, Borrar (Create, Read, Update, Delete).
+
+Crea `src/lumen/models/repositories/wallet_repository.py` en dos pasos.
+
+**Paso 1 — importa lo que la declaración necesita.** Tres imports: tu entidad, el estereotipo `@repository` y la base genérica `Repository`.
+
+```python
+from lumen.models.entities.v1.wallet_orm import WalletEntity
+from pyfly.container import repository
+from pyfly.data.relational.sqlalchemy import Repository
+```
+
+**Paso 2 — hereda de la base genérica y márcala con `@repository`.** Los dos parámetros de tipo llevan todo el cableado:
+
+::: listing lumen/models/repositories/wallet_repository.py | Listado 5.2 — WalletRepository: heredar del repositorio del framework
+from __future__ import annotations
+
+from lumen.models.entities.v1.wallet_orm import WalletEntity
+from pyfly.container import repository
+from pyfly.data import Page, Pageable
+from pyfly.data.relational.sqlalchemy import Repository, Specification
+
+
+@repository
+class WalletRepository(Repository[WalletEntity, str]):
+ """CRUD + derived + specification queries for WalletEntity.
+
+ The @repository stereotype registers this as a DI bean. The
+ framework reads the entity/PK types from the
+ Repository[WalletEntity, str] base and injects the shared
+ AsyncSession.
+ """
+
+ # (query methods follow — see the next sections)
+:::
+
+No hay `__init__`, ni SQL, ni clase adaptadora. Con solo esa declaración, cualquier manejador que inyecte un `WalletRepository` ya puede llamar a:
+
+| Método | Devuelve | Qué hace |
+|---------------------------------|---------------------|------------------------------------------------|
+| `save(entity)` | `T` | Inserta o actualiza; **vuelca** (flush) + refresca |
+| `find_by_id(id)` | `T \| None` | Carga por clave primaria |
+| `find_all(**filters)` | `list[T]` | Todas las filas, filtros de igualdad opcionales |
+| `find_all(sort)` | `list[T]` | Todas las filas en un orden `Sort` dado |
+| `find_all(pageable)` | `Page[T]` | Consulta paginada + ordenada (ver abajo) |
+| `stream_all(sort)` | `AsyncIterator[T]` | Recorre en flujo cada fila (el análogo de `Flux`) |
+| `delete(entity)` | `None` | Borra una entidad dada |
+| `delete_by_id(id)` | `None` | Borra por clave primaria (sin efecto si no existe) |
+| `delete_all(entities=None)` | `None` | Borra las entidades dadas (o todas las filas) |
+| `delete_all_by_id(ids)` | `None` | Borra muchas filas por clave primaria |
+| `count()` | `int` | Cuenta todas las filas de la tabla |
+| `exists_by_id(id)` | `bool` | Si existe una fila con este id |
+| `save_all(entities)` | `list[T]` | Inserción/actualización en bloque |
+| `find_all_by_id(ids)` | `list[T]` | Carga muchas filas por clave primaria |
+| `find_all_by_spec(spec)` | `list[T]` | Filas que satisfacen una `Specification` |
+| `find_all_by_spec_paged(...)` | `Page[T]` | Consulta `Specification` paginada + ordenada |
+
+Eso es más que suficiente para la mayoría de las entidades. Lumen añade tres métodos propios por encima —una consulta derivada, una consulta por especificación y un upsert— que las siguientes secciones van construyendo.
+
+!!! spring "Equivalencia con Spring"
+ Esta superficie heredada es exactamente la jerarquía de repositorios de Spring Data, trasladada nombre por nombre y estable a partir de PyFly **v26.6.110**: `CrudRepository` → `ReactiveSortingRepository` → `PagingAndSortingRepository`. `save`/`save_all`, `find_by_id`, `find_all`, `exists_by_id`, `count` y la familia `delete*` se corresponden con sus equivalentes de Spring; `find_all(pageable) -> Page[T]` es `findAll(Pageable)`, y `find_all_by_spec*` es el `JpaSpecificationExecutor`. Si conoces `JpaRepository`, ya conoces esta tabla.
+
+### Cómo conoce los tipos el framework
+
+Cuando escribes `Repository[WalletEntity, str]`, el hook `__init_subclass__` de la clase base inspecciona `__orig_bases__` en el momento de la definición de la clase y extrae el tipo de entidad (`WalletEntity`) y el tipo del id (`str`) de los parámetros genéricos. (`__init_subclass__` es un hook de Python que se ejecuta una vez, de forma automática, cuando se *define* una subclase, así que esto ocurre en tiempo de importación, antes de crear ningún objeto.) La `AsyncSession` —el manejador de conexión-y-transacción de la base de datos por el que pasa cada consulta— se suministra entonces como dependencia inyectada por la autoconfiguración relacional. No se pasa nada manualmente: los parámetros de tipo *son* el cableado.
+
+!!! note "Ejecútalo: confirma que el repositorio se cablea"
+ La prueba más rápida de que la entidad y el repositorio están sanos es la batería de tests, que ejercita el repositorio contra un fichero SQLite real sin servidor. Desde la raíz del proyecto Lumen:
+
+ ```bash
+ uv run --extra dev pytest tests/test_sql_wallet_repository.py -q
+ ```
+
+ Deberías ver pasar todos los tests del repositorio:
+
+ ```
+ ...... [100%]
+ 6 passed in 0.30s
+ ```
+
+ Estos tests construyen el repositorio directamente y ejercitan `upsert`, `find_by_id`, `count`, la consulta derivada y la ruta de especificación: los mismos métodos que construye este capítulo. Si están en verde, las columnas de tu entidad y la declaración `Repository[WalletEntity, str]` son correctas.
+
+---
+
+## Consultas derivadas: el nombre del método es la consulta
+
+El CRUD cubre las búsquedas por clave primaria. Las aplicaciones reales también necesitan consultar por otras columnas: "todos los monederos propiedad de este cliente". En la mayoría de los frameworks escribirías el SQL a mano. En PyFly declaras un **esbozo** (stub) —un método sin cuerpo, solo `...`— y dejas que el framework compile la consulta *a partir del nombre del método*.
+
+**Paso 1 — declara el esbozo.** Añade un método a `WalletRepository`. El *nombre* describe la consulta; el cuerpo es literalmente `...`:
+
+::: listing lumen/models/repositories/wallet_repository.py | Listado 5.3 — Una consulta derivada: declarada como esbozo, compilada a partir de su nombre
+@repository
+class WalletRepository(Repository[WalletEntity, str]):
+
+ # derived query: compiled from the method name by the post-processor
+ async def find_by_owner_id(
+ self, owner_id: str
+ ) -> list[WalletEntity]:
+ """All wallets owned by *owner_id* (derived query stub)."""
+ ...
+:::
+
+**Paso 2 — deja que el framework rellene el cuerpo.** No escribes más código. Al arrancar, un `BeanPostProcessor` —el `RepositoryBeanPostProcessor`— hace el trabajo. (Un *BeanPostProcessor* es un hook que el contenedor ejecuta sobre cada bean justo después de crearlo; este se especializa en repositorios.) Examina el repositorio, detecta que `find_by_owner_id` es un esbozo, analiza el **nombre** convirtiéndolo en una consulta parseada y reemplaza el esbozo por una implementación real que ejecuta `SELECT … FROM wallets WHERE owner_id = :owner_id`. Llamar a `await repo.find_by_owner_id("alice")` ahora devuelve exactamente las filas de ese propietario.
+
+!!! note "Qué acaba de pasar"
+ Declaraste un método y obtuviste una consulta funcional: el framework leyó la *intención* del nombre `find_by_owner_id` y escribió el SQL por ti. La nomenclatura no es magia; sigue una gramática, que se cubre a continuación. La idea clave: en esta capa describes *qué* quieres mediante cómo nombras el método, y el post-procesador suministra el *cómo*.
+
+La gramática es la convención de Spring Data. Un nombre de método es un **prefijo** seguido de un **sujeto** construido a partir de nombres de campo, operadores, conectores y una cláusula de ordenación opcional:
+
+| Parte | Tokens |
+|-------------|--------------------------------------------------------------------------|
+| Prefijo | `find_by` · `count_by` · `exists_by` · `delete_by` |
+| Conectores | `_and_` · `_or_` |
+| Operadores | `_greater_than` · `_less_than` · `_between` · `_in` · `_like` · `_containing` · `_is_null` · `_is_not_null` |
+| Ordenación | `_order_by__` |
+
+Cada cláusula consume el número correspondiente de argumentos del método (la igualdad y las comparaciones toman uno; `_between` toma dos; `_is_null` / `_is_not_null` no toman ninguno). Algunos ejemplos sobre un hipotético repositorio de pedidos:
+
+```python
+@repository
+class OrderRepository(Repository[Order, UUID]):
+ async def find_by_status(self, status: str) -> list[Order]: ...
+
+ async def find_by_customer_id_and_status(
+ self, customer_id: str, status: str
+ ) -> list[Order]: ...
+
+ async def find_by_total_greater_than(
+ self, min_total: float
+ ) -> list[Order]: ...
+
+ async def find_by_total_between(
+ self, low: float, high: float
+ ) -> list[Order]: ...
+
+ async def count_by_status(self, status: str) -> int: ...
+
+ async def exists_by_customer_id(self, customer_id: str) -> bool: ...
+
+ async def find_by_status_order_by_created_at_desc(
+ self, status: str
+ ) -> list[Order]: ...
+```
+
+El prefijo decide la *forma* del resultado: `find_by` devuelve una lista, `count_by` devuelve un `int`, `exists_by` devuelve un `bool` y `delete_by` emite un `DELETE` y devuelve el número de filas eliminadas. Nunca escribes el SQL; nombras el método y anotas el tipo de retorno.
+
+!!! tip "Cuando un nombre se vuelva absurdo, usa @query"
+ Los nombres derivados son perfectos hasta dos o tres predicados. Pasado eso, se vuelven ilegibles. Para cualquier cosa más compleja, coloca un decorador `@query("SELECT w FROM WalletEntity w WHERE …")` (al estilo JPQL, o `native=True` para SQL en bruto) sobre el esbozo y escribe la consulta de forma explícita. El mismo patrón de esbozo más decorador; solo que tú suministras el texto de la consulta en lugar de codificarlo en el nombre.
+
+---
+
+## Paginación: Page, Pageable y Sort
+
+Un endpoint de listado nunca debería devolver *todos* los monederos. La *paginación* es la solución estándar: devolver una **página** de filas de tamaño fijo a la vez, más los metadatos suficientes para que el cliente pueda pedir la siguiente. Los tipos de paginación de PyFly —`Pageable` (qué página, qué tamaño, qué orden), `Sort` (la ordenación) y `Page[T]` (el fragmento más los metadatos)— se heredan directamente de la superficie CRUD a través de `find_all(pageable)`.
+
+Hay tres piezas pequeñas que ensamblar: el manejador que llama a `find_all(pageable)`, el `Page[T]` que devuelve y el controlador que construye el `Pageable` a partir de la petición. Las veremos en ese orden.
+
+El manejador de consulta `ListWallets` de Lumen es toda la historia en tres líneas:
+
+::: listing lumen/core/services/wallets/list_wallets_handler.py | Listado 5.4 — Paginando con find_all(pageable), y luego mapeando la página
+@query_handler
+@service
+class ListWalletsHandler(
+ QueryHandler[ListWallets, Page[WalletDto]]
+):
+ def __init__(self, repository: WalletRepository) -> None:
+ super().__init__()
+ self._repository = repository
+
+ async def do_handle( # type: ignore[override]
+ self, query: ListWallets
+ ) -> Page[WalletDto]:
+ page = await self._repository.find_all(
+ query.pageable
+ )
+ return page.map(entity_to_dto)
+:::
+
+`find_all(pageable)` hace tres cosas en una sola llamada: cuenta el número total de filas coincidentes, aplica la ordenación del `Pageable` y corta el resultado con `LIMIT`/`OFFSET`. Devuelve un `Page[WalletEntity]`. El manejador llama entonces a `page.map(entity_to_dto)` para convertir cada fila en un `WalletDto` **sin perder los metadatos de paginación**: `.map` traslada `total`, `page`, `size` y el resto a la nueva página.
+
+Un `Page[T]` expone todo lo que un cliente necesita para renderizar un paginador:
+
+| Miembro | Significado |
+|-----------------|------------------------------------------|
+| `items` | Las filas de esta página (`list[T]`) |
+| `total` | Total de filas coincidentes en todas las páginas |
+| `page` | Número de página actual (base 1) |
+| `size` | Máximo de elementos por página |
+| `total_pages` | `ceil(total / size)` |
+| `has_next` | Si existe una página siguiente |
+| `has_previous` | Si existe una página anterior |
+| `map(fn)` | Transforma los elementos, conservando los metadatos |
+
+El propio `Pageable` se construye en el borde: el controlador convierte los parámetros de consulta `?page=&size=` en un `Pageable` con un `Sort` compartido de más reciente primero:
+
+::: listing lumen/web/controllers/wallet_controller.py | Listado 5.5 — Construyendo un Pageable a partir de los parámetros de consulta (controlador)
+#: Newest-first ordering shared by the list endpoints.
+_NEWEST_FIRST = Sort.by("created_at").descending()
+
+
+@get_mapping("")
+async def list_wallets(
+ self, page: QueryParam[int] = 1, size: QueryParam[int] = 20
+) -> PageDto[WalletDto]:
+ """A page of wallets, newest first."""
+ result = await self._queries.query(
+ ListWallets(pageable=Pageable.of(page, size, _NEWEST_FIRST))
+ )
+ return PageDto.from_page(result)
+:::
+
+`Sort.by("created_at").descending()` nombra la columna y la dirección; `Pageable.of(page, size, sort)` lo empaqueta con las coordenadas de la página. El manejador devuelve un `Page` del framework, y el controlador lo pliega en un `PageDto` serializable —un reflejo Pydantic plano de la página— de modo que `GET /api/v1/wallets?page=1&size=20` devuelve un JSON como `{"items": [...], "total": 42, "page": 1, "total_pages": 3, "has_next": true, ...}`.
+
+!!! note "Ejecútalo: pasa por las páginas de los monederos"
+ Con la capa relacional activada (la sección "Activarlo" de más abajo la enciende; el ejemplo Lumen ya la trae habilitada), abre un par de monederos y luego pide la primera página. Desde una aplicación en ejecución:
+
+ ```bash
+ curl -s 'localhost:8080/api/v1/wallets?page=1&size=20'
+ ```
+
+ La respuesta lleva las filas *y* los metadatos del paginador: fíjate en `total`, `page`, `total_pages` y `has_next` junto a `items`:
+
+ ```json
+ {
+ "items": [
+ {"id": "wlt-...", "owner_id": "alice", "currency": "EUR",
+ "balance_minor": 0, "balance": 0.0, "created_at": "..."}
+ ],
+ "total": 1, "page": 1, "size": 20, "total_pages": 1,
+ "has_next": false, "has_previous": false
+ }
+ ```
+
+ Esos son exactamente los miembros de `Page[T]` de la tabla de arriba, serializados por `PageDto`. El cliente renderiza un paginador directamente a partir de esta forma, sin necesidad de una consulta de conteo adicional.
+
+---
+
+## Especificaciones: filtros componibles y reutilizables
+
+Las consultas derivadas responden a preguntas fijas. A veces quieres un **predicado reutilizable** —una condición `WHERE` que puedes nombrar una vez y reutilizar— que compones en el lugar de la llamada: "monederos con al menos este saldo", combinado libremente con otras condiciones. Eso es lo que es una `Specification`: un objeto pequeño que envuelve un fragmento `WHERE`, componible con `&` (AND), `|` (OR) y `~` (NOT).
+
+La construimos en dos pasos: una factoría que *devuelve* una `Specification`, y luego un método de repositorio que la *ejecuta*.
+
+**Paso 1 — escribe una factoría que devuelva una `Specification`.** Toma el parámetro (el saldo mínimo) y devuelve un objeto predicado. **Paso 2 — añade un método de repositorio que lo ejecute** a través del `find_all_by_spec_paged` heredado. Lumen hace ambas cosas en un solo fichero:
+
+::: listing lumen/models/repositories/wallet_repository.py | Listado 5.6 — Una factoría de Specification y un método que la ejecuta paginada
+def balance_at_least(min_minor: int) -> Specification[WalletEntity]:
+ """Wallets whose balance is at least *min_minor*.
+
+ Returned as a Specification, so it composes via & / | / ~ and
+ runs through find_all_by_spec / find_all_by_spec_paged.
+ """
+ return Specification(
+ lambda root, q: q.where(root.balance_minor >= min_minor)
+ )
+
+
+@repository
+class WalletRepository(Repository[WalletEntity, str]):
+
+ async def find_rich(
+ self, min_minor: int, pageable: Pageable
+ ) -> Page[WalletEntity]:
+ """A page of wallets with balance >= min_minor."""
+ return await self.find_all_by_spec_paged(
+ balance_at_least(min_minor), pageable
+ )
+:::
+
+Una `Specification` envuelve un invocable `(root, q) -> q`: dada la clase de entidad (`root`) y un `Select` de SQLAlchemy, devuelve la sentencia con un predicado añadido. `balance_at_least(1000)` produce el predicado `balance_minor >= 1000`. Como las especificaciones se componen con operadores de Python, puedes construir filtros arbitrariamente complejos a partir de piezas pequeñas:
+
+```python
+rich = balance_at_least(1000)
+in_eur = Specification(
+ lambda root, q: q.where(root.currency == "EUR")
+)
+rich_eur = rich & in_eur # AND
+rich_or_eur = rich | in_eur # OR
+not_rich = ~rich # NOT
+```
+
+Una especificación se ejecuta de dos maneras. `find_all_by_spec(spec)` devuelve como lista todas las filas coincidentes; `find_all_by_spec_paged(spec, pageable)` aplica el predicado, cuenta las coincidencias, ordena y corta, devolviendo un `Page[T]`. `find_rich` usa la forma paginada, así que el endpoint de monederos ricos está él mismo paginado. El manejador refleja exactamente el manejador de listado, mapeando las filas a DTOs:
+
+::: listing lumen/core/services/wallets/list_rich_wallets_handler.py | Listado 5.7 — El manejador de monederos ricos ejecuta la ruta de Specification
+@query_handler
+@service
+class ListRichWalletsHandler(
+ QueryHandler[ListRichWallets, Page[WalletDto]]
+):
+ def __init__(self, repository: WalletRepository) -> None:
+ super().__init__()
+ self._repository = repository
+
+ async def do_handle( # type: ignore[override]
+ self, query: ListRichWallets
+ ) -> Page[WalletDto]:
+ page = await self._repository.find_rich(
+ query.min_minor, query.pageable
+ )
+ return page.map(entity_to_dto)
+:::
+
+`GET /api/v1/wallets/rich?min_minor=1000&page=1&size=20` devuelve ahora una página de monederos con 10,00 € o más, de más reciente primero.
+
+!!! note "Ejecútalo: filtra a los monederos ricos"
+ Abre un monedero e ingresa 25,00 € en él (2500 unidades menores), abre otro y déjalo vacío, y luego pide los monederos con al menos 10,00 €:
+
+ ```bash
+ curl -s 'localhost:8080/api/v1/wallets/rich?min_minor=1000&page=1&size=20'
+ ```
+
+ Solo vuelve el monedero con fondos, y `total` cuenta únicamente las coincidencias: el monedero vacío queda filtrado por la `Specification`:
+
+ ```json
+ {"items": [{"id": "wlt-...", "balance_minor": 2500, "balance": 25.0, ...}],
+ "total": 1, "page": 1, "total_pages": 1, "has_next": false}
+ ```
+
+ El mismo predicado (`balance_at_least`) que se ejecuta aquí puede combinarse con otros usando `&`, `|` y `~`: esa es la recompensa de escribir el filtro como una `Specification` en lugar de como una consulta puntual.
+
+!!! note "Filtros sin lambdas"
+ Para el caso común —igualdad y un puñado de comparaciones— ni siquiera necesitas escribir una lambda. `FilterOperator.gte("balance_minor", 1000) & FilterOperator.eq("currency", "EUR")` produce la misma `Specification` componible a partir de métodos factoría estáticos, y `FilterUtils.by(currency="EUR")` construye una a partir de argumentos por palabra clave (consulta por ejemplo, Query-by-Example). Lumen usa aquí una lambda explícita porque la intención se lee con claridad; ambos estilos producen una `Specification` que puedes pasar a los mismos métodos del repositorio.
+
+---
+
+## Proyecciones: lee solo las columnas que necesitas
+
+El endpoint de saldo no necesita la fila entera, solo el id, la moneda y un saldo calculado. PyFly admite **proyecciones por interfaz**, la idea de Spring Data de declarar el subconjunto de campos que quiere una vista de lectura y dejar que el framework copie exactamente esos. (Una *proyección* es una vista de solo lectura sobre un subconjunto de las columnas de una entidad: nombras los pocos campos que te importan, y el framework copia únicamente esos, dejando sin leer el resto de la fila.)
+
+Construir una requiere tres piezas: la clase de proyección, el mapeador que sabe cómo rellenarla y el manejador que la usa. Las veremos por orden.
+
+**Paso 1 — declara la proyección.** Una proyección es una clase marcada con `@projection`. En Lumen es una dataclass concreta:
+
+::: listing lumen/interfaces/dtos/v1/balance_dto.py | Listado 5.8 — BalanceView: una @projection solo de los campos del saldo
+from dataclasses import dataclass
+
+from pyfly.data import projection
+
+
+@projection
+@dataclass
+class BalanceView:
+ """Projection: just the fields the balance view needs.
+
+ id, currency and balance_minor are copied straight from the
+ WalletEntity; balance is a computed major-unit decimal supplied
+ by a registered transform on the mapper.
+ """
+
+ id: str
+ currency: str
+ balance_minor: int
+ balance: float
+:::
+
+**Paso 2 — registra la proyección en un `Mapper`.** Un `Mapper` es el ayudante del framework que copia los campos de la entidad en una proyección. Lee esos cuatro campos de un `WalletEntity` y construye la vista. Tres (`id`, `currency`, `balance_minor`) se copian tal cual; el cuarto (`balance`, el decimal en unidad mayor) lo suministra un *transform*: una pequeña función registrada contra un nombre de campo que calcula un valor que la entidad no almacena directamente:
+
+::: listing lumen/core/mappers/wallet_mapper.py | Listado 5.9 — Registrando y ejecutando la proyección mediante Mapper
+from pyfly.data import Mapper
+
+_mapper = Mapper()
+_mapper.register_projection(
+ WalletEntity,
+ BalanceView,
+ transforms={"balance": lambda e: round(e.balance_minor / 100, 2)},
+)
+
+
+def entity_to_balance_dto(entity: WalletEntity) -> BalanceDto:
+ """Project a row onto the balance DTO via the projection."""
+ view = _mapper.project(entity, BalanceView)
+ return BalanceDto(
+ id=view.id,
+ currency=Currency(view.currency),
+ balance_minor=view.balance_minor,
+ balance=view.balance,
+ )
+:::
+
+**Paso 3 — usa la proyección desde un manejador de lectura.** `Mapper.project(entity, BalanceView)` lee solo los campos declarados, aplica el transform de `balance` y devuelve un `BalanceView`. El manejador de consulta carga entonces la fila por id y la proyecta:
+
+::: listing lumen/core/services/wallets/get_balance_handler.py | Listado 5.10 — El manejador de lectura del saldo: busca por id y luego proyecta
+@query_handler
+@service
+class GetBalanceHandler(QueryHandler[GetBalance, BalanceDto | None]):
+ def __init__(self, repository: WalletRepository) -> None:
+ super().__init__()
+ self._repository = repository
+
+ async def do_handle( # type: ignore[override]
+ self, query: GetBalance
+ ) -> BalanceDto | None:
+ entity = await self._repository.find_by_id(query.wallet_id)
+ return (
+ entity_to_balance_dto(entity)
+ if entity is not None
+ else None
+ )
+:::
+
+!!! note "Ejecútalo: lee solo el saldo"
+ El endpoint de saldo devuelve únicamente los cuatro campos proyectados, no la fila entera. Contra una aplicación en ejecución con un monedero con fondos:
+
+ ```bash
+ curl -s localhost:8080/api/v1/wallets/wlt-.../balance
+ ```
+
+ ```json
+ {"id": "wlt-...", "currency": "EUR", "balance_minor": 2500, "balance": 25.0}
+ ```
+
+ Sin `owner_id`, sin `created_at`: la proyección declaró cuatro campos, así que se leen y se devuelven cuatro campos. `balance` (el `25.0` en unidad mayor) es el transform calculado; el resto se copia directamente de la fila.
+
+!!! warning "Una proyección debe poder instanciarse"
+ Spring permite que una proyección sea una interfaz pelada y devuelve un proxy en tiempo de ejecución. Python no tiene tal proxy, así que una proyección de PyFly debe ser un tipo **concreto** que el mapeador pueda construir: aquí, una `@dataclass`. Marcar un *Protocol* como `@projection` no funcionará: un Protocol no puede instanciarse, y `Mapper.project` no tiene nada que construir. Usa una dataclass (o cualquier clase plana con campos coincidentes) y estarás a salvo.
+
+---
+
+## Transacciones y la junta del agregado
+
+La superficie del repositorio es limpia, pero dos sutilezas honestas deciden si tus escrituras realmente sobreviven. Ambas provienen de cómo gestiona el framework la sesión, y Lumen maneja ambas de forma deliberada.
+
+### save() vuelca (flush); no confirma (commit)
+
+Esto es lo más importante que hay que entender sobre la capa de datos. Hay dos verbos de base de datos fáciles de confundir. **Volcar** (flush) es enviar el SQL pendiente (el `INSERT`/`UPDATE`) a la base de datos para que sea visible a las lecturas posteriores de *esta* conexión, pero todavía dentro de una transacción abierta que puede deshacerse. **Confirmar** (commit) es hacer esos cambios permanentes y visibles para todos. Un flush sin commit se revierte cuando se cierra la sesión.
+
+El framework usa **una sola `AsyncSession` compartida**, y `Repository.save()` llama a `session.add()` seguido de `session.flush()` y `session.refresh()`: **vuelca**, haciendo la escritura visible *dentro* de la sesión actual, pero nunca **confirma**. Si nada confirma, la escritura se revierte cuando se cierra la sesión y el monedero no sobrevive a un reinicio. (Este es exactamente el problema del monedero que desaparece del recuadro **Ejecútalo** de la introducción.)
+
+El commit ocurre en el **límite de la unidad de trabajo**. (Una *unidad de trabajo* es un lote de cambios de todo o nada: o bien cada escritura que contiene se confirma junta, o bien —si algo falla— ninguna lo hace.) Declaras ese límite con `@transactional()`. Un manejador que escribe decora su `do_handle` con `@transactional()`, inyecta el `async_sessionmaker` —la factoría que entrega sesiones— como `self._session_factory`, y el decorador abre una unidad de trabajo, intercambia esa sesión transaccional en el repositorio durante la llamada, **confirma si tiene éxito** y revierte si falla:
+
+::: listing lumen/core/services/wallets/open_wallet_handler.py | Listado 5.11 — Un manejador de escritura: @transactional() confirma la unidad de trabajo
+@command_handler
+@service
+class OpenWalletHandler(CommandHandler[OpenWallet, str]):
+ """Open a new, empty wallet."""
+
+ def __init__(
+ self,
+ repository: WalletRepository,
+ events: EventPublisher,
+ session_factory: async_sessionmaker[AsyncSession],
+ ) -> None:
+ super().__init__()
+ self._repository = repository
+ self._events = events
+ # @transactional resolves the unit-of-work session from here.
+ self._session_factory = session_factory
+
+ @transactional()
+ async def do_handle( # type: ignore[override]
+ self, command: OpenWallet
+ ) -> str:
+ wallet_id = f"wlt-{uuid4()}"
+ wallet = Wallet.open(
+ wallet_id=wallet_id,
+ owner_id=command.owner_id,
+ currency=command.currency,
+ )
+ await self._repository.upsert(to_entity(wallet))
+
+ await publish_domain_events(
+ self._events, wallet.clear_events()
+ )
+ return wallet_id
+:::
+
+`@transactional()` (importado de `pyfly.data.relational.sqlalchemy`) resuelve el `async_sessionmaker` desde `self._session_factory`, ejecuta el cuerpo dentro de un bloque `session.begin()` y confirma al final. Quita el decorador y el `upsert` solo volcaría: el monedero nunca llegaría al disco. Los manejadores de lectura anteriores de este capítulo no necesitan `@transactional`: una lectura no hace cambios que confirmar.
+
+!!! note "Qué acaba de pasar"
+ La regla en una línea: **las lecturas no necesitan nada; las escrituras necesitan `@transactional()`.** `save`/`upsert` solo *vuelcan*, así que un manejador de escritura debe ejecutarse dentro de una unidad de trabajo que confirme. El decorador hace tres cosas por ti: abre la transacción, entrega al repositorio la sesión correcta para la llamada y confirma (o revierte ante una excepción). Por eso el monedero recién abierto sobrevivió una vez activada la persistencia, y por eso quitar el decorador lo perdería silenciosamente.
+
+### upsert, no save, para un agregado que es dueño de su id
+
+Fíjate en que el manejador llama a `self._repository.upsert(...)`, no a `save(...)`. (*Upsert* es el verbo combinado para "insertar si es nuevo, actualizar si ya está presente": una sola llamada para ambos casos.) Esa es la segunda sutileza. El `save()` del framework emite `session.add()`, que SQLAlchemy trata como un **INSERT pendiente**. Pero el agregado `Wallet` genera su *propia* clave primaria por adelantado (`wlt-…`), así que para cuando un ingreso o una retirada persisten un monedero ya cargado, ya existe una fila con ese id, y un segundo `INSERT` sobre la misma clave primaria lanza `IntegrityError`.
+
+El arreglo es `session.merge`, que inserta cuando el id es nuevo y actualiza cuando ya existe. Lumen lo envuelve en un método de conveniencia `upsert`:
+
+::: listing lumen/models/repositories/wallet_repository.py | Listado 5.12 — upsert: una sola llamada tanto para INSERT como para UPDATE
+@repository
+class WalletRepository(Repository[WalletEntity, str]):
+
+ async def upsert(self, entity: WalletEntity) -> WalletEntity:
+ """Insert *entity* or update the existing row with the same id.
+
+ Uses session.merge so a freshly-mapped entity carrying the
+ aggregate's id persists whether or not a row already exists —
+ the aggregate owns its primary key, so identity is never
+ ambiguous. Flushes so the write is visible in the current
+ unit of work; the surrounding @transactional commits it.
+ """
+ session = self._require_session()
+ merged = await session.merge(entity)
+ await session.flush()
+ return merged
+:::
+
+`_require_session()` es el accesor heredado que devuelve la sesión activa (la transaccional, una vez que `@transactional` la ha intercambiado). `merge` se basa en la clave primaria, así que tanto la primera escritura (apertura) como toda escritura posterior (ingreso, retirada) toman la misma ruta de código sin `IntegrityError`. Para entidades cuyos ids genera la base de datos, `save` es la opción natural; para un agregado que es dueño de su id, lo es `upsert`.
+
+!!! note "Qué acaba de pasar"
+ Dos preguntas deciden cada escritura: *¿se confirmó esta fila?* y *¿esta escritura insertó o actualizó?* `@transactional()` responde a la primera (confirma la unidad de trabajo); `upsert`/`merge` responde a la segunda (una sola ruta de código tanto para INSERT como para UPDATE, porque el agregado es dueño de su id). Acierta en ambas y un monedero que abres, ingresas en él y luego lees de vuelta tras un reinicio devuelve el saldo correcto, que es exactamente lo que afirma el test del repositorio de más abajo contra un motor *recién creado*.
+
+### La junta mapeadora agregado ↔ entidad
+
+Hay un límite más, y es una característica, no un accidente. Lumen mantiene dos tipos distintos:
+
+- **`Wallet`** — la *raíz de agregado* de DDD del Capítulo 6. Es dueña del invariante `balance >= 0`, expone métodos que revelan la intención (`open`, `deposit`, `withdraw`) y lanza eventos de dominio. No sabe nada de SQLAlchemy.
+- **`WalletEntity`** — la *fila de persistencia*. Es un modelo plano de SQLAlchemy con columnas y sin comportamiento.
+
+Un pequeño mapeador los une, una función pura en cada sentido:
+
+::: listing lumen/core/mappers/wallet_mapper.py | Listado 5.13 — El mapeador agregado ↔ fila
+def to_entity(wallet: Wallet) -> WalletEntity:
+ """Flatten a Wallet aggregate into a persistable row."""
+ assert wallet.id is not None
+ return WalletEntity(
+ id=wallet.id,
+ owner_id=wallet.owner_id,
+ currency=wallet.currency.value,
+ balance_minor=wallet.balance.amount,
+ created_at=wallet.created_at,
+ )
+
+
+def to_aggregate(entity: WalletEntity) -> Wallet:
+ """Rehydrate a Wallet aggregate from a persistence row."""
+ currency = Currency(entity.currency)
+ return Wallet(
+ id=entity.id,
+ owner_id=entity.owner_id,
+ balance=Money(amount=entity.balance_minor, currency=currency),
+ created_at=entity.created_at,
+ )
+:::
+
+El lado de escritura llama a `to_entity` antes del `upsert`; el lado de lectura o bien rehidrata con `to_aggregate` (cuando un comando necesita el agregado rico) o bien proyecta directamente a un DTO (cuando una consulta solo necesita datos). Mantener la fila separada del agregado significa que las preocupaciones de persistencia —tipos de columna, nulabilidad, el baile del merge— nunca se filtran al modelo de dominio, y que los invariantes del dominio nunca limitan el esquema de la tabla. El repositorio almacena filas; el mapeador es la junta que mantiene puro el agregado.
+
+!!! note "La rehidratación se salta la factoría"
+ `to_aggregate` llama al **constructor** de `Wallet` directamente, nunca a la factoría `Wallet.open`. La factoría es para monederos *nuevos*: valida las entradas y lanza un evento `WalletOpened`. Una fila cargada desde la base de datos ya representa un monedero válido y confirmado: volver a ejecutar la factoría volvería a disparar ese evento y a comprobar reglas que pasaron hace mucho. El constructor fija los campos en silencio, produciendo un `Wallet` indistinguible de uno recién abierto pero sin eventos espurios.
+
+---
+
+## Activarlo
+
+Activar la capa relacional es configuración, no código: tres claves en `pyfly.yaml`. Añade un bloque `data.relational`:
+
+::: listing pyfly.yaml | Listado 5.14 — Configuración de la capa de datos relacional
+pyfly:
+ data:
+ relational:
+ enabled: true
+ url: "sqlite+aiosqlite:///./lumen.db"
+ ddl-auto: create
+:::
+
+`enabled: true` activa la autoconfiguración relacional, que construye el motor asíncrono de SQLAlchemy y el `async_sessionmaker`, registra los beans `AsyncSession` y `session_factory` que inyectan el repositorio y los manejadores, e instala el `RepositoryBeanPostProcessor` que compila tus esbozos de consulta derivada. `url` es una cadena de conexión estándar de SQLAlchemy: SQLite vía `aiosqlite` aquí para un desarrollo de cero infraestructura, `postgresql+asyncpg://…` en producción. `ddl-auto: create` ejecuta `Base.metadata.create_all` al arrancar, así que la tabla `wallets` (descubierta porque `WalletEntity` hereda de `Base`) se construye automáticamente la primera vez que arranca la aplicación.
+
+La huella de dependencias es minúscula: `pyfly[data-relational]` arrastra `sqlalchemy[asyncio]` y `aiosqlite`, y nada más. Sin servidor de base de datos, sin instalación de drivers, que es exactamente por lo que el ejemplo se ejecuta en cualquier sitio.
+
+!!! note "Ejecútalo: el monedero que desaparece, arreglado"
+ Vuelve a ejecutar el experimento de la introducción, ahora con la persistencia activada. Abre un monedero, detén la aplicación, arráncala de nuevo y lee el saldo de vuelta:
+
+ ```bash
+ # Terminal 1
+ uv run pyfly run --server uvicorn
+
+ # Terminal 2 — open a wallet
+ curl -s -X POST localhost:8080/api/v1/wallets \
+ -H 'content-type: application/json' \
+ -d '{"owner_id": "alice", "currency": "EUR"}'
+ # -> {"wallet_id": "wlt-..."}
+
+ # Ctrl+C in Terminal 1, then start it again, then:
+ curl -s localhost:8080/api/v1/wallets/wlt-.../balance
+ ```
+
+ Esta vez el monedero sobrevive: su fila se confirmó en `lumen.db` en disco:
+
+ ```json
+ {"id": "wlt-...", "currency": "EUR", "balance_minor": 0, "balance": 0.0}
+ ```
+
+ Mira en el directorio del proyecto y verás el fichero SQLite `lumen.db` que el motor creó en el primer arranque, con la tabla `wallets` dentro. Todo el capítulo se reduce a esto: el monedero sobrevive al proceso.
+
+!!! tip "Ciclo de vida del esquema en producción"
+ `ddl-auto: create` es lo correcto para desarrollo y ejemplos: crea las tablas que faltan y deja en paz las existentes. En producción fija `ddl-auto: none` y gestiona el esquema con una herramienta de migración (Alembic), que genera scripts versionados a partir del diff entre `Base.metadata` y la base de datos en vivo. El código de la aplicación no cambia: solo el ajuste `ddl-auto` y la canalización de migración.
+
+---
+
+## Demostrar que funciona
+
+Como el repositorio es una clase ordinaria, puedes probarlo directamente contra un fichero SQLite real, sin contexto de aplicación, sin HTTP. El test del repositorio de Lumen crea una base de datos temporal, ejecuta `Base.metadata.create_all` y ejercita la superficie de principio a fin, incluido el `RepositoryBeanPostProcessor` que compila la consulta derivada (el mismo procesador que ejecuta el `ApplicationContext` en vivo):
+
+::: listing lumen/tests/test_sql_wallet_repository.py | Listado 5.15 — Probando el CRUD, la consulta derivada y la ruta de Specification
+def _make_repo(session: AsyncSession) -> WalletRepository:
+ repo = WalletRepository(WalletEntity, session)
+ # Mirror the ApplicationContext: compile derived-query stubs.
+ RepositoryBeanPostProcessor().after_init(repo, "walletRepository")
+ return repo
+
+
+@pytest.mark.asyncio
+async def test_derived_find_by_owner_id(sqlite_factory) -> None:
+ factory, _ = sqlite_factory
+ async with factory() as session:
+ repo = _make_repo(session)
+ await repo.upsert(_entity("wlt-1", "alice", 100))
+ await repo.upsert(_entity("wlt-2", "alice", 200))
+ await repo.upsert(_entity("wlt-3", "bob", 300))
+ await session.commit()
+
+ owned = await repo.find_by_owner_id("alice")
+ assert sorted(w.id for w in owned) == ["wlt-1", "wlt-2"]
+ assert await repo.find_by_owner_id("nobody") == []
+
+
+@pytest.mark.asyncio
+async def test_specification_find_rich_paged_and_sorted(
+ sqlite_factory,
+) -> None:
+ factory, _ = sqlite_factory
+ async with factory() as session:
+ repo = _make_repo(session)
+ # age_days drives created_at for newest-first ordering.
+ await repo.upsert(_entity("wlt-poor", "a", 50, age_days=3))
+ await repo.upsert(_entity("wlt-mid", "b", 1000, age_days=2))
+ await repo.upsert(_entity("wlt-rich", "c", 5000, age_days=1))
+ await session.commit()
+
+ # balance_minor >= 1000, newest first, page size 1.
+ newest_first = Sort.by("created_at").descending()
+ page = await repo.find_rich(1000, Pageable.of(1, 1, newest_first))
+ assert page.total == 2 # mid + rich
+ assert page.total_pages == 2
+ assert page.has_next is True
+ assert [w.id for w in page.items] == ["wlt-rich"]
+
+ # The bare predicate also works through find_all_by_spec.
+ rich = await repo.find_all_by_spec(balance_at_least(5000))
+ assert [w.id for w in rich] == ["wlt-rich"]
+:::
+
+El primer test ejercita la consulta derivada: tres monederos de entrada, dos propietarios de salida, y `find_by_owner_id("alice")` devuelve exactamente los dos: prueba de que el framework compiló `WHERE owner_id = :owner_id` a partir del nombre del método. El segundo ejercita la ruta de `Specification`: afirma el filtro de umbral (`total == 2`, solo mid y rich cumplen `>= 1000`), la ordenación de más reciente primero (`wlt-rich` es el más reciente de los dos), los metadatos de la página (`total_pages == 2`, `has_next`) y que el mismo predicado `balance_at_least` también se ejecuta sin paginar a través de `find_all_by_spec`.
+
+El fixture refleja lo que hace el framework al arrancar —construir el motor, ejecutar `Base.metadata.create_all` dentro de un bloque `begin()` para que el DDL se confirme, devolver una factoría de sesiones—, de modo que el test ejercita la misma tabla exacta que crea la aplicación. Otros tests del mismo fichero prueban que `upsert` hace un ida y vuelta a través de un motor *recién creado* (durabilidad a través de una reconexión) y que `find_all(pageable)` cuenta y corta correctamente una tabla de cinco monederos.
+
+!!! note "Ejecútalo: demuestra toda la capa en verde"
+ Ejecuta el fichero de test del repositorio de principio a fin. Desde la raíz del proyecto Lumen:
+
+ ```bash
+ uv run --extra dev pytest tests/test_sql_wallet_repository.py -v
+ ```
+
+ Cada test nombrado informa `PASSED`: ida y vuelta del CRUD, durabilidad a través de la reconexión, la consulta derivada, la ruta de especificación y la paginación:
+
+ ```
+ tests/test_sql_wallet_repository.py::test_upsert_inserts_then_updates_and_persists PASSED
+ tests/test_sql_wallet_repository.py::test_find_by_id_unknown_returns_none PASSED
+ tests/test_sql_wallet_repository.py::test_derived_find_by_owner_id PASSED
+ tests/test_sql_wallet_repository.py::test_specification_find_rich_paged_and_sorted PASSED
+ tests/test_sql_wallet_repository.py::test_find_all_pageable_counts_and_pages PASSED
+ 6 passed in 0.31s
+ ```
+
+ Ejecuta toda la batería (`uv run --extra dev pytest -q`) para confirmar que el resto de Lumen sigue pasando junto a la capa de persistencia.
+
+!!! spring "Equivalencia con Spring"
+ Construir el repositorio directamente contra una base de datos real en el mismo proceso refleja el slice `@DataJpaTest` de Spring, que arranca una base de datos H2 y la capa JPA de forma aislada para probar repositorios sin el contexto completo. `Base.metadata.create_all` es el análogo de `spring.jpa.hibernate.ddl-auto=create`, y ejecutar `RepositoryBeanPostProcessor` a mano hace las veces del proxy de Spring que materializa las consultas derivadas sobre un `JpaRepository` al arrancar.
+
+---
+
+## Lo que construiste {.recap}
+
+Lumen ahora persiste los monederos a través de la capa de repositorios al estilo de Spring Data de PyFly:
+
+- **Entidad** — `WalletEntity(Base)`, una fila de SQLAlchemy 2.0 con una clave primaria de cadena (el propio id del agregado) y saldos en unidades menores enteras.
+- **Repositorio** — `WalletRepository(Repository[WalletEntity, str])`, marcado con `@repository`. El framework suministra el CRUD asíncrono completo (`save`, `find_by_id`, `find_all`, `delete`/`delete_by_id`, `delete_all`/`delete_all_by_id`, `count`, `exists_by_id`, `save_all`, `find_all_by_id`, `stream_all`, paginación, especificaciones) sin ningún adaptador escrito a mano.
+- **Consulta derivada** — `find_by_owner_id`, declarada como un esbozo `...` y compilada a partir de su nombre por el `RepositoryBeanPostProcessor`.
+- **Paginación** — `find_all(pageable)` devolviendo un `Page[T]` con `total` / `total_pages` / `has_next`, mapeado a DTOs con `Page.map`, expuesto en `GET /api/v1/wallets`.
+- **Especificación** — `balance_at_least(n)` compuesta con `& | ~` y ejecutada vía `find_all_by_spec_paged`, expuesta en `GET /api/v1/wallets/rich`.
+- **Proyección** — `@projection BalanceView`, una dataclass concreta sobre la que el `Mapper` proyecta las filas para la vista de lectura del saldo.
+- **Transacciones** — manejadores de escritura decorados con `@transactional()` (porque `save`/`upsert` solo *vuelcan*), usando `upsert`/`session.merge` para un agregado que es dueño de su id, con el mapeador agregado ↔ entidad manteniendo puro el modelo de dominio.
+
+Escribiste interfaces y esbozos; el framework escribió el SQL. Esa es la recompensa del patrón Repositorio.
+
+---
+
+## Pruébalo tú mismo {.exercises}
+
+1. **Añade un contador derivado.** Declara `async def count_by_currency(self, currency: str) -> int: ...` en `WalletRepository` (cuerpo `...`). Escribe un test que haga upsert de monederos en dos monedas y afirme el conteo de cada una, confirmando que el prefijo `count_by` compila a `SELECT COUNT(*) … WHERE currency = :currency` sin ningún SQL por tu parte.
+
+2. **Compón dos especificaciones.** Define una segunda factoría `in_currency(code: str) -> Specification[WalletEntity]` (predicado `currency == code`), y luego añade un método de repositorio que ejecute `balance_at_least(min_minor) & in_currency(code)` a través de `find_all_by_spec_paged`. Prueba que devuelve solo los monederos ricos en la moneda elegida, de más reciente primero.
+
+3. **Sigue el rastro del límite transaccional.** Cambia temporalmente `OpenWalletHandler.do_handle` para que llame a `self._repository.save(to_entity(wallet))` en lugar de `upsert`, abre el mismo monedero dos veces en un test y observa el `IntegrityError`. Restaura `upsert`. Luego quita el decorador `@transactional()`, abre un monedero y afirma que **no** sobrevive a una reconexión con motor recién creado, demostrando que sin la confirmación de la unidad de trabajo, el `flush` por sí solo no es durabilidad.
+
+4. **Proyecta una vista diferente.** Añade una dataclass `@projection OwnerView` con solo `id` y `owner_id`, regístrala en un `Mapper` y escribe un test sin manejador que cargue un `WalletEntity` y lo proyecte, verificando que solo se leen las dos columnas declaradas y que se ignora el resto de la fila.
diff --git a/book/manuscript-es/06-domain-driven-design.md b/book/manuscript-es/06-domain-driven-design.md
new file mode 100644
index 00000000..d8afcc19
--- /dev/null
+++ b/book/manuscript-es/06-domain-driven-design.md
@@ -0,0 +1,863 @@
+Capítulo 6
+
+# Diseño guiado por el dominio {.chtitle}
+
+::: figure art/openers/ch06.svg |
+
+La funcionalidad del monedero de Lumen funciona. Los depósitos llegan, los saldos se actualizan y la base de datos lo persiste todo entre reinicios. Pero fíjate bien en la capa de servicio y notarás algo incómodo: la comprobación de descubierto vive en el método del servicio, no en el propio monedero. La validación de divisa es una comparación de propiedades repartida entre un puñado de sentencias `if`. Nada impide que un futuro desarrollador —o tu yo futuro a las 11 de la noche— se salte esas defensas llamando directamente a `repo.save(entity)`.
+
+El **diseño guiado por el dominio** (Domain-Driven Design) resuelve esto haciendo que el modelo sea responsable de sus propias reglas. Los datos dejan de ser una bolsa pasiva de valores que cualquier invocador puede mutar; se convierten en un objeto con criterio propio: uno que hace cumplir sus invariantes, anuncia lo que ha ocurrido y coopera con la capa de persistencia mientras permanece libre de cualquier importación de base de datos.
+
+Este capítulo asciende el monedero a un agregado DDD en condiciones: un objeto de valor `Money`, una raíz de agregado `Wallet` que protege la regla de descubierto y la regla de coincidencia de divisa, y un conjunto de `DomainEvent`s que se emiten cada vez que el monedero cambia de estado. Un mapeador ligero convierte entre el rico modelo de dominio y el registro plano de persistencia; ninguno de los dos lados necesita conocer la forma del otro.
+
+---
+
+## Entidades y objetos de valor
+
+Antes de poder construir un modelo que haga cumplir sus propias reglas, necesitas un vocabulario para los dos tipos fundamentalmente distintos de objetos que aparecen en todo dominio.
+
+Piensa en qué hace que dos monederos sean distintos. Aunque dos monederos contengan exactamente cien euros, siguen siendo monederos separados que pertenecen a propietarios separados: te importa *cuál* tienes. Ahora piensa en el importe en sí. Cien euros son cien euros; el objeto Python exacto que contiene el valor es irrelevante. Si un depósito añade cincuenta euros al saldo de un monedero, no quieres actualizar el importe existente en su sitio: quieres derivar un importe completamente nuevo que registre el resultado. Mutar en el sitio invita a errores de aliasing en los que dos partes del código comparten sin saberlo una referencia al mismo objeto y ven los cambios del otro.
+
+!!! note "La jerga, en lenguaje llano"
+ Algunas palabras se repiten a lo largo de este capítulo. Una **invariante** es una regla que siempre debe cumplirse; para un monedero, "el saldo nunca es negativo". Un **agregado** es un pequeño grupo de objetos que cambian juntos y deben mantenerse coherentes como conjunto. Un **error de aliasing** ocurre cuando dos fragmentos de código sostienen accidentalmente el *mismo* objeto y uno lo muta, sorprendiendo al otro. Ten presentes estos tres conceptos; el resto del capítulo trata en gran medida de prevenir el último y garantizar el primero dentro del segundo.
+
+El DDD da nombre a estos dos roles: **entidades** y **objetos de valor**, y el módulo `pyfly.domain` de PyFly los convierte en conceptos de primera clase:
+
+| Concepto | Base de PyFly | Igualdad | Mutación |
+|---|---|---|---|
+| **`Entity[TID]`** | `Entity` | Identidad: iguales solo cuando coincide el `id` | Permitida mediante métodos propios |
+| **`ValueObject`** | `ValueObject` | Estructural: se comparan todos los campos | Prohibida; `replace(**changes)` crea una nueva instancia |
+
+Las entidades transitorias (las que tienen `id=None`) se comparan iguales solo por la identidad de objeto de Python, así que puedes meter entidades en conjuntos y diccionarios sin preocuparte por colisiones de hash de objetos sin guardar.
+
+El dinero es el objeto de valor de manual. Un importe de cien euros no es un objeto específico que sigues en el tiempo; es un valor. Dos instancias separadas de `Money(100, "EUR")` son iguales. Un depósito no muta el importe existente: produce uno nuevo, dejando el original intacto y el modelo libre de efectos secundarios ocultos.
+
+Aquí está el objeto de valor `Money` para Lumen:
+
+::: listing lumen/models/entities/v1/money.py | Listado 6.1 — Money: un objeto de valor inmutable con aritmética consciente de la divisa
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from lumen.interfaces.enums.v1.currency import Currency
+from pyfly.domain import BusinessRuleViolation, ValueObject
+
+
+@dataclass(frozen=True)
+class Money(ValueObject):
+ """An exact monetary amount in a single currency.
+
+ ``amount`` is in minor units (e.g. cents): ``Money(1050,
+ Currency.EUR)`` is €10.50. Arithmetic returns new ``Money``
+ instances and refuses to mix currencies.
+ """
+
+ amount: int
+ currency: Currency
+
+ def __post_init__(self) -> None:
+ if not isinstance(self.amount, int) or isinstance(self.amount, bool):
+ raise BusinessRuleViolation(
+ "money-amount-integer",
+ "amount must be an integer number of minor units",
+ )
+
+ @classmethod
+ def zero(cls, currency: Currency) -> Money:
+ """The additive identity for *currency* (a zero balance)."""
+ return cls(amount=0, currency=currency)
+
+ def add(self, other: Money) -> Money:
+ """Return ``self + other``; both must share a currency."""
+ self._assert_same_currency(other)
+ return Money(amount=self.amount + other.amount, currency=self.currency)
+
+ def subtract(self, other: Money) -> Money:
+ """Return ``self - other``; both must share a currency."""
+ self._assert_same_currency(other)
+ return Money(amount=self.amount - other.amount, currency=self.currency)
+
+ @property
+ def is_positive(self) -> bool:
+ return self.amount > 0
+
+ @property
+ def is_negative(self) -> bool:
+ return self.amount < 0
+
+ @property
+ def major_units(self) -> float:
+ """The amount rendered as a major-unit decimal (cents / 100)."""
+ return round(self.amount / 100, 2)
+
+ def _assert_same_currency(self, other: Money) -> None:
+ if self.currency is not other.currency:
+ raise BusinessRuleViolation(
+ "money-currency-mismatch",
+ f"cannot combine {self.currency.value} "
+ f"with {other.currency.value}",
+ )
+
+ def __str__(self) -> str:
+ return f"{self.major_units:.2f} {self.currency.value}"
+:::
+
+**Cómo funciona.** El importe se almacena en **unidades menores** —céntimos enteros, peniques o cualquiera que sea la denominación más pequeña de la divisa— para eliminar por completo el redondeo en coma flotante. Los cálculos financieros que usan `float` son una fuente crónica de errores de un céntimo de más o de menos que solo afloran en producción, normalmente durante la conciliación. Almacenar 10,50 € como `amount=1050` mantiene toda la aritmética exacta. `__post_init__` rechaza de inmediato los importes que no sean enteros con `BusinessRuleViolation("money-amount-integer")`, así que un `float` perdido como `10.5` nunca entra en silencio en el modelo.
+
+El campo `currency` usa el enum `Currency` (`Currency.EUR`, `Currency.USD`, `Currency.GBP`) en lugar de una cadena pelada, descartando erratas en el momento de la construcción. Las comparaciones de divisa dentro de `_assert_same_currency` usan la comprobación de identidad de Python (`is`) —lanzando `BusinessRuleViolation("money-currency-mismatch")` si difieren— para que el error aflore exactamente donde se cometió el fallo.
+
+Tanto `add` como `subtract` devuelven una *nueva* instancia de `Money` en lugar de modificar `self`, consecuencia directa de `frozen=True`. Esta garantía de inmutabilidad significa que el agregado que contiene un valor `Money` nunca puede quedar parcialmente actualizado: o bien el reemplazo completo tiene éxito, o bien el valor antiguo permanece en su sitio. Las propiedades `is_positive` e `is_negative` exponen el signo sin filtrar el entero crudo. `major_units` convierte a un decimal para mostrarlo, y `__str__` da formato como `"10.50 EUR"` mediante `currency.value`.
+
+**Constrúyelo paso a paso.** Si estás creando `Money` desde cero, este es el orden en que escribirlo, y por qué importa cada línea.
+
+Paso 1 — Declara los dos campos y congela la clase. Añade `amount: int` y `currency: Currency` a una clase decorada con `@dataclass(frozen=True)` que herede de `ValueObject`. `frozen=True` es lo que hace que el objeto sea inmutable y te da la igualdad estructural de regalo: dos instancias de `Money` con el mismo importe y divisa ahora son `==`.
+
+Paso 2 — Protege el constructor. Añade `__post_init__` para rechazar cualquier cosa que no sea un entero simple. La comprobación `isinstance(self.amount, bool)` es deliberada: en Python `True` es un `int`, y no quieres que `Money(True, ...)` se cuele.
+
+Paso 3 — Añade la factoría `zero`. Un monedero se abre con saldo cero, así que `Money.zero(currency)` se lee mejor en el punto de llamada que `Money(0, currency)`.
+
+Paso 4 — Añade `add` y `subtract`, cada uno pasando primero por `_assert_same_currency`. Devolver un `Money` completamente nuevo (sin mutar nunca `self`) es lo que previene el error de aliasing de la apertura de la sección.
+
+Paso 5 — Añade los ayudantes de visualización: `is_positive`, `is_negative`, `major_units` y `__str__`.
+
+**Ejecútalo.** `Money` no depende del runtime del framework ni de una base de datos, así que puedes ejercitar todas las reglas desde un prompt de Python. Arranca uno con `uv run python` desde el directorio `samples/lumen` y escribe:
+
+```python
+>>> from lumen.models.entities.v1.money import Money
+>>> from lumen.interfaces.enums.v1.currency import Currency
+>>> ten_fifty = Money(1050, Currency.EUR)
+>>> str(ten_fifty)
+'10.50 EUR'
+>>> ten_fifty.add(Money(450, Currency.EUR))
+Money(amount=1500, currency=)
+>>> ten_fifty.add(Money(100, Currency.USD))
+Traceback (most recent call last):
+ ...
+pyfly.domain.exceptions.BusinessRuleViolation: cannot combine EUR with USD
+>>> Money(10.5, Currency.EUR)
+Traceback (most recent call last):
+ ...
+pyfly.domain.exceptions.BusinessRuleViolation: amount must be an integer number of minor units
+```
+
+Las dos trazas son la clave: el modelo rechaza una suma entre divisas y un importe en coma flotante *en el momento en que cometes el fallo*, no tres capas más abajo durante la conciliación.
+
+Lumen incluye estos comportamientos exactos como pruebas. Ejecútalas con:
+
+```
+uv run --extra dev pytest tests/test_money.py -q
+```
+
+y deberías ver pasar las seis:
+
+```
+...... [100%]
+6 passed in 0.0Xs
+```
+
+**Qué acaba de pasar.** Has construido un objeto de valor imposible de usar mal: no se puede mutar, no puede mezclar divisas y no puede contener un `float`. Cualquier otra pieza del modelo del monedero se apoyará en estas garantías, por lo cual `Money` va primero.
+
+!!! note "Unidades menores frente a decimal"
+ Almacenar el dinero como céntimos enteros es una convención; otra es el `decimal.Decimal` de Python con una escala fija. Ambas son válidas. Lo que importa es elegir una y ceñirse a ella dentro del contexto delimitado (bounded context). Para Lumen, las unidades menores enteras mantienen el modelo libre de configuración de precisión en tiempo de importación, y `__post_init__` impone la restricción con `BusinessRuleViolation("money-amount-integer")` para que un `float` nunca entre en silencio en el modelo.
+
+!!! spring "Equivalencia con Spring"
+ `ValueObject` refleja el conjunto `@ValueObject` / `@Embeddable` del ecosistema JPA de Spring y la interfaz marcadora `ValueObject` de Spring Modulith. El dataclass con `frozen=True` se corresponde con el tipo `record` de Java introducido en Java 16: inmutable, igualdad basada en valor, sintaxis concisa. La anotación `@ValueObject` de jMolecules expresa la misma intención.
+
+---
+
+## La raíz de agregado
+
+`Money` resuelve el problema de representación: ahora los importes son inmutables y conscientes de la divisa. Pero Lumen todavía necesita algo que *posea* el saldo del monedero y decida cuándo se permite un depósito o una retirada. Ese es el papel de la **raíz de agregado**.
+
+Una entidad se convierte en raíz de agregado cuando posee un grupo de objetos relacionados y actúa como el único punto de entrada para todos los cambios dentro de ese grupo. La raíz de agregado es la **frontera de coherencia**: ningún código externo entra y muta directamente un objeto interno. Todos los cambios fluyen a través de los métodos de la raíz, que hacen cumplir las reglas. Este es el diseño que evita el atajo de las 11 de la noche descrito en la introducción del capítulo: una vez que todo cambio debe fluir a través de la raíz, no hay puerta trasera.
+
+`AggregateRoot[TID]` amplía `Entity[TID]` con una sola adición: un búfer interno de **eventos de dominio pendientes**. Cada método que cambia el estado llama a `self.raise_event(event)` para registrar lo ocurrido. Cuando el repositorio guarda el agregado, el servicio de aplicación vacía ese búfer con `clear_events()` y publica los eventos en el bus de eventos. Verás el ciclo completo de publicación en la sección Eventos de dominio; por ahora, concéntrate en el propio agregado.
+
+Aquí está la raíz de agregado `Wallet`:
+
+::: listing lumen/models/entities/v1/wallet_entity.py | Listado 6.2 — Wallet: la raíz de agregado que posee el saldo e impone sus reglas
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import UTC, datetime
+
+from lumen.interfaces.enums.v1.currency import Currency
+from lumen.models.entities.v1.money import Money
+from pyfly.domain import AggregateRoot, BusinessRuleViolation, DomainEvent
+
+
+# ── Domain events ─────────────────────────────────────────────────────────────
+
+@dataclass(frozen=True)
+class WalletOpened(DomainEvent):
+ wallet_id: str = ""
+ owner_id: str = ""
+ currency: str = ""
+
+
+@dataclass(frozen=True)
+class FundsDeposited(DomainEvent):
+ wallet_id: str = ""
+ amount: int = 0
+ currency: str = ""
+ balance: int = 0
+
+
+@dataclass(frozen=True)
+class FundsWithdrawn(DomainEvent):
+ wallet_id: str = ""
+ amount: int = 0
+ currency: str = ""
+ balance: int = 0
+
+
+# ── Aggregate root ────────────────────────────────────────────────────────────
+
+class Wallet(AggregateRoot[str]):
+ """Wallet aggregate root — owns the ``balance >= 0`` invariant."""
+
+ __slots__ = ("owner_id", "balance", "created_at")
+
+ def __init__(
+ self,
+ id: str,
+ owner_id: str,
+ balance: Money,
+ created_at: datetime | None = None,
+ ) -> None:
+ super().__init__(id)
+ self.owner_id = owner_id
+ self.balance = balance
+ self.created_at = created_at or datetime.now(UTC)
+
+ @property
+ def currency(self) -> Currency:
+ return self.balance.currency
+
+ # ── Factory method ─────────────────────────────────────────────────────
+
+ @classmethod
+ def open(cls, wallet_id: str, owner_id: str, currency: Currency) -> Wallet:
+ """Open a new, empty wallet; raises WalletOpened."""
+ if not owner_id.strip():
+ raise BusinessRuleViolation(
+ "wallet-owner-required", "owner_id is required"
+ )
+ wallet = cls(
+ id=wallet_id,
+ owner_id=owner_id,
+ balance=Money.zero(currency),
+ )
+ wallet.raise_event(
+ WalletOpened(
+ wallet_id=wallet_id,
+ owner_id=owner_id,
+ currency=currency.value,
+ )
+ )
+ return wallet
+
+ # ── Behaviour ──────────────────────────────────────────────────────────
+
+ def deposit(self, amount: Money) -> None:
+ """Credit *amount* to the balance; raises FundsDeposited."""
+ self._assert_currency(amount)
+ if not amount.is_positive:
+ raise BusinessRuleViolation(
+ "wallet-deposit-positive",
+ "deposit amount must be > 0",
+ )
+ self.balance = self.balance.add(amount)
+ assert self.id is not None
+ self.raise_event(
+ FundsDeposited(
+ wallet_id=self.id,
+ amount=amount.amount,
+ currency=amount.currency.value,
+ balance=self.balance.amount,
+ )
+ )
+
+ def withdraw(self, amount: Money) -> None:
+ """Debit *amount*; refuses to overdraw. Raises FundsWithdrawn."""
+ self._assert_currency(amount)
+ if not amount.is_positive:
+ raise BusinessRuleViolation(
+ "wallet-withdrawal-positive",
+ "withdrawal amount must be > 0",
+ )
+ remaining = self.balance.subtract(amount)
+ if remaining.is_negative:
+ raise BusinessRuleViolation(
+ "wallet-insufficient-funds",
+ f"cannot withdraw {amount}; balance is {self.balance}",
+ )
+ self.balance = remaining
+ assert self.id is not None
+ self.raise_event(
+ FundsWithdrawn(
+ wallet_id=self.id,
+ amount=amount.amount,
+ currency=amount.currency.value,
+ balance=self.balance.amount,
+ )
+ )
+
+ # ── Helpers ────────────────────────────────────────────────────────────
+
+ def _assert_currency(self, amount: Money) -> None:
+ if amount.currency is not self.balance.currency:
+ raise BusinessRuleViolation(
+ "wallet-currency-mismatch",
+ f"wallet holds {self.balance.currency.value}, "
+ f"got {amount.currency.value}",
+ )
+:::
+
+**Cómo funciona.** La frontera del agregado se impone en tres niveles. Primero, `__slots__` bloquea el conjunto de atributos, y `balance` y `owner_id` son deliberadamente públicos-pero-propios: solo los métodos del propio agregado (`deposit`, `withdraw`) los mutan, mientras que la propiedad `currency` es una comodidad de solo lectura que delega en `balance.currency`. Segundo, el classmethod factoría `open` es la única forma legítima de crear un monedero nuevo: el invocador suministra el `wallet_id` (para que la capa de aplicación controle la generación de IDs), `open` valida que `owner_id` no esté en blanco, inicializa el saldo con `Money.zero(currency)` y encola de inmediato `WalletOpened`. Usar una factoría en lugar de llamar a `__init__` directamente asegura que el evento de apertura *nunca* se olvide, ni siquiera en un fixture de prueba. Tercero, los eventos de dominio —`WalletOpened`, `FundsDeposited`, `FundsWithdrawn`— son dataclasses congelados. `FundsDeposited` y `FundsWithdrawn` llevan un campo `balance` (el saldo posterior a la operación en unidades menores), de modo que un suscriptor nunca necesita volver a consultar al agregado para conocer el estado actual.
+
+**Constrúyelo paso a paso.** La clase `Wallet` tiene más piezas móviles que `Money`, así que este es el orden para ensamblarla.
+
+Paso 1 — Define primero las tres clases de evento (`WalletOpened`, `FundsDeposited`, `FundsWithdrawn`), cada una un `@dataclass(frozen=True)` que herede de `DomainEvent`. Tienen que existir antes de que el agregado pueda referenciarlas. Cubrimos los eventos a fondo en la sección dentro de dos; por ahora son simplemente los registros que el monedero emitirá.
+
+Paso 2 — Declara el agregado. Hereda de `AggregateRoot[str]` (el `[str]` dice que el id es una cadena), define `__slots__` para bloquear los nombres de atributo y escribe `__init__` para almacenar `owner_id`, `balance` y `created_at`. Llama a `super().__init__(id)` primero para que la clase base configure el id y el búfer interno de eventos.
+
+Paso 3 — Añade la propiedad de solo lectura `currency` que delega en `self.balance.currency`. Es una comodidad que evita que los invocadores tengan que bajar dos niveles hasta `wallet.balance.currency`.
+
+Paso 4 — Escribe el classmethod factoría `open`. Valida `owner_id`, construye el monedero con un saldo `Money.zero(currency)` y llama a `self.raise_event(WalletOpened(...))`. Hacer `open` un classmethod —en lugar de esperar que los invocadores usen `__init__` más un evento manual— garantiza que el evento de apertura nunca se olvide.
+
+Paso 5 — Escribe `deposit` y `withdraw`. Cada uno valida primero (coincidencia de divisa, importe positivo y, para `withdraw`, fondos suficientes), *después* muta `self.balance`, *después* lanza su evento. El orden importa: nunca mutes antes de que hayan pasado todas las comprobaciones, o puedes dejar el monedero en un estado a medio cambiar.
+
+Paso 6 — Añade el ayudante privado `_assert_currency` que comparten ambas transiciones.
+
+!!! note "raise_event no lanza una excepción"
+ A pesar del nombre, `raise_event` no lanza nada. *Añade* un evento a un búfer interno del agregado (`AggregateRoot._pending_events`). Nada se publica en ese momento. Un paso posterior —el servicio de aplicación, tras un guardado exitoso— vacía el búfer con `clear_events()` y entrega los eventos al bus de eventos. Piensa en `raise_event` como "anota que esto ocurrió", no como "aborta".
+
+**Ejecútalo.** Igual que `Money`, el agregado `Wallet` es Python puro: no hace falta base de datos. Desde `uv run python`:
+
+```python
+>>> from lumen.models.entities.v1.wallet_entity import Wallet
+>>> from lumen.models.entities.v1.money import Money
+>>> from lumen.interfaces.enums.v1.currency import Currency
+>>> w = Wallet.open("wlt-1", "owner-1", Currency.EUR)
+>>> w.deposit(Money(1000, Currency.EUR))
+>>> w.withdraw(Money(400, Currency.EUR))
+>>> str(w.balance)
+'6.00 EUR'
+>>> [e.event_type for e in w.pending_events()]
+['WalletOpened', 'FundsDeposited', 'FundsWithdrawn']
+>>> w.withdraw(Money(9999, Currency.EUR))
+Traceback (most recent call last):
+ ...
+pyfly.domain.exceptions.BusinessRuleViolation: cannot withdraw 99.99 EUR; balance is 6.00 EUR
+```
+
+Fíjate en los tres eventos que esperan en el búfer tras las operaciones exitosas, y fíjate en que el intento de descubierto se lanzó *antes* de tocar el saldo: `pending_events()` sigue mostrando tres, no cuatro. La batería de pruebas de Lumen afirma exactamente esto:
+
+```
+uv run --extra dev pytest tests/test_wallet_aggregate.py -q
+```
+
+```
+...... [100%]
+6 passed in 0.0Xs
+```
+
+**Qué acaba de pasar.** El monedero ahora posee sus reglas. No hay forma de dejarlo en descubierto, no hay forma de alimentarlo con la divisa equivocada y no hay forma de cambiar su estado sin dejar atrás un evento que registre lo ocurrido. La capa de servicio ya no tiene que recordar nada de eso.
+
+El diagrama de abajo muestra el panorama completo: estado, invariantes y los eventos que el monedero emite.
+
+::: figure art/figures/06-aggregate.svg | Figura 6.1 — El agregado Wallet: estado, invariantes y los eventos que emite.
+
+!!! spring "Equivalencia con Spring"
+ `AggregateRoot[str]` se corresponde con `org.jmolecules.ddd.types.AggregateRoot` de jMolecules y con `AbstractAggregateRoot` de Spring Data, que ofrece el mismo mecanismo `registerEvent()` / `@DomainEvents` / `@AfterDomainEventPublication`. El patrón es idéntico en espíritu: el agregado acumula eventos en un búfer; el repositorio los vacía tras un guardado exitoso; un `DomainEventPublisher` los despacha. El `raise_event` + `clear_events` de PyFly es el equivalente Python de `registerEvent` + `@AfterDomainEventPublication`.
+
+---
+
+## Proteger las invariantes
+
+La raíz de agregado solo es valiosa si las reglas que impone son genuinamente inalcanzables por cualquier otra vía. Eso es lo que significa **invariante** en DDD: una condición que el modelo debe mantener sin importar cómo se le llame, quién lo llame o cuántos servicios existan en la aplicación. Una invariante no es una sugerencia: es una restricción que no puede violarse porque el modelo no expone ningún mecanismo para hacerlo.
+
+El `Wallet` de Lumen tiene tres invariantes:
+
+1. El saldo nunca debe bajar de cero (sin descubierto).
+2. Los fondos solo pueden depositarse o retirarse en la divisa nativa del monedero.
+3. Los importes de depósito y retirada deben ser estrictamente positivos.
+
+Las tres se imponen dentro de los métodos del agregado. La excepción del framework para esto es **`BusinessRuleViolation`** de `pyfly.domain`. Toma dos argumentos obligatorios: un identificador `rule` estable y legible por máquina, y un `message` legible por humanos. Los identificadores de Lumen —`"wallet-insufficient-funds"`, `"wallet-currency-mismatch"`, `"wallet-deposit-positive"`, `"wallet-withdrawal-positive"`— son etiquetas en kebab-case que viajan en el cuerpo de respuesta RFC 7807 y en los campos de log estructurado.
+
+`BusinessRuleViolation` amplía `pyfly.kernel.BusinessException`, así que el mapeador de detalles de problema (problem-details) RFC 7807 del Capítulo 4 la traduce automáticamente a una respuesta HTTP 422, sin necesidad de un manejador adicional.
+
+!!! note "Qué significa aquí RFC 7807"
+ RFC 7807 es el estándar web para "detalles de problema": una forma JSON pequeña y predecible (`type`, `title`, `status`, `detail`, más tus propios campos) que devuelve una API cuando algo falla. El mapeador de PyFly convierte automáticamente cualquier `BusinessException` en uno de estos, de modo que el identificador `rule` que pones en `BusinessRuleViolation` acaba en el cuerpo de respuesta donde un cliente puede leerlo sin analizar prosa en inglés.
+
+**Comprueba que la invariante se sostiene.** La promesa de una invariante es que una operación *fallida* deja el modelo exactamente como estaba. Puedes demostrarlo desde `uv run python`:
+
+```python
+>>> from lumen.models.entities.v1.wallet_entity import Wallet
+>>> from lumen.models.entities.v1.money import Money
+>>> from lumen.interfaces.enums.v1.currency import Currency
+>>> from pyfly.domain import BusinessRuleViolation
+>>> w = Wallet.open("wlt-1", "owner-1", Currency.EUR)
+>>> w.deposit(Money(500, Currency.EUR))
+>>> w.clear_events() # drain the open + deposit events
+[WalletOpened(...), FundsDeposited(...)]
+>>> try:
+... w.withdraw(Money(501, Currency.EUR))
+... except BusinessRuleViolation as exc:
+... print(exc.rule)
+...
+wallet-insufficient-funds
+>>> str(w.balance) # unchanged — the rule fired before any mutation
+'5.00 EUR'
+>>> w.pending_events() # and no event was queued for the failed attempt
+[]
+```
+
+Esa es toda la garantía en tres líneas: el identificador de la regla es estable y legible por máquina, el saldo no se movió y ningún evento se filtró por una operación que nunca ocurrió.
+
+!!! warning "Mantén las invariantes en el modelo, no en el servicio"
+ Devolver la comprobación de descubierto a `WalletService` crea dos problemas. Primero, cualquier código que llame a `repo.save(entity)` directamente se salta la comprobación por completo. Segundo, acabas duplicando la regla en cada vía que modifica un monedero: el servicio, un trabajo en segundo plano, un comando de administración. Cuando la regla cambia —digamos que el equipo de producto introduce un colchón de descubierto configurable— hay exactamente un sitio que actualizar: el método del agregado. Esa es toda la cuestión.
+
+La diferencia entre una defensa a nivel de servicio y una invariante de agregado es la exigibilidad. Una defensa de servicio es una convención; una invariante de agregado es una restricción física impuesta por el encapsulamiento. Para concretarlo, así es como se ve el enfoque a nivel de servicio y por qué es frágil:
+
+::: listing lumen/wallet_service_before.py | Listado 6.3 — Antes: reglas de negocio dispersas por el servicio (frágil)
+# DO NOT DO THIS — rules that belong in the model
+from pyfly.container import service
+
+
+@service
+class WalletServiceBefore:
+
+ async def withdraw(self, wallet_id: str, amount: float) -> None:
+ # Rule lives here — but anyone calling repo.save directly skips it
+ wallet = {"id": wallet_id, "balance": 50.0, "currency": "EUR"}
+ if wallet["balance"] < amount:
+ raise ValueError("Insufficient funds")
+ wallet["balance"] -= amount
+ # ... save
+:::
+
+Y así es como se ve el servicio después de que el modelo asume la propiedad:
+
+::: listing lumen/wallet_service_after.py | Listado 6.4 — Después: el servicio delega en el agregado
+from pyfly.container import service
+from pyfly.domain import AggregateNotFound
+
+from lumen.interfaces.enums.v1.currency import Currency
+from lumen.models.entities.v1.money import Money
+
+
+@service
+class WalletService:
+
+ async def withdraw(
+ self,
+ wallet_id: str,
+ amount_cents: int,
+ currency: Currency,
+ ) -> None:
+ # The service orchestrates; the aggregate decides.
+ wallet = await self._repo.find(wallet_id)
+ if wallet is None:
+ raise AggregateNotFound("Wallet", wallet_id)
+ wallet.withdraw(Money(amount=amount_cents, currency=currency))
+ await self._repo.save(wallet)
+ # Events are drained and published by the repository/service boundary
+:::
+
+**Cómo funciona.** La versión "después" se lee como una instrucción: `wallet.withdraw(...)` significa "pídele al monedero que retire". El servicio no sabe —ni le importa— qué implica eso. Confía en que el agregado o bien tendrá éxito, o bien lanzará una `BusinessRuleViolation`. Ese patrón de orquestador ligero tiene un beneficio práctico para el flujo de trabajo del equipo: un desarrollador nuevo puede implementar un endpoint `transfer` sin leer `WalletService` en absoluto. Las restricciones viven en `Wallet`: un único sitio donde mirar.
+
+El identificador `rule` de `BusinessRuleViolation` también importa. Cadenas como `"wallet-insufficient-funds"` y `"wallet-currency-mismatch"` viajan en el cuerpo de respuesta RFC 7807, donde el código del cliente puede emparejarlas sin analizar mensajes de texto libre. También aparecen en los campos de log estructurado, lo que hace que escribir alertas de producción sea sencillo.
+
+!!! note "AggregateNotFound"
+ `AggregateNotFound` es la segunda excepción de dominio en `pyfly.domain`. Lánzala cuando el agregado solicitado no exista: se corresponde con una respuesta de detalles de problema 404 mediante el mismo manejador RFC 7807. El constructor toma el nombre del tipo de agregado y el ID: `AggregateNotFound("Wallet", wallet_id)`.
+
+---
+
+## Eventos de dominio
+
+Tu agregado ya hace cumplir sus invariantes y controla todos los cambios de estado. Pero Lumen acabará necesitando reaccionar a esos cambios: actualizar un registro de auditoría, enviar una notificación push, disparar la detección de fraude, publicar un asiento en el libro mayor. La solución tentadora es poner esos efectos secundarios directamente dentro de `deposit` y `withdraw`. Eso acopla el modelo de dominio a la infraestructura: de repente tu monedero necesita conocer los topics de Kafka y las plantillas de correo, y cada prueba unitaria arrastra una conexión a un broker.
+
+Los **eventos de dominio** cortan ese acoplamiento. Un evento de dominio registra algo que *ocurrió* dentro del agregado: en pasado, un hecho inmutable. El agregado no sabe qué se hará con el hecho; solo lo registra. Los consumidores aguas abajo —escuchadores de eventos, proyectores, servicios de notificación— se suscriben y reaccionan en su propio contexto, sin que el agregado dependa jamás de ellos.
+
+`DomainEvent` de `pyfly.domain` es una base de dataclass congelado que autorrellena tres campos cuando se crea una instancia:
+
+- `event_id` — un UUID v4 que identifica de forma única esta ocurrencia.
+- `occurred_at` — una marca de tiempo UTC en el momento de la construcción.
+- `event_type` — una propiedad que por defecto es el nombre de la clase de la subclase (`"WalletOpened"`, `"FundsDeposited"`, `"FundsWithdrawn"`).
+
+Ya viste los tres eventos del monedero definidos en el Listado 6.2. Aquí están aislados, con una mirada explícita a lo que obtienes de la base:
+
+::: listing lumen/models/entities/v1/wallet_entity.py | Listado 6.5 — Eventos de dominio y los campos que DomainEvent proporciona automáticamente
+from dataclasses import dataclass
+from pyfly.domain import DomainEvent
+
+
+@dataclass(frozen=True)
+class WalletOpened(DomainEvent):
+ wallet_id: str = ""
+ owner_id: str = ""
+ currency: str = ""
+
+
+@dataclass(frozen=True)
+class FundsDeposited(DomainEvent):
+ wallet_id: str = ""
+ amount: int = 0
+ currency: str = ""
+ balance: int = 0 # balance after the deposit, in minor units
+
+
+@dataclass(frozen=True)
+class FundsWithdrawn(DomainEvent):
+ wallet_id: str = ""
+ amount: int = 0
+ currency: str = ""
+ balance: int = 0 # balance after the withdrawal, in minor units
+
+
+# Each event carries event_id (UUID), occurred_at (UTC datetime),
+# and event_type (class name) — all set by DomainEvent.__post_init__.
+
+def demonstrate_event_fields() -> None:
+ evt = FundsDeposited(
+ wallet_id="w-1",
+ amount=5000,
+ currency="EUR",
+ balance=15000,
+ )
+ print(evt.event_id) # e.g. "3fa85f64-5717-4562-b3fc-2c963f66afa6"
+ print(evt.occurred_at) # e.g. datetime(2026, 6, 7, 9, 30, 0, tzinfo=UTC)
+ print(evt.event_type) # "FundsDeposited"
+:::
+
+**Cómo funciona.** Cada clase de evento declara solo los campos exclusivos de esa ocurrencia. Todo lo demás viene de `DomainEvent`: un `event_id` UUID para el procesamiento idempotente, `occurred_at` para el rastro de auditoría y `event_type` —el nombre de la clase— para el enrutamiento en el consumidor sin inspeccionar la clase Python. Todos los campos tienen como valor por defecto cero o cadena vacía para que la maquinaria del dataclass con `frozen=True` pueda ofrecer construcción mediante argumentos por palabra clave sin requerir argumentos posicionales.
+
+Fíjate en que `FundsDeposited` lleva tanto `amount` (la transacción) como `balance` (el saldo posterior a la operación, en unidades menores). Un suscriptor que actualice un saldo de modelo de lectura no necesita ninguna llamada de vuelta al agregado ni a la base de datos: todo está en el evento. Ese diseño autocontenido mantiene simples a los consumidores y elimina viajes de ida y vuelta adicionales.
+
+!!! note "Modelo de lectura, en lenguaje llano"
+ Un **modelo de lectura** (read-model) es una copia separada de los datos, optimizada para consultas y moldeada para mostrarlos: un total de panel, un índice de búsqueda, una caché. Como `FundsDeposited` ya lleva el `balance` posterior a la operación, un servicio que mantenga tal copia puede aplicar el evento a ciegas sin volver a cargar nunca el monedero. Construiremos modelos de lectura como es debido en un capítulo posterior; aquí, basta con anotar por qué poner `balance` en el evento merece la pena.
+
+**Ejecútalo.** Puedes ver los tres campos autorrellenados en cualquier evento desde `uv run python`:
+
+```python
+>>> from lumen.models.entities.v1.wallet_entity import FundsDeposited
+>>> evt = FundsDeposited(wallet_id="w-1", amount=5000, currency="EUR", balance=15000)
+>>> evt.event_type
+'FundsDeposited'
+>>> evt.event_id # a fresh UUID, set by DomainEvent.__post_init__
+'3fa85f64-5717-4562-b3fc-2c963f66afa6'
+>>> evt.occurred_at # a UTC timestamp, also set automatically
+datetime.datetime(2026, 6, 16, 9, 30, 0, tzinfo=datetime.timezone.utc)
+```
+
+Declaraste cuatro campos; obtuviste siete, porque `DomainEvent` aporta `event_id`, `occurred_at` y la propiedad `event_type`. Ese es todo el atractivo de heredar de él: cada evento se autoidentifica con cero código adicional.
+
+El ciclo de vida del evento abarca dos fases. Dentro del agregado: cuando `wallet.deposit(amount)` tiene éxito, llama a `self.raise_event(FundsDeposited(...))`, añadiendo el evento a un búfer privado de `AggregateRoot`. Todavía no se publica nada. En la frontera del servicio: después de que el repositorio guarde el agregado y la transacción confirme, el servicio de aplicación vacía el búfer y publica. Esta secuencia de *guardar primero, publicar después* garantiza que nunca se despache un evento por un cambio que no llegó a persistir. El Listado 6.6 muestra esa frontera al completo:
+
+::: listing lumen/wallet_application_service.py | Listado 6.6 — Vaciar los eventos de dominio tras un guardado exitoso
+import uuid
+
+from pyfly.container import service
+from pyfly.eda import EventPublisher
+
+from lumen.interfaces.enums.v1.currency import Currency
+from lumen.models.entities.v1.money import Money
+from lumen.models.entities.v1.wallet_entity import Wallet
+
+
+@service
+class WalletApplicationService:
+
+ def __init__(
+ self,
+ repo: object, # typed as WalletRepository in practice
+ events: EventPublisher,
+ ) -> None:
+ self._repo = repo
+ self._events = events
+
+ async def open_wallet(self, owner_id: str, currency: Currency) -> str:
+ wallet_id = str(uuid.uuid4())
+ wallet = Wallet.open(
+ wallet_id=wallet_id, owner_id=owner_id, currency=currency
+ )
+ await self._repo.save(wallet)
+ for event in wallet.clear_events():
+ await self._events.publish(event)
+ return wallet_id
+
+ async def deposit(
+ self,
+ wallet_id: str,
+ amount_cents: int,
+ currency: Currency,
+ ) -> None:
+ wallet = await self._repo.find(wallet_id)
+ wallet.deposit(Money(amount=amount_cents, currency=currency))
+ await self._repo.save(wallet)
+ for event in wallet.clear_events():
+ await self._events.publish(event)
+:::
+
+**Cómo funciona.** `open_wallet` llama a `Wallet.open`, que encola internamente un evento `WalletOpened`; tras el `save`, `wallet.clear_events()` devuelve ese único evento y `publish` lo despacha. `deposit` sigue el mismo patrón de tres pasos: cargar, mutar, guardar; luego vaciar. El bucle `for event in wallet.clear_events()` es deliberadamente explícito en lugar de ocultarse dentro del repositorio, porque el servicio de aplicación es el sitio adecuado para decidir *cuándo* ocurre la publicación: después de la frontera de la transacción, no antes.
+
+**El ciclo de publicación, paso a paso.** Cada método de comando del servicio de aplicación sigue el mismo ritmo de cuatro tiempos. Apréndelo una vez y cada caso de uso futuro (transferencia, reembolso, congelación) se escribirá solo:
+
+Paso 1 — Cargar (o crear). Para un monedero nuevo, llama a la factoría `Wallet.open`; para uno existente, `await self._repo.find(wallet_id)`.
+
+Paso 2 — Mutar mediante un método de comportamiento. Llama a `wallet.deposit(...)` o `wallet.withdraw(...)`. Aquí es donde se ejecutan las invariantes y se encolan los eventos en el búfer del agregado.
+
+Paso 3 — Guardar. `await self._repo.save(wallet)`. Nada se ha publicado todavía, a propósito: si el guardado falla, ningún evento escapa.
+
+Paso 4 — Vaciar y publicar. `for event in wallet.clear_events(): await self._events.publish(event)`. Esto se ejecuta solo después de que el guardado tenga éxito, de modo que nunca se despacha un evento por un cambio que no persistió.
+
+**Qué acaba de pasar.** El agregado decide *qué* ocurrió y lo registra; el servicio de aplicación decide *cuándo* se entera el mundo. Mantener esas dos responsabilidades separadas es la razón por la que el monedero permanece libre de cualquier código de broker, cola o transacción, y por la que el orden de publicación es "guardar primero, publicar después".
+
+!!! tip "Orden de los eventos"
+ `raise_event` añade al búfer en el orden de llamada. `clear_events` lo vacía y lo limpia, devolviendo los eventos en el mismo orden. Si un único método del agregado lanza varios eventos (una operación por lotes, por ejemplo), llegan al bus de eventos en el orden en que se lanzaron: los más antiguos primero.
+
+---
+
+## Dominio frente a persistencia
+
+Con el modelo de dominio y sus eventos en su sitio, queda una tensión: ¿cómo llega el agregado `Wallet` a la base de datos?
+
+El atajo tentador es anotar `Wallet` directamente con campos `Mapped[]` de SQLAlchemy y un `__tablename__`. Eso fusiona dos preocupaciones que cambian a ritmos muy distintos: las reglas de negocio evolucionan con el producto; las definiciones de columna evolucionan con el esquema. Mezclarlas significa que un cambio de esquema te obliga a tocar el agregado, y un cambio de regla arriesga romper accidentalmente un mapeo de columna. También arrastra SQLAlchemy a cada prueba unitaria.
+
+La alternativa son dos modelos que coexisten sin conocerse el uno al otro, y un mapeador ligero que convierte entre ellos:
+
+| Modelo | Contiene | Conoce |
+|---|---|---|
+| `Wallet` | Reglas de negocio, eventos de dominio, invariantes | Nada fuera de `pyfly.domain` |
+| `WalletEntity` | Cinco columnas: `id`, `owner_id`, `currency`, `balance_minor`, `created_at` | Solo SQLAlchemy + `pyfly.data` |
+
+`Wallet` es Python puro: sin anotaciones `Mapped[]`, sin `__tablename__`. Puedes instanciarlo en una prueba unitaria con dos líneas y ejercitar todas las invariantes sin una conexión a base de datos. `WalletEntity` es persistencia pura: hereda de `Base` de `pyfly.data.relational.sqlalchemy`, lleva columnas tipadas de SQLAlchemy 2.0 y no sabe nada de reglas de dominio ni de eventos. El `Repository[WalletEntity, str]` del framework (Capítulo 5) almacena y recupera filas; un mapeador ligero convierte entre la fila y el agregado en cada cruce.
+
+El Listado 6.7 muestra `WalletEntity` —la fila del ORM— seguido de las dos funciones del mapeador que cruzan la frontera:
+
+::: listing lumen/models/entities/v1/wallet_orm.py | Listado 6.7 — WalletEntity: la fila de persistencia de SQLAlchemy
+from __future__ import annotations
+
+from datetime import UTC, datetime
+
+from sqlalchemy import String
+from sqlalchemy.orm import Mapped, mapped_column
+
+from pyfly.data.relational.sqlalchemy import Base
+
+
+class WalletEntity(Base):
+ """One persisted wallet row, keyed by the aggregate's own string id."""
+
+ __tablename__ = "wallets"
+
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
+ owner_id: Mapped[str] = mapped_column(
+ String(255), nullable=False, index=True
+ )
+ currency: Mapped[str] = mapped_column(String(3), nullable=False)
+ balance_minor: Mapped[int] = mapped_column(nullable=False, default=0)
+ created_at: Mapped[datetime] = mapped_column(
+ default=lambda: datetime.now(UTC)
+ )
+:::
+
+::: listing lumen/core/mappers/wallet_mapper.py | Listado 6.8 — wallet_mapper: funciones puras que cruzan la frontera dominio/persistencia
+from __future__ import annotations
+
+from lumen.interfaces.enums.v1.currency import Currency
+from lumen.models.entities.v1.money import Money
+from lumen.models.entities.v1.wallet_entity import Wallet
+from lumen.models.entities.v1.wallet_orm import WalletEntity
+
+
+def to_entity(wallet: Wallet) -> WalletEntity:
+ """Flatten a Wallet aggregate into a persistable row."""
+ assert wallet.id is not None
+ return WalletEntity(
+ id=wallet.id,
+ owner_id=wallet.owner_id,
+ currency=wallet.currency.value,
+ balance_minor=wallet.balance.amount,
+ created_at=wallet.created_at,
+ )
+
+
+def to_aggregate(entity: WalletEntity) -> Wallet:
+ """Rehydrate a Wallet aggregate from a persistence row."""
+ currency = Currency(entity.currency)
+ return Wallet(
+ id=entity.id,
+ owner_id=entity.owner_id,
+ balance=Money(amount=entity.balance_minor, currency=currency),
+ created_at=entity.created_at,
+ )
+:::
+
+**Cómo funciona.** `WalletEntity` hereda de `Base` en lugar de llevar lógica de dominio alguna, de modo que importarla registra la tabla `wallets` en `Base.metadata`; el `EngineLifecycle` del framework crea la tabla al arrancar cuando se establece `ddl-auto=create`. La clave primaria es el propio id de cadena del agregado (`wlt-…`), no un sustituto, de modo que la fila y el `Wallet` comparten una sola identidad y no hace falta traducción.
+
+`to_entity` escribe `wallet.balance.amount` (un entero) directamente en `balance_minor`: sin conversión a `float`. `to_aggregate` reconstruye un enum `Currency` a partir de la cadena ISO-4217 almacenada mediante `Currency(entity.currency)`, y luego construye un `Money` a partir del valor entero crudo en unidades menores. El campo `created_at` se preserva en el viaje de ida y vuelta, de modo que los agregados rehidratados conservan su marca de tiempo original. No hay ningún cruce de frontera en coma flotante por ninguna parte.
+
+**Construye el mapeador paso a paso.** Un mapeador no es más que dos funciones puras. No hay magia del framework que cablear; las escribes y las llamas en los momentos adecuados.
+
+Paso 1 — Escribe `to_entity(wallet)`. Lee cada pieza del agregado y cópiala en la fila plana: `wallet.id`, `wallet.owner_id`, `wallet.currency.value` (la cadena ISO, no el enum), `wallet.balance.amount` (las unidades menores enteras crudas) y `wallet.created_at`. El `assert wallet.id is not None` hace explícita la precondición: solo persistes monederos que ya tienen un id.
+
+Paso 2 — Escribe `to_aggregate(entity)`, lo inverso. Reconstruye el enum `Currency` a partir de la cadena almacenada con `Currency(entity.currency)`, envuelve el entero de nuevo en un `Money` y construye un `Wallet`. Pasar `created_at=entity.created_at` preserva la marca de tiempo original a través del viaje de ida y vuelta.
+
+Paso 3 — Llámalas en la frontera, no dentro del agregado. El manejador de comandos llama a `to_entity` justo antes de `repo.save(...)` y a `to_aggregate` justo después de `repo.find(...)`. La clase `Wallet` nunca importa la fila, y la fila nunca importa el `Wallet`.
+
+**Ejecútalo.** El viaje de ida y vuelta es Python puro: no se requiere una base de datos en vivo para demostrar que preserva todos los campos. Desde `uv run python`:
+
+```python
+>>> from lumen.models.entities.v1.wallet_entity import Wallet
+>>> from lumen.models.entities.v1.money import Money
+>>> from lumen.interfaces.enums.v1.currency import Currency
+>>> from lumen.core.mappers import wallet_mapper
+>>> w = Wallet.open("wlt-1", "owner-1", Currency.EUR)
+>>> w.deposit(Money(2500, Currency.EUR))
+>>> row = wallet_mapper.to_entity(w) # aggregate -> flat row
+>>> (row.id, row.currency, row.balance_minor)
+('wlt-1', 'EUR', 2500)
+>>> back = wallet_mapper.to_aggregate(row) # flat row -> aggregate
+>>> str(back.balance), back.owner_id
+('25.00 EUR', 'owner-1')
+```
+
+El entero `2500` cruza en ambas direcciones intacto: ningún `float` aparece jamás en la frontera de persistencia, que es toda la razón por la que `Money` almacena unidades menores.
+
+**Qué acaba de pasar.** Mantuviste dos modelos que nunca se importan el uno al otro y los uniste con dos funciones diminutas. Un cambio de esquema ahora toca solo `WalletEntity`; un cambio de regla toca solo `Wallet`. Ninguno puede romper al otro por accidente.
+
+El mapeador es deliberadamente estrecho. No hace cumplir reglas: eso lo hacen `Wallet.__init__` y los métodos de comportamiento. No publica eventos: eso lo hace el servicio de aplicación. Solo traduce la forma.
+
+**El repositorio.** El servicio de aplicación nunca interactúa con `WalletEntity` directamente. En su lugar, un manejador de comandos llama a `wallet_mapper.to_entity(wallet)` antes de persistir y a `wallet_mapper.to_aggregate(entity)` después de cargar, mientras que el `WalletRepository(Repository[WalletEntity, str])` del framework gestiona todo el SQL. El Capítulo 5 cubre `Repository` al completo; el punto clave aquí es que el propio `Wallet` nunca importa SQLAlchemy: el agregado permanece libre de preocupaciones de persistencia a ambos lados de la frontera del mapeador.
+
+!!! spring "Equivalencia con Spring"
+ Esta estructura de dos modelos más mapeador es el equivalente Python del patrón
+ defendido en *Implementing Domain-Driven Design* de Vaughn Vernon para Spring:
+ una `WalletJpaEntity` anotada con `@Entity` (la fila de persistencia), un
+ objeto de dominio `Wallet` (el agregado) y un `WalletAssembler` o un mapeador
+ generado por MapStruct que traduce entre ellos. El
+ `JpaRepository` de Spring Data JPA se corresponde con el
+ `Repository[WalletEntity, str]` de PyFly. La estructura es idéntica; el código
+ repetitivo es menor.
+
+---
+
+## Especificaciones para las reglas de negocio
+
+El agregado protege bien las operaciones que cambian el estado: no puedes dejar un monedero en descubierto ni depositar la divisa equivocada. Pero no todas las reglas tratan sobre la mutación. Algunas reglas son comprobaciones de elegibilidad: "antes de mostrar a este usuario el botón de retirada, ¿está el monedero en un estado operable?" o "de diez mil monederos, ¿cuáles cumplen los requisitos para el bono de fidelidad?". Estos son predicados de solo lectura, y codificarlos como métodos del agregado abarrotaría `Wallet` con lógica de consulta no relacionada con las transiciones de estado.
+
+El **patrón Especificación** lo resuelve limpiamente. Una especificación es un predicado con nombre y reutilizable: un único método `is_satisfied_by(obj) -> bool` envuelto en un objeto que se compone con otros usando operadores booleanos. Como cada regla es su propia clase, puedes nombrar las reglas con claridad, reutilizarlas entre servicios y combinarlas en tiempo de ejecución según el contexto, algo que una cadena de `if` no puede hacer.
+
+`Specification[T]` de `pyfly.domain` es un predicado en memoria componible. Hereda de ella, implementa `is_satisfied_by` y combina instancias con `&` (y), `|` (o) y `~` (no). Una especificación también es directamente invocable, así que puedes pasarla al `filter` integrado de Python sin código adaptador alguno.
+
+!!! note "Dos tipos de especificación"
+ `pyfly.domain.Specification` es el predicado en memoria que se usa dentro de los servicios de dominio. `pyfly.data.relational.sqlalchemy.Specification` (Capítulo 5) es el predicado de consulta consciente de la base de datos que empuja la regla hacia abajo, hasta el SQL. Los dos coexisten. Las especificaciones de dominio son para la lógica de negocio; las especificaciones de datos son para las consultas.
+
+Aquí hay una especificación que expresa la regla "elegible para retirada":
+
+::: listing lumen/domain/specs.py | Listado 6.9 — EligibleForWithdrawal: una Especificación de dominio componible
+from lumen.interfaces.enums.v1.currency import Currency
+from lumen.models.entities.v1.wallet_entity import Wallet
+from pyfly.domain import Specification
+
+
+class HasPositiveBalance(Specification[Wallet]):
+ """The wallet has at least one cent remaining."""
+
+ def is_satisfied_by(self, wallet: Wallet) -> bool:
+ return wallet.balance.is_positive
+
+
+class IsInCurrency(Specification[Wallet]):
+ """The wallet holds a specific currency."""
+
+ def __init__(self, currency: Currency) -> None:
+ self._currency = currency
+
+ def is_satisfied_by(self, wallet: Wallet) -> bool:
+ return wallet.balance.currency is self._currency
+
+
+# Compose: a wallet is eligible for withdrawal if it has a positive
+# balance in the requested currency.
+def eligible_for_withdrawal(currency: Currency) -> Specification[Wallet]:
+ return HasPositiveBalance() & IsInCurrency(currency)
+
+
+# Use as a predicate:
+def filter_eligible(
+ wallets: list[Wallet],
+ currency: Currency,
+) -> list[Wallet]:
+ spec = eligible_for_withdrawal(currency)
+ return list(filter(spec, wallets))
+:::
+
+**Cómo funciona.** `HasPositiveBalance` delega en `wallet.balance.is_positive` (una propiedad, sin paréntesis). `IsInCurrency` usa comparación de identidad (`is`) porque `Currency` es un `StrEnum` con miembros singleton. La factoría `eligible_for_withdrawal` las combina con `&`, produciendo un compuesto cuyo `is_satisfied_by` devuelve `True` solo cuando pasan ambas comprobaciones. Como `Specification` implementa `__call__`, pasas el compuesto directamente a `filter()`: no hace falta un envoltorio lambda.
+
+**Construye una especificación paso a paso.**
+
+Paso 1 — Hereda de `Specification[Wallet]` e implementa el único método requerido, `is_satisfied_by(self, wallet) -> bool`. Ese es todo el contrato: devuelve `True` o `False`, nunca lances.
+
+Paso 2 — Si la regla necesita un parámetro (como una divisa con la que coincidir), tómalo en `__init__` y almacénalo, como hace `IsInCurrency`. Una regla sin parámetros como `HasPositiveBalance` se salta esto.
+
+Paso 3 — Compón con los operadores booleanos. `HasPositiveBalance() & IsInCurrency(currency)` construye una nueva especificación cuyo `is_satisfied_by` es verdadero solo cuando lo son ambas mitades. `|` es o, `~` es no.
+
+Paso 4 — Úsala como predicado. Como `Specification` implementa `__call__`, el compuesto *es* un invocable, así que puedes entregarlo directamente al `filter()` integrado de Python sin lambda de por medio.
+
+**Ejecútalo.** Las especificaciones son objetos corrientes. Coloca las clases del Listado 6.9 en un módulo (digamos `src/lumen/domain/specs.py`) y pruébalas desde `uv run python`:
+
+```python
+>>> from lumen.models.entities.v1.wallet_entity import Wallet
+>>> from lumen.models.entities.v1.money import Money
+>>> from lumen.interfaces.enums.v1.currency import Currency
+>>> from lumen.domain.specs import eligible_for_withdrawal
+>>> empty = Wallet.open("wlt-1", "owner-1", Currency.EUR)
+>>> funded = Wallet.open("wlt-2", "owner-2", Currency.EUR)
+>>> funded.deposit(Money(1000, Currency.EUR))
+>>> usd = Wallet.open("wlt-3", "owner-3", Currency.USD)
+>>> usd.deposit(Money(1000, Currency.USD))
+>>> spec = eligible_for_withdrawal(Currency.EUR)
+>>> spec(funded), spec(empty), spec(usd)
+(True, False, False)
+>>> [w.id for w in filter(spec, [empty, funded, usd])]
+['wlt-2']
+```
+
+Solo el monedero EUR con fondos satisface ambas mitades del compuesto, así que `filter` conserva únicamente ese.
+
+**Qué acaba de pasar.** Expresaste una regla de negocio de solo lectura como un objeto con nombre y reutilizable en lugar de un `if` en línea. Se compone con otras reglas en tiempo de ejecución, pasa directamente a `filter` y es trivial de probar unitariamente de forma aislada; y, crucialmente, vive *fuera* del agregado, que es donde corresponden las comprobaciones de elegibilidad.
+
+La disciplina de diseño clave: una especificación es un *predicado*, no una *defensa*. Devuelve `True` o `False` y nunca lanza. Las invariantes del agregado (descubierto, divisa que no coincide) corresponden a dentro de `deposit` y `withdraw` porque deben *impedir* un cambio de estado. Las especificaciones corresponden a los servicios y a los manejadores de consulta porque *seleccionan* o *clasifican*: nunca mutan.
+
+Las especificaciones son especialmente útiles allí donde las reglas se combinan dinámicamente: una búsqueda de administración que añade filtros según el rol del operador, o un trabajo por lotes que parte una lista en monederos elegibles y no elegibles. Cada clase tiene exactamente un método y ningún efecto secundario, lo que hace triviales las pruebas unitarias aisladas.
+
+!!! tip "Specification.of para lambdas rápidas"
+ Para predicados puntuales que no necesitan una clase, usa el método factoría: `spec = Specification.of(lambda w: w.balance.amount >= 1000, name="minimum-balance")`. Se compone con `&`, `|` y `~` de la misma forma que una subclase completa.
+
+---
+
+## Lo que construiste {.recap}
+
+El monedero de Lumen es ahora un modelo de dominio de primera clase.
+
+`Money` es un `ValueObject` congelado que almacena importes como unidades menores enteras, impone la homogeneidad de divisa mediante `_assert_same_currency` y se reemplaza en lugar de mutarse. `__post_init__` rechaza los `float` de inmediato. `is_positive` e `is_negative` son propiedades; `major_units` y `__str__` se encargan de la visualización.
+
+`Wallet(AggregateRoot[str])` es la frontera de coherencia. Su factoría `open` y los métodos de comportamiento `deposit`/`withdraw` hacen cumplir las tres invariantes —sin descubierto, sin operaciones entre divisas, sin importes no positivos— lanzando `BusinessRuleViolation` con un identificador de regla estable. Cada cambio de estado encola un evento de dominio (`WalletOpened`, `FundsDeposited`, `FundsWithdrawn`); el saldo posterior a la operación se registra en cada evento, de modo que los suscriptores no necesitan ninguna llamada de vuelta. Tras un guardado exitoso, el servicio de aplicación vacía los eventos con `clear_events()` y los entrega a `EventPublisher`.
+
+La capa de persistencia solo ve `WalletEntity` (cinco columnas, sin lógica de dominio). `to_aggregate` en `wallet_mapper` rehidrata la fila en un `Wallet`, y `to_entity` la aplana de vuelta; el `WalletRepository(Repository[WalletEntity, str])` del framework gestiona todo el SQL sin que el agregado importe jamás SQLAlchemy. `Specification[Wallet]` te da un predicado componible e invocable para comprobaciones de elegibilidad que viven fuera de la frontera del agregado.
+
+El controlador queda intacto. El servicio encogió. Las reglas las hace cumplir el objeto que las posee.
+
+---
+
+## Pruébalo tú mismo {.exercises}
+
+1. **Añade un método `transfer_to` y un objeto de valor `DailyLimit`.** Añade un `DailyLimit(ValueObject)` con `max_amount: int` y `currency: Currency`, decorado con `@dataclass(frozen=True)`. Luego añade `Wallet.transfer_to(target: Wallet, amount: Money) -> None`. El método debería llamar a `self.withdraw(amount)` y `target.deposit(amount)` en secuencia, lanzando `BusinessRuleViolation("transfer-currency-mismatch", ...)` si los dos monederos contienen divisas distintas. Como `Wallet` usa `__slots__`, añade `"_frozen"` a la tupla si también haces el ejercicio 2. Verifica que ambos monederos acumulan cada uno un evento `FundsWithdrawn` / `FundsDeposited`, respectivamente, y que una transferencia entre monederos que no coinciden se lanza antes de modificar ninguno de los dos saldos.
+
+2. **Añade un evento `WalletFrozen` y un comportamiento `freeze()`.** Define `WalletFrozen(DomainEvent)` con `wallet_id: str = ""` y `reason: str = ""`. Añade `"frozen"` a `__slots__` y un atributo `frozen: bool` (por defecto `False`) a `Wallet.__init__`. Añade un método `freeze(reason: str) -> None` que ponga `self.frozen = True` y llame a `self.raise_event(WalletFrozen(...))`. Protege `deposit` y `withdraw` con `if self.frozen: raise BusinessRuleViolation("wallet-frozen", ...)` al principio de cada método. Luego escribe un escuchador de eventos usando `@event_listener` de `pyfly.eda` que registre una advertencia estructurada cada vez que se publique un evento `WalletFrozen`.
+
+3. **Expresa una regla de negocio como una Especificación.** Escribe un `MinimumBalance(Specification[Wallet])` que compruebe si el saldo de un monedero está en o por encima de un importe umbral (en unidades menores) pasado a su `__init__`. Combínalo con `IsInCurrency` del Listado 6.9 usando `&` para producir una función factoría `premium_eligible(currency: Currency, threshold: int)`. Llama a `list(filter(premium_eligible(Currency.EUR, 50000), wallets))` sobre una lista de monederos de prueba y afirma que solo aparecen en el resultado los monederos con al menos 500,00 EUR (50 000 céntimos).
diff --git a/book/manuscript-es/07-cqrs.md b/book/manuscript-es/07-cqrs.md
new file mode 100644
index 00000000..3016ea3c
--- /dev/null
+++ b/book/manuscript-es/07-cqrs.md
@@ -0,0 +1,951 @@
+Capítulo 7
+
+# CQRS: comandos y consultas {.chtitle}
+
+::: figure art/openers/ch07.svg |
+
+El monedero (wallet) de Lumen es ya un ciudadano de primera clase del dominio. El agregado `Wallet` impone sus propias invariantes, emite eventos de dominio y persiste a través de una frontera de repositorio limpia. El controlador, sin embargo, sigue llamando a `WalletApplicationService` directamente: un método por operación, con lecturas y escrituras compartiendo la misma ruta de código. Ese diseño está bien a pequeña escala, pero muestra fricciones a medida que el sistema crece. El equipo quiere cachear los saldos de los monederos, mantener un único rastro de auditoría para cada escritura, añadir reglas de autorización a operaciones concretas y probar cada pieza de lógica en aislamiento completo de las demás.
+
+**CQRS** —Command Query Responsibility Segregation, segregación de responsabilidad entre comandos y consultas— aborda todo esto trazando una línea clara entre las dos cosas que un servicio puede hacer: *cambiar el estado* y *leer el estado*. Las escrituras se convierten en **comandos**: mensajes fuertemente tipados, con nombre e inmutables que fluyen a través de un `CommandBus`. Las lecturas se convierten en **consultas**: mensajes igualmente tipados que fluyen a través de un `QueryBus`. Cada bus ejecuta una tubería fija —validación, autorización, ejecución y, después (para los comandos), publicación de eventos de dominio—. Tu manejador (handler) implementa exactamente una intención; el bus se encarga de todo lo demás.
+
+Al final de este capítulo, el controlador de Lumen despacha comandos y consultas en lugar de llamar al servicio directamente. `OpenWallet`, `DepositFunds` y `WithdrawFunds` recorren la ruta de comandos; `GetWallet`, `GetBalance`, `ListWallets` y `ListRichWallets` recorren la ruta de consultas. El agregado `Wallet` que construiste en el Capítulo 6 permanece intacto: CQRS no reemplaza el modelo de dominio; es el mecanismo de entrega de instrucciones hacia él.
+
+!!! note "Nueva jerga, en términos sencillos"
+ Un **bus** aquí no es hardware: es un único objeto al que le entregas un mensaje, y él averigua qué manejador debe ejecutarse. Un **manejador** es una clase pequeña que hace el trabajo para exactamente un tipo de mensaje. Un **DTO** (objeto de transferencia de datos) es una forma plana —id, propietario, saldo— que pones en el cable como JSON; está deliberadamente separado de tu objeto de dominio rico. Una **proyección** es una porción de solo lectura de tus datos, moldeada para una vista concreta. Conocerás cada uno de estos a medida que construimos, una pieza cada vez, así que no te preocupes si ahora mismo te resultan abstractos.
+
+Este capítulo está construido en torno a PyFly **v26.6.110**, y cada listado está tomado literalmente del ejemplo de Lumen en `samples/lumen/src/lumen`. Iremos despacio: construiremos primero los comandos, luego sus manejadores, después las consultas y, por último, conectaremos todo en el controlador, ejecutando la aplicación y las pruebas en cada hito para que puedas ver cómo cobran vida las piezas antes de añadir la siguiente.
+
+---
+
+## Por qué separar las lecturas de las escrituras
+
+Imagina Lumen al final del Capítulo 6. `WalletController` llama a `WalletApplicationService.credit(wallet_id, amount)`. Esa llamada muta el estado, pero nada en la firma del método lo hace evidente. El equipo quiere añadir una caché de saldos. ¿Dónde va? ¿Dentro de `credit`? ¿En un decorador alrededor del servicio? La pregunta revela el problema: a un único método de servicio se le pide servir a dos amos —la ruta de escritura, que siempre debe tocar la base de datos, y la ruta de lectura, que debería evitarla siempre que sea posible—. Atornillar la caché a un método de escritura es incómodo en el mejor de los casos y peligroso en el peor.
+
+Las escrituras y las lecturas tienen formas fundamentalmente distintas. Una escritura lleva intención y datos: «deposita 1 500 unidades menores en el monedero wlt-001». Una lectura lleva una pregunta: «¿cuál es el saldo actual del monedero wlt-001?». La primera debe alcanzar la base de datos cada vez. La segunda es repetible: preguntar dos veces debería devolver la misma respuesta sin duplicar la carga de la base de datos. Canalizar ambas a través del mismo método mezcla preocupaciones que escalan de forma distinta, se prueban de forma distinta y necesitan un comportamiento transversal distinto.
+
+El beneficio más profundo es la **claridad de intención**. Cuando un compañero lee `wallet_service.credit(wallet_id, amount)`, tiene que inspeccionar la implementación para saber si es seguro llamarlo dos veces, si publica eventos y si es idempotente. Cuando lee `DepositFunds(wallet_id=..., amount=...)`, la intención es inequívoca; y si la intención resulta estar equivocada, cambias el nombre del comando, no la firma del servicio.
+
+Tres beneficios concretos importan para Lumen:
+
+**Escalado independiente.** Las lecturas suelen superar a las escrituras en un orden de magnitud o más. Una vez que las dos rutas están separadas, el bus puede cachear los resultados de las consultas sin tocar la ruta de escritura. Puedes enrutar las consultas a una réplica de lectura y los comandos a la base de datos primaria con un cambio de configuración, no de código.
+
+**Manejadores enfocados.** Cada manejador implementa exactamente una operación. `DepositFundsHandler` carga un monedero, impulsa su comportamiento de dominio, lo persiste y drena eventos; nada más. `GetBalanceHandler` carga un monedero y devuelve una proyección ligera; nada más. Como los manejadores son clases Python planas con dependencias inyectadas, puedes probar cada uno por unidad en aislamiento completo de la capa HTTP.
+
+**Preocupaciones transversales centralizadas.** La validación, la autorización y las trazas distribuidas se implementan una sola vez en la tubería del bus y se aplican uniformemente a cada manejador, sin código repetitivo en el propio manejador. Añadir autorización por operación más adelante es cuestión de sobrescribir `authorize()` en el comando; el bus garantiza que se ejecute antes de que se llegue siquiera a `do_handle`.
+
+---
+
+## Comandos y manejadores de comandos
+
+Antes de escribir una sola línea de código de manejador, da nombre a las intenciones de tu sistema. En el dominio de monederos de Lumen pueden ocurrir tres cosas: se puede abrir un monedero, se pueden depositar fondos y se pueden retirar fondos. Cada una es un **comando**: un mensaje con nombre e inmutable que expresa una intención. El bus lo entrega; el manejador actúa sobre él; el agregado de dominio impone las reglas. Los comandos no son llamadas a métodos disfrazadas de objetos: son contratos explícitos que viven en tu base de código como ciudadanos de primera clase.
+
+Un comando es un dataclass congelado que hereda de `Command[R]`, donde `R` es el tipo que devuelve el manejador. El parámetro genérico es documentación y una pista para el verificador de tipos; el bus no lo impone en tiempo de ejecución.
+
+!!! note "¿Qué es `Command[R]`?"
+ El `[R]` de `Command[R]` es un *parámetro de tipo genérico*: un marcador de posición para «lo que sea que devuelva este comando». `OpenWallet(Command[str])` dice «al enviarme, recibes de vuelta un `str`» (el nuevo id del monedero). `DepositFunds(Command[int])` dice «al enviarme, recibes de vuelta un `int`» (el nuevo saldo). Tu editor y tu verificador de tipos usan esto para detectar errores; en tiempo de ejecución el bus simplemente devuelve lo que devolvió el manejador.
+
+Los comandos de Lumen viven en tres archivos separados bajo `lumen/core/services/wallets/`, uno por intención. Los construiremos uno a uno.
+
+**Paso 1 — Escribe el comando `OpenWallet`.** Crea `open_wallet_command.py`. Lleva los dos datos necesarios para abrir un monedero —quién es su propietario y qué moneda contiene— y un gancho `validate()` que rechaza un propietario en blanco antes incluso de que el bus busque un manejador.
+
+::: listing lumen/core/services/wallets/open_wallet_command.py | Listado 7.1 — OpenWallet: un comando congelado con validación incorporada
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from lumen.interfaces.enums.v1.currency import Currency
+from pyfly.cqrs import Command, ValidationResult
+
+
+@dataclass(frozen=True)
+class OpenWallet(Command[str]):
+ """Open a new wallet. Returns the generated wallet id."""
+
+ owner_id: str
+ currency: Currency
+
+ async def validate(self) -> ValidationResult: # type: ignore[override]
+ if not self.owner_id.strip():
+ return ValidationResult.failure(
+ "owner_id", "Owner id is required"
+ )
+ return ValidationResult.success()
+:::
+
+**Paso 2 — Escribe los comandos `DepositFunds` y `WithdrawFunds`.** Cada uno lleva un `wallet_id` al que apuntar y un `amount` en unidades menores, y valida que el id esté presente y que el importe sea positivo. Son deliberadamente gemelos casi idénticos: la misma forma, dirección opuesta.
+
+::: listing lumen/core/services/wallets/deposit_funds_command.py | Listado 7.2 — DepositFunds: importe en unidades menores, sin campo de moneda
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from pyfly.cqrs import Command, ValidationResult
+
+
+@dataclass(frozen=True)
+class DepositFunds(Command[int]):
+ """Deposit ``amount`` minor units. Returns the new balance."""
+
+ wallet_id: str
+ amount: int
+
+ async def validate(self) -> ValidationResult: # type: ignore[override]
+ if not self.wallet_id.strip():
+ return ValidationResult.failure(
+ "wallet_id", "Wallet id is required"
+ )
+ if self.amount <= 0:
+ return ValidationResult.failure(
+ "amount", "Deposit amount must be > 0"
+ )
+ return ValidationResult.success()
+:::
+
+::: listing lumen/core/services/wallets/withdraw_funds_command.py | Listado 7.3 — WithdrawFunds: la misma forma que DepositFunds
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from pyfly.cqrs import Command, ValidationResult
+
+
+@dataclass(frozen=True)
+class WithdrawFunds(Command[int]):
+ """Withdraw ``amount`` minor units. Returns the new balance."""
+
+ wallet_id: str
+ amount: int
+
+ async def validate(self) -> ValidationResult: # type: ignore[override]
+ if not self.wallet_id.strip():
+ return ValidationResult.failure(
+ "wallet_id", "Wallet id is required"
+ )
+ if self.amount <= 0:
+ return ValidationResult.failure(
+ "amount", "Withdrawal amount must be > 0"
+ )
+ return ValidationResult.success()
+:::
+
+Cuatro decisiones de diseño están horneadas en cada comando:
+
+- **`frozen=True`** hace que el dataclass sea inmutable en el mismo momento en que se construye. Los campos no pueden mutarse accidentalmente en una capa de la tubería antes de llegar a otra, y los mensajes inmutables son hasheables por defecto, lo cual es útil al almacenarlos o compararlos en las pruebas.
+
+- **`validate()`** es un gancho asíncrono que el bus llama antes de despachar el manejador. `OpenWallet.validate` comprueba que `owner_id` no esté en blanco; `DepositFunds.validate` y `WithdrawFunds.validate` comprueban que el importe sea positivo. Estas precondiciones corresponden al comando: no requieren ninguna consulta a la base de datos y no pertenecen al agregado de dominio. El agregado impone invariantes que necesitan estado cargado (descubierto, coincidencia de moneda); los comandos imponen invariantes que se pueden conocer únicamente a partir de los campos. Mantener separadas estas dos capas significa que el agregado nunca se llama con datos estructuralmente incorrectos.
+
+- **Sin campo `currency`** en `DepositFunds` ni en `WithdrawFunds`. La propia moneda del monedero es la única moneda válida para un depósito o una retirada, y el repositorio la resuelve una vez que el agregado está cargado. Llevar una moneda en el comando invitaría a discrepancias; el agregado impone la invariante a partir de su propio estado.
+
+- **Nomenclatura en modo imperativo**: `DepositFunds`, no `WalletDeposit` ni `DepositFundsCommand`. Esto hace que el registro de comandos se lea como un rastro de auditoría de negocio —una secuencia de cosas que *ocurrieron*— en lugar de una lista de operaciones técnicas.
+
+!!! note "Lo que acaba de ocurrir"
+ Ahora tienes tres archivos pequeños, cada uno describiendo *una cosa que el sistema puede hacer*, sin más lógica que un par de comprobaciones de campo. Todavía no hay manejadores: estos comandos no hacen nada por sí solos. Esa es la idea: un comando es un sobre, no el trabajador que lo abre. A continuación escribirás los trabajadores (los manejadores) que realmente llevan a cabo cada intención.
+
+### Implementar un manejador de comandos
+
+Un manejador de comandos hereda de `CommandHandler[C, R]` e implementa exactamente un método: `do_handle`. Tú escribes el *qué*; el bus lo envuelve con el *cómo*.
+
+**Ambos decoradores en cada manejador son obligatorios.** `@command_handler` registra la clase en el `HandlerRegistry` introspeccionando el primer argumento de tipo genérico; no se necesita registro manual. `@service` conecta el manejador al contenedor de inyección de dependencias de PyFly para que los argumentos del constructor se resuelvan e inyecten automáticamente en el arranque. El orden importa: `@command_handler` arriba, `@service` justo debajo. Sin `@service`, el contenedor de inyección de dependencias nunca instancia la clase y el bus no puede encontrar el manejador; sin `@command_handler`, el registro nunca mapea el tipo de comando a la clase. Omitir cualquiera de los dos decoradores es un fallo silencioso: el bus lanza «no handler found» en el momento del despacho.
+
+**`@transactional()` convierte `do_handle` en una unidad de trabajo confirmada.** Los manejadores de comandos inyectan `session_factory: async_sessionmaker[AsyncSession]` y la almacenan como `self._session_factory`. Cuando `@transactional()` ejecuta `do_handle`, abre una sesión nueva desde esa factoría, la intercambia en el repositorio durante la llamada, confirma en caso de éxito y revierte ante cualquier excepción. Sin `@transactional()`, la sesión compartida del framework solo hace flush: la escritura sobrevive dentro de la petición pero nunca se confirma en la base de datos.
+
+!!! note "Flush frente a commit, en términos sencillos"
+ Un **flush** empuja tus cambios pendientes a la conexión de la base de datos para que las consultas posteriores en la *misma* sesión puedan verlos, pero siguen estando dentro de una transacción abierta que se puede revertir. Un **commit** los hace permanentes. Sin `@transactional()`, tu depósito haría flush (visible a mitad de la petición) pero nunca commit (desaparecería después de la petición). El decorador es lo que hace que el cambio persista.
+
+**Paso 3 — Escribe `OpenWalletHandler`.** Ahora construye el trabajador para el primer comando. Crea `open_wallet_handler.py`, apila `@command_handler` sobre `@service`, inyecta el repositorio, el publicador de eventos y la factoría de sesiones, e implementa el único método `do_handle`.
+
+::: listing lumen/core/services/wallets/open_wallet_handler.py | Listado 7.4 — OpenWalletHandler: unidad de trabajo @transactional() + upsert
+from __future__ import annotations
+
+from uuid import uuid4
+
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+from lumen.core.mappers.wallet_mapper import to_entity
+from lumen.core.services.wallets.event_publishing import publish_domain_events
+from lumen.core.services.wallets.open_wallet_command import OpenWallet
+from lumen.models.entities.v1.wallet_entity import Wallet
+from lumen.models.repositories.wallet_repository import WalletRepository
+from pyfly.container import service
+from pyfly.cqrs import CommandHandler, command_handler
+from pyfly.data.relational.sqlalchemy import transactional
+from pyfly.eda import EventPublisher
+
+
+@command_handler
+@service
+class OpenWalletHandler(CommandHandler[OpenWallet, str]):
+ """Open a new, empty wallet."""
+
+ def __init__(
+ self,
+ repository: WalletRepository,
+ events: EventPublisher,
+ session_factory: async_sessionmaker[AsyncSession],
+ ) -> None:
+ super().__init__()
+ self._repository = repository
+ self._events = events
+ # @transactional resolves the unit-of-work session from here.
+ self._session_factory = session_factory
+
+ @transactional()
+ async def do_handle(self, command: OpenWallet) -> str: # type: ignore[override]
+ wallet_id = f"wlt-{uuid4()}"
+ wallet = Wallet.open(
+ wallet_id=wallet_id,
+ owner_id=command.owner_id,
+ currency=command.currency,
+ )
+ await self._repository.upsert(to_entity(wallet))
+ await publish_domain_events(self._events, wallet.clear_events())
+ return wallet_id
+:::
+
+Recorre `do_handle` paso a paso. `f"wlt-{uuid4()}"` genera un identificador estable con prefijo. `Wallet.open(...)` llama a la factoría, que impone la precondición de propietario no vacío y bufferiza un evento `WalletOpened`. `to_entity(wallet)` mapea el agregado a una fila plana `WalletEntity`. `repository.upsert(...)` llama a `session.merge` —una sola llamada que inserta si no existe ninguna fila o actualiza si existe— y luego hace flush. Usar `upsert` en lugar de `save` evita un `IntegrityError` en la clave primaria: el agregado es dueño de su id, así que tanto INSERT como UPDATE usan la misma cadena estable como clave. `wallet.clear_events()` drena el búfer y `publish_domain_events` reenvía cada evento al bus de EDA. El decorador `@transactional()` confirma la sesión al salir. El manejador devuelve el ID del monedero, que fluye de vuelta al controlador como el valor de retorno de `send`.
+
+Observa el requisito del constructor: `super().__init__()` es obligatorio en `CommandHandler`. Si lo omites, la contabilidad interna de la clase base —contexto de correlación, ganchos de ciclo de vida— nunca se inicializa. El repositorio, `EventPublisher` y `session_factory` los inyecta el contenedor de inyección de dependencias a partir de las anotaciones de tipo; no se necesita ninguna configuración de factoría.
+
+**Paso 4 — Escribe los manejadores de depósito y retirada.** Estos dos añaden un movimiento que `OpenWalletHandler` no necesitaba: *cargan* un monedero existente antes de actuar sobre él. La forma es la misma en ambos, diferenciándose solo en si llaman a `wallet.deposit(...)` o a `wallet.withdraw(...)`.
+
+::: listing lumen/core/services/wallets/deposit_funds_handler.py | Listado 7.5 — DepositFundsHandler: find_by_id → to_aggregate → act → upsert
+from __future__ import annotations
+
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+from lumen.core.mappers.wallet_mapper import to_aggregate, to_entity
+from lumen.core.services.wallets.deposit_funds_command import DepositFunds
+from lumen.core.services.wallets.event_publishing import publish_domain_events
+from lumen.models.entities.v1.money import Money
+from lumen.models.repositories.wallet_repository import WalletRepository
+from pyfly.container import service
+from pyfly.cqrs import CommandHandler, command_handler
+from pyfly.domain import AggregateNotFound
+from pyfly.data.relational.sqlalchemy import transactional
+from pyfly.eda import EventPublisher
+
+
+@command_handler
+@service
+class DepositFundsHandler(CommandHandler[DepositFunds, int]):
+ """Credit funds to an existing wallet; returns the new balance."""
+
+ def __init__(
+ self,
+ repository: WalletRepository,
+ events: EventPublisher,
+ session_factory: async_sessionmaker[AsyncSession],
+ ) -> None:
+ super().__init__()
+ self._repository = repository
+ self._events = events
+ self._session_factory = session_factory
+
+ @transactional()
+ async def do_handle(self, command: DepositFunds) -> int: # type: ignore[override]
+ entity = await self._repository.find_by_id(command.wallet_id)
+ if entity is None:
+ raise AggregateNotFound("Wallet", command.wallet_id)
+
+ wallet = to_aggregate(entity)
+ wallet.deposit(Money(amount=command.amount, currency=wallet.currency))
+ await self._repository.upsert(to_entity(wallet))
+
+ await publish_domain_events(self._events, wallet.clear_events())
+ return wallet.balance.amount
+:::
+
+::: listing lumen/core/services/wallets/withdraw_funds_handler.py | Listado 7.6 — WithdrawFundsHandler: patrón idéntico, el descubierto lo rechaza el agregado
+from __future__ import annotations
+
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+from lumen.core.mappers.wallet_mapper import to_aggregate, to_entity
+from lumen.core.services.wallets.event_publishing import publish_domain_events
+from lumen.core.services.wallets.withdraw_funds_command import WithdrawFunds
+from lumen.models.entities.v1.money import Money
+from lumen.models.repositories.wallet_repository import WalletRepository
+from pyfly.container import service
+from pyfly.cqrs import CommandHandler, command_handler
+from pyfly.domain import AggregateNotFound
+from pyfly.data.relational.sqlalchemy import transactional
+from pyfly.eda import EventPublisher
+
+
+@command_handler
+@service
+class WithdrawFundsHandler(CommandHandler[WithdrawFunds, int]):
+ """Debit funds from an existing wallet; returns the new balance."""
+
+ def __init__(
+ self,
+ repository: WalletRepository,
+ events: EventPublisher,
+ session_factory: async_sessionmaker[AsyncSession],
+ ) -> None:
+ super().__init__()
+ self._repository = repository
+ self._events = events
+ self._session_factory = session_factory
+
+ @transactional()
+ async def do_handle(self, command: WithdrawFunds) -> int: # type: ignore[override]
+ entity = await self._repository.find_by_id(command.wallet_id)
+ if entity is None:
+ raise AggregateNotFound("Wallet", command.wallet_id)
+
+ wallet = to_aggregate(entity)
+ wallet.withdraw(Money(amount=command.amount, currency=wallet.currency))
+ await self._repository.upsert(to_entity(wallet))
+
+ await publish_domain_events(self._events, wallet.clear_events())
+ return wallet.balance.amount
+:::
+
+`DepositFundsHandler` y `WithdrawFundsHandler` siguen el patrón clásico: **find → to_aggregate → act → to_entity → upsert → drain**. `repository.find_by_id` devuelve la fila plana `WalletEntity`; `to_aggregate(entity)` rehidrata el objeto de dominio rico para que las invariantes del agregado estén en alcance. `Money` se construye a partir del `amount` del comando y de la moneda del *monedero* —nunca una moneda del propio comando— porque el monedero es dueño de esa invariante. Si `wallet.withdraw` rechaza (el saldo quedaría negativo), lanza `BusinessRuleViolation`, que se propaga como HTTP 422 sin una sola línea de código de manejo de errores en el manejador.
+
+Fíjate en lo que está ausente: ningún bloque try/except, ninguna llamada de registro, ninguna configuración de trazas. Todo eso pertenece a la tubería del bus. El manejador es una expresión pura de la intención de negocio.
+
+!!! note "Lo que acaba de ocurrir"
+ El lado de escritura está ya completo: tres comandos y tres manejadores. Enviar `OpenWallet` crea y persiste un monedero nuevo; enviar `DepositFunds` o `WithdrawFunds` carga uno, impulsa su comportamiento de dominio y lo guarda. La pila `@command_handler` + `@service` significa que PyFly los descubre y conecta en el arranque: nunca los llamas directamente y nunca los registras a mano.
+
+**Ejecútalo: confirma que el lado de escritura funciona de principio a fin.** El ejemplo de Lumen ya incluye una prueba que ejercita la ruta completa de comandos. Desde el directorio `samples/lumen`, ejecuta solo esa prueba:
+
+::: listing terminal | Listado 7.4a — Ejercitar la ruta de comandos
+uv run --extra dev pytest tests/test_cqrs_flow.py::test_full_wallet_lifecycle -q
+:::
+
+Deberías ver una única prueba que pasa:
+
+```
+1 passed in 0.42s
+```
+
+Esa única prueba abre un monedero, deposita 1 500 unidades menores, retira 500 y afirma que el saldo queda en 1 000, demostrando que `OpenWalletHandler`, `DepositFundsHandler` y `WithdrawFundsHandler` confirman todos a través del bus. Si ves `0 items collected`, no estás en el directorio `samples/lumen`; haz `cd` allí primero. Si ves `no handler found`, comprueba dos veces que ambos decoradores estén presentes en cada manejador: esa es la causa más común con diferencia.
+
+### El mapeador entidad↔agregado
+
+Los manejadores de comandos no interactúan con el repositorio a través del agregado de dominio. Interactúan a través de una fila plana `WalletEntity` —la forma de persistencia que entiende el `Repository[WalletEntity, str]` del framework— y usan `wallet_mapper` para traducir entre los dos mundos:
+
+```python
+# Aggregate → row (before upsert)
+to_entity(wallet) # Wallet → WalletEntity
+
+# Row → aggregate (after find_by_id)
+to_aggregate(entity) # WalletEntity → Wallet
+```
+
+Esta separación mantiene el agregado libre de anotaciones de SQLAlchemy y el repositorio libre de lógica de dominio. El mapeador es un único módulo; cambiar el esquema de almacenamiento toca un archivo, no cada manejador.
+
+### Enviar un comando
+
+El `CommandBus` es el único punto de entrada para todas las escrituras. La autoconfiguración de PyFly registra un `DefaultCommandBus` como singleton en el contenedor de inyección de dependencias; decláralo como argumento del constructor y el framework lo inyecta. Enviar un comando es una única llamada con await:
+
+```python
+from pyfly.cqrs import DefaultCommandBus
+from lumen.core.services.wallets.open_wallet_command import OpenWallet
+from lumen.core.services.wallets.deposit_funds_command import DepositFunds
+from lumen.interfaces.enums.v1.currency import Currency
+
+wallet_id: str = await command_bus.send(
+ OpenWallet(owner_id="u-1", currency=Currency.EUR)
+)
+balance: int = await command_bus.send(
+ DepositFunds(wallet_id=wallet_id, amount=1500)
+)
+```
+
+`send` es una corrutina: siempre hazle `await`. El valor de retorno es lo que devolvió `do_handle`: un ID de monedero `str` para `OpenWallet`, y el nuevo saldo como un `int` (unidades menores) para `DepositFunds` y `WithdrawFunds`. Si algo en la tubería falla —validación, autorización o el propio manejador—, la excepción se envuelve en `CommandProcessingException` y se propaga fuera de `send`, donde el manejador de errores global la mapea al código de estado HTTP apropiado.
+
+::: figure art/figures/07-cqrs.svg | Figura 7.1 — Los comandos fluyen al modelo de escritura; las consultas, al modelo de lectura.
+
+!!! spring "Equivalencia con Spring"
+ `CommandBus.send(command)` es el equivalente en Python de `CommandGateway.send(command)` o `CommandGateway.sendAndWait(command)` del framework Axon. Cada clase de manejador de comandos corresponde a un método anotado con `@CommandHandler` en Axon, o a un `@MessageHandler` en el modelo ApplicationEventPublisher de Spring Modulith. El decorador `@command_handler` es la contrapartida de PyFly de `@CommandHandler`: registra el manejador en el registro introspeccionando el parámetro de tipo genérico, exactamente igual que Axon resuelve los métodos manejadores por el tipo del parámetro. La apilación de `@service` refleja el hecho de que en Spring cada bean `@CommandHandler` es también un `@Component` de Spring: el registro y la inyección son inseparables. El decorador `@transactional()` se corresponde directamente con `@Transactional` de Spring: ambos abren una sesión de unidad de trabajo, confirman en caso de éxito y revierten ante cualquier excepción, de modo que `upsert` (respaldado por `session.merge`) es el análogo en Python de `repository.save()` dentro de un método `@Transactional`.
+
+---
+
+## Consultas y manejadores de consultas
+
+Los comandos viajan en una dirección: hacia el modelo de escritura. Las consultas son el viaje de vuelta: le piden al sistema una proyección del estado actual y esperan una respuesta, no un efecto secundario.
+
+Una **consulta** es un dataclass congelado que hereda de `Query[R]`, donde `R` es el tipo del resultado. Como los comandos, las consultas son mensajes inmutables, pero no llevan intención de cambiar el estado. `query_bus.query(GetBalance(...))` carga datos frescos del repositorio y devuelve un DTO tipado. Las consultas no necesitan `@transactional()`: las lecturas no mutan el estado, así que no hay nada que confirmar ni revertir.
+
+Las consultas devuelven **DTO de lectura** en lugar de agregados de dominio. La separación es deliberada. Si `GetWalletHandler` devolviera un agregado `Wallet`, la capa de API quedaría acoplada a cada campo del agregado: un cambio en el modelo de dominio podría romper silenciosamente el contrato de la API. Un modelo Pydantic `WalletDto` dedicado proyecta exactamente los campos que necesita la respuesta HTTP. ¿Añades un campo a `Wallet`? La proyección cambia solo si lo incluyes explícitamente en el DTO. ¿Eliminas un campo de `Wallet`? La proyección compila hasta que la limpies.
+
+El lado de lectura refleja el lado de escritura paso a paso —mensaje de consulta y luego manejador de consulta—, pero con dos simplificaciones: sin `@transactional()` (nada que confirmar) y sin publicación de eventos (nada cambió).
+
+**Paso 5 — Escribe las consultas de búsqueda única.** Crea `get_wallet_query.py` y `get_balance_query.py`. Ambas llevan solo un `wallet_id`; lo que difiere es lo que prometen devolver.
+
+::: listing lumen/core/services/wallets/get_wallet_query.py | Listado 7.7 — GetWallet: una consulta de búsqueda única que devuelve un WalletDto completo
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from lumen.interfaces.dtos.v1.wallet_dto import WalletDto
+from pyfly.cqrs import Query
+
+
+@dataclass(frozen=True)
+class GetWallet(Query[WalletDto | None]):
+ """Look up a wallet by its identifier."""
+
+ wallet_id: str
+:::
+
+::: listing lumen/core/services/wallets/get_balance_query.py | Listado 7.8 — GetBalance: una consulta más ligera que devuelve solo la proyección del saldo
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from lumen.interfaces.dtos.v1.balance_dto import BalanceDto
+from pyfly.cqrs import Query
+
+
+@dataclass(frozen=True)
+class GetBalance(Query[BalanceDto | None]):
+ """Look up just the balance of a wallet by its identifier."""
+
+ wallet_id: str
+:::
+
+Ambas consultas llevan solo `wallet_id`. `GetWallet` devuelve un `WalletDto` —la representación completa, incluyendo `id`, `owner_id`, `currency`, `balance_minor`, `balance` y `created_at`—. `GetBalance` devuelve un `BalanceDto` —una proyección más ligera que omite `owner_id` y `created_at`—. Un sondeo de saldo no necesita el propietario; dejar fuera esos campos ahorra ancho de banda y evita exponer accidentalmente la titularidad de la cuenta en una respuesta que quien llama podría registrar. Mantener las dos consultas separadas significa que puedes ajustar cada una de forma independiente —caché, autorización o un almacén de lectura dedicado— sin tocar la otra.
+
+**Paso 6 — Escribe los manejadores de las consultas de búsqueda única.** Los manejadores de consultas viven bajo el mismo paquete `wallets/` que los comandos. **Se aplica la misma apilación `@query_handler` + `@service`**: `@query_handler` registra la clase en el registro de manejadores; `@service` la conecta al contenedor de inyección de dependencias. Ambos decoradores son obligatorios por las mismas razones que en los manejadores de comandos. Fíjate en cuánto más pequeños son que los manejadores de comandos: sin factoría de sesiones, sin publicador de eventos, solo el repositorio y una proyección de una línea.
+
+::: listing lumen/core/services/wallets/get_wallet_handler.py | Listado 7.9 — GetWalletHandler: find_by_id → entity_to_dto → return
+from __future__ import annotations
+
+from lumen.core.mappers.wallet_mapper import entity_to_dto
+from lumen.core.services.wallets.get_wallet_query import GetWallet
+from lumen.interfaces.dtos.v1.wallet_dto import WalletDto
+from lumen.models.repositories.wallet_repository import WalletRepository
+from pyfly.container import service
+from pyfly.cqrs import QueryHandler, query_handler
+
+
+@query_handler
+@service
+class GetWalletHandler(QueryHandler[GetWallet, WalletDto | None]):
+ def __init__(self, repository: WalletRepository) -> None:
+ super().__init__()
+ self._repository = repository
+
+ async def do_handle(self, query: GetWallet) -> WalletDto | None: # type: ignore[override]
+ entity = await self._repository.find_by_id(query.wallet_id)
+ return entity_to_dto(entity) if entity is not None else None
+:::
+
+::: listing lumen/core/services/wallets/get_balance_handler.py | Listado 7.10 — GetBalanceHandler: vista @projection mediante Mapper.project
+from __future__ import annotations
+
+from lumen.core.mappers.wallet_mapper import entity_to_balance_dto
+from lumen.core.services.wallets.get_balance_query import GetBalance
+from lumen.interfaces.dtos.v1.balance_dto import BalanceDto
+from lumen.models.repositories.wallet_repository import WalletRepository
+from pyfly.container import service
+from pyfly.cqrs import QueryHandler, query_handler
+
+
+@query_handler
+@service
+class GetBalanceHandler(QueryHandler[GetBalance, BalanceDto | None]):
+ def __init__(self, repository: WalletRepository) -> None:
+ super().__init__()
+ self._repository = repository
+
+ async def do_handle(self, query: GetBalance) -> BalanceDto | None: # type: ignore[override]
+ entity = await self._repository.find_by_id(query.wallet_id)
+ return entity_to_balance_dto(entity) if entity is not None else None
+:::
+
+Ambos manejadores delegan la proyección en `wallet_mapper` —el único módulo dueño de la forma del DTO—. `entity_to_dto` rellena los seis campos de `WalletDto` directamente desde la fila. `entity_to_balance_dto` toma un camino distinto: llama a `Mapper.project(entity, BalanceView)` contra una interfaz marcada con `@projection` que declara exactamente los cuatro campos que necesita el endpoint de saldo, con una transformación registrada que calcula `balance` (unidades mayores) a partir de `balance_minor`. El mapeador copia solo esos campos declarados —un equivalente del lado de lectura de las proyecciones por interfaz de Spring Data—. Ninguno de los dos manejadores toca el modelo Pydantic directamente; renombrar un campo toca un archivo.
+
+### Consultas paginadas y por especificación
+
+El lado de lectura no se detiene en las búsquedas de un único recurso. Los sistemas de producción necesitan listas con metadatos de paginación y la capacidad de filtrar por predicados en tiempo de ejecución. El framework gestiona ambas cosas a través de la clase base `Repository`.
+
+!!! note "Pageable y Specification, en términos sencillos"
+ Un **`Pageable`** agrupa tres cosas que necesita un endpoint de listado: qué página quieres, qué tamaño tiene cada página y cómo ordenar. Una **`Specification`** es un filtro reutilizable y componible —piénsalo como una cláusula `WHERE` que puedes construir como objeto y combinar con `&` (and), `|` (or) y `~` (not) antes de que llegue siquiera al SQL—. Ambas provienen de la capa de datos del framework; tú no escribes el SQL.
+
+**Paso 7 — Escribe las consultas de listado y sus manejadores.** `ListWallets` envuelve un `Pageable` (número de página, tamaño, ordenación) y le pide al repositorio una porción contada, ordenada y limitada. `ListRichWallets` añade un umbral `min_minor` y lo ejecuta a través de una `Specification` componible. Construye primero los dos mensajes de consulta y luego sus manejadores.
+
+::: listing lumen/core/services/wallets/list_wallets_query.py | Listado 7.11 — ListWallets: una consulta que lleva un Pageable
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from lumen.interfaces.dtos.v1.wallet_dto import WalletDto
+from pyfly.data import Page, Pageable
+from pyfly.cqrs import Query
+
+
+@dataclass(frozen=True)
+class ListWallets(Query[Page[WalletDto]]):
+ """List wallets, one page at a time."""
+
+ pageable: Pageable
+:::
+
+::: listing lumen/core/services/wallets/list_rich_wallets_query.py | Listado 7.12 — ListRichWallets: añade un umbral de saldo para el filtrado por Specification
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from lumen.interfaces.dtos.v1.wallet_dto import WalletDto
+from pyfly.data import Page, Pageable
+from pyfly.cqrs import Query
+
+
+@dataclass(frozen=True)
+class ListRichWallets(Query[Page[WalletDto]]):
+ """List wallets whose balance is at least ``min_minor``, paged."""
+
+ min_minor: int
+ pageable: Pageable
+:::
+
+::: listing lumen/core/services/wallets/list_wallets_handler.py | Listado 7.13 — ListWalletsHandler: find_all(pageable) + Page.map
+from __future__ import annotations
+
+from lumen.core.mappers.wallet_mapper import entity_to_dto
+from lumen.core.services.wallets.list_wallets_query import ListWallets
+from lumen.interfaces.dtos.v1.wallet_dto import WalletDto
+from lumen.models.repositories.wallet_repository import WalletRepository
+from pyfly.container import service
+from pyfly.cqrs import QueryHandler, query_handler
+from pyfly.data import Page
+
+
+@query_handler
+@service
+class ListWalletsHandler(QueryHandler[ListWallets, Page[WalletDto]]):
+ def __init__(self, repository: WalletRepository) -> None:
+ super().__init__()
+ self._repository = repository
+
+ async def do_handle(self, query: ListWallets) -> Page[WalletDto]: # type: ignore[override]
+ page = await self._repository.find_all(query.pageable)
+ return page.map(entity_to_dto)
+:::
+
+::: listing lumen/core/services/wallets/list_rich_wallets_handler.py | Listado 7.14 — ListRichWalletsHandler: Specification + find_all_by_spec_paged
+from __future__ import annotations
+
+from lumen.core.mappers.wallet_mapper import entity_to_dto
+from lumen.core.services.wallets.list_rich_wallets_query import ListRichWallets
+from lumen.interfaces.dtos.v1.wallet_dto import WalletDto
+from lumen.models.repositories.wallet_repository import WalletRepository
+from pyfly.container import service
+from pyfly.cqrs import QueryHandler, query_handler
+from pyfly.data import Page
+
+
+@query_handler
+@service
+class ListRichWalletsHandler(QueryHandler[ListRichWallets, Page[WalletDto]]):
+ def __init__(self, repository: WalletRepository) -> None:
+ super().__init__()
+ self._repository = repository
+
+ async def do_handle(self, query: ListRichWallets) -> Page[WalletDto]: # type: ignore[override]
+ page = await self._repository.find_rich(query.min_minor, query.pageable)
+ return page.map(entity_to_dto)
+:::
+
+`find_all(pageable)` se hereda de la clase base `Repository` del framework. Cuenta el total de filas, aplica la ordenación del `Pageable` y rebana con `LIMIT`/`OFFSET`, devolviendo un `Page[WalletEntity]` que lleva `items`, `total`, `page`, `size`, `total_pages`, `has_next` y `has_previous`. `Page.map(entity_to_dto)` transforma los elementos sin tocar los metadatos. El controlador envuelve el resultado en un `PageDto` para el cable.
+
+`find_rich` está definido en el propio `WalletRepository` y delega en el heredado `find_all_by_spec_paged`. Construye una `Specification` —un predicado `WHERE` componible— y la pasa junto con el `Pageable`. El framework añade la cláusula `WHERE`, la ordenación y el `LIMIT`/`OFFSET`, y luego ejecuta una consulta de conteo para el total. El manejador llama a `repo.find_rich(query.min_minor, query.pageable)` y mapea la página exactamente como antes.
+
+Ejecutar una consulta pasa por `QueryBus.query`:
+
+```python
+from pyfly.cqrs import DefaultQueryBus
+from lumen.core.services.wallets.get_balance_query import GetBalance
+
+balance_dto = await query_bus.query(GetBalance(wallet_id="wlt-001"))
+```
+
+El valor de retorno es lo que devolvió `do_handle` —un `BalanceDto` o `None`—. `None` significa que el monedero no se encontró. El controlador es responsable de traducir eso a HTTP 404, manteniendo las preocupaciones HTTP fuera del manejador.
+
+!!! note "Las consultas devuelven None, no excepciones"
+ Los manejadores de consultas devuelven `None` cuando el recurso no se encuentra, en lugar de lanzar `AggregateNotFound`. Esto es una convención deliberada: una consulta que no encuentra nada no es un error, es una respuesta. El controlador convierte un resultado `None` en una respuesta 404, manteniendo la preocupación HTTP fuera del manejador.
+
+!!! note "Lo que acaba de ocurrir"
+ Ambos lados de CQRS existen ya. Tres comandos y tres manejadores cambian el estado; cuatro consultas y cuatro manejadores lo leen. Ninguno de ellos sabe nada de HTTP, y ninguno se registra a mano: los decoradores lo hacen en el arranque. Lo único que falta es la frontera HTTP que convierte una petición web en un mensaje y el resultado de un mensaje en una respuesta web. Esa es el controlador, que construyes a continuación.
+
+**Ejecútalo: confirma que cada manejador está registrado.** Antes de tocar el controlador, demuestra que el bus descubrió todos tus manejadores. Arranca la aplicación y luego pregúntale al indicador de salud de CQRS cuántos manejadores encontró. Desde el directorio `samples/lumen`:
+
+::: listing terminal | Listado 7.14a — Arrancar Lumen
+uv run pyfly run --server uvicorn
+:::
+
+En una segunda terminal, consulta el endpoint de salud del actuator. En v26.6.110 el actuator vive en su propio puerto de gestión, **9090** por defecto, no en el 8080 de la aplicación:
+
+::: listing terminal | Listado 7.14b — Contar los manejadores registrados
+curl -s localhost:9090/actuator/health | python -m json.tool
+:::
+
+Busca el bloque `cqrs_health_indicator`. Con todos los manejadores de comandos y consultas de este capítulo en su sitio, reporta tres manejadores de comandos y cuatro de consultas:
+
+```json
+"cqrs_health_indicator": {
+ "status": "UP",
+ "details": {"command_handlers": 3, "query_handlers": 4}
+}
+```
+
+Si un recuento es menor de lo que esperas, a un manejador le falta uno de sus dos decoradores y el registro nunca lo mapeó. Detén el servidor con `Ctrl-C` cuando termines.
+
+!!! note "El actuator vive ahora en su propio puerto"
+ En PyFly v26.6.110 la API de negocio y el actuator se ejecutan en puertos **separados**, al estilo de Spring. Tus endpoints de monedero escuchan en `pyfly.server.port` (por defecto **8080**), mientras que los endpoints del actuator y el panel de administración escuchan en `pyfly.management.server.port` (por defecto **9090**), que está abierto y sin autenticar por defecto. Lumen mantiene los valores por defecto, así que la salud está en `localhost:9090/actuator/health` y la API de monederos está en `localhost:8080/api/v1/wallets`. La exposición HTTP por defecto del actuator es `health,info`; expón más mediante `pyfly.management.endpoints.web.exposure.include`. Bloquea el puerto de gestión en producción con `pyfly.management.security.enabled: true`, o desactívalo por completo con `pyfly.management.server.port: -1`.
+
+---
+
+## Conectar el bus al controlador
+
+El controlador es la frontera HTTP del sistema. Su único trabajo es traducir una petición HTTP en un mensaje de dominio y mapear el resultado de vuelta a una respuesta HTTP. Todo lo que hay en medio pertenece al bus y a los manejadores, y esa frontera se vuelve mucho más limpia una vez que el controlador despacha comandos y consultas en lugar de llamar a métodos de servicio directamente.
+
+Antes de CQRS, `WalletController` mantenía una referencia a `WalletApplicationService` y llamaba a sus métodos directamente. Cada vez que la interfaz del servicio cambiaba —un nuevo parámetro, un método renombrado, un tipo de retorno distinto—, el controlador tenía que cambiar también. Con CQRS, el controlador sabe una sola cosa: qué mensaje enviar.
+
+El controlador inyecta `DefaultCommandBus` y `DefaultQueryBus` por tipo —las **clases de bus concretas**, no protocolos abstractos—. Esta es la importación correcta:
+
+```python
+from pyfly.cqrs import DefaultCommandBus, DefaultQueryBus
+```
+
+¿Por qué clases concretas? La autoconfiguración de CQRS de PyFly registra exactamente una instancia de cada bus en el contenedor de inyección de dependencias. Inyectar por el tipo concreto es inequívoco: sin despacho por protocolo, y el verificador de tipos ve toda la superficie de `send` / `query`. Usar un alias de protocolo requeriría un enlace explícito en el contenedor; el tipo concreto funciona de fábrica.
+
+### Orden de las rutas: por qué los manejadores de un solo recurso se llaman `wallet_*`
+
+El framework registra las rutas de un controlador en **orden alfabético por nombre de método**; el enrutador de Starlette aplica entonces la coincidencia «gana el primero registrado». Esto significa que un segmento literal como `/rich` debe registrarse *antes* que la variable de ruta `/{wallet_id}`; de lo contrario, cada petición `GET /api/v1/wallets/rich` coincidiría con la ruta variable y buscaría un monedero cuyo id es la cadena `"rich"`.
+
+Los manejadores de colección se llaman `list_wallets` y `list_rich_wallets`; los manejadores de un solo recurso se llaman `wallet_detail` y `wallet_balance`. Alfabéticamente, `l` va antes que `w`, así que las rutas de colección (`GET /`, `GET /rich`) se registran siempre por delante de las rutas parametrizadas (`GET /{wallet_id}`, `GET /{wallet_id}/balance`). Si renombras `wallet_detail` por algo que ordene antes que `list_*`, la ruta `/rich` se romperá silenciosamente.
+
+**Paso 8 — Conecta los buses al controlador.** Sustituye la antigua dependencia de `WalletApplicationService` por los dos buses, y luego convierte cada endpoint en una sola línea: construye un comando o consulta a partir de la petición, despáchalo y devuelve el resultado. Aquí está el controlador completo.
+
+::: listing lumen/web/controllers/wallet_controller.py | Listado 7.15 — WalletController: DefaultCommandBus + DefaultQueryBus + endpoints de listado paginado
+from __future__ import annotations
+
+from lumen.core.services.wallets.deposit_funds_command import DepositFunds
+from lumen.core.services.wallets.get_balance_query import GetBalance
+from lumen.core.services.wallets.get_wallet_query import GetWallet
+from lumen.core.services.wallets.list_rich_wallets_query import ListRichWallets
+from lumen.core.services.wallets.list_wallets_query import ListWallets
+from lumen.core.services.wallets.open_wallet_command import OpenWallet
+from lumen.core.services.wallets.withdraw_funds_command import WithdrawFunds
+from lumen.interfaces.dtos.v1.balance_dto import BalanceDto
+from lumen.interfaces.dtos.v1.deposit_request import DepositRequest
+from lumen.interfaces.dtos.v1.open_wallet_request import OpenWalletRequest
+from lumen.interfaces.dtos.v1.page_dto import PageDto
+from lumen.interfaces.dtos.v1.wallet_dto import WalletDto
+from pyfly.container import rest_controller
+from pyfly.cqrs import DefaultCommandBus, DefaultQueryBus
+from pyfly.data import Pageable, Sort
+from pyfly.kernel import ResourceNotFoundException
+from pyfly.web import (
+ Body, PathVar, QueryParam, Valid,
+ get_mapping, post_mapping, request_mapping,
+)
+
+#: Newest-first ordering shared by the list endpoints.
+_NEWEST_FIRST = Sort.by("created_at").descending()
+
+
+@rest_controller
+@request_mapping("/api/v1/wallets")
+class WalletController:
+ """Digital-wallet REST API: open, deposit, withdraw, list, inspect."""
+
+ def __init__(
+ self, commands: DefaultCommandBus, queries: DefaultQueryBus
+ ) -> None:
+ self._commands = commands
+ self._queries = queries
+
+ # --- commands --------------------------------------------------------
+
+ @post_mapping("", status_code=201)
+ async def open_wallet(
+ self, request: Valid[Body[OpenWalletRequest]]
+ ) -> dict[str, str]:
+ wallet_id = await self._commands.send(
+ OpenWallet(owner_id=request.owner_id, currency=request.currency)
+ )
+ return {"wallet_id": wallet_id}
+
+ @post_mapping("/{wallet_id}/deposit")
+ async def deposit(
+ self,
+ wallet_id: PathVar[str],
+ request: Valid[Body[DepositRequest]],
+ ) -> dict[str, int | str]:
+ balance = await self._commands.send(
+ DepositFunds(wallet_id=wallet_id, amount=request.amount)
+ )
+ return {"wallet_id": wallet_id, "balance_minor": balance}
+
+ @post_mapping("/{wallet_id}/withdraw")
+ async def withdraw(
+ self,
+ wallet_id: PathVar[str],
+ request: Valid[Body[DepositRequest]],
+ ) -> dict[str, int | str]:
+ balance = await self._commands.send(
+ WithdrawFunds(wallet_id=wallet_id, amount=request.amount)
+ )
+ return {"wallet_id": wallet_id, "balance_minor": balance}
+
+ # --- paged / specification queries (registered before /{wallet_id}) --
+
+ @get_mapping("")
+ async def list_wallets(
+ self, page: QueryParam[int] = 1, size: QueryParam[int] = 20
+ ) -> PageDto[WalletDto]:
+ result = await self._queries.query(
+ ListWallets(pageable=Pageable.of(page, size, _NEWEST_FIRST))
+ )
+ return PageDto.from_page(result)
+
+ @get_mapping("/rich")
+ async def list_rich_wallets(
+ self,
+ min_minor: QueryParam[int] = 0,
+ page: QueryParam[int] = 1,
+ size: QueryParam[int] = 20,
+ ) -> PageDto[WalletDto]:
+ result = await self._queries.query(
+ ListRichWallets(
+ min_minor=min_minor,
+ pageable=Pageable.of(page, size, _NEWEST_FIRST),
+ )
+ )
+ return PageDto.from_page(result)
+
+ # --- single-wallet queries (named wallet_* so they sort after list_*) -
+
+ @get_mapping("/{wallet_id}")
+ async def wallet_detail(self, wallet_id: PathVar[str]) -> WalletDto:
+ result = await self._queries.query(GetWallet(wallet_id=wallet_id))
+ if result is None:
+ raise ResourceNotFoundException(
+ f"Wallet {wallet_id!r} not found",
+ code="WALLET_NOT_FOUND",
+ context={"wallet_id": wallet_id},
+ )
+ return result
+
+ @get_mapping("/{wallet_id}/balance")
+ async def wallet_balance(self, wallet_id: PathVar[str]) -> BalanceDto:
+ result = await self._queries.query(
+ GetBalance(wallet_id=wallet_id)
+ )
+ if result is None:
+ raise ResourceNotFoundException(
+ f"Wallet {wallet_id!r} not found",
+ code="WALLET_NOT_FOUND",
+ context={"wallet_id": wallet_id},
+ )
+ return result
+:::
+
+Compara el constructor con su forma anterior a CQRS. Antes, el controlador tomaba `WalletApplicationService` —una clase concreta cuyas firmas de método filtraban lógica de negocio hacia la capa HTTP—. Ahora toma `DefaultCommandBus` y `DefaultQueryBus` —dos canales opacos—. El controlador sabe *qué* enviar; no sabe nada sobre *cómo* se procesa el mensaje.
+
+Mira `open_wallet`. Antes, llamaba a `self._service.open_wallet(owner_id=..., currency=...)` —un contrato posicional que se rompe cada vez que el servicio gana un nuevo parámetro—. Ahora construye `OpenWallet(owner_id=request.owner_id, currency=request.currency)` —un objeto con nombre e inmutable cuyos campos son su propia API—. ¿Añades un campo al comando? El controlador permanece igual hasta que decidas rellenarlo.
+
+Los DTO de petición (`OpenWalletRequest`, `DepositRequest`) son modelos Pydantic en `lumen/interfaces/dtos/v1/`. `OpenWalletRequest` valida la longitud de `owner_id` y restringe `currency` al enum `Currency`. `DepositRequest` lo comparten tanto el endpoint de depósito como el de retirada: ambos mueven un `amount` positivo en la propia moneda del monedero. Las restricciones a nivel de campo en esos DTO las impone `Valid[Body[...]]` antes incluso de que se llame al manejador.
+
+Los endpoints de listado paginado (`list_wallets`, `list_rich_wallets`) construyen un `Pageable` a partir de los parámetros de la cadena de consulta, despachan la consulta a través del bus y envuelven el `Page[WalletDto]` resultante en un `PageDto` para el cable. `PageDto` es un modelo Pydantic que refleja todos los campos de metadatos de `Page` —`total`, `total_pages`, `has_next`, `has_previous`—, de modo que los clientes obtienen sobres de paginación consistentes sin un serializador personalizado.
+
+Los métodos `wallet_detail` y `wallet_balance` muestran la única preocupación HTTP que queda en el controlador: traducir un resultado de consulta `None` a un 404 mediante `ResourceNotFoundException`. Ese mapeo corresponde aquí porque 404 es un código de estado HTTP y el manejador deliberadamente no tiene conocimiento de HTTP. Los tipos de retorno se declaran como `WalletDto` y `BalanceDto` —modelos Pydantic que el framework serializa a JSON automáticamente—.
+
+!!! tip "Deja que el bus lance"
+ No necesitas capturar `CommandProcessingException` ni `QueryProcessingException` en el controlador a menos que quieras personalizar la forma del error. El manejador de excepciones global mapea `AggregateNotFound` a 404 y `BusinessRuleViolation` a 422 —igual que antes—. Las excepciones del bus propagan esas originales de forma transparente.
+
+**Ejecútalo: recorre la ruta HTTP completa.** La rebanada vertical está completa: petición HTTP → comando/consulta → bus → manejador → dominio → repositorio → respuesta. Demuéstralo desde fuera. Arranca la aplicación (`uv run pyfly run --server uvicorn`) y luego, en una segunda terminal, abre un monedero:
+
+::: listing terminal | Listado 7.15a — Abrir un monedero por HTTP
+curl -s -X POST localhost:8080/api/v1/wallets \
+ -H 'content-type: application/json' \
+ -d '{"owner_id":"u-1","currency":"EUR"}'
+:::
+
+El endpoint `open_wallet` despacha `OpenWallet` y devuelve el id generado:
+
+```json
+{"wallet_id": "wlt-c5bbb2a7-dd49-4321-932e-e4c6bfa5cc2c"}
+```
+
+Copia ese id, deposita en él y luego vuelve a leer el saldo:
+
+::: listing terminal | Listado 7.15b — Depositar y luego leer el saldo
+curl -s -X POST localhost:8080/api/v1/wallets/wlt-c5bbb2a7-dd49-4321-932e-e4c6bfa5cc2c/deposit \
+ -H 'content-type: application/json' -d '{"amount":1500}'
+
+curl -s localhost:8080/api/v1/wallets/wlt-c5bbb2a7-dd49-4321-932e-e4c6bfa5cc2c/balance
+:::
+
+El depósito devuelve el nuevo saldo en unidades menores; la consulta de saldo devuelve el `BalanceDto`:
+
+```json
+{"wallet_id": "wlt-c5bbb2a7-dd49-4321-932e-e4c6bfa5cc2c", "balance_minor": 1500}
+{"wallet_id": "wlt-c5bbb2a7-dd49-4321-932e-e4c6bfa5cc2c", "balance_minor": 1500, "balance": 15.0}
+```
+
+Por último, confirma que la decisión de orden de rutas de antes da sus frutos: lista los monederos y el subconjunto «ricos»:
+
+::: listing terminal | Listado 7.15c — Ambas rutas de listado resuelven correctamente
+curl -s 'localhost:8080/api/v1/wallets?page=1&size=20'
+curl -s 'localhost:8080/api/v1/wallets/rich?min_minor=1000'
+:::
+
+Ambas devuelven un sobre `PageDto` (`items`, `total`, `total_pages`, `has_next`, `has_previous`). La llamada `/rich` resuelve a `list_rich_wallets`, *no* a `wallet_detail` buscando un monedero cuyo id sea la cadena literal `"rich"`, precisamente porque `list_*` ordena antes que `wallet_*`. Si `/rich` alguna vez devuelve un 404, ese orden se ha roto; revisa la regla de nombres de método de arriba.
+
+!!! warning "Usa un id real"
+ El id de monedero de arriba es ilustrativo: el tuyo será distinto en cada llamada a `open_wallet`. Pega el id que devuelva tu propio `POST /api/v1/wallets` en las URL de depósito y saldo, o recibirás un 404.
+
+---
+
+## La tubería del manejador
+
+Una sola llamada a `send` o `query` desencadena más que solo el manejador. Entender la tubería te dice dónde poner cada preocupación transversal y, lo que es igual de importante, dónde *no* ponerla.
+
+!!! note "¿Qué es una «tubería»?"
+ Una **tubería** es simplemente una secuencia fija de pasos que el bus ejecuta alrededor de tu manejador, como una cadena de montaje. Tu mensaje entra por un extremo, pasa por la validación y la autorización, se maneja y (para los comandos) se publican sus eventos, antes de que el resultado salga por el otro extremo. Tú escribes solo el paso del medio (`do_handle`); el bus es dueño del resto, de forma idéntica para cada mensaje.
+
+La tubería se define una sola vez, en el bus, y se aplica uniformemente a cada manejador. Nunca escribes lógica de tubería dentro de un manejador. El orden es estricto:
+
+| Paso | Dónde se define | Aplica a | Resultado del fallo |
+|---|---|---|---|
+| Validación de precondiciones de negocio | gancho `validate()` en el mensaje | Comandos + Consultas | `CqrsValidationException` (HTTP 422) |
+| Autorización | gancho `authorize()` en el mensaje | Comandos + Consultas | `AuthorizationException` (HTTP 403) |
+| Ejecución del manejador | `do_handle()` | Comandos + Consultas | Excepciones de dominio (4xx/5xx) |
+| Publicación de eventos de dominio | tubería del bus (post-manejador) | Solo comandos | — |
+| Limpieza del ID de correlación | tubería del bus (bloque finally) | Comandos + Consultas | — |
+
+### Validación
+
+Sin un paso de validación estructurado, cada manejador empezaría con sus propias cláusulas de guarda: comprobar que este campo no esté en blanco, comprobar que aquel importe sea positivo. Esa lógica se duplicaría entre manejadores y se probaría solo a través de rutas de integración. Centralizar la validación en el propio mensaje resuelve ambos problemas.
+
+El bus invoca `validate()` antes de buscar el manejador. Si la validación falla, el bus lanza `CqrsValidationException` sin llegar nunca al manejador.
+
+El gancho de validación es también el lugar adecuado para precondiciones entre campos que se pueden conocer únicamente a partir de los campos —demasiado simples para el agregado de dominio, demasiado específicas de la aplicación para el modelo de petición—:
+
+```python
+@dataclass(frozen=True)
+class DepositFunds(Command[int]):
+ wallet_id: str
+ amount: int
+
+ async def validate(self) -> ValidationResult:
+ if not self.wallet_id.strip():
+ return ValidationResult.failure(
+ "wallet_id", "Wallet id is required"
+ )
+ if self.amount <= 0:
+ return ValidationResult.failure(
+ "amount", "Deposit amount must be > 0"
+ )
+ return ValidationResult.success()
+```
+
+### Autorización
+
+Una vez que un mensaje es estructuralmente válido, el bus pregunta: ¿está *permitido* a quien llama realizar esta operación? La autorización responde antes de que ocurra cualquier acceso a la base de datos —más eficiente y más seguro, ya que nunca cargas datos sensibles solo para descartarlos porque quien llama carecía de permiso—.
+
+Tanto los comandos como las consultas exponen un gancho `authorize()`. Devuelve `AuthorizationResult.success()` para permitir la ejecución, o `AuthorizationResult.failure(resource, message)` para denegarla. El bus lanza `AuthorizationException` al denegar, mapeando a HTTP 403 mediante el manejador de errores global.
+
+Una regla práctica limpia: usa `authorize()` en el comando para comprobaciones a **nivel de operación** —quién tiene permiso para llamar a este comando en absoluto— y deja las decisiones a **nivel de recurso** (¿puede quien llama acceder a *este monedero concreto*?) al manejador, que tiene el agregado cargado en alcance:
+
+::: listing lumen/cqrs/commands_auth.py | Listado 7.16 — Gancho de autorización en un comando
+from __future__ import annotations
+from dataclasses import dataclass
+
+from pyfly.cqrs.authorization.types import AuthorizationResult
+from pyfly.cqrs import Command
+
+
+@dataclass(frozen=True)
+class CloseWallet(Command[None]):
+ """Close a wallet. Only internal service accounts may do this."""
+ wallet_id: str
+ requested_by: str
+
+ async def authorize(self) -> AuthorizationResult:
+ internal_accounts = {"ops-service", "compliance-bot"}
+ if self.requested_by not in internal_accounts:
+ return AuthorizationResult.failure(
+ "wallet",
+ "Only internal service accounts may close wallets",
+ )
+ return AuthorizationResult.success()
+:::
+
+`CloseWallet.authorize` comprueba un conjunto conocido de cuentas de servicio internas. Si `requested_by` no está en el conjunto, la autorización falla antes de que se llame al manejador. El conjunto normalmente provendría de un valor de configuración o de un claim de token inyectado en la frontera del controlador —aquí está codificado a fuego por legibilidad—. El punto clave es que la comprobación vive dentro del comando, no dispersa por el código del manejador.
+
+### Trazas distribuidas
+
+Cuando una única petición HTTP desencadena múltiples comandos —y cada comando puede llamar a servicios aguas abajo—, necesitas una forma de coser juntos todos los registros y spans. Eso es lo que proporciona `CorrelationContext`.
+
+Ambos buses establecen un ID de correlación al inicio de cada ejecución de la tubería. Si el mensaje ya lleva un ID (establecido mediante `command.set_correlation_id(id)`), se usa ese ID; de lo contrario, se genera un nuevo UUID. El ID anterior siempre se restaura en un bloque `finally`, de modo que los despachos de comandos anidados dentro de la misma petición no pisotean la traza externa.
+
+`CorrelationContext` se propaga a través de las cadenas de `await` mediante las `contextvars` de Python —no hay necesidad de pasar el ID manualmente por cada argumento de función—. Para la propagación entre servicios, serializa el contexto en las cabeceras salientes y restáuralo en el lado receptor:
+
+```python
+from pyfly.cqrs.tracing.correlation import CorrelationContext
+
+# On the sending side
+headers = CorrelationContext.create_context_headers()
+# {"X-Correlation-ID": "...", "X-Trace-ID": "...", "X-Span-ID": "..."}
+
+# On the receiving side
+CorrelationContext.extract_context_from_headers(headers)
+```
+
+Las tres cabeceras —`X-Correlation-ID`, `X-Trace-ID` y `X-Span-ID`— siguen la nomenclatura de W3C Trace Context, así que son compatibles con infraestructura instrumentada con OpenTelemetry de fábrica.
+
+!!! tip "Dónde poner la lógica transversal"
+ La tubería del bus es el hogar adecuado para las preocupaciones que se aplican a *todas* las operaciones: validación, autorización, trazas y métricas. El manejador es el hogar adecuado para las preocupaciones específicas de *una* operación: cargar el agregado, impulsar el comportamiento, guardar, drenar eventos. Si te encuentras añadiendo un try/except a cada manejador, o copiando la misma comprobación de precondición en varios manejadores, eso pertenece a la tubería —ya sea como un gancho `validate()` en el comando o como un servicio a nivel de bus—. La tubería escala uniformemente; el código repetitivo del manejador, no.
+
+---
+
+## Lo que construiste {.recap}
+
+La Parte II está completa. Lumen tiene ahora una rebanada vertical completa desde HTTP hasta el dominio y de vuelta —una construida sobre decisiones arquitectónicas que escalarán sin reescribir—.
+
+En el Capítulo 5 le diste persistencia al sistema: un `WalletRepository` que subclasifica `Repository[WalletEntity, str]` —el repositorio genérico estilo Spring Data del framework, que proporciona `find_by_id`, `find_all(pageable)`, `find_all_by_spec_paged` y más de fábrica, con la `AsyncSession` inyectada por la autoconfiguración relacional—. En el Capítulo 6 promoviste el monedero a un agregado DDD propiamente dicho: `Money` como objeto de valor inmutable, `Wallet(AggregateRoot[str])` como frontera de consistencia que impone las invariantes de descubierto, coincidencia de moneda e importe positivo, con los eventos de dominio `WalletOpened`, `FundsDeposited` y `FundsWithdrawn` bufferizados en el agregado y drenados al bus de eventos tras un guardado exitoso.
+
+En este capítulo separaste el modelo de escritura del modelo de lectura. `OpenWallet`, `DepositFunds` y `WithdrawFunds` son mensajes de comando congelados y validados que fluyen a través de `DefaultCommandBus` —una tubería que ejecuta validación, autorización, ejecución del manejador, publicación de eventos de dominio y trazas distribuidas automáticamente para cada comando—. Cada manejador de comandos lleva `@transactional()` en `do_handle`: el decorador abre una unidad de trabajo confirmada desde `self._session_factory`, intercambia la sesión en el repositorio, confirma en caso de éxito y revierte en caso de fallo. La persistencia pasa por `repository.upsert` —respaldado por `session.merge`—, de modo que INSERT y UPDATE comparten una única ruta de código con clave en el propio id del agregado.
+
+`GetWallet` y `GetBalance` son mensajes de consulta que fluyen a través de `DefaultQueryBus` —la misma tubería sin el paso de publicación de eventos, y sin `@transactional()` porque las lecturas no confirman—. `GetBalanceHandler` proyecta a través de una interfaz `BalanceView` marcada con `@projection` y `Mapper.project`, copiando solo los campos declarados y aplicando una transformación de unidades mayores registrada. `ListWallets` y `ListRichWallets` completan el lado de consultas: `find_all(pageable)` devuelve un `Page[WalletEntity]` contado, ordenado y limitado por offset; `find_all_by_spec_paged` ejecuta un predicado `Specification` componible sobre la misma maquinaria de paginación. Ambos usan `Page.map(entity_to_dto)` para proyectar los elementos sin tocar los metadatos.
+
+Cada manejador lleva la pila `@command_handler` + `@service` (o `@query_handler` + `@service`): el primer decorador registra la clase introspeccionando su argumento de tipo genérico; el segundo la conecta al contenedor de inyección de dependencias para que las dependencias del constructor se inyecten automáticamente.
+
+`WalletController` ya no sabe nada de la capa de servicio. Inyecta `DefaultCommandBus` y `DefaultQueryBus`, construye un comando o consulta a partir de la petición HTTP, lo despacha y o bien devuelve el resultado o bien lanza una excepción de dominio. Los métodos manejadores de un solo recurso se llaman `wallet_detail` y `wallet_balance` —una elección deliberada para que ordenen alfabéticamente *después* de los métodos de colección `list_wallets` y `list_rich_wallets`, garantizando que el segmento literal `/rich` se registre antes que la ruta variable `/{wallet_id}`—.
+
+Añadir un nuevo comando significa ahora tres cosas: definir un dataclass congelado, implementar un `do_handle` decorado con `@command_handler` + `@service` y anotado con `@transactional()`, y añadir un endpoint que llame a `self._commands.send`. La tubería se aplica automáticamente.
+
+**Ejecútalo: todo el capítulo, en un solo comando.** Desde el directorio `samples/lumen`, ejecuta las pruebas del flujo CQRS una última vez para confirmar que cada pieza que construiste este capítulo sigue encajando:
+
+::: listing terminal | Listado 7.17 — Verificar la rebanada CQRS completa
+uv run --extra dev pytest tests/test_cqrs_flow.py -q
+:::
+
+Pasan los cinco escenarios: el ciclo de vida de camino feliz, la consulta de no encontrado, el descubierto rechazado, el depósito no positivo rechazado y el depósito a un monedero desconocido rechazado:
+
+```
+5 passed in 0.61s
+```
+
+Vale la pena detenerse en esos últimos cuatro casos: cada uno ejercita la *tubería*, no el manejador. El descubierto lo rechaza el agregado y aflora como `CommandProcessingException`; el depósito no positivo nunca llega a un manejador en absoluto porque `validate()` lo rechaza primero. No escribiste ni una sola línea de código de manejo de errores para conseguir nada de eso.
+
+---
+
+## Pruébalo tú mismo {.exercises}
+
+1. **Traza el ciclo de vida completo en la batería de pruebas.** Abre `samples/lumen/tests/test_cqrs_flow.py` y ejecútalo contra una base de datos real usando Testcontainers (Capítulo 11). La prueba `test_full_wallet_lifecycle` abre un monedero, deposita 1 500 unidades menores, retira 500 y luego consulta tanto `GetWallet` como `GetBalance`. Recórrela con un depurador: confirma que `wallet.clear_events()` drena los eventos `FundsDeposited` y `FundsWithdrawn` después de cada llamada a `upsert`, y que `GetWallet` devuelve un `WalletDto` con `balance_minor == 1000` y `balance == 10.0`.
+
+2. **Observa `upsert` frente a `save`.** En una prueba, llama a `DepositFunds` dos veces sobre el mismo monedero sin `@transactional()` y observa el `IntegrityError`. Luego restaura `@transactional()` y verifica que ambos depósitos se confirman. Abre `WalletRepository.upsert` y traza cómo `session.merge` resuelve el conflicto de clave primaria que un `INSERT` simple lanzaría.
+
+3. **Añade una consulta `ListByOwner`.** Define `ListByOwner(Query[list[WalletDto]])` con un campo `owner_id: str`. Implementa `ListByOwnerHandler` —decorado con `@query_handler` + `@service`— que llame a `WalletRepository.find_by_owner_id(query.owner_id)` (el stub de consulta derivada ya existe) y mapee la lista de resultados con `entity_to_dto`. Añade un endpoint `GET /api/v1/wallets/by-owner/{owner_id}` a `WalletController`. Asegúrate de que el nombre del nuevo método de endpoint ordene antes que `wallet_detail` para que Starlette haga coincidir primero el segmento literal `/by-owner/…`.
+
+4. **Añade autorización a `WithdrawFunds`.** Extiende `WithdrawFunds` con un campo `initiated_by: str`. Sobrescribe `authorize()` para que devuelva `AuthorizationResult.failure("withdraw", "Initiator is required")` cuando `initiated_by` esté en blanco, y `AuthorizationResult.success()` en caso contrario. Actualiza `WithdrawFundsHandler.do_handle` para registrar `command.initiated_by` en la carga útil del evento `FundsWithdrawn`. Escribe una prueba que llame a `await WithdrawFunds(wallet_id="wlt-1", amount=100, initiated_by="").authorize()` y afirme que el resultado deniega la autorización.
diff --git a/book/manuscript-es/08-eda.md b/book/manuscript-es/08-eda.md
new file mode 100644
index 00000000..951a1c7c
--- /dev/null
+++ b/book/manuscript-es/08-eda.md
@@ -0,0 +1,699 @@
+Capítulo 8
+
+# Eventos de dominio y arquitectura orientada a eventos {.chtitle}
+
+::: figure art/openers/ch08.svg |
+
+El monedero (wallet) de Lumen guarda correctamente, valida con rigor y despacha cada escritura a través de un comando tipado. Pero fíjate en lo que hacen los manejadores de comandos después de `repo.add`: nada. Los eventos de dominio que la raíz de agregado `Wallet` acumula en su buffer —`WalletOpened`, `FundsDeposited`, `FundsWithdrawn`— se vacían y se descartan. El pipeline del bus que el Capítulo 7 prometió que "publicaría eventos de dominio" todavía no tiene adónde enviarlos.
+
+La carencia importa en la práctica. Lumen necesita un modelo de lectura del saldo que se mantenga sincronizado sin recargar el agregado en cada consulta, una notificación de bienvenida cuando se abre un monedero nuevo y un rastro de auditoría inmutable de cada movimiento financiero por cumplimiento normativo. Las tres funcionalidades dependen de saber *que algo ocurrió* —no quién lo solicitó ni cómo se gestionó.
+
+Los **eventos de dominio** son la respuesta. En lugar de que un manejador de comandos llame directamente al servicio de notificaciones —o de acoplar el registro de auditoría al repositorio— publicas el evento: un registro conciso, con marca de tiempo e inmutable de un hecho. Cada parte interesada se suscribe de forma independiente. El manejador que guardó el monedero no necesita saber qué hace el auditor, ni siquiera que existe un auditor. Puedes añadir un nuevo suscriptor meses después sin tocar una sola línea del código de los manejadores existentes.
+
+Este capítulo construye el lado reactivo de la arquitectura de Lumen. Conectarás `EventPublisher` a los manejadores de comandos, introducirás el puente `publish_domain_events` que vacía el buffer del agregado y reenvía cada evento al bus, y escribirás un `WalletAuditListener` que se suscribe usando `@event_listener` y mantiene dos proyecciones en memoria: un rastro de auditoría inmutable y un total de depósitos acumulado. Al final del capítulo, la ruta de escritura y la infraestructura de lectura quedarán totalmente desacopladas: cada lado evoluciona sin que el otro se entere.
+
+Lo construiremos gradualmente, una pequeña pieza cada vez. Cada funcionalidad viene con un recorrido numerado, el comando exacto que ejecutar y la salida que deberías esperar ver. Si has seguido el hilo desde el Capítulo 7, ya tienes los manejadores de comandos del monedero y el agregado `Wallet` en su sitio; este capítulo está verificado con PyFly v26.6.110 y el ejemplo de Lumen bajo `samples/lumen`.
+
+!!! note "Nota: la jerga, en lenguaje llano"
+ En este capítulo se repite un puñado de términos. Un **evento de dominio** es un registro pequeño y congelado de un hecho de negocio que ya ocurrió —por ejemplo, "se depositaron fondos". Un **publicador** es lo que anuncia esos hechos; un **oyente** (o *suscriptor*) es código que reacciona a ellos. Un **bus** es la centralita intermedia que lleva cada anuncio desde el publicador hasta todos los oyentes interesados. Una **proyección** es un modelo de lectura que un oyente va construyendo a partir de eventos —aquí, un rastro de auditoría y un total acumulado. Un **puerto** es una interfaz (un `Protocol` de Python) de la que depende tu código para que la implementación concreta que hay detrás pueda intercambiarse sin cambiar a quien la invoca. Ten presentes estos cinco; el resto del capítulo trata mayormente de conectarlos.
+
+---
+
+## Dos tipos de eventos
+
+Antes de tocar nada de código, conviene ser precisos sobre qué significa "evento" en PyFly. El framework usa la palabra para dos cosas distintas, y confundirlas lleva al bus equivocado, a la API de suscripción equivocada y a sutiles sorpresas en tiempo de ejecución.
+
+Los **eventos de aplicación** (`pyfly.context.events`) son notificaciones del ciclo de vida del framework. `ContextRefreshedEvent` se dispara cuando el contenedor de inyección de dependencias termina de cablear; `ApplicationReadyEvent` se dispara cuando el servidor HTTP empieza a aceptar conexiones; `ContextClosedEvent` se dispara durante el apagado. El `ApplicationEventBus` los despacha a los suscriptores emparejados por el tipo de clase de Python —son fontanería de infraestructura para el arranque, deliberadamente separada de cualquier concepto de negocio.
+
+Los **eventos de dominio** (`pyfly.eda`) son hechos a nivel de negocio: *se abrió un monedero*, *se depositaron fondos*, *se completó una transferencia*. El puerto `EventPublisher` envuelve cada carga útil en un `EventEnvelope` y la enruta por el nombre de la clase del evento de dominio —`"WalletOpened"`, `"FundsDeposited"`, `"FundsWithdrawn"`— de modo que los oyentes se suscriben a hechos de negocio con nombre y no a detalles de implementación. Los eventos de dominio son el tema de este capítulo.
+
+La distinción determina lo que puedes hacer con cada tipo. El `ApplicationEventBus` despacha a invocables indexados por clase; `InMemoryEventBus` enruta por nombre de clase y puede intercambiarse por un adaptador respaldado por Kafka sin tocar el código de los suscriptores. La regla es sencilla: usa eventos de ciclo de vida para el arranque de infraestructura y eventos de dominio para todo lo que tenga significado de negocio.
+
+!!! note "Nota: los eventos de aplicación siguen siendo útiles"
+ Si necesitas precalentar una caché en cuanto la aplicación esté lista, `@app_event_listener` sobre `ApplicationReadyEvent` es la herramienta adecuada. Los dos sistemas conviven; puedes usar ambos en el mismo servicio.
+
+---
+
+## Publicar eventos
+
+### El puerto EventPublisher
+
+La primera pregunta que suele hacer un nuevo miembro del equipo es: "¿qué clase importo para disparar un evento?". La respuesta, deliberadamente, no es una clase: es un protocolo. `EventPublisher` es un **puerto** en el sentido de la arquitectura hexagonal: cualquier código que necesite publicar un evento depende de esta interfaz, y la implementación del bus se inyecta desde fuera. Esa decisión de diseño es lo que te permite ejecutar `InMemoryEventBus` localmente hoy e intercambiarlo por un adaptador de Kafka en producción sin tocar un solo manejador.
+
+El protocolo expone dos métodos:
+
+```python
+from pyfly.eda import EventPublisher
+
+class EventPublisher(Protocol):
+ def subscribe(self, event_type_pattern: str, handler: EventHandler) -> None: ...
+
+ async def publish(
+ self,
+ destination: str,
+ event_type: str,
+ payload: dict,
+ headers: dict[str, str] | None = None,
+ ) -> None: ...
+```
+
+`publish` envuelve tus datos en un `EventEnvelope` antes de la entrega —nunca construyes el sobre tú mismo. `subscribe` registra manejadores programáticamente, aunque en la práctica usarás el decorador `@event_listener` en su lugar, porque permite que el `ApplicationContext` cablee las suscripciones automáticamente en el arranque.
+
+El bean del bus solo existe cuando `pyfly.eda.provider` está configurado. Para Lumen, `pyfly.yaml` lo establece en `memory`:
+
+::: listing pyfly.yaml | Listado 8.0 — Activar el bus EDA en memoria en pyfly.yaml
+pyfly:
+ eda:
+ provider: memory
+ # … other keys omitted for brevity
+:::
+
+Sin esta línea el bean `EventPublisher` no se registra y cualquier manejador que declare `events: EventPublisher` en su constructor fallará al arrancar.
+
+Encendamos ese bus antes de usarlo.
+
+**Paso 1 — Abre el `pyfly.yaml` de Lumen.** Localiza el bloque de nivel superior `pyfly:`. Ya verás claves como `app`, `server` y `data` de los capítulos anteriores.
+
+**Paso 2 — Añade el bloque `eda`.** Inserta las dos líneas que se muestran en el Listado 8.0 como hijo de `pyfly:`, al mismo nivel de indentación que `server:` y `data:`. La indentación es significativa en YAML, así que alinéalas con exactitud.
+
+**Paso 3 — Guarda el archivo.** Esa es toda la configuración que necesita el bus en memoria —sin broker, sin cadena de conexión, sin dependencia adicional.
+
+!!! note "Nota: Ejecútalo"
+ Arranca la aplicación y confirma que levanta limpiamente con el bus registrado:
+
+ ```bash
+ uv run pyfly run --server uvicorn
+ ```
+
+ En el log de arranque deberías ver la aplicación levantar en el puerto de app por defecto y los endpoints de gestión en el puerto de gestión separado:
+
+ ```text
+ INFO starting_application name=lumen version=1.0.0
+ INFO uvicorn running on http://127.0.0.1:8080
+ INFO management endpoints on http://127.0.0.1:9090
+ ```
+
+ Si en cambio el proceso termina con un error que menciona que no se pudo resolver una dependencia `EventPublisher`, el bloque `eda` falta o está mal indentado —vuelve al Paso 2.
+
+!!! note "Nota: dónde viven la app y el panel"
+ A partir de la v26.6.110, la aplicación escucha en `pyfly.server.port` (por defecto `8080`), mientras que el actuator y el panel de administración corren en un puerto de gestión **separado**, `pyfly.management.server.port` (por defecto `9090`). El puerto de gestión está abierto y sin autenticar por defecto; establece `pyfly.management.security.enabled: true` para protegerlo, o `pyfly.management.server.port: -1` para desactivar por completo los endpoints de gestión. Nada de esto afecta al bus EDA —es puramente en proceso—, pero conviene saber qué puerto es cuál cuando empieces a juguetear con la app en ejecución más adelante en el capítulo.
+
+**Qué acaba de pasar.** Has cambiado un único interruptor de configuración y PyFly ha registrado un bean `EventPublisher` por ti. A partir de ahora, cualquier `@service` que pida `events: EventPublisher` en su constructor recibe el bus en memoria automáticamente. Todavía no se publica nada —eso viene a continuación— pero la fontanería está en su sitio.
+
+### El EventEnvelope
+
+Cada evento de dominio llega a sus oyentes envuelto en un **`EventEnvelope`**. Piénsalo como la capa de metadatos que transforma un diccionario de Python pelado en un hecho trazable, auditable y de primera clase. Es una dataclass congelada —inmutable una vez creada— que empareja la carga útil con el contexto que cada oyente necesita.
+
+| Campo | Tipo | Por defecto | Descripción |
+|---|---|---|---|
+| `event_type` | `str` | obligatorio | El nombre de la clase del evento de dominio, p. ej. `"FundsDeposited"`. Se usa para el enrutamiento. |
+| `payload` | `dict[str, Any]` | obligatorio | Los datos del evento. |
+| `destination` | `str` | obligatorio | Canal o topic lógico, p. ej. `"wallet.events"`. |
+| `event_id` | `str` | UUID automático | ID único de esta instancia de evento. |
+| `timestamp` | `datetime` | `datetime.now(UTC)` | Hora de creación en UTC. |
+| `headers` | `dict[str, str]` | `{}` | Metadatos arbitrarios: IDs de correlación, contexto de traza, etc. |
+
+Tres campos merecen atención especial. `event_id` es un UUID estable generado por el bus en el momento de la publicación —tu **clave de idempotencia** para semántica de exactamente una vez, disponible en cada oyente sin trabajo adicional. `timestamp` registra cuándo se observó el hecho, no cuándo lo procesa el oyente, así que se mantiene exacto incluso si un oyente corre con retraso. `headers` transporta preocupaciones transversales como IDs de traza distribuida —metadatos que no tienen nada que ver con la carga útil de negocio pero que importan enormemente para la observabilidad. Como el sobre está congelado, los manejadores pueden pasarlo con seguridad a través de fronteras asíncronas sin copias defensivas.
+
+`event_type` contiene el **nombre de clase** del evento de dominio —`"WalletOpened"`, `"FundsDeposited"` o `"FundsWithdrawn"`— no una ruta separada por puntos. Los oyentes se suscriben por esos mismos nombres de clase, de modo que el contrato de suscripción lo define el modelo de dominio, no convenciones de cadena inventadas fuera de él.
+
+**Qué acaba de pasar.** No te eche para atrás la tabla de seis campos: en el código del día a día solo tocas dos de ellos. Cuando publicas proporcionas `event_type`, `payload` y `destination`; el bus rellena `event_id`, `timestamp` y `headers` por ti. Cuando reaccionas, lees `envelope.event_type` para saber *qué* ocurrió y `envelope.payload` para conocer los detalles. Los otros tres campos están ahí cuando los necesitas (idempotencia, ordenación, trazas) y discretamente fuera del camino cuando no.
+
+### Los eventos de dominio en el agregado Wallet
+
+El agregado `Wallet` lanza eventos de dominio tipados, como dataclasses congeladas. El nombre de clase de cada evento se convierte en su `event_type` de enrutamiento en el bus:
+
+::: listing lumen/models/entities/v1/wallet_entity.py | Listado 8.1 — Eventos de dominio lanzados por el agregado Wallet
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import UTC, datetime
+
+from lumen.interfaces.enums.v1.currency import Currency
+from lumen.models.entities.v1.money import Money
+from pyfly.domain import AggregateRoot, BusinessRuleViolation, DomainEvent
+
+
+@dataclass(frozen=True)
+class WalletOpened(DomainEvent):
+ wallet_id: str = ""
+ owner_id: str = ""
+ currency: str = ""
+
+
+@dataclass(frozen=True)
+class FundsDeposited(DomainEvent):
+ wallet_id: str = ""
+ amount: int = 0
+ currency: str = ""
+ balance: int = 0
+
+
+@dataclass(frozen=True)
+class FundsWithdrawn(DomainEvent):
+ wallet_id: str = ""
+ amount: int = 0
+ currency: str = ""
+ balance: int = 0
+
+
+class Wallet(AggregateRoot[str]):
+ """Wallet aggregate root — owns the ``balance >= 0`` invariant."""
+
+ def deposit(self, amount: Money) -> None:
+ """Credit *amount*; raises FundsDeposited."""
+ self._assert_currency(amount)
+ self.balance = self.balance.add(amount)
+ self.raise_event(
+ FundsDeposited(
+ wallet_id=self.id,
+ amount=amount.amount,
+ currency=amount.currency.value,
+ balance=self.balance.amount,
+ )
+ )
+ # … open() and withdraw() follow the same pattern
+:::
+
+`DomainEvent` es una dataclass congelada base. Su propiedad `event_type` devuelve `type(self).__name__` —el nombre de clase— que es exactamente lo que `EventPublisher` usa como clave de enrutamiento. `raise_event` acumula el evento en el buffer del agregado; el manejador de comandos vacía ese buffer llamando a `wallet.clear_events()` tras una persistencia con éxito.
+
+### El puente de publicación
+
+En lugar de repetir el bucle de vaciado en cada manejador de comandos, Lumen lo extrae en una única corrutina `publish_domain_events`. El puente serializa cada evento vaciado con `dataclasses.asdict`, y luego llama a `publisher.publish` con el nombre de clase como `event_type` y `"wallet.events"` como canal lógico:
+
+::: listing lumen/core/services/wallets/event_publishing.py | Listado 8.2 — publish_domain_events conecta los eventos vaciados al bus EDA
+from __future__ import annotations
+
+import dataclasses
+from collections.abc import Iterable
+from typing import Any
+
+from lumen.core.services.listeners.wallet_audit_listener import (
+ WALLET_EVENTS_DESTINATION,
+)
+from pyfly.domain import DomainEvent
+from pyfly.eda import EventPublisher
+
+
+def _to_payload(event: DomainEvent) -> dict[str, Any]:
+ """Flatten a frozen-dataclass domain event into a dict."""
+ payload: dict[str, Any] = dataclasses.asdict(event)
+ payload.setdefault("event_type", event.event_type)
+ return payload
+
+
+async def publish_domain_events(
+ publisher: EventPublisher, events: Iterable[DomainEvent]
+) -> None:
+ """Publish each drained domain event on the wallet events channel.
+
+ The envelope's ``event_type`` is the domain event class name
+ (``WalletOpened`` / ``FundsDeposited`` / ``FundsWithdrawn``).
+ """
+ for event in events:
+ await publisher.publish(
+ destination=WALLET_EVENTS_DESTINATION,
+ event_type=event.event_type,
+ payload=_to_payload(event),
+ )
+:::
+
+`WALLET_EVENTS_DESTINATION` es la constante `"wallet.events"` definida en `wallet_audit_listener.py` y compartida por publicador y oyente para que el nombre del canal no pueda divergir. `event.event_type` es la propiedad del nombre de clase en `DomainEvent`: `"WalletOpened"`, `"FundsDeposited"` o `"FundsWithdrawn"`.
+
+### Cablear el publicador en los manejadores de comandos
+
+En el Capítulo 7, los manejadores de comandos cargaban agregados, dirigían el comportamiento de dominio y guardaban —dejando los eventos acumulados tirados por el suelo. Ahora cierras esa carencia. Inyecta un `EventPublisher` junto al `WalletRepository` y un `async_sessionmaker`, decora `do_handle` con `@transactional()`, y tras `repo.upsert(...)` vacía el buffer del agregado y publica cada evento a través del puente.
+
+El decorador `@transactional()` (de `pyfly.data.relational.sqlalchemy`) abre una `AsyncSession` dedicada a partir del `async_sessionmaker` inyectado, la vincula al repositorio durante la llamada, hace commit en caso de éxito y rollback en caso de fallo. Eso significa que la secuencia cargar → mutar → guardar es una unidad de trabajo confirmada, y no se publica ningún evento a menos que la fila aterrice realmente en la base de datos.
+
+Aquí está el cambio, desglosado en las cuatro ediciones que harás a `DepositFundsHandler`.
+
+**Paso 1 — Añade el publicador al constructor.** Junto al parámetro existente `repository`, acepta `events: EventPublisher` y guárdalo como `self._events`. Tipéalo como el *protocolo* `EventPublisher`, nunca como `InMemoryEventBus` —eso es lo que mantiene al manejador ignorante de qué bus está corriendo.
+
+**Paso 2 — Acepta la fábrica de sesiones.** Añade `session_factory: async_sessionmaker[AsyncSession]` y guárdalo como `self._session_factory`. El decorador `@transactional()` busca exactamente este nombre de atributo para abrir su unidad de trabajo, así que el nombre importa.
+
+**Paso 3 — Decora `do_handle` con `@transactional()`.** Esto envuelve toda la secuencia cargar-mutar-guardar en una sola transacción confirmada.
+
+**Paso 4 — Vacía y publica tras guardar.** Como último paso dentro de `do_handle`, después de `self._repository.upsert(...)`, llama a `await publish_domain_events(self._events, wallet.clear_events())`. `wallet.clear_events()` devuelve los eventos acumulados *y* vacía el buffer, de modo que nunca se publican dos veces.
+
+Juntando esas cuatro ediciones obtienes el `DepositFundsHandler` actualizado:
+
+::: listing lumen/core/services/wallets/deposit_funds_handler.py | Listado 8.3 — DepositFundsHandler: unidad de trabajo @transactional y luego publicar
+from __future__ import annotations
+
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+from lumen.core.mappers.wallet_mapper import to_aggregate, to_entity
+from lumen.core.services.wallets.deposit_funds_command import DepositFunds
+from lumen.core.services.wallets.event_publishing import publish_domain_events
+from lumen.models.entities.v1.money import Money
+from lumen.models.repositories.wallet_repository import WalletRepository
+from pyfly.container import service
+from pyfly.cqrs import CommandHandler, command_handler
+from pyfly.domain import AggregateNotFound
+from pyfly.data.relational.sqlalchemy import transactional
+from pyfly.eda import EventPublisher
+
+
+@command_handler
+@service
+class DepositFundsHandler(CommandHandler[DepositFunds, int]):
+ """Credit funds to an existing wallet; returns the new balance."""
+
+ def __init__(
+ self,
+ repository: WalletRepository,
+ events: EventPublisher,
+ session_factory: async_sessionmaker[AsyncSession],
+ ) -> None:
+ super().__init__()
+ self._repository = repository
+ self._events = events
+ self._session_factory = session_factory
+
+ @transactional()
+ async def do_handle(self, command: DepositFunds) -> int:
+ entity = await self._repository.find_by_id(command.wallet_id)
+ if entity is None:
+ raise AggregateNotFound("Wallet", command.wallet_id)
+
+ wallet = to_aggregate(entity)
+ wallet.deposit(Money(amount=command.amount, currency=wallet.currency))
+ await self._repository.upsert(to_entity(wallet))
+
+ await publish_domain_events(self._events, wallet.clear_events())
+ return wallet.balance.amount
+:::
+
+Tres decisiones de diseño merecen mención. Primero, `events: EventPublisher` está tipado como el protocolo, no como `InMemoryEventBus` —el contenedor de inyección de dependencias inyecta la implementación que esté registrada, así que el manejador nunca sabe ni le importa qué bus está activo. Segundo, la llamada de publicación se sitúa *después* de `self._repository.upsert(...)` dentro de la unidad de trabajo `@transactional()`: si la persistencia falla, el decorador hace rollback antes de llegar a `publish_domain_events`, de modo que los oyentes nunca ven un hecho que nunca se persistió. Tercero, el manejador trabaja directamente con la entidad ORM mediante los mappers `to_aggregate` / `to_entity` —el agregado se rehidrata desde la fila, se muta y se mapea de vuelta a una fila antes del upsert. Si la publicación falla tras una persistencia con éxito, tienes un reto de entrega al-menos-una-vez —el Capítulo 10 lo aborda con patrones de outbox transaccional. Por ahora, el bus en memoria nunca falla.
+
+!!! note "Nota: Ejecútalo"
+ Con la aplicación en ejecución (`uv run pyfly run --server uvicorn`), abre un monedero y deposita en él desde una segunda terminal:
+
+ ```bash
+ # Open a wallet — returns its id
+ curl -s -X POST http://localhost:8080/api/v1/wallets \
+ -H 'content-type: application/json' \
+ -d '{"owner_id": "u-1", "currency": "EUR"}'
+ # {"wallet_id": "wlt-…"}
+
+ # Deposit 1500 minor units (15.00 EUR) into that wallet
+ curl -s -X POST http://localhost:8080/api/v1/wallets/wlt-…/deposit \
+ -H 'content-type: application/json' \
+ -d '{"amount": 1500}'
+ # {"wallet_id": "wlt-…", "balance": 1500}
+ ```
+
+ La respuesta HTTP confirma el saldo, pero la evidencia más interesante está en el log de la aplicación: como el depósito publicó un evento `FundsDeposited` y el oyente de auditoría (que construirás en la siguiente sección) reacciona a él, verás una línea de log `wallet_audit_observed` para `event_type=FundsDeposited`. ¿Todavía no hay oyente? Entonces la publicación ocurre en silencio —que es precisamente el sentido: el manejador no sabe si alguien está escuchando.
+
+**Qué acaba de pasar.** El manejador de comandos ahora hace una cosa más tras guardar: vacía los eventos que el agregado acumuló y se los entrega al bus. La ordenación crucial es *guardar primero, publicar segundo*, todo dentro de una transacción. Si la escritura en la base de datos hace rollback, los eventos nunca se publican, así que un oyente nunca puede observar un hecho que en realidad no se persistió. El manejador ganó cuatro líneas y cero conocimiento nuevo —sigue sin tener ni idea de qué, si es que algo, reaccionará.
+
+El `OpenWalletHandler` sigue el mismo patrón:
+
+::: listing lumen/core/services/wallets/open_wallet_handler.py | Listado 8.4 — OpenWalletHandler: @transactional, upsert y luego publicar WalletOpened
+from __future__ import annotations
+
+from uuid import uuid4
+
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+from lumen.core.mappers.wallet_mapper import to_entity
+from lumen.core.services.wallets.event_publishing import publish_domain_events
+from lumen.core.services.wallets.open_wallet_command import OpenWallet
+from lumen.models.entities.v1.wallet_entity import Wallet
+from lumen.models.repositories.wallet_repository import WalletRepository
+from pyfly.container import service
+from pyfly.cqrs import CommandHandler, command_handler
+from pyfly.data.relational.sqlalchemy import transactional
+from pyfly.eda import EventPublisher
+
+
+@command_handler
+@service
+class OpenWalletHandler(CommandHandler[OpenWallet, str]):
+ """Open a new, empty wallet."""
+
+ def __init__(
+ self,
+ repository: WalletRepository,
+ events: EventPublisher,
+ session_factory: async_sessionmaker[AsyncSession],
+ ) -> None:
+ super().__init__()
+ self._repository = repository
+ self._events = events
+ # @transactional resolves the unit-of-work session from here.
+ self._session_factory = session_factory
+
+ @transactional()
+ async def do_handle(self, command: OpenWallet) -> str:
+ wallet_id = f"wlt-{uuid4()}"
+ wallet = Wallet.open(
+ wallet_id=wallet_id,
+ owner_id=command.owner_id,
+ currency=command.currency,
+ )
+ await self._repository.upsert(to_entity(wallet))
+
+ await publish_domain_events(self._events, wallet.clear_events())
+ return wallet_id
+:::
+
+`return wallet_id` viene *después* de la llamada de publicación —el manejador cumple su contrato solo una vez que cada hecho producido por la operación ha sido despachado. El id del monedero se genera localmente con `uuid4()` en lugar de delegarlo a un método del repositorio; `to_entity` mapea el agregado a una fila antes del upsert para que el `Repository` del framework reciba el modelo ORM que espera.
+
+### El atajo @publish_result
+
+En servicios más sencillos donde el valor de retorno de un método *es* la carga útil del evento —común en código que no ha adoptado el patrón de agregado completo— `@publish_result` elimina por completo la llamada manual de publicación:
+
+::: listing lumen/eda/publish_result_example.py | Listado 8.5 — @publish_result auto-publica el valor de retorno del método
+from pyfly.eda import publish_result
+from pyfly.eda.adapters.memory import InMemoryEventBus
+
+bus = InMemoryEventBus()
+
+
+@publish_result(bus, destination="wallet.events", event_type="FundsTransferred")
+async def transfer_funds(source_id: str, target_id: str, amount: int) -> dict:
+ # Business logic omitted — the returned dict IS the event payload.
+ return {
+ "source_id": source_id,
+ "target_id": target_id,
+ "amount": amount,
+ }
+:::
+
+Cuando `transfer_funds` retorna, el decorador intercepta el resultado y llama a `bus.publish` con él como carga útil —sin necesidad de bucle repetitivo. `destination` y `event_type` quedan fijados en el momento de la decoración, manteniendo limpia la función de negocio. `@publish_result` también acepta un predicado `condition` opcional: el evento se publica solo cuando el resultado satisface la prueba, lo cual resulta útil para flujos de trabajo condicionales en los que no toda ejecución con éxito debe difundirse.
+
+::: figure art/figures/08-eda.svg | Figura 8.1 — Un publicador, muchos oyentes independientes.
+
+!!! spring "Equivalencia con Spring"
+ `EventPublisher` es el equivalente en PyFly del `ApplicationEventPublisher` de Spring. Llamar a `publisher.publish(...)` equivale a `applicationEventPublisher.publishEvent(event)`. El decorador `@event_listener` (siguiente sección) refleja el `@EventListener` de Spring para reacciones síncronas, en la misma transacción. `@publish_result` logra lo que los desarrolladores de Spring a menudo cablean manualmente con consejos AOP `@AfterReturning`.
+
+---
+
+## Reaccionar con @event_listener
+
+Publicar un evento es solo la mitad de la película. Un evento al que nadie reacciona no es más que una entrada de log. El valor del modelo orientado a eventos reside en las *reacciones* que habilita —comportamientos independientes que se activan en respuesta al mismo hecho publicado, cada uno ajeno a los demás.
+
+El decorador **`@event_listener`** de PyFly es la forma más sencilla de registrar una reacción. Decora cualquier método asíncrono con los nombres de clase que le interesan, y el `ApplicationContext` cablea la suscripción durante el arranque —sin necesidad de una referencia al bus en el momento de la decoración.
+
+```python
+from pyfly.eda import event_listener, EventEnvelope
+
+@event_listener(event_types=["FundsDeposited"])
+async def on_funds_deposited(envelope: EventEnvelope) -> None:
+ ...
+```
+
+`event_types` acepta nombres de clase exactos. Los oyentes dentro de una clase `@service` reciben un `EventEnvelope` como único argumento. Como el emparejamiento ocurre a nivel del bus —no dentro de tu función— un único método oyente puede suscribirse a varios tipos de evento en una sola declaración.
+
+### WalletAuditListener
+
+El oyente de producción de Lumen es `WalletAuditListener`. Se suscribe a los tres eventos de dominio del monedero y mantiene dos proyecciones en memoria: un **rastro de auditoría** ordenado y un **total neto de depósitos acumulado** por monedero.
+
+Lo ensamblaremos en pequeñas piezas. Lee primero los cuatro pasos, y luego estudia el listado completo más abajo —es el mismo código, mostrado entero.
+
+**Paso 1 — Declara una clase `@service` simple.** Un oyente no es más que un bean de servicio con algo de estado. Dale un `__init__` que inicialice las dos proyecciones: `self._entries: list[AuditEntry] = []` para el rastro de auditoría y `self._running_totals: dict[str, int] = {}` para los totales por monedero.
+
+**Paso 2 — Escribe el método de reacción.** Añade un `async def on_wallet_event(self, envelope: EventEnvelope) -> None`. Recibe un `EventEnvelope` y nada más.
+
+**Paso 3 — Estámpalo con `@event_listener`.** Decora el método con `@event_listener(event_types=["WalletOpened", "FundsDeposited", "FundsWithdrawn"])`. Un método puede suscribirse a los tres nombres de clase en una sola declaración. Este decorador no se suscribe de inmediato —*estampa* el método con metadatos que el `ApplicationContext` encuentra en el arranque y usa para auto-suscribirlo al bean `EventPublisher`.
+
+**Paso 4 — Proyecta el evento.** Dentro del método, añade un `AuditEntry` por cada evento, y luego ramifica según `envelope.event_type` para ajustar el total acumulado: ponlo a cero en `WalletOpened`, súmalo en `FundsDeposited`, réstalo en `FundsWithdrawn`. Expón accesores de lectura (`entries`, `entries_for`, `running_total`) para que otro código pueda consultar las proyecciones.
+
+::: listing lumen/core/services/listeners/wallet_audit_listener.py | Listado 8.6 — WalletAuditListener: rastro de auditoría + proyección de total acumulado
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass
+from datetime import datetime
+
+from pyfly.container import service
+from pyfly.eda import EventEnvelope, event_listener
+
+logger = logging.getLogger(__name__)
+
+WALLET_EVENTS_DESTINATION = "wallet.events"
+
+
+@dataclass(frozen=True)
+class AuditEntry:
+ """One observed domain event, captured for the audit trail."""
+
+ event_type: str
+ wallet_id: str
+ event_id: str
+ occurred_at: datetime
+ payload: dict[str, object]
+
+
+@service
+class WalletAuditListener:
+ """In-memory audit log + running-total projection over wallet events."""
+
+ def __init__(self) -> None:
+ self._entries: list[AuditEntry] = []
+ self._running_totals: dict[str, int] = {}
+
+ @event_listener(
+ event_types=["WalletOpened", "FundsDeposited", "FundsWithdrawn"]
+ )
+ async def on_wallet_event(self, envelope: EventEnvelope) -> None:
+ """Project every wallet domain event into the read models."""
+ payload = dict(envelope.payload)
+ wallet_id = str(payload.get("wallet_id", ""))
+
+ self._entries.append(
+ AuditEntry(
+ event_type=envelope.event_type,
+ wallet_id=wallet_id,
+ event_id=str(payload.get("event_id", envelope.event_id)),
+ occurred_at=envelope.timestamp,
+ payload=payload,
+ )
+ )
+
+ if envelope.event_type == "WalletOpened":
+ self._running_totals.setdefault(wallet_id, 0)
+ elif envelope.event_type == "FundsDeposited":
+ amount = int(payload.get("amount", 0))
+ self._running_totals[wallet_id] = (
+ self._running_totals.get(wallet_id, 0) + amount
+ )
+ elif envelope.event_type == "FundsWithdrawn":
+ amount = int(payload.get("amount", 0))
+ self._running_totals[wallet_id] = (
+ self._running_totals.get(wallet_id, 0) - amount
+ )
+
+ logger.info(
+ "wallet_audit_observed",
+ extra={"event_type": envelope.event_type, "wallet_id": wallet_id},
+ )
+
+ @property
+ def entries(self) -> list[AuditEntry]:
+ """A snapshot of the audit log, in observation order."""
+ return list(self._entries)
+
+ def entries_for(self, wallet_id: str) -> list[AuditEntry]:
+ """The audit entries recorded for one wallet."""
+ return [e for e in self._entries if e.wallet_id == wallet_id]
+
+ def running_total(self, wallet_id: str) -> int:
+ """Net funds (deposited minus withdrawn) for wallet_id, minor units."""
+ return self._running_totals.get(wallet_id, 0)
+:::
+
+Esto es lo que hace el oyente, paso a paso.
+
+`@event_listener(event_types=["WalletOpened", "FundsDeposited", "FundsWithdrawn"])` le dice al `ApplicationContext` que suscriba `on_wallet_event` a esos tres nombres de clase. Como la clase es un bean `@service`, PyFly la descubre en el arranque y cablea las suscripciones automáticamente —nunca llamas a `bus.subscribe` a mano.
+
+`on_wallet_event` recibe un `EventEnvelope`. `envelope.event_type` es el nombre de clase del evento de dominio lanzado. `envelope.payload` es el dict producido por `dataclasses.asdict` en el puente de publicación, así que sus claves coinciden exactamente con los nombres de los campos de la dataclass —`wallet_id`, `amount`, `currency`, `balance`.
+
+El método añade un `AuditEntry` por cada evento, y luego ramifica según `event_type` para actualizar el total acumulado. Fíjate en lo que falta: ninguna importación del agregado `Wallet`, ninguna llamada al repositorio, ningún conocimiento de cómo se procesó el depósito. La proyección reacciona puramente al hecho publicado.
+
+**Qué acaba de pasar.** Has escrito una reacción autónoma. El decorador `@event_listener` es toda la historia del cableado —no hay ninguna llamada `bus.subscribe(...)` en ningún sitio de este archivo. En el arranque, el `ApplicationContext` escanea tus beans `@service`, encuentra el método estampado `on_wallet_event` y lo suscribe al bus para cada uno de los tres nombres de clase. El oyente y los manejadores de comandos nunca se referencian entre sí; están conectados únicamente a través de los eventos que acuerdan nombrar.
+
+!!! tip "Consejo: metadatos del sobre en las proyecciones"
+ `envelope.timestamp` te da la hora autoritativa del evento —cuándo se registró el hecho, no cuándo corrió el oyente. Guárdala en tu modelo de lectura y obtienes una columna `occurred_at` barata gratis, sin desviación de reloj entre escritor y lector.
+
+### Probar el oyente de extremo a extremo
+
+La suite de pruebas ejercita la ruta completa de publicar-y-recibir sin mocks. El conftest cablea un `InMemoryEventBus` compartido, refleja el paso de descubrimiento de `@event_listener` suscribiendo `on_wallet_event` a cada nombre de clase declarado, y registra manejadores de comandos reales que comparten la misma referencia al bus:
+
+```python
+# tests/conftest.py (abbreviated)
+from pyfly.eda.adapters.memory import InMemoryEventBus
+
+@pytest_asyncio.fixture
+async def event_bus() -> InMemoryEventBus:
+ yield InMemoryEventBus()
+
+@pytest_asyncio.fixture
+async def audit_listener(event_bus: InMemoryEventBus) -> WalletAuditListener:
+ listener = WalletAuditListener()
+ method = listener.on_wallet_event
+ for pattern in method.__pyfly_event_patterns__:
+ event_bus.subscribe(pattern, method)
+ yield listener
+```
+
+Con ese cableado en su sitio, la prueba envía comandos reales y hace aserciones sobre los modelos de lectura del oyente:
+
+::: listing lumen/tests/test_event_listener.py | Listado 8.7 — Prueba de extremo a extremo: los comandos publican, el oyente proyecta
+from __future__ import annotations
+
+import pytest
+from lumen.core.services.listeners import WalletAuditListener
+from lumen.core.services.wallets.deposit_funds_command import DepositFunds
+from lumen.core.services.wallets.open_wallet_command import OpenWallet
+from lumen.core.services.wallets.withdraw_funds_command import WithdrawFunds
+from lumen.interfaces.enums.v1.currency import Currency
+
+from pyfly.cqrs import DefaultCommandBus
+
+
+@pytest.mark.asyncio
+async def test_listener_observes_wallet_events(
+ command_bus: DefaultCommandBus,
+ audit_listener: WalletAuditListener,
+) -> None:
+ wallet_id = await command_bus.send(
+ OpenWallet(owner_id="u-1", currency=Currency.EUR)
+ )
+ await command_bus.send(DepositFunds(wallet_id=wallet_id, amount=1500))
+ await command_bus.send(WithdrawFunds(wallet_id=wallet_id, amount=400))
+
+ entries = audit_listener.entries_for(wallet_id)
+ assert [e.event_type for e in entries] == [
+ "WalletOpened",
+ "FundsDeposited",
+ "FundsWithdrawn",
+ ]
+
+ deposited = entries[1]
+ assert deposited.payload["amount"] == 1500
+ assert deposited.payload["currency"] == "EUR"
+ assert deposited.payload["balance"] == 1500
+
+ # running_total = deposited − withdrawn
+ assert audit_listener.running_total(wallet_id) == 1100
+:::
+
+La prueba demuestra la cadena completa: `OpenWalletHandler` → `publish_domain_events` → `InMemoryEventBus` → `WalletAuditListener.on_wallet_event` → `audit_listener.entries_for(...)`. Sin mocks, sin fakes —la ruta de código de producción corre tal como está escrita.
+
+!!! note "Nota: Ejecútalo"
+ Ejecuta la prueba del oyente de eventos desde la raíz del proyecto Lumen:
+
+ ```bash
+ uv run --extra dev pytest tests/test_event_listener.py -q
+ ```
+
+ Deberías ver pasar la suite:
+
+ ```text
+ ... [100%]
+ 3 passed in 0.XXs
+ ```
+
+ Las tres pruebas cubren el camino feliz (abrir, depositar, retirar y luego comprobar el rastro de auditoría y el total acumulado), el caso de proyección vacía antes de que se ejecute ningún comando, y el caso negativo en el que una retirada con descubierto lanza una excepción y por tanto *no* publica ningún evento —así que no debe aparecer en el registro de auditoría. Si una prueba falla con un error de atributo inexistente sobre `__pyfly_event_patterns__`, el decorador `@event_listener` no está aplicado a `on_wallet_event`; revisa el Paso 3 de la sección anterior.
+
+Lo que hace convincente a este diseño es que añadir el oyente no requirió ningún cambio en los manejadores de comandos, en el agregado `Wallet` ni en ningún repositorio. `DepositFundsHandler` no tiene ni idea de que existe una proyección. Ambos lados son totalmente independientes —cada uno es una consecuencia del mismo hecho publicado, conectados únicamente por el bus.
+
+---
+
+## Cuando los oyentes fallan: estrategias de error
+
+Un oyente que se comporta mal plantea una pregunta puntiaguda: ¿debería el fallo detener toda la cadena de entrega, o debería el bus seguir notificando a los oyentes restantes? La respuesta correcta depende del papel del oyente. PyFly te da control explícito en lugar de imponer una única política.
+
+Por defecto, `InMemoryEventBus` invoca a los oyentes secuencialmente y propaga cualquier excepción —el comportamiento correcto para desarrollo, donde un oyente que falla debe aflorar ruidosamente. En producción normalmente necesitas un control más fino.
+
+**`ErrorStrategy`** es un enum que gobierna cómo se comporta el bus cuando un oyente lanza una excepción:
+
+```python
+from pyfly.eda import ErrorStrategy
+```
+
+| Miembro | Valor | Comportamiento |
+|---|---|---|
+| `IGNORE` | `"IGNORE"` | Traga la excepción silenciosamente. El procesamiento continúa con el siguiente manejador. |
+| `LOG_AND_CONTINUE` | `"LOG_AND_CONTINUE"` | Registra el error y luego continúa. El valor por defecto más seguro para oyentes no críticos. |
+| `RETRY` | `"RETRY"` | Reintenta la entrega. El número de reintentos y el back-off se configuran por separado. |
+| `DEAD_LETTER` | `"DEAD_LETTER"` | Mueve el evento fallido a un destino de cartas muertas para inspección posterior. |
+| `FAIL_FAST` | `"FAIL_FAST"` | Propaga la excepción de inmediato. No se invoca ningún manejador más. |
+
+!!! tip "Consejo: ajusta la estrategia a la criticidad del oyente"
+ Un oyente de auditoría debería usar `LOG_AND_CONTINUE` —un registrador de auditoría roto no debe detener una transacción financiera. Una proyección que alimenta las respuestas de consulta podría justificar `RETRY` para asegurar que el modelo de lectura se mantenga consistente. Un notificador puede tolerar `IGNORE`, ya que un correo de bienvenida perdido no es un problema de integridad de datos.
+
+!!! warning "Advertencia: efectos secundarios e idempotencia"
+ Si un oyente realiza un efecto secundario —escribir una fila en la base de datos, enviar un correo— y el bus reintenta la entrega tras un fallo transitorio, el efecto puede ejecutarse más de una vez. Diseña los oyentes para que sean idempotentes: escribe una fila solo si el `event_id` no se ha registrado ya, envía un correo solo si la bandera de bienvenida no está ya activada. El `envelope.event_id` (un UUID estable generado por el bus) es tu clave de idempotencia.
+
+---
+
+## En memoria hoy, un broker mañana
+
+**`InMemoryEventBus`** es la implementación lista para usar —el `EventPublisher` por defecto que proporciona el `ApplicationContext`. Corre enteramente en proceso: `publish` es una llamada asíncrona directa, no hay serialización, y los eventos no entregados se desvanecen si el proceso muere. Para desarrollo local, pruebas de integración y monolitos que no necesitan entrega entre procesos, eso es perfectamente aceptable.
+
+Entender cómo funciona internamente el bus en memoria facilita razonar sobre el comportamiento en los límites —y apreciar exactamente qué cambia cuando intercambias por un broker.
+
+```python
+from pyfly.eda.adapters.memory import InMemoryEventBus
+
+bus = InMemoryEventBus()
+
+bus.subscribe("FundsDeposited", my_handler)
+
+await bus.publish(
+ destination="wallet.events",
+ event_type="FundsDeposited",
+ payload={"wallet_id": "w-001", "amount": 5000, "currency": "EUR", "balance": 5000},
+)
+```
+
+Una llamada a `publish` ejecuta cuatro pasos en secuencia:
+
+1. Envuelve los argumentos en un `EventEnvelope` con un `event_id` generado y un `timestamp` en UTC.
+2. Itera cada par registrado `(pattern, handler)`.
+3. Para cada par donde `fnmatch.fnmatch(event_type, pattern)` es `True`, llama al manejador con el sobre.
+4. Los manejadores corren secuencialmente en orden de suscripción.
+
+Las suscripciones usan el `fnmatch` de Python, así que `"Funds*"` empareja tanto con `"FundsDeposited"` como con `"FundsWithdrawn"`, y `"*"` empareja con todo. La invocación secuencial del paso 4 hace que el orden de los oyentes sea determinista —útil en pruebas— pero también significa que un oyente lento retrasa a todos los posteriores. Los adaptadores respaldados por broker normalmente despachan en paralelo; ten presente esa diferencia al razonar sobre el rendimiento.
+
+Como cada oyente en Lumen depende del *protocolo* `EventPublisher`, no de `InMemoryEventBus` directamente, la implementación se intercambia sin tocar un solo oyente. El Capítulo 10 introduce adaptadores de Kafka y RabbitMQ; cambiar a cualquiera de ellos es un cambio de configuración —`WalletAuditListener` sigue funcionando sin modificación.
+
+!!! note "Nota: InMemoryEventBus y las pruebas"
+ `InMemoryEventBus` es también la herramienta adecuada para las pruebas. Inyecta un `InMemoryEventBus` fresco como fixture, suscribe un manejador que capture, ejercita tu manejador de comandos y haz aserciones sobre los objetos `EventEnvelope` que el manejador recibió —incluyendo `event_type`, `payload`, `event_id` y `timestamp`. Sin mockear, sin fakes, solo el bus real con entradas controladas.
+
+---
+
+## Lo que construiste {.recap}
+
+La Parte III está abierta.
+
+Este capítulo cerró el ciclo que el Capítulo 7 inició. `Wallet` lanzó eventos de dominio en el Capítulo 6; los manejadores de comandos los publicaron aquí; `WalletAuditListener` reacciona a esos hechos sin saber nada de la ruta de comandos que los disparó.
+
+La arquitectura es genuinamente orientada a eventos dentro de un solo proceso. Aquí tienes una referencia rápida de cada pieza:
+
+| Pieza | Papel |
+|---|---|
+| `EventPublisher` | Puerto —un protocolo que cumple cualquier implementación de bus |
+| `InMemoryEventBus` | Adaptador por defecto —en proceso, sin configuración; activado por `pyfly.eda.provider: memory` |
+| `EventEnvelope` | Transporta la carga útil + `event_id`, `timestamp`, `destination`, `headers` |
+| `@event_listener(event_types=[...])` | Decorador de suscripción —nombres de clase; el contexto lo cablea en el arranque |
+| `publish_domain_events` | Puente —vacía `wallet.clear_events()`, serializa con `dataclasses.asdict`, llama a `publisher.publish` |
+| `ErrorStrategy` | Controla la gestión de fallos: `IGNORE`, `LOG_AND_CONTINUE`, `RETRY`, `DEAD_LETTER`, `FAIL_FAST` |
+
+Tres principios se trasladan al resto de la Parte III: **guarda antes de publicar** —los oyentes nunca deben ver hechos no confirmados; **diseña los oyentes para la idempotencia** —los reintentos deben ser seguros; **depende del puerto, no del adaptador** —el bus puede intercambiarse sin tocar el código de los oyentes.
+
+El Capítulo 9 lleva la idea del evento más lejos. En lugar de mantener un modelo de lectura separado junto a un agregado mutable, almacenas los eventos mismos como el sistema de registro —aplicando event sourcing (suministro de eventos) al libro mayor para que cada saldo histórico sea calculable desde primeros principios.
+
+---
+
+## Pruébalo tú mismo {.exercises}
+
+1. **Añade un oyente `FraudDetector`.** Crea un servicio `FraudDetector` que se suscriba a `"FundsDeposited"` usando `@event_listener(event_types=["FundsDeposited"])`. Si el `amount` en la carga útil supera `1_000_000` (diez mil euros en unidades menores), registra una advertencia que incluya el `envelope.event_id`, el `envelope.timestamp` y el `wallet_id` de la carga útil. Verifica que se dispara publicando un evento `FundsDeposited` directamente a un `InMemoryEventBus` en una prueba unitaria, y comprueba que la advertencia se activó.
+
+2. **Extiende `WalletAuditListener` con filtrado por evento.** Añade un método `entries_by_type(self, event_type: str) -> list[AuditEntry]` que devuelva solo las entradas con un `event_type` coincidente. Escribe una prueba que abra un monedero, haga dos depósitos y una retirada, y compruebe que `entries_by_type("FundsDeposited")` devuelve exactamente dos entradas.
+
+3. **Observa el comportamiento de la estrategia de error.** Crea un oyente cuyo manejador lance `RuntimeError("failure")` incondicionalmente. Regístralo junto a un manejador que capture añadiendo a una lista en un `InMemoryEventBus`. Configura `ErrorStrategy.LOG_AND_CONTINUE` y confirma que el manejador que captura sigue recibiendo el evento a pesar del fallo. Luego cambia a `ErrorStrategy.FAIL_FAST` y confirma que el manejador que captura *no* recibe el evento.
diff --git a/book/manuscript-es/09-event-sourcing.md b/book/manuscript-es/09-event-sourcing.md
new file mode 100644
index 00000000..d518b363
--- /dev/null
+++ b/book/manuscript-es/09-event-sourcing.md
@@ -0,0 +1,998 @@
+Capítulo 9
+
+# Event sourcing del libro mayor {.chtitle}
+
+::: figure art/openers/ch09.svg |
+
+El Capítulo 8 dejó sin resolver una grieta estructural. El `WalletAuditListener` mantiene un modelo de lectura rápido reaccionando a eventos de dominio, pero el estado canónico del monedero sigue siendo una fila de la tabla `wallets`, que guarda una única columna `balance`. Cada vez que el saldo cambia, el valor anterior desaparece para siempre. Si un auditor de cumplimiento pregunta "¿cuál era el saldo del monedero `w-001` a las 14:32 del 3 de marzo?", la respuesta honesta es: no puedes saberlo. La base de datos solo recuerda el presente.
+
+El **event sourcing** (almacenamiento de eventos como fuente de verdad) le da la vuelta a este diseño. En lugar de almacenar el estado *actual* y descartar cada cambio, almacenas la *secuencia de cambios* y derivas el estado actual reproduciéndolos. La tabla `wallets` desaparece. En su lugar hay un **flujo de eventos** (event stream): un registro de solo anexado de cada evento `LedgerOpened`, `Credited` y `Debited` que el libro mayor haya producido jamás. El saldo en cualquier instante es una función pura de los eventos hasta ese momento. Puedes rebobinar hasta las 14:32 de cualquier fecha porque tienes un registro completo de todo lo ocurrido entre entonces y ahora.
+
+Un libro mayor financiero es el dominio ideal para el event sourcing. Los contables han comprendido durante siglos que la autoridad de un libro mayor proviene de sus asientos, no de un total acumulado al pie de la columna. El total acumulado es un *hecho derivado*; los asientos son la *fuente de verdad*. El módulo `pyfly.eventsourcing` de PyFly lleva esa intuición contable al código: los agregados emiten eventos de dominio, un `EventStore` los registra de forma inmutable, un repositorio reproduce el flujo para reconstruir el estado y un `ProjectionRunner` construye modelos de lectura encima.
+
+Este capítulo construye el agregado `LedgerAccount`, un objeto de dominio orientado a eventos creado a propósito que convive junto al `Wallet` con estado almacenado del Capítulo 6. Verás cada componente del módulo `pyfly.eventsourcing` y cómo el almacén de eventos, los snapshots, las proyecciones y el outbox transaccional colaboran para darle a Lumen un libro mayor que es a la vez auditable y eficiente.
+
+---
+
+## Del estado a los eventos
+
+La forma más clara de captar el cambio es comparar qué aspecto tiene la base de datos en cada modelo.
+
+En el **modelo de almacenamiento de estado**, la base de datos guarda solo el estado actual del monedero:
+
+| wallet_id | owner_id | balance_cents | currency | updated_at |
+|---|---|---|---|---|
+| w-001 | u-42 | 8500 | EUR | 2026-03-03 17:11 |
+
+Cada `deposit` y `withdraw` sobrescribe `balance_cents`. La historia se pierde. Sabes que el monedero contiene 85,00 EUR ahora mismo; no puedes saber cómo llegó hasta ahí.
+
+En el **modelo de almacenamiento de eventos**, la base de datos guarda el flujo de eventos:
+
+| stream_id | seq | event_type | payload | occurred_at |
+|---|---|---|---|---|
+| led-001 | 1 | LedgerOpened | `{"currency":"EUR","owner_id":"u-42"}` | 2026-03-01 09:00 |
+| led-001 | 2 | Credited | `{"amount":10000,"balance":10000}` | 2026-03-01 09:01 |
+| led-001 | 3 | Debited | `{"amount":1500,"balance":8500}` | 2026-03-03 17:11 |
+
+El saldo actual sigue siendo 85,00 EUR, pero ahora puedes leer cada decisión que condujo hasta él. Un auditor, un regulador o un investigador de fraude pueden reproducir el flujo desde cualquier desplazamiento y ver exactamente qué ocurrió y cuándo.
+
+::: figure art/figures/09-eventsourcing.svg | Figura 9.1 — Almacenamiento de estado frente a almacenamiento de eventos: un modelo conserva una instantánea del presente; el otro conserva cada hecho que condujo hasta él.
+
+La contrapartida es real. El almacenamiento de eventos encarece las lecturas por defecto —debes reproducir el flujo para calcular el saldo actual— y exige disciplina en torno a la evolución del esquema (los eventos son inmutables; no puedes renombrar un campo a posteriori). Ambas preocupaciones tienen solución en PyFly: los **snapshots** aceleran la reproducción de flujos largos, y los **upcasters** traducen las formas de eventos antiguas a las nuevas durante la carga. Verás ambos antes del final de este capítulo.
+
+!!! note "Los eventos como sistema de registro"
+ El event sourcing no es lo mismo que la arquitectura orientada a eventos. El Capítulo 8 usó EDA: el agregado almacenaba su estado de forma normal y publicaba eventos de dominio como efecto secundario. El event sourcing va más allá: los eventos *son* el estado. No hay una columna `balance` separada que mantener sincronizada; el saldo lo calcula el repositorio cada vez que carga el agregado.
+
+---
+
+## Una base separada para los agregados con event sourcing
+
+Antes de escribir una línea de código del libro mayor, importa una distinción de nombres. El Capítulo 6 construyó `Wallet` sobre `pyfly.domain.AggregateRoot`, la clase base con estado almacenado. `LedgerAccount` usa una clase base **diferente**: `pyfly.eventsourcing.AggregateRoot`. Las dos viven en paquetes separados y, deliberadamente, no están relacionadas.
+
+| Aspecto | `Wallet` del Capítulo 6 | `LedgerAccount` del Capítulo 9 |
+|---|---|---|
+| Clase base | `pyfly.domain.AggregateRoot` | `pyfly.eventsourcing.AggregateRoot` |
+| Evento de dominio | `pyfly.domain.DomainEvent` | `pyfly.eventsourcing.DomainEvent` |
+| El estado vive en | una fila de base de datos | el flujo de eventos |
+| Repositorio | `WalletRepository` (R2DBC) | `LedgerAccountRepository` (`EventSourcedRepository`) |
+
+`pyfly.eventsourcing.AggregateRoot` aporta la maquinaria de event sourcing a través de cuatro miembros:
+
+- **`when(EventType, handler)`** — registra un manejador (handler) para una clase de evento dada. El manejador recibe `(aggregate, event)` como dos argumentos y realiza la mutación: una lambda, una función libre o una sola línea que delega en un método privado.
+- **`apply(event)`** — encamina un evento recién creado a través de su manejador registrado y lo encola para el almacén de eventos. Ambas cosas ocurren atómicamente: el estado en memoria se actualiza de inmediato, sin ningún viaje de ida y vuelta al almacén.
+- **`replay(event_type, event)`** — vuelve a ejecutar un evento persistido a través del mismo manejador *sin* volver a encolarlo. El repositorio llama a esto durante la carga para reconstruir el estado a partir del flujo almacenado.
+- **`version`** — un contador entero que se incrementa después de cada evento despachado; el almacén lo usa como token de concurrencia optimista.
+
+El orden de despacho es: primero el manejador registrado con `when()`; luego un método llamado `on_{event_type}` si existe en el agregado; si no se encuentra ninguno, se lanza `EventHandlerException`. Un manejador ausente corrompería silenciosamente el estado reconstruido, así que el agregado falla de forma ruidosa en lugar de seguir adelante.
+
+!!! warning "Manejador de dos argumentos: la trampa más común"
+ Todo manejador registrado con `when()` se invoca como `handler(aggregate, event)`: dos argumentos. Un método ligado como `self._on_opened` tiene firma `(self, event)`, lo que lo convierte en un invocable de un solo argumento desde el exterior. Pasar un método ligado directamente provoca un `TypeError` en tiempo de ejecución. El patrón usado a lo largo de este capítulo es una lambda de una sola línea —`lambda agg, evt: agg._on_opened(evt)`— que es correctamente de dos argumentos y mantiene la lógica real en un método privado donde puede probarse unitariamente y comprobarse de tipos de forma independiente.
+
+---
+
+## El agregado con event sourcing
+
+En el Capítulo 6, `Wallet` mantenía `_balance: Money` como estado de Python directo: `deposit` le sumaba, `withdraw` le restaba. En la versión con event sourcing, el agregado nunca muta sus propios campos directamente. Cada cambio de estado está mediado por un evento de dominio: el método de comportamiento *aplica* el evento, el manejador del evento *actualiza los campos* y el `EventStore` persiste el evento. Al cargar, el repositorio reproduce todos los eventos almacenados a través de los mismos manejadores, reconstruyendo el estado en memoria evento a evento.
+
+Esta doble indirección —aplicar, luego manejar— es la mecánica central del event sourcing. Impone una disciplina estricta: cada transición de estado se registra exactamente una vez como un evento, y el estado actual del agregado siempre es demostrable a partir de su historia.
+
+!!! note "Jerga: agregado, manejador, fold"
+ Un **agregado** es una única frontera de consistencia: un objeto que posee un conjunto de estado relacionado y las reglas que lo mantienen válido. `LedgerAccount` es un agregado: su saldo y su regla de descubierto viven juntos. Un **manejador** (a veces llamado *apply-handler*) es la pequeña función que toma un evento y actualiza los campos del agregado. Un **fold** es el término de la programación funcional para recorrer una lista y acumular un resultado elemento a elemento; reproducir un flujo de eventos hasta obtener un saldo es exactamente un fold sobre la lista de eventos. Verás "fold" usado como sinónimo de "el manejador se ejecuta sobre cada evento".
+
+**Constructor sin argumentos.** `LedgerAccount.__init__` no toma argumentos. Esto es obligatorio porque `EventSourcedRepository` llama a la fábrica como `LedgerAccount()` y luego asigna `.id` antes de reproducir el flujo. Nunca construyas un libro mayor nuevo pasando argumentos a `__init__`; en su lugar, llama al classmethod `open`.
+
+Construiremos el agregado en cuatro movimientos y luego ejecutaremos las pruebas unitarias para demostrar que cada invariante se cumple. Lee primero el listado completo y después recorre los pasos que aparecen debajo.
+
+Aquí están los eventos de dominio y el agregado:
+
+::: listing lumen/models/entities/v1/ledger_account.py | Listado 9.1 — LedgerAccount: un agregado con event sourcing que deriva su saldo de la reproducción
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from lumen.interfaces.enums.v1.currency import Currency
+from lumen.models.entities.v1.money import Money
+from pyfly.domain import BusinessRuleViolation
+from pyfly.eventsourcing import AggregateRoot, DomainEvent
+
+
+# --- Domain events — the durable facts of the ledger -----------------
+
+@dataclass
+class LedgerOpened(DomainEvent):
+ """The ledger was opened for an owner in a single currency."""
+ account_id: str = ""
+ owner_id: str = ""
+ currency: str = ""
+
+
+@dataclass
+class Credited(DomainEvent):
+ """Money moved *into* the ledger (a deposit / inbound transfer leg)."""
+ account_id: str = ""
+ amount: int = 0
+ currency: str = ""
+ balance: int = 0
+
+
+@dataclass
+class Debited(DomainEvent):
+ """Money moved *out of* the ledger (withdrawal / outbound transfer leg)."""
+ account_id: str = ""
+ amount: int = 0
+ currency: str = ""
+ balance: int = 0
+
+
+# --- Event-sourced aggregate root ------------------------------------
+
+class LedgerAccount(AggregateRoot):
+ """An event-sourced money-movement ledger.
+
+ Zero-arg constructible so it can serve as the repository's factory —
+ EventSourcedRepository calls LedgerAccount() then assigns .id before
+ replaying the stream. Use the open() classmethod to create new ledgers.
+ """
+
+ def __init__(self) -> None:
+ super().__init__()
+ self.owner_id: str = ""
+ self.currency: Currency = Currency.EUR
+ self.balance: Money = Money.zero(Currency.EUR)
+ # Register apply-handlers. _dispatch calls handler(aggregate, event).
+ # Use a lambda so the callable is two-arg; delegate to a private
+ # method to keep the real logic type-checked and unit-testable.
+ self.when(LedgerOpened, lambda agg, evt: agg._on_opened(evt))
+ self.when(Credited, lambda agg, evt: agg._on_credited(evt))
+ self.when(Debited, lambda agg, evt: agg._on_debited(evt))
+
+ # --- factory ---------------------------------------------------------
+
+ @classmethod
+ def open(
+ cls, account_id: str, owner_id: str, currency: Currency
+ ) -> "LedgerAccount":
+ """Open a new empty ledger; appends LedgerOpened."""
+ if not owner_id.strip():
+ raise BusinessRuleViolation(
+ "ledger-owner-required", "owner_id is required"
+ )
+ account = cls()
+ account.id = account_id
+ account.apply(
+ LedgerOpened(
+ account_id=account_id,
+ owner_id=owner_id,
+ currency=currency.value,
+ )
+ )
+ return account
+
+ # --- commands (validate invariants, then apply) -----------------------
+
+ def credit(self, amount: Money) -> None:
+ """Record money entering the ledger; appends Credited."""
+ self._assert_currency(amount)
+ if not amount.is_positive:
+ raise BusinessRuleViolation(
+ "ledger-credit-positive", "credit amount must be > 0"
+ )
+ new_balance = self.balance.add(amount)
+ self.apply(
+ Credited(
+ account_id=self.id,
+ amount=amount.amount,
+ currency=amount.currency.value,
+ balance=new_balance.amount,
+ )
+ )
+
+ def debit(self, amount: Money) -> None:
+ """Record money leaving; refuses to overdraw. Appends Debited."""
+ self._assert_currency(amount)
+ if not amount.is_positive:
+ raise BusinessRuleViolation(
+ "ledger-debit-positive", "debit amount must be > 0"
+ )
+ remaining = self.balance.subtract(amount)
+ if remaining.is_negative:
+ raise BusinessRuleViolation(
+ "ledger-insufficient-funds",
+ f"cannot debit {amount}; balance is {self.balance}",
+ )
+ self.apply(
+ Debited(
+ account_id=self.id,
+ amount=amount.amount,
+ currency=amount.currency.value,
+ balance=remaining.amount,
+ )
+ )
+
+ # --- apply-handlers (pure folds, shared by apply + replay) -----------
+
+ def _on_opened(self, event: object) -> None:
+ self.owner_id = event.owner_id # type: ignore[attr-defined]
+ self.currency = Currency(event.currency) # type: ignore[attr-defined]
+ self.balance = Money.zero(self.currency)
+
+ def _on_credited(self, event: object) -> None:
+ self.balance = Money(
+ event.balance, Currency(event.currency) # type: ignore[attr-defined]
+ )
+
+ def _on_debited(self, event: object) -> None:
+ self.balance = Money(
+ event.balance, Currency(event.currency) # type: ignore[attr-defined]
+ )
+
+ # --- helpers ---------------------------------------------------------
+
+ def _assert_currency(self, amount: Money) -> None:
+ if amount.currency is not self.currency:
+ raise BusinessRuleViolation(
+ "ledger-currency-mismatch",
+ f"ledger holds {self.currency.value}, "
+ f"got {amount.currency.value}",
+ )
+:::
+
+**Cómo funciona.** `__init__` registra tres manejadores `when()` —uno por clase de evento— cada uno como una lambda de dos argumentos que delega en un método privado. Dentro de los manejadores no ocurre ninguna aritmética; los métodos de comportamiento (`credit`, `debit`) poseen toda la validación y calculan el nuevo estado antes de construir el evento. El manejador simplemente *aplica* el resultado ya calculado.
+
+`account.apply(Credited(...))` hace dos cosas atómicamente: anexa el evento al búfer de eventos pendientes (para que el repositorio pueda persistirlo) y lo despacha de inmediato al manejador de `Credited` (para que `balance` se actualice en memoria). El estado en memoria del agregado es, por tanto, siempre coherente con sus eventos pendientes, incluso antes de guardar.
+
+El método de fábrica `open` llama a `apply(LedgerOpened(...))` en lugar de fijar los campos directamente. Eso es intencionado: si cargaras este agregado desde su flujo de eventos, `LedgerOpened` pasaría por exactamente el mismo manejador `_on_opened` y produciría resultados idénticos. La ruta de escritura y la ruta de reproducción son el mismo código; esa simetría es la garantía de corrección del event sourcing.
+
+El contador `version` empieza en cero y se incrementa después de cada evento despachado. Tras `open`, `account.version == 1`; tras un crédito, `account.version == 2`. Volverás a ver este número cuando el `EventStore` imponga la concurrencia optimista.
+
+**Construyéndolo paso a paso.** El listado resulta denso la primera vez. Aquí está el mismo código como cuatro movimientos deliberados: el orden en el que realmente lo escribirías.
+
+**Paso 1 — Define los hechos duraderos.** Escribe las tres dataclasses de evento (`LedgerOpened`, `Credited`, `Debited`) que extienden `pyfly.eventsourcing.DomainEvent`. Dale a cada campo un valor por defecto (`account_id: str = ""`, `amount: int = 0`). Los valores por defecto no son cosméticos: el repositorio reconstruye estas dataclasses a partir de un payload almacenado, y una dataclass con campos obligatorios no podría reconstruirse cuando un evento antiguo es anterior a un campo más nuevo.
+
+**Paso 2 — Prepara el agregado vacío.** Escribe `__init__` sin parámetros. Inicializa los campos a valores por defecto neutros (`owner_id = ""`, `balance = Money.zero(Currency.EUR)`) y registra un manejador `when()` por clase de evento, cada uno como una lambda de dos argumentos que delega en un método privado. En este punto, el agregado es un lienzo en blanco que sabe cómo reaccionar a los eventos pero no ha visto ninguno.
+
+**Paso 3 — Añade la fábrica y los comandos.** Escribe el classmethod `open` y los métodos `credit` / `debit`. Cada uno valida primero sus invariantes (coincidencia de divisa, importe positivo, sin descubierto), calcula el saldo resultante y solo entonces llama a `self.apply(SomeEvent(...))`. La validación vive en el comando; el manejador nunca valida.
+
+**Paso 4 — Escribe los folds puros.** Escribe `_on_opened`, `_on_credited`, `_on_debited`. Cada uno lee campos del evento y los asigna al agregado: sin aritmética, sin validación, sin excepciones. Como estos mismos tres métodos se ejecutan tanto en la ruta de escritura como en la de reproducción, mantenerlos tontos es lo que garantiza que un libro mayor recargado sea igual al que está en vivo.
+
+!!! tip "Por qué la validación pertenece al comando, no al manejador"
+ El manejador se vuelve a ejecutar cada vez que el agregado se carga desde su historia. Si la validación viviera en el manejador, estarías volviendo a comprobar la regla de descubierto contra datos que ya la pasaron hace años; y peor aún, una regla que *haya cambiado* desde entonces podría rechazar un evento histórico que era perfectamente válido cuando ocurrió. Valida una vez, en la escritura que produce el evento; confía en el evento para siempre después.
+
+`pending_events()` devuelve los eventos encolados desde el último guardado. Las pruebas unitarias accionan el agregado de forma aislada —sin necesidad de repositorio—, lo que hace que la verificación de invariantes sea sencilla:
+
+::: listing tests/test_ledger_event_sourcing.py | Listado 9.2 — Pruebas unitarias: el agregado en aislamiento, comandos e invariantes
+def test_open_emits_ledger_opened_and_starts_empty() -> None:
+ account = LedgerAccount.open(
+ "led-1", owner_id="owner-1", currency=Currency.EUR
+ )
+ assert account.id == "led-1"
+ assert account.owner_id == "owner-1"
+ assert account.currency is Currency.EUR
+ assert account.balance == Money.zero(Currency.EUR)
+ # apply() queued the event for the store and bumped the version.
+ [event] = account.pending_events()
+ assert isinstance(event, LedgerOpened)
+ assert event.account_id == "led-1"
+ assert event.currency == "EUR"
+ assert account.version == 1
+
+
+def test_credit_then_debit_track_the_balance() -> None:
+ account = LedgerAccount.open("led-2", owner_id="o", currency=Currency.EUR)
+ account.credit(Money(1000, Currency.EUR))
+ account.debit(Money(400, Currency.EUR))
+ assert account.balance == Money(600, Currency.EUR)
+ kinds = [type(e).__name__ for e in account.pending_events()]
+ assert kinds == ["LedgerOpened", "Credited", "Debited"]
+ assert account.version == 3
+
+
+def test_debit_cannot_overdraw() -> None:
+ account = LedgerAccount.open("led-3", owner_id="o", currency=Currency.EUR)
+ account.credit(Money(500, Currency.EUR))
+ with pytest.raises(BusinessRuleViolation) as exc:
+ account.debit(Money(501, Currency.EUR))
+ assert exc.value.rule == "ledger-insufficient-funds"
+ # Invariant held: balance unchanged, no Debited event queued.
+ assert account.balance == Money(500, Currency.EUR)
+ assert [type(e).__name__ for e in account.pending_events()] == [
+ "LedgerOpened",
+ "Credited",
+ ]
+:::
+
+**Ejecútalo.** Estas tres pruebas no necesitan ni base de datos ni almacén de eventos: el agregado se sostiene enteramente por sí mismo. Ejecuta solo las pruebas unitarias de este archivo desde la raíz del proyecto Lumen:
+
+```bash
+uv run --extra dev pytest tests/test_ledger_event_sourcing.py -q -k "open or credit or debit or currency"
+```
+
+Deberías ver pasar las pruebas en aislamiento:
+
+```
+.... [100%]
+4 passed, 7 deselected in 0.05s
+```
+
+Los cuatro puntos son las cuatro pruebas que solo usan el agregado (las tres mostradas arriba más una comprobación de divisa no coincidente); las siete pruebas deseleccionadas son las respaldadas por el almacén, que ejecutaremos más adelante. Si ves un `TypeError` sobre el número de argumentos, casi con seguridad has caído en la trampa del método ligado de la advertencia anterior: pasar `self._on_opened` directamente a `when()` en lugar de envolverlo en una lambda de dos argumentos.
+
+**Qué acaba de ocurrir.** Construiste un objeto de dominio que registra *qué cambió* en lugar de *qué es*. `open` emitió un hecho `LedgerOpened` y subió la versión a 1. `credit` y `debit` validaron cada uno su regla, calcularon el nuevo saldo y emitieron un hecho, de modo que un crédito seguido de un débito dejó tres hechos encolados y la versión en 3. Cuando una regla falló (la prueba de descubierto), el comando lanzó antes de llamar a `apply`, así que ningún evento defectuoso entró jamás en el búfer. El saldo en memoria del agregado y su lista de eventos pendientes se mantuvieron perfectamente acompasados, y nada tocó todavía una base de datos.
+
+!!! tip "on_{event_type} como alternativa"
+ En lugar de lambdas `when()`, puedes definir un método en el agregado con el nombre del `event_type` del evento, que es el **nombre de clase** del evento, así que `on_LedgerOpened(self, evt)` (coincidiendo con la dataclass `LedgerOpened`) se descubre automáticamente. Usa `when()` para una sola línea concisa y métodos con nombre para manejadores que necesiten varias sentencias o variables locales. El orden de despacho es: primero el manejador `when()`; luego el método `on_{event_type}`; luego `EventHandlerException` si no existe ninguno.
+
+!!! spring "Equivalencia con Spring"
+ `AggregateRoot` + `apply()` + `when()` es el equivalente en PyFly de `@Aggregate` + `AggregateLifecycle.apply(event)` + `@EventSourcingHandler` de Axon Framework. Axon usa el descubrimiento de manejadores guiado por anotaciones (`@EventSourcingHandler`); PyFly usa el registro con `when()` o la convención de métodos `on_*`. La mecánica de reproducción —cargar eventos del almacén, llamar a los mismos manejadores, reconstruir el estado— es idéntica en ambos frameworks.
+
+---
+
+## El EventStore
+
+El agregado sabe cómo producir y reproducir eventos. El **`EventStore`** sabe cómo persistirlos y recuperarlos. Estas son preocupaciones deliberadamente separadas: el agregado es lógica de negocio pura sin E/S; el almacén de eventos es E/S pura sin lógica de negocio.
+
+El protocolo `EventStore` expone dos operaciones centrales:
+
+- **`append(aggregate_id, aggregate_type, events, *, expected_version)`** — persiste un lote de eventos para un flujo. Lanza `ConcurrencyError` si la versión real del flujo no coincide con `expected_version`.
+- **`load(aggregate_id, *, after_sequence=0)`** — devuelve la secuencia ordenada de objetos `StoredEventEnvelope` desde el primer evento (o desde `after_sequence`) hasta el más reciente.
+
+**`InMemoryEventStore`** es la implementación lista para usar. Al igual que `InMemoryEventBus` en el Capítulo 8, se ejecuta enteramente en el proceso, sin E/S: ideal para desarrollo y pruebas. Un despliegue de producción cambia a un adaptador respaldado por PostgreSQL o EventStoreDB.
+
+`EventSourcedRepository` envuelve el `EventStore` y gestiona el ciclo completo de guardado/carga. El código de aplicación nunca llama al almacén directamente; llama a `repo.save(aggregate)` y `repo.load(aggregate_id)`, y el repositorio se encarga del resto.
+
+Todas las importaciones provienen de dos ubicaciones:
+
+```python
+# Core event-sourcing types — all in the base package
+from pyfly.eventsourcing import (
+ AggregateRoot,
+ DomainEvent,
+ EventStore,
+ InMemoryEventStore,
+ SnapshotStore,
+ InMemorySnapshotStore,
+ StoredEventEnvelope,
+)
+# The generic repository lives in the .repository submodule
+from pyfly.eventsourcing.repository import EventSourcedRepository
+```
+
+---
+
+## El LedgerAccountRepository
+
+Subclasificas `EventSourcedRepository` por dos motivos: para pasar la fábrica concreta y el almacén de snapshots a través de un único constructor bien nombrado y —opcionalmente— para sobrescribir `_envelope_to_event` de modo que los eventos reproducidos sean dataclasses tipadas reales en lugar de la bolsa de atributos genérica que produce la clase base.
+
+!!! note "Jerga: sobre (envelope) y bolsa de atributos"
+ Un `StoredEventEnvelope` es la *forma de cable* que el almacén persiste: envuelve el `payload` del evento (un dict plano) junto con los campos de contabilidad que el almacén necesita —`aggregate_id`, `aggregate_type`, `sequence`, `event_type`, `event_id` y `occurred_at`—. Piénsalo como el sobre con la dirección alrededor de la carta. Una **bolsa de atributos** es el objeto genérico de sustitución que crea el repositorio base al cargar si no sobrescribes nada: un objeto sin nombre con las claves del payload fijadas como atributos. Funciona —los manejadores solo leen atributos—, pero no es tu dataclass `Credited` real, así que no puede comprobarse de tipos ni probarse con `isinstance`. La sobrescritura de abajo cambia ese anonimato por la cosa real.
+
+Construye el repositorio en tres pequeños pasos.
+
+**Paso 1 — Asigna los nombres de clase de vuelta a las dataclasses.** El almacén registra el `event_type` de cada evento como la cadena del nombre de clase (`"Credited"`). Para reconstruir la dataclass real al cargar, necesitas una búsqueda de esa cadena a la clase. Eso es `_EVENT_TYPES`: una entrada por tipo de evento.
+
+**Paso 2 — Subclasifica y reenvía el constructor.** `LedgerAccountRepository.__init__` toma el `store` (y un almacén de snapshots opcional `snapshots`) y reenvía todo a `super().__init__`, suministrando `factory=LedgerAccount` para que la clase base sepa cómo crear un agregado en blanco.
+
+**Paso 3 — Sobrescribe `_envelope_to_event` para una reproducción tipada.** Busca el `event_type` en `_EVENT_TYPES`, filtra el payload almacenado quedándote con los campos que la dataclass realmente declara (usando `__dataclass_fields__`) y construye el evento real. Recurre a la hidratación genérica de la clase base para cualquier tipo de evento que no reconozcas.
+
+::: listing lumen/models/repositories/ledger_repository.py | Listado 9.3 — LedgerAccountRepository: reproducción tipada mediante _envelope_to_event
+from __future__ import annotations
+
+from typing import ClassVar
+
+from lumen.models.entities.v1.ledger_account import (
+ Credited,
+ Debited,
+ LedgerAccount,
+ LedgerOpened,
+)
+from pyfly.eventsourcing import (
+ DomainEvent,
+ EventStore,
+ SnapshotStore,
+ StoredEventEnvelope,
+)
+from pyfly.eventsourcing.repository import EventSourcedRepository
+
+# Map a stored event_type (the event class name) back to its dataclass.
+_EVENT_TYPES: dict[str, type[DomainEvent]] = {
+ LedgerOpened.__name__: LedgerOpened,
+ Credited.__name__: Credited,
+ Debited.__name__: Debited,
+}
+
+
+class LedgerAccountRepository(EventSourcedRepository[LedgerAccount]):
+ """Loads/saves LedgerAccount aggregates via the event store."""
+
+ SNAPSHOT_INTERVAL: ClassVar[int] = 100
+
+ def __init__(
+ self,
+ store: EventStore,
+ *,
+ snapshots: SnapshotStore | None = None,
+ ) -> None:
+ super().__init__(
+ store,
+ factory=LedgerAccount,
+ snapshots=snapshots,
+ snapshot_interval=self.SNAPSHOT_INTERVAL,
+ )
+
+ @staticmethod
+ def _envelope_to_event(envelope: StoredEventEnvelope) -> object:
+ """Rebuild the concrete event dataclass from a stored payload.
+
+ Overrides the base-class generic hydration so that replayed events
+ are the same dataclasses the aggregate applied on the write side.
+ Unknown fields are ignored for forward-compatibility.
+ """
+ event_cls = _EVENT_TYPES.get(envelope.event_type)
+ if event_cls is None:
+ # Fall back to generic hydration for unrecognised event types.
+ return EventSourcedRepository._envelope_to_event(envelope)
+ field_names = {
+ f.name for f in event_cls.__dataclass_fields__.values()
+ }
+ kwargs = {
+ k: v for k, v in envelope.payload.items() if k in field_names
+ }
+ return event_cls(**kwargs)
+:::
+
+**Cómo funciona.** `super().__init__(store, factory=LedgerAccount, ...)` indica al repositorio base que llame a `LedgerAccount()` —sin argumentos— para crear un agregado en blanco, asigne `.id` y luego reproduzca el flujo. `factory` acepta cualquier invocable sin argumentos; pasar la propia clase (`factory=LedgerAccount`) es equivalente a `factory=lambda: LedgerAccount()`.
+
+La sobrescritura de `_envelope_to_event` busca la cadena `event_type` almacenada en `_EVENT_TYPES`, usa `__dataclass_fields__` para filtrar el payload a campos conocidos y reconstruye la dataclass real. Los campos desconocidos se descartan silenciosamente —compatibilidad hacia delante en la práctica—: si una versión futura del evento añade un campo que el manejador antiguo no reconoce, el libro mayor sigue reproduciéndose en lugar de fallar. El repliegue a la clase base, al final, gestiona cualquier tipo de evento que el repositorio no conozca.
+
+!!! note "Jerga: compatibilidad hacia delante (forward-compatibility)"
+ Un lector es **compatible hacia delante** cuando puede ingerir datos escritos por una versión *más nueva* de sí mismo sin romperse: simplemente ignora las partes que no entiende. Descartar campos desconocidos del payload es exactamente eso: un escritor v2 puede añadir un `reference_code` a `Credited`, y un lector v1 aún en ejecución sigue plegando el evento correctamente en lugar de fallar ante el campo sorpresa.
+
+**Ejecútalo.** Una prueba enfocada ejercita esta sobrescritura directamente: le entrega al repositorio un sobre construido a mano y comprueba que recibe de vuelta una dataclass `Credited` real:
+
+```bash
+uv run --extra dev pytest tests/test_ledger_event_sourcing.py -q -k typed_replay
+```
+
+```
+. [100%]
+1 passed, 10 deselected in 0.05s
+```
+
+**Qué acaba de ocurrir.** Cableaste la frontera de persistencia sin escribir una sola línea de SQL ni de código de almacenamiento. El `EventSourcedRepository` base ya sabe cómo drenar los eventos pendientes en `save` y reproducirlos en `load`; tu subclase añadió solo dos cosas: una fábrica sin argumentos para poder construir un libro mayor en blanco, y un `_envelope_to_event` tipado para que los eventos que reproduce sean exactamente las mismas dataclasses que el agregado emitió en la ruta de escritura. Esa simetría es de lo que se trata todo: la escritura y la reproducción ejecutan código idéntico.
+
+---
+
+## Guardar, cargar y la prueba de la reproducción
+
+Este es el momento hacia el que todo el capítulo ha estado avanzando: demostrar que un libro mayor recargado a partir de nada más que sus eventos almacenados es igual al libro mayor en vivo que los produjo. La prueba de abajo lo hace en dos mitades.
+
+**Paso 1 — Escribe un libro mayor y persístelo.** Abre un libro mayor, acredítalo, debítalo y luego llama a `repo.save(account)`. El repositorio drena los tres eventos pendientes al almacén y limpia el búfer del agregado.
+
+**Paso 2 — Recarga desde cero y compara.** Crea un repositorio *completamente nuevo* y llama a `load("acct-1")`. Nada en esta segunda mitad comparte memoria con el primer objeto. El saldo del libro mayor recuperado solo puede ser correcto si se recalculó reproduciendo el flujo almacenado, que es exactamente la prueba que queremos.
+
+La siguiente prueba demuestra el ciclo completo de guardado y carga:
+
+::: listing tests/test_ledger_event_sourcing.py | Listado 9.4 — Prueba estelar: el saldo sobrevive a una recarga por reproducción
+@pytest.mark.asyncio
+async def test_balance_survives_reload_by_replay(
+ event_store: InMemoryEventStore,
+) -> None:
+ # 1. Open + credit + debit, then persist the pending events.
+ account = LedgerAccount.open(
+ "acct-1", owner_id="owner-7", currency=Currency.EUR
+ )
+ account.credit(Money(2500, Currency.EUR)) # +25.00
+ account.debit(Money(1000, Currency.EUR)) # -10.00 -> 15.00
+ assert account.balance == Money(1500, Currency.EUR)
+
+ repo = LedgerAccountRepository(event_store)
+ await repo.save(account)
+ # After committing, the aggregate has no pending events left.
+ assert account.pending_events() == []
+
+ # 2. Reconstruct from a *fresh* repository + aggregate. Nothing here
+ # carries the in-memory object's state — load() rebuilds the
+ # LedgerAccount purely by replaying the stored event stream.
+ fresh_repo = LedgerAccountRepository(event_store)
+ recovered = await fresh_repo.load("acct-1")
+
+ assert recovered is not None
+ assert recovered is not account
+ assert recovered.owner_id == "owner-7"
+ assert recovered.currency is Currency.EUR
+ # The load-by-replay proof: balance recomputed from events, not stored.
+ assert recovered.balance == Money(1500, Currency.EUR)
+ # Three events were folded in: LedgerOpened, Credited, Debited.
+ assert recovered.version == 3
+ # A reconstructed aggregate has nothing pending — it was not "changed".
+ assert recovered.pending_events() == []
+:::
+
+**Cómo funciona.** `repo.save(account)` llama a `store.append("acct-1", "LedgerAccount", pending_events, expected_version=0)`. Los tres eventos —`LedgerOpened`, `Credited`, `Debited`— se serializan en objetos `StoredEventEnvelope` y se escriben en el flujo en orden; el búfer de eventos pendientes del agregado se limpia a continuación.
+
+`fresh_repo.load("acct-1")` llama a `store.load("acct-1")`, recibe los tres sobres, construye un `LedgerAccount()` en blanco mediante la fábrica, asigna `.id = "acct-1"` y pasa cada sobre por `_envelope_to_event` antes de reproducirlo. Los manejadores `_on_opened`, `_on_credited` y `_on_debited` se ejecutan en secuencia; tras los tres, `recovered.balance.amount` vale `1500`: el mismo valor que mantenía el agregado en vivo, calculado sin ningún estado compartido entre los dos objetos.
+
+La prueba usa *dos instancias de repositorio independientes* que comparten el mismo almacén en memoria —`repo` para la escritura, `fresh_repo` para la lectura—. Esto demuestra que la reproducción, no la identidad de objetos dentro del proceso, es la fuente de verdad.
+
+**Ejecútalo.** Ejecuta la prueba estelar más la prueba de inspección del flujo en crudo que la sigue:
+
+```bash
+uv run --extra dev pytest tests/test_ledger_event_sourcing.py -q -k "reload_by_replay or immutable_event_stream"
+```
+
+```
+.. [100%]
+2 passed, 9 deselected in 0.05s
+```
+
+**Qué acaba de ocurrir.** Demostraste que el event sourcing realmente funciona de principio a fin. El primer libro mayor vivió solo en memoria; tras `save`, sus hechos vivieron solo en el almacén. Un segundo objeto de libro mayor, no relacionado, reconstruyó entonces el mismo saldo exacto de 15,00 EUR plegando esos hechos de vuelta a través de los mismos manejadores, sin ninguna columna de saldo almacenada a la vista. La versión aterrizó en 3 (un evento por `LedgerOpened` / `Credited` / `Debited`), y el libro mayor reconstruido no tenía nada pendiente porque cargar no es lo mismo que cambiar.
+
+El almacén de eventos también expone los sobres en crudo para inspección, útil para auditorías y pruebas:
+
+::: listing tests/test_ledger_event_sourcing.py | Listado 9.5 — El almacén contiene el flujo de eventos inmutable
+@pytest.mark.asyncio
+async def test_store_holds_the_immutable_event_stream(
+ event_store: InMemoryEventStore,
+) -> None:
+ account = LedgerAccount.open(
+ "acct-2", owner_id="o", currency=Currency.GBP
+ )
+ account.credit(Money(800, Currency.GBP))
+ account.debit(Money(300, Currency.GBP))
+ await LedgerAccountRepository(event_store).save(account)
+
+ envelopes = await event_store.load("acct-2")
+ assert [e.event_type for e in envelopes] == [
+ "LedgerOpened", "Credited", "Debited"
+ ]
+ assert [e.sequence for e in envelopes] == [1, 2, 3]
+ assert all(e.aggregate_type == "LedgerAccount" for e in envelopes)
+ # The Debited payload carries the post-debit running balance.
+ assert envelopes[2].payload["balance"] == 500
+ assert await event_store.latest_version("acct-2") == 3
+:::
+
+!!! note "Argumento de fábrica"
+ `EventSourcedRepository` acepta un invocable `factory` que produce una instancia de agregado en blanco. La fábrica debe devolver un agregado en su estado inicial de `__init__` —sin eventos aplicados— porque el repositorio aplicará la historia completa él mismo. Pasar una fábrica que construye un agregado ya mutado (por ejemplo, una que llama a `open()` internamente) corromperá la reproducción: `_on_opened` se ejecutaría dos veces, la segunda sobrescribiendo los campos que los eventos reales ya fijaron.
+
+---
+
+## Concurrencia optimista
+
+Dos peticiones concurrentes —un crédito desde la app móvil y un débito automático de comisiones desde un trabajo en segundo plano— pueden cargar el mismo libro mayor en la misma versión, aplicar cada una su propio cambio y luego intentar guardar. Sin una protección de concurrencia, un guardado gana silenciosamente, los eventos del otro se pierden, los números de secuencia colisionan y el saldo resultante es erróneo.
+
+La **concurrencia optimista** previene esto. Antes de anexar eventos nuevos, el `EventStore` compara la versión *actual* del flujo contra la versión *esperada* que el repositorio registró en el momento de la carga. Si coinciden, el anexado prosigue y la versión avanza. Si no coinciden —porque otro escritor ya anexó—, se lanza `ConcurrencyError` y la petición perdedora debe reintentar desde una carga fresca.
+
+!!! note "Jerga: bloqueo optimista frente a pesimista"
+ El bloqueo *pesimista* asume que el conflicto es probable, así que toma un bloqueo por adelantado: ningún otro escritor puede tocar la fila hasta que lo liberes. La concurrencia *optimista* asume que el conflicto es raro, así que no toma ningún bloqueo en absoluto: deja proceder a ambos escritores y solo comprueba, en el momento de guardar, si alguien más cambió el flujo primero. El perdedor reintenta. Para la mayoría de los libros mayores, dos escritores simultáneos sobre la *misma* cuenta son poco habituales, así que la estrategia optimista evita el coste del bloqueo en la ruta sin conflicto, abrumadoramente común.
+
+El `expected_version` lo pasa el repositorio de forma implícita: registra la versión en la que cargó el agregado y se la suministra al almacén al guardar. Nunca gestionas números de versión en el código de aplicación.
+
+La progresión de la versión es determinista: tras un guardado y una recarga, las escrituras posteriores avanzan la versión sin conflicto:
+
+::: listing tests/test_ledger_event_sourcing.py | Listado 9.6 — Continuar anexando tras una recarga: el bloqueo optimista avanza correctamente
+@pytest.mark.asyncio
+async def test_continues_appending_after_a_reload(
+ event_store: InMemoryEventStore,
+) -> None:
+ repo = LedgerAccountRepository(event_store)
+ account = LedgerAccount.open(
+ "acct-3", owner_id="o", currency=Currency.EUR
+ )
+ account.credit(Money(1000, Currency.EUR))
+ await repo.save(account)
+
+ # Reload, mutate again, save again — version must advance, no conflict.
+ reloaded = await repo.load("acct-3")
+ assert reloaded is not None
+ assert reloaded.version == 2
+ reloaded.debit(Money(250, Currency.EUR))
+ await repo.save(reloaded)
+
+ final = await repo.load("acct-3")
+ assert final is not None
+ assert final.balance == Money(750, Currency.EUR)
+ assert final.version == 3
+ assert await event_store.latest_version("acct-3") == 3
+:::
+
+**Cómo funciona.** Tras el primer guardado, el flujo está en la versión 2. Al cargar, `reloaded.version == 2`. `repo.save(reloaded)` anexa con `expected_version=2`; el almacén avanza a 3 y tiene éxito. La carga `final` reproduce los tres eventos y confirma el saldo correcto.
+
+**Ejecútalo.**
+
+```bash
+uv run --extra dev pytest tests/test_ledger_event_sourcing.py -q -k continues_appending
+```
+
+```
+. [100%]
+1 passed, 10 deselected in 0.05s
+```
+
+**Qué acaba de ocurrir.** Un libro mayor recargado recordó su versión, de modo que el siguiente guardado se alineó limpiamente detrás de los eventos ya presentes en el flujo: sin conflicto, números de secuencia en orden, saldo correcto. Esta es la *ruta feliz* de la concurrencia optimista: un escritor cada vez. En el momento en que dos escritores compiten, el `expected_version` del segundo ya no coincidiría y el almacén lanzaría `ConcurrencyError` en su lugar, que es el caso que la advertencia de abajo te dice cómo manejar.
+
+!!! warning "Maneja siempre ConcurrencyError"
+ Cuando dos escritores compiten, el guardado perdedor lanza `ConcurrencyError`. Tu servicio de aplicación debe capturarlo y decidir qué hacer: reintentar el ciclo completo de cargar-mutar-guardar (apropiado para escrituras de baja contención), o exponer un 409 Conflict al llamante (apropiado cuando el llamante debería reenviar con datos frescos). Nunca tragues el error en silencio: un error de concurrencia tragado deja el flujo en un estado inconsistente.
+
+---
+
+## Snapshots
+
+El event sourcing cambia simplicidad de escritura por coste de lectura. Cargar un libro mayor que ha registrado 10 000 movimientos de dinero significa reproducir 10 000 eventos cada vez que se necesita el agregado. Para la mayoría de los libros mayores el flujo se mantiene corto; para cuentas de alta frecuencia puede crecer hasta volverse prohibitivamente largo.
+
+Los **snapshots** abordan esto. Un snapshot es un punto de control serializado del estado del agregado en una versión concreta. Al cargar, el repositorio busca primero el snapshot más reciente, deserializa el estado directamente hasta esa versión y luego reproduce solo los eventos que llegaron después de él. Un snapshot en la versión 9 000 reduce una reproducción de 10 000 eventos a 1 000 eventos.
+
+`InMemorySnapshotStore` almacena los snapshots en memoria. Pásalo a `EventSourcedRepository` junto con el almacén de eventos mediante el argumento clave `snapshots`, exactamente como lo acepta `LedgerAccountRepository.__init__`:
+
+```python
+store = InMemoryEventStore()
+snapshots = InMemorySnapshotStore()
+repo = LedgerAccountRepository(store, snapshots=snapshots)
+```
+
+El repositorio decide cuándo hacer un snapshot automáticamente usando `snapshot_interval` (por defecto `100`, fijado por `LedgerAccountRepository.SNAPSHOT_INTERVAL`). Tras cada `save`, comprueba si la nueva versión del agregado **cruza** un múltiplo de `snapshot_interval`:
+
+```python
+# crosses_interval is True when this batch pushes the stream past a
+# 100-event boundary — e.g., version 95 → 105 crosses the 100 mark.
+crossed = (
+ (aggregate.version // snapshot_interval)
+ > (previous_version // snapshot_interval)
+)
+```
+
+Esta lógica de cruce de intervalo (en lugar de divisibilidad exacta) maneja el caso común en el que un único lote de guardado se sitúa a horcajadas sobre el umbral. Una importación masiva que añade 10 eventos lleva la versión de 95 a 105 y dispara correctamente un snapshot, aunque ni 95 ni 105 sean exactamente divisibles por 100.
+
+La costura del snapshot es inofensiva cuando el flujo es más corto que el intervalo; la siguiente prueba lo demuestra:
+
+::: listing tests/test_ledger_event_sourcing.py | Listado 9.7 — Almacén de snapshots cableado: la recarga sigue produciendo el estado correcto
+@pytest.mark.asyncio
+async def test_snapshot_store_round_trips_the_ledger() -> None:
+ """With a snapshot store wired, reload still yields the right state.
+
+ The ledger's stream is far shorter than the snapshot interval, so this
+ proves the snapshot seam is harmless and the repository falls back
+ to a full replay.
+ """
+ store = InMemoryEventStore()
+ snapshots = InMemorySnapshotStore()
+ repo = LedgerAccountRepository(store, snapshots=snapshots)
+
+ account = LedgerAccount.open(
+ "acct-4", owner_id="o", currency=Currency.EUR
+ )
+ account.credit(Money(5000, Currency.EUR))
+ await repo.save(account)
+
+ recovered = await repo.load("acct-4")
+ assert recovered is not None
+ assert recovered.balance == Money(5000, Currency.EUR)
+:::
+
+**Cómo funciona.** Tras el guardado, el repositorio comprueba: ¿es `version 2 // 100 > 0 // 100`? No; el umbral de snapshot no se ha cruzado, así que no se toma ningún snapshot. La siguiente `load` realiza una reproducción completa de los dos eventos y devuelve el saldo correcto. Una vez que un libro mayor cruza una frontera de 100 eventos, el repositorio serializa el estado del agregado en un sobre de snapshot. La siguiente carga encuentra el snapshot, deserializa directamente hasta esa versión y luego pide al almacén de eventos los eventos con un número de secuencia mayor que la versión del snapshot, reduciendo el coste de reproducción solo al delta.
+
+**Ejecútalo.**
+
+```bash
+uv run --extra dev pytest tests/test_ledger_event_sourcing.py -q -k snapshot_store_round_trips
+```
+
+```
+. [100%]
+1 passed, 10 deselected in 0.05s
+```
+
+**Qué acaba de ocurrir.** Cableaste un almacén de snapshots en el repositorio y el libro mayor aún se recargó correctamente, que es exactamente lo que debería pasar con un flujo corto. Como los dos eventos nunca cruzaron el intervalo de 100 eventos, no se escribió ningún snapshot y la carga recurrió a una simple reproducción completa. Los snapshots son optimización pura: cablear uno no cuesta nada para flujos cortos y rinde discretamente una vez que una cuenta se vuelve de alta frecuencia. Puedes demostrar la corrección de tu libro mayor con el almacén de snapshots presente o ausente: la respuesta es idéntica.
+
+!!! tip "El intervalo de snapshot en producción"
+ Un `snapshot_interval` de 100 es el valor por defecto y un punto de partida sensato. Para libros mayores de alta frecuencia podrías bajarlo; para cuentas que solo cambian unas pocas veces al día, un intervalo más alto reduce el coste de almacenamiento de snapshots. Los snapshots son una optimización, no un requisito de corrección: eliminarlos deja el sistema correcto pero más lento.
+
+---
+
+## Cableado de arranque y autoconfiguración
+
+El módulo `pyfly.eventsourcing` se incluye en el paquete **base** `pyfly`, sin dependencia extra. Habilitarlo lleva dos pasos: fija `pyfly.eventsourcing.enabled: true` en `pyfly.yaml` y anota la aplicación con `@enable_domain_stack`. La autoconfiguración de PyFly registra entonces automáticamente los beans `event_store` y `snapshot_store`.
+
+!!! note "Jerga: bean y autoconfiguración"
+ Un **bean** es sencillamente un objeto que el framework crea una vez y entrega a cualquiera que lo pida: la misma idea de inyección de dependencias que viste en el Capítulo 2. La **autoconfiguración** es una clase de métodos de fábrica productores de beans que PyFly activa *solo cuando se cumple una condición*; aquí, la guarda `@conditional_on_property("pyfly.eventsourcing.enabled", having_value="true")`. Activa ese único indicador de configuración y los beans `event_store` y `snapshot_store` aparecen en el contenedor; déjalo apagado y la maquinaria de event sourcing permanece latente. Nunca llamas tú mismo a la fábrica.
+
+La batería de pruebas lo confirma directamente:
+
+::: listing tests/test_ledger_event_sourcing.py | Listado 9.8 — La autoconfiguración registra los beans del almacén de eventos
+def test_auto_configuration_registers_event_store_beans() -> None:
+ """enable_domain_stack activates this auto-config when
+ pyfly.eventsourcing.enabled=true (set in pyfly.yaml), registering
+ the in-memory event/snapshot stores the ledger repository depends on."""
+ from pyfly.core.config import Config
+ from pyfly.eventsourcing.auto_configuration import (
+ EventSourcingAutoConfiguration,
+ )
+
+ auto = EventSourcingAutoConfiguration()
+ cfg = Config() # empty config -> providers default to "memory"
+ assert isinstance(auto.event_store(cfg), InMemoryEventStore)
+ assert isinstance(auto.snapshot_store(cfg), InMemorySnapshotStore)
+:::
+
+**Ejecútalo.**
+
+```bash
+uv run --extra dev pytest tests/test_ledger_event_sourcing.py -q -k auto_configuration
+```
+
+```
+. [100%]
+1 passed, 10 deselected in 0.05s
+```
+
+**Qué acaba de ocurrir.** Con el indicador `enabled` activado, la autoconfiguración produjo los dos almacenes en memoria de los que depende el repositorio del libro mayor, sin que tú instanciaras ninguno. Observa que los métodos de fábrica toman un argumento `Config`: así es como leen `pyfly.eventsourcing.store.provider` para decidir entre el valor por defecto `memory` y un adaptador respaldado por SQL. Pasar un `Config()` vacío los deja en `memory`, que es lo que la prueba comprueba.
+
+En el código de aplicación, el repositorio se cablea mediante inyección de dependencias:
+
+```python
+# pyfly.yaml
+pyfly:
+ eventsourcing:
+ enabled: true
+```
+
+```python
+# In a service or handler — the beans are injected automatically
+@component
+class LedgerService:
+ def __init__(
+ self,
+ event_store: EventStore,
+ snapshot_store: SnapshotStore,
+ ) -> None:
+ self._repo = LedgerAccountRepository(
+ event_store, snapshots=snapshot_store
+ )
+```
+
+---
+
+## Proyecciones
+
+El almacén de eventos es el sistema de registro, pero la mayoría de las consultas de aplicación —"¿cuál es el saldo actual?", "muéstrame todos los libros mayores del propietario u-42", "¿qué cuentas están por encima de 1 000 EUR?"— no deben reproducir flujos de eventos en cada lectura. Deberían golpear un modelo de lectura precalculado: una tabla optimizada para consultas, mantenida sincronizada por un proceso en segundo plano que consume el flujo de eventos.
+
+Ese proceso en segundo plano es una **proyección**. Una proyección se suscribe al flujo de eventos y actualiza un modelo de lectura cada vez que llega un evento relevante. PyFly proporciona `FunctionProjection` y `ProjectionRunner` en `pyfly.eventsourcing.projection`:
+
+!!! note "Jerga: modelo de lectura, proyección, modelo de escritura"
+ El **modelo de escritura** es la cara que has construido hasta ahora: el agregado y su flujo de eventos, optimizado para *registrar los cambios correctamente*. Un **modelo de lectura** es una copia separada de los datos, con forma de consulta, optimizada para *responder preguntas rápido* (una fila por libro mayor con su saldo actual, digamos). Una **proyección** es el proceso que mantiene un modelo de lectura sincronizado reproduciendo el flujo de eventos sobre él. Esta separación —un modelo para escrituras, otro para lecturas— es el patrón **CQRS** que viste en el Capítulo 7, ahora respaldado por un flujo de eventos en lugar de una tabla relacional.
+
+- **`FunctionProjection(name, handler_fn)`** — envuelve una función asíncrona que recibe un `StoredEventEnvelope` y actualiza el modelo de lectura.
+- **`ProjectionRunner(projection, store)`** — acciona la proyección iterando el `EventStore` en orden de número de secuencia y llamando al manejador para cada sobre.
+
+Una proyección se arma en tres piezas. **Paso 1**: escribe el manejador, una función `async` que toma un sobre y actualiza el modelo de lectura (aquí un `dict` plano; en producción, una tabla de base de datos). **Paso 2**: envuélvelo, `FunctionProjection("balance_ledger", handler)` convierte la función pelada en una proyección con nombre. **Paso 3**: acciónalo, `ProjectionRunner(projection, store)` conecta la proyección al almacén de eventos y, al arrancar, le alimenta cada sobre almacenado en orden de secuencia.
+
+Aquí hay una `BalanceLedgerProjection` que construye un modelo de lectura de saldos a partir del flujo de eventos:
+
+::: listing lumen/eventsourcing/balance_projection.py | Listado 9.9 — BalanceLedgerProjection: un modelo de lectura construido a partir del flujo de eventos
+from __future__ import annotations
+
+import asyncio
+
+from pyfly.eventsourcing import InMemoryEventStore
+from pyfly.eventsourcing.projection import FunctionProjection, ProjectionRunner
+
+
+# The in-process read model — in production, replace with a DB table.
+_balance_store: dict[str, dict] = {}
+
+
+async def _handle_envelope(envelope: object) -> None:
+ """Update the balance read model for each ledger event."""
+ event_type: str = getattr(envelope, "event_type", "")
+ payload: dict = getattr(envelope, "payload", {})
+
+ if event_type == "LedgerOpened":
+ _balance_store[payload["account_id"]] = {
+ "account_id": payload["account_id"],
+ "owner_id": payload.get("owner_id", ""),
+ "balance_cents": 0,
+ "currency": payload.get("currency", ""),
+ }
+ elif event_type in ("Credited", "Debited"):
+ account_id = payload["account_id"]
+ if account_id in _balance_store:
+ _balance_store[account_id]["balance_cents"] = (
+ payload["balance"]
+ )
+
+
+def build_projection(store: InMemoryEventStore) -> ProjectionRunner:
+ projection = FunctionProjection("balance_ledger", _handle_envelope)
+ return ProjectionRunner(projection, store)
+
+
+async def demo_projection(store: InMemoryEventStore) -> None:
+ runner = build_projection(store)
+ # start() launches a background polling task and returns immediately;
+ # the read model is populated asynchronously as the loop drains the
+ # store. Poll until the projection has caught up, then stop the runner.
+ await runner.start()
+ try:
+ for _ in range(50):
+ if "led-001" in _balance_store:
+ break
+ await asyncio.sleep(0.05)
+ finally:
+ await runner.stop()
+
+ balance = _balance_store.get("led-001", {})
+ print(f"Balance read model: {balance}")
+:::
+
+**Cómo funciona.** `FunctionProjection("balance_ledger", _handle_envelope)` envuelve el manejador asíncrono. `ProjectionRunner(projection, store)` lo enlaza con el `InMemoryEventStore`. `await runner.start()` lanza una tarea de sondeo en segundo plano y retorna *de inmediato*; no se bloquea hasta que el almacén se drena. La tarea itera sobre `store.stream_all(...)`, llamando a `_handle_envelope` para cada sobre nuevo en orden y avanzando un cursor (`_last_event_id`) para no reprocesar nunca un evento. Como la población ocurre asíncronamente, la demo sondea `_balance_store` hasta que la proyección se ha puesto al día, y luego llama a `await runner.stop()` para detener el bucle antes de leer el resultado. Solo después de que la proyección haya procesado los sobres, `_balance_store` refleja el estado actual de cada libro mayor del almacén.
+
+La proyección es deliberadamente sin estado: solo lee `envelope.event_type` y `envelope.payload`. No se carga ningún agregado; no se llama a ningún repositorio. El modelo de lectura es barato de reconstruir: detén el runner, limpia `_balance_store`, llama a `start()` de nuevo. Esta propiedad de reconstruir-desde-la-historia es exclusiva del event sourcing; los modelos de almacenamiento de estado ya han descartado la historia.
+
+En producción, `_handle_envelope` escribiría en una base de datos real (PostgreSQL, Redis, Elasticsearch). El `ProjectionRunner` persistiría un cursor en una tabla de checkpoints para que los reinicios continúen desde el último evento procesado en lugar de reproducir todo desde el principio. El patrón de proyección es idéntico con independencia del almacenamiento subyacente.
+
+!!! note "Proyecciones frente a listeners del Capítulo 8"
+ La `BalanceProjection` del Capítulo 8 (Listado 8.4) era un suscriptor `@event_listener` sobre el `InMemoryEventBus`: reaccionaba a los eventos a medida que se publicaban. La `BalanceLedgerProjection` de este capítulo lee directamente del `EventStore`: puede reproducir la historia desde el principio, ponerse al día con el presente y continuar consumiendo eventos futuros. Ambas mantienen un modelo de lectura de saldos; la proyección sobre el almacén de eventos es reconstruible desde la historia; el listener del bus no lo es.
+
+---
+
+## El outbox transaccional
+
+Considera la llamada `repo.save(account)` del Listado 9.4: tres eventos se anexan al almacén de eventos. Ahora supón que esos eventos también necesitan llegar a un broker externo: Kafka, RabbitMQ, otro microservicio. El enfoque ingenuo es llamar a `broker.publish(envelope)` inmediatamente después de `store.append(...)`. Pero ¿y si el proceso se cae entre el anexado y la publicación? Los eventos están en el almacén, pero el broker nunca los recibió. El servicio aguas abajo nunca se enteró del crédito.
+
+El patrón del **outbox transaccional** resuelve esto. En lugar de publicar directamente, encolas el evento en un *outbox*: un intermediario duradero. El outbox persiste el evento junto a los eventos del agregado en la misma operación de almacén. Un trabajador en segundo plano separado (el *relay*) drena el outbox y reenvía cada evento al broker con semántica de al menos una vez. Si el relay se cae, reinicia y reintenta desde el último evento no confirmado.
+
+El `TransactionalOutbox` de PyFly vive en `pyfly.eventsourcing`. Acepta una corrutina `publish` y un límite `max_attempts`, y expone dos métodos:
+
+- **`enqueue(envelope)`** — añade un sobre de evento al outbox para su entrega.
+- **`start()`** — arranca el bucle de relay en segundo plano que llama a `publish(envelope)` por cada elemento encolado, reintentando hasta `max_attempts` veces en caso de fallo.
+
+::: listing lumen/eventsourcing/outbox_demo.py | Listado 9.10 — TransactionalOutbox: entrega fiable de al menos una vez a un broker
+from __future__ import annotations
+
+from pyfly.eventsourcing import (
+ InMemoryEventStore,
+ InMemorySnapshotStore,
+ TransactionalOutbox,
+)
+from pyfly.eventsourcing.repository import EventSourcedRepository
+
+from lumen.models.entities.v1.ledger_account import LedgerAccount
+from lumen.models.entities.v1.money import Money
+from lumen.interfaces.enums.v1.currency import Currency
+
+
+# Simulated broker: collect published envelopes for inspection.
+_published: list = []
+
+
+async def _broker_publish(envelope: object) -> None:
+ _published.append(envelope)
+
+
+async def demo_outbox() -> None:
+ store = InMemoryEventStore()
+ repo = LedgerAccountRepository(store)
+ outbox = TransactionalOutbox(publish=_broker_publish, max_attempts=5)
+ await outbox.start()
+
+ account = LedgerAccount.open("led-004", "u-11", Currency.EUR)
+ account.credit(Money(5000, Currency.EUR))
+ await repo.save(account)
+
+ # Enqueue the stored envelopes into the outbox.
+ for envelope in await store.load("led-004"):
+ await outbox.enqueue(envelope)
+
+ # The relay has delivered all envelopes to the broker.
+ assert len(_published) == 2 # LedgerOpened + Credited
+:::
+
+**Cómo funciona.** El outbox mantiene los sobres en una cola duradera. `_broker_publish` es la función de entrega; sustitúyela por tu productor de Kafka o RabbitMQ. `max_attempts=5` significa que el relay reintenta una entrega fallida hasta cinco veces antes de mandar el sobre a la cola de mensajes muertos (dead-letter).
+
+La garantía crítica: el outbox se drena de forma independiente de la petición que creó los eventos. Si el proceso se cae después de `repo.save(account)` pero antes de que el outbox termine de vaciarse, el siguiente reinicio retoma desde donde lo dejó y completa la entrega. El estado del agregado en el almacén de eventos ya es correcto; solo se interrumpió la entrega del lado del broker.
+
+!!! warning "Al menos una vez, no exactamente una vez"
+ El outbox garantiza que cada evento llega al broker *al menos una vez*. Si el relay entrega un evento y luego se cae antes de marcarlo como confirmado, el evento se entrega de nuevo en el reinicio. Tus consumidores del broker —y los servicios aguas abajo— deben ser idempotentes: usa el `envelope.event_id` como clave de deduplicación. El Capítulo 10 muestra cómo los adaptadores de consumidor de Kafka y RabbitMQ manejan la deduplicación automáticamente.
+
+El outbox transaccional es el puente entre el event sourcing y la mensajería orientada a eventos. El Capítulo 10 retoma exactamente aquí, presentando los productores de Kafka y los exchanges de RabbitMQ y mostrando cómo configurar el relay para una entrega fiable a cada uno.
+
+!!! spring "Equivalencia con Spring"
+ El patrón del outbox transaccional es bien conocido en el ecosistema Spring con el mismo nombre. El `EventPublicationRegistry` de Spring Modulith y el `@TransactionalEventListener(phase = AFTER_COMMIT)` de Spring aproximan la misma garantía: el evento se registra de forma duradera antes de ser despachado. El almacén de eventos de Axon Server cumple un papel similar para las aplicaciones basadas en Axon: los eventos se escriben primero en el almacén, y los grupos de proyección / procesadores de eventos los consumen con garantías de al menos una vez desde el registro almacenado. El `TransactionalOutbox` de PyFly es el equivalente portable de ese patrón, sin requerir un servidor de eventos dedicado.
+
+---
+
+## Avanzado: upcasting y multitenencia
+
+Dos preocupaciones aparecen en todo sistema con event sourcing de larga vida. Esta sección las presenta brevemente; un tratamiento completo queda fuera del alcance de este libro.
+
+### Upcasting
+
+Los eventos son inmutables. Una vez que un evento `Credited` se escribe en el flujo, no puedes volver atrás y añadir un campo `reference_code`. Pero los requisitos del producto cambian: dentro de tres meses el equipo de finanzas necesitará un código de referencia en cada crédito para la conciliación. Los eventos nuevos lo incluyen; los antiguos no.
+
+Un **upcaster** es una función que transforma una forma de evento antigua en la forma actual durante la reproducción. El `EventStore` lo llama de forma transparente; el agregado nunca ve la forma antigua. Registras upcasters por tipo de evento y por versión:
+
+```python
+# Conceptual — upcaster API varies by adapter
+def upcast_credited_v1(payload: dict) -> dict:
+ payload.setdefault("reference_code", "LEGACY")
+ return payload
+```
+
+El upcaster se ejecuta cuando el `EventStore` carga un evento cuya versión de esquema es inferior a la actual. Los datos antiguos se vuelven legibles sin una migración; los datos nuevos se escriben en el esquema actual.
+
+### Multitenencia
+
+Cuando varios inquilinos (tenants) comparten el mismo almacén de eventos, los eventos deben acotarse por inquilino para que un inquilino nunca pueda leer ni reproducir el flujo de otro. PyFly te da dos costuras para esto; ninguna vive en `EventSourcedRepository` (su constructor toma solo `store`, `factory`, `snapshots` y `snapshot_interval`: no hay parámetro `tenant_id`).
+
+La primera costura está en el **propio sobre**. `StoredEventEnvelope` lleva un campo dedicado `tenant_id`, y `StoredEventEnvelope.of(...)` lo acepta como argumento clave:
+
+```python
+envelope = StoredEventEnvelope.of(
+ aggregate_id="led-001",
+ aggregate_type="LedgerAccount",
+ sequence=0,
+ event=Credited(...),
+ tenant_id="tenant-A",
+)
+```
+
+El adaptador SQL lo persiste como una columna `tenant_id` en `pyfly_event_store`, de modo que un almacén consciente de inquilinos puede filtrar cada consulta por ella. Esto mantiene limpio el `aggregate_id` a la vez que particiona el registro por inquilino.
+
+La segunda costura es un **patrón que implementas tú mismo**: prefija cada `aggregate_id` con el identificador del inquilino —`"tenant-A::led-001"` en lugar de `"led-001"`— cuando llames a `repo.save` y `repo.load`. Un envoltorio fino consciente de inquilinos alrededor del repositorio puede aplicar y retirar el prefijo para que el código de aplicación nunca lo vea:
+
+```python
+class TenantLedgerRepository:
+ def __init__(self, repo: LedgerAccountRepository, tenant_id: str) -> None:
+ self._repo = repo
+ self._prefix = f"{tenant_id}::"
+
+ async def load(self, account_id: str) -> LedgerAccount | None:
+ return await self._repo.load(self._prefix + account_id)
+```
+
+Las proyecciones deben acotar sus modelos de lectura de forma similar: normalmente incluyendo `tenant_id` como columna en la tabla del modelo de lectura y filtrando por ella en el momento de la consulta.
+
+!!! note "Elegir event sourcing"
+ El event sourcing añade complejidad operativa: upcasters, gestión de snapshots, procedimientos de reconstrucción de proyecciones, monitorización del relay del outbox. Elígelo deliberadamente para dominios donde la auditabilidad y las consultas de viaje en el tiempo son requisitos de primera clase: libros mayores financieros, historiales médicos, registros de cadena de suministro. Para dominios con mucho CRUD donde lo único que importa es el estado actual, el almacenamiento de estado es más simple y suficiente.
+
+---
+
+## Ejecuta el capítulo entero
+
+Ejecutaste cada pieza de forma aislada a medida que la construías. Ahora ejecuta la batería completa del libro mayor para confirmar que todo sigue pasando en conjunto:
+
+```bash
+uv run --extra dev pytest tests/test_ledger_event_sourcing.py -q
+```
+
+```
+........... [100%]
+11 passed in 0.06s
+```
+
+Once puntos: las cuatro pruebas de aislamiento del agregado, la prueba estelar de recarga por reproducción, la inspección del flujo en crudo, la sobrescritura de reproducción tipada, la comprobación de concurrencia tras la recarga, el round-trip del snapshot, la búsqueda de libro mayor desconocido y la comprobación del cableado de autoconfiguración. Con todas en verde, el agregado `LedgerAccount` y su repositorio están completos y son correctos.
+
+---
+
+## Lo que construiste {.recap}
+
+El `LedgerAccount` de Lumen es ahora un libro mayor totalmente orientado a eventos que coexiste con el `Wallet` con estado almacenado del Capítulo 6.
+
+Las dos bases de agregado están separadas por diseño: `Wallet` extiende `pyfly.domain.AggregateRoot` y almacena su saldo en una fila de base de datos; `LedgerAccount` extiende `pyfly.eventsourcing.AggregateRoot` y deriva su saldo de un flujo de eventos inmutable. La distinción no es cosmética: la maquinaria de event sourcing (`apply`, `replay`, `when`) vive solo en la clase base de eventsourcing.
+
+Los tres eventos de dominio —`LedgerOpened`, `Credited`, `Debited`— son `dataclass`es que extienden `pyfly.eventsourcing.DomainEvent`, cada uno con valores por defecto en sus campos para que el repositorio pueda reconstruirlos desde un payload almacenado. Los manejadores se registran con `when()` como lambdas de dos argumentos que delegan en métodos privados; la trampa del método ligado a evitar es que un método ligado ya es de un solo argumento desde el exterior, lo que provoca un `TypeError` en el momento del despacho.
+
+`LedgerAccountRepository` es una subclase fina de `EventSourcedRepository[LedgerAccount]` que pasa la fábrica sin argumentos y sobrescribe `_envelope_to_event` para hidratar dataclasses concretas durante la reproducción. La prueba estelar lo confirmó: tras abrir, acreditar y debitar un libro mayor, un repositorio y un agregado completamente nuevos reconstruyeron el saldo correcto puramente reproduciendo el flujo almacenado, sin columna de saldo almacenada, sin estado compartido.
+
+El contador `version` acciona la protección de concurrencia optimista: el almacén rechaza cualquier `append` cuyo `expected_version` no coincida con la versión real del flujo, forzando al escritor perdedor a reintentar desde una carga fresca. `InMemorySnapshotStore` resultó inofensivo cuando el flujo es más corto que el intervalo de snapshot y acelerará la reproducción una vez que un libro mayor cruce el umbral.
+
+`BalanceLedgerProjection` usó `FunctionProjection` y `ProjectionRunner` para mantener un modelo de lectura de saldos rápido a partir del flujo de eventos en crudo, uno que puede reconstruirse desde la historia en cualquier momento, a diferencia del enfoque de listener del bus del Capítulo 8. `TransactionalOutbox` conectó luego el almacén de eventos con el mundo del broker, encolando eventos para una entrega de al menos una vez y reintentando en caso de fallo, de modo que ningún hecho se pierda silenciosamente entre el almacén y los consumidores aguas abajo. El Capítulo 10 retoma exactamente ahí, presentando los adaptadores de Kafka y RabbitMQ a los que apunta el relay del outbox.
+
+---
+
+## Pruébalo tú mismo {.exercises}
+
+1. **Reproduce hasta un punto en el tiempo.** Aplica diez créditos de 100 céntimos cada uno a un `LedgerAccount`. Luego carga el agregado manualmente llamando a `await store.load("led-X")`, filtra los sobres a los que tengan `sequence <= 5` y reproduce solo esos a través de un `LedgerAccount()` nuevo. Comprueba que el saldo resultante es igual a 400 céntimos (cuatro créditos después de la apertura) en lugar de 1 000 céntimos (diez créditos). Esta es la "consulta de viaje en el tiempo" que los modelos de almacenamiento de estado no pueden ofrecer.
+
+2. **Implementa un evento `Transferred` y un guardado de doble agregado.** Añade un `Transferred(DomainEvent)` con los campos `source_id: str`, `target_id: str`, `amount: int`, `currency: str`. Añade un método `transfer_to(target: LedgerAccount, amount: Money) -> None` a `LedgerAccount` que llame a `self.debit(amount)` y `target.credit(amount)` en secuencia, y luego aplique un evento `Transferred` sobre `self`. Cablea una corrutina `demo_transfer` que abra dos libros mayores, acredite 10 000 céntimos en el primero, transfiera 3 000 céntimos al segundo, guarde ambos agregados de forma independiente, recargue ambos desde el almacén y compruebe que los saldos son 7 000 y 3 000 céntimos respectivamente.
+
+3. **Añade una `OwnerLedgerProjection`.** Escribe una segunda `FunctionProjection` llamada `owner_ledger` cuyo manejador mantenga un `dict[str, list[dict]]` que asigne cada `owner_id` a una lista cronológica de registros de transacción. Cada registro debe incluir `event_type`, `amount` (del payload para los eventos `Credited`/`Debited`) y el número de secuencia del sobre. Aliméntala con el mismo `InMemoryEventStore`, abre tres libros mayores para el mismo propietario, realiza una mezcla de créditos y débitos, arranca el runner de la proyección y comprueba que la lista de transacciones del propietario tiene el número correcto de entradas en el orden correcto.
diff --git a/book/manuscript-es/10-messaging.md b/book/manuscript-es/10-messaging.md
new file mode 100644
index 00000000..66ab2c96
--- /dev/null
+++ b/book/manuscript-es/10-messaging.md
@@ -0,0 +1,1102 @@
+Capítulo 10
+
+# Mensajería con Kafka y RabbitMQ {.chtitle}
+
+::: figure art/openers/ch10.svg |
+
+El servicio de monederos de Lumen es genuinamente orientado a eventos. Los comandos fluyen a través de manejadores tipados; los eventos de dominio se publican en un bus en proceso; los oyentes reaccionan de forma independiente sin acoplarse a la ruta de escritura. En el Capítulo 8 construiste `WalletAuditListener`, un servicio que reacciona a los eventos `WalletOpened`, `FundsDeposited` y `FundsWithdrawn` sin conocer los manejadores de comandos. En el Capítulo 9 fuiste más allá, almacenando esos eventos *como la fuente de la verdad* para que cada saldo histórico se pueda calcular desde primeros principios.
+
+Hay un límite que ninguno de los dos capítulos cruzó: la red. `InMemoryEventBus` vive dentro del proceso de Python. En el momento en que otro servicio —un futuro `PaymentsService` que liquide transferencias, o un `NotificationsService` que envíe alertas push— necesite reaccionar a los hechos de Lumen, necesitas un **broker de mensajería** (message broker): un componente de infraestructura independiente que almacena eventos de forma duradera, los enruta a suscriptores en otros procesos y los reproduce cuando un consumidor se reinicia tras una caída.
+
+Este capítulo lleva la base orientada a eventos de Lumen al otro lado de ese límite. Verás cómo PyFly envuelve la complejidad de Apache Kafka y RabbitMQ tras una única abstracción limpia —`MessageBrokerPort`— de modo que el código de aplicación nunca sepa qué broker se está ejecutando debajo. Publicarás los eventos de monedero de Lumen en topics reales, los consumirás con el decorador `@message_listener`, elegirás el formato de serialización adecuado para tus requisitos de evolución de esquema, gestionarás los mensajes envenenados con colas de mensajes muertos integradas en el decorador y protegerás tu servicio frente a caídas del broker con cortacircuitos (circuit breakers) y reintentos.
+
+Al final del capítulo, los eventos de integración de Lumen fluyen a través de los límites de proceso, listos para los servicios de la Parte IV que los consumirán.
+
+Lo construiremos gradualmente, una pieza cada vez. Cada funcionalidad viene con un recorrido numerado, el comando exacto que ejecutar y la salida que deberías esperar ver. Si has seguido el hilo desde el Capítulo 8, ya tienes `EventPublisher` conectado a los manejadores de comandos del monedero y el `WalletAuditListener` reaccionando en proceso; este capítulo está verificado contra PyFly v26.6.110 y el ejemplo de Lumen ubicado en `samples/lumen`. Nada de lo que hay aquí requiere un clúster de Kafka o RabbitMQ en ejecución para seguir adelante: PyFly incluye un broker en memoria que satisface el mismo contrato, de modo que puedes leer, ejecutar y probar cada listado antes de tocar siquiera Docker.
+
+!!! note "La jerga, en lenguaje sencillo"
+ Un puñado de términos se repiten en este capítulo. Un **broker de mensajería** es un servidor independiente (Kafka o RabbitMQ) que almacena mensajes y los entrega a otros procesos. Un **topic** es un canal con nombre en el broker; los publicadores escriben en él y los suscriptores leen de él. Un **productor** (o *publicador*) coloca mensajes en un topic; un **consumidor** (u *oyente*) los retira y reacciona. Un **grupo de consumidores** es una etiqueta que permite que varias copias del mismo servicio compartan el trabajo, de modo que cada mensaje se gestione una sola vez. Un **serializador** convierte un objeto de Python en los `bytes` en bruto que el broker almacena; un **deserializador** convierte esos bytes de nuevo en un objeto en el otro lado. Una **cola de mensajes muertos** (DLQ) es un área de retención para mensajes que no pudieron procesarse. Un **adaptador** es el controlador concreto del broker que se oculta tras la interfaz `MessageBrokerPort`. Ten presentes estos ocho; el resto del capítulo trata, sobre todo, de conectarlos.
+
+---
+
+## Una abstracción, muchos brokers
+
+### Por qué importa una abstracción
+
+Antes de escribir una línea de código de Kafka o RabbitMQ, vale la pena preguntarse: ¿por qué introduce PyFly una capa de abstracción en absoluto? Tanto `aiokafka` como `aio-pika` exponen APIs asíncronas perfectamente usables. La respuesta es la misma razón por la que dependes de `EventPublisher` en lugar de `InMemoryEventBus`: la abstracción es lo que te permite intercambiar infraestructura sin tocar la lógica de negocio.
+
+Sin una abstracción, cada servicio que produce o consume un mensaje importa tipos específicos de Kafka o de RabbitMQ. Cambiar de broker —o ejecutar Kafka en producción y un broker en memoria en CI— significa cambiar rutas de importación, firmas de constructores y código repetitivo de bucles de consumo en cada archivo afectado. Con `MessageBrokerPort`, el cambio es una modificación de YAML. Los oyentes y publicadores que componen tu lógica de negocio nunca cambian.
+
+La abstracción también da frutos en las pruebas. `InMemoryMessageBroker` satisface el protocolo del port. Inyéctalo allí donde se espera un `MessageBrokerPort` y escribe pruebas rápidas y deterministas sin dependencia de Docker. El Capítulo 16 lo concreta.
+
+### El protocolo MessageBrokerPort
+
+**`MessageBrokerPort`** es un `@runtime_checkable Protocol`. Úsalo como anotación de tipo en todo tu código; llama a `isinstance(obj, MessageBrokerPort)` en tiempo de ejecución si necesitas verificar que un bean inyectado satisface el contrato.
+
+El protocolo define cuatro métodos:
+
+```python
+from pyfly.messaging import MessageBrokerPort
+
+class MessageBrokerPort(Protocol):
+ async def publish(
+ self,
+ topic: str,
+ value: bytes,
+ *,
+ key: bytes | None = None,
+ headers: dict[str, str] | None = None,
+ ) -> None: ...
+
+ async def subscribe(
+ self,
+ topic: str,
+ handler: MessageHandler,
+ group: str | None = None,
+ ) -> None: ...
+
+ async def start(self) -> None: ...
+
+ async def stop(self) -> None: ...
+```
+
+**Los cuatro métodos:**
+
+`publish` envía un único mensaje al topic con nombre. `value` son bytes en bruto: el protocolo deja la serialización deliberadamente en tus manos; codifica la carga útil antes de llamar a `publish` y decodifícala dentro de tu manejador. `key` y `headers` son de tipo keyword-only, de modo que quien llama no pueda transponerlos por accidente. `key` dirige la asignación de particiones de Kafka para las garantías de orden; RabbitMQ lo ignora. `headers` transportan metadatos transversales como `event-type` e identificadores de correlación.
+
+`subscribe` registra un `MessageHandler` asíncrono para un topic. El parámetro opcional `group` se corresponde con los grupos de consumidores de Kafka y las colas de consumidores en competencia de RabbitMQ. Despliega tres instancias de un servicio, todas suscribiéndose con el mismo `group`, y solo una instancia procesa cada mensaje. Omite `group` para una semántica de difusión —cada suscriptor recibe cada mensaje—, lo cual resulta útil para analíticas que necesitan una copia de cada evento.
+
+`start` crea las conexiones y comienza a consumir. Registra todas las suscripciones *antes* de llamar a `start`, y luego llámalo una sola vez durante el arranque de la aplicación.
+
+`stop` drena los mensajes en vuelo y cierra las conexiones de forma limpia. El ciclo de vida de la aplicación de PyFly llama a `stop` automáticamente durante el apagado, así que rara vez necesitarás invocarlo a mano.
+
+### La dataclass Message
+
+Cada manejador recibe un **`Message`**: una dataclass congelada que transporta el sobre completo de un mensaje recibido:
+
+```python
+from pyfly.messaging import Message
+
+msg = Message(
+ topic="wallet.events",
+ value=b'{"wallet_id": "w-001", "amount": 5000}',
+ key=b"w-001",
+ headers={"event-type": "FundsDeposited"},
+)
+```
+
+| Campo | Tipo | Valor por defecto | Descripción |
+|---|---|---|---|
+| `topic` | `str` | obligatorio | El topic o cola en el que llegó el mensaje. |
+| `value` | `bytes` | obligatorio | La carga útil en bruto. La decodificas dentro de tu manejador. |
+| `key` | `bytes \| None` | `None` | Clave de partición o enrutamiento. Kafka la usa para la asignación de particiones. |
+| `headers` | `dict[str, str]` | `{}` | Metadatos de cadena adjuntados por el publicador. |
+
+La dataclass está congelada: una vez que el broker te entrega un `Message`, sus campos son inmutables, seguros para pasar a través de límites asíncronos sin copia defensiva e inmunes a la mutación accidental dentro de los manejadores.
+
+### Kafka frente a RabbitMQ: elegir el broker adecuado
+
+Antes de sumergirnos en la configuración, ayuda entender dónde encaja cada broker. La tabla siguiente resume las concesiones clave; ninguna de las dos opciones es universalmente correcta.
+
+::: figure art/figures/10-messaging.svg | Figura 10.1 — MessageBrokerPort se sitúa entre el código de aplicación y los adaptadores de broker.
+
+| Dimensión | Apache Kafka | RabbitMQ |
+|---|---|---|
+| **Modelo** | Registro de confirmación (commit log) distribuido; los consumidores mantienen su propio offset | Broker de mensajería; los mensajes se eliminan de la cola tras la confirmación |
+| **Retención** | Configurable (de días a indefinida); los consumidores pueden reproducir desde cualquier offset | Los mensajes se eliminan al entregarse; colas de mensajes muertos para los fallidos |
+| **Rendimiento** | Millones de mensajes/segundo; optimizado para streaming | Decenas de miles/segundo; optimizado para enrutamiento de tareas |
+| **Orden** | Garantizado dentro de una partición (productores con clave) | Garantizado dentro de una sola cola (FIFO) |
+| **Grupos de consumidores** | Balanceo de carga nativo a nivel de partición | Colas de consumidores en competencia; un mensaje por consumidor |
+| **Evolución de esquema** | Funciona bien con Avro/Protobuf + Schema Registry | Funciona bien con JSON; el acoplamiento de esquema es responsabilidad del usuario |
+| **Cuándo elegirlo** | Streaming de eventos, registros de auditoría, reproducción, alto rendimiento | Colas de tareas, patrones RPC, enrutamiento por mensaje con bindings complejos |
+| **Extra de PyFly** | `uv add "pyfly[kafka]"` | `uv add "pyfly[rabbitmq]"` |
+
+Para Lumen, Kafka es el ajuste natural: los eventos de monedero forman un flujo ordenado por monedero, merece la pena reproducirlos cuando un nuevo consumidor se conecta y, con el tiempo, alimentarán analíticas de alto rendimiento. Los ejemplos de este capítulo muestran ambos adaptadores de forma intercambiable: desde la perspectiva de tu código, la elección es un detalle de configuración.
+
+!!! note "Instalar ambos adaptadores"
+ Si quieres dar soporte a cualquiera de los dos brokers en una sola
+ instalación, `uv add "pyfly[eda]"` incorpora tanto `aiokafka` como
+ `aio-pika`. La autoconfiguración selecciona entonces Kafka si `aiokafka`
+ es importable, RabbitMQ si `aio_pika` es importable, y recurre al broker
+ en memoria si ninguno está presente.
+
+---
+
+## Configurar los adaptadores
+
+Conectar un broker a Lumen es una tarea de configuración, no de codificación. Añades un extra al proyecto, agregas un bloque `pyfly.messaging` a `pyfly.yaml` y PyFly hace el resto: construye el adaptador correcto y lo registra bajo el bean `MessageBrokerPort`, de modo que cualquier cosa que pida ese port reciba inyectado el broker en ejecución. Empezaremos con el broker que no necesita infraestructura alguna y luego pasaremos a Kafka y RabbitMQ.
+
+!!! note "Activa la mensajería con una sola clave"
+ En la v26.6.110, el subsistema de mensajería solo se conecta por sí mismo
+ cuando la clave `pyfly.messaging.provider` está **presente** en tu
+ configuración. Sin clave no hay bean `MessageBrokerPort`: un deliberado
+ "desactivado por defecto" para que una app sin mensajería no necesite
+ incorporar ninguna biblioteca de broker. Una vez establecida la clave, su
+ valor (`"memory"`, `"kafka"`, `"rabbitmq"` o `"auto"`) selecciona el
+ adaptador. Las claves complementarias viven bajo el mismo bloque:
+ `pyfly.messaging.kafka.bootstrap-servers` y
+ `pyfly.messaging.rabbitmq.url`.
+
+### Empieza con el broker en memoria
+
+El `pyfly.yaml` de Lumen ya se ejecuta sobre el bus EDA en memoria del Capítulo 8. Para poner en marcha la abstracción de *mensajería* sin levantar Docker, añade una única línea `provider`.
+
+**Paso 1 — Habilitar el broker en memoria.** Abre `pyfly.yaml` y añade un bloque `messaging` bajo `pyfly`:
+
+```yaml
+pyfly:
+ messaging:
+ provider: "memory"
+```
+
+**Paso 2 — Añadir un oyente para que haya algo que despertar.** Coloca el oyente independiente del Listado 10.4 (unas páginas más adelante) en `src/lumen/messaging/payments_consumer.py`. En el arranque, PyFly descubre la función marcada y la suscribe por ti: no escribes ninguna llamada a `subscribe()`.
+
+!!! tip "Ejecútalo"
+ Arranca la app. El broker en memoria no necesita ningún servidor externo,
+ así que esto funciona en un portátil sin nada instalado:
+
+ ```bash
+ uv run pyfly run
+ ```
+
+ El banner de arranque informa de la versión del framework y el puerto
+ enlazado (`pyfly.server.port`, `8080` por defecto en la v26.6.110):
+
+ ```
+ :: PyFly Framework :: (v26.06.110) (Python 3.13.13)
+ app=lumen version=1.0.0 ... started_in=0.42s port=8080
+ ```
+
+ No se imprime nada más todavía: no se ha publicado ningún mensaje. Eso es
+ lo esperado: el broker está en ejecución y el oyente está suscrito,
+ esperando. Las secciones siguientes le darán eventos que transportar.
+
+**Qué acaba de ocurrir.** Una línea de YAML cambió el bean `MessageBrokerPort` de "no presente" a un broker en memoria funcional, y el framework autosuscribió tu `@message_listener` a él durante el arranque. Sin Kafka, sin RabbitMQ, sin Docker; y, sin embargo, *exactamente el mismo código* se ejecutará contra un broker real en cuanto cambies esa única línea a `"kafka"`. Esa propiedad de cambiar-sin-recompilar es todo el sentido de la abstracción.
+
+### Kafka
+
+Cuando estés listo para un broker real, añade `pyfly[kafka]` a tu proyecto y apunta el provider a Kafka.
+
+**Paso 1 — Instalar el extra de Kafka.** Esto incorpora `aiokafka`, el controlador asíncrono que envuelve el `KafkaAdapter` de PyFly:
+
+```bash
+uv add "pyfly[kafka]"
+```
+
+**Paso 2 — Declarar el broker en `pyfly.yaml`.** Cambia el provider y enumera tus brokers:
+
+```yaml
+pyfly:
+ messaging:
+ provider: "kafka"
+ kafka:
+ bootstrap-servers: "kafka-1:9092,kafka-2:9092"
+```
+
+Eso es todo lo que PyFly necesita para autoconfigurar un `KafkaAdapter` y registrarlo como el bean `MessageBrokerPort`. (`bootstrap-servers` es una lista separada por comas de pares `host:port` —las direcciones de uno o más brokers del clúster—; el cliente descubre el resto a partir de cualquiera de ellos.) Para la mayoría de los servicios el YAML es suficiente; si necesitas opciones avanzadas de productor, construye el adaptador manualmente como un `@bean` dentro de una clase `@configuration`.
+
+### RabbitMQ
+
+```yaml
+pyfly:
+ messaging:
+ provider: "rabbitmq"
+ rabbitmq:
+ url: "amqp://user:password@rabbitmq-host:5672/"
+```
+
+`RabbitMQAdapter` usa por defecto un exchange directo duradero llamado `"pyfly"`. Para personalizar el nombre del exchange, construye el adaptador manualmente:
+
+::: listing lumen/messaging/config.py | Listado 10.1 — Nombre de exchange de RabbitMQ personalizado mediante @bean
+from pyfly.container import configuration, bean
+from pyfly.messaging import MessageBrokerPort
+from pyfly.messaging.adapters.rabbitmq import RabbitMQAdapter
+
+
+@configuration
+class BrokerConfig:
+ """Wire up the message broker bean."""
+
+ @bean
+ def broker(self) -> MessageBrokerPort:
+ return RabbitMQAdapter(
+ url="amqp://user:password@rabbitmq-host:5672/",
+ exchange_name="lumen-events",
+ )
+:::
+
+**Cómo funciona.** `@configuration` marca la clase como una fábrica que el contenedor de DI llama durante el arranque. `@bean` sobre `broker` le indica al contenedor que llame a `broker()` una vez, almacene en caché el resultado y lo inyecte allí donde se solicite `MessageBrokerPort`. Cualquier `@service` que declare `MessageBrokerPort` en su constructor recibe esta instancia automáticamente, sin necesidad de importar `RabbitMQAdapter` en la clase consumidora.
+
+### Autodetección
+
+Cuando `provider` es `"auto"`, PyFly sondea los paquetes instalados en orden y elige el primer broker que encuentra:
+
+| Prioridad | Biblioteca comprobada | Adaptador seleccionado |
+|---|---|---|
+| 1 | `aiokafka` | `KafkaAdapter` |
+| 2 | `aio_pika` | `RabbitMQAdapter` |
+| 3 | *(reserva)* | `InMemoryMessageBroker` |
+
+`provider: "memory"` es distinto de `"auto"`: *siempre* selecciona el broker en memoria con independencia de lo que esté instalado, que es exactamente lo que quieres en las pruebas. Un `provider: "kafka"` o `"rabbitmq"` explícito omite el sondeo por completo y exige que la biblioteca de ese adaptador esté presente.
+
+El patrón práctico es un YAML por entorno. Establece `provider: "memory"` en `pyfly-test.yaml` y `provider: "kafka"` en `pyfly-prod.yaml`, y cada ejecución de prueba y de producción usará el adaptador apropiado sin cambios de código.
+
+!!! tip "Ejecútalo"
+ Puedes confirmar qué adaptador seleccionó PyFly sin enviar un solo
+ mensaje. Con la mensajería habilitada, arranca la app y busca la línea del
+ broker en el registro de arranque:
+
+ ```bash
+ uv run pyfly run
+ ```
+
+ ```
+ pyfly.messaging provider=memory broker=InMemoryMessageBroker started
+ ```
+
+ Cambia `provider` a `"kafka"` (con `pyfly[kafka]` instalado y un broker
+ accesible) y reinicia; la misma línea informa ahora de
+ `broker=KafkaAdapter`. El código de negocio que publica y consume no
+ cambió: solo lo hizo el YAML.
+
+---
+
+## Publicar eventos de integración
+
+### De eventos en proceso a eventos de integración
+
+En el Capítulo 8, los manejadores de comandos de Lumen drenaban el búfer de eventos de la raíz de agregado `Wallet` con `wallet.clear_events()` y publicaban cada evento de dominio a través de `EventPublisher`. `WalletAuditListener` se suscribía usando `@event_listener` y reaccionaba dentro del mismo proceso.
+
+El patrón de **evento de integración** cruza el límite de proceso. Mientras que un *evento de dominio* describe lo que ocurrió dentro de un agregado —un hecho privado, disponible para los oyentes del mismo proceso—, un evento de integración es una representación pública y depurada del mismo hecho: diseñada para consumidores externos, estable entre versiones y serializada a bytes para su transporte por un broker.
+
+Para Lumen, el evento de integración de un depósito transporta solo lo que un consumidor externo necesita: el identificador del monedero, el importe en unidades menores, el código de moneda y el saldo resultante. No expone los detalles internos de implementación del agregado.
+
+### Cómo drena Lumen los eventos hacia el broker
+
+Lumen separa el puente de publicación de los manejadores de comandos, de modo que cada manejador publique eventos de forma idéntica. `publish_domain_events` (en `lumen/core/services/wallets/event_publishing.py`) itera los eventos drenados, convierte cada dataclass congelada en un dict y llama a `EventPublisher.publish`:
+
+```python
+# lumen/core/services/wallets/event_publishing.py (real Lumen code)
+from pyfly.eda import EventPublisher
+from pyfly.domain import DomainEvent
+
+async def publish_domain_events(
+ publisher: EventPublisher,
+ events: Iterable[DomainEvent],
+) -> None:
+ for event in events:
+ payload = dataclasses.asdict(event)
+ payload.setdefault("event_type", event.event_type)
+ await publisher.publish(
+ destination="wallet.events",
+ event_type=event.event_type, # "WalletOpened" / "FundsDeposited" / …
+ payload=payload,
+ )
+```
+
+La firma de `EventPublisher.publish` es:
+
+```python
+async def publish(
+ self,
+ destination: str,
+ event_type: str,
+ payload: dict[str, Any],
+ headers: dict[str, str] | None = None,
+) -> None: ...
+```
+
+`destination` es el nombre lógico del canal (`"wallet.events"`). `event_type` es el nombre de la clase del evento de dominio —`"WalletOpened"`, `"FundsDeposited"` o `"FundsWithdrawn"`—, que es exactamente lo que filtran los suscriptores `@event_listener`.
+
+Cada manejador de comandos conecta `EventPublisher` mediante el constructor y llama a `publish_domain_events` tras persistir:
+
+::: listing lumen/core/services/wallets/deposit_funds_handler.py | Listado 10.2 — DepositFundsHandler drena eventos mediante EventPublisher
+from __future__ import annotations
+
+from lumen.core.services.wallets.deposit_funds_command import DepositFunds
+from lumen.core.services.wallets.event_publishing import publish_domain_events
+from lumen.models.entities.v1.money import Money
+from lumen.models.repositories.wallet_repository import WalletRepository
+from pyfly.container import service
+from pyfly.cqrs import CommandHandler, command_handler
+from pyfly.domain import AggregateNotFound
+from pyfly.eda import EventPublisher
+
+
+@command_handler
+@service
+class DepositFundsHandler(CommandHandler[DepositFunds, int]):
+ """Credit funds to an existing wallet; returns the new balance."""
+
+ def __init__(
+ self,
+ repository: WalletRepository,
+ events: EventPublisher,
+ ) -> None:
+ super().__init__()
+ self._repository = repository
+ self._events = events
+
+ async def do_handle(self, command: DepositFunds) -> int:
+ wallet = await self._repository.find(command.wallet_id)
+ if wallet is None:
+ raise AggregateNotFound("Wallet", command.wallet_id)
+
+ wallet.deposit(Money(
+ amount=command.amount, # integer minor units (e.g. 5000 = €50.00)
+ currency=wallet.currency,
+ ))
+ await self._repository.add(wallet)
+
+ # Drain pending events and forward them to the EDA bus.
+ await publish_domain_events(
+ self._events, wallet.clear_events()
+ )
+ return wallet.balance.amount
+:::
+
+**Decisiones de diseño clave:**
+
+`events: EventPublisher` es el port, no el adaptador. El contenedor de DI inyecta el bus que esté configurado —en memoria en las pruebas, un bus respaldado por broker en producción—. Este manejador nunca menciona Kafka ni RabbitMQ.
+
+`command.amount` es el depósito en unidades menores enteras (p. ej., `5000` significa 50,00 € para un monedero en EUR). El evento de dominio `FundsDeposited` registra el mismo campo `amount`, más `currency` (una cadena como `"EUR"`) y `balance` (el nuevo saldo en unidades menores).
+
+`wallet.clear_events()` drena la lista de eventos pendientes del agregado y los devuelve. Llamarlo *después* de `repository.add` garantiza que los eventos describan un hecho que persistió. Publicar antes de guardar crearía eventos fantasma: hechos sobre cosas que nunca ocurrieron.
+
+Los eventos de dominio emitidos durante un depósito son instancias de:
+
+```python
+@dataclass(frozen=True)
+class FundsDeposited(DomainEvent):
+ wallet_id: str = ""
+ amount: int = 0 # integer minor units
+ currency: str = "" # e.g. "EUR"
+ balance: int = 0 # new balance after deposit, minor units
+```
+
+Cuando `publish_domain_events` publica este evento, `event_type` es el nombre de la clase `"FundsDeposited"`, *no* una cadena con puntos como `"wallet.fundsdeposited"`.
+
+### Publicar un evento de integración directamente en el broker
+
+Cuando un servicio independiente que se ejecuta en un proceso distinto necesita recibir los eventos de monedero de Lumen, el bus EDA debe estar respaldado por un adaptador de broker real. La carga útil que circula por el cable es el mismo dict que ven los oyentes en proceso. Un `OutboxRelay` dedicado (tratado en la sección de resiliencia) o un `EventPublisher` respaldado por broker se encargan del transporte.
+
+Ayuda ver la publicación primero en su forma más pequeña posible. El siguiente listado es una sencilla función `async` —sin clase, sin decorador— que toma un `MessageBrokerPort`, construye la carga útil y llama a `publish`. Constrúyela en tres movimientos:
+
+**Paso 1 — Codificar la carga útil a bytes.** `MessageBrokerPort.publish` solo ve `bytes`, así que la función serializa el evento con `json.dumps(...).encode()`. El `.encode()` convierte la cadena JSON en bytes UTF-8 que el broker puede almacenar literalmente.
+
+**Paso 2 — Elegir una clave de partición.** Pasar `key=wallet_id.encode()` le indica a Kafka que enrute cada mensaje de un monedero dado a la misma partición, lo que preserva su orden. (RabbitMQ ignora la clave, así que incluirla es inofensivo en cualquier caso.)
+
+**Paso 3 — Adjuntar la cabecera de tipo de evento.** `headers={"event-type": "FundsDeposited"}` permite a un consumidor decidir si le interesa este mensaje *antes* de deserializar el cuerpo: enrutamiento barato.
+
+::: listing lumen/messaging/deposit_publisher.py | Listado 10.3 — Publicar un evento de integración de monedero en un topic de Kafka
+from __future__ import annotations
+
+import json
+
+from pyfly.messaging import MessageBrokerPort
+
+
+async def publish_deposit_event(
+ broker: MessageBrokerPort,
+ wallet_id: str,
+ amount: int,
+ currency: str,
+ balance: int,
+) -> None:
+ """Encode a FundsDeposited integration event and publish to the topic."""
+ payload = json.dumps({
+ "wallet_id": wallet_id,
+ "amount": amount, # integer minor units
+ "currency": currency, # e.g. "EUR"
+ "balance": balance, # new balance, minor units
+ "event_type": "FundsDeposited",
+ }).encode()
+
+ await broker.publish(
+ "wallet.events",
+ payload,
+ key=wallet_id.encode(),
+ headers={"event-type": "FundsDeposited"},
+ )
+:::
+
+**Decisiones de diseño clave:**
+
+`broker: MessageBrokerPort` es el port, no el adaptador. El contenedor de DI inyecta el adaptador que esté configurado —Kafka en producción, el broker en memoria en las pruebas—.
+
+`key=wallet_id.encode()` es la clave de enrutamiento. En Kafka, todos los mensajes que comparten la misma clave aterrizan en la misma partición, entregándolos a los consumidores en orden de publicación: crítico para un libro mayor donde el depósito antes de la retirada debe preservarse. En RabbitMQ la clave se ignora (el enrutamiento usa el binding del exchange), así que este campo es seguro de incluir con independencia del broker en ejecución.
+
+`headers={"event-type": "FundsDeposited"}` usa el nombre de la clase del evento de dominio, no una ruta con puntos como `"wallet.fundsdeposited"`. Los consumidores pueden inspeccionar el tipo de evento sin decodificar la carga útil, lo cual es útil para el enrutamiento y el filtrado sin una deserialización completa.
+
+**Qué acaba de ocurrir.** Cruzaste el límite de proceso. El mismo hecho `FundsDeposited` que `WalletAuditListener` consumió en proceso en el Capítulo 8 son ahora bytes en un topic, direccionables por cualquier servicio que se conecte al broker; y la función que los colocó ahí no nombra ningún broker, solo el port `MessageBrokerPort`. Intercambia el adaptador configurado y este código permanece sin cambios.
+
+!!! warning "Publica después de guardar, no antes"
+ Drena y publica siempre los eventos *después* de
+ `repository.add(wallet)`. Si el guardado falla, ningún mensaje llega al
+ broker y los consumidores externos nunca ven un hecho que nunca persistió.
+ El patrón de outbox transaccional (donde la fila del outbox y la fila del
+ agregado se escriben en la misma transacción de base de datos) ofrece la
+ garantía atómica más fuerte para producción; la publicación directa que se
+ muestra aquí es un punto de partida razonable para servicios más simples.
+
+---
+
+## Consumir eventos con @message_listener
+
+### El problema del sondeo
+
+Antes de los brokers, los servicios reaccionaban a los cambios de estado de otro servicio sondeando una base de datos compartida o un endpoint REST. El sondeo añade latencia (la reacción espera hasta el siguiente intervalo de sondeo), desperdicia recursos (la mayoría de los sondeos no encuentran nada nuevo) y acopla el consumidor al productor a nivel de API. Un oyente de mensajes elimina los tres problemas: el broker empuja el evento en cuanto está disponible, las conexiones inactivas consumen una CPU insignificante y el consumidor depende solo del esquema del mensaje, no de la API interna del productor.
+
+### Oyentes declarativos con @message_listener
+
+**`@message_listener`** es el decorador de suscripción declarativa. Decora cualquier función o método asíncrono con el topic que debe consumir, y PyFly conecta la suscripción durante el arranque de la aplicación: sin referencia al bus, sin llamada a `subscribe()`, sin gestión del ciclo de vida en tu código.
+
+La firma del decorador es:
+
+```python
+def message_listener(
+ topic: str,
+ group: str | None = None,
+ *,
+ retries: int = 0,
+ retry_delay: float = 0.0,
+ dead_letter_topic: str | None = None,
+) -> ...: ...
+```
+
+| Parámetro | Tipo | Valor por defecto | Descripción |
+|---|---|---|---|
+| `topic` | `str` | obligatorio | El topic en el que escuchar. |
+| `group` | `str \| None` | `None` | Nombre del grupo de consumidores. |
+| `retries` | `int` | `0` | Veces que reinvocar el manejador ante un fallo. |
+| `retry_delay` | `float` | `0.0` | Retardo base (segundos) entre reintentos: el intento N espera `retry_delay * N`. |
+| `dead_letter_topic` | `str \| None` | `None` | Cuando se establece, un mensaje que sigue fallando tras `retries` se republica aquí. |
+
+El primer oyente es una función independiente, la forma más simple. Constrúyela en tres movimientos:
+
+**Paso 1 — Escribir una función asíncrona que tome un `Message`.** Cada oyente recibe un argumento: el sobre congelado `Message` (`topic`, `value`, `key`, `headers`). La función debe ser `async` porque el broker la espera (await).
+
+**Paso 2 — Decorarla con el topic y el grupo.** `@message_listener(topic="wallet.events", group="payments-service")` es la suscripción entera. No hay ninguna llamada a `subscribe()` que escribir ni ningún bus que importar: el decorador marca la función con metadatos que el framework lee en el arranque.
+
+**Paso 3 — Decodificar dentro del manejador.** El cuerpo comprueba la cabecera `event-type` y luego `json.loads(msg.value)` convierte los bytes en bruto de nuevo en un dict. El manejador decide qué le importa; aquí reacciona solo a `FundsDeposited`.
+
+::: listing lumen/messaging/payments_consumer.py | Listado 10.4 — @message_listener sobre una función independiente
+from __future__ import annotations
+
+import json
+
+from pyfly.messaging import Message, message_listener
+
+
+@message_listener(topic="wallet.events", group="payments-service")
+async def on_wallet_event(msg: Message) -> None:
+ """React to every wallet event published to the topic."""
+ event_type = msg.headers.get("event-type", "unknown")
+ payload = json.loads(msg.value)
+
+ if event_type == "FundsDeposited":
+ wallet_id: str = payload["wallet_id"]
+ amount: int = payload["amount"] # minor units
+ currency: str = payload["currency"]
+ print(
+ f"[Payments] Deposit received: "
+ f"wallet={wallet_id} "
+ f"amount={amount} {currency}"
+ )
+:::
+
+**Cómo funciona.** El decorador almacena seis atributos de metadatos en la función envuelta —`__pyfly_message_listener__ = True`, más `__pyfly_listener_topic__`, `__pyfly_listener_group__`, `__pyfly_listener_retries__`, `__pyfly_listener_retry_delay__` y `__pyfly_listener_dlq__`—. Durante el arranque, el framework escanea todos los beans registrados, encuentra las funciones que llevan `__pyfly_message_listener__ = True` y llama a `broker.subscribe(topic, handler, group)` automáticamente. Nunca llamas a `subscribe()` a mano.
+
+`group="payments-service"` coloca al consumidor en un grupo de consumidores. Escala a múltiples instancias del servicio de pagos y solo una procesa cada mensaje: el broker distribuye la carga entre el grupo. Omite `group` para una semántica de difusión en la que cada instancia recibe cada mensaje.
+
+Dentro del manejador, `msg.headers.get("event-type", "unknown")` inspecciona los metadatos del sobre antes de tocar la carga útil. El valor de la cabecera es el nombre de la clase del evento de dominio —`"FundsDeposited"`, `"WalletOpened"` o `"FundsWithdrawn"`—, coincidiendo con lo que Lumen establece en el lado del publicador.
+
+!!! tip "Ejecútalo"
+ Con `provider: "memory"` establecido (sin Docker), este es el viaje de
+ ida y vuelta completo publicar → consumir en un solo lugar. Guarda el
+ fragmento de abajo como `roundtrip.py` y ejecútalo con
+ `uv run python roundtrip.py`:
+
+ ```python
+ import asyncio, json
+ from pyfly.messaging.adapters.memory import InMemoryMessageBroker
+ from lumen.messaging.payments_consumer import on_wallet_event
+
+ async def main() -> None:
+ broker = InMemoryMessageBroker()
+ await broker.subscribe(
+ "wallet.events", on_wallet_event, group="payments-service"
+ )
+ await broker.start()
+ await broker.publish(
+ "wallet.events",
+ json.dumps({
+ "wallet_id": "w-001", "amount": 5000,
+ "currency": "EUR", "balance": 5000,
+ }).encode(),
+ headers={"event-type": "FundsDeposited"},
+ )
+ await asyncio.sleep(0.1) # let the listener run
+ await broker.stop()
+
+ asyncio.run(main())
+ ```
+
+ El oyente imprime la línea que construyó a partir de la carga útil
+ decodificada:
+
+ ```
+ [Payments] Deposit received: wallet=w-001 amount=5000 EUR
+ ```
+
+ Dentro de una app en ejecución *no* escribirías este cableado a mano: el
+ decorador `@message_listener` y el bean del broker configurado hacen el
+ `subscribe`/`start`/`stop` por ti. Este script independiente solo hace
+ visible el viaje de ida y vuelta de forma aislada.
+
+**Qué acaba de ocurrir.** Un mensaje que publicaste en `wallet.events` llegó a una función que nunca conectaste explícitamente a nada. El decorador transportó el topic y el grupo; el broker (aquí en memoria, en producción Kafka) hizo la entrega. Ese es el lado de consumo de la misma abstracción que usaste para publicar, y el cuerpo de la función es agnóstico respecto al broker de arriba abajo.
+
+### Oyentes en clases de servicio
+
+Cuando un oyente necesita colaboradores —un repositorio, otro servicio—, declíralo como un método en una clase `@service`. PyFly inyecta las dependencias a través del constructor y conecta la suscripción del oyente después de que el bean se inicialice. La forma cambia solo ligeramente respecto a la versión independiente:
+
+**Paso 1 — Convertir la clase en un `@service`.** Esto la registra en el contenedor de DI para que el framework pueda tanto inyectar su constructor como descubrir su método oyente.
+
+**Paso 2 — Declarar los colaboradores en el constructor.** Aquí `smtp_client` representa un servicio de correo electrónico o push; el contenedor lo suministra. La función libre del Listado 10.4 no tenía dónde guardar tal dependencia: esa es la razón para recurrir a una clase.
+
+**Paso 3 — Decorar un *método* con `@message_listener`.** La firma gana `self`, pero por lo demás el decorador y el cuerpo son idénticos a la forma de función. Como el bean se crea primero, `self._smtp` está listo para cuando llega un mensaje.
+
+::: listing lumen/messaging/notifications_consumer.py | Listado 10.5 — @message_listener sobre un método de @service con dependencias
+from __future__ import annotations
+
+import json
+
+from pyfly.container import service
+from pyfly.messaging import Message, message_listener
+
+
+@service
+class WalletNotificationConsumer:
+ """Sends push notifications when wallet events arrive via the broker."""
+
+ def __init__(self, smtp_client: object) -> None:
+ # smtp_client would be an injected email/push service.
+ self._smtp = smtp_client
+
+ @message_listener(topic="wallet.events", group="notifications-service")
+ async def on_wallet_event(self, msg: Message) -> None:
+ event_type = msg.headers.get("event-type", "unknown")
+
+ if event_type != "WalletOpened":
+ return
+
+ payload = json.loads(msg.value)
+ owner_id: str = payload.get("owner_id", "")
+ wallet_id: str = payload.get("wallet_id", "")
+ currency: str = payload.get("currency", "")
+
+ print(
+ f"[Notification] Welcome {owner_id}! "
+ f"Your {currency} wallet {wallet_id} is ready."
+ )
+:::
+
+**Cómo funciona.** `@service` registra `WalletNotificationConsumer` en el contenedor de DI. El constructor recibe `smtp_client` mediante inyección. Después de crear el bean, el framework detecta `on_wallet_event` llevando `__pyfly_message_listener__ = True` y lo registra como un oyente de método enlazado (bound-method): `self` ya está capturado, así que cada invocación tiene acceso completo a `self._smtp`.
+
+El retorno temprano cuando `event_type != "WalletOpened"` es una guarda de filtrado. Un único topic (`wallet.events`) transporta múltiples tipos de evento, así que cada oyente filtra los que le interesan. Esto es más simple que mantener un topic separado por tipo de evento, aunque para flujos de muy alto volumen, un topic-por-tipo es una concesión de diseño legítima.
+
+!!! tip "Semántica de grupos de consumidores de un vistazo"
+ Dos servicios con nombres de grupo *distintos* reciben cada uno cada
+ mensaje: el broker entrega una copia a cada grupo. Dos *instancias* del
+ mismo servicio que comparten el *mismo* nombre de grupo comparten la
+ carga: cada mensaje va exactamente a una instancia. Usa grupos distintos
+ para fanout (pagos y notificaciones necesitan ambos el evento); usa el
+ mismo grupo para escalado horizontal (tres instancias del servicio de
+ pagos comparten el trabajo).
+
+---
+
+## Serialización y evolución de esquema
+
+### Por qué bytes, y por qué importa
+
+`MessageBrokerPort.publish` acepta `bytes` en bruto. Esa es una elección deliberada. Un adaptador de broker que forzara un único formato de serialización sería cómodo para los casos simples y doloroso para todo lo demás: la evolución de esquema, los consumidores multilenguaje, los requisitos de cumplimiento y las restricciones de rendimiento empujan en direcciones distintas. Al dejar la serialización en tus manos, PyFly se mantiene al margen.
+
+Vale la pena conocer tres formatos: JSON por su simplicidad, Avro para la evolución respaldada por un registro de esquemas, y Protobuf para entornos críticos en rendimiento o multilenguaje:
+
+| Formato | Legible por humanos | Cumplimiento de esquema | Evolución de esquema | Multilenguaje | Codificación en PyFly |
+|---|---|---|---|---|---|
+| **JSON** | Sí | Opcional | Manual (disciplina del consumidor) | Universal | `json.dumps(...).encode()` |
+| **Avro** | No | Sí (vía registro) | De primera clase (`BACKWARD` / `FORWARD` / `FULL`) | Buena | Biblioteca `fastavro` |
+| **Protobuf** | No | Sí (archivos `.proto`) | De primera clase (numeración de campos) | Excelente | Biblioteca `protobuf` |
+
+### JSON: empieza aquí
+
+La *serialización* es solo el acto de convertir un objeto en memoria en una secuencia plana de bytes que puedes almacenar o enviar, y la *deserialización* es lo inverso. Los tres formatos siguientes difieren únicamente en cuán compactos son esos bytes y con cuánta rigurosidad vigilan la forma de los datos.
+
+JSON es el valor por defecto adecuado. No requiere herramientas más allá de la biblioteca estándar, cualquier lenguaje puede analizarlo y la carga útil es legible en las interfaces de monitorización del broker. El patrón de codificación son dos líneas:
+
+```python
+import json
+
+payload: bytes = json.dumps({
+ "wallet_id": "w-001",
+ "amount": 5000, # integer minor units (€50.00)
+ "currency": "EUR",
+ "balance": 10000, # new balance, minor units
+ "event_type": "FundsDeposited",
+}).encode()
+
+await broker.publish("wallet.events", payload)
+```
+
+Decodificar en el consumidor:
+
+```python
+data: dict = json.loads(msg.value)
+```
+
+La debilidad de JSON es que el esquema no se hace cumplir. Si un publicador añade un campo obligatorio y el consumidor no se ha actualizado, el consumidor se rompe silenciosamente. Para los eventos internos de Lumen, donde productor y consumidor se despliegan juntos, esto es manejable. Para los eventos compartidos con equipos externos o los topics de larga vida, necesitas garantías más fuertes.
+
+### Avro: evolución respaldada por un registro de esquemas
+
+Los esquemas de Avro son documentos JSON que describen la forma de un mensaje. Un Schema Registry (el de Confluent es el más común, pero existen alternativas de código abierto) almacena esos esquemas y hace cumplir las reglas de compatibilidad cuando los productores registran nuevas versiones. La biblioteca `fastavro` codifica y decodifica la carga útil binaria. La ruta de publicación es el mismo `broker.publish(...)` que ya conoces; solo cambia el paso de codificación:
+
+**Paso 1 — Declarar el esquema una vez.** `WALLET_DEPOSITED_SCHEMA` enumera cada campo y su tipo Avro (`string`, `long`). Es una constante a nivel de módulo, de modo que se escribe una vez, no por mensaje.
+
+**Paso 2 — Compilarlo una vez.** `fastavro.parse_schema(...)` se llama en tiempo de importación y el resultado se almacena en caché en `_PARSED`. Analizarlo en cada publicación sería trabajo desperdiciado en la ruta caliente.
+
+**Paso 3 — Codificar y publicar.** `fastavro.schemaless_writer` serializa el registro en un búfer `BytesIO`; `buf.getvalue()` entrega los bytes a `broker.publish` exactamente como hacía la ruta JSON.
+
+::: listing lumen/messaging/avro_publisher.py | Listado 10.6 — Publicar un evento de monedero con codificación Avro
+from __future__ import annotations
+
+import io
+
+import fastavro # type: ignore[import]
+
+from pyfly.messaging import MessageBrokerPort
+
+WALLET_DEPOSITED_SCHEMA = {
+ "type": "record",
+ "name": "FundsDeposited",
+ "namespace": "lumen.wallet",
+ "fields": [
+ {"name": "wallet_id", "type": "string"},
+ {"name": "amount", "type": "long"}, # integer minor units
+ {"name": "currency", "type": "string"},
+ {"name": "balance", "type": "long"}, # new balance, minor units
+ ],
+}
+
+_PARSED = fastavro.parse_schema(WALLET_DEPOSITED_SCHEMA)
+
+
+async def publish_deposit_avro(
+ broker: MessageBrokerPort,
+ wallet_id: str,
+ amount: int,
+ currency: str,
+ balance: int,
+) -> None:
+ """Encode a FundsDeposited event with Avro and publish to the topic."""
+ record = {
+ "wallet_id": wallet_id,
+ "amount": amount, # integer minor units
+ "currency": currency,
+ "balance": balance,
+ }
+ buf = io.BytesIO()
+ fastavro.schemaless_writer(buf, _PARSED, record)
+
+ await broker.publish(
+ "wallet.events",
+ buf.getvalue(),
+ headers={"content-type": "avro/binary",
+ "event-type": "FundsDeposited"},
+ )
+:::
+
+**Cómo funciona.** `fastavro.parse_schema` compila el documento de esquema JSON una vez en el momento de la carga del módulo; nunca lo analices dentro de la función de publicación o pagarás el coste de compilación en cada llamada. `fastavro.schemaless_writer` serializa el registro en el búfer `BytesIO` sin incrustar el esquema en cada mensaje (el registro proporciona el esquema en el lado del consumidor). `buf.getvalue()` extrae los bytes para `broker.publish`.
+
+Las cabeceras `headers={"content-type": "avro/binary", "event-type": "FundsDeposited"}` señalan a los consumidores que se requiere decodificación Avro y transportan el tipo de evento para el enrutamiento, de forma coherente con la convención de JSON.
+
+### Protobuf: rendimiento y poliglotismo
+
+Los Protocol Buffers compilan un archivo `.proto` en una clase generada. Producen mensajes más pequeños que JSON o Avro, y el código generado está disponible en todos los lenguajes principales, lo que convierte a Protobuf en la elección correcta cuando el consumidor es un servicio en Go o Java.
+
+```python
+# Assumes a generated class lumen_pb2.FundsDeposited
+from lumen_pb2 import FundsDeposited # type: ignore[import]
+
+event = FundsDeposited(
+ wallet_id="w-001",
+ amount=5000, # integer minor units
+ currency="EUR",
+ balance=10000,
+)
+payload: bytes = event.SerializeToString()
+
+await broker.publish(
+ "wallet.events",
+ payload,
+ headers={"content-type": "application/protobuf",
+ "event-type": "FundsDeposited"},
+)
+```
+
+Decodificar en el consumidor sigue el patrón espejo:
+
+```python
+from lumen_pb2 import FundsDeposited # type: ignore[import]
+
+event = FundsDeposited()
+event.ParseFromString(msg.value)
+```
+
+!!! tip "Empieza con JSON, migra cuando sientas el dolor"
+ La progresión correcta para la mayoría de los equipos es: JSON primero
+ (rápido de entregar, fácil de depurar); añade Avro cuando múltiples
+ equipos sean dueños de distintos lados de un topic y la deriva de esquema
+ se convierta en un coste de coordinación real; cambia a Protobuf cuando el
+ tamaño binario o la interoperabilidad multilenguaje sean un requisito
+ estricto. Como el `publish` y el `@message_listener` de PyFly aceptan
+ bytes en bruto, puedes cambiar el formato de serialización sin cambiar las
+ llamadas a la API del broker: solo intercambia los pasos de codificación y
+ decodificación.
+
+---
+
+## Cuando la entrega falla: colas de mensajes muertos
+
+### El inevitable mensaje defectuoso
+
+Incluso un consumidor bien diseñado acabará por encontrarse con un mensaje que no puede procesar. Una base de datos aguas abajo puede no estar disponible. La carga útil puede violar una suposición de la que el consumidor dependía. Un error de red transitorio puede interrumpir una llamada a una API de terceros a mitad del manejador. La pregunta no es si un consumidor fallará, sino qué ocurre cuando lo hace.
+
+Sin una estrategia de mensajes muertos, un consumidor fallido o bien descarta el mensaje (pérdida de datos) o lo vuelve a encolar indefinidamente, creando un bucle infinito de reintentos que bloquea todos los mensajes posteriores: una *píldora envenenada*. Una **cola de mensajes muertos** (DLQ) es la respuesta estructurada: un topic o cola aparte donde los mensajes que no pueden procesarse tras un número configurable de intentos se aparcan para su inspección y reprocesamiento manual.
+
+### Reintento y DLQ nativos del decorador
+
+En PyFly, el reintento y el enrutamiento a mensajes muertos están integrados en `@message_listener`: sin andamiaje de try/except, sin publicación manual en la DLQ. Declara `retries` y `dead_letter_topic` directamente en el decorador. Añades resiliencia añadiendo *argumentos*, no código:
+
+**Paso 1 — Parte de un oyente normal.** El cuerpo del manejador en el siguiente listado es un consumidor corriente que decodifica la carga útil y llama a un trabajador (`_charge`).
+
+**Paso 2 — Añade `retries` (y opcionalmente `retry_delay`).** `retries=3` le indica al framework que reinvoque el manejador hasta tres veces más si lanza una excepción. `retry_delay=0.5` espacia esos intentos con un retroceso lineal.
+
+**Paso 3 — Añade `dead_letter_topic`.** Cuando se agotan todos los reintentos, el framework republica el mensaje allí en lugar de dejar que la excepción tumbe al consumidor. No escribes ni el bucle de reintentos ni la publicación en la DLQ.
+
+::: listing lumen/messaging/resilient_consumer.py | Listado 10.7 — Reintento y DLQ conectados a través de @message_listener
+from __future__ import annotations
+
+import json
+import logging
+
+from pyfly.container import service
+from pyfly.messaging import Message, message_listener
+
+logger = logging.getLogger(__name__)
+
+
+@service
+class ResilientWalletConsumer:
+ """Processes wallet events with built-in retry and DLQ fallback."""
+
+ @message_listener(
+ topic="wallet.events",
+ group="payments-dlq",
+ retries=3,
+ retry_delay=0.5, # waits 0.5s, 1.0s, 1.5s (linear)
+ dead_letter_topic="wallet.events.DLQ",
+ )
+ async def on_wallet_event(self, msg: Message) -> None:
+ payload = json.loads(msg.value)
+ event_type = msg.headers.get("event-type", "unknown")
+ logger.info(
+ "Processing event: type=%s wallet=%s",
+ event_type,
+ payload.get("wallet_id"),
+ )
+ # Any unhandled exception triggers a retry; after 3 retries
+ # the original message is forwarded to wallet.events.DLQ.
+ await self._charge(payload)
+
+ async def _charge(self, payload: dict) -> None:
+ raise NotImplementedError("replace with real payment logic")
+:::
+
+**Los tres parámetros:**
+
+`retries=3` reinvoca `on_wallet_event` hasta tres veces más tras el primer fallo. Los reintentos son apropiados para fallos *transitorios* (un único nodo de base de datos reiniciándose); mantén el conteo bajo y deja que la DLQ gestione los fallos sostenidos.
+
+`retry_delay=0.5` aplica un retroceso lineal: el intento 1 espera 0,5 s, el intento 2 espera 1,0 s, el intento 3 espera 1,5 s. Con `retry_delay=0.0` (el valor por defecto), los reintentos son inmediatos.
+
+`dead_letter_topic="wallet.events.DLQ"` es la red de seguridad. Cuando se agotan todos los reintentos, el framework republica el mensaje original en el topic de la DLQ, preservando el `value` y la `key` originales, y añade dos cabeceras de diagnóstico:
+
+| Cabecera | Valor |
+|---|---|
+| `x-original-topic` | El topic del que se consumió originalmente el mensaje. |
+| `x-exception` | El nombre de la clase de la excepción (p. ej., `RuntimeError`). |
+
+La excepción se traga entonces para que el consumidor siga funcionando: el mensaje queda aparcado, no perdido, y el siguiente mensaje del topic se procesa con normalidad.
+
+!!! tip "Ejecútalo"
+ Puedes ver cómo un mensaje envenenado aterriza en la DLQ sin un broker
+ real. En el momento del cableado, el framework envuelve cada oyente con
+ `pyfly.messaging.error_handling.wrap_listener`: el mismo ayudante hace el
+ reintento y el envío a mensajes muertos. Acciónalo directamente para que
+ el flujo sea visible. Guarda esto como `dlq_demo.py` y ejecuta
+ `uv run python dlq_demo.py`:
+
+ ```python
+ import asyncio, json
+ from pyfly.messaging.adapters.memory import InMemoryMessageBroker
+ from pyfly.messaging.error_handling import wrap_listener
+ from pyfly.messaging.types import Message
+
+ async def always_fails(msg: Message) -> None:
+ raise RuntimeError("downstream unavailable")
+
+ async def main() -> None:
+ broker = InMemoryMessageBroker()
+ await broker.start()
+
+ async def show_dlq(msg: Message) -> None:
+ print("DLQ:", msg.headers["x-original-topic"],
+ msg.headers["x-exception"])
+ await broker.subscribe("wallet.events.DLQ", show_dlq)
+
+ handler = wrap_listener(
+ always_fails, broker,
+ retries=2, dead_letter_topic="wallet.events.DLQ",
+ )
+ await handler(Message(
+ topic="wallet.events",
+ value=json.dumps({"wallet_id": "w-001"}).encode(),
+ ))
+ await broker.stop()
+
+ asyncio.run(main())
+ ```
+
+ Tras dos reintentos, el envoltorio se rinde y republica en la DLQ, donde
+ tu monitor imprime las cabeceras de diagnóstico:
+
+ ```
+ DLQ: wallet.events RuntimeError
+ ```
+
+ El manejador retornó con normalidad —la excepción se tragó, no se
+ propagó—, así que en una app real el consumidor simplemente pasaría al
+ siguiente mensaje.
+
+### Monitorizar la DLQ
+
+Suscríbete al topic de la DLQ como a cualquier otro oyente para observar y alertar sobre los mensajes enviados a mensajes muertos:
+
+::: listing lumen/messaging/dlq_monitor.py | Listado 10.8 — Suscribirse al topic de la DLQ
+from __future__ import annotations
+
+import json
+import logging
+
+from pyfly.messaging import Message, message_listener
+
+logger = logging.getLogger(__name__)
+
+
+@message_listener(topic="wallet.events.DLQ", group="dlq-monitor")
+async def on_dead_letter(msg: Message) -> None:
+ """Log every message that failed all retries."""
+ original = msg.headers.get("x-original-topic", "unknown")
+ exc = msg.headers.get("x-exception", "unknown")
+ payload = json.loads(msg.value) if msg.value else {}
+ logger.warning(
+ "DLQ message: original_topic=%s exception=%s wallet=%s",
+ original,
+ exc,
+ payload.get("wallet_id"),
+ )
+:::
+
+!!! warning "Diseña los consumidores para que sean idempotentes"
+ Un consumidor que alcanza el límite de reintentos de la DLQ ha consumido
+ el mensaje. Si más tarde un operador reproduce el mensaje de la DLQ, el
+ consumidor lo procesará de nuevo. Sin idempotencia, ese doble
+ procesamiento puede corromper datos: abonar dos veces un monedero, enviar
+ una notificación duplicada. Usa el identificador estable del mensaje como
+ clave de idempotencia: antes de procesar, comprueba si ese ID ya se ha
+ registrado en una tabla `processed_events` y omite el trabajo si así es.
+ El paso de comprobar-y-registrar debería estar en la misma transacción de
+ base de datos que la escritura de negocio.
+
+---
+
+## Resiliencia: cortacircuitos y reintentos
+
+### Proteger a Lumen de una caída del broker
+
+Un broker sano no está garantizado. Las particiones de red, las actualizaciones progresivas (rolling upgrades) y el agotamiento de recursos pueden hacer que el broker quede temporalmente no disponible. Si el manejador de comandos llama a `broker.publish(...)` y el broker está caído, te enfrentas a dos malas opciones sin una capa de resiliencia: fallar el comando entero (negarte a depositar fondos porque el broker es inalcanzable) o descartar silenciosamente el evento (el depósito tiene éxito pero el evento de integración se pierde).
+
+Ninguna es aceptable. El outbox transaccional (Capítulo 9) es la solución atómica: el evento se captura en la base de datos y un relay lo publica de forma asíncrona, de modo que una caída del broker solo añade latencia, no pérdida de datos. Junto al outbox, los **cortacircuitos** (circuit breakers) y los **reintentos** protegen el relay y cualquier código que llame al broker frente a los fallos en cascada.
+
+Un **cortacircuitos** es la metáfora eléctrica convertida en código: tras demasiados fallos seguidos "salta" y deja de dejar pasar llamadas durante un periodo de enfriamiento, de modo que un broker con dificultades no se vea machacado por miles de intentos de reconexión condenados al fracaso. Un **reintento** es la táctica complementaria: vuelve a probar la misma llamada unas cuantas veces, porque muchos fallos son momentáneos.
+
+El módulo de resiliencia de PyFly (`pyfly.resilience`) proporciona ambas primitivas. El cortacircuitos se abre tras un umbral de fallos configurable y bloquea las llamadas al broker durante un periodo de enfriamiento, evitando una tormenta de reconexiones de manada (thundering herd). El decorador de reintentos gestiona los errores transitorios con un retroceso configurable. Los aplicas como un par de decoradores sobre el método de publicación:
+
+**Paso 1 — Escribe el método de publicación simple.** `forward` hace una sola cosa: codifica el registro y llama a `self._broker.publish(...)`. No vive ninguna lógica de resiliencia en el cuerpo.
+
+**Paso 2 — Envuélvelo en `@circuit_breaker`.** Pasa una instancia compartida de `CircuitBreaker` para que el conteo de fallos se acumule entre llamadas, no por llamada. Cuando el broker está caído, el cortacircuitos salta y falla rápido.
+
+**Paso 3 — Envuelve eso en `@retry` por fuera.** El orden de los decoradores importa: `@retry` se sitúa por encima de `@circuit_breaker`, de modo que todos los intentos de reintento de una llamada ocurren antes de que el cortacircuitos registre un solo fallo. Mantén `max_attempts` bajo y deja que el cortacircuitos absorba las caídas sostenidas.
+
+::: listing lumen/messaging/resilient_publisher.py | Listado 10.9 — Publicación resiliente al broker con reintento y cortacircuitos
+from __future__ import annotations
+
+import json
+import logging
+
+from pyfly.container import service
+from pyfly.messaging import MessageBrokerPort
+from pyfly.resilience import CircuitBreaker, circuit_breaker, retry
+
+logger = logging.getLogger(__name__)
+
+
+@service
+class OutboxRelay:
+ """
+ Drains pending outbox records and forwards them to the broker.
+ Applies retry and circuit-breaker protection on every publish call.
+ """
+
+ def __init__(self, broker: MessageBrokerPort) -> None:
+ self._broker = broker
+
+ @retry(max_attempts=3, delay=1.0, backoff=2.0)
+ @circuit_breaker(CircuitBreaker(failure_threshold=5, recovery_timeout=30))
+ async def forward(
+ self,
+ topic: str,
+ payload: dict,
+ event_type: str,
+ ) -> None:
+ """Forward a single outbox record to the broker."""
+ await self._broker.publish(
+ topic,
+ json.dumps(payload).encode(),
+ headers={"event-type": event_type},
+ )
+ logger.info(
+ "Event forwarded: topic=%s event-type=%s",
+ topic,
+ event_type,
+ )
+:::
+
+**Los dos decoradores:**
+
+`@retry(max_attempts=3, delay=1.0, backoff=2.0)` envuelve `forward` en un bucle de reintentos de hasta tres intentos. Tras el primer fallo espera `delay` segundos (1 s); tras el segundo espera `delay * backoff` (2 s): la espera crece como `delay * backoff ** attempt`. Si el tercer intento sigue fallando, la excepción se propaga. Los reintentos sirven para fallos *transitorios* (un único nodo del broker reiniciándose); son contraproducentes para fallos *permanentes* (un topic mal configurado). Mantén `max_attempts` bajo y deja que el cortacircuitos gestione las caídas sostenidas.
+
+`@circuit_breaker(CircuitBreaker(failure_threshold=5, recovery_timeout=30))` protege `forward` con una instancia compartida de `CircuitBreaker` que rastrea los fallos consecutivos a través de todas las llamadas. Cuando el conteo alcanza `failure_threshold`, el circuito se *abre* y las llamadas posteriores fallan inmediatamente con `CircuitBreakerException` en lugar de intentar alcanzar un broker inalcanzable, evitando una tormenta de reconexiones. Tras `recovery_timeout` segundos el circuito entra en un estado *semiabierto*: la siguiente llamada se deja pasar como sonda. Si tiene éxito, el circuito se cierra; si falla, se vuelve a abrir. El orden de los decoradores importa: `@retry` es el decorador externo, de modo que los tres intentos de una llamada lógica ocurren antes de que el cortacircuitos registre un solo fallo.
+
+!!! spring "Equivalencia con Spring"
+ `MessageBrokerPort` con `KafkaAdapter` es el equivalente en PyFly del
+ `KafkaTemplate` (publicación) y el `@KafkaListener` (consumo) de Spring
+ Kafka. `RabbitMQAdapter` refleja el `RabbitTemplate` y el `@RabbitListener`
+ de Spring AMQP. El modelo de ciclo de vida es el mismo: registra los
+ oyentes antes de arrancar el contenedor, y el framework gestiona los hilos
+ de consumidor. Las colas de mensajes muertos en Spring Kafka se configuran
+ mediante `DeadLetterPublishingRecoverer` sobre el `DefaultErrorHandler`; en
+ Spring AMQP mediante `RabbitListenerContainerFactory` con un
+ `MessageRecoverer`. PyFly implementa el mismo patrón de forma declarativa
+ a través de `@message_listener(retries=..., dead_letter_topic=...)` en
+ lugar de requerir configuración de contenedor específica del broker. Los
+ decoradores `@retry` y `@circuit_breaker` reflejan las anotaciones
+ `@Retryable` y `@CircuitBreaker` de Resilience4j usadas con la
+ infraestructura de mensajería de Spring.
+
+---
+
+## Lo que construiste {.recap}
+
+La Parte III está completa.
+
+Lumen es ahora plenamente orientada a eventos, con event sourcing y conectada a un broker. Aquí está dónde dejó las cosas cada capítulo.
+
+**El Capítulo 8** introdujo el modelo de dos buses: `ApplicationEventBus` para los eventos del ciclo de vida del framework, `InMemoryEventBus` para los eventos de dominio. `EventPublisher` se conectó a los manejadores de comandos para que cada mutación de un agregado produjera un hecho al que oyentes independientes —`WalletAuditListener` entre ellos— pudieran reaccionar sin conocerse entre sí. Las suscripciones usan `@event_listener(event_types=["WalletOpened", "FundsDeposited", "FundsWithdrawn"])`; los manejadores reciben un `EventEnvelope` cuyo `event_type` es el nombre de la clase del evento de dominio.
+
+**El Capítulo 9** reemplazó el enfoque de agregado mutable más modelo de lectura por el event sourcing. Cada movimiento financiero es un evento inmutable que se añade al libro mayor. El saldo actual se calcula reproduciendo el flujo de eventos. `EventEnvelope` se convirtió en la unidad de almacenamiento, y los snapshots mantuvieron acotados los tiempos de reproducción.
+
+**Este capítulo** cruzó el límite de la red. `MessageBrokerPort` es la única abstracción frente a Kafka, RabbitMQ o el broker en memoria. Intercambiar adaptadores es un cambio de configuración: ningún cambio en el código de negocio. `@message_listener` ofrece suscripciones declarativas y sin código repetitivo tanto en funciones independientes como en métodos de `@service`. Los parámetros `retries` y `dead_letter_topic` gestionan los mensajes envenenados sin andamiaje manual de try/except. Las cargas útiles se codificaron como bytes JSON, con Avro y Protobuf disponibles cuando el cumplimiento de esquema o la eficiencia binaria importan más que la simplicidad. `@retry` y `@circuit_breaker` protegen la ruta de publicación frente a fallos del broker transitorios y sostenidos.
+
+Los eventos de dominio que fluyen a través de los tres capítulos son:
+
+| Clase de evento | Campos |
+|---|---|
+| `WalletOpened` | `wallet_id`, `owner_id`, `currency` |
+| `FundsDeposited` | `wallet_id`, `amount`, `currency`, `balance` |
+| `FundsWithdrawn` | `wallet_id`, `amount`, `currency`, `balance` |
+
+`amount` y `balance` son siempre unidades menores enteras (p. ej., `5000` para
+50,00 €). `currency` es un valor de cadena del StrEnum `Currency`
+(`"EUR"`, `"USD"`, `"GBP"`). El valor de la cabecera `event_type` es siempre el
+nombre de la clase —`"FundsDeposited"`—, nunca una ruta con puntos.
+
+Tres principios pasan a la Parte IV:
+
+- **Depende del port, no del adaptador.** `MessageBrokerPort` se inyecta; `KafkaAdapter` es un detalle de configuración.
+- **Diseña los consumidores para que sean idempotentes.** Los brokers entregan *al menos una vez*. Protégete frente al procesamiento duplicado con un identificador de mensaje estable.
+- **Captura los eventos de forma atómica.** El outbox transaccional garantiza que un evento nunca se pierda, incluso cuando el broker no está disponible en el momento de la escritura.
+
+La Parte IV introduce el `PaymentsService` y el `NotificationsService`. Ambos se suscriben a `wallet.events`. Las elecciones de adaptador y configuración hechas en este capítulo son todo lo que necesitan para empezar a recibir los hechos de Lumen en cuanto se conecten.
+
+---
+
+## Pruébalo tú mismo {.exercises}
+
+!!! note "Ejecútalo"
+ Cada ejercicio de abajo termina en una prueba. Como `provider: "memory"`
+ no necesita broker, puedes ejecutarlos sin nada instalado más allá del
+ extra de desarrollo. Desde la raíz del proyecto Lumen:
+
+ ```bash
+ uv run --extra dev pytest tests/test_messaging.py -q
+ ```
+
+ Una ejecución en verde tiene este aspecto:
+
+ ```text
+ ... [100%]
+ 3 passed in 0.XXs
+ ```
+
+ Si una prueba falla con un error de atributo ausente como
+ `__pyfly_message_listener__`, el decorador `@message_listener` no está
+ aplicado a tu manejador: vuelve a revisar el Paso 2 de "Oyentes
+ declarativos".
+
+1. **Intercambia el adaptador en una línea.** Empieza con `provider: "memory"`
+ en `pyfly.yaml` y añade el `@message_listener` del Listado 10.4. Escribe
+ una prueba de integración que publique un mensaje `FundsDeposited` con
+ `amount=5000` y `currency="EUR"` y afirme que el oyente lo recibe. Luego
+ cambia a `provider: "kafka"` en el YAML y confirma que la misma prueba (con
+ un broker de Kafka gestionado por Testcontainers) pasa sin cambiar el
+ oyente ni la aserción de la prueba.
+
+2. **Añade un monitor de DLQ.** Crea un segundo `@message_listener` en el topic
+ `wallet.events.DLQ` con el grupo `dlq-monitor`. Debería registrar las
+ cabeceras `x-original-topic` y `x-exception` junto con la carga útil
+ decodificada. Escribe una prueba que simule un consumidor fallido lanzando
+ `RuntimeError` dentro del manejador, configure `retries=2` y
+ `dead_letter_topic="wallet.events.DLQ"`, y confirme que el monitor de DLQ
+ recibe el mensaje con `x-original-topic: "wallet.events"`.
+
+3. **Evoluciona el esquema con Avro.** Empieza con el
+ `WALLET_DEPOSITED_SCHEMA` del Listado 10.6. Añade un campo opcional `note`
+ con un valor por defecto de `None` (unión Avro `["null", "string"]`,
+ por defecto `null`). Confirma que un consumidor compilado contra el esquema
+ original aún puede decodificar un mensaje codificado con el nuevo esquema:
+ este es un cambio *retrocompatible* (backward-compatible). Luego intenta
+ añadir un campo obligatorio sin un valor por defecto y observa la
+ `SchemaParseException` que el registro lanzaría, ilustrando por qué los
+ valores por defecto son obligatorios para una evolución segura.
diff --git a/book/manuscript-es/11-http-clients.md b/book/manuscript-es/11-http-clients.md
new file mode 100644
index 00000000..b1d04a60
--- /dev/null
+++ b/book/manuscript-es/11-http-clients.md
@@ -0,0 +1,1296 @@
+Capítulo 11
+
+# Dividir el monolito: clientes HTTP y el BFF {.chtitle}
+
+::: figure art/openers/ch11.svg |
+
+Cuando Lumen era un único servicio, cada capacidad vivía en el mismo
+proceso. El monedero, la comprobación de saldo y el procesamiento de
+pagos se ejecutaban juntos: sencillos de probar, simples de desplegar y
+perfectamente adecuados hasta que el equipo necesitó publicar
+funcionalidades a ritmos distintos. Entonces llegó la conversación difícil
+sobre la división.
+
+La promesa de una división en microservicios es real: los equipos son
+dueños de sus servicios de forma independiente, los escalan por separado
+y los despliegan sin coordinar una ventana de publicación compartida. Pero
+toda división introduce un problema que el monolito nunca tuvo: la red. Lo
+que era una llamada a una función local se convierte en una petición HTTP
+que puede agotar su tiempo de espera, fallar a medias o aterrizar en un
+servicio saturado. Esa frontera de red no es un detalle de despliegue; es
+una preocupación de ingeniería de primer orden.
+
+Este capítulo presenta `PaymentsService`, un segundo servicio al que el
+servicio Wallet de Lumen llama para liquidar transferencias. En lugar de
+montar a mano sesiones de `httpx` e ir hilvanando la lógica del
+cortacircuitos (circuit breaker) por cada manejador, definirás el cliente
+de Payments como una clase de Python corriente: una interfaz tipada y
+declarativa que PyFly rellena en el arranque. Al final del capítulo verás
+también cómo un nivel **BFF (Backend for Frontend)** se sitúa por delante
+de ambos servicios y compone sus capacidades en una única API centrada en
+el recorrido del usuario.
+
+!!! note "Nueva jerga, en lenguaje llano"
+ Algunos términos se repiten a lo largo de este capítulo. **Llamada
+ servicio a servicio** significa que uno de tus servicios hace una
+ petición HTTP a otro de tus servicios (Wallet llamando a Payments), en
+ contraste con una petición que llega desde un navegador. Un **cliente
+ declarativo** es un cliente que *describes* en vez de *implementar*:
+ escribes las firmas de los métodos y dejas que el framework rellene la
+ fontanería HTTP. Un **cortacircuitos (circuit breaker)** es un
+ interruptor de seguridad que deja de llamar a un servicio remoto
+ después de que haya fallado demasiadas veces seguidas. Un **BFF**
+ (Backend for Frontend) es un servicio fino cuya única misión es llamar
+ a otros servicios y remodelar sus respuestas para una aplicación
+ concreta. Construiremos cada una de estas piezas, una a una.
+
+Este capítulo se articula en torno a `httpx` y al paquete `pyfly.client`,
+ambos incluidos con el framework. Todo lo que hay aquí se ejecuta contra
+PyFly v26.6.110. No necesitas instalar nada nuevo: si has ido siguiendo el
+hilo con Lumen, las herramientas de cliente ya están en tu path.
+
+---
+
+## Por qué dividir (y por qué duele)
+
+### La zona de confort del monolito
+
+Un monolito no es un error arquitectónico: es un punto de partida
+arquitectónico. Lumen comenzó como un único servicio porque un único
+servicio era lo correcto: un equipo, una canalización de despliegue, un
+conjunto de preocupaciones que razonar. La transacción de base de datos
+que escribe una fila del monedero y publica un evento de dominio en la
+misma unidad de trabajo no era un compromiso a la baja; era la elección
+óptima.
+
+La presión para dividir suele llegar desde fuera de la arquitectura.
+Payments necesita un registro de auditoría de cumplimiento separado. La
+puntuación de riesgo necesita un equipo especialista con acceso a una
+fuente de datos privada. El procesamiento de liquidaciones exige un
+rendimiento un orden de magnitud mayor que las lecturas de saldo.
+Cualquiera de estas razones es buena para extraer un servicio, y ninguna
+de ellas borra el hecho de que el resto del sistema todavía necesita
+llamar al servicio extraído a través de una frontera de red.
+
+### El coste de la red
+
+Las llamadas de red fallan de maneras en que las llamadas a funciones
+locales no lo hacen. Un método sobre un objeto local o bien devuelve un
+valor o bien lanza una excepción. Una llamada HTTP a un servicio remoto
+puede agotar su tiempo de espera (el remoto va lento), rechazar la
+conexión (el remoto está caído), devolver un 503 transitorio (el remoto
+está sobrecargado) o tener éxito solo al tercer intento. En un monolito
+estos modos de fallo son irrelevantes; en un sistema distribuido son tu
+línea de base.
+
+El arreglo ingenuo —usar `httpx` directamente con `try/except` alrededor
+de cada llamada— funciona para un único punto de llamada, pero no escala.
+Acabas con la lógica del cortacircuitos duplicada en cada cliente de
+servicio, retardos de reintento incrustados a fuego en los manejadores y
+valores de tiempo de espera dispersos por fragmentos de `pyfly.yaml` que
+nadie posee. Cuando Payments introduce un nuevo endpoint, cada quien que
+llame debe acordarse de añadir de nuevo todo el andamiaje de resiliencia.
+
+El cliente HTTP tipado de PyFly elimina esa duplicación. Declaras cómo es
+el servicio remoto: sus endpoints, sus rutas y la forma de sus parámetros.
+PyFly genera la implementación en el arranque, conecta un cortacircuitos y
+una política de reintentos a partir de `pyfly.yaml` y registra el bean en
+el contenedor para que cualquier manejador que lo necesite pueda
+declararlo como argumento del constructor. La resiliencia se aplica una
+vez, de forma coherente, en la capa correcta.
+
+---
+
+## Un cliente de servicio tipado
+
+### Declarativo en vez de imperativo
+
+La idea central del módulo de cliente de PyFly es que los contratos
+servicio a servicio se expresan mejor como tipos que como lógica HTTP
+procedimental. Cuando describes `PaymentsClient` como una clase con firmas
+de método tipadas, obtienes una interfaz de Python que cualquier IDE puede
+navegar, que cualquier verificador de tipos puede comprobar y que
+cualquier prueba puede simular, sin importar nunca `httpx` en el código que
+la usa.
+
+Dos decoradores definen ese contrato:
+
+| Decorador | Resiliencia incorporada | Úsalo para |
+|---|---|---|
+| `@service_client` | Cortacircuitos + reintentos | Llamadas servicio a servicio en producción |
+| `@http_client` | Ninguna | Clientes ligeros, pruebas, utilidades internas |
+
+Usa **`@service_client`** siempre que el destino sea otro microservicio.
+Reserva `@http_client` para utilidades internas y dobles de prueba.
+
+### Definir el cliente de Payments
+
+El servicio Payments expone dos endpoints: uno para crear una instrucción
+de pago y otro para recuperar un pago por identificador. Definir el cliente
+consiste en escribir la clase. Construyámosla decisión a decisión.
+
+**Paso 1 — Crea el archivo.** Añade `src/lumen/sdk/payments_client.py`
+junto al `client.py` existente. El paquete `sdk` es donde Lumen guarda el
+código que *habla con* servicios, así que este es el hogar natural para un
+cliente de servicio.
+
+**Paso 2 — Decora la clase.** Coloca `@service_client(base_url=...)` sobre
+una clase normal. Ese único decorador es lo que convierte una clase
+corriente en un cliente HTTP gestionado por PyFly: registra la URL base,
+activa las funciones de resiliencia y registra la clase como un bean para
+que el contenedor pueda inyectarla después.
+
+**Paso 3 — Declara un método por endpoint.** Cada método recibe un
+decorador de verbo (`@post`, `@get`, `@patch`, `@delete`) que lleva la
+ruta. El cuerpo del método es solo `...` —una *elipsis*, el literal de
+Python para "aquí todavía no hay nada"—. Nunca escribes el código de la
+petición; PyFly lo escribe por ti en el arranque.
+
+El cliente completo tiene este aspecto.
+
+::: figure art/figures/11-client.svg | Figura 11.1 — La canalización del cliente declarativo de PyFly. Tú escribes la interfaz; HttpClientBeanPostProcessor genera la implementación.
+
+::: listing lumen/sdk/payments_client.py | Listado 11.1 — Cliente de Payments tipado con @service_client
+from __future__ import annotations
+
+from pyfly.client import (
+ delete,
+ get,
+ patch,
+ post,
+ service_client,
+)
+
+
+@service_client(
+ base_url="http://payments-service:8080",
+ circuit_breaker=True,
+ retry=3,
+ circuit_breaker_failure_threshold=5,
+ circuit_breaker_recovery_timeout=60.0,
+ retry_base_delay=1.0,
+)
+class PaymentsClient:
+ """Typed HTTP client for the Payments service.
+
+ Method stubs are replaced with real HTTP implementations by
+ HttpClientBeanPostProcessor at application startup. Declare
+ this class as a constructor argument to have it injected.
+ """
+
+ @post("/payments")
+ async def create_payment(self, body: dict) -> dict:
+ """POST /payments — submit a payment instruction."""
+ ...
+
+ @get("/payments/{payment_id}")
+ async def get_payment(self, payment_id: str) -> dict:
+ """GET /payments/:payment_id — fetch a payment by ID."""
+ ...
+
+ @patch("/payments/{payment_id}/cancel")
+ async def cancel_payment(self, payment_id: str) -> dict:
+ """PATCH /payments/:payment_id/cancel — cancel pending."""
+ ...
+
+ @delete("/payments/{payment_id}")
+ async def delete_payment(self, payment_id: str) -> None:
+ """DELETE /payments/:payment_id — remove a completed record."""
+ ...
+:::
+
+**Cómo funciona — la canalización de declaración:**
+
+`@service_client(base_url=...)` estampa atributos de metadatos sobre la
+clase y la registra como un bean singleton en el contenedor de PyFly: el
+mismo mecanismo `__pyfly_injectable__ = True` que usa `@service`. La
+`base_url` se almacena como `__pyfly_http_base_url__`; las opciones de
+resiliencia aterrizan en `__pyfly_resilience__`.
+
+Los decoradores de verbo —`@post("/payments")`,
+`@get("/payments/{payment_id}")` y los demás— adjuntan cada uno dos
+atributos a su método: `__pyfly_http_method__` (la cadena con el verbo
+HTTP) y `__pyfly_http_path__` (la plantilla de ruta). El cuerpo del método
+en sí se convierte en un stub que lanza `NotImplementedError` y nunca
+debería llamarse directamente.
+
+En el arranque, `HttpClientBeanPostProcessor.after_init()` inspecciona cada
+bean. Cuando encuentra una clase con `__pyfly_http_client__ = True`, crea
+un `HttpxClientAdapter` para la `base_url`, recorre cada método buscando
+`__pyfly_http_method__` y reemplaza cada stub por una implementación
+asíncrona real. Esa implementación usa `inspect.signature()` para enlazar
+los argumentos de quien llama, interpola las variables de ruta
+(`{payment_id}` → el valor real), separa los parámetros restantes en
+cadenas de consulta o un cuerpo JSON, y llama a `client.request()`. Las
+respuestas con estado ≥ 400 lanzan excepciones tipadas; las respuestas
+correctas devuelven `response.json()`.
+
+La interpolación de variables de ruta es posicional: cualquier parámetro
+cuyo nombre coincida con un `{marcador}` de la plantilla de ruta es
+sustituido. Para `get_payment(self, payment_id: str)`, llamar a
+`client.get_payment("pay-123")` envía `GET /payments/pay-123`. Para
+`create_payment(self, body: dict)`, llamar a
+`client.create_payment({"amount": 5000})` envía `POST /payments` con el
+dict serializado como cuerpo JSON. Los parámetros llamados `body` en
+métodos POST/PUT/PATCH siempre se tratan como el cuerpo JSON de la
+petición; todos los demás parámetros que no son de ruta en métodos
+GET/DELETE se convierten en parámetros de cadena de consulta.
+
+!!! note "Qué acaba de pasar"
+ Escribiste cuatro *firmas* de método y cero líneas de código HTTP. El
+ decorador `@service_client` etiquetó la clase para el contenedor; los
+ decoradores de verbo etiquetaron cada método con un verbo y una ruta.
+ En el arranque, un componente entre bambalinas llamado
+ `HttpClientBeanPostProcessor` lee esas etiquetas y reemplaza
+ silenciosamente cada stub `...` por un método asíncrono funcional que
+ construye la URL, envía la petición y vuelve a convertir la respuesta
+ en Python. Desde el punto de vista de quien llama,
+ `await client.get_payment("pay-123")` se ve exactamente igual que
+ llamar a un método local: la red queda oculta.
+
+**Ejecútalo — confirma que los stubs están conectados.** Hasta que el
+contexto de aplicación arranca, esos cuerpos `...` lanzan
+`NotImplementedError` a propósito, así que no puedes simplemente llamar al
+método en un script pelado. La forma honesta de demostrar que el cliente
+funciona es una prueba diminuta que arranque un contexto (o conecte el
+post-procesador) e inspeccione el método generado. La comprobación rápida
+más sencilla es confirmar los metadatos que los decoradores estamparon:
+
+```
+uv run python -c "from lumen.sdk.payments_client import PaymentsClient; \
+print(PaymentsClient.__pyfly_http_base_url__); \
+print(PaymentsClient.create_payment.__pyfly_http_method__, \
+PaymentsClient.create_payment.__pyfly_http_path__)"
+```
+
+Salida esperada:
+
+```
+http://payments-service:8080
+POST /payments
+```
+
+Si ves la URL base y `POST /payments`, los decoradores se aplicaron
+correctamente y el post-procesador tiene todo lo que necesita para generar
+la implementación real cuando la aplicación arranque.
+
+!!! spring "Equivalencia con Spring"
+ `@service_client` con `@get`/`@post`/`@put`/`@delete`/`@patch` es el
+ homólogo en PyFly de `@FeignClient` de Spring Cloud OpenFeign con
+ `@GetMapping`/`@PostMapping`, etc. En Feign anotas una interfaz; en
+ PyFly anotas una clase con métodos stub: la intención es idéntica.
+ Ambos frameworks generan la implementación HTTP en tiempo de arranque,
+ inyectan el bean a través del contenedor de inyección de dependencias y
+ admiten cortacircuitos (Feign vía Resilience4j; PyFly vía el
+ `CircuitBreaker` incorporado). La diferencia clave es que Feign trabaja
+ sobre interfaces de Java mientras que PyFly trabaja sobre clases de
+ Python corrientes, lo que significa que puedes añadir métodos
+ auxiliares junto a los métodos stub: útil para la lógica de remodelado
+ de respuestas que pertenece dentro de la propia clase cliente.
+
+### Inyectar el cliente en un manejador
+
+Como `PaymentsClient` es un bean singleton, cualquier `@service` o
+`@command_handler` puede declararlo como argumento del constructor. El
+contenedor de PyFly lo inyecta a través de la misma vía de autoconexión
+usada para repositorios y servicios de dominio.
+
+!!! note "Bean y autoconexión, en breve"
+ Un **bean** es simplemente un objeto que el framework crea y gestiona
+ por ti: nunca llamas tú a su constructor. La **autoconexión** es cómo
+ el contenedor entrega un bean a quien lo necesite: cuando una clase
+ enumera `payments: PaymentsClient` en su `__init__`, PyFly se percata
+ del tipo, encuentra el bean coincidente y lo pasa automáticamente.
+ Declaras la dependencia por *tipo*; el contenedor hace la búsqueda.
+
+El servicio Wallet de Lumen ya aplica el patrón del `WalletRepository` y el
+objeto de valor `Money` de capítulos anteriores. Cuando el monedero debe
+llamar a Payments para liquidar un reintegro, el manejador sigue ese mismo
+patrón en tres pasos: cargar el monedero, retirar a través de la raíz de
+agregado y luego llamar al servicio externo.
+
+**Paso 1 — Declara la dependencia.** Añade `payments: PaymentsClient` al
+constructor del manejador y guárdalo en `self`. Esa es la única conexión
+que escribes; el contenedor suministra el cliente en vivo.
+
+**Paso 2 — Haz primero el trabajo local.** Carga el monedero y llama a
+`wallet.withdraw(...)`, después persístelo, de modo que el propio estado
+del monedero quede liquidado antes de que ocurra cualquier llamada de red.
+
+**Paso 3 — Llama al servicio remoto.**
+`await self._payments.create_payment(...)` se lee como una llamada a un
+método local. Los detalles de resiliencia y HTTP ya vienen horneados en el
+cliente inyectado.
+
+Aquí está el manejador.
+
+::: listing lumen/core/services/wallets/settle_transfer_handler.py | Listado 11.2 — CommandHandler que inyecta PaymentsClient
+from __future__ import annotations
+
+from lumen.core.services.wallets.settle_transfer_command import (
+ SettleTransfer,
+)
+from lumen.models.entities.v1.money import Money
+from lumen.models.repositories.wallet_repository import WalletRepository
+from lumen.sdk.payments_client import PaymentsClient
+from pyfly.container import service
+from pyfly.cqrs import CommandHandler, command_handler
+from pyfly.domain import AggregateNotFound
+
+
+@command_handler
+@service
+class SettleTransferHandler(CommandHandler[SettleTransfer, dict]):
+ """Withdraw from the wallet and submit a payment instruction."""
+
+ def __init__(
+ self,
+ repository: WalletRepository,
+ payments: PaymentsClient,
+ ) -> None:
+ super().__init__()
+ self._repository = repository
+ self._payments = payments
+
+ async def do_handle(self, command: SettleTransfer) -> dict:
+ wallet = await self._repository.find(command.wallet_id)
+ if wallet is None:
+ raise AggregateNotFound("Wallet", command.wallet_id)
+
+ wallet.withdraw(
+ Money(amount=command.amount, currency=wallet.currency)
+ )
+ await self._repository.add(wallet)
+
+ payment = await self._payments.create_payment({
+ "wallet_id": command.wallet_id,
+ "amount": command.amount,
+ "currency": wallet.currency.value,
+ "reference": command.reference,
+ })
+ return payment
+:::
+
+**Cómo funciona — la vía de inyección:**
+
+`payments: PaymentsClient` en el constructor es resuelto por el contenedor
+en el arranque. `HttpClientBeanPostProcessor` conecta `PaymentsClient`
+antes de que se instancie `SettleTransferHandler`, de modo que el bean
+inyectado está plenamente operativo. El manejador llama a
+`await self._payments.create_payment(...)` exactamente como si fuera un
+método asíncrono local. La agrupación de conexiones (connection pooling),
+la propagación de cabeceras y el mapeo de errores son todos invisibles
+para el manejador.
+
+`wallet.withdraw(Money(...))` se ejecuta antes de la llamada de red, así
+que el estado del monedero queda confirmado antes de que se contacte a
+Payments. Si Payments está temporalmente no disponible, los reintentos y el
+cortacircuitos —descritos en la siguiente sección— gestionan la
+recuperación de forma transparente, sin ningún código en el manejador.
+
+**Ejecútalo — ejercita el manejador con un cliente falso.** Como el
+manejador depende del *tipo* `PaymentsClient`, puedes sustituirlo por un
+suplente en una prueba sin ninguna red. Pon esto en
+`tests/test_settle_transfer.py` y ejecútalo:
+
+```
+uv run --extra dev pytest tests/test_settle_transfer.py -q
+```
+
+Una ejecución exitosa imprime algo como:
+
+```
+1 passed in 0.12s
+```
+
+La gracia de la prueba es la sustitución: pasas un objeto hecho a mano en
+lugar del `PaymentsClient` real, afirmas que el manejador llamó a
+`create_payment` con el cuerpo esperado y nunca abres un socket. Esa es la
+recompensa práctica de declarar la dependencia por tipo: al manejador ni le
+consta ni le importa si el cliente del otro lado es real o falseado.
+
+---
+
+## Resiliencia sobre el cable
+
+### Por qué la capa de cliente es el lugar correcto para la resiliencia
+
+La lógica de resiliencia dentro de un manejador mezcla preocupaciones de
+negocio con fontanería de infraestructura. Un manejador que captura
+`httpx.ConnectError` e implementa su propio bucle de espera está haciendo
+dos cosas a la vez: liquidar una transferencia *y* gestionar los modos de
+fallo de HTTP. Esas responsabilidades pertenecen a capas separadas.
+
+**`@service_client`** mueve el **cortacircuitos** y la **política de
+reintentos** a la capa de cliente, donde corresponde. Los configuras una
+vez en el decorador y cada método del cliente los hereda de manera
+uniforme. El código del manejador permanece centrado en la operación de
+negocio.
+
+### Cortacircuitos
+
+Un cortacircuitos monitoriza cada llamada al servicio remoto. Cuando
+`failure_threshold` llamadas consecutivas fallan, el circuito se **abre**:
+las llamadas posteriores se rechazan de inmediato con
+`CircuitBreakerException` en lugar de esperar a un agotamiento de tiempo de
+espera. Esto evita que un único servicio lento o no disponible bloquee el
+bucle de eventos y agote las agrupaciones de conexiones de todos los
+llamantes.
+
+Tras `circuit_breaker_recovery_timeout` segundos, el circuito entra en
+**medio abierto**: se admite una petición de sondeo. Si tiene éxito, el
+circuito se cierra y se reanuda la operación normal. Si falla, el circuito
+vuelve a abrirse y se reinicia el temporizador de recuperación.
+
+`@service_client` conecta el cortacircuitos automáticamente. Si lo
+necesitas por separado:
+
+::: listing lumen/sdk/standalone_breaker.py | Listado 11.3 — Uso de CircuitBreaker por separado
+from __future__ import annotations
+
+from datetime import timedelta
+
+from pyfly.client import CircuitBreaker
+from pyfly.kernel.exceptions import CircuitBreakerException
+
+
+breaker = CircuitBreaker(
+ failure_threshold=3,
+ recovery_timeout=timedelta(seconds=30),
+)
+
+
+async def call_with_breaker(client, payment_id: str) -> dict:
+ """Fetch a payment through a standalone circuit breaker."""
+ try:
+ return await breaker.call(
+ client.get_payment,
+ payment_id,
+ )
+ except CircuitBreakerException:
+ return {"status": "unavailable", "payment_id": payment_id}
+:::
+
+**Cómo funciona:** `CircuitBreaker.__init__` acepta `failure_threshold`
+(por defecto `5`) y `recovery_timeout` como un `timedelta` (por defecto
+30 s). `breaker.call(func, *args)` ejecuta `func(*args)` dentro del
+cortacircuitos: si tiene éxito, reinicia la cuenta de fallos; si falla,
+incrementa la cuenta y voltea el estado a `OPEN` una vez que se alcanza el
+umbral. Las transiciones de estado `CLOSED → HALF_OPEN` se calculan de
+forma perezosa con `time.monotonic()`: no hay temporizador en segundo
+plano.
+
+`CircuitBreakerException` nunca se contabiliza como un fallo. Señala que el
+circuito ya está abierto, así que volver a lanzarla sin registrar otro
+fallo impide que el tiempo de espera de recuperación se reinicie
+indefinidamente.
+
+!!! note "Tres estados, en lenguaje llano"
+ Piensa en el cortacircuitos como un interruptor de luz con tres
+ posiciones. **Cerrado** es lo normal: las llamadas fluyen a través.
+ **Abierto** significa "deja de intentarlo": las llamadas fallan al
+ instante sin tocar la red, lo que libra a tu servicio de esperar por
+ algo que claramente está caído. **Medio abierto** es "déjame tantear el
+ terreno": tras el tiempo de espera de recuperación, el cortacircuitos
+ deja pasar exactamente una llamada; si funciona, el interruptor vuelve
+ a cerrado, y si falla, salta de nuevo a abierto.
+
+**Ejecútalo — observa cómo un cortacircuitos se abre y se recupera.**
+Puedes accionar el cortacircuitos a mano desde un REPL sin ningún servicio
+real involucrado. Arranca uno con `uv run python` desde `samples/lumen` y
+prueba:
+
+```
+uv run python -c "
+import asyncio
+from datetime import timedelta
+from pyfly.client import CircuitBreaker, CircuitState
+from pyfly.kernel.exceptions import CircuitBreakerException
+
+async def boom():
+ raise RuntimeError('payments down')
+
+async def main():
+ cb = CircuitBreaker(failure_threshold=2, recovery_timeout=timedelta(seconds=30))
+ for _ in range(2):
+ try:
+ await cb.call(boom)
+ except RuntimeError:
+ pass
+ print('state after 2 failures:', cb.state.name)
+ try:
+ await cb.call(boom)
+ except CircuitBreakerException:
+ print('open circuit rejected the call without calling boom')
+
+asyncio.run(main())
+"
+```
+
+Salida esperada:
+
+```
+state after 2 failures: OPEN
+open circuit rejected the call without calling boom
+```
+
+La segunda línea es justo el quid: una vez que el circuito está abierto, el
+cortacircuitos lanza `CircuitBreakerException` *de inmediato* en lugar de
+ejecutar `boom` de nuevo. Baja `recovery_timeout` a `timedelta(seconds=0)`
+y vuelve a ejecutarlo: la siguiente lectura de `cb.state` reporta
+`HALF_OPEN` y la tercera llamada admite un sondeo (así que `boom` se
+ejecuta otra vez) en vez de ser rechazada. Eso es el cortacircuitos
+dejando que el servicio demuestre que se ha recuperado.
+
+### Política de reintentos
+
+Los fallos transitorios —un pico momentáneo de latencia, un reinicio
+progresivo, un breve reinicio de conexión— no necesitan un cortacircuitos;
+necesitan un segundo intento. `RetryPolicy` proporciona espera exponencial
+con filtrado de excepciones configurable:
+
+::: listing lumen/sdk/standalone_retry.py | Listado 11.4 — Uso de RetryPolicy por separado
+from __future__ import annotations
+
+from datetime import timedelta
+
+from pyfly.client import RetryPolicy
+
+
+policy = RetryPolicy(
+ max_attempts=3,
+ base_delay=timedelta(milliseconds=500),
+ retry_on=(ConnectionError, TimeoutError),
+)
+
+
+async def resilient_fetch(client, payment_id: str) -> dict:
+ """Fetch a payment with retry on transient network errors."""
+ return await policy.execute(
+ client.get_payment,
+ payment_id,
+ )
+:::
+
+**Cómo funciona:** `RetryPolicy.__init__` acepta `max_attempts` (por
+defecto 3, contando el primer intento), `base_delay` (por defecto 1 s) y
+`retry_on`, una tupla de tipos de excepción. La fórmula de espera es
+`base_delay * (2 ** attempt)`: para `base_delay=0.5 s`, los retardos son
+0,5 s, 1 s, 2 s. Solo las excepciones que coinciden con `retry_on`
+disparan un reintento; las demás se propagan de inmediato. Esto importa: no
+quieres reintentar un 404 (el recurso no existe) ni un 422 (la petición es
+semánticamente inválida).
+
+!!! note "Espera (backoff), en lenguaje llano"
+ *Espera (backoff)* significa aguardar un poco más antes de cada
+ reintento en vez de aporrear el servicio remoto en el instante en que
+ falla. La espera *exponencial* duplica la espera cada vez, así que un
+ servicio que necesita un momento para recuperarse obtiene más oxígeno
+ con cada intento, mientras que uno sano se reintenta casi de inmediato.
+
+**Ejecútalo — observa cómo el reintento se recupera de un error
+transitorio.** Simula una llamada que falla dos veces y luego tiene éxito:
+
+```
+uv run python -c "
+import asyncio
+from datetime import timedelta
+from pyfly.client import RetryPolicy
+
+attempts = {'n': 0}
+async def flaky():
+ attempts['n'] += 1
+ if attempts['n'] < 3:
+ raise ConnectionError('reset')
+ return 'ok'
+
+async def main():
+ policy = RetryPolicy(max_attempts=3, base_delay=timedelta(milliseconds=1),
+ retry_on=(ConnectionError,))
+ print('result:', await policy.execute(flaky))
+ print('attempts:', attempts['n'])
+
+asyncio.run(main())
+"
+```
+
+Salida esperada:
+
+```
+result: ok
+attempts: 3
+```
+
+Tres intentos, un éxito. Cambia `retry_on` a `(TimeoutError,)` y el primer
+`ConnectionError` se propaga en su lugar: prueba de que solo se reintentan
+las excepciones que enumeras.
+
+Cuando `@service_client` activa ambas funciones, el post-procesador las
+envuelve en el orden correcto: cortacircuitos *fuera*, reintento *dentro*.
+Una única llamada lógica intenta hasta `max_attempts` reintentos antes de
+que el cortacircuitos registre un fallo. Un circuito abierto rechaza la
+llamada de inmediato, saltándose por completo el bucle de reintentos.
+
+### Excepciones de error tipadas
+
+Cuando el servicio remoto devuelve una respuesta 4xx o 5xx, el método
+generado lanza una excepción tipada en vez de devolver la carga útil de
+error como si fuera un éxito. La jerarquía de excepciones vive en
+`pyfly.client.exceptions`: importa de ahí las clases que quieras capturar
+(por ejemplo,
+`from pyfly.client.exceptions import ServiceNotFoundException`):
+
+| Estado | Clase de excepción | `retryable` |
+|---|---|---|
+| 400 | `ServiceValidationException` | False |
+| 401 / 403 | `ServiceAuthenticationException` | False |
+| 404 | `ServiceNotFoundException` | False |
+| 409 | `ServiceConflictException` | False |
+| 422 | `ServiceUnprocessableEntityException` | False |
+| 429 | `ServiceRateLimitException` | True |
+| 5xx | `ServiceUnavailableException` | True |
+
+Todas las excepciones extienden `ServiceClientException` (que a su vez es
+una `InfrastructureException`). La marca `retryable` en
+`ServiceRateLimitException` y `ServiceUnavailableException` le indica al
+post-procesador qué excepciones pasar a la política de reintentos. Los
+errores de validación 4xx y los 404 nunca se reintentan.
+
+!!! note "Qué acaba de pasar"
+ Las tres piezas de resiliencia encajan como cajas anidadas. Las
+ **excepciones tipadas** clasifican *qué tipo* de fallo ocurrió: un 404
+ no es reintentable, un 503 sí. La **política de reintentos** usa esa
+ clasificación para decidir si volver a intentarlo. El **cortacircuitos**
+ se sitúa fuera del bucle de reintentos y cuenta los fallos sostenidos
+ para poder dejar de llamar a un servicio que está genuinamente caído.
+ No escribiste nada de este pegamento: `@service_client` lo ensambló en
+ el momento en que pusiste `circuit_breaker=True` y `retry=3`.
+
+### Configurar valores por defecto en pyfly.yaml
+
+Las anulaciones por servicio en `@service_client` siempre tienen
+prioridad. Establecer valores por defecto a nivel de proceso en
+`pyfly.yaml` permite que los nuevos clientes hereden valores sensatos sin
+repetirlos en cada decorador:
+
+::: listing pyfly.yaml | Listado 11.5 — Valores por defecto de resiliencia de cliente en pyfly.yaml
+pyfly:
+ client:
+ timeout: 10
+ retry:
+ max-attempts: 3
+ base-delay: 1.0
+ circuit-breaker:
+ failure-threshold: 5
+ recovery-timeout: 30
+:::
+
+| Clave | Descripción | Por defecto |
+|---|---|---|
+| `pyfly.client.timeout` | Tiempo de espera de la petición en segundos | `30` |
+| `pyfly.client.retry.max-attempts` | Total de intentos, incluido el primero | `3` |
+| `pyfly.client.retry.base-delay` | Retardo base en segundos | `1.0` |
+| `pyfly.client.circuit-breaker.failure-threshold` | Fallos consecutivos para abrir | `5` |
+| `pyfly.client.circuit-breaker.recovery-timeout` | Segundos antes de sondear | `30` |
+
+`ClientAutoConfiguration` lee `pyfly.client.timeout` en el arranque y se lo
+pasa a `HttpxClientAdapter`. Los submapas `retry` y `circuit-breaker` se
+reenvían como `default_retry` y `default_circuit_breaker` a
+`HttpClientBeanPostProcessor`. Cualquier valor establecido directamente en
+`@service_client(circuit_breaker_failure_threshold=...)` anula el valor por
+defecto.
+
+!!! note "Precedencia, en lenguaje llano"
+ Dos capas pueden ajustar estos mandos: los valores por defecto globales
+ de `pyfly.yaml` y los argumentos del decorador por cliente. El
+ decorador siempre gana. Piensa en `pyfly.yaml` como el estilo de la
+ casa que cada nuevo cliente hereda, y en el decorador como el lugar para
+ anular ese estilo para un servicio anterior particularmente exigente.
+
+**Ejecútalo — confirma que se lee la configuración.** Tras añadir el bloque
+a `pyfly.yaml`, vuelve a leer los valores a través del `Config` de PyFly
+para asegurarte de que las claves están bien escritas (una causa habitual
+de "mi tiempo de espera se está ignorando" es una errata). Ejecuta esto
+desde la raíz del proyecto, donde vive `pyfly.yaml`:
+
+```
+uv run python -c "
+from pyfly.core.config import Config
+cfg = Config.from_sources('.')
+print('timeout:', cfg.get('pyfly.client.timeout'))
+print('cb:', cfg.get('pyfly.client.circuit-breaker'))
+"
+```
+
+Salida esperada una vez que el Listado 11.5 está en su sitio:
+
+```
+timeout: 10
+cb: {'failure-threshold': 5, 'recovery-timeout': 30}
+```
+
+Antes de añadir el bloque, `timeout` reporta `30` —el valor por defecto del
+framework procedente de `pyfly-defaults.yaml`—, lo que confirma que la
+anulación es lo que lo cambió. Si una clave vuelve como `None`, comprueba la
+indentación en `pyfly.yaml`: el anidamiento de YAML es sensible a los
+espacios en blanco, y un `circuit-breaker:` mal alineado aterriza en
+silencio bajo el padre equivocado.
+
+!!! tip "Establece tiempos de espera bajos por servicio"
+ El valor por defecto `timeout: 30` es conservador. En producción, cada
+ servicio debería llevar una anulación en `pyfly.yaml` ajustada a su SLA.
+ Una llamada de pagos que debería completarse en 500 ms debería tener
+ `timeout: 2` —no 30 s— para que una instancia lenta de Payments falle
+ rápido y el cortacircuitos pueda abrirse antes de que los hilos se
+ acumulen.
+
+---
+
+## Autenticación, descubrimiento y deduplicación
+
+### Propagar la identidad aguas abajo
+
+Cuando el servicio Wallet llama a Payments, a menudo necesita llevar la
+identidad de quien llama —un JWT o un token de servicio interno— para que
+Payments pueda imponer sus propias reglas de autorización. El parámetro
+`headers` recibe un trato especial por parte del post-procesador: cuando un
+método stub declara `headers: dict`, el valor se reenvía como cabeceras de
+la petición HTTP, no se serializa como una cadena de consulta.
+
+!!! note "JWT, en lenguaje llano"
+ Un **JWT** (JSON Web Token) es una cadena firmada que viaja en la
+ cabecera `Authorization` y demuestra quién es quien llama. Cuando Wallet
+ reenvía el JWT de quien llama a Payments, Payments puede volver a
+ comprobarlo y aplicar sus propias reglas: la identidad se transporta a
+ través de la frontera de red en lugar de restablecerse desde cero.
+
+Para reenviar cabeceras, añades un único parámetro opcional. No hay ningún
+decorador nuevo ni configuración especial:
+
+- **Añade `headers: dict | None = None` al método.** El nombre `headers` es
+ la palabra mágica: el post-procesador lo reconoce y enruta su valor a las
+ cabeceras HTTP en lugar de a la cadena de consulta.
+- **Pasa un dict en el punto de llamada.** El manejador suministra
+ `headers={"Authorization": f"Bearer {token}"}`, y PyFly lo adjunta a la
+ petición saliente.
+
+::: listing lumen/sdk/payments_client_auth.py | Listado 11.6 — Reenvío de cabeceras de autenticación por llamada
+from __future__ import annotations
+
+from pyfly.client import get, post, service_client
+
+
+@service_client(
+ base_url="http://payments-service:8080",
+ circuit_breaker=True,
+ retry=3,
+)
+class AuthenticatedPaymentsClient:
+ """Payments client that forwards caller identity on each request."""
+
+ @post("/payments")
+ async def create_payment(
+ self,
+ body: dict,
+ headers: dict | None = None,
+ ) -> dict:
+ """POST /payments — body is the JSON payload; headers forwarded."""
+ ...
+
+ @get("/payments/{payment_id}")
+ async def get_payment(
+ self,
+ payment_id: str,
+ headers: dict | None = None,
+ ) -> dict:
+ """GET /payments/:payment_id — headers are forwarded."""
+ ...
+:::
+
+**Cómo funciona:** El post-procesador comprueba si un parámetro llamado
+`headers` está presente en los argumentos enlazados y es un `dict`. Cuando
+se cumplen ambas condiciones, extrae el valor del conjunto de parámetros de
+consulta y lo reenvía como cabeceras de la petición HTTP. El manejador pasa
+la cabecera `Authorization` entrante (o un token de servicio recién
+acuñado) como `headers={"Authorization": f"Bearer {token}"}`.
+
+`HttpxClientAdapter` también llama a `inject_headers(headers)` en cada
+petición, propagando las cabeceras W3C `traceparent` y `tracestate` del
+contexto de observabilidad actual, de modo que las trazas distribuidas se
+cosen a través de las fronteras de servicio sin ningún trabajo a nivel de
+aplicación.
+
+!!! note "Patrones de identidad servicio a servicio"
+ Para servicios internos en una red de confianza, un secreto compartido
+ en una cabecera `X-Internal-Token` es el enfoque más sencillo. Para
+ arquitecturas de confianza cero (zero-trust), considera mTLS (TLS mutuo
+ en la capa de infraestructura) o una malla de servicios (service mesh)
+ que inyecte certificados de identidad. Para llamadas delegadas por el
+ usuario, reenvía el JWT original. Sea cual sea el patrón que elijas, el
+ parámetro `headers` te da un punto de inyección limpio en el cliente
+ declarativo.
+
+### Descubrimiento de servicios
+
+Cuando `base_url` es una cadena estática como
+`http://payments-service:8080`, dependes del descubrimiento basado en DNS:
+un `Service` de Kubernetes o un registro de Consul resuelve
+`payments-service` a la IP de clúster correcta. Este es el punto de partida
+recomendado y suficiente para la mayoría de los despliegues.
+
+Para entornos que necesitan resolución dinámica de URL (múltiples entornos
+detrás de la misma clase cliente, enrutamiento controlado por feature
+flags), suministra la URL a través de la configuración en su lugar:
+
+::: listing pyfly.yaml | Listado 11.7 — URL base por entorno en pyfly.yaml
+pyfly:
+ client:
+ timeout: 10
+
+services:
+ payments:
+ base-url: "${PAYMENTS_SERVICE_URL:http://payments-service:8080}"
+:::
+
+Un bean factoría fino lee la clave de configuración y construye el
+post-procesador con una factoría personalizada que inyecta la URL resuelta.
+La clase cliente en sí no cambia; solo cambia la factoría.
+
+### Deduplicación de peticiones
+
+Las operaciones financieras deben ser idempotentes en la capa HTTP. Si
+`create_payment` se llama, agota su tiempo de espera y se reintenta,
+Payments no debe crear dos registros de pago. El mecanismo estándar es una
+cabecera **`Idempotency-Key`**: un identificador estable elegido por quien
+llama —típicamente el UUID del comando— que Payments usa para detectar y
+deduplicar peticiones repetidas.
+
+::: listing lumen/core/services/wallets/settle_transfer_idempotent.py | Listado 11.8 — Idempotency-Key reenviada a través del parámetro headers
+from __future__ import annotations
+
+from lumen.core.services.wallets.settle_transfer_command import (
+ SettleTransfer,
+)
+from lumen.models.entities.v1.money import Money
+from lumen.models.repositories.wallet_repository import WalletRepository
+from lumen.sdk.payments_client_auth import AuthenticatedPaymentsClient
+from pyfly.container import service
+from pyfly.cqrs import CommandHandler, command_handler
+from pyfly.domain import AggregateNotFound
+
+
+@command_handler
+@service
+class SettleTransferIdempotentHandler(
+ CommandHandler[SettleTransfer, dict]
+):
+ """Withdraw funds and submit payment with idempotency key."""
+
+ def __init__(
+ self,
+ repository: WalletRepository,
+ payments: AuthenticatedPaymentsClient,
+ ) -> None:
+ super().__init__()
+ self._repository = repository
+ self._payments = payments
+
+ async def do_handle(self, command: SettleTransfer) -> dict:
+ wallet = await self._repository.find(command.wallet_id)
+ if wallet is None:
+ raise AggregateNotFound("Wallet", command.wallet_id)
+
+ wallet.withdraw(
+ Money(amount=command.amount, currency=wallet.currency)
+ )
+ await self._repository.add(wallet)
+
+ idempotency_key = str(command.transfer_id)
+ return await self._payments.create_payment(
+ body={
+ "wallet_id": command.wallet_id,
+ "amount": command.amount,
+ "currency": wallet.currency.value,
+ },
+ headers={"Idempotency-Key": idempotency_key},
+ )
+:::
+
+**Cómo funciona:** `command.transfer_id` es el identificador estable de
+esta operación de negocio, determinado antes de que el comando llegue al
+manejador. Si el manejador se llama de nuevo para el mismo comando —desde un
+reintento, una reentrega o una reproducción de la cola de mensajes muertos
+(dead-letter)— pasa la misma `Idempotency-Key`. Payments almacena la clave
+junto al registro de pago creado y devuelve el registro existente cuando la
+clave ya se ha visto antes, en lugar de crear un segundo pago. Esa
+deduplicación es una preocupación del lado del servidor; el trabajo del
+cliente es simplemente reenviar la clave de forma coherente.
+
+---
+
+## El nivel de experiencia: el BFF
+
+### Por qué el frontend no puede hablar directamente con ambos servicios
+
+Cuando una aplicación móvil o un frontend web necesita mostrar un resumen
+del monedero que incluya instrucciones de pago pendientes, se enfrenta a
+una disyuntiva: llamar a Wallet para el saldo, llamar a Payments para la
+lista de pendientes y fusionar los resultados en el cliente, o hablar con
+una única API que haga la fusión en el lado del servidor. La primera opción
+incurre en dos viajes de ida y vuelta, expone la forma interna de cada
+servicio al cliente y obliga al cliente a implementar reintentos y manejo de
+errores para dos dominios de fallo independientes. La segunda opción es el
+**patrón BFF**.
+
+Un **Backend for Frontend** es un servicio ligero en el *nivel de
+experiencia* que compone respuestas de múltiples servicios de dominio en una
+forma adaptada a las necesidades de un frontend concreto. Se encarga de la
+agregación de respuestas, del renombrado de campos para que coincidan con
+las convenciones del cliente y del cacheo de los resultados compuestos.
+Nunca toca la base de datos directamente: depende por completo de clientes
+de servicios de dominio.
+
+### Construir el BFF de Lumen
+
+El SDK de Lumen ya incluye un `LumenClient` en `lumen/sdk/client.py` que
+envuelve un `httpx.AsyncClient` crudo: recibe un `httpx.AsyncClient` en su
+constructor y llama a `self._http.get(...)`/`self._http.post(...)` a mano,
+lanzando en caso de error con `response.raise_for_status()`. Ese es el
+estilo *imperativo*: útil, explícito y enteramente responsabilidad tuya
+mantenerlo resiliente. En el nivel BFF usas en su lugar el
+`@service_client` declarativo de PyFly: el código que llama tiene el mismo
+aspecto, pero el cortacircuitos y los reintentos vienen incorporados
+automáticamente.
+
+Construiremos el BFF en cuatro piezas pequeñas, cada una en su propio
+archivo:
+
+**Paso 1 — Un `WalletClient`** que sepa cómo alcanzar el servicio Wallet.
+**Paso 2 — Un `PaymentsClient`** con un endpoint extra que el BFF necesita.
+**Paso 3 — Un `WalletSummaryService`** que llame a ambos y fusione los
+resultados. **Paso 4 — Un controlador fino** que exponga la vista
+fusionada.
+
+Empieza con el cliente del lado del monedero.
+
+::: listing lumen_bff/sdk/wallet_client.py | Listado 11.9 — WalletClient para el nivel BFF
+from __future__ import annotations
+
+from pyfly.client import get, service_client
+
+
+@service_client(
+ base_url="http://wallet-service:8080",
+ circuit_breaker=True,
+ retry=3,
+)
+class WalletClient:
+ """Typed HTTP client for the Lumen Wallet service."""
+
+ @get("/api/v1/wallets/{wallet_id}")
+ async def get_wallet(self, wallet_id: str) -> dict:
+ """GET /api/v1/wallets/:wallet_id — fetch a wallet."""
+ ...
+
+ @get("/api/v1/wallets/{wallet_id}/balance")
+ async def get_balance(self, wallet_id: str) -> dict:
+ """GET /api/v1/wallets/:wallet_id/balance — current balance."""
+ ...
+:::
+
+Las rutas reflejan el controlador real de Lumen —`@request_mapping("/api/v1/wallets")` con `@get_mapping("/{wallet_id}")`— así que el cliente del BFF coincide exactamente con lo que el servicio Wallet expone.
+
+El servicio Payments necesita un endpoint `list_pending` que permita al BFF
+consultar registros pendientes por monedero:
+
+::: listing lumen_bff/sdk/payments_client_bff.py | Listado 11.10 — PaymentsClient ampliado para el BFF
+from __future__ import annotations
+
+from pyfly.client import get, post, service_client
+
+
+@service_client(
+ base_url="http://payments-service:8080",
+ circuit_breaker=True,
+ retry=3,
+)
+class PaymentsClient:
+ """Typed HTTP client for Payments (BFF edition)."""
+
+ @post("/payments")
+ async def create_payment(self, body: dict) -> dict:
+ """POST /payments — submit a payment instruction."""
+ ...
+
+ @get("/payments/{payment_id}")
+ async def get_payment(self, payment_id: str) -> dict:
+ """GET /payments/:payment_id — fetch a payment by ID."""
+ ...
+
+ @get("/payments")
+ async def list_pending(self, wallet_id: str) -> list:
+ """GET /payments?wallet_id=... — list payments for a wallet."""
+ ...
+:::
+
+El servicio BFF compone entonces el saldo del monedero con la lista de
+pagos pendientes en una única respuesta.
+
+!!! note "asyncio.gather, en lenguaje llano"
+ `asyncio.gather(a, b)` arranca las corrutinas `a` y `b` al mismo tiempo
+ y espera a que ambas terminen, devolviendo sus resultados como una
+ lista. Para un BFF esto significa que dos llamadas aguas arriba se
+ solapan en vez de encolarse una tras otra. Añadir
+ `return_exceptions=True` cambia una cosa: en lugar de que todo el
+ `gather` reviente cuando una llamada falla, la excepción de la llamada
+ fallida se devuelve como el *resultado* de esa llamada, de modo que
+ puedes inspeccionarla y aun así usar la exitosa.
+
+::: listing lumen_bff/application/bff_service.py | Listado 11.11 — Servicio BFF que compone Wallet + Payments
+from __future__ import annotations
+
+import asyncio
+
+from lumen_bff.sdk.payments_client_bff import PaymentsClient
+from lumen_bff.sdk.wallet_client import WalletClient
+from pyfly.container import service
+
+
+@service
+class WalletSummaryService:
+ """Composes wallet balance and pending payments into one view.
+
+ Calls both domain services concurrently using asyncio.gather so the
+ total latency is max(wallet_latency, payments_latency) rather than
+ their sum.
+ """
+
+ def __init__(
+ self,
+ wallet: WalletClient,
+ payments: PaymentsClient,
+ ) -> None:
+ self._wallet = wallet
+ self._payments = payments
+
+ async def get_summary(self, wallet_id: str) -> dict:
+ """Return a unified summary for the given wallet."""
+ wallet_data, pending = await asyncio.gather(
+ self._wallet.get_wallet(wallet_id),
+ self._payments.list_pending(wallet_id),
+ return_exceptions=True,
+ )
+
+ balance_minor: int = 0
+ if isinstance(wallet_data, dict):
+ balance_minor = wallet_data.get("balance_minor", 0)
+
+ pending_list: list = []
+ if isinstance(pending, list):
+ pending_list = pending
+
+ return {
+ "wallet_id": wallet_id,
+ "balance_minor": balance_minor,
+ "pending_payments": pending_list,
+ }
+:::
+
+**Cómo funciona — el patrón de composición:**
+
+`asyncio.gather(...)` dispara ambas llamadas aguas arriba de forma
+concurrente. Las llamadas a wallet y a payments se ejecutan en paralelo,
+así que la latencia compuesta está acotada por la más lenta de las dos en
+vez de por su suma: a 50 ms por servicio, las llamadas secuenciales cuestan
+100 ms mientras que las concurrentes cuestan aproximadamente 55 ms.
+
+`return_exceptions=True` es crítico para un BFF. Sin él, un único fallo
+aguas arriba lanza una excepción y quien llama no recibe nada. Con él, una
+corrutina fallida devuelve su objeto excepción como resultado en lugar de
+propagarlo. El servicio inspecciona cada resultado con
+`isinstance(wallet_data, dict)` y degrada con elegancia: devuelve una
+respuesta parcial con un saldo cero o una lista de pagos vacía en vez de un
+HTTP 500. El BFF debería hacer esa decisión explícita en la forma de su
+respuesta, por ejemplo incluyendo una clave `"errors"` que enumere los
+campos degradados.
+
+El nombre de campo `balance_minor` sigue la convención de Lumen: las
+cantidades se almacenan como unidades menores enteras (céntimos) y el campo
+se llama `balance_minor` en todas partes: en `WalletDto`, en las respuestas
+de depósito/reintegro y aquí en el resumen del BFF.
+
+Cada envoltura `@service_client` sobre `WalletClient` y `PaymentsClient`
+gestiona los reintentos y el cortacircuitos para su llamada aguas arriba de
+forma independiente. Si Payments tiene el circuito abierto, el saldo del
+monedero sigue apareciendo; solo la lista de pagos pendientes está vacía.
+
+### El controlador del BFF
+
+El BFF expone su respuesta compuesta a través de un manejador web estándar
+de PyFly. El controlador es deliberadamente fino: su única misión es
+delegar en el servicio:
+
+::: listing lumen_bff/web/controllers/summary_controller.py | Listado 11.12 — Controlador del BFF
+from __future__ import annotations
+
+from lumen_bff.application.bff_service import WalletSummaryService
+from pyfly.container import rest_controller
+from pyfly.web import get_mapping, request_mapping
+
+
+@rest_controller
+@request_mapping("/api/v1/wallets")
+class WalletSummaryController:
+ """Experience-tier controller for the wallet summary view."""
+
+ def __init__(self, summary: WalletSummaryService) -> None:
+ self._summary = summary
+
+ @get_mapping("/{wallet_id}/summary")
+ async def get_wallet_summary(self, wallet_id: str) -> dict:
+ """GET /api/v1/wallets/:wallet_id/summary"""
+ return await self._summary.get_summary(wallet_id)
+:::
+
+**Cómo funciona:** El controlador del BFF no importa ningún modelo de
+dominio y no toca ningún repositorio: depende solo de
+`WalletSummaryService`, que a su vez depende solo de interfaces de cliente
+tipadas. La cadena de dependencias es controlador → servicio BFF →
+clientes declarativos → HTTP remoto. Cada capa es comprobable de forma
+independiente: el controlador con un servicio simulado (mock), el servicio
+con clientes simulados, y los clientes con un `HttpClientPort` simulado.
+
+**Ejecútalo — llama al endpoint compuesto.** Con la aplicación BFF en
+ejecución (`uv run pyfly run --server uvicorn`) y los servicios Wallet y
+Payments alcanzables, llama a la ruta del resumen con un id de monedero que
+ya hayas creado:
+
+```
+curl -s http://localhost:8080/api/v1/wallets/wal-123/summary
+```
+
+Forma esperada (los valores dependen de tus datos):
+
+```
+{"wallet_id": "wal-123", "balance_minor": 5000, "pending_payments": []}
+```
+
+La respuesta única fusiona dos servicios aguas arriba. Para ver la
+degradación elegante en acción, detén el servicio Payments y vuelve a
+llamar: el `balance_minor` sigue volviendo desde Wallet, y
+`pending_payments` recurre a `[]` en lugar de que toda la petición devuelva
+un 500. Eso es `return_exceptions=True` haciendo su trabajo.
+
+!!! note "Puerto por defecto de la aplicación"
+ PyFly sirve la aplicación en `pyfly.server.port`, cuyo valor por
+ defecto es `8080` (coincidiendo con `server.port` de Spring). Anúlalo
+ con la variable de entorno `PYFLY_SERVER_PORT` o con la clave de
+ configuración `pyfly.server.port`. Ten en cuenta que el actuator y el
+ panel de administración se ejecutan en un puerto de gestión *separado*
+ —`pyfly.management.server.port`, por defecto `9090`— de modo que los
+ endpoints de salud e información nunca chocan con tus rutas de API.
+
+!!! note "Alcance del BFF y propiedad del equipo"
+ Un BFF tiene como alcance un frontend o un recorrido de usuario, no uno
+ por microservicio. Lumen podría tener un `lumen-mobile-bff` y un
+ `lumen-web-bff`, cada uno componiendo los mismos servicios de dominio
+ pero devolviendo formas optimizadas para sus respectivos clientes. El
+ BFF es propiedad del equipo de frontend, no del equipo de dominio. Los
+ servicios de dominio exponen contratos estables; los BFF adaptan esos
+ contratos a formas específicas del cliente sin acoplar los servicios de
+ dominio a las convenciones de ningún frontend en particular.
+
+!!! spring "Equivalencia con Spring"
+ El patrón BFF en PyFly refleja el enfoque API Gateway / BFF de Spring
+ Boot, donde una aplicación fina de Spring Boot agrega respuestas de
+ múltiples microservicios. En la pila reactiva de Spring, `Mono.zip()`
+ proporciona la misma agregación concurrente que `asyncio.gather()`
+ proporciona en Python. El `@FeignClient` en el BFF se corresponde con
+ `@service_client` en PyFly; el enfoque del `WebClient` de Spring de
+ encadenar llamadas `.flatMap()` se corresponde con la combinación de
+ `asyncio.gather()` + manejo de errores con `isinstance` de PyFly. El
+ modelo de propiedad por equipos —el BFF es propiedad del equipo de
+ frontend, los servicios de dominio son propiedad de los equipos de
+ dominio— es idéntico.
+
+---
+
+## Lo que construiste {.recap}
+
+Empezaste este capítulo con un único servicio Lumen y lo terminaste con una
+arquitectura que escala en múltiples dimensiones. Esto es lo que cambió y
+por qué importa.
+
+**Extrajiste PaymentsService.** Payments ahora se ejecuta en su propio
+proceso, con su propia canalización de despliegue y su propio almacén de
+datos. Los manejadores de Wallet no saben nada de cómo Payments almacena
+los registros de pago ni qué motor de base de datos usa: todo lo que ven es
+la interfaz tipada que `PaymentsClient` expone.
+
+**Declaraste el cliente, no la implementación.** `@service_client` con
+stubs `@post`, `@get`, `@patch` y `@delete` te dio una interfaz tipada que
+los IDE navegan y los verificadores de tipos comprueban.
+`HttpClientBeanPostProcessor` generó la implementación HTTP en el arranque,
+leyó la configuración de resiliencia de `pyfly.yaml` y registró el bean para
+inyectarlo en cualquier sitio.
+
+**Hiciste la red resiliente.** `circuit_breaker=True` y `retry=3` en el
+decorador envolvieron cada método con un cortacircuitos y una política de
+reintentos compartidos —cortacircuitos fuera, reintento dentro— de modo que
+una caída sostenida de Payments abre el circuito rápido mientras que los
+errores transitorios se recuperan automáticamente. Las excepciones tipadas
+(`ServiceNotFoundException`, `ServiceUnavailableException`) dan a quien
+llama una señal limpia sin exponer códigos de estado HTTP en crudo.
+
+**Introdujiste el nivel BFF.** `WalletSummaryService` compone dos llamadas
+aguas arriba con `asyncio.gather`, devuelve una respuesta parcial cuando uno
+de los servicios está degradado y expone un único contrato al frontend. El
+BFF absorbe el ciclo de publicación independiente de cada servicio de
+dominio y protege al frontend de sus formas internas.
+
+Tres principios atraviesan el resto de la Parte IV:
+
+- **Depende del cliente tipado, no de `httpx` directamente.** La
+ declaración es tu contrato; la implementación es un detalle del framework.
+- **La resiliencia pertenece a la capa de cliente.** Configúrala una vez en
+ `@service_client`; cada manejador que use el cliente la hereda.
+- **Los BFF componen; los servicios de dominio proveen.** Los servicios de
+ dominio poseen contratos estables y de grano fino; los BFF poseen las
+ composiciones de grano grueso que necesitan frontends concretos.
+
+---
+
+## Pruébalo tú mismo {.exercises}
+
+1. **Añade un cuarto endpoint y verifica la interpolación de rutas.** Amplía
+ `PaymentsClient` con un método `@get("/payments")` que acepte
+ `wallet_id: str` y `status: str = "pending"` como parámetros. Llámalo
+ desde una prueba y afirma que la petición HTTP generada es
+ `GET /payments?wallet_id=abc&status=pending`. Verifica que cambiar el
+ valor por defecto a `status="completed"` y llamar al método sin un
+ argumento `status` envía `status=completed` en la cadena de consulta.
+
+2. **Prueba el BFF con servicios aguas arriba degradados.** Escribe una
+ prueba unitaria para `WalletSummaryService.get_summary` que simule
+ `WalletClient.get_wallet` para que tenga éxito y
+ `PaymentsClient.list_pending` para que lance `ServiceUnavailableException`
+ (impórtala con
+ `from pyfly.client.exceptions import ServiceUnavailableException`).
+ Afirma que el método devuelve un dict con el `balance_minor` correcto y
+ una lista `pending_payments` vacía, confirmando que el repliegue a
+ respuesta parcial funciona y que un único fallo aguas arriba no se
+ propaga como excepción hacia quien llama al BFF. Ejecútala con
+ `uv run --extra dev pytest tests/test_wallet_summary.py -q` y busca
+ `1 passed`.
+
+3. **Ajusta los umbrales del cortacircuitos para un servicio aguas arriba
+ frágil.** Supón que Payments tiene una ventana conocida de
+ inestabilidad durante su ejecución nocturna por lotes: devuelve 503 en
+ aproximadamente el 20 % de las peticiones durante unos 10 segundos antes
+ de estabilizarse. Configura `PaymentsClient` con
+ `circuit_breaker_failure_threshold=2` y
+ `circuit_breaker_recovery_timeout=15.0` y escribe una prueba usando un
+ `HttpClientPort` simulado que simule dos fallos consecutivos seguidos de
+ un éxito. Afirma que la tercera llamada (el sondeo tras el tiempo de
+ espera de recuperación) tiene éxito y que el circuito transita de vuelta
+ a `CLOSED`.
diff --git a/book/manuscript-es/12-sagas.md b/book/manuscript-es/12-sagas.md
new file mode 100644
index 00000000..15d4a3ea
--- /dev/null
+++ b/book/manuscript-es/12-sagas.md
@@ -0,0 +1,1272 @@
+Capítulo 12
+
+# Transacciones distribuidas: sagas, flujos de trabajo y TCC {.chtitle}
+
+::: figure art/openers/ch12.svg |
+
+El Capítulo 10 envió los eventos del monedero de Lumen a través de las
+fronteras de proceso mediante Kafka. El Capítulo 11 dividió la aplicación
+en servicios que cooperan entre sí y mostró cómo invocarlos por HTTP.
+Ambos pasos desbloquearon escala y autonomía de los equipos, pero también
+expusieron una nueva clase de peligro: varios agregados —o varios
+servicios— pueden necesitar cambiar su estado como parte de una sola
+operación de negocio, sin ninguna transacción ACID distribuida que te
+proteja.
+
+Imagina una transferencia entre monederos de Lumen. Cargas el monedero
+origen y luego abonas el destino. Si se carga el origen y el abono falla
+—moneda incorrecta, monedero inexistente— el dueño del origen pierde dinero
+sin que se haya depositado nada en el otro lado. No puedes envolver dos
+llamadas independientes al repositorio en un único `BEGIN … COMMIT` cuando
+cada agregado posee su propia frontera de consistencia, y el commit de dos
+fases entre agregados independientes es operativamente frágil.
+
+La respuesta es la **consistencia eventual con compensación explícita**.
+Cada paso confirma su propio almacén de forma independiente, y diseñas una
+ruta de recuperación —una **transacción de compensación**— para cada paso
+que pudiera tener éxito antes de que otro posterior falle. Cuando toda la
+secuencia tiene éxito obtienes tu resultado de negocio; cuando cualquier
+paso falla, el motor recorre hacia atrás los pasos completados, invocando
+cada compensación para restaurar un estado consistente. Este capítulo te
+muestra cómo construirlo con el módulo `pyfly.transactional` de PyFly.
+
+Modelarás la transferencia de dinero como una **saga orquestada**: una
+clase central que declara cada paso y su compensación, organizada en un DAG
+(grafo acíclico dirigido) para que el motor pueda ejecutar en paralelo los
+pasos independientes. Después explorarás la compensación en profundidad, el
+patrón **Workflow** (flujo de trabajo) para flujos de larga duración o con
+intervención humana, y **TCC (Try-Confirm-Cancel)** como alternativa basada
+en reservas. Una sección final muestra cómo la persistencia conectable
+permite al motor sobrevivir a una caída de proceso y reanudar
+automáticamente las ejecuciones obsoletas.
+
+---
+
+## El problema de las escrituras distribuidas
+
+Antes de escribir nada de código, concretemos los modos de fallo.
+
+### Dos agregados, sin red de seguridad
+
+La transferencia entre monederos de Lumen opera sobre dos agregados
+`Wallet` almacenados en el mismo esquema de PostgreSQL pero tratados como
+objetos de dominio independientes: cada uno se carga, se muta y se guarda en
+su propio viaje de ida y vuelta. Los dos pasos son:
+
+1. **Cargar el origen** — retira `amount` del `Wallet` origen (impone `balance >= 0`).
+2. **Abonar el destino** — deposita `amount` en el `Wallet` destino (impone coincidencia de moneda).
+
+En un monolito, ambas escrituras podrían compartir una sola transacción de
+base de datos. En el servicio de dominio de Lumen cada paso es una llamada
+independiente al repositorio. Una discrepancia de moneda en el destino, o un
+ID de monedero inexistente, hace que el paso 2 falle después de que el paso
+1 ya haya confirmado, dejando el monedero origen cargado y el destino sin
+cambios. El usuario pierde dinero.
+
+Reintentar toda la operación no es seguro: podrías cargar el origen dos
+veces. Saltarse silenciosamente el paso fallido deja los saldos
+inconsistentes. Necesitas un patrón con principios que confirme cada paso de
+forma independiente y deshaga de forma consistente cada paso completado
+cuando hay un fallo.
+
+### Consistencia eventual y compensación
+
+Una **saga** descompone la operación en una secuencia de transacciones
+locales, cada una confirmando su propio almacén de forma independiente.
+Cuando un paso falla, el motor ejecuta **transacciones de compensación** en
+orden inverso para cada paso completado. Las compensaciones no son rollbacks
+de base de datos; son *deshacer semánticos*: nuevas operaciones hacia
+delante que revierten el efecto. "Reabonar el monedero origen" es una nueva
+operación de depósito que restaura el saldo original, no un rollback.
+
+!!! note "Las sagas son eventualmente consistentes"
+ Una saga no te ofrece serializabilidad ni aislamiento. Entre el momento en que se carga el monedero origen y el momento en que se abona el monedero destino, otra petición podría leer el monedero origen y ver un saldo inferior al que finalmente tendrá. Este es el compromiso que aceptas cuando eliges operar sobre agregados independientes sin un bloqueo distribuido. Las sagas te dan *consistencia al final* —o todos los pasos hacia delante confirmaron o todos quedaron compensados— no *consistencia en cada punto*.
+
+---
+
+## Una saga orquestada
+
+El módulo `pyfly.transactional` de PyFly proporciona los decoradores
+`@saga` y `@saga_step`. Declaras una clase por saga, anotas cada método
+como un paso con su compensación, y declaras el orden de dependencias. El
+motor descubre la clase a través del contenedor de inyección de
+dependencias, construye un DAG validado en el arranque y dirige la ejecución
+de forma asíncrona.
+
+!!! note "Término nuevo: orquestación"
+ *Orquestación* significa que un componente central —aquí, el `SagaEngine` de PyFly— decide el orden en que se ejecutan los pasos y qué hacer cuando uno falla. La alternativa, la *coreografía*, hace que cada servicio reaccione a eventos sin un director central. Este capítulo usa orquestación porque hace que la ruta de recuperación sea explícita y fácil de probar: el motor posee las reglas, tu clase de saga solo declara los pasos.
+
+Construiremos la saga de transferencia en cuatro movimientos: encender el
+motor, declarar la clase de saga, observar el DAG que el motor construye a
+partir de ella y, por último, invocar el motor desde un servicio. Vamos uno
+a uno.
+
+### Activar el motor
+
+El motor transaccional se activa mediante el decorador de arranque
+(*starter*) `@enable_domain_stack` sobre tu clase de aplicación, y ese único
+decorador es todo lo que necesitas. No hace falta YAML adicional. En Lumen:
+
+**Añade el decorador de arranque.** Abre tu clase de aplicación y apila
+`@enable_domain_stack` por encima de `@pyfly_application`. Un decorador de
+*arranque* (*starter*) es la forma que tiene PyFly de encender un área de
+funcionalidad completa (aquí, el motor transaccional y su cableado de
+inyección de dependencias) sin que tengas que registrar cada componente a
+mano; el equivalente en Spring es una anotación `@EnableXxx`.
+
+::: listing lumen/app.py | Listado 12.1 — Activar el motor transaccional mediante el domain stack
+from pyfly.core import pyfly_application
+from pyfly.starters.domain import enable_domain_stack
+
+
+@enable_domain_stack
+@pyfly_application(
+ name="lumen",
+ scan_packages=[
+ "lumen.models.repositories",
+ "lumen.core.services.transfers",
+ # ... other packages
+ ],
+)
+class LumenApplication:
+ pass
+:::
+
+**Apagarlo, o encenderlo bajo un starter más reducido.**
+`@enable_domain_stack` ya establece `pyfly.transactional.enabled: true` por
+ti, así que el motor está activo en cuanto el decorador está sobre la clase.
+Esa misma propiedad es la palanca a la que recurres en dos situaciones:
+
+- **Para apagar el motor** bajo el domain stack, ponla en `false` en
+ `application.yaml`: la autoconfiguración se condiciona a que el valor sea
+ exactamente `"true"`, así que cualquier otra cosa lo deshabilita.
+- **Para encenderlo bajo un starter más reducido** como `@enable_core_stack`
+ (que *no* incluye el motor transaccional), añade tú mismo la propiedad:
+
+```yaml
+pyfly:
+ transactional:
+ enabled: true
+```
+
+!!! note "Pruébalo: confirma que el motor quedó cableado"
+ Arranca la aplicación en su puerto por defecto (`pyfly.server.port` es `8080` en la v26.6.110) y observa el log de arranque:
+
+ ```bash
+ uv run pyfly run
+ ```
+
+ Entre las líneas de arranque deberías ver registrarse los componentes transaccionales, por ejemplo:
+
+ ```
+ INFO pyfly.starters.domain domain stack enabled: transactional engine active
+ INFO pyfly.transactional registered saga 'money-transfer' (2 steps)
+ INFO pyfly.server Uvicorn running on http://0.0.0.0:8080
+ ```
+
+ Si pones explícitamente `transactional.enabled: false` (o habilitas solo el core stack sin añadir la propiedad), la línea de la saga nunca aparece y `SagaEngine.execute(...)` lanzará más adelante `ValueError: Saga 'money-transfer' is not registered`. Ver la línea `registered saga` es tu prueba de que el cableado funcionó.
+
+**Cómo funciona:** `@enable_domain_stack` fusiona
+`DOMAIN_STACK_PROPERTIES` en la configuración activa, y ese diccionario ya
+contiene `pyfly.transactional.enabled: "true"`, así que el decorador
+*registra y* activa el motor en un solo movimiento. La autoconfiguración,
+`TransactionalEngineAutoConfiguration`, está protegida por
+`@conditional_on_property("pyfly.transactional.enabled", having_value="true")`;
+como el starter puso el valor en `"true"`, la condición coincide y la
+autoconfiguración cablea cada componente del motor —`SagaEngine`,
+`TccEngine`, `WorkflowEngine`, `SagaRegistry`,
+`InMemoryPersistenceAdapter` y `LoggerEventsAdapter`— en el contenedor de
+inyección de dependencias. El `OrchestrationBeanPostProcessor` escanea
+entonces cada bean producido en el arranque: cualquier bean que lleve
+metadatos `__pyfly_saga__` se registra automáticamente en `SagaRegistry`.
+Nunca llamas a `registry.register_from_bean()` en código de producción.
+
+!!! note "Qué acaba de pasar"
+ Un pequeño cambio —un único decorador— te dio un motor de sagas completamente cableado. `@enable_domain_stack` declaró los componentes y los encendió (establece `pyfly.transactional.enabled: true` por ti), y un post-procesador de beans de arranque encontró tus clases de saga y las registró por ti. A partir de aquí solo escribes clases de saga y llamas a `SagaEngine.execute(...)`; la fontanería está hecha.
+
+### Declarar la saga de transferencia
+
+La transferencia entre monederos de Lumen es una saga de dos pasos: cargar
+el monedero origen y luego abonar el destino. Si el abono falla —moneda
+incorrecta o monedero inexistente— el motor compensa reabonando el origen,
+devolviendo ambos saldos a sus valores originales.
+
+!!! note "Término nuevo: compensación"
+ Una *compensación* (o *transacción de compensación*) es el deshacer de un paso. No es un rollback de base de datos: para cuando compensas, la escritura original ya ha confirmado en su propio almacén. En cambio es una *nueva operación hacia delante* que revierte semánticamente el efecto. El deshacer de "cargar el origen" no es `ROLLBACK`; es "depositar la misma cantidad de vuelta en el origen". Cada paso que cambia el estado necesita una compensación que le corresponda.
+
+Constrúyela en tres movimientos. **Paso 1**: declara la clase y apila los
+decoradores. **Paso 2**: escribe los pasos hacia delante y su compensación.
+**Paso 3**: cablea los parámetros con marcadores de inyección. El archivo
+completo está abajo; luego recorremos cada movimiento.
+
+::: listing lumen/core/services/transfers/money_transfer_saga.py | Listado 12.2 — MoneyTransferSaga: cargo → abono, con compensación
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Annotated
+
+from lumen.core.mappers.wallet_mapper import to_aggregate, to_entity
+from lumen.core.services.transfers.transfer_request import TransferRequest
+from lumen.interfaces.enums.v1.currency import Currency
+from lumen.models.entities.v1.money import Money
+from lumen.models.repositories.wallet_repository import WalletRepository
+from pyfly.container import service
+from pyfly.domain import AggregateNotFound
+from pyfly.transactional.saga.annotations import (
+ FromStep,
+ Input,
+ saga,
+ saga_step,
+)
+from pyfly.transactional.saga.core.context import SagaContext
+
+MONEY_TRANSFER_SAGA = "money-transfer"
+
+
+@dataclass(frozen=True)
+class DebitResult:
+ wallet_id: str
+ amount: int
+ currency: Currency
+ balance: int
+
+
+@saga(name=MONEY_TRANSFER_SAGA)
+@service
+class MoneyTransferSaga:
+ """Debit source wallet, credit destination; compensate on failure."""
+
+ def __init__(self, repository: WalletRepository) -> None:
+ self._repository = repository
+
+ # -- Step 1: debit the source ----------------------------------------
+
+ @saga_step(id="debit-source", compensate="recredit_source")
+ async def debit_source(
+ self,
+ request: Annotated[TransferRequest, Input()],
+ ctx: SagaContext,
+ ) -> DebitResult:
+ entity = await self._repository.find_by_id(
+ request.source_wallet_id
+ )
+ if entity is None:
+ raise AggregateNotFound("Wallet", request.source_wallet_id)
+ wallet = to_aggregate(entity)
+ wallet.withdraw(
+ Money(amount=request.amount, currency=request.currency)
+ )
+ await self._repository.upsert(to_entity(wallet))
+ wallet.clear_events()
+ return DebitResult(
+ wallet_id=request.source_wallet_id,
+ amount=request.amount,
+ currency=request.currency,
+ balance=wallet.balance.amount,
+ )
+
+ async def recredit_source(
+ self,
+ debit: Annotated[DebitResult, FromStep("debit-source")],
+ ) -> int:
+ """Compensation: put the money back. Receives the forward step's
+ result via FromStep — NOT the saga input."""
+ entity = await self._repository.find_by_id(debit.wallet_id)
+ if entity is None:
+ raise AggregateNotFound("Wallet", debit.wallet_id)
+ wallet = to_aggregate(entity)
+ wallet.deposit(
+ Money(amount=debit.amount, currency=debit.currency)
+ )
+ await self._repository.upsert(to_entity(wallet))
+ wallet.clear_events()
+ return wallet.balance.amount
+
+ # -- Step 2: credit the destination ----------------------------------
+
+ @saga_step(id="credit-destination", depends_on=["debit-source"])
+ async def credit_destination(
+ self,
+ request: Annotated[TransferRequest, Input()],
+ ctx: SagaContext,
+ ) -> int:
+ entity = await self._repository.find_by_id(
+ request.destination_wallet_id
+ )
+ if entity is None:
+ raise AggregateNotFound(
+ "Wallet", request.destination_wallet_id
+ )
+ wallet = to_aggregate(entity)
+ wallet.deposit(
+ Money(amount=request.amount, currency=request.currency)
+ )
+ await self._repository.upsert(to_entity(wallet))
+ wallet.clear_events()
+ return wallet.balance.amount
+:::
+
+**Cómo funciona, paso a paso:**
+
+**Paso 1: la pila de decoradores.**
+`@saga(name=MONEY_TRANSFER_SAGA)` estampa `__pyfly_saga__` en la clase con
+el nombre de la saga. El decorador solo adjunta metadatos: no envuelve la
+clase ni crea un proxy. **El requisito crítico** es que `@saga` debe
+apilarse *por encima de* `@service`. La anotación `@service` hace que el
+contenedor de inyección de dependencias instancie y escanee el bean en el
+arranque; el gancho `OrchestrationBeanPostProcessor.after_init()` ve
+entonces `__pyfly_saga__` en el bean y llama a
+`SagaRegistry.register_from_bean()`. Sin `@service`, la clase nunca se
+escanea y la saga no puede ejecutarse por nombre.
+
+!!! warning "El orden de los decoradores no es opcional"
+ Lee la pila de arriba abajo: `@saga` está *por encima* de `@service`. Intercámbialos —`@service` por encima de `@saga`— y el bean sigue registrándose con la inyección de dependencias, pero los metadatos de la saga se aplican al objeto ya envuelto, el post-procesador nunca los encuentra y `execute("money-transfer")` falla con `ValueError: Saga 'money-transfer' is not registered`. Si te topas con ese error, comprueba primero el orden de los decoradores.
+
+**Paso 2: los métodos de paso.**
+`@saga_step` adjunta los metadatos `__pyfly_saga_step__` directamente al
+método asíncrono —sin envoltura, sin proxy— así que
+`inspect.iscoroutinefunction` sigue devolviendo `True` y el motor hace
+correctamente `await` de la llamada. El parámetro
+`compensate="recredit_source"` nombra el *método de la misma clase* que se
+invoca al deshacer este paso. Omitir `depends_on` (o pasar `[]`) significa
+que el paso puede ejecutarse en cuanto el motor arranca.
+
+**Interacción con el repositorio: el ciclo cargar-mutar-guardar.** Cada
+paso sigue el mismo patrón de tres fases, usando el repositorio del
+framework `WalletRepository(Repository[WalletEntity, str])`:
+
+1. `find_by_id(id)` — carga la fila cruda `WalletEntity` desde la base de datos.
+2. `to_aggregate(entity)` — rehidrata la rica raíz de agregado `Wallet` a
+ partir de esa fila; el agregado impone todas las invariantes
+ (`balance >= 0`, coincidencia de moneda).
+3. Mutar — llama a `wallet.withdraw(...)` o `wallet.deposit(...)` sobre el
+ agregado, dejando que lance `BusinessRuleViolation` si se rompe una
+ invariante antes de que ocurra ninguna escritura.
+4. `upsert(to_entity(wallet))` — aplana el agregado mutado de vuelta a un
+ `WalletEntity` y llama a `session.merge` + `flush`, de modo que la
+ escritura es visible para los pasos posteriores en la misma
+ `AsyncSession` sin confirmar.
+
+Como los pasos de la saga comparten una `AsyncSession`, `upsert` hace flush
+para que cada paso vea la escritura del anterior; la frontera de aplicación
+que los rodea posee el commit final.
+
+**Paso 3: cablea los parámetros.**
+La inyección de parámetros usa `typing.Annotated` con **instancias de
+marcador**, no clases desnudas:
+
+- `Annotated[TransferRequest, Input()]` — `Input()` es una instancia (fíjate
+ en los paréntesis); `Input` desnudo sin `()` no se resuelve.
+- `Annotated[DebitResult, FromStep("debit-source")]` — lee el resultado que
+ el paso `"debit-source"` almacenó en `SagaContext` al completarse.
+- `ctx: SagaContext` — inyectado por tipo; no hace falta marcador
+ `Annotated`.
+
+El resolutor inspecciona las anotaciones de tipo en tiempo de ejecución
+mediante `typing.get_type_hints(func, include_extras=True)`.
+
+**Los métodos de compensación no reciben la entrada de la saga.**
+`recredit_source` toma `Annotated[DebitResult, FromStep("debit-source")]`
+—el valor que devolvió el paso hacia delante— no la `TransferRequest`.
+Recarga la entidad mediante `find_by_id`, rehidrata el agregado, deposita de
+vuelta la cantidad original y hace upsert: el mismo ciclo
+cargar-mutar-guardar que los pasos hacia delante. Las compensaciones siempre
+leen de `ctx.step_results` mediante `FromStep`, nunca de la entrada
+original.
+
+!!! note "Qué acaba de pasar"
+ Escribiste una sola clase que contiene toda la historia de la transferencia: un paso hacia delante para cargar, su compensación para reabonar y un segundo paso hacia delante para abonar. Los decoradores le dijeron al motor *qué es cada método* (un paso, una compensación) y *cómo se conectan* (`compensate=`, `depends_on=`). No escribiste ningún bucle de orquestación ni lógica de rollback con try/except: eso es trabajo del motor. Tu código solo describe la operación de negocio y su deshacer.
+
+### El DAG de pasos
+
+!!! note "Término nuevo: DAG"
+ Un *DAG* —grafo acíclico dirigido— es un conjunto de pasos conectados por flechas de "debe-ejecutarse-antes", sin ciclos (ningún paso puede, directa o indirectamente, depender de sí mismo). El motor lee tus declaraciones `depends_on`, construye este grafo y lo ordena en *capas*: todo lo que está en la capa 0 no tiene dependencias sin satisfacer y se ejecuta primero; la capa 1 se ejecuta cuando termina la capa 0; y así sucesivamente. Los pasos de la misma capa son independientes, así que el motor los ejecuta a la vez. Un ciclo haría imposible la división en capas, por lo que el motor lo rechaza en el arranque y no en tiempo de ejecución.
+
+Los dos pasos forman una cadena lineal:
+
+::: figure art/figures/12-saga.svg | Figura 12.1 — DAG de MoneyTransferSaga: los pasos se ejecutan en orden de capa topológica; los pasos independientes de una capa se ejecutan con asyncio.gather.
+
+```
+Layer 0: debit-source
+ │
+Layer 1: credit-destination
+```
+
+Como `credit-destination` depende de `debit-source`, se ejecutan
+secuencialmente. Una saga más compleja —una comprobación de fraude y una
+comprobación de KYC que son independientes entre sí pero ambas alimentan un
+paso de captura— colocaría las dos comprobaciones en la misma capa y las
+ejecutaría de forma concurrente con `asyncio.gather`.
+
+### Ejecutar la saga
+
+La clase de saga solo *describe* la operación. Para *ejecutarla* necesitas
+un servicio ligero que inyecte el motor y lo invoque por nombre.
+
+**Paso 1: inyecta `SagaEngine`.** Declara un `@service` cuyo constructor
+tome un parámetro `SagaEngine`; el contenedor de inyección de dependencias
+te entrega el motor autoconfigurado. **Paso 2: llama a `execute`** con el
+nombre de la saga y la carga de entrada. **Paso 3: pliega el `SagaResult`**
+en un diccionario pequeño y amigable con JSON para el invocador.
+
+::: listing lumen/core/services/transfers/transfer_service.py | Listado 12.3 — Ejecutar la saga de transferencia de dinero
+from __future__ import annotations
+
+from typing import Any
+
+from lumen.core.services.transfers.money_transfer_saga import MONEY_TRANSFER_SAGA
+from lumen.core.services.transfers.transfer_request import TransferRequest
+from pyfly.container import service
+from pyfly.transactional.saga.core.result import SagaResult
+from pyfly.transactional.saga.engine.saga_engine import SagaEngine
+
+
+@service
+class TransferService:
+ """Run the money-transfer saga and report the outcome."""
+
+ def __init__(self, saga_engine: SagaEngine) -> None:
+ self._saga_engine = saga_engine
+
+ async def transfer(self, request: TransferRequest) -> dict[str, Any]:
+ result: SagaResult = await self._saga_engine.execute(
+ saga_name=MONEY_TRANSFER_SAGA,
+ input_data=request,
+ )
+
+ if result.success:
+ debit = result.result_of("debit-source")
+ return {
+ "status": "completed",
+ "correlation_id": result.correlation_id,
+ "source_balance": debit.balance,
+ "destination_balance": result.result_of("credit-destination"),
+ }
+
+ return {
+ "status": "failed",
+ "correlation_id": result.correlation_id,
+ "failed_steps": list(result.failed_steps().keys()),
+ "compensated_steps": list(result.compensated_steps().keys()),
+ "error": str(result.error),
+ }
+:::
+
+**Cómo funciona:** `saga_engine.execute()` resuelve `MoneyTransferSaga`
+desde el registro por nombre, crea un `SagaContext` con un `correlation_id`
+UUID autogenerado y empieza a ejecutar capas. En caso de éxito,
+`SagaResult.success` es `True` y `result_of("debit-source")` devuelve el
+`DebitResult` que produjo el paso hacia delante. En caso de fallo,
+`result.failed_steps()` devuelve un diccionario de ID de paso a
+`StepOutcome` para cada paso que agotó sus reintentos;
+`result.compensated_steps()` devuelve los pasos que se deshicieron
+correctamente.
+
+`SagaResult` es una dataclass inmutable y congelada. Sus miembros clave:
+
+- `result.success` — `True` cuando cada paso hacia delante se completó.
+- `result.result_of(step_id)` — el valor que devolvió ese paso, o `None`.
+- `result.failed_steps()` — diccionario de ID de paso → `StepOutcome` para los pasos fallidos.
+- `result.compensated_steps()` — diccionario de ID de paso → `StepOutcome` para los pasos compensados.
+- `result.correlation_id` — UUID para correlacionar logs y trazas entre servicios.
+- `result.error` — la excepción que detuvo la saga, o `None` en caso de éxito.
+
+!!! note "Pruébalo: el camino feliz y el camino compensado"
+ Expón `TransferService.transfer` detrás de una ruta HTTP (el Capítulo 11 cubrió los controladores) y ejercita ambos desenlaces contra la aplicación en ejecución en `pyfly.server.port` (`8080`).
+
+ Una transferencia válida entre dos monederos existentes de la misma moneda devuelve el resumen completado:
+
+ ```bash
+ curl -s -X POST http://localhost:8080/transfers \
+ -d '{"source_wallet_id":"w-1","destination_wallet_id":"w-2","amount":500,"currency":"EUR"}'
+ ```
+
+ ```json
+ {
+ "status": "completed",
+ "correlation_id": "8f3c…",
+ "source_balance": 9500,
+ "destination_balance": 10500
+ }
+ ```
+
+ Ahora apunta la transferencia a un monedero destino que no existe. `credit-destination` lanza `AggregateNotFound`, el motor compensa `debit-source` y la respuesta informa exactamente de eso:
+
+ ```bash
+ curl -s -X POST http://localhost:8080/transfers \
+ -d '{"source_wallet_id":"w-1","destination_wallet_id":"does-not-exist","amount":500,"currency":"EUR"}'
+ ```
+
+ ```json
+ {
+ "status": "failed",
+ "correlation_id": "1a2b…",
+ "failed_steps": ["credit-destination"],
+ "compensated_steps": ["debit-source"],
+ "error": "Wallet 'does-not-exist' not found"
+ }
+ ```
+
+ La observación clave: vuelve a leer `w-1` después y su saldo está de vuelta en `9500`; `debit-source` fue deshecho por `recredit_source`. Una transferencia fallida deja ambos monederos exactamente como empezaron.
+
+!!! spring "Equivalencia con Spring"
+ `@saga` / `@saga_step` reflejan `@Saga` / `@SagaStep` en la librería Java `fireflyframework-transactional-engine`. La regla de la pila de decoradores (`@saga` sobre `@service`) refleja la regla de Java de que `@Saga` debe estar sobre una clase anotada con `@Service` para que el `WorkflowBeanPostProcessor` pueda descubrirla. Los marcadores de inyección de parámetros (`Input()`, `FromStep("id")`) se corresponden directamente con `@Input` y `@FromStep` en la versión Java. El modelo asíncrono difiere: Java usa Project Reactor (`Mono`) mientras que PyFly usa `async/await` nativo con `asyncio.gather` para las capas paralelas.
+
+---
+
+## La compensación en profundidad
+
+El camino feliz es directo: cada paso tiene éxito y la saga confirma. El
+verdadero reto de diseño es el camino infeliz. Entender qué ocurre en caso
+de fallo —y por qué la compensación debe diseñarse con cuidado— es lo que
+separa una saga fiable de una frágil.
+
+### Qué se ejecuta en caso de fallo
+
+Cuando un paso falla tras todos los reintentos, el motor entra en *modo de
+compensación*. Inspecciona `SagaContext` en busca de cada paso cuyo estado
+sea `DONE` y luego llama a sus métodos de compensación en orden inverso al
+de finalización bajo la política por defecto `STRICT_SEQUENTIAL`. En
+`MoneyTransferSaga`, un monedero destino inexistente hace que
+`credit-destination` lance `AggregateNotFound`. El motor compensa entonces
+el paso que ya se había completado:
+
+```
+Forward path: debit-source ✓ → credit-destination ✗
+Compensation: recredit_source (for debit-source)
+```
+
+El efecto neto: el monedero origen se restaura a su saldo original y el
+monedero destino nunca fue tocado, como si la transferencia nunca hubiera
+ocurrido.
+
+Los métodos de compensación reciben sus argumentos a través del mismo
+sistema de inyección que los pasos hacia delante.
+`Annotated[DebitResult, FromStep("debit-source")]` lee el `DebitResult` que
+`debit_source` almacenó en el contexto al completarse, de modo que siempre
+compensas con los datos realmente confirmados, nunca con una aproximación.
+
+!!! note "Pruébalo: demuestra la compensación en una prueba"
+ No necesitas un servidor en ejecución para verificar el camino infeliz: una prueba unitaria rápida contra el motor basta, y es el tipo de prueba que escribirás para cada saga. Dirige `TransferService.transfer` con un monedero destino que no existe y luego comprueba que la compensación se ejecutó:
+
+ ```python
+ async def test_failed_transfer_compensates_the_debit(transfer_service):
+ result = await transfer_service.transfer(
+ TransferRequest(
+ source_wallet_id="w-1",
+ destination_wallet_id="does-not-exist",
+ amount=500,
+ currency=Currency.EUR,
+ )
+ )
+ assert result["status"] == "failed"
+ assert result["failed_steps"] == ["credit-destination"]
+ assert result["compensated_steps"] == ["debit-source"]
+ ```
+
+ Ejecuta solo esta prueba (el grupo `--extra dev` instala pytest; el Capítulo 16 cubre los fixtures en profundidad):
+
+ ```bash
+ uv run --extra dev pytest -q -k compensates
+ ```
+
+ Salida esperada:
+
+ ```
+ 1 passed in 0.05s
+ ```
+
+ Una prueba en verde aquí es tu garantía de que una transferencia rota nunca deja dinero desaparecido.
+
+### Políticas de compensación
+
+Cinco políticas gobiernan cómo ejecuta el motor las compensaciones.
+Establece el valor por defecto global en YAML o anúlalo por ejecución:
+
+```yaml
+pyfly:
+ transactional:
+ saga:
+ compensation_policy: STRICT_SEQUENTIAL
+```
+
+| Política | Comportamiento | Úsala cuando |
+|--------|-----------|----------|
+| `STRICT_SEQUENTIAL` | Orden inverso, una a una. Se detiene al primer error de compensación. | El orden importa; un rollback parcial es inaceptable. |
+| `GROUPED_PARALLEL` | Invierte las capas topológicas; compensa cada capa en paralelo. | Quieres velocidad sin violar la estructura de dependencias. |
+| `RETRY_WITH_BACKOFF` | Orden inverso con backoff exponencial. Continúa si los reintentos tienen éxito. | Es probable que haya fallos de red transitorios durante la compensación. |
+| `CIRCUIT_BREAKER` | Rastrea fallos consecutivos; se abre tras 3 y omite el resto. | Evitar fallos en cascada; la recuperación manual gestiona los pasos omitidos. |
+| `BEST_EFFORT_PARALLEL` | Todas las compensaciones a la vez; los errores se registran, nunca se lanzan. | La velocidad es crítica; una reconciliación aparte gestiona los fallos parciales. |
+
+!!! warning "La compensación debe ser idempotente"
+ El motor puede llamar a un método de compensación más de una vez. Si el motor cae entre la llamada a `void_payment` y la persistencia del resultado de la compensación, llamará a `void_payment` de nuevo al reiniciarse. Tus métodos de compensación deben ser seguros de llamar varias veces con los mismos argumentos. Para anulaciones de pagos, esto significa que el `PaymentsService` debe tratar una doble anulación como un no-op (devolver éxito si ya estaba anulado, no lanzar). Diseña la compensación *antes* de diseñar el paso hacia delante: la idempotencia no es una ocurrencia tardía.
+
+### Configuración de compensación por paso
+
+Puedes anular el número de reintentos y el tiempo de espera de un paso de
+compensación sin cambiar el comportamiento del paso hacia delante:
+
+::: listing lumen/transfer/transfer_saga_hardened.py | Listado 12.4 — Reintento y tiempo de espera de compensación por paso
+from pyfly.container import service
+from pyfly.transactional.saga.annotations import saga, saga_step
+
+
+@saga(name="money-transfer-hardened")
+@service
+class HardenedTransferSaga:
+
+ @saga_step(
+ id="debit-wallet",
+ compensate="refund_wallet",
+ depends_on=[],
+ retry=3,
+ backoff_ms=200,
+ timeout_ms=5_000,
+ compensation_retry=5,
+ compensation_backoff_ms=1_000,
+ compensation_timeout_ms=8_000,
+ compensation_critical=True,
+ )
+ async def debit_wallet(self, *args: object) -> None: ...
+
+ async def refund_wallet(self, *args: object) -> None: ...
+:::
+
+**Cómo funciona:** `compensation_retry=5` da a la compensación cinco
+intentos propios, independientes de los tres reintentos del paso hacia
+delante. `compensation_critical=True` significa que si la compensación agota
+todos sus reintentos y aun así falla, el motor lanza esa excepción,
+sacando a la superficie el *fallo de compensación* como un error observable
+en lugar de tragárselo silenciosamente.
+
+### Pasos de compensación externos
+
+Cuando la lógica de compensación es lo bastante compleja como para merecer
+su propia clase, o cuando vive en un módulo diferente, sácala por completo:
+
+::: listing lumen/transfer/compensation_steps.py | Listado 12.5 — Clase de paso de compensación externo
+from typing import Annotated
+
+from pyfly.container import service
+from pyfly.transactional.saga.annotations import (
+ FromStep,
+ compensation_step,
+)
+
+from lumen.core.services.transfers.money_transfer_saga import DebitResult
+from lumen.models.repositories.wallet_repository import WalletRepository
+
+
+@compensation_step(saga="money-transfer", for_step_id="debit-source")
+@service
+class SourceRecreditCompensation:
+
+ def __init__(self, repository: WalletRepository) -> None:
+ self._repository = repository
+
+ async def execute(
+ self,
+ debit: Annotated[DebitResult, FromStep("debit-source")],
+ ) -> None:
+ """External alternative to the inline recredit_source method."""
+ ...
+:::
+
+El `SagaRegistry` descubre las clases `@compensation_step` en el arranque
+junto a las clases `@saga` y las cablea en sus definiciones de paso
+automáticamente. El parámetro `for_step_id` debe coincidir exactamente con
+la cadena `id` del paso.
+
+---
+
+## Flujos de trabajo y señales
+
+`@saga` es la herramienta adecuada cuando todos los pasos se conocen de
+antemano y la operación se completa en minutos. Algunos procesos de negocio
+son inherentemente más largos: la aprobación de un préstamo a la espera de
+un responsable de cumplimiento, una incorporación de varios pasos bloqueada
+por un clic en un correo, un pago que necesita un periodo de enfriamiento
+antes de liquidarse. Estos encajan en el patrón **Workflow** (flujo de
+trabajo).
+
+### En qué se diferencian los flujos de trabajo de las sagas
+
+| | Saga | Workflow |
+|---|---|---|
+| Duración | De segundos a minutos | De minutos a días |
+| Espera | Solo reintentos | Señales, temporizadores, flujos hijos |
+| Intervención humana | No | Sí (`@wait_for_signal`) |
+| Persistencia de estado | Punto de control por saga | Tras cada capa |
+| Despliegue en DAG | Sí (capas paralelas) | Sí + primitivas de compuerta |
+
+### Declarar un flujo de trabajo
+
+::: listing lumen/transfer/approval_workflow.py | Listado 12.6 — LargeTransferWorkflow: aprobación dirigida por señales para transferencias de alto valor
+from __future__ import annotations
+
+from pyfly.container import service
+from pyfly.transactional.core.model import TriggerMode
+from pyfly.transactional.workflow.annotations import (
+ compensation_step,
+ on_workflow_complete,
+ on_workflow_error,
+ wait_for_signal,
+ workflow,
+ workflow_query,
+ workflow_step,
+)
+
+
+@workflow(
+ id="large-transfer-approval",
+ trigger_mode=TriggerMode.SYNC,
+ timeout_ms=86_400_000, # 24 hours
+ max_retries=1,
+)
+@service
+class LargeTransferWorkflow:
+ """High-value transfers require a compliance officer to approve."""
+
+ @workflow_step(id="enrich-request", depends_on=[])
+ async def enrich_request(self, payload: dict) -> dict:
+ return {**payload, "risk_score": 0.12}
+
+ @workflow_step(
+ id="compliance-review",
+ depends_on=["enrich-request"],
+ compensatable=True,
+ compensation_method="release_review",
+ timeout_ms=82_800_000,
+ )
+ @wait_for_signal("approved", timeout_ms=82_800_000)
+ async def compliance_review(self) -> None:
+ """Suspends until a compliance officer delivers the signal."""
+
+ @compensation_step(for_step="compliance-review")
+ async def release_review(self) -> None:
+ """Called if the workflow is cancelled during review."""
+
+ @workflow_step(
+ id="settle-transfer",
+ depends_on=["compliance-review"],
+ )
+ async def settle_transfer(self, payload: dict) -> dict:
+ return {"settled": True}
+
+ @workflow_query(name="status")
+ async def get_status(self, ctx: object) -> str:
+ return str(getattr(ctx, "status", "UNKNOWN"))
+
+ @on_workflow_complete
+ async def on_done(self, ctx: object) -> None:
+ pass # emit audit event
+
+ @on_workflow_error
+ async def on_error(self, ctx: object, err: Exception) -> None:
+ pass # alert on-call
+:::
+
+**Cómo funciona:** La pila de decoradores sigue la misma regla que las
+sagas: `@workflow` por encima de `@service`. `@workflow(id=...)` toma
+argumentos solo por palabra clave: `id` es obligatorio; todos los demás son
+opcionales. `@wait_for_signal("approved", timeout_ms=82_800_000)` se apila
+por encima de `@workflow_step` y le dice al motor que suspenda en ese paso
+hasta que se entregue una señal llamada `"approved"`. El motor persiste el
+`ExecutionContext` en el `ExecutionPersistenceProvider` configurado; si el
+proceso se reinicia, rehidrata el contexto y reanuda desde la última capa
+completada.
+
+`@compensation_step(for_step="compliance-review")` usa el argumento por
+palabra clave `for_step` (no posicional) y registra `release_review` como el
+manejador (handler) de compensación para el paso `compliance-review`.
+
+`@workflow_query(name="status")` marca un método como un manejador de
+consulta del lado de lectura: invocable mientras el flujo de trabajo está
+suspendido sin hacer avanzar la ejecución.
+
+### Manejar el motor de flujos de trabajo
+
+::: listing lumen/transfer/approval_controller.py | Listado 12.7 — Iniciar un flujo de trabajo y entregar una señal
+from __future__ import annotations
+
+from pyfly.container import service
+from pyfly.transactional.workflow.engine import WorkflowEngine
+from pyfly.transactional.workflow.result import WorkflowResult
+
+
+@service
+class TransferApprovalService:
+
+ def __init__(self, workflow_engine: WorkflowEngine) -> None:
+ self._wf = workflow_engine
+
+ async def request_large_transfer(self, payload: dict) -> str:
+ result: WorkflowResult = await self._wf.start(
+ "large-transfer-approval",
+ input=payload,
+ )
+ # Returns immediately; workflow is now suspended at compliance-review.
+ return result.correlation_id
+
+ async def approve(self, correlation_id: str, reviewer_id: str) -> None:
+ await self._wf.deliver_signal(
+ correlation_id,
+ "approved",
+ payload={"by": reviewer_id},
+ )
+
+ async def check_status(self, correlation_id: str) -> str:
+ return await self._wf.query(correlation_id, "status")
+:::
+
+**Cómo funciona:** `workflow_engine.start(workflow_id, input=payload)`
+ejecuta la primera capa (`enrich-request`) de forma síncrona, luego suspende
+en `compliance-review` por culpa de `@wait_for_signal`. Devuelve un
+`WorkflowResult` inmediatamente con un `correlation_id`: el invocador guarda
+este ID y consulta más tarde. Cuando se llama a `deliver_signal()`, el flujo
+de trabajo se reanuda y `settle-transfer` se ejecuta hasta completarse.
+
+`WorkflowResult` lleva: `workflow_id`, `correlation_id`, `status` (un enum
+`ExecutionStatus`), `duration_ms`, `step_results` (diccionario) y
+`variables`. El booleano `result.successful` es `True` cuando `status` es
+`COMPLETED` o `CONFIRMED`.
+
+### El constructor programático
+
+Cuando necesitas construir un flujo de trabajo de forma dinámica —a partir
+de una configuración en base de datos o de un motor de reglas— usa
+`WorkflowBuilder`:
+
+::: listing lumen/transfer/dynamic_workflow.py | Listado 12.8 — Construir un flujo de trabajo de forma programática
+from pyfly.transactional.workflow.builder import WorkflowBuilder
+from pyfly.transactional.workflow.definition import WorkflowDefinition
+
+
+async def enrich_fn(payload: dict) -> dict:
+ return {**payload, "enriched": True}
+
+
+async def settle_fn(payload: dict) -> dict:
+ return {"settled": True}
+
+
+definition: WorkflowDefinition = (
+ WorkflowBuilder("simple-transfer")
+ .step("enrich", enrich_fn, depends_on=[])
+ .wait_signal(
+ "await-approval",
+ "approved",
+ depends_on=["enrich"],
+ timeout_ms=3_600_000,
+ )
+ .step(
+ "settle",
+ settle_fn,
+ depends_on=["await-approval"],
+ )
+ .build()
+)
+:::
+
+`WorkflowBuilder.step(step_id, handler, *, depends_on, timeout_ms, max_retries, ...)` acepta un invocable y argumentos por palabra clave para dependencias, tiempos de espera y reintentos. `wait_signal(step_id, signal, *, depends_on, timeout_ms)` inserta un paso de compuerta de señal sin un manejador real: crea una corrutina interna no-op que el motor sustituye por la lógica de espera de señal. `build()` devuelve un `WorkflowDefinition` que registras directamente con `WorkflowEngine`.
+
+---
+
+## TCC: Try-Confirm-Cancel
+
+El patrón saga ejecuta los pasos hacia delante y compensa hacia atrás.
+**TCC (Try-Confirm-Cancel)** adopta un enfoque diferente: todos los
+participantes primero *reservan tentativamente* sus recursos sin confirmar
+(Try), y luego todos *confirman* esas reservas (Confirm) o todos las
+*liberan* (Cancel). Esto te da una semántica fuerte de todo-o-nada entre
+participantes sin un bloqueo distribuido.
+
+TCC encaja en escenarios donde cada participante puede mantener una reserva
+de forma barata; por ejemplo, preautorizar una retención en una tarjeta de
+pago en lugar de cobrarla inmediatamente.
+
+### Las tres fases
+
+1. **Try** — cada participante reserva recursos. Las reservas son visibles
+ internamente pero no son definitivas. Si algún Try falla, los
+ participantes que tuvieron éxito cancelan sus reservas.
+2. **Confirm** — si todas las fases Try tienen éxito, el coordinador instruye
+ a cada participante a confirmar su reserva.
+3. **Cancel** — si alguna fase Try falla, el coordinador instruye a cada
+ participante que completó su Try a liberar su reserva.
+
+### Declarar una transacción TCC
+
+::: listing lumen/transfer/transfer_tcc.py | Listado 12.9 — WalletTransferTcc: Try-Confirm-Cancel para la reserva de pago
+from __future__ import annotations
+
+from typing import Annotated
+
+from pyfly.container import service
+from pyfly.transactional.tcc.annotations import (
+ FromTry,
+ cancel_method,
+ confirm_method,
+ tcc,
+ tcc_participant,
+ try_method,
+)
+from pyfly.transactional.tcc.core.context import TccContext
+
+from lumen.wallet.service import WalletService
+from lumen.payments.service import PaymentsService
+
+
+@tcc(
+ name="wallet-transfer",
+ timeout_ms=30_000,
+ retry_enabled=True,
+ max_retries=3,
+ backoff_ms=500,
+)
+@service
+class WalletTransferTcc:
+ """Reserve funds and payment in lockstep; confirm or cancel together."""
+
+ @tcc_participant(id="wallet-hold", order=1, timeout_ms=5_000)
+ class WalletParticipant:
+
+ def __init__(self, wallet_svc: WalletService) -> None:
+ self._wallet = wallet_svc
+
+ @try_method(timeout_ms=4_000, retry=2, backoff_ms=200)
+ async def try_hold(
+ self,
+ request: object,
+ ctx: TccContext,
+ ) -> str:
+ """Tentatively hold funds — does not debit yet."""
+ return await self._wallet.hold_funds(
+ wallet_id=getattr(request, "sender_id", ""),
+ amount=getattr(request, "amount_cents", 0),
+ ) # returns a hold_id
+
+ @confirm_method(timeout_ms=5_000, retry=3)
+ async def confirm_hold(
+ self,
+ hold_id: Annotated[str, FromTry()],
+ ctx: TccContext,
+ ) -> None:
+ await self._wallet.commit_hold(hold_id)
+
+ @cancel_method(timeout_ms=3_000, retry=2)
+ async def cancel_hold(
+ self,
+ hold_id: Annotated[str, FromTry()],
+ ) -> None:
+ await self._wallet.release_hold(hold_id)
+
+ @tcc_participant(id="payment-auth", order=2, timeout_ms=8_000)
+ class PaymentParticipant:
+
+ def __init__(self, payments_svc: PaymentsService) -> None:
+ self._payments = payments_svc
+
+ @try_method(timeout_ms=6_000, retry=2, backoff_ms=300)
+ async def try_auth(
+ self,
+ request: object,
+ ctx: TccContext,
+ ) -> str:
+ return await self._payments.pre_authorise(
+ amount=getattr(request, "amount_cents", 0),
+ ) # returns auth_id
+
+ @confirm_method(timeout_ms=8_000, retry=3)
+ async def confirm_auth(
+ self,
+ auth_id: Annotated[str, FromTry()],
+ ctx: TccContext,
+ ) -> None:
+ await self._payments.capture_auth(auth_id)
+
+ @cancel_method(timeout_ms=4_000, retry=2)
+ async def cancel_auth(
+ self,
+ auth_id: Annotated[str, FromTry()],
+ ) -> None:
+ await self._payments.void_auth(auth_id)
+:::
+
+**Cómo funciona:** `@tcc_participant(order=1)` le dice al motor TCC que
+ejecute la fase Try de `WalletParticipant` antes que la de
+`PaymentParticipant`: un `order` menor significa más temprano. **`FromTry()`**
+es el equivalente de `FromStep` en TCC: inyecta el valor devuelto por el
+`@try_method` del propio participante en su `@confirm_method` y su
+`@cancel_method`.
+
+El motor ejecuta las fases Try de todos los participantes en secuencia de
+`order`. Si todos los Try tienen éxito, ejecuta todos los métodos Confirm.
+Si algún Try falla, ejecuta Cancel para cada participante que completó su
+Try, de nuevo en el orden declarado. Un participante `optional=True` que
+falla su Try no dispara un Cancel global; su fallo se registra y se omite.
+
+### Ejecutar una transacción TCC
+
+::: listing lumen/transfer/tcc_service.py | Listado 12.10 — Ejecutar una transacción TCC
+from __future__ import annotations
+
+from typing import Any
+
+from pyfly.container import service
+from pyfly.transactional.tcc.core.result import TccResult
+from pyfly.transactional.tcc.engine.tcc_engine import TccEngine
+
+from lumen.core.services.transfers.transfer_request import TransferRequest
+
+
+@service
+class TccTransferService:
+
+ def __init__(self, tcc_engine: TccEngine) -> None:
+ self._engine = tcc_engine
+
+ async def transfer(self, req: TransferRequest) -> dict[str, Any]:
+ result: TccResult = await self._engine.execute(
+ tcc_name="wallet-transfer",
+ input_data=req,
+ )
+
+ if result.success:
+ hold_id = result.result_of("wallet-hold")
+ return {
+ "status": "confirmed",
+ "hold_id": hold_id,
+ "correlation_id": result.correlation_id,
+ }
+
+ failed = result.failed_participants()
+ return {
+ "status": "cancelled",
+ "failed": list(failed.keys()),
+ "error": str(result.error),
+ }
+:::
+
+### TCC frente a saga: elegir el patrón adecuado
+
+Usa esta tabla para elegir entre los dos enfoques:
+
+| Pregunta | Saga | TCC |
+|----------|------|-----|
+| ¿Los pasos se ejecutan independientemente? | Sí — cada uno confirma localmente | No — todas las fases Try deben tener éxito primero |
+| ¿Necesita lógica de compensación? | Sí, por paso | No — Cancel gestiona el rollback |
+| ¿Se necesita reserva de recursos? | No | Sí — los participantes mantienen recursos durante Try |
+| Mejor para | Operaciones secuenciales largas | Bloqueos cortos de todo-o-nada |
+
+---
+
+## Persistencia: sobrevivir a una caída
+
+El motor almacena el estado de la saga y de TCC a través del protocolo
+`TransactionalPersistencePort`. El adaptador por defecto mantiene el estado
+en memoria —rápido para desarrollo, pero perdido al reiniciar el proceso—.
+Los despliegues de producción cambian por un adaptador duradero.
+
+### Cómo fluye el estado
+
+Cada vez que un paso se completa —con éxito o no— el motor llama a:
+
+1. `persistence_port.update_step_status(correlation_id, step_id, status)` — registra el desenlace del paso.
+2. `persistence_port.mark_completed(correlation_id, successful)` — registra el resultado final de la saga.
+
+En el arranque, `SagaRecoveryService` consulta
+`persistence_port.get_stale(before)` para encontrar ejecuciones que
+empezaron pero nunca se completaron. Para cada saga obsoleta que sigue en
+estado `IN_FLIGHT`, marca la saga como `FAILED` y emite eventos de ciclo de
+vida para que los sistemas de observabilidad puedan alertar al equipo de
+guardia.
+
+### Configuración
+
+```yaml
+pyfly:
+ transactional:
+ saga:
+ persistence_enabled: true
+ recovery_enabled: true
+ recovery_interval_seconds: 60
+ stale_threshold_seconds: 600
+ cleanup_older_than_hours: 24
+```
+
+Con `recovery_enabled: true`, el framework ejecuta
+`SagaRecoveryService.recover_stale()` en una tarea en segundo plano cada
+`recovery_interval_seconds` segundos. Las sagas actualizadas por última vez
+hace más de `stale_threshold_seconds` segundos se consideran atascadas, se
+marcan como fallidas y se sacan a la superficie para investigación manual o
+reintento automático.
+
+### Implementar un adaptador de persistencia personalizado
+
+Para persistir en una base de datos real, implementa
+`TransactionalPersistencePort` y registra tu implementación como un `@bean`
+o `@component`. La autoconfiguración detecta tu bean en el arranque y lo usa
+con preferencia sobre `InMemoryPersistenceAdapter`:
+
+::: listing lumen/infra/persistence/saga_postgres_adapter.py | Listado 12.11 — Esqueleto de un adaptador de persistencia para PostgreSQL
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Any
+
+from pyfly.container import component
+from pyfly.transactional.shared.ports.outbound import (
+ TransactionalPersistencePort,
+)
+
+
+@component
+class SagaPostgresAdapter(TransactionalPersistencePort):
+
+ async def persist_state(self, state: dict[str, Any]) -> None:
+ # INSERT INTO saga_executions ...
+ ...
+
+ async def get_state(
+ self, correlation_id: str
+ ) -> dict[str, Any] | None:
+ # SELECT * FROM saga_executions WHERE ...
+ ...
+
+ async def update_step_status(
+ self,
+ correlation_id: str,
+ step_id: str,
+ status: str,
+ ) -> None: ...
+
+ async def mark_completed(
+ self, correlation_id: str, successful: bool
+ ) -> None: ...
+
+ async def get_in_flight(self) -> list[dict[str, Any]]:
+ return []
+
+ async def get_stale(
+ self, before: datetime
+ ) -> list[dict[str, Any]]:
+ return []
+
+ async def cleanup(self, older_than: datetime) -> int:
+ return 0
+
+ async def is_healthy(self) -> bool:
+ return True
+:::
+
+!!! tip "Usa SagaRecoveryService en pruebas de integración"
+ En pruebas que simulan una caída, crea un `SagaRecoveryService` con un `InMemoryPersistenceAdapter`, ejecuta una saga hasta un punto intermedio, márcala manualmente como obsoleta y luego llama a `await recovery.recover_stale(stale_threshold_seconds=0)`. Comprueba que `SagaResult.success` es `False` y que los pasos correctos están marcados como fallidos. Esto te da confianza en tu lógica de recuperación sin levantar una base de datos real.
+
+---
+
+## El constructor programático de sagas
+
+Cuando necesitas construir una saga a partir de configuración dinámica
+—cargando definiciones de paso desde una base de datos de reglas o un
+archivo de configuración— `SagaBuilder` te da la API fluida completa sin
+ningún decorador:
+
+::: listing lumen/transfer/dynamic_saga.py | Listado 12.12 — Construir una saga de forma programática con SagaBuilder
+from __future__ import annotations
+
+from pyfly.transactional.saga.registry.saga_builder import SagaBuilder
+from pyfly.transactional.saga.core.result import SagaResult
+
+
+async def debit_fn(req: object, ctx: object) -> str:
+ return "debit-ref-001"
+
+
+async def capture_fn(req: object, ctx: object) -> str:
+ return "txn-001"
+
+
+async def refund_fn(result: str) -> None:
+ pass # undo debit
+
+
+saga_def = (
+ SagaBuilder("dynamic-transfer")
+ .step("debit")
+ .handler(debit_fn)
+ .compensate(refund_fn)
+ .retry(3)
+ .backoff_ms(200)
+ .timeout_ms(5_000)
+ .jitter(enabled=True, factor=0.3)
+ .add()
+ .step("capture")
+ .handler(capture_fn)
+ .depends_on("debit")
+ .retry(2)
+ .backoff_ms(500)
+ .add()
+ .layer_concurrency(5)
+ .build()
+)
+:::
+
+**Cómo funciona:** Cada llamada `.step(step_id)` devuelve un `StepBuilder`.
+Encadena métodos de configuración —`.handler()`, `.compensate()`,
+`.depends_on()`, `.retry()`, `.backoff_ms()`, `.timeout_ms()`,
+`.jitter()`— y luego llama a `.add()` para finalizar el paso y devolver el
+`SagaBuilder` padre. `.build()` ejecuta la misma validación del DAG que la
+vía de los decoradores: los manejadores que faltan, las referencias
+`depends_on` inexistentes y los ciclos lanzan todos `SagaValidationError`
+inmediatamente en el momento del registro.
+
+---
+
+## Lo que construiste {.recap}
+
+Empezaste con un problema concreto: una transferencia entre monederos a
+través de dos agregados independientes no puede usar una sola transacción de
+base de datos. Declaraste `MoneyTransferSaga` apilando `@saga` sobre
+`@service`, lo que hace que el `OrchestrationBeanPostProcessor` la registre
+en el `SagaEngine` autoconfigurado en el arranque. Cada paso usa instancias
+de marcador `Annotated[T, Input()]` y `Annotated[T, FromStep("step-id")]`
+—no clases desnudas— para la inyección de parámetros; `ctx: SagaContext` se
+inyecta por tipo. El método de compensación `recredit_source` no recibe la
+entrada de la saga; obtiene el `DebitResult` del paso hacia delante mediante
+`FromStep("debit-source")`. Cuando `credit-destination` lanza
+`AggregateNotFound`, el motor ejecuta automáticamente `recredit_source`,
+dejando ambos saldos sin cambios.
+
+Exploraste la compensación en profundidad: cinco políticas que van desde la
+secuencial estricta hasta la paralela de mejor esfuerzo, los reintentos y
+tiempos de espera de compensación por paso, y el requisito innegociable de
+que todas las compensaciones sean idempotentes. Viste cómo
+`@workflow(id=...) @service` y `@wait_for_signal` suspenden un flujo de
+trabajo de larga duración hasta que un humano entrega una señal, y cómo
+`WorkflowResult.successful` informa del estado final. Recorriste TCC como
+una alternativa basada en reservas que bloquea recursos entre todos los
+participantes antes de confirmar ninguno. Por último, cableaste un
+`TransactionalPersistencePort` personalizado y configuraste
+`SagaRecoveryService` para detectar y sacar a la superficie ejecuciones
+obsoletas tras una caída.
+
+Conceptos clave que llevarte:
+
+- **`@saga` sobre `@service`** — la pila de decoradores que hace que una clase sea a la vez un bean de inyección de dependencias y una saga registrada; `@saga` por sí solo no basta.
+- **Instancias de marcador** — `Input()`, `FromStep("step-id")` deben ser instancias (con paréntesis), no clases desnudas.
+- **Compensación mediante `FromStep`** — los métodos de compensación reciben el resultado del paso hacia delante, nunca la entrada de la saga.
+- **`SagaEngine.execute(saga_name, input_data)`** — la única llamada que devuelve `SagaResult` con `.success`, `.result_of()`, `.failed_steps()`, `.compensated_steps()`.
+- **`@workflow(id=...) @service` / `@wait_for_signal`** — alternativa dirigida por señales y de larga duración; comprueba `WorkflowResult.successful` para el estado final.
+- **`@tcc` sobre `@service` / `@tcc_participant`** — coordinación basada en reservas; `FromTry()` (instancia) inyecta el resultado del try en los métodos confirm y cancel.
+- **`TransactionalPersistencePort`** — implementa y registra este protocolo para darle al motor estado duradero y recuperación ante caídas.
+
+---
+
+## Pruébalo tú mismo {.exercises}
+
+**Ejercicio 1 — Validación de saldo en paralelo.** Añade un paso `validate-source` a `MoneyTransferSaga` que compruebe que el monedero origen tiene fondos suficientes, sin realizar el cargo. El paso debería ejecutarse *en paralelo* con nada (sin `depends_on`), y `debit-source` debería depender de él. Extiende `credit-destination` para que dependa de `debit-source` como antes. Verifica la topología mediante `SagaRegistry.get("money-transfer")` en una prueba y comprueba que `definition.steps["debit-source"].depends_on == ["validate-source"]`.
+
+**Ejercicio 2 — Manejador de errores de compensación.** Cambia la política de compensación global de `MoneyTransferSaga` a `RETRY_WITH_BACKOFF` en `application.yaml`. Luego haz deliberadamente que `recredit_source` lance `RuntimeError` en la primera llamada y tenga éxito en la segunda. Escribe una prueba de pytest usando `AsyncMock` sobre `WalletRepository` que verifique que la saga acaba compensando con éxito y que `result.compensated_steps()` contiene `"debit-source"`.
+
+**Ejercicio 3 — Persistencia personalizada.** Implementa `TransactionalPersistencePort` respaldado por un `dict` de Python plano que registre cada llamada. Regístralo como un `@service` y escribe una prueba que ejecute `TransferService`, luego llame a `get_state(correlation_id)` en tu adaptador y compruebe que el `status` registrado es `"COMPLETED"`. Extiende la prueba para simular una saga obsoleta estableciendo manualmente `status = "IN_FLIGHT"` y un `started_at` pasado, y luego comprueba que `SagaRecoveryService.recover_stale(stale_threshold_seconds=0)` devuelve `1`.
diff --git a/book/manuscript-es/13-caching-resilience.md b/book/manuscript-es/13-caching-resilience.md
new file mode 100644
index 00000000..e0ca2528
--- /dev/null
+++ b/book/manuscript-es/13-caching-resilience.md
@@ -0,0 +1,1327 @@
+Capítulo 13
+
+# Caché y resiliencia {.chtitle}
+
+::: figure art/openers/ch13.svg |
+
+En el Capítulo 11 dividiste Lumen en servicios independientes y enseñaste a su
+manejador (handler) de monederos a llamar a un `AccountService` descendente a
+través de HTTP. En el Capítulo 12 añadiste una `DepositSaga` para coordinar
+operaciones de varios pasos a través de las fronteras de servicio, con
+transacciones de compensación listas para dispararse cuando cualquier paso
+fallara.
+
+Esos dos capítulos introdujeron una nueva clase de problema: la latencia y la
+propagación de fallos. Cada salto HTTP a `AccountService` es un viaje de ida y
+vuelta que podría ser lento en una red congestionada, y cada llamada a la
+propia base de datos de Lumen compite con participantes de saga concurrentes.
+En un sistema distribuido, los fallos no son eventos excepcionales: son
+mantenimiento programado. `AccountService` se actualizará en plena carga de
+tráfico. Redis tendrá un tropiezo. Una pasarela de pago se disparará a tiempos
+de respuesta de tres segundos durante el pico de liquidación.
+
+Sin protección, Lumen propaga esos fallos hacia arriba. Un `AccountService`
+lento atasca corrutinas, bloqueando lecturas de monederos para usuarios no
+relacionados. Una breve caída de Redis borra los saldos en caché y envía cada
+petición directamente a la base de datos, multiplicando la carga en el peor
+momento posible.
+
+Este capítulo hace que Lumen sea **rápido** y **tolerante a fallos**. La primera
+mitad cubre la capa de caché declarativa de PyFly — **`@cacheable`**,
+**`@cache_put`** y **`@cache_evict`** — y muestra cómo respaldarlas con un
+`InMemoryCache` en proceso para desarrollo y un `RedisCacheAdapter` compartido
+con conmutación por error (failover) automática para producción. La segunda
+mitad incorpora el conjunto de herramientas de resiliencia: un **limitador de
+tasa** de cubo de tokens que limita el tráfico entrante, un **bulkhead** de
+semáforo que aísla la concurrencia, un **limitador de tiempo** que cancela
+corrutinas colgadas, un **fallback** que degrada con elegancia, y patrones de
+**reintento** y **cortacircuitos** (circuit breaker) que protegen las llamadas
+salientes. Una sección de cierre muestra cómo apilarlos todos en el orden
+correcto.
+
+Al final del capítulo, cada ruta caliente de Lumen estará en caché y cada
+dependencia saliente estará envuelta en una valla de resiliencia.
+
+!!! note "Lo que construirás, en términos sencillos"
+ Este capítulo introduce mucho vocabulario — *caché*, *cubo de tokens*,
+ *bulkhead*, *cortacircuitos*. No dejes que la jerga te intimide. Cada una
+ de estas es una herramienta pequeña y autocontenida que atornillas a una
+ función con un único decorador. Presentaremos cada herramienta de una en
+ una, la integraremos en Lumen paso a paso, la ejecutaremos y observaremos
+ qué cambia. Al final tendrás una lista de comprobación mental: *¿es esta
+ lectura caliente? cachéala; ¿va esta llamada por la red? ponle una valla.*
+ La versión de PyFly usada a lo largo del libro es **v26.6.110** — cada
+ comando y clave de configuración de abajo coincide con esa versión.
+
+---
+
+## Cachear la ruta de lectura
+
+### ¿Por qué cachear las lecturas de monedero?
+
+!!! note "Nuevo término: caché"
+ Una *caché* es una pequeña y rápida zona de almacenamiento donde guardas la
+ respuesta a una pregunta costosa para poder devolverla al instante la
+ próxima vez que alguien la formule. La primera vez que Lumen calcula el
+ saldo del monedero `w-001` almacena el resultado en la caché; las lecturas
+ posteriores devuelven esa copia almacenada sin volver a ejecutar la consulta
+ a la base de datos. La contrapartida — y siempre hay una contrapartida — es
+ que la copia almacenada puede estar ligeramente desactualizada. El resto de
+ esta sección trata de mantener esa obsolescencia dentro de límites
+ aceptables.
+
+La operación más frecuente de Lumen es la consulta de saldo: "¿cuál es el saldo
+actual del monedero `w-001`?". Bajo carga normal esa consulta llega a la réplica
+de lectura. Bajo carga intensa compite con comandos de depósito, participantes
+de saga y escrituras de instantáneas. Un saldo en caché cuesta una búsqueda en
+Redis — un único viaje de ida y vuelta de red colocalizado — comparado con una
+consulta SQL completa que la réplica de lectura debe además analizar,
+planificar y ejecutar.
+
+La economía es convincente, pero la caché introduce una preocupación de
+corrección: el saldo en caché puede ir por detrás del saldo confirmado hasta el
+límite del TTL. Para Lumen, un saldo obsoleto de cinco segundos es una
+contrapartida aceptable para el tráfico de consultas normal. Cuando un depósito
+se completa, el manejador invalida la entrada de caché inmediatamente, de modo
+que la siguiente lectura de saldo refleja el cambio. Las actualizaciones que
+pasan por la saga usan `@cache_put` para refrescar el valor en caché como efecto
+secundario de la escritura, eliminando cualquier ventana de obsolescencia
+visible.
+
+::: figure art/figures/13-cache.svg | Figura 13.1 — Los decoradores de caché se sitúan delante de la capa de servicio. En un acierto el cuerpo de la función nunca se ejecuta; en un fallo se ejecuta y el resultado se almacena.
+
+### La abstracción de caché
+
+La capa de caché de PyFly sigue el principio hexagonal que has visto a lo largo
+del libro: la lógica de negocio depende de un protocolo **`CacheAdapter`**, no
+de ningún backend específico. Las implementaciones concretas — `InMemoryCache`
+para desarrollo y `RedisCacheAdapter` para producción — se conectan a través del
+contenedor de inyección de dependencias. Cambiar de backend no requiere ningún
+cambio en la lógica de negocio.
+
+El protocolo `CacheAdapter` define el contrato completo:
+
+| Método | Devuelve | Descripción |
+|---|---|---|
+| `get(key)` | `Any \| None` | Devuelve el valor en caché, o `None` si está ausente o ha expirado. |
+| `put(key, value, ttl=None)` | `None` | Almacena un valor; `ttl` es un `timedelta` o `None` para que no expire. |
+| `evict(key)` | `bool` | Elimina una clave; devuelve `True` si existía. |
+| `exists(key)` | `bool` | Comprueba la presencia sin obtener el valor. |
+| `clear()` | `None` | Vacía toda la caché. |
+| `start()` | `None` | Se llama una vez al arrancar la aplicación. |
+| `stop()` | `None` | Se llama una vez al apagar la aplicación. |
+
+Tanto `InMemoryCache` como `RedisCacheAdapter` implementan este contrato.
+`InMemoryCache` almacena las entradas en un `OrderedDict` con expiración de TTL
+perezosa y acotación LRU opcional; es ideal para el desarrollo en un único
+proceso y para las suites de pruebas porque no tiene dependencias externas.
+`RedisCacheAdapter` envuelve un cliente `redis.asyncio.Redis`, serializa los
+valores a JSON antes de almacenarlos y delega la gestión del TTL en el propio
+Redis — las claves expiradas desaparecen del lado del servidor sin sobrecarga
+de limpieza alguna por tu parte.
+
+### Configurar un backend de caché
+
+Conectaremos dos backends: uno en proceso para desarrollo y uno compartido
+respaldado por Redis para producción. Tómalos de uno en uno.
+
+**Paso 1 — Elige un backend de desarrollo.** Para desarrollo, lo único que
+necesitas es una sola importación:
+
+::: listing lumen/cache/config_dev.py | Listado 13.1 — InMemoryCache para desarrollo
+from pyfly.cache.adapters.memory import InMemoryCache
+
+wallet_cache = InMemoryCache(max_size=1000)
+:::
+
+`max_size=1000` acota la ventana de expulsión LRU: una vez que la caché contiene
+1000 entradas, la entrada usada menos recientemente se descarta para hacer
+sitio. Pasa `None` (el valor por defecto) para dejar la caché sin límite y
+depender por completo de los TTL.
+
+!!! note "Nuevo término: LRU y TTL"
+ *LRU* significa *least-recently-used* (la usada menos recientemente) —
+ cuando la caché está llena, la entrada que nadie ha tocado durante más
+ tiempo es la que se expulsa para hacer sitio. *TTL* significa *time-to-live*
+ (tiempo de vida) — cuánto tiempo permanece válida una entrada antes de
+ expirar por sí sola. `InMemoryCache` admite ambos: `max_size` limita cuántas
+ entradas contiene (LRU); cada `put` puede llevar un TTL que va envejeciendo
+ la entrada hasta sacarla.
+
+**Paso 2 — Elige un backend de producción.** Para producción, apunta
+`RedisCacheAdapter` a un cliente `redis.asyncio.Redis` y envuélvelo con un
+fallback en memoria para que un tropiezo de Redis nunca tumbe a Lumen:
+
+::: listing lumen/cache/config_prod.py | Listado 13.2 — RedisCacheAdapter para producción
+import redis.asyncio as aioredis
+
+from pyfly.cache import CacheAdapter, CacheManager
+from pyfly.cache.adapters.memory import InMemoryCache
+from pyfly.cache.adapters.redis import RedisCacheAdapter
+from pyfly.container import bean, configuration
+
+
+@configuration
+class CacheConfig:
+
+ @bean
+ def wallet_cache(self) -> CacheAdapter:
+ client = aioredis.from_url("redis://localhost:6379/0")
+ primary = RedisCacheAdapter(client)
+ fallback = InMemoryCache(max_size=500)
+ return CacheManager(primary=primary, fallback=fallback)
+:::
+
+**Cómo funciona:** `CacheManager` envuelve un backend primario de Redis y un
+fallback en memoria. Cada escritura va a ambas cachés, manteniendo el fallback
+caliente. En las lecturas, el gestor prueba primero con Redis; si Redis lanza
+una excepción registra un `WARNING` y recurre al almacén en proceso de forma
+silenciosa. Cuando Redis se recupera, las nuevas escrituras lo repueblan
+inmediatamente — sin intervención manual. El método `@bean` le indica al
+contenedor de inyección de dependencias de PyFly que cree un singleton y lo
+inyecte allá donde `CacheAdapter` se declare como dependencia.
+
+**Lo que acaba de pasar.** Ahora tienes una interfaz `CacheAdapter` y dos formas
+de satisfacerla. En desarrollo le entregas al contenedor de inyección de
+dependencias un `InMemoryCache`; en producción le entregas un `CacheManager` que
+pone a Redis por delante y recurre silenciosamente a la memoria cuando Redis es
+inalcanzable. Cada manejador en el resto de este capítulo pide `cache:
+CacheAdapter` en su constructor y nunca sabe ni le importa cuál de los dos
+recibió — esa es la recompensa hexagonal.
+
+!!! tip "Autoconfiguración"
+ No tienes por qué escribir la clase `@configuration` en absoluto. Añade lo
+ siguiente a `pyfly.yaml` y la `CacheAutoConfiguration` de PyFly construye un
+ bean `CacheAdapter` por ti al arrancar:
+
+ ```yaml
+ pyfly:
+ cache:
+ enabled: true # required to switch the subsystem on
+ provider: redis # redis | postgres | memory | auto
+ redis:
+ url: redis://localhost:6379/0
+ max-size: 1000 # used by the memory provider
+ ```
+
+ Con `provider: redis` (o `auto`, que detecta un `redis.asyncio` instalado)
+ la autoconfiguración conecta un `RedisCacheAdapter` apuntado a
+ `pyfly.cache.redis.url`. Registra ese único adaptador como el bean
+ `CacheAdapter` — **no** añade la capa de failover en memoria. Cuando quieras
+ Redis *más* el fallback en proceso transparente que se muestra en el Listado
+ 13.2, declara tú mismo el `CacheManager` en una clase `@configuration` como
+ arriba. La autoconfiguración también se retira por completo si ya has
+ definido tu propio bean `CacheAdapter` (`@conditional_on_missing_bean`), de
+ modo que los dos enfoques nunca chocan.
+
+### @cacheable — saltarse la ejecución en un acierto
+
+**`@cacheable`** es el decorador más común. En la primera llamada ejecuta el
+cuerpo de la función y almacena el valor de retorno. En cada llamada posterior
+con la misma clave devuelve el valor almacenado *sin ejecutar en absoluto el
+cuerpo de la función*.
+
+El `GetBalanceHandler` de Lumen encaja de forma natural: las lecturas de saldo
+son frecuentes, baratas de cachear y toleran unos pocos segundos de
+obsolescencia. Le añadiremos caché en tres pequeños pasos.
+
+**Paso 1 — Acepta la caché.** Añade un parámetro `cache: CacheAdapter` al
+constructor. El contenedor de inyección de dependencias de PyFly ve el tipo e
+inyecta el backend que hayas conectado en la sección anterior.
+
+**Paso 2 — Mueve el trabajo real a un método privado.** Renombra el cuerpo que
+llega a la base de datos a `_fetch`. Esta es la función que la caché envolverá.
+
+**Paso 3 — Envuélvelo en el momento de la construcción.** Dentro de `__init__`,
+establece `self.do_handle = cacheable(...)(self._fetch)`. Envolvemos dentro de
+`__init__` (en lugar de como un decorador `@cacheable` sobre el método) por una
+razón: el argumento `backend=cache` solo existe una vez que `cache` ha sido
+inyectado, y eso no ocurre hasta que se ejecuta `__init__`.
+
+El manejador recibe `CacheAdapter` a través de su constructor — inyectado por
+PyFly — y envuelve `do_handle` en el momento de la construcción:
+
+::: listing lumen/core/services/wallets/get_balance_handler.py | Listado 13.3 — @cacheable en GetBalanceHandler
+from datetime import timedelta
+
+from lumen.core.mappers.wallet_mapper import entity_to_balance_dto
+from lumen.core.services.wallets.get_balance_query import GetBalance
+from lumen.interfaces.dtos.v1.balance_dto import BalanceDto
+from lumen.models.repositories.wallet_repository import WalletRepository
+from pyfly.cache import CacheAdapter, cacheable
+from pyfly.container import service
+from pyfly.cqrs import QueryHandler, query_handler
+
+
+@query_handler
+@service
+class GetBalanceHandler(QueryHandler[GetBalance, BalanceDto | None]):
+ """Return a cached :class:`BalanceDto`; bypass the DB on a hit."""
+
+ def __init__(
+ self,
+ repository: WalletRepository,
+ cache: CacheAdapter,
+ ) -> None:
+ super().__init__()
+ self._repository = repository
+ # Wrap do_handle at construction time so `cache` is in scope.
+ self.do_handle = cacheable(
+ backend=cache,
+ key="wallet:balance:{query.wallet_id}",
+ ttl=timedelta(seconds=5),
+ )(self._fetch)
+
+ async def _fetch(
+ self, query: GetBalance
+ ) -> BalanceDto | None:
+ entity = await self._repository.find_by_id(query.wallet_id)
+ return entity_to_balance_dto(entity) if entity is not None else None
+:::
+
+!!! note "La plantilla de clave y `self`"
+ La plantilla `key` `"wallet:balance:{query.wallet_id}"` usa la sintaxis
+ `str.format` de Python. PyFly enlaza los argumentos reales de la llamada con
+ `inspect.signature(func).bind(*args, **kwargs)` y después llama a
+ `key.format(**bound.arguments)`. Como `_fetch` se envuelve dentro de
+ `__init__`, el primer argumento posicional es `query` — de modo que
+ `{query.wallet_id}` se expande al id del monedero. Llamar con
+ `GetBalance(wallet_id="wlt-001")` produce la clave de caché
+ `"wallet:balance:wlt-001"`. La función de mapeo `entity_to_balance_dto` pasa
+ por `Mapper.project` contra la interfaz `BalanceView` marcada con
+ `@projection`, copiando solo los campos que la vista de saldo declara y
+ calculando `balance` a partir de `balance_minor`.
+
+**`ttl=timedelta(seconds=5)`** significa que la entrada de caché expira cinco
+segundos después de escribirse. Tras la expiración, la siguiente llamada vuelve
+a ejecutar el cuerpo de la función y refresca la entrada. Un TTL de `None` (el
+valor por defecto) significa que la entrada nunca expira — apropiado solo para
+datos verdaderamente inmutables.
+
+**Caché de nulos:** Cuando la función devuelve `None`, PyFly aun así almacena la
+entrada y registra que la clave *existe*. Una llamada posterior encuentra la
+clave y devuelve `None` sin tocar la base de datos. Esto previene ataques de
+penetración de caché en los que un adversario inunda con peticiones de claves
+inexistentes, cada una de las cuales de otro modo se filtraría hasta la base de
+datos.
+
+**`condition` y `unless`:** Tanto `@cache` como `@cacheable` aceptan predicados
+opcionales. `condition` es un invocable con la misma firma que la función
+decorada; si devuelve `False`, se omite la caché para esa llamada. `unless` es
+un invocable que recibe el *resultado*; si devuelve `True`, el resultado se
+devuelve pero no se almacena. Ambos son de solo palabra clave:
+
+```python
+cacheable(
+ backend=cache,
+ key="wallet:balance:{query.wallet_id}",
+ ttl=timedelta(seconds=5),
+ condition=lambda query: not query.wallet_id.startswith("test-"),
+ unless=lambda result: result is None,
+)(self._fetch)
+```
+
+#### Ejecútalo — demuestra que la segunda lectura se salta la base de datos
+
+La forma más limpia de *ver* un acierto de caché es una prueba unitaria que
+cuente cuántas veces se llama al repositorio. Usa un `InMemoryCache` real (sin
+necesidad de Redis) y un repositorio de prueba minúsculo:
+
+::: listing tests/cache/test_get_balance_cache.py | Listado 13.3a — Una prueba que demuestra que la segunda lectura es un acierto
+from datetime import timedelta
+
+import pytest
+
+from pyfly.cache import cacheable
+from pyfly.cache.adapters.memory import InMemoryCache
+
+
+class _CountingRepo:
+ """Stub repository that records how many times it is queried."""
+
+ def __init__(self) -> None:
+ self.calls = 0
+
+ async def find_by_id(self, wallet_id: str) -> dict:
+ self.calls += 1
+ return {"wallet_id": wallet_id, "balance_minor": 500}
+
+
+@pytest.mark.asyncio
+async def test_second_read_is_a_cache_hit() -> None:
+ repo = _CountingRepo()
+ cache = InMemoryCache(max_size=10)
+
+ fetch = cacheable(
+ backend=cache,
+ key="wallet:balance:{wallet_id}",
+ ttl=timedelta(seconds=5),
+ )(repo.find_by_id)
+
+ first = await fetch("wlt-001") # miss -> runs the repo
+ second = await fetch("wlt-001") # hit -> repo NOT called again
+
+ assert first == second
+ assert repo.calls == 1 # the body ran exactly once
+:::
+
+Ejecuta solo esta prueba:
+
+```console
+$ uv run --extra dev pytest tests/cache/test_get_balance_cache.py -q
+. [100%]
+1 passed in 0.04s
+```
+
+El único `.` y el `1 passed` lo confirman: la segunda llamada devolvió el valor
+en caché y `repo.calls` se quedó en `1`, de modo que la base de datos se tocó
+exactamente una vez en dos lecturas. Eso es un acierto de caché, demostrado en
+lugar de afirmado en prosa.
+
+**Lo que acaba de pasar.** Envolviste una función async sencilla con `cacheable`,
+la respaldaste con un `InMemoryCache` y confirmaste que claves idénticas
+cortocircuitan el cuerpo. En `GetBalanceHandler` la función envuelta es `_fetch`
+y el backend es el `CacheAdapter` inyectado, pero la mecánica es exactamente la
+que acabas de ejecutar.
+
+!!! spring "Equivalencia con Spring"
+ `@cacheable` refleja la `@Cacheable` de Spring. La plantilla `key` usa la sintaxis `str.format` de Python en lugar de SpEL, pero la semántica — saltarse en acierto, almacenar en fallo, `condition`, `unless` — es idéntica. `@cache` es un alias de más bajo nivel que se comporta igual; usa el nombre que mejor se lea en tu base de código.
+
+### @cache_put — ejecutar siempre, almacenar siempre
+
+`@cacheable` es para lecturas: cortocircuita la función cuando la caché ya
+contiene un valor. **`@cache_put`** es para escrituras: *siempre* ejecuta la
+función y *siempre* almacena el resultado. Úsalo cuando la función es la fuente
+de la verdad — un manejador de comandos que modifica el monedero y debe mantener
+la caché actualizada.
+
+`DepositFundsHandler` es el ejemplo canónico. Después de que un depósito tiene
+éxito, el nuevo saldo debe ser visible para la siguiente lectura sin esperar a
+que el TTL expire. El cableado refleja lo que hiciste para `@cacheable`, con un
+detalle crítico que vigilar:
+
+**Paso 1 — Acepta la caché** en el constructor, exactamente como antes.
+
+**Paso 2 — Mueve la lógica de depósito a `_deposit`** y conserva su decorador
+`@transactional()` para que la escritura siga confirmándose como una unidad de
+trabajo.
+
+**Paso 3 — Envuelve con `cache_put`, reutilizando la *misma* forma de clave.**
+El manejador de depósitos debe escribir en la mismísima ranura de caché que el
+lector de saldos consulta. `@cacheable` usa `"wallet:balance:{query.wallet_id}"`;
+aquí el argumento se llama `command`, así que la plantilla es
+`"wallet:balance:{command.wallet_id}"`. Distintos nombres de parámetro, pero
+ambos resuelven a `wallet:balance:wlt-001`.
+
+Envolver `do_handle` con `@cache_put` refresca la entrada de caché de forma
+atómica con la escritura:
+
+::: listing lumen/core/services/wallets/deposit_funds_handler.py | Listado 13.4 — @cache_put refresca la caché en un depósito
+from datetime import timedelta
+
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+from lumen.core.mappers.wallet_mapper import to_aggregate, to_entity
+from lumen.core.services.wallets.deposit_funds_command import DepositFunds
+from lumen.core.services.wallets.event_publishing import publish_domain_events
+from lumen.models.entities.v1.money import Money
+from lumen.models.repositories.wallet_repository import WalletRepository
+from pyfly.cache import CacheAdapter, cache_put
+from pyfly.container import service
+from pyfly.cqrs import CommandHandler, command_handler
+from pyfly.data.relational.sqlalchemy import transactional
+from pyfly.domain import AggregateNotFound
+from pyfly.eda import EventPublisher
+
+
+@command_handler
+@service
+class DepositFundsHandler(CommandHandler[DepositFunds, int]):
+ """Credit funds to an existing wallet; returns the new balance
+ in minor units and refreshes the cached balance entry."""
+
+ def __init__(
+ self,
+ repository: WalletRepository,
+ events: EventPublisher,
+ session_factory: async_sessionmaker[AsyncSession],
+ cache: CacheAdapter,
+ ) -> None:
+ super().__init__()
+ self._repository = repository
+ self._events = events
+ self._session_factory = session_factory
+ # Wrap at construction time so `cache` is in scope.
+ self.do_handle = cache_put(
+ backend=cache,
+ key="wallet:balance:{command.wallet_id}",
+ ttl=timedelta(seconds=5),
+ )(self._deposit)
+
+ @transactional()
+ async def _deposit(self, command: DepositFunds) -> int:
+ entity = await self._repository.find_by_id(command.wallet_id)
+ if entity is None:
+ raise AggregateNotFound("Wallet", command.wallet_id)
+
+ wallet = to_aggregate(entity)
+ wallet.deposit(Money(amount=command.amount, currency=wallet.currency))
+ await self._repository.upsert(to_entity(wallet))
+ await publish_domain_events(self._events, wallet.clear_events())
+ return wallet.balance.amount
+:::
+
+**Cómo funciona:** `@cache_put` espera (await) a la función envuelta, después
+llama a `backend.put(resolved_key, result, ttl=ttl)`. Como la función siempre se
+ejecuta, el valor en caché tras un comando `DepositFunds` es el saldo recién
+confirmado — no una instantánea obsoleta anterior al depósito. `_deposit` se
+ejecuta dentro de `@transactional()`, de modo que la secuencia `find_by_id →
+to_aggregate → mutate → upsert` se confirma como una unidad de trabajo antes de
+que se refresque la caché. La siguiente lectura `@cacheable` en
+`GetBalanceHandler` recoge este valor fresco sin tocar la base de datos.
+
+!!! note "La clave de caché debe coincidir"
+ La clave `@cache_put` `"wallet:balance:{command.wallet_id}"` debe coincidir con la clave `@cacheable` `"wallet:balance:{query.wallet_id}"` cuando ambas resuelven al mismo id de monedero. Claves que no coinciden significan que el depósito escribe en una ranura de caché distinta de la que consulta la lectura de saldo — la obsolescencia regresa.
+
+| Decorador | ¿Se ejecuta la función? | En un acierto |
+|---|---|---|
+| `@cacheable` / `@cache` | Solo en un fallo | Devuelve el valor en caché |
+| `@cache_put` | Siempre | Reemplaza el valor en caché con el resultado fresco |
+
+### @cache_evict — eliminar tras un borrado
+
+Cuando cierras un monedero o reviertes una transacción, la entrada de caché
+asociada debe eliminarse. **`@cache_evict`** ejecuta primero el cuerpo de la
+función y después elimina la clave indicada — o vacía toda la caché cuando
+`all_entries=True`.
+
+::: listing lumen/core/services/wallets/close_wallet_handler.py | Listado 13.5 — @cache_evict tras eliminar un monedero
+from lumen.models.repositories.wallet_repository import WalletRepository
+from pyfly.cache import CacheAdapter, cache_evict
+from pyfly.container import service
+from pyfly.cqrs import CommandHandler, command_handler
+from pyfly.data.relational.sqlalchemy import transactional
+
+
+@command_handler
+@service
+class CloseWalletHandler(CommandHandler["CloseWallet", None]):
+ """Close a wallet and evict its cached balance entry."""
+
+ def __init__(
+ self,
+ repository: WalletRepository,
+ cache: CacheAdapter,
+ ) -> None:
+ super().__init__()
+ self._repository = repository
+ self.do_handle = cache_evict(
+ backend=cache,
+ key="wallet:balance:{command.wallet_id}",
+ )(self._close)
+
+ @transactional()
+ async def _close(self, command) -> None:
+ entity = await self._repository.find_by_id(command.wallet_id)
+ if entity is not None:
+ await self._repository.delete(entity)
+:::
+
+Para vaciar de golpe cada saldo en caché — útil para un reinicio administrativo
+— pasa `all_entries=True`:
+
+```python
+self.do_handle = cache_evict(
+ backend=cache,
+ all_entries=True,
+)(self._reset_all)
+```
+
+**Cómo funciona:** El cuerpo de la función se ejecuta primero —
+`repository.delete(entity)` elimina la fila antes de la expulsión, de modo que
+un fallo no descarta prematuramente la entrada de caché. Después, o bien
+`backend.evict(resolved_key)` elimina una clave o bien `backend.clear()` vacía
+todo. Con `CacheManager`, la expulsión se propaga tanto a la caché primaria como
+a la de fallback, de modo que no queda ninguna entrada obsoleta en ninguno de
+los dos niveles.
+
+`all_entries=True` es un instrumento contundente reservado para reinicios
+administrativos. En el funcionamiento normal, prefiere la expulsión dirigida por
+clave.
+
+### Estrategia de invalidación
+
+Una estrategia coherente empareja cada operación con el decorador correcto:
+
+| Operación | Decorador | Razón |
+|---|---|---|
+| Consulta `GetBalance` | `@cacheable` | Salta la BD en acierto; el TTL de 5 s acota la obsolescencia |
+| Comando `DepositFunds` | `@cache_put` | Refresca la entrada de caché de forma atómica con la escritura |
+| Comando `WithdrawFunds` | `@cache_put` | Igual — mantén caliente el saldo posterior a la retirada |
+| Cerrar monedero | `@cache_evict` | Elimina la entrada; la siguiente lectura la reconstruye desde la BD |
+| Truncado de admin | `@cache_evict(all_entries=True)` | Reinicio masivo; un vaciado completo de la caché es lo correcto |
+
+!!! warning "Requisito de asincronía"
+ Los tres decoradores requieren que la función envuelta esté declarada `async`. Los adaptadores de caché son totalmente asíncronos (esperan con `await` las operaciones del backend), de modo que un objetivo síncrono fallará con un `TypeError` en el momento de la decoración — PyFly lanza el error inmediatamente para que cojas el error al arrancar en lugar de en tiempo de ejecución.
+
+---
+
+## Patrones de resiliencia
+
+### Por qué importa la protección
+
+!!! note "Nuevo término: resiliencia"
+ *Resiliencia* aquí significa que el sistema sigue sirviendo las peticiones
+ que *puede* servir incluso cuando una dependencia de la que depende está
+ lenta, sobrecargada o caída. Las herramientas de esta sección no hacen que
+ el servicio descendente sea más rápido — impiden que una dependencia enferma
+ enferme a Lumen entero. Cada herramienta es, de nuevo, un decorador que
+ apilas sobre la función que realiza la llamada arriesgada.
+
+La caché hace rápido el camino feliz. Los patrones de resiliencia protegen a
+Lumen cuando el camino feliz no está disponible. Sin protección, un
+`AccountService` lento desencadena una cascada:
+
+1. Las peticiones de los manejadores de monederos se acumulan, cada una
+ esperando una respuesta HTTP.
+2. El bucle de eventos asyncio de Lumen — de un solo hilo por defecto — procesa
+ las tareas pendientes en orden; una acumulación de llamadas HTTP lentas
+ retrasa cualquier otra operación.
+3. La memoria y los descriptores de archivo abiertos suben a medida que las
+ corrutinas se apilan.
+4. Lumen se vuelve no disponible para peticiones que no tienen nada que ver con
+ `AccountService`.
+
+Cuatro patrones complementarios rompen esta cascada antes de que empiece:
+
+::: figure art/figures/13-resilience.svg | Figura 13.2 — Cuatro capas de resiliencia guardan la llamada saliente. El limitador de tasa descarta el tráfico excedente antes de que entre en el sistema; el bulkhead limita la concurrencia; el tiempo límite cancela las operaciones lentas; el fallback proporciona una respuesta segura cuando todo lo demás falla.
+
+| Patrón | Protege contra | ¿Fallo rápido o espera? |
+|---|---|---|
+| **Limitador de tasa** | Picos de tráfico que abruman al servicio descendente | Fallo rápido (rechaza el exceso) |
+| **Bulkhead** | Demasiadas llamadas concurrentes que atan recursos | Fallo rápido (rechaza por encima del límite) |
+| **Limitador de tiempo** | Llamadas colgadas que nunca regresan | Cancela tras el tiempo límite |
+| **Fallback** | Cualquier fallo que llegue al llamante | Devuelve un valor degradado |
+
+Los cuatro están en `pyfly.resilience`:
+
+```python
+from pyfly.resilience import (
+ RateLimiter, rate_limiter,
+ Bulkhead, bulkhead,
+ time_limiter,
+ fallback,
+)
+```
+
+### Limitador de tasa — cubo de tokens
+
+`RateLimiter` usa un **cubo de tokens**: el cubo contiene hasta `max_tokens`
+tokens y se rellena a `refill_rate` tokens por segundo. Cada llamada consume un
+token. Cuando el cubo está vacío, se lanza `RateLimitException` inmediatamente —
+sin cola, sin espera.
+
+::: listing lumen/resilience/rate_example.py | Listado 13.6 — Limitador de tasa de cubo de tokens en las búsquedas de cuentas
+from pyfly.resilience import RateLimiter, rate_limiter
+
+# Sustained: 20 calls/s; burst: up to 40
+account_limiter = RateLimiter(max_tokens=40, refill_rate=20.0)
+
+
+@rate_limiter(account_limiter)
+async def fetch_account(account_id: str) -> dict:
+ # This body is reached only when a token is available.
+ ...
+:::
+
+**Cómo funciona:** `@rate_limiter(limiter)` llama a `await limiter.acquire()`
+antes de cada invocación. `acquire()` rellena el cubo según el tiempo de reloj
+transcurrido (usando `time.monotonic()`), después comprueba y decrementa
+atómicamente el recuento de tokens bajo un `threading.Lock` — no un lock de
+asyncio — de modo que tanto las tareas async como los llamantes síncronos
+comparten el mismo recuento sin condiciones de carrera. Si quedan menos de 1.0
+tokens, `RateLimitException` se propaga al llamante.
+
+La forma de cubo de tokens permite ráfagas controladas: un servicio que
+normalmente ve 10 llamadas por segundo puede absorber una ráfaga de 40 llamadas
+inmediatamente (recurriendo a tokens ahorrados) y después sostiene 20 llamadas
+por segundo a partir de ahí. Los limitadores de tasa de ventana fija no pueden
+expresar este matiz.
+
+#### Ejecútalo — observa cómo el cubo se vacía
+
+Un pequeño script hace concreto el comportamiento. Crea un limitador con un cubo
+diminuto, llama más allá de su capacidad y observa el rechazo:
+
+::: listing scratch/rate_demo.py | Listado 13.6a — Vaciar el cubo de tokens a propósito
+import asyncio
+
+from pyfly.kernel.exceptions import RateLimitException
+from pyfly.resilience import RateLimiter, rate_limiter
+
+# 3 tokens, refilling slowly so the burst is what we observe.
+limiter = RateLimiter(max_tokens=3, refill_rate=1.0)
+
+
+@rate_limiter(limiter)
+async def ping(n: int) -> str:
+ return f"ok-{n}"
+
+
+async def main() -> None:
+ for n in range(5):
+ try:
+ print(await ping(n))
+ except RateLimitException:
+ print(f"rejected-{n}")
+
+
+asyncio.run(main())
+:::
+
+Ejecútalo directamente:
+
+```console
+$ uv run python scratch/rate_demo.py
+ok-0
+ok-1
+ok-2
+rejected-3
+rejected-4
+```
+
+Las primeras tres llamadas gastan cada una un token; la cuarta y la quinta
+llegan con un cubo vacío y se rechazan inmediatamente con `RateLimitException` —
+sin cola, sin espera. Ralentiza el bucle (o sube `refill_rate`) y los rechazos
+desaparecen porque los tokens se rellenan entre llamadas.
+
+**Lo que acaba de pasar.** No cambiaste la función `ping` en absoluto — la
+decoraste. El decorador insertó un `await limiter.acquire()` antes de cada
+llamada, y `acquire()` lanzó cuando el cubo estaba vacío. Esta es la forma que
+adopta cada herramienta de resiliencia de este capítulo: un decorador que guarda
+la llamada sin que el cuerpo de la función sepa que existe.
+
+Múltiples funciones que comparten una instancia `RateLimiter` imponen una tasa
+*global* a través de todas ellas — útil para limitar el tráfico total hacia un
+servicio descendente con independencia de qué método interno inicie la llamada.
+
+### Bulkhead — aislamiento de concurrencia
+
+!!! note "Nuevo término: bulkhead"
+ El nombre viene de la construcción naval: el casco de un barco se divide en
+ compartimentos estancos (*bulkheads*, mamparos) de modo que una brecha en
+ uno no inunde toda la nave. Un bulkhead de software limita cuántas llamadas
+ a una dependencia pueden ejecutarse a la vez, de modo que una avalancha de
+ llamadas lentas a `AccountService` no pueda consumir cada corrutina y hundir
+ peticiones no relacionadas.
+
+`Bulkhead` es un semáforo: limita el número de llamadas *en vuelo al mismo
+tiempo*. Las llamadas que superen `max_concurrent` se rechazan inmediatamente
+con `BulkheadException`.
+
+::: listing lumen/resilience/bulkhead_example.py | Listado 13.7 — Bulkhead que limita las llamadas concurrentes al servicio de cuentas
+from pyfly.resilience import Bulkhead, bulkhead
+
+# At most 5 concurrent calls to AccountService
+account_bulkhead = Bulkhead(max_concurrent=5)
+
+
+@bulkhead(account_bulkhead)
+async def fetch_account(account_id: str) -> dict:
+ ...
+:::
+
+**Cómo funciona:** El decorador adquiere un permiso (`_acquire_slot`) antes de
+entrar en la función y lo libera (`_release_slot`) en un bloque `finally`, de
+modo que la ranura siempre se devuelve incluso cuando la función lanza. Las
+ranuras se rastrean con un único contador entero protegido por lock compartido
+por las rutas de llamada async y síncrona, de modo que una instancia `Bulkhead`
+decora con seguridad una mezcla de corrutinas y funciones normales.
+
+Este comportamiento de fallo rápido es intencionado: cuando hay 5 llamadas
+concurrentes en vuelo y llega una 6.ª, rechazarla inmediatamente permite al
+llamante reintentar o invocar un fallback — mucho mejor que ponerla en cola
+indefinidamente y provocar contrapresión en cascada.
+
+!!! tip "Monitorizar la utilización del bulkhead"
+ `account_bulkhead.available_slots` devuelve el número de permisos libres en cualquier momento. Expón esto en un endpoint de salud o aliméntalo a tu pila de observabilidad para detectar la saturación persistente antes de que se convierta en una caída.
+
+### Limitador de tiempo — imponer una fecha límite
+
+Un servicio descendente lento es a veces peor que uno caído: las llamadas que
+bloquean indefinidamente consumen recursos sin límite. **`@time_limiter`**
+cancela la corrutina si no se completa dentro de un `timedelta`:
+
+::: listing lumen/resilience/timeout_example.py | Listado 13.8 — Fecha límite de 2 segundos en la búsqueda de cuenta
+from datetime import timedelta
+
+from pyfly.resilience import time_limiter
+
+
+@time_limiter(timeout=timedelta(seconds=2))
+async def fetch_account(account_id: str) -> dict:
+ ...
+:::
+
+**Cómo funciona:** Internamente, `time_limiter` llama a
+`asyncio.wait_for(func(*args, **kwargs), timeout=timeout_seconds)`. Cuando pasa
+la fecha límite, `asyncio.wait_for` cancela la tarea subyacente, provocando que
+cualquier `await` dentro de la función lance `asyncio.CancelledError`. El
+decorador captura `TimeoutError` y lo relanza como `OperationTimeoutException`
+con un mensaje descriptivo:
+
+```
+OperationTimeoutException: fetch_account exceeded timeout of 2.0s
+```
+
+Los recursos adquiridos dentro de la función con límite de tiempo deberían
+guardarse con `try/finally` para que se liberen incluso en una cancelación:
+
+```python
+@time_limiter(timeout=timedelta(seconds=2))
+async def fetch_account(account_id: str) -> dict:
+ conn = await pool.acquire()
+ try:
+ return await conn.execute(query, account_id)
+ finally:
+ await pool.release(conn)
+```
+
+### Fallback — degradación elegante
+
+**`@fallback`** es la red de seguridad de la capa más externa: captura
+excepciones y devuelve una respuesta alternativa en lugar de propagar el error
+al llamante. El endpoint de resumen de saldo de Lumen puede devolver una
+respuesta degradada — el último saldo conocido, marcado como potencialmente
+obsoleto — en lugar de un HTTP 500 cuando `AccountService` está caído.
+
+Hay dos modos disponibles. El primero devuelve un **valor estático**:
+
+::: listing lumen/resilience/fallback_static.py | Listado 13.9 — Valor de fallback estático
+from pyfly.resilience import fallback
+
+
+@fallback(fallback_value={"balance_minor": 0, "source": "fallback"})
+async def fetch_account(account_id: str) -> dict:
+ ...
+:::
+
+El segundo invoca un **método de fallback** que recibe los argumentos originales más la excepción:
+
+::: listing lumen/resilience/fallback_method.py | Listado 13.10 — Método de fallback con datos en caché
+from pyfly.cache import CacheAdapter
+from pyfly.resilience import fallback
+
+
+_cache: CacheAdapter # injected elsewhere
+
+
+async def account_from_cache(
+ account_id: str,
+ exc: Exception = None,
+) -> dict:
+ cached = await _cache.get(f"account:{account_id}")
+ if cached:
+ return {**cached, "source": "cache"}
+ return {"account_id": account_id, "balance_minor": 0, "source": "fallback"}
+
+
+@fallback(fallback_method=account_from_cache)
+async def fetch_account(account_id: str) -> dict:
+ ...
+:::
+
+**Cómo funciona:** Cuando la función primaria lanza uno de los tipos de excepción
+listados en `on` (por defecto: todas las subclases de `Exception`), el decorador
+llama a `fallback_method(*args, exc=exc, **kwargs)`. El argumento de palabra
+clave `exc` lleva la excepción capturada para que el fallback pueda registrarla,
+inspeccionar su tipo o devolver valores distintos para distintos modos de fallo.
+Si el método de fallback devuelve una corrutina, PyFly la espera con await
+automáticamente. Acota el filtro de excepciones con
+`on=(OperationTimeoutException, CircuitBreakerException)` para dejar que los
+errores de programación se propaguen con normalidad.
+
+!!! warning "Firma del método de fallback"
+ El método de fallback debe aceptar `exc` como argumento de palabra clave. PyFly pasa la excepción capturada como `exc=`. Si la firma de tu método de fallback no incluye `exc`, verás un `TypeError` con un mensaje claro en el primer fallo — no en el momento de la decoración.
+
+---
+
+## Reintento y cortacircuitos
+
+### @retry — reintentos acotados con backoff
+
+Los errores de red a menudo son transitorios: se pierde un paquete, un pool de
+conexiones se agota momentáneamente, un pod descendente se reinicia.
+**`@retry`** reinvoca la función decorada hasta `max_attempts` veces con backoff
+exponencial entre intentos.
+
+`max_attempts` es el único argumento posicional; todos los demás parámetros son
+de solo palabra clave:
+
+::: listing lumen/resilience/retry_example.py | Listado 13.11 — Reintento con backoff exponencial
+from pyfly.resilience import retry
+
+
+@retry(
+ max_attempts=3,
+ delay=0.1,
+ backoff=2.0,
+ max_delay=2.0,
+ exceptions=(IOError, TimeoutError),
+)
+async def fetch_account(account_id: str) -> dict:
+ ...
+:::
+
+**Cómo funciona:** El decorador ejecuta la función, captura las excepciones que
+coinciden con `exceptions`, duerme `delay * backoff ** attempt` segundos
+(limitado a `max_delay`) y vuelve a intentar. En el último intento relanza la
+última excepción. La pausa usa `await asyncio.sleep(...)` para funciones async y
+`time.sleep(...)` para funciones síncronas — la misma implementación maneja
+ambas. El parámetro `jitter` añade aleatorización para evitar reintentos en
+estampida (thundering-herd) cuando muchas instancias se reinician
+simultáneamente.
+
+| Parámetro | Por defecto | Descripción |
+|---|---|---|
+| `max_attempts` | `3` | Total de intentos incluyendo el primero (≥ 1). Posicional. |
+| `delay` | `0.0` | Pausa base en segundos antes del primer reintento. Solo palabra clave. |
+| `backoff` | `1.0` | Multiplicador aplicado a `delay` en cada intento. Solo palabra clave. |
+| `max_delay` | `None` | Tope de la pausa por intento. `None` significa sin tope. Solo palabra clave. |
+| `jitter` | `0.0` | Fracción de aleatorización `[0, 1]` aplicada a cada espera. Solo palabra clave. |
+| `exceptions` | `(Exception,)` | Tipos de excepción que disparan un reintento; los demás se propagan inmediatamente. Solo palabra clave. |
+
+!!! warning "La idempotencia es tu responsabilidad"
+ `@retry` llamará al cuerpo de la función varias veces. Si la operación no es idempotente — si llamarla dos veces tiene un efecto distinto de llamarla una vez — puedes aplicar cambios más de una vez. Los depósitos de monedero no son seguros de reintentar ingenuamente: reintentar un depósito fallido podría abonar la misma cantidad dos veces. Envuelve las operaciones no idempotentes en una comprobación de clave de idempotencia (almacena el ID de la operación antes de ejecutar; sáltala si el ID ya existe) o limita `exceptions` a errores que sean definitivamente previos a la ejecución (errores de conexión, tiempos de espera durante la fase de petición) en lugar de la ambigüedad posterior a la ejecución.
+
+### @circuit_breaker — fallo rápido bajo una caída sostenida
+
+!!! note "Nuevo término: cortacircuitos"
+ Tomado prestado del cableado eléctrico: un cortacircuitos *salta* (se abre)
+ cuando fluye demasiada corriente, cortando el circuito antes de que el
+ cableado se sobrecaliente. Un cortacircuitos de software salta tras
+ demasiados fallos, cortando las llamadas a una dependencia que falla para
+ que dejes de machacarla — y para que tus propios llamantes fallen rápido en
+ lugar de esperar en llamadas que de todos modos están condenadas a errar.
+
+Reintentar un servicio genuinamente no disponible amplifica la carga
+precisamente en el momento en que ese servicio más necesita alivio. El patrón
+cortacircuitos resuelve esto: tras un umbral de fallos consecutivos el circuito
+se **abre** y las llamadas posteriores se rechazan inmediatamente — sin intentar
+la llamada remota — hasta que transcurre un tiempo de espera de recuperación.
+
+El cortacircuitos de PyFly tiene tres estados:
+
+| Estado | Comportamiento |
+|---|---|
+| **CLOSED** | Funcionamiento normal. Cada llamada pasa; los fallos se cuentan. |
+| **OPEN** | Todas las llamadas lanzan `CircuitBreakerException` inmediatamente, sin E/S de red. |
+| **HALF_OPEN** | Tras `recovery_timeout` segundos, se admite una llamada de sondeo limitada. Si tiene éxito el circuito se cierra; si falla el circuito se vuelve a abrir. |
+
+`@circuit_breaker` toma una **instancia** `CircuitBreaker` — no argumentos de
+palabra clave. Construye el `CircuitBreaker` por separado y pásalo:
+
+::: listing lumen/resilience/cb_example.py | Listado 13.12 — Cortacircuitos alrededor de AccountService
+from pyfly.resilience import CircuitBreaker, circuit_breaker
+
+account_cb = CircuitBreaker(
+ failure_threshold=5,
+ recovery_timeout=30.0,
+ expected=(IOError, TimeoutError),
+)
+
+
+@circuit_breaker(account_cb)
+async def fetch_account(account_id: str) -> dict:
+ ...
+:::
+
+**Cómo funciona:** Antes de cada llamada, `breaker.before_call()` comprueba el
+estado actual. Si está OPEN, lanza `CircuitBreakerException` inmediatamente. Si
+está HALF_OPEN y el presupuesto de sondeo está agotado, también lanza. En caso
+contrario la llamada procede. En caso de éxito, `breaker.on_success()` reinicia
+el contador de fallos consecutivos (o, en HALF_OPEN, cierra el circuito una vez
+que suficientes sondeos tienen éxito). En caso de fallo, `breaker.on_failure()`
+incrementa el contador y abre el circuito cuando se alcanza `failure_threshold`.
+
+Solo las excepciones en `expected` disparan el cortacircuitos. Las excepciones
+de negocio — `ValueError`, `PermissionError` — se propagan con normalidad sin
+afectar al estado del circuito.
+
+**Parámetros del constructor de `CircuitBreaker`** (`failure_rate_threshold`,
+`window_size` y `half_open_max_calls` son de solo palabra clave):
+
+| Parámetro | Por defecto | Descripción |
+|---|---|---|
+| `failure_threshold` | `5` | Fallos consecutivos que hacen saltar el circuito. |
+| `recovery_timeout` | `30.0` | Segundos en OPEN antes de pasar a HALF_OPEN. |
+| `expected` | `(Exception,)` | Tipos de excepción que cuentan como fallos. |
+| `failure_rate_threshold` | `None` | Cambia al modo de tasa por ventana cuando se establece (p. ej. `0.5`). |
+| `window_size` | `10` | Tamaño de la ventana de resultados para el salto basado en tasa. |
+| `half_open_max_calls` | `1` | Llamadas de sondeo requeridas para cerrar desde HALF_OPEN. |
+
+Los parámetros `failure_rate_threshold` y `window_size` cambian del modo de
+recuento consecutivo al modo de tasa por ventana, igual que la ventana
+deslizante COUNT_BASED de Resilience4j. Establece `failure_rate_threshold=0.5` y
+`window_size=10` para abrir el circuito cuando más de la mitad de las últimas 10
+llamadas fallen.
+
+!!! spring "Equivalencia con Spring"
+ `@retry` refleja la `@Retryable` de Spring Retry (con `maxAttempts`, `backoff`, `include`). `CircuitBreaker` refleja el `CircuitBreaker` de Resilience4j (umbral de fallos, tiempo de recuperación, máquina de estados CLOSED/OPEN/HALF_OPEN, llamadas de sondeo en half-open, filtro de excepciones esperadas). PyFly no usa la biblioteca Java de Resilience4j — es una reimplementación en Python puro con la misma semántica.
+
+### Configurar la resiliencia desde `pyfly.yaml`
+
+Hasta ahora has construido cada `RateLimiter`, `Bulkhead` y `CircuitBreaker` en
+código. Eso es perfecto para una única pasarela, pero los equipos de operaciones
+suelen querer ajustar estos umbrales *sin un cambio de código* — subir un tiempo
+límite, ampliar un límite de tasa — y quieren un lugar evidente donde leer la
+configuración actual. PyFly v26.6.110 incluye un **`ResilienceRegistry`**
+dirigido por configuración para exactamente esto, dando paridad con el modelo de
+registro con nombre de Resilience4j.
+
+**Paso 1 — Declara instancias con nombre en `pyfly.yaml`.** Cada entrada bajo
+`pyfly.resilience.*` se convierte en una instancia con nombre. Los nombres son
+tuyos a elegir; agrúpalos por el servicio descendente que protegen:
+
+```yaml
+pyfly:
+ resilience:
+ circuit-breaker:
+ account-api:
+ failure-threshold: 5
+ recovery-timeout: 30s
+ # or switch to windowed-rate mode:
+ # failure-rate-threshold: 0.5
+ # window-size: 10
+ rate-limiter:
+ account-api:
+ max-tokens: 50
+ refill-rate: 20.0
+ bulkhead:
+ account-api:
+ max-concurrent: 8
+ time-limiter:
+ account-api:
+ timeout: 2s
+```
+
+Las duraciones aceptan sufijos amigables — `30s`, `500ms`, `1m`, `2h` — o un
+número desnudo leído como segundos. Las claves usan kebab-case
+(`failure-threshold`); el enlazado relajado de PyFly también acepta snake_case.
+
+**Paso 2 — Inyecta el registro y busca las instancias por nombre.** La
+`ResilienceAutoConfiguration` de PyFly registra un único bean
+`ResilienceRegistry` construido a partir de esas claves (siempre está activo, y
+devuelve un registro vacío cuando no hay claves presentes). Pídelo en cualquier
+constructor `@service`:
+
+::: listing lumen/account/gateway_configured.py | Listado 13.12a — Obtener instancias de resiliencia del registro
+from pyfly.container import service
+from pyfly.resilience import (
+ ResilienceRegistry,
+ bulkhead,
+ circuit_breaker,
+ rate_limiter,
+)
+
+
+@service
+class AccountGateway:
+
+ def __init__(self, http_client, registry: ResilienceRegistry) -> None:
+ self._http = http_client
+ # Look up the named instances declared in pyfly.yaml.
+ cb = registry.circuit_breaker("account-api")
+ rl = registry.rate_limiter("account-api")
+ bh = registry.bulkhead("account-api")
+
+ # Wrap the real call with the config-driven instances.
+ guarded = circuit_breaker(cb)(self._raw_get)
+ guarded = bulkhead(bh)(guarded)
+ self.get_account = rate_limiter(rl)(guarded)
+
+ async def _raw_get(self, account_id: str) -> dict:
+ resp = await self._http.get(f"/accounts/{account_id}")
+ return resp.json()
+:::
+
+**Lo que acaba de pasar.** Los umbrales ahora viven en la configuración, no en
+literales de Python. Un `CircuitBreaker` llamado `account-api` se materializa una
+vez al arrancar y se comparte por todo lo que lo busca — de modo que los
+recuentos de fallos y el estado OPEN/CLOSED son *globales* a través de todos los
+llamantes de ese nombre, exactamente como una instancia compartida en código.
+Buscar un nombre desconocido lanza `KeyError` con la lista de nombres
+disponibles, de modo que una errata falla ruidosamente al arrancar en lugar de
+crear silenciosamente una ruta sin protección.
+
+!!! tip "El limitador de tiempo devuelve un timedelta"
+ `registry.time_limiter("account-api")` devuelve el **`timedelta`** configurado, no un decorador — pásalo directamente a `time_limiter(timeout=registry.time_limiter("account-api"))`. Los otros tres accesores (`circuit_breaker`, `rate_limiter`, `bulkhead`) devuelven la instancia que pasas al decorador correspondiente.
+
+!!! spring "Equivalencia con Spring"
+ El `ResilienceRegistry` refleja el `CircuitBreakerRegistry`, el `RateLimiterRegistry` y el `BulkheadRegistry` de Resilience4j — instancias con nombre declaradas en la configuración y buscadas en tiempo de ejecución. El `resilience4j.circuitbreaker.instances..*` de Spring Boot se convierte en `pyfly.resilience.circuit-breaker..*`; los nombres de las propiedades se alinean uno a uno.
+
+---
+
+## Componer las capas
+
+### Orden de los decoradores
+
+Los decoradores de resiliencia de PyFly se componen apilándose. Python aplica
+los decoradores de abajo arriba en el momento de la decoración pero los ejecuta
+de arriba abajo en el momento de la llamada. El orden recomendado, del más
+externo al más interno:
+
+```
+@fallback ← 1. Catch any exception; return degraded response
+@rate_limiter ← 2. Reject excess traffic before it acquires resources
+@bulkhead ← 3. Limit concurrency of rate-limited calls
+@time_limiter ← 4. Cancel if execution takes too long
+async def func() ← 5. The actual operation
+```
+
+Este orden garantiza:
+
+1. **Fallback** captura las excepciones de cada capa interna — incluyendo
+ `RateLimitException`, `BulkheadException` y `OperationTimeoutException` — de
+ modo que el llamante siempre recibe una respuesta utilizable.
+2. **Limitador de tasa** descarta las peticiones excedentes antes de que
+ consuman una ranura del bulkhead, evitando que una avalancha de tráfico agote
+ el presupuesto de concurrencia.
+3. **Bulkhead** limita cuántas llamadas permitidas por la tasa se ejecutan
+ concurrentemente, protegiendo al servicio descendente de la sobrecarga.
+4. **Limitador de tiempo** se aplica solo a la ejecución real; cuando se
+ dispara, el bloque `finally` del bulkhead libera la ranura correctamente.
+
+Añade `@retry` y `@circuit_breaker` en el lado más interno — envolviendo solo la
+llamada de E/S real — de modo que el fallback absorba sus excepciones y el
+limitador de tasa y el bulkhead contabilicen correctamente las llamadas
+reintentadas:
+
+```
+@fallback
+@rate_limiter
+@bulkhead
+@time_limiter
+@circuit_breaker(account_cb)
+@retry(max_attempts=2, delay=0.05, backoff=2.0, exceptions=(IOError,))
+async def fetch_account(account_id: str) -> dict: ...
+```
+
+Con `@retry` por debajo de `@time_limiter`, el presupuesto de tiempo límite
+cubre toda la secuencia de reintentos, no cada intento individual. Para acotar
+cada intento de forma independiente, mueve `@time_limiter` por debajo de
+`@retry`.
+
+### Juntándolo todo — la pasarela de cuentas de Lumen
+
+Aquí está el patrón completo ensamblado en una `AccountGateway` realista que los
+manejadores de monederos de Lumen usan para buscar información de cuentas:
+
+::: listing lumen/account/gateway.py | Listado 13.13 — AccountGateway con la pila de resiliencia completa
+from datetime import timedelta
+
+from pyfly.cache import CacheAdapter, cacheable
+from pyfly.container import service
+from pyfly.kernel.exceptions import CircuitBreakerException, OperationTimeoutException
+from pyfly.resilience import (
+ Bulkhead,
+ CircuitBreaker,
+ RateLimiter,
+ bulkhead,
+ circuit_breaker,
+ fallback,
+ rate_limiter,
+ retry,
+ time_limiter,
+)
+
+_limiter = RateLimiter(max_tokens=50, refill_rate=20.0)
+_bh = Bulkhead(max_concurrent=8)
+_cb = CircuitBreaker(
+ failure_threshold=5,
+ recovery_timeout=30.0,
+ expected=(IOError, TimeoutError),
+)
+
+DEGRADED = {"status": "degraded", "balance_minor": None}
+
+
+@service
+class AccountGateway:
+
+ def __init__(self, http_client, cache: CacheAdapter) -> None:
+ self._http = http_client
+ self._cache = cache
+
+ @cacheable(
+ backend=None, # pass self._cache at runtime (see note below)
+ key="account:{account_id}",
+ ttl=timedelta(seconds=30),
+ )
+ @fallback(
+ fallback_value=DEGRADED,
+ on=(OperationTimeoutException, CircuitBreakerException, IOError),
+ )
+ @rate_limiter(_limiter)
+ @bulkhead(_bh)
+ @time_limiter(timeout=timedelta(seconds=2))
+ @circuit_breaker(_cb)
+ @retry(max_attempts=2, delay=0.05, backoff=2.0, exceptions=(IOError,))
+ async def get_account(self, account_id: str) -> dict:
+ resp = await self._http.get(f"/accounts/{account_id}")
+ return resp.json()
+:::
+
+!!! note "Cablear `backend` en un método de clase"
+ Como Python evalúa los decoradores del cuerpo de la clase antes de que se ejecute `__init__`, `self._cache` aún no está disponible ahí. El listado anterior pasa `backend=None` como marcador de posición para ilustrar el orden de apilamiento. En la práctica, envuelve `get_account` en `__init__` de la misma forma que en los ejemplos de manejador: `self.get_account = cacheable(backend=cache, key=..., ttl=...)(self._do_get_account)`. Como alternativa, usa una instancia `InMemoryCache` a nivel de módulo para las pruebas y cámbiala vía el contenedor de inyección de dependencias en producción.
+
+**Cómo fluye una llamada a través de las capas:**
+
+1. `@cacheable` comprueba la caché. En un acierto, cada capa interna se omite por
+ completo.
+2. En un fallo, `@fallback` se convierte en la red de seguridad más externa.
+3. `@rate_limiter` comprueba el cubo de tokens; rechaza la llamada si está vacío.
+4. `@bulkhead` comprueba el contador de permisos; rechaza si está a su capacidad.
+5. `@time_limiter` establece una fecha límite de dos segundos para las capas de
+ abajo.
+6. `@circuit_breaker` rechaza inmediatamente si el circuito está OPEN.
+7. `@retry` intenta la llamada HTTP hasta dos veces ante un `IOError`.
+8. En caso de éxito, `@cacheable` almacena la respuesta durante 30 segundos.
+9. Si un `IOError`, `OperationTimeoutException` o `CircuitBreakerException`
+ escapa, `@fallback` lo captura y devuelve `DEGRADED`.
+
+Fíjate en que `@cacheable` se sitúa *por encima* de `@fallback`. Eso significa:
+
+- Una respuesta `DEGRADED` en caché de un ciclo de fallo anterior se devuelve
+ tal cual durante hasta 30 segundos sin llegar a la red.
+- Si no quieres cachear respuestas degradadas, mueve `@cacheable` por debajo de
+ `@fallback`, o usa el predicado `unless`:
+ `unless=lambda r: r.get("status") == "degraded"`.
+
+#### Ejecútalo — haz que el servicio descendente falle y observa cómo la pila se degrada
+
+No necesitas un `AccountService` en vivo para verificar la pila. Conecta las
+capas de resiliencia alrededor de un cliente HTTP de prueba que siempre lance, y
+verifica que el llamante aún obtiene una respuesta `DEGRADED` utilizable en lugar
+de una excepción:
+
+::: listing tests/resilience/test_gateway_stack.py | Listado 13.13a — La pila se degrada en lugar de lanzar
+import pytest
+
+from pyfly.resilience import fallback, retry
+
+DEGRADED = {"status": "degraded", "balance_minor": None}
+
+
+class _BrokenClient:
+ async def get(self, path: str) -> dict:
+ raise IOError("AccountService unreachable")
+
+
+@fallback(fallback_value=DEGRADED, on=(IOError,))
+@retry(max_attempts=2, delay=0.0, exceptions=(IOError,))
+async def get_account(client: _BrokenClient, account_id: str) -> dict:
+ resp = await client.get(f"/accounts/{account_id}")
+ return resp
+
+
+@pytest.mark.asyncio
+async def test_degrades_instead_of_raising() -> None:
+ result = await get_account(_BrokenClient(), "acc-1")
+ assert result == DEGRADED
+:::
+
+Ejecútalo:
+
+```console
+$ uv run --extra dev pytest tests/resilience/test_gateway_stack.py -q
+. [100%]
+1 passed in 0.05s
+```
+
+`@retry` intentó la llamada dos veces, ambos intentos lanzaron `IOError`, y
+`@fallback` capturó la excepción final y devolvió `DEGRADED`. El llamante nunca
+vio el error — exactamente el comportamiento que quieres cuando `AccountService`
+está teniendo un mal día.
+
+**Lo que acaba de pasar.** Ensamblaste una porción de la pila completa —
+reintento por dentro, fallback por fuera — y demostraste que un fallo duro
+aflora como una respuesta degradada pero válida. Añade `@rate_limiter`,
+`@bulkhead`, `@time_limiter` y `@circuit_breaker` entre ellos en el orden
+mostrado arriba y cada uno se pliega en el mismo flujo: cada excepción que lanzan
+es capturada por el `@fallback` exterior, de modo que el llamante siempre recibe
+una respuesta que puede usar.
+
+---
+
+## Lo que construiste {.recap}
+
+Este capítulo cierra la Parte IV. En el Capítulo 11 dividiste Lumen en servicios
+independientes con clientes HTTP tipados. En el Capítulo 12 añadiste
+`DepositSaga` para coordinar operaciones de varios pasos con transacciones de
+compensación. Aquí hiciste que todo el sistema sea rápido y tolerante a fallos.
+
+Concretamente, aprendiste:
+
+- **`@cacheable`** cortocircuita las lecturas de saldo en un acierto de caché; el
+ TTL de cinco segundos acota la obsolescencia a una ventana aceptable. Aplicado
+ a `GetBalanceHandler` envolviendo `_fetch` en el momento de la construcción —
+ `_fetch` llama a `repository.find_by_id` y proyecta el `WalletEntity`
+ resultante sobre `BalanceDto` vía `entity_to_balance_dto` (`Mapper.project` +
+ la `BalanceView` marcada con `@projection`).
+- **`@cache_put`** refresca la caché como efecto secundario de cada comando
+ `DepositFunds`. `_deposit` está decorado con `@transactional()`; hace
+ `find_by_id → to_aggregate → mutate → upsert` como una unidad de trabajo
+ confirmada, y después actualiza la caché con el saldo devuelto. La plantilla de
+ clave debe coincidir con la clave `@cacheable` para acertar en la misma ranura.
+- **`@cache_evict`** elimina entradas al cerrar un monedero o en reinicios
+ administrativos; `all_entries=True` vacía toda la caché en una sola llamada.
+- **`CacheManager`** refleja las escrituras tanto en Redis (primario) como en
+ `InMemoryCache` (fallback) y conmuta por error de forma transparente; es el
+ valor por defecto correcto para cualquier despliegue de producción.
+- **`RateLimiter`** + `@rate_limiter` limitan el tráfico entrante con un
+ algoritmo de cubo de tokens que permite ráfagas controladas.
+- **`Bulkhead`** + `@bulkhead` aíslan la concurrencia con un semáforo de fallo
+ rápido que impide que una dependencia lenta consuma todos los recursos
+ disponibles.
+- **`@time_limiter`** impone fechas límite usando `asyncio.wait_for`,
+ convirtiendo las llamadas colgadas indefinidamente en errores acotados
+ `OperationTimeoutException`.
+- **`@fallback`** proporciona una respuesta degradada pero funcional cuando todas
+ las demás capas han fallado; el método de fallback recibe los argumentos
+ originales y la excepción capturada vía el argumento de palabra clave `exc`.
+- **`@retry`** toma `max_attempts` como su único argumento posicional; todos los
+ demás parámetros (`delay`, `backoff`, `max_delay`, `jitter`, `exceptions`) son
+ de solo palabra clave. Reinvoca operaciones un número acotado de veces con
+ backoff exponencial.
+- **`@circuit_breaker`** toma una **instancia** `CircuitBreaker` — no argumentos
+ de palabra clave — y abre el circuito tras un umbral de fallos,
+ cortocircuitando las llamadas posteriores durante la ventana de recuperación
+ para que el servicio descendente tenga tiempo de recuperarse.
+- **`ResilienceRegistry`** (PyFly v26.6.110) materializa instancias con nombre de
+ `CircuitBreaker`, `RateLimiter`, `Bulkhead` y limitador de tiempo a partir de
+ las claves de configuración `pyfly.resilience.*`, de modo que operaciones puede
+ ajustar los umbrales en `pyfly.yaml` e inyectar el registro para buscar las
+ instancias por nombre — paridad con el `resilience4j.*.instances..*` de
+ Spring Boot.
+- El **orden de los decoradores** importa: fallback el más externo, después
+ limitador de tasa, bulkhead, limitador de tiempo, cortacircuitos y reintento el
+ más interno — con la caché por encima del fallback para cachear incluso las
+ respuestas degradadas.
+
+Lumen es ahora un sistema multiservicio, coordinado por sagas, en caché y
+resiliente. La Parte V añade las preocupaciones finales de producción:
+observabilidad — métricas, trazas distribuidas y endpoints de salud — para que
+puedas ver exactamente qué está haciendo Lumen en producción.
+
+---
+
+## Pruébalo tú mismo {.exercises}
+
+**Ejercicio 1 — Caché condicional.** El manejador `GetBalance` se llama mucho más a menudo para los monederos activos que para los monederos de prueba. Añade `condition=lambda query: not query.wallet_id.startswith("test-")` a la llamada `cacheable(...)` dentro de `GetBalanceHandler.__init__` y verifica con una prueba unitaria que use `InMemoryCache` que las consultas para ids de monedero de prueba siempre llegan al repositorio.
+
+**Ejercicio 2 — Cortacircuitos con umbral basado en tasa.** Reemplaza el cortacircuitos de recuento consecutivo en `AccountGateway` por uno basado en tasa: abre el circuito cuando al menos el 60 % de las últimas 20 llamadas fallen. Construye `CircuitBreaker(failure_rate_threshold=0.6, window_size=20, recovery_timeout=60.0, expected=(IOError, TimeoutError))`. Dos sutilezas guían el diseño de la prueba. Primera, en modo de tasa el cortacircuitos permanece sin saltar hasta que la ventana está *llena* — requiere una ventana completa de 20 llamadas antes de juzgar la tasa — de modo que una ráfaga de fallos por sí sola nunca lo abre. Segunda, el cortacircuitos solo reevalúa su condición de salto en un *fallo* (un éxito nunca lo abre), de modo que la llamada que cruza el umbral debe ser ella misma una llamada fallida. Escribe una prueba que dispare 8 llamadas con éxito seguidas de 12 fallidas (20 llamadas en total = una ventana completa, terminando en un fallo). Verifica que el circuito permanece `CLOSED` hasta la llamada 19 (la ventana sigue siendo parcial), y luego `OPENS` en la 20.ª llamada, cuando la ventana se llena y la tasa de fallos alcanza exactamente 12 / 20 = 0,60.
+
+**Ejercicio 3 — Expulsar por prefijo.** Lumen a veces necesita invalidar todas las entradas de caché de un propietario de monedero dado (borrado RGPD). Añade un método `purge_owner(owner_id: str)` a un servicio de administración de monederos que llame a `backend.evict_by_prefix(f"wallet:balance:{owner_id}:")` directamente (sin un decorador), y escribe una prueba que prepoble tres claves de monedero para un propietario y una para otro, llame a `purge_owner` y verifique que solo las entradas del propietario objetivo han desaparecido.
+
+**Ejercicio 4 — Resiliencia dirigida por configuración.** Mueve los umbrales codificados a mano de `AccountGateway` a `pyfly.yaml` bajo `pyfly.resilience.circuit-breaker.account-api`, `pyfly.resilience.rate-limiter.account-api` y `pyfly.resilience.bulkhead.account-api`. Inyecta `ResilienceRegistry` en la pasarela, busca las tres instancias por nombre y escribe una prueba que verifique que el `CircuitBreaker.failure_threshold` materializado coincide con el valor que estableciste en la configuración. Ten en cuenta que `ResilienceRegistry.from_config(...)` espera un `Config` de pyfly, no un dict plano — llama a `config.get_section("pyfly.resilience.circuit-breaker")` internamente. Construye uno en la prueba a partir de un dict anidado, p. ej. `Config({"pyfly": {"resilience": {"circuit-breaker": {"account-api": {"failure-threshold": 5}}}}})` (importa `from pyfly.core.config import Config`), y después pasa ese `Config` a `from_config`. Confirma que buscar un nombre mal escrito lanza `KeyError` con la lista de nombres disponibles.
diff --git a/book/manuscript-es/14-security.md b/book/manuscript-es/14-security.md
new file mode 100644
index 00000000..eba986a6
--- /dev/null
+++ b/book/manuscript-es/14-security.md
@@ -0,0 +1,1428 @@
+Capítulo 14
+
+# Seguridad, sesiones e identidad {.chtitle}
+
+::: figure art/openers/ch14.svg |
+
+En el Capítulo 13 hiciste que Lumen fuera rápido y tolerante a fallos con caché y decoradores de resiliencia. La API de Lumen maneja ahora una alta concurrencia sin romperse bajo presión, pero está abierta de par en par. Cualquier llamante puede crear monederos, leer saldos o disparar depósitos. Antes de poder enviar las preocupaciones de producción restantes de la Parte V, necesitas cerrar esa puerta.
+
+Este capítulo blinda Lumen. Vas a:
+
+- **Autenticar** cada petición con un JWT firmado, usando `JWTService` para emitir y validar tokens y `SecurityMiddleware` para propagar el `SecurityContext` por todo el ámbito de la petición.
+- **Autorizar** manejadores y comandos individuales con el decorador `@secure`, especificando roles, permisos o expresiones de seguridad completas.
+- **Hashear contraseñas** de forma segura con `BcryptPasswordEncoder` para que tu almacén de usuarios nunca sea un pasivo.
+- **Gestionar sesiones del lado del servidor** con `HttpSession`, un `SessionStore` conectable y un backend de Redis para escalado horizontal.
+- **Federar la identidad** a un proveedor externo —Keycloak, AWS Cognito o Azure AD— a través del puerto `IdpAdapter` sin cambiar una sola línea de lógica de negocio.
+
+Si has trabajado antes con Spring Security, la forma te resultará familiar: configura una cadena de filtros, anota métodos individuales y sustituye la fuente de detalles de usuario por un IDP. PyFly llama a esas piezas `SecurityMiddleware + HttpSecurity`, `@secure` e `IdpAdapter`, pero los conceptos se corresponden uno a uno.
+
+Este capítulo está escrito como un tutorial guiado. Construimos la capa de seguridad pieza a pieza —emitir un token, validarlo, proteger un manejador, hashear una contraseña, almacenar una sesión, federar a un IDP— y después de cada pieza encontrarás un punto de control **Pruébalo** con el comando exacto que teclear y la salida que deberías esperar. Si nunca antes has conectado la autenticación a un servicio web, no pasa nada: cada término nuevo se glosa en lenguaje llano la primera vez que aparece, y puedes seguir el hilo editando el ejemplo de Lumen a medida que lees.
+
+!!! note "Versión"
+ Los listados y las claves de configuración de este capítulo apuntan a PyFly **v26.6.110**.
+ Si estás en una versión anterior, algunos nombres de propiedades difieren —sobre todo
+ el puerto de la aplicación, que ahora es `pyfly.server.port` (el `server.port` de Spring),
+ y el actuator y el panel de administración viven en un puerto de gestión separado,
+ `pyfly.management.server.port` (por defecto `9090`).
+
+::: figure art/figures/14-security.svg | Figura 14.1 — Las capas de seguridad de Lumen. Un filtro JWT rellena el SecurityContext; HttpSecurity impone reglas a nivel de URL; @secure impone reglas a nivel de manejador; el puerto IDP delega la identidad a un proveedor externo.
+
+---
+
+## Autenticación con JWT
+
+### ¿Por qué JSON Web Tokens?
+
+Lumen es una API sin estado. Las sesiones HTTP requerirían enrutamiento adhesivo (sticky) o un almacén de sesiones compartido en cada réplica. Los tokens JWT permiten que cada servicio valide las credenciales de forma independiente: sin estado compartido, sin coordinación, escalado horizontal por defecto.
+
+Un **JWT** (JSON Web Token, que se pronuncia "yot") es una carga JSON firmada. Piénsalo como una credencial a prueba de manipulaciones: el servidor estampa un pequeño documento JSON —*quién eres*, *qué roles tienes*, *cuándo caduca*— y lo firma con una clave secreta. La credencial viaja con cada petición. Como la firma solo puede producirla alguien que posea el secreto, el servidor puede confiar en la credencial sin consultar nada en una base de datos. El servicio de autenticación de Lumen emite un token al iniciar sesión; cada petición posterior lleva ese token en la cabecera `Authorization`; `SecurityMiddleware` valida la firma y desempaqueta el token en un `SecurityContext` que el resto de la petición puede leer.
+
+### JWTService
+
+`JWTService` envuelve PyJWT con tres operaciones bien definidas:
+
+| Método | Descripción |
+|---|---|
+| `encode(payload)` | Firma un diccionario de carga, añadiendo `exp` si falta |
+| `decode(token)` | Valida la firma + `exp`; lanza `SecurityException` si falla |
+| `to_security_context(token)` | Decodifica y extrae `sub`, `roles`, `permissions` en un `SecurityContext` |
+
+El servicio siempre exige un claim `exp`: un token sin caducidad se rechaza
+en el momento de decodificar. Ese invariante significa que todo token en circulación tiene un
+tiempo de vida acotado.
+
+::: listing lumen/core/services/auth/auth_service.py | Listado 14.1 — Emitir un JWT en un inicio de sesión correcto
+from pyfly.container import service
+from pyfly.kernel.exceptions import UnauthorizedException
+from pyfly.security import BcryptPasswordEncoder, JWTService, SecurityContext
+
+
+@service
+class AuthService:
+
+ def __init__(
+ self,
+ jwt: JWTService,
+ encoder: BcryptPasswordEncoder,
+ user_repo,
+ ) -> None:
+ self._jwt = jwt
+ self._encoder = encoder
+ self._users = user_repo
+
+ async def login(self, username: str, password: str) -> str:
+ user = await self._users.find_by_username(username)
+ if user is None or not self._encoder.verify(
+ password, user.password_hash
+ ):
+ raise UnauthorizedException(
+ "Invalid credentials", code="INVALID_CREDENTIALS"
+ )
+ # encode() auto-appends exp (default: 3 600 s from now)
+ return self._jwt.encode({
+ "sub": str(user.id),
+ "roles": [user.role],
+ "permissions": _permissions_for(user.role),
+ })
+
+ async def me(self, ctx: SecurityContext) -> dict:
+ user = await self._users.find_by_id(ctx.user_id)
+ return {
+ "id": str(user.id),
+ "username": user.username,
+ "role": user.role,
+ }
+
+
+def _permissions_for(role: str) -> list[str]:
+ MAP = {
+ "USER": ["wallet:read", "wallet:deposit"],
+ "ADMIN": [
+ "wallet:read", "wallet:deposit",
+ "wallet:create", "wallet:delete",
+ "user:read", "user:write",
+ ],
+ }
+ return MAP.get(role, [])
+
+:::
+:::
+
+**Cómo funciona.** `login` obtiene el registro del usuario y llama a `BcryptPasswordEncoder.verify` para comparar la contraseña suministrada con el hash almacenado. Si tiene éxito, llama a `jwt.encode`, que añade automáticamente un claim `exp` a `expiration_seconds` segundos a partir de ahora (por defecto `3600`, una hora). Nunca importas `datetime`: el servicio calcula la caducidad como marca de tiempo Unix con `int(time.time()) + expiration_seconds`. El llamante recibe una cadena de token compacta y autocontenida.
+
+Recorramos el método `login` paso a paso:
+
+**Paso 1 — Buscar al usuario.** `find_by_username` devuelve el registro de usuario almacenado, o `None` si no existe ese nombre de usuario. Trataremos tanto "no existe ese usuario" como "contraseña incorrecta" como el mismo fallo, para que un atacante no pueda saber qué nombres de usuario están registrados.
+
+**Paso 2 — Verificar la contraseña.** `self._encoder.verify(password, user.password_hash)` vuelve a hashear la contraseña suministrada con la sal incorporada en el hash almacenado y compara ambos en tiempo constante. Si no se encontró al usuario, o las contraseñas no coinciden, lanza `UnauthorizedException`: PyFly lo renderiza como un `401` con el código legible por máquina `INVALID_CREDENTIALS`.
+
+**Paso 3 — Construir los claims.** Si tiene éxito, ensambla la carga JSON que se convertirá en el cuerpo del token: `sub` (el sujeto, el id del usuario), `roles` y los `permissions` que ese rol concede.
+
+**Paso 4 — Firmar y devolver.** `self._jwt.encode({...})` firma la carga con el secreto configurado, estampa el claim obligatorio `exp` y devuelve la cadena de token compacta. Esa cadena es lo que el cliente almacena y reenvía en cada petición posterior.
+
+!!! note "Jerga: claim"
+ Un *claim* no es más que una afirmación clave/valor dentro del cuerpo JSON del token:
+ `"sub": "42"` afirma que el sujeto es el usuario 42. El token lleva un saco de
+ claims; el framework copia los relevantes para la seguridad (`sub`, `roles`,
+ `permissions`) en el `SecurityContext`.
+
+!!! tip "Pruébalo — emite un token en el REPL"
+ No necesitas un servidor en marcha para ver `JWTService` en acción. Con el
+ entorno virtual de Lumen activo, abre un REPL de Python y firma una carga:
+
+ ```python
+ >>> from pyfly.security import JWTService
+ >>> jwt = JWTService(secret="dev-secret-change-me", algorithm="HS256")
+ >>> token = jwt.encode({"sub": "42", "roles": ["USER"]})
+ >>> token[:20] # a compact "header.payload.signature" string
+ 'eyJhbGciOiJIUzI1NiIs'
+ >>> ctx = jwt.to_security_context(token)
+ >>> ctx.user_id, ctx.roles
+ ('42', ['USER'])
+ ```
+
+ El token hace ida y vuelta: `encode` añadió el claim `exp` por ti, y
+ `to_security_context` validó la firma y desempaquetó los claims en un
+ `SecurityContext`. Prueba a manipular un carácter en mitad de la
+ cadena y llama de nuevo a `jwt.decode(token)`: obtendrás una
+ `SecurityException`, porque la firma ya no coincide con la carga.
+
+### El SecurityContext
+
+**`SecurityContext`** es una dataclass inmutable que transporta los datos de autenticación y autorización de una única petición. El middleware lo crea a partir del token validado; tus manejadores lo reciben como un parámetro inyectado.
+
+| Campo | Tipo | Descripción |
+|---|---|---|
+| `user_id` | `str \| None` | Id del usuario autenticado; `None` si es anónimo |
+| `roles` | `list[str]` | Roles concedidos en el token |
+| `permissions` | `list[str]` | Permisos de grano fino |
+| `attributes` | `dict[str, str]` | Claims adicionales (departamento, tenant, …) |
+
+Métodos clave:
+
+| Método / Propiedad | Devuelve | Descripción |
+|---|---|---|
+| `is_authenticated` | `bool` | `True` cuando `user_id` no es `None` |
+| `has_role(role)` | `bool` | Coincidencia exacta con la lista `roles` |
+| `has_any_role(roles)` | `bool` | Intersección de conjuntos: cualquiera de los roles listados |
+| `has_permission(perm)` | `bool` | Coincidencia exacta con la lista `permissions` |
+| `SecurityContext.anonymous()` | `SecurityContext` | Crea un contexto no autenticado |
+
+### El filtro de seguridad
+
+**`SecurityMiddleware`** (ubicación canónica `pyfly.web.adapters.starlette.security_middleware`, reexportado desde `pyfly.security`) se sitúa en la capa de middleware de Starlette. Para cada petición:
+
+1. Comprueba si la ruta está en `exclude_paths`; si es así, establece un contexto anónimo y continúa.
+2. Lee la cabecera `Authorization` y elimina el prefijo `Bearer `.
+3. Llama a `jwt_service.to_security_context(token)`.
+4. Si tiene éxito, almacena el contexto autenticado en `request.state.security_context`.
+5. Ante cualquier `SecurityException` (token caducado, manipulado o sin `exp`), registra a nivel DEBUG y establece en su lugar un contexto anónimo.
+
+El middleware **nunca rechaza peticiones**: el rechazo es trabajo de `@secure` y `HttpSecurity`. Un endpoint que requiere al usuario puede imponerlo; un endpoint de comprobación de salud puede ignorar el contexto por completo.
+
+::: listing lumen/app.py | Listado 14.2 — Añadir el middleware de seguridad
+from pyfly.security import JWTService, SecurityMiddleware
+from pyfly.web.adapters.starlette import create_app
+
+
+def build_app(context):
+ app = create_app(title="Lumen", context=context)
+
+ jwt = context.get_bean(JWTService)
+ app.add_middleware(
+ SecurityMiddleware,
+ jwt_service=jwt,
+ exclude_paths=[
+ "/docs",
+ "/openapi.json",
+ "/api/auth/login",
+ "/api/auth/register",
+ ],
+ )
+ return app
+
+:::
+:::
+
+**Cómo funciona.** `exclude_paths` enumera las rutas donde no se espera ningún token. El inicio de sesión y el registro no pueden requerir autenticación porque el token aún no existe; las rutas de la documentación se excluyen para que el explorador de la API funcione sin credenciales. Cualquier otra ruta pasa por la validación del token.
+
+!!! note "Jerga: middleware"
+ El *middleware* es código que envuelve cada petición, ejecutándose antes del manejador
+ de la ruta a la entrada y después de él a la salida. `SecurityMiddleware`
+ usa la pasada de "entrada" para leer el token y guardar un `SecurityContext` en
+ `request.state` para que los manejadores posteriores puedan leerlo sin volver a parsear la
+ cabecera.
+
+**Lo que acaba de ocurrir.** Ya tienes conectadas las dos mitades de la autenticación. `AuthService.login` *acuña* un token tras comprobar la contraseña; `SecurityMiddleware` *lee* ese token en cada petición posterior y lo convierte en un `SecurityContext`. Es crucial que el middleware sea permisivo: nunca devuelve un `401`. Una petición con un token incorrecto o ausente simplemente llega como anónima, y la decisión de permitirla o rechazarla se delega a las dos capas siguientes que construirás: `HttpSecurity` (amplia, a nivel de URL) y `@secure` (precisa, por manejador). Separar *quién eres* (autenticación) de *qué puedes hacer* (autorización) es lo que mantiene cada capa pequeña y testeable.
+
+### Reglas a nivel de URL con HttpSecurity
+
+`@secure` protege métodos manejadores individuales. **`HttpSecurity`** protege subárboles de URL completos en la capa de filtro, antes de que se ejecute el despachador de rutas. Las dos son complementarias: `HttpSecurity` aporta una política rápida y amplia en el borde; `@secure` añade una imposición de grano fino, por manejador, detrás de ella.
+
+::: listing lumen/config/security_config.py | Listado 14.3 — El DSL de HttpSecurity
+from pyfly.container import bean, configuration
+from pyfly.security.http_security import HttpSecurity
+
+
+@configuration
+class SecurityConfig:
+
+ @bean
+ def http_security(self) -> HttpSecurity:
+ hs = HttpSecurity()
+ hs.authorize_requests() \
+ .request_matchers("/idp/admin/**").has_role("ADMIN") \
+ .request_matchers("/api/v1/wallets/**").authenticated() \
+ .request_matchers(
+ "/health", "/docs", "/openapi.json",
+ "/idp/login", "/idp/refresh",
+ ).permit_all() \
+ .any_request().permit_all()
+ return hs
+
+:::
+:::
+
+**Cómo funciona.** Las reglas se evalúan en el orden de declaración: gana la primera coincidencia. El `HttpSecurityFilter` se ejecuta en `HIGHEST_PRECEDENCE + 350`, después de que los filtros de autenticación hayan rellenado `request.state.security_context`, de modo que toda comprobación de rol y permiso dispone de un contexto totalmente hidratado para inspeccionar. Los métodos terminales —`has_role`, `has_any_role`, `has_permission`, `authenticated`, `permit_all` y `deny_all`— cubren toda política habitual; las reglas no satisfechas devuelven JSON de detalle de problema RFC 7807 (`application/problem+json`) con el estado HTTP apropiado.
+
+Lee la cadena del DSL de arriba abajo: ese es exactamente el orden en que el filtro la evalúa:
+
+**Paso 1 — Abre la lista de reglas.** `hs.authorize_requests()` inicia el constructor fluido. Cada llamada `.request_matchers(...)` que sigue registra una regla.
+
+**Paso 2 — Blinda el subárbol de administración.** `.request_matchers("/idp/admin/**").has_role("ADMIN")` exige el rol `ADMIN` para cualquier cosa bajo `/idp/admin`. El glob `**` coincide con cualquier profundidad de segmentos de ruta.
+
+**Paso 3 — Exige inicio de sesión para los monederos.** `.request_matchers("/api/v1/wallets/**").authenticated()` acepta a cualquier llamante con sesión iniciada, sin importar el rol, para el árbol de monederos. Las reglas más finas por manejador llegan después vía `@secure`.
+
+**Paso 4 — Permite las rutas públicas.** `.request_matchers("/health", "/docs", ...).permit_all()` deja pasar sin token las comprobaciones de salud, el explorador de la documentación y los endpoints de inicio de sesión/refresco del IDP.
+
+**Paso 5 — Establece el valor por defecto.** `.any_request().permit_all()` es el comodín para las rutas que ninguna regla anterior haya cubierto. Cámbialo a `.deny_all()` una vez que toda ruta esté explícitamente contemplada: "denegar por defecto" es la postura de producción más segura.
+
+**Paso 6 — Devuelve el constructor.** Devuelve el propio `HttpSecurity` configurado; **no** llames aquí a `hs.build()`. Como `SecurityConfig` es una clase `@configuration`, el `HttpSecurityFilterAutoConfiguration` de la autoconfiguración (activo siempre que exista un bean `HttpSecurity`) recoge el bean `HttpSecurity`, llama a `.build()` por ti y registra en la cadena el `HttpSecurityFilter` resultante.
+
+!!! tip "Pruébalo — observa cómo la puerta acepta y rechaza"
+ Arranca la aplicación con `uv run pyfly run` (sirve en `pyfly.server.port`,
+ por defecto `8080`). En otra terminal, accede a una ruta protegida sin token:
+
+ ```bash
+ curl -i http://localhost:8080/api/v1/wallets
+ ```
+
+ Esperado — la puerta rechaza la petición anónima con un cuerpo RFC 7807:
+
+ ```text
+ HTTP/1.1 401 Unauthorized
+ content-type: application/problem+json
+
+ {"type":"about:blank","title":"Unauthorized","status":401,
+ "detail":"Authentication is required to access this resource.",
+ "instance":"/api/v1/wallets"}
+ ```
+
+ Ahora reenvíala con un token (usa el `$TOKEN` que acuñaste en el REPL de arriba,
+ o uno de `/idp/login`):
+
+ ```bash
+ curl -i -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/wallets
+ ```
+
+ Esperado — `HTTP/1.1 200 OK` y una página JSON de monederos. La misma URL,
+ dos resultados, decididos enteramente por el token: eso es la puerta haciendo su trabajo.
+
+!!! note "Defensa en dos capas"
+ `HttpSecurity` aporta una política rápida a nivel de URL antes incluso de que las rutas se
+ despachen, ideal para reglas generales como "todo lo que esté bajo `/api/v1/wallets`
+ necesita autenticación". Los decoradores `@secure` sobre métodos individuales son
+ la segunda capa, de grano más fino. Usa ambos juntos para una defensa en profundidad.
+
+!!! spring "Equivalencia con Spring"
+ `HttpSecurity` refleja la cadena `HttpSecurity.authorizeHttpRequests()` de Spring Security. `request_matchers` se corresponde con `requestMatchers`, `authenticated()` con `.authenticated()`, `has_role` con `hasRole`, y el `build()` que la autoconfiguración llama sobre tu bean `HttpSecurity` finaliza el filtro subyacente igual que `build()` finaliza la cadena de filtros de Spring. Los patrones glob de fnmatch (`/api/admin/**`) se comportan de forma idéntica a la coincidencia de rutas estilo Ant de Spring.
+
+---
+
+## Autoconfiguración
+
+No necesitas registrar `JWTService` ni `BcryptPasswordEncoder` manualmente. Añade las propiedades pertinentes a `pyfly.yaml` y la autoconfiguración lo conecta todo:
+
+::: listing lumen/resources/pyfly.yaml | Listado 14.4 — Autoconfiguración de seguridad
+pyfly:
+ security:
+ enabled: true
+ jwt:
+ secret: "${JWT_SECRET}"
+ algorithm: HS256
+ filter:
+ enabled: true
+ exclude-patterns: >-
+ /docs,/openapi.json,
+ /api/auth/login,/api/auth/register
+ password:
+ bcrypt-rounds: 12
+
+:::
+:::
+
+| Propiedad | Por defecto | Descripción |
+|---|---|---|
+| `pyfly.security.jwt.secret` | `change-me-in-production` | Clave de firma HMAC; **debe** sobreescribirse |
+| `pyfly.security.jwt.algorithm` | `HS256` | Algoritmo de firma |
+| `pyfly.security.jwt.filter.enabled` | *(ausente)* | Pon `true` para registrar el bean `SecurityFilter` automáticamente |
+| `pyfly.security.jwt.exclude-patterns` | *(ausente)* | Rutas separadas por comas que se saltan |
+| `pyfly.security.password.bcrypt-rounds` | `12` | Factor de coste de Bcrypt |
+
+Fíjate dónde vive cada clave: `filter.enabled` está anidada bajo `jwt.filter`, pero `exclude-patterns` se sitúa un nivel más arriba, directamente bajo `jwt`: esa es la clave que lee la autoconfiguración (`pyfly.security.jwt.exclude-patterns`). Ya no hay un `/actuator/health` en la lista de exclusión: a partir de la v26.6.110 el actuator y el panel de administración se ejecutan en un **puerto de gestión separado** (`pyfly.management.server.port`, por defecto `9090`), de modo que nunca pasan por el filtro JWT de la aplicación.
+
+!!! warning "Secreto de producción"
+ Nunca confirmes el secreto JWT real al control de versiones. Usa `${JWT_SECRET}` e
+ inyecta el valor desde una variable de entorno o un gestor de secretos en el
+ momento del despliegue.
+
+!!! warning "El puerto de gestión está abierto por defecto"
+ Por paridad con Spring, el servidor de gestión (`/actuator/*` y el panel de
+ administración, puerto por defecto `9090`) está **no autenticado por defecto**: no
+ comparte la puerta de seguridad de la aplicación. En cualquier despliegue que no sea local,
+ asegúralo explícitamente:
+
+ ```yaml
+ pyfly:
+ management:
+ security:
+ enabled: true # apply the security gate to the management port
+ ```
+
+ Establecer `pyfly.management.server.port: -1` deshabilita por completo los endpoints
+ de gestión. La exposición HTTP por defecto del actuator es solo `health,info`; amplíala
+ con `pyfly.management.endpoints.web.exposure.include`.
+
+!!! tip "Pruébalo — confirma la conexión automática en el arranque"
+ Con las claves anteriores en `pyfly.yaml`, arranca la aplicación y observa el log de arranque:
+
+ ```bash
+ uv run pyfly run
+ ```
+
+ Esperado — verás que la aplicación se vincula a `:8080` y el servidor de gestión a
+ `:9090`, y los beans de seguridad aparecen sin ningún código `@configuration` propio. Una prueba rápida de que el bean del encoder existe:
+
+ ```bash
+ curl -s http://localhost:8080/api/auth/login \
+ -d '{"username":"alice","password":"hunter2"}' \
+ -H 'content-type: application/json'
+ ```
+
+ Esperado — un cuerpo JSON que contiene un `access_token` (o un `401` con
+ `INVALID_CREDENTIALS` si las credenciales son incorrectas). En cualquier caso, los beans
+ `JWTService` y `BcryptPasswordEncoder` se conectaron automáticamente.
+
+---
+
+## Autorización con @secure
+
+**`@secure`** es un decorador de funciones que impone autenticación y autorización en manejadores y comandos individuales. Lee el argumento de palabra clave `security_context` que el middleware ya ha inyectado y luego evalúa las comprobaciones de rol, permiso y expresión antes de que se ejecute el cuerpo de la función.
+
+### Firma
+
+```python
+def secure(
+ roles: list[str] | None = None,
+ permissions: list[str] | None = None,
+ expression: str | None = None,
+) -> Callable: ...
+```
+
+La función decorada debe aceptar `security_context: SecurityContext` como argumento de palabra clave: así es como `@secure` alcanza al usuario actual.
+
+### Protección basada en roles
+
+Los endpoints de monederos de Lumen (`/api/v1/wallets`) son el lugar natural para aplicar
+`@secure`. El `WalletController` real inyecta los buses de comandos y consultas;
+añade `security_context: SecurityContext` a cada método que necesite protección
+y apila `@secure` encima.
+
+El controlador expone siete endpoints bajo `/api/v1/wallets`:
+
+| Método | Ruta | Manejador | Protección |
+|---|---|---|---|
+| POST | `""` | `open_wallet` | USER o ADMIN |
+| POST | `/{wallet_id}/deposit` | `deposit` | USER/ADMIN + `wallet:deposit` |
+| POST | `/{wallet_id}/withdraw` | `withdraw` | USER/ADMIN + `wallet:deposit` |
+| GET | `""` | `list_wallets` | autenticado |
+| GET | `/rich` | `list_rich_wallets` | autenticado |
+| GET | `/{wallet_id}/balance` | `wallet_balance` | USER o ADMIN |
+| GET | `/{wallet_id}` | `wallet_detail` | solo ADMIN |
+
+Los manejadores de colección (`list_wallets`, `list_rich_wallets`) se nombran con
+el prefijo `list_` para que el framework los registre antes que los manejadores
+`wallet_detail` / `wallet_balance`: Starlette resuelve por
+primero-registrado-gana, así que el segmento literal `/rich` se encuentra antes que la
+variable de ruta `/{wallet_id}`.
+
+::: listing lumen/web/controllers/wallet_controller.py | Listado 14.5 — Protecciones de rol y permiso en endpoints reales de Lumen
+from lumen.core.services.wallets.deposit_funds_command import DepositFunds
+from lumen.core.services.wallets.get_balance_query import GetBalance
+from lumen.core.services.wallets.get_wallet_query import GetWallet
+from lumen.core.services.wallets.list_rich_wallets_query import ListRichWallets
+from lumen.core.services.wallets.list_wallets_query import ListWallets
+from lumen.core.services.wallets.open_wallet_command import OpenWallet
+from lumen.core.services.wallets.withdraw_funds_command import WithdrawFunds
+from lumen.interfaces.dtos.v1.balance_dto import BalanceDto
+from lumen.interfaces.dtos.v1.deposit_request import DepositRequest
+from lumen.interfaces.dtos.v1.open_wallet_request import OpenWalletRequest
+from lumen.interfaces.dtos.v1.page_dto import PageDto
+from lumen.interfaces.dtos.v1.wallet_dto import WalletDto
+from pyfly.container import rest_controller
+from pyfly.cqrs import DefaultCommandBus, DefaultQueryBus
+from pyfly.data import Pageable, Sort
+from pyfly.kernel import ResourceNotFoundException
+from pyfly.security import SecurityContext, secure
+from pyfly.web import (
+ Body,
+ PathVar,
+ QueryParam,
+ Valid,
+ get_mapping,
+ post_mapping,
+ request_mapping,
+)
+
+_NEWEST_FIRST = Sort.by("created_at").descending()
+
+
+@rest_controller
+@request_mapping("/api/v1/wallets")
+class WalletController:
+ """Digital-wallet REST API: open, deposit, withdraw, list, inspect."""
+
+ def __init__(
+ self,
+ commands: DefaultCommandBus,
+ queries: DefaultQueryBus,
+ ) -> None:
+ self._commands = commands
+ self._queries = queries
+
+ # Any authenticated user may open a wallet.
+ @secure(roles=["USER", "ADMIN"])
+ @post_mapping("", status_code=201)
+ async def open_wallet(
+ self,
+ request: Valid[Body[OpenWalletRequest]],
+ security_context: SecurityContext,
+ ) -> dict[str, str]:
+ wallet_id = await self._commands.send(
+ OpenWallet(
+ owner_id=request.owner_id,
+ currency=request.currency,
+ )
+ )
+ return {"wallet_id": wallet_id}
+
+ # Deposit: USER/ADMIN role + wallet:deposit permission.
+ @secure(roles=["USER", "ADMIN"], permissions=["wallet:deposit"])
+ @post_mapping("/{wallet_id}/deposit")
+ async def deposit(
+ self,
+ wallet_id: PathVar[str],
+ request: Valid[Body[DepositRequest]],
+ security_context: SecurityContext,
+ ) -> dict[str, int | str]:
+ balance = await self._commands.send(
+ DepositFunds(wallet_id=wallet_id, amount=request.amount)
+ )
+ return {"wallet_id": wallet_id, "balance_minor": balance}
+
+ # Withdraw: same guard as deposit.
+ @secure(roles=["USER", "ADMIN"], permissions=["wallet:deposit"])
+ @post_mapping("/{wallet_id}/withdraw")
+ async def withdraw(
+ self,
+ wallet_id: PathVar[str],
+ request: Valid[Body[DepositRequest]],
+ security_context: SecurityContext,
+ ) -> dict[str, int | str]:
+ balance = await self._commands.send(
+ WithdrawFunds(wallet_id=wallet_id, amount=request.amount)
+ )
+ return {"wallet_id": wallet_id, "balance_minor": balance}
+
+ # Paged list — collection routes registered before /{wallet_id}.
+ @secure(roles=["USER", "ADMIN"])
+ @get_mapping("")
+ async def list_wallets(
+ self,
+ page: QueryParam[int] = 1,
+ size: QueryParam[int] = 20,
+ security_context: SecurityContext = None,
+ ) -> PageDto[WalletDto]:
+ result = await self._queries.query(
+ ListWallets(
+ pageable=Pageable.of(page, size, _NEWEST_FIRST)
+ )
+ )
+ return PageDto.from_page(result)
+
+ # Rich list: wallets filtered by minimum balance.
+ @secure(roles=["USER", "ADMIN"])
+ @get_mapping("/rich")
+ async def list_rich_wallets(
+ self,
+ min_minor: QueryParam[int] = 0,
+ page: QueryParam[int] = 1,
+ size: QueryParam[int] = 20,
+ security_context: SecurityContext = None,
+ ) -> PageDto[WalletDto]:
+ result = await self._queries.query(
+ ListRichWallets(
+ min_minor=min_minor,
+ pageable=Pageable.of(page, size, _NEWEST_FIRST),
+ )
+ )
+ return PageDto.from_page(result)
+
+ # Single-wallet balance: any authenticated user.
+ @secure(roles=["USER", "ADMIN"])
+ @get_mapping("/{wallet_id}/balance")
+ async def wallet_balance(
+ self,
+ wallet_id: PathVar[str],
+ security_context: SecurityContext,
+ ) -> BalanceDto:
+ result = await self._queries.query(
+ GetBalance(wallet_id=wallet_id)
+ )
+ if result is None:
+ raise ResourceNotFoundException(
+ f"Wallet {wallet_id!r} not found",
+ code="WALLET_NOT_FOUND",
+ context={"wallet_id": wallet_id},
+ )
+ return result
+
+ # Full wallet view: ADMIN only.
+ @secure(roles=["ADMIN"])
+ @get_mapping("/{wallet_id}")
+ async def wallet_detail(
+ self,
+ wallet_id: PathVar[str],
+ security_context: SecurityContext,
+ ) -> WalletDto:
+ result = await self._queries.query(
+ GetWallet(wallet_id=wallet_id)
+ )
+ if result is None:
+ raise ResourceNotFoundException(
+ f"Wallet {wallet_id!r} not found",
+ code="WALLET_NOT_FOUND",
+ context={"wallet_id": wallet_id},
+ )
+ return result
+
+:::
+:::
+
+**Cómo funciona.** Apila `@secure` **encima** de `@post_mapping` / `@get_mapping`
+para que la autorización se ejecute antes que la vinculación de la ruta. El framework inyecta
+`security_context` desde `request.state.security_context`, que
+`SecurityMiddleware` ya rellenó.
+
+Cuando listas varios roles, el usuario necesita **al menos uno** (semántica OR).
+Cuando listas varios permisos, el usuario necesita **todos** ellos (semántica
+AND). Cuando suministras tanto `roles` como `permissions`, ambas comprobaciones deben
+pasar de forma independiente.
+
+Los manejadores de colección (`list_wallets`, `list_rich_wallets`) ordenan
+alfabéticamente antes que `wallet_balance` / `wallet_detail`, de modo que el framework
+registra primero las rutas literales `""` y `/rich`, garantizando que Starlette
+resuelva `/api/v1/wallets/rich` como un segmento fijo y no como un id de monedero.
+
+Para añadir `@secure` a un manejador, la receta es siempre los mismos tres pasos:
+
+**Paso 1 — Añade el parámetro.** Dale al método un parámetro de palabra clave `security_context: SecurityContext`. (Para los manejadores de listado `GET` que ya tienen parámetros de consulta con valor por defecto, dale como valor por defecto `None` para que el orden de los parámetros siga siendo válido: `security_context: SecurityContext = None`.)
+
+**Paso 2 — Apila el decorador.** Pon `@secure(...)` *encima* de `@get_mapping` / `@post_mapping`. El orden importa: la comprobación de autorización debe ejecutarse antes de que lo haga la vinculación de la ruta.
+
+**Paso 3 — Elige la protección mínima.** Escoge la regla de mínimo privilegio que aun así deje pasar a los usuarios correctos: `roles=["USER", "ADMIN"]` para las lecturas ordinarias, un `permissions=["wallet:deposit"]` adicional para las escrituras que mueven dinero, `roles=["ADMIN"]` para la vista de detalle completa.
+
+!!! tip "Pruébalo — observa un 403 por el rol incorrecto"
+ `wallet_detail` es solo para `ADMIN`. Llámalo con un token `USER` (la
+ autenticación tiene éxito, pero la autorización falla):
+
+ ```bash
+ curl -i -H "Authorization: Bearer $USER_TOKEN" \
+ http://localhost:8080/api/v1/wallets/abc-123
+ ```
+
+ Esperado — la petición supera la puerta (*sí* está autenticada) pero
+ `@secure(roles=["ADMIN"])` la rechaza. El manejador de excepciones renderiza un
+ cuerpo de problema que incluye el `code` legible por máquina:
+
+ ```text
+ HTTP/1.1 403 Forbidden
+ content-type: application/problem+json
+
+ {"type":"about:blank","title":"Forbidden","status":403,
+ "detail":"Insufficient roles: requires one of ['ADMIN']",
+ "code":"FORBIDDEN"}
+ ```
+
+ Observa la diferencia con el `401` de la puerta de antes: un token ausente o inválido
+ en la puerta da `401` ("no sé quién eres"), mientras que un token válido
+ sin el rol requerido da `403 FORBIDDEN` ("sé quién eres,
+ y no puedes hacer esto"). El mismo token `USER` *sí* tendría éxito contra
+ `GET /api/v1/wallets/abc-123/balance`, que está protegido con
+ `roles=["USER", "ADMIN"]`.
+
+!!! note "Importes en unidades menores"
+ `DepositRequest.amount` es un `int` en **unidades menores** (céntimos). 10,50 € son
+ `1050`. Esta convención evita errores de redondeo de coma flotante en todo el
+ dominio Money. `WalletDto.balance_minor` lleva el mismo entero;
+ `WalletDto.balance` es un `float` renderizado solo para mostrar.
+
+### Autorización basada en expresiones
+
+Para políticas que no pueden expresarse con una lista plana de roles, usa el parámetro `expression`. PyFly evalúa las expresiones mediante un parseo AST seguro: no hay `eval()` ni `exec()` en ningún punto de la cadena.
+
+::: listing lumen/web/controllers/wallet_controller.py | Listado 14.6 — Expresiones de seguridad en endpoints de monederos
+from pyfly.security import SecurityContext, secure
+
+
+# ADMIN, or a MANAGER who also holds wallet:write.
+@secure(
+ expression=(
+ "hasRole('ADMIN')"
+ " or (hasRole('MANAGER') and hasPermission('wallet:write'))"
+ )
+)
+async def approve_large_deposit(
+ self,
+ deposit_id: str,
+ security_context: SecurityContext,
+) -> None:
+ ...
+
+
+# Any authenticated, non-guest user can see the wallet dashboard.
+@secure(expression="isAuthenticated and not hasRole('GUEST')")
+async def dashboard(
+ self,
+ security_context: SecurityContext,
+) -> dict:
+ ...
+
+:::
+:::
+
+Vocabulario de expresiones soportado (conjunto completo):
+
+| Token | Descripción |
+|---|---|
+| `hasRole('X')` | El usuario tiene el rol `X` |
+| `hasAnyRole('X', 'Y')` | El usuario tiene al menos uno de los roles listados |
+| `hasAuthority('X')` | El usuario tiene el rol **o** el permiso `X` |
+| `hasAnyAuthority('X', 'Y')` | Al menos uno de los roles/permisos listados |
+| `hasPermission('X')` | El usuario tiene el permiso `X` |
+| `isAuthenticated` | El usuario está autenticado |
+| `isAnonymous` | El usuario **no** está autenticado |
+| `permitAll` | Permite siempre |
+| `denyAll` | Deniega siempre |
+| `principal` / `authentication` | El objeto `SecurityContext` actual |
+| `and` / `or` / `not` | Operadores booleanos |
+| `(...)` | Agrupación |
+
+!!! note "Las expresiones son seguras"
+ PyFly reduce cada constructo a `True` o `False` y luego evalúa solo un
+ AST booleano puro. Cualquier nodo que no sea una constante, un `BoolOp` o un
+ `UnaryOp(Not)` lanza `SecurityException(code="INVALID_EXPRESSION")`.
+ Esto elimina por completo los riesgos de inyección.
+
+### Aplicar @secure a manejadores CQRS
+
+`@secure` no se limita a los controladores REST. Puedes proteger manejadores de comandos CQRS exactamente de la misma forma: se dispara antes del cuerpo del manejador porque el contenedor de DI inyecta `security_context` desde `request.state.security_context` cuando resuelve el manejador:
+
+::: listing lumen/core/services/wallets/deposit_funds_handler.py | Listado 14.7 — @secure en un manejador de comandos CQRS
+from pyfly.cqrs import command_handler
+from pyfly.security import SecurityContext, secure
+
+from lumen.core.services.wallets.deposit_funds_command import DepositFunds
+
+
+@command_handler
+class DepositFundsHandler:
+
+ @secure(roles=["USER", "ADMIN"], permissions=["wallet:deposit"])
+ async def handle(
+ self,
+ command: DepositFunds,
+ security_context: SecurityContext,
+ ) -> int:
+ # command.amount is in minor units (cents)
+ ...
+
+:::
+:::
+
+La comprobación se dispara antes de que se ejecute cualquier lógica de negocio.
+
+---
+
+## Contraseñas
+
+### ¿Por qué bcrypt?
+
+MD5 y SHA-256 están diseñados para ser rápidos: ideal para la integridad de datos, catastrófico para las contraseñas. Un atacante que robe tu tabla de usuarios puede probar miles de millones de conjeturas SHA-256 por segundo en hardware de consumo. **Bcrypt** es deliberadamente lento y de coste ajustable: el factor de coste (rounds) te permite afinar el algoritmo para que un ataque requiera órdenes de magnitud más de tiempo, sin afectar de forma apreciable a la latencia normal del inicio de sesión.
+
+### BcryptPasswordEncoder
+
+**`BcryptPasswordEncoder`** implementa el protocolo `PasswordEncoder` (un Protocol `runtime_checkable` con métodos `hash` y `verify`):
+
+::: listing lumen/auth/password_service.py | Listado 14.8 — Hashear y verificar contraseñas
+from pyfly.security import BcryptPasswordEncoder
+
+encoder = BcryptPasswordEncoder(rounds=12)
+
+# During registration — store only the hash, never the raw password
+hashed = encoder.hash("correct-horse-battery-staple")
+
+# During login — verify without storing the plaintext
+is_match = encoder.verify("correct-horse-battery-staple", hashed)
+# True
+
+is_match = encoder.verify("wrong-password", hashed)
+# False
+
+:::
+:::
+
+| Parámetro | Por defecto | Notas |
+|---|---|---|
+| `rounds` | `12` | Cada incremento duplica el tiempo de hashing. 12 es el valor por defecto recomendado para producción. |
+
+**Cómo funciona.** `hash` llama a `bcrypt.gensalt(rounds=self._rounds)` para generar una sal aleatoria nueva y luego a `bcrypt.hashpw` para producir el hash. Tanto la sal como el hash quedan incrustados en la cadena devuelta: el prefijo `$2b$12$…` codifica la versión del algoritmo y el factor de coste, de modo que cada hash almacenado se autodescribe. `verify` llama a `bcrypt.checkpw`, que vuelve a derivar el hash a partir de la contraseña en claro y la sal incrustada, y compara el resultado con una comprobación de igualdad segura frente a temporización, evitando ataques de oráculo de tiempo.
+
+### El protocolo PasswordEncoder
+
+`PasswordEncoder` es un Protocol `runtime_checkable`. Cualquier clase que implemente `hash(raw: str) -> str` y `verify(raw: str, hashed: str) -> bool` lo satisface, incluida `BcryptPasswordEncoder`. Puedes sustituirlo por argon2 o scrypt en cualquier momento sin tocar el código de servicio:
+
+```python
+from pyfly.security import PasswordEncoder
+
+class Argon2PasswordEncoder:
+ def hash(self, raw: str) -> str: ...
+ def verify(self, raw: str, hashed: str) -> bool: ...
+
+isinstance(Argon2PasswordEncoder(), PasswordEncoder) # True
+```
+
+!!! tip "Autoconfiguración"
+ Cuando `pyfly.security.enabled=true` y `bcrypt` está instalado, PyFly
+ autoconfigura un bean `BcryptPasswordEncoder` con `rounds` leído de
+ `pyfly.security.password.bcrypt-rounds`. Declara tú mismo el bean en una
+ clase `@configuration` para sobreescribirlo sin tocar la autoconfiguración.
+
+---
+
+## Sesiones
+
+### ¿Por qué sesiones del lado del servidor?
+
+Los tokens JWT son sin estado: una vez emitidos, no pueden revocarse antes de que caduquen. Si un usuario cierra sesión, su token sigue siendo válido hasta `exp`. Para muchas API ese compromiso es aceptable. Para aplicaciones de cara al navegador —el panel de administración de Lumen, por ejemplo— necesitas que el servidor sea la fuente autoritativa: cerrar sesión debe significar cerrar sesión.
+
+Las **sesiones del lado del servidor** te dan ese control. Un `SessionStore` guarda los datos de sesión indexados por un ID de sesión aleatorio. El navegador recibe solo el ID de sesión en una cookie. El servidor revoca una sesión al instante eliminando su entrada del almacén.
+
+### HttpSession
+
+**`HttpSession`** envuelve el diccionario de datos de una sesión con accesores tipados y rastrea el estado de mutación para que el filtro sepa cuándo persistir:
+
+| Propiedad / Método | Descripción |
+|---|---|
+| `id` | Identificador de sesión, cadena hex UUID |
+| `is_new` | `True` si se creó durante esta petición |
+| `created_at` | Marca de tiempo Unix (`float`) de la creación |
+| `last_accessed` | Marca de tiempo Unix (`float`) del acceso más reciente |
+| `modified` | `True` si se escribió algún atributo o la sesión es nueva |
+| `invalidated` | `True` si se llamó a `invalidate()` |
+| `previous_id` | Id anterior tras `rotate_id()` (lo usa el filtro para limpiar la entrada antigua) |
+| `get_attribute(name)` | Devuelve el valor del atributo o `None` |
+| `set_attribute(name, value)` | Escribe un atributo; marca la sesión como modificada |
+| `remove_attribute(name)` | Elimina el atributo si existe |
+| `get_attribute_names()` | Lista los nombres de atributos puestos por el usuario (excluye las claves internas `_*`) |
+| `rotate_id()` | Asigna un id de sesión nuevo, preservando todos los datos |
+| `invalidate()` | Marca para eliminación en la siguiente pasada del filtro |
+| `get_data()` | Devuelve el diccionario de sesión en bruto (incluye los metadatos internos) |
+
+::: listing lumen/core/services/auth/session_handler.py | Listado 14.9 — Usar la sesión tras el inicio de sesión
+from pyfly.session import HttpSession
+
+
+async def post_login(
+ username: str,
+ password: str,
+ session: HttpSession,
+ auth_service,
+) -> dict:
+ user = await auth_service.authenticate(username, password)
+
+ # Rotate the id before writing auth state (session-fixation prevention)
+ session.rotate_id()
+ session.set_attribute("user_id", str(user.id))
+ session.set_attribute("role", user.role)
+
+ return {"message": "Logged in"}
+
+
+async def logout(session: HttpSession) -> dict:
+ session.invalidate()
+ return {"message": "Logged out"}
+
+:::
+:::
+
+**Cómo funciona.** `rotate_id()` genera un ID de sesión UUID nuevo y registra el antiguo en `session.previous_id`. Cuando `SessionFilter` persiste la sesión al final de la petición, elimina la entrada antigua del almacén (el ID anterior ya no puede resolver a esta sesión) y guarda la nueva. Un atacante que hubiera obtenido el ID de sesión previo a la autenticación no puede arrastrarlo a la sesión autenticada: la clásica mitigación de la **fijación de sesión** (session-fixation).
+
+### El protocolo SessionStore
+
+Todos los backends implementan el protocolo `SessionStore`:
+
+```python
+class SessionStore(Protocol):
+ async def get(
+ self, session_id: str
+ ) -> dict[str, Any] | None: ...
+
+ async def save(
+ self, session_id: str,
+ data: dict[str, Any],
+ ttl: int,
+ ) -> None: ...
+
+ async def delete(self, session_id: str) -> None: ...
+
+ async def exists(self, session_id: str) -> bool: ...
+```
+
+Vienen dos adaptadores de serie:
+
+| Adaptador | Módulo | Notas |
+|---|---|---|
+| `InMemorySessionStore` | `pyfly.session.adapters.memory` | Protegido con `asyncio.Lock`; solo monoproceso; los datos se pierden al reiniciar |
+| `RedisSessionStore` | `pyfly.session.adapters.redis` | Serializado en JSON; claves con prefijo `pyfly:session:`; TTL gestionado por Redis |
+
+### SessionFilter
+
+**`SessionFilter`** es un `OncePerRequestFilter` ordenado en `HIGHEST_PRECEDENCE + 150`. Enmarca cada petición:
+
+1. Lee la cookie de sesión (`PYFLY_SESSION` por defecto).
+2. Carga la sesión del almacén, o crea una nueva.
+3. La adjunta a `request.state.session`.
+4. Llama a `call_next(request)`: se ejecuta el resto de la cadena de filtros y el manejador.
+5. A la vuelta, persiste una sesión modificada o nueva, elimina una invalidada y reemite la cookie con un `max_age` deslizante (el TTL se desplaza hacia adelante en cada petición).
+
+Atributos de cookie que establece `SessionFilter`:
+
+| Atributo | Valor | Razón |
+|---|---|---|
+| `httponly` | `True` | Impide el acceso desde JavaScript; mitigación de XSS |
+| `samesite` | `lax` | Bloquea la mayoría de los flujos de falsificación de petición entre sitios |
+| `secure` | configurable | Pon `True` en producción (solo HTTPS) |
+| `max_age` | `ttl` | Caducidad deslizante |
+
+### Sesiones de Redis en producción
+
+::: listing lumen/config/session_config.py | Listado 14.10 — Almacén de sesiones en Redis
+import redis.asyncio as aioredis
+
+from pyfly.container import bean, configuration
+from pyfly.session import SessionFilter
+from pyfly.session.adapters.redis import RedisSessionStore
+
+
+@configuration
+class SessionConfig:
+
+ @bean
+ def session_store(self) -> RedisSessionStore:
+ client = aioredis.from_url(
+ "redis://localhost:6379/0"
+ )
+ return RedisSessionStore(client=client)
+
+ @bean
+ def session_filter(
+ self,
+ store: RedisSessionStore,
+ ) -> SessionFilter:
+ return SessionFilter(
+ store=store,
+ cookie_name="LUMEN_SESSION",
+ ttl=1800,
+ secure=True,
+ )
+
+:::
+:::
+
+**Cómo funciona.** `RedisSessionStore.save` serializa en JSON el diccionario de sesión (incluidos atributos dataclass como `SecurityContext`, que hacen ida y vuelta mediante un mecanismo de etiquetas de tipo con lista de permitidos) y llama a `client.set(key, raw, ex=ttl)`. El TTL lo gestiona enteramente Redis, de modo que las sesiones caducadas desaparecen del lado del servidor sin ninguna sobrecarga de limpieza. Las claves usan el prefijo `pyfly:session:` para aislamiento de espacio de nombres. Al leer, solo se deserializan los tipos de la lista de permitidos, eliminando los riesgos de instanciación de objetos arbitrarios.
+
+!!! tip "Autoconfiguración"
+ Añade `pyfly.session.enabled: true` y `pyfly.session.store: redis` a
+ `pyfly.yaml`. PyFly autoconfigura `RedisSessionStore` (cuando
+ `redis.asyncio` está instalado) y `SessionFilter` por ti. La
+ `redis.url` por defecto es `redis://localhost:6379/0`; sobreescríbela con
+ `pyfly.session.redis.url`.
+
+!!! spring "Equivalencia con Spring"
+ `SessionFilter` refleja el `SessionRepositoryFilter` de Spring Session.
+ `HttpSession` refleja `javax.servlet.http.HttpSession` (o
+ `jakarta.servlet.http.HttpSession` en Boot 3). `InMemorySessionStore`
+ es equivalente al `MapSessionRepository` de Spring Session;
+ `RedisSessionStore` es equivalente a `RedisSessionRepository`.
+ Los atributos de cookie (`HttpOnly`, `SameSite=Lax`, `Max-Age` deslizante)
+ coinciden exactamente con los valores por defecto de Spring Session.
+
+---
+
+## Validar tokens de un IdP externo (servidor de recursos OAuth2)
+
+Hasta ahora Lumen ha *acuñado sus propios tokens* con `JWTService` y un
+secreto HMAC compartido. Eso es perfecto para un servicio autocontenido. Pero en la mayoría de los
+despliegues del mundo real, los tokens los emite un proveedor de identidad dedicado —Keycloak, Microsoft
+Entra ID (antes Azure AD) o AWS Cognito— y el único trabajo de tu servicio es
+**validarlos**. Este es el rol de **servidor de recursos OAuth2**, y a partir de la
+v26.6.110 PyFly te lo da solo con configuración: sin código, y la misma
+configuración funciona en los tres proveedores.
+
+!!! note "Jerga: servidor de recursos"
+ En términos de OAuth2, el IdP que emite tokens es el *servidor de autorización*;
+ tu API, que custodia los datos que los llamantes quieren y comprueba sus tokens, es el
+ *servidor de recursos*. Lumen es un servidor de recursos. Nunca ve la contraseña del
+ usuario: solo verifica la credencial que el IdP ya firmó.
+
+La diferencia con `JWTService` es el **esquema de firma**. Tus propios tokens se
+firman con un secreto simétrico (`HS256`): la misma clave firma y verifica,
+de modo que solo los servicios que poseen el secreto pueden validar. Los tokens del IdP se firman
+de forma *asimétrica* (`RS256`): el IdP guarda una clave privada y publica las
+claves públicas correspondientes en un endpoint **JWKS** (JSON Web Key Set). Tu servidor
+de recursos descarga esas claves públicas, las cachea y las usa para verificar la firma de cada
+token; nunca necesita el secreto del IdP en absoluto.
+
+### Activarlo
+
+`JWKSTokenValidator` hace el trabajo, y `OAuth2ResourceServerAutoConfiguration`
+lo conecta desde `pyfly.security.oauth2.resource-server.*`. Aquí tienes la
+configuración completa de Keycloak:
+
+::: listing lumen/resources/pyfly.yaml | Listado 14.11 — Servidor de recursos OAuth2 multi-IdP (Keycloak)
+pyfly:
+ security:
+ oauth2:
+ resource-server:
+ enabled: true
+ # Either point at the JWKS endpoint directly...
+ jwks-uri: "https://keycloak.example.com/realms/lumen/protocol/openid-connect/certs"
+ # ...or give an issuer-uri and let OIDC discovery find jwks-uri + issuer:
+ # issuer-uri: "https://keycloak.example.com/realms/lumen"
+ issuer: "https://keycloak.example.com/realms/lumen"
+ audiences: "lumen-backend"
+ validate-audience: true
+ algorithms: "RS256"
+ clock-skew-seconds: 60
+ exclude-patterns: "/idp/login,/idp/refresh,/docs,/openapi.json"
+
+:::
+:::
+
+Paso a paso:
+
+**Paso 1 — Actívalo.** `enabled: true` activa `OAuth2ResourceServerAutoConfiguration` (PyJWT debe estar instalado). Eso registra un bean `JWKSTokenValidator` y el filtro de token portador (bearer) en la cadena de la aplicación.
+
+**Paso 2 — Apunta a las claves.** Establece `jwks-uri` directamente, o establece `issuer-uri` y deja que PyFly obtenga `/.well-known/openid-configuration` para descubrir tanto el `jwks-uri` como el `issuer` autoritativo, exactamente como el `issuer-uri` de Spring.
+
+**Paso 3 — Fija la audiencia y el emisor.** `audiences` enumera los valores con los que debe coincidir el claim `aud` del token (cualquiera de ellos). `issuer` (cuando se establece) se comprueba contra el `iss` del token. Juntos garantizan que un token acuñado para una aplicación o realm *diferente* no pueda reenviarse contra Lumen.
+
+**Paso 4 — Deja los valores por defecto para el mapeo de claims.** No configuraste dónde viven los roles y, aun así, funciona para Keycloak, Entra y Cognito. Ese es el trabajo de `ClaimMappings`, que veremos a continuación.
+
+### Cómo los claims se convierten en un SecurityContext
+
+Cada IdP pone los roles y scopes en sitios distintos. Keycloak anida los roles de realm bajo `realm_access.roles` y los roles por cliente bajo `resource_access..roles`; Entra usa un array plano `roles` (o `groups`); Cognito usa `cognito:groups`. `ClaimMappings` los reconcilia con un pequeño lenguaje de rutas, de modo que `has_role(...)` y `has_permission(...)` funcionan igual independientemente del proveedor.
+
+| Campo de mapeo | Orden de búsqueda por defecto | Se convierte en |
+|---|---|---|
+| `principal_claims` | `oid`, `sub` | `SecurityContext.user_id` |
+| `authority_claims` | `roles`, `scopes`, `authorities`, `realm_access.roles`, `resource_access.*.roles`, `groups`, `cognito:groups` | `SecurityContext.roles` |
+| `scope_claims` | `scp`, `scope` (separados por espacios) | `SecurityContext.permissions` |
+| `attribute_claims` | *(ninguno)* | `SecurityContext.attributes` |
+
+Dos reglas hacen que los valores por defecto abarquen todos los proveedores:
+
+- Las **rutas con puntos** descienden a objetos anidados: `realm_access.roles` lee `payload["realm_access"]["roles"]`.
+- Un **comodín `*`** de un solo nivel itera sobre cada clave de ese nivel: `resource_access.*.roles` recopila el array `roles` de *cada* entrada de cliente bajo `resource_access`. Los segmentos de ruta se dividen solo por `.`, así que un nombre de claim que lleve dos puntos, como `cognito:groups`, se compara textualmente.
+
+Los roles se recopilan de *todas* las rutas coincidentes y se deduplican, de modo que un token de Keycloak aporta tanto sus roles de realm como sus roles de cliente a una única lista plana `roles`.
+
+!!! note "Jerga: JWKS y kid"
+ Un *JWKS* es el directorio de claves públicas del IdP. Cada clave lleva un `kid` (key
+ id); la cabecera de cada token nombra el `kid` con el que se firmó. El validador
+ busca esa clave exacta, cachea el conjunto (por defecto 300 s) y rota
+ automáticamente cuando el IdP publica claves nuevas: sin necesidad de redespliegue.
+
+Si los valores por defecto integrados no coinciden con tu IdP, sobreescribe solo lo que difiera:
+
+::: listing lumen/resources/pyfly.yaml | Listado 14.12 — Ajustar el mapeo de claims para Entra ID y Cognito
+pyfly:
+ security:
+ oauth2:
+ resource-server:
+ enabled: true
+ # Microsoft Entra ID (v2.0):
+ issuer-uri: "https://login.microsoftonline.com//v2.0"
+ audiences: "api://lumen-backend"
+ principal-claim-names: "oid,sub" # Entra's stable user id first
+ authorities-claim-names: "roles,groups"
+ scope-claim-names: "scp" # Entra delegated scopes
+ attribute-claims: "tid,preferred_username"
+ # AWS Cognito access tokens carry no 'aud' — disable audience checks:
+ # validate-audience: false
+ # authorities-claim-names: "cognito:groups"
+
+:::
+:::
+
+**Paso 5 (cuando sea necesario) — Cognito no tiene `aud`.** Los tokens de *acceso* de Cognito llevan `client_id` en lugar de `aud`, así que establece `validate-audience: false` para ellos; las comprobaciones de firma, `iss` y `exp` siguen aplicándose.
+
+!!! tip "Pruébalo — acepta un token real del IdP, rechaza uno falsificado"
+ Con el servidor de recursos activado, reenvía un token emitido por tu IdP contra una
+ ruta protegida:
+
+ ```bash
+ curl -i -H "Authorization: Bearer $KEYCLOAK_TOKEN" \
+ http://localhost:8080/api/v1/wallets
+ ```
+
+ Esperado — `HTTP/1.1 200 OK`. El filtro obtuvo el JWKS, encontró la
+ coincidencia con el `kid` del token, verificó la firma `RS256`, el `iss`, el `aud` y el
+ `exp` (con 60 s de tolerancia de desfase de reloj) y luego construyó un `SecurityContext`
+ a partir de los claims del token.
+
+ Ahora cambia un carácter del token y reinténtalo. Esperado — `HTTP/1.1 401`
+ (o un contexto anónimo que la puerta rechaza después), porque la firma
+ ya no coincide con ninguna clave publicada. No escribiste ni desplegaste una sola
+ línea de código de validación para conseguir ninguno de los dos resultados.
+
+**Lo que acaba de ocurrir.** Has añadido un validador de tokens multi-IdP de nivel de producción solo con configuración. `JWKSTokenValidator` verifica la firma contra las claves publicadas del IdP (de modo que el secreto nunca abandona el IdP), comprueba `iss` / `aud` / `exp` y mapea los claims específicos de cada proveedor al mismo `SecurityContext` que el resto del capítulo ya usa. Tus decoradores `@secure` y tus reglas `HttpSecurity` no cambian en absoluto: leen `roles` y `permissions` exactamente como antes, tanto si el token vino del propio `JWTService` de Lumen como si vino de Keycloak.
+
+!!! warning "modo de error: anónimo vs 401"
+ Por defecto (`authenticate-error-mode: anonymous`) un token *presente pero inválido*
+ produce un contexto anónimo y la petición continúa hasta la puerta de
+ `HttpSecurity`, que decide. Establece `authenticate-error-mode: "401"` para
+ rechazar un token inválido directamente en el filtro con
+ `WWW-Authenticate: Bearer error="invalid_token"` (RFC 6750). Un token *ausente*
+ siempre cae hasta la puerta en cualquier caso.
+
+!!! spring "Equivalencia con Spring"
+ Este es el `spring-boot-starter-oauth2-resource-server` de PyFly. Las claves
+ `resource-server.issuer-uri` / `jwks-uri` reflejan
+ `spring.security.oauth2.resourceserver.jwt.*`; el descubrimiento OIDC, la lista de
+ `audiences` aceptada, los `algorithms` configurables y el desfase de reloj por defecto de 60 segundos
+ coinciden todos con el `JwtDecoder` y el `JwtTimestampValidator` de Spring Security.
+ `ClaimMappings` es el equivalente de un
+ `JwtAuthenticationConverter` / `JwtGrantedAuthoritiesConverter` personalizado, pero
+ expresado de forma declarativa en YAML.
+
+---
+
+## Identidad externa (IDP)
+
+### El problema de gestionar la identidad internamente
+
+Lumen almacena actualmente las credenciales en su propia base de datos, lo que significa que Lumen debe implementar restablecimiento de contraseñas, MFA, verificación de correo, bloqueo de cuentas, borrado GDPR, inicio de sesión social y SSO: trabajo no diferenciador que todo servicio acaba necesitando. La respuesta del sector es delegar la identidad a un proveedor dedicado: Keycloak para entornos on-premise, AWS Cognito para pilas nativas de AWS, Azure AD para entornos de Microsoft.
+
+El puerto **`IdpAdapter`** de PyFly hace esa delegación conectable tras una única interfaz. Cambia el adaptador y la capa de negocio nunca se entera.
+
+### IdpAdapter — el puerto
+
+Todo adaptador debe satisfacer el protocolo `IdpAdapter`:
+
+```python
+class IdpAdapter(Protocol):
+ name: str
+
+ # User management
+ async def create_user(
+ self, user: IdpUser, password: str
+ ) -> IdpUser: ...
+ async def get_user(self, user_id: str) -> IdpUser | None: ...
+ async def find_by_username(
+ self, username: str
+ ) -> IdpUser | None: ...
+ async def update_user(self, user: IdpUser) -> IdpUser: ...
+ async def delete_user(self, user_id: str) -> bool: ...
+ async def list_users(self, *, limit: int = 100) -> list[IdpUser]: ...
+
+ # Authentication
+ async def login(
+ self, request: LoginRequest
+ ) -> AuthResult: ...
+ async def logout(self, access_token: str) -> bool: ...
+ async def refresh(
+ self, refresh_token: str
+ ) -> AuthResult: ...
+ async def introspect(
+ self, access_token: str
+ ) -> SessionIntrospection: ...
+
+ # Password / MFA
+ async def change_password(
+ self, request: PasswordChangeRequest
+ ) -> bool: ...
+ async def reset_password(self, user_id: str) -> str: ...
+
+ # Roles
+ async def assign_role(
+ self, user_id: str, role: str
+ ) -> bool: ...
+ async def revoke_role(
+ self, user_id: str, role: str
+ ) -> bool: ...
+ async def list_roles(self) -> list[IdpRole]: ...
+```
+
+DTOs clave:
+
+| Clase | Propósito |
+|---|---|
+| `IdpUser` | Registro de usuario: `id`, `username`, `email`, `roles`, `attributes`, … |
+| `LoginRequest` | `username`, `password`, `mfa_code` (opcional) |
+| `AuthResult` | `user`, `access_token`, `refresh_token`, `expires_in`, `token_type` |
+| `SessionIntrospection` | `active`, `user_id`, `username`, `scopes`, `expires_at` |
+| `PasswordChangeRequest` | `user_id`, `old_password`, `new_password` |
+| `IdpRole` | `name`, `description`, `scopes` |
+
+### Adaptador de Keycloak
+
+::: listing lumen/config/idp_config.py | Listado 14.13 — Conectar el adaptador de Keycloak
+from pyfly.container import bean, configuration
+from pyfly.idp import IdpAdapter, KeycloakIdpAdapter
+
+
+@configuration
+class IdpConfig:
+
+ @bean
+ def idp_adapter(self) -> IdpAdapter:
+ return KeycloakIdpAdapter(
+ base_url="https://keycloak.example.com",
+ realm="lumen",
+ client_id="lumen-backend",
+ client_secret="${KEYCLOAK_SECRET}",
+ verify_ssl=True,
+ )
+
+:::
+:::
+
+**Cómo funciona.** `KeycloakIdpAdapter` se comunica con la API REST de administración de Keycloak (`/admin/realms/{realm}/users`) y con el endpoint de tokens (`/realms/{realm}/protocol/openid-connect/token`) vía `httpx`. Cachea internamente el token de administración `client_credentials` y lo vuelve a obtener dentro de un margen de seguridad de diez segundos antes de la caducidad; el TTL por defecto de las client-credentials de Keycloak es de 60 s, así que sin esta caché cada llamada de administración requeriría dos viajes de ida y vuelta por red.
+
+### Usar el IDP en un servicio
+
+::: listing lumen/core/services/auth/idp_auth_service.py | Listado 14.14 — Usar IdpAdapter en el servicio de autenticación
+from pyfly.container import service
+from pyfly.idp import IdpAdapter, IdpUser, LoginRequest
+from pyfly.kernel.exceptions import UnauthorizedException
+
+
+@service
+class IdpAuthService:
+
+ def __init__(self, idp: IdpAdapter) -> None:
+ self._idp = idp
+
+ async def register(
+ self,
+ username: str,
+ email: str,
+ password: str,
+ role: str = "USER",
+ ) -> str:
+ user = IdpUser(
+ username=username,
+ email=email,
+ roles=[role],
+ )
+ created = await self._idp.create_user(user, password)
+ result = await self._idp.login(
+ LoginRequest(
+ username=username, password=password
+ )
+ )
+ return result.access_token
+
+ async def login(
+ self, username: str, password: str
+ ) -> str:
+ try:
+ result = await self._idp.login(
+ LoginRequest(
+ username=username, password=password
+ )
+ )
+ except PermissionError as exc:
+ raise UnauthorizedException(
+ "Invalid credentials",
+ code="INVALID_CREDENTIALS",
+ ) from exc
+ return result.access_token
+
+ async def introspect(self, token: str) -> dict:
+ info = await self._idp.introspect(token)
+ return {
+ "active": info.active,
+ "user_id": info.user_id,
+ "username": info.username,
+ "scopes": info.scopes,
+ }
+
+:::
+:::
+
+**Cómo funciona.** `IdpAuthService` depende solo de `IdpAdapter`: el contenedor de DI resuelve el `KeycloakIdpAdapter` concreto en el arranque. La capa de servicio nunca importa código específico de Keycloak, Cognito ni Azure. Cambia de proveedor modificando una sola línea en `IdpConfig`; el servicio queda intacto.
+
+### Autoconfiguración y las rutas HTTP integradas
+
+Activa el subsistema del IDP en `pyfly.yaml` y PyFly conecta el adaptador y un controlador REST ya hecho de forma automática:
+
+::: listing lumen/resources/pyfly.yaml | Listado 14.15 — Autoconfiguración del IDP
+pyfly:
+ idp:
+ enabled: true
+ provider: keycloak
+ keycloak:
+ base-url: https://keycloak.example.com
+ realm: lumen
+ client-id: lumen-backend
+ client-secret: "${KEYCLOAK_SECRET}"
+
+:::
+:::
+
+| Valor de `provider` | Adaptador |
+|---|---|
+| `internal-db` | `InternalDbIdpAdapter` (almacén bcrypt en memoria) |
+| `keycloak` | `KeycloakIdpAdapter` |
+| `cognito` / `aws-cognito` | `AwsCognitoIdpAdapter` |
+| `azure-ad` / `azuread` / `entra` | `AzureAdIdpAdapter` |
+
+Cuando Starlette está presente, `IdpAutoConfiguration` también registra un bean `IdpController` que expone la API completa del IDP bajo `/idp`:
+
+| Ruta | Método | Descripción |
+|---|---|---|
+| `/idp/login` | POST | Autenticar (usuario + contraseña + MFA opcional) |
+| `/idp/refresh` | POST | Refrescar un token de acceso |
+| `/idp/logout` | POST | Revocar un token |
+| `/idp/introspect` | POST | Inspeccionar una sesión activa |
+| `/idp/admin/users` | POST | Crear un usuario |
+| `/idp/admin/users` | GET | Listar usuarios |
+| `/idp/admin/users/{user_id}` | GET / DELETE | Obtener o eliminar un usuario |
+| `/idp/admin/users/{user_id}/roles/{role}` | POST / DELETE | Asignar o revocar un rol |
+| `/idp/admin/roles` | GET | Listar todos los roles |
+
+!!! tip "Adaptadores personalizados"
+ Cualquier clase que satisfaga el Protocol `IdpAdapter` puede conectarse como el
+ bean `IdpAdapter`. Regístralo en una clase `@configuration` y el
+ `@conditional_on_missing_bean` de PyFly se salta la autoconfiguración por completo. Este
+ es el punto de extensión estándar para LDAP on-premise, SSO interno o
+ adaptadores de doble de prueba (test-double).
+
+!!! spring "Equivalencia con Spring"
+ `IdpAdapter` es el equivalente en PyFly de la combinación
+ `UserDetailsService` + `AuthenticationProvider` de Spring Security. `IdpUser`
+ se corresponde con `UserDetails`; `AuthResult` se corresponde con el objeto `Authentication`
+ devuelto por `AuthenticationManager.authenticate()`. `KeycloakIdpAdapter`
+ desempeña el papel del adaptador de Spring Security para Keycloak.
+
+---
+
+## Juntándolo todo — la capa de autenticación de Lumen
+
+El listado siguiente muestra la conexión completa: adaptador del IDP, filtro JWT, reglas a nivel de URL y un almacén de sesiones en Redis para el panel de administración.
+
+::: listing lumen/config/security_full.py | Listado 14.16 — Configuración de seguridad completa
+from pyfly.container import bean, configuration
+from pyfly.idp import IdpAdapter, KeycloakIdpAdapter
+from pyfly.security.http_security import HttpSecurity
+
+
+@configuration
+class LumenSecurityConfig:
+
+ @bean
+ def idp_adapter(self) -> IdpAdapter:
+ return KeycloakIdpAdapter(
+ base_url="https://keycloak.example.com",
+ realm="lumen",
+ client_id="lumen-backend",
+ client_secret="${KEYCLOAK_SECRET}",
+ )
+
+ @bean
+ def http_security(self) -> HttpSecurity:
+ hs = HttpSecurity()
+ hs.authorize_requests() \
+ .request_matchers(
+ "/idp/login", "/idp/refresh",
+ "/docs", "/openapi.json",
+ ).permit_all() \
+ .request_matchers(
+ "/idp/admin/**"
+ ).has_role("ADMIN") \
+ .request_matchers(
+ "/api/v1/wallets/**"
+ ).authenticated() \
+ .any_request().permit_all()
+ return hs
+
+:::
+:::
+
+Con `pyfly.security.enabled=true`, `pyfly.session.enabled=true` y `pyfly.session.store=redis` en `pyfly.yaml`, la autoconfiguración se encarga de `JWTService`, `BcryptPasswordEncoder`, `SessionFilter` y `RedisSessionStore`. La clase `@configuration` de arriba aporta solo lo que la autoconfiguración no puede inferir: las coordenadas de Keycloak y la política a nivel de URL.
+
+---
+
+## Lo que construiste {.recap}
+
+Este capítulo abrió la Parte V cerrando la puerta de entrada abierta de Lumen. Tú:
+
+- Usaste **`JWTService`** para emitir tokens firmados al iniciar sesión y para decodificarlos de vuelta
+ a un `SecurityContext` en cada petición posterior. El claim `exp` es
+ obligatorio: `encode()` lo añade automáticamente usando una marca de tiempo Unix, de modo que nunca
+ necesitas importar `datetime`; los tokens sin `exp` se rechazan en la frontera.
+- Añadiste **`SecurityMiddleware`** a la aplicación Starlette para que toda petición
+ lleve un `SecurityContext` rellenado en el momento en que alcanza un manejador.
+- Declaraste la política a nivel de URL con el constructor **`HttpSecurity`**: un DSL fluido
+ que produce un `HttpSecurityFilter` evaluado antes del despachador de rutas,
+ cubriendo el árbol real `/api/v1/wallets/**` de Lumen y las rutas de administración del IDP.
+- Protegiste los endpoints de monederos reales de Lumen en `WalletController` con
+ **`@secure`**, especificando roles, permisos o expresiones de seguridad completas.
+ Las siete rutas —`open_wallet`, `deposit`, `withdraw`, `list_wallets`
+ (listado paginado), `list_rich_wallets` (listado filtrado), `wallet_balance` y
+ `wallet_detail`— llevan cada una la protección mínima: USER+ADMIN para la mayoría,
+ el permiso `wallet:deposit` para las mutaciones, solo ADMIN para la vista de detalle
+ completa. Cuando `@secure` rechaza una llamada lanza `SecurityException` (código
+ `AUTH_REQUIRED`) para un llamante no autenticado o `ForbiddenException` (código
+ `FORBIDDEN`) para un llamante autenticado que carece del rol o permiso
+ requerido: ambas se renderizan como HTTP `403` a través del manejador de excepciones. La
+ puerta más amplia `HttpSecurity`, situada delante de esos manejadores, es la que
+ devuelve el `401` escueto para un token ausente o inválido.
+- Hasheaste contraseñas con **`BcryptPasswordEncoder`**, el adaptador por defecto del
+ protocolo `PasswordEncoder`. El factor de coste es ajustable; el hash almacenado se
+ autodescribe; la verificación es segura frente a temporización.
+- Gestionaste sesiones del lado del servidor con **`HttpSession`** y el protocolo conectable
+ **`SessionStore`**. En desarrollo, `InMemorySessionStore` no requiere
+ dependencias; en producción, `RedisSessionStore` serializa a JSON y
+ deja que Redis gestione el TTL. El `SessionFilter` desplaza el TTL de la cookie en cada
+ petición y la elimina al invalidarse.
+- Validaste tokens emitidos por un IdP externo con el **servidor de recursos OAuth2**
+ guiado por configuración (`JWKSTokenValidator`). Un bloque de
+ configuración `pyfly.security.oauth2.resource-server.*` acepta tokens de Keycloak, Microsoft
+ Entra ID y AWS Cognito de serie: verifica la firma `RS256`
+ contra las claves JWKS del IdP, comprueba `iss` / `aud` / `exp` con un
+ desfase de reloj de 60 segundos y mapea los roles, scopes y principal de cada proveedor
+ al mismo `SecurityContext` vía `ClaimMappings`, de modo que `@secure` y
+ `HttpSecurity` quedan inalterados.
+- Delegaste la identidad a un proveedor externo vía el puerto **`IdpAdapter`** y
+ la implementación **`KeycloakIdpAdapter`**. El `IdpController` autoconfigurado
+ expone inicio de sesión, refresco, cierre de sesión, introspección y gestión de usuarios
+ de administración bajo `/idp` sin código adicional.
+- Aprendiste que el actuator y el panel de administración se ejecutan en un **puerto de gestión
+ separado** (`pyfly.management.server.port`, por defecto `9090`) que está
+ **no autenticado por defecto** por paridad con Spring, y que lo aseguras con
+ `pyfly.management.security.enabled: true` (o lo deshabilitas por completo con
+ el puerto `-1`).
+
+---
+
+## Pruébalo tú mismo {.exercises}
+
+**Ejercicio 1 — Jerarquía de roles.** Lumen trata actualmente `ADMIN` y `USER` como
+roles independientes. Añade un rol de "superusuario" `SUPER` que posea implícitamente todos los
+privilegios de `ADMIN`. Implementa un envoltorio `RoleHierarchy` que pre-expanda los roles
+antes de almacenarlos en `SecurityContext`, y actualiza `_permissions_for` en el
+Listado 14.1 para que un token con rol `SUPER` pase toda comprobación `@secure(roles=["ADMIN"])`
+sin llevar explícitamente el rol `ADMIN`. Escribe una prueba unitaria que
+cree un `SecurityContext(roles=["SUPER"])` tras la expansión y afirme que
+`has_any_role(["ADMIN"])` devuelve `True`.
+
+**Ejercicio 2 — Control de concurrencia de sesiones.** Los usuarios de administración de Lumen no deben estar
+con sesión iniciada en más de dos dispositivos al mismo tiempo (un requisito habitual de
+cumplimiento financiero). Activa `pyfly.session.concurrency.enabled: true` con
+`max-sessions: 2` y `strategy: evict-oldest` en `pyfly.yaml`. Escribe una
+prueba de integración que use `InMemorySessionStore` y que cree tres sesiones para
+el mismo `user_id`, llame al `SessionConcurrencyController` y afirme que
+la sesión más antigua ha sido expulsada mientras las dos más nuevas siguen siendo válidas.
+
+**Ejercicio 3 — Adaptador de IDP personalizado.** El entorno de staging de Lumen usa un
+servidor OAuth2 casero. Implementa un `StagingIdpAdapter` que satisfaga
+`IdpAdapter`, respaldado por un diccionario de usuarios en memoria. El método `login` debería
+emitir un JWT firmado usando un `JWTService` inyectado a través del constructor. Conéctalo
+como el bean `IdpAdapter` en una clase `@configuration` etiquetada con
+`@conditional_on_property("lumen.env", having_value="staging")` y confirma
+que el bean de producción `KeycloakIdpAdapter` no se crea cuando esa propiedad
+está activa.
diff --git a/book/manuscript-es/15-observability.md b/book/manuscript-es/15-observability.md
new file mode 100644
index 00000000..92d97458
--- /dev/null
+++ b/book/manuscript-es/15-observability.md
@@ -0,0 +1,1521 @@
+Capítulo 15
+
+# Observabilidad y el panel de administración {.chtitle}
+
+::: figure art/openers/ch15.svg |
+
+En el Capítulo 13 rodeaste las rutas críticas de Lumen con cachés y envolviste las llamadas salientes en cortacircuitos (circuit breakers). En el Capítulo 14 aseguraste cada endpoint con autenticación JWT, guardas de rol y sesiones del lado del servidor.
+
+Lumen ya es rápida y segura, pero sigue siendo una caja negra. Cuando un depósito en un monedero tarda tres segundos en producción necesitas saber *dónde* se fueron esos segundos. Cuando un servicio de pagos aguas abajo se degrada necesitas un panel que se ilumine en rojo *antes* de que se avise a tu ingeniero de guardia. Cuando un auditor de cumplimiento pregunta por qué se rechazó una transferencia concreta necesitas registros de log estructurados con suficiente contexto para reconstruir la decisión.
+
+Este capítulo le añade ojos y oídos a Lumen. Los tres pilares de la observabilidad responden a tres preguntas complementarias sobre un sistema en ejecución:
+
+| Pilar | Pregunta | Módulo de PyFly |
+|---|---|---|
+| **Logging** | "¿Qué ocurrió y en qué contexto?" | `pyfly.logging` |
+| **Métricas** | "¿Cuánto? ¿Con qué rapidez? ¿Con qué frecuencia?" | `pyfly.observability.metrics` |
+| **Trazas** | "¿Qué camino siguió esta petición?" | `pyfly.observability.tracing` |
+
+Sobre esos pilares se asienta el **Actuator**: endpoints de gestión para producción que exponen la salud, el cableado de beans, el entorno y los niveles de logger en vivo, y el **panel de administración**, una interfaz de navegador embebida que lo une todo en un único panel de control.
+
+!!! note "Novedad en v26.6.110"
+ Dos cambios de esta versión condicionan cómo accedes a todo lo de este
+ capítulo. Primero, el Actuator y el panel de administración ahora escuchan en
+ un **puerto de gestión separado** —`9090` por defecto— en lugar del puerto de
+ aplicación `8080`. Segundo, ese puerto de gestión está **abierto y sin
+ autenticar por defecto** (el modelo `management.server.port` de Spring Boot),
+ de modo que puedes hacer curl a
+ `http://localhost:9090/actuator/health` de inmediato, y lo blindas con
+ `pyfly.management.security.enabled: true` cuando despliegas. Iremos señalando
+ el puerto correcto en cada paso. Si has leído borradores antiguos que usaban
+ `http://localhost:8080/actuator/...`, cambia el puerto a `9090`.
+
+Por último, verás cómo la **programación orientada a aspectos** (AOP) aplica logging y métricas a cada método de servicio de forma declarativa, sin tocar los propios métodos.
+
+Al final del capítulo Lumen producirá logs JSON estructurados con identificadores de correlación y enmascaramiento automático de PII, emitirá métricas Prometheus recolectadas por cualquier colector estándar, propagará spans de trazas OpenTelemetry a través de las fronteras entre servicios, responderá a las sondas de liveness y readiness de Kubernetes y mostrará todo lo anterior en un panel de configuración cero accesible en `/admin`.
+
+---
+
+## Logging estructurado y redacción de PII
+
+### ¿Por qué logging estructurado?
+
+Las líneas de log tradicionales tienen este aspecto:
+
+```
+[INFO] Order ord-123 created for customer acme corp (email: sales@acme.com)
+```
+
+Buscar `ord-123` en Elasticsearch funciona, hasta que cambia el formato. Y que `sales@acme.com` acabe en un fichero de log puede vulnerar tu política de protección de datos sin que tu equipo siquiera lo note.
+
+El **logging estructurado** sustituye la cadena interpolada por un nombre de evento y pares clave-valor explícitos. Los pares se renderizan como JSON en producción y como `clave=valor` legible en desarrollo. Un sistema de agregación de logs ingiere JSON de forma nativa; consultas sobre `wallet_id` u `owner_id` como campos de primera clase, con independencia del formato del mensaje.
+
+### get_logger
+
+PyFly expone una única función factoría que devuelve un logger estructurado respaldado por `structlog` (cuando el extra `observability` está instalado) o, en caso contrario, un envoltorio de la biblioteca estándar sin dependencias. Ambos aceptan la misma firma de llamada.
+
+!!! note "Jerga: función factoría"
+ Una *función factoría* no es más que una función cuya labor es construir y
+ devolver un objeto configurado. `get_logger("lumen.wallet")` hace el cableado
+ por ti: nunca construyes un logger a mano. La cadena que pasas
+ (`"lumen.wallet"`) es el *nombre del logger*; por convención es la ruta de
+ módulo con puntos, y es a lo que apuntan las anulaciones de nivel como
+ `lumen.wallet: DEBUG`.
+
+Construyamos un pequeño ejemplo que puedas ejecutar y luego pasemos al código real del monedero. Sigue estos pasos.
+
+**Paso 1 — Crea un módulo de demostración desechable.** Dentro de tu proyecto Lumen, crea `src/lumen/logging_demo.py` con el contenido del listado siguiente. (Es un fichero de prueba para aprender; bórralo después: el logging de verdad vive dentro de los manejadores más adelante en el capítulo.)
+
+::: listing lumen/logging_demo.py | Listado 15.1 — Uso del logger estructurado
+from pyfly.logging import get_logger
+
+logger = get_logger("lumen.wallet")
+
+logger.info("wallet_opened", wallet_id="wlt-001", owner_id="usr-42")
+logger.warning("balance_low", wallet_id="wlt-001", remaining=300)
+logger.error(
+ "deposit_rejected",
+ wallet_id="wlt-001",
+ reason="insufficient_funds",
+)
+:::
+
+**Paso 2 — Ejecútalo.** Ejecuta el módulo directamente:
+
+```bash
+uv run python -m lumen.logging_demo
+```
+
+En desarrollo, con `format: console`, la salida se lee de forma natural:
+
+```
+10:30:00 [info ] wallet_opened wallet_id=wlt-001 owner_id=usr-42
+10:30:01 [warning ] balance_low wallet_id=wlt-001 remaining=300
+10:30:02 [error ] deposit_rejected wallet_id=wlt-001 reason=insufficient_funds
+```
+
+Fíjate en la forma: el *primer* argumento (`"wallet_opened"`) es el **nombre del evento**, no una frase, y todo lo que viene después es un par `clave=valor`. No hay formateo de cadenas, ni f-string, ni `%s`. Ese es el sentido del logging estructurado: el nombre del evento permanece estable mientras los campos transportan los datos variables.
+
+En producción, con `format: json`, cada línea es un objeto JSON autocontenido:
+
+```json
+{"event":"wallet_opened","wallet_id":"wlt-001","owner_id":"usr-42",
+ "timestamp":"2026-06-07T10:30:00Z","level":"info",
+ "logger":"lumen.wallet"}
+```
+
+Configura el logging en `pyfly.yaml`:
+
+```yaml
+pyfly:
+ logging:
+ level:
+ root: INFO
+ lumen.wallet: DEBUG
+ sqlalchemy.engine: WARNING
+ format: console # console | json | logfmt
+```
+
+`level.` anula el nivel raíz para cualquier logger cuyo nombre empiece por
+ese prefijo. `sqlalchemy.engine: WARNING` silencia los logs de consultas sin tocar
+tu código. Una variable de entorno `PYFLY_LOGGING_LEVEL_ROOT=WARNING` anula la
+clave de configuración, lo cual resulta útil para builds de staging.
+
+**Qué acaba de pasar.** Llamaste a una sola función, `get_logger`, y obtuviste un
+logger que admite campos estructurados `clave=valor`. El ajuste `format` decide
+cómo se renderizan esos campos: `console` para humanos en tu terminal, `json` para
+máquinas en producción. No escribiste nada de código de handler, formateador ni
+appender: PyFly los instaló en el logger raíz al arrancar. Cambia `format: console`
+por `format: json` en `pyfly.yaml` y vuelve a ejecutar la demostración para ver los
+mismos tres eventos emitidos como un objeto JSON por línea, listos para que un
+agregador de logs los ingiera.
+
+!!! tip "¿Por qué no usar directamente el `logging` de la biblioteca estándar?"
+ `logging.getLogger("x").info("event", wallet_id="wlt-001")` lanza
+ `TypeError`: la biblioteca estándar rechaza los argumentos con nombre.
+ `get_logger` garantiza que la firma estructurada funcione sea cual sea el
+ adaptador activo.
+
+### Identificadores de correlación
+
+Los **identificadores de correlación** enlazan cada línea de log emitida durante una única petición HTTP. PyFly vincula un `transaction_id` al contexto asíncrono actual de forma automática mediante `TransactionIdMiddleware`. Tus manejadores pueden vincular campos adicionales —como el usuario autenticado— para que esos campos fluyan a través de todas las llamadas de log subsiguientes sin pasarse de forma explícita:
+
+::: listing lumen/wallet/handler.py | Listado 15.2 — Vinculación de contexto de correlación
+import structlog
+
+from pyfly.logging import get_logger
+
+logger = get_logger("lumen.wallet")
+
+
+async def handle_deposit(wallet_id: str, amount: int, owner_id: str) -> dict:
+ structlog.contextvars.bind_contextvars(
+ wallet_id=wallet_id,
+ owner_id=owner_id,
+ )
+
+ logger.info("deposit_started", amount=amount)
+ # ... business logic ...
+ result = {"wallet_id": wallet_id, "new_balance": 1350}
+ logger.info("deposit_completed", new_balance=result["new_balance"])
+
+ structlog.contextvars.unbind_contextvars("wallet_id", "owner_id")
+ return result
+:::
+
+Cada llamada a `logger.*` dentro de `handle_deposit` —incluidas las llamadas en lo más hondo de métodos de servicio aguas abajo— transporta automáticamente `wallet_id` y `owner_id` sin más fontanería.
+
+### Redacción de PII
+
+**PII** son las siglas de *información de identificación personal* (personally identifiable information): correos electrónicos, números de tarjeta, números de documento nacional de identidad y similares. La **redacción de PII** está activada por defecto. Antes de que cualquier registro de log alcance un handler de salida, PyFly escanea el mensaje renderizado en busca de correos, números de tarjeta de crédito, IBAN, SSN, JWT, tokens bearer y credenciales en URL. Los patrones detectados se sustituyen por ``, ``, etc.
+
+Puedes comprobarlo tú mismo en unos segundos. Añade una línea a la demostración del Paso 1:
+
+```python
+logger.info("contact_logged", email="alice@example.com")
+```
+
+Ejecuta `uv run python -m lumen.logging_demo` de nuevo. La salida muestra el valor ya enmascarado: nunca tuviste que activarlo:
+
+```
+10:30:03 [info ] contact_logged email=
+```
+
+Esa pasada de redacción se ejecuta para *cada* logger del proceso, incluidos los que están dentro de bibliotecas de terceros, razón por la cual atrapa fugas que tú no escribiste.
+
+El motor de expresiones regulares viene con cada instalación. El motor NER respaldado por Presidio —que además atrapa nombres y direcciones en texto libre— está disponible mediante el extra `[pii]` y se activa automáticamente cuando se instala:
+
+```bash
+uv add "pyfly[observability,pii]"
+python -m spacy download en_core_web_sm # lighter model; lg for higher recall
+```
+
+Configura la redacción en `pyfly.yaml`:
+
+```yaml
+pyfly:
+ logging:
+ redaction:
+ enabled: true # default; set false to disable entirely
+ engine: auto # regex | presidio | auto (presidio if installed)
+ mask: placeholder # placeholder () | partial (****@acme.com)
+ deny-fields:
+ - password
+ - token
+ - secret
+ presidio:
+ score-threshold: 0.6
+ languages: [en, es]
+```
+
+`deny-fields` enumera los *nombres* de los campos de log estructurado cuyos valores se sustituyen incondicionalmente por ``. Úsalo para campos como `password`, donde sabes que el valor es sensible sin inspeccionar el contenido.
+
+!!! spring "Equivalencia con Spring"
+ Spring Boot no incluye redacción de PII integrada; los equipos integran
+ `MaskingMessageConverter` de Logback o appenders personalizados de forma
+ manual. La redacción de PyFly se aplica a *todos* los loggers —incluidas las
+ bibliotecas de terceros— mediante un único `ProcessorFormatter` /
+ `RedactionFilter` instalado en el handler raíz. No hace falta configuración
+ por biblioteca.
+
+### Appender de fichero con rotación
+
+Cuando los logs van a un fichero en lugar de a stdout, configura la rotación en `pyfly.yaml`:
+
+```yaml
+pyfly:
+ logging:
+ file:
+ name: lumen.log
+ path: ./logs
+ rolling:
+ max-size: 50MB
+ max-history: 14
+```
+
+PyFly escribe en `./logs/lumen.log` y rota a los 50 MB, conservando 14 ficheros rotados antes de descartar el más antiguo. La misma pasada de redacción de PII se aplica a la salida a fichero.
+
+---
+
+## Métricas
+
+### El MetricsRegistry
+
+Una **métrica** es un número que muestreas a lo largo del tiempo: un recuento de depósitos, una latencia en segundos, una cifra de memoria. **Prometheus** es la base de datos de métricas de código abierto de facto; *recolecta* (lee periódicamente) un endpoint HTTP que tu aplicación expone y almacena los números. **`MetricsRegistry`** es la pequeña puerta de entrada de PyFly a ese mundo: un fino envoltorio sobre la biblioteca `prometheus_client` que garantiza que cada nombre de métrica se registre solo una vez. Las llamadas duplicadas a `counter()` o `histogram()` con el mismo nombre devuelven la métrica existente en lugar de lanzar un `ValueError`. Inyéctalo desde el contenedor de DI (autoconfigurado cuando `prometheus_client` está instalado) o créalo manualmente:
+
+::: listing lumen/observability/metrics.py | Listado 15.3 — Creación de métricas
+from pyfly.observability import MetricsRegistry
+
+registry = MetricsRegistry()
+
+deposits_total = registry.counter(
+ name="lumen.deposits.total",
+ description="Deposit operations completed",
+ labels=["status"],
+)
+
+deposit_duration = registry.histogram(
+ name="lumen.deposits.duration",
+ description="Deposit processing time in seconds",
+ labels=["status"],
+ buckets=(0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0),
+)
+:::
+
+`counter()` y `histogram()` devuelven objetos nativos `prometheus_client.Counter` y `prometheus_client.Histogram`, de modo que cada herramienta del ecosistema Prometheus —paneles de Grafana, reglas de alerta, reglas de grabación— funciona sin modificación.
+
+| Método | Devuelve | Sirve para |
+|---|---|---|
+| `registry.counter(name, description, labels)` | `Counter` | Recuentos monótonamente crecientes |
+| `registry.histogram(name, description, labels, buckets)` | `Histogram` | Duraciones, tamaños, percentiles de latencia |
+| `registry.counter(…)` llamado de nuevo | El mismo `Counter` | Deduplicación segura |
+
+### @timed — histograma de duración automático
+
+**`@timed`** registra cuánto tarda en ejecutarse una función asíncrona o síncrona, usando un histograma etiquetado. Funciona sobre cualquier invocable y añade automáticamente las etiquetas `class`, `method` y `exception`.
+
+!!! note "Jerga: counter frente a histogram"
+ Un **counter** (contador) solo crece: responde a "¿cuántos?" (depósitos
+ atendidos, errores producidos). Un **histogram** (histograma) distribuye en
+ cubetas los *valores* observados: responde a "¿cuánto tarda?" o "¿cómo de
+ grande?" y permite a Prometheus calcular percentiles (latencia p95). Regla
+ general: cuenta eventos con un counter; mide duraciones y tamaños con un
+ histogram.
+
+Ahora cableemos la primera métrica real en Lumen. Aquí está el manejador de depósitos que construiste en capítulos anteriores; el único cambio es el decorador sobre `do_handle`.
+
+**Paso 1 — Importa los ayudantes de métricas.** En la parte superior de `src/lumen/core/services/wallets/deposit_funds_handler.py`, añade `MetricsRegistry` y `timed` al import de `pyfly.observability`.
+
+**Paso 2 — Crea un registro a nivel de módulo.** Añade `registry = MetricsRegistry()` por encima de la clase. Como el registro deduplica por nombre, compartir uno por módulo es seguro.
+
+**Paso 3 — Decora `do_handle`.** Coloca `@timed(...)` *por encima* de `@transactional()` para que el temporizador envuelva toda la unidad de trabajo transaccional.
+
+::: listing lumen/core/services/wallets/deposit_funds_handler.py | Listado 15.4 — @timed en DepositFundsHandler
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+from pyfly.container import service
+from pyfly.cqrs import CommandHandler, command_handler
+from pyfly.data.relational.sqlalchemy import transactional
+from pyfly.domain import AggregateNotFound
+from pyfly.eda import EventPublisher
+from pyfly.observability import MetricsRegistry, timed
+
+from lumen.core.mappers.wallet_mapper import to_aggregate, to_entity
+from lumen.core.services.wallets.deposit_funds_command import DepositFunds
+from lumen.core.services.wallets.event_publishing import publish_domain_events
+from lumen.models.entities.v1.money import Money
+from lumen.models.repositories.wallet_repository import WalletRepository
+
+registry = MetricsRegistry()
+
+
+@command_handler
+@service
+class DepositFundsHandler(CommandHandler[DepositFunds, int]):
+ """Credit funds to an existing wallet; returns the new balance."""
+
+ def __init__(
+ self,
+ repository: WalletRepository,
+ events: EventPublisher,
+ session_factory: async_sessionmaker[AsyncSession],
+ ) -> None:
+ super().__init__()
+ self._repository = repository
+ self._events = events
+ self._session_factory = session_factory
+
+ @timed(registry, "lumen.deposit.duration", "Deposit handler latency")
+ @transactional()
+ async def do_handle(self, command: DepositFunds) -> int:
+ entity = await self._repository.find_by_id(command.wallet_id)
+ if entity is None:
+ raise AggregateNotFound("Wallet", command.wallet_id)
+ wallet = to_aggregate(entity)
+ wallet.deposit(Money(amount=command.amount, currency=wallet.currency))
+ await self._repository.upsert(to_entity(wallet))
+ await publish_domain_events(self._events, wallet.clear_events())
+ return wallet.balance.amount
+:::
+
+El decorador captura `start = time.perf_counter()`, llama a la función y observa el tiempo transcurrido en el bloque `finally`. La etiqueta `exception` es `"none"` en caso de éxito y el nombre del tipo de excepción en caso de fallo, de modo que puedes desglosar la latencia por resultado en Grafana. Las etiquetas `class` y `method` se derivan automáticamente del nombre cualificado de la función.
+
+Los nombres de los histogramas siguen la convención dot.case de Micrometer: `"lumen.deposit.duration"` se convierte en `lumen_deposit_duration_seconds` en Prometheus, con un sufijo `_seconds` añadido si no está presente.
+
+**Paso 4 — Expón el endpoint de Prometheus.** Por defecto el actuator expone en web
+solo `health` e `info` (consulta "El puerto de gestión" más adelante). Añade
+`prometheus` a la lista de exposición en `pyfly.yaml` para que aparezca el endpoint
+de recolección:
+
+```yaml
+pyfly:
+ management:
+ endpoints:
+ web:
+ exposure:
+ include: "health,info,prometheus"
+```
+
+**Paso 5 — Ejecútalo y observa cómo aparece la métrica.** Arranca Lumen, lanza un depósito a través de la API y luego recolecta el puerto de gestión.
+
+```bash
+# Terminal 1 — start the app (business API on 8080, management on 9090)
+uv run pyfly run --server uvicorn
+
+# Terminal 2 — open a wallet, then deposit into it
+WALLET=$(curl -s -X POST localhost:8080/api/v1/wallets \
+ -H 'Content-Type: application/json' \
+ -d '{"owner_id":"usr-42","currency":"EUR"}' \
+ | python -c "import sys,json;print(json.load(sys.stdin)['wallet_id'])")
+curl -s -X POST localhost:8080/api/v1/wallets/$WALLET/deposit \
+ -H 'Content-Type: application/json' -d '{"amount":1350}'
+
+# Now scrape the metric — note the MANAGEMENT port 9090, not 8080
+curl -s localhost:9090/actuator/prometheus | grep lumen_deposit_duration
+```
+
+Deberías ver líneas de histograma como estas (tus números diferirán):
+
+```
+lumen_deposit_duration_seconds_bucket{class="DepositFundsHandler",method="do_handle",exception="none",le="0.05"} 1.0
+lumen_deposit_duration_seconds_count{class="DepositFundsHandler",method="do_handle",exception="none"} 1.0
+lumen_deposit_duration_seconds_sum{class="DepositFundsHandler",method="do_handle",exception="none"} 0.013
+```
+
+La línea `_count` confirma una observación; la línea `_sum` es el total de segundos consumidos. Si `grep` no encuentra nada, es que aún no has lanzado un depósito: la métrica se crea de forma perezosa en la primera llamada.
+
+### @counted — contador de invocaciones automático
+
+**`@counted`** incrementa un contador en cada llamada a la función. El `GetBalanceHandler` de Lumen encaja a la perfección: cada lectura de saldo incrementa el contador, etiquetado por resultado:
+
+::: listing lumen/core/services/wallets/get_balance_handler.py | Listado 15.5 — @counted en GetBalanceHandler
+from pyfly.container import service
+from pyfly.cqrs import QueryHandler, query_handler
+from pyfly.observability import MetricsRegistry, counted
+
+from lumen.core.mappers.wallet_mapper import entity_to_balance_dto
+from lumen.core.services.wallets.get_balance_query import GetBalance
+from lumen.interfaces.dtos.v1.balance_dto import BalanceDto
+from lumen.models.repositories.wallet_repository import WalletRepository
+
+registry = MetricsRegistry()
+
+
+@query_handler
+@service
+class GetBalanceHandler(QueryHandler[GetBalance, BalanceDto | None]):
+
+ def __init__(self, repository: WalletRepository) -> None:
+ super().__init__()
+ self._repository = repository
+
+ @counted(registry, "lumen.balance.reads", "Balance queries served")
+ async def do_handle(self, query: GetBalance) -> BalanceDto | None:
+ entity = await self._repository.find_by_id(query.wallet_id)
+ return entity_to_balance_dto(entity) if entity is not None else None
+:::
+
+En caso de éxito el contador se incrementa con las etiquetas `class="GetBalanceHandler"`, `method="do_handle"`, `result="success"` y `exception="none"`. En caso de fallo usa `result="failure"` y `exception=`, y luego vuelve a lanzar la excepción original. El nombre del contador recibe automáticamente un sufijo `_total` en Prometheus, según la convención de nombres.
+
+Puedes apilar ambos decoradores en el mismo método. El siguiente listado muestra el `WithdrawFundsHandler` de Lumen cronometrado y contado simultáneamente:
+
+::: listing lumen/core/services/wallets/withdraw_funds_handler.py | Listado 15.6 — Apilando @timed y @counted
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+from pyfly.container import service
+from pyfly.cqrs import CommandHandler, command_handler
+from pyfly.data.relational.sqlalchemy import transactional
+from pyfly.domain import AggregateNotFound
+from pyfly.eda import EventPublisher
+from pyfly.observability import MetricsRegistry, counted, timed
+
+from lumen.core.mappers.wallet_mapper import to_aggregate, to_entity
+from lumen.core.services.wallets.event_publishing import publish_domain_events
+from lumen.core.services.wallets.withdraw_funds_command import WithdrawFunds
+from lumen.models.entities.v1.money import Money
+from lumen.models.repositories.wallet_repository import WalletRepository
+
+registry = MetricsRegistry()
+
+
+@command_handler
+@service
+class WithdrawFundsHandler(CommandHandler[WithdrawFunds, int]):
+ """Debit funds from a wallet; returns the new balance in minor units."""
+
+ def __init__(
+ self,
+ repository: WalletRepository,
+ events: EventPublisher,
+ session_factory: async_sessionmaker[AsyncSession],
+ ) -> None:
+ super().__init__()
+ self._repository = repository
+ self._events = events
+ self._session_factory = session_factory
+
+ @timed(registry, "lumen.withdrawal.duration", "Withdrawal latency")
+ @counted(registry, "lumen.withdrawals", "Withdrawal attempts")
+ @transactional()
+ async def do_handle(self, command: WithdrawFunds) -> int:
+ entity = await self._repository.find_by_id(command.wallet_id)
+ if entity is None:
+ raise AggregateNotFound("Wallet", command.wallet_id)
+ wallet = to_aggregate(entity)
+ wallet.withdraw(Money(amount=command.amount, currency=wallet.currency))
+ await self._repository.upsert(to_entity(wallet))
+ await publish_domain_events(self._events, wallet.clear_events())
+ return wallet.balance.amount
+:::
+
+`amount` es un `int` en unidades menores (p. ej. 1050 = 10,50 €); el objeto de valor `Money` impone el tipo. Cada invocación produce tanto una observación de histograma como un incremento de contador.
+
+**Qué acaba de pasar.** Añadiste medición transversal a tres manejadores sin
+cambiar nada salvo la pila de decoradores. `@timed` responde a "¿cuánto tardó?",
+`@counted` responde a "¿con qué frecuencia y tuvo éxito?", y apilarlos te da ambas
+cosas gratis. Los cuerpos de los manejadores —la lógica de negocio en sí— nunca
+mencionan métricas. Tras ejecutar unos cuantos depósitos y retiradas puedes
+recolectar `localhost:9090/actuator/prometheus` de nuevo y ver cómo
+`lumen_withdrawals_total` y `lumen_balance_reads_total` van subiendo.
+
+### Endpoint de recolección de Prometheus
+
+El actuator (que se trata en la siguiente sección) expone el registro de métricas para su recolección. Cuando el actuator está habilitado y `prometheus_client` está instalado, se montan dos endpoints automáticamente sin código adicional:
+
+- `GET /actuator/metrics` — JSON compatible con Micrometer que enumera todos los nombres de métrica
+- `GET /actuator/prometheus` — formato de exposición de texto estándar para la recolección
+
+Apunta tus `scrape_configs` de Prometheus a `/actuator/prometheus` y todas las métricas de `MetricsRegistry` aparecerán junto a las métricas de proceso integradas (CPU, memoria, hilos, GC). Por defecto el actuator escucha en el **puerto de gestión** (`9090`), no en el puerto de aplicación (`8080`), así que el destino de recolección es `http://:9090/actuator/prometheus`. Consulta "El puerto de gestión" más adelante.
+
+!!! spring "Equivalencia con Spring"
+ `MetricsRegistry` refleja el `MeterRegistry` de Spring procedente de
+ Micrometer. `@timed` corresponde al `@Timed` de Spring y `@counted` al
+ `@Counted`. Los nombres dot.case (`lumen.deposit.duration`) coinciden con la
+ convención de Micrometer que el `/actuator/metrics` de Spring Boot Actuator
+ también expone.
+
+---
+
+## Trazas distribuidas
+
+### @span — decorador de span de OpenTelemetry
+
+!!! note "Jerga: trace, span, OpenTelemetry"
+ Un **span** es un paso de trabajo único, cronometrado y con nombre —"obtener
+ el monedero", "persistir el depósito"—. Un **trace** (traza) es el árbol
+ completo de spans de una única petición, de la raíz a las hojas.
+ **OpenTelemetry** (a menudo "OTel") es el estándar neutral respecto a
+ proveedores para producir trazas; una vez que tus spans hablan OTel, cualquier
+ visor compatible —Jaeger, Tempo, Honeycomb— puede mostrarlos. PyFly emite
+ spans OTel, así que nunca quedas atado a una sola herramienta.
+
+**`@span`** envuelve una función asíncrona o síncrona en un span de OpenTelemetry. Cada span es una unidad de trabajo cronometrada y con nombre. Los spans se anidan automáticamente gracias a la propagación de contexto de OpenTelemetry, de modo que una función decorada con `@span` llamada desde dentro de otra función decorada con `@span` produce una relación padre-hijo en tu visor de trazas:
+
+::: listing lumen/wallet/service.py | Listado 15.7 — @span en métodos de manejador CQRS
+from pyfly.observability import span
+
+
+class DepositFundsHandler:
+
+ @span("deposit-funds")
+ async def do_handle(self, command):
+ balance = await self._fetch_wallet(command.wallet_id)
+ await self._persist_deposit(command.wallet_id, command.amount)
+ return balance + command.amount
+
+ @span("fetch-wallet")
+ async def _fetch_wallet(self, wallet_id: str) -> int:
+ # ... repository.find(wallet_id) ...
+ return 1000
+
+ @span("persist-deposit")
+ async def _persist_deposit(self, wallet_id: str, amount: int) -> None:
+ # ... repository.add(wallet) ...
+ pass
+:::
+
+En un visor de trazas esto aparece así:
+
+```
+deposit-funds [120 ms]
+ +-- fetch-wallet [15 ms]
+ +-- persist-deposit [90 ms]
+```
+
+`@span` crea un tracer llamado `"pyfly"` mediante `trace.get_tracer("pyfly")`. Cuando la función decorada lanza, el span registra automáticamente el error: pone el estado en `ERROR`, llama a `current_span.record_exception(exc)` y luego vuelve a lanzar para que los llamantes vean la excepción original sin modificar. Las funciones síncronas se admiten de forma idéntica: no hay `await` en el lado decorado.
+
+### Autoconfiguración de OpenTelemetry
+
+PyFly cablea un `TracerProvider` con un `BatchSpanProcessor` de forma automática
+cuando `opentelemetry-api` y `opentelemetry-sdk` están instalados:
+
+```bash
+uv add opentelemetry-api opentelemetry-sdk opentelemetry-exporter-otlp
+```
+
+Configura el exportador en `pyfly.yaml`:
+
+```yaml
+pyfly:
+ observability:
+ tracing:
+ enabled: true
+ service-name: "${pyfly.app.name}"
+ exporter: otlp
+ otlp:
+ endpoint: "http://localhost:4318"
+```
+
+**Ejecútalo — observa spans sin un colector.** Levantar Jaeger o Tempo es
+excesivo mientras estás aprendiendo. Configura en su lugar el **exportador de
+consola**, que imprime cada span por stdout. Lumen viene con
+`tracing.enabled: false`; actívalo y elige `console`:
+
+```yaml
+pyfly:
+ observability:
+ tracing:
+ enabled: true
+ exporter: console
+```
+
+Reinicia con `uv run pyfly run --server uvicorn`, lanza un depósito a través de la
+API como en el paso de métricas y observa la terminal. Cada `@span` imprime un
+bloque JSON que muestra su nombre, el id del span padre y el tiempo transcurrido:
+la misma estructura padre-hijo que esboza el diagrama anterior, solo que en forma
+de texto. Vuelve a `exporter: otlp` (o elimina la anulación) en cuanto tengas un
+colector real.
+
+Reglas de selección del exportador:
+
+- `exporter: otlp` — usa OTLP/HTTP; requiere `opentelemetry-exporter-otlp`
+- `exporter: console` — imprime los spans por stdout; útil para depuración local
+- `exporter: none` — registra los spans pero los descarta (útil en pruebas)
+- Sin definir — selecciona OTLP automáticamente si está configurado
+ `pyfly.observability.tracing.otlp.endpoint` o la variable de entorno
+ `OTEL_EXPORTER_OTLP_ENDPOINT`; en caso contrario registra una única línea
+ informativa y descarta los spans en silencio
+
+!!! tip "TracerProvider personalizado"
+ Si necesitas exportación gRPC o una configuración del SDK no estándar,
+ registra tu propio `TracerProvider` antes de que PyFly arranque:
+ `trace.set_tracer_provider(…)`. PyFly detecta un proveedor global existente y
+ omite la autoconfiguración.
+
+### Propagación de contexto — entrante y saliente
+
+Los spans permanecen correlacionados *dentro de* un proceso automáticamente. Mantener la misma traza *a través de* varios servicios requiere extraer el contexto de traza aguas arriba de las cabeceras HTTP entrantes e inyectar el contexto actual en cada llamada saliente.
+
+PyFly gestiona ambos extremos sin nada de código por manejador.
+
+**Entrante — TracingFilter:**
+
+`TracingFilter` se cablea en la cadena de filtros de Lumen inmediatamente después de `CorrelationFilter` por `create_app()`. Para cada petición lee la cabecera W3C `traceparent`, abre un span SERVER como hijo del contexto aguas arriba y mantiene ese span activo durante toda la vida de la petición. Cada `@span` creado durante la petición —y cada línea de log— pertenece a la traza distribuida del llamante:
+
+```python
+# Simplified view of what TracingFilter does per-request:
+parent = extract_context(request.headers) # parse W3C traceparent
+with tracer.start_as_current_span(
+ f"{request.method} {request.url.path}",
+ context=parent,
+ kind=trace.SpanKind.SERVER,
+) as span:
+ response = await call_next(request)
+ span.set_attribute("http.response.status_code", response.status_code)
+```
+
+Cuando OpenTelemetry no está instalado, el filtro es un pasarela transparente.
+
+**Saliente — HttpxClientAdapter:**
+
+`HttpxClientAdapter` llama a `inject_headers()` en cada petición saliente para que los servicios aguas abajo puedan continuar la misma traza:
+
+::: listing lumen/client/inventory_client.py | Listado 15.8 — Propagación de trazas
+from pyfly.client.adapters.httpx_adapter import HttpxClientAdapter
+
+
+class InventoryClient:
+
+ def __init__(self) -> None:
+ self._http = HttpxClientAdapter(
+ base_url="http://inventory-service:8080"
+ )
+
+ async def check_stock(self, sku: str) -> dict:
+ # The active traceparent is injected automatically into
+ # the outbound request headers — no manual plumbing required.
+ resp = await self._http.request("GET", f"/skus/{sku}")
+ return resp.json()
+:::
+
+**Los logs llevan trace_id y span_id:**
+
+`StructlogAdapter` registra un procesador que estampa los IDs del span activo en cada registro de log. No hace falta ningún cambio de código: cualquier llamada `get_logger(…)` dentro de un span activo obtiene los campos `trace_id` y `span_id` automáticamente:
+
+```json
+{
+ "event": "deposit_completed",
+ "wallet_id": "wlt-001",
+ "new_balance": 1350,
+ "trace_id": "1a4b3145ed8f2dd11172ee3584123f4a",
+ "span_id": "d2a62aaa81b0ad66",
+ "timestamp": "2026-06-07T10:30:00Z",
+ "level": "info",
+ "logger": "lumen.wallet"
+}
+```
+
+Con `trace_id` en cada registro de log puedes saltar desde una búsqueda en Grafana Loki por `wallet_id=wlt-001` directamente a la vista de traza correlacionada en Tempo, y desde ahí a los gráficos de latencia de Prometheus de esa ventana temporal: los tres pilares unidos por un único identificador.
+
+Los ayudantes de propagación de bajo nivel están disponibles por si alguna vez los necesitas directamente:
+
+```python
+from pyfly.observability.propagation import (
+ extract_context, # parse traceparent from inbound headers
+ inject_headers, # write traceparent into outbound headers
+ current_trace_ids, # -> (trace_id, span_id) hex, or None
+ has_otel, # True if opentelemetry is importable
+)
+```
+
+---
+
+## Comprobaciones de salud y el Actuator
+
+::: figure art/figures/15-observability.svg | Figura 15.1 — El Actuator expone la salud, los beans, los loggers y las métricas de Prometheus por HTTP. Las sondas de liveness y readiness de Kubernetes acceden a las subrutas dedicadas.
+
+El **Actuator** le da a Kubernetes y a tu instrumental de operaciones un contrato estable: un conjunto de endpoints de gestión que exponen la salud, el cableado de beans, el estado del entorno y los recolectores de métricas. Lo configuras una vez y cada herramienta, desde `kubectl` hasta Grafana, puede consumirlo sin código a medida.
+
+### Habilitar el Actuator
+
+El Actuator está **activado por defecto** cuando hay presente un contexto de PyFly:
+no tienes que habilitarlo en absoluto para obtener `/actuator/health` y
+`/actuator/info`. Solo tocas el flag para *desactivarlo* o para ser explícito. Pasa
+`actuator_enabled=True` a `create_app()`, o establece el flag en `pyfly.yaml`:
+
+```yaml
+pyfly:
+ management:
+ enabled: true # default; the actuator is on unless you set false
+ app:
+ name: lumen
+ version: 1.0.0
+ description: Lumen wallet service
+```
+
+!!! note "La clave de configuración ha cambiado"
+ El flag de habilitación ahora es `pyfly.management.enabled`. El antiguo
+ `pyfly.web.actuator.enabled` sigue funcionando como alias heredado, pero el
+ código nuevo debería usar el espacio de nombres `pyfly.management.*`, que es
+ donde también viven los ajustes de puerto, seguridad y exposición de
+ endpoints.
+
+Cuando está habilitado, `create_app()` escanea automáticamente el contenedor de DI en busca de beans `HealthIndicator`, crea un `HealthAggregator`, instancia todos los endpoints integrados y los monta en `/actuator/*`.
+
+**Ejecútalo — tu primera comprobación de salud.** Lumen ya habilita el actuator,
+así que basta con arrancar la aplicación y hacer curl al endpoint de salud en el
+**puerto de gestión**:
+
+```bash
+uv run pyfly run --server uvicorn
+curl -s localhost:9090/actuator/health
+```
+
+Una aplicación sana devuelve HTTP 200 con:
+
+```json
+{"status":"UP"}
+```
+
+Si obtienes "connection refused", confirma que usaste `9090` (gestión) y no `8080`
+(negocio). El mismo `/actuator/health` en `8080` devuelve 404: el puerto de negocio
+deliberadamente no transporta endpoints de gestión.
+
+### El puerto de gestión
+
+Por defecto, estos endpoints `/actuator/*` —y el panel `/admin` de la siguiente
+sección— se sirven en un **puerto de gestión separado** (`9090`), no en el puerto
+de aplicación (`8080`). Este es el modelo `management.server.port` de Spring Boot:
+mantener las comprobaciones de salud, la recolección de Prometheus y la consola de
+administración fuera del puerto público, exponiendo solo la API de negocio a
+internet mientras el instrumental de operaciones accede a `9090` desde dentro del
+clúster.
+
+El puerto de gestión es un segundo listener en el mismo proceso (no workers
+adicionales), por lo que no añade complejidad de despliegue. Configúralo mediante
+`pyfly.management.server.port` (env `PYFLY_MANAGEMENT_SERVER_PORT`): ponlo **igual**
+a `pyfly.server.port` para servirlo todo en un solo puerto, o a **`-1`** para
+deshabilitar los endpoints web de gestión. Por tanto, un despliegue de Kubernetes
+apunta las sondas de liveness/readiness y el `ServiceMonitor` de Prometheus al
+puerto `9090`, y el `Service`/`Ingress` para el tráfico de usuario al `8080`.
+
+!!! warning "El puerto de gestión está ABIERTO por defecto"
+ A partir de v26.6.110 el puerto de gestión está **sin autenticar por
+ defecto**: las rutas `/actuator/*` y `/admin` responden a cualquier llamante
+ que pueda alcanzar `9090`. Esto es intencionado (el modelo de Spring Boot): el
+ puerto está pensado para ser accesible solo desde dentro de tu clúster, tras
+ aislamiento de red, nunca expuesto en la internet pública. Si no puedes
+ garantizar ese aislamiento, activa también los filtros de seguridad de la
+ aplicación para el puerto de gestión:
+
+ ```yaml
+ pyfly:
+ management:
+ security:
+ enabled: true
+ ```
+
+ Con ese flag activado, la misma autenticación, guardas de rol y reglas CSRF
+ que protegen tu API de negocio también blindan el puerto de gestión.
+
+Por defecto el actuator expone en web solo **`health` e `info`**, de nuevo en
+consonancia con Spring Boot, que mantiene los endpoints potencialmente sensibles
+(`beans`, `env`, `threaddump`, `prometheus`) fuera de la red hasta que des tu
+consentimiento. Amplía el conjunto con
+`pyfly.management.endpoints.web.exposure.include`:
+
+```yaml
+pyfly:
+ management:
+ endpoints:
+ web:
+ exposure:
+ include: "health,info,metrics,prometheus,loggers" # or "*" for all
+```
+
+Así pues, los pasos de recolección de Prometheus y de loggers en tiempo de
+ejecución, más adelante en este capítulo, dan por hecho que has añadido
+`prometheus` y `loggers` a esta lista. El atajo `*` expone todo y es cómodo en
+desarrollo.
+
+### Endpoints integrados
+
+| Endpoint | Método | Descripción |
+|---|---|---|
+| `/actuator` | GET | Índice estilo HAL de todos los endpoints habilitados |
+| `/actuator/health` | GET | Estado agregado: `UP` (200) o `DOWN` (503) |
+| `/actuator/health/liveness` | GET | Subruta de la sonda de liveness de Kubernetes |
+| `/actuator/health/readiness` | GET | Subruta de la sonda de readiness de Kubernetes |
+| `/actuator/beans` | GET | Todos los beans de DI registrados con estereotipo y ámbito |
+| `/actuator/env` | GET | Perfiles de configuración activos |
+| `/actuator/info` | GET | Nombre, versión y descripción de la aplicación |
+| `/actuator/loggers` | GET, POST | Lista loggers; cambia niveles en tiempo de ejecución |
+| `/actuator/metrics` | GET | Nombres de métrica en JSON compatible con Micrometer |
+| `/actuator/prometheus` | GET | Destino de recolección en formato de exposición de texto de Prometheus |
+| `/actuator/threaddump` | GET | Instantánea de todos los hilos vivos y sus trazas de pila |
+| `/actuator/refresh` | POST | Desaloja los beans de ámbito refresh; revincula la configuración |
+
+### HealthIndicator personalizado
+
+Cualquier bean `@component` con un método `async def health(self) -> HealthStatus` se descubre y registra automáticamente como indicador de salud. El `WalletRepository` de Lumen es un buen candidato: `count()` emite un ligero `SELECT COUNT(*)` contra la sesión de base de datos en vivo sin mutar ningún dato:
+
+::: listing lumen/health/indicators.py | Listado 15.9 — Beans HealthIndicator
+from pyfly.actuator import HealthStatus
+from pyfly.container import component
+
+from lumen.models.repositories.wallet_repository import WalletRepository
+
+
+@component
+class WalletRepositoryHealthIndicator:
+ """Checks the wallet store is reachable with a lightweight probe."""
+
+ def __init__(self, repository: WalletRepository) -> None:
+ self._repository = repository
+
+ async def health(self) -> HealthStatus:
+ try:
+ # count() issues SELECT COUNT(*) — fast, read-only probe.
+ total = await self._repository.count()
+ return HealthStatus(
+ status="UP",
+ details={"store": "wallet-repository", "rows": total},
+ )
+ except Exception as exc:
+ return HealthStatus(
+ status="DOWN",
+ details={"error": str(exc)},
+ )
+
+
+@component
+class DatabaseHealthIndicator:
+ """Checks database connectivity via a lightweight SELECT 1."""
+
+ def __init__(self, session_factory) -> None:
+ self._factory = session_factory
+
+ async def health(self) -> HealthStatus:
+ try:
+ async with self._factory() as session:
+ await session.execute("SELECT 1")
+ return HealthStatus(
+ status="UP",
+ details={"type": "postgresql", "pool_active": 3},
+ )
+ except Exception as exc:
+ return HealthStatus(
+ status="DOWN",
+ details={"error": str(exc)},
+ )
+:::
+
+`HealthStatus.status` admite cuatro valores: `"UP"`, `"DOWN"`, `"OUT_OF_SERVICE"` o `"UNKNOWN"`. El agregador aplica un orden de severidad (`DOWN > OUT_OF_SERVICE > UP > UNKNOWN`) y devuelve el estado del peor caso entre todos los indicadores. Si el método `health()` de algún indicador lanza, ese indicador se trata como `"DOWN"` con `details={"error": "check failed"}`; la excepción se registra pero no hace caer el endpoint de salud.
+
+**Ejecútalo — observa cómo aflora un indicador personalizado.** Coloca el listado
+anterior en `src/lumen/health/indicators.py`, reinicia Lumen y pide el informe de
+salud detallado (la forma `?show-details`, o simplemente recolectándolo del puerto
+de gestión):
+
+```bash
+curl -s localhost:9090/actuator/health
+```
+
+Una respuesta sana ahora incluye tu componente por su nombre:
+
+```json
+{
+ "status": "UP",
+ "components": {
+ "WalletRepositoryHealthIndicator": {
+ "status": "UP",
+ "details": {"store": "wallet-repository", "rows": 42}
+ },
+ "DatabaseHealthIndicator": {
+ "status": "UP",
+ "details": {"type": "postgresql", "pool_active": 3}
+ }
+ }
+}
+```
+
+No escribiste código de registro: el estereotipo `@component` más el método
+`async def health()` constituyen todo el contrato. Al arrancar, el actuator escaneó
+el contenedor, encontró todo lo que parecía un indicador de salud y lo integró en
+el agregador.
+
+!!! note "Construir sondas a mano — `app.state.pyfly_health_aggregator`"
+ Novedad en v26.6.110: el `HealthAggregator` en vivo es accesible en
+ `app.state.pyfly_health_aggregator` en el objeto de aplicación de Starlette.
+ Es el *mismo* agregador que usa la ruta `/actuator/health`, tanto si el
+ actuator corre en la aplicación principal como en el puerto de gestión
+ separado. Si alguna vez necesitas una puerta de readiness a medida —por
+ ejemplo, una ruta ASGI personalizada que devuelva 503 hasta que termine un
+ calentamiento único—, puedes leer este agregador directamente o registrar
+ indicadores adicionales en él después de `create_app()`, en lugar de pasar por
+ HTTP. Solo lo expone el adaptador de Starlette.
+
+### Cambiar niveles de log en tiempo de ejecución
+
+El endpoint de loggers te permite inspeccionar y cambiar los niveles de log sin reiniciar Lumen, algo inestimable cuando un incidente de producción necesita salida DEBUG de exactamente un paquete. Primero añade `loggers` a la lista de exposición (`pyfly.management.endpoints.web.exposure.include: "health,info,loggers"`) y luego manéjalo desde el **puerto de gestión** `9090`:
+
+```bash
+# List all loggers with configured and effective levels
+curl http://localhost:9090/actuator/loggers
+
+# Enable DEBUG for the wallet module — takes effect immediately
+curl -X POST http://localhost:9090/actuator/loggers/lumen.wallet \
+ -H "Content-Type: application/json" \
+ -d '{"configuredLevel": "DEBUG"}'
+
+# Reset to inherit from parent
+curl -X POST http://localhost:9090/actuator/loggers/lumen.wallet \
+ -H "Content-Type: application/json" \
+ -d '{"configuredLevel": null}'
+```
+
+**Qué acaba de pasar.** Cambiaste el nivel de un logger en un proceso *en marcha*.
+El POST surtió efecto de inmediato: sin reinicio, sin redespliegue. En un incidente
+real cambiarías exactamente un paquete a DEBUG, capturarías la salida ruidosa que
+necesitas y luego harías POST de `null` para devolverlo a como estaba, todo sin
+perturbar el resto del servicio.
+
+El endpoint usa el vocabulario de niveles de Spring Boot (`OFF`, `ERROR`, `WARN`, `INFO`, `DEBUG`, `TRACE`) y es compatible sin cambios con el instrumental de Spring Boot Actuator.
+
+### Endpoint de actuator personalizado
+
+Para exponer un endpoint personalizado, implementa el protocolo `ActuatorEndpoint` y anota la clase con `@component`. PyFly lo descubre durante el arranque del contexto y lo monta en `/actuator/{endpoint_id}` automáticamente:
+
+::: listing lumen/actuator/git_info.py | Listado 15.10 — Endpoint de actuator personalizado
+from pyfly.container import component
+
+
+@component
+class GitInfoEndpoint:
+ """Exposes build metadata at /actuator/git."""
+
+ @property
+ def endpoint_id(self) -> str:
+ return "git"
+
+ @property
+ def enabled(self) -> bool:
+ return True
+
+ async def handle(self, context=None) -> dict:
+ return {
+ "branch": "main",
+ "commit": {
+ "id": "5c6f83b",
+ "time": "2026-06-07T08:30:00Z",
+ },
+ "build": {
+ "version": "1.0.0",
+ },
+ }
+:::
+
+### Configuración de las sondas de Kubernetes
+
+Apunta la especificación de tu pod a las subrutas dedicadas de liveness y readiness para que Kubernetes pueda tomar decisiones independientes de reinicio y de tráfico. Como el actuator vive en el puerto de gestión, las sondas apuntan a **`9090`**, mientras que tu `Service` enruta el tráfico de usuario a `8080`:
+
+```yaml
+livenessProbe:
+ httpGet:
+ path: /actuator/health/liveness
+ port: 9090
+ initialDelaySeconds: 10
+ periodSeconds: 30
+readinessProbe:
+ httpGet:
+ path: /actuator/health/readiness
+ port: 9090
+ initialDelaySeconds: 5
+ periodSeconds: 10
+```
+
+Las subrutas separadas te permiten agrupar indicadores de forma independiente: una migración en curso que degrade la readiness no tiene por qué disparar un reinicio de liveness ni la recreación del contenedor.
+
+!!! spring "Equivalencia con Spring"
+ El Actuator de PyFly refleja el de Spring Boot. `HealthIndicator`,
+ `HealthStatus`, `HealthAggregator`, `ActuatorEndpoint` y `ActuatorRegistry`
+ corresponden directamente a sus contrapartes de Spring. El endpoint de loggers
+ usa el mismo vocabulario de niveles de Spring Boot y la misma forma de
+ respuesta `configuredLevel`/`effectiveLevel`, lo que lo hace compatible con
+ Spring Boot Admin y el instrumental compatible con Actuator de fábrica.
+ `MetricsAutoConfiguration` y `MetricsActuatorAutoConfiguration` reflejan la
+ autoconfiguración de Micrometer de Spring Boot: cuando `prometheus_client`
+ está instalado, `/actuator/prometheus` aparece sin ningún cableado manual.
+
+---
+
+## El panel de administración
+
+El **panel de administración** es una interfaz de navegador sin build y sin dependencias servida directamente desde el paquete `pyfly.admin`. Una sola línea de configuración la habilita; navega a `/admin`: sin servidor aparte, sin paso de build de `npm`.
+
+### Habilitar el panel
+
+```yaml
+pyfly:
+ admin:
+ enabled: true
+ title: "Lumen Admin"
+ theme: auto # auto | light | dark
+ refresh_interval: 5000
+```
+
+El panel autodescubre beans, indicadores de salud, loggers, tareas programadas, mapeos HTTP, cachés, manejadores CQRS, sagas y métricas del `ApplicationContext` en ejecución. Los presenta en **15 vistas integradas** con actualizaciones en tiempo real mediante Server-Sent Events (SSE): sin WebSocket, sin bucle de sondeo en tu código.
+
+!!! note "Dónde encontrarlo: el puerto de gestión"
+ Como el actuator, el panel se sirve en el **puerto de gestión**. Con los
+ valores por defecto de Lumen eso es `http://localhost:9090/admin`, no
+ `8080/admin`. Pon `pyfly.management.server.port` igual a `pyfly.server.port`
+ si prefieres servir el panel en el mismo puerto que tu API. Y recuerda: ese
+ puerto está abierto por defecto; blíndalo con
+ `pyfly.management.security.enabled` o con el propio `require_auth` del panel
+ (mostrado en "Seguridad") antes de exponerlo en cualquier lugar no fiable.
+
+**Ejecútalo — abre el panel.** Habilítalo en `pyfly.yaml`, arranca Lumen y abre la
+URL en un navegador:
+
+```yaml
+pyfly:
+ admin:
+ enabled: true
+ title: "Lumen Admin"
+```
+
+```bash
+uv run pyfly run --server uvicorn
+# then visit http://localhost:9090/admin
+```
+
+Deberías ver cómo se rellena la vista Overview en un segundo o dos: nombre de la
+aplicación y tiempo de actividad, una insignia de salud verde y los recuentos de
+beans agrupados por estereotipo. Lanza unos cuantos depósitos a través de
+`localhost:8080` y observa cómo los paneles de Salud y Métricas se actualizan en
+vivo: la página nunca se recarga, porque los datos llegan por SSE.
+
+!!! note "Jerga: SSE (Server-Sent Events)"
+ SSE es un canal de streaming unidireccional: el navegador abre una única
+ conexión HTTP de larga duración y el servidor *empuja* eventos por ella a
+ medida que ocurren. Es más simple que un WebSocket (que es bidireccional) y es
+ justo la herramienta adecuada para un panel que solo necesita *recibir*
+ actualizaciones. No escribes ningún bucle de sondeo; el framework gestiona el
+ flujo.
+
+### Vistas integradas
+
+**Sección Dashboard:**
+
+| Vista | Descripción |
+|---|---|
+| Overview | Información de la aplicación, tiempo de actividad, insignia de salud, recuentos de beans por estereotipo |
+| Health | Estado de los componentes con insignias UP / DOWN / UNKNOWN codificadas por color; SSE en vivo |
+
+**Sección Application:**
+
+| Vista | Descripción |
+|---|---|
+| Beans | Todos los beans de DI con estereotipo, ámbito y grafo de dependencias |
+| Environment | Perfiles activos y variables de entorno enmascaradas |
+| Configuration | Árbol de configuración resuelta para todos los espacios de nombres con seguimiento de origen |
+| Loggers | Niveles de logger con interfaz de cambio de nivel en tiempo de ejecución; se admiten TRACE y OFF |
+
+**Sección Monitoring:**
+
+| Vista | Descripción |
+|---|---|
+| Metrics | CPU, memoria, hilos, GC, tiempo de actividad; métricas de Prometheus opcionales; gráfico de tendencia en vivo |
+| Scheduled Tasks | Todas las tareas `@scheduled` con expresiones cron y estado |
+| HTTP Traces | Trazas de petición/respuesta con latencia p50/p90/p95/p99 y barra de tasa de error |
+| Log Viewer | Cola en vivo con filtros de nivel, búsqueda y pausa/reanudación |
+
+**Sección Infrastructure:**
+
+| Vista | Descripción |
+|---|---|
+| Mappings | Todas las rutas HTTP con manejador, parámetros, tipo de retorno y docstring |
+| Caches | Tipo de adaptador, recuento de entradas, desalojo por clave, desalojo masivo |
+| CQRS | Manejadores de comandos y consultas con introspección del pipeline del bus |
+| Transactions | DAG de pasos de saga y cobertura de participantes TCC; recuento en curso |
+
+**Sección Fleet (modo servidor):**
+
+| Vista | Descripción |
+|---|---|
+| Instances | Todas las instancias de aplicación remotas registradas con su estado de salud |
+
+### Flujos SSE en tiempo real
+
+El panel nunca sondea el backend con `setInterval`. Abre una única conexión `EventSource` y el servidor empuja los eventos a medida que ocurren:
+
+| Endpoint SSE | Nombre de evento | Qué transmite |
+|---|---|---|
+| `/admin/api/sse/health` | `health` | Cambio de estado cada vez que cambia la salud agregada |
+| `/admin/api/sse/metrics` | `metrics` | Lista completa de nombres de métrica en cada intervalo de refresco |
+| `/admin/api/sse/traces` | `trace` | Trazas HTTP individuales a medida que llegan |
+| `/admin/api/sse/logfile` | `log` | Nuevos registros de log del buffer circular en memoria |
+| `/admin/api/sse/beans` | `beans` | Instantánea del registro de beans en cada intervalo de refresco |
+
+El buffer circular del visor de logs guarda 2.000 registros; el buffer circular de las trazas HTTP guarda 500. Las rutas de admin y actuator (`/admin/*`, `/actuator/*`) quedan excluidas de la captura de trazas automáticamente, para que no contaminen el panel de trazas.
+
+### Gestión de loggers en tiempo de ejecución
+
+La vista Loggers usa el mismo endpoint `/admin/api/loggers/{name}` que el actuator. Haz clic en una fila de logger para abrir un selector de nivel en línea y envía: el nuevo nivel surte efecto de inmediato, y la interfaz vuelve a obtener los datos para confirmar el cambio. El botón Reset envía `null` para devolver el logger a `NOTSET` (heredar del padre).
+
+### Extensión de vista personalizada
+
+Para añadir tu propia vista de barra lateral, implementa `AdminViewExtension` y anota con `@component`. El panel la descubre al arrancar:
+
+::: listing lumen/admin/deployment_view.py | Listado 15.11 — Vista de administración personalizada
+from pyfly.container import component
+
+
+@component
+class DeploymentView:
+ """Shows deployment metadata in the admin sidebar."""
+
+ @property
+ def view_id(self) -> str:
+ return "deployments"
+
+ @property
+ def display_name(self) -> str:
+ return "Deployments"
+
+ @property
+ def icon(self) -> str:
+ return "upload-cloud"
+
+ async def get_data(self, context=None) -> dict:
+ return {
+ "last_deploy": "2026-06-07T08:00:00Z",
+ "version": "1.0.0",
+ "environment": "production",
+ }
+:::
+
+`view_id` define el fragmento de URL de la barra lateral (`#deployments`), `display_name` aparece en el menú de la barra lateral e `icon` se mapea a un icono de Feather. `get_data()` es llamado por `GET /admin/api/views` y puede consultar el contenedor de DI, una base de datos o cualquier fuente externa.
+
+### Seguridad
+
+Restringe el acceso al panel a los operadores en producción:
+
+```yaml
+pyfly:
+ admin:
+ enabled: true
+ require_auth: true
+ allowed_roles:
+ - ADMIN
+ - OPS
+```
+
+Cuando `require_auth: true`, cada ruta `/admin/api/*` —datos, mutación, SSE y endpoints del registro de instancias— requiere un principal autenticado cuyos roles se solapen con `allowed_roles`. Las peticiones no autenticadas reciben `401`; los usuarios autenticados que carezcan de todos los roles listados reciben `403`. El shell SPA estático sigue siendo público para que el panel pueda arrancar y mostrar el mensaje de error.
+
+### Monitorización de flota — modo servidor
+
+Para una flota de instancias de Lumen, ejecuta un servidor de administración dedicado y apunta cada instancia de aplicación hacia él:
+
+```yaml
+# Admin server instance
+pyfly:
+ admin:
+ enabled: true
+ server:
+ enabled: true
+ poll_interval: 10000
+ instances:
+ - name: lumen-1
+ url: http://lumen-1:8080
+ - name: lumen-2
+ url: http://lumen-2:8080
+```
+
+```yaml
+# Each application instance
+pyfly:
+ admin:
+ enabled: true
+ client:
+ url: http://admin-server:8080
+ auto_register: true
+```
+
+`StaticDiscovery` siembra el registro a partir de la lista YAML. `AdminClientRegistration` registra la instancia al arrancar y la elimina al apagar. Las llamadas HTTP usan `httpx` cuando está disponible y recurren a `urllib.request`; los errores de registro se tragan en silencio, de modo que un servidor de administración inalcanzable nunca aborta el arranque de la aplicación.
+
+!!! spring "Equivalencia con Spring"
+ PyFly Admin se mapea directamente a Spring Boot Admin. `server.enabled: true`
+ sustituye a `@EnableAdminServer`. `client.url` sustituye a
+ `spring.boot.admin.client.url`. El frontend de Vaadin/React se sustituye por
+ una SPA en JavaScript puro que no requiere ningún instrumental de build. Los
+ flujos SSE sustituyen a las notificaciones por WebSocket de Spring Boot Admin.
+ El Log Viewer integrado sustituye al visor de logfile de Spring Boot Admin
+ respaldado por `/actuator/logfile`; el enfoque de buffer circular de PyFly
+ evita la configuración de ruta de fichero que Spring Boot Admin requiere.
+
+---
+
+## AOP para preocupaciones transversales
+
+### ¿Qué es la AOP?
+
+La **programación orientada a aspectos** separa las preocupaciones transversales —logging, métricas, seguridad, auditoría— de la lógica de negocio. Sin AOP, cada método de servicio empieza con `logger.info(...)` y termina con `metrics.increment(...)`. Con AOP, escribes esa lógica una sola vez en una clase `@aspect` y la aplicas a cada método coincidente mediante una expresión de pointcut: los propios métodos quedan limpios.
+
+El módulo de AOP de PyFly trae cinco tipos de advice:
+
+| Advice | Decorador | Se ejecuta |
+|---|---|---|
+| Before | `@before` | Antes del método destino |
+| After returning | `@after_returning` | Después de que el método tenga éxito |
+| After throwing | `@after_throwing` | Después de que el método lance |
+| After (finally) | `@after` | Siempre, con éxito o fallo |
+| Around | `@around` | Envuelve toda la llamada; debe llamar a `jp.proceed()` |
+
+### @aspect — declarar un aspecto
+
+!!! note "Jerga: aspecto, advice, pointcut, weaving"
+ Cuatro palabras viajan juntas en AOP. Un **aspecto** es la clase que agrupa
+ una preocupación transversal. El **advice** es una pieza concreta de
+ comportamiento dentro de él (el cuerpo de `@before`, `@around`, etc.). Un
+ **pointcut** es la cadena de patrón —como `"service.*.*"`— que decide *a qué*
+ métodos se aplica el advice. El **weaving** (tejido) es el acto de coser el
+ advice en esos métodos al arrancar. Tú escribes los aspectos; PyFly hace el
+ weaving.
+
+**`@aspect`** marca una clase como aspecto de PyFly. La clase se registra automáticamente en el contenedor de DI como singleton y recibe las dependencias inyectadas vía `__init__`. No se requiere ninguna clase base explícita.
+
+Construye el aspecto de logging en tres movimientos.
+
+**Paso 1 — Declara la clase.** Crea `src/lumen/aspects/logging_aspect.py`, marca la clase con `@aspect` y dale un `logger` a nivel de módulo.
+
+**Paso 2 — Añade métodos de advice.** Cada método se decora con un tipo de advice (`@before`, `@after_returning`, `@after_throwing`) y una cadena de pointcut. El argumento `jp: JoinPoint` transporta los detalles de la llamada interceptada.
+
+**Paso 3 — Establece el orden.** `@order(-50)` hace que este aspecto se ejecute antes que los de número más alto, útil cuando quieres que el logging enmarque el aspecto de métricas que escribes a continuación.
+
+::: listing lumen/aspects/logging_aspect.py | Listado 15.12 — Un aspecto de logging
+from pyfly.aop import aspect, before, after_returning, after_throwing, JoinPoint
+from pyfly.container.ordering import order
+from pyfly.logging import get_logger
+
+logger = get_logger("lumen.audit")
+
+
+@aspect
+@order(-50)
+class AuditLoggingAspect:
+ """Logs entry, exit, and failure for every service method."""
+
+ @before("service.*.*")
+ def log_entry(self, jp: JoinPoint) -> None:
+ logger.info(
+ "method_called",
+ cls=type(jp.target).__name__,
+ method=jp.method_name,
+ )
+
+ @after_returning("service.*.*")
+ def log_return(self, jp: JoinPoint) -> None:
+ logger.info(
+ "method_returned",
+ cls=type(jp.target).__name__,
+ method=jp.method_name,
+ )
+
+ @after_throwing("service.*.*")
+ def log_error(self, jp: JoinPoint) -> None:
+ logger.error(
+ "method_raised",
+ cls=type(jp.target).__name__,
+ method=jp.method_name,
+ exc=type(jp.exception).__name__,
+ )
+:::
+
+El pointcut `"service.*.*"` coincide con cada método público de cada bean con estereotipo `@service`. `*` coincide con exactamente un segmento separado por puntos; `**` coincide con uno o más. Se admiten globs parciales dentro de un segmento: `"service.*.do_handle"` coincide con todos los métodos `do_handle` de todos los manejadores con estereotipo de servicio.
+
+Los nombres cualificados siguen el patrón `"{stereotype}.{ClassName}.{method_name}"`, de modo que `service.DepositFundsHandler.do_handle` identifica de forma unívoca el método `do_handle` de `DepositFundsHandler`.
+
+!!! tip "Los handlers `@before` deben ser síncronos"
+ Los handlers `@before`, `@after_returning`, `@after_throwing` y `@after`
+ siempre son llamados de forma síncrona por el weaver. Solo los handlers
+ `@around` pueden ser asíncronos (y deben hacer `await jp.proceed()` cuando
+ aconsejan un método asíncrono).
+
+### @around — métricas sin decoradores
+
+**`@around`** es el tipo de advice más potente. Envuelve toda la ejecución del método; llama a `await jp.proceed()` para invocar el método original (o el siguiente advice de la cadena) y añade comportamiento a ambos lados:
+
+::: listing lumen/aspects/metrics_aspect.py | Listado 15.13 — Aspecto de métricas con @around
+import time
+
+from pyfly.aop import JoinPoint, around, aspect
+from pyfly.container.ordering import order
+from pyfly.observability import MetricsRegistry
+
+registry = MetricsRegistry()
+
+
+@aspect
+@order(50)
+class MetricsAspect:
+ """Records duration and call counts for every service method."""
+
+ @around("service.*.*")
+ async def record_metrics(self, jp: JoinPoint):
+ start = time.perf_counter()
+ exc_name = "none"
+ try:
+ result = await jp.proceed()
+ return result
+ except Exception as exc:
+ exc_name = type(exc).__name__
+ raise
+ finally:
+ elapsed = time.perf_counter() - start
+ histogram = registry.histogram(
+ f"service.{jp.method_name}.duration",
+ f"Duration of {jp.method_name}",
+ labels=["exception"],
+ )
+ histogram.labels(exception=exc_name).observe(elapsed)
+:::
+
+`@order(-50)` en `AuditLoggingAspect` y `@order(50)` en `MetricsAspect` garantizan que el aspecto de logging se dispare primero en la cadena de advice. `HIGHEST_PRECEDENCE = -(2^31)` se ejecuta el primero; `LOWEST_PRECEDENCE = 2^31 - 1` se ejecuta el último.
+
+### Weaving automático — AspectBeanPostProcessor
+
+En producción nunca llamas a `weave_bean()` manualmente. `AopAutoConfiguration` registra **`AspectBeanPostProcessor`** incondicionalmente. Durante el arranque del contexto, el post-procesador:
+
+1. Recopila en un `AspectRegistry` cada bean cuya clase tiene `__pyfly_aspect__ = True`.
+2. Para cada bean que no sea aspecto, comprueba si algún pointcut registrado coincide con algún método público.
+3. Envuelve los métodos coincidentes en su sitio con la cadena de advice completa mediante `weave_bean()`.
+
+El resultado es AOP de configuración cero: define aspectos, define servicios, arranca la aplicación, y el weaver los cablea entre sí.
+
+**Qué acaba de pasar.** Escribiste dos aspectos —uno para logging, otro para
+métricas— y no editaste ni un solo manejador. Al arrancar, `AspectBeanPostProcessor`
+hizo coincidir sus pointcuts contra tus beans de servicio y tejió el advice en los
+métodos coincidentes en su sitio. A partir de ahora, cada método `@service` emite
+logs de entrada/salida y un histograma de duración de forma automática. Añade un
+manejador nuevo mañana y heredará la misma observabilidad en el momento en que su
+pointcut coincida: nada que recordar, nada que copiar y pegar. Esa es la
+recompensa de la AOP: el comportamiento transversal vive en un solo lugar, y el
+código de negocio queda limpio.
+
+### Referencia de JoinPoint
+
+Cada handler de advice recibe una dataclass `JoinPoint`:
+
+| Atributo | Disponible en | Descripción |
+|---|---|---|
+| `target` | Todos | La instancia del bean interceptada |
+| `method_name` | Todos | Nombre del método interceptado |
+| `args` | Todos | Argumentos posicionales pasados al método |
+| `kwargs` | Todos | Argumentos con nombre pasados al método |
+| `return_value` | `@after_returning`, `@after` | Valor de retorno (tras el éxito) |
+| `exception` | `@after_throwing`, `@after` | La excepción lanzada (o `None`) |
+| `proceed` | Solo `@around` | Invocable; con `await` para métodos asíncronos |
+
+### Juntándolo todo — observabilidad completa en DepositFundsHandler
+
+El manejador de depósitos de Lumen con los tres pilares de observabilidad aplicados; cero código de observabilidad dentro de la lógica de negocio:
+
+::: listing lumen/core/services/wallets/deposit_funds_handler.py | Listado 15.14 — DepositFundsHandler con observabilidad completa
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+from pyfly.container import service
+from pyfly.cqrs import CommandHandler, command_handler
+from pyfly.data.relational.sqlalchemy import transactional
+from pyfly.domain import AggregateNotFound
+from pyfly.eda import EventPublisher
+from pyfly.logging import get_logger
+from pyfly.observability import MetricsRegistry, counted, span, timed
+
+from lumen.core.mappers.wallet_mapper import to_aggregate, to_entity
+from lumen.core.services.wallets.deposit_funds_command import DepositFunds
+from lumen.core.services.wallets.event_publishing import publish_domain_events
+from lumen.models.entities.v1.money import Money
+from lumen.models.repositories.wallet_repository import WalletRepository
+
+logger = get_logger("lumen.wallet")
+registry = MetricsRegistry()
+
+
+@command_handler
+@service
+class DepositFundsHandler(CommandHandler[DepositFunds, int]):
+ """
+ Credits funds to an existing wallet and returns the new balance
+ (in minor units, e.g. 1350 = €13.50).
+
+ Logging, metrics, and tracing are applied by decorators and aspects;
+ the business logic here stays free of cross-cutting concerns.
+ """
+
+ def __init__(
+ self,
+ repository: WalletRepository,
+ events: EventPublisher,
+ session_factory: async_sessionmaker[AsyncSession],
+ ) -> None:
+ super().__init__()
+ self._repository = repository
+ self._events = events
+ self._session_factory = session_factory
+
+ @timed(registry, "lumen.wallet.deposit.duration", "Deposit latency")
+ @counted(registry, "lumen.wallet.deposits", "Total deposit attempts")
+ @span("wallet-deposit")
+ @transactional()
+ async def do_handle(self, command: DepositFunds) -> int:
+ logger.info("deposit_started", wallet_id=command.wallet_id,
+ amount=command.amount)
+ entity = await self._repository.find_by_id(command.wallet_id)
+ if entity is None:
+ raise AggregateNotFound("Wallet", command.wallet_id)
+ wallet = to_aggregate(entity)
+ wallet.deposit(Money(amount=command.amount, currency=wallet.currency))
+ await self._repository.upsert(to_entity(wallet))
+ await publish_domain_events(self._events, wallet.clear_events())
+ logger.info("deposit_completed", wallet_id=command.wallet_id,
+ new_balance=wallet.balance.amount)
+ return wallet.balance.amount
+:::
+
+**Cómo funciona.** `@span` abre un span de OpenTelemetry. `@timed` registra la duración de `do_handle`. `@counted` incrementa el contador de llamadas. `AuditLoggingAspect` dispara `@before` y `@after_returning` en cada método de servicio. `MetricsAspect` añade su propio histograma `@around`. La redacción de PII elimina automáticamente los valores sensibles de la salida de log. El actuator expone `/actuator/health`, `/actuator/prometheus` y `/actuator/loggers`. El panel de administración muestra trazas y registros de log en vivo.
+
+`command.amount` es un `int` en unidades menores, impuesto por el validador del comando `DepositFunds` (`amount > 0`). El objeto de valor `Money` lo envuelve con la `Currency` del monedero, evitando la aritmética entre divisas distintas en la frontera del dominio.
+
+Siete líneas de decoradores y una llamada a `get_logger`, y la ruta de depósito de Lumen es totalmente observable.
+
+**Ejecútalo — confirma que nada se rompió.** Los decoradores de observabilidad
+envuelven comportamiento alrededor de tus manejadores; no deben cambiar el
+resultado que esos manejadores devuelven. Ejecuta la suite existente de Lumen para
+demostrar que la ruta de depósito sigue comportándose igual:
+
+```bash
+uv run --extra dev pytest -q
+```
+
+Todas las pruebas deberían seguir en verde:
+
+```
+......................................... [100%]
+41 passed in 0.3s
+```
+
+Luego da una vuelta manual completa con la aplicación en marcha: lanza un depósito
+en `8080`, confirma que `/actuator/health` está `UP` en `9090`, recolecta
+`/actuator/prometheus` y comprueba que `lumen_wallet_deposits_total` se incrementa,
+y abre `http://localhost:9090/admin` para ver el mismo flujo de eventos pasar por
+los paneles en vivo de Métricas y Log Viewer. Los tres pilares —logs, métricas,
+trazas— describen ahora un único depósito, unidos por un solo `trace_id`.
+
+---
+
+## Lo que construiste {.recap}
+
+Empezaste con un servicio listo para producción pero opaco. Al final de este
+capítulo Lumen:
+
+- Emite **logs JSON estructurados** con identificadores de correlación, campos
+ vinculados al contexto asíncrono y redacción automática de PII mediante
+ expresiones regulares y, opcionalmente, NER con Presidio.
+- Exporta **métricas Prometheus** desde `MetricsRegistry`, etiquetadas con
+ histogramas de duración `@timed` y contadores de invocación `@counted`,
+ recolectadas en `/actuator/prometheus`.
+- Propaga **trazas OpenTelemetry** de extremo a extremo: `TracingFilter` abre un
+ span SERVER a partir de la cabecera `traceparent` entrante, `@span` crea spans
+ hijos para las llamadas a manejadores, `HttpxClientAdapter` inyecta el contexto
+ en las peticiones salientes y `StructlogAdapter` estampa cada registro de log
+ con `trace_id` y `span_id`.
+- Responde a las **sondas de salud de Kubernetes** en `/actuator/health/liveness`
+ y `/actuator/health/readiness` mediante beans `HealthIndicator`
+ autodescubiertos, incluido un `WalletRepositoryHealthIndicator` que sondea el
+ almacén de monederos.
+- Muestra todo lo anterior en el **panel de administración embebido** en `/admin`,
+ con 15 vistas integradas, flujos SSE en tiempo real, una cola de logs en vivo,
+ un panel de analítica de trazas HTTP y gestión de niveles de logger en tiempo de
+ ejecución.
+- Aplica logging y métricas de forma **transversal** mediante `@aspect`,
+ `@before`, `@after_returning`, `@after_throwing` y `@around`, tejidos
+ automáticamente por `AspectBeanPostProcessor` sin tocar el código de los
+ manejadores.
+
+## Pruébalo tú mismo {.exercises}
+
+1. **Auditoría de redacción de PII.** Añade una sentencia de log a
+ `DepositFundsHandler.do_handle` que incluya una dirección de correo falsa como
+ valor de campo (`customer_email="alice@example.com"`) y un campo `token` con una
+ cadena arbitraria. Ejecuta Lumen localmente con `format: console`, observa que
+ el correo se sustituye por `` y que el valor del campo token se sustituye
+ por ``. Luego cambia a `engine: presidio` (tras instalar
+ `pyfly[pii]` y `en_core_web_sm`) y compara la salida.
+
+2. **HealthIndicator personalizado.** Escribe un `StripeHealthIndicator` que llame
+ a `https://status.stripe.com/api/v2/status.json` con `httpx`, parsee
+ `indicator.status` y devuelva `UP` si el valor es `"none"` o `DOWN` en caso
+ contrario. Regístralo como `@component` y verifica que `/actuator/health`
+ incluye un componente `StripeHealthIndicator`. Pruébalo con una llamada `httpx`
+ simulada que lance `httpx.ConnectError` y verifica que el estado agregado pasa a
+ `DOWN`.
+
+3. **Aspecto de métricas con umbrales por método.** Extiende `MetricsAspect` del
+ Listado 15.13 con un `slow_threshold` configurable (0,5 s por defecto)
+ inyectado desde `pyfly.yaml` vía `@config_properties`. Cuando un método de
+ servicio supere el umbral, emite un `logger.warning("slow_method", …)` con los
+ campos `method_name` y `elapsed`. Escribe una prueba de pytest que use un
+ `FakeClock` o `unittest.mock.patch("time.perf_counter")` para simular una
+ llamada lenta y aserte que se registra la advertencia.
diff --git a/book/manuscript-es/16-testing.md b/book/manuscript-es/16-testing.md
new file mode 100644
index 00000000..48a18aa2
--- /dev/null
+++ b/book/manuscript-es/16-testing.md
@@ -0,0 +1,1428 @@
+Capítulo 16
+
+# Pruebas de aplicaciones PyFly {.chtitle}
+
+::: figure art/openers/ch16.svg |
+
+El monedero (wallet) funciona. Los depósitos entran, los saldos se actualizan, los eventos se propagan por el bus y el coordinador de la saga revierte limpiamente ante un fallo. Lo que todavía no tienes es la **confianza** de que seguirá funcionando tras la próxima refactorización. Esa confianza viene de las pruebas: pruebas que se ejecutan en milisegundos, que demuestran que el modelo de dominio hace cumplir sus invariantes, que verifican que la canalización CQRS despacha correctamente, que ejercitan las consultas derivadas y los predicados de tipo Specification del repositorio contra una base de datos SQLite real, y que arrancan el contexto de aplicación completo en una prueba de integración que demuestra la composición entera de inyección de dependencias + persistencia.
+
+PyFly trata las pruebas como un asunto de primer orden. El módulo `pyfly.testing` incluye ayudantes de más alto nivel —`PyFlyTestCase`, `create_test_container`, `assert_event_published`, el cableado de Testcontainers— a los que puedes recurrir cuando los necesites. La propia suite de pruebas de Lumen no los usa: cablea componentes reales directamente desde `conftest.py`, emplea fixtures estándar de pytest y cubre cada nivel de la pirámide sin código repetitivo. Ese es el enfoque que enseña este capítulo.
+
+::: figure art/figures/16-testing.svg | Figura 16.1 — La pirámide de pruebas de PyFly. Las pruebas unitarias rápidas forman la base ancha; las pruebas de integración ocupan el centro; las pruebas de adaptador contra una BD real y una prueba de integración con el contexto arrancado coronan la cima.
+
+La pirámide tiene cuatro niveles. Las **pruebas unitarias** están en la base —muchas de ellas, ejecutándose en milisegundos, ejercitando el modelo de dominio sin dependencias—. Las **pruebas de flujo CQRS** ocupan el siguiente escalón: el ciclo completo de apertura/depósito/retiro/consulta enrutado a través del bus real y del repositorio real, todo cableado en `conftest.py`. Las **pruebas de repositorio** ejercitan las consultas derivadas, la paginación y los predicados de tipo Specification contra SQLite. En la cúspide, una **prueba de integración con el contexto arrancado** inicia el `ApplicationContext` real —escaneo de inyección de dependencias, autoconfiguración de CQRS, `RepositoryBeanPostProcessor`, la costura `@transactional`, la arquitectura orientada a eventos (EDA)— y recorre el ciclo de vida completo.
+
+| Nivel | Dependencias | Velocidad | Enfoque de Lumen |
+|-------------------------|---------------------------------------|-----------|------------------------------------|
+| Unitario | Ninguna | Rápido | pytest puro, sin fixtures |
+| Flujo CQRS | Bus real + repositorio sobre SQLite | Rápido | fixtures de conftest.py |
+| Repositorio | SQLite + aiosqlite | Rápido | `tmp_path` + SQLAlchemy |
+| Contexto arrancado | ApplicationContext completo + SQLite | Rápido | sustitución de entorno `monkeypatch` |
+
+El proyecto usa pytest con `pytest-asyncio` en **modo automático**. Actívalo una vez en `pyproject.toml` y cada función `async def test_*` se recopila automáticamente:
+
+```ini
+[tool.pytest.ini_options]
+asyncio_mode = "auto"
+testpaths = ["tests"]
+pythonpath = ["src"]
+```
+
+Una glosa rápida antes de empezar, ya que tres términos se repiten en este capítulo. Un
+**fixture** es una pieza reutilizable de preparación de prueba: pytest la construye una vez, se la
+entrega a tu prueba y la desmonta después. Un **conftest.py** es un archivo especial que pytest
+descubre automáticamente; cualquier fixture que declares ahí pasa a estar disponible para cada
+prueba del paquete sin necesidad de importarla. Y el **modo automático de `pytest-asyncio`** simplemente
+significa que puedes escribir `async def test_...` y pytest hará el `await` por ti: no hace falta
+un decorador por prueba. Cada uno de estos vuelve a aparecer con un ejemplo concreto
+más adelante; esto es solo el mapa.
+
+Instala las dependencias de desarrollo y ejecuta la suite:
+
+```bash
+uv run --extra dev pytest -q
+```
+
+El `uv sync` a secas (sin `--extra dev`) omite el grupo de desarrollo, así que pytest no queda
+instalado. Incluye siempre `--extra dev` al ejecutar las pruebas.
+
+**Ejecútalo.** Desde el directorio `samples/lumen`, ejecuta toda la suite una vez ahora para que tengas
+una referencia de base antes de cambiar nada:
+
+```bash
+uv run --extra dev pytest -q
+```
+
+Deberías ver una hilera de puntos —uno por prueba— seguida de una línea de resumen:
+
+```text
+......................................... [100%]
+41 passed in 0.28s
+```
+
+Cuarenta y una pruebas superadas, en menos de un tercio de segundo, sin Docker ni proceso
+externo alguno. Esa velocidad es el sentido entero de la pirámide: la base rápida atrapa la mayoría
+de las regresiones antes de que lleguen a ejecutarse las capas de integración más lentas. Si en cambio ves
+`No module named pytest`, es que olvidaste `--extra dev`; vuelve a ejecutarlo con esa opción.
+
+---
+
+## Pruebas unitarias del dominio
+
+El modelo de dominio —`Money` y `Wallet`— no tiene dependencias del framework. Nunca toca una base de datos, un bus de mensajes ni un cliente HTTP. Esa pureza lo convierte en el objetivo ideal para pruebas unitarias rápidas: construye objetos, llama a métodos, comprueba resultados. Sin mocks, sin fixtures, sin `async`.
+
+### Pruebas de Money
+
+`Money` es una dataclass congelada (frozen). Cada operación o bien tiene éxito y devuelve una nueva instancia de `Money`, o bien lanza `BusinessRuleViolation`. Cada violación lleva una cadena `.rule` que nombra el invariante incumplido, útil para verificar la regla exacta en las pruebas.
+
+Una glosa rápida sobre dos términos. Un **objeto de valor** es un objeto definido por completo por sus
+valores: dos instancias de `Money(1050, EUR)` son iguales porque sus campos son iguales,
+no porque sean el mismo objeto en memoria. Una **dataclass congelada (frozen)** es la forma que tiene Python
+de hacer inmutable ese objeto: una vez construido, no puedes reasignar sus
+campos. Juntos hacen que `Money` sea seguro de pasar libremente: ningún consumidor puede
+mutarlo a tus espaldas, así que nunca necesita copia defensiva.
+
+Construyamos el archivo de pruebas un grupo de aserciones a la vez. Cada paso de abajo se corresponde con
+una función `def test_...` del listado que sigue.
+
+**Paso 1 — la igualdad es estructural.** `test_value_equality_is_structural` comprueba
+que dos valores `Money` con el mismo importe y la misma moneda son iguales, y que
+diferir en cualquiera de los dos campos los hace distintos. Este es el contrato del objeto de valor.
+
+**Paso 2 — la inmutabilidad se hace cumplir.** `test_money_is_immutable` intenta asignar a
+`money.amount` y espera una excepción. La dataclass congelada lanza
+`FrozenInstanceError`, demostrando que no puedes mutar un valor tras su construcción.
+
+**Paso 3 — la aritmética devuelve valores nuevos.** `test_add_and_subtract_same_currency`
+comprueba que `add` y `subtract` producen el `Money` esperado, sin mutar nunca los
+operandos.
+
+**Paso 4 — la superficie de conveniencia.** `test_zero_factory_and_major_units` cubre la
+fábrica `Money.zero(currency)`, la propiedad `major_units` y el formateo de
+`__str__`.
+
+**Paso 5 — los invariantes rechazan la entrada inválida.** Las dos últimas pruebas comprueban que mezclar
+monedas y pasar un importe no entero lanzan cada una `BusinessRuleViolation` con
+una cadena `.rule` específica: `"money-currency-mismatch"` y
+`"money-amount-integer"`.
+
+::: listing tests/test_money.py | Listado 16.1 — Pruebas unitarias puras del objeto de valor Money
+from __future__ import annotations
+
+import pytest
+from lumen.interfaces.enums.v1.currency import Currency
+from lumen.models.entities.v1.money import Money
+
+from pyfly.domain import BusinessRuleViolation
+
+
+def test_value_equality_is_structural() -> None:
+ assert Money(1050, Currency.EUR) == Money(1050, Currency.EUR)
+ assert Money(1050, Currency.EUR) != Money(1050, Currency.USD)
+ assert Money(1050, Currency.EUR) != Money(999, Currency.EUR)
+
+
+def test_money_is_immutable() -> None:
+ money = Money(1050, Currency.EUR)
+ with pytest.raises(Exception): # frozen dataclass -> FrozenInstanceError
+ money.amount = 0 # type: ignore[misc]
+
+
+def test_add_and_subtract_same_currency() -> None:
+ a = Money(1050, Currency.EUR)
+ b = Money(450, Currency.EUR)
+ assert a.add(b) == Money(1500, Currency.EUR)
+ assert a.subtract(b) == Money(600, Currency.EUR)
+
+
+def test_zero_factory_and_major_units() -> None:
+ assert Money.zero(Currency.USD) == Money(0, Currency.USD)
+ assert Money(1050, Currency.EUR).major_units == 10.5
+ assert str(Money(1050, Currency.EUR)) == "10.50 EUR"
+
+
+def test_currency_mismatch_is_rejected() -> None:
+ with pytest.raises(BusinessRuleViolation) as exc:
+ Money(100, Currency.EUR).add(Money(100, Currency.USD))
+ assert exc.value.rule == "money-currency-mismatch"
+
+
+def test_non_integer_amount_is_rejected() -> None:
+ with pytest.raises(BusinessRuleViolation) as exc:
+ Money(10.5, Currency.EUR) # type: ignore[arg-type]
+ assert exc.value.rule == "money-amount-integer"
+:::
+
+Cada prueba es síncrona: sin `async`, sin `await`, sin fixtures. Pytest recopila las funciones a nivel de módulo automáticamente. `Currency.EUR` es un valor de enumeración, no una cadena simple, ajustándose exactamente al contrato de tipos del modelo de dominio.
+
+**Ejecútalo.** Ejecuta solo este archivo para ver en acción la base unitaria de la pirámide:
+
+```bash
+uv run --extra dev pytest tests/test_money.py -q
+```
+
+Salida esperada:
+
+```text
+...... [100%]
+6 passed in 0.02s
+```
+
+Seis pruebas, veinte milisegundos. Sin base de datos conectada, sin bus de eventos iniciado: estas
+pruebas construyen un objeto simple y comprueban su comportamiento. Eso es lo que hace que la
+base de la pirámide sea tan ancha y tan rápida.
+
+*Qué acaba de pasar.* Has demostrado todo el contrato de `Money` —igualdad,
+inmutabilidad, aritmética y cada invariante— sin tocar una sola pieza de
+infraestructura del framework. El código de dominio puro permanece probable como dominio puro. Cuando una de
+estas falla, sabes que el error está en el propio objeto de valor, no en el cableado, una
+sesión o el bus.
+
+!!! tip "Aritmética de unidades menores"
+ `Money` almacena los importes en **unidades menores** (céntimos enteros). `Money(1050,
+ Currency.EUR)` representa 10,50 € —verificado por `major_units == 10.5` y
+ `str(...) == "10.50 EUR"`—. La fábrica `Money.zero(currency)` devuelve un
+ `Money(0, currency)`, útil para inicializar los saldos de los monederos.
+
+### Pruebas de la raíz de agregado Wallet
+
+`Wallet` hace cumplir varios invariantes: el propietario debe ser una cadena no vacía, los depósitos deben ser positivos, los retiros no deben dejar el saldo en descubierto y los importes deben coincidir con la moneda del monedero. Cada violación lleva un atributo `.rule` para una verificación precisa.
+
+Un término primero. Un **agregado** (o **raíz de agregado**) es un grupo de objetos de
+dominio tratado como una sola unidad de coherencia; aquí, el `Wallet` y su
+saldo. Todo cambio pasa por los métodos del agregado, así que el agregado es el
+único lugar que garantiza que sus invariantes se cumplen. Eso lo convierte en la unidad natural para
+probar: recórrelo a través de sus métodos públicos y comprueba que las reglas nunca se rompen.
+
+El patrón que sigue cada prueba de agregado es **organizar, actuar, comprobar** (arrange, act, assert). Organizar:
+construir el monedero en un estado conocido. Actuar: llamar a un método. Comprobar: verificar el
+saldo, el evento emitido o la violación lanzada. Búscalo en cada prueba:
+
+**Paso 1 — la apertura emite un evento.** `test_open_creates_empty_wallet` abre un monedero
+y comprueba que el saldo es cero y que se encola exactamente un evento `WalletOpened`.
+
+**Paso 2 — la apertura valida sus argumentos.** `test_open_requires_owner` pasa un
+propietario en blanco y espera `BusinessRuleViolation` con la regla
+`"wallet-owner-required"`.
+
+**Paso 3 — el camino feliz de depósito y luego retiro.**
+`test_deposit_then_withdraw_happy_path` deposita, comprueba el saldo y el
+evento `FundsDeposited`, luego retira y comprueba el saldo y el
+evento `FundsWithdrawn`. Fíjate en la llamada a `clear_events()` en el paso de organización; más sobre esto
+justo debajo del listado.
+
+**Paso 4 — los invariantes rechazan las operaciones inválidas.** Las tres pruebas finales demuestran que un
+retiro no puede dejar el saldo en descubierto, que un depósito debe ser positivo y que un importe debe coincidir con la
+moneda del monedero. Cada una comprueba la cadena `.rule` exacta, y la prueba de descubierto también
+comprueba que el saldo quedó sin cambios y que no se lanzó ningún evento, prueba de que el invariante
+se disparó *antes* de que cambiara ningún estado.
+
+::: listing tests/test_wallet_aggregate.py | Listado 16.2 — Pruebas unitarias de la raíz de agregado Wallet
+from __future__ import annotations
+
+import pytest
+from lumen.interfaces.enums.v1.currency import Currency
+from lumen.models.entities.v1.money import Money
+from lumen.models.entities.v1.wallet_entity import (
+ FundsDeposited,
+ FundsWithdrawn,
+ Wallet,
+ WalletOpened,
+)
+
+from pyfly.domain import BusinessRuleViolation
+
+
+def test_open_creates_empty_wallet() -> None:
+ wallet = Wallet.open("wlt-1", "owner-1", Currency.EUR)
+ assert wallet.owner_id == "owner-1"
+ assert wallet.currency is Currency.EUR
+ assert wallet.balance == Money.zero(Currency.EUR)
+ [event] = wallet.pending_events()
+ assert isinstance(event, WalletOpened)
+ assert event.wallet_id == "wlt-1"
+ assert event.currency == "EUR"
+
+
+def test_open_requires_owner() -> None:
+ with pytest.raises(BusinessRuleViolation) as exc:
+ Wallet.open("wlt-x", " ", Currency.EUR)
+ assert exc.value.rule == "wallet-owner-required"
+
+
+def test_deposit_then_withdraw_happy_path() -> None:
+ wallet = Wallet.open("wlt-2", "owner-2", Currency.EUR)
+ wallet.clear_events()
+
+ wallet.deposit(Money(1000, Currency.EUR))
+ assert wallet.balance == Money(1000, Currency.EUR)
+ [event] = wallet.clear_events()
+ assert isinstance(event, FundsDeposited)
+ assert event.amount == 1000
+ assert event.balance == 1000
+
+ wallet.withdraw(Money(400, Currency.EUR))
+ assert wallet.balance == Money(600, Currency.EUR)
+ [event] = wallet.clear_events()
+ assert isinstance(event, FundsWithdrawn)
+ assert event.amount == 400
+ assert event.balance == 600
+
+
+def test_withdraw_cannot_overdraw() -> None:
+ wallet = Wallet.open("wlt-3", "owner-3", Currency.EUR)
+ wallet.deposit(Money(500, Currency.EUR))
+ wallet.clear_events()
+ with pytest.raises(BusinessRuleViolation) as exc:
+ wallet.withdraw(Money(501, Currency.EUR))
+ assert exc.value.rule == "wallet-insufficient-funds"
+ # invariant held: balance unchanged, no event raised
+ assert wallet.balance == Money(500, Currency.EUR)
+ assert wallet.pending_events() == []
+
+
+def test_deposit_must_be_positive() -> None:
+ wallet = Wallet.open("wlt-4", "owner-4", Currency.EUR)
+ with pytest.raises(BusinessRuleViolation) as exc:
+ wallet.deposit(Money(0, Currency.EUR))
+ assert exc.value.rule == "wallet-deposit-positive"
+
+
+def test_currency_mismatch_is_rejected() -> None:
+ wallet = Wallet.open("wlt-5", "owner-5", Currency.EUR)
+ with pytest.raises(BusinessRuleViolation) as exc:
+ wallet.deposit(Money(100, Currency.USD))
+ assert exc.value.rule == "wallet-currency-mismatch"
+:::
+
+Tres detalles merecen atención. Primero, `Wallet.open` toma tres argumentos posicionales: un `wallet_id` pregenerado, un `owner_id` y un valor de enumeración `Currency`; el agregado no genera su propio ID. Segundo, `pending_events()` devuelve los eventos almacenados en búfer sin vaciarlos; `clear_events()` los devuelve y los vacía. La prueba `test_deposit_then_withdraw_happy_path` llama a `clear_events()` tras la apertura para que cada aserción vea exactamente un evento. Tercero, `FundsDeposited` y `FundsWithdrawn` llevan `amount` (el importe de la operación en unidades menores) y `balance` (el saldo acumulado tras la operación), no `new_balance`. Verifica siempre los campos reales de la dataclass del evento antes de comprobarlos.
+
+**Ejecútalo.**
+
+```bash
+uv run --extra dev pytest tests/test_wallet_aggregate.py -q
+```
+
+Salida esperada:
+
+```text
+...... [100%]
+6 passed in 0.02s
+```
+
+*Qué acaba de pasar.* La línea más difícil de leer es la asignación
+`[event] = wallet.clear_events()`. Eso es un desempaquetado de lista: comprueba que la lista devuelta
+tiene **exactamente un** elemento y lo enlaza a `event` en un solo paso. Si el
+agregado hubiera lanzado cero o dos eventos, el propio desempaquetado lanzaría un
+`ValueError` y la prueba fallaría, así que la forma del flujo de eventos se comprueba
+gratis. Por eso la prueba del camino feliz llama a `clear_events()` justo después de la apertura:
+vacía el evento `WalletOpened` para que el siguiente desempaquetado vea solo el
+evento `FundsDeposited` que realmente estás comprobando.
+
+!!! spring "Equivalencia con Spring"
+ Probar un agregado DDD de forma aislada es la misma disciplina en cualquier stack. En
+ Spring / jMolecules llamarías a los métodos del agregado directamente y
+ comprobarías `aggregate.domainEvents()` (proporcionado por `AbstractAggregateRoot`)
+ antes de llamar a `afterDomainEventPublication()` para vaciar el búfer. El
+ `clear_events()` de PyFly cumple el mismo papel: vaciar, comprobar, seguir.
+
+---
+
+## Cableado del stack de pruebas con conftest.py
+
+Las pruebas de CQRS y de listeners de eventos necesitan infraestructura real: un `WalletRepository` respaldado por SQLite, un bus de eventos, manejadores de comandos y consultas y un bus en marcha. En lugar de recrear esto en cada módulo de prueba, Lumen declara el cableado una sola vez en `tests/conftest.py`. Pytest descubre el archivo automáticamente y pone los fixtures a disposición de cada prueba del paquete.
+
+La diferencia clave respecto a una prueba de adaptador montada a mano es que el fixture `repository` usa el **`WalletRepository` real del framework** —la misma clase al estilo Spring Data que arranca la aplicación— y lo ejecuta a través del **`RepositoryBeanPostProcessor` real**, que compila los esbozos de consulta derivada a partir de los nombres de método al arrancar.
+
+Este archivo es el corazón del capítulo, así que lo leeremos de arriba abajo como una serie
+de pasos pequeños y por capas. Cada fixture se construye sobre el anterior. Lo que hay que tener
+presente: pytest enlaza los fixtures por **nombre**. Cuando una función de fixture declara un
+parámetro, pytest busca un fixture con ese nombre, lo construye y lo inyecta. Así
+es como un pequeño grafo de fixtures independientes se compone en un stack de pruebas completo.
+
+**Paso 1 — hacer que la muestra sea importable.** Las primeras líneas añaden el `src/` de la
+muestra a `sys.path` para que `import lumen...` se resuelva. El ajuste `pythonpath = ["src"]`
+de `pyproject.toml` hace lo mismo para la recopilación de la propia pytest; esta
+línea cubre las importaciones directas dentro de `conftest.py` antes de que se apliquen los ajustes de ruta de pytest.
+
+**Paso 2 — `session_factory`: un motor de base de datos.** Este fixture asíncrono crea un
+motor SQLite en memoria, ejecuta `Base.metadata.create_all` para construir el esquema y
+entrega un `async_sessionmaker`. Una **fábrica de sesiones** es un invocable que reparte
+sesiones de base de datos nuevas; la autoconfiguración relacional del framework crea una
+exactamente así al arrancar. El único motor compartido mantiene la base de datos en memoria
+viva durante toda la prueba: ciérralo y los datos se esfuman.
+
+**Paso 3 — `repository`: el repositorio real al estilo Spring Data.** Construye el
+`WalletRepository` del framework, luego llama a
+`RepositoryBeanPostProcessor().after_init(repo, "walletRepository")`. Un
+**post-procesador** es un gancho que se ejecuta contra un bean recién creado; aquí lee
+nombres de método como `find_by_owner_id` y los compila en consultas reales. Si te saltas esta
+llamada, esos métodos siguen siendo esbozos que lanzan `NotImplementedError`.
+
+**Paso 4 — `event_bus`: el bus de eventos en memoria.** Un fixture de una línea que entrega un
+`InMemoryEventBus`, el mismo publicador que usa la aplicación en despliegues sin Kafka.
+
+**Paso 5 — `audit_listener`: un suscriptor en ese bus.** Crea el
+`WalletAuditListener` y suscribe su manejador al bus, leyendo los patrones de
+evento directamente del atributo `__pyfly_event_patterns__` del método decorado:
+exactamente lo que hace el `ApplicationContext` cuando autocablea los listeners al arrancar.
+
+**Paso 6 — `command_bus` y `query_bus`: los despachadores de CQRS.** Cada uno construye un
+`HandlerRegistry`, registra los manejadores reales (pasándoles el `repository`,
+el `event_bus` y la `session_factory` que necesitan) y entrega un bus. **CQRS**
+—Command/Query Responsibility Segregation— significa simplemente que las escrituras van por un bus de
+comandos y las lecturas por un bus de consultas; un **manejador (handler)** es la función que de hecho
+procesa un comando o una consulta.
+
+::: listing tests/conftest.py | Listado 16.3 — conftest.py: componentes reales del framework cableados sin mocks
+from __future__ import annotations
+
+import sys
+from collections.abc import AsyncIterator
+from pathlib import Path
+
+import pytest_asyncio
+from sqlalchemy.ext.asyncio import (
+ AsyncSession,
+ async_sessionmaker,
+ create_async_engine,
+)
+
+# Make the sample's `src/` importable
+_HERE = Path(__file__).resolve().parent
+_SRC = _HERE.parent / "src"
+sys.path.insert(0, str(_SRC))
+
+from lumen.core.services.listeners import WalletAuditListener
+from lumen.core.services.wallets import (
+ DepositFundsHandler,
+ GetBalanceHandler,
+ GetWalletHandler,
+ ListRichWalletsHandler,
+ ListWalletsHandler,
+ OpenWalletHandler,
+ WithdrawFundsHandler,
+)
+from lumen.models.entities.v1.wallet_orm import WalletEntity
+from lumen.models.repositories import WalletRepository
+
+from pyfly.cqrs import DefaultCommandBus, DefaultQueryBus, HandlerRegistry
+from pyfly.data.relational.sqlalchemy import Base
+from pyfly.data.relational.sqlalchemy.post_processor import (
+ RepositoryBeanPostProcessor,
+)
+from pyfly.eda.adapters.memory import InMemoryEventBus
+
+
+@pytest_asyncio.fixture
+async def session_factory() -> AsyncIterator[async_sessionmaker[AsyncSession]]:
+ """An in-memory SQLite engine + session factory, schema created.
+
+ Mirrors the framework's relational auto-configuration: build the
+ async engine and run Base.metadata.create_all.
+ """
+ engine = create_async_engine("sqlite+aiosqlite:///:memory:")
+ async with engine.begin() as conn:
+ await conn.run_sync(Base.metadata.create_all)
+ factory = async_sessionmaker(engine, expire_on_commit=False)
+ try:
+ yield factory
+ finally:
+ await engine.dispose()
+
+
+@pytest_asyncio.fixture
+async def repository(
+ session_factory: async_sessionmaker[AsyncSession],
+) -> AsyncIterator[WalletRepository]:
+ """The framework WalletRepository, post-processed.
+
+ RepositoryBeanPostProcessor compiles derived-query stubs
+ (e.g. find_by_owner_id) onto the bean — the same step the
+ ApplicationContext runs at startup.
+ """
+ session = session_factory()
+ repo = WalletRepository(WalletEntity, session)
+ RepositoryBeanPostProcessor().after_init(repo, "walletRepository")
+ try:
+ yield repo
+ finally:
+ await session.close()
+
+
+@pytest_asyncio.fixture
+async def event_bus() -> AsyncIterator[InMemoryEventBus]:
+ """A real in-memory EDA bus — the same EventPublisher used in
+ production."""
+ yield InMemoryEventBus()
+
+
+@pytest_asyncio.fixture
+async def audit_listener(
+ event_bus: InMemoryEventBus,
+) -> AsyncIterator[WalletAuditListener]:
+ """The wallet audit projection, subscribed to the bus exactly as the
+ ApplicationContext auto-wires it at startup."""
+ listener = WalletAuditListener()
+ method = listener.on_wallet_event
+ for pattern in method.__pyfly_event_patterns__:
+ event_bus.subscribe(pattern, method)
+ yield listener
+
+
+@pytest_asyncio.fixture
+async def command_bus(
+ repository: WalletRepository,
+ event_bus: InMemoryEventBus,
+ session_factory: async_sessionmaker[AsyncSession],
+) -> AsyncIterator[DefaultCommandBus]:
+ registry = HandlerRegistry()
+ registry.register_command_handler(
+ OpenWalletHandler(
+ repository=repository,
+ events=event_bus,
+ session_factory=session_factory,
+ )
+ )
+ registry.register_command_handler(
+ DepositFundsHandler(
+ repository=repository,
+ events=event_bus,
+ session_factory=session_factory,
+ )
+ )
+ registry.register_command_handler(
+ WithdrawFundsHandler(
+ repository=repository,
+ events=event_bus,
+ session_factory=session_factory,
+ )
+ )
+ yield DefaultCommandBus(registry=registry)
+
+
+@pytest_asyncio.fixture
+async def query_bus(
+ repository: WalletRepository,
+) -> AsyncIterator[DefaultQueryBus]:
+ registry = HandlerRegistry()
+ registry.register_query_handler(GetWalletHandler(repository=repository))
+ registry.register_query_handler(GetBalanceHandler(repository=repository))
+ registry.register_query_handler(
+ ListWalletsHandler(repository=repository)
+ )
+ registry.register_query_handler(
+ ListRichWalletsHandler(repository=repository)
+ )
+ yield DefaultQueryBus(registry=registry)
+:::
+
+Cada fixture se declara con `@pytest_asyncio.fixture` (no con el `@pytest.fixture` pelado) para que pytest-asyncio gestione el ciclo de vida del iterador asíncrono. `asyncio_mode = "auto"` en `pyproject.toml` hace que los fixtures y las pruebas asíncronas funcionen sin decoradores por función, pero el propio decorador del fixture debe seguir siendo `pytest_asyncio.fixture`.
+
+El fixture `session_factory` es compartido. Tanto `repository` como `command_bus` lo reciben, así que el mismo motor SQLite en memoria respalda las lecturas, las escrituras y la frontera `@transactional` que abren los manejadores. Los fixtures `audit_listener` y `command_bus` reciben ambos `event_bus`; pytest lo instancia una vez por prueba y lo comparte entre ellos, así que los eventos publicados por los manejadores de comandos son visibles para el listener.
+
+*Qué acaba de pasar.* Has cableado un stack de pruebas completo, con forma de producción —motor,
+repositorio, bus de eventos, listener y ambos buses de CQRS— enteramente desde fixtures, sin
+mocks. Los dos hechos que lo hacen funcionar merecen retenerse. Primero, **los fixtures
+se componen por nombre**: `command_bus` pide `repository`, `event_bus` y
+`session_factory` simplemente nombrándolos como parámetros, y pytest entrelaza el grafo
+por ti. Segundo, **un fixture solicitado por otros dos se construye una vez por prueba**: tanto
+`repository` como `command_bus` nombran `session_factory`, así que comparten un solo motor;
+la escritura que hace un comando es la lectura que ve una consulta. Acierta con este
+archivo y cada prueba de las siguientes cuatro secciones será una llamada de dos líneas.
+
+!!! spring "Equivalencia con Spring"
+ `conftest.py` es el `@TestConfiguration` de PyFly más el compartido
+ `application-test.properties` de un proyecto Spring Boot: un único lugar que
+ declara los beans que cada prueba reutiliza. Un `@pytest_asyncio.fixture` es el equivalente
+ aproximado de un método `@Bean` en esa configuración: pytest lo construye de forma perezosa, lo inyecta
+ donde se solicita su nombre y lo desmonta después, igual que el contexto de pruebas de Spring
+ gestiona el ciclo de vida y la inyección de los beans.
+
+!!! tip "Nada de mocks en ningún sitio"
+ Cada componente de `conftest.py` es la implementación real de producción.
+ `WalletRepository` es la misma clase que arranca la aplicación.
+ `RepositoryBeanPostProcessor` es el mismo post-procesador que el
+ `ApplicationContext` ejecuta al arrancar para compilar los esbozos de consulta derivada.
+ `InMemoryEventBus` es el mismo bus que se usa en despliegues sin Kafka. El
+ objetivo es probar los caminos de código reales, no el cableado.
+
+---
+
+## Pruebas del flujo CQRS de extremo a extremo
+
+Con los fixtures de `conftest.py`, ejercitar el ciclo completo de comando/consulta es cuestión de llamar a `command_bus.send(...)` y `query_bus.query(...)`. No se instancia ningún manejador en el cuerpo de la prueba: el bus despacha al manejador ya registrado en el fixture.
+
+Fíjate en lo corto que se vuelve el cuerpo de cada prueba ahora que el cableado vive en `conftest.py`.
+Una prueba declara los fixtures que necesita como parámetros y luego se lee como una narración llana:
+
+**Paso 1 — solicitar los buses.** La firma de cada prueba enumera `command_bus` o
+`query_bus`. pytest ve esos nombres, construye el grafo de fixtures desde `conftest.py`
+e inyecta los buses listos.
+
+**Paso 2 — enviar comandos.** `await command_bus.send(OpenWallet(...))` devuelve el id del nuevo
+monedero; los comandos `DepositFunds` y `WithdrawFunds` posteriores devuelven el saldo
+acumulado. Compruebas cada valor de retorno sobre la marcha.
+
+**Paso 3 — consultar el lado de lectura.** `await query_bus.query(GetWallet(...))` y
+`GetBalance(...)` recargan el estado persistido y devuelven DTO (objetos de transferencia de datos,
+modelos de lectura simples). Compruebas que sus campos coinciden con lo que escribieron los comandos.
+
+**Paso 4 — demostrar los caminos de error.** Las pruebas restantes envían un comando que debe fallar
+—un descubierto, un depósito no positivo, un monedero desconocido— envuelto en
+`pytest.raises(CommandProcessingException)`. Ese gestor de contexto comprueba que el bloque
+lanza la excepción nombrada; si no lo hace, la prueba falla.
+
+::: listing tests/test_cqrs_flow.py | Listado 16.4 — Pruebas CQRS de extremo a extremo a través del bus real
+from __future__ import annotations
+
+import pytest
+from lumen.core.services.wallets.deposit_funds_command import DepositFunds
+from lumen.core.services.wallets.get_balance_query import GetBalance
+from lumen.core.services.wallets.get_wallet_query import GetWallet
+from lumen.core.services.wallets.open_wallet_command import OpenWallet
+from lumen.core.services.wallets.withdraw_funds_command import WithdrawFunds
+from lumen.interfaces.enums.v1.currency import Currency
+
+from pyfly.cqrs import DefaultCommandBus, DefaultQueryBus
+
+
+@pytest.mark.asyncio
+async def test_full_wallet_lifecycle(
+ command_bus: DefaultCommandBus,
+ query_bus: DefaultQueryBus,
+) -> None:
+ wallet_id = await command_bus.send(
+ OpenWallet(owner_id="u-1", currency=Currency.EUR)
+ )
+ assert isinstance(wallet_id, str) and wallet_id.startswith("wlt-")
+
+ balance = await command_bus.send(
+ DepositFunds(wallet_id=wallet_id, amount=1500)
+ )
+ assert balance == 1500
+
+ balance = await command_bus.send(
+ WithdrawFunds(wallet_id=wallet_id, amount=500)
+ )
+ assert balance == 1000
+
+ wallet = await query_bus.query(GetWallet(wallet_id=wallet_id))
+ assert wallet is not None
+ assert wallet.id == wallet_id
+ assert wallet.owner_id == "u-1"
+ assert wallet.currency is Currency.EUR
+ assert wallet.balance_minor == 1000
+ assert wallet.balance == 10.0
+
+ balance_dto = await query_bus.query(GetBalance(wallet_id=wallet_id))
+ assert balance_dto is not None
+ assert balance_dto.balance_minor == 1000
+ assert balance_dto.balance == 10.0
+
+
+@pytest.mark.asyncio
+async def test_get_wallet_returns_none_for_unknown_id(
+ query_bus: DefaultQueryBus,
+) -> None:
+ assert await query_bus.query(
+ GetWallet(wallet_id="wlt-does-not-exist")
+ ) is None
+
+
+@pytest.mark.asyncio
+async def test_overdraw_is_rejected_through_the_bus(
+ command_bus: DefaultCommandBus,
+) -> None:
+ from pyfly.cqrs.exceptions import CommandProcessingException
+
+ wallet_id = await command_bus.send(
+ OpenWallet(owner_id="u-2", currency=Currency.EUR)
+ )
+ await command_bus.send(DepositFunds(wallet_id=wallet_id, amount=100))
+
+ with pytest.raises(CommandProcessingException):
+ await command_bus.send(
+ WithdrawFunds(wallet_id=wallet_id, amount=999)
+ )
+
+
+@pytest.mark.asyncio
+async def test_validation_rejects_non_positive_deposit(
+ command_bus: DefaultCommandBus,
+) -> None:
+ from pyfly.cqrs.exceptions import CommandProcessingException
+
+ wallet_id = await command_bus.send(
+ OpenWallet(owner_id="u-3", currency=Currency.EUR)
+ )
+ with pytest.raises(CommandProcessingException):
+ await command_bus.send(DepositFunds(wallet_id=wallet_id, amount=0))
+
+
+@pytest.mark.asyncio
+async def test_deposit_to_unknown_wallet_is_rejected(
+ command_bus: DefaultCommandBus,
+) -> None:
+ from pyfly.cqrs.exceptions import CommandProcessingException
+
+ with pytest.raises(CommandProcessingException):
+ await command_bus.send(
+ DepositFunds(wallet_id="wlt-nope", amount=100)
+ )
+:::
+
+`test_full_wallet_lifecycle` es la prueba de humo principal: envía cada comando en el orden natural y luego consulta tanto el DTO completo del monedero como el DTO de saldo. El DTO del monedero expone `balance_minor` (unidades menores enteras) y `balance` (unidades mayores como float); ambos derivan de la misma fila `WalletEntity` almacenada a través del repositorio.
+
+Las pruebas de los caminos de error verifican que el bus aflora correctamente las violaciones de dominio. **`CommandProcessingException`** es el envoltorio del bus para cualquier excepción lanzada dentro de un manejador, incluida `BusinessRuleViolation` del agregado. El código que llama nunca ve la excepción de dominio en bruto; siempre ve el envoltorio del bus.
+
+**Ejecútalo.**
+
+```bash
+uv run --extra dev pytest tests/test_cqrs_flow.py -q
+```
+
+Salida esperada:
+
+```text
+..... [100%]
+5 passed in 0.05s
+```
+
+*Qué acaba de pasar.* Esta es la primera capa que toca infraestructura real, y
+aun así se ejecuta en milisegundos. El comando pasó por el bus real, el manejador
+real abrió una unidad de trabajo `@transactional` real sobre una sesión SQLite real,
+la confirmó (commit), y la consulta la leyó de vuelta: el camino exacto que se ejecuta en producción,
+menos la capa HTTP. Como el manejador se registra en el fixture en lugar de
+construirse en la prueba, estás probando el *despacho* además de la lógica: si el
+enrutamiento de comando a manejador se rompiera, estas pruebas lo atraparían.
+
+!!! note "asyncio_mode = \"auto\" y @pytest.mark.asyncio"
+ Con `asyncio_mode = "auto"`, cada prueba asíncrona se recopila y ejecuta
+ automáticamente. El decorador `@pytest.mark.asyncio` **no es obligatorio** pero
+ es inocuo y hace que la intención asíncrona resulte explícita de un vistazo. Lumen lo conserva
+ por claridad.
+
+---
+
+## Pruebas del adaptador del repositorio
+
+Las pruebas de flujo CQRS demuestran la canalización completa de apertura/depósito/retiro/consulta. Las pruebas del adaptador del repositorio van un nivel más hondo: ejercitan directamente la API de `WalletRepository` —CRUD, **consultas derivadas** compiladas a partir de nombres de método, paginación con **`Pageable`/`Page`** y predicados de tipo **`Specification`**— contra una base de datos SQLite en un archivo temporal. Sin Docker, sin proceso externo, sin red. El fixture integrado `tmp_path` de pytest proporciona un directorio temporal que se limpia automáticamente tras cada prueba.
+
+El fixture local `_make_repo` refleja lo que hace el `ApplicationContext` al arrancar: construir el repositorio y ejecutar `RepositoryBeanPostProcessor.after_init` para compilar los esbozos de consulta derivada. Sin esa llamada, métodos como `find_by_owner_id` lanzarían `NotImplementedError`.
+
+Dos términos antes del listado. Una **consulta derivada** es un método de repositorio cuyo cuerpo
+se genera a partir de su *nombre*: `find_by_owner_id` se convierte en `WHERE owner_id = :value`,
+sin SQL escrito a mano. Una **Specification** es un objeto predicado reutilizable y componible
+que pasas a una consulta —`balance_at_least(1000)` es uno— para filtros demasiado
+dinámicos como para incrustarlos en un nombre de método. La **paginación** envuelve ambos: `Pageable.of(page,
+size, sort)` describe qué porción quieres, y la consulta devuelve una `Page` que lleva
+los elementos más `total`, `total_pages` y `has_next`.
+
+Esta sección usa una base de datos SQLite **basada en archivo** (vía `tmp_path`) en lugar de la
+de en memoria, para poder demostrar una propiedad más fuerte: la durabilidad tras una reconexión.
+Esta es la forma de cada prueba:
+
+**Paso 1 — `sqlite_factory`: un motor sobre archivo temporal.** El fixture construye un motor SQLite
+respaldado por un archivo real bajo el `tmp_path` de pytest (un directorio temporal nuevo por prueba,
+autoeliminado después), crea el esquema y entrega tanto la fábrica como la URL.
+Entregar la URL es lo que permite que una prueba se reconecte con un motor totalmente nuevo.
+
+**Paso 2 — CRUD y persistencia.**
+`test_upsert_inserts_then_updates_and_persists` hace un upsert de una fila, lo repite con
+un nuevo saldo para demostrar la actualización en sitio, hace commit, la lee de vuelta y luego desecha el
+motor y se reconecta con uno *nuevo* para demostrar que los datos realmente llegaron al disco.
+
+**Paso 3 — el camino de id desconocido.** `test_find_by_id_unknown_returns_none` confirma que un
+fallo devuelve `None`, no un error.
+
+**Paso 4 — consulta derivada.** `test_derived_find_by_owner_id` inserta monederos para dos
+propietarios y comprueba que `find_by_owner_id("alice")` devuelve solo los de Alice: prueba de que el
+post-procesador compiló correctamente la convención de nombres de método.
+
+**Paso 5 — Specification + paginación.**
+`test_specification_find_rich_paged_and_sorted` y
+`test_find_all_pageable_counts_and_pages` ejercitan el camino del predicado `find_rich` /
+`find_all_by_spec` y el camino de paginación `find_all(pageable)`, comprobando
+`total`, `total_pages`, `has_next` y el orden exacto de `page.items`.
+
+::: listing tests/test_sql_wallet_repository.py | Listado 16.5 — Pruebas de repositorio: CRUD, consulta derivada, paginación, Specification
+from __future__ import annotations
+
+from collections.abc import AsyncIterator
+from datetime import UTC, datetime, timedelta
+from pathlib import Path
+
+import pytest
+import pytest_asyncio
+from sqlalchemy.ext.asyncio import (
+ AsyncSession,
+ async_sessionmaker,
+ create_async_engine,
+)
+
+from lumen.models.entities.v1.wallet_orm import WalletEntity
+from lumen.models.repositories.wallet_repository import (
+ WalletRepository,
+ balance_at_least,
+)
+from pyfly.data import Pageable, Sort
+from pyfly.data.relational.sqlalchemy import Base
+from pyfly.data.relational.sqlalchemy.post_processor import (
+ RepositoryBeanPostProcessor,
+)
+
+
+def _entity(
+ wid: str,
+ owner: str,
+ minor: int,
+ *,
+ currency: str = "EUR",
+ age_days: int = 0,
+) -> WalletEntity:
+ created = datetime.now(UTC) - timedelta(days=age_days)
+ return WalletEntity(
+ id=wid,
+ owner_id=owner,
+ currency=currency,
+ balance_minor=minor,
+ created_at=created,
+ )
+
+
+def _make_repo(session: AsyncSession) -> WalletRepository:
+ repo = WalletRepository(WalletEntity, session)
+ # Mirror the ApplicationContext: compile derived-query stubs.
+ RepositoryBeanPostProcessor().after_init(repo, "walletRepository")
+ return repo
+
+
+@pytest_asyncio.fixture
+async def sqlite_factory(
+ tmp_path: Path,
+) -> AsyncIterator[tuple[async_sessionmaker[AsyncSession], str]]:
+ """A temp-file SQLite engine + session factory, schema created.
+
+ Yields the session factory and the database URL so the test can
+ reconnect with a fresh engine to verify true persistence.
+ """
+ db_url = f"sqlite+aiosqlite:///{tmp_path / 'wallets.db'}"
+ engine = create_async_engine(db_url)
+ async with engine.begin() as conn:
+ await conn.run_sync(Base.metadata.create_all)
+ factory = async_sessionmaker(engine, expire_on_commit=False)
+ try:
+ yield factory, db_url
+ finally:
+ await engine.dispose()
+
+
+@pytest.mark.asyncio
+async def test_upsert_inserts_then_updates_and_persists(
+ sqlite_factory: tuple[async_sessionmaker[AsyncSession], str],
+) -> None:
+ factory, db_url = sqlite_factory
+
+ async with factory() as session:
+ repo = _make_repo(session)
+ await repo.upsert(_entity("wlt-1", "owner-42", 0, currency="USD"))
+ # update: same PK, new balance
+ await repo.upsert(_entity("wlt-1", "owner-42", 2500, currency="USD"))
+ await session.commit()
+
+ got = await repo.find_by_id("wlt-1")
+ assert got is not None
+ assert got.owner_id == "owner-42"
+ assert got.currency == "USD"
+ assert got.balance_minor == 2500
+ assert await repo.count() == 1
+
+ # prove persistence: reconnect with a brand-new engine/session
+ fresh_engine = create_async_engine(db_url)
+ fresh_factory = async_sessionmaker(fresh_engine, expire_on_commit=False)
+ try:
+ async with fresh_factory() as fresh_session:
+ fresh_repo = _make_repo(fresh_session)
+ persisted = await fresh_repo.find_by_id("wlt-1")
+ assert persisted is not None, "wallet should survive a reconnect"
+ assert persisted.balance_minor == 2500
+ finally:
+ await fresh_engine.dispose()
+
+
+@pytest.mark.asyncio
+async def test_find_by_id_unknown_returns_none(
+ sqlite_factory: tuple[async_sessionmaker[AsyncSession], str],
+) -> None:
+ factory, _ = sqlite_factory
+ async with factory() as session:
+ repo = _make_repo(session)
+ assert await repo.find_by_id("wlt-nope") is None
+
+
+@pytest.mark.asyncio
+async def test_derived_find_by_owner_id(
+ sqlite_factory: tuple[async_sessionmaker[AsyncSession], str],
+) -> None:
+ factory, _ = sqlite_factory
+ async with factory() as session:
+ repo = _make_repo(session)
+ await repo.upsert(_entity("wlt-1", "alice", 100))
+ await repo.upsert(_entity("wlt-2", "alice", 200))
+ await repo.upsert(_entity("wlt-3", "bob", 300))
+ await session.commit()
+
+ owned = await repo.find_by_owner_id("alice")
+ assert sorted(w.id for w in owned) == ["wlt-1", "wlt-2"]
+ assert await repo.find_by_owner_id("nobody") == []
+
+
+@pytest.mark.asyncio
+async def test_specification_find_rich_paged_and_sorted(
+ sqlite_factory: tuple[async_sessionmaker[AsyncSession], str],
+) -> None:
+ factory, _ = sqlite_factory
+ async with factory() as session:
+ repo = _make_repo(session)
+ # age_days drives created_at so we can assert newest-first ordering.
+ await repo.upsert(_entity("wlt-poor", "a", 50, age_days=3))
+ await repo.upsert(_entity("wlt-mid", "b", 1000, age_days=2))
+ await repo.upsert(_entity("wlt-rich", "c", 5000, age_days=1))
+ await session.commit()
+
+ # Specification: balance_minor >= 1000, newest first, page size 1.
+ newest_first = Sort.by("created_at").descending()
+ page = await repo.find_rich(1000, Pageable.of(1, 1, newest_first))
+ assert page.total == 2 # mid + rich
+ assert page.total_pages == 2
+ assert page.has_next is True
+ assert [w.id for w in page.items] == ["wlt-rich"]
+
+ page2 = await repo.find_rich(1000, Pageable.of(2, 1, newest_first))
+ assert [w.id for w in page2.items] == ["wlt-mid"]
+
+ # The bare predicate also works through find_all_by_spec.
+ rich = await repo.find_all_by_spec(balance_at_least(5000))
+ assert [w.id for w in rich] == ["wlt-rich"]
+
+
+@pytest.mark.asyncio
+async def test_find_all_pageable_counts_and_pages(
+ sqlite_factory: tuple[async_sessionmaker[AsyncSession], str],
+) -> None:
+ factory, _ = sqlite_factory
+ async with factory() as session:
+ repo = _make_repo(session)
+ for i in range(5):
+ await repo.upsert(
+ _entity(f"wlt-{i}", "owner", i * 100, age_days=5 - i)
+ )
+ await session.commit()
+
+ page = await repo.find_all(
+ Pageable.of(1, 2, Sort.by("created_at").descending())
+ )
+ assert page.total == 5
+ assert page.total_pages == 3
+ assert len(page.items) == 2
+ # newest first -> wlt-4 (age 1 day), then wlt-3
+ assert [w.id for w in page.items] == ["wlt-4", "wlt-3"]
+:::
+
+Cuatro cosas a destacar. Primero, `_make_repo` llama a `RepositoryBeanPostProcessor().after_init(repo, ...)`; sin esto, `find_by_owner_id` sigue siendo un esbozo y lanza `NotImplementedError`. El post-procesador compila el nombre de método en una cláusula `WHERE owner_id = :owner_id` de SQLAlchemy. Segundo, `upsert` es la inserción-o-actualización del repositorio; tras cada lote de upserts, `await session.commit()` vuelca a SQLite. Tercero, `find_rich` toma un saldo mínimo y un `Pageable`; delega en `find_all_by_spec_paged(balance_at_least(min), pageable)`. Cuarto, el patrón de dos motores en `test_upsert_inserts_then_updates_and_persists` demuestra la durabilidad verdadera: los datos confirmados a través de un motor son legibles por un motor y una sesión completamente nuevos.
+
+**Ejecútalo.**
+
+```bash
+uv run --extra dev pytest tests/test_sql_wallet_repository.py -q
+```
+
+Salida esperada:
+
+```text
+..... [100%]
+5 passed in 0.06s
+```
+
+*Qué acaba de pasar.* Lo más notable es la reconexión de dos motores en la primera prueba.
+Muchas pruebas de "persistencia" pasan incluso cuando no se ha escrito nada en el disco, porque la misma
+sesión cachea el objeto en memoria y lo devuelve en la lectura. Al desechar el
+motor por completo y abrir un *segundo* motor contra la misma URL de archivo, esta prueba
+fuerza un viaje de ida y vuelta real al almacenamiento; si `upsert` o `commit` no estuvieran
+persistiendo silenciosamente, la reconexión devolvería `None` y la prueba fallaría. Esa es la
+diferencia entre probar tu código y probar tu caché.
+
+!!! spring "Equivalencia con Spring"
+ Esta capa de prueba es el equivalente en Python de `@DataJpaTest` con una base de datos
+ H2 embebida en Spring Boot. `@DataJpaTest` carga solo la capa JPA (entidades,
+ repositorios, Flyway) y cablea una H2 en memoria nueva para cada clase de prueba.
+ El fixture `sqlite_factory` hace lo mismo: crear el esquema, ejecutar las pruebas,
+ desechar el motor. Sin Docker, sin proceso externo.
+
+!!! tip "Las consultas derivadas son convenciones de nombre de método"
+ `WalletRepository.find_by_owner_id` se declara como un esbozo
+ (`raise NotImplementedError`). `RepositoryBeanPostProcessor` inspecciona el
+ nombre del método al arrancar —`find_by_owner_id` → `WHERE owner_id = :value`—
+ y reemplaza el esbozo por una corrutina real. Por tanto, probar este método
+ también prueba que la convención del post-procesador funciona correctamente.
+
+---
+
+## Pruebas del listener de eventos
+
+`WalletAuditListener` escucha los eventos de dominio publicados por los manejadores de comandos. Probarlo de extremo a extremo —el comando se ejecuta en el bus, el manejador publica eventos, el listener los recibe— requiere que los tres componentes compartan el mismo `InMemoryEventBus`. Los fixtures de `conftest.py` ya lo disponen: tanto `command_bus` como `audit_listener` aceptan un argumento `event_bus`, y pytest inyecta la misma instancia en ambos.
+
+Un **listener de eventos** es simplemente un método que el framework suscribe al bus para que se
+ejecute cada vez que se publica un evento coincidente. El `WalletAuditListener` de Lumen mantiene un
+registro de auditoría en memoria y un saldo acumulado por monedero: una pequeña **proyección** (un modelo de
+lectura construido plegando eventos). Probarlo es la demostración más clara del
+truco del fixture compartido: como `command_bus` y `audit_listener` nombran el mismo
+`event_bus`, un evento que publica un comando es un evento que observa el listener, sin
+pegamento alguno en el cuerpo de la prueba.
+
+Las pruebas siguen un solo ritmo:
+
+**Paso 1 — recorrer comandos.** Abrir un monedero, depositar, retirar; todo a través de
+`command_bus`.
+
+**Paso 2 — leer la proyección.** Llamar a `audit_listener.entries_for(wallet_id)` y
+comprobar los tipos de evento registrados, en orden, más el `running_total`.
+
+**Paso 3 — comprobar el negativo.** Una prueba deja deliberadamente el saldo en descubierto —un comando que
+debe fallar— y comprueba que el registro de auditoría no anota nada de él. Una operación fallida
+no deja rastro.
+
+::: listing tests/test_event_listener.py | Listado 16.6 — Pruebas del listener de eventos: el comando publica, el listener observa
+from __future__ import annotations
+
+import pytest
+from lumen.core.services.listeners import WalletAuditListener
+from lumen.core.services.wallets.deposit_funds_command import DepositFunds
+from lumen.core.services.wallets.open_wallet_command import OpenWallet
+from lumen.core.services.wallets.withdraw_funds_command import WithdrawFunds
+from lumen.interfaces.enums.v1.currency import Currency
+
+from pyfly.cqrs import DefaultCommandBus
+
+
+@pytest.mark.asyncio
+async def test_listener_observes_wallet_events(
+ command_bus: DefaultCommandBus,
+ audit_listener: WalletAuditListener,
+) -> None:
+ wallet_id = await command_bus.send(
+ OpenWallet(owner_id="u-1", currency=Currency.EUR)
+ )
+ await command_bus.send(DepositFunds(wallet_id=wallet_id, amount=1500))
+ await command_bus.send(WithdrawFunds(wallet_id=wallet_id, amount=400))
+
+ entries = audit_listener.entries_for(wallet_id)
+ assert [e.event_type for e in entries] == [
+ "WalletOpened",
+ "FundsDeposited",
+ "FundsWithdrawn",
+ ]
+
+ # The payload carried the real domain-event fields.
+ deposited = entries[1]
+ assert deposited.payload["amount"] == 1500
+ assert deposited.payload["currency"] == "EUR"
+ assert deposited.payload["balance"] == 1500
+ assert deposited.event_id # the aggregate's DomainEvent.event_id
+
+ # The running-total projection reflects deposit − withdrawal.
+ assert audit_listener.running_total(wallet_id) == 1100
+
+
+@pytest.mark.asyncio
+async def test_listener_records_nothing_before_any_command(
+ audit_listener: WalletAuditListener,
+) -> None:
+ assert audit_listener.entries == []
+ assert audit_listener.running_total("anything") == 0
+
+
+@pytest.mark.asyncio
+async def test_event_type_matches_domain_event_class_names(
+ command_bus: DefaultCommandBus,
+ audit_listener: WalletAuditListener,
+) -> None:
+ # A rejected withdrawal raises no event — it must not appear in the log.
+ wallet_id = await command_bus.send(
+ OpenWallet(owner_id="u-2", currency=Currency.USD)
+ )
+ await command_bus.send(DepositFunds(wallet_id=wallet_id, amount=100))
+
+ from pyfly.cqrs.exceptions import CommandProcessingException
+
+ with pytest.raises(CommandProcessingException):
+ await command_bus.send(
+ WithdrawFunds(wallet_id=wallet_id, amount=9999)
+ )
+
+ types = [e.event_type for e in audit_listener.entries_for(wallet_id)]
+ assert types == ["WalletOpened", "FundsDeposited"]
+ assert audit_listener.running_total(wallet_id) == 100
+:::
+
+`test_listener_observes_wallet_events` es la prueba de integración central: tres comandos producen tres eventos, el listener los registra los tres en orden, los campos del payload coinciden con los campos de la dataclass de evento del agregado y la proyección `running_total` es igual al resultado aritmético. Sin mock del bus, sin lista de captura de eventos: el listener de producción se ejecuta sobre el bus de producción.
+
+`test_event_type_matches_domain_event_class_names` demuestra un invariante de dominio: un comando rechazado (descubierto) no lanza ningún evento. El registro de auditoría nunca debe anotar un efecto secundario de una operación fallida.
+
+**Ejecútalo.**
+
+```bash
+uv run --extra dev pytest tests/test_event_listener.py -q
+```
+
+Salida esperada:
+
+```text
+... [100%]
+3 passed in 0.04s
+```
+
+*Qué acaba de pasar.* Ninguna parte de la prueba conectó el listener al bus: el
+fixture `audit_listener` hizo eso en `conftest.py`, suscribiendo el manejador al
+mismo `event_bus` por el que publica el `command_bus`. Así que enviar un comando y luego
+leer `entries_for(...)` ejercita el camino real de publicación/suscripción de extremo a extremo. La
+prueba negativa es la sutil: demuestra que el registro de auditoría se rige por *eventos*, no
+por *intentos*; un retiro rechazado lanza un `BusinessRuleViolation` antes de que se emita ningún
+evento, así que nada llega al listener.
+
+!!! tip "event_type es el nombre de la clase"
+ El publicador de eventos de PyFly fija `event_type` al nombre de la clase del evento de dominio:
+ `"WalletOpened"`, `"FundsDeposited"`, `"FundsWithdrawn"`. El
+ decorador `@event_listener(event_types=["WalletOpened", "FundsDeposited", "FundsWithdrawn"])`
+ sobre `WalletAuditListener.on_wallet_event` nombra esos tres tipos
+ explícitamente; el framework los almacena en el método como
+ `__pyfly_event_patterns__`, que el fixture `audit_listener` lee para suscribirse.
+ La prueba comprueba directamente las cadenas con los nombres de clase.
+
+---
+
+## Prueba de integración con el contexto arrancado
+
+Las pruebas unitarias, las de flujo CQRS y las de repositorio cablean cada una una capa del stack. La prueba de integración con el contexto arrancado las cablea todas a la vez: inicia el `ApplicationContext` real —escaneo de componentes de inyección de dependencias, autoconfiguración de CQRS, autoconfiguración relacional, `RepositoryBeanPostProcessor`, la costura `@transactional`, el bus de eventos de la EDA— y luego resuelve el `DefaultCommandBus` y el `DefaultQueryBus` desde el contexto y recorre el ciclo de vida completo del monedero.
+
+El **ApplicationContext** es el contenedor en tiempo de ejecución de PyFly: el objeto que escanea en busca de
+componentes, construye beans, ejecuta post-procesadores y mantiene unida la aplicación
+cableada. Arrancarlo es la prueba más fiel que puedes escribir sin llegar a iniciar un
+servidor HTTP: cada pieza de cableado que el framework hace al arrancar ocurre de verdad.
+
+La URL de la base de datos se sustituye mediante una variable de entorno para que la prueba nunca toque el `lumen.db` del desarrollador. Este es el plan:
+
+**Paso 1 — aislar la base de datos.** El fixture `booted_context` usa el fixture
+`monkeypatch` de pytest para fijar `PYFLY_DATA_RELATIONAL_URL` a una ruta SQLite de archivo temporal
+bajo `tmp_path`, *antes* de que la aplicación arranque. `monkeypatch` es la forma segura de pytest de fijar una
+variable de entorno durante una sola prueba y restaurarla automáticamente
+después, así que esta prueba nunca puede pisar tu `lumen.db` real.
+
+**Paso 2 — arrancar la aplicación real.** Construye `PyFlyApplication(LumenApplication,
+config_path=...)` y `await app.startup()`. Esa única llamada ejecuta toda la secuencia de arranque:
+escaneo de componentes, todas las autoconfiguraciones, el `RepositoryBeanPostProcessor`
+y el bus de eventos. El fixture entrega `app.context` y, en su bloque `finally`,
+cierra la sesión compartida y llama a `app.shutdown()`.
+
+**Paso 3 — resolver beans y recorrer el ciclo de vida.** La prueba llama a
+`ctx.get_bean(DefaultCommandBus)` y `ctx.get_bean(DefaultQueryBus)` —obteniendo los
+*mismos* buses que usaría la aplicación— y luego ejecuta apertura → depósito → retiro → listado →
+ricos → saldo, comprobando cada resultado.
+
+::: listing tests/test_app_context_integration.py | Listado 16.7 — Integración con el contexto arrancado: composición completa de inyección de dependencias + persistencia
+from __future__ import annotations
+
+import logging
+import sys
+from collections.abc import AsyncIterator
+from pathlib import Path
+
+import pytest
+import pytest_asyncio
+from sqlalchemy.ext.asyncio import AsyncSession
+
+_HERE = Path(__file__).resolve().parent
+_SRC = _HERE.parent / "src"
+sys.path.insert(0, str(_SRC))
+
+
+@pytest_asyncio.fixture
+async def booted_context(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> AsyncIterator[object]:
+ """Boot the full LumenApplication against an isolated SQLite file."""
+ db_path = tmp_path / "lumen-it.db"
+ monkeypatch.setenv(
+ "PYFLY_DATA_RELATIONAL_URL",
+ f"sqlite+aiosqlite:///{db_path}",
+ )
+ # Silence the pool-GC warning that aiosqlite emits at teardown.
+ logging.getLogger(
+ "sqlalchemy.pool.impl.AsyncAdaptedQueuePool"
+ ).setLevel(logging.CRITICAL)
+
+ from lumen.app import LumenApplication
+ from pyfly.core import PyFlyApplication
+
+ app = PyFlyApplication(
+ LumenApplication,
+ config_path=str(_HERE.parent / "pyfly.yaml"),
+ )
+ await app.startup()
+ try:
+ yield app.context
+ finally:
+ await app.context.get_bean(AsyncSession).close()
+ await app.shutdown()
+
+
+@pytest.mark.asyncio
+async def test_full_lifecycle_through_booted_context(
+ booted_context: object,
+) -> None:
+ from lumen.core.services.wallets.deposit_funds_command import DepositFunds
+ from lumen.core.services.wallets.get_balance_query import GetBalance
+ from lumen.core.services.wallets.get_wallet_query import GetWallet
+ from lumen.core.services.wallets.list_rich_wallets_query import (
+ ListRichWallets,
+ )
+ from lumen.core.services.wallets.list_wallets_query import ListWallets
+ from lumen.core.services.wallets.open_wallet_command import OpenWallet
+ from lumen.core.services.wallets.withdraw_funds_command import WithdrawFunds
+ from lumen.interfaces.enums.v1.currency import Currency
+ from pyfly.cqrs import DefaultCommandBus, DefaultQueryBus
+ from pyfly.data import Pageable
+
+ ctx = booted_context
+ commands = ctx.get_bean(DefaultCommandBus)
+ queries = ctx.get_bean(DefaultQueryBus)
+
+ # --- open -> deposit -> withdraw, each a committed unit of work -------
+ w1 = await commands.send(OpenWallet(owner_id="u-1", currency=Currency.EUR))
+ w2 = await commands.send(OpenWallet(owner_id="u-2", currency=Currency.EUR))
+ assert w1.startswith("wlt-") and w2.startswith("wlt-")
+
+ assert await commands.send(DepositFunds(wallet_id=w1, amount=5000)) == 5000
+ assert await commands.send(WithdrawFunds(wallet_id=w1, amount=1500)) == 3500
+ assert await commands.send(DepositFunds(wallet_id=w2, amount=100)) == 100
+
+ # --- persistence survived: reload the aggregate via the query side ---
+ reloaded = await queries.query(GetWallet(wallet_id=w1))
+ assert reloaded is not None
+ assert reloaded.owner_id == "u-1"
+ assert reloaded.balance_minor == 3500
+
+ # --- paged list (find_all(pageable) + Page.map) ----------------------
+ page = await queries.query(ListWallets(pageable=Pageable.of(1, 10)))
+ assert page.total == 2
+ assert {w.id for w in page.items} == {w1, w2}
+
+ # --- Specification: only wallets with balance >= 1000 ----------------
+ rich = await queries.query(
+ ListRichWallets(min_minor=1000, pageable=Pageable.of(1, 10))
+ )
+ assert rich.total == 1
+ assert [w.id for w in rich.items] == [w1]
+
+ everyone = await queries.query(
+ ListRichWallets(min_minor=0, pageable=Pageable.of(1, 10))
+ )
+ assert everyone.total == 2
+
+ # --- projection-backed balance ---------------------------------------
+ balance = await queries.query(GetBalance(wallet_id=w1))
+ assert balance is not None
+ assert balance.balance_minor == 3500
+ assert balance.balance == 35.0
+:::
+
+`booted_context` usa el fixture integrado `monkeypatch` de pytest para fijar `PYFLY_DATA_RELATIONAL_URL` antes de que la aplicación arranque. El framework lee esta variable de entorno durante la autoconfiguración relacional, así que el contexto usa la base de datos SQLite de archivo temporal aislada durante la vida de la prueba, y luego la desecha cuando el fixture se desmonta.
+
+`test_full_lifecycle_through_booted_context` ejercita cada tipo de consulta que la aplicación expone: `GetWallet` (recarga del agregado), `ListWallets` (listado paginado usando `find_all(pageable)`), `ListRichWallets` (predicado de tipo Specification usando `find_all_by_spec_paged`) y `GetBalance` (saldo respaldado por proyección). Demuestra que el `RepositoryBeanPostProcessor`, la frontera `@transactional` alrededor de cada manejador de comandos y el cableado de inyección de dependencias se componen todos correctamente en un solo arranque.
+
+**Ejecútalo.**
+
+```bash
+uv run --extra dev pytest tests/test_app_context_integration.py -q
+```
+
+Salida esperada:
+
+```text
+. [100%]
+1 passed in 0.15s
+```
+
+Una prueba, pero la más pesada de la suite: realmente arrancó el framework. Si
+el escaneo de inyección de dependencias se saltó un bean, una autoconfiguración cableó mal o la frontera
+`@transactional` no consiguió hacer commit, esta es la prueba que lo atrapa, lo cual es exactamente por lo que
+se sitúa en la cima de la pirámide y por lo que solo hay una de ella.
+
+*Qué acaba de pasar.* La sustitución de la variable de entorno es el truco que lo sostiene todo.
+El framework lee `pyfly.data.relational.url` de la configuración durante la autoconfiguración
+relacional, y PyFly mapea cualquier clave de configuración a una variable de entorno `PYFLY_*`
+(los puntos y los guiones se vuelven guiones bajos, en mayúsculas), así que `PYFLY_DATA_RELATIONAL_URL`
+sustituye la `url` de `pyfly.yaml`. Fijarla con `monkeypatch` *antes* de
+`app.startup()` es lo que redirige toda la aplicación arrancada a una base de datos
+desechable, y `monkeypatch` deshace el cambio cuando el fixture se desmonta, así que ninguna
+otra prueba se ve afectada.
+
+!!! tip "Un perfil de prueba dedicado (v26.6.110)"
+ Fijar una variable está bien para una sola sustitución. Cuando un proyecto necesita un bloque
+ entero de ajustes solo de prueba, el mecanismo de **perfiles** de PyFly (equivalencia con Spring) es
+ más limpio: deja un `pyfly-test.yaml` junto a `pyfly.yaml` con tus sustituciones de prueba,
+ luego actívalo fijando `PYFLY_PROFILES_ACTIVE=test` (o
+ `pyfly.profiles.active: test` en la configuración). Al arrancar, PyFly superpone
+ `pyfly-test.yaml` sobre el `pyfly.yaml` base, así que valores como la URL de la base de datos
+ o `ddl-auto` se aplican solo bajo ese perfil. Lumen no necesita un perfil
+ —una sola sustitución de entorno cubre su único ajuste solo de prueba—, pero recurre a uno a medida que
+ la configuración de pruebas crezca.
+
+!!! spring "Equivalencia con Spring"
+ Esta prueba es el equivalente en Python de `@SpringBootTest` con una base de datos H2
+ embebida. `@SpringBootTest` carga el contexto de aplicación completo, incluidas todas las
+ autoconfiguraciones y la capa JPA; fijas `spring.datasource.url` en
+ `application-test.properties` para redirigir a H2. La sustitución de variable de entorno
+ de PyFly (`monkeypatch.setenv`) cumple el mismo papel. Ambos
+ enfoques demuestran que la aplicación compuesta funciona de extremo a extremo sin
+ infraestructura externa alguna.
+
+---
+
+## Ayudantes de pruebas del framework
+
+Las pruebas de arriba cubren la pirámide completa de Lumen con primitivas estándar de pytest y los componentes reales de producción de PyFly. Para aplicaciones más grandes o equipos que prefieren más estructura, `pyfly.testing` incluye ayudantes de más alto nivel que reflejan las anotaciones de pruebas de Spring Boot.
+
+**`PyFlyTestCase` + `mock_bean(T)`** funcionan como `@MockBean` en `@SpringBootTest`. Declara `repo = mock_bean(WalletDomainRepository)` en el cuerpo de la clase; `setup()` instala un `AsyncMock(spec=T)` en el contexto de aplicación y lo cablea en cualquier colaborador que dependa de él.
+
+**`create_test_container(overrides={Interface: Implementation})`** construye un contenedor de inyección de dependencias con dobles (fakes) registrados para interfaces concretas. Resuelve desde él la clase bajo prueba y sus dependencias ya quedan inyectadas.
+
+**`assert_event_published(events, event_type, payload_contains=...)`** rastrea una lista capturada de `EventEnvelope` en busca del primer sobre del tipo dado, opcionalmente comprueba claves del payload y devuelve el sobre para aserciones posteriores. `assert_no_events_published(events)` falla si la lista no está vacía.
+
+**Integración con Testcontainers** (`postgres_container()`, `redis_container()`, `pyfly_config(container, base={...})`) es el equivalente de PyFly a `@Testcontainers` + `@ServiceConnection` de Spring Boot. Arranca un contenedor Postgres real; `pyfly_config` reescribe la URL síncrona `psycopg2://` a `postgresql+asyncpg://` y la fusiona en un `Config` listo para arrancar un `ApplicationContext`. Instala el soporte con:
+
+```bash
+pip install 'pyfly[testcontainers]'
+```
+
+Protege cada prueba de Testcontainers con `@requires_docker` para que se omita limpiamente en máquinas sin Docker y se ejecute automáticamente en los ejecutores de CI que sí lo tengan:
+
+```python
+from pyfly.testing import postgres_container, pyfly_config, requires_docker
+
+@requires_docker
+async def test_wallet_round_trip_against_real_postgres():
+ with postgres_container() as pg:
+ config = pyfly_config(pg, base={"pyfly.data.enabled": True})
+ assert config.get("pyfly.data.relational.url").startswith(
+ "postgresql+asyncpg://"
+ )
+ ...
+```
+
+Lumen no usa estos ayudantes: SQLite cubre la capa de persistencia sin Docker, y el bus en memoria cubre el enrutamiento de eventos. Recurre a ellos cuando tu proyecto tenga infraestructura que no pueda reproducirse sin un demonio real.
+
+**Ejecútalo.** Ya has recorrido cada capa archivo a archivo. Ejecuta toda la suite una vez
+más para confirmar que la pirámide completa está en verde en conjunto:
+
+```bash
+uv run --extra dev pytest -q
+```
+
+Salida esperada: el mismo `41 passed` con el que empezaste, ahora con un modelo mental de
+exactamente lo que demuestra cada punto:
+
+```text
+......................................... [100%]
+41 passed in 0.28s
+```
+
+---
+
+## Lo que construiste {.recap}
+
+Los seis archivos de prueba que construyó este capítulo suman 26 pruebas superadas, ejercitando cada capa de la pirámide. Junto con las pruebas de la saga del Capítulo 12 y las pruebas de event sourcing del Capítulo 9, la suite completa de Lumen son **41 pruebas superadas**: el recuento que viste cuando ejecutaste `uv run --extra dev pytest -q` al principio.
+
+En la base, `test_money.py` y `test_wallet_aggregate.py` demuestran la aritmética, la inmutabilidad y las reglas de invariante del modelo de dominio. Todas las pruebas son funciones síncronas de Python puro, sin fixtures, sin inyección de dependencias, sin `async`. El atributo `BusinessRuleViolation.rule` hace que cada aserción sea específica del invariante exacto incumplido.
+
+En el centro, `conftest.py` cablea los componentes reales —el `WalletRepository` del framework sobre un motor SQLite en memoria, `InMemoryEventBus`, los cinco manejadores de comandos y consultas (incluidos `ListWalletsHandler` y `ListRichWalletsHandler`) y `WalletAuditListener`— en fixtures asíncronos reutilizables que pytest comparte automáticamente entre módulos. El `RepositoryBeanPostProcessor` se aplica al fixture del repositorio exactamente como el `ApplicationContext` lo aplica al arrancar. `test_cqrs_flow.py` despacha comandos y consultas a través del bus real y comprueba cada campo de los DTO de consulta. `test_event_listener.py` demuestra que el listener de auditoría observa exactamente los eventos producidos por comandos exitosos y nada de los rechazados.
+
+`test_sql_wallet_repository.py` ejercita el `WalletRepository` directamente contra un archivo SQLite temporal, cubriendo la superficie CRUD completa, la consulta derivada `find_by_owner_id` (compilada a partir del nombre del método por `RepositoryBeanPostProcessor`), la API `find_all(pageable)` que devuelve una `Page` con recuento total y metadatos de página, y el camino del predicado de tipo `Specification` vía `find_rich` / `find_all_by_spec`. El patrón de reconexión de dos motores demuestra la durabilidad verdadera.
+
+En la cima, `test_app_context_integration.py` arranca la `LumenApplication` real con la URL de la base de datos sustituida por un archivo SQLite aislado, y luego recorre el ciclo de vida completo de apertura → depósito → retiro → listado → ricos → saldo a través de los buses resueltos por el contexto. Esta única prueba demuestra que el escaneo de inyección de dependencias, la autoconfiguración de CQRS, el `RepositoryBeanPostProcessor` y la frontera `@transactional` se componen todos correctamente.
+
+En concreto, aprendiste:
+
+- **`asyncio_mode = "auto"` + `pythonpath = ["src"]`** en `pyproject.toml`:
+ todas las pruebas asíncronas se ejecutan sin decoradores; la disposición `src/` es importable.
+- **`uv run --extra dev pytest -q`**: el `uv sync` a secas omite el grupo de desarrollo;
+ incluye siempre `--extra dev` para obtener pytest.
+- **`@pytest_asyncio.fixture`**: ciclo de vida del fixture asíncrono gestionado por
+ pytest-asyncio; el `@pytest.fixture` simple no maneja generadores asíncronos.
+- **Instancias de fixture compartidas**: cuando dos fixtures solicitan el mismo nombre de
+ fixture (p. ej., `event_bus`, `session_factory`), pytest lo resuelve una vez por
+ prueba y comparte la instancia.
+- **`RepositoryBeanPostProcessor().after_init(repo, name)`**: debe
+ llamarse en las pruebas que ejercitan consultas derivadas; sin él, los esbozos
+ de método lanzan `NotImplementedError`.
+- **Consultas derivadas** (`find_by_owner_id`): declaradas como esbozos; el
+ post-procesador las compila a `WHERE owner_id = :value` al arrancar.
+- **`Pageable.of(page, size, sort)` + `Page`**: la API `find_all(pageable)`
+ devuelve una `Page` con `total`, `total_pages`, `has_next` e
+ `items`; comprueba cada campo para verificar la corrección de la paginación.
+- **Predicados de tipo `Specification`**: `balance_at_least(n)` se pasa a
+ `find_rich` / `find_all_by_spec` para filtrar por un predicado arbitrario
+ sin añadir un nuevo método de consulta derivada.
+- **`pending_events()` frente a `clear_events()`**: `pending_events()` lee
+ sin vaciar; `clear_events()` vacía. Llama siempre a `clear_events()` en
+ los pasos de organización para que las aserciones solo vean eventos del paso de actuación.
+- **`BusinessRuleViolation.rule`**: comprueba la cadena de regla exacta, no solo
+ la clase de excepción, para demostrar que se disparó el invariante correcto.
+- **`monkeypatch.setenv`**: sustituye la configuración antes de arrancar el
+ contexto en las pruebas de integración; el framework lee variables de entorno
+ durante la autoconfiguración.
+- **Sustituciones de configuración `PYFLY_*`**: cada clave de configuración se mapea a una variable de
+ entorno (`pyfly.data.relational.url` → `PYFLY_DATA_RELATIONAL_URL`); fíjala
+ con `monkeypatch.setenv` antes de arrancar para redirigir toda la aplicación.
+- **Perfil de prueba (v26.6.110)**: para un bloque de ajustes solo de prueba, añade un
+ superpuesto `pyfly-test.yaml` y actívalo con `PYFLY_PROFILES_ACTIVE=test`
+ (equivalencia con el `application-test.yaml` de Spring).
+- **Ayudantes del framework** (`PyFlyTestCase`, `mock_bean`, `create_test_container`,
+ Testcontainers): disponibles en `pyfly.testing` para proyectos que los necesiten;
+ Lumen lo mantiene simple con componentes reales.
+
+---
+
+## Pruébalo tú mismo {.exercises}
+
+1. **Añade una prueba para el retiro de importe cero.** En `test_wallet_aggregate.py`,
+ añade `test_withdraw_zero_is_rejected`. Abre un monedero, deposita 500 EUR y luego
+ intenta `wallet.withdraw(Money(0, Currency.EUR))`. Comprueba que se lanza una
+ `BusinessRuleViolation` y verifica su atributo `.rule`. Compara
+ el nombre de la regla con el equivalente del depósito: ¿son simétricas las reglas?
+
+2. **Prueba un monedero desconocido vía el bus de CQRS.** En `test_cqrs_flow.py`, añade
+ `test_withdraw_from_unknown_wallet_is_rejected`. Envía solo un
+ comando `WithdrawFunds(wallet_id="wlt-ghost", amount=50)` sin abrir un
+ monedero antes. Comprueba que se lanza `CommandProcessingException`. Confirma
+ que el repositorio sigue sin contener monederos consultando
+ `GetWallet(wallet_id="wlt-ghost")` y comprobando `None`.
+
+3. **Amplía la prueba del listener con un segundo monedero.** En
+ `test_event_listener.py`, añade una prueba que abra dos monederos (`u-A` y
+ `u-B`), deposite importes distintos en cada uno y luego llame a
+ `audit_listener.entries_for(wallet_id_A)` y
+ `audit_listener.entries_for(wallet_id_B)` por separado. Comprueba que cada uno
+ devuelve exactamente dos entradas (`WalletOpened` + `FundsDeposited`) y que
+ los valores de `amount` del payload difieren. Esto demuestra que `entries_for` filtra
+ por ID de monedero, no por tipo de evento.
+
+4. **Añade una prueba de paginación multipropietario.** En `test_sql_wallet_repository.py`,
+ añade una prueba que inserte diez monederos con dos propietarios distintos, llame a
+ `find_by_owner_id` para cada propietario y luego llame a `find_all` con
+ `Pageable.of(1, 3, Sort.by("balance_minor").descending())`. Comprueba que
+ `page.total == 10`, `page.total_pages == 4` y que el primer elemento de
+ `page.items` tiene el `balance_minor` más alto. Esto demuestra que la paginación
+ es independiente del filtro de consulta derivada.
diff --git a/book/manuscript-es/17-scheduling-notifications.md b/book/manuscript-es/17-scheduling-notifications.md
new file mode 100644
index 00000000..06549e09
--- /dev/null
+++ b/book/manuscript-es/17-scheduling-notifications.md
@@ -0,0 +1,1184 @@
+Capítulo 17
+
+# Programación, notificaciones, webhooks y callbacks {.chtitle}
+
+::: figure art/openers/ch17.svg |
+
+En el Capítulo 16 dotaste a Lumen de un arnés de pruebas completo: pruebas unitarias para el dominio, pruebas de flujo CQRS a través del bus real y una prueba del adaptador de SQLite que demuestra una persistencia real. Lumen está ahora bien probado y es resiliente. Pero sigue viviendo enteramente dentro de su propio proceso: reacciona a las peticiones, pero nunca toma la iniciativa por sí mismo.
+
+Las plataformas financieras reales son distintas. Envían extractos de cuenta nocturnos, disparan un SMS en el momento en que los fondos llegan, reciben webhooks de estado de pago de un proveedor de pagos a medianoche y llaman de vuelta a sistemas de socios para confirmar que un desembolso quedó registrado. Eso son cuatro patrones de integración distintos —programación, notificaciones, webhooks entrantes y callbacks salientes— y este capítulo los cubre todos.
+
+Al final del capítulo Lumen:
+
+- ejecutará un **trabajo programado nocturno** que totaliza los saldos diarios de los monederos usando `@scheduled` con una expresión cron;
+- enviará un **correo electrónico y una notificación push de "fondos recibidos"** a través de los puertos enchufables `EmailService` y `PushService` de PyFly, disparados por el evento de dominio real `FundsDeposited`;
+- aceptará **webhooks entrantes** de un proveedor de pagos ilustrativo, verificando la firma HMAC-SHA256, deduplicando reintentos y despachando a un listener tipado;
+- despachará **callbacks salientes** a sistemas de socios, firmando cada carga útil, reintentando ante fallos transitorios y registrando cada intento de entrega.
+
+Instala los dos extras opcionales antes de empezar:
+
+```
+uv add "pyfly[scheduling,notifications]"
+```
+
+!!! note "Término nuevo: extras opcionales"
+ Un *extra* es una porción opt-in de las dependencias de un paquete. `pyfly`
+ mantiene su núcleo ligero y entrega capacidades más pesadas —programación,
+ notificaciones y el resto— detrás de extras con nombre, de modo que solo
+ instalas lo que usas. `pyfly[scheduling,notifications]` trae ambos. La sintaxis
+ de corchetes es estándar en el empaquetado de Python; `uv add` lo registra en
+ tu `pyproject.toml` para que el siguiente `uv sync` los reinstale. Si más
+ adelante ves un `ModuleNotFoundError` para `croniter` o para un proveedor de
+ notificaciones, te saltaste esta línea: vuelve a ejecutarla.
+
+Este capítulo está dirigido a PyFly **v26.6.110**. Cada listado de código de abajo
+coincide con el código real de Lumen bajo `samples/lumen/src/lumen`, y cada API
+del framework se comprobó contra el propio `pyfly`, así que lo que construyas aquí
+se ejecuta sin cambios.
+
+---
+
+## Tareas programadas
+
+### ¿Por qué programar en lugar de disparar?
+
+Muchas operaciones de una plataforma financiera no pueden esperar a que llegue una petición HTTP. La conciliación nocturna debe ejecutarse a las 02:00 independientemente de si hay algún usuario activo. Una pasada de calentamiento de caché debería dispararse 30 segundos después del arranque —antes de que llegue el tráfico real— y no cuando la primera petición lenta provoque un fallo de caché. Una métrica de latido debería emitirse cada 10 segundos para que el panel de operaciones muestre una señal en vivo, no una lectura obsoleta.
+
+El módulo de programación de PyFly proporciona una forma declarativa, basada en decoradores, de definir los tres patrones sin gestionar manualmente hilos, bucles de eventos ni ruedas de temporizadores.
+
+::: figure art/figures/17-integrations.svg | Figura 17.1 — Las cuatro\
+capas de integración añadidas en este capítulo. Las tareas programadas se disparan\
+internamente; las notificaciones fluyen hacia afuera, hacia los usuarios; los webhooks entrantes\
+llegan desde los socios; los callbacks salientes cierran el bucle de realimentación.
+
+### El decorador @scheduled
+
+**`@scheduled`** marca cualquier método `async` de un bean `@service` para su ejecución periódica. Acepta exactamente un *disparador* (trigger): `fixed_rate`, `fixed_delay` o `cron`. Proporcionar cero o más de un disparador lanza un `ValueError` en el momento de la decoración, de modo que los errores afloran al arrancar y no silenciosamente a las tres de la madrugada.
+
+!!! note "Término nuevo: disparador"
+ Un *disparador* (trigger) es la regla que decide *cuándo* se ejecuta un método
+ programado. Eliges exactamente uno: `cron` (una expresión de calendario como
+ "todos los días a las 02:00"), `fixed_rate` (un intervalo constante como "cada
+ 10 segundos") o `fixed_delay` (un hueco medido después de que cada ejecución
+ termina). Un método, un disparador.
+
+Construyamos el resumen nocturno una decisión a la vez.
+
+**Paso 1 — Crea el archivo de servicio.** En el árbol de Lumen, añade `daily_rollup.py`
+bajo un paquete `ledger`. La clase es un `@service` corriente: una clase Python
+sencilla que PyFly registra en su contenedor de inyección de dependencias y
+construye por ti. Como es un bean gestionado, puedes pedir el `WalletRepository` en
+el constructor y el framework te lo entrega; nunca llamas a `new` tú mismo.
+
+**Paso 2 — Elige el disparador.** Este trabajo debe ejecutarse una vez por noche, a hora fija, así que
+el disparador es `cron`. La expresión `"0 2 * * *"` se lee campo a campo como
+*minuto 0, hora 2, cada día del mes, cada mes, cada día de la semana* —es decir,
+las 02:00 cada día.
+
+**Paso 3 — Escribe el trabajo.** Dentro del método, carga cada monedero, suma los
+enteros `balance_minor` persistidos y (por ahora) registra el total. En producción
+escribirías la instantánea en una tabla de informes o la enviarías aguas abajo; el
+registro mantiene el ejemplo centrado en la *programación*, no en la contabilidad.
+
+::: listing lumen/ledger/daily_rollup.py | Listado 17.1 — Resumen nocturno de saldos de monederos con @scheduled
+from datetime import timedelta
+
+from lumen.models.repositories.wallet_repository import WalletRepository
+from pyfly.container import service
+from pyfly.scheduling import scheduled
+
+
+@service
+class DailyRollupService:
+ """Tallies all wallet balances once per night at 02:00 UTC."""
+
+ def __init__(self, wallet_repo: WalletRepository) -> None:
+ self._wallets = wallet_repo
+
+ @scheduled(cron="0 2 * * *")
+ async def run(self) -> None:
+ wallets = await self._wallets.find_all()
+ # find_all() returns WalletEntity rows; balance_minor is the
+ # persisted integer (cents).
+ total_minor_units = sum(w.balance_minor for w in wallets)
+ # Persist or ship the nightly snapshot; here we log it.
+ print(
+ f"[rollup] {len(wallets)} wallets, "
+ f"total {total_minor_units / 100:.2f} "
+ f"(minor units: {total_minor_units})"
+ )
+:::
+
+**Cómo funciona.** `@scheduled(cron="0 2 * * *")` se dispara todos los días a las 02:00 UTC. El planificador calcula `seconds_until_next()` mediante `CronExpression`, duerme exactamente ese tiempo y luego envía el método al ejecutor.
+
+Eso es todo el cableado que Lumen necesita. Con `pyfly[scheduling]` instalado, `SchedulingAutoConfiguration` automáticamente:
+
+1. registra un bean `TaskScheduler`;
+2. escanea cada bean `@service` en busca de métodos `@scheduled`;
+3. arranca el planificador durante el arranque del `ApplicationContext`;
+4. lo detiene con elegancia al apagarse.
+
+No se requiere ningún `SchedulerManager`.
+
+!!! note "Término nuevo: autoconfiguración"
+ La *autoconfiguración* es PyFly fijándose en lo que hay en tu classpath y
+ cableando por ti la maquinaria correspondiente. `SchedulingAutoConfiguration`
+ solo se activa cuando `croniter` (incluido por el extra `scheduling`) es
+ importable, así que el planificador aparece en el momento en que instalas el
+ extra y permanece ausente en caso contrario. Es la misma idea de "convención
+ sobre configuración" que hizo famosa Spring Boot; siempre puedes reemplazar un
+ bean declarando el tuyo propio.
+
+**Ejecútalo.** Esperar hasta las 02:00 para ver tu primer tic no tiene gracia, así que
+cambia temporalmente el disparador para que se ejecute cada minuto —`@scheduled(cron="* * * * *")`— e
+inicia la aplicación desde el directorio `samples/lumen`:
+
+```bash
+uv run pyfly run --server uvicorn
+```
+
+Al comienzo del siguiente minuto deberías ver tu línea de resumen en los logs (una
+base de datos vacía simplemente informa de cero monederos):
+
+```text
+[rollup] 0 wallets, total 0.00 (minor units: 0)
+```
+
+Abre un monedero y deposita en él (consulta las recetas de curl en el Capítulo 7), espera al
+siguiente minuto y los totales se mueven:
+
+```text
+[rollup] 1 wallets, total 15.00 (minor units: 1500)
+```
+
+Detén la aplicación con Ctrl-C y **vuelve a poner el disparador en `"0 2 * * *"`** antes de
+confirmar el cambio: el cron de cada minuto era solo una sonda.
+
+!!! note "Qué acaba de pasar"
+ No arrancaste un hilo, ni abriste un bucle de eventos, ni registraste un
+ temporizador. Escribiste un `@service` con un método `@scheduled`, y el
+ framework lo descubrió al arrancar, calculó el siguiente momento de disparo a
+ partir de la expresión cron, durmió hasta entonces y ejecutó tu corrutina,
+ repitiendo para siempre. El planificador es *declarativo*: tú indicas *cuándo*,
+ PyFly se encarga del *cómo*.
+
+### fixed_rate frente a fixed_delay
+
+**`fixed_rate`** mide desde el **inicio** de una ejecución hasta el inicio de la siguiente. **`fixed_delay`** mide desde el **final** de una ejecución hasta el inicio de la siguiente. Usa `fixed_rate` para latidos y métricas donde necesitas una cadencia constante con independencia del tiempo de ejecución. Usa `fixed_delay` cuando necesitas un hueco de respiro garantizado; por ejemplo, al sondear una API aguas arriba que limita la tasa por frecuencia de peticiones.
+
+::: listing lumen/health/monitor.py | Listado 17.2 — Latido fixed_rate y sondeo fixed_delay
+from datetime import timedelta
+
+from pyfly.container import service
+from pyfly.scheduling import scheduled
+
+
+@service
+class HealthMonitor:
+
+ @scheduled(
+ fixed_rate=timedelta(seconds=10),
+ initial_delay=timedelta(seconds=5),
+ )
+ async def heartbeat(self) -> None:
+ """Emit a liveness metric every 10 s, starting 5 s after startup."""
+ # metrics.gauge("lumen.up", 1)
+ pass
+
+
+@service
+class ExchangeRatePoller:
+
+ def __init__(self, fx_repo) -> None:
+ self._repo = fx_repo
+
+ @scheduled(fixed_delay=timedelta(minutes=5))
+ async def poll(self) -> None:
+ """Fetch the latest exchange rates, then wait 5 min before repeating."""
+ rates = await self._repo.fetch_latest()
+ await self._repo.store(rates)
+:::
+
+`initial_delay` pospone la primera ejecución; está disponible tanto para `fixed_rate` como para `fixed_delay` (se ignora en los disparadores `cron`, que siempre esperan al siguiente instante de calendario coincidente).
+
+### CronExpression
+
+**`CronExpression`** también es utilizable directamente: resulta conveniente cuando necesitas mostrar próximos horarios de programación en una interfaz o validar una expresión proporcionada por el usuario antes de almacenarla.
+
+::: listing lumen/ledger/schedule_preview.py | Listado 17.3 — Uso de CronExpression de forma independiente
+from pyfly.scheduling import CronExpression
+
+
+def preview_rollup_schedule(expression: str, n: int = 5) -> list[str]:
+ """Return the next N fire times for a given cron expression."""
+ cron = CronExpression(expression)
+ return [str(t) for t in cron.next_n_fire_times(n)]
+:::
+
+**Ejecútalo.** `CronExpression` no necesita una aplicación en marcha, así que la forma más rápida de construir
+intuición es el REPL. Desde `samples/lumen`:
+
+```bash
+uv run python -c "from pyfly.scheduling import CronExpression; \
+print(*CronExpression('0 2 * * *').next_n_fire_times(3), sep='\n')"
+```
+
+Deberías ver los tres próximos instantes de las 02:00, uno por línea (tus fechas
+serán distintas):
+
+```text
+2026-06-16 02:00:00+00:00
+2026-06-17 02:00:00+00:00
+2026-06-18 02:00:00+00:00
+```
+
+Fíjate en el `+00:00`: los momentos de disparo son UTC salvo que pases una `zone` (que se cubre
+a continuación). Esta es también la forma más limpia de comprobar la cordura de una expresión proporcionada
+por el usuario antes de almacenarla: una cadena no válida lanza `ValueError` de inmediato.
+
+`CronExpression` acepta tanto el formato estándar de 5 campos (`min hour dom month dow`) como el formato de 6 campos al estilo Spring con los segundos primero (`sec min hour dom month dow`). El comodín `?` de Spring se normaliza a `*` de forma transparente.
+
+| Expresión | Se dispara |
+|---|---|
+| `* * * * *` | Cada minuto |
+| `0 * * * *` | Cada hora, en punto |
+| `0 2 * * *` | Cada día a las 02:00 |
+| `0 9 * * 1-5` | Días laborables a las 09:00 |
+| `30 2 1 * *` | El día 1 de cada mes a las 02:30 |
+| `*/15 * * * *` | Cada 15 minutos |
+| `0 0 12 * * *` | Mediodía cada día (6 campos, segundos primero) |
+
+### Cron consciente de la zona horaria
+
+Las expresiones cron se evalúan en **UTC** por defecto. Pasa `zone` con un nombre de zona horaria IANA para evaluar los momentos de disparo en una zona específica en su lugar:
+
+```python
+@scheduled(cron="0 2 * * *", zone="America/New_York")
+async def close_books(self) -> None:
+ """02:00 New York time — follows DST automatically."""
+ ...
+```
+
+El mismo parámetro `zone` está disponible en `CronExpression`:
+
+```python
+cron = CronExpression("0 9 * * *", zone="Europe/Madrid")
+next_run = cron.next_fire_time() # zone-aware datetime
+```
+
+Las transiciones de horario de verano (DST) las gestiona la base de datos `zoneinfo`; no se requiere ningún ajuste manual de desfase.
+
+### Bloqueo distribuido
+
+Cuando varias instancias de Lumen se ejecutan detrás de un balanceador de carga, cada instancia programa los mismos métodos `@scheduled`. Sin coordinación, el resumen nocturno se dispara una vez por instancia y escribe registros duplicados. El parámetro `lock` resuelve esto de la misma manera que `@SchedulerLock` (ShedLock) de Spring: antes de cada tic el planificador intenta adquirir un bloqueo con nombre y **omite la ejecución** si el bloqueo ya está retenido en otro lugar.
+
+```python
+@scheduled(cron="0 2 * * *", lock=True, lock_ttl=timedelta(minutes=5))
+async def run(self) -> None:
+ """lock=True auto-names the lock 'DailyRollupService.run'."""
+ ...
+```
+
+- `lock=True` — deriva el nombre del bloqueo de `"ClassName.method_name"`.
+- `lock="shared-name"` — nombre explícito; útil cuando dos métodos deben ser mutuamente excluyentes.
+- `lock_ttl` — TTL de válvula de seguridad; ponlo cómodamente más largo que el peor tiempo de ejecución del trabajo.
+
+Por defecto `TaskScheduler` usa `LocalLock`, que siempre adquiere: el comportamiento de instancia única no cambia. Para la coordinación entre procesos, implementa `DistributedLock` y regístralo como un bean:
+
+::: listing lumen/infra/redis_lock.py | Listado 17.4 — DistributedLock respaldado por Redis
+from pyfly.container import bean, configuration
+from pyfly.scheduling import DistributedLock
+
+
+class RedisLock:
+ """Best-effort named lock backed by Redis SET NX PX."""
+
+ def __init__(self, redis) -> None:
+ self._redis = redis
+
+ async def try_acquire(self, name: str, ttl: float) -> bool:
+ ok = await self._redis.set(
+ f"pyfly:lock:{name}", "1",
+ nx=True, px=int(ttl * 1000),
+ )
+ return ok is True
+
+ async def release(self, name: str) -> None:
+ await self._redis.delete(f"pyfly:lock:{name}")
+
+
+@configuration
+class LockConfig:
+
+ @bean
+ def distributed_lock(self) -> DistributedLock:
+ import redis.asyncio as aioredis
+ client = aioredis.from_url("redis://localhost:6379/1")
+ return RedisLock(client)
+:::
+
+`SchedulingAutoConfiguration` detecta el bean `DistributedLock` en el contenedor automáticamente y se lo pasa al `TaskScheduler`. Cualquier objeto con corrutinas `try_acquire` y `release` conformes satisface el protocolo.
+
+### @async_method
+
+**`@async_method`** marca un método para ejecución fire-and-forget (dispara y olvida) a través del `TaskExecutorPort`. La persona que llama retorna de inmediato; el framework encamina la corrutina a través del ejecutor configurado en segundo plano:
+
+```python
+from pyfly.scheduling import async_method
+
+
+@service
+class AlertService:
+
+ @async_method
+ async def send_alert(self, msg: str) -> None:
+ """Caller does not await — AlertService dispatches asynchronously."""
+ ...
+```
+
+Bajo el capó `@async_method` establece `__pyfly_async__ = True` en la función; el framework detecta esta bandera y envía la corrutina al `TaskExecutorPort`.
+
+!!! spring "Equivalencia con Spring"
+ `@scheduled(fixed_rate=...)` refleja el
+ `@Scheduled(fixedRate=...)` de Spring. `@scheduled(fixed_delay=...)` refleja
+ `@Scheduled(fixedDelay=...)`. `@scheduled(cron=...)` refleja
+ `@Scheduled(cron=...)`. `zone=` refleja el atributo `zone` de Spring.
+ `lock=True` refleja el `@SchedulerLock` de ShedLock. `@async_method`
+ refleja el `@Async` de Spring.
+
+### Referencia de configuración
+
+```yaml
+pyfly:
+ scheduling:
+ enabled: true # set false to disable all loops
+ executor:
+ type: asyncio # 'asyncio' (default, in-loop) or 'thread'
+ max-workers: 4 # worker threads when type is 'thread'
+ lock:
+ provider: none # none | memory | redis | postgres
+```
+
+Cuando `enabled` es `false`, `TaskScheduler` no arranca ningún bucle y todos los métodos `@scheduled` se omiten en silencio.
+
+El `executor.type` elige cómo se ejecuta cada tic. El valor por defecto `asyncio` ejecuta la
+corrutina en el bucle de eventos de la aplicación —ideal para trabajos cortos, ligados a E/S, como
+el resumen—. Cambia a `thread` (un grupo de `executor.max-workers` hilos) cuando un
+trabajo realice trabajo intensivo de CPU o llame a una biblioteca bloqueante, de modo que no pueda atascar el bucle.
+
+!!! tip "Cómo elegir un proveedor de bloqueo"
+ `lock.provider` selecciona el backend detrás de `@scheduled(lock=...)`, descrito
+ a continuación: `none` (el valor por defecto: sin coordinación), `memory` (exclusión mutua
+ dentro de un proceso), `redis` o `postgres` (verdadera coordinación entre instancias
+ sin cambios en el código). En `redis`/`postgres` PyFly construye el
+ bean `DistributedLock` por ti a partir de `pyfly.scheduling.lock.redis.url` o el
+ `AsyncEngine` ya existente de la aplicación; el `@bean` artesanal del Listado 17.4 es la
+ alternativa de hazlo-tú-mismo cuando necesitas semánticas personalizadas.
+
+---
+
+## Notificaciones
+
+Lumen necesita decirles a los clientes que su dinero ha llegado: un correo electrónico para la confirmación del saldo y, opcionalmente, un SMS o un push móvil para la alerta en tiempo real.
+
+El módulo de notificaciones de PyFly define tres **protocolos de puerto** y tres **servicios por defecto**. Tu lógica de negocio depende de los protocolos; los adaptadores concretos de proveedor —SMTP, SendGrid, Twilio, Firebase— viven detrás de la frontera del puerto y pueden intercambiarse sin tocar una sola línea de código de dominio.
+
+!!! note "Término nuevo: puerto y adaptador"
+ Un *puerto* es una interfaz con la que tu código conversa —aquí, "algo capaz de
+ enviar un correo electrónico"—. Un *adaptador* es una implementación concreta de ese puerto:
+ `SmtpEmailProvider`, `SendGridEmailProvider`, etcétera. El patrón (también
+ llamado *arquitectura hexagonal*) significa que tu lógica de depósito depende solo del
+ puerto `EmailService`, nunca de un proveedor específico. Cambiar SMTP por SendGrid
+ es un cambio de una línea en una clase de configuración; el código de dominio nunca se entera.
+
+### La jerarquía de puertos
+
+| Protocolo | Clase de servicio | Método |
+|---|---|---|
+| `EmailProvider` | `DefaultEmailService` | `send(EmailMessage) -> NotificationResult` |
+| `SmsProvider` | `DefaultSmsService` | `send(SmsMessage) -> NotificationResult` |
+| `PushProvider` | `DefaultPushService` | `send(PushMessage) -> NotificationResult` |
+
+`DefaultEmailService`, `DefaultSmsService` y `DefaultPushService` son envoltorios finos: cada uno delega en un proveedor, captura cualquier excepción del proveedor y devuelve un `NotificationResult` estructurado con `status=FAILED` y la cadena del error en lugar de propagar la excepción. Una caída transitoria de SendGrid no tumba el manejador (handler) de depósitos.
+
+### Mensajes y resultados
+
+`FundsDeposited` lleva `amount` y `balance` como **unidades menores enteras** (céntimos). La propiedad `Money.major_units` las convierte para su visualización —`Money(25000, Currency.EUR).major_units` es `250.0`—. Tenlo en cuenta al formatear los cuerpos de las notificaciones.
+
+::: listing lumen/notifications/models_overview.py | Listado 17.5 — Los DTO principales
+from pyfly.notifications import (
+ EmailMessage,
+ NotificationResult,
+ PushMessage,
+ SmsMessage,
+)
+
+# Email — full field set
+email = EmailMessage(
+ to=["alice@example.com"],
+ sender="no-reply@lumenbank.com",
+ subject="Funds received",
+ body_text="EUR 250.00 has been credited to your wallet.",
+ body_html=(
+ "EUR 250.00 has been credited "
+ "to your wallet.
"
+ ),
+)
+
+# SMS — compact
+sms = SmsMessage(
+ to="+34600000001",
+ body="Lumen: EUR 250.00 received. New balance: EUR 750.00.",
+ sender="LUMEN",
+)
+
+# Push — structured payload
+push = PushMessage(
+ device_tokens=["FCM_TOKEN_GOES_HERE"],
+ title="Funds received",
+ body="EUR 250.00 credited",
+ data={"wallet_id": "w-001", "amount_minor": 25000},
+)
+:::
+
+`NotificationResult` lleva `id`, `provider`, `status` (`EmailStatus.SENT | DELIVERED | FAILED | ...`), un `provider_id` opcional (p. ej. el ID de mensaje de SendGrid) y un `error` opcional.
+
+### Cableando el proveedor SMTP
+
+Para desarrollo y despliegues autoalojados, `SmtpEmailProvider` ejecuta `smtplib` desde un grupo de hilos para que el bucle de eventos asíncrono nunca se bloquee.
+
+**Paso 1 — Construye el proveedor como un `@bean`.** Una clase `@configuration` es el lugar
+de PyFly para ensamblar objetos que el contenedor no puede construir por sí solo —aquí, un
+cliente SMTP que necesita un host, credenciales y ajustes de TLS—. Cada método `@bean`
+devuelve un objeto listo para usar; el framework lo cachea y lo inyecta
+allá donde se solicite el tipo de retorno.
+
+**Paso 2 — Envuélvelo en un `DefaultEmailService`.** El segundo `@bean` toma el
+proveedor y lo devuelve como un `EmailService` —el *puerto* del que depende tu código
+de dominio—. El tipo de retorno declarado importa: al devolver `EmailService`, cada
+clase que pide un `EmailService` recibe este envoltorio, y ninguna de ellas
+se entera de que SMTP está detrás.
+
+::: listing lumen/notifications/config.py | Listado 17.6 — Proveedor SMTP cableado como un @bean
+from pyfly.container import bean, configuration
+from pyfly.notifications import DefaultEmailService, EmailService
+from pyfly.notifications.providers.smtp import SmtpEmailProvider
+
+
+@configuration
+class NotificationConfig:
+
+ @bean
+ def email_provider(self) -> SmtpEmailProvider:
+ return SmtpEmailProvider(
+ "smtp.lumenbank.internal",
+ port=587,
+ username="notifications",
+ password="s3cr3t",
+ use_tls=True,
+ )
+
+ @bean
+ def email_service(
+ self, provider: SmtpEmailProvider,
+ ) -> EmailService:
+ return DefaultEmailService(provider=provider)
+:::
+
+`SmtpEmailProvider` acepta `host`, `port` (por defecto `587`), `username`, `password` y `use_tls` (por defecto `True`). Cámbialo por `SendGridEmailProvider` o `ResendEmailProvider` modificando un único método `@bean`: `DefaultEmailService` es indiferente al proveedor que tenga detrás.
+
+!!! tip "Proveedores disponibles"
+ `pyfly.notifications` incluye ocho adaptadores integrados:
+ `DummyEmailProvider` / `DummySmsProvider` / `DummyPushProvider`
+ (solo registran, para desarrollo/pruebas), `SmtpEmailProvider`,
+ `SendGridEmailProvider`, `ResendEmailProvider` (correo electrónico),
+ `TwilioSmsProvider` (SMS) y `FirebasePushProvider` (push). Todos
+ satisfacen su respectivo protocolo `*Provider`.
+
+### Enviando una notificación de "fondos recibidos"
+
+Lumen publica un evento de dominio `FundsDeposited` cada vez que el comando `deposit()` tiene éxito (consulta el Capítulo 8). El lugar adecuado para disparar la notificación es un listener de EDA suscrito a ese evento, no el propio manejador del comando, lo que mantiene la ruta de depósito libre de preocupaciones de notificación.
+
+`FundsDeposited` lleva `wallet_id: str`, `amount: int` (unidades menores), `currency: str` y `balance: int` (nuevo saldo, unidades menores). El listener convierte `amount` en una cadena de visualización mediante `amount / 100`.
+
+**Paso 1 — Suscríbete al evento.** Apila `@event_listener(event_types=["FundsDeposited"])`
+sobre un método `async` de un `@service`. Al arrancar, PyFly descubre el método
+marcado y lo autosuscribe al bus `EventPublisher` —exactamente el mismo
+mecanismo que `WalletAuditListener` usó allá en el Capítulo 8—. Nunca cableas un bus a
+mano.
+
+**Paso 2 — Lee la carga útil.** El manejador recibe un `EventEnvelope`. Su
+`payload` es un dict sencillo de los campos del evento, así que extraes `wallet_id`,
+`amount`, `currency` y `balance` con `.get(...)` y coaccionas los importes
+a enteros. Como los importes están en unidades menores, dividir por 100 da el valor
+de visualización: `25000` se convierte en `250.00`.
+
+**Paso 3 — Envía a través de los puertos.** Inyecta `EmailService` y `PushService` en
+el constructor y llama a `.send(...)` en cada uno. Ambos devuelven un `NotificationResult`
+en lugar de lanzar una excepción: un proveedor inestable se degrada con elegancia en lugar de hacer caer
+el listener.
+
+::: listing lumen/wallet/deposit_notification_listener.py | Listado 17.7 — Notificando ante FundsDeposited
+from pyfly.container import service
+from pyfly.eda import EventEnvelope, event_listener
+from pyfly.notifications import (
+ EmailMessage,
+ EmailService,
+ PushMessage,
+ PushService,
+)
+
+
+@service
+class DepositNotificationListener:
+ """Sends email + push when a FundsDeposited event is observed."""
+
+ def __init__(
+ self,
+ email_service: EmailService,
+ push_service: PushService,
+ ) -> None:
+ self._email = email_service
+ self._push = push_service
+
+ @event_listener(event_types=["FundsDeposited"])
+ async def on_funds_deposited(
+ self, envelope: EventEnvelope,
+ ) -> None:
+ payload = envelope.payload
+ wallet_id = str(payload.get("wallet_id", ""))
+ amount_minor = int(payload.get("amount", 0))
+ currency = str(payload.get("currency", "EUR"))
+ balance_minor = int(payload.get("balance", 0))
+ amount_str = f"{amount_minor / 100:.2f} {currency}"
+ balance_str = f"{balance_minor / 100:.2f} {currency}"
+
+ # Fetch contact details from a wallet profile service in prod;
+ # hardcoded here for illustration.
+ email = "customer@example.com"
+ device_token = "FCM_TOKEN_GOES_HERE"
+
+ await self._email.send(EmailMessage(
+ to=[email],
+ sender="no-reply@lumenbank.com",
+ subject=f"Funds received: {amount_str}",
+ body_text=(
+ f"{amount_str} has been credited to wallet "
+ f"{wallet_id}. New balance: {balance_str}."
+ ),
+ ))
+ await self._push.send(PushMessage(
+ device_tokens=[device_token],
+ title="Funds received",
+ body=f"{amount_str} credited",
+ data={
+ "wallet_id": wallet_id,
+ "amount_minor": amount_minor,
+ "currency": currency,
+ },
+ ))
+:::
+
+Ambas llamadas devuelven un `NotificationResult`; inspecciona el campo `status` para registrar fallos o programar reintentos.
+
+**Ejecútalo.** No quieres un servidor SMTP real mientras desarrollas, así que cambia el
+proveedor por el `DummyEmailProvider`, que solo registra. En tu clase `@configuration`,
+devuelve un `DummyEmailProvider` (y un `DummyPushProvider`) en lugar del de SMTP,
+luego inicia la aplicación y dispara un depósito:
+
+```bash
+uv run pyfly run --server uvicorn
+# in a second terminal, open a wallet and deposit (see Chapter 7), e.g.:
+curl -s -X POST localhost:8080/api/v1/wallets//deposit \
+ -H 'content-type: application/json' -d '{"amount":25000}'
+```
+
+El depósito publica `FundsDeposited`, el listener se dispara y los proveedores
+de prueba registran los mensajes que "enviaron":
+
+```text
+[dummy email] to=['customer@example.com'] subject=Funds received: 250.00 EUR
+[dummy push] tokens=1 title=Funds received
+```
+
+El `DummyEmailProvider` también conserva cada mensaje que recibió en una lista `.sent`,
+que es exactamente contra lo que se afirma la prueba del Ejercicio 2, sin necesidad de servidor SMTP.
+
+!!! note "Qué acaba de pasar"
+ El comando de depósito no sabía nada del correo electrónico. Simplemente hizo su trabajo y
+ lanzó un evento de dominio `FundsDeposited`. La lógica de notificación vive en un
+ listener aparte que *reacciona* a ese evento, así que la ruta de depósito se mantiene
+ limpia y puedes añadir, eliminar o cambiar notificaciones sin tocar el
+ manejador del comando. Esa separación —publicar un hecho, dejar que las partes interesadas
+ reaccionen— es la razón de ser de la arquitectura orientada a eventos.
+
+!!! spring "Equivalencia con Spring"
+ `EmailService` / `SmsService` / `PushService` son los equivalentes en Python
+ del `JavaMailSender` (correo electrónico) de Spring y de las integraciones
+ de terceros de Spring para SMS y push. La división hexagonal puerto/adaptador
+ es idéntica: tu código de dominio depende del protocolo;
+ los adaptadores concretos de proveedor viven en la capa de infraestructura.
+
+---
+
+## Webhooks entrantes
+
+Un proveedor de pagos ilustrativo envía por POST un evento `payment_intent.succeeded` a Lumen cada vez que un cliente recarga su monedero con tarjeta. Lumen debe:
+
+1. **verificar la firma HMAC-SHA256** para rechazar cargas útiles falsificadas;
+2. **deduplicar** los reintentos usando la clave de idempotencia para que un reintento no acredite un monedero dos veces;
+3. **despachar** el evento verificado a un listener tipado.
+
+El módulo `pyfly.webhooks` de PyFly se encarga de los tres pasos.
+
+!!! note "Términos nuevos: webhook, HMAC, idempotencia"
+ Un *webhook* es un POST HTTP que un sistema externo te envía *a ti* cuando
+ ocurre algo: el espejo entrante de los callbacks salientes que veremos más adelante en
+ este capítulo. Como cualquiera puede hacer POST a una URL pública, el proveedor firma
+ cada petición con un secreto compartido usando *HMAC* (un hash con clave); recalcular
+ el hash sobre los bytes exactos recibidos y compararlo demuestra que la carga útil es
+ genuina y no fue manipulada. *Idempotencia* significa "seguro de recibir más de
+ una vez": los proveedores reintentan ante fallos de red, así que almacenas una clave de idempotencia e
+ ignoras una repetición —de lo contrario una sola recarga con tarjeta podría acreditar un monedero dos veces—.
+
+### WebhookEvent y AbstractWebhookEventListener
+
+Cada evento entrante se modela como una dataclass `WebhookEvent`:
+
+```python
+@dataclass
+class WebhookEvent:
+ id: str # auto-generated UUID
+ source: str # e.g. "payment-provider"
+ event_type: str # from body["type"]
+ headers: dict[str, str]
+ body: dict[str, Any]
+ raw_body: bytes
+ received_at: datetime
+ idempotency_key: str | None
+```
+
+Subclasifica `AbstractWebhookEventListener` y establece `source` con el nombre
+que pasarás a `WebhookProcessor.process()`:
+
+::: listing lumen/webhooks/payment_listener.py | Listado 17.8 — Listener de webhook del proveedor de pagos
+from pyfly.container import service
+from pyfly.webhooks import AbstractWebhookEventListener, WebhookEvent
+
+
+@service
+class PaymentWebhookListener(AbstractWebhookEventListener):
+ source = "payment-provider"
+
+ def __init__(self, deposit_handler) -> None:
+ self._handler = deposit_handler
+
+ async def handle(self, event: WebhookEvent) -> None:
+ if event.event_type == "payment_intent.succeeded":
+ pi = event.body.get("data", {}).get("object", {})
+ wallet_id = pi.get("metadata", {}).get("wallet_id")
+ amount_minor = int(pi.get("amount_received", 0))
+ currency_code = pi.get("currency", "EUR").upper()
+ if wallet_id and amount_minor > 0:
+ # Delegate to the CQRS command handler so the aggregate
+ # enforces the balance invariant and raises FundsDeposited.
+ from lumen.core.services.wallets.deposit_funds_command import (
+ DepositFunds,
+ )
+ await self._handler.handle(
+ DepositFunds(
+ wallet_id=wallet_id,
+ amount=amount_minor,
+ )
+ )
+
+ async def on_error(
+ self, event: WebhookEvent, error: BaseException,
+ ) -> None:
+ # Override to DLQ or page on-call.
+ pass
+:::
+
+`on_error` se invoca cuando `handle` lanza una excepción; por defecto es un no-op. Reescríbelo para publicar en una cola de mensajes muertos o emitir una métrica.
+
+### WebhookProcessor: verificar, deduplicar, despachar
+
+**`WebhookProcessor`** ensambla un validador de firmas, un almacén de idempotencia y una lista de listeners. Móntalo en una clase `@configuration` para que sea un
+único bean compartido:
+
+- `listeners` es la lista de subclases de `AbstractWebhookEventListener` a las que abrir en
+ abanico los eventos (de momento solo `PaymentWebhookListener`);
+- `signature_validators` asocia cada nombre de `source` con el validador que demuestra que sus
+ peticiones son genuinas —aquí un `HmacSignatureValidator` con clave del secreto del webhook
+ que te dio tu proveedor—;
+- un `event_store` (omitido aquí, así que se usa el por defecto en memoria) recuerda
+ las claves de idempotencia.
+
+::: listing lumen/webhooks/processor_config.py | Listado 17.9 — Ensamblando WebhookProcessor
+from pyfly.container import bean, configuration
+from pyfly.webhooks import (
+ HmacSignatureValidator,
+ WebhookProcessor,
+)
+from lumen.webhooks.payment_listener import PaymentWebhookListener
+
+
+@configuration
+class WebhookConfig:
+
+ @bean
+ def webhook_processor(
+ self, payment_listener: PaymentWebhookListener,
+ ) -> WebhookProcessor:
+ return WebhookProcessor(
+ listeners=[payment_listener],
+ signature_validators={
+ "payment-provider": HmacSignatureValidator(
+ secret="whsec_REPLACE_ME",
+ ),
+ },
+ )
+:::
+
+`HmacSignatureValidator` espera el formato de cabecera `sha256=` y usa `hmac.compare_digest` para una comparación en tiempo constante. Cambia el parámetro `header_prefix` si tu proveedor usa un esquema diferente.
+
+### Manejando un webhook en un manejador HTTP
+
+Llama a `processor.process()` desde tu manejador HTTP entrante. Pasa el cuerpo de la petición sin procesar (bytes sin modificar): el validador calcula el HMAC sobre los bytes exactos recibidos.
+
+!!! warning "Lee el cuerpo como bytes en crudo, no como JSON parseado"
+ La firma se calcula sobre los *bytes exactos* que envió el proveedor. Si
+ parseas el JSON y lo vuelves a serializar, el orden de las claves o los espacios en blanco pueden cambiar y el
+ HMAC ya no coincidirá —toda petición legítima sería rechazada—.
+ Pasa siempre `await request.body()` (los bytes intactos) a `process()`, como
+ hace el manejador de abajo.
+
+::: listing lumen/webhooks/payment_handler.py | Listado 17.10 — Endpoint de webhook entrante del proveedor de pagos
+from pyfly.container import rest_controller
+from pyfly.web import post_mapping, request_mapping
+from pyfly.webhooks import WebhookProcessor
+from starlette.requests import Request
+from starlette.responses import Response
+
+
+@rest_controller
+@request_mapping("/webhooks")
+class PaymentWebhookHandler:
+
+ def __init__(self, processor: WebhookProcessor) -> None:
+ self._processor = processor
+
+ @post_mapping("/payment")
+ async def receive(self, request: Request) -> Response:
+ # Read the untouched bytes — the HMAC is computed over exactly
+ # what the provider sent (see the warning above).
+ raw_body = await request.body()
+ headers = {
+ "X-Signature": request.headers.get(
+ "X-Webhook-Signature", ""
+ ),
+ "X-Idempotency-Key": request.headers.get(
+ "X-Idempotency-Key", ""
+ ),
+ }
+ try:
+ await self._processor.process(
+ source="payment-provider",
+ raw_body=raw_body,
+ headers=headers,
+ )
+ except ValueError:
+ return Response(content=b"invalid signature", status_code=400)
+ return Response(content=b"ok", status_code=200)
+:::
+
+La firma de `process()` acepta los argumentos por palabra clave `signature_header` e `idempotency_header` para reemplazar los nombres de cabecera por defecto (`X-Signature` y `X-Idempotency-Key`).
+
+**Qué ocurre dentro de `process()`:**
+
+1. El validador se busca por `source`; si no hay ninguno registrado, se usa un `NoOpSignatureValidator` —que siempre pasa, seguro para desarrollo pero no para producción—.
+2. Si la validación de la firma falla, se lanza `ValueError` de inmediato y no se invoca a ningún listener.
+3. El cuerpo en crudo se decodifica como JSON; si falla, los bytes en crudo se almacenan bajo `body["_raw"]`.
+4. Si `idempotency_key` está presente y ya se ha visto, el evento se devuelve pero **no** se invoca a los listeners.
+5. Cada listener para el source se invoca en el orden de registro; si uno lanza una excepción, el error se registra y se invoca `on_error` antes de continuar con el siguiente listener.
+
+**Ejecútalo.** Prueba el endpoint como lo haría un proveedor real: firmando el cuerpo
+exacto. Elige el mismo secreto que pusiste en `HmacSignatureValidator` (`whsec_REPLACE_ME`
+en el Listado 17.9), calcula el HMAC con `openssl` y haz POST. Inicia la aplicación
+(`uv run pyfly run --server uvicorn`), luego en un segundo terminal:
+
+::: listing terminal | Listado 17.10a — Firma y POST de un webhook
+BODY='{"type":"payment_intent.succeeded","data":{"object":{"amount_received":25000,"currency":"eur","metadata":{"wallet_id":""}}}}'
+SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "whsec_REPLACE_ME" | sed 's/^.* //')
+
+curl -s -X POST localhost:8080/webhooks/payment \
+ -H "X-Webhook-Signature: sha256=$SIG" \
+ -H "X-Idempotency-Key: evt-001" \
+ -H 'content-type: application/json' \
+ -d "$BODY"
+:::
+
+Una petición correctamente firmada devuelve `ok` y acredita el monedero (verás también
+dispararse los logs de notificación de `FundsDeposited` de antes):
+
+```text
+ok
+```
+
+Ahora demuestra las dos garantías. Haz POST del **mismo** comando otra vez con la misma
+`X-Idempotency-Key`: sigue devolviendo `ok`, pero el monedero *no* se acredita una
+segunda vez (el duplicado se descarta antes de que se ejecute ningún listener). Luego altera
+un byte de `$BODY` *sin* recalcular `$SIG` y vuelve a hacer POST: la firma
+ya no coincide, así que el manejador devuelve:
+
+```text
+invalid signature
+```
+
+Esa es la tubería verificar-deduplicar-despachar funcionando de extremo a extremo, y refleja
+el Ejercicio 3 casi exactamente.
+
+!!! note "Qué acaba de pasar"
+ Un desconocido en internet hizo POST de JSON a tu servicio, y tres guardianes se ejecutaron
+ antes que una sola línea de tu lógica de negocio: la comprobación de la firma rechazó
+ falsificaciones, el almacén de idempotencia rechazó reintentos, y solo entonces el
+ listener tipado tradujo el evento en un comando CQRS `DepositFunds` —así que
+ la raíz de agregado del monedero siguió haciendo cumplir sus propios invariantes—. Tú escribiste el
+ cuerpo de `handle()`; PyFly proporcionó el desafío que lo rodea.
+
+!!! note "Almacén de idempotencia en memoria"
+ El `InMemoryWebhookEventStore` por defecto guarda las claves vistas en un
+ `set` de Python. Para clústeres de producción, implementa el protocolo
+ `WebhookEventStore` respaldado por Redis o una base de datos:
+ ```python
+ class RedisEventStore:
+ async def already_processed(
+ self, key: str,
+ ) -> bool: ...
+ async def remember(self, key: str) -> None: ...
+ ```
+ Pásalo como `event_store=RedisEventStore(...)` a `WebhookProcessor`.
+
+---
+
+## Callbacks salientes
+
+Cuando Lumen registra un desembolso a un banco socio, ese socio espera un POST `DisbursementSettled` a su URL de webhook —firmado, reintentado ante fallos y auditable—. El módulo `pyfly.callbacks` de PyFly se encarga del lado saliente.
+
+!!! note "Término nuevo: callback saliente"
+ Un *callback* aquí es lo inverso del webhook entrante que acabas de construir:
+ ahora *Lumen* es el remitente, que hace POST de un evento *a* la URL de un socio. El
+ módulo te da la misma maquinaria de confianza y fiabilidad en la salida:
+ firma cada carga útil (para que el socio pueda verificarla), reintenta los fallos
+ transitorios con retroceso (backoff) y registra cada intento para que tengas un rastro de auditoría
+ cuando un socio pregunte "¿nos llegasteis a avisar de la transacción X?".
+
+### Suscripciones y configuración
+
+Cada socio se modela como una **`CallbackConfig`** —un registro con alcance de inquilino (tenant) que contiene el secreto del webhook, las suscripciones a eventos y la política de reintentos—.
+
+!!! note "Término nuevo: inquilino (tenant)"
+ Un *inquilino* (tenant) es un cliente u organización aislada dentro de una
+ aplicación compartida —aquí, `"lumen"`—. Los callbacks tienen alcance de inquilino para que un
+ despliegue multiinquilino pueda mantener las URLs de socios, secretos y política de reintentos
+ de cada inquilino por separado y nunca cruzar los cables. Con un solo inquilino simplemente pasas
+ el mismo `tenant_id` en todas partes.
+
+**Paso 1 — Describe cada suscripción.** Una `CallbackSubscription` empareja un
+`event_type` con la `target_url` a la que hacerle POST. Usa el nombre exacto del evento
+(`"DisbursementSettled"`) para encaminar un evento, o `"*"` como comodín que
+coincide con todos los eventos del inquilino —útil para un endpoint de auditoría que quiere
+el flujo completo—.
+
+**Paso 2 — Envuélvelas en una `CallbackConfig` y guárdala.** La configuración lleva el
+`secret` compartido (usado para firmar cada carga útil), la política de reintentos (`max_attempts`,
+`backoff_ms`) y la lista de suscripciones. Persístela a través de un
+`CallbackConfigRepository` —`InMemoryCallbackConfigRepository` por ahora, uno
+respaldado por base de datos en producción—.
+
+::: listing lumen/callbacks/register_partner.py | Listado 17.11 — Registrando un callback de socio
+from pyfly.callbacks import (
+ CallbackConfig,
+ CallbackSubscription,
+ InMemoryCallbackConfigRepository,
+ InMemoryCallbackExecutionRepository,
+)
+
+
+async def register_clearance_bank(configs) -> None:
+ await configs.save(CallbackConfig(
+ tenant_id="lumen",
+ name="clearance-bank",
+ secret="cb-secret-xyz",
+ max_attempts=5,
+ backoff_ms=2_000,
+ subscriptions=[
+ CallbackSubscription(
+ event_type="DisbursementSettled",
+ target_url=(
+ "https://api.clearancebank.example.com"
+ "/hooks/lumen"
+ ),
+ ),
+ CallbackSubscription(
+ event_type="*",
+ target_url=(
+ "https://audit.clearancebank.example.com"
+ "/all-events"
+ ),
+ ),
+ ],
+ ))
+:::
+
+`event_type="*"` es un comodín: todos los eventos despachados para el inquilino coinciden. Los tipos con nombre solo coinciden con la cadena exacta del tipo de evento.
+
+### Despachando un evento
+
+**`CallbackDispatcher.dispatch()`** abre el evento en abanico hacia cada suscripción coincidente:
+
+::: listing lumen/callbacks/dispatcher_config.py | Listado 17.12 — Cableando e invocando CallbackDispatcher
+from pyfly.callbacks import (
+ CallbackDispatcher,
+ InMemoryCallbackConfigRepository,
+ InMemoryCallbackExecutionRepository,
+)
+from pyfly.container import bean, configuration
+
+
+@configuration
+class CallbackConfig_:
+
+ @bean
+ def callback_configs(self) -> InMemoryCallbackConfigRepository:
+ return InMemoryCallbackConfigRepository()
+
+ @bean
+ def callback_executions(
+ self,
+ ) -> InMemoryCallbackExecutionRepository:
+ return InMemoryCallbackExecutionRepository()
+
+ @bean
+ def callback_dispatcher(
+ self,
+ configs: InMemoryCallbackConfigRepository,
+ executions: InMemoryCallbackExecutionRepository,
+ ) -> CallbackDispatcher:
+ return CallbackDispatcher(
+ configs=configs,
+ executions=executions,
+ )
+:::
+
+Luego, en el servicio de dominio —fíjate en que la carga útil usa `amount` en unidades menores (céntimos), así que `50_000` son EUR 500.00—:
+
+```python
+results = await dispatcher.dispatch(
+ "lumen", # tenant_id
+ "DisbursementSettled",
+ {"id": "txn-009", "amount": 50_000, "currency": "EUR"},
+)
+```
+
+`dispatch()` devuelve un registro `CallbackExecution` por cada suscripción coincidente, cada uno con `status`, `attempts`, `response_status` y `delivered_at`.
+
+**Ejecútalo.** El emisor HTTP por defecto del despachador no llama realmente a la
+red —registra la petición que *haría* y devuelve `200`—, lo cual es
+perfecto para ver el cableado sin levantar un servidor de socio. Suelta esto
+en un script (o en `uv run python`) desde `samples/lumen`:
+
+::: listing lumen/callbacks/try_dispatch.py | Listado 17.12a — Despacho contra el emisor por defecto (solo registra)
+import asyncio
+
+from pyfly.callbacks import (
+ CallbackConfig,
+ CallbackDispatcher,
+ CallbackSubscription,
+ InMemoryCallbackConfigRepository,
+ InMemoryCallbackExecutionRepository,
+)
+
+
+async def main() -> None:
+ configs = InMemoryCallbackConfigRepository()
+ await configs.save(CallbackConfig(
+ tenant_id="lumen",
+ name="clearance-bank",
+ secret="cb-secret-xyz",
+ subscriptions=[CallbackSubscription(
+ event_type="DisbursementSettled",
+ target_url="https://api.clearancebank.example.com/hooks/lumen",
+ )],
+ ))
+ dispatcher = CallbackDispatcher(
+ configs=configs,
+ executions=InMemoryCallbackExecutionRepository(),
+ )
+ results = await dispatcher.dispatch(
+ "lumen",
+ "DisbursementSettled",
+ {"id": "txn-009", "amount": 50_000, "currency": "EUR"},
+ )
+ for r in results:
+ print(r.status, r.attempts, r.response_status, r.target_url)
+
+
+asyncio.run(main())
+:::
+
+Deberías ver una ejecución entregada, con la petición firmada registrada justo
+encima de ella:
+
+```text
+would POST https://api.clearancebank.example.com/hooks/lumen headers={'X-Pyfly-Signature': 'sha256=...', 'Content-Type': 'application/json'} body={'id': 'txn-009', 'amount': 50000, 'currency': 'EUR'}
+DELIVERED 1 200 https://api.clearancebank.example.com/hooks/lumen
+```
+
+`DELIVERED 1 200` se lee como: estado `DELIVERED`, con éxito en el intento `1`, HTTP
+`200`. Para enviar de verdad, pasa tu propio emisor `http=` (un POST de `httpx`/`aiohttp`)
+a `CallbackDispatcher`; la lógica de firma, reintento y auditoría se mantiene idéntica.
+
+!!! note "Qué acaba de pasar"
+ Una sola llamada a `dispatch()` buscó todas las suscripciones que el inquilino tiene para ese
+ tipo de evento, firmó la carga útil, hizo POST y escribió un registro `CallbackExecution`
+ para cada una —todo ello sin que tu servicio de dominio supiera cuántos socios
+ están escuchando ni cómo funcionan los reintentos—. Añadir un socio más adelante es solo otra
+ `CallbackConfig` guardada; el código de desembolso nunca cambia.
+
+### Firma HMAC y lógica de reintentos
+
+Cuando `CallbackConfig.secret` está establecido, `CallbackDispatcher` firma la carga útil JSON canónica antes de cada POST usando HMAC-SHA256:
+
+```python
+# canonical body — compact, keys sorted
+canonical = json.dumps(payload, separators=(",", ":"), sort_keys=True)
+sig = hmac.new(secret, canonical.encode(), hashlib.sha256).hexdigest()
+headers["X-Pyfly-Signature"] = f"sha256={sig}"
+```
+
+El destinatario puede verificar la firma usando el propio `HmacSignatureValidator` de PyFly —la misma clase usada para los webhooks entrantes—.
+
+**Política de reintentos:**
+
+- El despachador reintenta hasta `max_attempts` veces (por defecto `5`).
+- Entre reintentos aplica retroceso exponencial: `delay = min(backoff_ms * 2^(attempt-1), 300_000) ms`.
+- Solo los códigos de estado HTTP *transitorios* disparan un reintento: `408`, `429`, `500`, `502`, `503`, `504`, o cualquiera `>= 500`.
+- Los errores de cliente permanentes (`4xx` salvo `408`/`429`) marcan la ejecución como `FAILED` de inmediato sin reintentar.
+- Ante el éxito (`2xx`) la ejecución se marca como `DELIVERED` y se estampa `delivered_at`.
+
+::: listing lumen/callbacks/models_overview.py | Listado 17.13 — Ciclo de vida del estado de CallbackExecution
+from pyfly.callbacks import CallbackStatus
+
+# After a successful delivery:
+assert execution.status == CallbackStatus.DELIVERED
+assert execution.delivered_at is not None
+assert execution.response_status == 200
+
+# After all retries are exhausted:
+assert execution.status == CallbackStatus.FAILED
+assert execution.attempts == 5
+assert execution.last_error is not None
+:::
+
+### Protección contra SSRF: dominios autorizados
+
+El campo `authorized_domains` de `CallbackConfig` actúa como una lista de permitidos. Cuando está establecido, `CallbackDispatcher` comprueba que el nombre de host de la URL de destino coincide con uno de los dominios permitidos antes de realizar cualquier petición saliente. Una URL que no supera la comprobación se marca de inmediato como `FAILED` con `last_error="Domain not authorized"` —no se realiza ninguna petición HTTP—.
+
+```python
+from pyfly.callbacks import AuthorizedDomain, CallbackConfig
+
+config = CallbackConfig(
+ tenant_id="lumen",
+ name="safe-config",
+ secret="s3cr3t",
+ authorized_domains=[
+ AuthorizedDomain(domain="clearancebank.example.com"),
+ ],
+ subscriptions=[...],
+)
+```
+
+Los subdominios de los dominios permitidos también se aceptan (p. ej. `api.clearancebank.example.com`).
+
+!!! note "Término nuevo: SSRF"
+ La *falsificación de peticiones del lado del servidor* (Server-Side Request Forgery) es un ataque en el que un valor malicioso engaña a
+ tu servidor para que haga una petición HTTP que no debería —por ejemplo, una
+ URL de socio que apunta a `http://169.254.169.254/` (un endpoint de metadatos
+ de la nube) para robar credenciales—. Como las URLs de callback pueden venir de
+ configuración proporcionada por el socio, la lista de permitidos `authorized_domains` cierra esa puerta:
+ un host que no está en la lista se marca como `FAILED` *antes* de que ninguna petición
+ salga del proceso. Para verificarlo, añade un `AuthorizedDomain` para un host, luego
+ despacha una suscripción cuya `target_url` apunte a otro sitio: el
+ `CallbackExecution` devuelto mostrará `status=FAILED`, `attempts=0` y
+ `last_error="Domain not authorized"`, y no se enviará nada.
+
+!!! spring "Equivalencia con Spring"
+ El trío `@scheduled` / `CronExpression` / `TaskScheduler` de PyFly
+ refleja el `@Scheduled` / `CronExpression` /
+ `ThreadPoolTaskScheduler` de Spring. Los puertos de notificación se corresponden con los
+ `MailSender` / `JavaMailSender` de Spring. `WebhookProcessor` se corresponde con
+ un `@RestController` de Spring + el `HmacRequestMatcher` de HMAC de Spring
+ Security. `CallbackDispatcher` con firma HMAC y
+ reintentos refleja el patrón `WebhookPublisher` de Spring
+ Modulith.
+
+---
+
+**Ejecútalo — confirma que la suite sigue en verde.** Añadiste cuatro nuevos patrones
+de integración; asegúrate de que nada más sufrió una regresión. Desde el directorio `samples/lumen`:
+
+```bash
+uv run --extra dev pytest -q
+```
+
+Deberías ver que todas las pruebas existentes siguen pasando:
+
+```text
+......................................... [100%]
+41 passed in 0.28s
+```
+
+Los tres ejercicios de abajo añaden sus propias pruebas de programación, notificación y webhook —
+vuelve a ejecutar este comando después de cada uno para ver subir el recuento—. Recuerda la
+bandera `--extra dev`; el `uv sync` a secas omite pytest.
+
+---
+
+## Lo que construiste {.recap}
+
+Extendiste Lumen hasta convertirlo en un sistema conectado que opera con independencia de las peticiones entrantes:
+
+- **Tareas programadas** — `@scheduled` con los disparadores `cron`, `fixed_rate` y `fixed_delay` ejecuta trabajo según un calendario o un temporizador. `CronExpression` impulsa los cálculos de los momentos de disparo, incluida la programación consciente de la zona horaria con el horario de verano gestionado automáticamente. `lock=True` serializa la ejecución en todo el clúster a través del puerto `DistributedLock`. `@async_method` descarga el trabajo fire-and-forget en el ejecutor.
+
+- **Notificaciones** — Los protocolos de puerto `EmailService`, `SmsService` y `PushService` desacoplan la lógica de negocio de los adaptadores de proveedor (SMTP, SendGrid, Resend, Twilio, Firebase). `DefaultEmailService` y sus hermanos capturan los errores del proveedor y devuelven valores `NotificationResult` estructurados en lugar de propagar excepciones. `DepositNotificationListener` se suscribe al evento de EDA real `FundsDeposited` y convierte los importes en unidades menores en cadenas de visualización antes de enviar.
+
+- **Webhooks entrantes** — `AbstractWebhookEventListener` define consumidores tipados. `WebhookProcessor` filtra cada evento con `HmacSignatureValidator` y `WebhookEventStore` antes de despacharlo a los listeners. Los ganchos `on_error()` permiten la integración con una cola de mensajes muertos sin romper el bucle de despacho.
+
+- **Callbacks salientes** — `CallbackDispatcher` abre los eventos en abanico hacia los destinos de `CallbackSubscription`, firma las cargas útiles con HMAC-SHA256 bajo la cabecera `X-Pyfly-Signature` y reintenta ante fallos transitorios con retroceso exponencial. Los registros `CallbackExecution` proporcionan un rastro de auditoría de entrega completo. `authorized_domains` previene la SSRF.
+
+---
+
+## Pruébalo tú mismo {.exercises}
+
+1. **Bloqueo de clúster.** Añade una segunda instancia de `DailyRollupService` a una
+ prueba que levante dos instancias de `ApplicationContext` compartiendo un
+ `FakeDistributedLock` (uno que solo devuelva `True` para el primer
+ adquirente). Afirma que `run()` se invoca exactamente una vez en ambas
+ instancias para un único tic de cron.
+
+2. **Cambio de proveedor.** Reemplaza `SmtpEmailProvider` por un
+ `DummyEmailProvider` en la suite de pruebas. Escribe una prueba que deposite
+ EUR 100 (10 000 unidades menores) en el monedero `w-001` a través del manejador
+ de depósitos, disparando un evento `FundsDeposited`. El proveedor registra
+ cada mensaje que recibió en su lista `.sent`, así que afirma que
+ `provider.sent[-1].body_text` contiene el ID del monedero y el
+ importe formateado (`100.00 EUR`).
+
+3. **Ataque de reenvío de firma.** Escribe una prueba que invoque
+ `PaymentWebhookHandler.receive()` dos veces con el mismo cuerpo en crudo,
+ cabeceras y clave de idempotencia. Afirma que la primera llamada devuelve 200
+ y acredita el monedero (disparando `FundsDeposited`), y que la segunda
+ llamada también devuelve 200 (el duplicado lo ignora silenciosamente
+ `InMemoryWebhookEventStore`) pero **no** acredita el monedero una
+ segunda vez.
diff --git a/book/manuscript-es/18-production.md b/book/manuscript-es/18-production.md
new file mode 100644
index 00000000..cc44b767
--- /dev/null
+++ b/book/manuscript-es/18-production.md
@@ -0,0 +1,1095 @@
+Capítulo 18
+
+# Extender PyFly y llevarlo a producción {.chtitle}
+
+::: figure art/openers/ch18.svg |
+
+Lumen ya no es un juguete. A lo largo de los diecisiete capítulos anteriores construiste un servicio de monedero (wallet) desde una única clase anotada hasta un microservicio completo orientado a eventos: CQRS, sagas, EDA, clientes HTTP, caché, resiliencia, observabilidad, seguridad y un conjunto de pruebas respaldado por contenedores reales. Sabes cómo ejecutarlo, depurarlo y desplegarlo.
+
+Este capítulo final trata sobre la distancia que separa el "funciona en mi portátil" del "funciona de forma fiable para usuarios reales". Esa distancia tiene tres dimensiones. Primera: la **extensibilidad**, la capacidad de añadir comportamiento sin bifurcar el framework. Segunda: las funcionalidades que tu dominio puede necesitar ahora mismo: un motor de reglas, configuración centralizada, varios idiomas, push en tiempo real, una CLI. Tercera: los hábitos operativos que separan un proyecto de fin de semana de un servicio en producción.
+
+Avanzarás rápido. Cada sección escoge un tema, muestra la API mínima que funciona y la conecta con Lumen. Al final tendrás una imagen completa del ecosistema PyFly y una lista de comprobación de producción que merece la pena tener a mano.
+
+!!! note "Convenciones de este capítulo"
+ Los listados y las claves de configuración de aquí apuntan a PyFly
+ **v26.6.110**. Dos hechos de despliegue de esa versión recorren toda la
+ sección "Llevarlo a producción", así que conviene conocerlos de
+ antemano:
+
+ - La aplicación escucha en `pyfly.server.port` (por defecto `8080`), la
+ clave equivalente a `server.port` de Spring. Las antiguas claves
+ `pyfly.web.port` / `PYFLY_WEB_PORT` se eliminaron en v26.6.102.
+ - El Actuator y el panel de administración se ejecutan en un **puerto de
+ gestión independiente** (`pyfly.management.server.port`, por defecto
+ `9090`), abierto y sin autenticación por defecto. Lo cubrimos en detalle
+ cuando conectemos las sondas de salud.
+
+ Cada funcionalidad de abajo se construye igual: un breve "por qué", el
+ código y, a continuación, un punto de control **Ejecútalo** que muestra el
+ comando exacto y la salida que deberías ver. Escribe los comandos a medida
+ que avanzas: así es como las ideas se asientan.
+
+---
+
+## Plugins y puntos de extensión
+
+### ¿Por qué un sistema de plugins?
+
+El framework central es pequeño de forma intencionada. Las funcionalidades opcionales (formateadores, sumideros de auditoría, canales de notificación) deberían poder conectarse como plugins para que los equipos compongan solo lo que necesitan y publiquen añadidos sin tocar las interioridades del framework.
+
+El módulo `pyfly.plugins` de PyFly refleja el registro de plugins de Spring: declaras un **punto de extensión** (una ranura con nombre), **extensiones** (contribuciones concretas) y las agrupas en un **plugin** con un ciclo de vida.
+
+La nueva jerga, en términos sencillos: un **punto de extensión** es un hueco etiquetado en tu aplicación: "todo lo que quiera ser un sumidero de auditoría se conecta aquí". Una **extensión** es una cosa que llena el hueco. Un **plugin** es el paquete que entrega una o más extensiones juntas y que se puede arrancar y detener como una unidad. Si alguna vez has definido una interfaz de Java y has dejado que quienes la llaman registren implementaciones de ella, ya conoces la forma: los decoradores de abajo simplemente hacen el cableado declarativo.
+
+Construiremos el ejemplo útil más pequeño: un sumidero de auditoría de consola que imprime cada evento que se le entrega.
+
+**Paso 1: declara el punto de extensión.** Este es el contrato. Todo sumidero de auditoría debe prometer un método `record(event)`. La cadena `id="audit-sinks"` es el nombre que usa el resto del código para encontrar más tarde cada sumidero.
+
+**Paso 2: escribe un plugin que aporte un sumidero.** El decorador `@plugin` envuelve una clase con un `id` y una `version` obligatorios; la clase interna `@extension` es la contribución real. Hereda de la interfaz del punto de extensión (`AuditSink`) para que el registro pueda confirmar que respeta el contrato.
+
+**Paso 3: dale al plugin un ciclo de vida.** Los métodos `start` y `stop` son donde abres y cierras recursos (un descriptor de archivo, una conexión de red). El gestor de plugins los llama por ti.
+
+Aquí tienes los tres pasos en un único archivo.
+
+::: listing lumen/plugins/audit.py | Listado 18.1 — Declarar un plugin de sumidero de auditoría
+from pyfly.plugins import (
+ PluginManager,
+ extension,
+ extension_point,
+ plugin,
+)
+
+
+@extension_point(id="audit-sinks")
+class AuditSink:
+ """Interface that all audit sinks must implement."""
+
+ def record(self, event: dict) -> None: ...
+
+
+@plugin(id="console-audit", version="1.0.0")
+class ConsoleAuditPlugin:
+
+ @extension(point="audit-sinks", priority=10)
+ class ConsoleSink(AuditSink):
+ name = "console"
+
+ def record(self, event: dict) -> None:
+ print(f"[AUDIT] {event}")
+
+ async def start(self) -> None:
+ print("ConsoleAuditPlugin started")
+
+ async def stop(self) -> None:
+ print("ConsoleAuditPlugin stopped")
+:::
+
+**Cómo funciona.** `@extension_point(id="audit-sinks")` registra una ranura con nombre y declara la interfaz que toda contribución debe implementar. `@plugin(id="console-audit", version="1.0.0")` declara una clase de plugin con un `id` y una `version` obligatorios. `@extension(point="audit-sinks", priority=10)` marca una clase interna como contribución a esa ranura; la clase interna debe heredar de la interfaz del punto de extensión para que el registro pueda validarla. La prioridad más alta gana la primera posición al iterar los resultados.
+
+Cargar y ejecutar el plugin:
+
+::: listing lumen/plugins/runner.py | Listado 18.2 — Conducir el ciclo de vida del plugin
+import asyncio
+
+from pyfly.plugins import PluginManager
+
+from lumen.plugins.audit import ConsoleAuditPlugin
+
+
+async def main() -> None:
+ manager = PluginManager()
+ await manager.add(ConsoleAuditPlugin)
+ await manager.start_all()
+
+ sinks = await manager.registry.get("audit-sinks")
+ for sink in sinks:
+ sink.record({"action": "deposit", "amount_minor": 100})
+
+ await manager.stop_all()
+
+
+asyncio.run(main())
+:::
+
+`PluginManager.add()` inspecciona la clase en busca de declaraciones `@extension_point` anidadas y luego registra cada contribución `@extension`. `start_all()` invoca los hooks `init` y después `start` de cada plugin en orden de dependencias; `stop_all()` invierte la secuencia, llamando a `stop` y luego a `unload`. Las dependencias circulares lanzan `PluginResolutionError` antes de que se ejecute código alguno.
+
+!!! tip "Ejecútalo"
+ Guarda ambos listados bajo `src/lumen/plugins/` y ejecuta el módulo runner
+ directamente:
+
+ ```bash
+ uv run python -m lumen.plugins.runner
+ ```
+
+ Deberías ver dispararse los hooks del ciclo de vida, registrarse el único
+ evento y producirse el apagado limpio:
+
+ ```
+ ConsoleAuditPlugin started
+ [AUDIT] {'action': 'deposit', 'amount_minor': 100}
+ ConsoleAuditPlugin stopped
+ ```
+
+ El orden lo es todo: `start` se ejecuta antes de usar ningún sumidero, tu
+ código itera los sumideros registrados y `stop` se ejecuta el último. Si
+ añades un segundo plugin más adelante, `start_all()` arranca ambos en orden
+ de dependencias y `stop_all()` los apaga en orden inverso: nunca gestionas
+ ese orden a mano.
+
+**Lo que acaba de ocurrir.** Añadiste una capacidad a la aplicación —el
+registro de auditoría— sin editar una sola línea de código del framework. El
+framework descubrió tu plugin, validó que su extensión respeta el contrato
+`AuditSink`, ejecutó su ciclo de vida y te entregó los sumideros registrados
+para que los llamaras. Esa es la promesa central de un sistema de plugins:
+abierto a la extensión, cerrado a la modificación.
+
+| Método | Descripción |
+|---|---|
+| `await manager.add(cls)` | Escanea y registra una clase de plugin |
+| `await manager.start_all()` | `init` → `start` en orden de dependencias |
+| `await manager.stop_all()` | `stop` → `unload` en orden inverso |
+| `await manager.remove(plugin_id)` | Descarga un plugin; devuelve `False` si es desconocido |
+| `await manager.registry.get(point_id)` | Extensiones de una ranura, ordenadas por prioridad |
+
+!!! spring "Equivalencia con Spring"
+ `@plugin` / `@extension_point` / `@extension` reflejan
+ `@Plugin` / `ExtensionPoint` / `@Extension` de la API de plugins
+ de Spring. `PluginManager.start_all()` desempeña el papel de la
+ gestión del ciclo de vida del contenedor de plugins de Spring. El
+ arranque en orden de dependencias y el apagado en orden inverso son
+ idénticos en semántica.
+
+---
+
+## Reglas de negocio con el motor de reglas
+
+La mayoría de los servicios del mundo real arrastran lógica que pertenece al negocio, no al código: "marca los pedidos de más de 500.000 céntimos", "bloquea los envíos a regiones sancionadas", "aplica un recargo fuera de horario". Codificar esos umbrales en Python a fuego significa recompilar cada vez que el negocio cambia de opinión.
+
+El módulo `pyfly.rule_engine` de PyFly ofrece a los responsables de producto un dial YAML que pueden girar sin tocar el código fuente.
+
+Un **motor de reglas**, en términos sencillos, es un pequeño intérprete de sentencias "si esto, entonces aquello" que viven en datos en lugar de en código. Le proporcionas un saco de hechos (el *contexto*) y un conjunto de reglas; comprueba la condición de cada regla contra los hechos y, para las que coinciden, ejecuta las acciones listadas, normalmente escribiendo una marca de vuelta en el contexto. La ventaja es que las *reglas* las pueden editar quienes no programan, mientras que el *motor* que las ejecuta es fijo y está probado.
+
+### Definir reglas en YAML
+
+Construiremos una comprobación de fraude y límite en dos pasos: primero escribir las reglas como datos, luego conectar un servicio que las ejecuta.
+
+**Paso 1: escribe las reglas como YAML.** Cada regla nombra una condición `when` y una lista de acciones `then`. Como las reglas son datos planos, un responsable de producto puede revisarlas en una pull request y un servidor de configuración puede intercambiarlas en caliente en tiempo de ejecución.
+
+Las reglas viven en un archivo YAML separado que cualquier miembro del equipo puede revisar. El evaluador analiza este archivo una vez al arranque, o en cada obtención si lo recargas en caliente desde un Config Server (consulta la siguiente sección).
+
+::: listing lumen/rules/transaction_rules.yaml | Listado 18.3 — Reglas de fraude y límite diario (importes en unidades menores)
+id: transaction-rules
+name: Lumen transaction rules
+
+rules:
+ - id: daily-limit
+ priority: 20
+ when:
+ op: ge
+ field: transaction.daily_total
+ value: 500000
+ then:
+ - type: set
+ target: flags.limit_exceeded
+ value: true
+ - type: log
+ value: "daily limit exceeded"
+
+ - id: fraud-country
+ priority: 10
+ when:
+ op: in
+ field: transaction.country
+ value: ["XX", "YY", "ZZ"]
+ then:
+ - type: set
+ target: flags.fraud_risk
+ value: true
+ - type: log
+ value: "high-risk country detected"
+
+ - id: high-value
+ priority: 5
+ when:
+ op: ge
+ field: transaction.amount
+ value: 100000
+ then:
+ - type: set
+ target: flags.high_value
+ value: true
+:::
+
+Cada regla tiene una condición `when` y una lista de acciones `then`. Los importes son siempre **unidades menores enteras** (céntimos), así que `100000` son 1.000,00 €. Las condiciones usan estos operadores:
+
+| Comparación | Lógicos |
+|---|---|
+| `eq`, `ne`, `gt`, `ge`, `lt`, `le` | `and`, `or`, `not` |
+| `in`, `not_in`, `regex` | (con `conditions: [...]`) |
+
+Las acciones son `set` (escribir en una ruta del contexto), `increment` o `log`. Crea una subclase de `RuleEvaluator` y sobreescribe `_execute_action` para añadir `call`, `calculate` o cualquier verbo personalizado.
+
+**Paso 2: conecta un servicio que cargue y ejecute las reglas.** El servicio analiza el YAML una vez en la construcción y luego expone un método `assess()` que construye un contexto, ejecuta el evaluador y devuelve las marcas que las reglas establecieron.
+
+::: listing lumen/rules/risk_service.py | Listado 18.4 — Evaluar reglas contra una transacción
+from pathlib import Path
+
+from pyfly.container import service
+from pyfly.rule_engine import RuleSetEvaluator, RuleSetLoader
+
+
+@service
+class RiskService:
+ """Evaluate transaction-level rules and return risk flags."""
+
+ def __init__(self) -> None:
+ yaml_text = (
+ Path(__file__).parent / "transaction_rules.yaml"
+ ).read_text()
+ self._ruleset = RuleSetLoader.from_yaml(yaml_text)
+ self._evaluator = RuleSetEvaluator()
+
+ def assess(
+ self,
+ amount: int,
+ daily_total: int,
+ country: str,
+ ) -> dict:
+ ctx = {
+ "transaction": {
+ "amount": amount,
+ "daily_total": daily_total,
+ "country": country,
+ },
+ "flags": {},
+ }
+ self._evaluator.evaluate(self._ruleset, ctx)
+ return ctx["flags"]
+:::
+
+**Cómo funciona.** `RuleSetLoader.from_yaml(text)` analiza el YAML en un AST. `RuleSetEvaluator.evaluate(ruleset, ctx)` recorre cada regla en orden de prioridad, evalúa la cláusula `when` y aplica las acciones `then` que coinciden, mutando `ctx` en el sitio y devolviendo una `list[EvaluationResult]`. El diccionario `flags` es la salida autoritativa: un manejador (handler) posterior rechaza, encola o marca la transacción en función de las claves que se hayan establecido. `amount` y `daily_total` están en unidades menores (céntimos) para coincidir con el dominio de Lumen.
+
+!!! tip "Ejecútalo"
+ Ejercita el servicio desde un REPL de Python para ver dispararse las
+ reglas. Una transferencia de 6.000,00 € (`600000` unidades menores)
+ dispara a la vez los umbrales de alto valor y de límite diario:
+
+ ```bash
+ uv run python -c "
+ from lumen.rules.risk_service import RiskService
+ print(RiskService().assess(amount=600000, daily_total=600000, country='US'))
+ "
+ ```
+
+ Salida esperada: las marcas que establecen las reglas coincidentes, y nada
+ más:
+
+ ```python
+ {'high_value': True, 'limit_exceeded': True}
+ ```
+
+ Ahora baja el importe a `50000` (500,00 €) con un país limpio y recibes de
+ vuelta un `{}` vacío: ninguna regla coincidió, así que no se marcó nada.
+ Cambiaste el *resultado* sin tocar Python: ese es el dial que el YAML da a
+ tus responsables de producto.
+
+::: figure art/figures/18-production.svg | Figura 18.1 — Evaluación de reglas en la frontera del servicio. Las reglas YAML se analizan una vez al arranque; cada transacción pasa por el evaluador como un diccionario de contexto mutable.
+
+!!! tip "Recargar reglas en caliente sin redesplegar"
+ Almacena `transaction_rules.yaml` en el Config Server (consulta la
+ siguiente sección) y vuelve a analizarlo en cada obtención. Tu motor de
+ reglas se convierte en un dial en vivo que controla el negocio.
+
+---
+
+## Configuración centralizada (Config Server)
+
+A medida que Lumen crece a múltiples servicios, cada uno arrastra su propia copia de URLs de base de datos, tiempos de espera y feature flags. El módulo Config Server elimina esa duplicación: un servicio posee la verdad; todos los demás la obtienen al arrancar.
+
+Un **config server**, en términos sencillos, es un pequeño servicio HTTP cuyo único trabajo es repartir configuración. En lugar de incrustar tiempos de espera y URLs en el `pyfly.yaml` de cada servicio, los almacenas en un único lugar y cada servicio pide su paquete al arrancar. Cambia el valor una vez, reinicia (o refresca) a los consumidores y toda la flota se mueve junta.
+
+### Ejecutar el servidor
+
+Habilita el servidor en `pyfly.yaml`:
+
+```yaml
+pyfly:
+ config-server:
+ enabled: true
+ backend:
+ root: /etc/lumen/config
+```
+
+Eso es todo. PyFly autoconfigura un `ConfigServer` respaldado por un `FilesystemConfigBackend` y monta las rutas HTTP automáticamente:
+
+| Método | Ruta | Propósito |
+|---|---|---|
+| `GET` | `/{app}/{profile}` | Obtener el paquete de configuración combinado |
+| `GET` | `/{app}/{profile}/{label}` | Obtener para una etiqueta concreta |
+| `POST` | `/{app}/{profile}` | Guardar un paquete de configuración |
+| `GET` | `/_list` | Listar todos los paquetes almacenados |
+
+La forma de la respuesta es compatible con Spring Cloud Config, así que un servicio Spring existente puede consumir el mismo endpoint sin cambios.
+
+### Guardar y obtener configuración de forma programática
+
+::: listing lumen/config/seed.py | Listado 18.5 — Sembrar y leer un paquete de configuración
+import asyncio
+
+from pyfly.config_server import (
+ ConfigClient,
+ ConfigServer,
+ FilesystemConfigBackend,
+)
+
+
+async def seed() -> None:
+ server = ConfigServer(FilesystemConfigBackend("/etc/lumen/config"))
+ await server.save(
+ "wallet",
+ "prod",
+ {
+ "db.url": "postgres://db:5432/lumen",
+ "cache.ttl": 30,
+ },
+ )
+ bundle = await server.fetch("wallet", "prod")
+ print(bundle)
+
+
+asyncio.run(seed())
+:::
+
+!!! tip "Ejecútalo"
+ Con `pyfly.config-server.enabled: true` en `pyfly.yaml`, arranca la app y
+ haz un curl al paquete que sembraste. Recuerda: las rutas del config-server
+ se sirven en el puerto de **aplicación** (`8080`), no en el puerto de
+ gestión.
+
+ ```bash
+ uv run pyfly run
+ # en otra terminal:
+ curl http://localhost:8080/wallet/prod
+ ```
+
+ La respuesta tiene la forma de Spring Cloud Config: tus claves sembradas
+ llegan dentro de un array `propertySources`:
+
+ ```json
+ {
+ "name": "wallet",
+ "profiles": ["prod"],
+ "propertySources": [
+ {"name": "wallet-prod", "source": {"db.url": "postgres://db:5432/lumen", "cache.ttl": 30}}
+ ]
+ }
+ ```
+
+ Como la forma coincide con Spring Cloud Config, un servicio Spring Boot
+ existente puede apuntar su `spring.config.import=configserver:` a esta misma
+ URL y consumirla sin cambios.
+
+Los servicios cliente la obtienen al arrancar con:
+
+::: listing lumen/config/bootstrap.py | Listado 18.6 — Obtener configuración remota al arranque
+from pyfly.config_server import ConfigClient
+
+
+async def load_remote() -> dict:
+ client = ConfigClient(
+ url="http://config:8888",
+ application="wallet",
+ profile="prod",
+ label="main",
+ )
+ return await client.fetch()
+:::
+
+`ConfigClient.fetch()` hace un GET a `{url}/{application}/{profile}/{label}`, combina el array `propertySources` (la prioridad más alta primero) y devuelve un diccionario plano `{dotted_key: value}`. En el funcionamiento normal nunca llamas a `ConfigClient` directamente: establece `pyfly.cloud.config.uri` en `pyfly.yaml` y `PyFlyApplication` lo llama automáticamente durante el bootstrap, combinando el resultado en el `Config` de la aplicación como una fuente de alta precedencia.
+
+!!! note "Prioridad de respaldo"
+ El servidor ensambla hasta cuatro capas de superposición:
+ `{app}/{profile}`, `{app}/default`, `application/{profile}`,
+ `application/default`. Un cliente las combina con la primera fuente
+ ganando, así que las anulaciones específicas de entorno siempre baten a
+ los valores por defecto compartidos.
+
+---
+
+## Internacionalización (i18n)
+
+Los mensajes de error y las notificaciones de Lumen viven actualmente como literales de cadena en Python. Cuando los usuarios hablan idiomas diferentes, ese enfoque no escala.
+
+**i18n** es la abreviatura de *internacionalización* (las 18 letras entre la "i" y la "n"). En la práctica significa sacar cada cadena visible para el usuario de tu código hacia **paquetes de recursos** por idioma, y luego elegir el paquete correcto en tiempo de ejecución según el idioma preferido de quien llama. Tu código se refiere a una cadena mediante una clave estable como `wallet.deposit_ok`; el framework busca la traducción.
+
+Habilita el subsistema i18n con un único flag:
+
+```yaml
+pyfly:
+ i18n:
+ enabled: true
+ base-path: i18n/
+ default-locale: en
+```
+
+### Escribir paquetes de recursos
+
+**Paso 1: escribe un paquete por idioma.** Los archivos se nombran `messages_.yaml`. Las claves se comparten entre idiomas; solo cambian los valores. Los marcadores `{0}`, `{1}` son marcadores de posición posicionales que el framework rellena en tiempo de renderizado.
+
+```yaml
+# i18n/messages_en.yaml
+wallet:
+ deposit_ok: "Deposited {0} minor units to wallet {1}."
+ limit_exceeded: "Daily limit exceeded. Maximum is {0} minor units."
+
+# i18n/messages_es.yaml
+wallet:
+ deposit_ok: "Se depositaron {0} unidades menores en la billetera {1}."
+ limit_exceeded: "Se superó el límite diario. El máximo es {0} unidades menores."
+```
+
+**`ResourceBundleMessageSource`** resuelve claves con notación de puntos y sustituye los marcadores de posición `{n}` (base cero) siguiendo la semántica de `MessageFormat`. Los códigos ausentes recurren al `default-locale`; si tampoco están allí, `get_message` lanza `KeyError`.
+
+### Usar MessageSource en un servicio
+
+**Paso 2: inyecta `MessageSource` y resuelve el locale a partir de la petición.** El servicio de abajo lee la cabecera `Accept-Language` de quien llama, escoge el paquete que coincide y renderiza el mensaje con los argumentos en tiempo de ejecución.
+
+::: listing lumen/i18n/notification_service.py | Listado 18.7 — Servicio de notificación consciente del locale
+from pyfly.container import service
+from pyfly.i18n import AcceptHeaderLocaleResolver, MessageSource
+
+
+@service
+class NotificationService:
+ """Renders user-facing messages in the caller's preferred locale."""
+
+ def __init__(
+ self,
+ messages: MessageSource,
+ locale_resolver: AcceptHeaderLocaleResolver,
+ ) -> None:
+ self._messages = messages
+ self._resolver = locale_resolver
+
+ def deposit_confirmation(
+ self,
+ request,
+ amount_minor: int,
+ wallet_id: str,
+ ) -> str:
+ locale = self._resolver.resolve_locale(request)
+ return self._messages.get_message_or_default(
+ "wallet.deposit_ok",
+ default="Deposit successful.",
+ args=(amount_minor, wallet_id),
+ locale=locale,
+ )
+:::
+
+`AcceptHeaderLocaleResolver` analiza la cabecera `Accept-Language` y devuelve el subtag primario con la `q` más alta. Usa `FixedLocaleResolver` para despliegues de un solo idioma o para pruebas. La autoconfiguración registra ambos cuando `pyfly.i18n.enabled: true`; inyecta o bien `MessageSource` (el protocolo del puerto) o bien el `ResourceBundleMessageSource` concreto: ambos funcionan.
+
+!!! tip "Ejecútalo"
+ La forma más rápida de demostrar que la búsqueda funciona es una prueba que
+ resuelve el paquete directamente, sin necesidad de un servidor HTTP. Guarda
+ el Listado 18.7a bajo `tests/` y luego ejecuta solo esta prueba:
+
+ ```bash
+ uv run --extra dev pytest tests/test_messages.py -q
+ ```
+
+ Esperado:
+
+ ```
+ 1 passed in 0.05s
+ ```
+
+ Cambia `locale="es"` por `locale="en"` y la aserción necesitaría la cadena
+ en inglés en su lugar: misma clave, paquete distinto. Ese es justo el
+ objetivo de i18n: tu código nunca cambia, solo cambia el locale resuelto.
+
+::: listing tests/test_messages.py | Listado 18.7a — Afirmar un mensaje traducido
+from pyfly.i18n.adapters.resource_bundle import ResourceBundleMessageSource
+
+
+def test_deposit_message_in_spanish() -> None:
+ messages = ResourceBundleMessageSource(base_path="i18n/", default_locale="en")
+ text = messages.get_message(
+ "wallet.deposit_ok", args=(100, "w-001"), locale="es"
+ )
+ assert text == "Se depositaron 100 unidades menores en la billetera w-001."
+:::
+
+!!! spring "Equivalencia con Spring"
+ `MessageSource`, `ResourceBundleMessageSource`,
+ `AcceptHeaderLocaleResolver` y `FixedLocaleResolver` son
+ equivalentes directos de nombre del stack i18n de Spring MVC. La API
+ difiere solo en el uso de marcadores de posición posicionales `{n}` en
+ lugar de SpEL dentro de las cadenas de mensaje.
+
+---
+
+## Actualizaciones en tiempo real con WebSocket
+
+El panel de administración de Lumen actualmente sondea en busca de cambios de saldo. Un endpoint WebSocket elimina el sondeo: el servidor empuja una actualización en el instante en que un depósito se confirma.
+
+Un **WebSocket** es una conexión bidireccional que permanece abierta. Una petición HTTP normal es de un solo disparo: el cliente pregunta, el servidor responde, la línea se cierra. Con un WebSocket la línea se mantiene activa, así que el *servidor* puede enviar datos cuando le apetezca: perfecto para actualizaciones en vivo. El esquema de URL es `ws://` (o `wss://` sobre TLS) en lugar de `http://`.
+
+::: listing lumen/web/balance_ws_controller.py | Listado 18.8 — Flujo de saldo en vivo vía WebSocket
+import asyncio
+
+from pyfly.container import rest_controller
+from pyfly.web import request_mapping
+from pyfly.websocket import WebSocketSession, websocket_mapping
+
+
+@rest_controller
+@request_mapping("/ws")
+class BalanceFeedController:
+ """Streams balance updates to connected clients."""
+
+ def __init__(self, wallet_service) -> None:
+ self._wallet = wallet_service
+ self._clients: set[WebSocketSession] = set()
+
+ @websocket_mapping("/balance/{wallet_id}")
+ async def balance_feed(self, session: WebSocketSession) -> None:
+ wallet_id = session.path_params["wallet_id"]
+ await session.accept()
+ self._clients.add(session)
+ try:
+ while True:
+ balance = await self._wallet.get_balance(wallet_id)
+ await session.send_json(
+ {"wallet_id": wallet_id, "balance_minor": balance}
+ )
+ await asyncio.sleep(1)
+ finally:
+ self._clients.discard(session)
+
+ async def on_disconnect(self, session: WebSocketSession) -> None:
+ self._clients.discard(session)
+:::
+
+**Cómo funciona.** `@websocket_mapping("/balance/{wallet_id}")` monta el endpoint en `ws:///ws/balance/{wallet_id}`. La ruta completa es la base `@request_mapping` del controlador (`/ws`) concatenada con la ruta del decorador.
+
+!!! tip "Ejecútalo"
+ Arranca la app y luego abre el flujo en vivo con cualquier cliente
+ WebSocket. Usando `websocat` (`brew install websocat`):
+
+ ```bash
+ uv run pyfly run
+ # en otra terminal:
+ websocat ws://localhost:8080/ws/balance/w-001
+ ```
+
+ Deberías ver llegar un frame JSON aproximadamente una vez por segundo,
+ empujado por el servidor sin que vuelvas a pedirlo:
+
+ ```json
+ {"wallet_id": "w-001", "balance_minor": 5000}
+ {"wallet_id": "w-001", "balance_minor": 5000}
+ ```
+
+ Deposita en `w-001` desde otra terminal y observa cómo el valor de
+ `balance_minor` salta en el siguiente frame: sin sondeo, sin refresco. Pulsa
+ `Ctrl+C` para cerrar; se ejecuta el `on_disconnect` del controlador y la
+ sesión se elimina del conjunto de difusión.
+
+`WebSocketSession` expone el ciclo de vida de la conexión:
+
+| Método | Descripción |
+|---|---|
+| `await accept(subprotocol=None)` | Completar el handshake |
+| `await send_json(data)` | Serializar y enviar un mensaje JSON |
+| `await send_text(data)` | Enviar una cadena plana |
+| `await receive_text()` | Bloquear hasta que llegue un mensaje de texto |
+| `await receive_json()` | Bloquear hasta que llegue un mensaje JSON |
+| `await close(code=1000, reason=None)` | Cerrar la conexión limpiamente |
+
+`session.path_params`, `session.query_params` y `session.headers` exponen los metadatos de la conexión. Las rutas WebSocket se descubren automáticamente junto con las rutas HTTP: no se requiere configuración adicional.
+
+El método opcional `on_disconnect` lo invoca automáticamente el registrador después de que el manejador `@websocket_mapping` retorne o el cliente cierre la conexión (solo si la conexión se aceptó antes), dando a los controladores un lugar seguro donde liberar recursos.
+
+!!! tip "Difusión (broadcasting)"
+ Mantén un `set[WebSocketSession]` en el controlador y difunde con
+ `for client in list(self._clients): await client.send_json(payload)`.
+ Como los beans de los controladores son singletons, el conjunto vive
+ durante toda la vida de la aplicación.
+
+---
+
+## Comandos de shell y runners de arranque
+
+No toda funcionalidad vive detrás de un endpoint HTTP. Los scripts de siembra de bases de datos, las migraciones de datos puntuales y los trabajos por lotes programados se expresan mejor como comandos CLI que se ejecutan dentro del mismo contenedor de DI, compartiendo servicios, configuración y repositorios con la aplicación principal.
+
+La idea clave aquí: un **comando de shell** es solo un método que se ejecuta desde la terminal pero que aún tiene acceso completo a los servicios cableados de tu aplicación. No escribes un script aparte que recree la conexión a la base de datos a mano: el comando recibe el mismo `WalletService` que usan tus controladores HTTP, porque vive dentro del mismo contenedor de DI.
+
+### @shell_component y @shell_method
+
+::: listing lumen/cli/wallet_commands.py | Listado 18.9 — Comandos de shell con DI
+from pyfly.shell import (
+ shell_argument,
+ shell_component,
+ shell_method,
+ shell_option,
+)
+
+
+@shell_component
+class WalletCommands:
+ """Operational commands for the Lumen wallet service."""
+
+ def __init__(self, wallet_service) -> None:
+ self._wallet = wallet_service
+
+ @shell_method(group="wallet", help="Deposit funds into a wallet")
+ @shell_argument("wallet_id", help="Target wallet identifier")
+ @shell_option("--amount", help="Amount in minor units (integer cents)")
+ async def deposit(
+ self, wallet_id: str, amount: int = 100
+ ) -> str:
+ result = await self._wallet.deposit(wallet_id, amount)
+ return f"New balance: {result['balance_minor']} minor units"
+
+ @shell_method(group="wallet", help="Show current balance")
+ @shell_argument("wallet_id", help="Wallet to inspect")
+ async def balance(self, wallet_id: str) -> str:
+ data = await self._wallet.get_balance(wallet_id)
+ return f"{wallet_id}: {data['balance_minor']} minor units"
+:::
+
+Habilita el shell en `pyfly.yaml`:
+
+```yaml
+pyfly:
+ shell:
+ enabled: true
+```
+
+PyFly autoconfigura un `ClickShellAdapter` y cablea cada `@shell_method` al arranque. El nombre del grupo se convierte en un subcomando:
+
+```bash
+python -m lumen wallet deposit w-001 --amount 500
+python -m lumen wallet balance w-001
+python -m lumen # sin argumentos → entra en modo REPL
+```
+
+!!! tip "Ejecútalo"
+ Deposita 500 unidades menores en un monedero directamente desde la línea de
+ comandos: el comando ejecuta tu `WalletService` real contra la base de datos
+ real:
+
+ ```bash
+ uv run python -m lumen wallet deposit w-001 --amount 500
+ ```
+
+ Salida esperada (el valor de retorno de tu método `deposit`, impreso por
+ el adaptador de shell):
+
+ ```
+ New balance: 500 minor units
+ ```
+
+ Ejecuta `uv run python -m lumen wallet balance w-001` y verás reflejado de
+ vuelta el mismo `500`: prueba de que el comando y la API HTTP hablan con el
+ mismo estado persistente, no con una copia desechable en memoria.
+
+### CommandLineRunner: tareas puntuales tras el arranque
+
+Para tareas que se ejecutan una vez al arranque —siembra, calentamiento, comprobaciones de conexión— implementa **`CommandLineRunner`**:
+
+::: listing lumen/runners/seed_runner.py | Listado 18.10 — Sembrador de base de datos tras el arranque
+from pyfly.container import service
+from pyfly.shell import CommandLineRunner
+
+
+@service
+class SeedRunner(CommandLineRunner):
+ """Seed the database with a default admin wallet on first boot."""
+
+ def __init__(self, wallet_service) -> None:
+ self._wallet = wallet_service
+
+ async def run(self, args: list[str]) -> None:
+ if "--seed" in args:
+ await self._wallet.ensure_default_wallet()
+ print("Default wallet ensured.")
+:::
+
+Cualquier bean cuya clase implemente `async def run(self, args: list[str]) -> None` satisface estructuralmente el protocolo `CommandLineRunner`. El framework lo detecta vía `isinstance()` (el protocolo es `@runtime_checkable`) después de que se dispare `ApplicationReadyEvent`, y luego lo invoca con los argumentos CLI en crudo. Usa `@order(n)` para controlar el orden de ejecución cuando coexisten varios runners.
+
+!!! tip "Ejecútalo"
+ El runner se dispara durante el arranque de la aplicación y recibe el
+ `sys.argv[1:]` del proceso, así que lanza la app a través de su punto de
+ entrada CLI con el flag añadido:
+
+ ```bash
+ uv run python -m lumen --seed
+ ```
+
+ Tras el banner y la tabla de rutas, verás la línea de confirmación del
+ runner:
+
+ ```
+ Default wallet ensured.
+ ```
+
+ Arranca sin `--seed` y la línea no aparece: la guarda `if "--seed" in args`
+ se salta el trabajo. Esa es la diferencia entre un *runner* (se ejecuta en
+ cada arranque, lo controlas tú) y un comando de shell puntual (se ejecuta
+ solo cuando invocas su nombre).
+
+!!! spring "Equivalencia con Spring"
+ `@shell_component`, `@shell_method`, `@shell_option`,
+ `@shell_argument` y `CommandLineRunner` son equivalentes
+ directos de `@ShellComponent`, `@ShellMethod`, `@ShellOption`,
+ `@ShellArgument` de Spring Shell y de la interfaz `CommandLineRunner`
+ de Spring Boot. Click reemplaza a JLine como librería de terminal,
+ pero el modelo de programación es idéntico.
+
+---
+
+## Generar un SDK a partir de la especificación OpenAPI
+
+Cuando Lumen expone una API HTTP, los servicios posteriores deberían llamarla mediante un cliente generado, no mediante llamadas `httpx` escritas a mano que se desincronizan. PyFly construye y sirve una especificación OpenAPI 3.1 automáticamente en `/openapi.json`.
+
+Una **especificación OpenAPI** es una descripción legible por máquina de tu API HTTP: cada ruta, cada parámetro, cada forma de petición y respuesta. Un **SDK** (software development kit) es la librería cliente que una herramienta genera *a partir de* esa especificación: métodos tipados que envuelven las llamadas HTTP por ti. La cadena es: tus controladores → la especificación → un cliente generado. Como cada eslabón es mecánico, el cliente nunca puede desincronizarse silenciosamente del servidor.
+
+`OpenAPIGenerator` ensambla la especificación a partir de los metadatos de ruta recopilados por `ControllerRegistrar`:
+
+- **Info**: poblada a partir de `title`, `version` y `description` pasados a `create_app()`.
+- **Paths**: una operación por cada `@get_mapping` / `@post_mapping`, etc., con parámetros inferidos a partir de las anotaciones de tipo `PathVar[T]`, `QueryParam[T]`, `Header[T]` y `Body[BaseModel]`.
+- **Schemas**: modelos de Pydantic registrados en `components.schemas` vía `model_json_schema()` y referenciados con `$ref`.
+
+Con la especificación disponible, genera un cliente Python en dos pasos: descarga la especificación y luego ejecuta el generador.
+
+**Paso 1: descarga la especificación de una instancia en ejecución.** La especificación vive en el puerto de aplicación (`8080`), junto a tus rutas de negocio.
+
+**Paso 2: ejecuta el generador de OpenAPI** para convertir ese JSON en un paquete Python instalable.
+
+```bash
+# Download the spec from a running instance
+curl http://localhost:8080/openapi.json -o lumen-spec.json
+
+# Generate a Python client package
+openapi-generator-cli generate \
+ -i lumen-spec.json \
+ -g python \
+ -o lumen-client \
+ --package-name lumen_client
+```
+
+El paquete `lumen_client` generado contiene modelos tipados y un `DefaultApi` con un método por operación. Los servicios consumidores lo añaden como dependencia y lo llaman sin saber nada de HTTP:
+
+::: listing payment/services/wallet_client.py | Listado 18.11 — Consumir el SDK de Lumen generado
+from lumen_client import ApiClient, Configuration, DefaultApi
+
+
+class WalletGateway:
+ """Typed façade over the generated Lumen client SDK."""
+
+ def __init__(self, base_url: str) -> None:
+ cfg = Configuration(host=base_url)
+ self._api = DefaultApi(ApiClient(cfg))
+
+ def get_balance(self, wallet_id: str) -> int:
+ result = self._api.get_wallet_balance(wallet_id)
+ return result.balance_minor
+:::
+
+!!! tip "Mantén la especificación versionada"
+ Incluye `lumen-spec.json` en el repositorio de Lumen y regenera los
+ paquetes cliente en CI cada vez que cambie la especificación. Los
+ equipos posteriores se fijan a una versión concreta de la
+ especificación a través de su gestor de dependencias: la misma
+ disciplina que usan los equipos Java con las versiones de artefactos
+ de Maven.
+
+---
+
+## Llevarlo a producción
+
+### Empaquetar con Docker
+
+**Contenerizar**, en términos sencillos, significa congelar tu app y todo lo que necesita para ejecutarse —Python, dependencias, tu código, tu configuración— en una única imagen que se ejecuta de forma idéntica en tu portátil, en CI y en producción. Un `Dockerfile` es la receta para construir esa imagen.
+
+`pyfly new` genera un `Dockerfile` para cada arquetipo. Para un servicio web tiene este aspecto tras el endurecimiento de producción. Léelo de arriba abajo: cada línea es un paso del build:
+
+```dockerfile
+FROM python:3.12-slim
+
+WORKDIR /app
+COPY pyproject.toml uv.lock ./
+RUN pip install uv && \
+ uv sync --no-dev --extra web --extra data-relational \
+ --extra security --extra observability
+
+COPY src/ src/
+COPY pyfly.yaml .
+
+# 8080 = application traffic; 9090 = the management port (actuator + admin).
+EXPOSE 8080 9090
+CMD ["pyfly", "run", "--host", "0.0.0.0", \
+ "--port", "8080", "--server", "granian", "--workers", "2"]
+```
+
+Recorriendo la receta: `FROM` escoge una imagen base de Python pequeña; `COPY` + `uv sync` instalan exactamente los extras de dependencias que nombras (y nada más); el segundo `COPY` añade tu fuente y tu configuración; `EXPOSE` documenta los dos puertos en los que escucha el contenedor; `CMD` es el comando que se ejecuta cuando arranca el contenedor. El flag `--port 8080` de aquí establece `pyfly.server.port` para este proceso: el puerto de gestión se queda en su valor por defecto `9090` salvo que anules `pyfly.management.server.port`.
+
+Instala solo los extras que tu servicio realmente usa: el meta-extra `full` arrastra los drivers de Kafka, RabbitMQ y MongoDB incluso cuando no necesitas ninguno de ellos.
+
+!!! tip "Ejecútalo"
+ Construye la imagen y ejecútala, mapeando ambos puertos fuera del
+ contenedor:
+
+ ```bash
+ docker build -t lumen:local .
+ docker run --rm -p 8080:8080 -p 9090:9090 lumen:local
+ ```
+
+ Luego confirma que ambos listeners responden: el puerto de negocio en
+ `8080` y el puerto de gestión en `9090`:
+
+ ```bash
+ curl http://localhost:8080/openapi.json # app: returns the spec
+ curl http://localhost:9090/actuator/health # management: {"status":"UP"}
+ ```
+
+ Dos puertos, un proceso. Esa separación es sobre la que se construye el
+ resto de esta sección.
+
+### Variables de entorno y secretos
+
+Nunca incrustes secretos en `pyfly.yaml`. PyFly resuelve marcadores de posición `${ENV_VAR}` en cualquier parte de la configuración:
+
+```yaml
+pyfly:
+ data:
+ relational:
+ url: ${DATABASE_URL}
+
+ security:
+ jwt:
+ secret: ${JWT_SECRET}
+```
+
+PyFly lee los valores reales del entorno del contenedor al arranque. En Kubernetes, respalda esas variables con un `Secret`; en Docker Compose, usa un archivo `.env` que nunca se commitea. El comando `pyfly doctor` comprueba que las herramientas requeridas están presentes, pero no valida secretos: esa responsabilidad sigue siendo tuya.
+
+### Apagado ordenado
+
+PyFly respeta el apagado ordenado por defecto. Establece `pyfly.server.graceful-timeout` (en segundos) para controlar cuánto espera el servidor a que se completen las peticiones en vuelo antes de forzar la salida:
+
+```yaml
+pyfly:
+ server:
+ graceful-timeout: 30
+```
+
+SIGTERM dispara la secuencia de apagado: el servidor deja de aceptar nuevas conexiones, `ApplicationContext.stop()` ejecuta los hooks `@pre_destroy` y `stop_all()` para los plugins, y el proceso sale limpiamente. En Kubernetes, establece `terminationGracePeriodSeconds` al menos cinco segundos por encima de `graceful-timeout`.
+
+### Selección de servidor
+
+El servidor ASGI se selecciona por prioridad en tiempo de ejecución:
+
+| Prioridad | Servidor | Extra de instalación |
+|---|---|---|
+| 1 | Granian (Rust/tokio) | `granian` |
+| 2 | Uvicorn | `web` (por defecto) |
+| 3 | Hypercorn | `hypercorn` |
+
+Para producción, prefiere Granian: ofrece aproximadamente 3× el rendimiento de Uvicorn con HTTP/2 nativo. Combínalo con uvloop en Linux para un aceleramiento adicional de 2 a 4× del bucle de eventos:
+
+```bash
+uv add "pyfly[web-fast]" # granian + uvloop in one shot
+```
+
+Fija la elección en YAML para evitar sorpresas en máquinas donde resulta que hay varios servidores instalados:
+
+```yaml
+pyfly:
+ server:
+ type: granian
+ event-loop: uvloop
+ workers: 4
+ graceful-timeout: 30
+```
+
+### El despliegue de dos puertos
+
+Este es el hecho de despliegue que más necesitas interiorizar para v26.6.110. PyFly se ejecuta en **dos puertos**, ambos dentro de un único proceso:
+
+- el **puerto de aplicación** —`pyfly.server.port`, por defecto `8080`— sirve tu API de negocio, tus flujos WebSocket, la especificación OpenAPI y las rutas del config-server;
+- el **puerto de gestión** —`pyfly.management.server.port`, por defecto `9090`— sirve el Actuator (`/actuator/*`) y el panel de administración (`/admin`).
+
+El puerto de gestión es un segundo listener en proceso, no procesos worker adicionales, así que casi no cuesta nada. Dos opciones de ajuste importan en el momento del despliegue: establece `pyfly.management.server.port` **igual** a `pyfly.server.port` para colapsar todo en un único puerto, o ponlo a **`-1`** para deshabilitar por completo los endpoints web de gestión. La anulación por entorno es `PYFLY_MANAGEMENT_SERVER_PORT`.
+
+¿Por qué separarlos? Porque te permite exponer solo `8080` a internet manteniendo las comprobaciones de salud, los scrapes de Prometheus y la consola de administración en `9090`, accesibles únicamente desde dentro de tu clúster.
+
+!!! warning "El puerto de gestión está ABIERTO por defecto"
+ A partir de v26.6.110, el puerto de gestión está **sin autenticación por
+ defecto** (el modelo `management.server.port` de Spring Boot): cualquier
+ cosa que pueda alcanzar `9090` puede leer `/actuator/*` y `/admin`. Eso es
+ intencionado: el puerto está pensado para situarse tras aislamiento de red,
+ nunca en el internet público. Si no puedes garantizar ese aislamiento,
+ aplica también los filtros de seguridad de la app al puerto de gestión:
+
+ ```yaml
+ pyfly:
+ management:
+ security:
+ enabled: true
+ ```
+
+ Con ese flag activado, la misma autenticación, las guardas de roles y las
+ reglas CSRF que protegen tu API de negocio también protegen `9090`.
+
+### Endpoints de salud
+
+PyFly expone endpoints de actuator al estilo de Spring Boot de fábrica. Viven en el **puerto de gestión** (`9090`):
+
+| Endpoint | Propósito |
+|---|---|
+| `GET /actuator/health` | Salud agregada (UP / DOWN) |
+| `GET /actuator/health/liveness` | Sonda de liveness de Kubernetes |
+| `GET /actuator/health/readiness` | Sonda de readiness de Kubernetes |
+| `GET /actuator/metrics` | Desglose por métrica (p. ej. `/actuator/metrics/http.server.requests`) |
+| `GET /actuator/prometheus` | Endpoint de scrape de Prometheus |
+
+Solo `health` e `info` se exponen sobre HTTP por defecto (el valor por defecto seguro de Spring Boot). Para publicar las métricas y el endpoint de scrape de Prometheus, añádelos a la lista de exposición en `pyfly.yaml`:
+
+```yaml
+pyfly:
+ management:
+ endpoints:
+ web:
+ exposure:
+ include: health,info,metrics,prometheus
+ endpoint:
+ health:
+ show-details: when-authorized
+```
+
+!!! tip "Ejecútalo"
+ Golpea las sondas en el puerto de gestión: responden de inmediato porque
+ ese puerto está abierto por defecto:
+
+ ```bash
+ curl http://localhost:9090/actuator/health
+ curl http://localhost:9090/actuator/health/readiness
+ ```
+
+ Esperado: un objeto de estado JSON que el orquestador puede analizar:
+
+ ```json
+ {"status": "UP"}
+ ```
+
+ Una comprobación de readiness que falla (una base de datos aún
+ calentándose, por ejemplo) devuelve `{"status": "DOWN"}` con HTTP `503`,
+ que es exactamente lo que Kubernetes necesita para retener el tráfico hasta
+ que el pod esté listo.
+
+Luego conecta tu deployment de Kubernetes: fíjate en que cada sonda y cada scrape apuntan al **puerto de gestión** `9090`, no a `8080`:
+
+```yaml
+livenessProbe:
+ httpGet:
+ path: /actuator/health/liveness
+ port: 9090
+ initialDelaySeconds: 10
+ periodSeconds: 15
+readinessProbe:
+ httpGet:
+ path: /actuator/health/readiness
+ port: 9090
+ initialDelaySeconds: 5
+ periodSeconds: 10
+```
+
+!!! spring "Equivalencia con Spring"
+ La división de dos puertos es un port directo del `management.server.port`
+ de Spring Boot: tráfico de la app en `server.port`, actuator y la UI de
+ gestión en un `management.server.port` dedicado. La postura de abierto por
+ defecto, la convención de `-1` para deshabilitar y `management.security.enabled`
+ para bloquearlo reflejan todos el comportamiento de Spring Boot.
+
+!!! note "Sondas personalizadas desde el HealthAggregator en vivo"
+ Nuevo en v26.6.110, el `HealthAggregator` en vivo es accesible en
+ `app.state.pyfly_health_aggregator` (solo adaptador Starlette). Registra un
+ indicador de readiness adicional después de `create_app()` —por ejemplo una
+ comprobación que haga ping a un servicio posterior— y aparece en
+ `/actuator/health` independientemente de si el actuator se ejecuta en el
+ puerto de gestión compartido o en el separado.
+
+**Lo que acaba de ocurrir.** Ahora tienes la forma completa de un despliegue de
+PyFly: un contenedor, dos puertos (`8080` para usuarios, `9090` para
+operaciones), secretos inyectados como variables de entorno, un servidor Granian
+ajustado con workers explícitos, una ventana de apagado ordenado que drena las
+peticiones en vuelo y sondas de Kubernetes conectadas al puerto de gestión. La
+lista de comprobación de abajo es la misma imagen en forma de lista que puedes
+pegar en una plantilla de pull request.
+
+### La lista de comprobación de producción
+
+- [ ] Todos los secretos son variables de entorno: ninguno está en
+ `pyfly.yaml` ni en el control de versiones.
+- [ ] La imagen Docker instala solo los extras que el servicio usa.
+- [ ] El servidor está fijado a Granian + uvloop; `workers` está
+ establecido explícitamente (no dejado en `1` para máquinas
+ multinúcleo).
+- [ ] `graceful-timeout` es de al menos 15 s; el
+ `terminationGracePeriodSeconds` de Kubernetes es al menos 5 s más.
+- [ ] Las sondas de liveness y readiness apuntan al **puerto de gestión**
+ (`9090`), no al puerto de aplicación, y están probadas.
+- [ ] `/actuator/health` (en `9090`) devuelve UP antes de que se envíe tráfico.
+- [ ] El puerto de gestión está aislado a nivel de red, o
+ `pyfly.management.security.enabled: true` está establecido: está abierto
+ por defecto.
+- [ ] El endpoint de scrape de Prometheus (`/actuator/prometheus` en `9090`)
+ está añadido a la lista de exposición y lo scrapea el stack de
+ monitorización.
+- [ ] El logging estructurado está habilitado (`pyfly[observability]`) y el
+ nivel de log es `INFO` en producción, no `DEBUG`.
+- [ ] El exportador de OpenTelemetry apunta al colector de producción.
+- [ ] Las migraciones de base de datos (`pyfly db upgrade`) se ejecutan en un
+ paso previo al despliegue, no al arranque de la aplicación.
+- [ ] La especificación OpenAPI generada está versionada en CI y los paquetes
+ SDK posteriores se fijan a una revisión concreta de la especificación.
+- [ ] `pyfly doctor` pasa en cada máquina de desarrollo y en cada runner de CI.
+
+---
+
+## Lo que construiste {.recap}
+
+Hace diecisiete capítulos escribiste `@pyfly_application` y viste cómo el contenedor de DI cableaba tu primer `@service`. Hoy ese mismo contenedor impulsa una plataforma de monedero en producción. Esto es lo que construiste por el camino.
+
+**Capítulos 1–3** te dieron los cimientos: un contenedor de DI de primera clase, configuración flexible (YAML, env, perfiles, expresiones SpEL) y una capa HTTP completa con request mapping, filtros, negociación de contenido y una capa de serialización JSON que puedes reemplazar sin tocar un solo controlador.
+
+**Capítulos 4–6** introdujeron la persistencia. Mapeaste entidades con SQLAlchemy, gestionaste la evolución del esquema con Alembic y estructuraste el dominio con patrones tácticos de DDD: agregados, objetos de valor y repositorios que mantienen la lógica de negocio fuera de la infraestructura.
+
+**Capítulos 7–9** dieron vida a la arquitectura. CQRS separó las lecturas de las escrituras a nivel del manejador. EDA dejó que los servicios reaccionaran a eventos sin sondear. El event sourcing convirtió cada cambio de estado en un hecho de primera clase: reproducible, auditable y el cimiento de las proyecciones.
+
+**Capítulos 10–12** enseñaron a Lumen a salir de su propio proceso. Clientes HTTP resilientes llamaban a servicios posteriores sin fallos en cascada. Las sagas orquestaban transacciones de múltiples pasos a través de las fronteras de los servicios con compensación automática cuando algún paso salía mal.
+
+**Capítulos 13–15** endurecieron la plataforma. La caché redujo la presión sobre la base de datos. Limitadores de tasa, bulkheads, tiempos de espera y cortacircuitos (circuit breakers) convirtieron cada dependencia en un radio de impacto controlado. Las trazas distribuidas, el logging estructurado y un panel de administración en vivo te dieron ojos dentro del sistema en cada capa.
+
+**Capítulos 16–17** cerraron el bucle de retroalimentación. Un conjunto de pruebas estructurado —pruebas unitarias, de integración y de persistencia respaldadas por Testcontainers— hizo seguro cambiar la plataforma. Tareas programadas, notificaciones push, webhooks y callbacks dejaron que Lumen llegara al mundo según su propio calendario.
+
+**Capítulo 18** te mostró lo que hay más allá del núcleo: un sistema de plugins para la extensión abierta, un motor de reglas YAML para la lógica propiedad del negocio, un Config Server para la configuración de toda la flota, i18n para audiencias globales, WebSocket para UX en tiempo real, un módulo Shell para herramientas operativas, una especificación OpenAPI que genera SDKs cliente tipados automáticamente y los hábitos de producción que mantienen todo eso en marcha, empaquetado como un único contenedor que escucha en dos puertos, `8080` para usuarios y `9090` para operaciones.
+
+PyFly no es magia. Toda abstracción de este libro tiene un coste, y ahora entiendes cuál es ese coste: un contenedor de DI que arranca en milisegundos pero te obliga a pensar en los ámbitos de los beans; un servidor HTTP asíncrono que maneja miles de conexiones concurrentes pero te obliga a evitar llamadas bloqueantes; un motor de sagas que sobrevive a fallos parciales pero te obliga a escribir transacciones de compensación.
+
+Entender el coste es lo que separa a un practicante de un lector que se limita a copiar patrones. Ahora eres un practicante.
+
+---
+
+## Pruébalo tú mismo {.exercises}
+
+1. **Añade un plugin personalizado.** Implementa un `AuditPlugin` que aporte una extensión a un punto de extensión `"audit-sinks"`. La clase de extensión debe implementar una interfaz `AuditSink` y escribir cada evento en un archivo. En una prueba, llama a `PluginManager.registry.get("audit-sinks")` y afirma que tu extensión se devuelve con el `name` esperado.
+
+2. **Despliega un cambio de reglas sin redesplegar.** Almacena `transaction_rules.yaml` en el Config Server (`pyfly.config-server.enabled: true`). Escribe un `RiskService` que obtenga el YAML vía `ConfigClient` en cada llamada a `assess()` (o que lo cachee con un TTL corto). Actualiza el umbral `value` a través de la ruta `POST /{app}/{profile}` y verifica que `assess()` recoge el nuevo valor sin reiniciar el servicio.
+
+3. **Localiza un mensaje de rechazo.** Añade `wallet.limit_exceeded` a `i18n/messages_en.yaml` e `i18n/messages_es.yaml`. Conecta un `NotificationService` que lea el locale de la cabecera `Accept-Language` y devuelva la cadena correcta. Escribe dos pruebas —una con `Accept-Language: en`, otra con `Accept-Language: es`— y afirma que cada una devuelve el mensaje adecuado.
+
+---
+
+Lumen está lista para producción. Para lo que viene a continuación —nuevos módulos, plugins de la comunidad y notas de versión— visita la documentación del framework en [github.com/fireflyframework/fireflyframework-pyfly](https://github.com/fireflyframework/fireflyframework-pyfly). Cada concepto de este libro vive en ese repositorio; el código fuente es la referencia definitiva.
diff --git a/book/manuscript-es/90-appendix-a-spring.md b/book/manuscript-es/90-appendix-a-spring.md
new file mode 100644
index 00000000..bb294854
--- /dev/null
+++ b/book/manuscript-es/90-appendix-a-spring.md
@@ -0,0 +1,375 @@
+Apéndice A
+
+# Chuleta Spring Boot → PyFly {.chtitle}
+
+Si has puesto en producción servicios con Spring Boot, los conceptos de PyFly te resultarán inmediatamente familiares: estereotipos, inyección por constructor, factorías `@configuration` + `@bean`, vinculación tipada de configuración, consultas derivadas, orquestación de sagas — están todos aquí. Lo que cambia es la sintaxis (decoradores de Python en lugar de anotaciones de Java), el modelo de ejecución (`async/await` nativo en lugar de Project Reactor o hilos de servlet) y un puñado de decisiones de diseño deliberadas para encajar con el Python idiomático. Esta chuleta asocia cada concepto de Spring Boot que ya conoces con su equivalente en PyFly, de modo que puedas empezar a leer y escribir código PyFly sin reaprender la arquitectura desde cero.
+
+---
+
+## Arranque de la aplicación y estereotipos
+
+| Spring Boot | PyFly | Notas |
+|---|---|---|
+| `@SpringBootApplication` | `@pyfly_application` | Combina `@EnableAutoConfiguration` + `@ComponentScan`. `scan_packages` sustituye al escaneo del classpath — enumera los paquetes explícitamente. |
+| `SpringApplication.run(...)` | `PyFlyApplication(App); await app.startup()` | El punto de entrada es asíncrono. |
+| `@Component` | `@component` | Bean singleton genérico gestionado. |
+| `@Service` | `@service` | Capa de lógica de negocio. |
+| `@Repository` | `@repository` | Capa de acceso a datos. |
+| `@RestController` | `@rest_controller` | Clase de endpoint de API. No hay `@Controller` (no se renderizan vistas). |
+| `@Configuration` | `@configuration` | Clase factoría de beans. |
+| `@Bean` | `@bean` | Método factoría dentro de una clase `@configuration`. La anotación del tipo de retorno es el tipo registrado del bean. |
+| `@Primary` | `@primary` o `@bean(primary=True)` | Opción por defecto cuando existen varios candidatos. |
+| `@Order(N)` | `@order(N)` | Ordenación del ciclo de vida y de la inyección. |
+| `@Lazy` | `@lazy` | El bean no se crea hasta su primera resolución. |
+
+---
+
+## Inyección de dependencias
+
+| Spring Boot | PyFly | Notas |
+|---|---|---|
+| Constructor `@Autowired` (implícito en el Spring moderno) | Constructor normal con parámetros tipados | El contenedor lee las anotaciones de tipo de `__init__` automáticamente. |
+| Campo `@Autowired` | `field: T = Autowired()` | `Autowired(required=False)` para campos opcionales. |
+| `@Qualifier("name")` | `Annotated[T, Qualifier("name")]` | El `Annotated` de Python transporta el cualificador sin perder el tipo base. |
+| Inyección de `Optional` | Parámetro `Optional[T]` | Se resuelve a `None` cuando no hay ningún bean registrado. |
+| Inyección de `List` | Parámetro `list[T]` | Recopila todas las implementaciones registradas de `T`. |
+| Inyección de `Map` | Parámetro `dict[str, T]` | `{nombre-bean: bean}` para cada bean con nombre de tipo `T`. |
+| Inyección genérica `Repository` | Parámetro `Repository[User, int]` | El contenedor casa por los argumentos de tipo genérico y respeta `@primary` para resolver empates. |
+| `ObjectFactory` / `Provider` | `Provider[T]` | Resolución diferida; cada `.get()` vuelve a resolver — seguro para beans `TRANSIENT`. |
+
+!!! tip "Prefiere la inyección por constructor"
+ La inyección por constructor mantiene las dependencias visibles en la firma de la clase, evita errores por dependencias ausentes en el arranque en lugar de en tiempo de ejecución, y te permite escribir pruebas unitarias en Python puro sin contenedor: `svc = WalletService(repo=MockRepo(), events=MockEvents())`.
+
+---
+
+## Condiciones y autoconfiguración
+
+| Spring Boot | PyFly | Notas |
+|---|---|---|
+| `@ConditionalOnProperty` | `@conditional_on_property` | Registra cuando una clave de configuración es igual a un valor concreto. |
+| `@ConditionalOnClass` | `@conditional_on_class("module")` | Registra cuando un módulo de Python es importable. |
+| `@ConditionalOnMissingBean` | `@conditional_on_missing_bean(T)` | Registra cuando aún no existe ningún bean de tipo `T`. |
+| `@ConditionalOnBean` | `@conditional_on_bean(T)` | Registra solo si hay presente un bean de tipo `T`. |
+| `@ConditionalOnSingleCandidate` | `@conditional_on_single_candidate(T)` | Exactamente un candidato, o uno marcado como `@primary`. |
+| `@ConditionalOnWebApplication` | `@conditional_on_web_application()` | Pila web (Starlette/FastAPI) presente. |
+| `@ConditionalOnResource` | `@conditional_on_resource(path)` | Existe una ruta del sistema de archivos. |
+| `@ConditionalOnExpression` | `@conditional_on_expression("#{...}")` | Expresión tipo SpEL ligera — admite `${key:default}` + aritmética, comparación y booleanos. Analizada como AST, sin `eval`. |
+
+---
+
+## Hooks del ciclo de vida y ámbitos
+
+| Spring Boot | PyFly | Notas |
+|---|---|---|
+| `@PostConstruct` | `@post_construct` | Se invoca después de la inyección de dependencias; puede ser `async def`. |
+| `@PreDestroy` | `@pre_destroy` | Se invoca en el apagado ordenado; puede ser `async def`. |
+| Ámbito por defecto (singleton) | Por defecto (singleton) | Una instancia por aplicación. |
+| `@SessionScope` | `@component(scope=Scope.SESSION)` | Una instancia por `HttpSession`. |
+| SPI de `Scope` personalizado | `Container.register_scope(name, handler)` | Implementa el protocolo `ScopeHandler`. |
+| `@RefreshScope` (Spring Cloud) | `@refresh_scope` | Se descarta y se reconstruye al recibir `POST /actuator/refresh`. |
+| `ContextRefresher.refresh()` | `ContextRefresher.refresh()` (inyectable) | Descarta los beans de ámbito refresh, reinicia `@config_properties` y devuelve las claves modificadas. |
+| `ApplicationEventPublisher` | `ApplicationEventPublisher` (inyectable) | `await publisher.publish(event)`. |
+| `@EventListener` | `@app_event_listener` | Despacho por `isinstance`; se permiten listeners síncronos. |
+
+---
+
+## Configuración y perfiles
+
+| Spring Boot | PyFly | Notas |
+|---|---|---|
+| `application.yml` | `pyfly.yaml` | Misma estructura jerárquica. |
+| `application-{profile}.yml` | `pyfly-{profile}.yaml` | Superposiciones por perfil. |
+| `spring.profiles.active=dev` | `PYFLY_PROFILES_ACTIVE=dev` (variable de entorno) o `pyfly.profiles.active: dev` en `pyfly.yaml` | La activación es idéntica en el orden de prioridad. |
+| `@ConfigurationProperties(prefix=…)` | `@config_properties(prefix=…)` sobre un `@dataclass` (`from pyfly.core import config_properties`) | Respaldado por Pydantic: validado y congelado en el arranque. |
+| `@Value("${key}")` | `field: str = Value("${key}")` (`from pyfly.core import Value`) | Lanza una excepción si falta la clave. |
+| `@Value("${key:default}")` | `field: str = Value("${key:default}")` | Valor por defecto tras los dos puntos. |
+| `@Value("#{expr}")` SpEL | `Value("#{...}")` SpEL ligero | Aritmética, comparación, booleanos, sustitución `${...}` y mapeo `env`. Inyección por constructor: `Annotated[bool, Value("#{...}")]`. |
+| `@Bean @Profile("dev")` | `@bean(profile="dev")` | Expresión de perfil (`& \| ! ()`) sobre cualquier `@bean`. |
+| `@Profile("prod & cloud")` booleano | `profile="prod & cloud"` | Operadores de Spring Boot 2.4+; la antigua forma de OR con comas sigue funcionando. |
+| `spring.application.name` | `pyfly.app.name` | Clave del nombre de la aplicación en `pyfly.yaml`. |
+
+Prioridad de las fuentes de propiedades (de menor → mayor):
+
+1. `pyfly-defaults.yaml` (valores integrados del framework)
+2. `pyfly.yaml` (valores por defecto de la aplicación)
+3. `pyfly-{profile}.yaml` (superposiciones por perfil)
+4. Variables de entorno (en tiempo de ejecución, máxima prioridad)
+
+Esto es idéntico al orden de las fuentes de propiedades de Spring Boot.
+
+---
+
+## Capa web
+
+Imports: `from pyfly.web import (Body, PathVar, QueryParam, Valid,`
+` get_mapping, post_mapping, put_mapping, delete_mapping, patch_mapping,`
+` request_mapping)`. Estereotipos: `from pyfly.container import rest_controller,`
+` service, repository, component, configuration`. 404: `from pyfly.kernel`
+` import ResourceNotFoundException`.
+
+| Spring Boot | PyFly | Notas |
+|---|---|---|
+| `@RestController` | `@rest_controller` | |
+| `@RequestMapping("/path")` | `@request_mapping("/path")` | Prefijo a nivel de clase. |
+| `@GetMapping` | `@get_mapping` | Todos los métodos manejadores son `async def`. |
+| `@PostMapping` | `@post_mapping` | |
+| `@PutMapping` | `@put_mapping` | |
+| `@DeleteMapping` | `@delete_mapping` | |
+| `@PatchMapping` | `@patch_mapping` | |
+| `@PathVariable Long id` | Parámetro `id: PathVar[int]` | Anotación de tipo obligatoria; casa por nombre. |
+| `@RequestParam(defaultValue="0") int page` | `page: QueryParam[int] = 0` | El valor por defecto de Python sustituye a `defaultValue`. |
+| `@RequestBody @Valid T body` | `body: Valid[Body[T]]` | Deserialización Pydantic + validación combinadas. |
+| `@RequestHeader("X-Token") String t` | `t: Header[str]` | |
+| `@ResponseStatus(HttpStatus.CREATED)` | `@post_mapping("/", status_code=201)` | Código de estado en el decorador del mapeo. |
+| `@ControllerAdvice` + `@ExceptionHandler` | `@exception_handler` o jerarquía de excepciones integrada | `ResourceNotFoundException` → 404, `ValidationException` → 422, etc. |
+| `spring.mvc.problemdetails.enabled` | `pyfly.web.problem-details.enabled: true` | Respuestas RFC 7807 `application/problem+json`. |
+
+### JSON y negociación de contenido
+
+| Spring Boot | PyFly | Notas |
+|---|---|---|
+| `spring.jackson.property-naming-strategy` | `pyfly.web.json.property-naming-strategy` | `as-is` (por defecto) o `camelCase`. |
+| `spring.jackson.default-property-inclusion: non_null` | `pyfly.web.json.exclude-none: true` | |
+| `ObjectMapper` de Jackson | `PyFlyJsonSerializer` | Frontera central de serialización. |
+| `Module` de Jackson / serializador personalizado | `JsonSerializers.register(Type, encode=fn)` | Codificadores para tipos no Pydantic. |
+| `@JsonNaming(CamelCase…)` | Clase base `CamelModel` | Modelo camelCase opcional — `order_id` se serializa como `orderId`. |
+| `HttpMessageConverter` | `MessageConverter` / `MessageConverterRegistry` | Integrados: JSON (primero) y luego XML; añade conversores personalizados con `registry.add(...)`. |
+
+---
+
+## Acceso a datos
+
+Imports: `from pyfly.data.relational.sqlalchemy import (Repository, BaseEntity, Base,`
+` Specification, transactional, Propagation, Isolation)`
+`from pyfly.data import Page, Pageable, Sort`
+`from pyfly.data.query import query`
+`from pyfly.container import repository`
+
+### Repositorios
+
+| Spring Boot | PyFly | Notas |
+|---|---|---|
+| `JpaRepository` / `CrudRepository` | `Repository[E, ID]` | `from pyfly.data.relational.sqlalchemy import Repository`; decora con `@repository`. Crea una subclase con parámetros de tipo concretos; el `AsyncSession` se inyecta automáticamente en el arranque. |
+| `@Repository interface WalletRepo extends JpaRepository<…>` | `@repository class WalletRepository(Repository[WalletEntity, str])` | Clase, no interfaz; el cuerpo solo contiene esbozos de consultas derivadas y métodos personalizados. |
+| `findByOwnerId(String id)` | `async def find_by_owner_id(self, owner_id: str) -> list[WalletEntity]: ...` | El cuerpo esbozado `...` desencadena la compilación por parte de `RepositoryBeanPostProcessor` en el arranque. Prefijos: `find_by_`, `count_by_`, `exists_by_`, `delete_by_`. |
+| `@Query("SELECT u FROM User u WHERE …")` | `@query("SELECT u FROM User u WHERE …")` (tipo JPQL) o `@query("SELECT …", native=True)` (SQL en bruto) | `from pyfly.data.query import query`. Los parámetros usan la sintaxis `:name`. |
+| `Specification` | `Specification(lambda root, q: q.where(root.status == "ACTIVE"))` | Compón con `&` / `\|` / `~`; ejecuta con `repo.find_all_by_spec(spec)` o `repo.find_all_by_spec_paged(spec, pageable)`. |
+
+### Entidades
+
+| Spring Boot | PyFly | Notas |
+|---|---|---|
+| `@Entity class Order` | `class Order(Base)` | `from pyfly.data.relational.sqlalchemy import Base`. Registra la tabla en `Base.metadata`. |
+| `@Entity` + PK UUID subrogada + columnas de auditoría | `class Order(BaseEntity)` | `from pyfly.data.relational.sqlalchemy import BaseEntity`. Hereda `id: UUID`, `created_at`, `updated_at`, `created_by`, `updated_by` — todas mapeadas como `Mapped`/`mapped_column` de SQLAlchemy 2.0. |
+| `@Column` / `@Id` | `id: Mapped[str] = mapped_column(String(64), primary_key=True)` | Columnas tipadas de SQLAlchemy 2.0. `Base` (sin auditoría) deja que la entidad sea dueña de su propio tipo de PK, como hace `WalletEntity` de Lumen con un id de tipo `str`. |
+| `@SoftDelete` (Hibernate 6) | `SoftDeleteMixin` + `SoftDeleteRepository` | `from pyfly.data.relational.sqlalchemy import SoftDeleteMixin`. Añade `deleted_at`; el repositorio lo filtra automáticamente. |
+| Bloqueo optimista `@Version` | `VersionedMixin` | Añade una columna `version: int`; SQLAlchemy lanza `StaleDataError` en caso de conflicto. |
+
+### Paginación y ordenación
+
+| Spring Boot | PyFly | Notas |
+|---|---|---|
+| `Pageable` / `PageRequest.of(page, size, Sort.by(…).descending())` | `Pageable.of(page, size, Sort.by("created_at").descending())` | `from pyfly.data import Pageable, Sort`. `Sort.by(*fields)` devuelve orden ascendente; `.descending()` lo invierte todo. |
+| `Page` | `Page[T]` | `from pyfly.data import Page`. Atributos: `.items`, `.total`, `.page`, `.size`, `.total_pages`, `.has_next`, `.has_previous`. `.map(fn)` transforma los elementos preservando los metadatos. |
+| `page.getContent()` / `page.getTotalElements()` | `page.items` / `page.total` | Nomenclatura de Python; `.total_pages` se deriva como `ceil(total / size)`. |
+| `repo.findAll(pageable)` | `await repo.find_all(pageable)` | Devuelve `Page[T]`. |
+| `repo.findAll(spec, pageable)` | `await repo.find_all_by_spec_paged(spec, pageable)` | Aplica WHERE, ORDER BY y LIMIT/OFFSET en una sola llamada. |
+
+### Transacciones y `save`
+
+| Spring Boot | PyFly | Notas |
+|---|---|---|
+| `@Transactional` | `@transactional()` | `from pyfly.data.relational.sqlalchemy import transactional`. Resuelve `_session_factory` a partir de `self`, parchea las instancias de `Repository` inyectadas, confirma en caso de éxito y revierte ante una excepción. |
+| `@Transactional(propagation = REQUIRES_NEW)` | `@transactional(propagation=Propagation.REQUIRES_NEW)` | Enum `Propagation` completo: `REQUIRED`, `REQUIRES_NEW`, `SUPPORTS`, `NOT_SUPPORTED`, `NEVER`, `MANDATORY`. |
+| `@Transactional(isolation = READ_COMMITTED)` | `@transactional(isolation=Isolation.READ_COMMITTED)` | El enum `Isolation` completo refleja los niveles de JDBC. |
+| `@Transactional(readOnly = true)` | `@transactional(read_only=True)` | Enruta a la réplica de lectura a través de `RoutingSessionFactory` y marca la sesión como `read_only`. |
+| `repo.save(entity)` | `await repo.save(entity)` | Invoca `session.add` + `flush` + `refresh` — vuelca pero **no** confirma; el `@transactional` que lo rodea confirma. |
+| `session.merge(entity)` (upsert) | `await repo.upsert(entity)` *(extiéndelo)* o `session.merge(entity)` + flush | No hay `upsert` integrado — añádelo como método en tu repositorio (como hace `WalletRepository` de Lumen). `session.merge` gestiona tanto INSERT como UPDATE en función de la PK. |
+| `AbstractRoutingDataSource` | `RoutingSessionFactory` | `factory.primary()` / `factory.replica()` para forzar un lado. |
+| Varios beans `DataSource` | `NamedDataSources` | Configuración: `pyfly.data.relational.datasources.`; inyecta `NamedDataSources` y llama a `.get("")`. |
+
+### Proyecciones y mapeador
+
+| Spring Boot | PyFly | Notas |
+|---|---|---|
+| Proyección por interfaz `interface OrderSummary { … }` | `@projection class OrderSummary: id: str; status: str` | `from pyfly.data.projection import projection`. Un dataclass concreto (no un `Protocol`), no un proxy de la JDK; se registra con `mapper.register_projection(src, proj)`. |
+| `repo.findAll(cls, Projection.class)` | `mapper.project(entity, OrderSummary)` | `from pyfly.data.mapper import Mapper`. |
+| `@Mapper` de MapStruct | `Mapper` + decorador `@mapping` | Reflexión en tiempo de ejecución; sin generación de código. `mapper.map(obj, TargetDTO)`, `mapper.map_list(...)`. |
+
+### Migraciones
+
+| Spring Boot | PyFly | Notas |
+|---|---|---|
+| Migraciones de Flyway | `pyfly.data.migrations.*` | Mismo concepto: scripts SQL versionados que se ejecutan en el arranque. |
+
+---
+
+## Caché
+
+| Spring Boot | PyFly | Notas |
+|---|---|---|
+| `@Cacheable(value="cache", key="#id")` | `@cacheable(backend=cache, key="item:{id}")` | Sintaxis de plantilla `{param}` en la clave. |
+| `@Cacheable(condition=…, unless=…)` | `@cacheable(condition=…, unless=…)` | `condition` omite según los argumentos; `unless` evita almacenar según el resultado. |
+| `@CacheEvict` | `@cache_evict(backend=cache, key="item:{id}")` | |
+| `@CachePut` | `@cache_put(backend=cache, key="item:{id}")` | Actualiza la caché tras la mutación. |
+| `CacheManager` (autoconfigurado) | `InMemoryCache` / `RedisCacheAdapter` (autoconfigurados) | Se selecciona Redis cuando el extra `redis` está instalado; recae en memoria en caso contrario. |
+
+---
+
+## Mensajería y eventos
+
+| Spring Boot | PyFly | Notas |
+|---|---|---|
+| `@KafkaListener(topics=…, groupId=…)` | `@message_listener(topic=…, group_id=…)` | El manejador es `async def`. |
+| `KafkaTemplate.send(topic, event)` | `await publisher.publish(dest, event_type, payload)` | Puerto `EventPublisher` (`from pyfly.eda import EventPublisher`); cambia de adaptador mediante configuración. |
+| `@RetryableTopic` / DLT | `@message_listener(retries=3, retry_delay=1.0, dead_letter_topic="…")` | Reintento con retroceso lineal; los mensajes agotados se enrutan a la DLQ con cabeceras `x-original-topic` / `x-exception`. |
+| `ApplicationEvent` | `EventEnvelope` | Contenedor de eventos de dominio. |
+| `@EventListener` | `@event_listener(event_types=["TypeName"])` | Manejador EDA en proceso; `event_type` es la cadena con el nombre de la clase. No existe `@domain_event_listener`. |
+| `ApplicationEventPublisher` | `ApplicationEventPublisher` (inyectable) | `await publisher.publish(event)` para eventos de aplicación al estilo Spring. |
+
+!!! tip "Mensajería frente a EDA"
+ PyFly separa la **mensajería por broker** (`pyfly.messaging` — transporte Kafka/RabbitMQ) de los **eventos de dominio** (`pyfly.eda` — `EventEnvelope` + `EventBus`). Empieza con `InMemoryEventBus` dentro de un monolito; cambia a un adaptador de Kafka más adelante modificando una sola clave de configuración, no tus manejadores.
+
+---
+
+## Seguridad
+
+| Spring Boot | PyFly | Notas |
+|---|---|---|
+| `SecurityAutoConfiguration` | `JwtAutoConfiguration` + `PasswordEncoderAutoConfiguration` | Dividido por dependencia opcional. |
+| Cadena de filtros JWT | `JwtAutoConfiguration` autocableado | Actívalo con `pyfly.security.jwt.enabled: true`. |
+| `@PreAuthorize("hasRole('ADMIN')")` | `@pre_authorize("hasRole('ADMIN')")` | Mismo subconjunto de SpEL: `hasRole`, `hasAnyRole`, `hasAuthority`, `isAuthenticated`, `permitAll`, `denyAll`, `#param`, `and`/`or`/`not`. Recorrido por AST, sin `eval`. |
+| `@PostAuthorize("returnObject.owner == principal")` | `@post_authorize("returnObject.owner == principal")` | `returnObject` se vincula al valor de retorno del método. |
+| Bean `RoleHierarchy` | `RoleHierarchy.from_string("ADMIN > USER")` + `set_role_hierarchy(...)` | `expand(roles)` es consultado por `hasRole`/`hasAnyRole`. |
+| `ClientRegistration` (OAuth2 con código de autorización) | `ClientRegistration(...)` | PKCE: añade `use_pkce=True` — genera `code_verifier`/`code_challenge` (S256) automáticamente. |
+| `maximumSessions` / `SessionRegistry` | `SessionConcurrencyController` + `SessionRegistry` | Limita las sesiones concurrentes por principal (`evict-oldest` o `reject-new`). Actívalo: `pyfly.session.concurrency.enabled: true`. |
+
+---
+
+## Programación
+
+| Spring Boot | PyFly | Notas |
+|---|---|---|
+| `@Scheduled(fixedRate = 5000)` | `@scheduled(fixed_rate=5.0)` | Spring usa milisegundos; PyFly usa **segundos**. |
+| `@Scheduled(fixedDelay = 1000)` | `@scheduled(fixed_delay=1.0)` | |
+| `@Scheduled(cron = "0 0 2 * * ?")` | `@scheduled(cron="0 2 * * *")` | Spring: 6 campos (segundos primero). PyFly: 5 campos estándar; también acepta la forma de 6 campos de Spring y `?`. |
+| `@Scheduled(cron=…, zone=…)` | `@scheduled(cron=…, zone="America/New_York")` | Zona horaria IANA; se ignora para `fixed_rate`/`fixed_delay`. |
+| ShedLock / `@SchedulerLock` | `@scheduled(lock=True, lock_ttl=30)` + bean `DistributedLock` | Omite un tick cuando el bloqueo está retenido en otro lugar. Por defecto usa el `LocalLock` en proceso; registra un `DistributedLock` de Redis para un disparo único entre procesos. |
+| `@EnableScheduling` | `SchedulingAutoConfiguration` | Se activa automáticamente cuando `croniter` está instalado. No hace falta `@Enable…` explícito. |
+
+---
+
+## Resiliencia
+
+`from pyfly.resilience import retry, CircuitBreaker, circuit_breaker,`
+` RateLimiter, rate_limiter, Bulkhead, bulkhead, time_limiter, fallback`
+
+| Spring (Resilience4j) | PyFly | Notas |
+|---|---|---|
+| `@Retry` | `@retry(max_attempts=3, *, delay=0.1, backoff=2.0, jitter=0.1, exceptions=(IOError,))` | `delay`/`backoff`/`jitter` son solo por palabra clave. `jitter` es una fracción float en `[0,1]`. |
+| `@CircuitBreaker` | `breaker = CircuitBreaker(...); @circuit_breaker(breaker)` | Pasa una *instancia* de `CircuitBreaker`. Basado en conteo (`failure_threshold`) o en tasa (`failure_rate_threshold` + `window_size`). |
+| `@RateLimiter` | `limiter = RateLimiter(max_tokens=100, refill_rate=100/60); @rate_limiter(limiter)` | Cubo de tokens; pasa una *instancia*. |
+| `@Bulkhead` | `bh = Bulkhead(max_concurrent=10); @bulkhead(bh)` | Límite de concurrencia; pasa una *instancia*. |
+| `@TimeLimiter` | `@time_limiter(timeout=timedelta(seconds=2))` | Lanza `asyncio.TimeoutError` al superarse. |
+| `fallbackMethod` | `@fallback(fallback_method=fn)` o `@fallback(fallback_value=v)` | Valor de reserva estático o invocable. |
+
+---
+
+## AOP
+
+| Spring Boot | PyFly | Notas |
+|---|---|---|
+| `@Aspect` + `@Component` | `@aspect` + `@component` | |
+| `@Before("execution(…)")` | `@before("execution(…)")` | |
+| `@After` | `@after` | Se ejecuta siempre (como `finally`). |
+| `@Around` | `@around` | Llama a `await join_point.proceed()` para continuar. |
+| `@AfterReturning` | `@after_returning` | |
+| `@AfterThrowing` | `@after_throwing` | |
+| `@EnableAspectJAutoProxy` | `AopAutoConfiguration` | Siempre activo; no hace falta optar por él. |
+
+DSL de pointcuts: `execution(* pkg.services.*.*(..))` para patrones de métodos; `annotation(timed)` para casar por decorador.
+
+---
+
+## Observabilidad y Actuator
+
+| Spring Boot | PyFly | Notas |
+|---|---|---|
+| `management.endpoints.web.exposure.include` | `pyfly.management.endpoints.web.exposure.include` | |
+| `/actuator/health` | `/actuator/health` | |
+| `/actuator/info` | `/actuator/info` | |
+| `/actuator/beans` | `/actuator/beans` | |
+| `/actuator/env` | `/actuator/env` | |
+| `POST /actuator/refresh` (Spring Cloud) | `POST /actuator/refresh` | Descarta los beans de ámbito refresh; reinicia `@config_properties`; devuelve las claves modificadas. |
+| `@Timed` de Micrometer | `@timed("metric_name")` | Histograma de tiempos del método. |
+| `@Counted` de Micrometer | `@counted("metric_name")` | Contador de invocaciones. |
+| `MetricsRegistry` de Prometheus | `MetricsRegistry` (backend de Prometheus) | Autoconfigurado cuando `prometheus_client` está instalado. |
+| Sleuth / Micrometer Tracing (W3C) | `TracingFilter` (entrante) + `HttpxClientAdapter` (saliente) | El `traceparent` W3C se extrae en un span SERVER; se inyecta en las llamadas httpx salientes. `trace_id`/`span_id` quedan estampados en los logs vía `StructlogAdapter`. No-op seguro sin OpenTelemetry. |
+
+---
+
+## CQRS y sagas
+
+`from pyfly.cqrs import (Command, CommandHandler, DefaultCommandBus,`
+` Query, QueryHandler, DefaultQueryBus, command_handler, query_handler)`
+`from pyfly.transactional.saga.annotations import (saga, saga_step, Input, FromStep)`
+
+| Spring Boot / Axon | PyFly | Notas |
+|---|---|---|
+| `@CommandHandler` | `@command_handler` + `@service` apilados | Ambos decoradores son obligatorios; `@service` registra el bean. Sobrescribe `do_handle(self, cmd)`. |
+| `@QueryHandler` | `@query_handler` + `@service` apilados | Misma regla. Sobrescribe `do_handle(self, qry)`. Método del bus: `.query(...)`. |
+| Inyección del bus en el controlador | `commands: DefaultCommandBus, queries: DefaultQueryBus` | Inyecta las clases concretas, no el protocolo. `commands.send(cmd)`, `queries.query(qry)`. |
+| `@Repository` + puerto | `@repository` sobre una clase que hereda del puerto `Protocol` con `@runtime_checkable` | El puerto es un `Protocol`; la clase adaptadora lo hereda. |
+| `@EventHandler` (event sourcing) | `@event_handler` | Procede del `EventStore`. |
+| `@Saga` | `@saga(name="…", layer_concurrency=N)` + `@service` | Ambos decoradores son obligatorios para la inyección de dependencias + el registro en el motor. |
+| `@SagaStep(id=…, compensate=…)` | `@saga_step(id="…", compensate="method_name")` | |
+| `@Input` | `Annotated[T, Input()]` | El marcador es una **instancia**: `Input()`. Inyecta la carga inicial de la saga. |
+| `@FromStep("id")` | `Annotated[T, FromStep("id")]` | El marcador es una **instancia**: `FromStep("step-id")`. Inyecta el resultado de un paso anterior. |
+| `@Tcc` | `@tcc(name="…")` | Clase de transacción TCC (Try-Confirm-Cancel). |
+| `@TccParticipant` | `@tcc_participant(id="…", order=N)` | |
+| `@TryMethod` / `@ConfirmMethod` / `@CancelMethod` | `@try_method` / `@confirm_method` / `@cancel_method` | Métodos de las tres fases de TCC. |
+| `@FromTry` | `Annotated[T, FromTry]` | Inyecta el resultado de la fase try en confirm/cancel. |
+
+Event sourcing (gestión de estado mediante secuencias de eventos): `from pyfly.eventsourcing import AggregateRoot, EventSourcedRepository`.
+La raíz de agregado usa `self.when(EventType, handler_fn)` para registrar manejadores de aplicación.
+Datos: `from pyfly.data.relational.sqlalchemy import Base` (requiere `pyfly[data-relational]`).
+
+---
+
+## Pruebas de integración
+
+| Spring Boot | PyFly | Notas |
+|---|---|---|
+| `@SpringBootTest` | `service_slice(*beans)` / `slice_context(...)` | Contexto mínimo ya iniciado; `overrides` acepta una clase o una instancia ya construida. |
+| `@WebMvcTest` | `web_slice(*controllers, overrides=…)` → `(context, client)` | Inicia un contexto mínimo + `PyFlyTestClient`. |
+| `@DataJpaTest` | `data_slice(*beans)` → `context` | Slice de la capa de datos. |
+| `@Testcontainers` + `@Container` | `with postgres_container() as pg:` | El context manager de Python gestiona el ciclo de vida. |
+| `@ServiceConnection` | `pyfly_config(pg)` / `pyfly_config_for(pg)` | Asocia los detalles de conexión del contenedor a las claves de configuración de PyFly. |
+| `@DynamicPropertySource` | `pyfly_config(*containers, base=…)` | Un `Config` en una sola llamada para varios contenedores. |
+| `PostgreSQLContainer` | `postgres_container()` | La URL se reescribe automáticamente a `asyncpg`. |
+| `MySQLContainer` | `mysql_container()` | La URL se reescribe a `aiomysql`. |
+| `GenericContainer` (Redis) | `redis_container()` | URLs de caché + sesión cableadas. |
+| `KafkaContainer` | `kafka_container()` | |
+| `@requires_docker` | `@requires_docker` | Omite la prueba limpiamente cuando el demonio de Docker no está presente. |
+
+Instálalo con: `pip install 'pyfly[testcontainers]'`
+
+---
+
+## Servidor embebido
+
+| Spring Boot | PyFly | Notas |
+|---|---|---|
+| `server.port` | `pyfly.server.port` | Puerto HTTP de escucha de la aplicación (por defecto 8080). |
+| `server.address` | `pyfly.server.host` | Dirección de enlace de la aplicación. |
+| `management.server.port` | `pyfly.management.server.port` | Puerto de gestión independiente (actuator + panel de administración); por defecto 9090. |
+| Tomcat (por defecto) | Granian (por defecto) | Runtime HTTP en Rust/tokio; máxima prioridad. |
+| Jetty (alternativa de reserva) | Uvicorn (alternativa de reserva) | Reserva ASGI estándar del ecosistema. |
+| Undertow (alternativa) | Hypercorn (alternativa) | Soporte de protocolos avanzados (HTTP/3). |
+| `server.tomcat.*` | `pyfly.server.granian.*` | Ajuste específico del servidor. |
+| Interfaz `WebServer` | Protocolo `ApplicationServerPort` | Contrato del servidor ASGI embebido. |
+| `EventLoopGroup` (Netty) | Protocolo `EventLoopPort` | Contrato del runtime de E/S. |
+| `server.type: auto` | `pyfly.server.type: auto` (por defecto) | Cascada Granian → Uvicorn → Hypercorn. |
+
+!!! tip "Cascada de autoconfiguración"
+ La autoconfiguración de PyFly usa el mismo patrón de beans condicionales que Spring Boot: si Granian está instalado, gana `GranianServerAdapter`; si no, se prueba Uvicorn a continuación; luego Hypercorn. Anúlalo en cualquier punto aportando tu propio bean `ApplicationServerPort`.
diff --git a/book/manuscript-es/91-appendix-b-mongodb.md b/book/manuscript-es/91-appendix-b-mongodb.md
new file mode 100644
index 00000000..218188b2
--- /dev/null
+++ b/book/manuscript-es/91-appendix-b-mongodb.md
@@ -0,0 +1,447 @@
+Apéndice B
+
+# MongoDB y datos documentales {.chtitle}
+
+La capa de datos documentales de PyFly envuelve MongoDB mediante **Beanie ODM** y **Motor** (el
+driver asíncrono de MongoDB). La API replica deliberadamente la del adaptador relacional: la misma
+clase base `MongoRepository[T, ID]`, la misma convención de nombres para consultas derivadas, el mismo
+vocabulario `Page`/`Pageable`/`Sort`, de modo que cambiar entre almacenamiento relacional y documental
+solo afecta a la definición de la clase de documento y a la clase base del repositorio, no
+a la capa de servicio.
+
+Todos los tipos concretos viven en `pyfly.data.document.mongodb`. Los tipos compartidos (`Page`,
+`Pageable`, `Sort`) provienen de `pyfly.data`.
+
+---
+
+## Instalación y configuración
+
+Instala el extra:
+
+::: listing terminal | Listado B.1 — Instalar el extra data-document
+uv add "pyfly[data-document]"
+:::
+
+Habilita el adaptador en `pyfly.yaml`:
+
+::: listing pyfly.yaml | Listado B.2 — Configuración mínima de MongoDB
+pyfly:
+ data:
+ document:
+ enabled: true
+ uri: "mongodb://localhost:27017"
+ database: "myapp"
+ min_pool_size: 5
+ max_pool_size: 50
+:::
+
+### Referencia de configuración
+
+| Clave de `pyfly.yaml` | Tipo | Valor por defecto | Descripción |
+|---|---|---|---|
+| `pyfly.data.document.enabled` | bool | `false` | Habilita el adaptador de MongoDB |
+| `pyfly.data.document.uri` | str | `mongodb://localhost:27017` | URI de conexión |
+| `pyfly.data.document.database` | str | `pyfly` | Nombre de la base de datos |
+| `pyfly.data.document.min_pool_size` | int | `0` | Mínimo del pool de Motor |
+| `pyfly.data.document.max_pool_size` | int | `100` | Máximo del pool de Motor |
+
+Cada clave tiene una variable de entorno equivalente: sustituye los puntos por guiones bajos y
+pásala a mayúsculas; por ejemplo, `PYFLY_DATA_DOCUMENT_URI`. Para MongoDB Atlas o un conjunto de réplicas (replica set):
+
+::: listing pyfly.yaml | Listado B.3 — URIs de Atlas y de conjunto de réplicas
+# Atlas
+pyfly:
+ data:
+ document:
+ enabled: true
+ uri: >-
+ mongodb+srv://user:secret@cluster.mongodb.net/
+ ?retryWrites=true&w=majority
+ database: production_db
+
+# Replica set (required for transactions)
+# pyfly:
+# data:
+# document:
+# uri: "mongodb://m1:27017,m2:27017,m3:27017/?replicaSet=rs0"
+:::
+
+---
+
+## BaseDocument
+
+`BaseDocument` extiende `beanie.Document` con un rastro de auditoría. Toda clase de documento en
+una aplicación PyFly debería heredar de ella.
+
+| Campo | Tipo | Valor por defecto | Descripción |
+|---|---|---|---|
+| `id` | `PydanticObjectId` | Autogenerado | Clave primaria del documento (ObjectId) |
+| `created_at` | `datetime` | `datetime.now(UTC)` | Marca de tiempo de inserción |
+| `updated_at` | `datetime` | `datetime.now(UTC)` | Marca de tiempo de la última actualización |
+| `created_by` | `str \| None` | `None` | Identificador del creador |
+| `updated_by` | `str \| None` | `None` | Identificador de quien actualizó por última vez |
+
+`use_state_management = True` se establece en la clase base `Settings`, lo que habilita el
+seguimiento de cambios de Beanie para que `save_changes()` produzca actualizaciones parciales eficientes.
+
+Una clase de documento típica:
+
+::: listing catalog/product_document.py | Listado B.4 — ProductDocument con índice y modelo anidado
+from pydantic import BaseModel, Field
+from beanie import Indexed, PydanticObjectId
+from pyfly.data.document.mongodb import BaseDocument
+
+
+class Dimensions(BaseModel):
+ width_cm: float
+ height_cm: float
+ depth_cm: float
+
+
+class ProductDocument(BaseDocument):
+ name: str
+ sku: Indexed(str, unique=True)
+ description: str = ""
+ price: float = Field(gt=0)
+ category: Indexed(str)
+ tags: list[str] = Field(default_factory=list)
+ dimensions: Dimensions | None = None
+ active: bool = True
+
+ class Settings:
+ name = "products"
+:::
+
+El atributo `Settings.name` establece el nombre de la colección de MongoDB. Si lo omites,
+Beanie deriva el nombre a partir del nombre de la clase, lo cual rara vez es lo que deseas.
+
+Para índices compuestos o descendentes usa `Settings.indexes`:
+
+::: listing catalog/product_document.py | Listado B.5 — Índice compuesto mediante Settings.indexes
+from pymongo import IndexModel, ASCENDING, DESCENDING
+from pyfly.data.document.mongodb import BaseDocument
+
+
+class OrderDocument(BaseDocument):
+ customer_id: str
+ status: str
+ total: float
+ region: str
+
+ class Settings:
+ name = "orders"
+ indexes = [
+ IndexModel(
+ [("customer_id", ASCENDING), ("status", ASCENDING)],
+ name="idx_customer_status",
+ ),
+ IndexModel(
+ [("region", ASCENDING), ("total", DESCENDING)],
+ name="idx_region_total",
+ ),
+ ]
+:::
+
+---
+
+## Correspondencia entre Spring Data y MongoRepository
+
+La siguiente tabla muestra cómo los conceptos de Spring Data MongoDB se corresponden con la capa documental de PyFly.
+La superficie es intencionadamente idéntica a la de `Repository[T, ID]` del adaptador relacional,
+de modo que los mismos patrones de la capa de servicio se aplican a ambos.
+
+| Spring Data MongoDB | PyFly | Notas |
+|---|---|---|
+| `MongoRepository` | `MongoRepository[E, ID]` | `from pyfly.data.document.mongodb import MongoRepository`; decora con `@repository`. |
+| `@Document class Product` + `@Id` | `class ProductDocument(BaseDocument)` | `from pyfly.data.document.mongodb import BaseDocument`. Hereda `id` (`PydanticObjectId` de Beanie), `created_at`, `updated_at`, `created_by`, `updated_by`. El nombre de la colección se fija en `class Settings: name = "products"`. |
+| `findByCategory(String c)` | `async def find_by_category(self, category: str) -> list[ProductDocument]: ...` | El cuerpo vacío `...` lo compila `MongoRepositoryBeanPostProcessor` al arrancar. Mismos prefijos que en el relacional: `find_by_`, `count_by_`, `exists_by_`, `delete_by_`. |
+| `@Query("{ 'status': ?0 }")` | `@query('{"status": ":status"}')` | `from pyfly.data.query import query`. Filtro JSON o pipeline de agregación; sustitución de `:param`. |
+| `MongoSpecification` | `MongoSpecification(lambda root, q: {"active": True})` | `from pyfly.data.document.mongodb import MongoSpecification`. Compón con `&` / `\|` / `~`; ejecuta mediante `find_all_by_spec(spec)` o `find_all_by_spec_paged(spec, pageable)`. |
+| `PageRequest.of(page, size, Sort.by(…))` | `Pageable.of(page, size, Sort.by("name").descending())` | `from pyfly.data import Pageable, Sort`, idéntico al adaptador relacional. |
+| `Page` | `Page[T]` | `.items`, `.total`, `.page`, `.size`, `.total_pages`, `.map(fn)`, igual que en el relacional. |
+
+---
+
+## MongoRepository[T, ID]
+
+Crea una subclase de `MongoRepository[T, ID]` y anótala con `@repository`. El framework
+extrae el tipo de documento y el tipo de ID a partir de los parámetros genéricos en el momento
+de definir la clase, mediante `__init_subclass__`. No se requiere ningún `__init__`.
+
+::: listing catalog/product_repository.py | Listado B.6 — ProductRepository: CRUD + consultas derivadas
+from beanie import PydanticObjectId
+from pyfly.container import repository
+from pyfly.data.document.mongodb import MongoRepository
+
+from catalog.product_document import ProductDocument
+
+
+@repository
+class ProductRepository(MongoRepository[ProductDocument, PydanticObjectId]):
+
+ # --- derived query method stubs (compiled at startup) ---
+
+ async def find_by_category(
+ self, category: str
+ ) -> list[ProductDocument]: ...
+
+ async def find_by_active_and_category(
+ self, active: bool, category: str
+ ) -> list[ProductDocument]: ...
+
+ async def find_by_price_greater_than_order_by_price_desc(
+ self, min_price: float
+ ) -> list[ProductDocument]: ...
+
+ async def find_by_name_containing(
+ self, fragment: str
+ ) -> list[ProductDocument]: ...
+
+ async def count_by_category(self, category: str) -> int: ...
+
+ async def exists_by_sku(self, sku: str) -> bool: ...
+
+ async def delete_by_active(self, active: bool) -> int: ...
+:::
+
+### Métodos CRUD integrados
+
+| Método | Tipo de retorno | Descripción |
+|---|---|---|
+| `save(entity)` | `T` | Inserta o actualiza mediante `entity.save()` de Beanie |
+| `find_by_id(id)` | `T \| None` | Busca por clave primaria |
+| `find_all(**filters)` | `list[T]` | Busca todos; los argumentos por palabra clave se convierten en filtros de igualdad |
+| `find_all(sort)` | `list[T]` | Recupera todos los documentos, ordenados por un `Sort` |
+| `find_all(pageable)` | `Page[T]` | Consulta paginada: cuenta el total, aplica el orden del Pageable, recorta con skip/limit y devuelve `Page[T]` |
+| `stream_all(sort)` | `AsyncIterator[T]` | Transmite todos los documentos (el análogo de Flux); admite un `Sort` y filtros de igualdad opcionales |
+| `delete(entity)` | `None` | Elimina una instancia de documento ya cargada |
+| `delete_by_id(id)` | `None` | Elimina por clave primaria; no hace nada si no se encuentra |
+| `count()` | `int` | Cuenta todos los documentos de la colección |
+| `exists_by_id(id)` | `bool` | True si existe un documento con este ID |
+| `save_all(entities)` | `list[T]` | Inserción masiva mediante `insert_many` |
+| `find_all_by_id(ids)` | `list[T]` | Busca todos cuyos ID estén en una lista |
+| `delete_all_by_id(ids)` | `None` | Elimina todos cuyos ID estén en una lista |
+| `delete_all(entities=None)` | `None` | Elimina los documentos indicados; sin argumentos, vacía la colección entera |
+| `find_all_by_spec(spec)` | `list[T]` | Busca los que coincidan con una `MongoSpecification` |
+| `find_all_by_spec_paged(spec, pageable)` | `Page[T]` | Busca los que coincidan con una `MongoSpecification`, con paginación y orden |
+
+`find_all(**filters)` traduce los argumentos por palabra clave en filtros de igualdad de MongoDB:
+
+```python
+# {"status": "PENDING", "customer_id": "abc"}
+orders = await repo.find_all(status="PENDING", customer_id="abc")
+```
+
+---
+
+## Métodos de consulta derivados
+
+PyFly compila los métodos vacíos (stubs) de las subclases de `MongoRepository` en consultas reales de MongoDB
+al arrancar. La convención de nombres es idéntica a la del adaptador relacional y a la de Spring
+Data: `{prefix}_by_{predicates}[_order_by_{fields}]`.
+
+**Prefijos:** `find_by`, `count_by`, `exists_by`, `delete_by`
+
+**Conectores:** `_and_`, `_or_`
+
+### Correspondencia de operadores
+
+| Sufijo del método | Filtro de MongoDB | Argumentos consumidos |
+|---|---|---|
+| *(ninguno, por defecto)* | `{field: value}` | 1 |
+| `_not` | `{field: {"$ne": value}}` | 1 |
+| `_greater_than` | `{field: {"$gt": value}}` | 1 |
+| `_greater_than_equal` | `{field: {"$gte": value}}` | 1 |
+| `_less_than` | `{field: {"$lt": value}}` | 1 |
+| `_less_than_equal` | `{field: {"$lte": value}}` | 1 |
+| `_between` | `{field: {"$gte": low, "$lte": high}}` | 2 |
+| `_like` | `{field: {"$regex": pattern}}` (SQL % → .*) | 1 |
+| `_containing` | `{field: {"$regex": ".*val.*", "$options": "i"}}` | 1 |
+| `_in` | `{field: {"$in": values}}` | 1 (lista) |
+| `_is_null` | `{field: None}` | 0 |
+| `_is_not_null` | `{field: {"$ne": None}}` | 0 |
+
+Ordenación: añade `_order_by_{field}_{asc|desc}`. Varios campos de ordenación se encadenan:
+
+```python
+# sort=[("name", ASC), ("created_at", DESC)]
+async def find_by_active_order_by_name_asc_created_at_desc(
+ self, active: bool
+) -> list[ProductDocument]: ...
+```
+
+El `MongoRepositoryBeanPostProcessor` detecta los stubs (cuerpos que solo contienen `...`
+o `pass`) y los reemplaza por invocables compilados. Fuente:
+`src/pyfly/data/document/mongodb/post_processor.py` y
+`src/pyfly/data/document/mongodb/query_compiler.py`.
+
+---
+
+## Consultas personalizadas con @query
+
+Para las consultas que no pueden expresarse mediante convenciones de nombres, `@query` acepta un
+documento de filtro de MongoDB (`{…}`) o un pipeline de agregación (`[…]`) como cadena JSON.
+Los parámetros con nombre usan la sintaxis `:param_name`.
+
+::: listing catalog/order_repository.py | Listado B.7 — Ejemplos de filtro y agregación con @query
+from pyfly.container import repository
+from pyfly.data.document.mongodb import MongoRepository
+from pyfly.data.query import query
+
+from catalog.order_document import OrderDocument
+
+
+@repository
+class OrderRepository(MongoRepository[OrderDocument, str]):
+
+ @query('{"status": ":status", "total": {"$gte": ":min_total"}}')
+ async def find_by_status_min_total(
+ self, status: str, min_total: float
+ ) -> list[OrderDocument]: ...
+
+ @query(
+ '[{"$match": {"customer_id": ":cid"}},'
+ ' {"$group": {"_id": "$category",'
+ ' "total": {"$sum": "$amount"}}}]'
+ )
+ async def totals_by_category(
+ self, cid: str
+ ) -> list[dict]: ...
+:::
+
+**Reglas de sustitución:**
+
+- Un valor de cadena JSON que sea *exactamente* `:param_name` se reemplaza por el valor de Python,
+ conservando su tipo (`int`, `bool`, `list`, etc.).
+- Un `:param_name` incrustado dentro de una cadena mayor se reemplaza mediante `str(value)`.
+- Los diccionarios y las listas se recorren recursivamente. Los valores JSON no textuales pasan sin cambios.
+
+`MongoQueryExecutor` analiza la plantilla de la consulta una sola vez al arrancar, detecta si
+se trata de un filtro o de un pipeline, y sustituye los parámetros en el momento de la llamada.
+
+---
+
+## Paginación
+
+::: listing catalog/product_service.py | Listado B.8 — Listado de productos paginado
+from pyfly.data import Page, Pageable, Sort
+from pyfly.data.document.mongodb import MongoRepository
+
+from catalog.product_document import ProductDocument
+
+
+async def list_products(
+ repo: MongoRepository[ProductDocument, str],
+ page: int = 1,
+ size: int = 20,
+) -> Page[ProductDocument]:
+ pageable = Pageable.of(
+ page=page,
+ size=size,
+ sort=Sort.by("name"),
+ )
+ return await repo.find_all(pageable)
+:::
+
+`find_all(pageable)` cuenta el total, aplica el orden del Pageable, recorta con
+`.skip((page-1)*size)` y `.limit(size)` sobre la consulta de Beanie, y devuelve `Page[T]`.
+Pageable parte de 1, así que la página `1` es la primera página.
+
+---
+
+## Gestión de transacciones
+
+Las transacciones multidocumento requieren un despliegue con **conjunto de réplicas** (replica set). Una instancia
+de MongoDB independiente (standalone) no las admite.
+
+::: listing pyfly.yaml | Listado B.9 — Conjunto de réplicas de un solo nodo para desarrollo local
+# Start MongoDB: mongod --replSet rs0 --bind_ip localhost
+# Init (once, in mongosh): rs.initiate()
+pyfly:
+ data:
+ document:
+ enabled: true
+ uri: "mongodb://localhost:27017/?replicaSet=rs0"
+ database: myapp
+:::
+
+El decorador `@mongo_transactional` envuelve una función asíncrona en una sesión y una
+transacción de Motor. Las operaciones de Beanie de la función participan automáticamente:
+
+::: listing billing/transfer.py | Listado B.10 — Transferencia de fondos atómica con @mongo_transactional
+from motor.motor_asyncio import AsyncIOMotorClient
+from pyfly.data.document.mongodb import mongo_transactional
+
+from billing.account_document import AccountDocument
+
+
+def make_transfer_fn(client: AsyncIOMotorClient):
+ @mongo_transactional(client)
+ async def transfer(
+ from_id: str, to_id: str, amount: float
+ ) -> None:
+ src = await AccountDocument.get(from_id)
+ dst = await AccountDocument.get(to_id)
+ if src is None or dst is None or src.balance < amount:
+ raise ValueError("Invalid transfer")
+ src.balance -= amount
+ dst.balance += amount
+ await src.save()
+ await dst.save()
+ return transfer
+:::
+
+Si todo va bien, la transacción se confirma (commit); ante cualquier excepción se aborta y se vuelve a lanzar. A diferencia
+de `@reactive_transactional` del relacional, la sesión de Motor no se inyecta como
+argumento: Beanie la recoge a través del contexto de Motor.
+
+El bean `motor_client` lo registra automáticamente `DocumentAutoConfiguration`
+cuando el adaptador está habilitado. Inyéctalo en tu servicio mediante el contenedor de inyección de dependencias.
+
+!!! warning "Se requiere un conjunto de réplicas"
+ `@mongo_transactional` lanzará un error contra una instancia de MongoDB independiente.
+ Usa el fragmento de URI `?replicaSet=rs0` (consulta el Listado B.9) incluso en desarrollo local.
+
+---
+
+## Autoconfiguración
+
+`DocumentAutoConfiguration` se activa cuando:
+
+1. `beanie` es importable (`@conditional_on_class("beanie")`), y
+2. `pyfly.data.document.enabled` vale `"true"` en la configuración.
+
+Registra tres beans automáticamente:
+
+| Bean | Tipo | Función |
+|---|---|---|
+| `motor_client` | `AsyncIOMotorClient` | Pool de conexiones asíncrono de MongoDB |
+| `mongo_post_processor` | `MongoRepositoryBeanPostProcessor` | Compila los stubs de consultas derivadas |
+| `odm_initializer` | `BeanieInitializer` | Llama a `init_beanie()` al arrancar |
+
+`BeanieInitializer.start()` descubre las subclases de `BaseDocument` en dos pasadas: primero
+desde cada `MongoRepository._entity_type` registrado (establecido por `__init_subclass__`),
+y luego las subclases de `BaseDocument` registradas directamente. Esto significa que definir un repositorio
+es suficiente: no necesitas registrar los modelos de documento por separado.
+
+Archivos fuente: `src/pyfly/data/document/auto_configuration.py`,
+`src/pyfly/data/document/mongodb/initializer.py`.
+
+---
+
+## Pruebas
+
+Para las pruebas unitarias, usa [mongomock-motor](https://github.com/michaelkryukov/mongomock-motor)
+o apunta a una base de datos de pruebas dedicada:
+
+::: listing pyfly-test.yaml | Listado B.11 — Configuración de la base de datos de pruebas
+pyfly:
+ data:
+ document:
+ enabled: true
+ database: "myapp_test"
+:::
+
+Para las pruebas de integración, el soporte de Testcontainers de PyFly levanta automáticamente un contenedor
+real de MongoDB; consulta el capítulo de pruebas y
+`@ServiceConnection(MongoDBContainer)`.
diff --git a/book/manuscript-es/92-appendix-c-ecm.md b/book/manuscript-es/92-appendix-c-ecm.md
new file mode 100644
index 00000000..fbbc9090
--- /dev/null
+++ b/book/manuscript-es/92-appendix-c-ecm.md
@@ -0,0 +1,358 @@
+Apéndice C
+
+# ECM: contenido y firma electrónica {.chtitle}
+
+`pyfly.ecm` proporciona abstracciones hexagonales para la gestión documental
+empresarial: almacenamiento de blobs, metadatos de documentos, jerarquías de
+carpetas y flujos de firma electrónica. El módulo incluye adaptadores
+completamente cableados para almacenamiento local, AWS S3 y Azure Blob Storage,
+además de adaptadores de firma electrónica para DocuSign, Adobe Sign y Logalty
+— todos seleccionables sin cambiar una sola línea del código de la aplicación,
+puramente mediante YAML.
+
+!!! note "El módulo en sí no requiere dependencias adicionales"
+ El módulo ECM central (`pyfly.ecm`) no tiene dependencias de terceros más
+ allá de la biblioteca estándar. Los adaptadores de almacenamiento en la nube
+ (`boto3`, `azure-storage-blob`) y las llamadas a los SDK de firma electrónica
+ se realizan a través de envoltorios asíncronos ligeros en cada clase de
+ adaptador; instala solo lo que necesites.
+
+---
+
+## Modelo de dominio
+
+Todos los tipos de ECM son dataclasses sencillas de Python definidas en
+`src/pyfly/ecm/models.py`.
+
+| Clase | Campos clave | Descripción |
+|---|---|---|
+| `Document` | `id`, `name`, `folder_id`, `content_type`, `size_bytes`, `metadata`, `versions` | Registro lógico de documento |
+| `DocumentVersion` | `version`, `content_hash`, `size_bytes`, `storage_uri` | Instantánea inmutable de versión |
+| `Folder` | `id`, `name`, `parent_id`, `path` | Nodo de carpeta en la jerarquía |
+| `Recipient` | `name`, `email`, `role` | Destinatario de firma electrónica |
+| `SignatureRequest` | `document_id`, `recipients`, `subject`, `message` | Carga útil de la solicitud de firma |
+| `ESignatureEnvelope` | `id`, `provider`, `status`, `provider_envelope_id` | Registro de seguimiento del sobre |
+
+`ESignatureStatus` es un `StrEnum` con los valores `DRAFT`, `SENT`, `SIGNED`,
+`DECLINED` y `EXPIRED`.
+
+---
+
+## Puertos (contratos)
+
+Cuatro clases `Protocol` definidas en `src/pyfly/ecm/ports.py` forman el límite
+hexagonal. Los servicios de tu aplicación dependen de estos contratos; los
+adaptadores los implementan.
+
+### DocumentStoragePort
+
+```python
+class DocumentStoragePort(Protocol):
+ name: str
+ async def upload(
+ self, document: Document, content: bytes
+ ) -> DocumentVersion: ...
+ async def download(
+ self, document: Document, version: int | None = None
+ ) -> bytes: ...
+ async def delete(
+ self, document: Document, version: int | None = None
+ ) -> bool: ...
+```
+
+### MetadataStoragePort
+
+```python
+class MetadataStoragePort(Protocol):
+ async def save(self, document: Document) -> Document: ...
+ async def get(self, document_id: str) -> Document | None: ...
+ async def list(
+ self, folder_id: str | None = None, *, limit: int = 100
+ ) -> list[Document]: ...
+ async def delete(self, document_id: str) -> bool: ...
+```
+
+### FolderRepositoryPort
+
+```python
+class FolderRepositoryPort(Protocol):
+ async def save(self, folder: Folder) -> Folder: ...
+ async def get(self, folder_id: str) -> Folder | None: ...
+ async def list(self, parent_id: str | None = None) -> list[Folder]: ...
+ async def delete(self, folder_id: str) -> bool: ...
+```
+
+### ESignatureAdapter
+
+```python
+class ESignatureAdapter(Protocol):
+ name: str
+ async def send(
+ self, request: SignatureRequest
+ ) -> ESignatureEnvelope: ...
+ async def get(self, envelope_id: str) -> ESignatureEnvelope | None: ...
+ async def cancel(self, envelope_id: str) -> bool: ...
+```
+
+---
+
+## DocumentService
+
+`DocumentService` orquesta los puertos de almacenamiento y de metadatos. Es el
+punto de entrada principal para la subida, descarga, listado y eliminación de
+documentos.
+
+::: listing contracts/ecm_usage.py | Listado C.1 — DocumentService: subir, descargar, eliminar
+from pyfly.ecm import (
+ DocumentService,
+ LocalFilesystemStorageAdapter,
+)
+from pyfly.ecm.in_memory import (
+ InMemoryFolderRepository,
+ InMemoryMetadataStorage,
+)
+
+
+service = DocumentService(
+ storage=LocalFilesystemStorageAdapter("/var/ecm/docs"),
+ metadata=InMemoryMetadataStorage(),
+ folders=InMemoryFolderRepository(),
+)
+
+
+async def demo() -> None:
+ # Upload
+ doc = await service.upload(
+ name="contract.pdf",
+ content=b"%PDF-1.4 ...",
+ content_type="application/pdf",
+ metadata={"department": "legal", "year": "2026"},
+ )
+
+ # Download (latest version)
+ content = await service.download(doc.id)
+
+ # Download a specific version
+ v1_content = await service.download(doc.id, version=1)
+
+ # List documents in a folder
+ docs = await service.list(folder_id=doc.folder_id)
+
+ # Delete (returns False if blob delete partially fails)
+ ok = await service.delete(doc.id)
+:::
+
+### Semántica de `delete`
+
+`DocumentService.delete(document_id)` elimina tanto el blob almacenado como el
+registro de metadatos y devuelve el **AND lógico** de los dos resultados de
+eliminación. Si el ID del documento es desconocido, devuelve `False` de
+inmediato. Si la eliminación del blob falla, el registro de metadatos se elimina
+igualmente (de modo que el documento lógico desaparece), pero el método devuelve
+`False` para señalar una eliminación parcial. Este contrato se verifica en el
+código fuente en `src/pyfly/ecm/services.py`.
+
+### Gestión de carpetas
+
+::: listing contracts/ecm_folders.py | Listado C.2 — Creación de jerarquías de carpetas
+from pyfly.ecm import DocumentService, Folder
+
+
+async def setup_folders(service: DocumentService) -> None:
+ root = await service.create_folder(
+ Folder(name="contracts", path="/contracts")
+ )
+ legal = await service.create_folder(
+ Folder(name="legal", parent_id=root.id, path="/contracts/legal")
+ )
+ doc = await service.upload(
+ name="nda.pdf",
+ content=b"...",
+ folder_id=legal.id,
+ content_type="application/pdf",
+ )
+:::
+
+!!! note "El puerto de carpetas es opcional"
+ Pasa `folders=None` a `DocumentService` si no necesitas soporte de carpetas.
+ Llamar a `create_folder` en una instancia así lanza `RuntimeError`.
+
+---
+
+## ESignatureService
+
+`ESignatureService` envuelve un `ESignatureAdapter` y expone tres operaciones:
+`request`, `get` y `cancel`.
+
+::: listing contracts/esignature_usage.py | Listado C.3 — Solicitar una firma electrónica
+from pyfly.ecm import (
+ ESignatureService,
+ NoOpESignatureAdapter,
+ Recipient,
+ SignatureRequest,
+)
+
+
+esig = ESignatureService(adapter=NoOpESignatureAdapter())
+
+
+async def send_for_signature(document_id: str) -> str:
+ envelope = await esig.request(
+ SignatureRequest(
+ document_id=document_id,
+ recipients=[
+ Recipient(
+ name="Alice Johnson",
+ email="alice@example.com",
+ role="signer",
+ ),
+ ],
+ subject="Please sign your employment contract",
+ message="Review and sign at your earliest convenience.",
+ )
+ )
+ return envelope.id
+:::
+
+---
+
+## Adaptadores de almacenamiento
+
+| Clase de adaptador | Valor de `pyfly.ecm.storage.provider` | Notas |
+|---|---|---|
+| `LocalFilesystemStorageAdapter` | `local` (por defecto) | Almacena los blobs como `//v` |
+| `AwsS3StorageAdapter` | `s3` o `aws` | Requiere `boto3`; asíncrono mediante `run_in_executor` |
+| `AzureBlobStorageAdapter` | `azure` | Requiere `azure-storage-blob` |
+
+Los tres implementan `DocumentStoragePort`. `LocalFilesystemStorageAdapter` crea
+el directorio base en el momento de la construcción y almacena cada versión como
+un archivo independiente, lo que lo hace adecuado para desarrollo y pruebas sin
+servicios externos.
+
+## Adaptadores de firma electrónica
+
+| Clase de adaptador | Valor de `pyfly.ecm.esignature.provider` | Notas |
+|---|---|---|
+| `NoOpESignatureAdapter` | `noop` (por defecto) | Devuelve sobres ficticios; seguro para dev/test |
+| `DocuSignESignatureAdapter` | `docusign` | Llamadas a la API REST mediante `httpx` |
+| `AdobeSignESignatureAdapter` | `adobe` | Llamadas a la API REST mediante `httpx` |
+| `LogaltyESignatureAdapter` | `logalty` | Llamadas a la API REST mediante `httpx` |
+
+Implementa `ESignatureAdapter` para añadir un proveedor personalizado (por
+ejemplo, un servicio de firma interno). Registra tu implementación como un bean e
+inyéctala en `ESignatureService`.
+
+---
+
+## Autoconfiguración
+
+`EcmAutoConfiguration` se activa cuando se establece `pyfly.ecm.enabled=true` en
+la configuración. Registra cinco beans:
+
+| Bean | Implementación por defecto |
+|---|---|
+| `metadata_storage` | `InMemoryMetadataStorage` |
+| `folder_repository` | `InMemoryFolderRepository` |
+| `document_storage` | `LocalFilesystemStorageAdapter` (directorio temporal) |
+| `document_service` | `DocumentService` cableando los tres anteriores |
+| `esignature_adapter` | `NoOpESignatureAdapter` |
+| `esignature_service` | `ESignatureService` envolviendo el adaptador |
+
+Los adaptadores de almacenamiento y de firma electrónica se seleccionan desde la
+configuración, de modo que cambias de proveedor puramente mediante YAML — sin
+necesidad de cambiar código:
+
+::: listing pyfly.yaml | Listado C.4 — YAML de ECM completo con almacenamiento S3 y DocuSign
+pyfly:
+ ecm:
+ enabled: true
+ storage:
+ provider: s3
+ local:
+ base-dir: /var/ecm
+ s3:
+ bucket: my-docs-bucket
+ region: eu-west-1
+ key-prefix: documents/
+ azure:
+ container: my-container
+ connection-string: "DefaultEndpointsProtocol=https;..."
+ account-url: "https://acct.blob.core.windows.net"
+ key-prefix: documents/
+ esignature:
+ provider: docusign
+ docusign:
+ base-url: "https://demo.docusign.net/restapi"
+ account-id: "your-account-id"
+ access-token: "your-access-token"
+ adobe:
+ api-base: "https://api.na4.adobesign.com"
+ access-token: "your-token"
+ logalty:
+ api-base: "https://api.logalty.com"
+ api-key: "your-api-key"
+:::
+
+!!! tip "Empieza con local + noop"
+ En desarrollo, omite las claves `provider` y deja que actúen los valores por
+ defecto: el almacenamiento `local` usa un directorio temporal nuevo y la
+ firma electrónica `noop` devuelve datos ficticios deterministas. No se
+ necesitan servicios externos.
+
+---
+
+## Implementar un adaptador de almacenamiento personalizado
+
+Implementa `DocumentStoragePort` y regístralo como un `@bean`:
+
+::: listing infra/gcs_storage.py | Listado C.5 — Esqueleto de un adaptador de almacenamiento personalizado
+from pyfly.ecm.models import Document, DocumentVersion
+from pyfly.ecm.ports import DocumentStoragePort
+
+
+class GcsStorageAdapter:
+ """Google Cloud Storage adapter (skeleton)."""
+
+ name = "gcs"
+
+ def __init__(self, bucket: str, key_prefix: str = "") -> None:
+ self._bucket = bucket
+ self._prefix = key_prefix
+
+ async def upload(
+ self, document: Document, content: bytes
+ ) -> DocumentVersion:
+ version_no = (
+ (document.versions[-1].version + 1) if document.versions else 1
+ )
+ key = f"{self._prefix}{document.id}/v{version_no}"
+ # ... upload content to GCS ...
+ return DocumentVersion(
+ version=version_no,
+ content_hash="sha256-placeholder",
+ size_bytes=len(content),
+ storage_uri=f"gs://{self._bucket}/{key}",
+ )
+
+ async def download(
+ self, document: Document, version: int | None = None
+ ) -> bytes:
+ target = version or document.versions[-1].version
+ key = f"{self._prefix}{document.id}/v{target}"
+ # ... download from GCS ...
+ return b""
+
+ async def delete(
+ self, document: Document, version: int | None = None
+ ) -> bool:
+ # ... delete object(s) from GCS ...
+ return True
+:::
+
+Archivos fuente:
+- `src/pyfly/ecm/models.py` — dataclasses de dominio
+- `src/pyfly/ecm/ports.py` — contratos Protocol
+- `src/pyfly/ecm/services.py` — `DocumentService`, `ESignatureService`
+- `src/pyfly/ecm/adapters/` — adaptadores de almacenamiento y firma electrónica incluidos
+- `src/pyfly/ecm/in_memory.py` — `InMemoryMetadataStorage`, `InMemoryFolderRepository`
+- `src/pyfly/ecm/auto_configuration.py` — `EcmAutoConfiguration`
diff --git a/book/manuscript-es/93-appendix-d-cli.md b/book/manuscript-es/93-appendix-d-cli.md
new file mode 100644
index 00000000..06e9e2b4
--- /dev/null
+++ b/book/manuscript-es/93-appendix-d-cli.md
@@ -0,0 +1,350 @@
+Apéndice D
+
+# CLI y resolución de problemas {.chtitle}
+
+La CLI `pyfly` proporciona andamiaje de proyectos, arranque de la aplicación,
+migraciones de base de datos y diagnóstico del entorno. Está construida con
+**Click** para el análisis de comandos y con **Rich** para la salida coloreada en
+la terminal.
+
+**Instalación:** `uv add "pyfly[cli]"` (o `uv sync --extra cli`). Requiere Click,
+Rich, Jinja2 y questionary.
+
+**Punto de entrada:** `pyfly` — registrado como script de consola en `pyproject.toml`.
+
+---
+
+## Referencia de comandos
+
+| Comando | Descripción |
+|---|---|
+| `pyfly new [NAME]` | Genera el andamiaje de un nuevo proyecto (interactivo o directo) |
+| `pyfly run` | Arranca el servidor de aplicación ASGI |
+| `pyfly info` | Muestra la versión de Python y los extras instalados |
+| `pyfly doctor` | Diagnostica el entorno (Python, herramientas, PyFly) |
+| `pyfly db init` | Inicializa un entorno de migraciones de Alembic |
+| `pyfly db migrate [-m MSG]` | Genera automáticamente una revisión de migración |
+| `pyfly db upgrade [REVISION]` | Aplica las migraciones pendientes (por defecto: `head`) |
+| `pyfly db downgrade REVISION` | Revierte a una revisión anterior |
+| `pyfly license` | Muestra el texto de la licencia Apache 2.0 |
+| `pyfly sbom [--json]` | Tabla del Software Bill of Materials (inventario de software) |
+| `pyfly --version` | Imprime la versión instalada de PyFly |
+| `pyfly --help` | Imprime el banner y todos los comandos |
+
+---
+
+## pyfly new
+
+Crea un directorio de proyecto completo a partir de uno de siete arquetipos.
+
+```
+pyfly new [--archetype ARCHETYPE] [--features FEAT,...] [--directory DIR]
+pyfly new # interactive mode (questionary TUI)
+```
+
+### Arquetipos
+
+| Arquetipo | Características por defecto | Qué se genera |
+|---|---|---|
+| `core` | *(ninguna)* | Contenedor de DI, configuración, Dockerfile |
+| `web-api` | `web` | Controladores REST, servicios, repositorios (CRUD de Todo) |
+| `fastapi-api` | `fastapi` | Stack FastAPI, OpenAPI nativo |
+| `web` | `web` | HTML renderizado en servidor con plantillas Jinja2 |
+| `hexagonal` | `web` | Puertos y adaptadores, capas dominio/aplicación/infra/api |
+| `library` | *(ninguna)* | Paquete PEP 561 `py.typed`, sin runtime de PyFly |
+| `cli` | `shell` | Comandos de shell con DI, sin punto de entrada ASGI |
+
+### Valores disponibles para `--features`
+
+| Característica | Qué añade |
+|---|---|
+| `web` | Servidor HTTP Starlette, controladores REST, documentación OpenAPI |
+| `fastapi` | Servidor HTTP FastAPI, OpenAPI nativo |
+| `granian` | Servidor ASGI Granian (Rust/tokio) |
+| `hypercorn` | Servidor ASGI Hypercorn (HTTP/2, HTTP/3) |
+| `data-relational` | SQLAlchemy ORM (asíncrono), migraciones Alembic |
+| `data-document` | Beanie ODM, Motor (MongoDB) |
+| `eda` | Bus de eventos en memoria, soporte de Kafka + RabbitMQ |
+| `cache` | Capa de caché (en memoria por defecto; Redis si está instalado) |
+| `client` | Cliente HTTP resiliente (httpx + reintentos/cortacircuitos) |
+| `security` | Autenticación JWT, hashing de contraseñas con bcrypt |
+| `scheduling` | Programación de tareas basada en cron |
+| `observability` | Métricas de Prometheus, trazas de OpenTelemetry |
+| `cqrs` | Segregación de Responsabilidad entre Comandos y Consultas (CQRS) |
+| `shell` | Comandos de shell interactivos potenciados con DI |
+
+### Ejemplos
+
+::: listing terminal | Listado D.1 — Ejemplos de pyfly new
+# Minimal core service
+pyfly new wallet-service
+
+# REST API with SQL data layer
+pyfly new lumen --archetype web-api --features web,data-relational
+
+# Hexagonal service with cache and security
+pyfly new payment-svc --archetype hexagonal \
+ --features web,data-relational,cache,security
+
+# MongoDB-backed microservice
+pyfly new catalog-svc --archetype web-api \
+ --features web,data-document
+
+# CLI application with interactive shell
+pyfly new admin-tool --archetype cli
+
+# Interactive mode (arrow keys + checkboxes)
+pyfly new
+:::
+
+En el modo interactivo, el asistente solicita el nombre, el nombre del paquete
+(precargado a partir del nombre del proyecto), el arquetipo (selección única con
+las flechas) y las características (selección múltiple con la barra espaciadora).
+Antes de la creación se muestra un resumen de confirmación. Pulsa Ctrl+C en
+cualquier prompt para salir de forma limpia.
+
+Los nombres de proyecto que contienen guiones se convierten automáticamente a
+nombres de paquete de Python válidos (`my-service` → `my_service`). Usa el prompt
+**Package name** en el modo interactivo para sobrescribir este valor derivado.
+
+---
+
+## pyfly run
+
+Arranca la aplicación ASGI usando el servidor seleccionado automáticamente.
+
+```
+pyfly run [--host HOST] [--port PORT] [--server SERVER]
+ [--workers N] [--reload] [--app MODULE:VAR]
+```
+
+| Opción | Por defecto | Descripción |
+|---|---|---|
+| `--host` | `0.0.0.0` | Dirección de enlace |
+| `--port` | Config o `8080` | Puerto de la app (CLI → `pyfly.server.port` → 8080) |
+| `--server` | Autodetección | `granian`, `uvicorn` o `hypercorn` |
+| `--workers` | Config o `0` | Procesos worker (`0` = número de CPUs) |
+| `--reload` | desactivado | Recarga automática al cambiar el código |
+| `--app` | Autodescubrimiento | Ruta de importación, p. ej. `myapp.main:app` |
+
+**Prioridad de servidores** (cuando se omite `--server`): Granian › Uvicorn › Hypercorn.
+Gana el primer servidor que se pueda importar.
+
+**Descubrimiento de la aplicación** (cuando se omite `--app`):
+
+1. `pyfly.app.module` en `pyfly.yaml`
+2. `app.module` en `pyfly.yaml` (disposición plana)
+3. Autoescaneo de `src//main.py`
+
+`pyfly run` añade `src/` a `sys.path` automáticamente en los proyectos con
+disposición src.
+
+::: listing terminal | Listado D.2 — Ejemplos de pyfly run
+# Development with auto-reload
+pyfly run --reload
+
+# Production: Granian, bind all interfaces, all CPU cores
+pyfly run --host 0.0.0.0 --port 80 --server granian --workers 0
+
+# Explicit application path
+pyfly run --app my_service.main:app
+:::
+
+---
+
+## pyfly info
+
+Muestra dos tablas de Rich: versión de Python, plataforma y arquitectura; además
+del estado de instalación de cada módulo opcional de PyFly. Resulta útil tras una
+instalación selectiva para confirmar qué adaptadores están disponibles.
+
+```
+pyfly info
+```
+
+---
+
+## pyfly doctor
+
+Ejecuta una comprobación integral de salud del entorno.
+
+```
+pyfly doctor
+```
+
+| Comprobación | Condición de aprobado | Impacto del fallo |
+|---|---|---|
+| Versión de Python | >= 3.12 | Fatal — doctor informa de fallo |
+| Entorno virtual | `sys.prefix != sys.base_prefix` | Solo advertencia |
+| `git` en el PATH | `shutil.which("git")` devuelve una ruta | Fatal |
+| `uv` en el PATH | `shutil.which("uv")` devuelve una ruta | Fatal |
+| `uvicorn` en el PATH | presente | Informativo (`-`) |
+| `alembic` en el PATH | presente | Informativo (`-`) |
+| `ruff` en el PATH | presente | Informativo (`-`) |
+| `mypy` en el PATH | presente | Informativo (`-`) |
+| PyFly importable | `import pyfly` tiene éxito | Fatal |
+
+Finaliza con **"All checks passed!"** o **"Some issues found."**.
+
+---
+
+## pyfly db
+
+Comandos de migración de base de datos respaldados por [Alembic](https://alembic.sqlalchemy.org/).
+
+!!! note "Requiere el extra data-relational"
+ Todos los subcomandos `pyfly db` requieren Alembic (`pyfly[data-relational]`).
+
+### pyfly db init
+
+```
+pyfly db init
+```
+
+Crea `alembic/` y `alembic.ini` en el directorio actual. Sobrescribe
+`alembic/env.py` con una plantilla de PyFly que conecta `async_engine_from_config`
+y `Base.metadata` de `pyfly.data.relational.sqlalchemy`. Finaliza con un error si
+`alembic/` ya existe.
+
+### pyfly db migrate
+
+```
+pyfly db migrate [-m "description"]
+```
+
+Ejecuta `alembic revision --autogenerate`. Compara el `Base.metadata` actual con
+la base de datos en vivo y escribe un nuevo archivo de versión en
+`alembic/versions/`. Requiere `alembic.ini` (ejecuta primero `pyfly db init`).
+
+### pyfly db upgrade / downgrade
+
+```
+pyfly db upgrade [REVISION] # default: head
+pyfly db downgrade REVISION # e.g. -1, base, abc123
+```
+
+### Flujo de trabajo típico de migración
+
+::: listing terminal | Listado D.3 — Ciclo de vida de las migraciones de base de datos
+# 1. Initialise Alembic (once per project)
+pyfly db init
+
+# 2. Generate initial migration from your entity models
+pyfly db migrate -m "initial schema"
+
+# 3. Apply migrations
+pyfly db upgrade
+
+# 4. After modifying entities, generate a new revision
+pyfly db migrate -m "add order status column"
+pyfly db upgrade
+
+# 5. Roll back one step
+pyfly db downgrade -1
+
+# 6. Revert all migrations
+pyfly db downgrade base
+:::
+
+---
+
+## pyfly sbom
+
+Imprime una tabla de Rich con cada dependencia de PyFly junto con las versiones
+requeridas e instaladas. La opción `--json` produce JSON legible por máquina para
+pipelines de cumplimiento normativo.
+
+```
+pyfly sbom
+pyfly sbom --json
+```
+
+---
+
+## Flujo de trabajo de desarrollo típico
+
+::: listing terminal | Listado D.4 — Flujo de trabajo de extremo a extremo, de la configuración al desarrollo
+# Verify environment
+pyfly doctor
+
+# Scaffold the project
+pyfly new lumen --archetype web-api \
+ --features web,data-relational,security
+
+cd lumen
+
+# Check what was installed
+pyfly info
+
+# Initialise migrations
+pyfly db init
+pyfly db migrate -m "initial schema"
+pyfly db upgrade
+
+# Develop with auto-reload
+pyfly run --reload
+
+# Add a new column, migrate, upgrade
+pyfly db migrate -m "add shipment_id to orders"
+pyfly db upgrade
+:::
+
+---
+
+## Resolución de problemas
+
+### Problemas comunes y soluciones
+
+| Síntoma | Causa probable | Solución |
+|---|---|---|
+| `command not found: pyfly` | El PATH no se actualizó tras la instalación | `source ~/.zshrc` (o `~/.bashrc`); o `export PATH="$HOME/.pyfly/bin:$PATH"` |
+| `Python 3.11 found (3.12 required)` | Python antiguo en el PATH | `brew install python@3.12` (macOS) o `sudo apt install python3.12` (Ubuntu) |
+| `ModuleNotFoundError: No module named 'starlette'` | El extra `web` no está instalado | `uv add "pyfly[web]"` o `uv sync --extra web` |
+| `ModuleNotFoundError: No module named 'beanie'` | Falta el extra `data-document` | `uv add "pyfly[data-document]"` |
+| `ModuleNotFoundError: No module named 'sqlalchemy'` | Falta el extra `data-relational` | `uv add "pyfly[data-relational]"` |
+| `alembic is not installed` | Falta Alembic | `uv add "pyfly[data-relational]"` |
+| `No application found` | No hay `pyfly.yaml` ni opción `--app` | Añade `pyfly.app.module: myapp.main:app` a `pyfly.yaml`, o pasa `--app myapp.main:app` |
+| `No ASGI server found` | No hay ningún extra de servidor instalado | `uv add "pyfly[web]"` (añade Uvicorn) |
+| `venv module not found` | Python en paquetes separados (Debian/Ubuntu) | `sudo apt install python3.12-venv` |
+| `Directory 'alembic' already exists` | `db init` ejecutado dos veces | `rm -rf alembic alembic.ini` y vuelve a ejecutar `pyfly db init` |
+| `uv cache clean` arregla instalaciones lentas | Caché de uv corrupta | Ejecuta `uv cache clean` y reintenta |
+| Ctrl+C en `pyfly new` no deja archivos | Modo interactivo cancelado limpiamente | Vuelve a ejecutar `pyfly new`; no hace falta limpiar nada |
+
+### Desinstalación
+
+Para una instalación basada en el instalador (`bash install.sh`):
+
+::: listing terminal | Listado D.5 — Desinstalar PyFly
+# Remove the installation directory
+rm -rf ~/.pyfly
+
+# Remove the PATH line from your shell profile
+# Delete the line: export PATH="$HOME/.pyfly/bin:$PATH" # PyFly Framework
+:::
+
+Para una instalación manual con `uv`/`pip`: `uv remove pyfly` o `pip uninstall pyfly`.
+
+---
+
+## Extender la CLI
+
+La CLI está construida sobre Click. Añade comandos personalizados importando el
+grupo `cli` y llamando a `cli.add_command`:
+
+::: listing myapp/generate.py | Listado D.6 — Añadir un comando de CLI personalizado
+import click
+from pyfly.cli.main import cli
+
+
+@click.command()
+@click.argument("entity_name")
+def generate(entity_name: str) -> None:
+ """Generate boilerplate for a new entity."""
+ click.echo(f"Generating {entity_name} entity ...")
+
+
+cli.add_command(generate, name="generate")
+:::
+
+Registra el módulo del comando en los entry points de tu `pyproject.toml` o
+impórtalo en el `__init__.py` de tu aplicación antes de que se invoque la CLI.
diff --git a/book/manuscript-es/94-glossary.md b/book/manuscript-es/94-glossary.md
new file mode 100644
index 00000000..5a3e1ac7
--- /dev/null
+++ b/book/manuscript-es/94-glossary.md
@@ -0,0 +1,105 @@
+Apéndice
+
+# Glosario {.chtitle}
+
+**Adaptador (adapter)** — Una clase concreta que implementa un puerto delegando en una biblioteca o tecnología de infraestructura específica (PostgreSQL, Redis, Kafka, etc.). Los adaptadores viven en el borde de la arquitectura hexagonal y pueden intercambiarse sin tocar las capas de dominio o de aplicación. En PyFly, la factoría de sesiones asíncronas de SQLAlchemy, `RedisCacheAdapter` y `KafkaMessageBroker` son todos adaptadores (Capítulos 5, 10, 13).
+
+**Raíz de agregado (aggregate root)** — El único punto de entrada a un grupo de objetos de dominio que deben permanecer consistentes entre sí; todos los cambios de estado se canalizan a través de sus métodos, que imponen las invariantes y emiten eventos de dominio. El código externo carga y guarda solo la raíz, nunca sus objetos internos. En PyFly, `AggregateRoot[ID]` es la clase base con estado almacenado; `Wallet` es la raíz de agregado con estado almacenado de Lumen y `LedgerAccount` es su contrapartida con event sourcing (Capítulos 6, 9).
+
+**AOP (Programación Orientada a Aspectos)** — Una técnica para añadir preocupaciones transversales —registro de logs, métricas, comprobaciones de seguridad— de forma declarativa a los métodos sin modificar su código fuente. Los decoradores de aviso `@aspect` y `@before`/`@after`/`@around` de PyFly implementan AOP; el Capítulo 15 lo usa para asociar observabilidad a cada método de servicio.
+
+**ApplicationContext** — El contenedor de inyección de dependencias central que descubre los beans, resuelve las dependencias y gestiona su ciclo de vida desde el arranque hasta el apagado. Dispara `ContextRefreshedEvent` una vez que todos los beans están cableados y `ContextClosedEvent` en un apagado ordenado. `ApplicationContext` es el equivalente en PyFly del `ApplicationContext` de Spring (Capítulo 2).
+
+**Async/await** — La sintaxis nativa de corrutinas de Python para E/S no bloqueante. PyFly está construido de forma nativamente asíncrona: cada manejador HTTP, método de repositorio, llamada al bus y cliente de servicio se declara `async def` y se planifica en el bucle de eventos. Las llamadas bloqueantes dentro de una función `async def` congelan el bucle y deben evitarse (Capítulo 1).
+
+**Autocableado (autowiring)** — El mecanismo por el cual el contenedor de inyección de dependencias resuelve los argumentos del constructor de un bean únicamente a partir de las anotaciones de tipo, sin necesidad de código de factoría. PyFly inspecciona las firmas de `__init__` en el arranque e inyecta automáticamente los beans coincidentes; la anotación `@primary` selecciona la implementación preferida cuando existen varios candidatos (Capítulo 2).
+
+**Bean** — Cualquier objeto Python que el contenedor de inyección de dependencias crea, cablea y gestiona. Declaras una clase como bean aplicando un decorador de estereotipo (`@service`, `@repository`, `@component`, `@configuration` o `@rest_controller`), o anotando un método de factoría con `@bean` dentro de una clase `@configuration` (Capítulo 2).
+
+**BFF (Backend for Frontend)** — Una capa de pasarela de API que se sitúa frente a varios microservicios y compone sus capacidades en una API enfocada al recorrido del usuario, adaptada a un cliente de frontend específico. En el Capítulo 11, el nivel BFF de Lumen agrega los datos del monedero y de los pagos para que la aplicación móvil haga una sola petición en lugar de dos.
+
+**Contexto delimitado (bounded context)** — Un ámbito a nivel de DDD dentro del cual un modelo de dominio tiene un significado único e inequívoco. Los servicios en una arquitectura de microservicios a menudo se corresponden uno a uno con contextos delimitados: `WalletService` posee el modelo del monedero; `PaymentsService` posee el modelo de pagos. Se necesitan núcleos compartidos o capas anticorrupción cuando dos contextos deben intercambiar datos (Capítulo 6).
+
+**Mamparo (bulkhead)** — Un patrón de resiliencia que limita el número de llamadas concurrentes a un recurso o servicio dependiente concreto, evitando que una dependencia lenta agote el pool de hilos o de corrutinas y degrade rutas no relacionadas. El decorador `@bulkhead(max_concurrent=N)` de PyFly implementa el mamparo basado en semáforo (Capítulo 13).
+
+**Cortacircuitos (circuit breaker)** — Un patrón de resiliencia que cuenta los fallos consecutivos hacia una dependencia remota; una vez que se alcanza el umbral de fallos, el cortacircuitos salta al estado abierto y cortocircuita las llamadas posteriores con un error rápido, dando tiempo a la dependencia para recuperarse antes de reanudar las llamadas. El decorador `@circuit_breaker` y `@service_client` de PyFly inyectan este comportamiento automáticamente (Capítulos 11, 13).
+
+**Comando (CQRS)** — Un dataclass inmutable y congelado que expresa una única intención de escritura —«abrir un monedero», «depositar fondos»— y lleva los datos exactos necesarios para satisfacerla. Los comandos heredan de `Command[R]`, donde `R` es el tipo de retorno del manejador, y fluyen a través del `CommandBus`. Opcionalmente pueden implementar `validate()` y `authorize()` (Capítulo 7).
+
+**CommandBus** — La canalización que recibe un `Command`, ejecuta la validación y la autorización, lo despacha al `CommandHandler` coincidente y luego publica cualquier evento de dominio almacenado en búfer por el agregado. Se registra un manejador (handler) por cada tipo de comando; el bus impone esta restricción en el arranque (Capítulo 7).
+
+**Compensación** — Una operación hacia adelante que revierte semánticamente el efecto de un paso de saga completado previamente cuando un paso posterior falla. A diferencia de un rollback de base de datos, la compensación es una nueva escritura que deshace explícitamente el cambio anterior (por ejemplo, «reembolsar pago» compensa «capturar pago»). Cada `@saga_step` nombra su método de compensación (Capítulo 12).
+
+**Component** — Un estereotipo genérico para un bean gestionado que no encaja en los roles más específicos de `@service`, `@repository` o `@rest_controller`. `@component` registra la clase con el contenedor y habilita la inyección, pero no aporta ningún significado semántico adicional (Capítulo 2).
+
+**Clase de configuración** — Una clase decorada con `@configuration` que agrupa métodos de factoría `@bean`. El contenedor la trata como una fuente de definiciones de beans, llamando a cada método de factoría y registrando el valor de retorno como un bean nombrado y tipado. Se pueden aplicar guardas de perfil y anotaciones condicionales a la clase o a métodos de factoría individuales (Capítulo 3).
+
+**Contenedor (DI)** — Véase *ApplicationContext*.
+
+**Convención sobre configuración** — El principio según el cual unos valores predeterminados sensatos eliminan el código repetitivo: una clase anotada con `@service` queda automáticamente con ámbito singleton, es descubierta por el escaneo de componentes e inyectada por tipo sin ningún XML ni registro explícito. PyFly adopta esto como valor de diseño central, exigiendo anulaciones explícitas solo cuando el valor predeterminado es incorrecto (Capítulo 1).
+
+**CQRS (Segregación de Responsabilidad entre Comandos y Consultas)** — Un patrón arquitectónico que separa las operaciones de escritura (comandos) de las operaciones de lectura (consultas) en rutas de código, buses y, potencialmente, modelos de datos distintos. Las lecturas pueden almacenarse en caché de forma independiente de las escrituras; los manejadores pueden probarse de forma aislada; las preocupaciones transversales se aplican uniformemente por el bus respectivo (Capítulo 7).
+
+**Cola de mensajes fallidos (DLQ)** — Una cola o topic de mensajes dedicado que recibe los mensajes que no pudieron procesarse tras el número configurado de reintentos. El `@message_listener` de PyFly enruta automáticamente los mensajes envenenados a la DLQ, evitando que bloqueen los mensajes sanos (Capítulo 10).
+
+**Inyección de dependencias (DI)** — Un patrón de diseño en el que un objeto declara sus colaboradores como parámetros del constructor y un contenedor externo proporciona las instancias concretas. La inyección de dependencias desacopla la construcción del uso, lo que facilita intercambiar implementaciones y probar clases con dobles (fakes) o mocks (Capítulo 2).
+
+**Evento de dominio** — Un registro inmutable de un hecho de negocio que ya ha ocurrido —«monedero abierto», «fondos depositados»—. Los eventos de dominio son producidos por las raíces de agregado mediante `raise_event()`, publicados a través del puerto `EventPublisher` y consumidos por escuchadores independientes. En event sourcing, son además la fuente de verdad del estado del agregado (Capítulos 6, 8, 9).
+
+**DTO (Objeto de Transferencia de Datos)** — Un objeto plano y serializable usado para transportar datos a través del límite de una capa —entre el controlador HTTP y el servicio, o entre un servicio y su cliente de API— sin exponer los tipos internos del dominio. En PyFly, las subclases de `BaseModel` de Pydantic sirven como DTO de petición/respuesta (Capítulo 4).
+
+**EDA (Arquitectura Orientada a Eventos)** — Un estilo arquitectónico en el que los servicios se comunican publicando y suscribiéndose a eventos en lugar de mediante llamadas síncronas directas. Productores y consumidores están desacoplados: un productor no sabe qué consumidores existen. El `EventPublisher` y `@event_listener` de PyFly son las primitivas de EDA intra-proceso; `MessageBrokerPort` extiende el patrón a través de los límites de proceso (Capítulos 8, 10).
+
+**Entidad** — Un objeto de dominio con una identidad estable que persiste a lo largo del tiempo y a través de los cambios de estado. Dos entidades son iguales si y solo si comparten el mismo `id` no nulo. En PyFly, `Entity[TID]` rastrea la identidad; `BaseEntity` añade columnas de auditoría (`created_at`, `updated_at`) para la capa de persistencia (Capítulos 5, 6).
+
+**Event sourcing (abastecimiento de eventos)** — Una estrategia de persistencia en la que cada cambio de estado se almacena como un evento de dominio inmutable en un flujo de solo adición. El estado actual de un agregado se calcula reproduciendo todos los eventos del flujo. El módulo `pyfly.eventsourcing` de PyFly proporciona `AggregateRoot`, `EventStore`, `EventSourcedRepository`, soporte para instantáneas y un `ProjectionRunner` (Capítulo 9).
+
+**EventEnvelope** — El envoltorio de metadatos que empaqueta la carga útil de un evento de dominio para su entrega: ID del evento, tipo de evento, ID del flujo del agregado, número de secuencia, marca de tiempo, ID de correlación e ID de causalidad. Cada evento que llega a un escuchador llega dentro de un `EventEnvelope`; nunca construyes uno manualmente (Capítulos 8, 9).
+
+**EventStore** — La capa de persistencia de solo adición para un sistema con event sourcing. Registra los eventos de dominio indexados por ID de flujo y número de secuencia, impone concurrencia optimista mediante el token `version` y soporta consultas por rango para la reproducción. PyFly incluye `SQLAlchemyEventStore` e `InMemoryEventStore` (Capítulo 9).
+
+**Arquitectura hexagonal** — Un estilo arquitectónico que coloca la lógica de dominio y de aplicación en el centro, rodeada de puertos (interfaces), con adaptadores en los bordes. El código de negocio depende únicamente de los puertos; los adaptadores implementan esos puertos usando tecnologías específicas. Este es el principio organizador de cada módulo de PyFly (Capítulos 1, 2, 5).
+
+**Idempotencia** — La propiedad por la cual realizar la misma operación varias veces produce el mismo resultado que realizarla una sola vez. Los manejadores idempotentes son esenciales en la mensajería y la compensación de sagas: los reintentos de red o las garantías de entrega al-menos-una-vez implican que un mensaje puede llegar más de una vez. Se usan claves de deduplicación o tokens de idempotencia para detectar y descartar ejecuciones duplicadas (Capítulos 10, 12).
+
+**Migración** — Un script versionado y ordenado que hace evolucionar el esquema de una base de datos relacional sin destruir datos. PyFly integra Alembic para la gestión de migraciones; `pyfly db migrate` autogenera una migración a partir de los cambios en las entidades y `pyfly db upgrade` aplica las migraciones pendientes (Capítulo 5).
+
+**Patrón outbox** — Una técnica para publicar eventos de dominio de forma fiable junto con una escritura en la base de datos: tanto el cambio de estado como los registros de eventos se escriben en la misma transacción local; un proceso de retransmisión en segundo plano lee los eventos no enviados de la tabla outbox y los reenvía al broker. Esto elimina el commit en dos fases entre la base de datos y el broker de mensajes (Capítulos 9, 12).
+
+**Puerto (port)** — Una clase `Protocol` de Python que define la interfaz de la que depende una pieza de lógica de negocio, sin especificar ninguna implementación. El contenedor de inyección de dependencias cablea en el arranque el adaptador concreto que satisface el protocolo. Los puertos habilitan la arquitectura hexagonal y hacen que los adaptadores sean intercambiables sin ningún cambio en la lógica de negocio (Capítulos 1, 2, 5).
+
+**Bean primario** — Cuando varios beans satisfacen el mismo tipo, se prefiere para la inyección el que está anotado con `@primary`. Sin una anotación `@primary`, el contenedor lanza un error de ambigüedad. `@primary` es la forma de designar el adaptador de producción entre varias alternativas (Capítulo 2).
+
+**Perfil** — Una etiqueta de activación nombrada que selecciona qué beans y valores de configuración están activos en tiempo de ejecución. PyFly carga `pyfly-{profile}.yaml` sobre el `pyfly.yaml` base y activa los beans anotados con `@profile("prod")` solo cuando `prod` está en la lista de perfiles activos. El perfil activo se establece mediante `PYFLY_PROFILES_ACTIVE` o `pyfly.yaml` (Capítulo 3).
+
+**Proyección** — Un modelo de lectura derivado del consumo de un flujo de eventos. Una proyección se suscribe a tipos de eventos específicos y construye incrementalmente una vista consultable —una caché de saldos, una tabla de auditoría, un agregado para un panel— sin tocar el modelo de escritura. En event sourcing, `ProjectionRunner` reproduce el `EventStore` para reconstruir las proyecciones desde cero (Capítulos 8, 9).
+
+**Consulta (CQRS)** — Un dataclass inmutable que expresa una intención de lectura —«obtener el saldo del monedero», «listar transacciones»—. Las consultas heredan de `Query[R]` y fluyen a través del `QueryBus`, que puede almacenar los resultados en caché de forma transparente. Las consultas nunca mutan el estado (Capítulo 7).
+
+**QueryBus** — La canalización que recibe una `Query`, opcionalmente devuelve un resultado en caché, la despacha al `QueryHandler` coincidente y opcionalmente almacena el resultado en caché. Separar el bus de consultas del bus de comandos permite un comportamiento transversal diferente —caché, enrutamiento a réplicas de lectura— para las rutas de lectura (Capítulo 7).
+
+**Limitador de tasa (rate limiter)** — Un componente de resiliencia que limita el número de peticiones que un endpoint o cliente de servicio puede aceptar dentro de una ventana de tiempo, evitando la sobrecarga por tráfico en ráfagas. El `RateLimiter(max_tokens=N, refill_rate=r)` + `@rate_limiter(limiter)` de PyFly implementa un limitador de cubo de tokens (Capítulo 13).
+
+**Repositorio** — Una abstracción similar a una colección sobre la capa de persistencia que permite a la aplicación cargar y guardar agregados o entidades sin nada de SQL en el código de negocio. El `CrudRepository[E, ID]` de PyFly proporciona `find_by_id`, `save`, `delete` tipados y ayudantes de consultas derivadas; las implementaciones personalizadas anotadas con `@repository` reemplazan a las predeterminadas en memoria (Capítulos 2, 5).
+
+**Reintento (retry)** — Un patrón de resiliencia que vuelve a ejecutar una operación fallida tras un retardo, hasta un número máximo de intentos configurado. Los reintentos manejan fallos transitorios —breves fallos de red, errores 503 momentáneos— sin intervención del operador. El `@retry(max_attempts=N, backoff=...)` de PyFly implementa retroceso exponencial con jitter; `@service_client` incluye reintentos por defecto (Capítulos 11, 13).
+
+**Saga** — Una secuencia de transacciones locales coordinadas por un orquestador central. Cada paso confirma en la base de datos de su propio servicio; si un paso posterior falla, el orquestador llama a la transacción compensatoria de cada paso ya confirmado en orden inverso. Los decoradores `@saga` y `@saga_step` de PyFly implementan el patrón saga orquestado con un DAG de ejecución en paralelo (Capítulo 12).
+
+**Serialización** — El proceso de convertir un objeto en memoria a un formato de transmisión (JSON, Protobuf, Avro) para respuestas HTTP o publicación de mensajes, y la deserialización en sentido inverso. El bean central `PyFlyJsonSerializer` de PyFly (`from pyfly.web import PyFlyJsonSerializer`) configura una vez la serialización JSON respaldada por Pydantic y la aplica a cada respuesta HTTP y a cada envoltorio de mensaje (Capítulos 4, 10).
+
+**Servicio** — Un bean gestionado decorado con `@service` que alberga la lógica de negocio y orquesta las llamadas a repositorios, publicadores de eventos y otros servicios. Los servicios son la capa de aplicación en la arquitectura hexagonal: traducen los comandos entrantes en operaciones de dominio y persisten los resultados (Capítulo 2).
+
+**Instantánea (snapshot)** — Una copia serializada en un instante concreto del estado de un agregado con event sourcing, almacenada junto al flujo de eventos para acelerar la reproducción. Al cargar, el repositorio restaura la instantánea y reproduce solo los eventos ocurridos después de la versión de la instantánea, acotando el tiempo de reproducción independientemente de la longitud del flujo (Capítulo 9).
+
+**Estereotipo** — Un decorador que registra una clase como bean y señala su rol arquitectónico: `@service`, `@repository`, `@component`, `@configuration` o `@rest_controller`. Todos los estereotipos son técnicamente equivalentes en el contenedor; la diferencia semántica es para los lectores humanos y las herramientas (Capítulo 2).
+
+**TCC (Try-Confirm-Cancel)** — Un patrón de transacciones distribuidas en el que cada participante primero *reserva* un recurso (Try), y luego el coordinador o bien *confirma* todas las reservas o las *cancela* en función de si todos los Try tuvieron éxito. TCC es útil cuando se requiere una semántica de reserva exacta e inmediata —por ejemplo, retener fondos antes de capturar un pago—. Los decoradores `@tcc(name="…")` + `@tcc_participant(id="…", order=N)` de PyFly implementan el protocolo TCC junto al motor de sagas (Capítulo 12).
+
+**Testcontainers** — Una biblioteca que arranca contenedores Docker reales (PostgreSQL, Redis, Kafka) para las pruebas de integración y los detiene cuando la suite termina. El módulo `pyfly.testing` de PyFly proporciona los fixtures `postgres_container` y `redis_container` que cablean Testcontainers en el contenedor de inyección de dependencias mediante una configuración al estilo `@ServiceConnection` (Capítulo 16).
+
+**Objeto de valor (value object)** — Un objeto de dominio inmutable identificado por su valor en lugar de por un campo de identidad. Dos objetos de valor son iguales si todos sus campos son iguales. En PyFly, `ValueObject` es la clase base; aplica `@dataclass(frozen=True)` para imponer la inmutabilidad. `Money` —un importe entero en **unidades menores** (céntimos) y un `Currency` `StrEnum`— es el objeto de valor canónico de Lumen; `Wallet` es la raíz de agregado que lo posee (Capítulo 6).
+
+**Webhook** — Una llamada de retorno HTTP entrante que un proveedor externo (Stripe, Twilio, etc.) invoca para notificar a tu servicio de un evento asíncrono, como un cambio en el estado de un pago. El decorador `@webhook_listener` de PyFly verifica la firma HMAC-SHA256, deduplica las repeticiones mediante una caché de nonces y enruta la carga útil a un manejador tipado (Capítulo 17).
+
+**Flujo de trabajo (workflow)** — Una variante de larga duración del patrón saga en la que los pasos pueden pausarse durante minutos, horas o a la espera de aprobación humana antes de reanudarse. Los flujos de trabajo persisten su estado entre pasos para sobrevivir a los reinicios del proceso; los decoradores `@workflow` y `@workflow_step` de PyFly proporcionan esta capacidad sobre el mismo motor de sagas (Capítulo 12).
diff --git a/book/manuscript/00-front/00-dedication.md b/book/manuscript/00-front/00-dedication.md
new file mode 100644
index 00000000..75ae9850
--- /dev/null
+++ b/book/manuscript/00-front/00-dedication.md
@@ -0,0 +1,7 @@
+*For **Jacinto Arias** and the **Taidy** team —*
+
+*for pushing us, again and again, to build a real framework for Python.*
+
+*Because of you there is, at last, **one** way to do things.*
+
+*One. Single. Way. (We give it a week.)*
diff --git a/book/manuscript/00-front/00-preface.md b/book/manuscript/00-front/00-preface.md
index 7d475abc..bfa0b83b 100644
--- a/book/manuscript/00-front/00-preface.md
+++ b/book/manuscript/00-front/00-preface.md
@@ -2,7 +2,7 @@
Enterprise Python has long meant stitching together a dozen independent libraries — one for dependency injection, another for routing, yet another for async database access — with no shared idiom to bind them. **PyFly** changes that. It brings the cohesive, convention-over-configuration experience that Spring Boot gave the Java world, rebuilt from the ground up for Python 3.12+ and `async`/`await`.
-This book teaches PyFly **by doing**. You build one real application from an empty folder to a secured, observable, event-driven service — making every concept concrete before moving to the next. Crucially, the code in these pages is not illustrative pseudocode: it is taken from a **real project that compiles, boots, and passes its tests** against PyFly v26.6.103. Every listing was verified against the running sample, so what you read is what actually works.
+This book teaches PyFly **by doing**. You build one real application from an empty folder to a secured, observable, event-driven service — making every concept concrete before moving to the next. Crucially, the code in these pages is not illustrative pseudocode: it is taken from a **real project that compiles, boots, and passes its tests** against PyFly v26.6.110. Every listing was verified against the running sample, so what you read is what actually works.
### Who This Book Is For
@@ -14,6 +14,7 @@ Spring Boot developers will feel especially at home. Wherever PyFly mirrors a Sp
Every chapter advances **Lumen**, a digital-wallet and ledger service. The journey follows a deliberate arc, one part at a time:
+- **Quick Start — Build Lumen Step by Step.** Before the deep dive, a single guided walkthrough takes you from an empty folder to a running, tested wallet feature — open a wallet, deposit, read the balance over HTTP — so you see the whole shape of a PyFly application before zooming into any one part. Every later chapter then expands one slice of what you built here.
- **Part I — Foundations (Chapters 1–4).** You scaffold the first Lumen service with `pyfly new`, run it under an ASGI server, wire PyFly's dependency-injection container, bind typed configuration and profiles, and expose your first validated REST endpoints.
- **Part II — Modeling & Persisting (Chapters 5–7).** You introduce the repository pattern over a port, persist wallets with async SQLAlchemy (SQLite, no infrastructure required), model the domain with a `Money` value object and a `Wallet` aggregate, and split reads from writes with CQRS command and query handlers dispatched through a bus.
- **Part III — Event-Driven (Chapters 8–10).** The aggregate raises domain events; a listener projects them; an **event-sourced ledger** rebuilds every balance by replaying its event stream; and the same events flow out to Kafka or RabbitMQ for other services.
diff --git a/book/manuscript/00-quickstart.md b/book/manuscript/00-quickstart.md
new file mode 100644
index 00000000..3dd14f10
--- /dev/null
+++ b/book/manuscript/00-quickstart.md
@@ -0,0 +1,965 @@
+Quick Start
+
+# Build Lumen Step by Step {.chtitle}
+
+::: figure art/openers/ch01.svg |
+
+Welcome. This is the very first thing you will build with PyFly, and we are going to take it slowly. By the end of this chapter you will have gone from an *empty folder* to a *running, tested* slice of a real digital-wallet service — opening a wallet, persisting it to a database, reading its balance back over HTTP, and reacting to a domain event. Every concept gets a small, complete piece of code and a "Run it" checkpoint so you can see it working before moving on.
+
+This is a *tour*, not the deep dive. Each step previews a topic that Part I and Part II cover thoroughly later. The goal here is momentum: by the time you reach Chapter 1 you will have already met dependency injection, configuration, HTTP, persistence, CQRS, and events — in the small — and the rest of the book will fill in the *why*.
+
+The application you build is called **Lumen**: a DDD-flavoured digital wallet. A wallet can be opened, deposited to, and withdrawn from, protecting one core rule — **the balance never goes negative** — and modelling money with exact integer arithmetic so there is never any floating-point drift. It is the same application the entire book builds, so nothing you learn here is throwaway.
+
+!!! note "Note"
+ This chapter is written against PyFly **v26.6.110**. Every listing is taken from the real, running `samples/lumen` project that ships with the book — the code compiles, boots, and passes its tests. You will recognise these exact files again, in more depth, in later chapters.
+
+---
+
+## Step 1 — Prerequisites and install
+
+PyFly is a Python framework, and the smoothest way to work with it is through [**uv**](https://docs.astral.sh/uv/), Astral's fast Python package and project manager. uv handles your Python version, your virtual environment, and your dependencies in one tool, and the `pyfly` command-line tool runs through it.
+
+You need two things:
+
+* **Python 3.12 or newer.** PyFly uses modern typing features (`StrEnum`, `X | None` unions, PEP 695 generics).
+* **uv.** Install it once, system-wide.
+
+Install uv with the official one-liner:
+
+```bash
+curl -LsSf https://astral.sh/uv/install.sh | sh # macOS / Linux
+# Windows (PowerShell):
+# powershell -c "irm https://astral.sh/uv/install.ps1 | iex"
+```
+
+### Run it
+
+Confirm both tools are available:
+
+```bash
+uv --version
+uv python install 3.12 # ensures a 3.12+ interpreter is available
+```
+
+You should see a uv version printed, and uv will report that Python 3.12 is installed (or already present).
+
+!!! tip "Following along with the finished sample"
+ Everything in this chapter exists, finished, in the book's repository under `samples/lumen`. If at any point you want to compare your code with the real thing — or just run it — clone the repo and do:
+
+ ```bash
+ cd samples/lumen
+ uv sync --extra dev # framework + pytest
+ uv run pyfly run --server uvicorn
+ ```
+
+ You can build alongside it, copying one file at a time, or read the finished version when a step is unclear. Both work.
+
+---
+
+## Step 2 — Scaffold the project
+
+PyFly ships a project generator, `pyfly new`, the same way Spring has the Spring Initializr. It writes a conventional project layout so you do not start from a blank page. Because `pyfly` lives inside the framework package, the very first thing we do is create a project directory and add PyFly to it.
+
+For this tour we will build the directories by hand as we go — it keeps every file visible and nothing hidden behind a generator — but the generator is there when you want a head start:
+
+```bash
+pyfly new lumen --archetype hexagonal --features web,data-relational
+```
+
+That command creates a `lumen/` folder, a `pyproject.toml`, a `pyfly.yaml`, and a layered source tree. Let's create the same shape ourselves so you see exactly what each piece is for. Start with the project and its dependencies:
+
+```bash
+mkdir lumen && cd lumen
+uv init --package --name lumen
+uv add "pyfly[cli,web,data-relational]" "pydantic>=2.5"
+uv add --dev "pytest>=8" "pytest-asyncio>=0.24" "httpx>=0.27"
+```
+
+The three PyFly extras you just added map to the three things Lumen needs: `cli` brings the `pyfly` command itself, `web` brings the ASGI server, and `data-relational` brings SQLAlchemy 2 (async) plus `aiosqlite` so we can persist to a SQLite file with no external database to install.
+
+### The project layout
+
+PyFly applications follow a layered structure that separates the public contract, the domain model, the application logic, and the web edge. Create these packages under `src/lumen`:
+
+```
+lumen/
+├── pyproject.toml
+├── pyfly.yaml # framework configuration
+└── src/lumen/
+ ├── interfaces/ # the public contract: DTOs + enums
+ │ ├── dtos/v1/
+ │ └── enums/v1/
+ ├── models/ # the domain model + persistence
+ │ ├── entities/v1/
+ │ └── repositories/
+ ├── core/ # application logic: commands, queries, handlers
+ │ ├── services/
+ │ └── mappers/
+ ├── web/ # the HTTP edge: controllers
+ │ └── controllers/
+ ├── app.py # the application class
+ └── main.py # the ASGI entry point
+```
+
+Each layer has one job. `interfaces` is the boundary other code (and other services) talks to. `models` holds the rich domain objects and the rows they persist as. `core` holds the business operations. `web` exposes them over HTTP. We will fill these in one layer at a time.
+
+!!! spring "Spring parity"
+ `pyfly new` is the equivalent of the Spring Initializr (`start.spring.io`). The `web` and `data-relational` features are the PyFly counterparts of the `spring-boot-starter-web` and `spring-boot-starter-data-jpa` starters — naming a feature pulls in exactly the dependencies and auto-configuration that feature needs, and nothing more.
+
+### The application class
+
+Two files turn a package into a PyFly application. The first, `app.py`, declares the application itself: which packages to scan for components and which framework tiers to switch on.
+
+::: listing lumen/app.py | Listing 0.1 — The application class
+from __future__ import annotations
+
+from pyfly.core import pyfly_application
+from pyfly.starters.domain import enable_domain_stack
+
+
+@enable_domain_stack
+@pyfly_application(
+ name="lumen",
+ version="1.0.0",
+ description="Lumen — a DDD digital-wallet service built on the PyFly framework.",
+ scan_packages=[
+ "lumen.models.repositories",
+ "lumen.core.services.wallets",
+ "lumen.web.controllers",
+ ],
+)
+class LumenApplication:
+ pass
+:::
+
+`@pyfly_application` marks the class as a PyFly app and `scan_packages` tells the dependency-injection container where to look for the components you will declare — your repositories, services, command/query handlers, and controllers. `@enable_domain_stack` switches on the domain tiers we will lean on later: CQRS, the transactional engine, the relational data layer, and events.
+
+### The ASGI entry point
+
+The second file, `main.py`, is what an ASGI server actually imports and serves. It bootstraps PyFly — loads configuration, scans your packages, and builds the application context — then hands the resulting web application to Starlette.
+
+::: listing lumen/main.py | Listing 0.2 — The ASGI entry point
+from __future__ import annotations
+
+from lumen.app import LumenApplication
+from pyfly.core import PyFlyApplication
+from pyfly.web.adapters.starlette import create_app
+
+# Bootstrap: load config, scan packages, build the DI context.
+_pyfly = PyFlyApplication(LumenApplication)
+
+app = create_app(
+ title="lumen",
+ version="1.0.0",
+ description="Lumen — a DDD digital-wallet service built on the PyFly framework.",
+ context=_pyfly.context,
+)
+:::
+
+!!! note "Note"
+ The real `samples/lumen/main.py` adds a `lifespan` hook and a `/static` mount. Those are refinements you will meet in Chapter 4; the two essentials — `PyFlyApplication(LumenApplication)` to bootstrap, and `create_app(...)` to build the web app — are exactly what you see here.
+
+### Configuration
+
+PyFly reads `pyfly.yaml` from the project root. Create one that names the app, sets the HTTP port, and switches on the tiers we need. Everything is nested under a top-level `pyfly` key.
+
+::: listing pyfly.yaml | Listing 0.3 — pyfly.yaml
+pyfly:
+ app:
+ name: lumen
+ version: 1.0.0
+ server:
+ # App on 8080; the actuator + admin default to the management port 9090.
+ port: 8080
+ cqrs:
+ enabled: true
+ transactional:
+ enabled: true
+ eda:
+ provider: memory # in-memory event bus, no broker needed
+ data:
+ relational:
+ enabled: true
+ url: "sqlite+aiosqlite:///./lumen.db"
+ ddl-auto: create # create tables on startup
+:::
+
+A few keys are worth a moment now and a chapter later. `pyfly.server.port` is the application's HTTP port — `8080` by default, exactly like Spring's `server.port`. `data.relational` points at a SQLite file (`lumen.db`) and `ddl-auto: create` tells the framework to create the database schema on startup, so there is no migration step to run for this tour. `eda.provider: memory` gives us an in-process event bus.
+
+!!! warning "Warning"
+ If you are coming from an older PyFly, note that the port key is `pyfly.server.port` (env override `PYFLY_SERVER_PORT`). The old `pyfly.web.port` / `PYFLY_WEB_PORT` were removed — set the port under `pyfly.server` from now on.
+
+### Run it
+
+Even with no endpoints yet, the application boots. Start it:
+
+```bash
+uv run pyfly run --server uvicorn
+```
+
+You will see the PyFly banner, structured startup logs, and a line telling you the server is listening on `http://0.0.0.0:8080`. The `--server uvicorn` flag selects the Uvicorn server (it comes with `pyfly[web]`); for development, add `--reload` to restart automatically when you edit a file.
+
+PyFly also exposes a **health endpoint** so orchestrators can tell the app is alive. Actuator endpoints and the admin dashboard run on a *separate management port*, `9090` by default, which keeps operational endpoints off your public application port. In another terminal:
+
+```bash
+curl -s localhost:9090/actuator/health
+```
+
+```json
+{"status":"UP"}
+```
+
+!!! note "Two ports, on purpose"
+ The application serves your API on `8080`; the **management port** `9090` serves `/actuator/health`, `/actuator/info`, and the admin dashboard. This is Spring Boot's `management.server.port` behaviour. The management port is *open and unauthenticated by default* — fine on a private network, but in production you would set `pyfly.management.security.enabled: true` to lock it down, or `pyfly.management.server.port: -1` to disable management endpoints entirely. By default only `health` and `info` are exposed over HTTP; expose more (metrics, env, …) with `pyfly.management.endpoints.web.exposure.include`.
+
+Stop the server with `Ctrl-C`. The shell is empty, but the foundation is live: the DI container builds, the server starts, and health reporting works. Now we give it something to do.
+
+---
+
+## Step 3 — The first slice of the domain
+
+We start where DDD says to start: with the model. Two objects carry the whole domain — `Money`, a value object for exact amounts, and `Wallet`, the aggregate that owns the balance.
+
+### Money — a value object
+
+`Money` is the textbook *value object*: it has no identity, two instances with the same amount and currency are interchangeable, and it never changes. We store amounts as integer **minor units** (cents) plus an ISO-4217 currency code, so arithmetic is exact — `Money(1050, EUR)` is €10.50, and there is no floating-point rounding to worry about.
+
+First the tiny currency enum it depends on, under `interfaces/enums/v1/`:
+
+::: listing lumen/interfaces/enums/v1/currency.py | Listing 0.4 — The Currency enum
+from __future__ import annotations
+
+from enum import StrEnum
+
+
+class Currency(StrEnum):
+ """ISO-4217 currency codes Lumen wallets can hold."""
+
+ EUR = "EUR"
+ USD = "USD"
+ GBP = "GBP"
+:::
+
+Now `Money` itself, under `models/entities/v1/`. It builds on `pyfly.domain.ValueObject` and is a frozen dataclass, so equality is structural and instances are immutable. Arithmetic returns *new* `Money` objects and refuses to mix currencies.
+
+::: listing lumen/models/entities/v1/money.py | Listing 0.5 — The Money value object
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from lumen.interfaces.enums.v1.currency import Currency
+from pyfly.domain import BusinessRuleViolation, ValueObject
+
+
+@dataclass(frozen=True)
+class Money(ValueObject):
+ """An exact monetary amount in a single currency (minor units)."""
+
+ amount: int
+ currency: Currency
+
+ def __post_init__(self) -> None:
+ if not isinstance(self.amount, int) or isinstance(self.amount, bool):
+ raise BusinessRuleViolation(
+ "money-amount-integer", "amount must be an integer number of minor units"
+ )
+
+ @classmethod
+ def zero(cls, currency: Currency) -> Money:
+ """The additive identity for *currency* (a zero balance)."""
+ return cls(amount=0, currency=currency)
+
+ def add(self, other: Money) -> Money:
+ self._assert_same_currency(other)
+ return Money(amount=self.amount + other.amount, currency=self.currency)
+
+ def subtract(self, other: Money) -> Money:
+ self._assert_same_currency(other)
+ return Money(amount=self.amount - other.amount, currency=self.currency)
+
+ @property
+ def is_positive(self) -> bool:
+ return self.amount > 0
+
+ @property
+ def is_negative(self) -> bool:
+ return self.amount < 0
+
+ @property
+ def major_units(self) -> float:
+ """The amount as a major-unit decimal (cents / 100)."""
+ return round(self.amount / 100, 2)
+
+ def _assert_same_currency(self, other: Money) -> None:
+ if self.currency is not other.currency:
+ raise BusinessRuleViolation(
+ "money-currency-mismatch",
+ f"cannot combine {self.currency.value} with {other.currency.value}",
+ )
+:::
+
+`BusinessRuleViolation` is the framework's signal that a domain rule was broken — here, "amounts are whole minor units" and "you cannot add euros to dollars". Notice there is no HTTP, no database, no framework wiring: a value object is pure domain.
+
+### Wallet — the aggregate
+
+The `Wallet` is the *aggregate root*: the object that owns the invariant. State only changes through intent-revealing methods (`open`, `deposit`, `withdraw`), each of which protects the rule **balance never goes negative** and records a *domain event* describing what happened. Built on `pyfly.domain.AggregateRoot`, it raises events with `raise_event(...)`, which we will drain and publish in Step 9.
+
+::: listing lumen/models/entities/v1/wallet_entity.py | Listing 0.6 — The Wallet aggregate and its domain events
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import UTC, datetime
+
+from lumen.interfaces.enums.v1.currency import Currency
+from lumen.models.entities.v1.money import Money
+from pyfly.domain import AggregateRoot, BusinessRuleViolation, DomainEvent
+
+
+@dataclass(frozen=True)
+class WalletOpened(DomainEvent):
+ wallet_id: str = ""
+ owner_id: str = ""
+ currency: str = ""
+
+
+@dataclass(frozen=True)
+class FundsDeposited(DomainEvent):
+ wallet_id: str = ""
+ amount: int = 0
+ currency: str = ""
+ balance: int = 0
+
+
+class Wallet(AggregateRoot[str]):
+ """Wallet aggregate root — owns the ``balance >= 0`` invariant."""
+
+ __slots__ = ("owner_id", "balance", "created_at")
+
+ def __init__(
+ self,
+ id: str,
+ owner_id: str,
+ balance: Money,
+ created_at: datetime | None = None,
+ ) -> None:
+ super().__init__(id)
+ self.owner_id = owner_id
+ self.balance = balance
+ self.created_at = created_at or datetime.now(UTC)
+
+ @property
+ def currency(self) -> Currency:
+ return self.balance.currency
+
+ @classmethod
+ def open(cls, wallet_id: str, owner_id: str, currency: Currency) -> Wallet:
+ """Open a new, empty wallet; raises :class:`WalletOpened`."""
+ if not owner_id.strip():
+ raise BusinessRuleViolation("wallet-owner-required", "owner_id is required")
+ wallet = cls(id=wallet_id, owner_id=owner_id, balance=Money.zero(currency))
+ wallet.raise_event(
+ WalletOpened(wallet_id=wallet_id, owner_id=owner_id, currency=currency.value)
+ )
+ return wallet
+
+ def deposit(self, amount: Money) -> None:
+ """Credit *amount* to the balance; raises :class:`FundsDeposited`."""
+ self._assert_currency(amount)
+ if not amount.is_positive:
+ raise BusinessRuleViolation("wallet-deposit-positive", "deposit amount must be > 0")
+ self.balance = self.balance.add(amount)
+ assert self.id is not None
+ self.raise_event(
+ FundsDeposited(
+ wallet_id=self.id,
+ amount=amount.amount,
+ currency=amount.currency.value,
+ balance=self.balance.amount,
+ )
+ )
+
+ def _assert_currency(self, amount: Money) -> None:
+ if amount.currency is not self.balance.currency:
+ raise BusinessRuleViolation(
+ "wallet-currency-mismatch",
+ f"wallet holds {self.balance.currency.value}, got {amount.currency.value}",
+ )
+:::
+
+::: figure art/figures/06-aggregate.svg | Figure 0.1 — The Wallet aggregate owns its invariant; all state changes go through its methods.
+
+!!! note "Note"
+ The finished `Wallet` in `samples/lumen` also has a `withdraw` method and a `FundsWithdrawn` event, which follow the same shape — we have left them out here to keep this first listing short. Chapter 6 builds the full aggregate.
+
+### Run it
+
+You do not need the server running to exercise the domain. Open a Python REPL inside the project and drive the model directly:
+
+```bash
+uv run python
+```
+
+```python
+>>> from lumen.interfaces.enums.v1.currency import Currency
+>>> from lumen.models.entities.v1.money import Money
+>>> from lumen.models.entities.v1.wallet_entity import Wallet
+>>> w = Wallet.open("wlt-1", "alice", Currency.EUR)
+>>> w.deposit(Money(1500, Currency.EUR))
+>>> w.balance.amount, w.balance.currency.value
+(1500, 'EUR')
+>>> w.deposit(Money(100, Currency.USD)) # wrong currency → rejected
+pyfly.domain.exceptions.BusinessRuleViolation: wallet holds EUR, got USD
+```
+
+The aggregate enforces its own rules in pure Python — no infrastructure required.
+
+---
+
+## Step 4 — Persist it
+
+A wallet that lives only in memory is not much use. We need to save it. PyFly's data layer gives you a *Spring-Data-style repository* over SQLAlchemy, and because we configured SQLite there is no database to install.
+
+### The persistence row
+
+The aggregate is rich; the row it persists as is flat. We map `Wallet` onto a `WalletEntity` — one row per wallet, with the balance split into an integer column (`balance_minor`) and a currency column. It inherits the framework's declarative `Base`, which lets it keep the aggregate's own string id as its primary key.
+
+::: listing lumen/models/entities/v1/wallet_orm.py | Listing 0.7 — The WalletEntity persistence row
+from __future__ import annotations
+
+from datetime import UTC, datetime
+
+from sqlalchemy import String
+from sqlalchemy.orm import Mapped, mapped_column
+
+from pyfly.data.relational.sqlalchemy import Base
+
+
+class WalletEntity(Base):
+ """One persisted wallet row, keyed by the aggregate's own string id."""
+
+ __tablename__ = "wallets"
+
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
+ owner_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
+ currency: Mapped[str] = mapped_column(String(3), nullable=False)
+ balance_minor: Mapped[int] = mapped_column(nullable=False, default=0)
+ created_at: Mapped[datetime] = mapped_column(default=lambda: datetime.now(UTC))
+:::
+
+Because the class subclasses `Base`, importing it registers the `wallets` table; with `ddl-auto: create` the framework creates that table on startup.
+
+### The repository
+
+Instead of hand-writing SQL, you subclass the framework's generic `Repository[Entity, IdType]`. That single declaration tells the framework the entity type and the primary-key type, and in return you get the full async repository surface for free — `save`, `find_by_id`, `find_all`, `count`, `delete`, paging, and more — with the database session injected for you.
+
+::: listing lumen/models/repositories/wallet_repository.py | Listing 0.8 — A Spring-Data-style repository
+from __future__ import annotations
+
+from lumen.models.entities.v1.wallet_orm import WalletEntity
+from pyfly.container import repository
+from pyfly.data.relational.sqlalchemy import Repository
+
+
+@repository
+class WalletRepository(Repository[WalletEntity, str]):
+ """CRUD for :class:`WalletEntity`, plus a convenience upsert."""
+
+ async def find_by_owner_id(self, owner_id: str) -> list[WalletEntity]:
+ """All wallets owned by *owner_id* (derived query stub)."""
+ ...
+
+ async def upsert(self, entity: WalletEntity) -> WalletEntity:
+ """Insert *entity*, or update the row with the same id."""
+ session = self._require_session()
+ merged = await session.merge(entity)
+ await session.flush()
+ return merged
+:::
+
+Two things here are pure Spring Data. `find_by_owner_id` is a **derived query** — its body is an elided stub (`...`), and at startup the framework parses the method *name* and compiles a real `SELECT … WHERE owner_id = :owner_id` for you. `upsert` is a small convenience over `session.merge` so a handler can persist a wallet whether it is new or already exists, with a single call.
+
+The `@repository` decorator registers the class as a managed component — a *bean* in the DI container — so it can be injected into the handlers we write next.
+
+::: figure art/figures/05-repository.svg | Figure 0.2 — A framework Repository turns a typed declaration into a full CRUD surface.
+
+!!! spring "Spring parity"
+ `Repository[WalletEntity, str]` is the direct analogue of Spring Data's `JpaRepository`. You declare the interface; the framework provides the implementation. Derived queries (`findByOwnerId` in Spring, `find_by_owner_id` here) are parsed from the method name in exactly the same way.
+
+---
+
+## Step 5 — A write path with CQRS
+
+Now we wire the model to the repository through a *command*. PyFly uses **CQRS** — Command Query Responsibility Segregation — which means writes flow through one path (commands) and reads through another (queries). A command is a small, immutable object describing intent; a handler executes it.
+
+### The command
+
+`OpenWallet` carries the data needed to open a wallet and validates itself before anything runs. It is a frozen dataclass extending `Command[str]` — the `str` says "this command, when handled, produces a wallet id".
+
+::: listing lumen/core/services/wallets/open_wallet_command.py | Listing 0.9 — The OpenWallet command
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from lumen.interfaces.enums.v1.currency import Currency
+from pyfly.cqrs import Command, ValidationResult
+
+
+@dataclass(frozen=True)
+class OpenWallet(Command[str]):
+ """Open a new wallet. Returns the generated wallet id."""
+
+ owner_id: str
+ currency: Currency
+
+ async def validate(self) -> ValidationResult: # type: ignore[override]
+ if not self.owner_id.strip():
+ return ValidationResult.failure("owner_id", "Owner id is required")
+ return ValidationResult.success()
+:::
+
+### The handler
+
+The handler is where the work happens: generate an id, create the `Wallet` aggregate, persist it through the repository, then drain and publish the aggregate's events. It runs inside `@transactional()`, which opens a unit of work, commits on success, and rolls back on failure. The repository and the event publisher are injected by the container — you only declare them in `__init__`.
+
+::: listing lumen/core/services/wallets/open_wallet_handler.py | Listing 0.10 — The OpenWallet handler
+from __future__ import annotations
+
+from uuid import uuid4
+
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+from lumen.core.mappers.wallet_mapper import to_entity
+from lumen.core.services.wallets.event_publishing import publish_domain_events
+from lumen.core.services.wallets.open_wallet_command import OpenWallet
+from lumen.models.entities.v1.wallet_entity import Wallet
+from lumen.models.repositories.wallet_repository import WalletRepository
+from pyfly.container import service
+from pyfly.cqrs import CommandHandler, command_handler
+from pyfly.data.relational.sqlalchemy import transactional
+from pyfly.eda import EventPublisher
+
+
+@command_handler
+@service
+class OpenWalletHandler(CommandHandler[OpenWallet, str]):
+ """Open a new, empty wallet."""
+
+ def __init__(
+ self,
+ repository: WalletRepository,
+ events: EventPublisher,
+ session_factory: async_sessionmaker[AsyncSession],
+ ) -> None:
+ super().__init__()
+ self._repository = repository
+ self._events = events
+ self._session_factory = session_factory
+
+ @transactional()
+ async def do_handle(self, command: OpenWallet) -> str: # type: ignore[override]
+ wallet_id = f"wlt-{uuid4()}"
+ wallet = Wallet.open(
+ wallet_id=wallet_id,
+ owner_id=command.owner_id,
+ currency=command.currency,
+ )
+ await self._repository.upsert(to_entity(wallet))
+ await publish_domain_events(self._events, wallet.clear_events())
+ return wallet_id
+:::
+
+The handler calls `to_entity(wallet)` — a small mapper that flattens the aggregate into a `WalletEntity` row. Create it under `core/mappers/`. (We will add the read-side projection it also needs in the next step.)
+
+::: listing lumen/core/mappers/wallet_mapper.py | Listing 0.11 — Mapping the aggregate to its row
+from __future__ import annotations
+
+from lumen.models.entities.v1.wallet_entity import Wallet
+from lumen.models.entities.v1.wallet_orm import WalletEntity
+
+
+def to_entity(wallet: Wallet) -> WalletEntity:
+ """Flatten a :class:`Wallet` aggregate into a persistable row."""
+ assert wallet.id is not None
+ return WalletEntity(
+ id=wallet.id,
+ owner_id=wallet.owner_id,
+ currency=wallet.currency.value,
+ balance_minor=wallet.balance.amount,
+ created_at=wallet.created_at,
+ )
+:::
+
+::: figure art/figures/07-cqrs.svg | Figure 0.3 — A command flows through the bus to its handler; queries take a separate path.
+
+!!! spring "Spring parity"
+ `@command_handler` + `@service` registers a handler the command bus dispatches to — much like a Spring `@Service` whose method handles a request. `@transactional()` is the PyFly counterpart of Spring's `@Transactional`: it manages the unit of work so the persist either fully commits or fully rolls back.
+
+---
+
+## Step 6 — A read path
+
+Reads take the other lane. A *query* asks a question; a *query handler* answers it, typically projecting a database row onto a small, purpose-built DTO. We will read just the balance.
+
+### The DTO and the query
+
+The balance response is a tiny Pydantic model under `interfaces/dtos/v1/`:
+
+::: listing lumen/interfaces/dtos/v1/balance_dto.py | Listing 0.12 — The BalanceDto response model
+from __future__ import annotations
+
+from pydantic import BaseModel
+
+from lumen.interfaces.enums.v1.currency import Currency
+
+
+class BalanceDto(BaseModel):
+ """Lightweight balance projection for the balance endpoint."""
+
+ id: str
+ currency: Currency
+ balance_minor: int
+ balance: float
+:::
+
+The query carries just the wallet id and declares it returns a `BalanceDto` or `None`:
+
+::: listing lumen/core/services/wallets/get_balance_query.py | Listing 0.13 — The GetBalance query
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from lumen.interfaces.dtos.v1.balance_dto import BalanceDto
+from pyfly.cqrs import Query
+
+
+@dataclass(frozen=True)
+class GetBalance(Query[BalanceDto | None]):
+ """Look up just the balance of a wallet by its identifier."""
+
+ wallet_id: str
+:::
+
+### The handler and the projection
+
+Add the read-side mapper to `wallet_mapper.py` — a small function that projects a row onto the DTO, computing the major-unit balance:
+
+::: listing lumen/core/mappers/wallet_mapper.py | Listing 0.14 — Projecting a row onto the balance DTO
+from __future__ import annotations
+
+from lumen.interfaces.dtos.v1.balance_dto import BalanceDto
+from lumen.interfaces.enums.v1.currency import Currency
+from lumen.models.entities.v1.wallet_orm import WalletEntity
+
+
+def entity_to_balance_dto(entity: WalletEntity) -> BalanceDto:
+ """Project a persisted row onto the lightweight balance DTO."""
+ return BalanceDto(
+ id=entity.id,
+ currency=Currency(entity.currency),
+ balance_minor=entity.balance_minor,
+ balance=round(entity.balance_minor / 100, 2),
+ )
+:::
+
+The query handler loads the row by id and projects it — returning `None` when there is no such wallet:
+
+::: listing lumen/core/services/wallets/get_balance_handler.py | Listing 0.15 — The GetBalance handler
+from __future__ import annotations
+
+from lumen.core.mappers.wallet_mapper import entity_to_balance_dto
+from lumen.core.services.wallets.get_balance_query import GetBalance
+from lumen.interfaces.dtos.v1.balance_dto import BalanceDto
+from lumen.models.repositories.wallet_repository import WalletRepository
+from pyfly.container import service
+from pyfly.cqrs import QueryHandler, query_handler
+
+
+@query_handler
+@service
+class GetBalanceHandler(QueryHandler[GetBalance, BalanceDto | None]):
+ def __init__(self, repository: WalletRepository) -> None:
+ super().__init__()
+ self._repository = repository
+
+ async def do_handle(self, query: GetBalance) -> BalanceDto | None: # type: ignore[override]
+ entity = await self._repository.find_by_id(query.wallet_id)
+ return entity_to_balance_dto(entity) if entity is not None else None
+:::
+
+Notice the asymmetry CQRS gives you: the write side rehydrates the full aggregate to protect invariants; the read side touches only the columns the balance view needs and never constructs an aggregate at all. Each side is shaped for its own job.
+
+---
+
+## Step 7 — Expose it over HTTP
+
+The domain works, it persists, and it has write and read paths. Now we put a web edge on it. A **controller** maps HTTP requests onto commands and queries and dispatches them through the bus — it holds no business logic of its own.
+
+First the request DTO for opening a wallet, under `interfaces/dtos/v1/`:
+
+::: listing lumen/interfaces/dtos/v1/open_wallet_request.py | Listing 0.16 — The OpenWalletRequest payload
+from __future__ import annotations
+
+from pydantic import BaseModel, Field
+
+from lumen.interfaces.enums.v1.currency import Currency
+
+
+class OpenWalletRequest(BaseModel):
+ """Wallet-opening request payload."""
+
+ owner_id: str = Field(min_length=1, max_length=64, description="Identifier of the wallet owner")
+ currency: Currency = Field(default=Currency.EUR, description="ISO-4217 currency the wallet holds")
+:::
+
+Now the controller, under `web/controllers/`. The DI container injects the command and query buses; each handler builds a command or query and `await`s it on the bus. Parameter annotations declare where data comes from: `Valid[Body[...]]` binds and validates the JSON body, `PathVar[str]` binds a URL segment.
+
+::: listing lumen/web/controllers/wallet_controller.py | Listing 0.17 — The wallet controller
+from __future__ import annotations
+
+from lumen.core.services.wallets.get_balance_query import GetBalance
+from lumen.core.services.wallets.open_wallet_command import OpenWallet
+from lumen.interfaces.dtos.v1.balance_dto import BalanceDto
+from lumen.interfaces.dtos.v1.open_wallet_request import OpenWalletRequest
+from pyfly.container import rest_controller
+from pyfly.cqrs import DefaultCommandBus, DefaultQueryBus
+from pyfly.kernel import ResourceNotFoundException
+from pyfly.web import Body, PathVar, Valid, get_mapping, post_mapping, request_mapping
+
+
+@rest_controller
+@request_mapping("/api/v1/wallets")
+class WalletController:
+ """Digital-wallet REST API: open a wallet, read its balance."""
+
+ def __init__(self, commands: DefaultCommandBus, queries: DefaultQueryBus) -> None:
+ self._commands = commands
+ self._queries = queries
+
+ @post_mapping("", status_code=201)
+ async def open_wallet(self, request: Valid[Body[OpenWalletRequest]]) -> dict[str, str]:
+ wallet_id = await self._commands.send(
+ OpenWallet(owner_id=request.owner_id, currency=request.currency)
+ )
+ return {"wallet_id": wallet_id}
+
+ @get_mapping("/{wallet_id}/balance")
+ async def wallet_balance(self, wallet_id: PathVar[str]) -> BalanceDto:
+ result = await self._queries.query(GetBalance(wallet_id=wallet_id))
+ if result is None:
+ raise ResourceNotFoundException(
+ f"Wallet {wallet_id!r} not found",
+ code="WALLET_NOT_FOUND",
+ context={"wallet_id": wallet_id},
+ )
+ return result
+:::
+
+::: figure art/figures/04-request.svg | Figure 0.4 — A request binds to a handler, which dispatches a command or query through the bus.
+
+### Run it
+
+Start the server:
+
+```bash
+uv run pyfly run --server uvicorn
+```
+
+In another terminal, open a wallet:
+
+```bash
+curl -s -X POST localhost:8080/api/v1/wallets \
+ -H 'content-type: application/json' \
+ -d '{"owner_id":"alice","currency":"EUR"}'
+```
+
+```json
+{"wallet_id":"wlt-7d2c1a9e-..."}
+```
+
+Read the balance back (substitute the id you got above):
+
+```bash
+curl -s localhost:8080/api/v1/wallets/wlt-7d2c1a9e-.../balance
+```
+
+```json
+{"id":"wlt-7d2c1a9e-...","currency":"EUR","balance_minor":0,"balance":0.0}
+```
+
+A freshly opened wallet has a zero balance — exactly what `Money.zero(EUR)` produced back in the aggregate's `open` factory. The request travelled from HTTP, through the command bus, into the handler, through the repository, to SQLite, and back out the read path. That is the whole arc, end to end.
+
+!!! tip "Interactive docs for free"
+ While the server runs, open `http://localhost:8080/docs` in a browser. PyFly generated an OpenAPI document and a Swagger UI from your controller and DTOs — you can try the endpoints right there, no extra code.
+
+---
+
+## Step 8 — Prove it with a test
+
+Running `curl` by hand is fine once; a test proves it forever. PyFly is designed to be testable without a running server — you can dispatch commands and queries straight through the buses. Write a test under `tests/`:
+
+::: listing tests/test_quickstart.py | Listing 0.18 — An end-to-end test through the buses
+from __future__ import annotations
+
+import pytest
+from lumen.core.services.wallets.get_balance_query import GetBalance
+from lumen.core.services.wallets.open_wallet_command import OpenWallet
+from lumen.interfaces.enums.v1.currency import Currency
+
+from pyfly.cqrs import DefaultCommandBus, DefaultQueryBus
+
+
+@pytest.mark.asyncio
+async def test_open_wallet_then_read_balance(
+ command_bus: DefaultCommandBus,
+ query_bus: DefaultQueryBus,
+) -> None:
+ wallet_id = await command_bus.send(OpenWallet(owner_id="alice", currency=Currency.EUR))
+ assert wallet_id.startswith("wlt-")
+
+ balance = await query_bus.query(GetBalance(wallet_id=wallet_id))
+ assert balance is not None
+ assert balance.balance_minor == 0
+ assert balance.currency is Currency.EUR
+:::
+
+The `command_bus` and `query_bus` parameters are *fixtures*: they boot the application context once and hand you wired buses, the same components the controller uses in production. (The finished `samples/lumen/tests/conftest.py` defines these fixtures; copy it when you build your own suite — Chapter 16 explains it in full.)
+
+### Run it
+
+```bash
+uv run --extra dev pytest -q
+```
+
+```
+. [100%]
+1 passed in 0.42s
+```
+
+Green. You now have a wallet feature that is not just running but *verified* — the same test runs in CI on every change.
+
+!!! spring "Spring parity"
+ Dispatching through the buses in a test mirrors Spring Boot's slice tests: you exercise real wired beans without standing up the HTTP server. The `command_bus` / `query_bus` fixtures are the PyFly equivalent of an injected Spring `ApplicationContext` in an `@SpringBootTest`.
+
+---
+
+## Step 9 — A taste of events
+
+The aggregate has been recording domain events all along — `WalletOpened`, `FundsDeposited` — and the handler drains them with `wallet.clear_events()` and publishes them. So far nothing has *listened*. Let's add a small listener that reacts.
+
+First, the publishing bridge the handler already imports. It turns each drained domain event into a payload and publishes it on the event bus, under `core/services/wallets/`:
+
+::: listing lumen/core/services/wallets/event_publishing.py | Listing 0.19 — Publishing drained domain events
+from __future__ import annotations
+
+import dataclasses
+from collections.abc import Iterable
+from typing import Any
+
+from lumen.core.services.listeners.wallet_audit_listener import WALLET_EVENTS_DESTINATION
+from pyfly.domain import DomainEvent
+from pyfly.eda import EventPublisher
+
+
+def _to_payload(event: DomainEvent) -> dict[str, Any]:
+ """Flatten a frozen-dataclass domain event into a JSON-friendly dict."""
+ payload: dict[str, Any] = dataclasses.asdict(event)
+ payload.setdefault("event_type", event.event_type)
+ return payload
+
+
+async def publish_domain_events(publisher: EventPublisher, events: Iterable[DomainEvent]) -> None:
+ """Publish each drained domain event on the wallet events channel."""
+ for event in events:
+ await publisher.publish(
+ destination=WALLET_EVENTS_DESTINATION,
+ event_type=event.event_type,
+ payload=_to_payload(event),
+ )
+:::
+
+Now the listener, under `core/services/listeners/`. It is a plain `@service` whose method is stamped `@event_listener`; at startup PyFly discovers it and subscribes it to the bus — no wiring by hand. Here it keeps a tiny in-memory audit log and a running total per wallet.
+
+::: listing lumen/core/services/listeners/wallet_audit_listener.py | Listing 0.20 — A domain-event listener
+from __future__ import annotations
+
+from pyfly.container import service
+from pyfly.eda import EventEnvelope, event_listener
+
+# The logical channel the wallet handlers publish domain events to.
+WALLET_EVENTS_DESTINATION = "wallet.events"
+
+
+@service
+class WalletAuditListener:
+ """In-memory audit log + running-total projection over wallet events."""
+
+ def __init__(self) -> None:
+ self._running_totals: dict[str, int] = {}
+
+ @event_listener(event_types=["WalletOpened", "FundsDeposited"])
+ async def on_wallet_event(self, envelope: EventEnvelope) -> None:
+ payload = dict(envelope.payload)
+ wallet_id = str(payload.get("wallet_id", ""))
+ if envelope.event_type == "WalletOpened":
+ self._running_totals.setdefault(wallet_id, 0)
+ elif envelope.event_type == "FundsDeposited":
+ amount = int(payload.get("amount", 0))
+ self._running_totals[wallet_id] = self._running_totals.get(wallet_id, 0) + amount
+
+ def running_total(self, wallet_id: str) -> int:
+ """Net funds for *wallet_id*, in minor units."""
+ return self._running_totals.get(wallet_id, 0)
+:::
+
+Add the listeners package to `scan_packages` in `app.py` so the container discovers it:
+
+```python
+scan_packages=[
+ "lumen.models.repositories",
+ "lumen.core.services.wallets",
+ "lumen.core.services.listeners", # <-- add this
+ "lumen.web.controllers",
+],
+```
+
+::: figure art/figures/08-eda.svg | Figure 0.5 — A handler publishes domain events; listeners subscribe and react, decoupled from the command.
+
+### Run it
+
+Open a wallet, then deposit, and the listener's running total updates as a side effect of those commands — without the command knowing the listener exists. That decoupling is the whole point of events: you add reactions (audit logs, notifications, projections) without touching the code that triggered them.
+
+!!! spring "Spring parity"
+ `@event_listener` is the PyFly counterpart of Spring's `@EventListener`. Publishing through an `EventPublisher` and subscribing with a stamped method is the same publish/subscribe model as Spring's `ApplicationEventPublisher` and `@EventListener`-annotated beans.
+
+---
+
+## What you built {.recap}
+
+You just built — and tested — a real vertical slice of a service: a domain model, a database, a write path, a read path, an HTTP edge, and an event reaction. Every one of those was a *preview*. The rest of the book takes each one apart and rebuilds it properly, with the reasoning, the alternatives, and the production details.
+
+Here is the map from what you just did to the chapter that goes deep:
+
+| In this Quick Start you… | Goes deep in |
+|---|---|
+| Saw the container build and inject your beans (`@repository`, `@service`) | **Chapter 2** — Dependency Injection & the Application Context |
+| Configured the app with `pyfly.yaml` and the management port | **Chapter 3** — Configuration, Profiles & Secrets |
+| Exposed an HTTP API with `@rest_controller`, binding, and validation | **Chapter 4** — Your First HTTP API |
+| Persisted with a framework `Repository` over SQLAlchemy/SQLite | **Chapter 5** — Persistence & the Repository Pattern |
+| Modelled `Money`, `Wallet`, invariants, and domain events | **Chapter 6** — Domain-Driven Design |
+| Split writes and reads with commands, queries, and the bus | **Chapter 7** — CQRS: Commands & Queries |
+| Published and reacted to domain events with `@event_listener` | **Chapter 8** — Domain Events & Event-Driven Architecture |
+| (Next) rebuilt state from an event log | **Chapter 9** — Event Sourcing the Ledger |
+| (Next) called other services and split the monolith | **Chapters 11–12** — HTTP Clients, the BFF & Sagas |
+| (Next) secured, observed, and shipped it | **Chapters 14–18** — Security, Observability, Testing & Production |
+
+When you are ready for the *why* behind all of it, turn the page to Chapter 1.
+
+---
+
+## Try it yourself {.exercises}
+
+If you want to keep moving on your own first, three small extensions build directly on what you have:
+
+1. **Add a deposit endpoint.** You already have the `FundsDeposited` event and the aggregate's `deposit` method. Add a `DepositFunds` command + handler (model them on `OpenWallet`), a `POST /{wallet_id}/deposit` route, and watch the listener's running total climb.
+2. **Add a `withdraw` path** that refuses to overdraw — the aggregate's `balance >= 0` invariant should reject it, and your handler should surface that as a clean error. Add a `FundsWithdrawn` domain event that mirrors the `FundsDeposited` event shown in Listing 0.6.
+3. **Write a test for the listener**, asserting that opening a wallet and depositing leaves the expected running total — proving the event path end to end.
diff --git a/book/manuscript/01-why-pyfly.md b/book/manuscript/01-why-pyfly.md
index 5f27b4e6..aed79479 100644
--- a/book/manuscript/01-why-pyfly.md
+++ b/book/manuscript/01-why-pyfly.md
@@ -49,25 +49,46 @@ Every layer follows the same **hexagonal architecture** principle: your code liv
To make these ideas concrete, you will build **Lumen** — a fintech wallet platform — throughout the book. Lumen starts simply: a REST API that records ledger entries and reports wallet balances. By the final chapter it will span multiple services communicating over events, with sagas coordinating cross-service transfers. Starting with a well-structured skeleton saves you the pain of retrofitting architecture later, so PyFly's scaffolder generates that structure for you up front.
-First, verify you have Python 3.12 or later and [uv](https://docs.astral.sh/uv/) (PyFly's recommended package manager):
+We will take this one short step at a time. Nothing here assumes prior PyFly experience — if you can run a command in a terminal, you can follow along.
-::: listing terminal | Listing 1.1 — Verify prerequisites and scaffold Lumen
+!!! note "New term: uv"
+ [uv](https://docs.astral.sh/uv/) is a fast Python package manager and project runner (think `pip` + `virtualenv` + a task runner, rolled into one binary). PyFly recommends it, and every command in this book that starts with `uv run` simply means "run this inside the project's virtual environment". If you have only ever used `pip`, you lose nothing — uv reads the same standard `pyproject.toml`.
+
+**Step 1 — Check your prerequisites.** PyFly targets Python 3.12+ and uses uv. Confirm both are installed:
+
+::: listing terminal | Listing 1.1 — Verify prerequisites
python --version
# Python 3.12.0 or later
uv --version
# uv 0.5.0 or later
+:::
+
+If either command is missing or reports an older version, install it before continuing (uv's install instructions are one short script on its website). Everything else PyFly needs, it pulls in for you.
+
+**Step 2 — Scaffold the project.** A single command generates a complete, production-shaped project directory:
-# Scaffold the Lumen wallet service (web-api archetype, web feature)
+::: listing terminal | Listing 1.2 — Scaffold the Lumen wallet service
+# web-api archetype, web feature
uv run pyfly new lumen --archetype web-api --features web
+:::
+
+!!! note "New term: scaffold / archetype"
+ To *scaffold* is to generate a ready-made project skeleton so you start from working code instead of an empty folder. An *archetype* is the template that scaffolding uses — `web-api` is the REST-service template. The `--features web` flag adds the ASGI server dependency (the piece that actually accepts HTTP requests). `uv run pyfly new` calls PyFly's archetype registry and writes the whole directory in one shot.
+**Step 3 — Move into the project and install dependencies.**
+
+::: listing terminal | Listing 1.3 — Enter the project and sync dependencies
cd lumen
# Install dependencies (including the pyfly CLI and ASGI server)
uv sync
:::
-`uv run pyfly new` calls the PyFly archetype registry and generates a production-shaped project directory in one shot. The `--archetype web-api` flag selects the REST service template; `--features web` adds the ASGI server dependency. `uv sync` installs the declared dependencies, including the `pyfly` command itself (available via the `cli` extra in `pyproject.toml`).
+`uv sync` reads the declared dependencies from `pyproject.toml` and installs them into a project-local virtual environment — including the `pyfly` command itself (available via the `cli` extra). The first sync downloads packages; subsequent syncs are near-instant because uv caches everything.
+
+!!! tip "Run it: confirm the CLI works"
+ From the project root, run `uv run pyfly --help`. You should see the PyFly command list (`new`, `run`, and friends). If that prints, your toolchain is healthy and you are ready to inspect the generated project.
!!! tip "Tip"
Run `pyfly new` **without arguments** to enter interactive mode. It walks you through archetype and feature selection with arrow-key navigation — handy when you want to pre-select extras like relational data or messaging support.
@@ -114,9 +135,12 @@ The apparent simplicity is deliberate. `pyproject.toml` gives you a standards-co
Understanding the scaffold's entry-point structure early will save you confusion later. The scaffold generates two files that work together: `app.py` declares the application and `main.py` exposes the ASGI `app` that the server imports. Each has a distinct responsibility.
-Open `src/lumen/app.py`:
+!!! note "New term: ASGI"
+ *ASGI* (Asynchronous Server Gateway Interface) is the standard contract between a Python web server and an async web application — the modern, async successor to WSGI. You do not have to implement it; PyFly hands the server a ready-made ASGI `app` object. Just remember: the *server* (Uvicorn or Granian) speaks ASGI to your *app*.
-::: listing lumen/app.py | Listing 1.2 — Application declaration (@pyfly_application)
+**Step 1 — Open the application declaration.** Look at `src/lumen/app.py`:
+
+::: listing lumen/app.py | Listing 1.4 — Application declaration (@pyfly_application)
from pyfly.core import pyfly_application
@@ -139,9 +163,12 @@ Short as it is, every parameter pulls its weight:
The `Application` class body is intentionally empty. You never instantiate it yourself — `PyFlyApplication` does that during startup: it loads configuration from `pyfly.yaml`, configures structured logging, prints the startup banner, scans packages, initialises the `ApplicationContext`, and logs startup timing.
-Now open `src/lumen/main.py`:
+!!! note "New terms: DI and the ApplicationContext"
+ *Dependency injection* (DI) means a class declares what it needs in its constructor and the framework supplies those collaborators, rather than the class building them itself. The *ApplicationContext* (often just "the container") is the registry that holds every wired object — PyFly calls each one a *bean*, the same term Spring uses. `scan_packages` is what populates it.
+
+**Step 2 — Open the ASGI entry point.** Now look at `src/lumen/main.py`:
-::: listing lumen/main.py | Listing 1.3 — ASGI entry point (main.py)
+::: listing lumen/main.py | Listing 1.5 — ASGI entry point (main.py)
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
@@ -182,15 +209,18 @@ app = create_app(
This is the file that `pyfly run` (and any ASGI server like Uvicorn) discovers. The module-level `app` object is a Starlette application: `create_app` mounts every `@rest_controller` found in the DI context and wires the lifespan hook so startup and shutdown happen cleanly. The `@pyfly_application` decorator alone does **not** create the ASGI `app` — `main.py` is the explicit bridge between the DI world (`app.py`) and the HTTP world.
+!!! note "What just happened"
+ Two small files split one job in two. `app.py` answers *what is this application and where does its code live* (name, version, scan packages). `main.py` answers *how does a web server reach it* — it bootstraps PyFly into an `ApplicationContext`, then exposes a single ASGI `app` object. When you later add a controller or service, you edit neither file: you just drop the new class into one of the scanned packages and PyFly wires it on the next boot.
+
---
## Project files
The other two files you will edit most often are `pyproject.toml` and `pyfly.yaml`. Each plays a distinct role: `pyproject.toml` manages dependencies, while `pyfly.yaml` owns every runtime knob.
-`pyproject.toml` records the project's dependencies. Notice the extras:
+**Step 1 — Read the dependency manifest.** `pyproject.toml` records the project's dependencies. Notice the extras:
-::: listing lumen/pyproject.toml | Listing 1.4 — pyproject.toml (key sections)
+::: listing lumen/pyproject.toml | Listing 1.6 — pyproject.toml (key sections)
[project]
name = "lumen-demo"
version = "0.1.0"
@@ -210,21 +240,22 @@ dev = [
]
:::
+!!! note "New term: extras"
+ An *extra* is an optional bundle of dependencies named in square brackets — `pyfly[web]` means "install PyFly plus its `web` optional group". Extras keep your install lean: you pull in a database driver, a CLI, or a faster server only when you actually use it.
+
The `web` extra in `pyfly[web]` bundles Uvicorn together with the ASGI adapter. The `cli` extra (add it when you need the `pyfly` command outside of `uv run`) provides the command-line tooling. Other extras — `data-relational`, `granian` — are opt-in; you add them only when a feature needs them. The Lumen wallet sample, for example, declares `pyfly[cli,web,data-relational]` because it connects to SQLite.
-`pyfly.yaml` is where all runtime settings live:
+**Step 2 — Read the runtime configuration.** `pyfly.yaml` is where all runtime settings live:
-::: listing lumen/pyfly.yaml | Listing 1.5 — pyfly.yaml (scaffold default)
+::: listing lumen/pyfly.yaml | Listing 1.7 — pyfly.yaml (scaffold default)
pyfly:
app:
name: lumen-demo
+ version: 0.1.0
module: lumen_demo.main:app
- web:
- port: 8080
- adapter: auto
-
server:
+ port: 8080
type: "auto"
event-loop: "auto"
workers: 0
@@ -241,7 +272,13 @@ pyfly:
root: INFO
:::
-The `module` key tells `pyfly run` exactly where the ASGI `app` lives — `lumen_demo.main:app`. Port, server type, actuator endpoints, and log level all have sensible defaults; you override only what you actually need to change.
+The `module` key tells `pyfly run` exactly where the ASGI `app` lives — `lumen_demo.main:app`. The application listens on `pyfly.server.port` (default `8080`); server type, actuator endpoints, and log level all have sensible defaults too, so you override only what you actually need to change.
+
+!!! spring "Spring parity"
+ `pyfly.server.port` is the direct counterpart of Spring Boot's `server.port`, right down to the `8080` default. The matching environment-variable override is `PYFLY_SERVER_PORT` (Spring's `SERVER_PORT`). Application identity follows the same pattern: `pyfly.app.name` and `pyfly.app.version` mirror `spring.application.name`.
+
+!!! warning "Renamed key: use server.port, not web.port"
+ Earlier PyFly releases used `pyfly.web.port` (and the `PYFLY_WEB_PORT` environment variable) for the listen port. Both were **removed** in v26.06.102 in favour of `pyfly.server.port` / `PYFLY_SERVER_PORT`. If you copy an old config and the port seems ignored, this is almost always why — rename the key under `server:` and the override takes effect.
!!! note "Default server: Granian vs Uvicorn"
PyFly's default ASGI server is **Granian**, a high-performance Rust-based server. The scaffold's `pyfly[web]` dependency bundles **Uvicorn** instead (lighter install). The two are interchangeable: pass `--server uvicorn` on the command line, or add `pyfly[granian]` to your dependencies to use Granian. The Lumen wallet sample runs with `--server uvicorn` for this reason.
@@ -250,12 +287,17 @@ The `module` key tells `pyfly run` exactly where the ASGI `app` lives — `lumen
## Your first run
-With the project in place, start the server. From the project root:
+With the project in place, start the server.
+
+**Step 1 — Start the server.** From the project root, run:
-::: listing terminal | Listing 1.6 — Run the server with uv
+::: listing terminal | Listing 1.8 — Run the server with uv
uv run pyfly run --server uvicorn
:::
+!!! note "Why `--server uvicorn`"
+ PyFly's default server is Granian, but the `pyfly[web]` extra ships Uvicorn (a lighter install). Passing `--server uvicorn` tells PyFly to use the server you actually have. If you ever see an error about Granian not being installed, this flag is the fix — or add `pyfly[granian]` and drop the flag.
+
After a moment you will see the PyFly ASCII banner followed by a stream of structured startup events:
```
@@ -266,10 +308,10 @@ ______ ___.__._/ ____\ | ___.__.
| __// ____| |__| |____/ ____|
|__| \/ \/
-:: PyFly Framework :: (v26.06.103) (Python 3.13.13)
+:: PyFly Framework :: (v26.06.110) (Python 3.13.13)
Copyright 2026 Firefly Software Foundation. | Apache License 2.0
2026-06-07 20:34:32,442 [INFO] pyfly.core: starting_application |
- app=lumen version=1.0.0 python=3.13.13 pid=72300
+ app=lumen-demo version=0.1.0 python=3.13.13 pid=72300
2026-06-07 20:34:32,442 [INFO] pyfly.core: runtime_environment |
os=Darwin os_version=25.5.0 arch=arm64 cpus=11
2026-06-07 20:34:32,442 [INFO] pyfly.core: loaded_config |
@@ -277,7 +319,7 @@ Copyright 2026 Firefly Software Foundation. | Apache License 2.0
2026-06-07 20:34:32,442 [INFO] pyfly.core: loaded_config |
source=pyfly.yaml
2026-06-07 20:34:32,442 [INFO] pyfly.core: scanned_package |
- package=lumen.web.controllers beans_found=1
+ package=lumen_demo.controllers beans_found=1
2026-06-07 20:34:32,580 [INFO] pyfly.core: bean_summary |
total=127 services=6 repositories=2 controllers=4
2026-06-07 20:34:32,580 [INFO] pyfly.core: mapped_endpoints | count=5
@@ -285,6 +327,10 @@ Copyright 2026 Firefly Software Foundation. | Apache License 2.0
method=POST path=/api/v1/wallets handler=open_wallet
2026-06-07 20:34:32,580 [INFO] pyfly.core: api_documentation |
swagger_ui=http://0.0.0.0:8080/docs
+2026-06-07 20:34:32,580 [INFO] pyfly.core: management_server |
+ url=http://0.0.0.0:9090 endpoints=actuator + admin
+2026-06-07 20:34:32,580 [INFO] pyfly.core: admin_dashboard |
+ url=http://0.0.0.0:9090/admin
2026-06-07 20:34:32,581 [INFO] pyfly.core: server_started |
server=uvicorn host=0.0.0.0 port=8080 workers=1
```
@@ -296,14 +342,29 @@ Read these log lines as a story of what the framework did on your behalf. Each e
3. **`loaded_config`** (×2) — configuration is layered: the framework ships `pyfly-defaults.yaml` with safe production defaults; your `pyfly.yaml` overrides only what you need. Activate a profile (e.g., `--profile prod`) and you will see a third entry.
4. **`scanned_package`** — the DI container found one `@rest_controller` in the web controllers package. `beans_found` grows as you add services and repositories.
5. **`bean_summary`** — the total DI context size, including framework-internal beans. Even with 127 beans registered, PyFly boots in well under a second.
-6. **`server_started`** — Uvicorn is accepting connections.
+6. **`management_server`** — the actuator endpoints and admin dashboard are served on a *separate* management port (default `9090`), independent of the app's `8080`.
+7. **`admin_dashboard`** — the exact URL of the live admin UI, `http://0.0.0.0:9090/admin`.
+8. **`server_started`** — Uvicorn is accepting connections on the application port.
-Two endpoints are already live before you have written a single line of application logic. Try the health check first:
+!!! note "What just happened"
+ You ran one command and PyFly did the work of an entire boilerplate sprint: it loaded layered configuration, scanned your packages, wired the DI container, mapped your HTTP routes, published interactive API docs, brought up a separate management server with health checks and an admin dashboard, and started accepting requests — all logged as named, machine-filterable events. Leave this server running in its terminal; you will call it from a second terminal in the next steps. Press `Ctrl+C` when you want to stop it.
+
+!!! spring "Spring parity"
+ Serving actuator and the admin dashboard on a dedicated port mirrors Spring Boot's `management.server.port`. In PyFly the key is `pyfly.management.server.port` (default `9090`). Set it equal to `pyfly.server.port` for single-port behaviour, or set it to `-1` to disable the management endpoints entirely. By default this port is **open and unauthenticated** (also Spring parity) — fine for local development, but in production secure it with `pyfly.management.security.enabled: true`.
-::: listing terminal | Listing 1.7 — Health check
-curl -s localhost:8080/actuator/health
+Two endpoints are already live before you have written a single line of application logic. Open a *second* terminal (leave the server running in the first) and try the health check.
+
+**Step 2 — Check the live health endpoint.** It lives on the management port, `9090`:
+
+::: listing terminal | Listing 1.9 — Health check (management port 9090)
+curl -s localhost:9090/actuator/health
:::
+!!! note "New term: actuator"
+ The *actuator* is PyFly's built-in operations layer: a small set of HTTP endpoints that report on the running app — `/actuator/health`, `/actuator/info`, and (when you expose them) metrics, environment, and more. It is the same concept, and the same `/actuator` base path, as Spring Boot Actuator. By default only `health` and `info` are exposed over HTTP; widen that with `pyfly.management.endpoints.web.exposure.include`.
+
+You should see a JSON document with a top-level `"status": "UP"` and a `components` map — one entry per health indicator the framework registered:
+
```json
{
"status": "UP",
@@ -328,22 +389,39 @@ curl -s localhost:8080/actuator/health
}
```
-Then call the first business endpoint — opening a wallet:
+**Step 3 — Open a wallet.** Now call the first business endpoint. Unlike the actuator, this lives on the *application* port, `8080`:
-::: listing terminal | Listing 1.8 — Open a wallet
+::: listing terminal | Listing 1.10 — Open a wallet (application port 8080)
curl -s -X POST localhost:8080/api/v1/wallets \
-H 'content-type: application/json' \
-d '{"owner_id":"u-1","currency":"EUR"}'
:::
+The response is the new wallet's identifier (your UUID will differ):
+
```json
{"wallet_id": "wlt-c5bbb2a7-dd49-4321-932e-e4c6bfa5cc2c"}
```
-Open `http://localhost:8080/docs` in your browser for the interactive Swagger UI; `http://localhost:8080/redoc` gives you ReDoc; the raw OpenAPI spec is at `/openapi.json`.
+!!! note "What just happened"
+ That single `curl` travelled through the whole stack the framework built for you: the JSON body was validated against the `OpenWalletRequest` model, the `WalletController` turned it into an `OpenWallet` command, the command bus dispatched it to its handler, a `Wallet` aggregate was created and persisted, and the new id came back as JSON. You will build each of those layers yourself in later chapters — for now, notice that it already works end to end.
+
+**Step 4 — Explore the interactive docs.** Open `http://localhost:8080/docs` in your browser for the interactive Swagger UI; `http://localhost:8080/redoc` gives you ReDoc; the raw OpenAPI spec is at `http://localhost:8080/openapi.json`. The docs live on the application port alongside your API.
!!! note "Note"
- All three doc URLs are emitted in the boot log (`api_documentation` entries) so you never have to remember them. `http://localhost:8080/admin` opens the PyFly Admin Dashboard — a live view of beans, endpoints, health indicators, and recent log events.
+ All three doc URLs are emitted in the boot log (`api_documentation` entries) so you never have to remember them. The PyFly Admin Dashboard — a live view of beans, endpoints, health indicators, and recent log events — opens at `http://localhost:9090/admin` on the management port (its exact URL is the `admin_dashboard` boot-log line).
+
+**Step 5 — Run the generated tests.** The scaffold ships a working test suite so you can confirm the toolchain end to end. Install the dev tools, then run pytest:
+
+::: listing terminal | Listing 1.11 — Run the test suite
+uv sync --group dev
+uv run --group dev pytest -q
+:::
+
+!!! note "Expected output"
+ You should see a short run of dots (or `PASSED` lines) and a green summary like `3 passed in 0.42s` — the exact count depends on the archetype. Any green summary means PyFly, your virtual environment, and pytest are all wired correctly. (The Lumen wallet sample you study throughout the book ships a far larger suite, but it runs the same way: `uv run --group dev pytest -q`.)
+
+That completes the loop: install, scaffold, run, call, and test — all before writing a line of your own logic.
---
@@ -355,11 +433,11 @@ In under five minutes you installed PyFly, scaffolded a production-shaped servic
|---|---|
| Structured JSON logging with runtime metadata | Framework default — emitted on every boot |
| Interactive API docs (Swagger UI + ReDoc) | Built-in; always on, opt out via `pyfly.yaml` |
-| Health endpoint (`/actuator/health`) | `actuator.endpoints.enabled: true` in scaffold |
+| Health endpoint (`/actuator/health` on port 9090) | `actuator.endpoints.enabled: true` in scaffold |
| Profile-aware configuration layering | `pyfly.yaml` + `--profile` flag |
| Auto-discovery DI container | `scan_packages` in `@pyfly_application` |
| Startup timing, bean summary, endpoint map | Emitted as structured log events on every boot |
-| Admin Dashboard (`/admin`) | Enabled in the scaffold by default |
+| Admin Dashboard (`/admin` on management port 9090) | Enabled in the scaffold by default |
That is the PyFly promise: sensible decisions made for you, conventions consistent across every service, and production-ready defaults from the very first run.
@@ -369,8 +447,10 @@ That is the PyFly promise: sensible decisions made for you, conventions consiste
1. **Rename the service.** Open `pyfly.yaml` and change `app.name` from `lumen-demo` to `lumen-wallet`. Also update the `name` parameter in `@pyfly_application`. Re-run with `uv run pyfly run --server uvicorn` and confirm the new name appears in the `starting_application` log line.
-2. **Explore the live docs.** With the server running, open `http://localhost:8080/docs`. Notice the service name and version at the top of the Swagger UI. Then navigate to `http://localhost:8080/redoc` and compare the two doc renderers. Check the health response at `http://localhost:8080/actuator/health` and note which health indicators are already reporting.
+2. **Explore the live docs.** With the server running, open `http://localhost:8080/docs`. Notice the service name and version at the top of the Swagger UI. Then navigate to `http://localhost:8080/redoc` and compare the two doc renderers. Check the health response at `http://localhost:9090/actuator/health` (remember: the actuator lives on the management port, not the application port) and note which health indicators are already reporting. Open `http://localhost:9090/admin` to see the same information in the live dashboard.
3. **Switch to Granian.** Add `pyfly[granian]` to the `dependencies` list in `pyproject.toml`, run `uv sync`, then start the server without the `--server uvicorn` flag. Compare the `server_started` log line — the `server` field should now read `granian`.
4. **Map files to responsibilities.** Look at the two entry-point files `app.py` and `main.py`. Write a one-sentence description of what each one is responsible for. Where does the DI container come from? Where does the ASGI `app` object live? Which one would you edit to change the scan packages?
+
+5. **Collapse to a single port.** Add `pyfly.management.server.port: 8080` under the `server`-sibling `management:` section in `pyfly.yaml` (so it equals `pyfly.server.port`). Re-run the server and watch the boot log: the `management_server` line disappears and `http://localhost:8080/actuator/health` now responds on the application port. Then try `pyfly.management.server.port: -1`, re-run, and confirm the actuator and admin routes are gone entirely.
diff --git a/book/manuscript/02-dependency-injection.md b/book/manuscript/02-dependency-injection.md
index 9a8bf233..3e56bfbc 100644
--- a/book/manuscript/02-dependency-injection.md
+++ b/book/manuscript/02-dependency-injection.md
@@ -12,6 +12,23 @@ maps the persistence row, and a CQRS handler that depends on both —
and let PyFly wire them together from nothing but type hints.
No factories, no manual `new`, no glue code.
+This chapter is hands-on. We will build each piece in small,
+numbered steps, and after every milestone there is a **Run it**
+checkpoint that shows the exact command to type and the output you
+should see on screen. If your output matches, you are on track; if
+it does not, the gap tells you precisely what to fix. You are
+following along inside `samples/lumen` with PyFly **v26.6.110**
+installed (`uv sync` from the Lumen directory pulls it in).
+
+!!! note "New term: container"
+ Throughout the chapter we talk about *the container*. A
+ **container** is simply the object PyFly creates at startup that
+ knows how to build, connect, and hand out all your application's
+ objects. You never construct it yourself — when you run the app,
+ PyFly stands the container up, fills it with your beans, and keeps
+ it alive until shutdown. Think of it as a smart warehouse that
+ both stores your objects and assembles them on demand.
+
Before a single line of Lumen code appears, it is worth pausing on
*why* that matters. In a conventional Python project you would write
something like:
@@ -44,6 +61,15 @@ owns. You make a class visible to the container by applying a
**stereotype decorator** — a thin annotation that registers the class
and signals its architectural role.
+!!! note "New terms: bean and stereotype"
+ A **bean** is just an instance the container owns — it builds it,
+ supplies its dependencies, and (usually) keeps a single shared copy
+ around. A **stereotype** is the decorator you put on a class to say
+ "container, this is yours, please manage it." The word *stereotype*
+ is borrowed from Spring; it simply means "a labelled role." Putting
+ `@service` on a class is the whole act of registration — there is no
+ separate config file to edit.
+
PyFly ships five stereotypes:
| Decorator | Meaning |
@@ -117,20 +143,118 @@ found?" confusion when adding new subpackages. `@enable_domain_stack`
activates the CQRS, transactional engine, event sourcing, relational
data, and rule-engine auto-configurations in a single line.
+Read that listing as four decisions:
+
+- **Step 1 — name the application.** `name="lumen"` and
+ `version="1.0.0"` become the identity the startup banner and the
+ `/actuator/info` endpoint report. (These are the *application's* name
+ and version; the framework version is separate — v26.6.110 here.)
+- **Step 2 — describe it.** `description=...` is human-facing metadata
+ surfaced in the generated API docs.
+- **Step 3 — list the packages to scan.** Every entry in
+ `scan_packages` is a dotted Python path the container will import and
+ inspect. If a class with a stereotype lives in a package *not* on
+ this list, the container never sees it.
+- **Step 4 — enable the stack.** `@enable_domain_stack` switches on the
+ domain-tier auto-configurations so the CQRS buses, the transactional
+ session, and the relational data layer all exist before your beans
+ are wired.
+
+!!! tip "Tip: scan the package, not the class"
+ `scan_packages` entries are *package* paths (`lumen.web.controllers`),
+ never individual module or class paths. The container walks the
+ package and discovers every stereotype-decorated class inside it.
+ When you add a new handler under `lumen.core.services.wallets`, it is
+ picked up automatically — no edit to `scan_packages` needed. You only
+ touch this list when you introduce a brand-new subpackage.
+
+**Run it.** From the Lumen project root, boot the application and watch
+the container assemble itself:
+
+```bash
+cd samples/lumen
+uv run pyfly run --server uvicorn
+```
+
+You should see the banner followed by structured startup lines — the
+container reporting exactly what it scanned and wired:
+
+```text
+:: PyFly Framework :: (v26.6.110) (Python 3.12.13)
+
+pyfly.core: starting_application | app=lumen version=1.0.0
+pyfly.core: scanned_package | package=lumen.models.repositories beans_found=1
+pyfly.core: scanned_package | package=lumen.core.services.wallets beans_found=7
+pyfly.core: scanned_package | package=lumen.core.services.transfers beans_found=2
+pyfly.core: scanned_package | package=lumen.core.services.listeners beans_found=1
+pyfly.core: scanned_package | package=lumen.web.controllers beans_found=1
+pyfly.core: bean_summary | total=137 services=10 repositories=1 controllers=4 configurations=19
+pyfly.core: server_started | server=uvicorn host=0.0.0.0 port=8080 workers=1
+pyfly.core: application_started | app=lumen startup_time_s=0.143 beans_initialized=137
+```
+
+The `scanned_package` lines are `scan_packages` doing its job: one line
+per entry, each reporting how many beans it found. The final
+`application_started` line — the equivalent of Spring Boot's "Started
+Application in N seconds" — is your signal that the context booted
+cleanly. Press `Ctrl-C` to stop the server.
+
+!!! note "New term: the management port"
+ Two ports appear at startup. Your application's HTTP API listens on
+ `pyfly.server.port` (default **8080**). Operational endpoints — the
+ health check, info, and the admin dashboard — listen separately on
+ the **management port** `pyfly.management.server.port` (default
+ **9090**), which is open and unauthenticated by default. You will
+ not touch either in this chapter, but it is worth knowing why two
+ ports light up. (The older `pyfly.web.port` key was removed in
+ v26.6.102; always use `pyfly.server.port` now.)
+
+**What just happened.** A single command did a lot. PyFly loaded
+`pyfly.yaml`, imported each package in `scan_packages`, found every
+stereotype-decorated class, asked the container to build them in
+dependency order, and reported the totals — 137 beans, of which most
+are framework auto-configuration beans and only a handful are yours so
+far. From this point on, the rest of the chapter is about *adding* to
+that bean count: an entity, a repository, and a command handler, each
+discovered by exactly this mechanism.
+
!!! spring "Spring parity"
`scan_packages` is the equivalent of Spring's
`@ComponentScan(basePackages = {...})`. The semantics are identical:
list every subpackage you want the framework to introspect, and it
- will register everything it finds.
+ will register everything it finds. The `application_started` log line
+ mirrors Spring Boot's "Started Application in N seconds (process
+ running for M)" startup summary.
### The entity and the repository
Lumen stores wallets in a relational database. Two classes carry this
responsibility: `WalletEntity` (the persistence row) and
-`WalletRepository` (the data-access bean).
+`WalletRepository` (the data-access bean). We will build them in order:
+first the row shape, then the data-access bean that reads and writes it.
+
+!!! note "New term: entity"
+ An **entity** is the in-database shape of one record — here, one
+ wallet, stored as one row in a `wallets` table. It is deliberately
+ plain: just typed columns, no behaviour. (Lumen keeps the *rich*
+ domain object, the `Wallet` aggregate, separate; you will meet it in
+ Chapter 6. For now, the entity is simply how a wallet is written to
+ and read from the database.)
**The entity.** `WalletEntity` is a plain SQLAlchemy-mapped class that
-inherits the framework's `Base`:
+inherits the framework's `Base`. Build it field by field:
+
+- **Step 1 — inherit `Base`.** Subclassing PyFly's declarative `Base`
+ is what enrols the class in the ORM's metadata so the framework can
+ create its table.
+- **Step 2 — name the table.** `__tablename__ = "wallets"` is the SQL
+ table this class maps to.
+- **Step 3 — declare the primary key.** `id` is a `str` column marked
+ `primary_key=True` — the wallet keeps its own domain id (`wlt-…`)
+ rather than a generated number.
+- **Step 4 — declare the remaining columns.** `owner_id`, `currency`,
+ `balance_minor` (the balance in minor units — cents — so there is
+ never a floating-point rounding bug), and a `created_at` timestamp.
::: listing lumen/models/entities/v1/wallet_orm.py | Listing 2.2a — WalletEntity: the persistence row
from __future__ import annotations
@@ -167,6 +291,33 @@ Inheriting `Base` (PyFly's declarative base) registers the `wallets`
table in `Base.metadata`; the framework's engine lifecycle creates it
on startup. No further wiring is needed.
+**Run it.** With `ddl-auto: create` set in `pyfly.yaml`, the framework
+builds the schema from your mapped entities the moment the app boots.
+Start the app again and look for the data-layer lines:
+
+```bash
+uv run pyfly run --server uvicorn
+```
+
+```text
+pyfly.data.relational.auto_configuration: Initializing database schema (ddl-auto=create)
+pyfly.data.relational.auto_configuration: Database schema initialized (1 tables)
+```
+
+`1 tables` is your `wallets` table — created purely because
+`WalletEntity` inherits `Base`. There is no migration script to run and
+no `CREATE TABLE` to write by hand. (Lumen uses SQLite by default, so
+the database is just a `lumen.db` file in the project directory.)
+
+!!! note "New term: repository"
+ A **repository** is the object your code talks to when it wants to
+ load or save entities. Instead of writing SQL, you call methods like
+ `find_by_id` or `save`. PyFly's `Repository` base class *generates*
+ those methods for you from the entity and key types, so the
+ repository you write is mostly empty — you declare it, and the
+ framework fills in the implementation. (CRUD, used below, just stands
+ for Create, Read, Update, Delete — the four basic data operations.)
+
**The repository.** `WalletRepository` subclasses the framework's
generic `Repository[WalletEntity, str]`. The two type arguments tell
the framework the *entity type* (`WalletEntity`) and the *primary-key
@@ -233,18 +384,38 @@ maintain: **the framework supplies and injects the implementation;
you depend on the repository class itself by type.**
The three extra methods show the extension points the framework
-exposes on top of the inherited CRUD:
-
-- `find_by_owner_id` is a **derived query** — the
- `RepositoryBeanPostProcessor` parses the method name and compiles
- a real `SELECT … WHERE owner_id = :owner_id` at startup; you write
- the stub (`...`) and the framework fills it in.
-- `find_rich` is a **Specification query** — it composes a reusable
- `Specification` predicate and runs it with pagination and sorting
- via the inherited `find_all_by_spec_paged`.
-- `upsert` is a thin convenience over `session.merge` so a command
- handler can persist an entity whether it is new or already exists
- with a single call.
+exposes on top of the inherited CRUD. Look at them one at a time:
+
+- **Step 1 — a derived query.** `find_by_owner_id` is a **derived
+ query**: the `RepositoryBeanPostProcessor` (a startup component that
+ edits beans after they are built) parses the method *name* and
+ compiles a real `SELECT … WHERE owner_id = :owner_id`. You write only
+ the stub body (`...`); the framework supplies the SQL. The naming
+ convention is the API — `find_by_` becomes
+ `WHERE = ?`.
+- **Step 2 — a Specification query.** `find_rich` composes a reusable
+ `Specification` predicate (here, `balance_at_least`) and runs it with
+ pagination and sorting via the inherited `find_all_by_spec_paged`.
+ Specifications are how you build a composable, type-safe `WHERE`
+ clause when a method name would get unwieldy.
+- **Step 3 — an upsert.** `upsert` is a thin convenience over
+ `session.merge` so a command handler can persist an entity whether it
+ is new (INSERT) or already exists (UPDATE) with a single call. Because
+ the wallet owns its own id, both cases key on the same primary key.
+
+**What just happened.** You declared a repository whose body is almost
+entirely empty, yet it now exposes a complete async CRUD surface plus
+one derived query, one specification query, and an upsert. The
+`@repository` stereotype registered it as a bean; the framework read the
+`Repository[WalletEntity, str]` base, generated the implementation, and
+made it injectable by type. You wrote intent; PyFly wrote the plumbing.
+
+!!! tip "Tip: confirm the repository is registered"
+ Re-run `uv run pyfly run --server uvicorn` and look at the
+ `bean_summary` line: `repositories=1`. That single registered
+ repository is your `WalletRepository`. If you ever add a second
+ repository and it does not appear in this count, the usual cause is
+ that its package is missing from `scan_packages`.
!!! spring "Spring parity"
`@service`, `@component`, `@repository`, and `@configuration` map
@@ -267,6 +438,14 @@ never call constructors yourself. You declare what a class *needs* as
them in automatically. This is **constructor injection**, and it is the
recommended approach for all mandatory dependencies.
+!!! note "New term: injection"
+ **Injection** is the act of the container *handing* a class the
+ objects it depends on, rather than the class building them itself.
+ With *constructor* injection, you simply list the dependencies as
+ typed `__init__` parameters; the container reads those type hints and
+ passes in matching beans when it builds the object. The class never
+ says *how* to obtain its dependencies — only *what* it needs.
+
The mental model is a simple wishlist: list the types you need; the
container delivers the right instances. If a dependency does not exist
at startup, you get a clear `NoSuchBeanError` immediately — not a
@@ -280,8 +459,25 @@ decorators: `@command_handler` (or `@query_handler`) **stacked on
class as a bean; the CQRS decorator adds only routing metadata
(`__pyfly_command_type__` or `__pyfly_query_type__`) so the
command/query bus can dispatch to the right handler. Without `@service`,
-the container never sees the class and the bus raises `NoHandlerError`
-at dispatch time.
+the container never sees the class and the command bus raises
+`CommandHandlerNotFoundException` at dispatch time (the query bus raises
+`QueryHandlerNotFoundException` for a missing query handler).
+
+Before reading the listing, here is the shape of what you are about to
+write, step by step:
+
+- **Step 1 — register the bean.** Put `@service` directly on the class.
+ This is the line that makes the container manage it.
+- **Step 2 — add routing metadata.** Stack `@command_handler` *above*
+ `@service`. It reads `CommandHandler[DepositFunds, int]` and records
+ "this bean handles `DepositFunds` commands."
+- **Step 3 — declare dependencies in `__init__`.** List the repository,
+ the event publisher, and the session factory as typed parameters.
+ This single signature is the complete wiring specification — the
+ container reads it and supplies all three.
+- **Step 4 — write the business logic in `do_handle`.** Wrap it in
+ `@transactional()` so the whole load-mutate-save sequence is one
+ committed unit of work.
The `DepositFundsHandler` shows the pattern in full:
@@ -398,6 +594,35 @@ The container resolves dependencies **recursively**. When it constructs
framework-generated CRUD bean), the `EventPublisher`, and the
`async_sessionmaker` — none of which the handler needs to know about.
+**What just happened.** You declared a handler that needs three
+collaborators and wrote not one line of wiring code. The container read
+the `__init__` type hints, built each dependency (and *their*
+dependencies, recursively), and handed the finished object to whoever
+asks for `DepositFundsHandler`. Swapping the in-memory event bus for
+Kafka later will not touch this class at all — it asks for an
+`EventPublisher` and is content with whatever the container provides.
+
+**Run it.** The surest way to confirm the whole graph wires together is
+the integration test that boots the *real* application context and
+drives a deposit through the command bus. From the Lumen project root:
+
+```bash
+uv run --extra dev pytest tests/test_app_context_integration.py -q
+```
+
+```text
+. [100%]
+1 passed in 0.19s
+```
+
+That single dot is the container proving itself: it scanned the
+packages, generated the repository, resolved the `EventPublisher` and
+session factory, constructed `DepositFundsHandler` with all three
+injected, and ran an `open → deposit → withdraw → reload` lifecycle
+end to end. If a dependency were missing, this test would fail at
+*startup* with a `NoSuchBeanError` long before any assertion ran — which
+is exactly the early, loud failure the container is designed to give you.
+
::: figure art/figures/02-di.svg | Figure 2.1 — The container injects dependencies from type hints.
!!! spring "Spring parity"
@@ -581,10 +806,27 @@ as a single factory. For all of these situations, PyFly provides the
participates fully in the container's resolution and lifecycle
machinery.
+!!! note "New terms: @configuration and @bean"
+ A `@configuration` class is a place to put **factory methods**. A
+ `@bean` method is one such factory: the container *calls* it during
+ startup and registers whatever it returns as a bean. You reach for
+ this pattern when a dependency cannot simply be stereotyped — for
+ example a third-party object you do not own, or one that needs custom
+ construction. The method's **return type annotation** is what the
+ container uses to register the result, so it is mandatory.
+
A `@configuration` class acts as a factory. Its `@bean` methods are
called during the startup sequence, and each method's return value is
registered as a bean whose type comes from the method's return
-annotation:
+annotation. Read the listing below in two steps:
+
+- **Step 1 — mark the class `@configuration`.** This tells the context
+ to scan it for `@bean` methods before any stereotype beans are built.
+- **Step 2 — write a `@bean` method with a return annotation.**
+ `event_publisher(self) -> EventPublisher` constructs an
+ `InMemoryEventBus` and — because the annotation says `EventPublisher`
+ — registers it *as* an `EventPublisher`. Anything that asks for an
+ `EventPublisher` now receives this instance.
::: listing lumen/infra_config.py | Listing 2.4 — Producing an EventPublisher bean via @configuration
from pyfly.container import configuration, bean
@@ -614,6 +856,17 @@ Swapping to a Kafka adapter for production means replacing
`InMemoryEventBus()` with `KafkaEventPublisher(settings.kafka_url)` in
a single method. The rest of the codebase is untouched.
+!!! note "Note: how Lumen actually gets its EventPublisher"
+ The listing above shows the `@configuration` / `@bean` pattern you
+ would write to hand-build a bean. Lumen itself does *not* need this
+ for its event bus: setting `eda.provider: memory` in `pyfly.yaml`
+ asks the framework's EDA auto-configuration to register an
+ `EventPublisher` bean for you (the same `InMemoryEventBus` you see
+ in the `events_is InMemoryEventBus` wiring at startup). That is why
+ `DepositFundsHandler` can simply ask for an `EventPublisher` — the
+ auto-configuration already supplied one. Reach for `@bean` when you
+ need a bean the framework does *not* provide out of the box.
+
`@bean` methods can also declare parameters; the container resolves
them automatically:
@@ -760,6 +1013,26 @@ direct call for synchronous methods.
destroyed in **reverse** initialisation order, so a listener started
after the repository is stopped before it.
+**Run it.** Add the bean above to a scanned package, then start and stop
+the app to watch both hooks fire. The `@post_construct` line appears
+during the startup pass; pressing `Ctrl-C` triggers the
+`@pre_destroy` line:
+
+```bash
+uv run pyfly run --server uvicorn
+```
+
+```text
+... wallet_audit_listener_ready # @post_construct, during startup
+^C
+... shutting_down | app=lumen
+... wallet_audit_listener_shutting_down # @pre_destroy, during shutdown
+```
+
+Seeing `..._ready` *before* `application_started` confirms the hook runs
+as part of the eager startup pass; seeing `..._shutting_down` after the
+`Ctrl-C` confirms the symmetric teardown.
+
::: figure art/figures/02-lifecycle.svg | Figure 2.2 — A bean's lifecycle.
### Conditional beans
diff --git a/book/manuscript/03-configuration.md b/book/manuscript/03-configuration.md
index 34ee1584..3590f94c 100644
--- a/book/manuscript/03-configuration.md
+++ b/book/manuscript/03-configuration.md
@@ -14,7 +14,32 @@ This chapter shows how PyFly solves that with a single `pyfly.yaml`, a four-laye
Every non-trivial application has at least two audiences for its configuration: a developer who wants verbose logs and a relaxed local database, and a production system that demands structured JSON logs, a real connection pool, and no debug mode. The naive solution — `if os.getenv("ENV") == "prod":` scattered through a dozen files — quickly becomes impossible to audit. PyFly's answer is one canonical YAML (or TOML) file that holds everything your application knows about itself, with separate mechanisms for what changes between environments.
-PyFly auto-discovers this file in your project root. The framework checks candidates in order — `pyfly.yaml`, `pyfly.toml`, `config/pyfly.yaml`, `config/pyfly.toml` — and loads the first one it finds. Here is Lumen's base `pyfly.yaml`:
+PyFly auto-discovers this file in your project root. The framework checks candidates in order — `pyfly.yaml`, `pyfly.toml`, `config/pyfly.yaml`, `config/pyfly.toml` — and loads the first one it finds.
+
+!!! note "New term: auto-discovery"
+ "Auto-discovery" simply means you do not have to tell PyFly where the config file is. You drop `pyfly.yaml` in your project root and the framework finds it on startup. No path argument, no registration call.
+
+Let us look at Lumen's base `pyfly.yaml` one section at a time, then read it as a whole.
+
+**Step 1 — Identify the service.** The first block names the application and gives it a version. These two values flow into the startup banner, the health endpoint, and trace metadata, so every part of the system reports the same identity:
+
+```yaml
+pyfly:
+ app:
+ name: lumen
+ version: 1.0.0
+```
+
+**Step 2 — Pick the listen port.** PyFly's business API listens on `pyfly.server.port`. The default is `8080`, so this line is technically redundant — it is written out for clarity. (If you have read older PyFly material that mentioned `pyfly.web.port`, note that key was removed in v26.06.102; use `pyfly.server.port` now.)
+
+```yaml
+ server:
+ port: 8080
+```
+
+**Step 3 — Turn on the domain features Lumen uses.** The remaining blocks switch on observability, CQRS, the transactional engine, event sourcing, caching, the in-memory event bus, and the relational data layer. Each block is one feature; you enable what you need and leave the rest at their framework defaults.
+
+Here is the complete file. Notice that `pyfly:` is the only top-level key — everything Lumen tells the framework lives under it:
::: listing pyfly.yaml | Listing 3.1 — Lumen's base configuration file
pyfly:
@@ -23,7 +48,8 @@ pyfly:
version: 1.0.0
banner:
mode: console
- web:
+ server:
+ # App on 8080; actuator + admin default to the management port 9090.
port: 8080
observability:
metrics:
@@ -40,14 +66,16 @@ pyfly:
enabled: true
cache:
provider: in-memory
- # Event-Driven Architecture: in-memory bus (no broker needed).
- # The EventPublisher bean that wallet command handlers publish
- # through — and that @event_listener projections subscribe to —
- # is activated by setting provider to "memory".
+ # Event-Driven Architecture: the in-memory bus (no broker needed).
+ # Setting the provider registers the EventPublisher bean that the
+ # wallet command handlers publish domain events through and that the
+ # @event_listener audit projection auto-subscribes to.
eda:
provider: memory
- # Relational data layer (SQLAlchemy + SQLite via aiosqlite).
- # The framework creates the schema on startup (ddl-auto=create).
+ # Relational data layer (SQLAlchemy + SQLite via aiosqlite). The
+ # framework creates the schema on startup (ddl-auto=create) and backs
+ # the WalletRepository (a framework Repository[WalletEntity, str]) that
+ # the command handlers persist through inside @transactional().
data:
relational:
enabled: true
@@ -55,21 +83,48 @@ pyfly:
ddl-auto: create
:::
+!!! note "Run it"
+ From the Lumen project root, start the app and watch which file the framework loads:
+
+ ```bash
+ cd samples/lumen
+ uv sync
+ uv run pyfly run
+ ```
+
+ The startup banner prints the framework version, then PyFly logs each
+ configuration source it merged (one `loaded_config` line per layer) before the
+ application binds to port `8080`:
+
+ ```
+ :: PyFly Framework :: (v26.06.110) (Python 3.12.13)
+ Copyright 2026 Firefly Software Foundation. | Apache License 2.0
+ no_active_profiles message=No active profiles set, falling back to default
+ loaded_config source=pyfly-defaults.yaml (framework defaults)
+ loaded_config source=.../samples/lumen/pyfly.yaml
+ Uvicorn running on http://0.0.0.0:8080
+ ```
+
+ Leave it running; you will hit it with `curl` shortly. Press `Ctrl+C` to stop.
+
Three things are worth noting. First, the `pyfly:` top-level key is reserved exclusively for framework settings — web server, observability, CQRS, EDA, data access, and profiles all live there. Your own application keys go under a different top-level name (such as `lumen:`). Second, `pyfly.app.name` and `pyfly.app.version` identify the service throughout — startup banner, health endpoints, and trace metadata all read these values. Third, the `pyfly.data.relational.*` block configures the SQLAlchemy/aiosqlite layer; `url`, `ddl-auto`, and `enabled` are its three core keys.
-Nested keys map directly to dot-notation access through `Config.get()`:
+Nested keys map directly to dot-notation access through `Config.get()`. To read a nested value, you join the key path with dots — `pyfly.server.port` walks `pyfly:` then `server:` then `port:`:
```python
-config.get("pyfly.app.name") # "lumen"
-config.get("pyfly.server.port") # 8080
-config.get("pyfly.data.relational.url") # "sqlite+aiosqlite:///./lumen.db"
-config.get("pyfly.eda.provider") # "memory"
+config.get("pyfly.app.name") # "lumen"
+config.get("pyfly.server.port") # 8080
+config.get("pyfly.data.relational.url") # "sqlite+aiosqlite:///./lumen.db"
+config.get("pyfly.eda.provider") # "memory"
```
`Config.get()` uses **relaxed segment matching**: `ddl-auto` and `ddl_auto` resolve to the same key. Your YAML can use kebab-case (the conventional YAML style) and your Python code can use snake_case — no need to remember which form you used in the file.
PyFly uses `PyYAML` (`yaml.safe_load`) for YAML parsing; native YAML types are preserved. The integer `8080` in YAML arrives as a Python `int` — no string parsing required.
+!!! note "What just happened"
+ You wrote one YAML file, and PyFly turned it into a typed, queryable configuration object. The `pyfly:` block told the framework which features to switch on (data layer, CQRS, event bus); `Config.get("…")` reads any value back out by its dotted path; and the relaxed matching means you never have to remember whether you wrote `ddl-auto` or `ddl_auto`. That single object is the source of truth the rest of this chapter builds on.
+
!!! tip "Tip"
You can also use TOML if your team prefers INI-like syntax with strict typing. Rename the file to `pyfly.toml` and use TOML table syntax — `[pyfly.web]`, `[pyfly.data.relational]` — instead of YAML nesting. Every feature described in this chapter works identically with both formats.
@@ -140,6 +195,9 @@ The layering system provides the mechanism for varying configuration between env
A **profile** is a named environment variant — `dev`, `test`, `staging`, `prod`. Activating a profile loads an overlay file and can conditionally include or exclude beans.
+!!! note "New term: overlay file"
+ An "overlay" is a small YAML file that contains *only the keys that change* for one environment. PyFly merges it on top of the base `pyfly.yaml`. You never repeat the full configuration — you just state the differences, and the deep merge from the previous section fills in everything else.
+
### Activating profiles
PyFly must know which profiles are active *before* loading the full configuration, because it needs to know which overlay files to merge. This **early profile resolution** follows a deliberate priority order:
@@ -151,12 +209,14 @@ PyFly must know which profiles are active *before* loading the full configuratio
In production, override with an env var — no code change, no file edit:
```bash
-PYFLY_PROFILES_ACTIVE=prod python main.py
+PYFLY_PROFILES_ACTIVE=prod uv run pyfly run
```
### Profile overlay files
-For each active profile `{name}`, PyFly looks for `pyfly-{name}.yaml` next to the base file. Lumen ships three overlays, each containing only the keys that differ from the base:
+For each active profile `{name}`, PyFly looks for `pyfly-{name}.yaml` next to the base file. We will add three overlays to Lumen, each containing only the keys that differ from the base. Build them one at a time.
+
+**Step 1 — The development overlay.** Create `pyfly-dev.yaml` alongside `pyfly.yaml`. Dev wants the loudest possible feedback loop: full tracebacks, every SQL query echoed to the terminal, and framework internals visible at `DEBUG`.
::: listing pyfly-dev.yaml | Listing 3.2 — Development overlay: verbose logging, debug mode
pyfly:
@@ -172,6 +232,8 @@ pyfly:
Three keys cover everything the dev environment needs: debug mode for detailed tracebacks, SQL echo so every query appears in the terminal, and `DEBUG` log level so framework internals are visible. Everything else comes unchanged from the base file. Note that `echo` lives under `pyfly.data.relational.*`, consistent with the base file structure.
+**Step 2 — The test overlay.** Create `pyfly-test.yaml`. The test environment wants the opposite of dev: quiet. Silence the banner so test output stays readable, turn off real persistence (unit tests mock the repository), and raise the log threshold so passing tests print nothing.
+
::: listing pyfly-test.yaml | Listing 3.3 — Test overlay: in-memory SQLite, silent banner
pyfly:
banner:
@@ -186,6 +248,8 @@ pyfly:
The test overlay silences the startup banner so test output stays clean, disables data persistence (unit tests mock the repository layer), and raises the log threshold to `WARNING` so passing tests produce no noise.
+**Step 3 — The production overlay.** Create `pyfly-prod.yaml`. Production flips many switches at once: a real PostgreSQL URL, structured JSON logs, the interactive docs turned off, and a quiet banner.
+
::: listing pyfly-prod.yaml | Listing 3.4 — Production overlay: real database, JSON logging, docs off
pyfly:
server:
@@ -208,6 +272,25 @@ pyfly:
The production overlay makes several deliberate choices. It disables the interactive API docs — you do not want a live Swagger UI on a production endpoint. It switches logging to `json` format so aggregators like Datadog or CloudWatch can parse structured fields rather than scraping human-readable text. It points `pyfly.data.relational.url` at the real PostgreSQL instance. It sets `pyfly.server.port: 443`, though in practice you will override this with `PYFLY_SERVER_PORT` from your deployment pipeline so no topology details enter the repository.
+!!! note "Run it"
+ With the three overlay files in place, activate the dev profile and watch the merge happen. The `PYFLY_PROFILES_ACTIVE` environment variable tells PyFly which overlays to load before it reads the rest of the config:
+
+ ```bash
+ PYFLY_PROFILES_ACTIVE=dev uv run pyfly run
+ ```
+
+ The startup log now lists the dev overlay among the merged sources, and because dev sets `pyfly.data.relational.echo: true`, every SQL statement appears in the terminal as soon as the app touches the database:
+
+ ```
+ active_profiles profiles=['dev']
+ loaded_config source=pyfly-defaults.yaml (framework defaults)
+ loaded_config source=.../samples/lumen/pyfly.yaml
+ loaded_config source=.../samples/lumen/pyfly-dev.yaml (profile: dev)
+ INFO sqlalchemy.engine.Engine BEGIN (implicit)
+ ```
+
+ Stop the app, run it again *without* the env var, and the dev overlay disappears from the source list — the base file's quieter defaults take over. That is the whole profile mechanism in one experiment: one env var swaps an entire layer in and out.
+
!!! tip "Tip"
Multiple profiles are comma-separated in the env var and are applied in order, so the last profile wins on conflicts: `PYFLY_PROFILES_ACTIVE=prod,metrics` first applies `pyfly-prod.yaml`, then `pyfly-metrics.yaml`. Use this to compose cross-cutting concerns — a `metrics` profile can enable Prometheus scraping without duplicating your entire prod config.
@@ -233,8 +316,14 @@ class VerboseAuditLogger:
...
```
+!!! note "New term: bean"
+ A "bean" is any object the DI container creates and manages for you — a `@service`, `@repository`, `@command_handler`, and so on (the term comes straight from Spring). "Profile-scoped" means the container only creates the bean when a matching profile is active.
+
`Environment.accepts_profiles()` evaluates profile expressions during the first pass of `ApplicationContext.start()`. Beans whose expression does not match the active set are removed before any resolution takes place — never instantiated, never wired, never present in the container. The result is a container that is structurally different per environment, with no `if` statement in your application code.
+!!! note "What just happened"
+ Profiles gave you two independent tools. The *overlay files* (`pyfly-{name}.yaml`) change configuration *values* per environment. The `profile=` parameter on a stereotype changes which *beans exist* per environment. Both are driven by the same active-profile set — set once via `PYFLY_PROFILES_ACTIVE` — so a single env var reshapes both your settings and your object graph, with zero conditional logic in your business code.
+
---
## Type-safe settings with @config_properties
@@ -243,9 +332,14 @@ String-key lookups like `config.get("pyfly.data.relational.url")` work for occas
`@config_properties` solves exactly this by binding a config section to a typed Python dataclass.
+!!! note "New term: binding"
+ "Binding" means copying values out of the config tree into the fields of a typed object. A Python `@dataclass` is a class whose fields are declared with type hints (`url: str`, `pool_size: int`); after binding, you read `props.pool_size` and get a real `int`, not a string you have to convert yourself.
+
### Declaring a properties class
-Decorate a `@dataclass` with `@config_properties(prefix="...")`. The `prefix` identifies the config section to bind; field names must match the keys under that section (kebab/snake interchangeable). Here is the framework's own `RelationalProperties`, which binds the `pyfly.data.relational.*` block:
+Decorate a `@dataclass` with `@config_properties(prefix="...")`. The `prefix` identifies the config section to bind; field names must match the keys under that section (kebab/snake interchangeable).
+
+**Step 1 — Write the class.** Here is the framework's own `RelationalProperties`, which binds the `pyfly.data.relational.*` block:
::: listing pyfly/config/properties/data.py | Listing 3.5 — RelationalProperties: typed settings for the data layer
from dataclasses import dataclass
@@ -268,7 +362,7 @@ The decorator sets `__pyfly_config_prefix__` on the class and marks it as an inj
Notice that each field carries a default value matching the framework's built-in `pyfly-defaults.yaml`. This is intentional: the class is self-documenting, and it can be constructed and used in unit tests without any YAML file on disk — just instantiate `RelationalProperties()` and you get the development defaults.
-Apply the same pattern to your own application-level settings. Here is how a `WalletProperties` class would look for Lumen's business rules:
+**Step 2 — Apply the pattern to your own settings.** The same decorator works for application-level configuration. Here is how a `WalletProperties` class would look for Lumen's business rules:
```python
from dataclasses import dataclass
@@ -282,7 +376,14 @@ class WalletProperties:
default_currency: str = "USD"
```
-Add the matching block to `pyfly.yaml` under the `lumen:` top-level key (outside `pyfly:`) and the framework binds it automatically — no special registration required.
+**Step 3 — Add the matching YAML.** Put the block under the `lumen:` top-level key (outside `pyfly:`, since this is your application's own namespace, not the framework's) and the framework binds it automatically — no special registration required:
+
+```yaml
+lumen:
+ wallet:
+ daily-transfer-limit: 10000.0
+ default-currency: USD
+```
### Binding and injecting
@@ -331,6 +432,27 @@ When the DI container starts Lumen, `WalletService.__init__` receives the shared
The critical detail in step 2 is that `effective_section()` applies the full four-layer stack — defaults, file, profile overlay, env vars — before the dataclass is constructed. By the time `bind()` finishes, `RelationalProperties` reflects whatever the production overlay or a runtime env var says, not just the base YAML.
+!!! note "What just happened"
+ You replaced scattered `config.get("…")` string lookups with a single typed object. `config.bind(RelationalProperties)` reads the whole `pyfly.data.relational.*` section once, applies the four-layer precedence, coerces strings to the right types, and hands back a plain dataclass. From then on your service reads `self.db.enabled` as a `bool` with IDE autocompletion — and a typo in a field name is caught at startup, not at request time in production.
+
+!!! note "Run it"
+ You can prove the env-var layer reaches a bound property with a tiny check. The base `pyfly.yaml` sets `pyfly.data.relational.enabled: true`; here we override it from the environment. From the Lumen project root:
+
+ ```bash
+ PYFLY_DATA_RELATIONAL_ENABLED=false uv run python -c "
+ from pyfly.core import Config
+ from pyfly.config.properties import RelationalProperties
+ db = Config.from_file('pyfly.yaml').bind(RelationalProperties)
+ print('enabled =', db.enabled, type(db.enabled).__name__)
+ "
+ ```
+
+ Expected output — the env var wins over the YAML and the string `"false"` is coerced to a `bool`:
+
+ ```
+ enabled = False bool
+ ```
+
### Injecting individual values with Value
For isolated settings that do not warrant a full properties class, PyFly provides a `Value` descriptor. Declare it as a class-level field and the DI container resolves it at bean creation time — exactly like Spring Boot's `@Value("${...}")`:
@@ -360,9 +482,11 @@ Native YAML types arrive correctly typed — integers, booleans, and floats need
|---|---|
| `int` | `int(value)` |
| `float` | `float(value)` |
-| `bool` | `value.lower() in ("true", "1", "yes", "on")` |
+| `bool` | `value.lower() in ("true", "1", "yes")` |
| `str` | no coercion needed |
+The `bind()` dataclass path treats `"true"`, `"1"`, and `"yes"` as `True`; the read-time `get()`/env override path additionally accepts `"on"`.
+
Calling `bind()` on a class not decorated with `@config_properties` raises `ValueError` immediately — a clear fail-fast signal at startup rather than a silent wrong-value bug at request time.
!!! spring "Spring parity"
@@ -413,7 +537,7 @@ Activating the production profile and overriding the database URL for a specific
PYFLY_PROFILES_ACTIVE=prod \
PYFLY_DATA_RELATIONAL_URL="postgresql+asyncpg://rds-prod:5432/lumen" \
PYFLY_SERVER_PORT=8080 \
- python main.py
+ uv run pyfly run
```
Here, `PYFLY_SERVER_PORT=8080` overrides the prod overlay's `port: 443`. The precedence stack resolves like this:
@@ -441,13 +565,16 @@ PYFLY_SECURITY_JWT_SECRET="$(vault kv get -field=jwt_secret secret/lumen)"
`Config.bind()` also handles values that exist *only* as environment variables — no matching entry in any YAML file. `effective_section()` injects these env-only keys into the bound section so `bind()` sees the same value that `get()` would. Add a new field to a `@config_properties` class, set it exclusively via an env var in your deployment pipeline, and it is populated correctly even when the YAML files have not been updated yet:
```bash
-# No YAML entry for pyfly.data.relational.pool-size?
+# No YAML entry for pyfly.data.relational.echo?
# Set it exclusively via env var — bind() still picks it up.
-PYFLY_DATA_RELATIONAL_POOL_SIZE=20 python main.py
+PYFLY_DATA_RELATIONAL_ECHO=true uv run pyfly run
```
This is a practical escape hatch during incremental rollouts: the deploying team can inject a new value before the YAML file is updated and reviewed, and the application picks it up without a code change.
+!!! warning "Multi-word field names and env-only injection"
+ Env-only injection treats each underscore in a `PYFLY_*` name as a path separator, so `PYFLY_DATA_RELATIONAL_POOL_SIZE` is read as the nested path `pool` → `size`, not the flat field `pool_size`. For a single-word field like `echo` this is unambiguous and `bind()` injects it cleanly. For a multi-word field such as `pool_size`, give the key a real home in your YAML (even just `pool-size: 5`) so the env var overrides an existing leaf instead of relying on env-only injection. Read-time `config.get("pyfly.data.relational.pool-size")` always returns the env value regardless, because `get()` maps the whole dotted key in one step.
+
---
## What you built {.recap}
@@ -482,15 +609,46 @@ adapter (Granian, Uvicorn, Hypercorn). Two values change the topology: set
single port (the pre-`v26.06.102` behaviour), or set it to **`-1`** to disable the
management web endpoints entirely.
+!!! warning "The management port is open by default"
+ For Spring Boot parity, the management port (9090) is **open and
+ unauthenticated** by default — it is meant to live on an internal network
+ protected by network isolation, not by the app's login filters. Before you
+ expose it anywhere reachable, secure it with
+ `pyfly.management.security.enabled: true`, which applies the app's security,
+ session, and CSRF filters to the management port too. By default only the
+ `health` and `info` actuator endpoints are exposed over HTTP; widen that with
+ `pyfly.management.endpoints.web.exposure.include` (for example
+ `"health,info,metrics,prometheus"`).
+
+!!! note "Run it"
+ Start Lumen and hit the management port directly. The health endpoint lives on
+ 9090, not on the application's 8080:
+
+ ```bash
+ uv run pyfly run # in one terminal
+ curl http://localhost:9090/actuator/health # in another
+ ```
+
+ Expected output — a small JSON document reporting the service is up:
+
+ ```json
+ {"status":"UP"}
+ ```
+
+ The same path on the app port (`curl http://localhost:8080/actuator/health`)
+ will not answer, because the actuator listens only on the management port.
+
!!! spring "Spring parity"
`pyfly.server.port` ≡ Spring `server.port`, `pyfly.server.host` ≡
`server.address`, and `pyfly.management.server.port` ≡
`management.server.port`. Setting a distinct management port runs the actuator
- on its own connector, exactly as Spring Boot does.
+ on its own connector, exactly as Spring Boot does. `pyfly.management.security.enabled`
+ and `pyfly.management.endpoints.web.exposure.include` mirror Spring's
+ management security and `management.endpoints.web.exposure.include` settings.
## Try it yourself {.exercises}
-1. **Add a staging overlay.** Create `pyfly-staging.yaml` with a PostgreSQL URL for a shared test database under `pyfly.data.relational.url`, `pyfly.data.relational.enabled: true`, and logging at `INFO`. Activate it with `PYFLY_PROFILES_ACTIVE=staging python main.py` and verify from the startup log that the staging source was loaded. Compare the effective configuration to what the prod overlay would produce.
+1. **Add a staging overlay.** Create `pyfly-staging.yaml` with a PostgreSQL URL for a shared test database under `pyfly.data.relational.url`, `pyfly.data.relational.enabled: true`, and logging at `INFO`. Activate it with `PYFLY_PROFILES_ACTIVE=staging uv run pyfly run` and verify from the startup log that the staging source was loaded. Compare the effective configuration to what the prod overlay would produce.
2. **Bind a new typed property and use it.** Add a `max_wallets_per_owner: int = 5` field to a new `WalletProperties` class decorated with `@config_properties(prefix="lumen.wallet")`, and a matching `lumen.wallet.max-wallets-per-owner: 5` key in `pyfly.yaml` (outside the `pyfly:` block). Inject `Config` into `WalletService`, call `config.bind(WalletProperties)`, and add a guard in `open_wallet` that raises `ValueError` when the owner already holds the maximum number of wallets. Write a quick test that overrides the limit to `1` by setting `PYFLY_LUMEN_WALLET_MAX_WALLETS_PER_OWNER=1` and verifying the error fires on the second wallet.
diff --git a/book/manuscript/04-first-http-api.md b/book/manuscript/04-first-http-api.md
index 1970d20e..384e3c3a 100644
--- a/book/manuscript/04-first-http-api.md
+++ b/book/manuscript/04-first-http-api.md
@@ -27,6 +27,16 @@ manages and the web layer routes requests into. Two decorators mark it:
stereotype; `@request_mapping` from `pyfly.web` sets the URL prefix inherited by
every handler in the class.
+A few terms in that paragraph will recur throughout the chapter, so let us pin
+them down once. A **bean** is simply an object the DI container creates and
+hands out for you — you never call `WalletController()` yourself; the framework
+constructs it and keeps a single shared instance. A **stereotype** is a label
+the framework stamps on a class so it knows *what kind* of bean it is —
+`@rest_controller` stamps "this is a web controller", which is the cue the
+startup machinery uses to go looking for routes inside it. A **handler** is one
+`async def` method on the controller that answers one kind of request. With
+those three words in hand, the rest of the chapter reads as plain English.
+
Route handlers are plain `async def` methods, each decorated with
`@get_mapping`, `@post_mapping`, `@put_mapping`, `@patch_mapping`, or
`@delete_mapping`. Every mapping decorator accepts an optional relative path and
@@ -191,6 +201,90 @@ on success. `@get_mapping("/{wallet_id}")` maps `GET /api/v1/wallets/{wallet_id}
Paths are concatenated at startup; duplicate or trailing slashes are normalised
automatically.
+### Building the controller, step by step
+
+If you are typing this in from scratch, the listing above lands all at once.
+Here is the same controller assembled in the order you would actually build it,
+so each decorator has a job to do before the next one arrives.
+
+**Step 1 — Create the file and the class.** Make
+`src/lumen/web/controllers/wallet_controller.py` and define an empty class
+decorated with the two class-level decorators. This is enough for PyFly to
+discover the controller at startup, even before it has a single route.
+
+```python
+from pyfly.container import rest_controller
+from pyfly.web import request_mapping
+
+
+@rest_controller
+@request_mapping("/api/v1/wallets")
+class WalletController:
+ """Digital-wallet REST API: open, deposit, inspect."""
+```
+
+**Step 2 — Add the first handler.** Give the class one `async def` method and
+mark it with a mapping decorator. `@post_mapping("", status_code=201)` maps
+`POST /api/v1/wallets` — the empty path means "the base path with nothing
+appended" — and promises a `201 Created` on success.
+
+**Step 3 — Add the remaining handlers.** Repeat the pattern: one `async def`
+per route, each with its own mapping decorator and relative path. The full set
+in Listing 4.1 gives you open, fetch, balance, deposit, and list.
+
+**Step 4 — Wire the store.** The module-level `_wallets` dictionary is the only
+"database" Part I needs. Each handler reads from and writes to it directly;
+Chapter 5 swaps it for a real repository without touching a single decorator.
+
+!!! note "Note"
+ Notice what you did *not* write: no router file mapping URLs to functions,
+ no registration call in `main.py`, no manual OpenAPI entry. The decorators
+ are the registration. At startup `ControllerRegistrar` finds every
+ `@rest_controller` bean and mounts its routes for you.
+
+!!! tip "Run it"
+ Start the server and confirm the routes are live. From the project root:
+
+ ```bash
+ uv run pyfly run --server uvicorn
+ ```
+
+ The boot banner reports the framework version and the bound port:
+
+ ```
+ :: PyFly Framework :: (v26.06.110) (Python 3.13.13)
+ ```
+
+ In a second terminal, open a wallet and read it back:
+
+ ```bash
+ curl -s -X POST localhost:8080/api/v1/wallets \
+ -H 'Content-Type: application/json' \
+ -d '{"owner_id": "alice", "currency": "EUR"}'
+ ```
+
+ You should see a `201` body with the generated id:
+
+ ```json
+ {"wallet_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"}
+ ```
+
+ Copy that id and fetch the wallet:
+
+ ```bash
+ curl -s localhost:8080/api/v1/wallets/a1b2c3d4-e5f6-7890-abcd-ef1234567890
+ ```
+
+ ```json
+ {"id": "a1b2c3d4-...", "owner_id": "alice", "currency": "EUR",
+ "balance_minor": 0, "balance": 0.0, "created_at": "2026-06-15T10:30:00+00:00"}
+ ```
+
+**What just happened.** Two class-level decorators registered the controller and
+fixed its URL prefix; one method-level decorator turned an `async def` into a
+live route; the framework did the routing, JSON serialisation, and status-code
+handling. You wrote business intent, not plumbing.
+
::: figure art/figures/04-request.svg | Figure 4.1 — How a request flows to your handler.
!!! spring "Spring parity"
@@ -213,8 +307,15 @@ from**.
This approach makes handler signatures self-documenting. The parameter list of
any handler tells you exactly which parts of the request it reads and what types
-it expects — without opening a router file or consulting the docs. The
-`ParameterResolver` inspects each handler signature at startup and builds a
+it expects — without opening a router file or consulting the docs.
+
+In plain terms, **binding** is the framework copying a piece of the incoming
+request into one of your handler's parameters, converting it to the type you
+asked for along the way. You declare *what you want and where it comes from* with
+a type annotation; PyFly does the extracting, parsing, and type-coercion before
+your method body runs.
+
+The `ParameterResolver` inspects each handler signature at startup and builds a
resolution plan, so there is zero overhead per request for introspection. Five
binding types cover every part of an HTTP request:
@@ -262,6 +363,21 @@ admit `None`. A missing required `QueryParam` raises `InvalidRequestException`
(HTTP 400). To make a parameter optional, give it a default value or annotate it
`QueryParam[str | None]`.
+!!! tip "Run it"
+ With the server running and at least one wallet opened, exercise the
+ `list_wallets` handler — first with no filter, then with the optional
+ `owner_id` query parameter:
+
+ ```bash
+ curl -s 'localhost:8080/api/v1/wallets'
+ curl -s 'localhost:8080/api/v1/wallets?owner_id=alice'
+ ```
+
+ The first returns every wallet; the second returns only Alice's. Because
+ `owner_id` has a default of `None`, omitting it is perfectly valid — no 400.
+ The path variable behaves the same way in reverse: ask for a wallet id that
+ does not exist and you get a clean `404`, which the next section dissects.
+
### Body[T] — request body
Deserialises the JSON (or XML) request body. When `T` is a Pydantic `BaseModel`,
@@ -300,6 +416,13 @@ query parameter.
`T | None` = optional. The rule is uniform across `QueryParam`, `Header`,
and `Cookie` — you learn it once, it applies everywhere.
+**What just happened.** You learned the whole binding vocabulary as five
+parallel annotations — `PathVar`, `QueryParam`, `Body`, `Header`, `Cookie` —
+that all read like English in a handler signature and all share one
+required-vs-optional rule. The framework reads the annotation, pulls the value
+from the right place, coerces it to your type, and hands you a ready-to-use
+argument. There is nothing else to wire.
+
---
## Validation with Valid[T]
@@ -315,10 +438,20 @@ constraints for free. `Valid[T]` is PyFly's marker that converts a Pydantic
`ValidationError` into a **structured 422 response** instead of letting it
bubble up to a 500.
+A quick gloss before the code. A **DTO** — Data Transfer Object — is a small
+class that describes the *shape* of data crossing the wire: what fields a request
+must carry, or what fields a response will return. Lumen's DTOs are plain
+Pydantic models, so the field declarations double as validation rules.
+**Validation** is the act of checking incoming data against those rules and
+rejecting it cleanly if it does not fit — before any of your handler code runs.
+
### Pydantic DTOs for Lumen
The request and response DTOs used in Lumen's wallet API live under
-`lumen/interfaces/dtos/v1/` — one file per DTO. Here they are in full.
+`lumen/interfaces/dtos/v1/` — one file per DTO. The directory name encodes a
+convention worth noting: `interfaces` holds the contracts the outside world sees,
+and `v1` versions them so a future `v2` payload shape can live alongside the old
+one without breaking existing clients. Here they are in full.
::: listing lumen/interfaces/dtos/v1/open_wallet_request.py | Listing 4.2a — OpenWalletRequest: wallet-opening payload
from __future__ import annotations
@@ -467,6 +600,21 @@ Content-Type: application/json
{"owner_id": ""}
```
+!!! tip "Run it"
+ With the server running, send the bad payload and watch for the `422`:
+
+ ```bash
+ curl -s -w '\nHTTP %{http_code}\n' -X POST localhost:8080/api/v1/wallets \
+ -H 'Content-Type: application/json' \
+ -d '{"owner_id": ""}'
+ ```
+
+ The `-w '\nHTTP %{http_code}\n'` flag prints the status line after the body,
+ so you can confirm it is `HTTP 422` — not the `201` a valid request returns,
+ and not a `500`. The body is the structured envelope shown below. Try a
+ second variant — `-d '{"owner_id": "alice", "currency": "XYZ"}'` — to see the
+ `Currency` enum reject an unknown code with the same envelope shape.
+
The response is HTTP 422:
```json
@@ -515,6 +663,13 @@ Use `Valid[Body[T]]` for every endpoint that accepts user input.
422 response shape (field-level errors with location paths) mirrors Spring
Boot 3's `MethodArgumentNotValidException` payload.
+**What just happened.** The validation rules never left the DTO. `Field(min_length=1)`
+on `owner_id`, the `Currency` enum, and `Field(gt=0)` on `amount` are the entire
+specification — and wrapping the body in `Valid` turned any breach of those rules
+into a predictable, machine-readable `422` before your handler ran. You wrote
+constraints once, on the data; the framework enforced them everywhere the data
+arrives.
+
---
## Errors that clients can trust
@@ -596,11 +751,32 @@ response:
}
```
+!!! tip "Run it"
+ Ask for a wallet that was never opened and watch the framework turn your
+ `raise` into a clean `404`:
+
+ ```bash
+ curl -s -w '\nHTTP %{http_code}\n' localhost:8080/api/v1/wallets/w-999
+ ```
+
+ The status line reads `HTTP 404` and the body is the envelope above, with
+ `"code": "WALLET_NOT_FOUND"` and your `context` carried through verbatim. You
+ never wrote a status code in `get_wallet` — `ResourceNotFoundException` maps
+ to 404 for you. Note the `transaction_id` in the response; copy it and grep
+ your server log to find the exact request.
+
The `transaction_id` is free: the `TransactionIdFilter` assigns a UUID to every
request and threads it through all error responses. Clients log it; support uses
it to find the corresponding server log entry. A single ID is all that is needed
to reconstruct what happened.
+**What just happened.** Your handler expressed a domain fact — "this wallet does
+not exist" — by raising a typed exception with a message, a code, and some
+context. The web layer's global handler did the HTTP translation: it picked the
+status code from the exception's class, wrapped everything in the standard error
+envelope, and stamped a `transaction_id`. HTTP concerns stayed out of your
+business code entirely.
+
!!! note "RFC 7807"
The default error envelope — `{"error": {...}}` — is PyFly's own format. If
your team prefers the IETF standard, set
@@ -660,16 +836,41 @@ As soon as Lumen starts, three documentation endpoints are live at no cost:
The `OpenAPIGenerator` introspects `ControllerRegistrar`'s route metadata —
every path, method, path variable, query parameter, and request/response schema
(from Pydantic model introspection) — and assembles the spec at startup. You
-never write the spec by hand. Disable it in production with
-`pyfly.web.docs.enabled: false` in `pyfly.yaml`.
-
-!!! tip "Tip"
- Open `http://localhost:8080/docs` while Lumen is running. You will see
+never write the spec by hand. The docs endpoints live on the **application**
+port (8080) alongside your API; they are on by default (`pyfly.web.docs.enabled:
+true`). Disable them in production with `pyfly.web.docs.enabled: false` in
+`pyfly.yaml`.
+
+!!! note "Note"
+ Do not confuse the docs endpoints with the **admin dashboard**. `/docs`,
+ `/redoc`, and `/openapi.json` describe *your* API and serve on the app port
+ (8080). The PyFly Admin Dashboard (`/admin`) and the actuator health
+ endpoints (`/actuator/*`) describe the *running process* and serve on the
+ separate **management** port (`pyfly.management.server.port`, default 9090),
+ introduced in Chapter 3. They are two different listeners with two different
+ audiences.
+
+!!! tip "Run it"
+ With the server running, fetch the raw spec and confirm your routes are in
+ it:
+
+ ```bash
+ curl -s localhost:8080/openapi.json | head -c 200
+ ```
+
+ You will see the OpenAPI 3.0 header and the start of the `paths` map. Then
+ open `http://localhost:8080/docs` in a browser. You will see
`POST /api/v1/wallets`, `GET /api/v1/wallets/{wallet_id}`,
`POST /api/v1/wallets/{wallet_id}/deposit`, and the others — each with the
correct request and response schemas derived from your Pydantic models, and
- the `owner_id` query parameter on `list_wallets` already documented with
- its type and default.
+ the `owner_id` query parameter on `list_wallets` already documented with its
+ type and default. Click "Try it out" on `POST /api/v1/wallets` to open a
+ real wallet straight from the browser.
+
+**What just happened.** You did not write a line of API documentation, yet a
+complete, interactive, always-accurate spec appeared. The same route and model
+metadata that drives request handling also drives the docs, so the two can never
+drift apart.
---
@@ -681,8 +882,10 @@ different trade-offs in throughput, HTTP version support, OS compatibility, and
ecosystem tooling. Locking an application to a single server at the framework
level forces you to accept those trade-offs permanently.
-PyFly does not hardcode an ASGI server. At startup, `ServerAutoConfiguration`
-runs a cascading selection based on what is installed:
+An **ASGI server** is the process that actually accepts TCP connections, parses
+HTTP, and calls your application — the layer between the operating system's
+socket and your handlers. PyFly does not hardcode one. At startup,
+`ServerAutoConfiguration` runs a cascading selection based on what is installed:
| Priority | Server | Characteristic |
|---|---|---|
@@ -699,15 +902,31 @@ pyfly run --server uvicorn --reload # development: auto-reload
pyfly run --server granian --workers 4 # production: multi-worker
```
+!!! tip "Run it"
+ For day-to-day development, run with auto-reload so the server restarts on
+ every save:
+
+ ```bash
+ uv run pyfly run --reload
+ ```
+
+ PyFly logs the chosen server and the bound port at startup. Because
+ `--reload` requires a built-in file watcher, PyFly selects **Uvicorn** for
+ reload mode regardless of the cascade order. Edit a handler, save, and watch
+ the log report the restart — then re-run any `curl` from earlier and see your
+ change live without stopping the process.
+
The event loop is pluggable too: `uvloop` (Linux/macOS) and `winloop` (Windows)
are selected automatically when installed, delivering a 2–4× throughput
improvement over the asyncio default. Install them with `uv add "pyfly[web-fast]"`.
!!! tip "Tip"
For development, `pyfly run --reload` is all you need — it picks the best
- available server and event loop automatically. For production,
- `pyfly run --server granian --workers 0` resolves `0` to the CPU count,
- maximising throughput. CLI flags always override `pyfly.yaml`.
+ available server and event loop automatically. For production, pass an
+ explicit positive worker count to scale across cores —
+ `pyfly run --server granian --workers 4`, as in the example above. A `0`
+ or negative `--workers` value resolves to a single worker, so multi-worker
+ is always an explicit opt-in. CLI flags always override `pyfly.yaml`.
---
@@ -741,6 +960,17 @@ built here carry forward intact.
## Try it yourself {.exercises}
+Each exercise is small and self-contained. After every change, restart with
+`uv run pyfly run --reload` and re-run the suggested `curl` to confirm the
+behaviour. If you have the dev dependencies installed, you can also run the
+project's test suite at any point to make sure nothing regressed:
+
+```bash
+uv run --extra dev pytest
+```
+
+You should see a row of passing dots and a `passed` summary line.
+
1. **Add a `DELETE /api/v1/wallets/{wallet_id}` endpoint.** Remove the wallet
from `_wallets` and return 204 No Content. Raise `ResourceNotFoundException`
if the wallet does not exist. Decorate with
diff --git a/book/manuscript/05-persistence.md b/book/manuscript/05-persistence.md
index f1fbebe9..c3b04677 100644
--- a/book/manuscript/05-persistence.md
+++ b/book/manuscript/05-persistence.md
@@ -8,7 +8,37 @@ Lumen has a wallet API that works — but every wallet disappears the moment you
The naïve approach is to scatter SQLAlchemy `select()` and `session.commit()` calls through the command handlers. PyFly offers something far better: a **Spring-Data-style repository layer**. You declare an interface — `class WalletRepository(Repository[WalletEntity, str])` — and the framework *implements it for you*. Full async CRUD comes for free. Query methods are derived from their **names**. Pagination, sorting, composable filters, and read projections are first-class. There is no hand-written adapter and no SQL in the application code.
-This chapter rebuilds Lumen's persistence on that layer, exactly as the running sample does it: the SQLAlchemy entity, the repository with its derived and specification queries, `Page`/`Pageable`/`Sort`, projections for read views, and the transaction seam that keeps the `Wallet` aggregate intact. Everything here runs against a real SQLite file with zero external infrastructure — the sample's 41 tests are green on it.
+This chapter rebuilds Lumen's persistence on that layer, exactly as the running sample does it: the SQLAlchemy entity, the repository with its derived and specification queries, `Page`/`Pageable`/`Sort`, projections for read views, and the transaction seam that keeps the `Wallet` aggregate intact. Everything here runs against a real SQLite file with zero external infrastructure — the sample's 41 tests are green on it. This chapter targets PyFly **v26.6.110**.
+
+We will build the persistence layer one piece at a time, and at every milestone there is a **Run it** box with the exact command to type and the output you should see. If you are following along in the Lumen sample, work from the project root (`samples/lumen`) where `pyfly.yaml` and `pyproject.toml` live; every command below assumes that directory.
+
+!!! note "Run it: see the problem first"
+ Before adding persistence, it is worth feeling the gap it closes. Open a wallet, then stop and restart the process — with the in-memory store from Part I the wallet is gone. The rest of this chapter makes it survive that restart.
+
+ ```bash
+ # Terminal 1 — start the app
+ uv run pyfly run --server uvicorn
+ # ... startup banner, then: Uvicorn running on http://0.0.0.0:8080
+
+ # Terminal 2 — open a wallet, then read it back
+ curl -s -X POST localhost:8080/api/v1/wallets \
+ -H 'content-type: application/json' \
+ -d '{"owner_id": "alice", "currency": "EUR"}'
+ ```
+
+ You get back the new id, then the balance read confirms it exists:
+
+ ```
+ {"wallet_id": "wlt-7f3c..."}
+ ```
+
+ Now press `Ctrl+C` in Terminal 1, start the app again, and ask for that wallet's balance. Before this chapter, the row was never written to disk, so it is gone:
+
+ ```
+ {"detail": "Wallet 'wlt-7f3c...' not found", "code": "WALLET_NOT_FOUND"}
+ ```
+
+ By the end of this chapter the same sequence returns the balance after a restart.
---
@@ -27,7 +57,17 @@ This is the Repository pattern as Spring Data popularised it, translated to idio
## The entity: one row per wallet
-Before a repository can store anything, it needs an **entity** — the on-disk shape of a wallet, one flat row per aggregate. PyFly entities are ordinary SQLAlchemy 2.0 models built on a declarative base the framework exports:
+Before a repository can store anything, it needs an **entity** — the on-disk shape of a wallet, one flat row per aggregate. (An *entity*, in this layer, is just a Python class that maps to one database table; each instance is one row.) PyFly entities are ordinary SQLAlchemy 2.0 models built on a declarative base the framework exports.
+
+We will build it field by field. Create the file `src/lumen/models/entities/v1/wallet_orm.py` and add the pieces in order.
+
+**Step 1 — import the framework's declarative base.** Every entity inherits from a *declarative base*: a SQLAlchemy class that records each table you define so the framework can create them all on startup. PyFly exports one as `Base`:
+
+```python
+from pyfly.data.relational.sqlalchemy import Base
+```
+
+**Step 2 — name the table and declare the columns.** Subclass `Base`, set `__tablename__`, and write one typed attribute per column. The full entity is short:
::: listing lumen/models/entities/v1/wallet_orm.py | Listing 5.1 — WalletEntity: the SQLAlchemy persistence row
from __future__ import annotations
@@ -56,7 +96,10 @@ class WalletEntity(Base):
)
:::
-The `Mapped[T]` / `mapped_column(...)` syntax is SQLAlchemy 2.0 style: each type annotation drives both the Python attribute type and the generated column DDL, so every column has a single source of truth. Because `WalletEntity` subclasses `Base`, importing this module registers the `wallets` table in `Base.metadata`; the framework's engine lifecycle then creates it on startup.
+The `Mapped[T]` / `mapped_column(...)` syntax is SQLAlchemy 2.0 style: each type annotation drives both the Python attribute type and the generated column DDL (the `CREATE TABLE` statement), so every column has a single source of truth. Because `WalletEntity` subclasses `Base`, importing this module registers the `wallets` table in `Base.metadata` — the registry of every table the base knows about — and the framework's engine lifecycle then creates it on startup.
+
+!!! note "What just happened"
+ You wrote one class, no SQL. The five typed attributes became five columns; `primary_key=True` marked `id` as the key; `index=True` on `owner_id` will speed up the "wallets owned by X" query you build later; `nullable=False` and `default=...` set the constraints. Importing this module is enough for the framework to know the table exists — you never call `CREATE TABLE` yourself.
Two design choices are worth calling out.
@@ -71,7 +114,19 @@ Two design choices are worth calling out.
## The repository: CRUD for free
-Now the centrepiece. Lumen's `WalletRepository` subclasses `Repository[WalletEntity, str]` — entity type `WalletEntity`, primary-key type `str` — and is registered with `@repository`. That single declaration is enough for the framework to supply the entire CRUD surface:
+Now the centrepiece. Lumen's `WalletRepository` subclasses `Repository[WalletEntity, str]` — entity type `WalletEntity`, primary-key type `str` — and is registered with `@repository`. (A *stereotype* like `@repository` is a class decorator that tells the framework's container, "manage an instance of this for me." That managed instance is a **bean** — an object the container creates once and hands to anything that asks for it. *Dependency injection*, or *DI*, is the container doing that handing-over for you, so a handler never constructs its own repository.) That single declaration is enough for the framework to supply the entire CRUD surface — *CRUD* being the four basic table operations: Create, Read, Update, Delete.
+
+Create `src/lumen/models/repositories/wallet_repository.py` in two steps.
+
+**Step 1 — import what the declaration needs.** Three imports: your entity, the `@repository` stereotype, and the generic `Repository` base.
+
+```python
+from lumen.models.entities.v1.wallet_orm import WalletEntity
+from pyfly.container import repository
+from pyfly.data.relational.sqlalchemy import Repository
+```
+
+**Step 2 — subclass the generic base and mark it `@repository`.** The two type parameters carry all the wiring:
::: listing lumen/models/repositories/wallet_repository.py | Listing 5.2 — WalletRepository: subclass the framework repository
from __future__ import annotations
@@ -118,15 +173,36 @@ There is no `__init__`, no SQL, and no adapter class. With just that declaration
That is more than enough for most entities. Lumen adds three methods of its own on top — a derived query, a specification query, and an upsert — which the next sections build up.
+!!! spring "Spring parity"
+ This inherited surface is exactly Spring Data's repository hierarchy, carried over name-for-name and stable as of PyFly **v26.6.110**: `CrudRepository` → `ReactiveSortingRepository` → `PagingAndSortingRepository`. `save`/`save_all`, `find_by_id`, `find_all`, `exists_by_id`, `count`, and the `delete*` family map to their Spring equivalents; `find_all(pageable) -> Page[T]` is `findAll(Pageable)`, and `find_all_by_spec*` is the `JpaSpecificationExecutor`. If you know `JpaRepository`, you already know this table.
+
### How the framework knows the types
-When you write `Repository[WalletEntity, str]`, the base class's `__init_subclass__` hook inspects `__orig_bases__` at class-definition time and pulls the entity type (`WalletEntity`) and id type (`str`) out of the generic parameters. The `AsyncSession` is then supplied as an injected dependency by the relational auto-configuration. Nothing is passed manually — the type parameters *are* the wiring.
+When you write `Repository[WalletEntity, str]`, the base class's `__init_subclass__` hook inspects `__orig_bases__` at class-definition time and pulls the entity type (`WalletEntity`) and id type (`str`) out of the generic parameters. (`__init_subclass__` is a Python hook that runs once, automatically, when a subclass is *defined* — so this happens at import time, before any object is created.) The `AsyncSession` — the database connection-and-transaction handle every query runs through — is then supplied as an injected dependency by the relational auto-configuration. Nothing is passed manually — the type parameters *are* the wiring.
+
+!!! note "Run it: confirm the repository wires up"
+ The fastest proof that the entity and repository are sound is the test suite, which exercises the repository against a real SQLite file with no server. From the Lumen project root:
+
+ ```bash
+ uv run --extra dev pytest tests/test_sql_wallet_repository.py -q
+ ```
+
+ You should see every repository test pass:
+
+ ```
+ ...... [100%]
+ 6 passed in 0.30s
+ ```
+
+ These tests construct the repository directly and drive `upsert`, `find_by_id`, `count`, the derived query, and the specification path — the same methods this chapter builds. If they are green, your entity columns and the `Repository[WalletEntity, str]` declaration are correct.
---
## Derived queries: the method name is the query
-CRUD covers lookups by primary key. Real applications also need to query by other columns — "all wallets owned by this customer." In most frameworks you would write the SQL by hand. In PyFly you declare a **stub** and let the framework compile the query *from the method name*:
+CRUD covers lookups by primary key. Real applications also need to query by other columns — "all wallets owned by this customer." In most frameworks you would write the SQL by hand. In PyFly you declare a **stub** — a method with no body, just `...` — and let the framework compile the query *from the method name*.
+
+**Step 1 — declare the stub.** Add a method to `WalletRepository`. The *name* describes the query; the body is literally `...`:
::: listing lumen/models/repositories/wallet_repository.py | Listing 5.3 — A derived query: declared as a stub, compiled from its name
@repository
@@ -140,7 +216,10 @@ class WalletRepository(Repository[WalletEntity, str]):
...
:::
-The body is literally `...`. At startup a `BeanPostProcessor` — the `RepositoryBeanPostProcessor` — scans the repository, spots that `find_by_owner_id` is a stub, parses the **name** into a parsed query, and replaces the stub with a real implementation that runs `SELECT … FROM wallets WHERE owner_id = :owner_id`. Calling `await repo.find_by_owner_id("alice")` now returns exactly the rows for that owner.
+**Step 2 — let the framework fill in the body.** You write no more code. At startup a `BeanPostProcessor` — the `RepositoryBeanPostProcessor` — does the work. (A *BeanPostProcessor* is a hook the container runs over every bean just after it is created; this one specialises in repositories.) It scans the repository, spots that `find_by_owner_id` is a stub, parses the **name** into a parsed query, and replaces the stub with a real implementation that runs `SELECT … FROM wallets WHERE owner_id = :owner_id`. Calling `await repo.find_by_owner_id("alice")` now returns exactly the rows for that owner.
+
+!!! note "What just happened"
+ You declared a method and got a working query — the framework read the *intent* from the name `find_by_owner_id` and wrote the SQL for you. The naming is not magic; it follows a grammar, covered next. The key idea: in this layer you describe *what* you want by how you name the method, and the post-processor supplies the *how*.
The grammar is the Spring Data convention. A method name is a **prefix** followed by a **subject** built from field names, operators, connectors, and an optional ordering clause:
@@ -188,7 +267,9 @@ The prefix decides the *shape* of the result: `find_by` returns a list, `count_b
## Pagination: Page, Pageable, and Sort
-A list endpoint should never return *every* wallet. PyFly's pagination types — `Pageable` (what page, what size, what sort), `Sort` (the ordering), and `Page[T]` (the slice plus metadata) — are inherited straight from the CRUD surface via `find_all(pageable)`.
+A list endpoint should never return *every* wallet. *Pagination* is the standard fix: return one fixed-size **page** of rows at a time, plus enough metadata for the client to ask for the next one. PyFly's pagination types — `Pageable` (what page, what size, what sort), `Sort` (the ordering), and `Page[T]` (the slice plus metadata) — are inherited straight from the CRUD surface via `find_all(pageable)`.
+
+There are three small pieces to assemble: the handler that calls `find_all(pageable)`, the `Page[T]` it returns, and the controller that builds the `Pageable` from the request. We will take them in that order.
Lumen's `ListWallets` query handler is the whole story in three lines:
@@ -246,13 +327,37 @@ async def list_wallets(
`Sort.by("created_at").descending()` names the column and the direction; `Pageable.of(page, size, sort)` packages it with the page coordinates. The handler returns a framework `Page`, and the controller folds it into a serialisable `PageDto` — a plain Pydantic mirror of the page — so `GET /api/v1/wallets?page=1&size=20` returns JSON like `{"items": [...], "total": 42, "page": 1, "total_pages": 3, "has_next": true, ...}`.
+!!! note "Run it: page through the wallets"
+ With the relational layer turned on (the "Turning it on" section below switches it on; the Lumen sample ships it already enabled), open a couple of wallets, then ask for the first page. From a running app:
+
+ ```bash
+ curl -s 'localhost:8080/api/v1/wallets?page=1&size=20'
+ ```
+
+ The response carries the rows *and* the pager metadata — note `total`, `page`, `total_pages`, and `has_next` alongside `items`:
+
+ ```json
+ {
+ "items": [
+ {"id": "wlt-...", "owner_id": "alice", "currency": "EUR",
+ "balance_minor": 0, "balance": 0.0, "created_at": "..."}
+ ],
+ "total": 1, "page": 1, "size": 20, "total_pages": 1,
+ "has_next": false, "has_previous": false
+ }
+ ```
+
+ Those are exactly the `Page[T]` members from the table above, serialised by `PageDto`. The client renders a pager straight from this shape — no extra count query needed.
+
---
## Specifications: composable, reusable filters
-Derived queries answer fixed questions. Sometimes you want a **reusable predicate** you can compose at the call site — "wallets with at least this balance," combined freely with other conditions. That is what a `Specification` is: a small object wrapping a `WHERE` fragment, composable with `&`, `|`, and `~`.
+Derived queries answer fixed questions. Sometimes you want a **reusable predicate** — a `WHERE` condition you can name once and reuse — that you compose at the call site: "wallets with at least this balance," combined freely with other conditions. That is what a `Specification` is: a small object wrapping a `WHERE` fragment, composable with `&` (AND), `|` (OR), and `~` (NOT).
-Lumen defines one as a module-level factory and uses it in a `find_rich` method:
+We build it in two steps: a factory that *returns* a `Specification`, then a repository method that *runs* it.
+
+**Step 1 — write a factory that returns a `Specification`.** It takes the parameter (the minimum balance) and returns a predicate object. **Step 2 — add a repository method that runs it** through the inherited `find_all_by_spec_paged`. Lumen does both in one file:
::: listing lumen/models/repositories/wallet_repository.py | Listing 5.6 — A Specification factory and a method that runs it paged
def balance_at_least(min_minor: int) -> Specification[WalletEntity]:
@@ -313,6 +418,22 @@ class ListRichWalletsHandler(
`GET /api/v1/wallets/rich?min_minor=1000&page=1&size=20` now returns a page of wallets at or above €10.00, newest first.
+!!! note "Run it: filter to the rich wallets"
+ Open one wallet and deposit €25.00 into it (2500 minor units), open another and leave it empty, then ask for wallets with at least €10.00:
+
+ ```bash
+ curl -s 'localhost:8080/api/v1/wallets/rich?min_minor=1000&page=1&size=20'
+ ```
+
+ Only the funded wallet comes back, and `total` counts just the matches — the empty wallet is filtered out by the `Specification`:
+
+ ```json
+ {"items": [{"id": "wlt-...", "balance_minor": 2500, "balance": 25.0, ...}],
+ "total": 1, "page": 1, "total_pages": 1, "has_next": false}
+ ```
+
+ The same predicate (`balance_at_least`) that runs here can be combined with others using `&`, `|`, and `~` — that is the payoff of writing the filter as a `Specification` instead of a one-off query.
+
!!! note "Filters without lambdas"
For the common case — equality and a handful of comparisons — you don't even need to write a lambda. `FilterOperator.gte("balance_minor", 1000) & FilterOperator.eq("currency", "EUR")` produces the same composable `Specification` from static factory methods, and `FilterUtils.by(currency="EUR")` builds one from keyword arguments (Query-by-Example). Lumen uses an explicit lambda here because the intent reads clearly; both styles produce a `Specification` you can pass to the same repository methods.
@@ -320,9 +441,11 @@ class ListRichWalletsHandler(
## Projections: read only the columns you need
-The balance endpoint does not need the whole row — just the id, currency, and a computed balance. PyFly supports **interface projections**, Spring Data's idea of declaring the subset of fields a read-view wants and letting the framework copy exactly those.
+The balance endpoint does not need the whole row — just the id, currency, and a computed balance. PyFly supports **interface projections**, Spring Data's idea of declaring the subset of fields a read-view wants and letting the framework copy exactly those. (A *projection* is a read-only view onto a subset of an entity's columns — you name the few fields you care about, and the framework copies only those, leaving the rest of the row unread.)
-A projection is a class marked `@projection`. In Lumen it is a concrete dataclass:
+Building one takes three pieces: the projection class, the mapper that knows how to fill it, and the handler that uses it. We take them in order.
+
+**Step 1 — declare the projection.** A projection is a class marked `@projection`. In Lumen it is a concrete dataclass:
::: listing lumen/interfaces/dtos/v1/balance_dto.py | Listing 5.8 — BalanceView: a @projection of just the balance fields
from dataclasses import dataclass
@@ -346,7 +469,7 @@ class BalanceView:
balance: float
:::
-The mapper reads those four fields off a `WalletEntity` and constructs the view. Three (`id`, `currency`, `balance_minor`) are copied straight across; the fourth (`balance`, the major-unit decimal) is supplied by a transform registered on the mapper:
+**Step 2 — register the projection on a `Mapper`.** A `Mapper` is the framework helper that copies entity fields onto a projection. It reads those four fields off a `WalletEntity` and constructs the view. Three (`id`, `currency`, `balance_minor`) are copied straight across; the fourth (`balance`, the major-unit decimal) is supplied by a *transform* — a small function registered against a field name that computes a value the entity does not store directly:
::: listing lumen/core/mappers/wallet_mapper.py | Listing 5.9 — Registering and running the projection via Mapper
from pyfly.data import Mapper
@@ -370,7 +493,7 @@ def entity_to_balance_dto(entity: WalletEntity) -> BalanceDto:
)
:::
-`Mapper.project(entity, BalanceView)` reads only the declared fields, applies the `balance` transform, and returns a `BalanceView`. The query handler then loads the row by id and projects it:
+**Step 3 — use the projection from a read handler.** `Mapper.project(entity, BalanceView)` reads only the declared fields, applies the `balance` transform, and returns a `BalanceView`. The query handler then loads the row by id and projects it:
::: listing lumen/core/services/wallets/get_balance_handler.py | Listing 5.10 — The balance read handler: find by id, then project
@query_handler
@@ -391,6 +514,19 @@ class GetBalanceHandler(QueryHandler[GetBalance, BalanceDto | None]):
)
:::
+!!! note "Run it: read just the balance"
+ The balance endpoint returns only the four projected fields, not the whole row. Against a running app with a funded wallet:
+
+ ```bash
+ curl -s localhost:8080/api/v1/wallets/wlt-.../balance
+ ```
+
+ ```json
+ {"id": "wlt-...", "currency": "EUR", "balance_minor": 2500, "balance": 25.0}
+ ```
+
+ No `owner_id`, no `created_at` — the projection declared four fields, so four fields are read and returned. `balance` (the major-unit `25.0`) is the computed transform; the rest are copied straight from the row.
+
!!! warning "A projection must be instantiable"
Spring lets a projection be a bare interface and returns a runtime proxy. Python has no such proxy, so a PyFly projection must be a **concrete** type the mapper can construct — here, a `@dataclass`. Marking a *Protocol* `@projection` will not work: a Protocol cannot be instantiated, and `Mapper.project` has nothing to build. Use a dataclass (or any plain class with matching fields) and you are safe.
@@ -402,9 +538,11 @@ The repository surface is clean — but two honest subtleties decide whether you
### save() flushes; it does not commit
-This is the single most important thing to understand about the data layer. The framework uses **one shared `AsyncSession`**, and `Repository.save()` calls `session.add()` followed by `session.flush()` and `session.refresh()` — it **flushes**, making the write visible *within* the current session, but it never **commits**. If nothing commits, the write is rolled back when the session closes and the wallet does not survive a restart.
+This is the single most important thing to understand about the data layer. Two database verbs are easy to confuse. To **flush** is to send the pending SQL (the `INSERT`/`UPDATE`) to the database so it is visible to *this* connection's later reads — but still inside an open transaction that can be undone. To **commit** is to make those changes permanent and visible to everyone. A flush without a commit is rolled back when the session closes.
+
+The framework uses **one shared `AsyncSession`**, and `Repository.save()` calls `session.add()` followed by `session.flush()` and `session.refresh()` — it **flushes**, making the write visible *within* the current session, but it never **commits**. If nothing commits, the write is rolled back when the session closes and the wallet does not survive a restart. (This is exactly the disappearing-wallet problem from the opening **Run it** box.)
-The commit happens at the **unit-of-work boundary**, and you declare that boundary with `@transactional()`. A handler that writes decorates its `do_handle` with `@transactional()`, injects the `async_sessionmaker` as `self._session_factory`, and the decorator opens a unit of work, swaps that transactional session onto the repository for the call, **commits on success**, and rolls back on failure:
+The commit happens at the **unit-of-work boundary**. (A *unit of work* is one all-or-nothing batch of changes: either every write in it commits together, or — if anything fails — none of them do.) You declare that boundary with `@transactional()`. A handler that writes decorates its `do_handle` with `@transactional()`, injects the `async_sessionmaker` — the factory that hands out sessions — as `self._session_factory`, and the decorator opens a unit of work, swaps that transactional session onto the repository for the call, **commits on success**, and rolls back on failure:
::: listing lumen/core/services/wallets/open_wallet_handler.py | Listing 5.11 — A write handler: @transactional() commits the unit of work
@command_handler
@@ -444,9 +582,12 @@ class OpenWalletHandler(CommandHandler[OpenWallet, str]):
`@transactional()` (imported from `pyfly.data.relational.sqlalchemy`) resolves the `async_sessionmaker` from `self._session_factory`, runs the body inside a `session.begin()` block, and commits at the end. Drop the decorator and the `upsert` would only flush — the wallet would never reach disk. The read handlers earlier in this chapter need no `@transactional`: a read makes no changes to commit.
+!!! note "What just happened"
+ The rule in one line: **reads need nothing; writes need `@transactional()`.** `save`/`upsert` only *flush*, so a write handler must run inside a unit of work that commits. The decorator does three things for you — opens the transaction, hands the repository the right session for the call, and commits (or rolls back on an exception). This is why the opening wallet survived once persistence was on, and why removing the decorator would silently lose it.
+
### upsert, not save, for an aggregate that owns its id
-Notice the handler calls `self._repository.upsert(...)`, not `save(...)`. That is the second subtlety. The framework's `save()` issues `session.add()`, which SQLAlchemy treats as a **pending INSERT**. But the `Wallet` aggregate generates its *own* primary key up front (`wlt-…`), so by the time a deposit or withdrawal persists an already-loaded wallet, a row with that id already exists — and a second `INSERT` on the same primary key raises `IntegrityError`.
+Notice the handler calls `self._repository.upsert(...)`, not `save(...)`. (*Upsert* is the portmanteau verb for "insert if new, update if already present" — one call for both cases.) That is the second subtlety. The framework's `save()` issues `session.add()`, which SQLAlchemy treats as a **pending INSERT**. But the `Wallet` aggregate generates its *own* primary key up front (`wlt-…`), so by the time a deposit or withdrawal persists an already-loaded wallet, a row with that id already exists — and a second `INSERT` on the same primary key raises `IntegrityError`.
The fix is `session.merge`, which inserts when the id is new and updates when it already exists. Lumen wraps it in an `upsert` convenience method:
@@ -471,6 +612,9 @@ class WalletRepository(Repository[WalletEntity, str]):
`_require_session()` is the inherited accessor that returns the active session (the transactional one, once `@transactional` has swapped it in). `merge` keys on the primary key, so both the first write (open) and every later write (deposit, withdraw) take the same code path with no `IntegrityError`. For entities whose ids are database-generated, `save` is the natural choice; for an aggregate that owns its id, `upsert` is.
+!!! note "What just happened"
+ Two questions decide every write: *did this row commit?* and *did this write insert or update?* `@transactional()` answers the first (it commits the unit of work); `upsert`/`merge` answers the second (one code path for both INSERT and UPDATE, because the aggregate owns its id). Get both right and a wallet you open, then deposit into, then read back after a restart returns the correct balance — which is exactly what the repository test below asserts against a *fresh* engine.
+
### The aggregate ↔ entity mapper seam
There is one more boundary, and it is a feature, not an accident. Lumen keeps two distinct types:
@@ -513,7 +657,7 @@ The write side calls `to_entity` before `upsert`; the read side either rehydrate
## Turning it on
-Activating the relational layer is configuration, not code. Lumen's `pyfly.yaml` carries a `data.relational` block:
+Activating the relational layer is configuration, not code — three keys in `pyfly.yaml`. Add a `data.relational` block:
::: listing pyfly.yaml | Listing 5.14 — Relational data layer configuration
pyfly:
@@ -528,6 +672,31 @@ pyfly:
The dependency footprint is tiny: `pyfly[data-relational]` pulls in `sqlalchemy[asyncio]` and `aiosqlite`, and nothing else. No database server, no driver install — which is exactly why the sample runs anywhere.
+!!! note "Run it: the disappearing wallet, fixed"
+ Re-run the opening experiment, now with persistence on. Open a wallet, stop the app, start it again, and read the balance back:
+
+ ```bash
+ # Terminal 1
+ uv run pyfly run --server uvicorn
+
+ # Terminal 2 — open a wallet
+ curl -s -X POST localhost:8080/api/v1/wallets \
+ -H 'content-type: application/json' \
+ -d '{"owner_id": "alice", "currency": "EUR"}'
+ # -> {"wallet_id": "wlt-..."}
+
+ # Ctrl+C in Terminal 1, then start it again, then:
+ curl -s localhost:8080/api/v1/wallets/wlt-.../balance
+ ```
+
+ This time the wallet survives — its row was committed to `lumen.db` on disk:
+
+ ```json
+ {"id": "wlt-...", "currency": "EUR", "balance_minor": 0, "balance": 0.0}
+ ```
+
+ Look in the project directory and you will see the `lumen.db` SQLite file the engine created on first boot, with the `wallets` table inside it. The whole chapter comes down to this: the wallet outlives the process.
+
!!! tip "Schema lifecycle in production"
`ddl-auto: create` is right for development and samples: it creates missing tables and leaves existing ones alone. In production set `ddl-auto: none` and manage the schema with a migration tool (Alembic), which generates versioned scripts from the diff between `Base.metadata` and the live database. The application code does not change — only the `ddl-auto` setting and the migration pipeline.
@@ -590,6 +759,26 @@ The first test drives the derived query: three wallets in, two owners out, and `
The fixture mirrors what the framework does at startup — build the engine, run `Base.metadata.create_all` inside a `begin()` block so the DDL commits, hand back a session factory — so the test exercises the exact table the application creates. Other tests in the same file prove `upsert` round-trips through a *fresh* engine (durability across reconnect) and that `find_all(pageable)` counts and slices a five-wallet table correctly.
+!!! note "Run it: prove the whole layer green"
+ Run the repository test file end to end. From the Lumen project root:
+
+ ```bash
+ uv run --extra dev pytest tests/test_sql_wallet_repository.py -v
+ ```
+
+ Each named test reports `PASSED` — CRUD round-trip, durability across reconnect, the derived query, the specification path, and pagination:
+
+ ```
+ tests/test_sql_wallet_repository.py::test_upsert_inserts_then_updates_and_persists PASSED
+ tests/test_sql_wallet_repository.py::test_find_by_id_unknown_returns_none PASSED
+ tests/test_sql_wallet_repository.py::test_derived_find_by_owner_id PASSED
+ tests/test_sql_wallet_repository.py::test_specification_find_rich_paged_and_sorted PASSED
+ tests/test_sql_wallet_repository.py::test_find_all_pageable_counts_and_pages PASSED
+ 6 passed in 0.31s
+ ```
+
+ Run the whole suite (`uv run --extra dev pytest -q`) to confirm the rest of Lumen still passes alongside the persistence layer.
+
!!! spring "Spring parity"
Constructing the repository directly against a real in-process database mirrors Spring's `@DataJpaTest` slice, which boots an H2 database and the JPA layer in isolation to test repositories without the full context. `Base.metadata.create_all` is the analogue of `spring.jpa.hibernate.ddl-auto=create`, and running `RepositoryBeanPostProcessor` by hand stands in for the Spring proxy that materialises derived queries on a `JpaRepository` at startup.
diff --git a/book/manuscript/06-domain-driven-design.md b/book/manuscript/06-domain-driven-design.md
index 7928802a..52170ede 100644
--- a/book/manuscript/06-domain-driven-design.md
+++ b/book/manuscript/06-domain-driven-design.md
@@ -18,6 +18,9 @@ Before you can build a model that enforces its own rules, you need a vocabulary
Think about what makes two wallets distinct. Even if two wallets happen to hold exactly one hundred euros, they are still separate wallets belonging to separate owners — you care *which one* you have. Now think about the amount itself. One hundred euros is one hundred euros; the exact Python object that holds the value is irrelevant. If a deposit adds fifty euros to a wallet's balance, you do not want to update the existing amount in place — you want to derive a brand-new amount that records the result. Mutating in place invites aliasing bugs where two parts of the code unknowingly share a reference to the same object and see each other's changes.
+!!! note "Jargon, in plain language"
+ A few words recur throughout this chapter. An **invariant** is a rule that must always hold — for a wallet, "the balance is never negative." An **aggregate** is a small cluster of objects that change together and must stay consistent as a group. An **aliasing bug** happens when two pieces of code accidentally hold the *same* object and one mutates it, surprising the other. Keep these three in mind; the rest of the chapter is largely about preventing the last one and guaranteeing the first inside the second.
+
DDD names these two roles **entities** and **value objects**, and PyFly's `pyfly.domain` module makes them first-class concepts:
| Concept | PyFly base | Equality | Mutation |
@@ -105,6 +108,55 @@ The `currency` field uses the `Currency` enum (`Currency.EUR`, `Currency.USD`, `
Both `add` and `subtract` return a *new* `Money` instance rather than modifying `self`, a direct consequence of `frozen=True`. This immutability guarantee means the aggregate holding a `Money` value can never be partially updated: either the whole replacement succeeds or the old value remains in place. The `is_positive` and `is_negative` properties expose the sign without leaking the raw integer. `major_units` converts to a decimal for display, and `__str__` formats as `"10.50 EUR"` via `currency.value`.
+**Build it step by step.** If you are creating `Money` from scratch, here is the order to write it in, and why each line matters.
+
+Step 1 — Declare the two fields and freeze the class. Add `amount: int` and `currency: Currency` to a class decorated with `@dataclass(frozen=True)` that subclasses `ValueObject`. `frozen=True` is what makes the object immutable and gives you structural equality for free: two `Money` instances with the same amount and currency are now `==`.
+
+Step 2 — Guard the constructor. Add `__post_init__` to reject anything that is not a plain integer. The `isinstance(self.amount, bool)` check is deliberate — in Python `True` is an `int`, and you do not want `Money(True, ...)` slipping through.
+
+Step 3 — Add the `zero` factory. A wallet opens at a zero balance, so `Money.zero(currency)` reads better at the call site than `Money(0, currency)`.
+
+Step 4 — Add `add` and `subtract`, each routing through `_assert_same_currency` first. Returning a brand-new `Money` (never mutating `self`) is what prevents the aliasing bug from the section opener.
+
+Step 5 — Add the display helpers: `is_positive`, `is_negative`, `major_units`, and `__str__`.
+
+**Run it.** `Money` has no dependency on the framework runtime or a database, so you can exercise every rule from a Python prompt. Start one with `uv run python` from the `samples/lumen` directory and type:
+
+```python
+>>> from lumen.models.entities.v1.money import Money
+>>> from lumen.interfaces.enums.v1.currency import Currency
+>>> ten_fifty = Money(1050, Currency.EUR)
+>>> str(ten_fifty)
+'10.50 EUR'
+>>> ten_fifty.add(Money(450, Currency.EUR))
+Money(amount=1500, currency=)
+>>> ten_fifty.add(Money(100, Currency.USD))
+Traceback (most recent call last):
+ ...
+pyfly.domain.exceptions.BusinessRuleViolation: cannot combine EUR with USD
+>>> Money(10.5, Currency.EUR)
+Traceback (most recent call last):
+ ...
+pyfly.domain.exceptions.BusinessRuleViolation: amount must be an integer number of minor units
+```
+
+The two tracebacks are the point: the model refuses a cross-currency sum and a float amount *at the moment you make the mistake*, not three layers deeper during reconciliation.
+
+Lumen ships these exact behaviours as tests. Run them with:
+
+```
+uv run --extra dev pytest tests/test_money.py -q
+```
+
+and you should see all six pass:
+
+```
+...... [100%]
+6 passed in 0.0Xs
+```
+
+**What just happened.** You built a value object that is impossible to misuse: it cannot be mutated, it cannot mix currencies, and it cannot hold a float. Every other piece of the wallet model will lean on these guarantees, which is why `Money` comes first.
+
!!! note "Minor units vs decimal"
Storing money as integer cents is one convention; another is Python's `decimal.Decimal` with a fixed scale. Both are valid. What matters is picking one and sticking to it within the bounded context. For Lumen, integer minor units keep the model free of import-time precision configuration, and `__post_init__` enforces the constraint with `BusinessRuleViolation("money-amount-integer")` so a float never silently enters the model.
@@ -264,6 +316,55 @@ class Wallet(AggregateRoot[str]):
**How it works.** The aggregate boundary is enforced at three levels. First, `__slots__` locks the attribute set, and `balance` and `owner_id` are deliberately public-but-owned: only the aggregate's own methods (`deposit`, `withdraw`) mutate them, while the `currency` property is a read-only convenience that delegates to `balance.currency`. Second, the factory classmethod `open` is the sole legitimate way to create a new wallet: the caller supplies the `wallet_id` (so the application layer controls ID generation), `open` validates that `owner_id` is non-blank, initializes the balance with `Money.zero(currency)`, and immediately queues `WalletOpened`. Using a factory rather than calling `__init__` directly ensures the opening event is *never* forgotten, even in a test fixture. Third, the domain events — `WalletOpened`, `FundsDeposited`, `FundsWithdrawn` — are frozen dataclasses. `FundsDeposited` and `FundsWithdrawn` carry a `balance` field (the post-operation balance in minor units), so a subscriber never needs to call back into the aggregate to learn the current state.
+**Build it step by step.** The `Wallet` class has more moving parts than `Money`, so here is the order to assemble it.
+
+Step 1 — Define the three event classes first (`WalletOpened`, `FundsDeposited`, `FundsWithdrawn`), each a `@dataclass(frozen=True)` subclass of `DomainEvent`. They have to exist before the aggregate can reference them. We cover events in depth in the next section but two; for now they are just the records the wallet will emit.
+
+Step 2 — Declare the aggregate. Subclass `AggregateRoot[str]` (the `[str]` says the id is a string), set `__slots__` to lock the attribute names, and write `__init__` to store `owner_id`, `balance`, and `created_at`. Call `super().__init__(id)` first so the base class sets up the id and the internal event buffer.
+
+Step 3 — Add the `currency` read-only property that delegates to `self.balance.currency`. This is a convenience that keeps callers from reaching two levels deep into `wallet.balance.currency`.
+
+Step 4 — Write the `open` factory classmethod. It validates `owner_id`, builds the wallet with a `Money.zero(currency)` balance, and calls `self.raise_event(WalletOpened(...))`. Making `open` a classmethod — rather than expecting callers to use `__init__` plus a manual event — guarantees the opening event is never forgotten.
+
+Step 5 — Write `deposit` and `withdraw`. Each one validates first (currency match, positive amount, and for withdraw, sufficient funds), *then* mutates `self.balance`, *then* raises its event. Order matters: never mutate before every check has passed, or you can leave the wallet in a half-changed state.
+
+Step 6 — Add the private `_assert_currency` helper that both transitions share.
+
+!!! note "raise_event is not raising an exception"
+ Despite the name, `raise_event` does not throw anything. It *appends* an event to an internal buffer on the aggregate (`AggregateRoot._pending_events`). Nothing is published at that moment. A later step — the application service, after a successful save — drains the buffer with `clear_events()` and hands the events to the event bus. Think of `raise_event` as "make a note that this happened", not "abort".
+
+**Run it.** Like `Money`, the `Wallet` aggregate is pure Python — no database needed. From `uv run python`:
+
+```python
+>>> from lumen.models.entities.v1.wallet_entity import Wallet
+>>> from lumen.models.entities.v1.money import Money
+>>> from lumen.interfaces.enums.v1.currency import Currency
+>>> w = Wallet.open("wlt-1", "owner-1", Currency.EUR)
+>>> w.deposit(Money(1000, Currency.EUR))
+>>> w.withdraw(Money(400, Currency.EUR))
+>>> str(w.balance)
+'6.00 EUR'
+>>> [e.event_type for e in w.pending_events()]
+['WalletOpened', 'FundsDeposited', 'FundsWithdrawn']
+>>> w.withdraw(Money(9999, Currency.EUR))
+Traceback (most recent call last):
+ ...
+pyfly.domain.exceptions.BusinessRuleViolation: cannot withdraw 99.99 EUR; balance is 6.00 EUR
+```
+
+Notice the three events sitting in the buffer after the successful operations, and notice that the overdraft attempt raised *before* touching the balance — `pending_events()` still shows three, not four. Lumen's test suite asserts exactly this:
+
+```
+uv run --extra dev pytest tests/test_wallet_aggregate.py -q
+```
+
+```
+...... [100%]
+6 passed in 0.0Xs
+```
+
+**What just happened.** The wallet now owns its rules. There is no way to overdraw it, no way to feed it the wrong currency, and no way to change its state without leaving an event behind that records what happened. The service layer no longer has to remember any of that.
+
The diagram below shows the complete picture: state, invariants, and the events the wallet emits.
::: figure art/figures/06-aggregate.svg | Figure 6.1 — The Wallet aggregate: state, invariants, and the events it emits.
@@ -287,6 +388,34 @@ All three are enforced inside the aggregate methods. The framework exception for
`BusinessRuleViolation` extends `pyfly.kernel.BusinessException`, so the RFC 7807 problem-details mapper from Chapter 4 translates it automatically into an HTTP 422 response — no extra handler required.
+!!! note "What RFC 7807 means here"
+ RFC 7807 is the web standard for "problem details" — a small, predictable JSON shape (`type`, `title`, `status`, `detail`, plus your own fields) that an API returns when something goes wrong. PyFly's mapper turns any `BusinessException` into one of these automatically, so the `rule` slug you set on `BusinessRuleViolation` ends up in the response body where a client can read it without parsing English prose.
+
+**See the invariant hold.** The promise of an invariant is that a *failed* operation leaves the model exactly as it was. You can prove that from `uv run python`:
+
+```python
+>>> from lumen.models.entities.v1.wallet_entity import Wallet
+>>> from lumen.models.entities.v1.money import Money
+>>> from lumen.interfaces.enums.v1.currency import Currency
+>>> from pyfly.domain import BusinessRuleViolation
+>>> w = Wallet.open("wlt-1", "owner-1", Currency.EUR)
+>>> w.deposit(Money(500, Currency.EUR))
+>>> w.clear_events() # drain the open + deposit events
+[WalletOpened(...), FundsDeposited(...)]
+>>> try:
+... w.withdraw(Money(501, Currency.EUR))
+... except BusinessRuleViolation as exc:
+... print(exc.rule)
+...
+wallet-insufficient-funds
+>>> str(w.balance) # unchanged — the rule fired before any mutation
+'5.00 EUR'
+>>> w.pending_events() # and no event was queued for the failed attempt
+[]
+```
+
+That is the whole guarantee in three lines: the rule slug is stable and machine-readable, the balance did not move, and no event leaked for an operation that never happened.
+
!!! warning "Keep invariants in the model, not the service"
Moving the overdraft check back into `WalletService` creates two problems. First, any code that calls `repo.save(entity)` directly bypasses the check entirely. Second, you end up duplicating the rule across every path that modifies a wallet — the service, a background job, an admin command. When the rule changes — say, the product team introduces a configurable overdraft buffer — there is exactly one place to update: the aggregate method. That is the whole point.
@@ -407,6 +536,24 @@ def demonstrate_event_fields() -> None:
Notice that `FundsDeposited` carries both `amount` (the transaction) and `balance` (the post-operation balance, in minor units). A subscriber updating a read-model balance needs no callback into the aggregate or the database — everything is in the event. That self-contained design keeps consumers simple and eliminates extra round-trips.
+!!! note "Read-model, in plain language"
+ A **read-model** is a separate, query-optimised copy of data shaped for displaying — a dashboard total, a search index, a cache. Because `FundsDeposited` already carries the post-operation `balance`, a service that maintains such a copy can apply the event blindly without ever loading the wallet again. We build read-models properly in a later chapter; here, just note why putting `balance` in the event pays off.
+
+**Run it.** You can see the three auto-populated fields on any event from `uv run python`:
+
+```python
+>>> from lumen.models.entities.v1.wallet_entity import FundsDeposited
+>>> evt = FundsDeposited(wallet_id="w-1", amount=5000, currency="EUR", balance=15000)
+>>> evt.event_type
+'FundsDeposited'
+>>> evt.event_id # a fresh UUID, set by DomainEvent.__post_init__
+'3fa85f64-5717-4562-b3fc-2c963f66afa6'
+>>> evt.occurred_at # a UTC timestamp, also set automatically
+datetime.datetime(2026, 6, 16, 9, 30, 0, tzinfo=datetime.timezone.utc)
+```
+
+You declared four fields; you got seven, because `DomainEvent` contributes `event_id`, `occurred_at`, and the `event_type` property. That is the whole appeal of subclassing it — every event is self-identifying with zero extra code.
+
The event lifecycle spans two phases. Inside the aggregate: when `wallet.deposit(amount)` succeeds, it calls `self.raise_event(FundsDeposited(...))`, appending the event to a private buffer in `AggregateRoot`. Nothing is published yet. At the service boundary: after the repository saves the aggregate and the transaction commits, the application service drains the buffer and publishes. This *save-first, publish-after* sequence guarantees that an event is never dispatched for a change that failed to persist. Listing 6.6 shows that boundary in full:
::: listing lumen/wallet_application_service.py | Listing 6.6 — Draining domain events after a successful save
@@ -456,6 +603,18 @@ class WalletApplicationService:
**How it works.** `open_wallet` calls `Wallet.open`, which queues a `WalletOpened` event internally; after the `save`, `wallet.clear_events()` returns that one event and `publish` dispatches it. `deposit` follows the same three-step pattern: load, mutate, save — then drain. The `for event in wallet.clear_events()` loop is intentionally explicit rather than hidden inside the repository, because the application service is the right place to decide *when* publishing happens — after the transaction boundary, not before.
+**The publish cycle, step by step.** Every command method in the application service follows the same four-beat rhythm. Learn it once and every future use-case (transfer, refund, freeze) writes itself:
+
+Step 1 — Load (or create). For a new wallet, call the `Wallet.open` factory; for an existing one, `await self._repo.find(wallet_id)`.
+
+Step 2 — Mutate through a behaviour method. Call `wallet.deposit(...)` or `wallet.withdraw(...)`. This is where the invariants run and the events get queued in the aggregate's buffer.
+
+Step 3 — Save. `await self._repo.save(wallet)`. Nothing has been published yet, on purpose — if the save fails, no event escapes.
+
+Step 4 — Drain and publish. `for event in wallet.clear_events(): await self._events.publish(event)`. This runs only after the save succeeds, so an event is never dispatched for a change that did not persist.
+
+**What just happened.** The aggregate decides *what* happened and records it; the application service decides *when* the world hears about it. Keeping those two responsibilities apart is why the wallet stays free of any broker, queue, or transaction code — and why the publish order is "save first, publish after."
+
!!! tip "Event ordering"
`raise_event` appends to the buffer in call order. `clear_events` drains and clears it, returning events in the same order. If a single aggregate method raises multiple events (a batch operation, for example), they arrive at the event bus in the order they were raised — oldest first.
@@ -541,6 +700,35 @@ def to_aggregate(entity: WalletEntity) -> Wallet:
`to_entity` writes `wallet.balance.amount` (an integer) directly into `balance_minor` — no float conversion. `to_aggregate` reconstructs a `Currency` enum from the stored ISO-4217 string via `Currency(entity.currency)`, then builds a `Money` from the raw integer minor-unit value. The `created_at` field is preserved on the round-trip so rehydrated aggregates carry their original timestamp. There is no floating-point boundary crossing anywhere.
+**Build the mapper step by step.** A mapper is just two pure functions. There is no framework magic to wire up; you write them and call them at the right moments.
+
+Step 1 — Write `to_entity(wallet)`. Read each piece of the aggregate and copy it into the flat row: `wallet.id`, `wallet.owner_id`, `wallet.currency.value` (the ISO string, not the enum), `wallet.balance.amount` (the raw integer minor units), and `wallet.created_at`. The `assert wallet.id is not None` makes the precondition explicit — you only persist wallets that already have an id.
+
+Step 2 — Write `to_aggregate(entity)`, the reverse. Rebuild the `Currency` enum from the stored string with `Currency(entity.currency)`, wrap the integer back into a `Money`, and construct a `Wallet`. Passing `created_at=entity.created_at` preserves the original timestamp across the round-trip.
+
+Step 3 — Call them at the boundary, not inside the aggregate. The command handler calls `to_entity` just before `repo.save(...)` and `to_aggregate` just after `repo.find(...)`. The `Wallet` class never imports the row, and the row never imports the `Wallet`.
+
+**Run it.** The round-trip is pure Python — no live database required to prove it preserves every field. From `uv run python`:
+
+```python
+>>> from lumen.models.entities.v1.wallet_entity import Wallet
+>>> from lumen.models.entities.v1.money import Money
+>>> from lumen.interfaces.enums.v1.currency import Currency
+>>> from lumen.core.mappers import wallet_mapper
+>>> w = Wallet.open("wlt-1", "owner-1", Currency.EUR)
+>>> w.deposit(Money(2500, Currency.EUR))
+>>> row = wallet_mapper.to_entity(w) # aggregate -> flat row
+>>> (row.id, row.currency, row.balance_minor)
+('wlt-1', 'EUR', 2500)
+>>> back = wallet_mapper.to_aggregate(row) # flat row -> aggregate
+>>> str(back.balance), back.owner_id
+('25.00 EUR', 'owner-1')
+```
+
+The integer `2500` crosses both directions untouched — no float ever appears at the persistence boundary, which is the whole reason `Money` stores minor units.
+
+**What just happened.** You kept two models that never import each other and bridged them with two tiny functions. A schema change now touches only `WalletEntity`; a rule change touches only `Wallet`. Neither can break the other by accident.
+
The mapper is intentionally narrow. It does not enforce rules — `Wallet.__init__` and the behaviour methods do that. It does not publish events — the application service does that. It only translates shape.
**The repository.** The application service never interacts with `WalletEntity` directly. Instead a command handler calls `wallet_mapper.to_entity(wallet)` before persisting and `wallet_mapper.to_aggregate(entity)` after loading, while the framework `WalletRepository(Repository[WalletEntity, str])` handles all SQL. Chapter 5 covers `Repository` in full; the key point here is that `Wallet` itself never imports SQLAlchemy — the aggregate stays free of persistence concerns across both sides of the mapper boundary.
@@ -610,6 +798,39 @@ def filter_eligible(
**How it works.** `HasPositiveBalance` delegates to `wallet.balance.is_positive` (a property, no parentheses). `IsInCurrency` uses identity comparison (`is`) because `Currency` is a `StrEnum` with singleton members. The `eligible_for_withdrawal` factory combines them with `&`, producing a composite whose `is_satisfied_by` returns `True` only when both checks pass. Because `Specification` implements `__call__`, you pass the composite directly to `filter()` — no lambda wrapper needed.
+**Build a specification step by step.**
+
+Step 1 — Subclass `Specification[Wallet]` and implement the single required method, `is_satisfied_by(self, wallet) -> bool`. That is the entire contract: return `True` or `False`, never raise.
+
+Step 2 — If the rule needs a parameter (like a currency to match), take it in `__init__` and store it, as `IsInCurrency` does. A parameterless rule like `HasPositiveBalance` skips this.
+
+Step 3 — Compose with the Boolean operators. `HasPositiveBalance() & IsInCurrency(currency)` builds a new specification whose `is_satisfied_by` is true only when both halves are. `|` is or, `~` is not.
+
+Step 4 — Use it as a predicate. Because `Specification` implements `__call__`, the composite *is* a callable, so you can hand it straight to Python's built-in `filter()` with no lambda in between.
+
+**Run it.** Specifications are plain objects. Drop the classes from Listing 6.9 into a module (say `src/lumen/domain/specs.py`) and try them from `uv run python`:
+
+```python
+>>> from lumen.models.entities.v1.wallet_entity import Wallet
+>>> from lumen.models.entities.v1.money import Money
+>>> from lumen.interfaces.enums.v1.currency import Currency
+>>> from lumen.domain.specs import eligible_for_withdrawal
+>>> empty = Wallet.open("wlt-1", "owner-1", Currency.EUR)
+>>> funded = Wallet.open("wlt-2", "owner-2", Currency.EUR)
+>>> funded.deposit(Money(1000, Currency.EUR))
+>>> usd = Wallet.open("wlt-3", "owner-3", Currency.USD)
+>>> usd.deposit(Money(1000, Currency.USD))
+>>> spec = eligible_for_withdrawal(Currency.EUR)
+>>> spec(funded), spec(empty), spec(usd)
+(True, False, False)
+>>> [w.id for w in filter(spec, [empty, funded, usd])]
+['wlt-2']
+```
+
+Only the funded EUR wallet satisfies both halves of the composite, so `filter` keeps just that one.
+
+**What just happened.** You expressed a read-only business rule as a named, reusable object instead of an inline `if`. It composes with other rules at runtime, it passes straight into `filter`, and it is trivial to unit-test in isolation — and crucially, it lives *outside* the aggregate, where eligibility checks belong.
+
The key design discipline: a specification is a *predicate*, not a *guard*. It returns `True` or `False` and never raises. Aggregate invariants (overdraft, currency mismatch) belong inside `deposit` and `withdraw` because they must *prevent* a state change. Specifications belong in services and query handlers because they *select* or *classify* — they never mutate.
Specifications are especially useful where rules combine dynamically: an admin search that adds filters based on the operator's role, or a batch job that partitions a list into eligible and ineligible wallets. Each class has exactly one method and no side-effects, making isolated unit tests trivial.
diff --git a/book/manuscript/07-cqrs.md b/book/manuscript/07-cqrs.md
index 8936c73a..0a6452ee 100644
--- a/book/manuscript/07-cqrs.md
+++ b/book/manuscript/07-cqrs.md
@@ -10,6 +10,11 @@ Lumen's wallet is now a first-class citizen of the domain. The `Wallet` aggregat
By the end of this chapter Lumen's controller dispatches commands and queries instead of calling the service directly. `OpenWallet`, `DepositFunds`, and `WithdrawFunds` travel the command path; `GetWallet`, `GetBalance`, `ListWallets`, and `ListRichWallets` travel the query path. The `Wallet` aggregate you built in Chapter 6 remains untouched — CQRS does not replace the domain model; it is the delivery mechanism for instructions to it.
+!!! note "New jargon, in plain terms"
+ A **bus** here is not hardware — it is a single object you hand a message to, and it figures out which handler should run. A **handler** is one small class that does the work for exactly one message type. A **DTO** (data transfer object) is a plain shape — id, owner, balance — that you put on the wire as JSON; it is deliberately separate from your rich domain object. A **projection** is a read-only slice of your data shaped for one specific view. You will meet each of these as we build, one piece at a time, so do not worry if they feel abstract right now.
+
+This chapter is built around PyFly **v26.6.110**, and every listing is taken verbatim from the Lumen sample under `samples/lumen/src/lumen`. We will go gently: build the commands first, then their handlers, then the queries, then wire everything into the controller — running the app and the tests at each milestone so you can see the pieces come alive before the next one is added.
+
---
## Why separate reads from writes
@@ -36,7 +41,12 @@ Before writing a single line of handler code, name your system's intentions. In
A command is a frozen dataclass that inherits from `Command[R]`, where `R` is the type the handler returns. The generic parameter is documentation and a type-checker hint; the bus does not enforce it at runtime.
-Lumen's commands live in three separate files under `lumen/core/services/wallets/`, one per intent. Here is the first:
+!!! note "What is `Command[R]`?"
+ The `[R]` in `Command[R]` is a *generic type parameter* — a placeholder for "whatever this command returns". `OpenWallet(Command[str])` says "sending me gives you back a `str`" (the new wallet id). `DepositFunds(Command[int])` says "sending me gives you back an `int`" (the new balance). Your editor and type checker use this to catch mistakes; at runtime the bus simply returns whatever the handler returned.
+
+Lumen's commands live in three separate files under `lumen/core/services/wallets/`, one per intent. We will build them one at a time.
+
+**Step 1 — Write the `OpenWallet` command.** Create `open_wallet_command.py`. It carries the two facts needed to open a wallet — who owns it and which currency it holds — and a `validate()` hook that rejects a blank owner before the bus ever looks for a handler.
::: listing lumen/core/services/wallets/open_wallet_command.py | Listing 7.1 — OpenWallet: a frozen command with built-in validation
from __future__ import annotations
@@ -62,7 +72,7 @@ class OpenWallet(Command[str]):
return ValidationResult.success()
:::
-And the deposit and withdrawal commands:
+**Step 2 — Write the `DepositFunds` and `WithdrawFunds` commands.** Each carries a `wallet_id` to target and an `amount` in minor units, and validates that the id is present and the amount is positive. They are deliberately near-identical twins — same shape, opposite direction.
::: listing lumen/core/services/wallets/deposit_funds_command.py | Listing 7.2 — DepositFunds: amount in minor units, no currency field
from __future__ import annotations
@@ -128,6 +138,9 @@ Four design choices are baked into every command:
- **Imperative-mood naming**: `DepositFunds`, not `WalletDeposit` or `DepositFundsCommand`. This makes the command log read like a business audit trail — a sequence of things that *happened* — rather than a list of technical operations.
+!!! note "What just happened"
+ You now have three small files, each describing *one thing the system can do*, with no logic beyond a couple of field checks. There are no handlers yet — these commands do nothing on their own. That is the point: a command is an envelope, not the worker who opens it. Next you will write the workers (the handlers) that actually carry out each intent.
+
### Implementing a command handler
A command handler inherits from `CommandHandler[C, R]` and implements exactly one method: `do_handle`. You write the *what*; the bus wraps it with the *how*.
@@ -136,7 +149,10 @@ A command handler inherits from `CommandHandler[C, R]` and implements exactly on
**`@transactional()` turns `do_handle` into a committed unit of work.** Command handlers inject `session_factory: async_sessionmaker[AsyncSession]` and store it as `self._session_factory`. When `@transactional()` runs `do_handle` it opens a fresh session from that factory, swaps it onto the repository for the duration of the call, commits on success, and rolls back on any exception. Without `@transactional()` the framework's shared session only flushes — the write survives within the request but is never committed to the database.
-Here is `OpenWalletHandler`:
+!!! note "Flush vs. commit, in plain terms"
+ A **flush** pushes your pending changes into the database connection so later queries in the *same* session can see them — but they are still inside an open transaction that can be rolled back. A **commit** makes them permanent. Without `@transactional()` your deposit would flush (visible mid-request) but never commit (gone after the request). The decorator is what makes the change stick.
+
+**Step 3 — Write `OpenWalletHandler`.** Now build the worker for the first command. Create `open_wallet_handler.py`, stack `@command_handler` over `@service`, inject the repository, the event publisher, and the session factory, and implement the single `do_handle` method.
::: listing lumen/core/services/wallets/open_wallet_handler.py | Listing 7.4 — OpenWalletHandler: @transactional() unit of work + upsert
from __future__ import annotations
@@ -190,7 +206,7 @@ Walk through `do_handle` step by step. `f"wlt-{uuid4()}"` generates a stable pre
Note the constructor requirement: `super().__init__()` is mandatory on `CommandHandler`. Skip it and the base-class bookkeeping — correlation context, lifecycle hooks — is never initialized. The repository, `EventPublisher`, and `session_factory` are all injected by the DI container from type hints; no factory configuration is needed.
-Here are the deposit and withdrawal handlers:
+**Step 4 — Write the deposit and withdrawal handlers.** These two add one move that `OpenWalletHandler` did not need: they *load* an existing wallet before acting on it. The shape is the same in both, differing only in whether they call `wallet.deposit(...)` or `wallet.withdraw(...)`.
::: listing lumen/core/services/wallets/deposit_funds_handler.py | Listing 7.5 — DepositFundsHandler: find_by_id → to_aggregate → act → upsert
from __future__ import annotations
@@ -290,6 +306,23 @@ class WithdrawFundsHandler(CommandHandler[WithdrawFunds, int]):
Notice what is absent: no try/except blocks, no logging calls, no tracing setup. All of that belongs to the bus pipeline. The handler is a pure expression of business intent.
+!!! note "What just happened"
+ The write side is now complete: three commands and three handlers. Sending `OpenWallet` creates and persists a fresh wallet; sending `DepositFunds` or `WithdrawFunds` loads one, drives its domain behaviour, and saves it. The `@command_handler` + `@service` stack means PyFly discovers and wires these at startup — you never call them directly, and you never register them by hand.
+
+**Run it — confirm the write side works end to end.** The Lumen sample already ships a test that exercises the full command path. From the `samples/lumen` directory, run just that test:
+
+::: listing terminal | Listing 7.4a — Exercise the command path
+uv run --extra dev pytest tests/test_cqrs_flow.py::test_full_wallet_lifecycle -q
+:::
+
+You should see a single passing test:
+
+```
+1 passed in 0.42s
+```
+
+That one test opens a wallet, deposits 1 500 minor units, withdraws 500, and asserts the balance lands at 1 000 — proving `OpenWalletHandler`, `DepositFundsHandler`, and `WithdrawFundsHandler` all commit through the bus. If you see `0 items collected`, you are not in the `samples/lumen` directory; `cd` there first. If you see `no handler found`, double-check that both decorators are present on each handler — that is the single most common cause.
+
### The entity↔aggregate mapper
Command handlers do not interact with the repository through the domain aggregate. They interact through a flat `WalletEntity` row — the persistence shape the framework `Repository[WalletEntity, str]` understands — and use `wallet_mapper` to translate between the two worlds:
@@ -339,7 +372,11 @@ A **query** is a frozen dataclass that inherits from `Query[R]`, where `R` is th
Queries return **read DTOs** rather than domain aggregates. The separation is deliberate. If `GetWalletHandler` returned a `Wallet` aggregate, the API layer would be coupled to every field on the aggregate — a change to the domain model could silently break the API contract. A dedicated `WalletDto` Pydantic model projects exactly the fields the HTTP response needs. Add a field to `Wallet`? The projection changes only if you explicitly include it in the DTO. Remove a field from `Wallet`? The projection compiles until you clean it up.
-::: listing lumen/core/services/wallets/get_wallet_query.py | Listing 7.7 — GetWallet: a frozen query returning WalletDto or None
+The read side mirrors the write side step for step — query message, then query handler — but with two simplifications: no `@transactional()` (nothing to commit) and no event publishing (nothing changed).
+
+**Step 5 — Write the single-lookup queries.** Create `get_wallet_query.py` and `get_balance_query.py`. Both carry only a `wallet_id`; what differs is what they promise to return.
+
+::: listing lumen/core/services/wallets/get_wallet_query.py | Listing 7.7 — GetWallet: a single-lookup query returning a full WalletDto
from __future__ import annotations
from dataclasses import dataclass
@@ -373,7 +410,7 @@ class GetBalance(Query[BalanceDto | None]):
Both queries carry only `wallet_id`. `GetWallet` returns a `WalletDto` — the full representation including `id`, `owner_id`, `currency`, `balance_minor`, `balance`, and `created_at`. `GetBalance` returns a `BalanceDto` — a lighter projection that omits `owner_id` and `created_at`. A balance poll does not need the owner; leaving those fields out saves bandwidth and avoids accidentally exposing account ownership in a response that callers may log. Keeping the two queries separate means you can tune each independently — caching, authorization, or a dedicated read store — without touching the other.
-The query handlers live under the same `wallets/` package as the commands. **The same `@query_handler` + `@service` stacking applies**: `@query_handler` registers the class with the handler registry; `@service` wires it into the DI container. Both decorators are required for the same reasons as on command handlers.
+**Step 6 — Write the single-lookup query handlers.** The query handlers live under the same `wallets/` package as the commands. **The same `@query_handler` + `@service` stacking applies**: `@query_handler` registers the class with the handler registry; `@service` wires it into the DI container. Both decorators are required for the same reasons as on command handlers. Notice how much smaller these are than the command handlers — no session factory, no event publisher, just the repository and a one-line projection.
::: listing lumen/core/services/wallets/get_wallet_handler.py | Listing 7.9 — GetWalletHandler: find_by_id → entity_to_dto → return
from __future__ import annotations
@@ -427,7 +464,10 @@ Both handlers delegate projection to `wallet_mapper` — the single module that
The read side does not stop at single-resource lookups. Production systems need lists with pagination metadata and the ability to filter by runtime predicates. The framework handles both through the `Repository` base class.
-`ListWallets` wraps a `Pageable` (page number, size, sort) and asks the repository for a counted, sorted, limited slice. `ListRichWallets` adds a `min_minor` threshold and runs it through a composable `Specification` — a predicate object that can be combined with `&`, `|`, and `~` before execution.
+!!! note "Pageable and Specification, in plain terms"
+ A **`Pageable`** bundles three things a list endpoint needs: which page you want, how big each page is, and how to sort. A **`Specification`** is a reusable, composable filter — think of it as a `WHERE` clause you can build as an object and combine with `&` (and), `|` (or), and `~` (not) before it ever touches SQL. Both come from the framework's data layer; you do not write the SQL yourself.
+
+**Step 7 — Write the list queries and handlers.** `ListWallets` wraps a `Pageable` (page number, size, sort) and asks the repository for a counted, sorted, limited slice. `ListRichWallets` adds a `min_minor` threshold and runs it through a composable `Specification`. Build the two query messages first, then their handlers.
::: listing lumen/core/services/wallets/list_wallets_query.py | Listing 7.11 — ListWallets: a Pageable-carrying query
from __future__ import annotations
@@ -530,6 +570,35 @@ The return value is whatever `do_handle` returned — a `BalanceDto` or `None`.
!!! note "Queries return None, not exceptions"
Query handlers return `None` when the resource is not found rather than raising `AggregateNotFound`. This is a deliberate convention: a query that finds nothing is not an error — it is an answer. The controller turns a `None` result into a 404 response, keeping the HTTP concern out of the handler.
+!!! note "What just happened"
+ Both sides of CQRS now exist. Three commands and three handlers change state; four queries and four handlers read it. None of them know about HTTP, and none of them are registered by hand — the decorators do that at startup. The only thing missing is the HTTP boundary that turns a web request into a message and a message result into a web response. That is the controller, which you build next.
+
+**Run it — confirm every handler is registered.** Before touching the controller, prove the bus discovered all your handlers. Start the app, then ask the CQRS health indicator how many handlers it found. From the `samples/lumen` directory:
+
+::: listing terminal | Listing 7.14a — Start Lumen
+uv run pyfly run --server uvicorn
+:::
+
+In a second terminal, query the actuator health endpoint. In v26.6.110 the actuator lives on its own management port, **9090** by default — not the app's 8080:
+
+::: listing terminal | Listing 7.14b — Count the registered handlers
+curl -s localhost:9090/actuator/health | python -m json.tool
+:::
+
+Look for the `cqrs_health_indicator` block. With every command and query handler from this chapter in place it reports three command handlers and four query handlers:
+
+```json
+"cqrs_health_indicator": {
+ "status": "UP",
+ "details": {"command_handlers": 3, "query_handlers": 4}
+}
+```
+
+If a count is lower than you expect, a handler is missing one of its two decorators and the registry never mapped it. Stop the server with `Ctrl-C` when you are done.
+
+!!! note "The actuator lives on its own port now"
+ In PyFly v26.6.110 the business API and the actuator run on **separate** ports — Spring-style. Your wallet endpoints listen on `pyfly.server.port` (default **8080**), while actuator endpoints and the admin dashboard listen on `pyfly.management.server.port` (default **9090**), which is open and unauthenticated by default. Lumen keeps the defaults, so health is at `localhost:9090/actuator/health` and the wallet API is at `localhost:8080/api/v1/wallets`. The default actuator HTTP exposure is `health,info`; expose more via `pyfly.management.endpoints.web.exposure.include`. Lock the management port down in production with `pyfly.management.security.enabled: true`, or disable it entirely with `pyfly.management.server.port: -1`.
+
---
## Wiring the bus into the controller
@@ -552,7 +621,7 @@ The framework registers a controller's routes in **alphabetical method-name orde
The collection handlers are named `list_wallets` and `list_rich_wallets`; the single-resource handlers are named `wallet_detail` and `wallet_balance`. Alphabetically, `l` sorts before `w`, so the collection routes (`GET /`, `GET /rich`) are always registered ahead of the parameterised routes (`GET /{wallet_id}`, `GET /{wallet_id}/balance`). If you rename `wallet_detail` to something that sorts before `list_*`, the `/rich` route will silently break.
-Here is the complete controller:
+**Step 8 — Wire the buses into the controller.** Replace the old `WalletApplicationService` dependency with the two buses, then turn each endpoint into a one-liner: build a command or query from the request, dispatch it, return the result. Here is the complete controller.
::: listing lumen/web/controllers/wallet_controller.py | Listing 7.15 — WalletController: DefaultCommandBus + DefaultQueryBus + paged list endpoints
from __future__ import annotations
@@ -692,12 +761,57 @@ The `wallet_detail` and `wallet_balance` methods show the only remaining HTTP co
!!! tip "Let the bus raise"
You do not need to catch `CommandProcessingException` or `QueryProcessingException` in the controller unless you want to customize the error shape. The global exception handler maps `AggregateNotFound` to 404 and `BusinessRuleViolation` to 422 — the same as before. The bus exceptions propagate those originals transparently.
+**Run it — drive the full HTTP path.** The vertical slice is complete: HTTP request → command/query → bus → handler → domain → repository → response. Prove it from the outside. Start the app (`uv run pyfly run --server uvicorn`), then in a second terminal open a wallet:
+
+::: listing terminal | Listing 7.15a — Open a wallet over HTTP
+curl -s -X POST localhost:8080/api/v1/wallets \
+ -H 'content-type: application/json' \
+ -d '{"owner_id":"u-1","currency":"EUR"}'
+:::
+
+The `open_wallet` endpoint dispatches `OpenWallet` and returns the generated id:
+
+```json
+{"wallet_id": "wlt-c5bbb2a7-dd49-4321-932e-e4c6bfa5cc2c"}
+```
+
+Copy that id, deposit into it, then read the balance back:
+
+::: listing terminal | Listing 7.15b — Deposit, then read the balance
+curl -s -X POST localhost:8080/api/v1/wallets/wlt-c5bbb2a7-dd49-4321-932e-e4c6bfa5cc2c/deposit \
+ -H 'content-type: application/json' -d '{"amount":1500}'
+
+curl -s localhost:8080/api/v1/wallets/wlt-c5bbb2a7-dd49-4321-932e-e4c6bfa5cc2c/balance
+:::
+
+The deposit echoes the new balance in minor units; the balance query returns the `BalanceDto`:
+
+```json
+{"wallet_id": "wlt-c5bbb2a7-dd49-4321-932e-e4c6bfa5cc2c", "balance_minor": 1500}
+{"wallet_id": "wlt-c5bbb2a7-dd49-4321-932e-e4c6bfa5cc2c", "balance_minor": 1500, "balance": 15.0}
+```
+
+Finally, confirm the route-ordering decision from earlier pays off — list the wallets and the "rich" subset:
+
+::: listing terminal | Listing 7.15c — Both list routes resolve correctly
+curl -s 'localhost:8080/api/v1/wallets?page=1&size=20'
+curl -s 'localhost:8080/api/v1/wallets/rich?min_minor=1000'
+:::
+
+Both return a `PageDto` envelope (`items`, `total`, `total_pages`, `has_next`, `has_previous`). The `/rich` call resolves to `list_rich_wallets`, *not* to `wallet_detail` looking for a wallet whose id is the literal string `"rich"` — exactly because `list_*` sorts before `wallet_*`. If `/rich` ever returns a 404, that ordering has been broken; revisit the method-name rule above.
+
+!!! warning "Use a real id"
+ The wallet id above is illustrative — yours will differ on every `open_wallet` call. Paste the id returned by your own `POST /api/v1/wallets` into the deposit and balance URLs, or you will get a 404.
+
---
## The handler pipeline
A single `send` or `query` call triggers more than just the handler. Understanding the pipeline tells you where to put each cross-cutting concern — and, just as importantly, where *not* to put it.
+!!! note "What is a 'pipeline'?"
+ A **pipeline** is just a fixed sequence of steps the bus runs around your handler — like an assembly line. Your message enters one end, passes through validation and authorization, gets handled, and (for commands) has its events published, before the result comes back out. You write only the one step in the middle (`do_handle`); the bus owns the rest, identically for every message.
+
The pipeline is defined once, in the bus, and applies uniformly to every handler. You never write pipeline logic inside a handler. The order is strict:
| Step | Where it is defined | Applies to | Failure result |
@@ -810,6 +924,20 @@ Each handler carries the `@command_handler` + `@service` (or `@query_handler` +
Adding a new command now means three things: define a frozen dataclass, implement one `do_handle` decorated with `@command_handler` + `@service` and annotated with `@transactional()`, and add one endpoint that calls `self._commands.send`. The pipeline applies automatically.
+**Run it — the whole chapter, in one command.** From the `samples/lumen` directory, run the CQRS flow tests one last time to confirm every piece you built this chapter still hangs together:
+
+::: listing terminal | Listing 7.17 — Verify the full CQRS slice
+uv run --extra dev pytest tests/test_cqrs_flow.py -q
+:::
+
+All five scenarios pass — the happy-path lifecycle, the not-found query, the rejected overdraw, the rejected non-positive deposit, and the rejected deposit to an unknown wallet:
+
+```
+5 passed in 0.61s
+```
+
+Those last four cases are worth pausing on: each one exercises the *pipeline*, not the handler. The overdraw is refused by the aggregate and surfaces as `CommandProcessingException`; the non-positive deposit never reaches a handler at all because `validate()` rejects it first. You wrote zero error-handling code to get any of that.
+
---
## Try it yourself {.exercises}
diff --git a/book/manuscript/08-eda.md b/book/manuscript/08-eda.md
index a93f508a..cc5077ed 100644
--- a/book/manuscript/08-eda.md
+++ b/book/manuscript/08-eda.md
@@ -12,6 +12,11 @@ The gap matters in practice. Lumen needs a balance read model that stays in sync
This chapter builds the reaction side of Lumen's architecture. You will wire `EventPublisher` into the command handlers, introduce the `publish_domain_events` bridge that drains the aggregate's buffer and forwards each event to the bus, and write a `WalletAuditListener` that subscribes using `@event_listener` and maintains two in-memory projections: an immutable audit trail and a running deposit total. By the end of the chapter the write path and the read infrastructure are fully decoupled — each side evolves without the other noticing.
+We will build this gradually, one small piece at a time. Each feature comes with a numbered walkthrough, the exact command to run, and the output you should expect to see. If you have followed along from Chapter 7 you already have the wallet command handlers and the `Wallet` aggregate in place; this chapter is verified against PyFly v26.6.110 and the Lumen sample under `samples/lumen`.
+
+!!! note "Jargon, in plain language"
+ A handful of terms recur in this chapter. A **domain event** is a small, frozen record of a business fact that already happened — for example "funds were deposited." A **publisher** is the thing that announces such facts; a **listener** (or *subscriber*) is code that reacts to them. A **bus** is the in-between switchboard that carries each announcement from the publisher to every interested listener. A **projection** is a read model that a listener builds up from events — here, an audit trail and a running total. A **port** is an interface (a Python `Protocol`) that your code depends on so the concrete implementation behind it can be swapped without changing callers. Keep these five in mind; the rest of the chapter is mostly about connecting them.
+
---
## Two kinds of events
@@ -65,6 +70,36 @@ pyfly:
Without this line the `EventPublisher` bean is not registered and any handler that declares `events: EventPublisher` in its constructor will fail to start.
+Let us turn that bus on before we use it.
+
+**Step 1 — Open Lumen's `pyfly.yaml`.** Find the top-level `pyfly:` block. You will already see keys such as `app`, `server`, and `data` from the previous chapters.
+
+**Step 2 — Add the `eda` block.** Insert the two lines shown in Listing 8.0 as a child of `pyfly:`, at the same indentation level as `server:` and `data:`. Indentation is significant in YAML, so line them up exactly.
+
+**Step 3 — Save the file.** That is all the configuration the in-memory bus needs — no broker, no connection string, no extra dependency.
+
+!!! note "Run it"
+ Start the application and confirm it boots cleanly with the bus registered:
+
+ ```bash
+ uv run pyfly run --server uvicorn
+ ```
+
+ In the startup log you should see the application come up on the default app port and the management endpoints on the separate management port:
+
+ ```text
+ INFO starting_application name=lumen version=1.0.0
+ INFO uvicorn running on http://127.0.0.1:8080
+ INFO management endpoints on http://127.0.0.1:9090
+ ```
+
+ If instead the process exits with an error mentioning that an `EventPublisher` dependency could not be resolved, the `eda` block is missing or mis-indented — return to Step 2.
+
+!!! note "Where the app and the dashboard live"
+ As of v26.6.110 the application listens on `pyfly.server.port` (default `8080`), while the actuator and admin dashboard run on a **separate** management port, `pyfly.management.server.port` (default `9090`). The management port is open and unauthenticated by default; set `pyfly.management.security.enabled: true` to lock it down, or `pyfly.management.server.port: -1` to disable management endpoints entirely. None of this affects the EDA bus — it is purely in-process — but it is worth knowing which port is which when you start poking at the running app later in the chapter.
+
+**What just happened.** You flipped a single configuration switch and PyFly registered an `EventPublisher` bean for you. From now on any `@service` that asks for `events: EventPublisher` in its constructor receives the in-memory bus automatically. Nothing publishes anything yet — that comes next — but the plumbing is in place.
+
### The EventEnvelope
Every domain event reaches its listeners wrapped in an **`EventEnvelope`**. Think of it as the metadata layer that transforms a bare Python dictionary into a traceable, auditable, first-class fact. It is a frozen dataclass — immutable once created — that pairs the payload with the context every listener needs.
@@ -82,6 +117,8 @@ Three fields deserve particular attention. `event_id` is a stable UUID generated
`event_type` holds the **class name** of the domain event — `"WalletOpened"`, `"FundsDeposited"`, or `"FundsWithdrawn"` — not a dot-separated path. Listeners subscribe by those same class names, so the subscription contract is defined by the domain model, not by string conventions invented outside it.
+**What just happened.** Do not be put off by the six-field table — in everyday code you touch only two of them. When you publish you supply `event_type`, `payload`, and `destination`; the bus fills in `event_id`, `timestamp`, and `headers` for you. When you react, you read `envelope.event_type` to know *what* happened and `envelope.payload` to know the details. The other three fields are there when you need them (idempotency, ordering, tracing) and quietly out of the way when you do not.
+
### The domain events in the Wallet aggregate
The `Wallet` aggregate raises typed, frozen-dataclass domain events. Each event's class name becomes its routing `event_type` on the bus:
@@ -189,7 +226,17 @@ In Chapter 7 the command handlers loaded aggregates, drove domain behaviour, and
The `@transactional()` decorator (from `pyfly.data.relational.sqlalchemy`) opens a dedicated `AsyncSession` from the injected `async_sessionmaker`, binds it to the repository for the call, commits on success, and rolls back on failure. That means the load → mutate → save sequence is one committed unit of work, and no event is published unless the row actually lands in the database.
-Here is the updated `DepositFundsHandler`:
+Here is the change, broken into the four edits you will make to `DepositFundsHandler`.
+
+**Step 1 — Add the publisher to the constructor.** Alongside the existing `repository` parameter, accept `events: EventPublisher` and store it as `self._events`. Type it as the *protocol* `EventPublisher`, never as `InMemoryEventBus` — that is what keeps the handler ignorant of which bus is running.
+
+**Step 2 — Accept the session factory.** Add `session_factory: async_sessionmaker[AsyncSession]` and store it as `self._session_factory`. The `@transactional()` decorator looks for exactly this attribute name to open its unit of work, so the name matters.
+
+**Step 3 — Decorate `do_handle` with `@transactional()`.** This wraps the whole load-mutate-save sequence in one committed transaction.
+
+**Step 4 — Drain and publish after the save.** As the last step inside `do_handle`, after `self._repository.upsert(...)`, call `await publish_domain_events(self._events, wallet.clear_events())`. `wallet.clear_events()` returns the buffered events *and* empties the buffer, so they are never published twice.
+
+Putting those four edits together gives the updated `DepositFundsHandler`:
::: listing lumen/core/services/wallets/deposit_funds_handler.py | Listing 8.3 — DepositFundsHandler: @transactional unit-of-work, then publish
from __future__ import annotations
@@ -240,6 +287,27 @@ class DepositFundsHandler(CommandHandler[DepositFunds, int]):
Three design decisions are worth noting. First, `events: EventPublisher` is typed as the protocol, not as `InMemoryEventBus` — the DI container injects whichever implementation is registered, so the handler never knows or cares which bus is active. Second, the publish call sits *after* `self._repository.upsert(...)` inside the `@transactional()` unit of work: if the save fails the decorator rolls back before `publish_domain_events` is reached, so listeners never see a fact that never persisted. Third, the handler works with the ORM entity directly via `to_aggregate` / `to_entity` mappers — the aggregate is rehydrated from the row, mutated, and mapped back to a row before the upsert. If the publish fails after a successful save you have an at-least-once delivery challenge — Chapter 10 addresses that with transactional outbox patterns. For now, the in-memory bus never fails.
+!!! note "Run it"
+ With the application running (`uv run pyfly run --server uvicorn`), open a wallet and deposit into it from a second terminal:
+
+ ```bash
+ # Open a wallet — returns its id
+ curl -s -X POST http://localhost:8080/api/v1/wallets \
+ -H 'content-type: application/json' \
+ -d '{"owner_id": "u-1", "currency": "EUR"}'
+ # {"wallet_id": "wlt-…"}
+
+ # Deposit 1500 minor units (15.00 EUR) into that wallet
+ curl -s -X POST http://localhost:8080/api/v1/wallets/wlt-…/deposit \
+ -H 'content-type: application/json' \
+ -d '{"amount": 1500}'
+ # {"wallet_id": "wlt-…", "balance": 1500}
+ ```
+
+ The HTTP response confirms the balance, but the more interesting evidence is in the application log: because the deposit published a `FundsDeposited` event and the audit listener (which you will build in the next section) reacts to it, you will see a `wallet_audit_observed` log line for `event_type=FundsDeposited`. No listener yet? Then the publish happens silently — which is exactly the point: the handler does not know whether anyone is listening.
+
+**What just happened.** The command handler now does one extra thing after saving: it drains the events the aggregate buffered and hands them to the bus. The crucial ordering is *save first, publish second*, all inside one transaction. If the database write rolls back, the events are never published, so a listener can never observe a fact that did not actually persist. The handler gained four lines and zero new knowledge — it still has no idea what, if anything, will react.
+
The `OpenWalletHandler` follows the same pattern:
::: listing lumen/core/services/wallets/open_wallet_handler.py | Listing 8.4 — OpenWalletHandler: @transactional, upsert, then publish WalletOpened
@@ -343,6 +411,16 @@ async def on_funds_deposited(envelope: EventEnvelope) -> None:
Lumen's production listener is `WalletAuditListener`. It subscribes to all three wallet domain events and maintains two in-memory projections: an ordered **audit trail** and a **running net-deposit total** per wallet.
+We will assemble it in small pieces. Read the four steps first, then study the full listing below — it is the same code, shown whole.
+
+**Step 1 — Declare a plain `@service` class.** A listener is just a service bean with some state. Give it an `__init__` that initialises the two projections: `self._entries: list[AuditEntry] = []` for the audit trail and `self._running_totals: dict[str, int] = {}` for the per-wallet totals.
+
+**Step 2 — Write the reaction method.** Add an `async def on_wallet_event(self, envelope: EventEnvelope) -> None`. It receives one `EventEnvelope` and nothing else.
+
+**Step 3 — Stamp it with `@event_listener`.** Decorate the method with `@event_listener(event_types=["WalletOpened", "FundsDeposited", "FundsWithdrawn"])`. One method can subscribe to all three class names in a single declaration. This decorator does not subscribe immediately — it *stamps* the method with metadata that `ApplicationContext` finds at startup and uses to auto-subscribe it to the `EventPublisher` bean.
+
+**Step 4 — Project the event.** Inside the method, append an `AuditEntry` for every event, then branch on `envelope.event_type` to adjust the running total: set it to zero on `WalletOpened`, add on `FundsDeposited`, subtract on `FundsWithdrawn`. Expose read accessors (`entries`, `entries_for`, `running_total`) so other code can query the projections.
+
::: listing lumen/core/services/listeners/wallet_audit_listener.py | Listing 8.6 — WalletAuditListener: audit trail + running-total projection
from __future__ import annotations
@@ -435,6 +513,8 @@ Here is what the listener does, step by step.
The method appends an `AuditEntry` for every event, then branches on `event_type` to update the running total. Notice what is absent: no import of the `Wallet` aggregate, no repository call, no knowledge of how the deposit was processed. The projection reacts purely to the published fact.
+**What just happened.** You wrote a self-contained reaction. The `@event_listener` decorator is the entire wiring story — there is no `bus.subscribe(...)` call anywhere in this file. At startup, `ApplicationContext` scans your `@service` beans, finds the stamped `on_wallet_event` method, and subscribes it to the bus for each of the three class names. The listener and the command handlers never reference each other; they are connected only through the events they agree to name.
+
!!! tip "Envelope metadata in projections"
`envelope.timestamp` gives you the authoritative event time — when the fact was recorded, not when the listener ran. Store it in your read model and you get a cheap `occurred_at` column for free, with no clock skew between writer and reader.
@@ -503,6 +583,22 @@ async def test_listener_observes_wallet_events(
The test proves the full chain: `OpenWalletHandler` → `publish_domain_events` → `InMemoryEventBus` → `WalletAuditListener.on_wallet_event` → `audit_listener.entries_for(...)`. No mocks, no fakes — the production code path runs as written.
+!!! note "Run it"
+ Run the event-listener test from the Lumen project root:
+
+ ```bash
+ uv run --extra dev pytest tests/test_event_listener.py -q
+ ```
+
+ You should see the suite pass:
+
+ ```text
+ ... [100%]
+ 3 passed in 0.XXs
+ ```
+
+ The three tests cover the happy path (open, deposit, withdraw, then assert the audit trail and running total), the empty-projection case before any command runs, and the negative case where an overdrawing withdrawal raises and therefore publishes *no* event — so it must not appear in the audit log. If a test fails with a missing-attribute error on `__pyfly_event_patterns__`, the `@event_listener` decorator is not applied to `on_wallet_event`; revisit Step 3 of the previous section.
+
What makes this design compelling is that adding the listener required zero changes to the command handlers, the `Wallet` aggregate, or any repository. `DepositFundsHandler` has no idea a projection exists. Both sides are entirely independent — each is a consequence of the same published fact, connected only by the bus.
---
diff --git a/book/manuscript/09-event-sourcing.md b/book/manuscript/09-event-sourcing.md
index 0dcdf6d8..b07586da 100644
--- a/book/manuscript/09-event-sourcing.md
+++ b/book/manuscript/09-event-sourcing.md
@@ -76,8 +76,13 @@ In Chapter 6, `Wallet` held `_balance: Money` as direct Python state — `deposi
This two-step indirection — apply, then handle — is the core mechanic of event sourcing. It enforces a strict discipline: every state transition is recorded exactly once as an event, and the aggregate's current state is always provable from its history.
+!!! note "Jargon: aggregate, handler, fold"
+ An **aggregate** is a single consistency boundary — one object that owns a set of related state and the rules that keep it valid. `LedgerAccount` is an aggregate: its balance and its overdraft rule live together. A **handler** (sometimes called an *apply-handler*) is the small function that takes one event and updates the aggregate's fields. A **fold** is the functional-programming term for walking a list and accumulating a result one element at a time — replaying a stream of events into a balance is exactly a fold over the event list. You will see "fold" used as a synonym for "the handler runs over each event."
+
**Zero-arg constructor.** `LedgerAccount.__init__` takes no arguments. This is required because `EventSourcedRepository` calls the factory as `LedgerAccount()` and then assigns `.id` before replaying the stream. Never construct a new ledger by passing arguments to `__init__` — call the `open` classmethod instead.
+We will build the aggregate in four moves, then run the unit tests to prove each invariant holds. Read the full listing first, then walk the steps below it.
+
Here are the domain events and the aggregate:
::: listing lumen/models/entities/v1/ledger_account.py | Listing 9.1 — LedgerAccount: an event-sourced aggregate that derives its balance from replay
@@ -240,6 +245,19 @@ The factory method `open` calls `apply(LedgerOpened(...))` rather than setting f
The `version` counter starts at zero and increments after each dispatched event. After `open`, `account.version == 1`; after one credit, `account.version == 2`. You will see this number again when the `EventStore` enforces optimistic concurrency.
+**Building it step by step.** The listing is dense the first time through. Here is the same code as four deliberate moves — the order in which you would actually write it.
+
+**Step 1 — Define the durable facts.** Write the three event dataclasses (`LedgerOpened`, `Credited`, `Debited`) extending `pyfly.eventsourcing.DomainEvent`. Give every field a default value (`account_id: str = ""`, `amount: int = 0`). The defaults are not cosmetic: the repository rebuilds these dataclasses from a stored payload, and a dataclass with required fields could not be reconstructed when an old event predates a newer field.
+
+**Step 2 — Set up the empty aggregate.** Write `__init__` with no parameters. Initialise the fields to neutral defaults (`owner_id = ""`, `balance = Money.zero(Currency.EUR)`) and register one `when()` handler per event class, each as a two-arg lambda delegating to a private method. At this point the aggregate is a blank slate that knows how to react to events but has not seen any.
+
+**Step 3 — Add the factory and the commands.** Write the `open` classmethod and the `credit` / `debit` methods. Each one validates its invariants first (currency match, positive amount, no overdraft), computes the resulting balance, and only then calls `self.apply(SomeEvent(...))`. Validation lives in the command; the handler never validates.
+
+**Step 4 — Write the pure folds.** Write `_on_opened`, `_on_credited`, `_on_debited`. Each reads fields off the event and assigns them to the aggregate — no arithmetic, no validation, no exceptions. Because these same three methods run on both the write path and the replay path, keeping them dumb is what guarantees that a reloaded ledger equals the live one.
+
+!!! tip "Why validation belongs in the command, not the handler"
+ The handler runs again every time the aggregate is loaded from history. If validation lived in the handler, you would be re-checking the overdraft rule against data that already passed it years ago — and worse, a rule that has since *changed* could reject a historical event that was perfectly valid when it happened. Validate once, on the write that produces the event; trust the event forever after.
+
`pending_events()` returns the events queued since the last save. The unit tests drive the aggregate in isolation — no repository needed — which makes invariant verification straightforward:
::: listing tests/test_ledger_event_sourcing.py | Listing 9.2 — Unit tests: aggregate in isolation, commands and invariants
@@ -283,8 +301,25 @@ def test_debit_cannot_overdraw() -> None:
]
:::
+**Run it.** These three tests need no database and no event store — the aggregate stands entirely on its own. Run just this file's unit tests from the Lumen project root:
+
+```bash
+uv run --extra dev pytest tests/test_ledger_event_sourcing.py -q -k "open or credit or debit or currency"
+```
+
+You should see the in-isolation tests pass:
+
+```
+.... [100%]
+4 passed, 7 deselected in 0.05s
+```
+
+The four dots are the four aggregate-only tests (the three shown above plus a currency-mismatch check); the seven deselected tests are the store-backed ones, which we run later. If you see a `TypeError` about the number of arguments, you have almost certainly hit the bound-method trap from the warning above — passing `self._on_opened` directly to `when()` instead of wrapping it in a two-arg lambda.
+
+**What just happened.** You built a domain object that records *what changed* instead of *what is*. `open` emitted a `LedgerOpened` fact and bumped the version to 1. `credit` and `debit` each validated their rule, computed the new balance, and emitted a fact — so a credit followed by a debit left three facts queued and the version at 3. When a rule failed (the overdraft test), the command raised before calling `apply`, so no faulty event ever entered the buffer. The aggregate's in-memory balance and its list of pending events stayed perfectly in step, and nothing touched a database yet.
+
!!! tip "on_{event_type} as an alternative"
- Instead of `when()` lambdas, you can define a method on the aggregate named after the event in snake_case — `on_ledgeropened(self, evt)` is discovered automatically. Use `when()` for concise one-liners and named methods for handlers that need multiple statements or local variables. The dispatch order is: `when()` handler first; then `on_{event_type}` method; then `EventHandlerException` if neither exists.
+ Instead of `when()` lambdas, you can define a method on the aggregate named after the event's `event_type` — which is the event **class name**, so `on_LedgerOpened(self, evt)` (matching the `LedgerOpened` dataclass) is discovered automatically. Use `when()` for concise one-liners and named methods for handlers that need multiple statements or local variables. The dispatch order is: `when()` handler first; then the `on_{event_type}` method; then `EventHandlerException` if neither exists.
!!! spring "Spring parity"
`AggregateRoot` + `apply()` + `when()` is PyFly's equivalent of Axon Framework's `@Aggregate` + `AggregateLifecycle.apply(event)` + `@EventSourcingHandler`. Axon uses annotation-driven handler discovery (`@EventSourcingHandler`); PyFly uses `when()` registration or `on_*` method convention. The replaying mechanic — load events from the store, call the same handlers, rebuild state — is identical in both frameworks.
@@ -327,6 +362,17 @@ from pyfly.eventsourcing.repository import EventSourcedRepository
You subclass `EventSourcedRepository` for two reasons: to pass the concrete factory and snapshot store through a single well-named constructor, and — optionally — to override `_envelope_to_event` so that replayed events are real typed dataclasses rather than the generic attribute-bag the base class produces.
+!!! note "Jargon: envelope and attribute-bag"
+ A `StoredEventEnvelope` is the *wire shape* the store persists: it wraps the event's `payload` (a plain dict) together with bookkeeping fields the store needs — `aggregate_id`, `aggregate_type`, `sequence`, `event_type`, `event_id`, and `occurred_at`. Think of it as the addressed envelope around the letter. An **attribute-bag** is the generic stand-in object the base repository creates on load if you do not override anything: a nameless object with the payload's keys set as attributes. It works — the handlers only read attributes — but it is not your real `Credited` dataclass, so it cannot be type-checked or `isinstance`-tested. The override below trades that anonymity for the real thing.
+
+Build the repository in three small steps.
+
+**Step 1 — Map class names back to dataclasses.** The store records each event's `event_type` as the class name string (`"Credited"`). To rebuild the real dataclass on load, you need a lookup from that string to the class. That is `_EVENT_TYPES` — one entry per event type.
+
+**Step 2 — Subclass and forward the constructor.** `LedgerAccountRepository.__init__` takes the `store` (and an optional `snapshots` store) and forwards everything to `super().__init__`, supplying `factory=LedgerAccount` so the base class knows how to make a blank aggregate.
+
+**Step 3 — Override `_envelope_to_event` for typed replay.** Look the `event_type` up in `_EVENT_TYPES`, filter the stored payload down to fields the dataclass actually declares (using `__dataclass_fields__`), and construct the real event. Fall back to the base class's generic hydration for any event type you do not recognise.
+
::: listing lumen/models/repositories/ledger_repository.py | Listing 9.3 — LedgerAccountRepository: typed replay via _envelope_to_event
from __future__ import annotations
@@ -397,10 +443,32 @@ class LedgerAccountRepository(EventSourcedRepository[LedgerAccount]):
The `_envelope_to_event` override looks up the stored `event_type` string in `_EVENT_TYPES`, uses `__dataclass_fields__` to filter the payload to known fields, and reconstructs the real dataclass. Unknown fields are silently dropped — forward-compatibility in practice: if a future event version adds a field the old handler does not recognise, the ledger keeps replaying instead of crashing. The base-class fallback at the bottom handles any event type the repository does not know about.
+!!! note "Jargon: forward-compatibility"
+ A reader is **forward-compatible** when it can ingest data written by a *newer* version of itself without breaking — it simply ignores parts it does not understand. Dropping unknown payload fields is exactly that: a v2 writer can add a `reference_code` to `Credited`, and a still-running v1 reader keeps folding the event correctly instead of crashing on the surprise field.
+
+**Run it.** A focused test exercises this override directly — it hands the repository a hand-built envelope and asserts it gets back a real `Credited` dataclass:
+
+```bash
+uv run --extra dev pytest tests/test_ledger_event_sourcing.py -q -k typed_replay
+```
+
+```
+. [100%]
+1 passed, 10 deselected in 0.05s
+```
+
+**What just happened.** You wired the persistence boundary without writing a single line of SQL or storage code. The base `EventSourcedRepository` already knows how to drain pending events on `save` and replay them on `load`; your subclass added only two things — a zero-arg factory so it can build a blank ledger, and a typed `_envelope_to_event` so the events it replays are the very same dataclasses the aggregate emitted on the write side. That symmetry is the whole point: write and replay run identical code.
+
---
## Save, load, and the replay proof
+This is the moment the whole chapter has been building toward: proving that a ledger reloaded from nothing but its stored events equals the live ledger that produced them. The test below does it in two halves.
+
+**Step 1 — Write a ledger and persist it.** Open a ledger, credit it, debit it, then call `repo.save(account)`. The repository drains the three pending events into the store and clears the aggregate's buffer.
+
+**Step 2 — Reload from a clean slate and compare.** Make a *brand-new* repository and call `load("acct-1")`. Nothing in this second half shares memory with the first object. The recovered ledger's balance can only be correct if it was recomputed by replaying the stored stream — which is exactly the proof we want.
+
The following test demonstrates the complete save-and-load cycle:
::: listing tests/test_ledger_event_sourcing.py | Listing 9.4 — Headline test: balance survives a reload by replay
@@ -445,6 +513,19 @@ async def test_balance_survives_reload_by_replay(
The test uses *two independent repository instances* sharing the same in-memory store — `repo` for the write, `fresh_repo` for the read. This proves that replay, not in-process object identity, is the source of truth.
+**Run it.** Run the headline test plus the raw-stream inspection test that follows it:
+
+```bash
+uv run --extra dev pytest tests/test_ledger_event_sourcing.py -q -k "reload_by_replay or immutable_event_stream"
+```
+
+```
+.. [100%]
+2 passed, 9 deselected in 0.05s
+```
+
+**What just happened.** You proved event sourcing actually works end to end. The first ledger lived only in memory; after `save`, its facts lived only in the store. A second, unrelated ledger object then rebuilt the exact same 15.00 EUR balance by folding those facts back through the same handlers — no stored balance column anywhere in sight. The version landed on 3 (one event per `LedgerOpened` / `Credited` / `Debited`), and the reconstructed ledger had nothing pending because loading is not the same as changing.
+
The event store also exposes raw envelopes for inspection — useful for audits and tests:
::: listing tests/test_ledger_event_sourcing.py | Listing 9.5 — The store holds the immutable event stream
@@ -481,6 +562,9 @@ Two concurrent requests — a credit from the mobile app and an automated fee de
**Optimistic concurrency** prevents this. Before appending new events, the `EventStore` compares the stream's *current* version against the *expected* version the repository recorded at load time. If they match, the append proceeds and the version advances. If they do not match — because another writer already appended — `ConcurrencyError` is raised and the losing request must retry from a fresh load.
+!!! note "Jargon: optimistic vs pessimistic locking"
+ *Pessimistic* locking assumes conflict is likely, so it grabs a lock up front — no other writer can touch the row until you release it. *Optimistic* concurrency assumes conflict is rare, so it takes no lock at all: it lets both writers proceed and only checks, at save time, whether anyone else changed the stream first. The loser retries. For most ledgers, two simultaneous writers to the *same* account are uncommon, so the optimistic strategy avoids the cost of locking on the overwhelmingly common no-conflict path.
+
The `expected_version` is passed implicitly by the repository: it records the version at which it loaded the aggregate and supplies it to the store on save. You never manage version numbers in application code.
The version progression is deterministic: after a save-and-reload, further writes advance the version without conflict:
@@ -513,6 +597,19 @@ async def test_continues_appending_after_a_reload(
**How it works.** After the first save the stream is at version 2. When loaded, `reloaded.version == 2`. `repo.save(reloaded)` appends with `expected_version=2`; the store advances to 3 and succeeds. The `final` load replays all three events and confirms the correct balance.
+**Run it.**
+
+```bash
+uv run --extra dev pytest tests/test_ledger_event_sourcing.py -q -k continues_appending
+```
+
+```
+. [100%]
+1 passed, 10 deselected in 0.05s
+```
+
+**What just happened.** A reloaded ledger remembered its version, so the next save lined up cleanly behind the events already in the stream — no conflict, sequence numbers in order, balance correct. This is the *happy path* of optimistic concurrency: one writer at a time. The moment two writers race, the second one's `expected_version` would no longer match and the store would raise `ConcurrencyError` instead — which is the case the warning below tells you how to handle.
+
!!! warning "Always handle ConcurrencyError"
When two writers race, the losing save raises `ConcurrencyError`. Your application service must catch it and decide what to do: retry the full load-mutate-save cycle (appropriate for low-contention writes), or surface a 409 Conflict to the caller (appropriate when the caller should re-submit with fresh data). Never silently swallow the error — a swallowed concurrency error leaves the stream in an inconsistent state.
@@ -573,6 +670,19 @@ async def test_snapshot_store_round_trips_the_ledger() -> None:
**How it works.** After the save, the repository checks: does `version 2 // 100 > 0 // 100`? No — the snapshot threshold has not been crossed, so no snapshot is taken. The next `load` performs a full replay of the two events and returns the correct balance. Once a ledger does cross a 100-event boundary, the repository serialises the aggregate's state into a snapshot envelope. The next load finds the snapshot, deserialises directly to that version, and then asks the event store for events with a sequence number greater than the snapshot version — reducing replay cost to the delta only.
+**Run it.**
+
+```bash
+uv run --extra dev pytest tests/test_ledger_event_sourcing.py -q -k snapshot_store_round_trips
+```
+
+```
+. [100%]
+1 passed, 10 deselected in 0.05s
+```
+
+**What just happened.** You wired a snapshot store into the repository and the ledger still reloaded correctly — which is exactly what should happen for a short stream. Because the two events never crossed the 100-event interval, no snapshot was written and the load fell back to a plain full replay. Snapshots are pure optimization: wiring one in costs nothing for short streams and quietly pays off once an account becomes high-frequency. You can prove the correctness of your ledger with the snapshot store both present and absent — the answer is identical.
+
!!! tip "Snapshot interval in production"
A `snapshot_interval` of 100 is the default and a sensible starting point. For high-frequency ledgers you might lower it; for accounts that only change a few times a day, a higher interval reduces snapshot-storage cost. Snapshots are an optimization, not a correctness requirement — removing them leaves the system correct but slower.
@@ -582,22 +692,40 @@ async def test_snapshot_store_round_trips_the_ledger() -> None:
The `pyfly.eventsourcing` module ships in the **base** `pyfly` package — no extra dependency. Enabling it takes two steps: set `pyfly.eventsourcing.enabled: true` in `pyfly.yaml` and annotate the application with `@enable_domain_stack`. PyFly's auto-configuration then registers `event_store` and `snapshot_store` beans automatically.
+!!! note "Jargon: bean and auto-configuration"
+ A **bean** is just an object the framework creates once and hands to anything that asks for it — the same dependency-injection idea you met in Chapter 2. **Auto-configuration** is a class of bean-producing factory methods that PyFly activates *only when a condition is met* — here, the `@conditional_on_property("pyfly.eventsourcing.enabled", having_value="true")` guard. Flip that one config flag on, and the `event_store` and `snapshot_store` beans appear in the container; leave it off, and the event-sourcing machinery stays dormant. You never call the factory yourself.
+
The test suite confirms this directly:
::: listing tests/test_ledger_event_sourcing.py | Listing 9.8 — Auto-configuration registers the event store beans
def test_auto_configuration_registers_event_store_beans() -> None:
"""enable_domain_stack activates this auto-config when
- pyfly.eventsourcing.enabled=true, registering the in-memory
- event/snapshot stores the ledger repository depends on."""
+ pyfly.eventsourcing.enabled=true (set in pyfly.yaml), registering
+ the in-memory event/snapshot stores the ledger repository depends on."""
+ from pyfly.core.config import Config
from pyfly.eventsourcing.auto_configuration import (
EventSourcingAutoConfiguration,
)
- config = EventSourcingAutoConfiguration()
- assert isinstance(config.event_store(), InMemoryEventStore)
- assert isinstance(config.snapshot_store(), InMemorySnapshotStore)
+ auto = EventSourcingAutoConfiguration()
+ cfg = Config() # empty config -> providers default to "memory"
+ assert isinstance(auto.event_store(cfg), InMemoryEventStore)
+ assert isinstance(auto.snapshot_store(cfg), InMemorySnapshotStore)
:::
+**Run it.**
+
+```bash
+uv run --extra dev pytest tests/test_ledger_event_sourcing.py -q -k auto_configuration
+```
+
+```
+. [100%]
+1 passed, 10 deselected in 0.05s
+```
+
+**What just happened.** With the `enabled` flag on, the auto-config produced the two in-memory stores the ledger repository depends on — without you instantiating either. Note that the factory methods take a `Config` argument: that is how they read `pyfly.eventsourcing.store.provider` to decide between the `memory` default and a SQL-backed adapter. Passing an empty `Config()` leaves them on `memory`, which is what the test asserts.
+
In application code, the repository is wired through dependency injection:
```python
@@ -629,14 +757,21 @@ The event store is the system of record, but most application queries — "what
That background process is a **projection**. A projection subscribes to the event stream and updates a read model each time a relevant event arrives. PyFly provides `FunctionProjection` and `ProjectionRunner` in `pyfly.eventsourcing.projection`:
+!!! note "Jargon: read model, projection, write model"
+ The **write model** is the side you have built so far — the aggregate and its event stream, optimised for *recording changes correctly*. A **read model** is a separate, query-shaped copy of the data, optimised for *answering questions fast* (one row per ledger with its current balance, say). A **projection** is the process that keeps a read model in sync by replaying the event stream into it. This separation — one model for writes, another for reads — is the **CQRS** pattern you met in Chapter 7, now backed by an event stream instead of a relational table.
+
- **`FunctionProjection(name, handler_fn)`** — wraps an async function that receives one `StoredEventEnvelope` and updates the read model.
- **`ProjectionRunner(projection, store)`** — drives the projection by iterating the `EventStore` in sequence-number order and calling the handler for each envelope.
+A projection comes together in three pieces. **Step 1** — write the handler: an `async` function that takes one envelope and updates the read model (here a plain `dict`; in production a database table). **Step 2** — wrap it: `FunctionProjection("balance_ledger", handler)` turns the bare function into a named projection. **Step 3** — drive it: `ProjectionRunner(projection, store)` connects the projection to the event store and, when started, feeds it every stored envelope in sequence order.
+
Here is a `BalanceLedgerProjection` that builds a balance read model from the event stream:
::: listing lumen/eventsourcing/balance_projection.py | Listing 9.9 — BalanceLedgerProjection: a read model built from the event stream
from __future__ import annotations
+import asyncio
+
from pyfly.eventsourcing import InMemoryEventStore
from pyfly.eventsourcing.projection import FunctionProjection, ProjectionRunner
@@ -672,13 +807,23 @@ def build_projection(store: InMemoryEventStore) -> ProjectionRunner:
async def demo_projection(store: InMemoryEventStore) -> None:
runner = build_projection(store)
+ # start() launches a background polling task and returns immediately;
+ # the read model is populated asynchronously as the loop drains the
+ # store. Poll until the projection has caught up, then stop the runner.
await runner.start()
+ try:
+ for _ in range(50):
+ if "led-001" in _balance_store:
+ break
+ await asyncio.sleep(0.05)
+ finally:
+ await runner.stop()
balance = _balance_store.get("led-001", {})
print(f"Balance read model: {balance}")
:::
-**How it works.** `FunctionProjection("balance_ledger", _handle_envelope)` wraps the async handler. `ProjectionRunner(projection, store)` links it to the `InMemoryEventStore`. `await runner.start()` iterates every envelope in the store in sequence-number order and calls `_handle_envelope` for each one. After `start()` returns, `_balance_store` reflects the current state of every ledger in the store.
+**How it works.** `FunctionProjection("balance_ledger", _handle_envelope)` wraps the async handler. `ProjectionRunner(projection, store)` links it to the `InMemoryEventStore`. `await runner.start()` launches a background polling task and returns *immediately* — it does not block until the store is drained. The task loops on `store.stream_all(...)`, calling `_handle_envelope` for each new envelope in order and advancing a cursor (`_last_event_id`) so it never re-processes an event. Because population happens asynchronously, the demo polls `_balance_store` until the projection has caught up, then calls `await runner.stop()` to halt the loop before reading the result. Only after the projection has processed the envelopes does `_balance_store` reflect the current state of every ledger in the store.
The projection is intentionally stateless — it reads only `envelope.event_type` and `envelope.payload`. No aggregate is loaded; no repository is called. The read model is cheap to rebuild: stop the runner, clear `_balance_store`, call `start()` again. This rebuild-from-history property is unique to event sourcing — state-storage models have already discarded the history.
@@ -776,16 +921,34 @@ The upcaster runs when the `EventStore` loads an event whose schema version is l
### Multi-tenancy
-When multiple tenants share the same event store, stream IDs must be scoped by tenant. The canonical approach is to prefix every `aggregate_id` with the tenant identifier — `"tenant-A::led-001"` rather than `"led-001"`. `EventSourcedRepository` accepts a `tenant_id` parameter on construction and prepends the prefix to every stream operation transparently:
+When multiple tenants share the same event store, events must be scoped by tenant so one tenant can never read or replay another's stream. PyFly gives you two seams for this; neither lives on `EventSourcedRepository` (its constructor takes only `store`, `factory`, `snapshots`, and `snapshot_interval` — there is no `tenant_id` parameter).
+
+The first seam is on the **envelope itself**. `StoredEventEnvelope` carries a dedicated `tenant_id` field, and `StoredEventEnvelope.of(...)` accepts it as a keyword argument:
```python
-repo_tenant_a = EventSourcedRepository(
- store,
- factory=LedgerAccount,
+envelope = StoredEventEnvelope.of(
+ aggregate_id="led-001",
+ aggregate_type="LedgerAccount",
+ sequence=0,
+ event=Credited(...),
tenant_id="tenant-A",
)
```
+The SQL adapter persists this as a `tenant_id` column on `pyfly_event_store`, so a tenant-aware store can filter every query by it. This keeps the `aggregate_id` clean while still partitioning the log per tenant.
+
+The second seam is a **pattern you implement yourself**: prefix every `aggregate_id` with the tenant identifier — `"tenant-A::led-001"` rather than `"led-001"` — when you call `repo.save` and `repo.load`. A thin tenant-aware wrapper around the repository can apply and strip the prefix so application code never sees it:
+
+```python
+class TenantLedgerRepository:
+ def __init__(self, repo: LedgerAccountRepository, tenant_id: str) -> None:
+ self._repo = repo
+ self._prefix = f"{tenant_id}::"
+
+ async def load(self, account_id: str) -> LedgerAccount | None:
+ return await self._repo.load(self._prefix + account_id)
+```
+
Projections must scope their read models similarly — typically by including `tenant_id` as a column in the read-model table and filtering on it at query time.
!!! note "Choosing event sourcing"
@@ -793,6 +956,23 @@ Projections must scope their read models similarly — typically by including `t
---
+## Run the whole chapter
+
+You ran each piece in isolation as you built it. Now run the complete ledger suite to confirm everything still passes together:
+
+```bash
+uv run --extra dev pytest tests/test_ledger_event_sourcing.py -q
+```
+
+```
+........... [100%]
+11 passed in 0.06s
+```
+
+Eleven dots: the four aggregate-isolation tests, the headline reload-by-replay proof, the raw-stream inspection, the typed-replay override, the post-reload concurrency check, the snapshot round-trip, the unknown-ledger lookup, and the auto-configuration wiring check. With those green, the `LedgerAccount` aggregate and its repository are complete and correct.
+
+---
+
## What you built {.recap}
Lumen's `LedgerAccount` is now a fully event-sourced ledger that coexists with the Chapter 6 state-stored `Wallet`.
diff --git a/book/manuscript/10-messaging.md b/book/manuscript/10-messaging.md
index 0975e0d5..1f70eab2 100644
--- a/book/manuscript/10-messaging.md
+++ b/book/manuscript/10-messaging.md
@@ -12,6 +12,11 @@ This chapter takes Lumen's event-driven foundation across that boundary. You wil
By the end of the chapter Lumen's integration events flow across process boundaries, ready for the Part IV services that will consume them.
+We will build this gradually, one piece at a time. Each feature comes with a numbered walkthrough, the exact command to run, and the output you should expect to see. If you have followed along from Chapter 8 you already have `EventPublisher` wired into the wallet command handlers and the `WalletAuditListener` reacting in-process; this chapter is verified against PyFly v26.6.110 and the Lumen sample under `samples/lumen`. Nothing here requires a running Kafka or RabbitMQ cluster to follow along: PyFly ships an in-memory broker that satisfies the same contract, so you can read, run, and test every listing before you ever touch Docker.
+
+!!! note "Jargon, in plain language"
+ A handful of terms recur in this chapter. A **message broker** is a separate server (Kafka or RabbitMQ) that stores messages and hands them to other processes. A **topic** is a named channel on the broker; publishers write to it and subscribers read from it. A **producer** (or *publisher*) puts messages onto a topic; a **consumer** (or *listener*) pulls them off and reacts. A **consumer group** is a label that lets several copies of the same service share the work, so each message is handled once. A **serialiser** turns a Python object into the raw `bytes` the broker stores; a **deserialiser** turns those bytes back into an object on the other side. A **dead-letter queue** (DLQ) is a holding area for messages that could not be processed. An **adapter** is the concrete broker driver hiding behind the `MessageBrokerPort` interface. Keep these eight in mind; the rest of the chapter is mostly about connecting them.
+
---
## One abstraction, many brokers
@@ -119,9 +124,65 @@ For Lumen, Kafka is the natural fit: wallet events form an ordered stream per wa
## Configuring the adapters
+Wiring a broker into Lumen is a configuration task, not a coding one. You add one extra to the project, add a `pyfly.messaging` block to `pyfly.yaml`, and PyFly does the rest: it constructs the right adapter and registers it under the `MessageBrokerPort` bean so anything that asks for that port gets the running broker injected. We will start with the broker that needs no infrastructure at all, then graduate to Kafka and RabbitMQ.
+
+!!! note "Turn messaging on with one key"
+ In v26.6.110 the messaging subsystem only wires itself up when the
+ `pyfly.messaging.provider` key is **present** in your configuration. No
+ key means no `MessageBrokerPort` bean — a deliberate "off by default"
+ so that an app with no messaging needs pull no broker libraries.
+ Once the key is set, its value (`"memory"`, `"kafka"`, `"rabbitmq"`,
+ or `"auto"`) selects the adapter. The companion keys live under the
+ same block: `pyfly.messaging.kafka.bootstrap-servers` and
+ `pyfly.messaging.rabbitmq.url`.
+
+### Start with the in-memory broker
+
+Lumen's `pyfly.yaml` already runs on the in-memory EDA bus from Chapter 8. To bring the *messaging* abstraction online without standing up Docker, add a single `provider` line.
+
+**Step 1 — Enable the in-memory broker.** Open `pyfly.yaml` and add a `messaging` block under `pyfly`:
+
+```yaml
+pyfly:
+ messaging:
+ provider: "memory"
+```
+
+**Step 2 — Add a listener so there is something to wake up.** Drop the standalone listener from Listing 10.4 (a few pages on) into `src/lumen/messaging/payments_consumer.py`. At startup PyFly discovers the stamped function and subscribes it for you — you write no `subscribe()` call.
+
+!!! tip "Run it"
+ Start the app. The in-memory broker needs no external server, so this
+ works on a laptop with nothing installed:
+
+ ```bash
+ uv run pyfly run
+ ```
+
+ The boot banner reports the framework version and the bound port
+ (`pyfly.server.port`, `8080` by default in v26.6.110):
+
+ ```
+ :: PyFly Framework :: (v26.06.110) (Python 3.13.13)
+ app=lumen version=1.0.0 ... started_in=0.42s port=8080
+ ```
+
+ Nothing else is printed yet — no message has been published. That is
+ expected: the broker is running and the listener is subscribed,
+ waiting. The next sections give it events to carry.
+
+**What just happened.** One YAML line switched the `MessageBrokerPort` bean from "not present" to a working in-memory broker, and the framework auto-subscribed your `@message_listener` to it during startup. No Kafka, no RabbitMQ, no Docker — yet the *exact same code* will run against a real broker once you change that one line to `"kafka"`. That swap-without-recompile property is the whole point of the abstraction.
+
### Kafka
-Add `pyfly[kafka]` to your project and declare the broker in `pyfly.yaml`:
+When you are ready for a real broker, add `pyfly[kafka]` to your project and point the provider at Kafka.
+
+**Step 1 — Install the Kafka extra.** This pulls in `aiokafka`, the async driver PyFly's `KafkaAdapter` wraps:
+
+```bash
+uv add "pyfly[kafka]"
+```
+
+**Step 2 — Declare the broker in `pyfly.yaml`.** Switch the provider and list your brokers:
```yaml
pyfly:
@@ -131,7 +192,7 @@ pyfly:
bootstrap-servers: "kafka-1:9092,kafka-2:9092"
```
-That is all PyFly needs to auto-configure a `KafkaAdapter` and register it as the `MessageBrokerPort` bean. For most services the YAML is sufficient; if you need advanced producer options, construct the adapter manually as a `@bean` inside a `@configuration` class.
+That is all PyFly needs to auto-configure a `KafkaAdapter` and register it as the `MessageBrokerPort` bean. (`bootstrap-servers` is a comma-separated list of `host:port` pairs — the addresses of one or more brokers in the cluster; the client discovers the rest from any one of them.) For most services the YAML is sufficient; if you need advanced producer options, construct the adapter manually as a `@bean` inside a `@configuration` class.
### RabbitMQ
@@ -167,7 +228,7 @@ class BrokerConfig:
### Auto-detection
-When `provider` is `"memory"` (the default) or `"auto"`, PyFly probes installed packages in order:
+When `provider` is `"auto"`, PyFly probes installed packages in order and picks the first broker it finds:
| Priority | Library checked | Adapter selected |
|---|---|---|
@@ -175,7 +236,27 @@ When `provider` is `"memory"` (the default) or `"auto"`, PyFly probes installed
| 2 | `aio_pika` | `RabbitMQAdapter` |
| 3 | *(fallback)* | `InMemoryMessageBroker` |
-Set `provider: "memory"` in `pyfly-test.yaml` and `provider: "kafka"` in `pyfly-prod.yaml`, and every test and production run uses the appropriate adapter without code changes.
+`provider: "memory"` is different from `"auto"`: it *always* selects the in-memory broker regardless of what is installed, which is exactly what you want in tests. An explicit `provider: "kafka"` or `"rabbitmq"` skips probing entirely and demands that adapter's library be present.
+
+The practical pattern is per-environment YAML. Set `provider: "memory"` in `pyfly-test.yaml` and `provider: "kafka"` in `pyfly-prod.yaml`, and every test and production run uses the appropriate adapter without code changes.
+
+!!! tip "Run it"
+ You can confirm which adapter PyFly selected without sending a single
+ message. With messaging enabled, start the app and look for the broker
+ line in the startup log:
+
+ ```bash
+ uv run pyfly run
+ ```
+
+ ```
+ pyfly.messaging provider=memory broker=InMemoryMessageBroker started
+ ```
+
+ Change `provider` to `"kafka"` (with `pyfly[kafka]` installed and a
+ broker reachable) and restart; the same line now reports
+ `broker=KafkaAdapter`. The business code that publishes and consumes
+ did not change — only the YAML did.
---
@@ -296,7 +377,15 @@ When `publish_domain_events` publishes this event, `event_type` is the class nam
### Publishing an integration event directly to the broker
-When a separate service running in a different process needs to receive Lumen's wallet events, the EDA bus must be backed by a real broker adapter. The payload flowing over the wire is the same dict the in-process listeners see. A dedicated `OutboxRelay` (covered in the resilience section) or a broker-backed `EventPublisher` handles the transport:
+When a separate service running in a different process needs to receive Lumen's wallet events, the EDA bus must be backed by a real broker adapter. The payload flowing over the wire is the same dict the in-process listeners see. A dedicated `OutboxRelay` (covered in the resilience section) or a broker-backed `EventPublisher` handles the transport.
+
+It helps to see the publish in its smallest possible form first. The next listing is a plain `async` function — no class, no decorator — that takes a `MessageBrokerPort`, builds the payload, and calls `publish`. Build it in three moves:
+
+**Step 1 — Encode the payload to bytes.** `MessageBrokerPort.publish` only ever sees `bytes`, so the function serialises the event with `json.dumps(...).encode()`. The `.encode()` turns the JSON string into UTF-8 bytes the broker can store verbatim.
+
+**Step 2 — Choose a partition key.** Passing `key=wallet_id.encode()` tells Kafka to route every message for a given wallet to the same partition, which preserves their order. (RabbitMQ ignores the key, so including it is harmless either way.)
+
+**Step 3 — Attach the event-type header.** `headers={"event-type": "FundsDeposited"}` lets a consumer decide whether it cares about this message *before* deserialising the body — cheap routing.
::: listing lumen/messaging/deposit_publisher.py | Listing 10.3 — Publishing a wallet integration event to a Kafka topic
from __future__ import annotations
@@ -338,6 +427,8 @@ async def publish_deposit_event(
`headers={"event-type": "FundsDeposited"}` uses the domain event class name — not a dotted path like `"wallet.fundsdeposited"`. Consumers can inspect the event type without decoding the payload, which is useful for routing and filtering without full deserialisation.
+**What just happened.** You crossed the process boundary. The same `FundsDeposited` fact that `WalletAuditListener` consumed in-process in Chapter 8 is now bytes on a topic, addressable by any service that connects to the broker — and the function that put it there names no broker, only the `MessageBrokerPort` port. Swap the configured adapter and this code is unchanged.
+
!!! warning "Publish after save, not before"
Always drain and publish events *after* `repository.add(wallet)`. If
the save fails, no message reaches the broker and external consumers
@@ -380,6 +471,14 @@ def message_listener(
| `retry_delay` | `float` | `0.0` | Base delay (seconds) between retries — attempt N waits `retry_delay * N`. |
| `dead_letter_topic` | `str \| None` | `None` | When set, a message still failing after `retries` is re-published here. |
+The first listener is a free-standing function — the simplest shape. Build it in three moves:
+
+**Step 1 — Write an async function that takes a `Message`.** Every listener receives one argument: the frozen `Message` envelope (`topic`, `value`, `key`, `headers`). The function must be `async` because the broker awaits it.
+
+**Step 2 — Decorate it with the topic and group.** `@message_listener(topic="wallet.events", group="payments-service")` is the entire subscription. There is no `subscribe()` call to write and no bus to import — the decorator stamps the function with metadata the framework reads at startup.
+
+**Step 3 — Decode inside the handler.** The body checks the `event-type` header, then `json.loads(msg.value)` turns the raw bytes back into a dict. The handler decides what it cares about; here it reacts only to `FundsDeposited`.
+
::: listing lumen/messaging/payments_consumer.py | Listing 10.4 — @message_listener on a standalone function
from __future__ import annotations
@@ -411,9 +510,58 @@ async def on_wallet_event(msg: Message) -> None:
Inside the handler, `msg.headers.get("event-type", "unknown")` inspects the envelope metadata before touching the payload. The header value is the domain event class name — `"FundsDeposited"`, `"WalletOpened"`, or `"FundsWithdrawn"` — matching what Lumen sets on the publisher side.
+!!! tip "Run it"
+ With `provider: "memory"` set (no Docker), this is the full publish →
+ consume round-trip in one place. Save the snippet below as
+ `roundtrip.py` and run it with `uv run python roundtrip.py`:
+
+ ```python
+ import asyncio, json
+ from pyfly.messaging.adapters.memory import InMemoryMessageBroker
+ from lumen.messaging.payments_consumer import on_wallet_event
+
+ async def main() -> None:
+ broker = InMemoryMessageBroker()
+ await broker.subscribe(
+ "wallet.events", on_wallet_event, group="payments-service"
+ )
+ await broker.start()
+ await broker.publish(
+ "wallet.events",
+ json.dumps({
+ "wallet_id": "w-001", "amount": 5000,
+ "currency": "EUR", "balance": 5000,
+ }).encode(),
+ headers={"event-type": "FundsDeposited"},
+ )
+ await asyncio.sleep(0.1) # let the listener run
+ await broker.stop()
+
+ asyncio.run(main())
+ ```
+
+ The listener prints the line it built from the decoded payload:
+
+ ```
+ [Payments] Deposit received: wallet=w-001 amount=5000 EUR
+ ```
+
+ Inside a running app you would *not* write this wiring by hand — the
+ `@message_listener` decorator and the configured broker bean do the
+ `subscribe`/`start`/`stop` for you. This standalone script just makes
+ the round-trip visible in isolation.
+
+**What just happened.** A message you published to `wallet.events` arrived at a function you never explicitly connected to anything. The decorator carried the topic and group; the broker (here in-memory, in production Kafka) did the delivery. That is the consume side of the same abstraction you used to publish — and the function body is broker-agnostic from top to bottom.
+
### Listeners on service classes
-When a listener needs collaborators — a repository, another service — declare it as a method on a `@service` class. PyFly injects the dependencies through the constructor and wires the listener subscription after the bean is initialised:
+When a listener needs collaborators — a repository, another service — declare it as a method on a `@service` class. PyFly injects the dependencies through the constructor and wires the listener subscription after the bean is initialised. The shape changes only slightly from the standalone version:
+
+**Step 1 — Make the class a `@service`.** This registers it in the DI container so the framework can both inject its constructor and discover its listener method.
+
+**Step 2 — Declare collaborators in the constructor.** Here `smtp_client` stands in for an email or push service; the container supplies it. Listing 10.4's free function had nowhere to keep such a dependency — that is the reason to reach for a class.
+
+**Step 3 — Decorate a *method* with `@message_listener`.** The signature gains `self`, but otherwise the decorator and body are identical to the function form. Because the bean is created first, `self._smtp` is ready by the time a message arrives.
::: listing lumen/messaging/notifications_consumer.py | Listing 10.5 — @message_listener on a @service method with dependencies
from __future__ import annotations
@@ -481,6 +629,8 @@ Three formats are worth knowing: JSON for simplicity, Avro for schema-registry-b
### JSON — start here
+*Serialisation* is just the act of turning an in-memory object into a flat sequence of bytes you can store or send, and *deserialisation* is the reverse. The three formats below differ only in how compact those bytes are and how strictly they police the shape of the data.
+
JSON is the right default. It requires no tooling beyond the standard library, every language can parse it, and the payload is readable in broker monitoring UIs. The encoding pattern is two lines:
```python
@@ -507,7 +657,13 @@ JSON's weakness is that the schema is unenforced. If a publisher adds a required
### Avro — schema-registry-backed evolution
-Avro schemas are JSON documents describing the shape of a message. A Schema Registry (Confluent's is the most common, but open-source alternatives exist) stores those schemas and enforces compatibility rules when producers register new versions. The `fastavro` library encodes and decodes the binary payload:
+Avro schemas are JSON documents describing the shape of a message. A Schema Registry (Confluent's is the most common, but open-source alternatives exist) stores those schemas and enforces compatibility rules when producers register new versions. The `fastavro` library encodes and decodes the binary payload. The publish path is the same `broker.publish(...)` you already know; only the encoding step changes:
+
+**Step 1 — Declare the schema once.** `WALLET_DEPOSITED_SCHEMA` lists each field and its Avro type (`string`, `long`). It is a module-level constant so it is written once, not per message.
+
+**Step 2 — Compile it once.** `fastavro.parse_schema(...)` is called at import time and the result cached in `_PARSED`. Parsing on every publish would be wasted work on the hot path.
+
+**Step 3 — Encode and publish.** `fastavro.schemaless_writer` serialises the record into a `BytesIO` buffer; `buf.getvalue()` hands the bytes to `broker.publish` exactly as the JSON path did.
::: listing lumen/messaging/avro_publisher.py | Listing 10.6 — Publishing a wallet event with Avro encoding
from __future__ import annotations
@@ -616,7 +772,13 @@ Without a dead-letter strategy, a failed consumer either drops the message (data
### Decorator-native retry and DLQ
-In PyFly, retry and dead-letter routing are built into `@message_listener` — no try/except scaffolding, no manual publish to the DLQ. Declare `retries` and `dead_letter_topic` directly on the decorator:
+In PyFly, retry and dead-letter routing are built into `@message_listener` — no try/except scaffolding, no manual publish to the DLQ. Declare `retries` and `dead_letter_topic` directly on the decorator. You add resilience by adding *arguments*, not code:
+
+**Step 1 — Start from a normal listener.** The handler body in the next listing is an ordinary consumer that decodes the payload and calls a worker (`_charge`).
+
+**Step 2 — Add `retries` (and optionally `retry_delay`).** `retries=3` tells the framework to re-invoke the handler up to three more times if it raises. `retry_delay=0.5` spaces those attempts out with linear back-off.
+
+**Step 3 — Add `dead_letter_topic`.** When all retries are exhausted, the framework re-publishes the message there instead of letting the exception crash the consumer. You write neither the retry loop nor the DLQ publish.
::: listing lumen/messaging/resilient_consumer.py | Listing 10.7 — Retry and DLQ wired through @message_listener
from __future__ import annotations
@@ -672,6 +834,55 @@ class ResilientWalletConsumer:
The exception is then swallowed so the consumer keeps running — the message is parked, not lost, and the next message on the topic is processed normally.
+!!! tip "Run it"
+ You can watch a poisoned message land in the DLQ without a real broker.
+ At wiring time the framework wraps every listener with
+ `pyfly.messaging.error_handling.wrap_listener` — the same helper does
+ the retrying and dead-lettering. Drive it directly so the flow is
+ visible. Save this as `dlq_demo.py` and run `uv run python dlq_demo.py`:
+
+ ```python
+ import asyncio, json
+ from pyfly.messaging.adapters.memory import InMemoryMessageBroker
+ from pyfly.messaging.error_handling import wrap_listener
+ from pyfly.messaging.types import Message
+
+ async def always_fails(msg: Message) -> None:
+ raise RuntimeError("downstream unavailable")
+
+ async def main() -> None:
+ broker = InMemoryMessageBroker()
+ await broker.start()
+
+ async def show_dlq(msg: Message) -> None:
+ print("DLQ:", msg.headers["x-original-topic"],
+ msg.headers["x-exception"])
+ await broker.subscribe("wallet.events.DLQ", show_dlq)
+
+ handler = wrap_listener(
+ always_fails, broker,
+ retries=2, dead_letter_topic="wallet.events.DLQ",
+ )
+ await handler(Message(
+ topic="wallet.events",
+ value=json.dumps({"wallet_id": "w-001"}).encode(),
+ ))
+ await broker.stop()
+
+ asyncio.run(main())
+ ```
+
+ After two retries the wrapper gives up and re-publishes to the DLQ,
+ where your monitor prints the diagnostic headers:
+
+ ```
+ DLQ: wallet.events RuntimeError
+ ```
+
+ The handler returned normally — the exception was swallowed, not
+ propagated — so in a real app the consumer would simply move on to the
+ next message.
+
### Monitoring the DLQ
Subscribe to the DLQ topic like any other listener to observe and alert on dead-lettered messages:
@@ -722,7 +933,15 @@ A healthy broker is not guaranteed. Network partitions, rolling upgrades, and re
Neither is acceptable. The transactional outbox (Chapter 9) is the atomic solution — the event is captured in the database and a relay publishes it asynchronously, so a broker outage adds only latency, not data loss. Alongside the outbox, **circuit breakers** and **retries** protect the relay and any broker-calling code from cascading failures.
-PyFly's resilience module (`pyfly.resilience`) provides both primitives. The circuit breaker opens after a configurable failure threshold and blocks calls to the broker during a cool-down period, preventing a thundering-herd reconnection storm. The retry decorator handles transient errors with configurable back-off:
+A **circuit breaker** is the electrical metaphor made into code: after too many failures in a row it "trips" and stops letting calls through for a cool-down period, so a struggling broker is not hammered by thousands of doomed reconnection attempts. A **retry** is the complementary tactic — try the same call again a few times, because many failures are momentary.
+
+PyFly's resilience module (`pyfly.resilience`) provides both primitives. The circuit breaker opens after a configurable failure threshold and blocks calls to the broker during a cool-down period, preventing a thundering-herd reconnection storm. The retry decorator handles transient errors with configurable back-off. You apply them as a pair of decorators on the publish method:
+
+**Step 1 — Write the plain publish method.** `forward` does one thing: encode the record and call `self._broker.publish(...)`. No resilience logic lives in the body.
+
+**Step 2 — Wrap it in `@circuit_breaker`.** Pass a shared `CircuitBreaker` instance so the failure count accumulates across calls, not per call. When the broker is down, the breaker trips and fails fast.
+
+**Step 3 — Wrap that in `@retry` on the outside.** Decorator order matters: `@retry` sits above `@circuit_breaker`, so all of one call's retry attempts happen before the breaker registers a single failure. Keep `max_attempts` low and let the breaker absorb sustained outages.
::: listing lumen/messaging/resilient_publisher.py | Listing 10.9 — Resilient broker publishing with retry and circuit breaker
from __future__ import annotations
@@ -829,6 +1048,26 @@ Part IV introduces the `PaymentsService` and `NotificationsService`. Both subscr
## Try it yourself {.exercises}
+!!! note "Run it"
+ Each exercise below ends in a test. Because `provider: "memory"` needs
+ no broker, you can run them with nothing installed beyond the dev
+ extra. From the Lumen project root:
+
+ ```bash
+ uv run --extra dev pytest tests/test_messaging.py -q
+ ```
+
+ A green run looks like:
+
+ ```text
+ ... [100%]
+ 3 passed in 0.XXs
+ ```
+
+ If a test fails with a missing-attribute error such as
+ `__pyfly_message_listener__`, the `@message_listener` decorator is not
+ applied to your handler — recheck Step 2 of "Declarative listeners."
+
1. **Swap the adapter in one line.** Start with `provider: "memory"` in
`pyfly.yaml` and add the `@message_listener` from Listing 10.4. Write
an integration test that publishes a `FundsDeposited` message with
diff --git a/book/manuscript/11-http-clients.md b/book/manuscript/11-http-clients.md
index 63b40660..49be5f85 100644
--- a/book/manuscript/11-http-clients.md
+++ b/book/manuscript/11-http-clients.md
@@ -27,6 +27,24 @@ end of the chapter you will also see how a **BFF (Backend for Frontend)**
tier sits in front of both services and composes their capabilities into
a single, user-journey-focused API.
+!!! note "New jargon, in plain language"
+ A few terms recur throughout this chapter. **Service-to-service call**
+ means one of your services making an HTTP request to another of your
+ services (Wallet calling Payments), as opposed to a request coming
+ from a browser. A **declarative client** is a client you *describe*
+ rather than *implement*: you write the method signatures and let the
+ framework fill in the HTTP plumbing. A **circuit breaker** is a safety
+ switch that stops calling a remote service after it has failed too
+ many times in a row. A **BFF** (Backend for Frontend) is a thin
+ service whose only job is to call other services and reshape their
+ answers for one particular app. We will build each of these one piece
+ at a time.
+
+This chapter is built around `httpx` and the `pyfly.client` package, both
+of which ship with the framework. Everything here runs against PyFly
+v26.6.110. You do not need to install anything new — if you have been
+following along with Lumen, the client tooling is already on your path.
+
---
## Why split (and why it hurts)
@@ -99,7 +117,26 @@ Reserve `@http_client` for internal utilities and test doubles.
The Payments service exposes two endpoints: one to create a payment
instruction and one to retrieve a payment by identifier. Defining the
-client means writing the class:
+client means writing the class. Let us build it one decision at a time.
+
+**Step 1 — Create the file.** Add `src/lumen/sdk/payments_client.py`
+alongside the existing `client.py`. The `sdk` package is where Lumen keeps
+the code that *talks to* services, so this is the natural home for a
+service client.
+
+**Step 2 — Decorate the class.** Put `@service_client(base_url=...)` on a
+plain class. That single decorator is what turns an ordinary class into a
+PyFly-managed HTTP client: it records the base URL, switches on the
+resilience features, and registers the class as a bean so the container
+can inject it later.
+
+**Step 3 — Declare one method per endpoint.** Each method gets a verb
+decorator (`@post`, `@get`, `@patch`, `@delete`) carrying the path. The
+method body is just `...` — an *ellipsis*, Python's literal for "nothing
+here yet." You never write the request code; PyFly writes it for you at
+startup.
+
+The full client looks like this.
::: figure art/figures/11-client.svg | Figure 11.1 — The PyFly declarative client pipeline. You write the interface; HttpClientBeanPostProcessor generates the implementation.
@@ -185,6 +222,42 @@ the dict serialised as the JSON body. Parameters named `body` on
POST/PUT/PATCH methods are always treated as the JSON request body; all other
non-path parameters on GET/DELETE become query-string parameters.
+!!! note "What just happened"
+ You wrote four method *signatures* and zero lines of HTTP code. The
+ `@service_client` decorator tagged the class for the container; the
+ verb decorators tagged each method with a verb and a path. At startup,
+ a behind-the-scenes component called `HttpClientBeanPostProcessor`
+ reads those tags and quietly replaces every `...` stub with a working
+ async method that builds the URL, sends the request, and turns the
+ response back into Python. From the caller's point of view,
+ `await client.get_payment("pay-123")` looks exactly like calling a
+ local method — the network is hidden.
+
+**Run it — confirm the stubs are wired.** Until the application context
+starts, those `...` bodies raise `NotImplementedError` on purpose, so you
+cannot just call the method in a bare script. The honest way to prove the
+client works is a tiny test that starts a context (or wires the
+post-processor) and inspects the generated method. The quickest smoke
+check is to confirm the metadata the decorators stamped on:
+
+```
+uv run python -c "from lumen.sdk.payments_client import PaymentsClient; \
+print(PaymentsClient.__pyfly_http_base_url__); \
+print(PaymentsClient.create_payment.__pyfly_http_method__, \
+PaymentsClient.create_payment.__pyfly_http_path__)"
+```
+
+Expected output:
+
+```
+http://payments-service:8080
+POST /payments
+```
+
+If you see the base URL and `POST /payments`, the decorators applied
+correctly and the post-processor has everything it needs to generate the
+real implementation when the app boots.
+
!!! spring "Spring parity"
`@service_client` with `@get`/`@post`/`@put`/`@delete`/`@patch` is
PyFly's counterpart of Spring Cloud OpenFeign's `@FeignClient` with
@@ -205,10 +278,33 @@ Because `PaymentsClient` is a singleton bean, any `@service` or
container injects it through the same autowiring path used for
repositories and domain services.
+!!! note "Bean and autowiring, briefly"
+ A **bean** is just an object the framework creates and manages for you
+ — you never call its constructor yourself. **Autowiring** is how the
+ container hands a bean to whatever needs it: when a class lists
+ `payments: PaymentsClient` in its `__init__`, PyFly notices the type,
+ finds the matching bean, and passes it in automatically. You declare
+ the dependency by *type*; the container does the lookup.
+
Lumen's Wallet service already applies the `WalletRepository` and `Money`
value object pattern from earlier chapters. When the wallet must call
-Payments to settle a withdrawal, the handler follows that same pattern:
-withdraw through the aggregate, then call the external service:
+Payments to settle a withdrawal, the handler follows that same pattern in
+three steps: load the wallet, withdraw through the aggregate, then call the
+external service.
+
+**Step 1 — Declare the dependency.** Add `payments: PaymentsClient` to the
+handler's constructor and stash it on `self`. That is the only wiring you
+write; the container supplies the live client.
+
+**Step 2 — Do the local work first.** Load the wallet and call
+`wallet.withdraw(...)`, then persist it, so the wallet's own state is
+settled before any network call happens.
+
+**Step 3 — Call the remote service.** `await self._payments.create_payment(...)`
+reads like a local method call. The resilience and HTTP details are
+already baked into the injected client.
+
+Here is the handler.
::: listing lumen/core/services/wallets/settle_transfer_handler.py | Listing 11.2 — CommandHandler injecting PaymentsClient
from __future__ import annotations
@@ -272,6 +368,28 @@ temporarily unavailable, the retry and circuit breaker — described in the
next section — handle recovery transparently, without any code in the
handler.
+**Run it — exercise the handler with a fake client.** Because the handler
+depends on the *type* `PaymentsClient`, you can substitute a stand-in in a
+test without any network. Drop this into `tests/test_settle_transfer.py`
+and run it:
+
+```
+uv run --extra dev pytest tests/test_settle_transfer.py -q
+```
+
+A passing run prints something like:
+
+```
+1 passed in 0.12s
+```
+
+The point of the test is the substitution: you pass a hand-made object in
+place of the real `PaymentsClient`, assert the handler called
+`create_payment` with the expected body, and never open a socket. That is
+the practical payoff of declaring the dependency by type — the handler
+neither knows nor cares whether the client on the other end is real or
+faked.
+
---
## Resilience on the wire
@@ -344,6 +462,60 @@ state transitions `CLOSED → HALF_OPEN` are computed lazily with
the circuit is already open, so re-raising it without recording another
failure prevents the recovery timeout from resetting indefinitely.
+!!! note "Three states, in plain language"
+ Think of the breaker as a light switch with three positions.
+ **Closed** is normal — calls flow through. **Open** means "stop
+ trying" — calls fail instantly without touching the network, which
+ spares your service from waiting on something that is clearly down.
+ **Half-open** is "let me test the water" — after the recovery timeout,
+ the breaker lets exactly one call through; if it works, the switch
+ goes back to closed, and if it fails, it snaps open again.
+
+**Run it — watch a breaker open and recover.** You can drive the breaker
+by hand from a REPL with no real service involved. Start one with
+`uv run python` from `samples/lumen` and try:
+
+```
+uv run python -c "
+import asyncio
+from datetime import timedelta
+from pyfly.client import CircuitBreaker, CircuitState
+from pyfly.kernel.exceptions import CircuitBreakerException
+
+async def boom():
+ raise RuntimeError('payments down')
+
+async def main():
+ cb = CircuitBreaker(failure_threshold=2, recovery_timeout=timedelta(seconds=30))
+ for _ in range(2):
+ try:
+ await cb.call(boom)
+ except RuntimeError:
+ pass
+ print('state after 2 failures:', cb.state.name)
+ try:
+ await cb.call(boom)
+ except CircuitBreakerException:
+ print('open circuit rejected the call without calling boom')
+
+asyncio.run(main())
+"
+```
+
+Expected output:
+
+```
+state after 2 failures: OPEN
+open circuit rejected the call without calling boom
+```
+
+The second line is the whole point: once the circuit is open, the breaker
+raises `CircuitBreakerException` *immediately* instead of running `boom`
+again. Drop `recovery_timeout` to `timedelta(seconds=0)` and re-run — the
+next read of `cb.state` reports `HALF_OPEN` and the third call admits a
+probe (so `boom` runs again) rather than being rejected. That is the
+breaker letting the service prove it has recovered.
+
### Retry policy
Transient failures — a momentary latency spike, a rolling restart, a
@@ -383,6 +555,50 @@ others propagate immediately. This matters: you do not want to retry a
404 (the resource does not exist) or a 422 (the request is semantically
invalid).
+!!! note "Backoff, in plain language"
+ *Backoff* means waiting a little longer before each retry instead of
+ hammering the remote service the instant it fails. *Exponential*
+ backoff doubles the wait each time, so a service that needs a moment
+ to recover gets more breathing room with every attempt while a healthy
+ one is retried almost immediately.
+
+**Run it — see retry recover from a transient error.** Simulate a call
+that fails twice and then succeeds:
+
+```
+uv run python -c "
+import asyncio
+from datetime import timedelta
+from pyfly.client import RetryPolicy
+
+attempts = {'n': 0}
+async def flaky():
+ attempts['n'] += 1
+ if attempts['n'] < 3:
+ raise ConnectionError('reset')
+ return 'ok'
+
+async def main():
+ policy = RetryPolicy(max_attempts=3, base_delay=timedelta(milliseconds=1),
+ retry_on=(ConnectionError,))
+ print('result:', await policy.execute(flaky))
+ print('attempts:', attempts['n'])
+
+asyncio.run(main())
+"
+```
+
+Expected output:
+
+```
+result: ok
+attempts: 3
+```
+
+Three attempts, one success. Change `retry_on` to `(TimeoutError,)` and
+the very first `ConnectionError` propagates instead — proof that only the
+exceptions you list are retried.
+
When `@service_client` enables both features, the post-processor wraps
them in the correct order: circuit breaker *outside*, retry *inside*. A
single logical call attempts up to `max_attempts` retries before the
@@ -393,7 +609,10 @@ immediately, bypassing the retry loop entirely.
When the remote service returns a 4xx or 5xx response, the generated
method raises a typed exception instead of returning the error payload as
-if it were a success. The exception hierarchy lives in `pyfly.client`:
+if it were a success. The exception hierarchy lives in
+`pyfly.client.exceptions` — import the classes you want to catch from
+there (for example,
+`from pyfly.client.exceptions import ServiceNotFoundException`):
| Status | Exception class | `retryable` |
|---|---|---|
@@ -411,6 +630,16 @@ All exceptions extend `ServiceClientException` (itself an
post-processor which exceptions to pass to the retry policy. 4xx
validation errors and 404s are never retried.
+!!! note "What just happened"
+ The three resilience pieces fit together like nested boxes. The
+ **typed exceptions** classify *what kind* of failure occurred — a 404
+ is not retryable, a 503 is. The **retry policy** uses that
+ classification to decide whether to try again. The **circuit breaker**
+ sits outside the retry loop and counts sustained failures so it can
+ stop calling a service that is genuinely down. You did not write any
+ of this glue: `@service_client` assembled it the moment you set
+ `circuit_breaker=True` and `retry=3`.
+
### Configuring defaults in pyfly.yaml
Per-service overrides on `@service_client` always take precedence.
@@ -444,6 +673,41 @@ to `HttpClientBeanPostProcessor`. Any value set directly on
`@service_client(circuit_breaker_failure_threshold=...)` overrides the
default.
+!!! note "Precedence, in plain language"
+ Two layers can set these knobs: the global `pyfly.yaml` defaults and
+ the per-client decorator arguments. The decorator always wins. Think
+ of `pyfly.yaml` as the house style every new client inherits, and the
+ decorator as the place to override that style for one demanding
+ upstream.
+
+**Run it — confirm the config is read.** After adding the block to
+`pyfly.yaml`, read the values back through PyFly's `Config` to be sure the
+keys are spelled correctly (a common cause of "my timeout is being
+ignored" is a typo). Run this from the project root, where `pyfly.yaml`
+lives:
+
+```
+uv run python -c "
+from pyfly.core.config import Config
+cfg = Config.from_sources('.')
+print('timeout:', cfg.get('pyfly.client.timeout'))
+print('cb:', cfg.get('pyfly.client.circuit-breaker'))
+"
+```
+
+Expected output once Listing 11.5 is in place:
+
+```
+timeout: 10
+cb: {'failure-threshold': 5, 'recovery-timeout': 30}
+```
+
+Before you add the block, `timeout` reports `30` — the framework default
+from `pyfly-defaults.yaml` — which confirms the override is what changed
+it. If a key comes back as `None`, check the indentation in `pyfly.yaml`:
+YAML nesting is whitespace-sensitive, and a misaligned `circuit-breaker:`
+silently lands under the wrong parent.
+
!!! tip "Set per-service timeouts low"
The default `timeout: 30` is conservative. In production, each
service should carry a `pyfly.yaml` override tuned to its SLA. A
@@ -464,6 +728,23 @@ treated specially by the post-processor: when a stub method declares
`headers: dict`, the value is forwarded as HTTP request headers, not
serialised as a query string.
+!!! note "JWT, in plain language"
+ A **JWT** (JSON Web Token) is a signed string that travels in the
+ `Authorization` header and proves who the caller is. When Wallet
+ forwards the caller's JWT to Payments, Payments can re-check it and
+ apply its own rules — the identity is carried across the network
+ boundary rather than re-established from scratch.
+
+To forward headers, you add a single optional parameter. There is no new
+decorator and no special configuration:
+
+- **Add `headers: dict | None = None` to the method.** The name `headers`
+ is the magic word — the post-processor recognises it and routes its
+ value to HTTP headers instead of the query string.
+- **Pass a dict at the call site.** The handler supplies
+ `headers={"Authorization": f"Bearer {token}"}`, and PyFly attaches it to
+ the outgoing request.
+
::: listing lumen/sdk/payments_client_auth.py | Listing 11.6 — Forwarding auth headers per-call
from __future__ import annotations
@@ -636,9 +917,22 @@ domain service clients.
### Building the Lumen BFF
The Lumen SDK already ships a `LumenClient` in `lumen/sdk/client.py` that
-wraps a raw `httpx.AsyncClient`. In the BFF tier you use PyFly's
-declarative `@service_client` instead — the interface is identical but
-resilience is built in automatically. Here is the wallet-side client:
+wraps a raw `httpx.AsyncClient` — it takes an `httpx.AsyncClient` in its
+constructor and calls `self._http.get(...)`/`self._http.post(...)` by hand,
+raising on error with `response.raise_for_status()`. That is the
+*imperative* style: useful, explicit, and entirely your responsibility to
+keep resilient. In the BFF tier you use PyFly's declarative
+`@service_client` instead — the calling code looks the same, but the
+circuit breaker and retry are built in automatically.
+
+We will build the BFF in four small pieces, each its own file:
+
+**Step 1 — A `WalletClient`** that knows how to reach the Wallet service.
+**Step 2 — A `PaymentsClient`** with one extra endpoint the BFF needs.
+**Step 3 — A `WalletSummaryService`** that calls both and merges the
+results. **Step 4 — A thin controller** that exposes the merged view.
+
+Start with the wallet-side client.
::: listing lumen_bff/sdk/wallet_client.py | Listing 11.9 — WalletClient for the BFF tier
from __future__ import annotations
@@ -701,7 +995,16 @@ class PaymentsClient:
:::
The BFF service then composes the wallet balance with the pending payments
-list into a single response:
+list into a single response.
+
+!!! note "asyncio.gather, in plain language"
+ `asyncio.gather(a, b)` starts coroutines `a` and `b` at the same time
+ and waits for both to finish, returning their results as a list. For a
+ BFF this means two upstream calls overlap instead of queuing one after
+ the other. Adding `return_exceptions=True` changes one thing: instead
+ of the whole `gather` blowing up when one call fails, the failed call's
+ exception is handed back as that call's *result*, so you can inspect it
+ and still use the successful one.
::: listing lumen_bff/application/bff_service.py | Listing 11.11 — BFF service composing Wallet + Payments
from __future__ import annotations
@@ -814,6 +1117,36 @@ controller → BFF service → declarative clients → remote HTTP. Each layer
is independently testable: the controller with a mock service, the
service with mock clients, and the clients with a mock `HttpClientPort`.
+**Run it — call the composed endpoint.** With the BFF application running
+(`uv run pyfly run --server uvicorn`) and the Wallet and Payments services
+reachable, hit the summary route with a wallet id you have already
+created:
+
+```
+curl -s http://localhost:8080/api/v1/wallets/wal-123/summary
+```
+
+Expected shape (values depend on your data):
+
+```
+{"wallet_id": "wal-123", "balance_minor": 5000, "pending_payments": []}
+```
+
+The single response merges two upstream services. To see the graceful
+degradation in action, stop the Payments service and call again — the
+`balance_minor` still comes back from Wallet, and `pending_payments`
+falls back to `[]` rather than the whole request returning a 500. That is
+`return_exceptions=True` doing its job.
+
+!!! note "Default app port"
+ PyFly serves the application on `pyfly.server.port`, which defaults to
+ `8080` (matching Spring's `server.port`). Override it with the
+ `PYFLY_SERVER_PORT` environment variable or the `pyfly.server.port`
+ config key. Note that the actuator and admin dashboard run on a
+ *separate* management port — `pyfly.management.server.port`, default
+ `9090` — so health and info endpoints never collide with your API
+ routes.
+
!!! note "BFF scope and team ownership"
A BFF is scoped to one frontend or one user journey — not one per
microservice. Lumen might have a `lumen-mobile-bff` and a
@@ -896,11 +1229,15 @@ Three principles carry through the rest of Part IV:
2. **Test the BFF with degraded upstream services.** Write a unit test
for `WalletSummaryService.get_summary` that mocks
`WalletClient.get_wallet` to succeed and
- `PaymentsClient.list_pending` to raise `ServiceUnavailableException`.
+ `PaymentsClient.list_pending` to raise `ServiceUnavailableException`
+ (import it with
+ `from pyfly.client.exceptions import ServiceUnavailableException`).
Assert that the method returns a dict with the correct `balance_minor`
and an empty `pending_payments` list — confirming that the
partial-response fallback works and a single upstream failure does not
- propagate as an exception to the BFF's caller.
+ propagate as an exception to the BFF's caller. Run it with
+ `uv run --extra dev pytest tests/test_wallet_summary.py -q` and look
+ for `1 passed`.
3. **Tune circuit breaker thresholds for a brittle upstream.** Suppose
Payments has a known flakiness window during its nightly batch run:
diff --git a/book/manuscript/12-sagas.md b/book/manuscript/12-sagas.md
index e65c3e56..9df20e2a 100644
--- a/book/manuscript/12-sagas.md
+++ b/book/manuscript/12-sagas.md
@@ -86,11 +86,24 @@ method as a step with its compensation, and declare the dependency
ordering. The engine discovers the class through the DI container, builds
a validated DAG at startup, and drives execution asynchronously.
+!!! note "New term: orchestration"
+ *Orchestration* means one central component — here, PyFly's `SagaEngine` — decides the order in which steps run and what to do when one fails. The alternative, *choreography*, has each service react to events with no central conductor. This chapter uses orchestration because it makes the recovery path explicit and easy to test: the engine owns the rules, your saga class just declares the steps.
+
+We will build the transfer saga in four moves: turn the engine on, declare
+the saga class, look at the DAG the engine builds from it, then call the
+engine from a service. Take them one at a time.
+
### Enabling the engine
The transactional engine is activated by the `@enable_domain_stack`
-starter decorator on your application class, together with a single YAML
-property. In Lumen:
+starter decorator on your application class — and that single decorator is
+all you need. No extra YAML is required. In Lumen:
+
+**Add the starter decorator.** Open your application class and
+stack `@enable_domain_stack` above `@pyfly_application`. A *starter*
+decorator is PyFly's way of switching on a whole feature area (here, the
+transactional engine and its DI wiring) without you registering each
+component by hand — the Spring equivalent is an `@EnableXxx` annotation.
::: listing lumen/app.py | Listing 12.1 — Enabling the transactional engine via the domain stack
from pyfly.core import pyfly_application
@@ -110,7 +123,17 @@ class LumenApplication:
pass
:::
-Add the property in `application.yaml`:
+**Turning it off, or on under a narrower starter.** `@enable_domain_stack`
+already sets `pyfly.transactional.enabled: true` for you, so the engine is
+live as soon as the decorator is on the class. The same property is the
+knob you reach for in two situations:
+
+- **To switch the engine off** under the domain stack, set it to `false` in
+ `application.yaml` — the auto-configuration is gated on the value being
+ exactly `"true"`, so anything else disables it.
+- **To switch it on under a narrower starter** such as `@enable_core_stack`
+ (which does *not* include the transactional engine), add the property
+ yourself:
```yaml
pyfly:
@@ -118,17 +141,41 @@ pyfly:
enabled: true
```
-**How it works:** `@enable_domain_stack` imports
-`TransactionalEngineAutoConfiguration`, guarded by
-`@conditional_on_property("pyfly.transactional.enabled", having_value="true")`.
-When the property is set, the auto-configuration wires every engine
-component — `SagaEngine`, `TccEngine`, `WorkflowEngine`, `SagaRegistry`,
+!!! note "Run it: confirm the engine wired up"
+ Start the app on its default port (`pyfly.server.port` is `8080` in v26.6.110) and watch the startup log:
+
+ ```bash
+ uv run pyfly run
+ ```
+
+ Among the startup lines you should see the transactional components register, for example:
+
+ ```
+ INFO pyfly.starters.domain domain stack enabled: transactional engine active
+ INFO pyfly.transactional registered saga 'money-transfer' (2 steps)
+ INFO pyfly.server Uvicorn running on http://0.0.0.0:8080
+ ```
+
+ If you explicitly set `transactional.enabled: false` (or enable only the core stack without adding the property), the saga line never appears and `SagaEngine.execute(...)` later raises `ValueError: Saga 'money-transfer' is not registered`. Seeing the `registered saga` line is your proof the wiring worked.
+
+**How it works:** `@enable_domain_stack` merges `DOMAIN_STACK_PROPERTIES`
+into the active config, and that dict already contains
+`pyfly.transactional.enabled: "true"` — so the decorator both registers
+*and* activates the engine in one move. The auto-configuration,
+`TransactionalEngineAutoConfiguration`, is guarded by
+`@conditional_on_property("pyfly.transactional.enabled", having_value="true")`;
+because the starter set the value to `"true"`, the condition matches and
+the auto-configuration wires every engine component — `SagaEngine`,
+`TccEngine`, `WorkflowEngine`, `SagaRegistry`,
`InMemoryPersistenceAdapter`, and `LoggerEventsAdapter` — into the DI
container. The `OrchestrationBeanPostProcessor` then scans every bean
produced at startup: any bean carrying `__pyfly_saga__` metadata is
registered into `SagaRegistry` automatically. You never call
`registry.register_from_bean()` in production code.
+!!! note "What just happened"
+ One small change — a single decorator — gave you a fully wired saga engine. `@enable_domain_stack` both declared the components and turned them on (it sets `pyfly.transactional.enabled: true` for you), and a startup bean post-processor found your saga classes and registered them for you. From here on you only write saga classes and call `SagaEngine.execute(...)`; the plumbing is done.
+
### Declaring the transfer saga
Lumen's wallet transfer is a two-step saga: debit the source wallet, then
@@ -136,6 +183,14 @@ credit the destination. If the credit fails — wrong currency or missing
wallet — the engine compensates by re-crediting the source, returning both
balances to their original values.
+!!! note "New term: compensation"
+ A *compensation* (or *compensating transaction*) is the undo for a step. It is not a database rollback — by the time you compensate, the original write has already committed to its own store. Instead it is a *new forward operation* that semantically reverses the effect. The undo for "debit the source" is not `ROLLBACK`; it is "deposit the same amount back into the source". Every step that changes state needs a matching compensation.
+
+Build it in three moves. **Step 1** — declare the class and stack the
+decorators. **Step 2** — write the forward steps and their compensation.
+**Step 3** — wire the parameters with injection markers. The complete file
+is below; we then walk each move.
+
::: listing lumen/core/services/transfers/money_transfer_saga.py | Listing 12.2 — MoneyTransferSaga: debit → credit, with compensation
from __future__ import annotations
@@ -245,6 +300,7 @@ class MoneyTransferSaga:
**How it works — step by step:**
+**Step 1 — the decorator stack.**
`@saga(name=MONEY_TRANSFER_SAGA)` stamps `__pyfly_saga__` on the class
with the saga name. The decorator only attaches metadata — it does not
wrap the class or create a proxy. **The critical requirement** is that
@@ -255,6 +311,10 @@ causes the DI container to instantiate and scan the bean at startup; the
Without `@service`, the class is never scanned and the saga cannot be
executed by name.
+!!! warning "Decorator order is not optional"
+ Read the stack top-to-bottom: `@saga` is *above* `@service`. Swap them — `@service` above `@saga` — and the bean still registers with DI, but the saga metadata is applied to the already-wrapped object, the post-processor never finds it, and `execute("money-transfer")` fails with `ValueError: Saga 'money-transfer' is not registered`. If you hit that error, check the decorator order first.
+
+**Step 2 — the step methods.**
`@saga_step` attaches `__pyfly_saga_step__` metadata directly to the
async method — no wrapper, no proxy — so `inspect.iscoroutinefunction`
keeps returning `True` and the engine correctly `await`s the call. The
@@ -282,6 +342,7 @@ Because saga steps share one `AsyncSession`, `upsert` flushes so each
step sees the previous step's write; the surrounding application boundary
owns the final commit.
+**Step 3 — wire the parameters.**
Parameter injection uses `typing.Annotated` with **marker instances**, not
bare classes:
@@ -302,8 +363,14 @@ back, and upserts — the same load-mutate-save cycle as the forward steps.
Compensations always read from `ctx.step_results` via `FromStep`, never
from the original input.
+!!! note "What just happened"
+ You wrote one class that holds the whole transfer story: a forward step to debit, its compensation to re-credit, and a second forward step to credit. The decorators told the engine *what each method is* (a step, a compensation) and *how they connect* (`compensate=`, `depends_on=`). You did not write any orchestration loop or try/except rollback logic — that is the engine's job. Your code only describes the business operation and its undo.
+
### The step DAG
+!!! note "New term: DAG"
+ A *DAG* — directed acyclic graph — is a set of steps connected by "must-run-before" arrows, with no cycles (no step can, directly or indirectly, depend on itself). The engine reads your `depends_on` declarations, builds this graph, and sorts it into *layers*: everything in layer 0 has no unmet dependencies and runs first; layer 1 runs once layer 0 finishes; and so on. Steps in the same layer are independent, so the engine runs them at the same time. A cycle would make the layering impossible, so the engine rejects it at startup rather than at run time.
+
The two steps form a linear chain:
::: figure art/figures/12-saga.svg | Figure 12.1 — DAG for MoneyTransferSaga: steps run in topological-layer order; independent steps in a layer run with asyncio.gather.
@@ -322,7 +389,14 @@ two checks in the same layer and run them concurrently with
### Executing the saga
-Inject `SagaEngine` from the DI container and call `execute`:
+The saga class only *describes* the operation. To *run* it you need a thin
+service that injects the engine and calls it by name.
+
+**Step 1 — inject `SagaEngine`.** Declare a `@service` whose constructor
+takes a `SagaEngine` parameter; the DI container hands you the
+auto-configured engine. **Step 2 — call `execute`** with the saga name and
+the input payload. **Step 3 — fold the `SagaResult`** into a small,
+JSON-friendly dict for the caller.
::: listing lumen/core/services/transfers/transfer_service.py | Listing 12.3 — Executing the money transfer saga
from __future__ import annotations
@@ -385,6 +459,44 @@ successfully rolled back.
- `result.correlation_id` — UUID to correlate logs and traces across services.
- `result.error` — the exception that stopped the saga, or `None` on success.
+!!! note "Run it: the happy path and the compensated path"
+ Expose `TransferService.transfer` behind an HTTP route (Chapter 11 covered controllers) and exercise both outcomes against the running app on `pyfly.server.port` (`8080`).
+
+ A valid transfer between two existing same-currency wallets returns the completed summary:
+
+ ```bash
+ curl -s -X POST http://localhost:8080/transfers \
+ -d '{"source_wallet_id":"w-1","destination_wallet_id":"w-2","amount":500,"currency":"EUR"}'
+ ```
+
+ ```json
+ {
+ "status": "completed",
+ "correlation_id": "8f3c…",
+ "source_balance": 9500,
+ "destination_balance": 10500
+ }
+ ```
+
+ Now point the transfer at a destination wallet that does not exist. `credit-destination` raises `AggregateNotFound`, the engine compensates `debit-source`, and the response reports exactly that:
+
+ ```bash
+ curl -s -X POST http://localhost:8080/transfers \
+ -d '{"source_wallet_id":"w-1","destination_wallet_id":"does-not-exist","amount":500,"currency":"EUR"}'
+ ```
+
+ ```json
+ {
+ "status": "failed",
+ "correlation_id": "1a2b…",
+ "failed_steps": ["credit-destination"],
+ "compensated_steps": ["debit-source"],
+ "error": "Wallet 'does-not-exist' not found"
+ }
+ ```
+
+ The key observation: re-read `w-1` afterwards and its balance is back to `9500` — `debit-source` was rolled back by `recredit_source`. A failed transfer leaves both wallets exactly as they started.
+
!!! spring "Spring parity"
`@saga` / `@saga_step` mirror `@Saga` / `@SagaStep` in the Java `fireflyframework-transactional-engine` library. The decorator-stack rule (`@saga` on `@service`) mirrors the Java rule that `@Saga` must be on a `@Service`-annotated class so the `WorkflowBeanPostProcessor` can discover it. The parameter-injection markers (`Input()`, `FromStep("id")`) map directly to `@Input` and `@FromStep` in the Java version. The async model differs: Java uses Project Reactor (`Mono`) while PyFly uses native `async/await` with `asyncio.gather` for parallel layers.
@@ -421,6 +533,38 @@ reads the `DebitResult` that `debit_source` stored in the context when it
completed — so you always compensate with the actual committed data, never
an approximation.
+!!! note "Run it: prove compensation in a test"
+ You do not need a running server to verify the unhappy path — a fast unit test against the engine is enough, and it is the kind of test you will write for every saga. Drive `TransferService.transfer` with a destination wallet that does not exist, then assert the compensation ran:
+
+ ```python
+ async def test_failed_transfer_compensates_the_debit(transfer_service):
+ result = await transfer_service.transfer(
+ TransferRequest(
+ source_wallet_id="w-1",
+ destination_wallet_id="does-not-exist",
+ amount=500,
+ currency=Currency.EUR,
+ )
+ )
+ assert result["status"] == "failed"
+ assert result["failed_steps"] == ["credit-destination"]
+ assert result["compensated_steps"] == ["debit-source"]
+ ```
+
+ Run just this test (the `--extra dev` group installs pytest; Chapter 16 covers fixtures in depth):
+
+ ```bash
+ uv run --extra dev pytest -q -k compensates
+ ```
+
+ Expected output:
+
+ ```
+ 1 passed in 0.05s
+ ```
+
+ A green test here is your guarantee that a broken transfer never leaves money missing.
+
### Compensation policies
Five policies govern how the engine runs compensations. Set the global
diff --git a/book/manuscript/13-caching-resilience.md b/book/manuscript/13-caching-resilience.md
index 87288887..f4ea85cc 100644
--- a/book/manuscript/13-caching-resilience.md
+++ b/book/manuscript/13-caching-resilience.md
@@ -38,12 +38,31 @@ in the right order.
By the end of the chapter, every hot path in Lumen will be cached and every
outbound dependency wrapped in a resilience fence.
+!!! note "What you will build, in plain terms"
+ This chapter introduces a lot of vocabulary — *cache*, *token bucket*,
+ *bulkhead*, *circuit breaker*. Do not let the jargon intimidate you. Every
+ one of these is a small, self-contained tool that you bolt onto a function
+ with a single decorator. We will introduce each tool one at a time, build
+ it into Lumen step by step, run it, and watch what changes. By the end you
+ will have a mental checklist: *is this read hot? cache it; is this call
+ going over the network? fence it.* The version of PyFly used throughout is
+ **v26.6.110** — every command and config key below matches that release.
+
---
## Caching the read path
### Why cache wallet reads?
+!!! note "New term: cache"
+ A *cache* is a small, fast holding area where you keep the answer to an
+ expensive question so you can hand it back instantly the next time someone
+ asks. The first time Lumen computes wallet `w-001`'s balance it stores the
+ result in the cache; subsequent reads return that stored copy without
+ re-running the database query. The trade-off — and there is always a
+ trade-off — is that the stored copy can be slightly out of date. The rest
+ of this section is about keeping that staleness inside acceptable bounds.
+
Lumen's most frequent operation is the balance query: "what is wallet
`w-001`'s current balance?" Under normal load that query hits the read
replica. Under heavy load it competes with deposit commands, saga
@@ -91,7 +110,11 @@ with zero cleanup overhead on your side.
### Setting up a cache backend
-For development, a single import is all you need:
+We will wire up two backends: an in-process one for development and a shared
+Redis-backed one for production. Take them one at a time.
+
+**Step 1 — Pick a development backend.** For development, a single import is
+all you need:
::: listing lumen/cache/config_dev.py | Listing 13.1 — InMemoryCache for development
from pyfly.cache.adapters.memory import InMemoryCache
@@ -103,7 +126,16 @@ wallet_cache = InMemoryCache(max_size=1000)
entries, the least-recently-used entry is dropped to make room. Pass `None`
(the default) to leave the cache unbounded and rely entirely on TTLs.
-For production, point `RedisCacheAdapter` at a `redis.asyncio.Redis` client:
+!!! note "New term: LRU and TTL"
+ *LRU* stands for *least-recently-used* — when the cache is full, the entry
+ nobody has touched for the longest is the one evicted to make room. *TTL*
+ stands for *time-to-live* — how long an entry stays valid before it expires
+ on its own. `InMemoryCache` supports both: `max_size` caps how many entries
+ it holds (LRU); each `put` can carry a TTL that ages the entry out.
+
+**Step 2 — Pick a production backend.** For production, point
+`RedisCacheAdapter` at a `redis.asyncio.Redis` client, and wrap it with an
+in-memory fallback so a Redis hiccup never takes Lumen down:
::: listing lumen/cache/config_prod.py | Listing 13.2 — RedisCacheAdapter for production
import redis.asyncio as aioredis
@@ -134,8 +166,37 @@ required. The `@bean` method tells PyFly's DI container to create a
singleton and inject it wherever `CacheAdapter` is declared as a
dependency.
+**What just happened.** You now have one `CacheAdapter` interface and two
+ways to satisfy it. In development you hand the DI container an
+`InMemoryCache`; in production you hand it a `CacheManager` that fronts Redis
+and quietly falls back to memory when Redis is unreachable. Every handler in
+the rest of this chapter asks for `cache: CacheAdapter` in its constructor and
+never knows or cares which one it got — that is the hexagonal payoff.
+
!!! tip "Auto-configuration"
- Add `pyfly.cache.enabled: true` and `pyfly.cache.provider: redis` to `pyfly.yaml` and PyFly will wire `RedisCacheAdapter` + `InMemoryCache` into a `CacheManager` automatically — no `@configuration` class needed.
+ You do not have to write the `@configuration` class at all. Add the
+ following to `pyfly.yaml` and PyFly's `CacheAutoConfiguration` builds a
+ `CacheAdapter` bean for you at startup:
+
+ ```yaml
+ pyfly:
+ cache:
+ enabled: true # required to switch the subsystem on
+ provider: redis # redis | postgres | memory | auto
+ redis:
+ url: redis://localhost:6379/0
+ max-size: 1000 # used by the memory provider
+ ```
+
+ With `provider: redis` (or `auto`, which detects an installed
+ `redis.asyncio`) the auto-config wires a `RedisCacheAdapter` pointed at
+ `pyfly.cache.redis.url`. It registers that single adapter as the
+ `CacheAdapter` bean — it does **not** add the in-memory failover layer.
+ When you want Redis *plus* the transparent in-process fallback shown in
+ Listing 13.2, declare the `CacheManager` yourself in a `@configuration`
+ class as above. The auto-config also backs off entirely if you have
+ already defined your own `CacheAdapter` bean (`@conditional_on_missing_bean`),
+ so the two approaches never collide.
### @cacheable — skip execution on a hit
@@ -145,9 +206,24 @@ the same key it returns the stored value *without executing the function body
at all*.
Lumen's `GetBalanceHandler` is a natural fit: balance reads are frequent,
-cheap to cache, and tolerate a few seconds of staleness. The handler receives
-`CacheAdapter` through its constructor — injected by PyFly — and wraps
-`do_handle` at construction time:
+cheap to cache, and tolerate a few seconds of staleness. We will add caching
+to it in three small moves.
+
+**Step 1 — Accept the cache.** Add a `cache: CacheAdapter` parameter to the
+constructor. PyFly's DI container sees the type and injects whichever backend
+you wired up in the previous section.
+
+**Step 2 — Move the real work into a private method.** Rename the body that
+hits the database to `_fetch`. This is the function the cache will wrap.
+
+**Step 3 — Wrap it at construction time.** Inside `__init__`, set
+`self.do_handle = cacheable(...)(self._fetch)`. We wrap inside `__init__`
+(rather than as a `@cacheable` decorator on the method) for one reason: the
+`backend=cache` argument only exists once `cache` has been injected, and that
+does not happen until `__init__` runs.
+
+The handler receives `CacheAdapter` through its constructor — injected by
+PyFly — and wraps `do_handle` at construction time:
::: listing lumen/core/services/wallets/get_balance_handler.py | Listing 13.3 — @cacheable on GetBalanceHandler
from datetime import timedelta
@@ -227,6 +303,69 @@ cacheable(
)(self._fetch)
```
+#### Run it — prove the second read skips the database
+
+The cleanest way to *see* a cache hit is a unit test that counts how many
+times the repository is called. Use a real `InMemoryCache` (no Redis needed)
+and a tiny stub repository:
+
+::: listing tests/cache/test_get_balance_cache.py | Listing 13.3a — A test that proves the second read is a hit
+from datetime import timedelta
+
+import pytest
+
+from pyfly.cache import cacheable
+from pyfly.cache.adapters.memory import InMemoryCache
+
+
+class _CountingRepo:
+ """Stub repository that records how many times it is queried."""
+
+ def __init__(self) -> None:
+ self.calls = 0
+
+ async def find_by_id(self, wallet_id: str) -> dict:
+ self.calls += 1
+ return {"wallet_id": wallet_id, "balance_minor": 500}
+
+
+@pytest.mark.asyncio
+async def test_second_read_is_a_cache_hit() -> None:
+ repo = _CountingRepo()
+ cache = InMemoryCache(max_size=10)
+
+ fetch = cacheable(
+ backend=cache,
+ key="wallet:balance:{wallet_id}",
+ ttl=timedelta(seconds=5),
+ )(repo.find_by_id)
+
+ first = await fetch("wlt-001") # miss -> runs the repo
+ second = await fetch("wlt-001") # hit -> repo NOT called again
+
+ assert first == second
+ assert repo.calls == 1 # the body ran exactly once
+:::
+
+Run just this test:
+
+```console
+$ uv run --extra dev pytest tests/cache/test_get_balance_cache.py -q
+. [100%]
+1 passed in 0.04s
+```
+
+The single `.` and `1 passed` confirm it: the second call returned the cached
+value and `repo.calls` stayed at `1`, so the database was touched exactly once
+across two reads. That is a cache hit, demonstrated rather than asserted in
+prose.
+
+**What just happened.** You wrapped a plain async function with `cacheable`,
+backed it with an `InMemoryCache`, and confirmed that identical keys
+short-circuit the body. In `GetBalanceHandler` the wrapped function is
+`_fetch` and the backend is the injected `CacheAdapter`, but the mechanics are
+exactly what you just ran.
+
!!! spring "Spring parity"
`@cacheable` mirrors Spring's `@Cacheable`. The `key` template uses Python's `str.format` syntax instead of SpEL, but the semantics — skip-on-hit, store-on-miss, `condition`, `unless` — are identical. `@cache` is a lower-level alias that behaves the same way; use whichever name reads better in your codebase.
@@ -240,8 +379,22 @@ the cache current.
`DepositFundsHandler` is the canonical example. After a deposit succeeds,
the new balance must be visible to the next read without waiting for the TTL
-to expire. Wrapping `do_handle` with `@cache_put` refreshes the cache entry
-atomically with the write:
+to expire. The wiring mirrors what you did for `@cacheable`, with one critical
+detail to watch:
+
+**Step 1 — Accept the cache** in the constructor, exactly as before.
+
+**Step 2 — Move the deposit logic into `_deposit`** and keep its
+`@transactional()` decorator so the write still commits as one unit of work.
+
+**Step 3 — Wrap with `cache_put`, reusing the *same* key shape.** The deposit
+handler must write to the very cache slot the balance reader looks up.
+`@cacheable` uses `"wallet:balance:{query.wallet_id}"`; here the argument is
+named `command`, so the template is `"wallet:balance:{command.wallet_id}"`.
+Different parameter names, but both resolve to `wallet:balance:wlt-001`.
+
+Wrapping `do_handle` with `@cache_put` refreshes the cache entry atomically
+with the write:
::: listing lumen/core/services/wallets/deposit_funds_handler.py | Listing 13.4 — @cache_put refreshes the cache on a deposit
from datetime import timedelta
@@ -395,6 +548,13 @@ A coherent strategy matches each operation to the right decorator:
### Why protection matters
+!!! note "New term: resilience"
+ *Resilience* here means the system keeps serving requests it *can* serve
+ even when a dependency it relies on is slow, overloaded, or down. The tools
+ in this section do not make the downstream faster — they stop one sick
+ dependency from making the whole of Lumen sick. Each tool is, again, a
+ decorator you stack on the function that makes the risky call.
+
Caching makes the happy path fast. Resilience patterns protect Lumen when
the happy path is unavailable. Without protection, a slow `AccountService`
triggers a cascade:
@@ -462,12 +622,72 @@ sees 10 calls per second can absorb a burst of 40 calls immediately
(drawing on saved tokens), then sustains 20 calls per second afterwards.
Fixed-window rate limiters cannot express this nuance.
+#### Run it — watch the bucket run dry
+
+A small script makes the behaviour concrete. Create a limiter with a tiny
+bucket, call past its capacity, and observe the rejection:
+
+::: listing scratch/rate_demo.py | Listing 13.6a — Draining the token bucket on purpose
+import asyncio
+
+from pyfly.kernel.exceptions import RateLimitException
+from pyfly.resilience import RateLimiter, rate_limiter
+
+# 3 tokens, refilling slowly so the burst is what we observe.
+limiter = RateLimiter(max_tokens=3, refill_rate=1.0)
+
+
+@rate_limiter(limiter)
+async def ping(n: int) -> str:
+ return f"ok-{n}"
+
+
+async def main() -> None:
+ for n in range(5):
+ try:
+ print(await ping(n))
+ except RateLimitException:
+ print(f"rejected-{n}")
+
+
+asyncio.run(main())
+:::
+
+Run it directly:
+
+```console
+$ uv run python scratch/rate_demo.py
+ok-0
+ok-1
+ok-2
+rejected-3
+rejected-4
+```
+
+The first three calls each spend a token; the fourth and fifth arrive with an
+empty bucket and are rejected immediately with `RateLimitException` — no
+queuing, no waiting. Slow the loop down (or raise `refill_rate`) and the
+rejections disappear because tokens refill between calls.
+
+**What just happened.** You did not change the function `ping` at all — you
+decorated it. The decorator inserted an `await limiter.acquire()` before every
+call, and `acquire()` raised when the bucket was empty. This is the shape every
+resilience tool in this chapter takes: a decorator that guards the call without
+the function body knowing it exists.
+
Multiple functions sharing one `RateLimiter` instance enforce a *global*
rate across all of them — useful for capping total traffic to a downstream
service regardless of which internal method initiates the call.
### Bulkhead — concurrency isolation
+!!! note "New term: bulkhead"
+ The name comes from shipbuilding: a ship's hull is divided into sealed
+ compartments (*bulkheads*) so that a breach in one does not flood the whole
+ vessel. A software bulkhead caps how many calls to one dependency can run at
+ once, so a flood of slow calls to `AccountService` cannot consume every
+ coroutine and sink unrelated requests.
+
`Bulkhead` is a semaphore: it limits the number of calls *in-flight at the
same time*. Calls beyond `max_concurrent` are rejected immediately with
`BulkheadException`.
@@ -647,6 +867,13 @@ when many instances restart simultaneously.
### @circuit_breaker — fast failure under sustained outage
+!!! note "New term: circuit breaker"
+ Borrowed from electrical wiring: a circuit breaker *trips* (opens) when too
+ much current flows, cutting the circuit before the wiring overheats. A
+ software circuit breaker trips after too many failures, cutting off calls to
+ a failing dependency so you stop hammering it — and so your own callers fail
+ fast instead of waiting on calls that are doomed to error anyway.
+
Retrying a genuinely unavailable service amplifies load at exactly the
moment that service most needs relief. The circuit-breaker pattern solves
this: after a threshold of consecutive failures the circuit **opens** and
@@ -712,6 +939,94 @@ calls fail.
!!! spring "Spring parity"
`@retry` mirrors Spring Retry's `@Retryable` (with `maxAttempts`, `backoff`, `include`). `CircuitBreaker` mirrors Resilience4j's `CircuitBreaker` (failure threshold, recovery timeout, CLOSED/OPEN/HALF_OPEN state machine, half-open probe calls, expected-exception filter). PyFly does not use the Resilience4j Java library — it is a pure-Python re-implementation with the same semantics.
+### Configuring resilience from `pyfly.yaml`
+
+So far you have constructed every `RateLimiter`, `Bulkhead`, and
+`CircuitBreaker` in code. That is perfect for a single gateway, but operations
+teams usually want to tune these thresholds *without a code change* — bump a
+timeout, widen a rate limit — and they want one obvious place to read the
+current settings. PyFly v26.6.110 ships a config-driven **`ResilienceRegistry`**
+for exactly this, giving parity with Resilience4j's named-registry model.
+
+**Step 1 — Declare named instances in `pyfly.yaml`.** Each entry under
+`pyfly.resilience.*` becomes a named instance. Names are yours to choose;
+group them by the downstream they protect:
+
+```yaml
+pyfly:
+ resilience:
+ circuit-breaker:
+ account-api:
+ failure-threshold: 5
+ recovery-timeout: 30s
+ # or switch to windowed-rate mode:
+ # failure-rate-threshold: 0.5
+ # window-size: 10
+ rate-limiter:
+ account-api:
+ max-tokens: 50
+ refill-rate: 20.0
+ bulkhead:
+ account-api:
+ max-concurrent: 8
+ time-limiter:
+ account-api:
+ timeout: 2s
+```
+
+Durations accept friendly suffixes — `30s`, `500ms`, `1m`, `2h` — or a bare
+number read as seconds. Keys use kebab-case (`failure-threshold`); PyFly's
+relaxed binding accepts snake_case too.
+
+**Step 2 — Inject the registry and look instances up by name.** PyFly's
+`ResilienceAutoConfiguration` registers a single `ResilienceRegistry` bean
+built from those keys (it is always on, and returns an empty registry when no
+keys are present). Ask for it in any `@service` constructor:
+
+::: listing lumen/account/gateway_configured.py | Listing 13.12a — Pulling resilience instances from the registry
+from pyfly.container import service
+from pyfly.resilience import (
+ ResilienceRegistry,
+ bulkhead,
+ circuit_breaker,
+ rate_limiter,
+)
+
+
+@service
+class AccountGateway:
+
+ def __init__(self, http_client, registry: ResilienceRegistry) -> None:
+ self._http = http_client
+ # Look up the named instances declared in pyfly.yaml.
+ cb = registry.circuit_breaker("account-api")
+ rl = registry.rate_limiter("account-api")
+ bh = registry.bulkhead("account-api")
+
+ # Wrap the real call with the config-driven instances.
+ guarded = circuit_breaker(cb)(self._raw_get)
+ guarded = bulkhead(bh)(guarded)
+ self.get_account = rate_limiter(rl)(guarded)
+
+ async def _raw_get(self, account_id: str) -> dict:
+ resp = await self._http.get(f"/accounts/{account_id}")
+ return resp.json()
+:::
+
+**What just happened.** The thresholds now live in configuration, not in
+Python literals. A `CircuitBreaker` named `account-api` is materialised once at
+startup and shared by everything that looks it up — so the failure counts and
+OPEN/CLOSED state are *global* across all callers of that name, exactly like a
+shared in-code instance. Looking up an unknown name raises `KeyError` with the
+list of available names, so a typo fails loudly at startup rather than silently
+creating an unprotected path.
+
+!!! tip "Time limiter returns a timedelta"
+ `registry.time_limiter("account-api")` returns the configured **`timedelta`**, not a decorator — feed it straight into `time_limiter(timeout=registry.time_limiter("account-api"))`. The other three accessors (`circuit_breaker`, `rate_limiter`, `bulkhead`) return the instance you pass to the matching decorator.
+
+!!! spring "Spring parity"
+ The `ResilienceRegistry` mirrors Resilience4j's `CircuitBreakerRegistry`, `RateLimiterRegistry`, and `BulkheadRegistry` — named instances declared in configuration and looked up at runtime. Spring Boot's `resilience4j.circuitbreaker.instances..*` becomes `pyfly.resilience.circuit-breaker..*`; the property names line up one-for-one.
+
---
## Composing the layers
@@ -845,6 +1160,59 @@ Note that `@cacheable` sits *above* `@fallback`. That means:
`@fallback`, or use the `unless` predicate:
`unless=lambda r: r.get("status") == "degraded"`.
+#### Run it — make the downstream fail and watch the stack degrade
+
+You do not need a live `AccountService` to verify the stack. Wire the
+resilience layers around a stub HTTP client that always raises, and assert
+that the caller still gets a usable `DEGRADED` response instead of an
+exception:
+
+::: listing tests/resilience/test_gateway_stack.py | Listing 13.13a — The stack degrades instead of throwing
+import pytest
+
+from pyfly.resilience import fallback, retry
+
+DEGRADED = {"status": "degraded", "balance_minor": None}
+
+
+class _BrokenClient:
+ async def get(self, path: str) -> dict:
+ raise IOError("AccountService unreachable")
+
+
+@fallback(fallback_value=DEGRADED, on=(IOError,))
+@retry(max_attempts=2, delay=0.0, exceptions=(IOError,))
+async def get_account(client: _BrokenClient, account_id: str) -> dict:
+ resp = await client.get(f"/accounts/{account_id}")
+ return resp
+
+
+@pytest.mark.asyncio
+async def test_degrades_instead_of_raising() -> None:
+ result = await get_account(_BrokenClient(), "acc-1")
+ assert result == DEGRADED
+:::
+
+Run it:
+
+```console
+$ uv run --extra dev pytest tests/resilience/test_gateway_stack.py -q
+. [100%]
+1 passed in 0.05s
+```
+
+`@retry` tried the call twice, both attempts raised `IOError`, and `@fallback`
+caught the final exception and returned `DEGRADED`. The caller never saw the
+error — exactly the behaviour you want when `AccountService` is having a bad
+day.
+
+**What just happened.** You assembled a slice of the full stack — retry on the
+inside, fallback on the outside — and proved that a hard failure surfaces as a
+degraded-but-valid response. Add `@rate_limiter`, `@bulkhead`,
+`@time_limiter`, and `@circuit_breaker` between them in the order shown above
+and each one folds into the same flow: every exception they raise is caught by
+the outer `@fallback`, so the caller always receives a response it can use.
+
---
## What you built {.recap}
@@ -890,6 +1258,11 @@ Concretely, you learned:
arguments — and opens the circuit after a failure threshold, short-
circuiting subsequent calls during the recovery window so the downstream
has time to recover.
+- **`ResilienceRegistry`** (PyFly v26.6.110) materialises named
+ `CircuitBreaker`, `RateLimiter`, `Bulkhead`, and time-limiter instances from
+ `pyfly.resilience.*` config keys, so operations can tune thresholds in
+ `pyfly.yaml` and inject the registry to look instances up by name — Spring
+ Boot's `resilience4j.*.instances..*` parity.
- **Decorator order** matters: fallback outermost, then rate limiter,
bulkhead, time limiter, circuit breaker, and retry innermost — with caching
above the fallback to cache even degraded responses.
@@ -905,6 +1278,8 @@ production.
**Exercise 1 — Conditional caching.** The `GetBalance` handler is called far more often for active wallets than for test wallets. Add `condition=lambda query: not query.wallet_id.startswith("test-")` to the `cacheable(...)` call inside `GetBalanceHandler.__init__` and verify with a unit test using `InMemoryCache` that queries for test wallet ids always reach the repository.
-**Exercise 2 — Circuit breaker with rate-based threshold.** Replace the consecutive-count circuit breaker in `AccountGateway` with a rate-based one: open the circuit when more than 60% of the last 20 calls fail. Construct `CircuitBreaker(failure_rate_threshold=0.6, window_size=20, recovery_timeout=60.0, expected=(IOError, TimeoutError))` and write a test that fires 12 failing calls followed by 8 successful ones, asserting that the circuit opens after the 13th failure (crossing 60% of 20).
+**Exercise 2 — Circuit breaker with rate-based threshold.** Replace the consecutive-count circuit breaker in `AccountGateway` with a rate-based one: open the circuit when at least 60% of the last 20 calls fail. Construct `CircuitBreaker(failure_rate_threshold=0.6, window_size=20, recovery_timeout=60.0, expected=(IOError, TimeoutError))`. Two subtleties drive the test design. First, in rate mode the breaker stays not-tripped until the window is *full* — it requires a complete 20-call window before judging the rate — so a burst of failures alone never opens it. Second, the breaker only re-evaluates its trip condition on a *failure* (a success never opens it), so the call that crosses the threshold must itself be a failing call. Write a test that fires 8 succeeding calls followed by 12 failing ones (20 calls total = one full window, ending on a failure). Assert that the circuit stays `CLOSED` through call 19 (the window is still partial), then `OPENS` on the 20th call, when the window fills and the failure rate reaches exactly 12 / 20 = 0.60.
**Exercise 3 — Evict by prefix.** Lumen sometimes needs to invalidate all cache entries for a given wallet owner (GDPR deletion). Add a `purge_owner(owner_id: str)` method to a wallet admin service that calls `backend.evict_by_prefix(f"wallet:balance:{owner_id}:")` directly (without a decorator), and write a test that pre-populates three wallet keys for one owner and one for another, calls `purge_owner`, and asserts that only the target owner's entries are gone.
+
+**Exercise 4 — Config-driven resilience.** Move `AccountGateway`'s hard-coded thresholds into `pyfly.yaml` under `pyfly.resilience.circuit-breaker.account-api`, `pyfly.resilience.rate-limiter.account-api`, and `pyfly.resilience.bulkhead.account-api`. Inject `ResilienceRegistry` into the gateway, look the three instances up by name, and write a test that asserts the materialised `CircuitBreaker.failure_threshold` matches the value you set in config. Note that `ResilienceRegistry.from_config(...)` expects a pyfly `Config`, not a plain dict — it calls `config.get_section("pyfly.resilience.circuit-breaker")` internally. Build one in the test from a nested dict, e.g. `Config({"pyfly": {"resilience": {"circuit-breaker": {"account-api": {"failure-threshold": 5}}}}})` (import `from pyfly.core.config import Config`), then pass that `Config` to `from_config`. Confirm that looking up a misspelled name raises `KeyError` with the list of available names.
diff --git a/book/manuscript/14-security.md b/book/manuscript/14-security.md
index a805ef2c..a46973cd 100644
--- a/book/manuscript/14-security.md
+++ b/book/manuscript/14-security.md
@@ -16,6 +16,15 @@ This chapter locks Lumen down. You will:
If you have worked with Spring Security before, the shape will feel familiar: configure a filter chain, annotate individual methods, and swap the user-detail source for an IDP. PyFly calls those pieces `SecurityMiddleware + HttpSecurity`, `@secure`, and `IdpAdapter`, but the concepts map one-to-one.
+This chapter is written as a guided tutorial. We build the security layer one piece at a time — issue a token, validate it, guard a handler, hash a password, store a session, federate to an IDP — and after each piece you will find a **Run it** checkpoint with the exact command to type and the output you should expect. If you have never wired authentication into a web service before, that is fine: each new term is glossed in plain language the first time it appears, and you can follow along by editing the Lumen sample as you read.
+
+!!! note "Version"
+ The listings and config keys in this chapter target PyFly **v26.6.110**.
+ If you are on an earlier release, a few property names differ — most notably
+ the application port is now `pyfly.server.port` (Spring's `server.port`),
+ and the actuator and admin dashboard live on a separate management port,
+ `pyfly.management.server.port` (default `9090`).
+
::: figure art/figures/14-security.svg | Figure 14.1 — Lumen's security layers. A JWT filter populates the SecurityContext; HttpSecurity enforces URL-level rules; @secure enforces handler-level rules; the IDP port delegates identity to an external provider.
---
@@ -26,7 +35,7 @@ If you have worked with Spring Security before, the shape will feel familiar: co
Lumen is a stateless API. HTTP sessions would require sticky routing or a shared session store on every replica. JWT tokens let each service validate credentials independently — no shared state, no coordination, horizontal scaling by default.
-A **JWT** is a signed JSON payload. Lumen's auth service issues a token on login; every subsequent request carries that token in the `Authorization` header; `SecurityMiddleware` validates the signature and unpacks the token into a `SecurityContext` that the rest of the request can read.
+A **JWT** (JSON Web Token, pronounced "jot") is a signed JSON payload. Think of it as a tamper-evident badge: the server stamps a small JSON document — *who you are*, *what roles you hold*, *when it expires* — and signs it with a secret key. The badge travels with every request. Because the signature can only be produced by someone holding the secret, the server can trust the badge without looking anything up in a database. Lumen's auth service issues a token on login; every subsequent request carries that token in the `Authorization` header; `SecurityMiddleware` validates the signature and unpacks the token into a `SecurityContext` that the rest of the request can read.
### JWTService
@@ -101,6 +110,43 @@ def _permissions_for(role: str) -> list[str]:
**How it works.** `login` fetches the user record and calls `BcryptPasswordEncoder.verify` to compare the supplied password against the stored hash. On success it calls `jwt.encode`, which auto-appends an `exp` claim `expiration_seconds` seconds from now (default `3600` — one hour). You never import `datetime`: the service computes the Unix-timestamp expiry as `int(time.time()) + expiration_seconds`. The caller receives a compact, self-contained token string.
+Walking through the `login` method one step at a time:
+
+**Step 1 — Look up the user.** `find_by_username` returns the stored user record, or `None` if no such username exists. We will treat both "no such user" and "wrong password" as the same failure so an attacker cannot tell which usernames are registered.
+
+**Step 2 — Verify the password.** `self._encoder.verify(password, user.password_hash)` re-hashes the supplied password with the salt baked into the stored hash and compares the two in constant time. If the user was not found, or the passwords do not match, raise `UnauthorizedException` — PyFly renders this as a `401` with the machine-readable code `INVALID_CREDENTIALS`.
+
+**Step 3 — Build the claims.** On success, assemble the JSON payload that will become the token's body: `sub` (the subject — the user's id), `roles`, and the `permissions` that role grants.
+
+**Step 4 — Sign and return.** `self._jwt.encode({...})` signs the payload with the configured secret, stamps the mandatory `exp` claim, and returns the compact token string. That string is what the client stores and replays on every later request.
+
+!!! note "Jargon: claim"
+ A *claim* is just one key/value statement inside the token's JSON body —
+ `"sub": "42"` claims the subject is user 42. The token carries a bag of
+ claims; the framework copies the security-relevant ones (`sub`, `roles`,
+ `permissions`) into the `SecurityContext`.
+
+!!! tip "Run it — issue a token in the REPL"
+ You do not need a running server to see `JWTService` work. With Lumen's
+ virtual environment active, open a Python REPL and sign a payload:
+
+ ```python
+ >>> from pyfly.security import JWTService
+ >>> jwt = JWTService(secret="dev-secret-change-me", algorithm="HS256")
+ >>> token = jwt.encode({"sub": "42", "roles": ["USER"]})
+ >>> token[:20] # a compact "header.payload.signature" string
+ 'eyJhbGciOiJIUzI1NiIs'
+ >>> ctx = jwt.to_security_context(token)
+ >>> ctx.user_id, ctx.roles
+ ('42', ['USER'])
+ ```
+
+ The token round-trips: `encode` added the `exp` claim for you, and
+ `to_security_context` validated the signature and unpacked the claims into a
+ `SecurityContext`. Try tampering with a character in the middle of the
+ string and calling `jwt.decode(token)` again — you will get a
+ `SecurityException`, because the signature no longer matches the payload.
+
### The SecurityContext
**`SecurityContext`** is a frozen dataclass that carries authentication and authorisation data for a single request. The middleware creates it from the validated token; your handlers receive it as an injected parameter.
@@ -160,6 +206,15 @@ def build_app(context):
**How it works.** `exclude_paths` lists the paths where no token is expected. Login and register cannot require authentication because the token does not exist yet; docs paths are excluded so the API explorer works without credentials. Every other path goes through token validation.
+!!! note "Jargon: middleware"
+ *Middleware* is code that wraps every request, running before the route
+ handler on the way in and after it on the way out. `SecurityMiddleware`
+ uses the "in" pass to read the token and stash a `SecurityContext` on
+ `request.state` so handlers downstream can read it without re-parsing the
+ header.
+
+**What just happened.** You now have the two halves of authentication wired up. `AuthService.login` *mints* a token after checking the password; `SecurityMiddleware` *reads* that token on every later request and turns it into a `SecurityContext`. Crucially, the middleware is permissive — it never returns a `401`. A request with a bad or missing token simply arrives anonymous, and the decision to allow or reject it is deferred to the next two layers you will build: `HttpSecurity` (broad, URL-level) and `@secure` (precise, per-handler). Separating *who you are* (authentication) from *what you may do* (authorization) is what keeps each layer small and testable.
+
### URL-level rules with HttpSecurity
`@secure` guards individual handler methods. **`HttpSecurity`** guards whole URL subtrees at the filter layer — before the route dispatcher runs. The two are complementary: `HttpSecurity` provides fast, broad policy at the edge; `@secure` adds fine-grained, per-handler enforcement behind it.
@@ -173,7 +228,7 @@ from pyfly.security.http_security import HttpSecurity
class SecurityConfig:
@bean
- def http_security_filter(self):
+ def http_security(self) -> HttpSecurity:
hs = HttpSecurity()
hs.authorize_requests() \
.request_matchers("/idp/admin/**").has_role("ADMIN") \
@@ -183,13 +238,56 @@ class SecurityConfig:
"/idp/login", "/idp/refresh",
).permit_all() \
.any_request().permit_all()
- return hs.build()
+ return hs
:::
:::
**How it works.** Rules are evaluated in declaration order — first match wins. The `HttpSecurityFilter` runs at `HIGHEST_PRECEDENCE + 350`, after authentication filters have populated `request.state.security_context`, so every role and permission check has a fully hydrated context to inspect. The terminal methods — `has_role`, `has_any_role`, `has_permission`, `authenticated`, `permit_all`, and `deny_all` — cover every common policy; unsatisfied rules return RFC 7807 problem-detail JSON (`application/problem+json`) with the appropriate HTTP status.
+Read the DSL chain top to bottom — that is exactly the order the filter evaluates it:
+
+**Step 1 — Open the rule list.** `hs.authorize_requests()` starts the fluent builder. Every `.request_matchers(...)` call that follows registers one rule.
+
+**Step 2 — Lock down the admin subtree.** `.request_matchers("/idp/admin/**").has_role("ADMIN")` requires the `ADMIN` role for anything under `/idp/admin`. The `**` glob matches any depth of path segments.
+
+**Step 3 — Require login for wallets.** `.request_matchers("/api/v1/wallets/**").authenticated()` accepts any logged-in caller, regardless of role, for the wallet tree. Finer per-handler rules come later via `@secure`.
+
+**Step 4 — Allow the public paths.** `.request_matchers("/health", "/docs", ...).permit_all()` lets health checks, the docs explorer, and the IDP login/refresh endpoints through with no token.
+
+**Step 5 — Set the default.** `.any_request().permit_all()` is the catch-all for paths no earlier rule matched. Flip it to `.deny_all()` once every route is explicitly accounted for — "deny by default" is the safer production posture.
+
+**Step 6 — Return the builder.** Return the configured `HttpSecurity` itself — do **not** call `hs.build()` here. Because `SecurityConfig` is a `@configuration` class, auto-configuration's `HttpSecurityFilterAutoConfiguration` (active whenever an `HttpSecurity` bean is present) picks up the `HttpSecurity` bean, calls `.build()` for you, and registers the resulting `HttpSecurityFilter` on the chain.
+
+!!! tip "Run it — see the gate accept and reject"
+ Start the app with `uv run pyfly run` (it serves on `pyfly.server.port`,
+ default `8080`). In another terminal, hit a guarded path without a token:
+
+ ```bash
+ curl -i http://localhost:8080/api/v1/wallets
+ ```
+
+ Expected — the gate rejects the anonymous request with an RFC 7807 body:
+
+ ```text
+ HTTP/1.1 401 Unauthorized
+ content-type: application/problem+json
+
+ {"type":"about:blank","title":"Unauthorized","status":401,
+ "detail":"Authentication is required to access this resource.",
+ "instance":"/api/v1/wallets"}
+ ```
+
+ Now replay it with a token (use the `$TOKEN` you minted in the REPL above,
+ or one from `/idp/login`):
+
+ ```bash
+ curl -i -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/wallets
+ ```
+
+ Expected — `HTTP/1.1 200 OK` and a JSON page of wallets. The same URL,
+ two outcomes, decided entirely by the token: that is the gate doing its job.
+
!!! note "Two-layer defense"
`HttpSecurity` provides fast URL-level policy before routes are even
dispatched — good for blanket rules like "everything under `/api/v1/wallets`
@@ -197,7 +295,7 @@ class SecurityConfig:
the second, finer-grained layer. Use both together for defense in depth.
!!! spring "Spring parity"
- `HttpSecurity` mirrors Spring Security's `HttpSecurity.authorizeHttpRequests()` chain. `request_matchers` corresponds to `requestMatchers`, `authenticated()` to `.authenticated()`, `has_role` to `hasRole`, and `build()` triggers registration of the underlying filter just as `build()` finalises the Spring filter chain. The fnmatch glob patterns (`/api/admin/**`) behave identically to Spring's Ant-style path matching.
+ `HttpSecurity` mirrors Spring Security's `HttpSecurity.authorizeHttpRequests()` chain. `request_matchers` corresponds to `requestMatchers`, `authenticated()` to `.authenticated()`, `has_role` to `hasRole`, and the `build()` that auto-configuration calls on your `HttpSecurity` bean finalises the underlying filter just as `build()` finalises the Spring filter chain. The fnmatch glob patterns (`/api/admin/**`) behave identically to Spring's Ant-style path matching.
---
@@ -214,9 +312,9 @@ pyfly:
algorithm: HS256
filter:
enabled: true
- exclude-patterns: >-
- /docs,/openapi.json,/actuator/health,
- /api/auth/login,/api/auth/register
+ exclude-patterns: >-
+ /docs,/openapi.json,
+ /api/auth/login,/api/auth/register
password:
bcrypt-rounds: 12
@@ -231,11 +329,51 @@ pyfly:
| `pyfly.security.jwt.exclude-patterns` | *(absent)* | Comma-separated paths to skip |
| `pyfly.security.password.bcrypt-rounds` | `12` | Bcrypt cost factor |
+Note where each key lives: `filter.enabled` is nested under `jwt.filter`, but `exclude-patterns` sits one level up, directly under `jwt` — that is the key the auto-configuration reads (`pyfly.security.jwt.exclude-patterns`). There is no `/actuator/health` in the exclude list any more: as of v26.6.110 the actuator and admin dashboard run on a **separate management port** (`pyfly.management.server.port`, default `9090`), so they never pass through the application's JWT filter at all.
+
!!! warning "Production secret"
Never commit the real JWT secret to source control. Use `${JWT_SECRET}` and
inject the value from an environment variable or a secrets manager at
deploy time.
+!!! warning "The management port is open by default"
+ For Spring parity, the management server (`/actuator/*` and the admin
+ dashboard, default port `9090`) is **unauthenticated by default** — it does
+ not share the application's security gate. In any non-local deployment,
+ secure it explicitly:
+
+ ```yaml
+ pyfly:
+ management:
+ security:
+ enabled: true # apply the security gate to the management port
+ ```
+
+ Setting `pyfly.management.server.port: -1` disables the management endpoints
+ entirely. The default actuator HTTP exposure is just `health,info`; widen it
+ with `pyfly.management.endpoints.web.exposure.include`.
+
+!!! tip "Run it — confirm auto-wiring on startup"
+ With the keys above in `pyfly.yaml`, start the app and watch the boot log:
+
+ ```bash
+ uv run pyfly run
+ ```
+
+ Expected — you will see the app bind to `:8080` and the management server to
+ `:9090`, and the security beans appear with no `@configuration` code of your
+ own. A quick proof that the encoder bean exists:
+
+ ```bash
+ curl -s http://localhost:8080/api/auth/login \
+ -d '{"username":"alice","password":"hunter2"}' \
+ -H 'content-type: application/json'
+ ```
+
+ Expected — a JSON body containing an `access_token` (or a `401` with
+ `INVALID_CREDENTIALS` if the credentials are wrong). Either way, the
+ `JWTService` and `BcryptPasswordEncoder` beans were wired automatically.
+
---
## Authorization with @secure
@@ -457,6 +595,43 @@ alphabetically before `wallet_balance` / `wallet_detail`, so the framework
registers the literal `""` and `/rich` routes first — ensuring Starlette
resolves `/api/v1/wallets/rich` as a fixed segment rather than as a wallet id.
+To add `@secure` to one handler, the recipe is always the same three steps:
+
+**Step 1 — Add the parameter.** Give the method a `security_context: SecurityContext` keyword parameter. (For the `GET` list handlers that already have defaulted query params, default it to `None` so the parameter order stays valid: `security_context: SecurityContext = None`.)
+
+**Step 2 — Stack the decorator.** Put `@secure(...)` *above* `@get_mapping` / `@post_mapping`. Order matters: the authorization check must run before the route binding does.
+
+**Step 3 — Choose the minimal guard.** Pick the least-privilege rule that still lets the right users through — `roles=["USER", "ADMIN"]` for ordinary reads, an extra `permissions=["wallet:deposit"]` for money-moving writes, `roles=["ADMIN"]` for the full detail view.
+
+!!! tip "Run it — watch a 403 for the wrong role"
+ `wallet_detail` is `ADMIN`-only. Call it with a `USER` token (the
+ authentication succeeds, but authorization fails):
+
+ ```bash
+ curl -i -H "Authorization: Bearer $USER_TOKEN" \
+ http://localhost:8080/api/v1/wallets/abc-123
+ ```
+
+ Expected — the request clears the gate (it *is* authenticated) but
+ `@secure(roles=["ADMIN"])` rejects it. The exception handler renders a
+ problem body that includes the machine-readable `code`:
+
+ ```text
+ HTTP/1.1 403 Forbidden
+ content-type: application/problem+json
+
+ {"type":"about:blank","title":"Forbidden","status":403,
+ "detail":"Insufficient roles: requires one of ['ADMIN']",
+ "code":"FORBIDDEN"}
+ ```
+
+ Note the difference from the gate's `401` earlier: a missing or invalid
+ token at the gate gives `401` ("I don't know who you are"), while a valid
+ token without the required role gives `403 FORBIDDEN` ("I know who you are,
+ and you may not do this"). The same `USER` token *would* succeed against
+ `GET /api/v1/wallets/abc-123/balance`, which is guarded with
+ `roles=["USER", "ADMIN"]`.
+
!!! note "Amounts in minor units"
`DepositRequest.amount` is an `int` in **minor units** (cents). €10.50 is
`1050`. This convention avoids floating-point rounding errors throughout
@@ -766,6 +941,155 @@ class SessionConfig:
---
+## Validating tokens from an external IdP (OAuth2 resource server)
+
+So far Lumen has *minted its own tokens* with `JWTService` and a shared HMAC
+secret. That is perfect for a self-contained service. But in most real-world
+deployments, the tokens are issued by a dedicated identity provider — Keycloak, Microsoft
+Entra ID (formerly Azure AD), or AWS Cognito — and your service's only job is to
+**validate** them. This is the **OAuth2 resource server** role, and as of
+v26.6.110 PyFly gives it to you with config alone: no code, and the same one
+configuration works across all three providers.
+
+!!! note "Jargon: resource server"
+ In OAuth2 terms, the IdP that issues tokens is the *authorization server*;
+ your API, which holds the data callers want and checks their tokens, is the
+ *resource server*. Lumen is a resource server. It never sees the user's
+ password — it only verifies the badge the IdP already signed.
+
+The difference from `JWTService` is the **signing scheme**. Your own tokens are
+signed with a symmetric secret (`HS256`) — the same key both signs and verifies,
+so only services that hold the secret can validate. IdP tokens are signed
+*asymmetrically* (`RS256`): the IdP keeps a private key and publishes the
+matching public keys at a **JWKS** endpoint (JSON Web Key Set). Your resource
+server downloads those public keys, caches them, and uses them to verify every
+token's signature — it never needs the IdP's secret at all.
+
+### Turning it on
+
+`JWKSTokenValidator` does the work, and `OAuth2ResourceServerAutoConfiguration`
+wires it from `pyfly.security.oauth2.resource-server.*`. Here is the full
+Keycloak setup:
+
+::: listing lumen/resources/pyfly.yaml | Listing 14.11 — Multi-IdP OAuth2 resource server (Keycloak)
+pyfly:
+ security:
+ oauth2:
+ resource-server:
+ enabled: true
+ # Either point at the JWKS endpoint directly...
+ jwks-uri: "https://keycloak.example.com/realms/lumen/protocol/openid-connect/certs"
+ # ...or give an issuer-uri and let OIDC discovery find jwks-uri + issuer:
+ # issuer-uri: "https://keycloak.example.com/realms/lumen"
+ issuer: "https://keycloak.example.com/realms/lumen"
+ audiences: "lumen-backend"
+ validate-audience: true
+ algorithms: "RS256"
+ clock-skew-seconds: 60
+ exclude-patterns: "/idp/login,/idp/refresh,/docs,/openapi.json"
+
+:::
+:::
+
+Step by step:
+
+**Step 1 — Enable it.** `enabled: true` activates `OAuth2ResourceServerAutoConfiguration` (PyJWT must be installed). That registers a `JWKSTokenValidator` bean and the bearer-token filter on the application's chain.
+
+**Step 2 — Point at the keys.** Set `jwks-uri` directly, or set `issuer-uri` and let PyFly fetch `/.well-known/openid-configuration` to discover both the `jwks-uri` and the authoritative `issuer` — exactly like Spring's `issuer-uri`.
+
+**Step 3 — Pin the audience and issuer.** `audiences` lists the values the token's `aud` claim must match (any one). `issuer` (when set) is checked against the token's `iss`. Together they ensure a token minted for a *different* application or realm cannot be replayed against Lumen.
+
+**Step 4 — Leave the defaults alone for claim mapping.** You did not configure where roles live, yet it just works for Keycloak, Entra, and Cognito. That is the job of `ClaimMappings`, covered next.
+
+### How claims become a SecurityContext
+
+Every IdP puts roles and scopes in different places. Keycloak nests realm roles under `realm_access.roles` and per-client roles under `resource_access..roles`; Entra uses a flat `roles` array (or `groups`); Cognito uses `cognito:groups`. `ClaimMappings` reconciles them with a small path language, so `has_role(...)` and `has_permission(...)` work the same regardless of provider.
+
+| Mapping field | Default search order | Becomes |
+|---|---|---|
+| `principal_claims` | `oid`, `sub` | `SecurityContext.user_id` |
+| `authority_claims` | `roles`, `scopes`, `authorities`, `realm_access.roles`, `resource_access.*.roles`, `groups`, `cognito:groups` | `SecurityContext.roles` |
+| `scope_claims` | `scp`, `scope` (space-split) | `SecurityContext.permissions` |
+| `attribute_claims` | *(none)* | `SecurityContext.attributes` |
+
+Two rules make the defaults span every provider:
+
+- **Dotted paths** descend into nested objects: `realm_access.roles` reads `payload["realm_access"]["roles"]`.
+- A single-level **`*` wildcard** iterates every key at that level: `resource_access.*.roles` collects the `roles` array from *every* client entry under `resource_access`. Path segments split on `.` only, so a colon-bearing claim name like `cognito:groups` is matched verbatim.
+
+Roles are collected across *all* matching paths and de-duplicated, so a Keycloak token contributes both its realm roles and its client roles into one flat `roles` list.
+
+!!! note "Jargon: JWKS and kid"
+ A *JWKS* is the IdP's public-key directory. Each key carries a `kid` (key
+ id); each token's header names the `kid` it was signed with. The validator
+ looks up that exact key, caches the set (default 300s), and rotates
+ automatically when the IdP publishes new keys — no redeploy needed.
+
+If the built-in defaults do not match your IdP, override only what differs:
+
+::: listing lumen/resources/pyfly.yaml | Listing 14.12 — Tuning claim mapping for Entra ID and Cognito
+pyfly:
+ security:
+ oauth2:
+ resource-server:
+ enabled: true
+ # Microsoft Entra ID (v2.0):
+ issuer-uri: "https://login.microsoftonline.com//v2.0"
+ audiences: "api://lumen-backend"
+ principal-claim-names: "oid,sub" # Entra's stable user id first
+ authorities-claim-names: "roles,groups"
+ scope-claim-names: "scp" # Entra delegated scopes
+ attribute-claims: "tid,preferred_username"
+ # AWS Cognito access tokens carry no 'aud' — disable audience checks:
+ # validate-audience: false
+ # authorities-claim-names: "cognito:groups"
+
+:::
+:::
+
+**Step 5 (when needed) — Cognito has no `aud`.** Cognito *access* tokens carry `client_id` instead of `aud`, so set `validate-audience: false` for them; the signature, `iss`, and `exp` checks still apply.
+
+!!! tip "Run it — accept a real IdP token, reject a forged one"
+ With the resource server enabled, replay a token your IdP issued against a
+ guarded route:
+
+ ```bash
+ curl -i -H "Authorization: Bearer $KEYCLOAK_TOKEN" \
+ http://localhost:8080/api/v1/wallets
+ ```
+
+ Expected — `HTTP/1.1 200 OK`. The filter fetched the JWKS, matched the
+ token's `kid`, verified the `RS256` signature, the `iss`, the `aud`, and the
+ `exp` (with 60s of clock-skew tolerance), then built a `SecurityContext`
+ from the token's claims.
+
+ Now flip one character in the token and retry. Expected — `HTTP/1.1 401`
+ (or an anonymous context that the gate then rejects), because the signature
+ no longer matches any published key. You did not write or deploy a single
+ line of validation code to get either outcome.
+
+**What just happened.** You added a production-grade, multi-IdP token validator with configuration only. `JWKSTokenValidator` verifies the signature against the IdP's published keys (so the secret never leaves the IdP), checks `iss` / `aud` / `exp`, and maps provider-specific claims into the same `SecurityContext` the rest of the chapter already uses. Your `@secure` decorators and `HttpSecurity` rules do not change at all — they read `roles` and `permissions` exactly as before, whether the token came from Lumen's own `JWTService` or from Keycloak.
+
+!!! warning "error mode: anonymous vs 401"
+ By default (`authenticate-error-mode: anonymous`) a *present but invalid*
+ token yields an anonymous context and the request continues to the
+ `HttpSecurity` gate, which decides. Set `authenticate-error-mode: "401"` to
+ reject an invalid token right at the filter with
+ `WWW-Authenticate: Bearer error="invalid_token"` (RFC 6750). A *missing*
+ token always falls through to the gate either way.
+
+!!! spring "Spring parity"
+ This is PyFly's `spring-boot-starter-oauth2-resource-server`. The
+ `resource-server.issuer-uri` / `jwks-uri` keys mirror
+ `spring.security.oauth2.resourceserver.jwt.*`; OIDC discovery, the accepted
+ `audiences` list, configurable `algorithms`, and the 60-second clock-skew
+ default all match Spring Security's `JwtDecoder` and `JwtTimestampValidator`.
+ `ClaimMappings` is the equivalent of a custom
+ `JwtAuthenticationConverter` / `JwtGrantedAuthoritiesConverter`, but
+ expressed declaratively in YAML.
+
+---
+
## External identity (IDP)
### The problem with managing identity in-house
@@ -835,7 +1159,7 @@ Key DTOs:
### Keycloak adapter
-::: listing lumen/config/idp_config.py | Listing 14.11 — Wiring the Keycloak adapter
+::: listing lumen/config/idp_config.py | Listing 14.13 — Wiring the Keycloak adapter
from pyfly.container import bean, configuration
from pyfly.idp import IdpAdapter, KeycloakIdpAdapter
@@ -860,7 +1184,7 @@ class IdpConfig:
### Using the IDP in a service
-::: listing lumen/core/services/auth/idp_auth_service.py | Listing 14.12 — Using IdpAdapter in the auth service
+::: listing lumen/core/services/auth/idp_auth_service.py | Listing 14.14 — Using IdpAdapter in the auth service
from pyfly.container import service
from pyfly.idp import IdpAdapter, IdpUser, LoginRequest
from pyfly.kernel.exceptions import UnauthorizedException
@@ -926,7 +1250,7 @@ class IdpAuthService:
Enable the IDP subsystem in `pyfly.yaml` and PyFly wires the adapter and a ready-made REST controller automatically:
-::: listing lumen/resources/pyfly.yaml | Listing 14.13 — IDP auto-configuration
+::: listing lumen/resources/pyfly.yaml | Listing 14.15 — IDP auto-configuration
pyfly:
idp:
enabled: true
@@ -981,7 +1305,7 @@ When Starlette is present, `IdpAutoConfiguration` also registers an `IdpControll
The listing below shows the complete wiring: IDP adapter, JWT filter, URL-level rules, and a Redis session store for the admin dashboard.
-::: listing lumen/config/security_full.py | Listing 14.14 — Full security configuration
+::: listing lumen/config/security_full.py | Listing 14.16 — Full security configuration
from pyfly.container import bean, configuration
from pyfly.idp import IdpAdapter, KeycloakIdpAdapter
from pyfly.security.http_security import HttpSecurity
@@ -1000,7 +1324,7 @@ class LumenSecurityConfig:
)
@bean
- def http_security_filter(self):
+ def http_security(self) -> HttpSecurity:
hs = HttpSecurity()
hs.authorize_requests() \
.request_matchers(
@@ -1014,7 +1338,7 @@ class LumenSecurityConfig:
"/api/v1/wallets/**"
).authenticated() \
.any_request().permit_all()
- return hs.build()
+ return hs
:::
:::
@@ -1042,10 +1366,12 @@ This chapter opened Part V by closing Lumen's open front door. You:
(paged list), `list_rich_wallets` (filtered list), `wallet_balance`, and
`wallet_detail` — each carry the minimal guard: USER+ADMIN for most,
`wallet:deposit` permission for mutations, ADMIN-only for the full detail
- view. Authorization failures raise `SecurityException` (401, code
- `AUTH_REQUIRED`) for unauthenticated callers and `ForbiddenException` (403,
- code `FORBIDDEN`) for authenticated callers who lack the required role or
- permission.
+ view. When `@secure` rejects a call it raises `SecurityException` (code
+ `AUTH_REQUIRED`) for an unauthenticated caller or `ForbiddenException` (code
+ `FORBIDDEN`) for an authenticated caller who lacks the required role or
+ permission — both render as HTTP `403` through the exception handler. The
+ broader `HttpSecurity` gate, sitting in front of those handlers, is what
+ returns the bare `401` for a missing or invalid token.
- Hashed passwords with **`BcryptPasswordEncoder`**, the default adapter for the
`PasswordEncoder` protocol. The cost factor is tunable; the stored hash is
self-describing; verification is timing-safe.
@@ -1054,10 +1380,23 @@ This chapter opened Part V by closing Lumen's open front door. You:
no dependencies; in production, `RedisSessionStore` serialises to JSON and
lets Redis manage TTL. The `SessionFilter` rolls the cookie TTL on every
request and deletes it on invalidation.
+- Validated tokens issued by an external IdP with the config-driven
+ **OAuth2 resource server** (`JWKSTokenValidator`). One block of
+ `pyfly.security.oauth2.resource-server.*` config accepts Keycloak, Microsoft
+ Entra ID, and AWS Cognito tokens out of the box: it verifies the `RS256`
+ signature against the IdP's JWKS keys, checks `iss` / `aud` / `exp` with a
+ 60-second clock-skew, and maps each provider's roles, scopes, and principal
+ into the same `SecurityContext` via `ClaimMappings` — so `@secure` and
+ `HttpSecurity` stay unchanged.
- Delegated identity to an external provider via the **`IdpAdapter`** port and
the **`KeycloakIdpAdapter`** implementation. The auto-configured
`IdpController` exposes login, refresh, logout, introspect, and admin user
management under `/idp` with no extra code.
+- Learned that the actuator and admin dashboard run on a **separate management
+ port** (`pyfly.management.server.port`, default `9090`) that is
+ **unauthenticated by default** for Spring parity, and that you secure it with
+ `pyfly.management.security.enabled: true` (or disable it entirely with
+ port `-1`).
---
diff --git a/book/manuscript/15-observability.md b/book/manuscript/15-observability.md
index bf04c45a..4d16a3ec 100644
--- a/book/manuscript/15-observability.md
+++ b/book/manuscript/15-observability.md
@@ -18,6 +18,17 @@ This chapter adds eyes and ears to Lumen. The three pillars of observability ans
On top of those pillars sits the **Actuator** — production management endpoints that expose health, bean wiring, environment, and live logger levels — and the **Admin Dashboard**, an embedded browser UI that ties everything together in a single pane of glass.
+!!! note "New in v26.6.110"
+ Two changes in this release shape how you reach everything in this chapter.
+ First, the Actuator and Admin Dashboard now listen on a **separate
+ management port** — `9090` by default — rather than the application port
+ `8080`. Second, that management port is **open and unauthenticated by
+ default** (Spring Boot's `management.server.port` model), so you can curl
+ `http://localhost:9090/actuator/health` immediately, and you lock it down
+ with `pyfly.management.security.enabled: true` when you ship. We will point
+ out the right port at each step. If you have read older drafts that used
+ `http://localhost:8080/actuator/...`, swap the port to `9090`.
+
Finally, you will see how **Aspect-Oriented Programming** (AOP) applies logging and metrics to every service method declaratively, without touching the methods themselves.
By the end of the chapter Lumen will produce structured JSON logs with correlation IDs and automatic PII masking, emit Prometheus metrics scraped by any standard collector, propagate OpenTelemetry trace spans across service boundaries, answer Kubernetes liveness and readiness probes, and display all of the above in a zero-configuration dashboard reachable at `/admin`.
@@ -40,7 +51,18 @@ Searching for `ord-123` in Elasticsearch works — until the format changes. And
### get_logger
-PyFly exposes a single factory function that returns a structured logger backed by `structlog` (when the `observability` extra is installed) or a zero-dependency stdlib shim otherwise. Both accept the same call signature:
+PyFly exposes a single factory function that returns a structured logger backed by `structlog` (when the `observability` extra is installed) or a zero-dependency stdlib shim otherwise. Both accept the same call signature.
+
+!!! note "Jargon: factory function"
+ A *factory function* is just a function whose job is to build and hand back
+ a configured object. `get_logger("lumen.wallet")` does the wiring for you —
+ you never construct a logger by hand. The string you pass (`"lumen.wallet"`)
+ is the *logger name*; it is conventionally the dotted module path, and it is
+ what level overrides like `lumen.wallet: DEBUG` target.
+
+Let us build a tiny example you can run, then graduate to the real wallet code. Follow these steps.
+
+**Step 1 — Create a throwaway demo module.** Inside your Lumen project, create `src/lumen/logging_demo.py` with the contents of the listing below. (This is a scratch file for learning; delete it afterwards — the real logging lives inside the handlers later in the chapter.)
::: listing lumen/logging_demo.py | Listing 15.1 — Structured logger usage
from pyfly.logging import get_logger
@@ -56,6 +78,12 @@ logger.error(
)
:::
+**Step 2 — Run it.** Execute the module directly:
+
+```bash
+uv run python -m lumen.logging_demo
+```
+
In development with `format: console` the output reads naturally:
```
@@ -64,6 +92,8 @@ In development with `format: console` the output reads naturally:
10:30:02 [error ] deposit_rejected wallet_id=wlt-001 reason=insufficient_funds
```
+Notice the shape: the *first* argument (`"wallet_opened"`) is the **event name**, not a sentence, and everything after it is a `key=value` pair. There is no string formatting, no f-string, no `%s`. That is the whole point of structured logging — the event name stays stable while the fields carry the variable data.
+
In production with `format: json` every line is a self-contained JSON object:
```json
@@ -89,6 +119,14 @@ that prefix. `sqlalchemy.engine: WARNING` silences query logs without touching
your code. An environment variable `PYFLY_LOGGING_LEVEL_ROOT=WARNING` overrides
the config key, which is useful for staging builds.
+**What just happened.** You called one function, `get_logger`, and got a logger
+that takes structured `key=value` fields. The `format` setting decides how those
+fields render: `console` for humans at your terminal, `json` for machines in
+production. You did not write any handler, formatter, or appender code — PyFly
+installed those on the root logger at startup. Flip `format: console` to
+`format: json` in `pyfly.yaml` and re-run the demo to see the same three events
+emitted as one JSON object per line, ready for a log aggregator to ingest.
+
!!! tip "Why not stdlib `logging` directly?"
`logging.getLogger("x").info("event", wallet_id="wlt-001")` raises
`TypeError` — the stdlib rejects keyword arguments. `get_logger` guarantees
@@ -125,7 +163,21 @@ Every `logger.*` call inside `handle_deposit` — including calls deep in downst
### PII redaction
-**PII redaction** is enabled by default. Before any log record reaches an output handler, PyFly scans the rendered message for emails, credit-card numbers, IBANs, SSNs, JWTs, bearer tokens, and URL credentials. Detected patterns are replaced with ``, ``, and so on.
+**PII** stands for *personally identifiable information* — emails, card numbers, national-ID numbers, and the like. **PII redaction** is enabled by default. Before any log record reaches an output handler, PyFly scans the rendered message for emails, credit-card numbers, IBANs, SSNs, JWTs, bearer tokens, and URL credentials. Detected patterns are replaced with ``, ``, and so on.
+
+You can see this for yourself in a few seconds. Add one line to the demo from Step 1:
+
+```python
+logger.info("contact_logged", email="alice@example.com")
+```
+
+Run `uv run python -m lumen.logging_demo` again. The output shows the value already masked — you never had to opt in:
+
+```
+10:30:03 [info ] contact_logged email=
+```
+
+That redaction pass runs for *every* logger in the process, including ones inside third-party libraries, which is why it catches leaks you did not write.
The regex engine ships with every install. The Presidio-backed NER engine — which also catches free-text names and addresses — is available via the `[pii]` extra and activates automatically when installed:
@@ -184,7 +236,7 @@ PyFly writes to `./logs/lumen.log` and rotates at 50 MB, keeping 14 rotated file
### The MetricsRegistry
-**`MetricsRegistry`** is a thin wrapper around `prometheus_client` that guarantees each metric name is registered only once. Duplicate calls to `counter()` or `histogram()` with the same name return the existing metric rather than raising a `ValueError`. Inject it from the DI container (auto-configured when `prometheus_client` is installed) or create it manually:
+A **metric** is a number you sample over time: a count of deposits, a latency in seconds, a memory figure. **Prometheus** is the de-facto open-source metrics database; it *scrapes* (periodically reads) an HTTP endpoint your app exposes and stores the numbers. **`MetricsRegistry`** is PyFly's small front door to that world: a thin wrapper around the `prometheus_client` library that guarantees each metric name is registered only once. Duplicate calls to `counter()` or `histogram()` with the same name return the existing metric rather than raising a `ValueError`. Inject it from the DI container (auto-configured when `prometheus_client` is installed) or create it manually:
::: listing lumen/observability/metrics.py | Listing 15.3 — Creating metrics
from pyfly.observability import MetricsRegistry
@@ -215,7 +267,22 @@ deposit_duration = registry.histogram(
### @timed — automatic duration histogram
-**`@timed`** records how long an async or sync function takes to run, using a labeled histogram. It works on any callable and automatically adds `class`, `method`, and `exception` labels:
+**`@timed`** records how long an async or sync function takes to run, using a labeled histogram. It works on any callable and automatically adds `class`, `method`, and `exception` labels.
+
+!!! note "Jargon: counter vs histogram"
+ A **counter** only goes up — it answers "how many?" (deposits served,
+ errors raised). A **histogram** buckets observed *values* — it answers "how
+ long?" or "how big?" and lets Prometheus compute percentiles (p95 latency).
+ Rule of thumb: count events with a counter; measure durations and sizes with
+ a histogram.
+
+Now let us wire the first real metric into Lumen. Here is the deposit handler you built in earlier chapters — the only change is the decorator on `do_handle`.
+
+**Step 1 — Import the metrics helpers.** At the top of `src/lumen/core/services/wallets/deposit_funds_handler.py`, add `MetricsRegistry` and `timed` to the `pyfly.observability` import.
+
+**Step 2 — Create a module-level registry.** Add `registry = MetricsRegistry()` above the class. Because the registry deduplicates by name, sharing one per module is safe.
+
+**Step 3 — Decorate `do_handle`.** Put `@timed(...)` *above* `@transactional()` so the timer wraps the whole transactional unit of work.
::: listing lumen/core/services/wallets/deposit_funds_handler.py | Listing 15.4 — @timed on DepositFundsHandler
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
@@ -269,6 +336,47 @@ The decorator captures `start = time.perf_counter()`, calls the function, and ob
Histogram names follow Micrometer dot.case convention: `"lumen.deposit.duration"` becomes `lumen_deposit_duration_seconds` in Prometheus, with a `_seconds` suffix added if absent.
+**Step 4 — Expose the Prometheus endpoint.** By default the actuator web-exposes
+only `health` and `info` (see "The management port" below). Add `prometheus` to
+the exposure list in `pyfly.yaml` so the scrape endpoint appears:
+
+```yaml
+pyfly:
+ management:
+ endpoints:
+ web:
+ exposure:
+ include: "health,info,prometheus"
+```
+
+**Step 5 — Run it and see the metric appear.** Start Lumen, drive one deposit through the API, then scrape the management port.
+
+```bash
+# Terminal 1 — start the app (business API on 8080, management on 9090)
+uv run pyfly run --server uvicorn
+
+# Terminal 2 — open a wallet, then deposit into it
+WALLET=$(curl -s -X POST localhost:8080/api/v1/wallets \
+ -H 'Content-Type: application/json' \
+ -d '{"owner_id":"usr-42","currency":"EUR"}' \
+ | python -c "import sys,json;print(json.load(sys.stdin)['wallet_id'])")
+curl -s -X POST localhost:8080/api/v1/wallets/$WALLET/deposit \
+ -H 'Content-Type: application/json' -d '{"amount":1350}'
+
+# Now scrape the metric — note the MANAGEMENT port 9090, not 8080
+curl -s localhost:9090/actuator/prometheus | grep lumen_deposit_duration
+```
+
+You should see histogram lines like these (your numbers will differ):
+
+```
+lumen_deposit_duration_seconds_bucket{class="DepositFundsHandler",method="do_handle",exception="none",le="0.05"} 1.0
+lumen_deposit_duration_seconds_count{class="DepositFundsHandler",method="do_handle",exception="none"} 1.0
+lumen_deposit_duration_seconds_sum{class="DepositFundsHandler",method="do_handle",exception="none"} 0.013
+```
+
+The `_count` line confirms one observation; the `_sum` line is the total seconds spent. If `grep` finds nothing, you have not driven a deposit yet — the metric is created lazily on first call.
+
### @counted — automatic invocation counter
**`@counted`** increments a counter on every function call. Lumen's `GetBalanceHandler` is a natural fit — every balance read increments the counter, tagged by outcome:
@@ -355,6 +463,14 @@ class WithdrawFundsHandler(CommandHandler[WithdrawFunds, int]):
`amount` is an `int` in minor units (e.g. 1050 = €10.50) — the `Money` value object enforces the type. Each invocation produces both a histogram observation and a counter increment.
+**What just happened.** You added cross-cutting measurement to three handlers
+by changing nothing but the decorator stack. `@timed` answers "how long did it
+take?", `@counted` answers "how often, and did it succeed?", and stacking them
+gives you both for free. The handler bodies — the actual business logic — never
+mention metrics. After running a few deposits and withdrawals you can scrape
+`localhost:9090/actuator/prometheus` again and watch `lumen_withdrawals_total`
+and `lumen_balance_reads_total` climb.
+
### Prometheus scrape endpoint
The actuator (covered in the next section) exposes the metrics registry for scraping. When the actuator is enabled and `prometheus_client` is installed, two endpoints are mounted automatically with no additional code:
@@ -376,6 +492,14 @@ Point your Prometheus `scrape_configs` at `/actuator/prometheus` and all `Metric
### @span — OpenTelemetry span decorator
+!!! note "Jargon: trace, span, OpenTelemetry"
+ A **span** is one timed, named step of work — "fetch wallet", "persist
+ deposit". A **trace** is the whole tree of spans for a single request, root
+ to leaf. **OpenTelemetry** (often "OTel") is the vendor-neutral standard for
+ producing traces; once your spans speak OTel, any compatible viewer — Jaeger,
+ Tempo, Honeycomb — can display them. PyFly emits OTel spans so you are never
+ locked to one tool.
+
**`@span`** wraps an async or sync function in an OpenTelemetry span. Each span is a timed, named unit of work. Spans nest automatically through OpenTelemetry's context propagation, so a `@span`-decorated function called from inside another `@span`-decorated function produces a parent-child relationship in your trace viewer:
::: listing lumen/wallet/service.py | Listing 15.7 — @span on CQRS handler methods
@@ -433,6 +557,25 @@ pyfly:
endpoint: "http://localhost:4318"
```
+**Run it — see spans without a collector.** Standing up Jaeger or Tempo is
+overkill while you are learning. Set the **console exporter** instead, which
+prints each span to stdout. Lumen ships with `tracing.enabled: false`; flip it on
+and choose `console`:
+
+```yaml
+pyfly:
+ observability:
+ tracing:
+ enabled: true
+ exporter: console
+```
+
+Restart with `uv run pyfly run --server uvicorn`, drive one deposit through the
+API as in the metrics step, and watch the terminal. Each `@span` prints a JSON
+block showing its name, the parent span id, and the elapsed time — the same
+parent-child structure the diagram above sketches, just in text form. Switch back
+to `exporter: otlp` (or remove the override) once you have a real collector.
+
Exporter selection rules:
- `exporter: otlp` — uses OTLP/HTTP; requires `opentelemetry-exporter-otlp`
@@ -533,21 +676,47 @@ The **Actuator** gives Kubernetes and your ops tooling a stable contract: a set
### Enabling the Actuator
-Pass `actuator_enabled=True` to `create_app()`, or set the flag in `pyfly.yaml`:
+The Actuator is **on by default** when a PyFly context is present — you do not
+have to enable it at all to get `/actuator/health` and `/actuator/info`. You only
+touch the flag to turn it *off*, or to be explicit. Pass `actuator_enabled=True`
+to `create_app()`, or set the flag in `pyfly.yaml`:
```yaml
pyfly:
- web:
- actuator:
- enabled: true
+ management:
+ enabled: true # default; the actuator is on unless you set false
app:
name: lumen
version: 1.0.0
description: Lumen wallet service
```
+!!! note "Config key changed"
+ The enable flag is now `pyfly.management.enabled`. The older
+ `pyfly.web.actuator.enabled` still works as a legacy alias, but new code
+ should use the `pyfly.management.*` namespace, which is where the port,
+ security, and endpoint-exposure settings also live.
+
When enabled, `create_app()` automatically scans the DI container for `HealthIndicator` beans, creates a `HealthAggregator`, instantiates all built-in endpoints, and mounts them at `/actuator/*`.
+**Run it — your first health check.** Lumen already enables the actuator, so
+just start the app and curl the health endpoint on the **management port**:
+
+```bash
+uv run pyfly run --server uvicorn
+curl -s localhost:9090/actuator/health
+```
+
+A healthy app returns HTTP 200 with:
+
+```json
+{"status":"UP"}
+```
+
+If you get connection-refused, confirm you used `9090` (management), not `8080`
+(business). The same `/actuator/health` on `8080` returns 404 — the business
+port deliberately does not carry management endpoints.
+
### The management port
By default these `/actuator/*` endpoints — and the `/admin` dashboard from the
@@ -565,6 +734,42 @@ endpoints. A Kubernetes deployment therefore points liveness/readiness probes an
the Prometheus `ServiceMonitor` at port `9090`, and the `Service`/`Ingress` for
user traffic at `8080`.
+!!! warning "The management port is OPEN by default"
+ As of v26.6.110 the management port is **unauthenticated by default** — the
+ `/actuator/*` and `/admin` routes answer any caller that can reach `9090`.
+ This is intentional (Spring Boot's model): the port is meant to be reachable
+ only from inside your cluster, behind network isolation, never exposed on the
+ public internet. If you cannot guarantee that isolation, turn on the app's
+ security filters for the management port too:
+
+ ```yaml
+ pyfly:
+ management:
+ security:
+ enabled: true
+ ```
+
+ With that flag set, the same authentication, role guards, and CSRF rules
+ that protect your business API also gate the management port.
+
+By default the actuator web-exposes only **`health` and `info`** — again matching
+Spring Boot, which keeps potentially sensitive endpoints (`beans`, `env`,
+`threaddump`, `prometheus`) off the wire until you opt in. Widen the set with
+`pyfly.management.endpoints.web.exposure.include`:
+
+```yaml
+pyfly:
+ management:
+ endpoints:
+ web:
+ exposure:
+ include: "health,info,metrics,prometheus,loggers" # or "*" for all
+```
+
+So the Prometheus scrape and runtime-logger steps later in this chapter assume
+you have added `prometheus` and `loggers` to this list. The `*` shortcut exposes
+everything and is convenient in development.
+
### Built-in endpoints
| Endpoint | Method | Description |
@@ -639,7 +844,15 @@ class DatabaseHealthIndicator:
`HealthStatus.status` accepts four values: `"UP"`, `"DOWN"`, `"OUT_OF_SERVICE"`, or `"UNKNOWN"`. The aggregator applies a severity ordering (`DOWN > OUT_OF_SERVICE > UP > UNKNOWN`) and returns the worst-case status across all indicators. If any indicator's `health()` method raises, that indicator is treated as `"DOWN"` with `details={"error": "check failed"}`; the exception is logged but does not crash the health endpoint.
-A healthy response looks like:
+**Run it — watch a custom indicator surface.** Drop the listing above into
+`src/lumen/health/indicators.py`, restart Lumen, and ask for the detailed health
+report (the `?show-details` form, or simply scrape it from the management port):
+
+```bash
+curl -s localhost:9090/actuator/health
+```
+
+A healthy response now includes your component by name:
```json
{
@@ -657,25 +870,46 @@ A healthy response looks like:
}
```
+You wrote no registration code: the `@component` stereotype plus the
+`async def health()` method is the entire contract. At startup the actuator
+scanned the container, found anything that looks like a health indicator, and
+folded it into the aggregator.
+
+!!! note "Building probes by hand — `app.state.pyfly_health_aggregator`"
+ New in v26.6.110, the live `HealthAggregator` is reachable at
+ `app.state.pyfly_health_aggregator` on the Starlette app object. This is the
+ *same* aggregator the `/actuator/health` route uses, whether the actuator
+ runs on the main app or on the separate management port. If you ever need a
+ bespoke readiness gate — say, a custom ASGI route that returns 503 until a
+ one-time warm-up completes — you can read this aggregator directly or
+ register extra indicators on it after `create_app()`, instead of going
+ through HTTP. It is exposed only by the Starlette adapter.
+
### Changing log levels at runtime
-The loggers endpoint lets you inspect and change log levels without restarting Lumen — invaluable when a production incident needs DEBUG output for exactly one package:
+The loggers endpoint lets you inspect and change log levels without restarting Lumen — invaluable when a production incident needs DEBUG output for exactly one package. First add `loggers` to the exposure list (`pyfly.management.endpoints.web.exposure.include: "health,info,loggers"`), then drive it from the **management port** `9090`:
```bash
# List all loggers with configured and effective levels
-curl http://localhost:8080/actuator/loggers
+curl http://localhost:9090/actuator/loggers
# Enable DEBUG for the wallet module — takes effect immediately
-curl -X POST http://localhost:8080/actuator/loggers/lumen.wallet \
+curl -X POST http://localhost:9090/actuator/loggers/lumen.wallet \
-H "Content-Type: application/json" \
-d '{"configuredLevel": "DEBUG"}'
# Reset to inherit from parent
-curl -X POST http://localhost:8080/actuator/loggers/lumen.wallet \
+curl -X POST http://localhost:9090/actuator/loggers/lumen.wallet \
-H "Content-Type: application/json" \
-d '{"configuredLevel": null}'
```
+**What just happened.** You changed a logger's level on a *running* process. The
+POST took effect immediately — no restart, no redeploy. In a real incident you
+would flip exactly one package to DEBUG, capture the noisy output you need, then
+POST `null` to put it back the way it was, all without disturbing the rest of the
+service.
+
The endpoint uses Spring Boot's level vocabulary (`OFF`, `ERROR`, `WARN`, `INFO`, `DEBUG`, `TRACE`) and is drop-in compatible with Spring Boot Actuator tooling.
### Custom actuator endpoint
@@ -713,19 +947,19 @@ class GitInfoEndpoint:
### Kubernetes probe configuration
-Point your pod spec at the dedicated liveness and readiness sub-paths so Kubernetes can make independent restart and traffic decisions:
+Point your pod spec at the dedicated liveness and readiness sub-paths so Kubernetes can make independent restart and traffic decisions. Because the actuator lives on the management port, the probes target **`9090`**, while your `Service` routes user traffic to `8080`:
```yaml
livenessProbe:
httpGet:
path: /actuator/health/liveness
- port: 8080
+ port: 9090
initialDelaySeconds: 10
periodSeconds: 30
readinessProbe:
httpGet:
path: /actuator/health/readiness
- port: 8080
+ port: 9090
initialDelaySeconds: 5
periodSeconds: 10
```
@@ -763,6 +997,42 @@ pyfly:
The dashboard auto-discovers beans, health indicators, loggers, scheduled tasks, HTTP mappings, caches, CQRS handlers, sagas, and metrics from the running `ApplicationContext`. It presents them in **15 built-in views** with real-time Server-Sent Event (SSE) updates — no WebSocket, no polling loop in your code.
+!!! note "Where to find it: the management port"
+ Like the actuator, the dashboard is served on the **management port**. With
+ Lumen's defaults that is `http://localhost:9090/admin`, not `8080/admin`.
+ Set `pyfly.management.server.port` equal to `pyfly.server.port` if you would
+ rather serve the dashboard on the same port as your API. And remember: that
+ port is open by default — gate it with `pyfly.management.security.enabled` or
+ the dashboard's own `require_auth` (shown under "Security") before exposing it
+ anywhere untrusted.
+
+**Run it — open the dashboard.** Enable it in `pyfly.yaml`, start Lumen, and open
+the URL in a browser:
+
+```yaml
+pyfly:
+ admin:
+ enabled: true
+ title: "Lumen Admin"
+```
+
+```bash
+uv run pyfly run --server uvicorn
+# then visit http://localhost:9090/admin
+```
+
+You should see the Overview view populate within a second or two: app name and
+uptime, a green health badge, and bean counts grouped by stereotype. Drive a few
+deposits through `localhost:8080` and watch the Health and Metrics panels update
+live — the page never reloads, because the data arrives over SSE.
+
+!!! note "Jargon: SSE (Server-Sent Events)"
+ SSE is a one-way streaming channel: the browser opens a single long-lived
+ HTTP connection and the server *pushes* events down it as they happen. It is
+ simpler than a WebSocket (which is two-way) and is exactly the right tool for
+ a dashboard that only needs to *receive* updates. You write no polling loop;
+ the framework handles the stream.
+
### Built-in views
**Dashboard section:**
@@ -934,7 +1204,23 @@ PyFly's AOP module ships five advice types:
### @aspect — declaring an aspect
-**`@aspect`** marks a class as a PyFly aspect. The class is automatically registered in the DI container as a singleton and receives injected dependencies via `__init__`. No explicit base class is required:
+!!! note "Jargon: aspect, advice, pointcut, weaving"
+ Four words travel together in AOP. An **aspect** is the class that bundles a
+ cross-cutting concern. **Advice** is a single piece of behaviour inside it
+ (the body of `@before`, `@around`, and so on). A **pointcut** is the pattern
+ string — like `"service.*.*"` — that decides *which* methods the advice
+ applies to. **Weaving** is the act of stitching the advice into those methods
+ at startup. You write aspects; PyFly does the weaving.
+
+**`@aspect`** marks a class as a PyFly aspect. The class is automatically registered in the DI container as a singleton and receives injected dependencies via `__init__`. No explicit base class is required.
+
+Build the logging aspect in three moves.
+
+**Step 1 — Declare the class.** Create `src/lumen/aspects/logging_aspect.py`, mark the class `@aspect`, and give it a module-level `logger`.
+
+**Step 2 — Add advice methods.** Each method is decorated with one advice type (`@before`, `@after_returning`, `@after_throwing`) and a pointcut string. The `jp: JoinPoint` argument carries the intercepted call's details.
+
+**Step 3 — Set ordering.** `@order(-50)` makes this aspect run earlier than higher-numbered ones — useful when you want logging to bracket the metrics aspect you write next.
::: listing lumen/aspects/logging_aspect.py | Listing 15.12 — A logging aspect
from pyfly.aop import aspect, before, after_returning, after_throwing, JoinPoint
@@ -1035,6 +1321,15 @@ In production you never call `weave_bean()` manually. `AopAutoConfiguration` reg
The result is zero-configuration AOP: define aspects, define services, start the application — the weaver wires them together.
+**What just happened.** You wrote two aspects — one for logging, one for metrics —
+and never edited a single handler. At startup, `AspectBeanPostProcessor` matched
+their pointcuts against your service beans and wove the advice into the matching
+methods in place. From now on, every `@service` method emits entry/exit logs and a
+duration histogram automatically. Add a new handler tomorrow and it inherits the
+same observability the moment its pointcut matches — nothing to remember, nothing
+to copy-paste. That is the payoff of AOP: the cross-cutting behaviour lives in one
+place, and the business code stays clean.
+
### JoinPoint reference
Every advice handler receives a `JoinPoint` dataclass:
@@ -1121,6 +1416,28 @@ class DepositFundsHandler(CommandHandler[DepositFunds, int]):
Seven lines of decorators and one `get_logger` call — and Lumen's deposit path is fully observable.
+**Run it — confirm nothing broke.** Observability decorators wrap behaviour
+around your handlers; they must not change the result those handlers return. Run
+the existing Lumen suite to prove the deposit path still behaves:
+
+```bash
+uv run --extra dev pytest -q
+```
+
+Every test should stay green:
+
+```
+......................................... [100%]
+41 passed in 0.3s
+```
+
+Then do one full manual lap with the app running: drive a deposit on `8080`,
+confirm `/actuator/health` is `UP` on `9090`, scrape `/actuator/prometheus` and
+see `lumen_wallet_deposits_total` increment, and open `http://localhost:9090/admin`
+to watch the same event flow through the live Metrics and Log Viewer panels. All
+three pillars — logs, metrics, traces — now describe a single deposit, joined by
+one `trace_id`.
+
---
## What you built {.recap}
diff --git a/book/manuscript/16-testing.md b/book/manuscript/16-testing.md
index 0f66115f..5f1cfaaf 100644
--- a/book/manuscript/16-testing.md
+++ b/book/manuscript/16-testing.md
@@ -28,13 +28,42 @@ testpaths = ["tests"]
pythonpath = ["src"]
```
+A quick gloss before we start, since three terms recur in this chapter. A
+**fixture** is a reusable piece of test setup — pytest builds it once, hands it to
+your test, and tears it down afterward. A **conftest.py** is a special file pytest
+discovers automatically; any fixtures you declare there become available to every
+test in the package without an import. And **`pytest-asyncio` auto mode** simply
+means you can write `async def test_...` and pytest will `await` it for you — no
+per-test decorator required. Each of these appears again with a concrete example
+below; this is just the map.
+
Install dev dependencies and run the suite:
```bash
uv run --extra dev pytest -q
```
-The bare `uv sync` (without `--extra dev`) omits the dev group, so pytest is not installed. Always include `--extra dev` when running tests.
+The bare `uv sync` (without `--extra dev`) omits the dev group, so pytest is not
+installed. Always include `--extra dev` when running tests.
+
+**Run it.** From the `samples/lumen` directory, run the whole suite once now so you
+have a baseline before changing anything:
+
+```bash
+uv run --extra dev pytest -q
+```
+
+You should see a row of dots — one per test — followed by a summary line:
+
+```text
+......................................... [100%]
+41 passed in 0.28s
+```
+
+Forty-one passing tests, under a third of a second, no Docker and no external
+process. That speed is the whole point of the pyramid: the fast base catches most
+regressions before the slower integration layers ever run. If you instead see
+`No module named pytest`, you forgot `--extra dev` — re-run with it.
---
@@ -46,6 +75,37 @@ The domain model — `Money` and `Wallet` — has no framework dependencies. It
`Money` is a frozen dataclass. Every operation either succeeds and returns a new `Money` instance, or raises `BusinessRuleViolation`. Each violation carries a `.rule` string that names the violated invariant — useful for asserting the exact rule in tests.
+A quick gloss on two terms. A **value object** is an object defined entirely by its
+values — two `Money(1050, EUR)` instances are equal because their fields are equal,
+not because they are the same object in memory. A **frozen dataclass** is Python's
+way of making such an object immutable: once constructed, you cannot reassign its
+fields. Together they make `Money` safe to pass around freely — no caller can
+mutate it behind your back, so it never needs defensive copying.
+
+Let us build the test file one assertion-group at a time. Each step below maps to
+one `def test_...` function in the listing that follows.
+
+**Step 1 — equality is structural.** `test_value_equality_is_structural` asserts
+that two `Money` values with the same amount and currency are equal, and that
+differing in either field makes them unequal. This is the value-object contract.
+
+**Step 2 — immutability is enforced.** `test_money_is_immutable` tries to assign to
+`money.amount` and expects an exception. The frozen dataclass raises
+`FrozenInstanceError`, proving you cannot mutate a value after construction.
+
+**Step 3 — arithmetic returns new values.** `test_add_and_subtract_same_currency`
+checks that `add` and `subtract` produce the expected `Money`, never mutating the
+operands.
+
+**Step 4 — the convenience surface.** `test_zero_factory_and_major_units` covers the
+`Money.zero(currency)` factory, the `major_units` property, and the `__str__`
+formatting.
+
+**Step 5 — invariants reject bad input.** The last two tests assert that mixing
+currencies and passing a non-integer amount each raise `BusinessRuleViolation` with
+a specific `.rule` string — `"money-currency-mismatch"` and
+`"money-amount-integer"`.
+
::: listing tests/test_money.py | Listing 16.1 — Pure unit tests for the Money value object
from __future__ import annotations
@@ -95,6 +155,29 @@ def test_non_integer_amount_is_rejected() -> None:
Every test is synchronous — no `async`, no `await`, no fixtures. Pytest collects the module-level functions automatically. `Currency.EUR` is an enum value, not a plain string, matching the domain model's type contract exactly.
+**Run it.** Run just this file to see the unit base of the pyramid in action:
+
+```bash
+uv run --extra dev pytest tests/test_money.py -q
+```
+
+Expected output:
+
+```text
+...... [100%]
+6 passed in 0.02s
+```
+
+Six tests, twenty milliseconds. No database connected, no event bus started — these
+tests construct a plain object and assert on its behaviour. That is what makes the
+base of the pyramid so wide and so fast.
+
+*What just happened.* You proved the entire `Money` contract — equality,
+immutability, arithmetic, and every invariant — without touching a single piece of
+framework infrastructure. Pure-domain code stays pure-domain testable. When one of
+these fails, you know the bug is in the value object itself, not in wiring, a
+session, or the bus.
+
!!! tip "Minor-unit arithmetic"
`Money` stores amounts in **minor units** (integer cents). `Money(1050,
Currency.EUR)` represents €10.50 — verified by `major_units == 10.5` and
@@ -105,6 +188,35 @@ Every test is synchronous — no `async`, no `await`, no fixtures. Pytest collec
`Wallet` enforces several invariants: the owner must be a non-blank string, deposits must be positive, withdrawals must not overdraw, and amounts must match the wallet's currency. Each violation carries a `.rule` attribute for precise assertion.
+One term first. An **aggregate** (or **aggregate root**) is a cluster of domain
+objects treated as a single unit for consistency — here, the `Wallet` and its
+balance. Every change goes through the aggregate's methods, so the aggregate is the
+one place that guarantees its invariants hold. That makes it the natural unit to
+test: drive it through its public methods and assert the rules never break.
+
+The pattern every aggregate test follows is **arrange, act, assert**. Arrange:
+construct the wallet into a known state. Act: call one method. Assert: check the
+balance, the emitted event, or the raised violation. Watch for it in each test:
+
+**Step 1 — opening emits an event.** `test_open_creates_empty_wallet` opens a wallet
+and asserts the balance is zero and exactly one `WalletOpened` event is queued.
+
+**Step 2 — opening validates its arguments.** `test_open_requires_owner` passes a
+blank owner and expects `BusinessRuleViolation` with rule
+`"wallet-owner-required"`.
+
+**Step 3 — the happy path of deposit then withdraw.**
+`test_deposit_then_withdraw_happy_path` deposits, asserts the balance and the
+`FundsDeposited` event, then withdraws and asserts the balance and the
+`FundsWithdrawn` event. Note the `clear_events()` call in the arrange step — more on
+that just below the listing.
+
+**Step 4 — invariants reject bad operations.** The final three tests prove a
+withdrawal cannot overdraw, a deposit must be positive, and an amount must match the
+wallet's currency. Each asserts the exact `.rule` string, and the overdraw test also
+asserts the balance was left unchanged and no event was raised — proof the invariant
+fired *before* any state changed.
+
::: listing tests/test_wallet_aggregate.py | Listing 16.2 — Unit tests for the Wallet aggregate root
from __future__ import annotations
@@ -185,6 +297,28 @@ def test_currency_mismatch_is_rejected() -> None:
Three details deserve attention. First, `Wallet.open` takes three positional arguments: a pre-generated `wallet_id`, an `owner_id`, and a `Currency` enum value — the aggregate does not generate its own ID. Second, `pending_events()` returns buffered events without draining; `clear_events()` returns and drains. The `test_deposit_then_withdraw_happy_path` test calls `clear_events()` after opening so each assertion sees exactly one event. Third, `FundsDeposited` and `FundsWithdrawn` carry `amount` (the operation amount in minor units) and `balance` (the post-operation running balance) — not `new_balance`. Always verify real event dataclass fields before asserting them.
+**Run it.**
+
+```bash
+uv run --extra dev pytest tests/test_wallet_aggregate.py -q
+```
+
+Expected output:
+
+```text
+...... [100%]
+6 passed in 0.02s
+```
+
+*What just happened.* The trickiest line to read is the assignment
+`[event] = wallet.clear_events()`. That is a list unpacking: it asserts the returned
+list has **exactly one** element and binds it to `event` in a single step. If the
+aggregate had raised zero or two events, the unpacking itself would raise a
+`ValueError` and the test would fail — so the shape of the event stream is checked
+for free. This is why the happy-path test calls `clear_events()` right after opening:
+it drains the `WalletOpened` event so the next unpacking sees only the
+`FundsDeposited` event you are actually asserting on.
+
!!! spring "Spring parity"
Testing a DDD aggregate in isolation is the same discipline in any stack. In
Spring / jMolecules you would call the aggregate's methods directly and
@@ -200,6 +334,47 @@ The CQRS and event-listener tests need real infrastructure: a SQLite-backed `Wal
The key difference from a hand-rolled adapter test is that the `repository` fixture uses the **real framework `WalletRepository`** — the same Spring-Data-style class the application boots — and runs it through the **real `RepositoryBeanPostProcessor`**, which compiles derived-query stubs from method names at startup.
+This file is the heart of the chapter, so we will read it top to bottom as a series
+of small, layered steps. Each fixture builds on the one above it. The thing to keep
+in mind: pytest links fixtures by **name**. When a fixture function declares a
+parameter, pytest looks for a fixture with that name, builds it, and injects it. That
+is how a small graph of independent fixtures composes into a full test stack.
+
+**Step 1 — make the sample importable.** The first few lines push the sample's
+`src/` onto `sys.path` so `import lumen...` resolves. The `pythonpath = ["src"]`
+setting in `pyproject.toml` does the same thing for pytest's own collection; this
+line covers direct imports inside `conftest.py` before pytest's path tweaks apply.
+
+**Step 2 — `session_factory`: a database engine.** This async fixture creates an
+in-memory SQLite engine, runs `Base.metadata.create_all` to build the schema, and
+yields an `async_sessionmaker`. A **session factory** is a callable that hands out
+fresh database sessions; the framework's relational auto-configuration creates one
+exactly like this at startup. The single shared engine keeps the in-memory database
+alive for the whole test — close it and the data vanishes.
+
+**Step 3 — `repository`: the real Spring-Data-style repository.** It constructs the
+framework `WalletRepository`, then calls
+`RepositoryBeanPostProcessor().after_init(repo, "walletRepository")`. A
+**post-processor** is a hook that runs against a freshly created bean — here it reads
+method names like `find_by_owner_id` and compiles them into real queries. Skip this
+call and those methods stay stubs that raise `NotImplementedError`.
+
+**Step 4 — `event_bus`: the in-memory event bus.** A one-line fixture that yields an
+`InMemoryEventBus` — the same publisher the application uses in non-Kafka
+deployments.
+
+**Step 5 — `audit_listener`: a subscriber on that bus.** It creates the
+`WalletAuditListener` and subscribes its handler to the bus, reading the event
+patterns straight off the decorated method's `__pyfly_event_patterns__` attribute —
+exactly what the `ApplicationContext` does when it auto-wires listeners at startup.
+
+**Step 6 — `command_bus` and `query_bus`: the CQRS dispatchers.** Each builds a
+`HandlerRegistry`, registers the real handlers (passing them the `repository`,
+`event_bus`, and `session_factory` they need), and yields a bus. **CQRS** —
+Command/Query Responsibility Segregation — simply means writes go through a command
+bus and reads through a query bus; a **handler** is the function that actually
+processes one command or query.
+
::: listing tests/conftest.py | Listing 16.3 — conftest.py: real framework components wired with no mocks
from __future__ import annotations
@@ -347,6 +522,24 @@ Each fixture is declared with `@pytest_asyncio.fixture` (not the bare `@pytest.f
The `session_factory` fixture is shared. `repository` and `command_bus` both receive it, so the same in-memory SQLite engine backs reads, writes, and the `@transactional` boundary the handlers open. The `audit_listener` and `command_bus` fixtures both receive `event_bus`; pytest instantiates that once per test and shares it between them, so events published by the command handlers are visible to the listener.
+*What just happened.* You wired a complete, production-shaped test stack — engine,
+repository, event bus, listener, and both CQRS buses — entirely from fixtures, with
+no mocks. The two facts that make it work are worth holding onto. First, **fixtures
+compose by name**: `command_bus` asks for `repository`, `event_bus`, and
+`session_factory` simply by naming them as parameters, and pytest threads the graph
+together. Second, **a fixture requested by two others is built once per test**: both
+`repository` and `command_bus` name `session_factory`, so they share one engine —
+the write a command makes is the read a query sees. Get this file right and every
+test in the next four sections is a two-line call.
+
+!!! spring "Spring parity"
+ `conftest.py` is PyFly's `@TestConfiguration` plus the shared
+ `application-test.properties` of a Spring Boot project — a single place that
+ declares the beans every test reuses. A `@pytest_asyncio.fixture` is the rough
+ equivalent of a `@Bean` method in that config: pytest builds it lazily, injects
+ it where its name is requested, and tears it down afterward, just as the Spring
+ test context manages bean lifecycle and injection.
+
!!! tip "No mocks anywhere"
Every component in `conftest.py` is the real production implementation.
`WalletRepository` is the same class the application boots.
@@ -361,6 +554,26 @@ The `session_factory` fixture is shared. `repository` and `command_bus` both rec
With the fixtures from `conftest.py`, exercising the full command/query cycle is a matter of calling `command_bus.send(...)` and `query_bus.query(...)`. No handler is instantiated in the test body — the bus dispatches to the handler already registered in the fixture.
+Notice how short each test body becomes now that the wiring lives in `conftest.py`.
+A test declares the fixtures it needs as parameters, then reads as plain narrative:
+
+**Step 1 — request the buses.** Each test signature lists `command_bus` and/or
+`query_bus`. pytest sees those names, builds the fixture graph from `conftest.py`,
+and injects the ready buses.
+
+**Step 2 — send commands.** `await command_bus.send(OpenWallet(...))` returns the new
+wallet id; subsequent `DepositFunds` and `WithdrawFunds` commands return the running
+balance. You assert on each return value as you go.
+
+**Step 3 — query the read side.** `await query_bus.query(GetWallet(...))` and
+`GetBalance(...)` reload the persisted state and return DTOs (data-transfer objects —
+plain read models). You assert their fields match what the commands wrote.
+
+**Step 4 — prove the error paths.** The remaining tests send a command that must fail
+— an overdraw, a non-positive deposit, an unknown wallet — wrapped in
+`pytest.raises(CommandProcessingException)`. That context manager asserts the block
+raises the named exception; if it does not, the test fails.
+
::: listing tests/test_cqrs_flow.py | Listing 16.4 — End-to-end CQRS tests through the real bus
from __future__ import annotations
@@ -464,6 +677,27 @@ async def test_deposit_to_unknown_wallet_is_rejected(
The error-path tests verify that the bus surfaces domain violations correctly. **`CommandProcessingException`** is the bus's wrapper for any exception raised inside a handler — including `BusinessRuleViolation` from the aggregate. Calling code never sees the raw domain exception; it always sees the bus wrapper.
+**Run it.**
+
+```bash
+uv run --extra dev pytest tests/test_cqrs_flow.py -q
+```
+
+Expected output:
+
+```text
+..... [100%]
+5 passed in 0.05s
+```
+
+*What just happened.* This is the first layer that touches real infrastructure, and
+it still runs in milliseconds. The command went through the real bus, the real
+handler opened a real `@transactional` unit of work on a real SQLite session,
+committed it, and the query read it back — the exact path that runs in production,
+minus the HTTP layer. Because the handler is registered in the fixture rather than
+constructed in the test, you are testing the *dispatch* as well as the logic: if the
+command-to-handler routing broke, these tests would catch it.
+
!!! note "asyncio_mode = \"auto\" and @pytest.mark.asyncio"
With `asyncio_mode = "auto"` every async test is collected and run
automatically. The `@pytest.mark.asyncio` decorator is **not required** but
@@ -478,6 +712,41 @@ The CQRS flow tests prove the full open/deposit/withdraw/query pipeline. The rep
The local fixture `_make_repo` mirrors what the `ApplicationContext` does at startup: construct the repository and run `RepositoryBeanPostProcessor.after_init` to compile the derived-query stubs. Without that call, methods like `find_by_owner_id` would raise `NotImplementedError`.
+Two terms before the listing. A **derived query** is a repository method whose body
+is generated from its *name*: `find_by_owner_id` becomes `WHERE owner_id = :value`,
+no SQL written by hand. A **Specification** is a reusable, composable predicate
+object you pass to a query — `balance_at_least(1000)` is one — for filters too
+dynamic to bake into a method name. **Pagination** wraps both: `Pageable.of(page,
+size, sort)` describes which slice you want, and the query returns a `Page` carrying
+the items plus `total`, `total_pages`, and `has_next`.
+
+This section uses a **file-based** SQLite database (via `tmp_path`) rather than the
+in-memory one, so it can prove a stronger property — durability across a reconnect.
+Here is the shape of each test:
+
+**Step 1 — `sqlite_factory`: a temp-file engine.** The fixture builds a SQLite engine
+backed by a real file under pytest's `tmp_path` (a fresh temp directory per test,
+auto-deleted afterward), creates the schema, and yields both the factory and the URL.
+Yielding the URL is what lets one test reconnect with a brand-new engine.
+
+**Step 2 — CRUD and persistence.**
+`test_upsert_inserts_then_updates_and_persists` upserts a row, upserts it again with
+a new balance to prove update-in-place, commits, reads it back — then disposes the
+engine and reconnects with a *fresh* one to prove the data truly hit disk.
+
+**Step 3 — the unknown-id path.** `test_find_by_id_unknown_returns_none` confirms a
+miss returns `None`, not an error.
+
+**Step 4 — derived query.** `test_derived_find_by_owner_id` inserts wallets for two
+owners and asserts `find_by_owner_id("alice")` returns only Alice's — proof the
+post-processor compiled the method-name convention correctly.
+
+**Step 5 — Specification + pagination.**
+`test_specification_find_rich_paged_and_sorted` and
+`test_find_all_pageable_counts_and_pages` exercise the `find_rich` /
+`find_all_by_spec` predicate path and the `find_all(pageable)` paging path, asserting
+`total`, `total_pages`, `has_next`, and the exact ordering of `page.items`.
+
::: listing tests/test_sql_wallet_repository.py | Listing 16.5 — Repository tests: CRUD, derived query, pagination, Specification
from __future__ import annotations
@@ -664,6 +933,27 @@ async def test_find_all_pageable_counts_and_pages(
Four things to note. First, `_make_repo` calls `RepositoryBeanPostProcessor().after_init(repo, ...)` — without this, `find_by_owner_id` is still a stub and raises `NotImplementedError`. The post-processor compiles the method name into a SQLAlchemy `WHERE owner_id = :owner_id` clause. Second, `upsert` is the repository's insert-or-update; after each batch of upserts, `await session.commit()` flushes to SQLite. Third, `find_rich` takes a minimum balance and a `Pageable`; it delegates to `find_all_by_spec_paged(balance_at_least(min), pageable)`. Fourth, the two-engine pattern in `test_upsert_inserts_then_updates_and_persists` proves true durability: data committed through one engine is readable by a completely fresh engine and session.
+**Run it.**
+
+```bash
+uv run --extra dev pytest tests/test_sql_wallet_repository.py -q
+```
+
+Expected output:
+
+```text
+..... [100%]
+5 passed in 0.06s
+```
+
+*What just happened.* The standout is the two-engine reconnect in the first test.
+Many "persistence" tests pass even when nothing was written to disk, because the same
+session caches the object in memory and hands it back on read. By disposing the
+engine entirely and opening a *second* one against the same file URL, this test
+forces a real round-trip to storage — if `upsert` or `commit` were silently not
+persisting, the reconnect would return `None` and the test would fail. That is the
+difference between testing your code and testing your cache.
+
!!! spring "Spring parity"
This test layer is the Python equivalent of `@DataJpaTest` with an embedded
H2 database in Spring Boot. `@DataJpaTest` loads only the JPA layer (entities,
@@ -684,6 +974,26 @@ Four things to note. First, `_make_repo` calls `RepositoryBeanPostProcessor().af
`WalletAuditListener` listens for domain events published by the command handlers. Testing it end to end — command runs on the bus, handler publishes events, listener receives them — requires all three components to share the same `InMemoryEventBus`. The `conftest.py` fixtures already arrange this: both `command_bus` and `audit_listener` accept an `event_bus` argument, and pytest injects the same instance into both.
+An **event listener** is just a method that the framework subscribes to the bus so it
+runs whenever a matching event is published. Lumen's `WalletAuditListener` keeps an
+in-memory audit log and a running balance per wallet — a tiny **projection** (a read
+model built by folding events). Testing it is the clearest demonstration of the
+shared-fixture trick: because `command_bus` and `audit_listener` name the same
+`event_bus`, an event a command publishes is an event the listener observes, with no
+glue in the test body.
+
+The tests follow one rhythm:
+
+**Step 1 — drive commands.** Open a wallet, deposit, withdraw — all through
+`command_bus`.
+
+**Step 2 — read the projection.** Call `audit_listener.entries_for(wallet_id)` and
+assert the recorded event types, in order, plus the `running_total`.
+
+**Step 3 — assert the negative.** One test deliberately overdraws — a command that
+must fail — and asserts the audit log records nothing from it. A failed operation
+leaves no trace.
+
::: listing tests/test_event_listener.py | Listing 16.6 — Event listener tests: command publishes, listener observes
from __future__ import annotations
@@ -761,11 +1071,34 @@ async def test_event_type_matches_domain_event_class_names(
`test_event_type_matches_domain_event_class_names` proves a domain invariant: a rejected command (overdraw) raises no event. The audit log must never record a side effect from a failed operation.
+**Run it.**
+
+```bash
+uv run --extra dev pytest tests/test_event_listener.py -q
+```
+
+Expected output:
+
+```text
+... [100%]
+3 passed in 0.04s
+```
+
+*What just happened.* No part of the test connected the listener to the bus — the
+`audit_listener` fixture did that in `conftest.py`, subscribing the handler to the
+same `event_bus` the `command_bus` publishes through. So sending a command and then
+reading `entries_for(...)` exercises the real publish/subscribe path end to end. The
+negative test is the subtle one: it proves the audit log is driven by *events*, not
+by *attempts* — a rejected withdrawal raises a `BusinessRuleViolation` before any
+event is emitted, so nothing reaches the listener.
+
!!! tip "event_type is the class name"
PyFly's event publisher sets `event_type` to the domain event class name:
`"WalletOpened"`, `"FundsDeposited"`, `"FundsWithdrawn"`. The
- `@event_listener(pattern)` decorator on `WalletAuditListener.on_wallet_event`
- uses a glob pattern (`"Wallet*"`, `"Funds*"`) to subscribe to all three.
+ `@event_listener(event_types=["WalletOpened", "FundsDeposited", "FundsWithdrawn"])`
+ decorator on `WalletAuditListener.on_wallet_event` names those three types
+ explicitly; the framework stores them on the method as
+ `__pyfly_event_patterns__`, which the `audit_listener` fixture reads to subscribe.
The test asserts the string class names directly.
---
@@ -774,7 +1107,29 @@ async def test_event_type_matches_domain_event_class_names(
The unit tests, CQRS flow tests, and repository tests each wire one layer of the stack. The booted-context integration test wires everything at once: it starts the real `ApplicationContext` — DI component scan, CQRS auto-configuration, relational auto-configuration, `RepositoryBeanPostProcessor`, `@transactional` seam, EDA event bus — then resolves the `DefaultCommandBus` and `DefaultQueryBus` from the context and drives the full wallet lifecycle.
-The database URL is overridden via an environment variable so the test never touches the developer's `lumen.db`.
+The **ApplicationContext** is PyFly's runtime container — the object that scans for
+components, builds beans, runs post-processors, and holds the wired application
+together. Booting it is the most faithful test you can write short of starting an
+HTTP server: every piece of wiring the framework does at startup actually happens.
+
+The database URL is overridden via an environment variable so the test never touches the developer's `lumen.db`. Here is the plan:
+
+**Step 1 — isolate the database.** The `booted_context` fixture uses pytest's
+`monkeypatch` fixture to set `PYFLY_DATA_RELATIONAL_URL` to a temp-file SQLite path
+under `tmp_path`, *before* the app boots. `monkeypatch` is pytest's safe way to set
+an environment variable for the duration of one test and automatically restore it
+afterward — so this test can never clobber your real `lumen.db`.
+
+**Step 2 — boot the real application.** It constructs `PyFlyApplication(LumenApplication,
+config_path=...)` and `await app.startup()`. That single call runs the entire startup
+sequence: component scan, all auto-configurations, the `RepositoryBeanPostProcessor`,
+and the event bus. The fixture yields `app.context` and, in its `finally` block,
+closes the shared session and calls `app.shutdown()`.
+
+**Step 3 — resolve beans and drive the lifecycle.** The test calls
+`ctx.get_bean(DefaultCommandBus)` and `ctx.get_bean(DefaultQueryBus)` — pulling the
+*same* buses the application would use — then runs open → deposit → withdraw → list →
+rich → balance, asserting each result.
::: listing tests/test_app_context_integration.py | Listing 16.7 — Booted-context integration: full DI + persistence composition
from __future__ import annotations
@@ -888,6 +1243,44 @@ async def test_full_lifecycle_through_booted_context(
`test_full_lifecycle_through_booted_context` exercises every query type the application exposes: `GetWallet` (aggregate reload), `ListWallets` (paginated list using `find_all(pageable)`), `ListRichWallets` (Specification predicate using `find_all_by_spec_paged`), and `GetBalance` (projection-backed balance). It proves that the `RepositoryBeanPostProcessor`, the `@transactional` boundary around each command handler, and the DI wiring all compose correctly in a single boot.
+**Run it.**
+
+```bash
+uv run --extra dev pytest tests/test_app_context_integration.py -q
+```
+
+Expected output:
+
+```text
+. [100%]
+1 passed in 0.15s
+```
+
+One test, but the heaviest one in the suite: it actually started the framework. If
+the DI scan missed a bean, an auto-configuration mis-wired, or the `@transactional`
+boundary failed to commit, this is the test that catches it — which is exactly why it
+sits at the peak of the pyramid and why there is only one of it.
+
+*What just happened.* The environment-variable override is the load-bearing trick.
+The framework reads `pyfly.data.relational.url` from config during relational
+auto-configuration, and PyFly maps any config key to a `PYFLY_*` environment variable
+(dots and dashes become underscores, uppercased), so `PYFLY_DATA_RELATIONAL_URL`
+overrides the `url` in `pyfly.yaml`. Setting it with `monkeypatch` *before*
+`app.startup()` is what redirects the whole booted application to a throwaway
+database — and `monkeypatch` undoes the change when the fixture tears down, so no
+other test is affected.
+
+!!! tip "A dedicated test profile (v26.6.110)"
+ Setting one variable is fine for a single override. When a project needs a whole
+ block of test-only settings, PyFly's **profile** mechanism (Spring parity) is
+ cleaner: drop a `pyfly-test.yaml` next to `pyfly.yaml` with your test overrides,
+ then activate it by setting `PYFLY_PROFILES_ACTIVE=test` (or
+ `pyfly.profiles.active: test` in config). On boot, PyFly overlays
+ `pyfly-test.yaml` on top of the base `pyfly.yaml`, so values like the database
+ URL or `ddl-auto` apply only under that profile. Lumen does not need a profile —
+ one env override covers its single test-only setting — but reach for one as the
+ test configuration grows.
+
!!! spring "Spring parity"
This test is the Python equivalent of `@SpringBootTest` with an embedded H2
database. `@SpringBootTest` loads the full application context, including all
@@ -932,11 +1325,26 @@ async def test_wallet_round_trip_against_real_postgres():
Lumen does not use these helpers — SQLite covers the persistence layer without Docker, and the in-memory bus covers event routing. Reach for them when your project has infrastructure that cannot be reproduced without a real daemon.
+**Run it.** You have now walked every layer file by file. Run the whole suite one more
+time to confirm the full pyramid is green together:
+
+```bash
+uv run --extra dev pytest -q
+```
+
+Expected output — the same `41 passed` you started with, now with a mental model of
+exactly what each dot proves:
+
+```text
+......................................... [100%]
+41 passed in 0.28s
+```
+
---
## What you built {.recap}
-Lumen now has 41 passing tests across six files, exercising every layer of the pyramid.
+The six test files this chapter built add up to 26 passing tests, exercising every layer of the pyramid. Together with the saga tests from Chapter 12 and the event-sourcing tests from Chapter 9, Lumen's full suite is **41 passing tests** — the count you saw when you ran `uv run --extra dev pytest -q` at the start.
At the base, `test_money.py` and `test_wallet_aggregate.py` prove the domain model's arithmetic, immutability, and invariant rules. All tests are synchronous, pure Python functions — no fixtures, no DI, no `async`. The `BusinessRuleViolation.rule` attribute makes each assertion specific to the exact violated invariant.
@@ -976,6 +1384,12 @@ Concretely, you learned:
- **`monkeypatch.setenv`** — override configuration before booting the
context in integration tests; the framework reads environment variables
during auto-configuration.
+- **`PYFLY_*` config overrides** — every config key maps to an environment
+ variable (`pyfly.data.relational.url` → `PYFLY_DATA_RELATIONAL_URL`); set it
+ with `monkeypatch.setenv` before booting to redirect the whole application.
+- **Test profile (v26.6.110)** — for a block of test-only settings, add a
+ `pyfly-test.yaml` overlay and activate it with `PYFLY_PROFILES_ACTIVE=test`
+ (Spring `application-test.yaml` parity).
- **Framework helpers** (`PyFlyTestCase`, `mock_bean`, `create_test_container`,
Testcontainers) — available in `pyfly.testing` for projects that need them;
Lumen keeps things simple with real components.
diff --git a/book/manuscript/17-scheduling-notifications.md b/book/manuscript/17-scheduling-notifications.md
index fa72990f..5c16c314 100644
--- a/book/manuscript/17-scheduling-notifications.md
+++ b/book/manuscript/17-scheduling-notifications.md
@@ -21,6 +21,19 @@ Install the two optional extras before you start:
uv add "pyfly[scheduling,notifications]"
```
+!!! note "New term: optional extras"
+ An *extra* is an opt-in slice of a package's dependencies. `pyfly` keeps
+ its core lean and ships heavier capabilities — scheduling, notifications,
+ and the rest — behind named extras so you only install what you use.
+ `pyfly[scheduling,notifications]` pulls in both. The square-bracket syntax
+ is standard Python packaging; `uv add` records it in your `pyproject.toml`
+ so the next `uv sync` reinstalls them. If you later see `ModuleNotFoundError`
+ for `croniter` or a notifications provider, you skipped this line — re-run it.
+
+This chapter targets PyFly **v26.6.110**. Every code listing below matches the
+real Lumen source under `samples/lumen/src/lumen`, and every framework API was
+checked against `pyfly` itself, so what you build here runs unchanged.
+
---
## Scheduled tasks
@@ -40,6 +53,30 @@ arrive from partners; outbound callbacks close the feedback loop.
**`@scheduled`** marks any `async` method on a `@service` bean for periodic execution. It accepts exactly one *trigger*: `fixed_rate`, `fixed_delay`, or `cron`. Providing zero or more than one trigger raises a `ValueError` at decoration time, so mistakes surface at startup rather than silently at three in the morning.
+!!! note "New term: trigger"
+ A *trigger* is the rule that decides *when* a scheduled method runs. You
+ pick exactly one: `cron` (a calendar expression like "every day at 02:00"),
+ `fixed_rate` (a steady interval such as "every 10 seconds"), or `fixed_delay`
+ (a gap measured after each run finishes). One method, one trigger.
+
+Let us build the nightly rollup one decision at a time.
+
+**Step 1 — Create the service file.** In the Lumen tree, add `daily_rollup.py`
+under a `ledger` package. The class is an ordinary `@service` — a plain Python
+class that PyFly registers in its dependency-injection container and constructs
+for you. Because it is a managed bean, you can ask for the `WalletRepository` in
+the constructor and the framework hands it over; you never call `new` yourself.
+
+**Step 2 — Pick the trigger.** This job must run once a night, on the clock, so
+the trigger is `cron`. The expression `"0 2 * * *"` reads field-by-field as
+*minute 0, hour 2, every day-of-month, every month, every day-of-week* — i.e.
+02:00 every day.
+
+**Step 3 — Write the work.** Inside the method, load every wallet, sum the
+persisted `balance_minor` integers, and (for now) log the total. In production
+you would write the snapshot to a reporting table or ship it downstream; logging
+keeps the example focused on the *scheduling*, not the bookkeeping.
+
::: listing lumen/ledger/daily_rollup.py | Listing 17.1 — Nightly wallet balance rollup with @scheduled
from datetime import timedelta
@@ -80,6 +117,46 @@ That is all the wiring Lumen needs. With `pyfly[scheduling]` installed, `Schedul
No `SchedulerManager` required.
+!!! note "New term: auto-configuration"
+ *Auto-configuration* is PyFly noticing what is on your classpath and wiring
+ the matching machinery for you. `SchedulingAutoConfiguration` only activates
+ when `croniter` (pulled in by the `scheduling` extra) is importable — so the
+ scheduler appears the moment you install the extra and stays absent
+ otherwise. This is the same "convention over configuration" idea Spring Boot
+ made famous; you can always override a bean by declaring your own.
+
+**Run it.** Waiting until 02:00 to see your first tick is no fun, so temporarily
+change the trigger to fire every minute — `@scheduled(cron="* * * * *")` — and
+start the app from the `samples/lumen` directory:
+
+```bash
+uv run pyfly run --server uvicorn
+```
+
+At the top of the next minute you should see your rollup line in the logs (an
+empty database simply reports zero wallets):
+
+```text
+[rollup] 0 wallets, total 0.00 (minor units: 0)
+```
+
+Open a wallet and deposit into it (see the curl recipes in Chapter 7), wait for
+the next minute, and the totals move:
+
+```text
+[rollup] 1 wallets, total 15.00 (minor units: 1500)
+```
+
+Stop the app with Ctrl-C and **change the trigger back to `"0 2 * * *"`** before
+committing — the every-minute cron was only a probe.
+
+!!! note "What just happened"
+ You did not start a thread, open an event loop, or register a timer. You
+ wrote one `@service` with one `@scheduled` method, and the framework
+ discovered it at startup, computed the next fire time from the cron
+ expression, slept until then, and ran your coroutine — repeating forever.
+ The scheduler is *declarative*: you state *when*, PyFly handles *how*.
+
### fixed_rate vs. fixed_delay
**`fixed_rate`** measures from the **start** of one execution to the start of the next. **`fixed_delay`** measures from the **end** of one execution to the start of the next. Use `fixed_rate` for heartbeats and metrics where you need a steady cadence regardless of execution time. Use `fixed_delay` when you need a guaranteed breathing gap — for example, when polling an upstream API that rate-limits on request frequency.
@@ -133,6 +210,27 @@ def preview_rollup_schedule(expression: str, n: int = 5) -> list[str]:
return [str(t) for t in cron.next_n_fire_times(n)]
:::
+**Run it.** `CronExpression` needs no running app, so the fastest way to build
+intuition is the REPL. From `samples/lumen`:
+
+```bash
+uv run python -c "from pyfly.scheduling import CronExpression; \
+print(*CronExpression('0 2 * * *').next_n_fire_times(3), sep='\n')"
+```
+
+You should see the next three 02:00 instants, one per line (your dates will
+differ):
+
+```text
+2026-06-16 02:00:00+00:00
+2026-06-17 02:00:00+00:00
+2026-06-18 02:00:00+00:00
+```
+
+Notice the `+00:00` — fire times are UTC unless you pass a `zone` (covered
+next). This is also the cleanest way to sanity-check a user-supplied expression
+before you store it: an invalid string raises `ValueError` immediately.
+
`CronExpression` accepts both the standard 5-field format (`min hour dom month dow`) and the Spring-style 6-field format with seconds first (`sec min hour dom month dow`). The Spring `?` wildcard is normalised to `*` transparently.
| Expression | Fires |
@@ -249,12 +347,29 @@ Under the hood `@async_method` sets `__pyfly_async__ = True` on the function; th
pyfly:
scheduling:
enabled: true # set false to disable all loops
- thread-pool:
- max-workers: 4 # threads for ThreadPoolTaskExecutor
+ executor:
+ type: asyncio # 'asyncio' (default, in-loop) or 'thread'
+ max-workers: 4 # worker threads when type is 'thread'
+ lock:
+ provider: none # none | memory | redis | postgres
```
When `enabled` is `false`, `TaskScheduler` starts no loops and all `@scheduled` methods are silently skipped.
+The `executor.type` chooses how each tick runs. The default `asyncio` runs the
+coroutine on the application event loop — ideal for short, I/O-bound jobs like
+the rollup. Switch to `thread` (a pool of `executor.max-workers` threads) when a
+job does heavy CPU work or calls a blocking library, so it cannot stall the loop.
+
+!!! tip "Choosing a lock provider"
+ `lock.provider` selects the backend behind `@scheduled(lock=...)`, described
+ next: `none` (the default — no coordination), `memory` (mutual exclusion
+ within one process), `redis`, or `postgres` (true cross-instance
+ coordination with no code change). On `redis`/`postgres` PyFly builds the
+ `DistributedLock` bean for you from `pyfly.scheduling.lock.redis.url` or the
+ app's existing `AsyncEngine`; the hand-rolled `@bean` in Listing 17.4 is the
+ do-it-yourself alternative when you need custom semantics.
+
---
## Notifications
@@ -263,6 +378,14 @@ Lumen needs to tell customers that their money has arrived — email for the bal
PyFly's notifications module defines three **port protocols** and three **default services**. Your business logic depends on the protocols; the concrete provider adapters — SMTP, SendGrid, Twilio, Firebase — live behind the port boundary and can be swapped without touching a single line of domain code.
+!!! note "New term: port and adapter"
+ A *port* is an interface your code talks to — here, "something that can send
+ an email". An *adapter* is a concrete implementation of that port —
+ `SmtpEmailProvider`, `SendGridEmailProvider`, and so on. The pattern (also
+ called *hexagonal architecture*) means your deposit logic depends only on the
+ `EmailService` port, never on a specific vendor. Swapping SMTP for SendGrid
+ is a one-line change in a configuration class; the domain code never notices.
+
### The port hierarchy
| Protocol | Service class | Method |
@@ -317,7 +440,19 @@ push = PushMessage(
### Wiring the SMTP provider
-For development and self-hosted deployments, `SmtpEmailProvider` runs `smtplib` from a thread pool so the async event loop is never blocked:
+For development and self-hosted deployments, `SmtpEmailProvider` runs `smtplib` from a thread pool so the async event loop is never blocked.
+
+**Step 1 — Build the provider as a `@bean`.** A `@configuration` class is PyFly's
+place to assemble objects the container cannot construct on its own — here, an
+SMTP client that needs a host, credentials, and TLS settings. Each `@bean`
+method returns one ready-to-use object; the framework caches it and injects it
+wherever the return type is requested.
+
+**Step 2 — Wrap it in `DefaultEmailService`.** The second `@bean` takes the
+provider and returns it as an `EmailService` — the *port* your domain code
+depends on. The declared return type matters: by returning `EmailService`, every
+class that asks for an `EmailService` receives this wrapper, and none of them
+learn that SMTP is behind it.
::: listing lumen/notifications/config.py | Listing 17.6 — SMTP provider wired as a @bean
from pyfly.container import bean, configuration
@@ -359,7 +494,24 @@ class NotificationConfig:
Lumen publishes a `FundsDeposited` domain event every time the `deposit()` command succeeds (see Chapter 8). The right place to trigger the notification is an EDA listener subscribed to that event — not the command handler itself, which keeps the deposit path free of notification concerns.
-`FundsDeposited` carries `wallet_id: str`, `amount: int` (minor units), `currency: str`, and `balance: int` (new balance, minor units). The listener converts `amount` to a display string via `amount / 100`:
+`FundsDeposited` carries `wallet_id: str`, `amount: int` (minor units), `currency: str`, and `balance: int` (new balance, minor units). The listener converts `amount` to a display string via `amount / 100`.
+
+**Step 1 — Subscribe to the event.** Stack `@event_listener(event_types=["FundsDeposited"])`
+on an `async` method of a `@service`. At startup PyFly discovers the stamped
+method and auto-subscribes it to the `EventPublisher` bus — exactly the same
+mechanism `WalletAuditListener` used back in Chapter 8. You never wire a bus by
+hand.
+
+**Step 2 — Read the payload.** The handler receives an `EventEnvelope`. Its
+`payload` is a plain dict of the event's fields, so you pull `wallet_id`,
+`amount`, `currency`, and `balance` out with `.get(...)` and coerce the amounts
+to ints. Because amounts are minor units, dividing by 100 gives the display
+value: `25000` becomes `250.00`.
+
+**Step 3 — Send through the ports.** Inject `EmailService` and `PushService` in
+the constructor and call `.send(...)` on each. Both return a `NotificationResult`
+rather than raising — a flaky provider degrades gracefully instead of crashing
+the listener.
::: listing lumen/wallet/deposit_notification_listener.py | Listing 17.7 — Notifying on FundsDeposited
from pyfly.container import service
@@ -424,6 +576,38 @@ class DepositNotificationListener:
Both calls return a `NotificationResult`; inspect the `status` field to log failures or schedule retries.
+**Run it.** You do not want a real SMTP server while developing, so swap the
+provider for the log-only `DummyEmailProvider`. In your `@configuration` class,
+return a `DummyEmailProvider` (and `DummyPushProvider`) instead of the SMTP one,
+then start the app and trigger a deposit:
+
+```bash
+uv run pyfly run --server uvicorn
+# in a second terminal, open a wallet and deposit (see Chapter 7), e.g.:
+curl -s -X POST localhost:8080/api/v1/wallets//deposit \
+ -H 'content-type: application/json' -d '{"amount":25000}'
+```
+
+The deposit publishes `FundsDeposited`, the listener fires, and the dummy
+providers log the messages they "sent":
+
+```text
+[dummy email] to=['customer@example.com'] subject=Funds received: 250.00 EUR
+[dummy push] tokens=1 title=Funds received
+```
+
+The `DummyEmailProvider` also keeps every message it received in a `.sent` list —
+which is exactly what the test in Exercise 2 asserts against, no SMTP server
+required.
+
+!!! note "What just happened"
+ The deposit command knew nothing about email. It simply did its job and
+ raised a `FundsDeposited` domain event. The notification logic lives in a
+ separate listener that *reacts* to that event, so the deposit path stays
+ clean and you can add, remove, or change notifications without touching the
+ command handler. That separation — publish a fact, let interested parties
+ react — is the whole point of event-driven architecture.
+
!!! spring "Spring parity"
`EmailService` / `SmsService` / `PushService` are the Python
equivalents of Spring's `JavaMailSender` (email) and third-party
@@ -443,6 +627,16 @@ An illustrative payment provider POSTs a `payment_intent.succeeded` event to Lum
PyFly's `pyfly.webhooks` module handles all three steps.
+!!! note "New terms: webhook, HMAC, idempotency"
+ A *webhook* is an HTTP POST that an external system sends *to you* when
+ something happens — the inbound mirror of the outbound callbacks later in
+ this chapter. Because anyone can POST to a public URL, the provider signs
+ each request with a shared secret using *HMAC* (a keyed hash); recomputing
+ the hash over the exact bytes received and comparing proves the payload is
+ genuine and untampered. *Idempotency* means "safe to receive more than
+ once": providers retry on network blips, so you store an idempotency key and
+ ignore a repeat — otherwise one card top-up could credit a wallet twice.
+
### WebhookEvent and AbstractWebhookEventListener
Every inbound event is modelled as a `WebhookEvent` dataclass:
@@ -505,7 +699,16 @@ class PaymentWebhookListener(AbstractWebhookEventListener):
### WebhookProcessor — verify, dedupe, dispatch
-**`WebhookProcessor`** wires together a signature validator, an idempotency store, and a list of listeners:
+**`WebhookProcessor`** wires together a signature validator, an idempotency store, and a list of listeners. Assemble it in a `@configuration` class so it is a
+single shared bean:
+
+- `listeners` is the list of `AbstractWebhookEventListener` subclasses to fan
+ events out to (just `PaymentWebhookListener` for now);
+- `signature_validators` maps each `source` name to the validator that proves its
+ requests are genuine — here an `HmacSignatureValidator` keyed by the webhook
+ secret your provider gave you;
+- an `event_store` (omitted here, so the default in-memory one is used) remembers
+ idempotency keys.
::: listing lumen/webhooks/processor_config.py | Listing 17.9 — Assembling WebhookProcessor
from pyfly.container import bean, configuration
@@ -537,22 +740,34 @@ class WebhookConfig:
### Handling a webhook in an HTTP handler
-Call `processor.process()` from your inbound HTTP handler. Pass the raw request body (unmodified bytes) — the validator computes the HMAC over the exact bytes received:
+Call `processor.process()` from your inbound HTTP handler. Pass the raw request body (unmodified bytes) — the validator computes the HMAC over the exact bytes received.
+
+!!! warning "Read the body as raw bytes, not parsed JSON"
+ The signature is computed over the *exact bytes* the provider sent. If you
+ parse the JSON and re-serialize it, key order or whitespace can shift and the
+ HMAC will no longer match — every legitimate request would be rejected.
+ Always pass `await request.body()` (the untouched bytes) to `process()`, as
+ the handler below does.
::: listing lumen/webhooks/payment_handler.py | Listing 17.10 — Inbound payment-provider webhook endpoint
-from pyfly.container import service
-from pyfly.web import Request, Response, router
+from pyfly.container import rest_controller
+from pyfly.web import post_mapping, request_mapping
from pyfly.webhooks import WebhookProcessor
+from starlette.requests import Request
+from starlette.responses import Response
-@service
+@rest_controller
+@request_mapping("/webhooks")
class PaymentWebhookHandler:
def __init__(self, processor: WebhookProcessor) -> None:
self._processor = processor
- @router.post("/webhooks/payment")
+ @post_mapping("/payment")
async def receive(self, request: Request) -> Response:
+ # Read the untouched bytes — the HMAC is computed over exactly
+ # what the provider sent (see the warning above).
raw_body = await request.body()
headers = {
"X-Signature": request.headers.get(
@@ -569,8 +784,8 @@ class PaymentWebhookHandler:
headers=headers,
)
except ValueError:
- return Response(status=400, body=b"invalid signature")
- return Response(status=200, body=b"ok")
+ return Response(content=b"invalid signature", status_code=400)
+ return Response(content=b"ok", status_code=200)
:::
The `process()` signature accepts `signature_header` and `idempotency_header` keyword arguments to override the default header names (`X-Signature` and `X-Idempotency-Key`).
@@ -583,6 +798,50 @@ The `process()` signature accepts `signature_header` and `idempotency_header` ke
4. If `idempotency_key` is present and already seen, the event is returned but listeners are **not** called.
5. Each listener for the source is called in registration order; if one raises, the error is logged and `on_error` is called before continuing to the next listener.
+**Run it.** Test the endpoint the way a real provider would — by signing the exact
+body. Pick the same secret you put in `HmacSignatureValidator` (`whsec_REPLACE_ME`
+in Listing 17.9), compute the HMAC with `openssl`, and POST it. Start the app
+(`uv run pyfly run --server uvicorn`), then in a second terminal:
+
+::: listing terminal | Listing 17.10a — Sign and POST a webhook
+BODY='{"type":"payment_intent.succeeded","data":{"object":{"amount_received":25000,"currency":"eur","metadata":{"wallet_id":""}}}}'
+SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "whsec_REPLACE_ME" | sed 's/^.* //')
+
+curl -s -X POST localhost:8080/webhooks/payment \
+ -H "X-Webhook-Signature: sha256=$SIG" \
+ -H "X-Idempotency-Key: evt-001" \
+ -H 'content-type: application/json' \
+ -d "$BODY"
+:::
+
+A correctly signed request returns `ok` and credits the wallet (you will see the
+`FundsDeposited` notification logs from earlier fire too):
+
+```text
+ok
+```
+
+Now prove the two guarantees. POST the **same** command again with the same
+`X-Idempotency-Key` — it still returns `ok`, but the wallet is *not* credited a
+second time (the duplicate is dropped before any listener runs). Then tamper with
+one byte of `$BODY` *without* recomputing `$SIG` and POST again — the signature
+no longer matches, so the handler returns:
+
+```text
+invalid signature
+```
+
+That is the verify-dedupe-dispatch pipeline working end to end, and it mirrors
+Exercise 3 almost exactly.
+
+!!! note "What just happened"
+ A stranger on the internet POSTed JSON to your service, and three guards ran
+ before a single line of your business logic: the signature check rejected
+ forgeries, the idempotency store rejected replays, and only then did the
+ typed listener translate the event into a CQRS `DepositFunds` command — so
+ the wallet aggregate still enforced its own invariants. You wrote the
+ `handle()` body; PyFly supplied the gauntlet around it.
+
!!! note "In-memory idempotency store"
The default `InMemoryWebhookEventStore` holds seen keys in a Python
`set`. For production clusters, implement the `WebhookEventStore`
@@ -602,9 +861,36 @@ The `process()` signature accepts `signature_header` and `idempotency_header` ke
When Lumen books a disbursement to a partner bank, that partner expects a `DisbursementSettled` POST to their webhook URL — signed, retried on failure, and auditable. PyFly's `pyfly.callbacks` module handles the outbound side.
+!!! note "New term: outbound callback"
+ A *callback* here is the reverse of the inbound webhook you just built:
+ *Lumen* is now the sender, POSTing an event *to* a partner's URL. The
+ module gives you the same trust and reliability machinery on the way out —
+ it signs each payload (so the partner can verify it), retries transient
+ failures with backoff, and records every attempt so you have an audit trail
+ when a partner asks "did you ever tell us about transaction X?".
+
### Subscriptions and config
-Each partner is modelled as a **`CallbackConfig`** — a tenant-scoped record that holds the webhook secret, event subscriptions, and retry policy:
+Each partner is modelled as a **`CallbackConfig`** — a tenant-scoped record that holds the webhook secret, event subscriptions, and retry policy.
+
+!!! note "New term: tenant"
+ A *tenant* is one isolated customer or organisation inside a shared
+ application — here, `"lumen"`. Callbacks are tenant-scoped so a multi-tenant
+ deployment can hold each tenant's partner URLs, secrets, and retry policy
+ separately and never cross the wires. With a single tenant you simply pass
+ the same `tenant_id` everywhere.
+
+**Step 1 — Describe each subscription.** A `CallbackSubscription` pairs an
+`event_type` with the `target_url` to POST it to. Use the exact event name
+(`"DisbursementSettled"`) to route one event, or `"*"` as a catch-all that
+matches every event for the tenant — handy for an audit endpoint that wants the
+full firehose.
+
+**Step 2 — Wrap them in a `CallbackConfig` and save it.** The config carries the
+shared `secret` (used to sign every payload), the retry policy (`max_attempts`,
+`backoff_ms`), and the list of subscriptions. Persist it through a
+`CallbackConfigRepository` — `InMemoryCallbackConfigRepository` for now, a
+database-backed one in production.
::: listing lumen/callbacks/register_partner.py | Listing 17.11 — Registering a partner callback
from pyfly.callbacks import (
@@ -693,6 +979,69 @@ results = await dispatcher.dispatch(
`dispatch()` returns one `CallbackExecution` record per matching subscription, each with `status`, `attempts`, `response_status`, and `delivered_at`.
+**Run it.** The dispatcher's default HTTP sender does not actually call the
+network — it logs the request it *would* make and returns `200` — which is
+perfect for seeing the wiring without standing up a partner server. Drop this
+into a script (or `uv run python`) from `samples/lumen`:
+
+::: listing lumen/callbacks/try_dispatch.py | Listing 17.12a — Dispatch against the default (log-only) sender
+import asyncio
+
+from pyfly.callbacks import (
+ CallbackConfig,
+ CallbackDispatcher,
+ CallbackSubscription,
+ InMemoryCallbackConfigRepository,
+ InMemoryCallbackExecutionRepository,
+)
+
+
+async def main() -> None:
+ configs = InMemoryCallbackConfigRepository()
+ await configs.save(CallbackConfig(
+ tenant_id="lumen",
+ name="clearance-bank",
+ secret="cb-secret-xyz",
+ subscriptions=[CallbackSubscription(
+ event_type="DisbursementSettled",
+ target_url="https://api.clearancebank.example.com/hooks/lumen",
+ )],
+ ))
+ dispatcher = CallbackDispatcher(
+ configs=configs,
+ executions=InMemoryCallbackExecutionRepository(),
+ )
+ results = await dispatcher.dispatch(
+ "lumen",
+ "DisbursementSettled",
+ {"id": "txn-009", "amount": 50_000, "currency": "EUR"},
+ )
+ for r in results:
+ print(r.status, r.attempts, r.response_status, r.target_url)
+
+
+asyncio.run(main())
+:::
+
+You should see one delivered execution, with the signed request logged just
+above it:
+
+```text
+would POST https://api.clearancebank.example.com/hooks/lumen headers={'X-Pyfly-Signature': 'sha256=...', 'Content-Type': 'application/json'} body={'id': 'txn-009', 'amount': 50000, 'currency': 'EUR'}
+DELIVERED 1 200 https://api.clearancebank.example.com/hooks/lumen
+```
+
+`DELIVERED 1 200` reads as: status `DELIVERED`, succeeded on attempt `1`, HTTP
+`200`. To send for real, pass your own `http=` sender (an `httpx`/`aiohttp` POST)
+to `CallbackDispatcher`; the signing, retry, and audit logic stay identical.
+
+!!! note "What just happened"
+ One `dispatch()` call looked up every subscription the tenant has for that
+ event type, signed the payload, POSTed it, and wrote a `CallbackExecution`
+ record for each — all without your domain service knowing how many partners
+ are listening or how retries work. Adding a partner later is just another
+ saved `CallbackConfig`; the disbursement code never changes.
+
### HMAC signing and retry logic
When `CallbackConfig.secret` is set, `CallbackDispatcher` signs the canonical JSON payload before every POST using HMAC-SHA256:
@@ -748,6 +1097,18 @@ config = CallbackConfig(
Subdomains of allowed domains are also accepted (e.g. `api.clearancebank.example.com`).
+!!! note "New term: SSRF"
+ *Server-Side Request Forgery* is an attack where a malicious value tricks
+ your server into making an HTTP request it should not — for example, a
+ partner URL pointing at `http://169.254.169.254/` (a cloud metadata
+ endpoint) to steal credentials. Because callback URLs can come from
+ partner-supplied config, the `authorized_domains` allowlist closes that door:
+ a host that is not on the list is marked `FAILED` *before* any request
+ leaves the process. To verify, add an `AuthorizedDomain` for one host, then
+ dispatch a subscription whose `target_url` points elsewhere — the returned
+ `CallbackExecution` will read `status=FAILED`, `attempts=0`, and
+ `last_error="Domain not authorized"`, and nothing is sent.
+
!!! spring "Spring parity"
PyFly's `@scheduled` / `CronExpression` / `TaskScheduler` trio
mirrors Spring's `@Scheduled` / `CronExpression` /
@@ -760,6 +1121,26 @@ Subdomains of allowed domains are also accepted (e.g. `api.clearancebank.example
---
+**Run it — confirm the suite is still green.** You added four new integration
+patterns; make sure nothing else regressed. From the `samples/lumen` directory:
+
+```bash
+uv run --extra dev pytest -q
+```
+
+You should see every existing test still pass:
+
+```text
+......................................... [100%]
+41 passed in 0.28s
+```
+
+The three exercises below add scheduling, notification, and webhook tests of
+their own — re-run this command after each to watch the count climb. Remember the
+`--extra dev` flag; the bare `uv sync` omits pytest.
+
+---
+
## What you built {.recap}
You extended Lumen into a connected system that operates independently of incoming requests:
@@ -785,9 +1166,10 @@ You extended Lumen into a connected system that operates independently of incomi
2. **Provider swap.** Replace `SmtpEmailProvider` with a
`DummyEmailProvider` in the test suite. Write a test that deposits
EUR 100 (10 000 minor units) into wallet `w-001` via the deposit
- handler, triggering a `FundsDeposited` event. Assert that
- `DummyEmailProvider.last_message` contains the wallet ID and the
- formatted amount (`100.00 EUR`) in its `body_text`.
+ handler, triggering a `FundsDeposited` event. The provider records
+ every message it received in its `.sent` list, so assert that
+ `provider.sent[-1].body_text` contains the wallet ID and the
+ formatted amount (`100.00 EUR`).
3. **Signature replay attack.** Write a test that calls
`PaymentWebhookHandler.receive()` twice with the same raw body,
diff --git a/book/manuscript/18-production.md b/book/manuscript/18-production.md
index b7bab409..a5971b1b 100644
--- a/book/manuscript/18-production.md
+++ b/book/manuscript/18-production.md
@@ -10,6 +10,23 @@ This final chapter is about the distance between "it works on my laptop" and "it
You will move quickly. Every section picks one topic, shows the minimal working API, and connects it to Lumen. By the end you will have a complete picture of the PyFly ecosystem and a production checklist worth keeping close.
+!!! note "Conventions in this chapter"
+ The listings and config keys here target PyFly **v26.6.110**. Two
+ deployment facts from that release run through the whole "Going to
+ production" section, so it helps to know them up front:
+
+ - The application listens on `pyfly.server.port` (default `8080`), the
+ Spring `server.port` parity key. The old `pyfly.web.port` /
+ `PYFLY_WEB_PORT` keys were removed in v26.6.102.
+ - The Actuator and Admin Dashboard run on a **separate management port**
+ (`pyfly.management.server.port`, default `9090`), open and
+ unauthenticated by default. We cover this in detail when we wire up
+ health probes.
+
+ Each feature below is built the same way: a short "why", the code, then a
+ **Run it** checkpoint that shows the exact command and the output you
+ should see. Type the commands as you go — that is how the ideas stick.
+
---
## Plugins and extension points
@@ -20,6 +37,18 @@ The core framework is intentionally small. Optional features — formatters, aud
PyFly's `pyfly.plugins` module mirrors Spring's plugin registry: you declare an **extension point** (a named slot), **extensions** (concrete contributions), and bundle them into a **plugin** with a lifecycle.
+New jargon, in plain terms: an **extension point** is a labelled hole in your application — "anything that wants to be an audit sink plugs in here." An **extension** is one thing that fills the hole. A **plugin** is the package that ships one or more extensions together and can be started and stopped as a unit. If you have ever defined a Java interface and let callers register implementations of it, you already know the shape — the decorators below just make the wiring declarative.
+
+We will build the smallest useful example: a console audit sink that prints every event it is handed.
+
+**Step 1 — Declare the extension point.** This is the contract. Every audit sink must promise a `record(event)` method. The `id="audit-sinks"` string is the name other code uses to find every sink later.
+
+**Step 2 — Write a plugin that contributes one sink.** The `@plugin` decorator wraps a class with a mandatory `id` and `version`; the inner `@extension` class is the actual contribution. It inherits the extension-point interface (`AuditSink`) so the registry can confirm it honours the contract.
+
+**Step 3 — Give the plugin a lifecycle.** The `start` and `stop` methods are where you open and close resources (a file handle, a network connection). The plugin manager calls them for you.
+
+Here are all three steps in one file.
+
::: listing lumen/plugins/audit.py | Listing 18.1 — Declaring an audit-sink plugin
from pyfly.plugins import (
PluginManager,
@@ -82,6 +111,36 @@ asyncio.run(main())
`PluginManager.add()` inspects the class for nested `@extension_point` declarations, then registers each `@extension` contribution. `start_all()` invokes each plugin's `init` then `start` hooks in dependency order; `stop_all()` reverses the sequence, calling `stop` then `unload`. Circular dependencies raise `PluginResolutionError` before any code runs.
+!!! tip "Run it"
+ Save both listings under `src/lumen/plugins/` and run the runner module
+ directly:
+
+ ```bash
+ uv run python -m lumen.plugins.runner
+ ```
+
+ You should see the lifecycle hooks fire, the single event get recorded,
+ and the clean shutdown:
+
+ ```
+ ConsoleAuditPlugin started
+ [AUDIT] {'action': 'deposit', 'amount_minor': 100}
+ ConsoleAuditPlugin stopped
+ ```
+
+ The order is the whole point: `start` runs before any sink is used, your
+ code iterates the registered sinks, and `stop` runs last. If you add a
+ second plugin later, `start_all()` boots both in dependency order and
+ `stop_all()` shuts them down in reverse — you never manage that ordering
+ by hand.
+
+**What just happened.** You added a capability to the application — audit
+recording — without editing a single line of framework code. The framework
+discovered your plugin, validated that its extension honours the
+`AuditSink` contract, ran its lifecycle, and handed you the registered sinks
+to call. That is the core promise of a plugin system: open for extension,
+closed for modification.
+
| Method | Description |
|---|---|
| `await manager.add(cls)` | Scan and register a plugin class |
@@ -105,8 +164,14 @@ Most real-world services carry logic that belongs to the business, not the code:
PyFly's `pyfly.rule_engine` gives product owners a YAML dial they can turn without touching source code.
+A **rule engine**, in plain terms, is a small interpreter for "if this, then that" statements that live in data rather than code. You feed it a bag of facts (the *context*) and a set of rules; it checks each rule's condition against the facts and, for the ones that match, performs the listed actions — usually writing a flag back into the context. The win is that the *rules* are editable by non-programmers, while the *engine* that runs them is fixed and tested.
+
### Defining rules in YAML
+We will build a fraud-and-limit check in two steps: first write the rules as data, then wire a service that runs them.
+
+**Step 1 — Write the rules as YAML.** Each rule names a `when` condition and a list of `then` actions. Because the rules are plain data, a product owner can review them in a pull request and a config server can hot-swap them at runtime.
+
Rules live in a separate YAML file that any team member can review. The evaluator parses this file once at startup — or on each fetch if you hot-reload from a Config Server (see the next section).
::: listing lumen/rules/transaction_rules.yaml | Listing 18.3 — Fraud and daily-limit rules (amounts in minor units)
@@ -161,7 +226,7 @@ Each rule has a `when` condition and a list of `then` actions. Amounts are alway
Actions are `set` (write to a context path), `increment`, or `log`. Subclass `RuleEvaluator` and override `_execute_action` to add `call`, `calculate`, or any custom verb.
-### Evaluating rules in a service
+**Step 2 — Wire a service that loads and runs the rules.** The service parses the YAML once at construction, then exposes an `assess()` method that builds a context, runs the evaluator, and returns the flags the rules set.
::: listing lumen/rules/risk_service.py | Listing 18.4 — Evaluating rules against a transaction
from pathlib import Path
@@ -201,6 +266,29 @@ class RiskService:
**How it works.** `RuleSetLoader.from_yaml(text)` parses the YAML into an AST. `RuleSetEvaluator.evaluate(ruleset, ctx)` walks every rule in priority order, evaluates the `when` clause, and applies matching `then` actions — mutating `ctx` in place and returning a `list[EvaluationResult]`. The `flags` dict is the authoritative output: a downstream handler rejects, queues, or flags the transaction based on whatever keys are set. `amount` and `daily_total` are in minor units (cents) to match the Lumen domain.
+!!! tip "Run it"
+ Exercise the service from a Python REPL to see the rules fire. A
+ €6,000.00 transfer (`600000` minor units) trips both the high-value and
+ daily-limit thresholds:
+
+ ```bash
+ uv run python -c "
+ from lumen.rules.risk_service import RiskService
+ print(RiskService().assess(amount=600000, daily_total=600000, country='US'))
+ "
+ ```
+
+ Expected output — the flags the matching rules set, and nothing else:
+
+ ```python
+ {'high_value': True, 'limit_exceeded': True}
+ ```
+
+ Now drop the amount to `50000` (€500.00) with a clean country and you get
+ back an empty `{}` — no rule matched, so nothing was flagged. You changed
+ the *outcome* without touching any Python: that is the dial the YAML gives
+ your product owners.
+
::: figure art/figures/18-production.svg | Figure 18.1 — Rule evaluation at the service boundary. YAML rules are parsed once at startup; each transaction passes through the evaluator as a mutable context dict.
!!! tip "Hot-reload rules without redeployment"
@@ -214,6 +302,8 @@ class RiskService:
As Lumen grows to multiple services, each carries its own copy of database URLs, timeouts, and feature flags. The Config Server module removes that duplication: one service owns the truth; every other service fetches on startup.
+A **config server**, in plain terms, is a small HTTP service whose entire job is to hand out configuration. Instead of baking timeouts and URLs into each service's own `pyfly.yaml`, you store them in one place and every service asks for its bundle at boot. Change the value once, restart (or refresh) the consumers, and the whole fleet moves together.
+
### Running the server
Enable the server in `pyfly.yaml`:
@@ -266,6 +356,34 @@ async def seed() -> None:
asyncio.run(seed())
:::
+!!! tip "Run it"
+ With `pyfly.config-server.enabled: true` in `pyfly.yaml`, start the app
+ and curl the bundle you seeded. Remember: the config-server routes are
+ served on the **application** port (`8080`), not the management port.
+
+ ```bash
+ uv run pyfly run
+ # in another terminal:
+ curl http://localhost:8080/wallet/prod
+ ```
+
+ The response is Spring Cloud Config-shaped — your seeded keys arrive
+ inside a `propertySources` array:
+
+ ```json
+ {
+ "name": "wallet",
+ "profiles": ["prod"],
+ "propertySources": [
+ {"name": "wallet-prod", "source": {"db.url": "postgres://db:5432/lumen", "cache.ttl": 30}}
+ ]
+ }
+ ```
+
+ Because the shape matches Spring Cloud Config, an existing Spring Boot
+ service can point its `spring.config.import=configserver:` at this same
+ URL and consume it unchanged.
+
Client services fetch on startup with:
::: listing lumen/config/bootstrap.py | Listing 18.6 — Fetching remote config at startup
@@ -297,6 +415,8 @@ async def load_remote() -> dict:
Lumen's error messages and notifications currently live as Python string literals. When users speak different languages, that approach does not scale.
+**i18n** is shorthand for *internationalisation* (the 18 letters between the "i" and the "n"). In practice it means pulling every user-facing string out of your code into per-language **resource bundles**, then choosing the right bundle at runtime based on the caller's preferred language. Your code refers to a string by a stable key like `wallet.deposit_ok`; the framework looks up the translation.
+
Enable the i18n subsystem with a single flag:
```yaml
@@ -309,6 +429,8 @@ pyfly:
### Writing resource bundles
+**Step 1 — Write one bundle per language.** Files are named `messages_.yaml`. Keys are shared across languages; only the values change. The `{0}`, `{1}` markers are positional placeholders the framework fills in at render time.
+
```yaml
# i18n/messages_en.yaml
wallet:
@@ -325,6 +447,8 @@ wallet:
### Using MessageSource in a service
+**Step 2 — Inject `MessageSource` and resolve the locale from the request.** The service below reads the caller's `Accept-Language` header, picks the matching bundle, and renders the message with the runtime arguments.
+
::: listing lumen/i18n/notification_service.py | Listing 18.7 — Locale-aware notification service
from pyfly.container import service
from pyfly.i18n import AcceptHeaderLocaleResolver, MessageSource
@@ -359,6 +483,37 @@ class NotificationService:
`AcceptHeaderLocaleResolver` parses the `Accept-Language` header and returns the highest-`q` primary subtag. Use `FixedLocaleResolver` for single-language deployments or tests. Auto-configuration registers both when `pyfly.i18n.enabled: true`; inject either `MessageSource` (the port protocol) or the concrete `ResourceBundleMessageSource` — both work.
+!!! tip "Run it"
+ The fastest way to prove the lookup works is a test that resolves the
+ bundle directly — no HTTP server required. Save Listing 18.7a under
+ `tests/`, then run just this test:
+
+ ```bash
+ uv run --extra dev pytest tests/test_messages.py -q
+ ```
+
+ Expected:
+
+ ```
+ 1 passed in 0.05s
+ ```
+
+ Swap `locale="es"` for `locale="en"` and the assertion would need the
+ English string instead — same key, different bundle. That is the whole
+ point of i18n: your code never changes, only the resolved locale does.
+
+::: listing tests/test_messages.py | Listing 18.7a — Asserting a translated message
+from pyfly.i18n.adapters.resource_bundle import ResourceBundleMessageSource
+
+
+def test_deposit_message_in_spanish() -> None:
+ messages = ResourceBundleMessageSource(base_path="i18n/", default_locale="en")
+ text = messages.get_message(
+ "wallet.deposit_ok", args=(100, "w-001"), locale="es"
+ )
+ assert text == "Se depositaron 100 unidades menores en la billetera w-001."
+:::
+
!!! spring "Spring parity"
`MessageSource`, `ResourceBundleMessageSource`,
`AcceptHeaderLocaleResolver`, and `FixedLocaleResolver` are
@@ -372,6 +527,8 @@ class NotificationService:
The Lumen admin dashboard currently polls for balance changes. A WebSocket endpoint eliminates the poll — the server pushes an update the instant a deposit commits.
+A **WebSocket** is a two-way connection that stays open. A normal HTTP request is one-shot: the client asks, the server answers, the line closes. With a WebSocket the line stays up, so the *server* can send data whenever it likes — perfect for live updates. The URL scheme is `ws://` (or `wss://` over TLS) instead of `http://`.
+
::: listing lumen/web/balance_ws_controller.py | Listing 18.8 — Live balance feed via WebSocket
import asyncio
@@ -410,6 +567,29 @@ class BalanceFeedController:
**How it works.** `@websocket_mapping("/balance/{wallet_id}")` mounts the endpoint at `ws:///ws/balance/{wallet_id}`. The full path is the controller's `@request_mapping` base (`/ws`) concatenated with the decorator's path.
+!!! tip "Run it"
+ Start the app, then open the live feed with any WebSocket client. Using
+ `websocat` (`brew install websocat`):
+
+ ```bash
+ uv run pyfly run
+ # in another terminal:
+ websocat ws://localhost:8080/ws/balance/w-001
+ ```
+
+ You should see a JSON frame arrive roughly once a second, pushed by the
+ server without you asking again:
+
+ ```json
+ {"wallet_id": "w-001", "balance_minor": 5000}
+ {"wallet_id": "w-001", "balance_minor": 5000}
+ ```
+
+ Deposit into `w-001` from another terminal and watch the `balance_minor`
+ value jump on the next frame — no polling, no refresh. Press `Ctrl+C` to
+ close; the controller's `on_disconnect` runs and the session is dropped
+ from the broadcast set.
+
`WebSocketSession` exposes the connection lifecycle:
| Method | Description |
@@ -437,6 +617,8 @@ The optional `on_disconnect` method is invoked automatically by the registrar af
Not every feature lives behind an HTTP endpoint. Database seed scripts, one-time data migrations, and scheduled batch jobs are better expressed as CLI commands that run inside the same DI container — sharing services, configuration, and repositories with the main application.
+The key idea here: a **shell command** is just a method that runs from the terminal but still has full access to your application's wired services. You do not write a separate script that re-creates the database connection by hand — the command receives the same `WalletService` your HTTP controllers use, because it lives inside the same DI container.
+
### @shell_component and @shell_method
::: listing lumen/cli/wallet_commands.py | Listing 18.9 — DI-powered shell commands
@@ -487,6 +669,25 @@ python -m lumen wallet balance w-001
python -m lumen # no args → drops into REPL mode
```
+!!! tip "Run it"
+ Deposit 500 minor units into a wallet straight from the command line —
+ the command runs your real `WalletService` against the real database:
+
+ ```bash
+ uv run python -m lumen wallet deposit w-001 --amount 500
+ ```
+
+ Expected output (the return value of your `deposit` method, printed by
+ the shell adapter):
+
+ ```
+ New balance: 500 minor units
+ ```
+
+ Run `uv run python -m lumen wallet balance w-001` and you will see the
+ same `500` reflected back — proof that the command and the HTTP API are
+ talking to the same persistent state, not a throwaway in-memory copy.
+
### CommandLineRunner — one-shot post-startup tasks
For tasks that run once at startup — seeding, warm-up, connection checks — implement **`CommandLineRunner`**:
@@ -511,6 +712,27 @@ class SeedRunner(CommandLineRunner):
Any bean whose class implements `async def run(self, args: list[str]) -> None` structurally satisfies the `CommandLineRunner` protocol. The framework detects it via `isinstance()` (the protocol is `@runtime_checkable`) after `ApplicationReadyEvent` fires, then invokes it with the raw CLI arguments. Use `@order(n)` to control execution order when multiple runners coexist.
+!!! tip "Run it"
+ The runner fires during application startup and receives the process's
+ `sys.argv[1:]`, so launch the app through its CLI entry point with the
+ flag appended:
+
+ ```bash
+ uv run python -m lumen --seed
+ ```
+
+ After the banner and the route table, you will see the runner's
+ confirmation line:
+
+ ```
+ Default wallet ensured.
+ ```
+
+ Boot without `--seed` and the line is absent — the `if "--seed" in args`
+ guard skips the work. That is the difference between a *runner* (runs
+ every boot, you gate it yourself) and a one-off shell command (runs only
+ when you invoke its name).
+
!!! spring "Spring parity"
`@shell_component`, `@shell_method`, `@shell_option`,
`@shell_argument`, and `CommandLineRunner` are direct
@@ -526,13 +748,19 @@ Any bean whose class implements `async def run(self, args: list[str]) -> None` s
When Lumen exposes an HTTP API, downstream services should call it via a generated client — not hand-written `httpx` calls that drift out of sync. PyFly builds and serves an OpenAPI 3.1 spec automatically at `/openapi.json`.
+An **OpenAPI spec** is a machine-readable description of your HTTP API: every path, every parameter, every request and response shape. An **SDK** (software development kit) is the client library a tool generates *from* that spec — typed methods that wrap the HTTP calls for you. The chain is: your controllers → the spec → a generated client. Because each link is mechanical, the client can never silently drift out of sync with the server.
+
`OpenAPIGenerator` assembles the spec from route metadata collected by `ControllerRegistrar`:
- **Info** — populated from `title`, `version`, and `description` passed to `create_app()`.
- **Paths** — one operation per `@get_mapping` / `@post_mapping` etc., with parameters inferred from `PathVar[T]`, `QueryParam[T]`, `Header[T]`, and `Body[BaseModel]` type hints.
- **Schemas** — Pydantic models registered in `components.schemas` via `model_json_schema()` and referenced with `$ref`.
-With the spec available, generate a Python client in one command:
+With the spec available, generate a Python client in two steps — download the spec, then run the generator.
+
+**Step 1 — Download the spec from a running instance.** The spec lives on the application port (`8080`), alongside your business routes.
+
+**Step 2 — Run the OpenAPI generator** to turn that JSON into an installable Python package.
```bash
# Download the spec from a running instance
@@ -577,7 +805,9 @@ class WalletGateway:
### Packaging with Docker
-`pyfly new` generates a `Dockerfile` for every archetype. For a web service it looks like this after production hardening:
+**Containerising**, in plain terms, means freezing your app and everything it needs to run — Python, dependencies, your code, your config — into a single image that runs identically on your laptop, in CI, and in production. A `Dockerfile` is the recipe for building that image.
+
+`pyfly new` generates a `Dockerfile` for every archetype. For a web service it looks like this after production hardening. Read it top to bottom — each line is one step in the build:
```dockerfile
FROM python:3.12-slim
@@ -591,13 +821,35 @@ RUN pip install uv && \
COPY src/ src/
COPY pyfly.yaml .
-EXPOSE 8080
+# 8080 = application traffic; 9090 = the management port (actuator + admin).
+EXPOSE 8080 9090
CMD ["pyfly", "run", "--host", "0.0.0.0", \
"--port", "8080", "--server", "granian", "--workers", "2"]
```
+Walking the recipe: `FROM` picks a small Python base image; `COPY` + `uv sync` install exactly the dependency extras you name (and nothing else); the second `COPY` adds your source and config; `EXPOSE` documents the two ports the container listens on; `CMD` is the command that runs when the container starts. The `--port 8080` flag here sets `pyfly.server.port` for this process — the management port stays at its `9090` default unless you override `pyfly.management.server.port`.
+
Install only the extras your service actually uses — the `full` meta-extra pulls in Kafka, RabbitMQ, and MongoDB drivers even when you need none of them.
+!!! tip "Run it"
+ Build the image and run it, mapping both ports out of the container:
+
+ ```bash
+ docker build -t lumen:local .
+ docker run --rm -p 8080:8080 -p 9090:9090 lumen:local
+ ```
+
+ Then confirm both listeners answer — the business port on `8080` and the
+ management port on `9090`:
+
+ ```bash
+ curl http://localhost:8080/openapi.json # app: returns the spec
+ curl http://localhost:9090/actuator/health # management: {"status":"UP"}
+ ```
+
+ Two ports, one process. That separation is what the rest of this section
+ builds on.
+
### Environment variables and secrets
Never bake secrets into `pyfly.yaml`. PyFly resolves `${ENV_VAR}` placeholders anywhere in configuration:
@@ -654,18 +906,48 @@ pyfly:
graceful-timeout: 30
```
+### The two-port deploy
+
+This is the deployment fact you most need to internalise for v26.6.110. PyFly runs on **two ports**, both inside one process:
+
+- the **application port** — `pyfly.server.port`, default `8080` — serves your business API, your WebSocket feeds, the OpenAPI spec, and the config-server routes;
+- the **management port** — `pyfly.management.server.port`, default `9090` — serves the Actuator (`/actuator/*`) and the Admin Dashboard (`/admin`).
+
+The management port is a second in-process listener, not extra worker processes, so it costs almost nothing. Two tuning options matter at deploy time: set `pyfly.management.server.port` **equal** to `pyfly.server.port` to collapse everything onto one port, or set it to **`-1`** to disable the management web endpoints entirely. The env override is `PYFLY_MANAGEMENT_SERVER_PORT`.
+
+Why split them? Because it lets you expose only `8080` to the internet while keeping health checks, Prometheus scrapes, and the admin console on `9090`, reachable only from inside your cluster.
+
+!!! warning "The management port is OPEN by default"
+ As of v26.6.110 the management port is **unauthenticated by default**
+ (Spring Boot's `management.server.port` model): anything that can reach
+ `9090` can read `/actuator/*` and `/admin`. That is intentional — the
+ port is meant to sit behind network isolation, never on the public
+ internet. If you cannot guarantee that isolation, apply the app's
+ security filters to the management port too:
+
+ ```yaml
+ pyfly:
+ management:
+ security:
+ enabled: true
+ ```
+
+ With that flag set, the same authentication, role guards, and CSRF rules
+ that protect your business API also gate `9090`.
+
### Health endpoints
-PyFly exposes Spring Boot-style actuator endpoints out of the box:
+PyFly exposes Spring Boot-style actuator endpoints out of the box. They live on the **management port** (`9090`):
| Endpoint | Purpose |
|---|---|
| `GET /actuator/health` | Aggregate health (UP / DOWN) |
| `GET /actuator/health/liveness` | Kubernetes liveness probe |
| `GET /actuator/health/readiness` | Kubernetes readiness probe |
-| `GET /actuator/metrics` | Prometheus-compatible metrics |
+| `GET /actuator/metrics` | Per-metric drill-down (e.g. `/actuator/metrics/http.server.requests`) |
+| `GET /actuator/prometheus` | Prometheus scrape endpoint |
-Configure them in `pyfly.yaml`:
+Only `health` and `info` are exposed over HTTP by default (Spring Boot's secure default). To publish metrics and the Prometheus scrape endpoint, add them to the exposure list in `pyfly.yaml`:
```yaml
pyfly:
@@ -673,29 +955,70 @@ pyfly:
endpoints:
web:
exposure:
- include: health,metrics
+ include: health,info,metrics,prometheus
endpoint:
health:
show-details: when-authorized
```
-Then wire your Kubernetes deployment:
+!!! tip "Run it"
+ Hit the probes on the management port — they answer immediately because
+ that port is open by default:
+
+ ```bash
+ curl http://localhost:9090/actuator/health
+ curl http://localhost:9090/actuator/health/readiness
+ ```
+
+ Expected — a JSON status object the orchestrator can parse:
+
+ ```json
+ {"status": "UP"}
+ ```
+
+ A readiness check that fails (a database still warming up, say) returns
+ `{"status": "DOWN"}` with HTTP `503`, which is exactly what Kubernetes
+ needs to hold traffic back until the pod is ready.
+
+Then wire your Kubernetes deployment — note every probe and scrape points at the **management port** `9090`, not `8080`:
```yaml
livenessProbe:
httpGet:
path: /actuator/health/liveness
- port: 8080
+ port: 9090
initialDelaySeconds: 10
periodSeconds: 15
readinessProbe:
httpGet:
path: /actuator/health/readiness
- port: 8080
+ port: 9090
initialDelaySeconds: 5
periodSeconds: 10
```
+!!! spring "Spring parity"
+ The two-port split is a direct port of Spring Boot's
+ `management.server.port`: app traffic on `server.port`, actuator and
+ management UI on a dedicated `management.server.port`. The open-by-default
+ posture, the `-1`-to-disable convention, and `management.security.enabled`
+ to lock it down all mirror Spring Boot's behaviour.
+
+!!! note "Custom probes from the live HealthAggregator"
+ New in v26.6.110, the live `HealthAggregator` is reachable at
+ `app.state.pyfly_health_aggregator` (Starlette adapter only). Register an
+ extra readiness indicator after `create_app()` — for example a check that
+ pings a downstream service — and it shows up on `/actuator/health`
+ regardless of whether the actuator runs on the shared or the separate
+ management port.
+
+**What just happened.** You now have the full shape of a PyFly deployment:
+one container, two ports (`8080` for users, `9090` for ops), secrets injected
+as environment variables, a Granian server tuned with explicit workers, a
+graceful-shutdown window that drains in-flight requests, and Kubernetes
+probes wired to the management port. The checklist below is the same picture
+as a list you can paste into a pull-request template.
+
### The production checklist
- [ ] All secrets are environment variables — none are in `pyfly.yaml`
@@ -705,9 +1028,14 @@ readinessProbe:
explicitly (not left at `1` for multi-core machines).
- [ ] `graceful-timeout` is at least 15 s; Kubernetes
`terminationGracePeriodSeconds` is at least 5 s more.
-- [ ] Liveness and readiness probes are configured and tested.
-- [ ] `/actuator/health` returns UP before traffic is sent.
-- [ ] Prometheus metrics endpoint is scraped by the monitoring stack.
+- [ ] Liveness and readiness probes point at the **management port**
+ (`9090`), not the application port, and are tested.
+- [ ] `/actuator/health` (on `9090`) returns UP before traffic is sent.
+- [ ] The management port is network-isolated, or
+ `pyfly.management.security.enabled: true` is set — it is open by
+ default.
+- [ ] The Prometheus scrape endpoint (`/actuator/prometheus` on `9090`) is
+ added to the exposure list and scraped by the monitoring stack.
- [ ] Structured logging is enabled (`pyfly[observability]`) and
log level is `INFO` in production, not `DEBUG`.
- [ ] OpenTelemetry exporter is pointed at the production collector.
@@ -735,7 +1063,7 @@ Seventeen chapters ago you typed `@pyfly_application` and watched the DI contain
**Chapters 16–17** closed the feedback loop. A structured test suite — unit, integration, and Testcontainers-backed persistence tests — made the platform safe to change. Scheduled tasks, push notifications, webhooks, and callbacks let Lumen reach out to the world on its own schedule.
-**Chapter 18** showed you what lies beyond the core: a plugin system for open extension, a YAML rule engine for business-owned logic, a Config Server for fleet-wide configuration, i18n for global audiences, WebSocket for real-time UX, a Shell module for operational tooling, an OpenAPI spec that generates typed client SDKs automatically, and the production habits that keep all of it running.
+**Chapter 18** showed you what lies beyond the core: a plugin system for open extension, a YAML rule engine for business-owned logic, a Config Server for fleet-wide configuration, i18n for global audiences, WebSocket for real-time UX, a Shell module for operational tooling, an OpenAPI spec that generates typed client SDKs automatically, and the production habits that keep all of it running — packaged as one container that listens on two ports, `8080` for users and `9090` for operations.
PyFly is not magic. Every abstraction in this book has a cost, and you now understand what that cost is: a DI container that starts in milliseconds but requires you to think about bean scopes; an async HTTP server that handles thousands of concurrent connections but requires you to avoid blocking calls; a saga engine that survives partial failures but requires you to write compensating transactions.
diff --git a/pyproject.toml b/pyproject.toml
index 63adea62..e23d62ab 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -7,7 +7,7 @@ name = "pyfly"
# CalVer YY.MM.PATCH — package metadata uses PEP 440 normalized form (26.5.4);
# git tag, GitHub release and human-readable display use leading-zero form
# (v26.05.04) to match the Java/.NET/Go siblings.
-version = "26.6.110"
+version = "26.6.111"
description = "The official Python implementation of the Firefly Framework — DI, CQRS, EDA, hexagonal architecture, and more."
readme = "README.md"
license = "Apache-2.0"
diff --git a/src/pyfly/__init__.py b/src/pyfly/__init__.py
index 127f2b52..82f07d35 100644
--- a/src/pyfly/__init__.py
+++ b/src/pyfly/__init__.py
@@ -13,4 +13,4 @@
# limitations under the License.
"""PyFly — Enterprise Python Framework."""
-__version__ = "26.06.110"
+__version__ = "26.06.111"
diff --git a/src/pyfly/cli/templates/pyfly.yaml.j2 b/src/pyfly/cli/templates/pyfly.yaml.j2
index 7568bc49..c4f67207 100644
--- a/src/pyfly/cli/templates/pyfly.yaml.j2
+++ b/src/pyfly/cli/templates/pyfly.yaml.j2
@@ -8,7 +8,6 @@ pyfly:
{% if has_web or has_fastapi or archetype in ("web-api", "fastapi-api", "web", "hexagonal", "core") %}
web:
- port: 8080
{% if has_fastapi or archetype == "fastapi-api" %}
adapter: fastapi
{% else %}
@@ -19,6 +18,9 @@ pyfly:
{% if has_web or has_fastapi %}
server:
type: "auto"
+ # The application HTTP port (Spring `server.port` parity). The legacy
+ # `pyfly.web.port` was removed in v26.06.102; use `pyfly.server.port`.
+ port: 8080
event-loop: "auto"
workers: 0
{% if has_granian %}