[go: up one dir, main page]

100% encontró este documento útil (2 votos)
1K vistas304 páginas

1.architecture-Patterns-With-Python Spanish

El documento presenta patrones de arquitectura utilizando Python, enfocándose en el desarrollo basado en pruebas, diseño impulsado por el dominio y microservicios basados en eventos. Los autores, Harry Percival y Bob Gregory, ofrecen un enfoque práctico para implementar estos patrones en aplicaciones reales, incluyendo la creación de una arquitectura adecuada para el modelado de dominios y la integración de eventos. Se cubren temas como el patrón de repositorio, la unidad de trabajo y la segregación de responsabilidades en arquitecturas impulsadas por eventos.
Derechos de autor
© © All Rights Reserved
Nos tomamos en serio los derechos de los contenidos. Si sospechas que se trata de tu contenido, reclámalo aquí.
Formatos disponibles
Descarga como PDF, TXT o lee en línea desde Scribd
100% encontró este documento útil (2 votos)
1K vistas304 páginas

1.architecture-Patterns-With-Python Spanish

El documento presenta patrones de arquitectura utilizando Python, enfocándose en el desarrollo basado en pruebas, diseño impulsado por el dominio y microservicios basados en eventos. Los autores, Harry Percival y Bob Gregory, ofrecen un enfoque práctico para implementar estos patrones en aplicaciones reales, incluyendo la creación de una arquitectura adecuada para el modelado de dominios y la integración de eventos. Se cubren temas como el patrón de repositorio, la unidad de trabajo y la segregación de responsabilidades en arquitecturas impulsadas por eventos.
Derechos de autor
© © All Rights Reserved
Nos tomamos en serio los derechos de los contenidos. Si sospechas que se trata de tu contenido, reclámalo aquí.
Formatos disponibles
Descarga como PDF, TXT o lee en línea desde Scribd
Está en la página 1/ 304

Machine Translated by Google

Arquitectura
Patrones
con Python
Habilitación del desarrollo basado en pruebas,
Diseño basado en dominios y basado en eventos
microservicios

Harry JW Percival
y Bob Gregory
Machine Translated by Google
Machine Translated by Google

Patrones de arquitectura con Python


Habilitación del desarrollo basado en pruebas,
Diseño impulsado por el dominio, y
Microservicios basados en eventos

Harry Percival y Bob Gregory

Pekín Boston Farnham Sebastopol Tokio


Machine Translated by Google

Patrones de arquitectura con


Python por Harry Percival y Bob Gregory

Copyright © 2020 Harry Percival y Bob Gregory. Reservados todos los derechos.

Impreso en los Estados Unidos de América.

Publicado por O'Reilly Media, Inc., 1005 Gravenstein Highway North, Sebastopol, CA 95472.

Los libros de O'Reilly se pueden comprar con fines educativos, comerciales o de promoción de ventas. Las ediciones en línea también están
disponibles para la mayoría de los títulos (http://oreilly.com). Para obtener más información, comuníquese con nuestro departamento de ventas
corporativo/institucional: 800-998-9938 o corporate@oreilly.com.

Editor de adquisiciones: Ryan Shaw Indexador: Ellen Troutman-Zaig


Editor de desarrollo: Corbin Collins Diseñador de interiores: David Futato
Editora de producción: Katherine Tozer Diseño de portada: Karen Montgomery
Correctora: Sharon Wilkey Ilustrador: Rebecca Demarest

Corrector: Arthur Johnson

Marzo 2020: Primera edición

Historial de revisiones de la primera edición


2020-03-05: Primer lanzamiento

Consulte http://oreilly.com/catalog/errata.csp?isbn=9781492052203 para conocer los detalles de la versión.

El logotipo de O'Reilly es una marca comercial registrada de O'Reilly Media, Inc. Los patrones de arquitectura con Python, la imagen de portada
y la imagen comercial relacionada son marcas comerciales de O'Reilly Media, Inc.

Las opiniones expresadas en este trabajo son las de los autores y no representan las opiniones del editor.
Si bien el editor y los autores se han esforzado de buena fe para garantizar que la información y las instrucciones contenidas en este trabajo
sean precisas, el editor y los autores renuncian a toda responsabilidad por errores u omisiones, incluida, entre otras, la responsabilidad por
daños resultantes del uso o confianza en este trabajo. El uso de la información e instrucciones contenidas en este trabajo es bajo su propio
riesgo. Si alguna muestra de código u otra tecnología que este trabajo contiene o describe está sujeta a licencias de código abierto o a los
derechos de propiedad intelectual de otros, es su responsabilidad asegurarse de que su uso cumpla con dichas licencias y/o derechos.

978-1-492-05220-3

[LSI]
Machine Translated by Google

Tabla de contenido

Prefacio. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . ix

Introducción. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . xvii

Parte I. Creación de una arquitectura para admitir el modelado de dominios

1. Modelado de dominio. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
¿Qué es un modelo de dominio? 56
Exploración del dominio Lenguaje Unidad de
prueba Modelos de dominio Las clases de 9 10

datos son excelentes para objetos de valor Objetos y 15

entidades de valor No todo tiene que ser un objeto: 17

una función de servicio de dominio 19

Los métodos mágicos de Python Permítanos usar nuestros modelos con Python idiomático 20

Las excepciones también pueden expresar conceptos de dominio 20

2. Patrón de repositorio. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 23
Persistiendo nuestro modelo de dominio 24

Algo de pseudocódigo: ¿Qué vamos a necesitar? 24

Aplicación del DIP al recordatorio de acceso 25


a datos: nuestro modelo 26

La forma ORM "normal": el modelo depende del ORM 27

Invertir la dependencia: ORM depende del modelo 28

Introducción al patrón de repositorio El repositorio 31

en abstracto ¿Cuál es la compensación? 32


33

¡Crear un repositorio falso para pruebas ahora es trivial! 37

iii
Machine Translated by Google

¿Qué es un puerto y qué es un adaptador en Python? 37

Envolver 38

3. Un breve interludio: sobre el acoplamiento y las abstracciones. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 41


Resumen de la capacidad de prueba de las ayudas estatales Elección de la(s) abstracción(es) correcta(s) 43
46

Implementando nuestras abstracciones elegidas 47

Probando de extremo a extremo con falsificaciones e inyección de dependencia 49

¿Por qué no simplemente parchearlo? 51

Envolver 53

4. Nuestro primer caso de uso: Flask API y capa de servicio. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 55

Conectando nuestra aplicación al mundo real Una primera 57


prueba de extremo a extremo Las condiciones de error de 57

implementación sencillas que requieren comprobaciones de 58

la base de datos Introducción de una capa de servicio y uso de 60

FakeRepository para realizar pruebas unitarias Una función de servicio típica ¿Por qué todo 61

se denomina servicio? 63
66

Poner las cosas en carpetas para ver dónde pertenece todo Resumen 67

El DIP en acción 68
68

5. TDD en High Gear y Low Gear. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 71 ¿Cómo se ve nuestra


pirámide de prueba? 72

¿Deberían trasladarse las pruebas de la capa de dominio a la capa de servicio? 72

Sobre la decisión de qué tipo de pruebas escribir en 73

marcha alta y baja Desacoplamiento completo de las 74

pruebas de la capa de servicio del dominio 75

Mitigación: mantener todas las dependencias de dominio en funciones de accesorios 76

Agregar un servicio faltante 76

Llevar la mejora a través de las pruebas E2E 78

Envolver 79

6. Patrón de Unidad de Trabajo. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 81 La unidad de

trabajo colabora con el repositorio Realización de pruebas de una UoW con la unidad de trabajo de pruebas de 83
integración y su administrador de contexto La unidad de trabajo real utiliza SQLAlchemy Sessions Unidad de 84
trabajo falsa para las pruebas Uso de la UoW en la capa de servicio Pruebas explícitas para compromiso/ 85
Comportamiento de reversión 86
87
88
89

IV | Tabla de contenido
Machine Translated by Google

Confirmaciones explícitas versus implícitas 90

Ejemplos: uso de UoW para agrupar múltiples operaciones en una unidad atómica 91

Ejemplo 1: reasignar 91

Ejemplo 2: cambiar la cantidad de lote 91

Poner en orden las pruebas de integración 92

Envolver 93

7. Agregados y límites de consistencia. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 95


¿Por qué no simplemente ejecutar todo en una hoja de cálculo? 96

Invariantes, restricciones y consistencia Invariantes, 96

concurrencia y bloqueos ¿Qué es un agregado? 97


98

Elegir un agregado Un 99

agregado = un repositorio ¿Qué pasa con el 102


rendimiento? 104

Simultaneidad optimista con números de versión Opciones de 105

implementación para números de versión Pruebas de nuestras 107

reglas de integridad de datos Aplicación de reglas de 109

simultaneidad mediante transacciones de base de datos


Niveles de aislamiento 110

Ejemplo de control de concurrencia pesimista: SELECCIONAR PARA ACTUALIZAR 111

Envolver 111

Resumen de la parte I 113

Parte II. Arquitectura impulsada por eventos

8. Eventos y el Bus de Mensajes. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .117


Evitar hacer un desastre 118

En primer lugar, ¡evitemos ensuciar nuestros controladores web y no 118


ensuciar nuestro modelo ni la capa de servicio! 119
120

Principio de responsabilidad única 120

¡Todos a bordo del autobús de mensajes! 121


El modelo registra eventos Los 121

eventos son clases de datos simples El 121


modelo genera eventos El bus de 122

mensajes asigna eventos a los controladores 123

Opción 1: la capa de servicio toma eventos del modelo y los coloca en el bus de mensajes
124

Opción 2: la capa de servicio genera sus propios eventos 125

Opción 3: la UoW publica eventos en el bus de mensajes 126

Tabla de contenido | v
Machine Translated by Google

Envolver 130

9. Ir a la ciudad en el autobús de mensajes. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 133 Un


nuevo requisito nos lleva a una nueva arquitectura 135

Imaginar un cambio de arquitectura: todo será un controlador de eventos 136 137


Refactorización de funciones de servicio a controladores de mensajes
El bus de mensajes ahora recopila eventos de la UoW 139
Nuestras pruebas también están escritas en términos de eventos 141

Un feo truco temporal: el bus de mensajes tiene que devolver resultados 141

Modificando nuestra API para trabajar con eventos 142

Implementando nuestro nuevo requisito 143


Nuestro nuevo evento 143

Prueba de conducción de un nuevo controlador 144

Implementación 145
Un nuevo método en el modelo de dominio 146

Opcionalmente: Controladores de eventos de pruebas unitarias aislados con un mensaje falso


Autobús 147

Resumen 149
¿Qué hemos logrado? 150

¿Por qué lo hemos logrado? 150

10. Comandos y controlador de comandos. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 151


Comandos y eventos Diferencias en el manejo de excepciones Discusión: eventos, comandos y 151
manejo de errores Recuperación de errores sincrónicamente Resumen 153
155
158
160

11. Arquitectura impulsada por eventos: uso de eventos para integrar microservicios. . . . . . . . . . . . . .
161 Bola de barro distribuida y pensamiento en sustantivos Manejo de errores en sistemas 162
distribuidos La alternativa: desacoplamiento temporal usando mensajería asíncrona usando un 165
canal Pub/Sub de Redis para la integración Probarlo todo usando una prueba de extremo a extremo
167
Redis es otro adaptador delgado Alrededor de nuestro autobús de mensajes Nuestro nuevo evento
168
saliente Eventos internos frente a eventos externos 169
170
171
172

Envolver 172

12. Segregación de responsabilidad de consulta de comando (CQRS). . . . . . . . . . . . . . . . . . . . . . . . . . .


175 modelos de dominio son para escribir La mayoría de los usuarios no van a comprar sus muebles
176
177

nosotros
| Tabla de contenido
Machine Translated by Google

Publicar/Redireccionar/Obtener y CQS 179


Aférrense a su almuerzo, amigos 181

Prueba de vistas CQRS 182

Alternativa "obvia" 1: usar el repositorio existente 182


Su modelo de dominio no está optimizado para operaciones de lectura 183

Alternativa "Obvia" 2: Usar el ORM 184


SELECT N+1 y otras consideraciones de rendimiento 184

Es hora de saltar por completo al tiburón 185

Actualización de una tabla de modelo de lectura mediante un controlador de eventos 186

Cambiar la implementación de nuestro modelo de lectura es fácil 188


Envolver 189

13. Inyección de dependencia (y Bootstrapping). . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 191


193
Dependencias implícitas versus explícitas ¿No son las dependencias explícitas totalmente extrañas
y Java-y? 194

Preparación de controladores: DI manual con cierres y parciales Una 196

alternativa Uso de clases Se proporciona un bus de mensajes de 197


secuencia de comandos Bootstrap Controladores en tiempo de ejecución 199

Uso de Bootstrap en nuestros puntos de entrada Inicialización de DI en 201

nuestras pruebas Construcción de un adaptador "correctamente": un 203

ejemplo resuelto Definir las implementaciones abstractas y concretas 204

Hacer una versión falsa para sus pruebas Averigüe cómo la integración 205
Probar lo real Resumen 206
206
207
209

Epílogo. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 211

A. Diagrama y tabla de resumen. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 229

B. Una estructura de proyecto de plantilla. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 231

C. Intercambiar la infraestructura: hacer todo con CSV. . . . . . . . . . . . . . . . . . . . . . . 239

D. Repositorio y Unidad de Patrones de Trabajo con Django. . . . . . . . . . . . . . . . . . . . . . . . . . . . . 245

E. Validación. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 255

Índice. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 265

Tabla de contenido | viii


Machine Translated by Google
Machine Translated by Google

Prefacio

Quizás se pregunte quiénes somos y por qué escribimos este libro.

Al final del último libro de Harry, Test-Driven Development with Python (O'Reilly), se encontró
haciendo un montón de preguntas sobre arquitectura, como: ¿Cuál es la mejor manera de estructurar
su aplicación para que sea fácil de probar? Más específicamente, ¿de modo que su lógica
empresarial central esté cubierta por pruebas unitarias y para que minimice la cantidad de pruebas
de integración y de extremo a extremo que necesita? Hizo referencias vagas a "Arquitectura
hexagonal", "Puertos y adaptadores" y "Núcleo funcional, carcasa imperativa", pero si fuera honesto,
tendría que admitir que no eran cosas que realmente entendiera o que hubiera hecho. en la práctica.

Y luego tuvo la suerte de encontrarse con Bob, que tiene las respuestas a todas estas preguntas.

Bob terminó siendo un arquitecto de software porque nadie más en su equipo lo estaba haciendo.
Resultó ser bastante malo en eso, pero tuvo la suerte de encontrarse con Ian Cooper, quien le
enseñó nuevas formas de escribir y pensar en el código.

Gestión de la complejidad, resolución de problemas empresariales

Ambos trabajamos para MADE.com, una empresa europea de comercio electrónico que vende
muebles en línea; allí, aplicamos las técnicas de este libro para construir sistemas distribuidos que
modelan problemas de negocios del mundo real. Nuestro dominio de ejemplo es el primer sistema
que Bob creó para MADE, y este libro es un intento de escribir todo lo que tenemos que enseñar a
los nuevos programadores cuando se unen a uno de nuestros equipos.

MADE.com opera una cadena de suministro global de socios y fabricantes de transporte. Para
mantener los costos bajos, tratamos de optimizar la entrega de existencias a nuestros almacenes
para que no tengamos productos sin vender tirados por el lugar.

Lo ideal es que el sofá que quieres comprar llegue a puerto el mismo día que decidas comprarlo, y
te lo enviamos directamente a tu casa sin tener que guardarlo nunca.

ix
Machine Translated by Google

Obtener el momento adecuado es un acto de equilibrio complicado cuando los productos tardan tres
meses en llegar por barco de contenedores. En el camino, las cosas se rompen o se dañan por el agua,
las tormentas causan retrasos inesperados, los socios logísticos manejan mal los productos, se pierde
papeleo, los clientes cambian de opinión y modifican sus pedidos, etc.

Resolvemos esos problemas mediante la creación de software inteligente que representa los tipos de
operaciones que tienen lugar en el mundo real para que podamos automatizar la mayor parte del negocio
posible.

¿Por qué Python?

Si está leyendo este libro, probablemente no necesitemos convencerlo de que Python es excelente, por
lo que la verdadera pregunta es "¿Por qué la comunidad de Python necesita un libro como este?" La
respuesta tiene que ver con la popularidad y la madurez de Python: aunque Python es probablemente el
lenguaje de programación de más rápido crecimiento en el mundo y se está acercando a la cima de las
tablas de popularidad absoluta, apenas está comenzando a asumir el tipo de problemas que ha tenido el
mundo de C# y Java. trabajando durante años. Las startups se convierten en negocios reales; Las
aplicaciones web y las automatizaciones con secuencias de comandos se están convirtiendo (susúrralo)
en software empresarial.

En el mundo de Python, a menudo citamos el Zen de Python: "Debe haber una, y preferiblemente solo
una, forma obvia de hacerlo". 1 Desafortunadamente, a medida que crece el tamaño del proyecto, la
forma más obvia de hacer las cosas no siempre es la forma que le ayuda a gestionar la complejidad y los
requisitos cambiantes.

Ninguna de las técnicas y patrones que discutimos en este libro son nuevos, pero en su mayoría son
nuevos en el mundo de Python. Y este libro no es un reemplazo para los clásicos en el campo, como el
Diseño impulsado por el dominio de Eric Evans o los Patrones de arquitectura de aplicaciones
empresariales de Martin Fowler (ambos publicados por Addison-Wesley Professional), a los que nos
referimos con frecuencia y lo alentamos a leer. y leer.

Pero todos los ejemplos de código clásico en la literatura tienden a estar escritos en Java o C++/#, y si
eres una persona de Python y no has usado ninguno de esos lenguajes en mucho tiempo (o nunca), ese
código las listas pueden ser bastante... difíciles. Hay una razón por la cual la última edición de ese otro
texto clásico, Refactorización de Fowler (Addison-Wesley Professional), está en JavaScript.

1 python -c "importar esto"

x | Prefacio
Machine Translated by Google

Arquitectura impulsada por eventos y TDD, DDD


En orden de notoriedad, conocemos tres herramientas para gestionar la complejidad:

1. El desarrollo basado en pruebas (TDD) nos ayuda a crear un código correcto y nos permite refactorizar o
agregar nuevas funciones, sin temor a la regresión. Pero puede ser difícil obtener lo mejor de nuestras
pruebas: ¿Cómo nos aseguramos de que se ejecuten lo más rápido posible? ¿Que obtengamos tanta
cobertura y retroalimentación de pruebas unitarias rápidas y libres de dependencias y tengamos la cantidad
mínima de pruebas de extremo a extremo más lentas y escamosas?

2. El diseño basado en el dominio (DDD) nos pide que centremos nuestros esfuerzos en crear un buen modelo
del dominio comercial, pero ¿cómo nos aseguramos de que nuestros modelos no estén sobrecargados con
problemas de infraestructura y no se vuelvan difíciles de cambiar?

3. Los (micro)servicios débilmente acoplados integrados a través de mensajes (a veces llamados microservicios
reactivos) son una respuesta bien establecida para administrar la complejidad en múltiples aplicaciones o
dominios comerciales. Pero no siempre es obvio cómo hacer que encajen con las herramientas establecidas
del mundo de Python: Flask, Django, Celery, etc.

No se desanime si no está trabajando con (o no está interesado en) los


microservicios. La gran mayoría de los patrones que discutimos, incluida
gran parte del material de la arquitectura impulsada por eventos, es
absolutamente aplicable en una arquitectura monolítica.

Nuestro objetivo con este libro es presentar varios patrones arquitectónicos clásicos y mostrar cómo admiten
TDD, DDD y servicios basados en eventos. Esperamos que sirva como referencia para implementarlos de forma
pitónica, y que la gente pueda usarlo como un primer paso para futuras investigaciones en este campo.

Quién debería leer este libro

Aquí hay algunas cosas que asumimos sobre usted, querido lector:

• Ha estado cerca de algunas aplicaciones de Python razonablemente complejas. • Ha visto

algo del dolor que conlleva tratar de manejar esa complejidad. • No necesariamente sabes nada sobre DDD

o cualquiera de las aplicaciones clásicas.


patrones de arquitectura.

Estructuramos nuestras exploraciones de patrones arquitectónicos en torno a una aplicación de ejemplo,


construyéndola capítulo por capítulo. Usamos TDD en el trabajo, por lo que tendemos a mostrar primero las listas
de pruebas, seguidas de la implementación. Si no está acostumbrado a trabajar primero con las pruebas, es posible que

Prefacio | xi
Machine Translated by Google

se siente un poco extraño al principio, pero esperamos que pronto se acostumbre a ver el código
"usado" (es decir, desde el exterior) antes de ver cómo está construido por dentro.

Utilizamos algunos marcos y tecnologías específicos de Python, incluidos Flask, SQL-Alchemy y pytest,
así como Docker y Redis. Si ya está familiarizado con ellos, eso no le hará daño, pero no creemos que
sea necesario. Uno de nuestros principales objetivos con este libro es construir una arquitectura para
la cual las opciones tecnológicas específicas se conviertan en detalles menores de implementación.

Una breve descripción de lo que aprenderá


El libro esta dividido en dos partes; aquí hay un vistazo a los temas que cubriremos y los capítulos en
los que viven.

Parte I, Creación de una arquitectura para admitir el modelado de dominios

Modelado de dominio y DDD (Capítulos 1 y 7)


En algún nivel, todos han aprendido la lección de que los problemas comerciales complejos deben
reflejarse en el código, en forma de un modelo del dominio. Pero, ¿por qué siempre parece ser
tan difícil hacerlo sin enredarse con problemas de infraestructura, nuestros marcos web o cualquier
otra cosa? En el primer capítulo, brindamos una descripción general amplia del modelado de
dominio y DDD, y mostramos cómo comenzar con un modelo que no tiene dependencias externas
y pruebas unitarias rápidas. Más adelante volvemos a los patrones DDD para discutir cómo elegir
el agregado correcto y cómo esta elección se relaciona con cuestiones de integridad de datos.

Patrones de repositorio, capa de servicio y unidad de trabajo (capítulos 2, 4 y 5)


En estos tres capítulos presentamos tres patrones estrechamente relacionados y que se refuerzan
mutuamente que respaldan nuestra ambición de mantener el modelo libre de dependencias
extrañas. Creamos una capa de abstracción en torno al almacenamiento persistente y creamos
una capa de servicio para definir los puntos de entrada a nuestro sistema y capturar los casos de
uso principales. Mostramos cómo esta capa facilita la creación de puntos de entrada delgados a
nuestro sistema, ya sea una API de Flask o una CLI.

Algunas reflexiones sobre pruebas y abstracciones (Capítulos 3 y 6)


Después de presentar la primera abstracción (el patrón Repositorio), aprovechamos la oportunidad
para una discusión general sobre cómo elegir las abstracciones y cuál es su papel en la elección
de cómo se acopla nuestro software. Después de presentar el patrón de capa de servicio,
hablamos un poco sobre cómo lograr una pirámide de prueba y escribir pruebas unitarias en el
nivel más alto posible de abstracción.

xi | Prefacio
Machine Translated by Google

Parte II, Arquitectura impulsada por eventos

Arquitectura basada en eventos (capítulos 8 a 11)


Presentamos otros tres patrones que se refuerzan mutuamente: los patrones de eventos de dominio, bus
de mensajes y controlador. Los eventos de dominio son un vehículo para capturar la idea de que algunas
interacciones con un sistema son desencadenantes para otras. Usamos un bus de mensajes para permitir
que las acciones activen eventos y llamen a los controladores apropiados. Pasamos a discutir cómo se
pueden usar los eventos como un patrón para la integración entre servicios en una arquitectura de
microservicios. Finalmente, distinguimos entre comandos y eventos. Nuestra aplicación ahora es
fundamentalmente un sistema de procesamiento de mensajes.

Segregación de responsabilidad de consulta de comando (Capítulo 12)


Presentamos un ejemplo de segregación de responsabilidad de consulta de comando, con y sin eventos.

Inyección de dependencia (Capítulo 13)


Ordenamos nuestras dependencias explícitas e implícitas e implementamos un marco de inyección de
dependencia simple.

Contenido adicional

¿Cómo llego desde aquí? (Epílogo)


La implementación de patrones arquitectónicos siempre parece fácil cuando muestra un ejemplo simple,
comenzando desde cero, pero muchos de ustedes probablemente se estarán preguntando cómo aplicar
estos principios al software existente. Proporcionaremos algunos consejos en el epílogo y algunos enlaces
a lecturas adicionales.

Código de ejemplo y codificación a lo largo

Estás leyendo un libro, pero probablemente estarás de acuerdo con nosotros cuando decimos que la mejor
manera de aprender sobre código es programar. Aprendimos la mayor parte de lo que sabemos al trabajar en
equipo con personas, escribir código con ellas y aprender haciendo, y nos gustaría recrear esa experiencia
tanto como sea posible para usted en este libro.

Como resultado, hemos estructurado el libro en torno a un solo proyecto de ejemplo (aunque a veces
agregamos otros ejemplos). Construiremos este proyecto a medida que avancen los capítulos, como si se
hubiera asociado con nosotros y estuviéramos explicando lo que estamos haciendo y por qué en cada paso.

Pero para familiarizarse realmente con estos patrones, debe jugar con el código y tener una idea de cómo
funciona. Encontrarás todo el código en GitHub; cada capítulo tiene su propia rama. También puede encontrar
una lista de las sucursales en GitHub.

Prefacio | XIII
Machine Translated by Google

Aquí hay tres formas en que puede codificar junto con el libro:

• Inicie su propio repositorio e intente desarrollar la aplicación como lo hacemos nosotros, siguiendo los ejemplos
de las listas del libro y, de vez en cuando, buscando sugerencias en nuestro repositorio. Sin embargo, una
palabra de advertencia: si ha leído el libro anterior de Harry y ha codificado junto con eso, encontrará que
este libro requiere que descubra más por su cuenta; es posible que deba apoyarse bastante en las versiones
de trabajo en GitHub.

• Trate de aplicar cada patrón, capítulo por capítulo, a su propio proyecto (preferiblemente pequeño/juguete), y
vea si puede hacerlo funcionar para su caso de uso. Esto es alto riesgo/alta recompensa (¡y además un gran
esfuerzo!). Puede tomar bastante trabajo hacer que las cosas funcionen para los detalles de su proyecto,
pero por otro lado, es probable que aprenda más.

• Para un menor esfuerzo, en cada capítulo describimos un "Ejercicio para el lector" y le indicamos una ubicación
de GitHub donde puede descargar un código parcialmente terminado para el capítulo al que le faltan algunas
partes para que lo escriba usted mismo.

Particularmente si tiene la intención de aplicar algunos de estos patrones en sus propios proyectos, trabajar con
un ejemplo simple es una excelente manera de practicar de manera segura.

Como mínimo, haga una verificación de git del código de nuestro


repositorio a medida que lee cada capítulo. Ser capaz de saltar y ver el
código en el contexto de una aplicación real en funcionamiento ayudará
a responder muchas preguntas sobre la marcha y hará que todo sea más
real. Encontrará instrucciones sobre cómo hacerlo al principio de cada capítulo.

Licencia
El código (y la versión en línea del libro) tiene una licencia Creative Commons CC BY-NC-ND, lo que significa que
puede copiarlo y compartirlo con quien quiera, sin fines comerciales, siempre que dar atribución. Si desea reutilizar
parte del contenido de este libro y tiene alguna duda sobre la licencia, comuníquese con O'Reilly en
permisos@oreilly.com.

La edición impresa tiene una licencia diferente; por favor vea la página de derechos de autor.

Las convenciones usadas en este libro


En este libro se utilizan las siguientes convenciones tipográficas:

Cursiva

Indica nuevos términos, URL, direcciones de correo electrónico, nombres de archivo y extensiones de archivo.

xiv | Prefacio
Machine Translated by Google

Ancho constante
Se utiliza para listas de programas, así como dentro de párrafos para referirse a elementos de
programas como nombres de variables o funciones, bases de datos, tipos de datos, variables
de entorno, declaraciones y palabras clave.

Negrita de ancho constante


Muestra comandos u otro texto que el usuario debe escribir literalmente.

Cursiva de ancho constante

Muestra el texto que se debe reemplazar con valores proporcionados por el usuario o por valores
determinados por el contexto.

Este elemento significa un consejo o sugerencia.

Este elemento significa una nota general.

Este elemento indica una advertencia o precaución.

Aprendizaje en línea de O'Reilly

Durante más de 40 años, O'Reilly Media ha brindado capacitación,


conocimientos y perspectivas en tecnología y negocios para ayudar a las
empresas a tener éxito.

Nuestra red única de expertos e innovadores comparte su conocimiento y experiencia a través de


libros, artículos, conferencias y nuestra plataforma de aprendizaje en línea. La plataforma de
aprendizaje en línea de O'Reilly le brinda acceso a pedido a cursos de capacitación en vivo, rutas
de aprendizaje en profundidad, entornos de codificación interactivos y una amplia colección de texto
y video de O'Reilly y más de 200 editores más. Para obtener más información, visite http://oreilly.com.

Prefacio | XV
Machine Translated by Google

Cómo contactar a O'Reilly


Dirija sus comentarios y preguntas sobre este libro a la editorial:

O'Reilly Media, Inc.


1005 Gravenstein Highway North
Sebastopol, CA 95472 800-998-9938 (en
los Estados Unidos o Canadá) 707-829-0515 (internacional
o local) 707-829-0104 (fax)

Tenemos una página web para este libro, donde enumeramos las erratas, ejemplos y cualquier información
adicional. Puede acceder a esta página en https://oreil.ly/architecture-patterns-python.

Envíe un correo electrónico a bookquestions@oreilly.com para comentar o hacer preguntas técnicas sobre
este libro.

Para obtener más información sobre nuestros libros, cursos, conferencias y noticias, visite nuestro sitio web
en http://www.oreilly.com.

Encuéntrenos en Facebook: http://facebook.com/oreilly

Síganos en Twitter: http://twitter.com/oreillymedia

Míranos en YouTube: http://www.youtube.com/oreillymedia

Expresiones de gratitud
A nuestros revisores técnicos, David Seddon, Ed Jung y Hynek Schlawack: absolutamente no los merecemos.
Todos ustedes son increíblemente dedicados, concienzudos y rigurosos.
Cada uno de ustedes es inmensamente inteligente y sus diferentes puntos de vista fueron útiles y
complementarios entre sí. Gracias desde el fondo de nuestros corazones.

Un enorme agradecimiento también a nuestros lectores de Early Release por sus comentarios y sugerencias:
Ian Cooper, Abdullah Ariff, Jonathan Meier, Gil Gonçalves, Matthieu Choplin, Ben Judson, James Gregory,
ÿukasz Lechowicz, Clinton Roy, Vitorino Araújo, Susan Goodbody, Josh Harwood, Daniel Butler, Liu Haibin,
Jimmy Davies, Ignacio Vergara Kausel, Gaia Canestrani, Renne Rocha, Pedroabi, Ashia Zawaduk, Jostein
Leira, Brandon Rhodes y muchos más; nuestras disculpas si no te hemos incluido en esta lista.

Súper-mega-gracias a nuestro editor Corbin Collins por su amabilidad y por ser un incansable defensor del
lector. Gracias igualmente superlativas al personal de producción, Katherine Tozer, Sharon Wilkey, Ellen
Troutman-Zaig y Rebecca Demarest, por su dedicación, profesionalismo y atención a los detalles. Este libro
ha mejorado enormemente gracias a ti.

Cualquier error que quede en el libro es nuestro, naturalmente.

xi | Prefacio
Machine Translated by Google

Introducción

¿Por qué nuestros diseños salen mal?


¿Qué te viene a la mente cuando escuchas la palabra caos? Tal vez piense en una bolsa de valores
ruidosa, o en su cocina por la mañana, todo confuso y revuelto.
Cuando piensas en el orden de las palabras, tal vez piensas en una habitación vacía, serena y
tranquila. Sin embargo, para los científicos, el caos se caracteriza por la homogeneidad (similitud) y el
orden por la complejidad (diferencia).

Por ejemplo, un jardín bien cuidado es un sistema altamente ordenado. Los jardineros definen los
límites con caminos y cercas, y marcan macizos de flores o huertos. Con el tiempo, el jardín evoluciona,
haciéndose más rico y espeso; pero sin un esfuerzo deliberado, el jardín se volverá salvaje. Las malas
hierbas y los pastos ahogarán a otras plantas, cubriendo los caminos, hasta que eventualmente cada
parte se vea igual nuevamente: salvaje y sin control.

Los sistemas de software también tienden al caos. Cuando comenzamos a construir un nuevo sistema,
tenemos grandes ideas de que nuestro código estará limpio y bien ordenado, pero con el tiempo nos
damos cuenta de que reúne casos complicados y extremos y termina en un confuso pantano de clases
de administrador y módulos de utilidad. Descubrimos que nuestra arquitectura sensiblemente
estratificada se ha derrumbado sobre sí misma como una bagatela demasiado empapada. Los
sistemas de software caóticos se caracterizan por una similitud de funciones: controladores de API
que tienen conocimiento del dominio y envían correos electrónicos y realizan registros; clases de
"lógica de negocios" que no realizan cálculos pero realizan operaciones de E/S; y todo acoplado a
todo lo demás, de modo que cambiar cualquier parte del sistema se vuelve peligroso. Esto es tan
común que los ingenieros de software tienen su propio término para el caos: el antipatrón Big Ball of Mud (Figura P-1).

xvii
Machine Translated by Google

Figura P-1. Un diagrama de dependencia de la vida real (fuente: "Dependencia empresarial: Gran bola
de hilo" por Alex Papadimoulis)

Una gran bola de barro es el estado natural del software de la misma


manera que la naturaleza salvaje es el estado natural de su jardín. Se
necesita energía y dirección para evitar el colapso.

Afortunadamente, las técnicas para evitar crear una gran bola de barro no son complejas.

Encapsulación y abstracciones
La encapsulación y la abstracción son herramientas que todos buscamos instintivamente como
programadores, incluso si no todos usamos estas palabras exactas. Permítanos detenernos en ellos por
un momento, ya que son un tema de fondo recurrente del libro.

El término encapsulación cubre dos ideas estrechamente relacionadas: simplificar el comportamiento y


ocultar datos. En esta discusión, estamos usando el primer sentido. Encapsulamos el comportamiento por

xviii | Introducción
Machine Translated by Google

identificar una tarea que debe realizarse en nuestro código y asignar esa tarea a un objeto o función bien
definidos. Llamamos a ese objeto o función una abstracción.

Eche un vistazo a los siguientes dos fragmentos de código de Python:

Haz una búsqueda con urllib

importar
json desde urllib.request importar
urlopen desde urllib.parse importar urlencode

params = dict(q='Salchichas', format='json') handle


= urlopen('http://api.duckduckgo.com' + '?' + urlencode(params)) raw_text =
handle.read().decode ('utf8') analizado = json.loads(raw_text)

resultados = analizado['Temas
relacionados'] para r en resultados: si
'Texto' en r:
'
imprimir(r['PrimeraURL'] + - ' + r['Texto'])

Haz una búsqueda con solicitudes

solicitudes de importación

params = dict(q='Salchichas', format='json') analizado


= request.get ('http://api.duckduckgo.com/', params=params).json()

resultados = analizados ['Temas relacionados']


para r en resultados:
if 'Texto' en r:
'
print(r['FirstURL'] + - ' + r['Texto'])

Ambas listas de códigos hacen lo mismo: envían valores codificados en forma a una URL para usar una API de
motor de búsqueda. Pero el segundo es más sencillo de leer y comprender porque opera en un nivel más alto
de abstracción.

Podemos dar un paso más allá identificando y nombrando la tarea que queremos que el código realice por
nosotros y usando una abstracción de nivel aún más alto para hacerlo explícito:

Haz una búsqueda con el módulo duckduckgo

import duckduckgo
para r en duckduckgo.query('Salchichas').resultados:
'
print(r.url + - ' + texto r)

Encapsular el comportamiento mediante el uso de abstracciones es una herramienta poderosa para hacer que
el código sea más expresivo, más comprobable y más fácil de mantener.

Introducción | xix
Machine Translated by Google

En la literatura del mundo orientado a objetos (OO), una de las


caracterizaciones clásicas de este enfoque se denomina diseño impulsado
por la responsabilidad; utiliza las palabras funciones y responsabilidades en lugar de tareas.
El punto principal es pensar en el código en términos de comportamiento,
más que en términos de datos o algoritmos.1

Abstracciones y ABC
En un lenguaje OO tradicional como Java o C#, puede usar una clase base abstracta (ABC) o una
interfaz para definir una abstracción. En Python puedes (y a veces lo hacemos) usar ABC, pero
también puedes confiar felizmente en la tipificación pato.

La abstracción puede significar simplemente "la API pública de lo que está usando", por ejemplo,
un nombre de función más algunos argumentos.

La mayoría de los patrones de este libro implican elegir una abstracción, por lo que verá
muchos ejemplos en cada capítulo. Además, el Capítulo 3 analiza específicamente algunas
heurísticas generales para elegir abstracciones.

capas
La encapsulación y la abstracción nos ayudan a ocultar detalles y proteger la consistencia de
nuestros datos, pero también debemos prestar atención a las interacciones entre nuestros
objetos y funciones. Cuando una función, módulo u objeto utiliza otro, decimos que uno
depende del otro. Estas dependencias forman una especie de red o gráfico.

En una gran bola de lodo, las dependencias están fuera de control (como vio en la Figura P-1).
Cambiar un nodo del gráfico se vuelve difícil porque tiene el potencial de afectar muchas otras
partes del sistema. Las arquitecturas en capas son una forma de abordar este problema. En
una arquitectura en capas, dividimos nuestro código en categorías o roles discretos e
introducimos reglas sobre qué categorías de código pueden llamarse entre sí.

Uno de los ejemplos más comunes es la arquitectura de tres capas que se muestra en la
Figura P-2.

1 Si se ha encontrado con tarjetas de clase-responsabilidad-colaborador (CRC), están impulsando lo mismo: pensar


Hablar sobre las responsabilidades te ayuda a decidir cómo dividir las cosas.

XX | Introducción
Machine Translated by Google

Figura P-2. arquitectura en capas

La arquitectura en capas es quizás el patrón más común para construir software empresarial. En este
modelo tenemos componentes de interfaz de usuario, que pueden ser una página web, una API o una
línea de comandos; estos componentes de la interfaz de usuario se comunican con una capa de lógica
comercial que contiene nuestras reglas comerciales y nuestros flujos de trabajo; y finalmente, tenemos
una capa de base de datos que es responsable de almacenar y recuperar datos.

Durante el resto de este libro, daremos la vuelta sistemáticamente a este modelo obedeciendo un
principio simple.

El principio de inversión de dependencia


Es posible que ya esté familiarizado con el principio de inversión de dependencia (DIP), porque es la
D en SOLID.2

Desafortunadamente, no podemos ilustrar el DIP usando tres listados de códigos diminutos como lo
hicimos para la encapsulación. Sin embargo, toda la Parte I es esencialmente un ejemplo práctico de
la implementación del DIP en una aplicación, por lo que se llenará de ejemplos concretos.

Mientras tanto, podemos hablar sobre la definición formal de DIP:

1. Los módulos de alto nivel no deben depender de los módulos de bajo nivel. Ambos deberían
depender de abstracciones.

2. Las abstracciones no deben depender de los detalles. En cambio, los detalles deben depender de
abstracciones

Pero ¿qué significa esto? Vamos a tomarlo poco a poco.

Los módulos de alto nivel son el código que realmente le importa a su organización. Tal vez trabaje
para una compañía farmacéutica y sus módulos de alto nivel traten con pacientes.

2 SOLID es un acrónimo de los cinco principios de diseño orientado a objetos de Robert C. Martin: responsabilidad
única, abierto para extensión pero cerrado para modificación, sustitución de Liskov, segregación de interfaz e inversión
de dependencia. Ver “SOLID: Los Primeros 5 Principios del Diseño Orientado a Objetos” por Samuel Oloruntoba.

Introducción | xxx
Machine Translated by Google

y juicios Tal vez trabaje para un banco y sus módulos de alto nivel administren transacciones e
intercambios. Los módulos de alto nivel de un sistema de software son las funciones, clases y paquetes
que se ocupan de nuestros conceptos del mundo real.

Por el contrario, los módulos de bajo nivel son el código que no le importa a su organización.
Es poco probable que su departamento de recursos humanos se entusiasme con los sistemas de
archivos o los conectores de red. No es frecuente que discuta SMTP, HTTP o AMQP con su equipo de finanzas.
Para nuestros interesados no técnicos, estos conceptos de bajo nivel no son interesantes ni relevantes.
Todo lo que les importa es si los conceptos de alto nivel funcionan correctamente. Si la nómina se ejecuta
a tiempo, es poco probable que a su empresa le importe si se trata de un trabajo cron o de una función
transitoria que se ejecuta en Kubernetes.

Depende de no significa necesariamente importaciones o llamadas, sino más bien una idea más general
de que un módulo conoce o necesita otro módulo.

Y ya hemos mencionado las abstracciones: son interfaces simplificadas que encapsulan el


comportamiento, de la misma manera que nuestro módulo duckduckgo encapsuló la API de un motor de
búsqueda.

Todos los problemas en informática se pueden resolver agregando otro nivel de indirección.
—David Rueda

Entonces, la primera parte del DIP dice que nuestro código comercial no debe depender de detalles
técnicos; en cambio, ambos deberían usar abstracciones.

¿Por qué? En términos generales, porque queremos poder cambiarlos independientemente uno del otro.
Los módulos de alto nivel deben ser fáciles de cambiar en respuesta a las necesidades comerciales.
Los módulos de bajo nivel (detalles) suelen ser, en la práctica, más difíciles de cambiar: piense en
refactorizar para cambiar el nombre de una función en lugar de definir, probar e implementar una
migración de base de datos para cambiar el nombre de una columna. No queremos que los cambios en
la lógica empresarial se desaceleren porque están estrechamente relacionados con detalles de
infraestructura de bajo nivel. Pero, del mismo modo, es importante poder cambiar los detalles de su
infraestructura cuando lo necesite (piense en fragmentar una base de datos, por ejemplo), sin necesidad
de realizar cambios en su capa empresarial. Agregar una abstracción entre ellos (la famosa capa
adicional de direccionamiento indirecto) permite que los dos cambien (más) independientemente el uno
del otro.

La segunda parte es aún más misteriosa. “Las abstracciones no deberían depender de los detalles”
parece bastante claro, pero “Los detalles deberían depender de las abstracciones” es difícil de imaginar.
¿Cómo podemos tener una abstracción que no dependa de los detalles que está abstrayendo? Para
cuando lleguemos al Capítulo 4, tendremos un ejemplo concreto que debería aclarar todo esto un poco.

XXII | Introducción
Machine Translated by Google

Un lugar para toda nuestra lógica empresarial: el modelo de dominio


Pero antes de que podamos darle la vuelta a nuestra arquitectura de tres capas, debemos hablar
más sobre esa capa intermedia: los módulos de alto nivel o la lógica empresarial. Una de las razones
más comunes por las que nuestros diseños fallan es que la lógica empresarial se extiende por las
capas de nuestra aplicación, lo que dificulta su identificación, comprensión y cambio.

El Capítulo 1 muestra cómo crear una capa empresarial con un patrón de modelo de dominio. El
resto de los patrones en la Parte I muestran cómo podemos mantener el modelo de dominio fácil de
cambiar y libre de preocupaciones de bajo nivel eligiendo las abstracciones correctas y aplicando
continuamente el DIP.

Introducción | XXIII
Machine Translated by Google
Machine Translated by Google

PARTE I

Construyendo una arquitectura para soportar


Modelado de dominio

La mayoría de los desarrolladores nunca han visto un modelo de dominio, solo un modelo de datos.

—Cyrille Martraire, DDD UE 2017

La mayoría de los desarrolladores con los que hablamos sobre arquitectura tienen la persistente sensación
de que las cosas podrían mejorar. A menudo están tratando de rescatar un sistema que de alguna manera
ha fallado y están tratando de volver a poner alguna estructura en una bola de barro. Saben que su lógica
empresarial no debe estar esparcida por todos lados, pero no tienen idea de cómo solucionarlo.

Hemos encontrado que muchos desarrolladores, cuando se les pide que diseñen un nuevo sistema,
inmediatamente comienzan a construir un esquema de base de datos, con el modelo de objetos tratado
como una idea de último momento. Aquí es donde todo empieza a salir mal. En su lugar, el comportamiento
debe ser lo primero e impulsar nuestros requisitos de almacenamiento. Después de todo, a nuestros clientes
no les importa el modelo de datos. Les importa lo que hace el sistema; de lo contrario, solo usarían una hoja de cálculo.

La primera parte del libro analiza cómo construir un modelo de objetos enriquecido a través de TDD (en el
Capítulo 1), y luego mostraremos cómo mantener ese modelo desvinculado de las preocupaciones técnicas.
Mostramos cómo crear código que ignore la persistencia y cómo crear API estables alrededor de nuestro
dominio para que podamos refactorizar agresivamente.

Para ello, presentamos cuatro patrones de diseño clave:

• El patrón Repository, una abstracción sobre la idea de almacenamiento persistente • El patrón

Service Layer para definir claramente dónde comienzan y terminan nuestros casos de uso
Machine Translated by Google

• El patrón Unidad de trabajo para proporcionar operaciones atómicas • El

patrón Agregado para reforzar la integridad de nuestros datos

Si desea una imagen de hacia dónde nos dirigimos, eche un vistazo a la Figura I-1, pero no se preocupe si todavía
no tiene sentido. Introducimos cada casilla de la figura, una a una, a lo largo de esta parte del libro.

Figura I-1. Un diagrama de componentes para nuestra aplicación al final de la Parte I

También nos tomamos un poco de tiempo para hablar sobre el acoplamiento y las abstracciones, ilustrándolo con
un ejemplo simple que muestra cómo y por qué elegimos nuestras abstracciones.
Machine Translated by Google

Tres apéndices son exploraciones adicionales del contenido de la Parte I:

• El Apéndice B es un resumen de la infraestructura de nuestro código de ejemplo: cómo construimos y


ejecutamos las imágenes de Docker, dónde administramos la información de configuración y cómo
ejecutamos diferentes tipos de pruebas.

• El Apéndice C es un tipo de contenido de "prueba está en el pudín", que muestra lo fácil que es
cambiar toda nuestra infraestructura (la API de Flask, el ORM y Postgres) por un modelo de E/S
totalmente diferente que involucra una CLI y CSV.

• Finalmente, el Apéndice D puede ser de interés si se pregunta cómo se verían estos patrones si usa
Django en lugar de Flask y SQLAlchemy.
Machine Translated by Google
Machine Translated by Google

CAPÍTULO 1

Modelado de dominio

Este capítulo analiza cómo podemos modelar procesos comerciales con código, de una manera que sea altamente
compatible con TDD. Discutiremos por qué es importante el modelado de dominios y veremos algunos patrones clave para
modelar dominios: entidad, objeto de valor y servicio de dominio.

La figura 1-1 es un marcador de posición visual simple para nuestro patrón de modelo de dominio. Completaremos algunos
detalles en este capítulo y, a medida que avancemos a otros capítulos, construiremos cosas en torno al modelo de dominio,
pero siempre debería poder encontrar estas pequeñas formas en el centro.

Figura 1-1. Una ilustración de marcador de posición de nuestro modelo de dominio

5
Machine Translated by Google

¿Qué es un modelo de dominio?

En la introducción, usamos el término capa de lógica empresarial para describir la capa central de una
arquitectura de tres capas. Para el resto del libro, usaremos el término modelo de dominio en su lugar.
Este es un término de la comunidad DDD que hace un mejor trabajo al capturar nuestro significado
previsto (consulte la siguiente barra lateral para obtener más información sobre DDD).

El dominio es una forma elegante de decir el problema que estás tratando de resolver. Sus autores
trabajan actualmente para una tienda online de muebles. Según el sistema del que esté hablando, el
dominio podría ser compras y adquisiciones, o diseño de productos, o logística y entrega. La mayoría de
los programadores pasan sus días tratando de mejorar o automatizar los procesos comerciales; el dominio
es el conjunto de actividades que esos procesos soportan.

Un modelo es un mapa de un proceso o fenómeno que captura una propiedad útil.


Los humanos son excepcionalmente buenos para producir modelos de cosas en sus cabezas. Por ejemplo,
cuando alguien te lanza una pelota, puedes predecir su movimiento casi inconscientemente, porque tienes
un modelo de la forma en que los objetos se mueven en el espacio. Su modelo no es perfecto de ninguna
manera. Los humanos tienen intuiciones terribles sobre cómo se comportan los objetos a velocidades
cercanas a la luz o en el vacío porque nuestro modelo nunca fue diseñado para cubrir esos casos. Eso no
significa que el modelo esté equivocado, pero sí que algunas predicciones quedan fuera de su dominio.

El modelo de dominio es el mapa mental que los empresarios tienen de sus negocios.
Toda la gente de negocios tiene estos mapas mentales: es la forma en que los humanos piensan sobre
procesos complejos.

Puede saber cuándo están navegando en estos mapas porque usan lenguaje comercial. La jerga surge
naturalmente entre las personas que colaboran en sistemas complejos.

Imagina que tú, nuestro desafortunado lector, de repente fueras transportado a años luz de la Tierra a
bordo de una nave extraterrestre con tus amigos y familiares y tuvieras que descubrir, desde los primeros
principios, cómo navegar de regreso a casa.

En sus primeros días, podría presionar botones al azar, pero pronto aprendería qué botones hacían qué,
para que pudieran darse instrucciones unos a otros. “Presiona el botón rojo cerca de la llave intermitente
y luego tira la gran palanca hacia el aparato del radar”, podrías decir.

En un par de semanas, se volvería más preciso al adoptar palabras para describir las funciones de la
nave: "Aumentar los niveles de oxígeno en la bodega de carga tres" o "encender los pequeños propulsores".
Después de unos meses, habrá adoptado un lenguaje para procesos complejos completos: "Iniciar
secuencia de aterrizaje" o "preparar para deformación". Este proceso ocurriría de forma bastante natural,
sin ningún esfuerzo formal para construir un glosario compartido.

6 | Capítulo 1: Modelado de dominio


Machine Translated by Google

Este no es un libro DDD. Deberías leer un libro de DDD.


El diseño basado en dominios, o DDD, popularizó el concepto de modelado de dominios,1 y ha sido un
movimiento de gran éxito en la transformación de la forma en que las personas diseñan software
centrándose en el dominio empresarial central. Muchos de los patrones de arquitectura que cubrimos en
este libro, incluidos Entity, Aggregate, Value Object (consulte el Capítulo 7) y Repository (en el próximo
capítulo), provienen de la tradición DDD.

En pocas palabras, DDD dice que lo más importante del software es que proporciona un modelo útil de un
problema. Si conseguimos ese modelo correcto, nuestro software ofrece valor y hace posibles cosas
nuevas.

Si nos equivocamos en el modelo, se convierte en un obstáculo que hay que sortear. En este libro,
podemos mostrar los conceptos básicos de la construcción de un modelo de dominio y la construcción de
una arquitectura a su alrededor que deje el modelo lo más libre posible de restricciones externas, de modo
que sea fácil de evolucionar y cambiar.

Pero hay mucho más en DDD y en los procesos, herramientas y técnicas para desarrollar un modelo de
dominio. Sin embargo, esperamos darle una probada y no podemos alentarlo lo suficiente para que
continúe y lea un libro de DDD adecuado:

• El “libro azul” original, Domain-Driven Design de Eric Evans (Addison-Wesley


Profesional)

• El "libro rojo", Implementación del diseño basado en dominios por Vaughn Vernon
(Addison-Wesley Professional)

Así es en el mundo mundano de los negocios. La terminología utilizada por las partes interesadas del negocio
representa una comprensión destilada del modelo de dominio, donde las ideas y los procesos complejos se
reducen a una sola palabra o frase.

Cuando escuchamos a las partes interesadas de nuestro negocio usar palabras desconocidas o usar términos
de una manera específica, debemos escuchar para comprender el significado más profundo y codificar su
experiencia ganada con tanto esfuerzo en nuestro software.

Vamos a utilizar un modelo de dominio del mundo real a lo largo de este libro, específicamente un modelo de
nuestro empleo actual. MADE.com es un exitoso minorista de muebles.
Obtenemos nuestros muebles de fabricantes de todo el mundo y los vendemos en
Europa.

1 DDD no originó el modelado de dominio. Eric Evans se refiere al libro de 2002 Object Design de Rebecca Wirfs
Brock y Alan McKean (Addison-Wesley Professional), que introdujo el diseño basado en la responsabilidad, del
cual DDD es un caso especial que trata el dominio. Pero incluso eso es demasiado tarde, y los entusiastas de OO
le dirán que mire más atrás a Ivar Jacobson y Grady Booch; el término existe desde mediados de la década de 1980.

¿Qué es un modelo de dominio? | 7


Machine Translated by Google

Cuando compra un sofá o una mesa de café, tenemos que averiguar cuál es la mejor manera de llevar
sus productos de Polonia, China o Vietnam a su sala de estar.

En un nivel alto, tenemos sistemas separados que son responsables de comprar existencias, vender
existencias a los clientes y enviar mercancías a los clientes. Un sistema en el medio necesita coordinar
el proceso asignando existencias a los pedidos de un cliente; consulte la Figura 1-2.

Figura 1-2. Diagrama de contexto para el servicio de asignación

Para los propósitos de este libro, estamos imaginando que la empresa decide implementar una nueva y
emocionante forma de asignar existencias. Hasta ahora, la empresa ha estado presentando las
existencias y los plazos de entrega en función de lo que está físicamente disponible en el almacén. Si se
agota el almacén, un producto aparece como "agotado" hasta que llegue el próximo envío del fabricante.

Aquí está la innovación: si tenemos un sistema que puede realizar un seguimiento de todos nuestros
envíos y cuándo deben llegar, podemos tratar los productos en esos barcos como existencias reales y
parte de nuestro inventario, solo que con plazos de entrega un poco más largos. Parecerá que hay menos
productos agotados, venderemos más y la empresa puede ahorrar dinero al mantener un inventario más
bajo en el almacén nacional.

8 | Capítulo 1: Modelado de dominio


Machine Translated by Google

Pero asignar pedidos ya no es una cuestión trivial de disminuir una sola cantidad en el sistema de
almacén. Necesitamos un mecanismo de asignación más complejo. Tiempo para un poco de modelado
de dominio.

Explorando el lenguaje del dominio


Comprender el modelo de dominio requiere tiempo, paciencia y notas adhesivas. Tenemos una
conversación inicial con nuestros expertos comerciales y acordamos un glosario y algunas reglas para
la primera versión mínima del modelo de dominio. Siempre que sea posible, pedimos ejemplos
concretos para ilustrar cada regla.

Nos aseguramos de expresar esas reglas en la jerga comercial (el lenguaje omnipresente en la
terminología de DDD). Elegimos identificadores memorables para nuestros objetos para que sea más
fácil hablar de los ejemplos.

"Algunas notas sobre la asignación" muestra algunas notas que podríamos haber tomado mientras
conversábamos con nuestros expertos de dominio sobre la asignación.

Algunas notas sobre la asignación

Un producto se identifica por un SKU, pronunciado "sesgo", que es la abreviatura de unidad de


mantenimiento de existencias. Los clientes hacen pedidos. Un pedido se identifica por una referencia de
pedido y consta de varias líneas de pedido, donde cada línea tiene un SKU y una cantidad. Por ejemplo:

• 10 unidades de SILLA ROJA

• 1 unidad de TASTELESS-LAMP

El departamento de compras ordena pequeños lotes de existencias. Un lote de existencias tiene una
identificación única llamada referencia, un SKU y una cantidad.

Necesitamos asignar líneas de pedido a lotes. Cuando hayamos asignado una línea de pedido a un lote,
enviaremos existencias de ese lote específico a la dirección de entrega del cliente.
Cuando asignamos x unidades de stock a un lote, la cantidad disponible se reduce en x.
Por ejemplo:

• Tenemos un lote de 20 SMALL-TABLE, y asignamos una línea de pedido para 2


MESA PEQUEÑA.

• El lote debe tener 18 SMALL-TABLE restantes.

No podemos asignar a un lote si la cantidad disponible es menor que la cantidad de la línea de pedido.
Por ejemplo:

• Tenemos un lote de 1 COJÍN AZUL y una línea de pedido de 2 AZULES


ALMOHADÓN.

Explorando el lenguaje de dominio | 9


Machine Translated by Google

• No deberíamos poder asignar la línea al lote.

No podemos asignar la misma línea dos veces. Por ejemplo:

• Tenemos un lote de 10 BLUE-VASE, y asignamos una línea de pedido para 2 BLUE


JARRÓN.

• Si volvemos a asignar la línea de pedido al mismo lote, el lote aún debe tener un
cantidad disponible de 8.

Los lotes tienen una ETA si se están enviando actualmente o pueden estar en stock en el almacén.
Asignamos al stock de almacén con preferencia a los lotes de envío. Asignamos los lotes de envío en
orden de cuál tiene la ETA más temprana.

Modelos de dominio de pruebas unitarias

No le mostraremos cómo funciona TDD en este libro, pero queremos mostrarle cómo construiríamos
un modelo a partir de esta conversación empresarial.

Ejercicio para el lector


¿Por qué no intentar resolver este problema usted mismo? Escriba algunas pruebas unitarias para ver si
puede capturar la esencia de estas reglas comerciales en un código agradable y limpio.

Encontrará algunas pruebas unitarias de marcador de posición en GitHub, pero puede comenzar desde
cero o combinarlas/reescribirlas como desee.

Así es como se vería una de nuestras primeras pruebas:

Una primera prueba de asignación (test_batches.py)

def prueba_asignación_a_un_lote_reduce_la_cantidad_disponible():
lote = Lote("lote-001", "TABLA PEQUEÑA", cant. =20, eta=fecha.hoy()) línea = LíneaPedido('ref-
pedido', "TABLA PEQUEÑA", 2)

lote.asignar(línea)

afirmar lote.cantidad_disponible == 18

El nombre de nuestra prueba unitaria describe el comportamiento que queremos ver del sistema, y
los nombres de las clases y variables que usamos se toman del negocio.
jerga. Podríamos mostrar este código a nuestros compañeros de trabajo no técnicos y estarían de
acuerdo en que describe correctamente el comportamiento del sistema.

10 | Capítulo 1: Modelado de dominio


Machine Translated by Google

Y aquí hay un modelo de dominio que cumple con nuestros requisitos:

Primer corte de un modelo de dominio para lotes (model.py)


@dataclass(congelado=Verdadero)
clase OrderLine:
orderid: str
sku: str qty: int

lote de clase :
def __init__( self,
ref: str, sku: str, qty: int, eta: Opcional[fecha]
):
self.reference = ref
self.sku = sku self.eta =
eta self.disponible_cantidad
= cantidad

def allocate(self, línea: OrderLine):


self.cantidad_disponible -= línea.cantidad

OrderLine es una clase de datos inmutable sin comportamiento.2

No mostramos las importaciones en la mayoría de las listas de códigos, en un intento por mantenerlas limpias.
Esperamos que pueda adivinar que esto vino a través de dataclasses import dataclass; asimismo,
escribiendo.Opcional y fechahora.fecha . Si desea volver a verificar algo, puede ver el código de trabajo
completo para cada capítulo en su rama (por ejemplo, chapter_01_domain_model).

Las sugerencias de tipo siguen siendo un tema de controversia en el mundo de Python. Para los modelos de
dominio, a veces pueden ayudar a aclarar o documentar cuáles son los argumentos esperados, y las personas
con IDE a menudo los agradecen. Puede decidir que el precio pagado en términos de legibilidad es demasiado
alto.

Nuestra implementación aquí es trivial: un Lote simplemente envuelve un número entero disponible_cantidad, y
decrementamos ese valor en la asignación. Hemos escrito bastante código solo para restar un número de otro, pero
creemos que modelar nuestro dominio con precisión valdrá la pena.3

Escribamos algunas nuevas pruebas fallidas:

2 En versiones anteriores de Python, podríamos haber usado una tupla con nombre. También puede consultar el artículo de Hynek Schlawack.
excelentes atributos

3 ¿O tal vez piensa que no hay suficiente código? ¿Qué pasa con algún tipo de verificación de que el SKU en OrderLine

coincide con Batch.sku? Guardamos algunos pensamientos sobre la validación para el Apéndice E.

Modelos de dominio de pruebas unitarias | 11


Machine Translated by Google

Lógica de prueba para lo que podemos asignar (test_batches.py)

def make_batch_and_line(sku, batch_qty, line_qty): return


( Batch("batch-001", sku, batch_qty, eta=date.today()),
OrderLine("order-123", sku, line_qty)

def prueba_puede_asignar_si_disponible_mayor_de_requerido():
lote_grande, línea_pequeña = hacer_lote_y_línea("LÁMPARA ELEGANTE", 20, 2) afirmar
lote_grande.puede_asignar(línea_pequeña)

def test_cannot_allocate_if_available_smaller_than_required():
small_batch, large_line = make_batch_and_line("ELEGANT-LAMP", 2, 20) afirmar
small_batch.can_allocate(large_line) es falso

def prueba_puede_asignar_si_disponible_igual_a_required():
lote, línea = make_batch_and_line("ELEGANT-LAMP", 2, 2) afirmar
lote.can_allocate(línea)

def test_cannot_allocate_if_skus_do_not_match(): lote = Lote("lote-001",


"SILLA INCOMODABLE", 100, eta=Ninguno) different_sku_line = OrderLine("pedido-123",
"TOSTADOR CARO", 10) afirmar lote.can_allocate( different_sku_line) es falso

No hay nada demasiado inesperado aquí. Hemos refactorizado nuestro conjunto de pruebas para que no
sigamos repitiendo las mismas líneas de código para crear un lote y una línea para el mismo SKU; y
hemos escrito cuatro pruebas simples para un nuevo método can_allocate. Una vez más, tenga en cuenta
que los nombres que usamos reflejan el lenguaje de nuestros expertos en dominios, y los ejemplos que
acordamos se escriben directamente en el código.

También podemos implementar esto directamente, escribiendo el método can_allocate de Batch:

Un nuevo método en el modelo (model.py)

def can_allocate(self, línea: OrderLine) -> bool:


return self.sku == line.sku y self.cantidad_disponible >= line.qty

Hasta ahora, podemos administrar la implementación simplemente incrementando y disminuyendo Batch.


Available_quantity, pero a medida que avanzamos en las pruebas de desalocate() , nos veremos obligados
a buscar una solución más inteligente:

12 | Capítulo 1: Modelado de dominio


Machine Translated by Google

Esta prueba va a requerir un modelo más inteligente


(test_batches.py) def test_can_only_deallocate_allocated_lines(): lote, línea_no asignada =
make_batch_and_line("DECORATIVE-TRINKET", 20, 2) lote.deallocate(línea_no asignada) asertar
lote.cantidad_disponible == 20

En esta prueba, afirmamos que desasignar una línea de un lote no tiene efecto a menos que el
lote haya asignado previamente la línea. Para que esto funcione, nuestro lote debe comprender
qué líneas se han asignado. Veamos la implementación:

El modelo de dominio ahora rastrea las asignaciones (model.py)

lote de clase :
def __init__( self,
ref: str, sku: str, qty: int, eta: Opcional[fecha]
):
self.reference = ref
self.sku = sku self.eta =
eta self._purchased_quantity
= qty self._allocations = set() # type:
Set[OrderLine]

def allocate(self, línea: OrderLine): if


self.can_allocate(line):
self._allocations.add(línea)

def desasignar(self, line: OrderLine): if line in


self._allocations:
self._allocations.remove(línea)

@propiedad
def cantidad_asignada (auto) -> int:
return sum(line.qty for line in self._allocations)

@property
def cantidad_disponible (self) -> int: return
self._cantidad_comprada - self.cantidad_asignada

def can_allocate(self, línea: OrderLine) -> bool:


return self.sku == line.sku y self.cantidad_disponible >= line.qty

La Figura 1-3 muestra el modelo en UML.

Modelos de dominio de pruebas unitarias | 13


Machine Translated by Google

Figura 1-3. Nuestro modelo en UML

¡Ahora estamos llegando a alguna parte! Un lote ahora realiza un seguimiento de un conjunto de objetos
de línea de pedido asignados. Cuando asignamos, si tenemos suficiente cantidad disponible, simplemente
agregamos al conjunto. Nuestra cantidad_disponible ahora es una propiedad calculada: cantidad
comprada menos cantidad asignada.

Sí, hay mucho más que podríamos hacer. Es un poco desconcertante que tanto allocate() como
desalocate() puedan fallar silenciosamente, pero tenemos lo básico.

Por cierto, el uso de un conjunto para ._allocations nos simplifica el manejo de la última prueba, porque
los elementos de un conjunto son únicos:

¡Última prueba de lote! (test_lotes.py)


def test_allocation_is_idempotent(): lote,
línea = make_batch_and_line("ANGULAR-DESK", 20, 2)
lote.allocate(línea) lote.allocate(línea) aseverar lote.cantidad_disponible
== 18

Por el momento, probablemente sea una crítica válida decir que el modelo de dominio es demasiado
trivial para molestarse con DDD (¡o incluso con la orientación a objetos!). En la vida real, surge cualquier
cantidad de reglas comerciales y casos extremos: los clientes pueden solicitar la entrega en fechas
futuras específicas, lo que significa que es posible que no queramos asignarlos al lote más antiguo.
Algunos SKU no están en lotes, sino que se piden a pedido directamente de los proveedores, por lo que
tienen una lógica diferente. Dependiendo de la ubicación del cliente, podemos asignar solo un subconjunto
de almacenes y envíos que se encuentran en su región, excepto algunos SKU que nos complace entregar
desde un almacén en una región diferente si no tenemos existencias en el hogar. región. Y así. ¡Un
negocio real en el mundo real sabe cómo acumular complejidad más rápido de lo que podemos mostrar
en la página!

Pero tomando este modelo de dominio simple como marcador de posición para algo más complejo,
ampliaremos nuestro modelo de dominio simple en el resto del libro y lo conectaremos al mundo real de
API, bases de datos y hojas de cálculo. Veremos como pega

14 | Capítulo 1: Modelado de dominio


Machine Translated by Google

rígidamente a nuestros principios de encapsulación y una cuidadosa estratificación nos ayudarán a evitar
una bola de barro.

Más tipos para obtener más sugerencias

de tipos Si realmente quiere ir a la ciudad con sugerencias de tipos, podría ir tan lejos como
envolver tipos primitivos usando escribiendo.NewType:

Simplemente llevándolo demasiado lejos, Bob

desde clases de datos importar clase de


datos desde escribir importar NewType

Cantidad = NewType("Cantidad", int)


Sku = NewType("Sku", str)
Referencia = NewType("Referencia", str)
...

lote de clase :
def __init__(self, ref: Referencia, sku: Sku, qty: Cantidad): self.sku = sku
self.reference = ref

self._cantidad_comprada = cantidad

Eso permitiría que nuestro verificador de tipos se asegure de que no pasemos un Sku donde
se espera una Referencia , por ejemplo.

Si piensas que esto es maravilloso o espantoso es un tema de debate.4

Las clases de datos son excelentes para los objetos de

valor Hemos usado la línea generosamente en las listas de códigos anteriores, pero ¿qué es una línea? En
nuestro lenguaje comercial, un pedido tiene varios elementos de línea, donde cada línea tiene un SKU y una
cantidad. Podemos imaginar que un archivo YAML simple que contenga información de pedidos se vería así:

Información de pedido como YAML

Referencia_pedido: 12345
Líneas:
- sku: RED-SILLA
cantidad: 25 -
sku: BLU-SILLA
cantidad:
25 - sku: GRN-SILLA
cantidad: 25

4 Es espantoso. Por favor, por favor, no hagas esto. -Harry

Modelos de dominio de pruebas unitarias | 15


Machine Translated by Google

Tenga en cuenta que mientras un pedido tiene una referencia que lo identifica de forma única, una línea no.
(Incluso si agregamos la referencia del pedido a la clase OrderLine , no es algo que identifique de manera
única la línea en sí).

Cada vez que tenemos un concepto de negocio que tiene datos pero no identidad, a menudo elegimos
representarlo utilizando el patrón de objeto de valor. Un objeto de valor es cualquier objeto de dominio que
se identifica de forma única por los datos que contiene; normalmente los hacemos inmutables:

OrderLine es un objeto de valor


@dataclass(frozen=True) class
OrderLine: orderid:
OrderReference sku:
ProductReference
cantidad: Cantidad

Una de las cosas buenas que nos dan las clases de datos (o tuplas con nombre) es la igualdad de valores,
que es la forma elegante de decir: "Dos líneas con el mismo orderid, sku y qty son iguales".

Más ejemplos de objetos de valor


desde clases de datos importar clase de
datos desde escribir importar NamedTuple
desde colecciones importar namedtuple

@dataclass(congelado=Verdadero)
Nombre de la clase :

nombre : str apellido:


str

clase Dinero(TupleNombrado):
moneda: str valor:
int

Línea = tupla nombrada('Línea', ['sku', 'cantidad'])

def test_equality(): afirmar


Dinero('gbp', 10) == Dinero('gbp', 10) afirmar Nombre('Harry',
'Percival') != Nombre('Bob', 'Gregory') afirmar Línea( 'SILLA-ROJA', 5) ==
Línea('SILLA-ROJA', 5)

Estos objetos de valor coinciden con nuestra intuición del mundo real sobre cómo funcionan sus valores.
No importa de qué billete de 10€ estemos hablando, porque todos tienen el mismo valor. Asimismo, dos
nombres son iguales si tanto el nombre como el apellido coinciden; y dos líneas son equivalentes si tienen
el mismo pedido de cliente, código de producto y cantidad.
Sin embargo, todavía podemos tener un comportamiento complejo en un objeto de valor. De hecho, es
común soportar operaciones sobre valores; por ejemplo, operadores matemáticos:

16 | Capítulo 1: Modelado de dominio


Machine Translated by Google

Matemáticas con objetos de valor

cinco = Dinero('gbp', 5) diez


= Dinero('gbp', 10)

def can_add_money_values_for_the_same_currency(): afirmar


cinco + cinco == diez

def can_subtract_money_values():
afirmar diez - cinco == cinco

def añadiendo_diferentes_monedas_fallas(): with


pytest.raises(ValueError): Dinero('usd', 10) +
Dinero('gbp', 10)

def puede_multiplicar_dinero_por_un_número():
afirmar cinco * 5 == Dinero('gbp', 25)

def multiplicando_dos_valores_dinero_es_un_error(): with


pytest.raises(TypeError): diez * cinco

Objetos y entidades de valor Una

línea de pedido se identifica de forma única por su ID de pedido, SKU y cantidad; si cambiamos
uno de esos valores, ahora tenemos una nueva línea. Esa es la definición de un objeto de
valor: cualquier objeto que se identifica solo por sus datos y no tiene una identidad de larga duración.
Sin embargo, ¿qué pasa con un lote? Que se identifica por una referencia.

Usamos el término entidad para describir un objeto de dominio que tiene una identidad de larga
duración. En la página anterior, presentamos una clase Nombre como objeto de valor. Si tomamos el
nombre Harry Percival y cambiamos una letra, tenemos el nuevo objeto Nombre Barry Percival.

Debe quedar claro que Harry Percival no es igual a Barry Percival:

Un nombre en sí mismo no puede cambiar…

def prueba_nombre_igualdad():
afirmar Nombre("Harry", "Percival") != Nombre("Barry", "Percival")

Pero, ¿qué pasa con Harry como persona? Las personas cambian de nombre, de estado civil e incluso
de género, pero seguimos reconociéndolas como el mismo individuo. Eso es porque los humanos, a
diferencia de los nombres, tienen una identidad persistente:

¡Pero una persona puede!

Persona de clase :

def __init__(self, nombre: Nombre):


self.name = nombre

Modelos de dominio de pruebas unitarias | 17


Machine Translated by Google

def test_barry_is_harry():
harry = Persona(Nombre("Harry", "Percival"))
barry = harry

barry.nombre = Nombre("Barry", "Percival")

afirmar harry es barry y barry es harry

Las entidades, a diferencia de los valores, tienen igualdad de identidad. Podemos cambiar sus valores, y
siguen siendo reconociblemente lo mismo. Los lotes, en nuestro ejemplo, son entidades. Podemos asignar
líneas a un lote, o cambiar la fecha en la que esperamos que llegue, y seguirá siendo la misma entidad.

Por lo general, hacemos esto explícito en el código implementando operadores de igualdad en las entidades:

Implementando operadores de igualdad (model.py)


lote de clase :
...

def __eq__(self, other): if


not isinstance(other, Batch): return
False return other.reference ==
self.reference

def __hash__(self):
return hash(self.reference)

El método mágico __eq__ de Python define el comportamiento de la clase para el operador == .5

Tanto para objetos de entidad como de valor, también vale la pena pensar en cómo funcionará __hash__ . Es
el método mágico que usa Python para controlar el comportamiento de los objetos cuando los agregas a
conjuntos o los usas como teclas de dictado; puede encontrar más información en los documentos de Python.

Para los objetos de valor, el hash debe basarse en todos los atributos de valor y debemos asegurarnos de
que los objetos sean inmutables. Obtenemos esto gratis especificando @fro zen=True en la clase de datos.

Para las entidades, la opción más simple es decir que el hash es Ninguno, lo que significa que el objeto no se
puede modificar y no puede, por ejemplo, usarse en un conjunto. Si por algún motivo decide que realmente
desea utilizar operaciones de establecimiento o dictado con entidades, el hash debe basarse en los atributos,
como .reference, que definen la identidad única de la entidad a lo largo del tiempo. También debe intentar
hacer que ese atributo sea de solo lectura.

5 El método __eq__ se pronuncia “dunder-EQ”. Por algunos, al menos.

18 | Capítulo 1: Modelado de dominio


Machine Translated by Google

Este es un territorio complicado; no debe modificar __hash__ sin


modificar también __eq__. Si no está seguro de lo que está haciendo,
se sugiere leer más. "Python Hashes and Equality" de nuestro revisor
técnico Hynek Schlawack es un buen lugar para comenzar.

No todo tiene que ser un objeto: un servicio de dominio


Función
Creamos un modelo para representar lotes, pero lo que en realidad debemos hacer es asignar
líneas de pedido a un conjunto específico de lotes que representan todo nuestro stock.

A veces, simplemente no es una cosa.

—Eric Evans, Diseño basado en dominios

Evans analiza la idea de las operaciones del servicio de dominio que no tienen un lugar natural en
una entidad o un objeto de valor.6 Algo que asigna una línea de pedido, dado un conjunto de lotes,
suena mucho a una función y podemos aprovechar del hecho de que Python es un lenguaje
multiparadigma y simplemente convertirlo en una función.

Veamos cómo podemos probar una función de este tipo:

Probando nuestro servicio de dominio (test_allocate.py)

def test_prefers_current_stock_batches_to_shipments():
in_stock_batch = Batch("en-stock-batch", "RETRO-CLOCK", 100, eta=Ninguno)
shipping_batch = Batch(" shipment -batch", "RETRO-CLOCK", 100, eta=mañana) line =
OrderLine( "oref", "RETRO-RELOJ", 10)

allocate(line, [in_stock_batch, shipping_batch ])

afirmar en_stock_batch.cantidad_disponible == 90
afirmar envío_lote.cantidad_disponible == 100

def test_prefers_earlier_batches():
más temprano = Lote("lote-rápido", "CUCHARA-MINIMALISTA", 100, eta=hoy) medio
= Lote("lote-normal", "CUCHARA-MINIMALISTA", 100, eta=mañana) último =
Lote("lento -lote", "MINIMALISTA-CUCHARA", 100, eta=más tarde) línea =
OrderLine("pedido1", "MINIMALISTA-CUCHARA", 10)

allocate(línea, [medio, más temprano, más reciente])

afirmar más temprano.cantidad_disponible == 90

6 Los servicios de dominio no son lo mismo que los servicios de la capa de servicios, aunque a menudo están estrechamente
relacionados. Un servicio de dominio representa un concepto o proceso comercial, mientras que un servicio de capa de servicio
representa un caso de uso para su aplicación. A menudo, la capa de servicio llamará a un servicio de dominio.

No todo tiene que ser un objeto: una función de servicio de dominio | 19


Machine Translated by Google

afirmar medio.cantidad_disponible == 100


afirmar último.cantidad_disponible == 100

def test_returns_allocated_batch_ref():
in_stock_batch = Batch("in-stock-batch-ref", "HIGHBROW-POSTER", 100, eta=Ninguno)
shipping_batch = Lote(" shipment -batch-ref", "HIGHBROW-POSTER", 100, eta=mañana) línea =
OrderLine("oref", "HIGHBROW-POSTER", 10) asignación = allocate(line, [in_stock_batch,
shipping_batch ]) asertar asignación == in_stock_batch.reference

Y nuestro servicio podría verse así:

Una función independiente para nuestro servicio de dominio (model.py)

def allocate(line: OrderLine, lotes: List[Batch]) -> str:


lote = siguiente
( b para b en ordenados (lotes) si b.can_allocate (línea)

) lote.asignar(línea)
volver lote.referencia

Los métodos mágicos de Python Permítanos usar nuestros modelos con Python idiomático Puede o no

gustarle el uso de next() en el código anterior, pero estamos bastante seguros de que estará de acuerdo en que
poder usar sorted() en nuestra lista de lotes es Python agradable e idiomático.

Para que funcione, implementamos __gt__ en nuestro modelo de dominio:

Los métodos mágicos pueden expresar la semántica del dominio (model.py)


lote de clase :
...

def __gt__(self, otro): si


self.eta es Ninguno:
devuelve Falso
si other.eta es Ninguno:
return True
return self.eta > otro.eta

Eso es adorable.

Las excepciones también pueden expresar conceptos de dominio

Tenemos un último concepto que cubrir: las excepciones también pueden usarse para expresar conceptos de
dominio. En nuestras conversaciones con expertos en dominios, aprendimos sobre la posibilidad de que no se
pueda asignar un pedido porque no tenemos existencias, y podemos capturar eso usando una excepción de
dominio:

20 | Capítulo 1: Modelado de dominio


Machine Translated by Google

Prueba de excepción de falta de existencias (test_allocate.py)

def test_raises_out_of_stock_exception_if_cannot_allocate(): batch =


Batch('batch1', 'SMALL-FORK', 10, eta=today)
allocate(OrderLine('order1', 'SMALL-FORK', 10), [batch])

con pytest.raises(OutOfStock, match='SMALL-FORK'):


allocate(OrderLine('order2', 'SMALL-FORK', 1), [lote])

Resumen de modelado de dominio

Modelado de
dominio Esta es la parte de su código que está más cerca del negocio, la que tiene más
probabilidades de cambiar y el lugar donde entrega el mayor valor al negocio. Que sea fácil
de entender y modificar.

Distinguir entidades de objetos de valor


Un objeto de valor se define por sus atributos. Por lo general, se implementa mejor como un
tipo inmutable. Si cambia un atributo en un objeto de valor, representa un objeto diferente.
Por el contrario, una entidad tiene atributos que pueden variar con el tiempo y seguirá siendo
la misma entidad. Es importante definir qué identifica de manera única a una entidad
(generalmente algún tipo de nombre o campo de referencia).

No todo tiene que ser un objeto.


Python es un lenguaje multiparadigma, así que deja que los "verbos" en tu código sean funciones.
Para cada FooManager, BarBuilder o BazFactory, a menudo hay un
manage_foo(), build_bar() o get_baz() más expresivo y legible esperando a suceder.
Este es el momento de aplicar sus mejores principios de
diseño OO. Revise los principios SOLID y todas las demás buenas heurísticas como "tiene
un frente a es-a", "prefiere la composición sobre la herencia", etc.

También querrá pensar en los límites de consistencia y los agregados. Pero


ese es un tema para el Capítulo 7.

No lo aburriremos demasiado con la implementación, pero lo principal a tener en cuenta es que


tenemos cuidado al nombrar nuestras excepciones en el lenguaje ubicuo, tal como lo hacemos con
nuestras entidades, objetos de valor y servicios:

Generación de una excepción de dominio (model.py)

clase OutOfStock (Excepción):


pasar

def allocate(line: OrderLine, lotes: List[Batch]) -> str:

prueba: lote = siguiente (


...

No todo tiene que ser un objeto: una función de servicio de dominio | 21


Machine Translated by Google

excepto StopIteration:
aumentar OutOfStock(f'Agotado para sku {line.sku}')

La figura 1-4 es una representación visual de dónde terminamos.

Figura 1-4. Nuestro modelo de dominio al final del capítulo

¡Eso probablemente sea suficiente por ahora! Tenemos un servicio de dominio que podemos usar para nuestro primer
caso de uso. Pero primero necesitaremos una base de datos...

22 | Capítulo 1: Modelado de dominio


Machine Translated by Google

CAPITULO 2

Patrón de repositorio

Es hora de cumplir nuestra promesa de utilizar el principio de inversión de dependencia como una forma
de desvincular nuestra lógica central de las preocupaciones de infraestructura.

Presentaremos el patrón Repository, una abstracción simplificadora sobre el almacenamiento de datos,


que nos permite desacoplar nuestra capa de modelo de la capa de datos. Presentaremos un ejemplo
concreto de cómo esta abstracción simplificadora hace que nuestro sistema sea más comprobable al
ocultar las complejidades de la base de datos.

La Figura 2-1 muestra una pequeña vista previa de lo que vamos a construir: un objeto Repositorio que
se encuentra entre nuestro modelo de dominio y la base de datos.

Figura 2-1. Antes y después del patrón Repositorio

23
Machine Translated by Google

El código de este capítulo se encuentra en la rama chapter_02_repository


en GitHub.

git clone https://github.com/cosmicpython/code.git cd code git


checkout chapter_02_repository # o para codificar, consulte el
capítulo anterior: git checkout chapter_01_domain_model

Persistir en nuestro modelo de dominio

En el Capítulo 1 creamos un modelo de dominio simple que puede asignar pedidos a lotes de existencias.
Es fácil para nosotros escribir pruebas contra este código porque no hay dependencias o infraestructura
para configurar. Si necesitáramos ejecutar una base de datos o una API y crear datos de prueba,
nuestras pruebas serían más difíciles de escribir y mantener.

Lamentablemente, en algún momento tendremos que poner nuestro pequeño modelo perfecto en manos
de los usuarios y enfrentarnos al mundo real de las hojas de cálculo, los navegadores web y las
condiciones de carrera. En los próximos capítulos, veremos cómo podemos conectar nuestro modelo
de dominio idealizado al estado externo.

Esperamos estar trabajando de manera ágil, por lo que nuestra prioridad es llegar a un producto mínimo
viable lo más rápido posible. En nuestro caso, será una API web. En un proyecto real, puede sumergirse
directamente con algunas pruebas de extremo a extremo y comenzar a conectar un marco web,
probando cosas de afuera hacia adentro.

Pero sabemos que, pase lo que pase, vamos a necesitar alguna forma de almacenamiento persistente,
y este es un libro de texto, por lo que podemos permitirnos un poco más de desarrollo de abajo hacia
arriba y comenzar a pensar en el almacenamiento. y bases de datos.

Algo de pseudocódigo: ¿Qué vamos a necesitar?

Cuando construimos nuestro primer punto final de API, sabemos que vamos a tener un código que se
parece más o menos al siguiente.

Cómo se verá nuestro primer punto final de API

@flask.route.gubbins def
allocate_endpoint(): # extraer
la línea de orden de la línea de solicitud
= OrderLine (request.params, ...) # cargar
todos los lotes de la base de datos lotes = #
llamar a nuestro
... servicio de dominio
allocate(line, lotes) # luego guarde la
asignación en la base de datos de alguna
manera
volver 201

24 | Capítulo 2: Patrón de repositorio


Machine Translated by Google

Usamos Flask porque es liviano, pero no es necesario ser un usuario


de Flask para entender este libro. De hecho, le mostraremos cómo
hacer que su elección de marco sea un detalle menor.

Necesitaremos una forma de recuperar información por lotes de la base de datos e instanciar nuestros objetos de
modelo de dominio a partir de ella, y también necesitaremos una forma de guardarlos en la base de datos.

¿Qué? Oh, "gubbins" es una palabra británica para "cosas". Puedes simplemente ignorar eso. Es pseudocódigo,
¿de acuerdo?

Aplicación del DIP al acceso a datos


Como se mencionó en la introducción, una arquitectura en capas es un enfoque común para estructurar un
sistema que tiene una interfaz de usuario, algo de lógica y una base de datos (consulte la Figura 2-2).

Figura 2-2. arquitectura en capas

La estructura Model-View-Template de Django está estrechamente relacionada, al igual que Model-View


Controller (MVC). En cualquier caso, el objetivo es mantener las capas separadas (lo cual es bueno), y que cada
capa dependa solo de la que está debajo.

Pero queremos que nuestro modelo de dominio no tenga dependencias de ningún tipo. 1 No queremos que los
problemas de infraestructura se desborden en nuestro modelo de dominio y ralenticen nuestras pruebas unitarias
o nuestra capacidad para realizar cambios.

En cambio, como se discutió en la introducción, pensaremos en nuestro modelo como si estuviera en el “adentro”,
y las dependencias fluyen hacia adentro; esto es lo que la gente a veces llama arquitectura cebolla (vea la Figura
2-3).

1 Supongo que queremos decir "sin dependencias con estado". Depender de una biblioteca auxiliar está bien; dependiendo de un
ORM o un framework web no lo es.

Aplicación del DIP al acceso a datos | 25


Machine Translated by Google

Figura 2-3. Arquitectura de cebolla

¿Esto es Puertos y Adaptadores?

Si ha estado leyendo sobre patrones arquitectónicos, es posible que se esté haciendo preguntas
como esta:

¿Son estos puertos y adaptadores? ¿O es arquitectura hexagonal? ¿Es lo mismo que la


arquitectura de cebolla? ¿Qué pasa con la arquitectura limpia? ¿Qué es un puerto y qué es un adaptador?
¿Por qué ustedes tienen tantas palabras para lo mismo?

Aunque a algunas personas les gusta criticar las diferencias, todos estos son más o menos
nombres para lo mismo, y todos se reducen al principio de inversión de dependencia: los módulos
de alto nivel (el dominio) no deberían depender de los de bajo nivel. (la infraestructura).2

Más adelante en el libro , entraremos en algunos de los aspectos esenciales de "dependiendo de


las abstracciones" y si existe un equivalente Pythonic de las interfaces . Consulte también "¿Qué
es un puerto y qué es un adaptador en Python?" en la página 37.

Recordatorio: Nuestro modelo

Recordemos nuestro modelo de dominio (ver Figura 2-4): una asignación es el concepto de vincular
una línea de pedido a un lote. Estamos almacenando las asignaciones como una colección en
nuestro objeto Batch .

2 Mark Seemann tiene una excelente entrada de blog sobre el tema.

26 | Capítulo 2: Patrón de repositorio


Machine Translated by Google

Figura 2-4. nuestro modelo

Veamos cómo podemos traducir esto a una base de datos relacional.

La forma “normal” de ORM: el modelo depende de ORM En estos

días, es poco probable que los miembros de su equipo estén elaborando manualmente sus propias
consultas SQL. En cambio, es casi seguro que esté utilizando algún tipo de marco para generar SQL para
usted en función de sus objetos modelo.

Estos marcos se denominan mapeadores relacionales de objetos (ORM) porque existen para cerrar la
brecha conceptual entre el mundo de los objetos y el modelado de dominios y el mundo de las bases de
datos y el álgebra relacional.

Lo más importante que nos brinda un ORM es la ignorancia de la persistencia: la idea de que nuestro
modelo de dominio sofisticado no necesita saber nada acerca de cómo se cargan o persisten los datos.
Esto ayuda a mantener nuestro dominio libre de dependencias directas de tecnologías de bases de datos
particulares.3

Pero si sigue el tutorial típico de SQLAlchemy, terminará con algo como esto:

Sintaxis "declarativa" de SQLAlchemy, el modelo depende de ORM


(orm.py) de sqlalchemy import Columna, ForeignKey, Integer, String de sqlalchemy.ext.declarative import
declarative_base from sqlalchemy.orm relación de importación

Base = base_declarativa()

Orden de clase (Base):


id = Columna (Entero, clave_principal=Verdadero)

clase OrderLine(Base):

3 En este sentido, usar un ORM ya es un ejemplo del DIP. En lugar de depender de SQL codificado,
dependen de una abstracción, el ORM. Pero eso no es suficiente para nosotros, ¡no en este libro!

Recordatorio: Nuestro Modelo | 27


Machine Translated by Google

id = Columna(Entero, clave_principal=Verdadero)
sku = Columna(Cadena(250)) cantidad =
Entero(Cadena(250)) id_pedido =
Columna(Entero, ClaveExterna('pedido.id')) orden =
relación(Pedido)

Asignación de clase (Base):


...

No necesita entender SQLAlchemy para ver que nuestro modelo prístino ahora está lleno de dependencias
en el ORM y, además, está empezando a verse feo. ¿Podemos realmente decir que este modelo ignora
la base de datos? ¿Cómo puede estar separado de las preocupaciones de almacenamiento cuando las
propiedades de nuestro modelo están directamente acopladas a las columnas de la base de datos?

El ORM de Django es esencialmente el mismo, pero más restrictivo


Si está más acostumbrado a Django, el fragmento SQLAlchemy "declarativo" anterior se
traduce en algo como esto:

Ejemplo de Django ORM


Orden de clase (modelos.Modelo):
pasar

clase OrderLine(modelos.Modelo):
sku = modelos.CharField(longitud_máxima=255)
cantidad = modelos.IntegerField () pedido =
modelos.ForeignKey(Pedido)

Asignación de clase (modelos.Modelo):


...

El punto es el mismo: nuestras clases modelo heredan directamente de las clases ORM, por lo
que nuestro modelo depende del ORM. Queremos que sea al revés.

Django no proporciona un equivalente para el mapeador clásico de SQLAlchemy, pero consulte


el Apéndice D para ver ejemplos de cómo aplicar la inversión de dependencia y el patrón de
Repositorio a Django.

Invertir la dependencia: ORM depende del modelo Bueno, afortunadamente,

esa no es la única forma de usar SQLAlchemy. La alternativa es definir su esquema por separado y definir
un mapeador explícito sobre cómo convertir entre el esquema y nuestro modelo de dominio, lo que
SQLAlchemy llama un mapeo clásico:

28 | Capítulo 2: Patrón de repositorio


Machine Translated by Google

Mapeo ORM explícito con objetos SQLAlchemy Table (orm.py)

de sqlalchemy.orm mapeador de importación , relación

modelo de importación

metadatos = metadatos()

order_lines =
Table( 'order_lines', metadatos,
Columna('id', Entero, clave_principal=Verdadero, incremento automático=Verdadero),
Columna('sku', Cadena(255)),
Columna('cantidad', Entero, anulable=Falso),
Columna('orderid', String(255)),
)

...

def start_mappers():
lines_mapper = mapper(modelo.OrderLine, order_lines)

El ORM importa (o “depende de” o “conoce”) el modelo de dominio, y no al revés.

Definimos las tablas y columnas de nuestra base de datos utilizando las abstracciones de SQLAlchemy.4

Cuando llamamos a la función del mapeador , SQLAlchemy hace su magia para vincular nuestras clases
de modelo de dominio a las diversas tablas que hemos definido.

El resultado final será que, si llamamos a start_mappers, podremos cargar y guardar fácilmente instancias de
modelos de dominio desde y hacia la base de datos. Pero si nunca llamamos a esa función, nuestras clases

de modelo de dominio permanecen felizmente inconscientes de la base de datos.

Esto nos brinda todos los beneficios de SQLAlchemy, incluida la capacidad de usar alambique para migraciones
y la capacidad de consultar de manera transparente utilizando nuestras clases de dominio, como veremos.
ver.

Cuando intenta construir su configuración ORM por primera vez, puede ser útil escribir pruebas para ella, como
en el siguiente ejemplo:

Probando el ORM directamente (pruebas descartables)


(test_orm.py) def test_orderline_mapper_can_load_lines(session): session.execute( 'INSERT INTO
order_lines (orderid, sku, qty) VALORES
'

4 Incluso en proyectos en los que no usamos un ORM, a menudo usamos SQLAlchemy junto con Alambic para crear
esquemas declarativamente en Python y administrar migraciones, conexiones y sesiones.

Recordatorio: Nuestro Modelo | 29


Machine Translated by Google

'("pedido1", "SILLA-ROJA", 12),'


'("pedido1", "MESA-ROJA", 13),'
'("pedido2", "LABIAL-AZUL", 14)'

) esperado = [
model.OrderLine("order1", "RED-SILLA", 12),
model.OrderLine("order1", "RED-TABLE", 13),
model.OrderLine("order2", "BLUE-LIPSTICK", 14) ,

] afirmar session.query(model.OrderLine).all() == esperado

def test_orderline_mapper_can_save_lines(session):
new_line = model.OrderLine("order1", "DECORATIVE-WIDGET", 12)
session.add(new_line) session.commit ()

filas = list(session.execute('SELECT orderid, sku, qty FROM "order_lines"')) afirmar filas ==


[("order1", "DECORATIVE-WIDGET", 12)]

Si no ha usado pytest, es necesario explicar el argumento de la sesión para esta prueba.


No necesita preocuparse por los detalles de pytest o sus accesorios para los fines de este libro,
pero la breve explicación es que puede definir dependencias comunes para sus pruebas como
"accesorios" y pytest las inyectará en el pruebas que los necesitan mirando sus argumentos de
función. En este caso, es una sesión de base de datos SQLAlchemy.

Probablemente no mantendría estas pruebas, como verá en breve, una vez que haya dado el paso de
invertir la dependencia de ORM y el modelo de dominio, es solo un pequeño paso adicional para
implementar otra abstracción llamada patrón de Repositorio, que será más fácil escribir pruebas en
contra y proporcionará una interfaz simple para falsificar más adelante en las pruebas.

Pero ya logramos nuestro objetivo de invertir la dependencia tradicional: el modelo de dominio se


mantiene “puro” y libre de problemas de infraestructura. Podríamos desechar SQLAlchemy y usar un
ORM diferente, o un sistema de persistencia totalmente diferente, y el modelo de dominio no necesita
cambiar en absoluto.

Dependiendo de lo que esté haciendo en su modelo de dominio, y especialmente si se aleja del


paradigma OO, puede que le resulte cada vez más difícil lograr que el ORM produzca el comportamiento
exacto que necesita, y es posible que deba modificar su modelo de dominio.5 Como suele suceder con
las decisiones arquitectónicas, deberá considerar una compensación.
Como dice el Zen de Python, “¡La practicidad vence a la pureza!”

5 Saludos a los mantenedores de SQLAlchemy, increíblemente útiles, y a Mike Bayer en particular.

30 | Capítulo 2: Patrón de repositorio


Machine Translated by Google

En este punto, sin embargo, nuestro punto final de la API podría parecerse a lo siguiente, y podríamos hacer
que funcione correctamente:

Usando SQLAlchemy directamente en nuestro punto final de API

@flask.route.gubbins def
allocate_endpoint():
sesión = inicio_sesión()

# extraer la línea de pedido de la línea de


solicitud = OrderLine ( request.json['orderid'],
request.json['sku'], request.json['qty'],

# cargar todos los lotes de los lotes de


base de datos = session.query (Batch).all()

# llamar a nuestro servicio de dominio


asignar (línea, lotes)

# guardar la asignación de nuevo en la base de datos


session.commit()

volver 201

Introducción al patrón de repositorio


El patrón Repository es una abstracción sobre el almacenamiento persistente. Oculta los aburridos detalles
del acceso a los datos al pretender que todos nuestros datos están en la memoria.

Si tuviéramos memoria infinita en nuestras computadoras portátiles, no necesitaríamos bases de datos torpes.
En cambio, podríamos usar nuestros objetos cuando quisiéramos. Como se veria eso?

Tienes que obtener tus datos de algún lado.

importar todos_mis_datos

def create_a_batch(): lote


= Lote(...)
todos_mis_datos.lotes.añadir(lote)

def modificar_a_batch(batch_id, new_quantity):


lote = todos_mis_datos.lotes.get(id_lote)
lote.cambiar_cantidad_inicial(nueva_cantidad)

Aunque nuestros objetos están en la memoria, debemos colocarlos en algún lugar para poder encontrarlos
nuevamente. Nuestros datos en memoria nos permitirían agregar nuevos objetos, como una lista o un conjunto.
Debido a que los objetos están en la memoria, nunca necesitamos llamar a un método .save() ; simplemente
buscamos el objeto que nos importa y lo modificamos en la memoria.

Introducción al patrón de repositorio | 31


Machine Translated by Google

El repositorio en resumen El repositorio

más simple tiene solo dos métodos: add() para colocar un nuevo elemento en el repositorio y get()
para devolver un elemento agregado previamente . nuestro dominio y nuestra capa de servicio.
Esta simplicidad autoimpuesta nos impide acoplar nuestro modelo de dominio a la base de datos.

Así es como se vería una clase base abstracta (ABC) para nuestro repositorio:

El repositorio más simple posible (repository.py)


clase AbstractRepository(abc.ABC):

@abc.abstractmethod
def add(self, lote: modelo.Lote): generar
NotImplementedError

@abc.abstractmethod
def get(self, reference) -> model.Batch: aumentar
NotImplementedError

Sugerencia de Python: @abc.abstractmethod es una de las únicas cosas que hacen que ABC
realmente "funcione" en Python. Python se negará a permitirle instanciar una clase que no
implemente todos los métodos abstractos definidos en su clase principal.7

aumentar NotImplementedError está bien, pero no es necesario ni suficiente. De hecho, sus


métodos abstractos pueden tener un comportamiento real al que las subclases pueden llamar,
si realmente lo desea.

6 Puede estar pensando: "¿Qué pasa con la lista , la eliminación o la actualización? " Sin embargo, en un mundo ideal, modificamos
nuestros objetos modelo uno a la vez, y la eliminación generalmente se maneja como una eliminación temporal, es decir, lote.cancel().
Finalmente, la actualización está a cargo del patrón Unidad de trabajo, como verá en el Capítulo 6.

7 Para cosechar realmente los beneficios de los ABC (tales como pueden ser), ejecute ayudantes como pylint y mypy.

32 | Capítulo 2: Patrón de repositorio


Machine Translated by Google

Clases base abstractas, tipificación pato y protocolos


Estamos usando clases base abstractas en este libro por razones didácticas: esperamos
que ayuden a explicar cuál es la interfaz de la abstracción del repositorio.

En la vida real, a veces nos encontramos eliminando ABC de nuestro código de producción,
porque Python hace que sea demasiado fácil ignorarlos y terminan sin mantenimiento y, en el
peor de los casos, engañosos. En la práctica, a menudo solo confiamos en el tipeo de pato de
Python para habilitar las abstracciones. Para un Pythonista, un repositorio es cualquier objeto
que tiene métodos add(thing) y get(id) .

Una alternativa a considerar son los protocolos PEP 544. Éstos le permiten escribir sin la
posibilidad de herencia, lo que gustará especialmente a los fanáticos de “preferir la composición
sobre la herencia”.

¿Qué es la compensación?

¿Sabes que dicen que los economistas saben el precio de todo y el valor de nada?
Bueno, los programadores conocen los beneficios de todo y las compensaciones de nada.
—Rich Hickey

Siempre que presentemos un patrón arquitectónico en este libro, siempre preguntaremos: “¿Qué
obtenemos con esto? ¿Y cuánto nos cuesta?

Por lo general, como mínimo, introduciremos una capa adicional de abstracción y, aunque podemos
esperar que reduzca la complejidad en general, agrega complejidad localmente y tiene un costo en
términos de la cantidad bruta de partes móviles y en curso
mantenimiento.

Sin embargo, el patrón Repository es probablemente una de las opciones más fáciles del libro, si ya se
está dirigiendo a la ruta de inversión de dependencia y DDD. En lo que respecta a nuestro código, en
realidad solo estamos intercambiando la abstracción de SQLAlchemy (ses sion.query(Batch)) por una
diferente (batches_repo.get) que diseñamos.

Tendremos que escribir algunas líneas de código en nuestra clase de repositorio cada vez que agreguemos
un nuevo objeto de dominio que queramos recuperar, pero a cambio obtenemos una simple abstracción
sobre nuestra capa de almacenamiento, que controlamos. El patrón Repositorio facilitaría la realización
de cambios fundamentales en la forma en que almacenamos las cosas (consulte el Apéndice C) y, como
veremos, es fácil falsificarlo para las pruebas unitarias.

Además, el patrón Repository es tan común en el mundo de DDD que, si colabora con programadores
que llegaron a Python desde los mundos de Java y C#, es probable que lo reconozcan. La figura 2-5
ilustra el patrón.

Introducción al patrón de repositorio | 33


Machine Translated by Google

Figura 2-5. Patrón de repositorio

Como siempre, comenzamos con una prueba. Esto probablemente se clasificaría como una prueba de integración,
ya que estamos verificando que nuestro código (el repositorio) esté correctamente integrado con la base de datos;
por lo tanto, las pruebas tienden a mezclar SQL sin procesar con llamadas y aserciones en nuestro propio código.

A diferencia de las pruebas ORM anteriores, estas pruebas son buenas candidatas
para permanecer como parte de su base de código a largo plazo, particularmente
si alguna parte de su modelo de dominio significa que el mapa relacional de objetos
no es trivial.

Prueba de repositorio para guardar un objeto (test_repository.py)


def test_repository_can_save_a_batch(sesión): lote =
modelo.Batch("batch1", "RUSTY-SOAPDISH", 100, eta=Ninguno)

repo = repositorio.SqlAlchemyRepository(sesión)
repo.add(lote) sesión.commit ()

filas =
list(session.execute( 'SELECCIONE referencia, sku, _cantidad_comprada, eta DE "lotes"'
))
afirmar filas == [("batch1", "RUSTY-SOAPDISH", 100, None)]

repo.add() es el método bajo prueba aquí.

Mantenemos el .commit() fuera del repositorio y lo hacemos responsabilidad de la persona que llama. Hay
ventajas y desventajas para esto; algunas de nuestras razones se aclararán cuando lleguemos al Capítulo 6.

Usamos el SQL sin procesar para verificar que se hayan guardado los datos correctos.

La siguiente prueba consiste en recuperar lotes y asignaciones, por lo que es más compleja:

34 | Capítulo 2: Patrón de repositorio


Machine Translated by Google

Prueba de repositorio para recuperar un objeto complejo


(test_repository.py) def insert_order_line(session): session.execute( 'INSERT INTO order_lines (orderid,
sku, qty)'

' VALORES ("pedido 1", "SOFÁ GENÉRICO", 12)'

) [[orderline_id]] = sesión.ejecutar(
'SELECCIONE id DE order_lines DONDE orderid=:orderid AND sku=:sku',
dict(orderid="order1", sku="GENERIC-SOFA")

) devuelve orderline_id

def insert_batch(sesión, lote_id):


...

def test_repository_can_retrieve_a_batch_with_allocations(sesión):
orderline_id = insertar_order_line(sesión) lote1_id
= insertar_lote(sesión, "lote1") insertar_lote(sesión,
"lote2") insertar_asignación(sesión, orderline_id ,
lote1_id)

repo = repository.SqlAlchemyRepository(sesión)
recuperado = repo.get("batch1")

esperado = modelo.Lote("lote1", " SOFA GENÉRICO", 100, eta=Ninguno) afirmación


recuperada == esperada # Lote.__eq__ solo compara la afirmación de referencia recuperada.sku ==
esperada.sku afirmación recuperada._cantidad_comprada == esperada ._afirmación de
cantidad_comprada recuperada._asignaciones == { model.OrderLine ("order1", "GENERIC-SOFA",
12),

Esto prueba el lado de lectura, por lo que el SQL sin formato está preparando los datos para que
los lea repo.get().

Le ahorraremos los detalles de insert_batch e insert_allocation; el objetivo es crear un par de lotes


y, para el lote que nos interesa, tener asignada una línea de pedido existente.

Y eso es lo que verificamos aquí. La primera afirmación == verifica que los tipos coincidan y que la
referencia sea la misma (porque, como recordará, Batch es una entidad y tenemos un eq
personalizado para ello).

Por lo tanto, también verificamos explícitamente sus principales atributos, incluido ._allocations, que
es un conjunto de Python de objetos de valor OrderLine .

Ya sea que escriba minuciosamente o no pruebas para cada modelo es una cuestión de juicio. Una vez
que haya probado una clase para crear/modificar/guardar, puede estar feliz de continuar y hacer

Introducción al patrón de repositorio | 35


Machine Translated by Google

los demás con una mínima prueba de ida y vuelta, o incluso nada, si todos siguen un patrón similar.
En nuestro caso, la configuración de ORM que configura el conjunto ._allocations es un poco
compleja, por lo que amerita una prueba específica.

Terminas con algo como esto:

Un repositorio típico (repository.py)

clase SqlAlchemyRepository(AbstractRepository):

def __init__(self, sesión):


self.session = sesión

def add(auto, lote):


self.session.add(lote)

def get(auto, referencia):


devuelve self.session.query(model.Batch).filter_by(reference=reference).one()

def list(self):
return self.session.query(model.Batch).all()

Y ahora nuestro punto final Flask podría parecerse a lo siguiente:

Usando nuestro repositorio directamente en nuestro punto final API

@flask.route.gubbins def
allocate_endpoint():
lotes = SqlAlchemyRepository.list() líneas =
[ OrderLine(l['orderid'], l['sku'], l['qty']) for l in
request.params...

] allocate(líneas, lotes)
session.commit() return 201

Ejercicio para el lector


Nos encontramos con un amigo en una conferencia de DDD el otro día que dijo: "No he usado
un ORM en 10 años". El patrón Repository y un ORM actúan como abstracciones frente a
SQL sin procesar, por lo que no es realmente necesario usar uno detrás del otro. ¿Por qué
no intentar implementar nuestro repositorio sin usar el ORM? Encontrarás el código en GitHub.

Hemos dejado las pruebas del repositorio, pero decidir qué SQL escribir depende de usted.
Quizás sea más difícil de lo que crees; quizás sea más fácil. Pero lo bueno es que al resto de
su aplicación simplemente no le importa.

36 | Capítulo 2: Patrón de repositorio


Machine Translated by Google

¡Crear un repositorio falso para pruebas ahora es trivial!


Este es uno de los mayores beneficios del patrón Repositorio:

Un repositorio falso simple usando un conjunto (repository.py)

clase FakeRepository (AbstractRepository):

def __init__(uno mismo, lotes):


self._lotes = conjunto(lotes)

def agregar(auto, lote):


self._lotes.agregar(lote)

def get(auto, referencia):


volver siguiente (b para b en self._batches si b.reference == referencia)

def list(self):
return list(self._batches)

Debido a que es un envoltorio simple alrededor de un conjunto, todos los métodos son de una sola línea.

Usar un repositorio falso en las pruebas es realmente fácil, y tenemos una abstracción simple que es fácil de usar y
razonar sobre:

Ejemplo de uso de repositorio falso (test_api.py)

fake_repo = FakeRepository([lote1, lote2, lote3])

Verás esta falsificación en acción en el próximo capítulo.

Construir falsificaciones para sus abstracciones es una excelente manera de obtener


comentarios sobre el diseño: si es difícil de falsificar, la abstracción probablemente sea
demasiado complicada.

¿Qué es un puerto y qué es un adaptador en Python?


No queremos detenernos demasiado en la terminología porque lo principal en lo que queremos centrarnos es en la
inversión de dependencias, y los detalles de la técnica que utilice no importan demasiado. Además, somos conscientes
de que diferentes personas usan definiciones ligeramente diferentes.

Los puertos y adaptadores surgieron del mundo OO, y la definición que mantenemos es que el puerto es la interfaz entre
nuestra aplicación y lo que sea que deseemos abstraer, y el adaptador es la implementación detrás de esa interfaz o
abstracción.

Ahora, Python no tiene interfaces per se, por lo que aunque suele ser fácil identificar un adaptador, definir el puerto puede
ser más difícil. Si está utilizando una clase base abstracta, eso es

¡Crear un repositorio falso para pruebas ahora es trivial!


| 37
Machine Translated by Google

el puerto. De lo contrario, el puerto es solo el tipo de pato al que se ajustan sus adaptadores y
que su aplicación principal espera: los nombres de funciones y métodos en uso, y sus nombres
y tipos de argumentos.

Concretamente, en este capítulo, AbstractRepository es el puerto, y SqlAlchemyRepository y


FakeRepository son los adaptadores.

Envolver
Teniendo en cuenta la cita de Rich Hickey, en cada capítulo resumimos los costos y beneficios
de cada patrón arquitectónico que presentamos. Queremos dejar claro que no estamos diciendo
que todas las aplicaciones deban construirse de esta manera; solo a veces la complejidad de
la aplicación y el dominio hacen que valga la pena invertir tiempo y esfuerzo en agregar estas
capas adicionales de indirección.

Con eso en mente, la Tabla 2-1 muestra algunos de los pros y los contras del patrón Repositorio
y nuestro modelo de persistencia-ignorante.

Tabla 2-1. Patrón de repositorio e ignorancia persistente: las ventajas y desventajas

ventajas Contras

• Tenemos una interfaz simple entre el almacenamiento persistente y nuestro modelo • Un ORM ya te compra algo de desacoplamiento.
de dominio. Cambiar claves foráneas puede ser difícil, pero debería

• Es fácil crear una versión falsa del repositorio para pruebas unitarias o intercambiar ser bastante fácil cambiar entre MySQL y Postgres si alguna

diferentes soluciones de almacenamiento, porque hemos desvinculado completamente el vez lo necesita.

modelo de las preocupaciones de infraestructura. • Escribir el modelo de dominio antes

de pensar en la persistencia nos ayuda a centrarnos en el problema empresarial en cuestión. • El mantenimiento de mapeos ORM a mano requiere trabajo
adicional y código adicional.
Si alguna vez queremos cambiar radicalmente nuestro enfoque, podemos hacerlo en

nuestro modelo, sin necesidad de preocuparnos por las claves externas o las migraciones • Cualquier capa adicional de direccionamiento indirecto siempre
hasta más tarde. • El esquema de nuestra base de datos es realmente simple porque aumenta los costos de mantenimiento y agrega un "factor

tenemos control total sobre cómo asignamos nuestros objetos a las tablas. WTF" para los programadores de Python que nunca antes

habían visto el patrón del Repositorio.

La Figura 2-6 muestra la tesis básica: sí, para casos simples, un modelo de dominio
desacoplado es más difícil que un patrón ORM/ActiveRecord simple.8

Si su aplicación es simplemente un contenedor CRUD (crear-leer-actualizar-


eliminar) alrededor de una base de datos, entonces no necesita un modelo de
dominio o un repositorio.

8 Diagrama inspirado en una publicación llamada “Complejidad global, simplicidad local” de Rob Vens.

38 | Capítulo 2: Patrón de repositorio


Machine Translated by Google

Pero cuanto más complejo sea el dominio, más valdrá la pena invertir en liberarse de las preocupaciones de
infraestructura en términos de la facilidad para realizar cambios.

Figura 2-6. Compensaciones del modelo de dominio como diagrama

Nuestro código de ejemplo no es lo suficientemente complejo como para dar más que una pista de cómo se ve el lado
derecho del gráfico, pero las pistas están ahí. Imagínese, por ejemplo, si un día decidimos que queremos cambiar las
asignaciones para vivir en OrderLine en lugar de en el objeto Batch : si estuviéramos usando Django, digamos,
tendríamos que definir y pensar en la migración de la base de datos antes de podría ejecutar cualquier prueba. Tal
como están las cosas, debido a que nuestro modelo son simples objetos antiguos de Python, podemos cambiar un set()
para que sea un atributo nuevo, sin necesidad de pensar en la base de datos hasta más tarde.

Resumen de patrones de repositorio

Aplicar inversión de dependencia a su ORM


Nuestro modelo de dominio debe estar libre de problemas de infraestructura, por lo que su ORM debe
importar su modelo y no al revés.

El patrón Repository es una simple abstracción en torno al almacenamiento permanente


El repositorio le da la ilusión de una colección de objetos en memoria. Facilita la creación de un
FakeRepository para realizar pruebas e intercambiar detalles fundamentales de su infraestructura sin
interrumpir su aplicación principal. Consulte el Apéndice C para ver un ejemplo.

Te estarás preguntando, ¿cómo instanciamos estos repositorios, falsos o reales? ¿Cómo será realmente nuestra
aplicación Flask? Lo descubrirá en la próxima entrega emocionante, el patrón de la capa de servicio.

Pero antes, una breve digresión.

Resumen | 39
Machine Translated by Google
Machine Translated by Google

CAPÍTULO 3

Un breve interludio: sobre el


acoplamiento y las abstraccione

Permítanos una breve digresión sobre el tema de las abstracciones, querido lector. Hemos hablado
mucho de abstracciones. El patrón Repository es una abstracción sobre el almacenamiento permanente,
por ejemplo. Pero, ¿qué hace una buena abstracción? ¿Qué queremos de las abstracciones? ¿Y cómo
se relacionan con las pruebas?

El código de este capítulo está en la rama chapter_03_abstractions


en GitHub:

clon de git https://github.com/cosmicpython/code.git pago de


git chapter_03_abstractions

Un tema clave en este libro, oculto entre los patrones de fantasía, es que podemos usar abstracciones
simples para ocultar detalles desordenados. Cuando estamos escribiendo código por diversión, o en
un kata,1 podemos jugar con ideas libremente, elaborando cosas y refactorizando agresivamente. Sin
embargo, en un sistema a gran escala, nos vemos limitados por las decisiones que se toman en otras
partes del sistema.

Cuando no podemos cambiar el componente A por temor a romper el componente B, decimos que los
componentes se han acoplado. A nivel local, el acoplamiento es algo bueno: es una señal de que
nuestro código está trabajando en conjunto, cada componente apoya a los demás, todos encajando en
su lugar como los engranajes de un reloj. En la jerga, decimos que esto funciona cuando hay una alta
cohesión entre los elementos acoplados.

1 Un código kata es un desafío de programación pequeño y contenido que a menudo se usa para practicar TDD. Véase “Kata: el único
Manera de aprender TDD” por Peter Provost.

41
Machine Translated by Google

A nivel mundial, el acoplamiento es una molestia: aumenta el riesgo y el costo de cambiar nuestro código, a veces
hasta el punto en que nos sentimos incapaces de realizar ningún cambio. Este es el problema con el patrón Ball
of Mud: a medida que crece la aplicación, si no podemos evitar el acoplamiento entre elementos que no tienen
cohesión, ese acoplamiento aumenta superlinealmente hasta que ya no podemos cambiar nuestros sistemas de
manera efectiva.

Podemos reducir el grado de acoplamiento dentro de un sistema (Figura 3-1) abstrayendo los detalles (Figura 3-2).

Figura 3-1. mucho acoplamiento

Figura 3-2. Menos acoplamiento

En ambos diagramas, tenemos un par de subsistemas, uno dependiente del otro. En la Figura 3-1, hay un alto
grado de acoplamiento entre los dos; el número de flechas indica muchos tipos de dependencias entre los dos. Si
necesitamos cambiar el sistema B, es muy probable que el cambio se extienda al sistema A.

En la figura 3-2, sin embargo, hemos reducido el grado de acoplamiento al insertar una abstracción nueva y más
simple. Debido a que es más simple, el sistema A tiene menos tipos de dependencias en la abstracción. La
abstracción sirve para protegernos del cambio al ocultar los detalles complejos de cualquier cosa que haga el
sistema B: podemos cambiar las flechas de la derecha sin cambiar las de la izquierda.

42 | Capítulo 3: Un breve interludio: sobre el acoplamiento y las abstracciones


Machine Translated by Google

Resumen de la comprobabilidad de las ayudas estatales

Veamos un ejemplo. Imagine que queremos escribir código para sincronizar dos directorios de archivos, a
los que llamaremos origen y destino:

• Si existe un archivo en el origen pero no en el destino, cópielo. • Si existe un archivo en el

origen, pero tiene un nombre diferente al del destino, cambie el nombre del archivo de destino para que
coincida.

• Si existe un archivo en el destino pero no en el origen, elimínelo.

Nuestros requisitos primero y tercero son bastante simples: solo podemos comparar dos listas de rutas. Sin
embargo, nuestro segundo es más complicado. Para detectar cambios de nombre, tendremos que
inspeccionar el contenido de los archivos. Para ello, podemos utilizar una función hash como MD5 o SHA-1.
El código para generar un hash SHA-1 a partir de un archivo es bastante simple:

Hashing de un archivo (sync.py)

TAMAÑO DE BLOQUE = 65536

def hash_file(ruta): hasher =


hashlib.sha1() with path.open("rb")
como archivo:
buf = archivo.leer(TAMAÑO DE BLOQUE)
mientras que buf:

hasher.update(buf) buf =
file.read(BLOCKSIZE)
devolver hasher.hexdigest()

Ahora tenemos que escribir la parte que toma las decisiones sobre qué hacer: la lógica empresarial, por así

decirlo.

Cuando tenemos que abordar un problema desde los primeros principios, generalmente tratamos de escribir
una implementación simple y luego refactorizar hacia un mejor diseño. Usaremos este enfoque a lo largo del
libro, porque es la forma en que escribimos código en el mundo real: comience con una solución para la parte
más pequeña del problema y luego haga que la solución sea más rica y mejor diseñada.

Nuestro primer enfoque hackish se parece a esto:

Algoritmo de sincronización básico (sync.py)

import hashlib
import os import
shutil from pathlib
import Path

sincronización def (origen, destino):


# Camine por la carpeta de origen y cree un diccionario de nombres de archivo y sus hashes

Resumen de la comprobabilidad de las ayudas estatales | 43


Machine Translated by Google

source_hashes = {}
para carpeta, archivos
_, en os.walk(fuente): para fn en
archivos:
source_hashes[hash_file(Ruta(carpeta) / fn)] = fn

seen = set() # Realizar un seguimiento de los archivos que hemos encontrado en el objetivo

# Camine por la carpeta de destino y obtenga los nombres de archivo


y hashes para _,
la carpeta, archivos
archivos: en os.walk(dest):
dest_path = Path(folder) para fn en
/ fn dest_hash =
hash_file(dest_path) seen.add(dest_hash)

# si hay un archivo en el destino que no está en el origen, elimínelo si


dest_hash no está en source_hashes: dest_path.remove()

# si hay un archivo en el destino que tiene una ruta diferente en el origen, #


muévalo a la ruta correcta elif dest_hash en source_hashes y fn !=
source_hashes[dest_hash]: shutil.move(dest_path, Path(folder) /
source_hashes[dest_hash] )

# para cada archivo que aparece en el origen pero no en el destino, copie el archivo # en
el destino para src_hash, fn en source_hashes.items(): si src_hash no se ve:

shutil.copy(Ruta(origen) / fn, Ruta(destino) / fn)

¡Fantástico! Tenemos algo de código y se ve bien, pero antes de ejecutarlo en nuestro disco
duro, tal vez deberíamos probarlo. ¿Cómo hacemos para probar este tipo de cosas?

Algunas pruebas de extremo a extremo (test_sync.py)

def test_when_a_file_exists_in_the_source_but_not_the_destination():

intente: fuente = tempfile.mkdtemp()


dest = tempfile.mkdtemp()

content = "Soy un archivo muy útil"


(Ruta(fuente) / 'mi-archivo').write_text(contenido)

sincronización (origen, destino)

ruta_esperada = Ruta(destino) / 'mi-archivo'


afirmar ruta_esperada.existe() afirmar
ruta_esperada.leer_texto() == contenido

finalmente: shutil.rmtree
(fuente) shutil.rmtree (destino)

44 | Capítulo 3: Un breve interludio: sobre el acoplamiento y las abstracciones


Machine Translated by Google

def prueba_cuando_un_archivo_ha_sido_renombrado_en_la_fuente():

intente: fuente = tempfile.mkdtemp()


dest = tempfile.mkdtemp()

content = "Soy un archivo que fue renombrado"


source_path = Path(source) / 'source-filename'
old_dest_path = Path(dest) / 'dest-filename '
Expected_dest_path = Path( dest) / 'source-filename'
source_path.write_text (contenido) old_dest_path.write_text
(contenido)

sincronización (origen, destino)

afirmar old_dest_path.exists ( ) es falso _ _

finalmente: shutil.rmtree
(fuente) shutil.rmtree (destino)

¡Wowsers, eso es mucha configuración para dos casos simples! El problema es que nuestra lógica de dominio,
"descubrir la diferencia entre dos directorios", está estrechamente relacionada con el código de E/S. No podemos
ejecutar nuestro algoritmo de diferencias sin llamar a pathlib, shutil y
módulos hashlib .

Y el problema es que, incluso con nuestros requisitos actuales, no hemos escrito suficientes pruebas: la
implementación actual tiene varios errores (el shutil.move() es incorrecto, por ejemplo). Obtener una cobertura
decente y revelar estos errores significa escribir más pruebas, pero si todas son tan difíciles de manejar como las
anteriores, eso se volverá muy doloroso muy rápidamente.

Además de eso, nuestro código no es muy extensible. Imagínese intentar implementar un indicador de ejecución
en seco que haga que nuestro código simplemente imprima lo que va a hacer, en lugar de hacerlo realmente. ¿O
qué pasaría si quisiéramos sincronizar con un servidor remoto o con el almacenamiento en la nube?

Nuestro código de alto nivel está acoplado a detalles de bajo nivel y está complicando la vida. A medida que los
escenarios que consideramos se vuelvan más complejos, nuestras pruebas se volverán más difíciles de manejar.
Definitivamente podemos refactorizar estas pruebas (algunas de las tareas de limpieza podrían incluirse en
dispositivos de prueba de pytest, por ejemplo), pero mientras realicemos operaciones del sistema de archivos, se
mantendrán lentas y serán difíciles de leer y escribir.

Resumen de la comprobabilidad de las ayudas estatales | 45


Machine Translated by Google

Elegir la(s) abstracción(es) correcta(s)


¿Qué podríamos hacer para reescribir nuestro código y hacerlo más comprobable?

Primero, debemos pensar en lo que nuestro código necesita del sistema de archivos. Al leer el código, podemos
ver que están sucediendo tres cosas distintas. Podemos pensar en estos como tres responsabilidades distintas
que tiene el código:

1. Interrogamos el sistema de archivos usando os.walk y determinamos hashes para una serie de rutas. Esto
es similar tanto en el caso de origen como en el de destino.

2. Decidimos si un archivo es nuevo, renombrado o redundante.

3. Copiamos, movemos o eliminamos archivos para que coincidan con la fuente.

Recuerde que queremos encontrar abstracciones simplificadoras para cada una de estas responsabilidades.
Eso nos permitirá ocultar los detalles desordenados para que podamos concentrarnos en la lógica interesante.2

En este capítulo, estamos refactorizando un código retorcido en una


estructura más comprobable identificando las tareas separadas que deben
realizarse y asignando cada tarea a un actor claramente definido, de manera
similar al ejemplo de duckduckgo .

Para los pasos 1 y 2, ya comenzamos intuitivamente a usar una abstracción, un diccionario de hash para rutas.
Es posible que ya haya estado pensando: "¿Por qué no crea un diccionario para la carpeta de destino, así como
la fuente, y luego simplemente comparamos dos diccionarios?" Esa parece una buena manera de abstraer el
estado actual del sistema de archivos:

archivos_origen = {'hash1': 'ruta1', 'hash2': 'ruta2'} archivos_destino =


{'hash1': 'ruta1', 'hash2': 'rutaX'}

¿Qué pasa con pasar del paso 2 al paso 3? ¿Cómo podemos abstraer la interacción real de mover/copiar/
eliminar el sistema de archivos?

Aplicaremos aquí un truco que emplearemos a gran escala más adelante en el libro. Vamos a separar lo que
queremos hacer de cómo hacerlo. Vamos a hacer que nuestro programa genere una lista de comandos que se
vean así:

("COPIA", "ruta de origen", "ruta de destino"),


("MOVER", "antiguo", "nuevo"),

Ahora podríamos escribir pruebas que solo usen dos dictados del sistema de archivos como entradas, y
esperaríamos listas de tuplas de cadenas que representen acciones como salidas.

2 Si está acostumbrado a pensar en términos de interfaces, eso es lo que estamos tratando de definir aquí.

46 | Capítulo 3: Un breve interludio: sobre el acoplamiento y las abstracciones


Machine Translated by Google

En lugar de decir: "Dado este sistema de archivos real, cuando ejecute mi función, verifique qué
acciones han sucedido", decimos: "Dada esta abstracción de un sistema de archivos, ¿qué
abstracción de acciones del sistema de archivos sucederá?"

Entradas y salidas simplificadas en nuestras pruebas (test_sync.py)

def prueba_cuando_un_archivo_existe_en_el_origen_pero_no_en_el_destino():
src_hashes = {'hash1': 'fn1'} dst_hashes = {} acciones_esperadas = [('COPY', '/
src/fn1', '/dst/fn1')]

...

def prueba_cuando_un_archivo_ha_sido_renombrado_en_la_fuente():
src_hashes = {'hash1': 'fn1'}
dst_hashes = {' hash1': ' fn2 '}
acciones_esperadas == [('MOVER', '/dst/fn2', '/dst/fn1')]
...

Implementando nuestras abstracciones elegidas


Eso está muy bien, pero ¿cómo escribimos esas nuevas pruebas y cómo cambiamos nuestra
implementación para que todo funcione?

Nuestro objetivo es aislar la parte inteligente de nuestro sistema y poder probarlo a fondo sin
necesidad de configurar un sistema de archivos real. Crearemos un "núcleo" de código que no
dependa del estado externo y luego veremos cómo responde cuando le damos información del
mundo exterior (este tipo de enfoque fue caracterizado por Gary Bernhardt como Functional Core,
Imperative Shell o FCIS).

Comencemos por dividir el código para separar las partes con estado de la lógica.

Y nuestra función de nivel superior no contendrá casi ninguna lógica; es solo una serie imperativa de
pasos: recopilar entradas, llamar a nuestra lógica, aplicar salidas:

Divida nuestro código en tres (sync.py)

sincronización def (origen, destino):


# shell imperativo paso 1, recopilar entradas
source_hashes = read_paths_and_hashes(source)
dest_hashes = read_paths_and_hashes(dest)

# paso 2: llamar a las acciones


principales funcionales = determine_actions(source_hashes, dest_hashes, source, dest)

# shell imperativo paso 3, aplicar salidas para


acción, *ruta en acciones:
if action == 'copiar':
shutil.copyfile(*rutas) if
action == 'mover':
shutil.move(*rutas)

Implementando nuestras abstracciones elegidas | 47


Machine Translated by Google

si acción == 'eliminar':
os.remove(rutas[0])

Aquí está la primera función que eliminamos, read_paths_and_hashes(), que aísla la parte de E/S de
nuestra aplicación.

Aquí es donde se labra el núcleo funcional, la lógica empresarial.

El código para construir el diccionario de rutas y hashes ahora es trivialmente fácil de escribir:

Una función que solo realiza E/S (sync.py)

def read_paths_and_hashes(root):
hashes = {} for folder, files in
os.walk(root): for in files:
_, fnhashes[hash_file(Path(folder) /
fn)] = fn

devolver hashes

La función determine_actions() será el núcleo de nuestra lógica comercial, que dice: "Dados estos dos
conjuntos de hash y nombres de archivo, ¿qué debemos copiar/mover/eliminar?". Toma estructuras de datos
simples y devuelve estructuras de datos simples:

Una función que solo hace lógica de negocios (sync.py)

def determine_actions(src_hashes, dst_hashes, src_folder, dst_folder):


para sha, nombre de archivo en
src_hashes.items(): si sha no está en
dst_hashes: sourcepath = Path(src_folder) /
filename destpath = Path(dst_folder) / filename
yield 'copy', sourcepath, destpath

elif dst_hashes[sha] != nombre de


archivo: olddestpath = Path(dst_folder) / dst_hashes[sha]
newdestpath = Path(dst_folder) / filename yield 'move',
olddestpath, newdestpath

para sha, nombre de archivo en dst_hashes.items():


si sha no está en src_hashes: produce
'eliminar', dst_folder / nombre de archivo

Nuestras pruebas ahora actúan directamente sobre la función determine_actions() :

Pruebas más bonitas (test_sync.py)

def test_when_a_file_exists_in_the_source_but_not_the_destination(): src_hashes


= {'hash1': 'fn1'} dst_hashes = {} acciones = determine_actions(src_hashes,
dst_hashes, Path('/src'), Path('/dst')) asert list(actions) = = [('copiar', Ruta('/src/
fn1'), Ruta('/dst/fn1'))]

...

48 | Capítulo 3: Un breve interludio: sobre el acoplamiento y las abstracciones


Machine Translated by Google

def prueba_cuando_un_archivo_ha_sido_renombrado_en_la_fuente():
src_hashes = {'hash1': 'fn1'}
dst_hashes = {'hash1': 'fn2'} acciones
= determine_actions(src_hashes, dst_hashes, Path('/src'), Path('/dst')) asertar lista(acciones ) ==
[('mover', Ruta('/dst/fn2'), Ruta('/dst/fn1'))]

Debido a que hemos desenredado la lógica de nuestro programa, el código para identificar cambios, de los
detalles de bajo nivel de E/S, podemos probar fácilmente el núcleo de nuestro código.

Con este enfoque, pasamos de probar nuestra función de punto de entrada principal, sync(), a probar una
función de nivel inferior, determine_actions(). Puede decidir que está bien porque sync() ahora es muy
simple. O puede decidir mantener algunas pruebas de integración/aceptación para probar esa
sincronización(). Pero hay otra opción, que es modificar la función sync() para que pueda probarse de
forma unitaria y de extremo a extremo; es un enfoque que Bob denomina pruebas de extremo a extremo.

Pruebas de extremo a extremo con falsificaciones e inyección de dependencias

Cuando comenzamos a escribir un nuevo sistema, a menudo nos enfocamos primero en la lógica central,
conduciéndola con pruebas unitarias directas. Sin embargo, en algún momento, queremos probar juntos
partes más grandes del sistema.

Podríamos volver a nuestras pruebas de extremo a extremo, pero siguen siendo tan difíciles de escribir y
mantener como antes. En cambio, a menudo escribimos pruebas que invocan un sistema completo pero
falsifican la E/S, de borde a borde:

Dependencias explícitas (sync.py)

def sync (lector, sistema de archivos, source_root, dest_root):

source_hashes = lector(source_root)
dest_hashes = lector(dest_root)

para sha, nombre de archivo en src_hashes.items():


si sha no está en dest_hashes: sourcepath =
source_root / filename destpath = dest_root /
filename filesystem.copy(destpath, sourcepath)

elif dest_hashes[sha] != nombre de archivo:


olddestpath = dest_root / dest_hashes[sha] newdestpath
= dest_root / filename filesystem.move(olddestpath,
newdestpath)

para sha, nombre de archivo en dst_hashes.items():


si sha no está en source_hashes:
filesystem.delete(dest_root/filename)

Implementando nuestras abstracciones elegidas | 49


Machine Translated by Google

Nuestra función de nivel superior ahora expone dos nuevas dependencias, un lector y un sistema de
archivos.

Invocamos al lector para que produzca nuestros archivos dict.

Invocamos el sistema de archivos para aplicar los cambios que detectamos.

Aunque estamos utilizando la inyección de dependencia, no es necesario


definir una clase base abstracta ni ningún tipo de interfaz explícita. En este
libro, a menudo mostramos ABC porque esperamos que te ayuden a
entender cuál es la abstracción, pero no son necesarios. La naturaleza
dinámica de Python significa que siempre podemos confiar en la tipificación pato.

Pruebas usando DI

clase FakeFileSystem (lista):

def copiar(self, src, dest):


self.append(('COPY', src, dest))

def move(self, src, dest):


self.append(('MOVE', src, dest))

def eliminar(self, dest):


self.append(('DELETE', src, dest))

def test_when_a_file_exists_in_the_source_but_not_the_destination(): source = {"sha1":


"my-file" } dest = {} filesystem = FakeFileSystem()

lector = {"/fuente": fuente, "/destino": destino}


synchronise_dirs(lector.pop, sistema de archivos, "/fuente", "/destino")

afirmar sistema de archivos == [("COPIA", "/fuente/mi-archivo", "/destino/mi-archivo")]

def test_when_a_file_has_been_renamed_in_the_source(): fuente =


{"sha1": "archivo renombrado" } dest = {"sha1": "archivo original" }
sistema de archivos = FakeFileSystem()

lector = {"/fuente": fuente, "/destino": destino}


synchronise_dirs(lector.pop, sistema de archivos, "/fuente", "/destino")

afirmar sistema de archivos == [("MOVER", "/destino/archivo-original", "/destino/archivo-renombrado")]

50 | Capítulo 3: Un breve interludio: sobre el acoplamiento y las abstracciones


Machine Translated by Google

A Bob le encanta usar listas para crear dobles de prueba simples, aunque sus compañeros de trabajo se
enojen. Significa que podemos escribir pruebas como afirmar foo no en la base de datos.

Cada método en nuestro FakeFileSystem simplemente agrega algo a la lista para que podamos inspeccionarlo
más tarde. Este es un ejemplo de un objeto espía.

La ventaja de este enfoque es que nuestras pruebas actúan exactamente sobre la misma función que utiliza nuestro
código de producción. La desventaja es que tenemos que hacer explícitos nuestros componentes con estado y
pasarlos. David Heinemeier Hansson, el creador de Ruby on Rails, describió esto como "daño de diseño inducido por
pruebas".

En cualquier caso, ahora podemos trabajar para corregir todos los errores en nuestra implementación; ahora es
mucho más fácil enumerar las pruebas para todos los casos extremos.

¿Por qué no simplemente parchearlo?

En este punto, es posible que se esté rascando la cabeza y pensando: "¿Por qué no usa mock.patch y se ahorra el
esfuerzo?"

Evitamos el uso de simulacros en este libro y también en nuestro código de producción. No vamos a entrar en una
Guerra Santa, pero nuestro instinto es que los marcos burlones, en particular los parches mono, son un olor a código.

En cambio, nos gusta identificar claramente las responsabilidades en nuestra base de código y separar esas
responsabilidades en objetos pequeños y enfocados que son fáciles de reemplazar con un doble de prueba.

Puede ver un ejemplo en el Capítulo 8, donde simulamos.patch() un módulo


de envío de correo electrónico, pero eventualmente lo reemplazamos con un
poco explícito de inyección de dependencia en el Capítulo 13.

Tenemos tres razones estrechamente relacionadas para nuestra preferencia:

• Parchear la dependencia que está utilizando hace posible la prueba unitaria del código, pero no hace nada para
mejorar el diseño. El uso de mock.patch no permitirá que su código funcione con un indicador de ejecución en
seco , ni lo ayudará a ejecutarse contra un servidor FTP. Para eso, necesitarás introducir abstracciones.

• Las pruebas que usan simulacros tienden a estar más acopladas a los detalles de implementación del código
base. Eso es porque las pruebas simuladas verifican las interacciones entre las cosas: ¿llamamos a shutil.copy
con los argumentos correctos? Este acoplamiento entre el código y la prueba tiende a hacer que las pruebas
sean más frágiles, según nuestra experiencia. • El uso excesivo de simulacros genera conjuntos de pruebas

complicados que no explican el código.

Implementando nuestras abstracciones elegidas | 51


Machine Translated by Google

Diseñar para la capacidad de prueba realmente significa diseñar para la extensibilidad.


Cambiamos un poco más de complejidad por un diseño más limpio que admite casos
de uso novedosos.

Mocks Versus Fakes; Estilo clásico Versus London-School TDD


Aquí hay una definición corta y algo simplista de la diferencia entre simulacros y falsificaciones:

• Los simulacros se usan para verificar cómo se usa algo; tienen métodos como afirmar_llamado_una
vez_con(). Están asociados con la escuela de Londres TDD. • Las falsificaciones son

implementaciones funcionales de lo que reemplazan, pero están diseñadas para usarse solo en
pruebas. No funcionarían “en la vida real”; nuestro repositorio en memoria es un buen ejemplo.
Pero puede usarlos para hacer afirmaciones sobre el estado final de un sistema en lugar de los
comportamientos a lo largo del camino, por lo que están asociados con TDD de estilo clásico.

Estamos combinando ligeramente simulacros con espías y falsificaciones con stubs aquí, y puede
leer la respuesta larga y correcta en el ensayo clásico de Martin Fowler sobre el tema llamado "Mocks
Aren't Stubs".

Probablemente tampoco ayude que los objetos MagicMock proporcionados por unittest.mock no
sean, estrictamente hablando, simulacros; son espías, en todo caso. Pero también se usan a menudo
como stubs o dummy. Listo, prometemos que ya hemos terminado con los detalles de doble
terminología de la prueba.

¿Qué hay de la escuela londinense frente al TDD de estilo clásico? Puede leer más sobre estos dos
en el artículo de Martin Fowler que acabamos de citar, así como en el sitio de Software Engineering
Stack Exchange, pero en este libro estamos firmemente en el campo clásico. Nos gusta construir
nuestras pruebas en torno al estado tanto en la configuración como en las aserciones, y nos gusta
trabajar al más alto nivel de abstracción posible en lugar de verificar el comportamiento de los
colaboradores intermediarios.3 Lea más sobre esto en “Sobre la decisión de qué tipo de Pruebas

para escribir” en la página 73.

Vemos TDD como una práctica de diseño en primer lugar y una práctica de prueba en segundo lugar. Las pruebas
actúan como un registro de nuestras elecciones de diseño y sirven para explicarnos el sistema cuando volvemos
al código después de una larga ausencia.

3 Lo cual no quiere decir que pensemos que la gente de la escuela de Londres está equivocada. Algunas personas increíblemente inteligentes trabajan de esa manera.

Simplemente no es a lo que estamos acostumbrados.

52 | Capítulo 3: Un breve interludio: sobre el acoplamiento y las abstracciones


Machine Translated by Google

Las pruebas que utilizan demasiados simulacros se ven abrumadas por el código de configuración que oculta la
historia que nos interesa.

Steve Freeman tiene un gran ejemplo de pruebas superpuestas en su charla "Desarrollo basado en pruebas".
También debe consultar esta charla de PyCon, "Mocking and Patching Pitfalls", de nuestro estimado revisor de
tecnología, Ed Jung, que también aborda la burla y sus alternativas. Y mientras recomendamos charlas, no se pierda
a Brandon Rhodes hablando sobre "Elevación de su E/S", que cubre muy bien los problemas de los que estamos
hablando, usando otro ejemplo simple.

En este capítulo, hemos dedicado mucho tiempo a reemplazar las pruebas de


extremo a extremo con pruebas unitarias. ¡Eso no significa que pensemos que
nunca debe usar las pruebas E2E! En este libro, mostramos técnicas para llevarlo
a una pirámide de prueba decente con tantas pruebas unitarias como sea posible
y con la cantidad mínima de pruebas E2E que necesita para sentirse seguro.
Continúe leyendo "Resumen: reglas generales para diferentes tipos de pruebas"
en la página 79 para obtener más detalles.

Entonces, ¿cuál usamos en este libro? ¿Composición funcional u


orientada a objetos?
Ambas cosas. Nuestro modelo de dominio está completamente libre de dependencias y efectos secundarios, por lo
que ese es nuestro núcleo funcional. La capa de servicio que construimos a su alrededor (en el Capítulo 4) nos
permite llevar el sistema de extremo a extremo, y usamos la inyección de dependencia para proporcionar esos
servicios con componentes con estado, de modo que aún podamos probarlos unitariamente.

Consulte el Capítulo 13 para obtener más información sobre cómo hacer que nuestra inyección de dependencia sea
más explícita y centralizada.

Envolver
Veremos que esta idea surge una y otra vez en el libro: podemos hacer que nuestros sistemas sean más fáciles de
probar y mantener al simplificar la interfaz entre nuestra lógica comercial y las E/S desordenadas. Encontrar la
abstracción correcta es complicado, pero aquí hay algunas heurísticas y preguntas que debe hacerse:

• ¿Puedo elegir una estructura de datos familiar de Python para representar el estado del sistema desordenado y
luego tratar de imaginar una sola función que pueda devolver ese estado? • ¿Dónde puedo trazar una línea

entre mis sistemas, dónde puedo tallar una costura para pegar esa abstracción?

• ¿Cuál es una forma sensata de dividir las cosas en componentes con diferentes responsabilidades? ¿Qué
conceptos implícitos puedo hacer explícitos?

Resumen | 53
Machine Translated by Google

• ¿Cuáles son las dependencias y cuál es la lógica comercial central?

¡La práctica hace menos imperfecto! Y ahora volvamos a nuestra programación habitual…

54 | Capítulo 3: Un breve interludio: sobre el acoplamiento y las abstracciones


Machine Translated by Google

CAPÍTULO 4

Nuestro primer caso de uso:

Flask API y capa de servicio

¡Volvamos a nuestro proyecto de asignaciones! La Figura 4-1 muestra el punto al que llegamos al final
del Capítulo 2, que cubría el patrón Repositorio.

Figura 4-1. Antes: manejamos nuestra aplicación hablando con repositorios y el modelo de dominio

55
Machine Translated by Google

En este capítulo, analizamos las diferencias entre la lógica de orquestación, la lógica comercial y el código de interfaz, y
presentamos el patrón de capa de servicio para encargarse de orquestar nuestros flujos de trabajo y definir los casos de
uso de nuestro sistema.

También hablaremos sobre las pruebas: al combinar la capa de servicio con nuestra abstracción de repositorio sobre la
base de datos, podemos escribir pruebas rápidas, no solo de nuestro modelo de dominio, sino de todo el flujo de trabajo
para un caso de uso.

La Figura 4-2 muestra lo que buscamos: vamos a agregar una API de Flask que se comunicará con la capa de servicio,
que servirá como punto de entrada a nuestro modelo de dominio. Debido a que nuestra capa de servicio depende de

AbstractRepository, podemos realizar pruebas unitarias usando FakeRepository pero ejecutar nuestro código de producción

usando SqlAlchemyRepository.

Figura 4-2. La capa de servicio se convertirá en la principal vía de acceso a nuestra aplicación.

56 | Capítulo 4: Nuestro primer caso de uso: Flask API y capa de servicio


Machine Translated by Google

En nuestros diagramas, usamos la convención de que los componentes nuevos se resaltan con texto/líneas
en negrita (y color amarillo/naranja, si está leyendo una versión digital).

El código de este capítulo está en la rama chapter_04_service_layer


en GitHub:

clon de git https://github.com/cosmicpython/code.git código de


CD
git checkout chapter_04_service_layer # o
para codificar, consulte el Capítulo 2: git checkout
chapter_02_repository

Conectando nuestra aplicación al mundo real


Como cualquier buen equipo ágil, nos esforzamos por sacar un MVP y ponerlo frente a los usuarios para
comenzar a recopilar comentarios. Tenemos el núcleo de nuestro modelo de dominio y el servicio de dominio
que necesitamos para asignar pedidos, y tenemos la interfaz de repositorio para almacenamiento permanente.

Conectemos todas las partes móviles lo más rápido que podamos y luego refactoricemos hacia una arquitectura
más limpia. Este es nuestro plan:

1. Use Flask para colocar un punto final de API frente a nuestro servicio de asignación de dominio. Conecte
la sesión de la base de datos y nuestro repositorio. Pruébelo con una prueba de extremo a extremo y
algo de SQL rápido y sucio para preparar los datos de prueba.

2. Refactorice una capa de servicio que pueda servir como abstracción para capturar el caso de uso y que
se ubicará entre Flask y nuestro modelo de dominio. Construye algún servicio

pruebas de capa y mostrar cómo pueden usar FakeRepository.

3. Experimente con diferentes tipos de parámetros para nuestras funciones de capa de servicio; muestran
que el uso de tipos de datos primitivos permite que los clientes de la capa de servicio (nuestras pruebas
y nuestra API de Flask) se desacoplen de la capa del modelo.

Una primera prueba de extremo a extremo

Nadie está interesado en entrar en un debate terminológico largo sobre lo que cuenta como una prueba de
extremo a extremo (E2E) frente a una prueba funcional frente a una prueba de aceptación frente a una prueba
de integración frente a una prueba unitaria. Diferentes proyectos necesitan diferentes combinaciones de
pruebas, y hemos visto proyectos perfectamente exitosos que simplemente dividen las cosas en "pruebas
rápidas" y "pruebas lentas".

Por ahora, queremos escribir una o tal vez dos pruebas que van a ejercer un "real"
Punto final de API (usando HTTP) y hable con una base de datos real. Llamémoslas pruebas de extremo a
extremo porque es uno de los nombres que más se explica por sí mismo.

Conectando nuestra aplicación al mundo real | 57


Machine Translated by Google

A continuación se muestra un primer corte:

Una primera prueba de API (test_api.py)

@pytest.mark.usefixtures('restart_api') def
test_api_returns_allocation(add_stock):
sku, otrosku = random_sku(), random_sku('otro') earlybatch
= random_batchref(1) laterbatch = random_batchref(2) otro
lote = random_batchref (3) add_stock([

(lote posterior, sku, 100, '2011-01-02'), (primer


lote, sku, 100, '2011-01-01'), (otro lote, otros
ku, 100, ninguno),
])
datos = {'orderid': random_orderid(), 'sku': sku, 'qty': 3} url =
config.get_api_url() r = solicitudes.post(f'{url}/asignar', json=datos )
afirmar r.status_code == 201 afirmar r.json()['batchref'] == lote temprano

random_sku(), random_batchref(), etc., son pequeñas funciones auxiliares que


generan caracteres aleatorios mediante el uso del módulo uuid . Debido a que ahora
estamos ejecutando contra una base de datos real, esta es una forma de evitar que
varias pruebas y ejecuciones interfieran entre sí.

add_stock es un dispositivo de ayuda que simplemente oculta los detalles de la inserción manual de filas en la
base de datos usando SQL. Mostraremos una mejor manera de hacer esto más adelante en el capítulo.

config.py es un módulo en el que guardamos información de configuración.

Todos resuelven estos problemas de diferentes maneras, pero necesitará alguna forma de hacer girar Flask,
posiblemente en un contenedor, y de comunicarse con una base de datos de Postgres. Si desea ver cómo lo hicimos,
consulte el Apéndice B.

La implementación directa
Implementando las cosas de la manera más obvia, podría obtener algo como esto:

Primer corte de la aplicación Flask (ask_app.py)

de matraz importar Flask, jsonify, solicitud de


sqlalchemy importar create_engine de
sqlalchemy.orm importar sessionmaker

importar
configuración
importar modelo importar orm

58 | Capítulo 4: Nuestro primer caso de uso: Flask API y capa de servicio


Machine Translated by Google

importar repositorio

orm.start_mappers()
get_session = sessionmaker(bind=create_engine(config.get_postgres_uri())) app =
Flask(__name__)

@app.route("/asignar", métodos=['POST']) def


allocate_endpoint():
session = get_session() lotes
= repository.SqlAlchemyRepository(session).list() line =
model.OrderLine( request.json['orderid'], request.json['sku'],
request.json['qty'],

loteref = modelo.asignar(línea, lotes)

devuelve jsonify ({'referencia de lote': referencia de lote}), 201

Hasta aquí todo bien. No hay necesidad de mucho más de su sinsentido de "arquitectura astronauta",
Bob y Harry, pueden estar pensando.

Pero espera un minuto, no hay confirmación. En realidad, no estamos guardando nuestra asignación
en la base de datos. Ahora necesitamos una segunda prueba, ya sea una que inspeccione el estado
de la base de datos después (no muy recuadro negro), o tal vez una que verifique que no podemos
asignar una segunda línea si una primera ya debería haber agotado el lote:

Las asignaciones de prueba se conservan (test_api.py)

@pytest.mark.usefixtures('restart_api') def
test_allocations_are_persisted(add_stock):
sku = random_sku()
lote1, lote2 = random_batchref(1), random_batchref(2) order1 , order2
= random_orderid(1), random_orderid(2) add_stock([

(lote1, sku, 10, '2011-01-01'), (lote2,


sku, 10, '2011-01-02'),
])
línea1 = {'orderid': order1, 'sku': sku, 'qty': 10} line2 = {'orderid':
order2, 'sku': sku, 'qty': 10} url = config.get_api_url( )

# el primer pedido utiliza todo el stock en el lote 1 r =


solicitudes.post(f'{url}/asignar', json=línea1) afirmar
r.status_code == 201 afirmar r.json()['batchref'] == lote1

# el segundo pedido debe ir al lote 2 r =


request.post(f'{url}/allocate', json=line2)

La implementación sencilla | 59
Machine Translated by Google

afirmar r.status_code == 201


afirmar r.json()['batchref'] == lote2

No es tan encantador, pero eso nos obligará a agregar el compromiso.

Condiciones de error que requieren comprobaciones de la base de datos

Sin embargo, si seguimos así, las cosas se pondrán cada vez más feas.

Supongamos que queremos agregar un poco de manejo de errores. ¿Qué sucede si el dominio genera un error
por un SKU que está agotado? ¿O qué pasa con un SKU que ni siquiera existe? Eso no es

algo que el dominio ni siquiera sabe, ni debería. Es más una verificación de cordura que debemos implementar
en la capa de la base de datos, incluso antes de invocar el servicio de dominio.

Ahora estamos viendo dos pruebas más de extremo a extremo:

Aún más pruebas en la capa E2E (test_api.py)

@pytest.mark.usefixtures('restart_api') def
test_400_message_for_out_of_stock(add_stock): sku,
smalL_batch, large_order = random_sku(), random_batchref(), random_orderid() add_stock([

(pequeño_lote, sku, 10, '2011-01-01'),


])
data = {'orderid': large_order, 'sku': sku, 'qty': 20} url =
config.get_api_url() r = request.post (f'{url}/allocate', json=data)
afirmar r.status_code == 400 afirmar r.json()['mensaje'] == f'Agotado
para sku {sku}'

@pytest.mark.usefixtures('restart_api') def
test_400_message_for_invalid_sku():
unknown_sku, orderid = random_sku(), random_orderid() data =
{'orderid': orderid, 'sku': unknown_sku, 'qty': 20} url = config.get_api_url()
r = solicitudes.post(f'{url} /asignar', json=datos) afirmar r.status_code ==
400 afirmar r.json()['mensaje'] == f'Sku no válido {unknown_sku}'

En la primera prueba, intentamos asignar más unidades de las que tenemos en stock.

En el segundo, el SKU simplemente no existe (porque nunca llamamos a add_stock), por lo que no es
válido en lo que respecta a nuestra aplicación.

Y claro, también podríamos implementarlo en la aplicación Flask:

60 | Capítulo 4: Nuestro primer caso de uso: Flask API y capa de servicio


Machine Translated by Google

La aplicación Flask comienza a ponerse crujiente (ask_app.py)

def is_valid_sku(sku, lotes): devolver


sku en {b.sku para b en lotes}

@app.route("/asignar", métodos=['POST']) def


allocate_endpoint():
session = get_session() lotes
= repository.SqlAlchemyRepository(session).list() line =
model.OrderLine( request.json['orderid'], request.json['sku'],
request.json['qty'],

si no is_valid_sku(line.sku, lotes): return


jsonify({'message': f'Invalid sku {line.sku}'}), 400

intente: batchref = model.allocate(line, lotes) excepto


model.OutOfStock como e: return jsonify({'message':
str(e)}), 400

sesión.commit()
devuelve jsonify({'batchref': loteref}), 201

Pero nuestra aplicación Flask está empezando a parecer un poco difícil de manejar. Y nuestro número de
pruebas E2E está empezando a salirse de control, y pronto terminaremos con una pirámide de prueba invertida
(o "modelo de cono de helado", como le gusta llamarlo a Bob).

Introducción a una capa de servicio y uso de FakeRepository para


Prueba de unidad

Si observamos lo que está haciendo nuestra aplicación Flask, hay mucho de lo que podríamos llamar
orquestación: obtener cosas de nuestro repositorio, validar nuestra entrada contra el estado de la base de
datos, manejar errores y comprometerse en el camino feliz. La mayoría de estas cosas no tienen nada que
ver con tener un punto final de API web (las necesitaría si estuviera creando una CLI, por ejemplo; consulte el
Apéndice C), y en realidad no son cosas que deban probarse. por pruebas de extremo a extremo.

A menudo tiene sentido dividir una capa de servicio, a veces llamada capa de orquestación o capa de casos
de uso.

¿Recuerdas el FakeRepository que preparamos en el Capítulo 3?

Nuestro repositorio falso, una colección de lotes en memoria (test_services.py)


clase FakeRepository(repositorio.AbstractRepository):

def __init__(uno mismo, lotes):

| 61
Introducción a una capa de servicio y uso de FakeRepository para realizar pruebas unitarias
Machine Translated by Google

self._lotes = conjunto(lotes)

def agregar(auto, lote):


self._lotes.agregar(lote)

def get(auto, referencia):


volver siguiente (b para b en self._batches si b.reference == referencia)

def list(self):
return list(self._batches)

Aquí es donde será útil; nos permite probar nuestra capa de servicio con una unidad agradable y rápida
pruebas:

Pruebas unitarias con falsificaciones en la capa de servicio (test_services.py)

def test_returns_allocation(): line =


model.OrderLine("o1", "COMPLICATED-LAMP", 10) batch =
model.Batch("b1", "COMPLICATED-LAMP", 100, eta=Ninguno) repo =
FakeRepository([ lote])

resultado = services.allocate(line, repo, FakeSession()) asertar


resultado == "b1"

def test_error_for_invalid_sku(): línea =


modelo.OrderLine("o1", "NONEXISTENTSKU", 10) lote =
modelo.Batch("b1", "AREALSKU", 100, eta=Ninguno) repo =
FakeRepository([lote])

con pytest.raises(services.InvalidSku, match="Invalid sku NONEXISTENTSKU"):


services.allocate(line, repo, FakeSession())

FakeRepository contiene los objetos Batch que utilizará nuestra prueba.

Nuestro módulo de servicios (services.py) definirá una función de capa de servicio allocate() . Se ubicará entre
nuestra función allocate_endpoint() en la capa API y la función de servicio de dominio allocate() de nuestro
modelo de dominio.1

También necesitamos una FakeSession para falsificar la sesión de la base de datos, como se muestra en el

siguiente fragmento de código.

Una sesión de base de datos falsa (test_services.py)

clase FakeSession():
comprometido = Falso

1 Los servicios de la capa de servicio y los servicios de dominio tienen nombres similares que resultan confusos. Abordaremos este tema
más adelante en "¿Por qué todo se llama servicio?" en la página 66.

62 | Capítulo 4: Nuestro primer caso de uso: Flask API y capa de servicio


Machine Translated by Google

def commit(self):
self.committed = True

Esta sesión falsa es solo una solución temporal. Nos desharemos de él y haremos las cosas aún mejor pronto, en el
Capítulo 6. Pero mientras tanto, el falso .commit() nos permite migrar una tercera prueba desde la capa E2E:

Una segunda prueba en la capa de servicio (test_services.py)

def test_commits():
línea = modelo.OrderLine('o1', 'OMINOUS-MIRROR', 10) lote
= modelo.Batch('b1', 'OMINOSO-ESPEJO', 100, eta=Ninguno) repo =
FakeRepository([ lote]) sesión = FakeSession()

services.allocate(line, repo, session) aseverar


session.committed es True

Una función de servicio típica


Escribiremos una función de servicio que se parece a esto:

Servicio de asignación básica (services.py)

clase InvalidSku (Excepción):


pasar

def is_valid_sku(sku, lotes): devolver


sku en {b.sku para b en lotes}

def allocate(line: OrderLine, repo: AbstractRepository, session) -> str:


lotes = repo.list() si no
is_valid_sku(line.sku, lotes):
aumentar InvalidSku(f'Invalid sku {line.sku}')
loteref = model.allocate(línea, lotes)
session.commit() return loteref

Las funciones típicas de la capa de servicio tienen pasos similares:

Obtenemos algunos objetos del repositorio.

Hacemos algunas comprobaciones o afirmaciones sobre la solicitud frente al estado actual del mundo.

Llamamos a un servicio de dominio.

Si todo está bien, guardamos/actualizamos cualquier estado que hayamos cambiado.

| 63
Introducción a una capa de servicio y uso de FakeRepository para realizar pruebas unitarias
Machine Translated by Google

Ese último paso es un poco insatisfactorio en este momento, ya que nuestra capa de servicio está
estrechamente acoplada a nuestra capa de base de datos. Mejoraremos eso en el Capítulo 6 con el patrón
Unidad de trabajo.

Depende de las abstracciones

Observe una cosa más sobre nuestra función de capa de servicio:

def allocate(line: OrderLine, repo: AbstractRepository, session) -> str:

Depende de un repositorio. Elegimos hacer explícita la dependencia y usamos la sugerencia de tipo para
decir que dependemos de AbstractRepository. Esto significa que funcionará tanto cuando las pruebas le
proporcionen un FakeRepository como cuando la aplicación Flask le proporcione un SqlAlchemyRepository.

Si recuerda "El principio de inversión de dependencia" en la página xxi, esto es lo que queremos decir
cuando decimos que debemos "depender de abstracciones". Nuestro módulo de alto nivel, la capa de
servicio, depende de la abstracción del repositorio. Y los detalles de la implementación de nuestra
elección específica de almacenamiento persistente también dependen de esa misma abstracción.
Consulte las Figuras 4-3 y 4-4.

Consulte también en el Apéndice C un ejemplo resuelto de intercambiar los detalles de qué sistema de
almacenamiento persistente usar mientras se dejan las abstracciones intactas.

Pero lo esencial de la capa de servicio está ahí, y nuestra aplicación Flask ahora se ve mucho más limpia:

Aplicación Flask que delega a la capa de servicio (ask_app.py)

@app.route("/asignar", métodos=['POST']) def


allocate_endpoint():
session = get_session() repo
= repository.SqlAlchemyRepository(session) line =
model.OrderLine(
solicitud.json['orderid'],
solicitud.json['sku'],
solicitud.json['cantidad'],

)
prueba: batchref = services.allocate(línea, repositorio, sesión)
excepto (model.OutOfStock, services.InvalidSku) como e:
devolver jsonify({'mensaje': str(e)}), 400

devuelve jsonify ({'referencia de lote': referencia de lote}), 201

Instanciamos una sesión de base de datos y algunos objetos de repositorio.

64 | Capítulo 4: Nuestro primer caso de uso: Flask API y capa de servicio


Machine Translated by Google

Extraemos los comandos del usuario de la solicitud web y los pasamos a un servicio de dominio.

Devolvemos algunas respuestas JSON con los códigos de estado apropiados.

Las responsabilidades de la aplicación Flask son solo cosas web estándar: administración de
sesiones por solicitud, análisis de información de parámetros POST, códigos de estado de respuesta
y JSON. Toda la lógica de orquestación está en la capa de caso de uso/servicio, y la lógica del
dominio permanece en el dominio.

Finalmente, podemos reducir con confianza nuestras pruebas E2E a solo dos, una para el camino
feliz y otra para el camino infeliz:

E2E solo prueba caminos felices e infelices (test_api.py)

@pytest.mark.usefixtures('restart_api') def
test_happy_path_returns_201_and_allocated_batch(add_stock): sku, otherku
= random_sku(), random_sku('other') earlybatch = random_batchref(1)
laterbatch = random_batchref(2) otro lote = random_batchref (3) add_stock
([

(lote posterior, sku, 100, '2011-01-02'), (primer


lote, sku, 100, '2011-01-01'), (otro lote, otros
ku, 100, ninguno),
])
datos = {'orderid': random_orderid(), 'sku': sku, 'qty': 3} url =
config.get_api_url() r = solicitudes.post(f'{url}/asignar', json=datos )
afirmar r.status_code == 201 afirmar r.json()['batchref'] == lote temprano

@pytest.mark.usefixtures('restart_api') def
test_unhappy_path_returns_400_and_error_message():
unknown_sku, orderid = random_sku(), random_orderid() data =
{'orderid': orderid, 'sku': unknown_sku, 'qty': 20} url = config.get_api_url()
r = solicitudes.post(f'{url} /asignar', json=datos) afirmar r.status_code ==
400 afirmar r.json()['mensaje'] == f'Sku no válido {unknown_sku}'

Hemos dividido con éxito nuestras pruebas en dos amplias categorías: pruebas sobre material web,
que implementamos de principio a fin; y pruebas sobre cosas de orquestación, que podemos probar
contra la capa de servicio en la memoria.

Introducción a una capa de servicio y uso de FakeRepository para realizar pruebas unitarias
| sesenta y cinco
Machine Translated by Google

Ejercicio para el lector


Ahora que tenemos un servicio de asignación, ¿por qué no crear un servicio para desasignar?
Agregamos una prueba E2E y algunas pruebas de capa de servicio de código auxiliar para que pueda comenzar a
usar GitHub.

Si eso no es suficiente, continúe con las pruebas E2E y ask_app.py, y refactorice el adaptador Flask para que sea
más RESTful. ¡Observe cómo hacerlo no requiere ningún cambio en nuestra capa de servicio o capa de dominio!

Si decide que desea crear un punto final de solo lectura para recuperar
información de asignación, simplemente haga "lo más simple que
pueda funcionar", que es repo.get() directamente en el controlador Flask.
Hablaremos más sobre lecturas versus escrituras en el Capítulo 12.

¿Por qué todo se llama servicio?


Algunos de ustedes probablemente se estén rascando la cabeza en este punto tratando de averiguar
exactamente cuál es la diferencia entre un servicio de dominio y una capa de servicio.

Lo sentimos, no elegimos los nombres, o tendríamos formas mucho más geniales y amigables de hablar
sobre estas cosas.

Estamos usando dos cosas llamadas un servicio en este capítulo. El primero es un servicio de aplicación
(nuestra capa de servicio). Su trabajo es manejar las solicitudes del mundo exterior y orquestar una operación.
Lo que queremos decir es que la capa de servicio impulsa la aplicación siguiendo una serie de pasos simples:

• Obtener algunos datos de la base de datos

• Actualizar el modelo de dominio •

Conservar cualquier cambio

Este es el tipo de trabajo aburrido que tiene que ocurrir para cada operación en su sistema, y mantenerlo
separado de la lógica comercial ayuda a mantener las cosas ordenadas.

El segundo tipo de servicio es un servicio de dominio. Este es el nombre de una parte de la lógica que
pertenece al modelo de dominio pero que no se encuentra naturalmente dentro de una entidad con estado o
un objeto de valor. Por ejemplo, si estuviera creando una aplicación de carrito de compras, podría optar por
crear reglas de impuestos como un servicio de dominio. Calcular impuestos es un trabajo separado de
actualizar el carrito y es una parte importante del modelo, pero no parece correcto tener una entidad
persistente para el trabajo. En su lugar, una clase TaxCalculator sin estado o una función de cálculo de
impuestos pueden hacer el trabajo.

66 | Capítulo 4: Nuestro primer caso de uso: Flask API y capa de servicio


Machine Translated by Google

Poner cosas en carpetas para ver dónde pertenece todo


A medida que nuestra aplicación crezca, necesitaremos seguir ordenando nuestra estructura de directorios. El diseño de
nuestro proyecto nos da pistas útiles sobre qué tipos de objetos encontraremos en cada archivo.

Aquí hay una forma en que podríamos organizar las cosas:

Algunas subcarpetas

.
ÿÿÿ config.py ÿÿÿ
dominio ÿ ÿÿÿ
__init__.py
model.py
ÿ ÿÿÿ
ÿÿÿ
service_layer
__init__.py
ÿ ÿÿÿ ÿ
services.py ÿÿÿ adaptadores
ÿ ÿÿÿ __ÿ__.py
repository.py
ÿ orm.py
ÿÿÿ ÿ
ÿÿÿ
puntos de entrada
__init__.py
ÿ ÿÿÿ
ÿfrasco_app.py ÿÿÿ pruebas
ÿÿÿ __init__.py
conftest.pyÿÿÿ
ÿÿÿ
unidadtest_batches.py
ÿ ÿÿÿpy ÿ ÿÿÿ ÿ
ÿÿÿ
test_services.py
integración
ÿÿÿ ÿ ÿÿÿ
test_orm.py ÿ test_repository.py
ÿÿÿ e2e ÿÿÿ
ÿÿÿ

ÿÿÿ

ÿÿÿ

prueba_api.py

Tengamos una carpeta para nuestro modelo de dominio. Actualmente es solo un archivo, pero para una aplicación
más compleja, puede tener un archivo por clase; es posible que tenga clases principales auxiliares para Entity,

ValueObject y Aggregate, y puede agregar un archivoExceptions.py para las excepciones de la capa de dominio y,
como verá en la Parte II, commands.py y events.py.

Distinguiremos la capa de servicio. Actualmente, ese es solo un archivo llamado services.py para nuestras funciones
de capa de servicio. Podría agregar excepciones de capa de servicio aquí y, como verá en el Capítulo 5, agregaremos
unit_of_work.py.

Poner cosas en carpetas para ver dónde pertenece todo | 67


Machine Translated by Google

Adaptadores es un guiño a la terminología de puertos y adaptadores. Esto se completará con cualquier otra
abstracción en torno a la E/S externa (por ejemplo, un redis_client.py). Estrictamente hablando, llamaría a
estos adaptadores secundarios o adaptadores impulsados, oa veces adaptadores orientados hacia adentro.

Los puntos de entrada son los lugares desde los que manejamos nuestra aplicación. En la terminología
oficial de puertos y adaptadores, estos también son adaptadores y se denominan adaptadores primarios,
impulsores o orientados hacia el exterior.

¿Qué pasa con los puertos? Como recordará, son las interfaces abstractas que implementan los adaptadores.
Tendemos a mantenerlos en el mismo archivo que los adaptadores que los implementan.

Envolver
Agregar la capa de servicio realmente nos ha comprado mucho:

• Nuestros extremos de API de Flask se vuelven muy delgados y fáciles de escribir: su única responsabilidad
es hacer "cosas web", como analizar JSON y producir los códigos HTTP correctos para casos felices o
desafortunados.

• Hemos definido una API clara para nuestro dominio, un conjunto de casos de uso o puntos de entrada que
puede usar cualquier adaptador sin necesidad de saber nada sobre las clases de nuestro modelo de
dominio, ya sea una API, una CLI (consulte el Apéndice C), o las pruebas!
También son un adaptador para nuestro dominio.

• Podemos escribir pruebas a toda velocidad mediante el uso de la capa de servicio, lo que nos deja libres
para refactorizar el modelo de dominio de cualquier forma que consideremos adecuada. Siempre que
podamos ofrecer los mismos casos de uso, podemos experimentar con nuevos diseños sin necesidad de
volver a escribir un montón de pruebas.

• Y nuestra pirámide de prueba se ve bien: la mayor parte de nuestras pruebas son pruebas unitarias rápidas,
con solo el mínimo de E2E y pruebas de integración.

El DIP en acción
La Figura 4-3 muestra las dependencias de nuestra capa de servicio: el modelo de dominio y AbstractRepository
(la terminología de puerto, en puertos y adaptadores).

Cuando ejecutamos las pruebas, la Figura 4-4 muestra cómo implementamos las dependencias abstractas
usando FakeRepository (el adaptador).

Y cuando realmente ejecutamos nuestra aplicación, intercambiamos la dependencia "real" que se muestra en la
Figura 4-5.

68 | Capítulo 4: Nuestro primer caso de uso: Flask API y capa de servicio


Machine Translated by Google

Figura 4-3. Dependencias abstractas de la capa de servicio

Figura 4-4. Las pruebas proporcionan una implementación de la dependencia abstracta.

Figura 4-5. Dependencias en tiempo de ejecución

Resumen | 69
Machine Translated by Google

Maravilloso.

Hagamos una pausa en la Tabla 4-1, en la que consideramos los pros y los contras de tener una
capa de servicio.

Tabla 4-1. Capa de servicio: las ventajas y desventajas

ventajas Contras

• Contamos con un solo lugar para capturar todos los casos de uso de • Si su aplicación es puramente una aplicación web, sus controladores/funciones de

nuestra aplicación. • Hemos colocado nuestra lógica de dominio vista pueden ser el único lugar para capturar todos los casos de uso. • Es otra

inteligente detrás de una API, lo que nos deja libres para refactorizar. capa más de abstracción. • Poner demasiada lógica en la capa de servicio puede

conducir al antipatrón de dominio anémico. Es mejor introducir esta capa después de

• Hemos separado claramente "stu que habla HTTP" de "stu que habla detectar la lógica de orquestación que se infiltra en los controladores.
asignación".

• Cuando se combina con el patrón Repositorio y

FakeRepository, tenemos una buena manera de escribir pruebas en • Puede obtener muchos de los beneficios que se derivan de tener un rico

un nivel más alto que la capa de dominio; podemos probar más de modelos de dominio simplemente sacando la lógica de sus controladores y

nuestro flujo de trabajo sin necesidad de usar pruebas de integración bajando a la capa del modelo, sin necesidad de agregar una capa adicional en el

(lea el Capítulo 5 para obtener más detalles al respecto). medio (también conocido como "modelos pesados, controladores delgados").

Pero todavía hay algunas incomodidades que arreglar:

• La capa de servicio todavía está estrechamente unida al dominio, porque su API se expresa en
términos de objetos OrderLine . En el Capítulo 5, solucionaremos eso y hablaremos sobre la
forma en que la capa de servicio permite un TDD más productivo.

• La capa de servicio está estrechamente acoplada a un objeto de sesión . En el Capítulo 6,


presentaremos un patrón más que funciona de cerca con los patrones de Repositorio y Capa de
Servicio, el patrón de Unidad de Trabajo, y todo será absolutamente encantador. ¡Verás!

70 | Capítulo 4: Nuestro primer caso de uso: Flask API y capa de servicio


Machine Translated by Google

CAPÍTULO 5

TDD en High Gear y Low Gear

Hemos introducido la capa de servicio para capturar algunas de las responsabilidades de orquestación
adicionales que necesitamos de una aplicación en funcionamiento. La capa de servicio nos ayuda a definir
claramente nuestros casos de uso y el flujo de trabajo para cada uno: qué necesitamos obtener de
nuestros repositorios, qué comprobaciones previas y validación del estado actual debemos hacer, y qué
ahorramos al final.

Pero actualmente, muchas de nuestras pruebas unitarias operan a un nivel inferior, actuando directamente
sobre el modelo. En este capítulo, discutiremos las compensaciones involucradas en mover esas pruebas
al nivel de la capa de servicio, y algunas pautas de prueba más generales.

Harry dice: Ver una pirámide de prueba en acción fue un momento de iluminación
Aquí hay algunas palabras de Harry directamente: Al principio era escéptico de todos los

patrones arquitectónicos de Bob, pero ver una pirámide de prueba real me convirtió.

Una vez que implementa el modelado de dominio y la capa de servicio, realmente puede llegar a una etapa
en la que las pruebas unitarias superan en número a las pruebas de integración y de extremo a extremo en
un orden de magnitud. Después de haber trabajado en lugares donde la compilación de la prueba E2E
tomaría horas ("esperar hasta mañana", esencialmente), no puedo decirle qué diferencia hace poder ejecutar
todas sus pruebas en minutos o segundos.

Siga leyendo para obtener algunas pautas sobre cómo decidir qué tipo de pruebas escribir y en qué nivel.
La forma de pensar de marcha alta versus marcha baja realmente cambió mi vida de prueba.

71
Machine Translated by Google

¿Cómo se ve nuestra pirámide de prueba?

Veamos qué le hace a nuestra pirámide de prueba este movimiento hacia el uso de una capa de servicio, con sus propias
pruebas de capa de servicio:

Contar tipos de pruebas

$ grep -c test_ test_*.py tests/


unit/test_allocate.py:4 tests/unit/
test_batches.py:8 tests/unit/
test_services.py:3

pruebas/integración/test_orm.py:6
pruebas/integración/test_repository.py:2

pruebas/e2e/test_api.py:2

¡Nada mal! Tenemos 15 pruebas unitarias, 8 pruebas de integración y solo 2 pruebas de extremo a extremo. Eso ya es
una pirámide de prueba de aspecto saludable.

¿Deberían trasladarse las pruebas de la capa de dominio a la capa de servicio?

Veamos qué sucede si llevamos esto un paso más allá. Dado que podemos probar nuestro software contra la capa de
servicio, ya no necesitamos más pruebas para el modelo de dominio.
En su lugar, podríamos reescribir todas las pruebas a nivel de dominio del Capítulo 1 en términos de la capa de servicio:

Reescribiendo una prueba de dominio en la capa de servicio (tests/unit/test_services.py)

# prueba de capa de
dominio: def test_prefers_current_stock_batches_to_shipments():
in_stock_batch = Batch("en-stock-batch", "RETRO-CLOCK", 100, eta=Ninguno)
shipping_batch = Batch(" shipment -batch", "RETRO-CLOCK", 100, eta=mañana) line =
OrderLine( "oref", "RETRO-RELOJ", 10)

allocate(line, [in_stock_batch, shipping_batch ])

afirmar en_stock_batch.cantidad_disponible == 90 afirmar


envío_lote.cantidad_disponible == 100

# prueba de capa de
servicio: def test_prefers_warehouse_batches_to_shipments():
in_stock_batch = Batch("en-stock-batch", "RETRO-CLOCK", 100, eta=Ninguno)
shipping_batch = Batch(" shipment -batch", "RETRO-CLOCK", 100, eta=mañana) repo =
FakeRepository( [in_stock_batch, shipping_batch ]) sesión = FakeSession()

línea = OrderLine('oref', "RETRO-RELOJ", 10)

72 | Capítulo 5: TDD en High Gear y Low Gear


Machine Translated by Google

services.allocate(línea, repositorio, sesión)

afirmar en_stock_batch.cantidad_disponible == 90
afirmar envío_lote.cantidad_disponible == 100

¿Por qué querríamos hacer eso?

Se supone que las pruebas nos ayudan a cambiar nuestro sistema sin miedo, pero a menudo vemos equipos que
escriben demasiadas pruebas contra su modelo de dominio. Esto causa problemas cuando cambian su base de
código y descubren que necesitan actualizar decenas o incluso cientos de pruebas unitarias.

Esto tiene sentido si se detiene a pensar en el propósito de las pruebas automatizadas. Usamos pruebas para
hacer cumplir que una propiedad del sistema no cambia mientras estamos trabajando. Usamos pruebas para
verificar que la API continúa devolviendo 200, que la sesión de la base de datos continúa comprometiéndose y
que las órdenes aún se están asignando.

Si accidentalmente cambiamos uno de esos comportamientos, nuestras pruebas fallarán. Sin embargo, la otra
cara de la moneda es que si queremos cambiar el diseño de nuestro código, cualquier prueba que dependa
directamente de ese código también fallará.

A medida que avanzamos en el libro, verá cómo la capa de servicio forma una API para nuestro sistema que
podemos controlar de varias maneras. Probar contra esta API reduce la cantidad de código que necesitamos
cambiar cuando refactorizamos nuestro modelo de dominio. Si nos limitamos a realizar pruebas solo en la capa
de servicio, no tendremos ninguna prueba que interactúe directamente con métodos o atributos "privados" en
nuestros objetos modelo, lo que nos deja más libres para refactorizarlos.

Cada línea de código que ponemos en una prueba es como una gota de pegamento
que mantiene el sistema en una forma particular. Cuantas más pruebas de bajo nivel
tengamos, más difícil será cambiar las cosas.

Sobre decidir qué tipo de pruebas escribir


Quizás se esté preguntando: “¿Debería reescribir todas mis pruebas unitarias, entonces? ¿Está mal escribir
pruebas contra el modelo de dominio?” Para responder a esas preguntas, es importante comprender la
compensación entre el acoplamiento y la retroalimentación del diseño (consulte la Figura 5-1).

Sobre decidir qué tipo de pruebas escribir | 73


Machine Translated by Google

Figura 5-1. El espectro de prueba

La programación extrema (XP) nos exhorta a “escuchar el código”. Cuando estamos escribiendo pruebas,
podemos encontrar que el código es difícil de usar o notar un olor a código. Este es un desencadenante
para que refactoricemos y reconsideremos nuestro diseño.

Sin embargo, solo recibimos esa retroalimentación cuando trabajamos de cerca con el código de destino.
Una prueba de la API HTTP no nos dice nada sobre el diseño detallado de nuestros objetos, porque se
encuentra en un nivel de abstracción mucho más alto.

Por otro lado, podemos reescribir toda nuestra aplicación y, mientras no cambiemos las URL o los
formatos de solicitud, nuestras pruebas HTTP seguirán pasando. Esto nos da la confianza de que los
cambios a gran escala, como cambiar el esquema de la base de datos, no han roto nuestro código.

En el otro extremo del espectro, las pruebas que escribimos en el Capítulo 1 nos ayudaron a desarrollar
nuestra comprensión de los objetos que necesitamos. Las pruebas nos guiaron hacia un diseño que tiene
sentido y se lee en el idioma del dominio. Cuando nuestras pruebas se leen en el idioma del dominio, nos
sentimos cómodos de que nuestro código coincida con nuestra intuición sobre el problema que estamos
tratando de resolver.

Debido a que las pruebas están escritas en el lenguaje del dominio, actúan como documentación viva
para nuestro modelo. Un nuevo miembro del equipo puede leer estas pruebas para comprender
rápidamente cómo funciona el sistema y cómo se interrelacionan los conceptos básicos.

A menudo, "esbozamos" nuevos comportamientos escribiendo pruebas en este nivel para ver cómo se
vería el código. Sin embargo, cuando queramos mejorar el diseño del código, necesitaremos reemplazar
o eliminar estas pruebas, ya que están estrechamente vinculadas a una implementación particular.

Engranaje alto y bajo


La mayoría de las veces, cuando agregamos una nueva función o solucionamos un error, no necesitamos
realizar cambios importantes en el modelo de dominio. En estos casos, preferimos escribir pruebas contra
servicios debido al menor acoplamiento y mayor cobertura.

Por ejemplo, al escribir una función add_stock o una función cancel_order , podemos trabajar más rápido
y con menos acoplamiento al escribir pruebas en la capa de servicio.

74 | Capítulo 5: TDD en High Gear y Low Gear


Machine Translated by Google

Al comenzar un nuevo proyecto o al enfrentar un problema particularmente complicado, volveremos a escribir


pruebas contra el modelo de dominio para obtener mejores comentarios y documentación ejecutable de nuestra
intención.

La metáfora que usamos es la de cambiar de marcha. Al iniciar un viaje, la bicicleta debe estar en una marcha
baja para que pueda vencer la inercia. Una vez que estamos en marcha, podemos ir más rápido y de manera
más eficiente cambiando a una marcha más alta; pero si de repente nos encontramos con una colina empinada o
nos vemos obligados a reducir la velocidad por un peligro, bajamos de nuevo a una marcha más baja hasta que
podamos volver a acelerar.

Desacoplamiento completo de las pruebas de la capa de servicio del dominio

Todavía tenemos dependencias directas en el dominio en nuestras pruebas de capa de servicio, porque usamos
objetos de dominio para configurar nuestros datos de prueba e invocar nuestras funciones de capa de servicio.

Para tener una capa de servicio completamente desacoplada del dominio, necesitamos reescribir su API para
que funcione en términos de primitivas.

Nuestra capa de servicio actualmente toma un objeto de dominio OrderLine :

Antes: allocate toma un objeto de dominio (service_layer/services.py) def


allocate(line: OrderLine, repo: AbstractRepository, session) -> str:

¿Cómo se vería si sus parámetros fueran todos tipos primitivos?

Después: asignar toma cadenas e enteros (service_layer/services.py)

asignar por defecto (


orderid: str, sku: str, qty: int, repo: AbstractRepository, session ) -> str:

También reescribimos las pruebas en esos términos:

Las pruebas ahora usan primitivas en la llamada de función (tests/unit/


test_services.py) def test_returns_allocation():
lote = modelo.Lote("lote1", "LÁMPARA-COMPLICADA", 100, eta=Ninguno) repo =
FakeRepository([lote])

result = services.allocate("o1", "COMPLICATED-LAMP", 10, repo, FakeSession()) aseverar resultado == "batch1"

Pero nuestras pruebas aún dependen del dominio, porque aún creamos instancias de objetos Batch de forma
manual. Entonces, si algún día decidimos refactorizar masivamente cómo funciona nuestro modelo Batch ,
tendremos que cambiar un montón de pruebas.

Desacoplamiento completo de las pruebas de la capa de servicio del dominio | 75


Machine Translated by Google

Mitigación: mantener todas las dependencias de dominio en funciones de accesorios Al

menos podríamos abstraer eso en una función auxiliar o un accesorio en nuestras pruebas. Aquí hay una
forma de hacerlo, agregando una función de fábrica en FakeRepository:

Las funciones de fábrica para accesorios son una posibilidad (tests/unit/test_services.py)


clase FakeRepository (conjunto):

@staticmethod
def for_batch(ref, sku, qty, eta=Ninguno): return
FakeRepository([ model.Batch(ref, sku, qty,
eta),
])

...

def test_returns_allocation(): repo =


FakeRepository.for_batch("batch1", "COMPLICATED-LAMP", 100, eta=Ninguno) result =
services.allocate("o1", "COMPLICATED-LAMP", 10, repo, FakeSession() ) afirmar resultado ==
"lote1"

Al menos eso movería todas las dependencias de nuestras pruebas en el dominio a un solo lugar.

Agregar un servicio faltante Sin

embargo, podríamos ir un paso más allá. Si tuviéramos un servicio para agregar stock, podríamos usarlo y
hacer que nuestras pruebas de capa de servicio se expresen completamente en términos de los casos de
uso oficiales de la capa de servicio, eliminando todas las dependencias en el dominio:

Prueba para el nuevo servicio add_batch (tests/unit/test_services.py)


def test_add_batch():
repositorio, sesión = FakeRepository([]), FakeSession()
services.add_batch("b1", "CRUNCHY-ARMCHAIR", 100, Ninguno, repositorio, sesión)
afirmar repo.get("b1") es no Ninguno afirmar sesión.comprometido

En general, si necesita hacer cosas de la capa de dominio directamente en


las pruebas de la capa de servicio, puede ser una indicación de que su
capa de servicio está incompleta.

76 | Capítulo 5: TDD en High Gear y Low Gear


Machine Translated by Google

Y la implementación es solo dos líneas:

Un nuevo servicio para add_batch (service_layer/services.py)

def añadir_lote(
ref: str, sku: str, qty: int, eta: Optional[date], repo: AbstractRepository,
session,
):
repo.add(model.Batch(ref, sku, qty, eta)) session.commit()

asignar por defecto (


orderid: str, sku: str, qty: int, repo: AbstractRepository, session ) -> str:

...

¿Debería escribir un nuevo servicio solo porque ayudaría a eliminar las


dependencias de sus pruebas? Probablemente no. Pero en este caso,
casi definitivamente necesitaríamos un servicio add_batch algún día de
todos modos.

Eso ahora nos permite reescribir todas nuestras pruebas de la capa de servicio puramente en términos de los servicios
mismos, usando solo primitivas y sin ninguna dependencia del modelo:

Las pruebas de servicios ahora usan solo servicios (tests/unit/test_services.py)

def test_allocate_returns_allocation(): repo, session =


FakeRepository([]), FakeSession() services.add_batch("batch1",
"COMPLICATED-LAMP", 100, None, repo, session) result = services.allocate("o1", "LÁMPARA-COMPLICADA",
10, repositorio, sesión) afirmar resultado == "lote1"

def test_allocate_errors_for_invalid_sku():
repositorio, sesión = FakeRepository([]), FakeSession()
services.add_batch("b1", "AREALSKU", 100, Ninguno, repositorio, sesión)

con pytest.raises(services.InvalidSku, match="Invalid sku NONEXISTENTSKU"): services.allocate("o1",


"NONEXISTENTSKU", 10, repo, FakeSession())

Este es un lugar realmente agradable para estar. Nuestras pruebas de capa de servicio dependen solo de la capa de
servicio en sí, lo que nos deja completamente libres para refactorizar el modelo como mejor nos parezca.

Desacoplamiento completo de las pruebas de la capa de servicio del dominio | 77


Machine Translated by Google

Llevar la mejora a través de las pruebas E2E


De la misma manera que agregar add_batch ayudó a desacoplar nuestras pruebas de capa de servicio
del modelo, agregar un punto final de API para agregar un lote eliminaría la necesidad del feo accesorio
add_stock , y nuestras pruebas E2E podrían estar libres de esas consultas SQL codificadas. y la
dependencia directa de la base de datos.

Gracias a nuestra función de servicio, agregar el punto final es fácil, con solo un poco de JSON y una sola
llamada de función requerida:

API para agregar un lote (puntos de entrada/ask_app.py)

@app.route("/add_batch", métodos=['POST']) def


add_batch(): session = get_session() repo =
repository.SqlAlchemyRepository(session) eta
= request.json['eta'] si eta no lo es Ninguno: eta =
datetime.fromisoformat(eta).date()
services.add_batch( request.json['ref'], request.json['sku'],
request.json['qty'], eta, repo, session

) devuelve 'OK', 201

¿Estás pensando para ti mismo, POST a /add_batch? ¡Eso no es muy


RESTful! Tienes toda la razón. Estamos siendo felizmente descuidados,
pero si desea que todo sea más RESTy, tal vez un POST a / lotes, ¡entonces
déjese llevar! Debido a que Flask es un adaptador delgado, será fácil. Vea
la siguiente barra lateral.

Y nuestras consultas SQL codificadas de conftest.py se reemplazan con algunas llamadas a la API, lo que
significa que las pruebas de la API no tienen otras dependencias que la API, lo que también es bueno:

Las pruebas de API ahora pueden agregar sus propios lotes (tests/e2e/test_api.py)

def post_to_add_batch(ref, sku, qty, eta): url =


config.get_api_url() r = request.post ( f'{url}/
add_batch', json={'ref': ref, 'sku': sku, ' cantidad':
cantidad, 'eta': eta}

) afirmar r.status_code == 201

@pytest.mark.usefixtures('postgres_db')
@pytest.mark.usefixtures('restart_api') def
test_happy_path_returns_201_and_allocated_batch(): sku, otherku
= random_sku(), random_sku('other')

78 | Capítulo 5: TDD en High Gear y Low Gear


Machine Translated by Google

lote inicial = ref. lote aleatorio(1) lote


posterior = ref. lote aleatorio(2) otro
lote = ref. lote aleatorio(3)
post_to_add_batch (laterbatch, sku, 100, '2011-01-02')
post_to_add_batch(earlybatch, sku, 100, '2011-01-01')
post_to_add_batch(otherbatch, otherku, 100, None) data =
{'orderid': random_orderid(), 'sku': sku, 'qty': 3} url = config.get_api_url()
r = request.post (f'{url }/asignar', json=datos) afirmar r.status_code ==
201 afirmar r.json()['batchref'] == lote temprano

Envolver
Una vez que tenga una capa de servicio en su lugar, realmente puede mover la mayor parte de su cobertura de
prueba a pruebas unitarias y desarrollar una pirámide de prueba saludable.

Resumen: reglas generales para diferentes tipos de pruebas

Apunta a una prueba de extremo a extremo por característica

Esto podría escribirse contra una API HTTP, por ejemplo. El objetivo es demostrar que la característica
funciona y que todas las partes móviles están pegadas correctamente.

Escriba la mayor parte de sus pruebas contra la capa de


servicio. Estas pruebas de extremo a extremo ofrecen un buen compromiso entre cobertura, tiempo de
ejecución y eficiencia. Cada prueba tiende a cubrir una ruta de código de una característica y usa
falsificaciones para E/S. Este es el lugar para cubrir exhaustivamente todos los casos extremos y los
entresijos de su lógica empresarial.1

Mantenga un pequeño núcleo de pruebas escritas contra su modelo de dominio


Estas pruebas tienen una cobertura muy enfocada y son más frágiles, pero tienen la retroalimentación
más alta. No tenga miedo de eliminar estas pruebas si la funcionalidad se cubre posteriormente con
pruebas en la capa de servicio.

El manejo de errores cuenta como una


característica Idealmente, su aplicación se estructurará de tal manera que todos los errores que
aparezcan en sus puntos de entrada (por ejemplo, Flask) se manejen de la misma manera. Esto
significa que debe probar solo el camino feliz para cada función y reservar una prueba de extremo a
extremo para todos los caminos infelices (y muchas pruebas unitarias de caminos infelices, por supuesto).

1 Una preocupación válida acerca de escribir pruebas en un nivel más alto es que puede conducir a una explosión combinatoria para más
casos de uso complejos. En estos casos, puede ser útil descender a pruebas unitarias de nivel inferior de los diversos objetos de dominio que
colaboran. Pero consulte también el Capítulo 8 y “Opcionalmente: Controladores de eventos de pruebas unitarias de forma aislada con un
bus de mensajes falsos” en la página 147.

Resumen | 79
Machine Translated by Google

Algunas cosas ayudarán en el camino:

• Exprese su capa de servicio en términos de primitivas en lugar de objetos de dominio. • En

un mundo ideal, tendrá todos los servicios que necesita para poder probar completamente contra
la capa de servicio, en lugar de piratear el estado a través de repositorios o la base de datos.
Esto también vale la pena en sus pruebas de extremo a extremo.

¡Hasta el próximo capítulo!

80 | Capítulo 5: TDD en High Gear y Low Gear


Machine Translated by Google

CAPÍTULO 6

Patrón de unidad de trabajo

En este capítulo, presentaremos la pieza final del rompecabezas que une los patrones de Repositorio
y Capa de Servicio: el patrón de Unidad de Trabajo.

Si el patrón Repository es nuestra abstracción sobre la idea de almacenamiento persistente, el patrón


Unit of Work (UoW) es nuestra abstracción sobre la idea de operaciones atómicas. Nos permitirá
finalmente y completamente desacoplar nuestra capa de servicio de la capa de datos.

La Figura 6-1 muestra que, actualmente, se produce mucha comunicación entre las capas de nuestra
infraestructura: la API se comunica directamente con la capa de la base de datos para iniciar una
sesión, se comunica con la capa del repositorio para inicializar SQLAlchemyRepository y se comunica
con el servicio capa para pedirle que asigne.

El código de este capítulo está en la rama chapter_06_uow en


GitHub:
git clone https://github.com/cosmicpython/code.git cd code git
checkout chapter_06_uow # o para codificar, consulte el
Capítulo 4: git checkout chapter_04_service_layer

81
Machine Translated by Google

Figura 6-1. Sin UoW: API habla directamente con tres capas

La figura 6-2 muestra nuestro estado objetivo. La API de Flask ahora solo hace dos cosas: inicializa una unidad de
trabajo e invoca un servicio. El servicio colabora con la UoW (nos gusta pensar que la UoW es parte de la capa de

servicio), pero ni la función del servicio en sí ni Flask ahora necesitan comunicarse directamente con la base de
datos.

Y lo haremos todo utilizando una hermosa pieza de sintaxis de Python, un administrador de contexto.

82 | Capítulo 6: Patrón de unidad de trabajo


Machine Translated by Google

Figura 6-2. Con UoW: UoW ahora administra el estado de la base de datos

La Unidad de Trabajo Colabora con el Repositorio


Veamos la unidad de trabajo (o UoW, que pronunciamos “you-wow”) en acción. Así es como se verá la capa de
servicio cuando hayamos terminado:

Vista previa de la unidad de trabajo en acción (src/allocation/service_layer/services.py)


asignar por defecto (
orderid: str, sku: str, qty: int, uow:
unit_of_work.AbstractUnitOfWork ) -> str:
line = OrderLine(orderid, sku, qty) with uow:

lotes = uow.lotes.lista()
...
loteref = modelo.asignar(línea, lotes) uow.commit()

La Unidad de Trabajo colabora con el Repositorio | 83


Machine Translated by Google

Iniciaremos una UoW como administrador de contexto.

uow.batches es el repositorio de lotes, por lo que la UoW nos proporciona acceso a nuestro almacenamiento
permanente.

Cuando terminamos, confirmamos o revertimos nuestro trabajo, usando la UoW.

La UoW actúa como un único punto de entrada a nuestro almacenamiento persistente y realiza un seguimiento de los
objetos que se cargaron y del estado más reciente.1

Esto nos da tres cosas útiles:

• Una instantánea estable de la base de datos con la que trabajar, de modo que los objetos que usamos no cambien
a la mitad de una operación. • Una forma de conservar todos nuestros cambios a la vez, de modo que si algo

sale mal, no
terminar en un estado inconsistente

• Una API simple para nuestras preocupaciones de persistencia y un lugar práctico para obtener un repositorio

Prueba de manejo de una UoW con pruebas de integración

Aquí están nuestras pruebas de integración para la UOW:

Una prueba básica de "ida y vuelta" para una UoW (tests/integration/test_uow.py)

def test_uow_can_retrieve_a_batch_and_allocate_to_it(session_factory): session =


session_factory() insert_batch(session, 'batch1', 'HIPSTER-WORKBENCH', 100,
None) session.commit()

uow = unidad_de_trabajo.SqlAlchemyUnitOfWork(session_factory)
with uow: lote = uow.lotes.get(referencia='lote1') línea =
modelo.OrderLine('o1', 'HIPSTER-WORKBENCH', 10)
lote.asignar(línea) uow .comprometerse()

batchref = get_allocated_batch_ref(sesión, 'o1', 'HIPSTER-WORKBENCH') afirmar


loteref == 'lote1'

1 Es posible que haya encontrado el uso de la palabra colaboradores para describir objetos que trabajan juntos para lograr un
objetivo. La unidad de trabajo y el repositorio son un gran ejemplo de colaboradores en el sentido de modelado de objetos. En
el diseño impulsado por la responsabilidad, los grupos de objetos que colaboran en sus roles se denominan vecindarios de
objetos, lo que es, en nuestra opinión profesional, totalmente adorable.

84 | Capítulo 6: Patrón de unidad de trabajo


Machine Translated by Google

Inicializamos el UoW usando nuestra fábrica de sesión personalizada y obtenemos un objeto uow
para usar en nuestro bloque with .

La UoW nos da acceso al repositorio de lotes a través de uow.batches.

Llamamos a commit() cuando hayamos terminado.

Para los curiosos, los ayudantes insert_batch y get_allocated_batch_ref se ven así:

Ayudantes para hacer cosas de SQL (tests/integration/test_uow.py)

def insert_batch(sesión, ref, sku, cantidad, eta):


session.execute( 'INSERTAR EN lotes (referencia, sku,
_cantidad_comprada, eta)'
'
VALORES (:ref, :sku, :qty, :eta)', dict(ref=ref,
sku=sku, qty=qty, eta=eta)
)

def get_allocated_batch_ref(sesión, orderid, sku): [[orderlineid]] =


session.execute(
'SELECCIONE id DE order_lines DONDE orderid=:orderid AND sku=:sku', dict(orderid=orderid,
sku=sku)

) [[batchref]] = session.execute( 'SELECCIONE


b.reference DESDE asignaciones ÚNASE lotes COMO b ON batch_id = b.id'
' DONDE orderline_id=:orderlineid',
dict(orderlineid=orderlineid)

) devolver referencia de lote

Unidad de Trabajo y su Administrador de Contexto

En nuestras pruebas, hemos definido implícitamente una interfaz para lo que debe hacer una UoW.
Hagámoslo explícito usando una clase base abstracta:

Administrador de contexto UoW abstracto (src/allocation/service_layer/unit_of_work.py)

clase AbstractUnitOfWork(abc.ABC):
lotes: repositorio.AbstractRepository

def __exit__(self, *argumentos):


self.rollback()

@abc.abstractmethod def
commit(self): aumentar
NotImplementedError

@abc.abstractmethod

Gerente de Unidad de Trabajo y su Contexto | 85


Machine Translated by Google

def rollback(self):
aumentar NotImplementedError

La UoW proporciona un atributo llamado .batches, que nos dará acceso al repositorio de lotes.

Si nunca has visto un administrador de contexto, __enter__ y __exit__ son los dos métodos mágicos que se
ejecutan cuando ingresamos al bloque with y cuando salimos de él, respectivamente. Son nuestras fases de
instalación y desmontaje.

Llamaremos a este método para confirmar explícitamente nuestro trabajo cuando estemos listos.

Si no nos comprometemos, o si salimos del administrador de contexto generando un error, hacemos una
reversión. (La reversión no tiene efecto si se ha llamado a commit() . Siga leyendo para obtener más información
sobre esto).

La unidad de trabajo real utiliza sesiones de SQLAlchemy


Lo principal que agrega nuestra implementación concreta es la sesión de la base de datos:

El SQLAlchemy UoW real (src/allocation/service_layer/unit_of_work.py)

DEFAULT_SESSION_FACTORY =
sesionista(bind=create_engine( config.get_postgres_uri(),
))

clase SqlAlchemyUnitOfWork(AbstractUnitOfWork):

def __init__(self, session_factory=DEFAULT_SESSION_FACTORY):


self.session_factory = session_factory

def __enter__(self):
self.session = self.session_factory() # tipo: Sesión self.batches =
repository.SqlAlchemyRepository(self.session) return super().__enter__()

def __exit__(self, *args):


super().__exit__(*args)
self.session.close()

def commit(self):
self.session.commit()

def revertir(auto):
self.session.rollback()

86 | Capítulo 6: Patrón de unidad de trabajo


Machine Translated by Google

El módulo define una fábrica de sesiones predeterminada que se conectará a Postgres, pero permitimos que se
anule en nuestras pruebas de integración para que podamos usar SQLite en su lugar.

El método __enter__ es responsable de iniciar una sesión de base de datos e instanciar un repositorio real que
pueda usar esa sesión.

Cerramos la sesión al salir.

Finalmente, proporcionamos métodos concretos commit() y rollback() que utilizan nuestra sesión de base de datos.

Unidad de trabajo falsa para pruebas

Así es como usamos una UoW falsa en nuestras pruebas de capa de servicio:

Fake UoW (tests/unit/test_services.py)


class FakeUnitOfWork(unit_of_work.AbstractUnitOfWork):

def __init__(self):
self.lotes = FakeRepository([])
self.committed = False

def commit(self):
self.committed = True

def revertir (auto):


pasar

def test_add_batch():
uow = FakeUnitOfWork()
services.add_batch("b1", "CRUNCHY-ARMCHAIR", 100, None, uow)
afirmar uow.batches.get("b1") no es Ninguno afirmar uow.committed

def test_allocate_returns_allocation(): uow =


FakeUnitOfWork() services.add_batch("batch1",
"COMPLICATED-LAMP", 100, None, uow) result = services.allocate("o1",
"COMPLICATED-LAMP", 10, uow) afirmar resultado == "lote1"

...

Gerente de Unidad de Trabajo y su Contexto | 87


Machine Translated by Google

FakeUnitOfWork y FakeRepository están estrechamente acoplados, al igual que las


clases reales Uni tofWork y Repository . Eso está bien porque reconocemos que los
objetos son colaboradores.

Observe la similitud con la función commit() falsa de FakeSession (de la que ahora podemos
deshacernos). Pero es una mejora sustancial porque ahora estamos falsificando código que
escribimos nosotros en lugar de código de terceros. Algunas personas dicen: “No te burles de
lo que no tienes”.

En nuestras pruebas, podemos instanciar una UoW y pasarla a nuestra capa de servicio, en
lugar de pasar un repositorio y una sesión. Esto es considerablemente menos engorroso.

No te burles de lo que no tienes


¿Por qué nos sentimos más cómodos burlándonos de la UoW que de la sesión? Nuestras dos
falsificaciones logran lo mismo: nos brindan una forma de intercambiar nuestra capa de persistencia
para que podamos ejecutar pruebas en la memoria en lugar de tener que hablar con una base de datos
real. La diferencia está en el diseño resultante.

Si solo nos preocupamos por escribir pruebas que se ejecuten rápidamente, podríamos crear simulacros
que reemplacen a SQLAlchemy y usarlos en toda nuestra base de código. El problema es que Session
es un objeto complejo que expone muchas funciones relacionadas con la persistencia. Es fácil usar
Session para realizar consultas arbitrarias en la base de datos, pero eso lleva rápidamente a que el
código de acceso a los datos se esparza por todo el código base. Para evitar eso, queremos limitar el
acceso a nuestra capa de persistencia para que cada componente tenga exactamente lo que necesita
y nada más.

Al acoplarse a la interfaz de sesión , elige acoplarse a toda la complejidad de SQLAlchemy. En cambio,


queremos elegir una abstracción más simple y usarla para separar claramente las responsabilidades.
Nuestra UoW es mucho más simple que una sesión y nos sentimos cómodos con la capa de servicio
que puede iniciar y detener unidades de trabajo.

“No te burles de lo que no tienes” es una regla general que nos obliga a construir estas abstracciones
simples sobre subsistemas desordenados. Esto tiene el mismo beneficio de rendimiento que burlarse
de la sesión de SQLAlchemy, pero nos anima a pensar detenidamente en nuestros diseños.

Uso de la UoW en la capa de servicio


Así es como se ve nuestra nueva capa de servicio:

Capa de servicio usando UoW (src/allocation/service_layer/services.py)

def add_batch( ref:


str, sku: str, qty: int, eta: Opcional[fecha],

88 | Capítulo 6: Patrón de unidad de trabajo


Machine Translated by Google

uow: unidad_de_trabajo.AbstractUnitOfWork
):
con uow:
uow.batches.add(model.Batch(ref, sku, qty, eta))
uow.commit()

asignar por defecto (


orderid: str, sku: str, qty: int, uow:
unit_of_work.AbstractUnitOfWork ) -> str:
line = OrderLine(orderid, sku, qty) with uow: lotes =
uow.batches.list() if not is_valid_sku(line. sku,
lotes):

aumentar InvalidSku(f'Invalid sku {line.sku}')


loteref = model.allocate(línea, lotes) uow.commit()
devuelve loteref

Nuestra capa de servicio ahora tiene solo una dependencia, una vez más en un resumen
UoW.

Pruebas explícitas para Commit/Rollback Behavior


Para convencernos de que el comportamiento de confirmación/reversión funciona, escribimos un par de
pruebas:

Pruebas de integración para el comportamiento de reversión (tests/integration/test_uow.py)

def test_rolls_back_uncommitted_work_by_default(session_factory): uow =


unidad_de_trabajo.SqlAlchemyUnitOfWork(session_factory) with uow:
insert_batch(uow.session, 'batch1', 'MEDIUM-PINTH', 100, None)

nueva_sesión = session_factory() filas


= lista(nueva_sesión.ejecutar('SELECCIONAR * DE "lotes"')) afirmar
filas == []

def test_rolls_back_on_error(session_factory): class


MyException(Exception):
pasar

uow = unidad_de_trabajo.SqlAlchemyUnitOfWork(session_factory)
with pytest.raises(MyException): with uow: insert_batch(uow.session,
'batch1', 'LARGE-FORK', 100, None) raise MyException()

nueva_sesión = session_factory()

Pruebas explícitas para Commit/Rollback Behavior | 89


Machine Translated by Google

filas = lista (nueva_sesión.ejecutar ('SELECCIONAR * DE "lotes"')) afirmar


filas == []

No lo hemos mostrado aquí, pero puede valer la pena probar algunos de los
comportamientos de base de datos más "oscuros", como las transacciones,
con la base de datos "real", es decir, el mismo motor. Por ahora, nos estamos
saliendo con la nuestra usando SQLite en lugar de Postgres, pero en el Capítulo
7 cambiaremos algunas de las pruebas para usar la base de datos real. ¡Es
conveniente que nuestra clase UoW lo haga fácil!

Confirmaciones explícitas versus implícitas

Ahora divagamos brevemente sobre las diferentes formas de implementar el patrón UoW.

Podríamos imaginar una versión ligeramente diferente de la UoW que se compromete de forma predeterminada
y retrocede solo si detecta una excepción:

Una UoW con compromiso implícito... (src/allocation/unit_of_work.py)

clase AbstractUnitOfWork(abc.ABC):

def __enter__(auto):
retornar auto

def __exit__(self, exn_type, exn_value , traceback ):


si exn_type es Ninguno:
self.commit() otra
cosa:
self.rollback()

¿Deberíamos tener un compromiso implícito en el camino feliz?

¿Y retroceder solo en casos de excepción?

Nos permitiría guardar una línea de código y eliminar la confirmación explícita de nuestro código de cliente:

...nos ahorraría una línea de código (src/allocation/service_layer/services.py)


def add_batch(ref: str, sku: str, qty: int, eta: Optional[date], uow):
con uow:
uow.batches.add(model.Batch(ref, sku, qty, eta)) # uow.commit()

Esta es una decisión de juicio, pero tendemos a preferir requerir el compromiso explícito para que tengamos
que elegir cuándo vaciar el estado.

Aunque usamos una línea adicional de código, esto hace que el software sea seguro por defecto. El
comportamiento predeterminado es no cambiar nada. A su vez, eso hace que nuestro código sea más fácil de leer.

90 | Capítulo 6: Patrón de unidad de trabajo


Machine Translated by Google

Son about porque solo hay una ruta de código que conduce a cambios en el sistema: éxito total y
una confirmación explícita. Cualquier otra ruta de código, cualquier excepción, cualquier salida
anticipada del alcance de la UoW lleva a un estado seguro.

Del mismo modo, preferimos retroceder de forma predeterminada porque es más fácil de entender;
esto retrocede hasta la última confirmación, por lo que el usuario hizo una o eliminamos sus cambios.
Duro pero simple.

Ejemplos: uso de UoW para agrupar múltiples operaciones


en una unidad atómica

Aquí hay algunos ejemplos que muestran el patrón de Unidad de trabajo en uso. Puede ver cómo
conduce a un razonamiento simple sobre qué bloques de código suceden juntos.

Ejemplo 1: reasignar
Supongamos que queremos poder desasignar y luego reasignar pedidos:

Reasignar función de servicio

def reasignar(línea: OrderLine, uow: AbstractUnitOfWork) -> str:


con uow:
lote = uow.batches.get(sku=line.sku) si el lote
es None: raise InvalidSku(f'Invalid sku
{line.sku}')
lote.desasignar(línea)
asignar(línea) uow.commit()

Si desalocate() falla, no queremos llamar a allocate(), obviamente.

Si allocate() falla, probablemente tampoco queramos cometer desalocate() .

Ejemplo 2: cambiar la cantidad del lote Nuestra

compañía naviera nos llama para decirnos que una de las puertas del contenedor se abrió y la mitad
de nuestros sofás se han caído al Océano Índico. ¡Ups!

Cambiar cantidad

def change_batch_quantity(batchref: str, new_qty: int, uow: AbstractUnitOfWork):


with uow:
lote = uow.batches.get(reference=batchref)
lote.cambiar_cantidad_comprada(nueva_cantidad)
while lote.cantidad_disponible < 0: línea =
lote.desasignar_uno() uow.commit()

Ejemplos: uso de UoW para agrupar varias operaciones en una unidad atómica | 91
Machine Translated by Google

Aquí es posible que necesitemos desasignar cualquier número de líneas. Si obtenemos una falla en
cualquier etapa, probablemente no queramos confirmar ninguno de los cambios.

Poner en orden las pruebas de integración


Ahora tenemos tres conjuntos de pruebas, todas esencialmente apuntando a la base de datos: test_orm.py,
test_repository.py y test_uow.py. ¿Deberíamos tirar alguno?

ÿÿÿ pruebas ÿÿÿ

conftest.py ÿÿÿ e2e ÿ

test_api.py ÿÿÿ integración


ÿÿÿ
ÿ ÿÿÿ test_orm.py
test_repository.py
ÿ ÿÿÿ ÿ

test_uow.py ÿÿÿ ÿ pytest.ini ÿÿÿ

test_allocate.py
test_services.py
ÿÿÿ test_batches.py

ÿÿÿ

ÿÿÿ

Siempre debe sentirse libre de desechar las pruebas si cree que no agregarán valor a largo plazo. Diríamos que
test_orm.py fue principalmente una herramienta para ayudarnos a aprender SQLAlchemy, por lo que no lo
necesitaremos a largo plazo, especialmente si las cosas principales que hace están cubiertas en test_repository.py.
Puede conservar esa última prueba, pero ciertamente podríamos ver un argumento para mantener todo en el
nivel más alto posible de abstracción (tal como lo hicimos para las pruebas unitarias).

Ejercicio para el lector


Para este capítulo, probablemente lo mejor que puede intentar es implementar una UoW desde cero.
El código, como siempre, está en GitHub. Podría seguir el modelo que tenemos muy de cerca, o tal
vez experimentar separando el UoW (cuyas responsabilidades son commit(), rollback() y proporcionar
el repositorio .batches ) del administrador de contexto, cuyo trabajo es inicializar las cosas, y luego
haga la confirmación o reversión al salir.
Si tiene ganas de volverse completamente funcional en lugar de jugar con todas estas clases, puede
usar @contextmanager de contextlib.

Hemos eliminado tanto la UoW real como las falsificaciones, así como también hemos reducido la
UoW abstracta. ¿Por qué no nos envía un enlace a su repositorio si se le ocurre algo de lo que está
particularmente orgulloso?

92 | Capítulo 6: Patrón de unidad de trabajo


Machine Translated by Google

Este es otro ejemplo de la lección del Capítulo 5: a medida que construimos


mejores abstracciones, podemos mover nuestras pruebas para ejecutarlas,
lo que nos deja libres para cambiar los detalles subyacentes.

Envolver
Esperamos haberlo convencido de que el patrón Unidad de trabajo es útil y que el administrador de contexto
es una forma Pythonic realmente agradable de agrupar visualmente el código en bloques que queremos que
suceda atómicamente.

Este patrón es tan útil, de hecho, que SQLAlchemy ya usa una UoW con la forma del objeto Session . El
objeto Session en SQLAlchemy es la forma en que su aplicación carga datos de la base de datos.

Cada vez que carga una nueva entidad desde la base de datos, la sesión comienza a realizar un seguimiento
de los cambios en la entidad y, cuando se cancela la sesión, todos los cambios persisten juntos. ¿Por qué
nos esforzamos por abstraer la sesión de SQLAlchemy si ya implementa el patrón que queremos?

La tabla 6-1 analiza algunas de las ventajas y desventajas.

Tabla 6-1. Patrón de unidad de trabajo: las ventajas y desventajas

ventajas Contras

• Tenemos una buena abstracción sobre el concepto de operaciones atómicas, y el administrador • Su ORM probablemente ya tenga algunos perfectamente

de contexto facilita ver, visualmente, qué bloques de código se agrupan atómicamente. buenas abstracciones en torno a la atomicidad. SQLAlchemy incluso

tiene administradores de contexto. Puede recorrer un largo camino

• Tenemos un control explícito sobre cuándo comienza y finaliza una transacción, y simplemente pasando una sesión. • Hemos hecho que parezca fácil,

nuestra aplicación falla de una manera segura por defecto. pero debe pensar con mucho cuidado en cosas como reversiones,

Nunca tenemos que preocuparnos de que una operación esté parcialmente comprometida. subprocesos múltiples y transacciones anidadas. Tal vez simplemente

• Es un buen lugar para colocar todos sus repositorios para que el código del cliente pueda acceder apegarse a lo que le brinda Django o Flask-SQLAlchemy hará que su

a ellos. vida sea más simple.

• Como verá en capítulos posteriores, la atomicidad no se trata solo de

transacciones; nos puede ayudar a trabajar con eventos y el bus de mensajes.

Por un lado, la API de sesión es rica y admite operaciones que no queremos ni necesitamos en nuestro
dominio. Nuestra UnitOfWork simplifica la sesión a su núcleo esencial: se puede iniciar, confirmar o desechar.

Por otro lado, estamos usando UnitOfWork para acceder a nuestros objetos del Repositorio . Esta es una
buena parte de la usabilidad del desarrollador que no podríamos hacer con una simple sesión de SQLAlchemy.

Resumen | 93
Machine Translated by Google

Resumen del patrón de unidad de trabajo

El patrón Unidad de trabajo es una abstracción en torno a la integridad de los datos.

Ayuda a hacer cumplir la consistencia de nuestro modelo de dominio y mejora el rendimiento, al permitirnos realizar una sola

operación de descarga al final de una operación.

Trabaja en estrecha colaboración con los patrones de Repositorio y Capa de Servicio. El

patrón de Unidad de Trabajo completa nuestras abstracciones sobre el acceso a los datos al representar actualizaciones

atómicas. Cada uno de nuestros casos de uso de la capa de servicio se ejecuta en una sola unidad de trabajo que tiene éxito
o falla como un bloque.

Este es un caso encantador para un administrador de contexto.

Los administradores de contexto son una forma idiomática de definir el alcance en Python. Podemos usar un administrador de

contexto para revertir automáticamente nuestro trabajo al final de una solicitud, lo que significa que el sistema es seguro de

forma predeterminada.

SQLAlchemy ya implementa este patrón. Introducimos una

abstracción aún más simple sobre el objeto de sesión de SQLAlchemy para "estrechar" la interfaz entre el ORM y nuestro

código. Esto ayuda a mantenernos débilmente acoplados.

Por último, estamos nuevamente motivados por el principio de inversión de dependencia: nuestra
capa de servicio depende de una abstracción delgada y adjuntamos una implementación concreta
en el borde exterior del sistema. Esto se alinea muy bien con las propias recomendaciones de
SQLAlchemy:

Mantenga el ciclo de vida de la sesión (y generalmente la transacción) separado y externo.


El enfoque más completo, recomendado para aplicaciones más sustanciales, intentará mantener
los detalles de la gestión de sesiones, transacciones y excepciones lo más lejos posible de los
detalles del programa que realiza su trabajo.

—Documentación de "Conceptos básicos de sesión" de SQLALchemy

94 | Capítulo 6: Patrón de unidad de trabajo


Machine Translated by Google

CAPÍTULO 7

Agregados y límites de consistencia

En este capítulo, nos gustaría revisar nuestro modelo de dominio para hablar sobre invariantes y restricciones, y ver cómo nuestros

objetos de dominio pueden mantener su propia consistencia interna, tanto conceptualmente como en almacenamiento persistente.

Discutiremos el concepto de un límite de consistencia y mostraremos cómo hacerlo explícito puede ayudarnos a construir software

de alto rendimiento sin comprometer la capacidad de mantenimiento.

La Figura 7-1 muestra una vista previa de hacia dónde nos dirigimos: presentaremos un nuevo objeto de modelo llamado Producto

para envolver varios lotes, y en su lugar, haremos que el antiguo servicio de dominio allocate() esté disponible como un método en
Producto .

Figura 7-1. Agregando el Producto agregado

¿Por qué? Vamos a averiguar.

95
Machine Translated by Google

El código de este capítulo se encuentra en la rama appendix_csvs en


GitHub:

git clone https://github.com/cosmicpython/code.git cd code git


checkout appendix_csvs # o para codificar, consulte el capítulo
anterior: git checkout chapter_06_uow

¿Por qué no simplemente ejecutar todo en una hoja de cálculo?

¿Cuál es el punto de un modelo de dominio, de todos modos? ¿Cuál es el problema fundamental que estamos
tratando de abordar?

¿No podríamos ejecutar todo en una hoja de cálculo? Muchos de nuestros usuarios estarían encantados con
eso. A los usuarios comerciales les gustan las hojas de cálculo porque son simples, familiares y, sin embargo,
enormemente poderosas.

De hecho, una enorme cantidad de procesos de negocios funcionan mediante el envío manual de hojas de
cálculo por correo electrónico. Esta arquitectura “CSV sobre SMTP” tiene una complejidad inicial baja, pero
tiende a no escalar muy bien porque es difícil aplicar la lógica y mantener la consistencia.

¿Quién puede ver este campo en particular? ¿Quién puede actualizarlo? ¿Qué sucede cuando tratamos de
ordenar 350 sillas o 10,000,000 de mesas? ¿Puede un empleado tener un salario negativo?

Estas son las restricciones de un sistema. Gran parte de la lógica de dominio que escribimos existe para
hacer cumplir estas restricciones a fin de mantener las invariantes del sistema. Los invariantes son las cosas
que tienen que ser verdaderas cada vez que terminamos una operación.

Invariantes, Restricciones y Consistencia

Las dos palabras son algo intercambiables, pero una restricción es una regla que restringe los posibles
estados en los que puede entrar nuestro modelo, mientras que un invariante se define con un poco más de
precisión como una condición que siempre es verdadera.

Si estuviéramos escribiendo un sistema de reserva de hotel, podríamos tener la restricción de que no se


permiten reservas dobles. Esto respalda la invariante de que una habitación no puede tener más de una
reserva para la misma noche.

Por supuesto, a veces es posible que necesitemos doblar las reglas temporalmente. Tal vez necesitemos
cambiar las habitaciones debido a una reserva VIP. Mientras movemos las reservas en la memoria, es posible
que tengamos reservas dobles, pero nuestro modelo de dominio debe garantizar que, cuando terminemos,
terminemos en un estado consistente final, donde se cumplen las invariantes. Si no podemos encontrar una
manera de acomodar a todos nuestros invitados, debemos generar un error y negarnos a completar la
operación.

96 | Capítulo 7: Agregados y límites de consistencia


Machine Translated by Google

Veamos un par de ejemplos concretos de nuestros requisitos comerciales; Empezaremos con este:

Una línea de pedido se puede asignar a un solo lote a la vez.


-El negocio

Esta es una regla de negocio que impone un invariante. Lo invariable es que una línea de pedido se
asigna a cero o a un lote, pero nunca a más de uno. Necesitamos asegurarnos de que nuestro código
nunca llame accidentalmente a Batch.allocate() en dos lotes diferentes para la misma línea y, actualmente,
no hay nada que nos impida explícitamente hacerlo.

Invariantes, concurrencia y bloqueos


Veamos otra de nuestras reglas de negocio:

No podemos asignar a un lote si la cantidad disponible es menor que la cantidad de la línea de pedido.

-El negocio

Aquí, la restricción es que no podemos asignar más stock del que está disponible a un lote, por lo que
nunca sobrevendemos el stock asignando dos clientes al mismo colchón físico, por ejemplo. Cada vez
que actualizamos el estado del sistema, nuestro código debe asegurarse de que no rompamos el
invariante, que es que la cantidad disponible debe ser mayor o igual a cero.

En una aplicación de un solo hilo y de un solo usuario, es relativamente fácil para nosotros mantener este
invariante. Podemos simplemente asignar existencias una línea a la vez y generar un error si no hay
existencias disponibles.

Esto se vuelve mucho más difícil cuando introducimos la idea de concurrencia. De repente, podríamos
estar asignando existencias para múltiples líneas de pedido simultáneamente. Incluso podríamos estar
asignando líneas de pedido al mismo tiempo que procesamos los cambios en los lotes.

Solemos resolver este problema aplicando bloqueos a las tablas de nuestra base de datos. Esto evita que
ocurran dos operaciones simultáneamente en la misma fila o en la misma tabla.

Cuando comenzamos a pensar en ampliar nuestra aplicación, nos damos cuenta de que nuestro modelo
de asignación de líneas en todos los lotes disponibles puede no escalar. Si procesamos decenas de miles
de pedidos por hora y cientos de miles de líneas de pedido, no podemos mantener un bloqueo sobre toda
la tabla de lotes para cada uno de ellos; como mínimo, tendremos interbloqueos o problemas de rendimiento.

Invariantes, Restricciones y Consistencia | 97


Machine Translated by Google

¿Qué es un agregado?
Bien, si no podemos bloquear toda la base de datos cada vez que queremos asignar una línea de pedido,
¿qué debemos hacer en su lugar? Queremos proteger las invariantes de nuestro sistema pero permitir el
mayor grado de concurrencia. Mantener nuestras invariantes inevitablemente significa evitar escrituras
simultáneas; si varios usuarios pueden asignar DEADLY-SPOON al mismo tiempo, corremos el riesgo de
sobreasignar.

Por otro lado, no hay ninguna razón por la que no podamos asignar DEADLY-SPOON al mismo tiempo que
FLIMSY-DESK. Es seguro asignar dos productos al mismo tiempo porque no hay una invariante que los cubra
a ambos. No necesitamos que sean consistentes entre sí.

El patrón Aggregate es un patrón de diseño de la comunidad DDD que nos ayuda a resolver esta tensión. Un
agregado es solo un objeto de dominio que contiene otros objetos de dominio y nos permite tratar la colección
completa como una sola unidad.

La única forma de modificar los objetos dentro del agregado es cargar todo y llamar a métodos en el agregado
mismo.

A medida que un modelo se vuelve más complejo y crece con más entidades y objetos de valor, haciendo
referencia entre sí en un gráfico enredado, puede ser difícil hacer un seguimiento de quién puede modificar qué.
Especialmente cuando tenemos colecciones en el modelo (nuestros lotes son una colección), es una buena
idea designar algunas entidades para que sean el único punto de entrada para modificar sus objetos
relacionados. Hace que el sistema sea conceptualmente más simple y fácil de razonar si nombra algunos
objetos para que estén a cargo de la consistencia para los demás.
ers.

Por ejemplo, si estamos construyendo un sitio de compras, el carrito podría ser un buen agregado: es una
colección de artículos que podemos tratar como una sola unidad. Es importante destacar que queremos cargar
la cesta completa como un único blob desde nuestro almacén de datos. No queremos dos solicitudes para
modificar la cesta al mismo tiempo, o corremos el riesgo de errores de concurrencia extraños.
En su lugar, queremos que cada cambio en la cesta se ejecute en una sola transacción de base de datos.

No queremos modificar varias cestas en una transacción, porque no hay caso de uso para cambiar las cestas
de varios clientes al mismo tiempo. Cada canasta es un único límite de consistencia responsable de mantener
sus propias invariantes.

Un AGREGADO es un grupo de objetos asociados que tratamos como una unidad para el propósito
de cambios de datos.

—Eric Evans, Libro azul de diseño basado en dominios

Según Evans, nuestro agregado tiene una entidad raíz (el carrito) que encapsula el acceso a los artículos.
Cada artículo tiene su propia identidad, pero otras partes del sistema siempre se referirán al Carrito solo como
un todo indivisible.

98 | Capítulo 7: Agregados y límites de consistencia


Machine Translated by Google

Así como a veces usamos guiones bajos para marcar métodos o


funciones como "privados", puede pensar en los agregados como las
clases "públicas" de nuestro modelo, y el resto de las entidades y
objetos de valor como "privados".

Elegir un agregado
¿Qué agregado debemos usar para nuestro sistema? La elección es algo arbitraria, pero es
importante. El agregado será el límite donde nos aseguremos de que cada operación termine en un
estado consistente. Esto nos ayuda a razonar sobre nuestro software y evitar problemas extraños
de carrera. Queremos dibujar un límite alrededor de una pequeña cantidad de objetos (cuanto más
pequeños, mejor para el rendimiento) que tienen que ser coherentes entre sí, y debemos darle un
buen nombre a este límite.

El objeto que estamos manipulando bajo las sábanas es Batch. ¿Cómo llamamos a una colección
de lotes? ¿Cómo debemos dividir todos los lotes en el sistema en islas discretas de consistencia?

Podríamos usar Envío como nuestro límite. Cada envío contiene varios lotes, y todos viajan a
nuestro almacén al mismo tiempo. O tal vez podríamos usar Almacén como nuestro límite: cada
almacén contiene muchos lotes, y contar todo el stock al mismo tiempo podría tener sentido.

Sin embargo, ninguno de estos conceptos nos satisface realmente. Deberíamos poder asignar
DEADLY-SPOON y FLIMSY-DESK al mismo tiempo, incluso si están en el mismo almacén o en el
mismo envío. Estos conceptos tienen la granularidad incorrecta.

Cuando asignamos una línea de pedido, solo nos interesan los lotes que tienen el mismo SKU que
la línea de pedido. Podría funcionar algún tipo de concepto como GlobalSkuStock : una colección
de todos los lotes para un SKU determinado.

Sin embargo, es un nombre difícil de manejar, por lo que después de algunos cambios en SkuStock,
Stock, Pro ductStock, etc., decidimos llamarlo simplemente Producto; después de todo, ese fue el
primer concepto con el que nos encontramos en nuestra exploración del lenguaje del dominio. en
el Capítulo 1.

Así que el plan es este: cuando queremos asignar una línea de pedido, en lugar de la Figura 7-2,
donde buscamos todos los objetos Batch del mundo y los pasamos al servicio de dominio allocate() ...

Elección de un agregado | 99
Machine Translated by Google

Figura 7-2. Antes: asignar contra todos los lotes utilizando el servicio de dominio

…pasaremos al mundo de la Figura 7-3, en el que hay un nuevo objeto Producto para el SKU
particular de nuestra línea de pedido, y estará a cargo de todos los lotes para ese SKU, y podemos
llamar a un método .allocate() en ese lugar.

Figura 7-3. Después: solicite al Producto que asigne sus lotes

Veamos cómo se ve eso en forma de código:

100 | Capítulo 7: Agregados y límites de consistencia


Machine Translated by Google

Nuestro agregado elegido, Producto (src/allocation/domain/model.py)


producto de clase :

def __init__(self, sku: str, lotes: Lista[Lote]):


self.sku = sku
self.lotes = lotes

def allocate(self, línea: OrderLine) -> str:

intente: lote =
siguiente ( b para b en ordenados (auto. lotes) si b.can_allocate (línea)

) lote.asignar(línea)
volver lote.referencia
excepto StopIteration:
aumentar OutOfStock(f'Agotado para sku {line.sku}')

El identificador principal del producto es el sku.

Nuestra clase Producto contiene una referencia a una colección de lotes para ese SKU.

Finalmente, podemos mover el servicio de dominio allocate() para que sea un método en el
agregado del Producto .

Es posible que este Producto no se parezca a lo que esperarías que se viera


un modelo de Producto . Sin precio, sin descripción, sin dimensiones. Nuestro
servicio de asignación no se preocupa por ninguna de esas cosas. Este es el
poder de los contextos acotados; el concepto de un producto en una aplicación
puede ser muy diferente al de otra. Consulte la siguiente barra lateral para
obtener más información.

Elección de un agregado | 101


Machine Translated by Google

Agregados, contextos acotados y microservicios Una de las


contribuciones más importantes de Evans y la comunidad DDD es el concepto de contextos
acotados.

En esencia, esta fue una reacción contra los intentos de capturar empresas enteras en un
solo modelo. La palabra cliente significa diferentes cosas para las personas en ventas,
servicio al cliente, logística, soporte, etc. Los atributos necesarios en un contexto son
irrelevantes en otro; más pernicioso, los conceptos con el mismo nombre pueden tener
significados completamente diferentes en diferentes contextos. En lugar de intentar construir
un solo modelo (o clase o base de datos) para capturar todos los casos de uso, es mejor
tener varios modelos, trazar límites alrededor de cada contexto y manejar la traducción entre
diferentes contextos explícitamente.

Este concepto se traduce muy bien en el mundo de los microservicios, donde cada
microservicio es libre de tener su propio concepto de "cliente" y sus propias reglas para
traducirlo hacia y desde otros microservicios con los que se integra.

En nuestro ejemplo, el servicio de asignación tiene Producto (sku, lotes), mientras que el
comercio electrónico tendrá Producto (sku, descripción, precio, image_url, dimensiones, etc.).
Como regla general, sus modelos de dominio deben incluir solo los datos que necesitan para
realizar los cálculos.

Ya sea que tenga o no una arquitectura de microservicios, una consideración clave al elegir
sus agregados es también elegir el contexto delimitado en el que operarán. Al restringir el
contexto, puede mantener una cantidad baja de agregados y su tamaño manejable.

Una vez más, nos vemos obligados a decir que no podemos darle a este tema el tratamiento
que se merece aquí, y solo podemos alentarlo a que lo lea en otro lugar. El enlace de Fowler
al comienzo de esta barra lateral es un buen punto de partida, y cualquier libro de DDD (o
cualquier otro) tendrá un capítulo o más sobre contextos acotados.

Un agregado = un repositorio
Una vez que defina ciertas entidades para que sean agregados, debemos aplicar la regla de que
son las únicas entidades a las que el mundo exterior puede acceder públicamente. En otras
palabras, los únicos repositorios que se nos permiten deben ser repositorios que devuelvan agregados.

La regla de que los repositorios solo deben devolver agregados es el lugar


principal donde aplicamos la convención de que los agregados son la única
forma de ingresar a nuestro modelo de dominio. ¡Cuidado con romperlo!

102 | Capítulo 7: Agregados y límites de consistencia


Machine Translated by Google

En nuestro caso, cambiaremos de BatchRepository a ProductRepository:

Nuestro nuevo UoW y repositorio (unit_of_work.py y repository.py)


clase AbstractUnitOfWork(abc.ABC):
productos: repositorio.AbstractProductRepository

...

clase AbstractProductRepository(abc.ABC):

@abc.abstractmethod
def add(self, producto):
...

@abc.abstractmethod
def get(self, sku) -> modelo.Producto:
...

La capa ORM necesitará algunos ajustes para que los lotes correctos se carguen automáticamente
y se asocien con los objetos del Producto . Lo bueno es que el patrón Repository significa que
no tenemos que preocuparnos por eso todavía. Simplemente podemos usar nuestro
FakeRepository y luego pasar el nuevo modelo a nuestra capa de servicio para ver cómo se ve
con Product como su punto de entrada principal:

Capa de servicio (src/allocation/service_layer/services.py)


def añadir_lote(
ref: str, sku: str, qty: int, eta: Optional[date], uow:
unit_of_work.AbstractUnitOfWork
):
con uow:
producto = uow.products.get(sku=sku) si el
producto es Ninguno: product =
model.Product(sku, lotes=[]) uow.products.add(product)

producto.lotes.append(modelo.Lote(ref, sku, qty, eta)) uow.commit()

asignar por defecto (


orderid: str, sku: str, qty: int, uow:
unit_of_work.AbstractUnitOfWork ) -> str: line
= OrderLine(orderid, sku, qty) with uow: product =
uow.products.get(sku=line.sku) if el producto es
Ninguno: aumentar InvalidSku(f'Invalid sku
{line.sku}') batchref = product.allocate(line)
uow.commit() return batchref

Un agregado = un repositorio | 103


Machine Translated by Google

¿Qué pasa con el rendimiento?


Hemos mencionado algunas veces que estamos modelando con agregados porque queremos tener un software
de alto rendimiento, pero aquí estamos cargando todos los lotes cuando solo necesitamos uno. Puede esperar
que eso sea ineficiente, pero hay algunas razones por las que nos sentimos cómodos aquí.

Primero, estamos modelando nuestros datos a propósito para que podamos hacer una sola consulta a la base de
datos para leer y una sola actualización para conservar nuestros cambios. Esto tiende a funcionar mucho mejor
que los sistemas que emiten muchas consultas ad hoc. En los sistemas que no se modelan de esta manera, a
menudo encontramos que las transacciones se vuelven más largas y complejas a medida que el software
evoluciona.

En segundo lugar, nuestras estructuras de datos son mínimas y comprenden unas pocas cadenas y números
enteros por fila. Podemos cargar fácilmente decenas o incluso cientos de lotes en unos pocos milisegundos.

Tercero, esperamos tener solo 20 o más lotes de cada producto a la vez. Una vez que se agota un lote, podemos
descontarlo de nuestros cálculos. Esto significa que la cantidad de datos que estamos obteniendo no debería
salirse de control con el tiempo.

Si esperáramos tener miles de lotes activos para un producto, tendríamos un par de opciones. Por un lado,
podríamos usar la carga diferida para los lotes de un producto. Desde la perspectiva de nuestro código, nada
cambiaría, pero en segundo plano, SQLAlchemy revisaría los datos por nosotros. Esto daría lugar a más
solicitudes, cada una de las cuales obtendría un número menor de filas. Debido a que solo necesitamos encontrar
un solo lote con suficiente capacidad para nuestro pedido, esto podría funcionar bastante bien.

Ejercicio para el lector


Acaba de ver las capas superiores principales del código, por lo que esto no debería ser demasiado difícil, pero nos
gustaría que implementara el agregado del Producto a partir de Lote, tal como lo hicimos nosotros.

Por supuesto, puede hacer trampa y copiar/pegar de las listas anteriores, pero incluso si lo hace, aún tendrá que
resolver algunos desafíos por su cuenta, como agregar el modelo al ORM y asegurarse de que todas las partes
móviles pueden hablar entre ellos, lo que esperamos sea instructivo.

Encontrarás el código en GitHub. Hemos incluido una implementación de "trampa" en los delegados de la función
allocate() existente , por lo que debería poder evolucionar hacia la realidad.

Hemos marcado un par de pruebas con @pytest.skip(). Después de leer el resto de este capítulo, vuelva a estas
pruebas para intentar implementar números de versión.
¡Puntos de bonificación si puede hacer que SQLAlchemy los haga por usted por arte de magia!

104 | Capítulo 7: Agregados y límites de consistencia


Machine Translated by Google

Si todo lo demás falla, simplemente buscaríamos un agregado diferente. Tal vez podríamos dividir los lotes
por región o por almacén. Tal vez podríamos rediseñar nuestra estrategia de acceso a datos en torno al
concepto de envío. El patrón Agregado está diseñado para ayudar a administrar algunas restricciones
técnicas relacionadas con la consistencia y el rendimiento. No hay un agregado correcto, y deberíamos
sentirnos cómodos cambiando de opinión si descubrimos que nuestros límites están causando problemas
de rendimiento.

Simultaneidad optimista con números de versión


Tenemos nuestro nuevo agregado, por lo que hemos resuelto el problema conceptual de elegir un objeto
que esté a cargo de los límites de consistencia. Ahora dediquemos un poco de tiempo a hablar sobre cómo
hacer cumplir la integridad de los datos en el nivel de la base de datos.

Esta sección tiene muchos detalles de implementación; por ejemplo, algunos de


ellos son específicos de Postgres. Pero de manera más general, estamos
mostrando una forma de administrar los problemas de concurrencia, pero es solo
un enfoque. Los requisitos reales en esta área varían mucho de un proyecto a
otro. No debe esperar poder copiar y pegar código desde aquí a producción.

No queremos mantener un bloqueo sobre toda la tabla de lotes , pero ¿cómo implementaremos mantener
un bloqueo solo sobre las filas de un SKU en particular?

Una respuesta es tener un único atributo en el modelo de producto que actúe como marcador de que se
ha completado todo el cambio de estado y utilizarlo como el único recurso por el que los trabajadores
simultáneos pueden luchar. Si dos transacciones leen el estado del mundo por lotes al mismo tiempo, y
ambas quieren actualizar las tablas de asignaciones , forzamos a ambas a intentar actualizar también
version_number en la tabla de productos , de tal manera que solo una de ellas pueda hacerlo. gana y el
mundo se mantiene constante.

La figura 7-4 ilustra dos transacciones simultáneas que realizan sus operaciones de lectura al mismo
tiempo, por lo que ven un Producto con, por ejemplo, versión=3. Ambos llaman a Prod uct.allocate() para
modificar un estado. Pero configuramos nuestras reglas de integridad de la base de datos de modo que
solo uno de ellos puede confirmar el nuevo Producto con la versión = 4, y la otra actualización se rechaza.

Los números de versión son solo una forma de implementar el bloqueo optimista.
Podría lograr lo mismo configurando el nivel de aislamiento de transacciones de
Postgres en SERIALIZABLE, pero eso a menudo tiene un alto costo de
rendimiento. Los números de versión también hacen explícitos los conceptos
implícitos.

Concurrencia optimista con números de versión | 105


Machine Translated by Google

Figura 7-4. Diagrama de secuencia: dos transacciones que intentan una actualización simultánea en
el Producto

106 | Capítulo 7: Agregados y límites de consistencia


Machine Translated by Google

Control de concurrencia optimista y reintentos Lo


que hemos implementado aquí se denomina control de concurrencia optimista porque
nuestra suposición predeterminada es que todo estará bien cuando dos usuarios deseen
realizar cambios en la base de datos. Creemos que es poco probable que entren en
conflicto entre sí, así que dejamos que sigan adelante y nos aseguramos de tener una
forma de notar si hay un problema.

El control de concurrencia pesimista funciona bajo el supuesto de que dos usuarios van a causar conflictos y
queremos evitar conflictos en todos los casos, por lo que bloqueamos todo solo para estar seguros. En nuestro
ejemplo, eso significaría bloquear toda la tabla de lotes o usar SELECCIONAR PARA ACTUALIZAR; estamos
fingiendo que los hemos descartado por razones de rendimiento, pero en la vida real querrás hacer algunas
evaluaciones y mediciones. tuyo.

Con el bloqueo pesimista, no necesita pensar en manejar fallas porque la base de datos las evitará por usted
(aunque sí necesita pensar en interbloqueos).
Con el bloqueo optimista, debe manejar explícitamente la posibilidad de fallas en el caso (con suerte poco
probable) de un conflicto.

La forma habitual de manejar un error es volver a intentar la operación fallida desde el principio.
Imagine que tenemos dos clientes, Harry y Bob, y cada uno envía un pedido de SHINY-TABLE. Ambos
subprocesos cargan el producto en la versión 1 y asignan stock. La base de datos impide la actualización
simultánea y el pedido de Bob falla con un error. Cuando volvemos a intentar la operación, el pedido de Bob
carga el producto en la versión 2 e intenta asignarlo nuevamente. Si queda suficiente stock, todo está bien; de
lo contrario, recibirá OutOfStock.
La mayoría de las operaciones se pueden volver a intentar de esta manera en el caso de un problema de simultaneidad.

Obtenga más información sobre los reintentos en “Recuperación de errores sincrónicamente” en la página 158
y “Footguns” en la página 226.

Opciones de implementación para números de versión

Hay esencialmente tres opciones para implementar números de versión:

1. version_number vive en el dominio; lo agregamos al constructor del Producto , y


Product.allocate() es responsable de incrementarlo.

2. ¡La capa de servicio podría hacerlo! El número de versión no es estrictamente una preocupación del
dominio, por lo que nuestra capa de servicio podría suponer que el repositorio adjunta el número de
versión actual al Producto , y la capa de servicio lo incrementará antes de realizar la confirmación ().

3. Dado que podría decirse que es un problema de infraestructura, la UoW y el repositorio podrían
hacerlo por arte de magia. El repositorio tiene acceso a los números de versión de cualquier producto que

Concurrencia optimista con números de versión | 107


Machine Translated by Google

recupera, y cuando la UoW realiza una confirmación, puede incrementar el número de versión
de cualquier producto que conozca, suponiendo que hayan cambiado.

La opción 3 no es ideal, porque no hay una forma real de hacerlo sin tener que asumir que todos
los productos han cambiado, por lo que incrementaremos los números de versión cuando no sea
necesario.1

La opción 2 implica mezclar la responsabilidad de mutar el estado entre la capa de servicio y la


capa de dominio, por lo que también es un poco complicado.

Entonces, al final, aunque los números de versión no tienen que ser una preocupación del dominio,
puede decidir que la compensación más limpia es colocarlos en el dominio:

Nuestro agregado elegido, Producto (src/allocation/domain/model.py)


producto de clase :

def __init__(self, sku: str, lotes: Lista[Lote], número_versión: int = 0):


self.sku = sku
self.batches = lotes
self.version_number = version_number

def allocate(self, línea: OrderLine) -> str:

intente: lote =
siguiente ( b para b en ordenados (auto. lotes) si b.can_allocate (línea)

) batch.allocate(line)
self.version_number += 1
return batch.reference
excepto StopIteration: raise
OutOfStock(f'Outofstock for sku {line.sku}')

¡Ahí está!

Si se está rascando la cabeza con este negocio del número de versión,


puede ser útil recordar que el número no es importante. Lo importante
es que la fila de la base de datos del Producto se modifica cada vez que
hacemos un cambio en el agregado del Producto . El número de versión
es una forma simple y comprensible para los humanos de modelar algo
que cambia en cada escritura, pero también podría ser un UUID aleatorio
cada vez.

1 Tal vez podríamos obtener algo de magia ORM/SQLAlchemy para decirnos cuándo un objeto está sucio, pero ¿cómo podría eso
funciona en el caso genérico, por ejemplo, para un CsvRepository?

108 | Capítulo 7: Agregados y límites de consistencia


Machine Translated by Google

Pruebas para nuestras reglas de integridad de datos

Ahora, para asegurarnos de que podemos obtener el comportamiento que queremos: si tenemos dos intentos
simultáneos de hacer una asignación en el mismo Producto, uno de ellos debería fallar, porque no pueden actualizar el
número de versión.

Primero, simulemos una transacción "lenta" usando una función que realiza la asignación y luego realiza una suspensión
explícita:2

time.sleep puede reproducir el comportamiento de concurrencia (tests/integration/test_uow.py)

def try_to_allocate(orderid, sku, Exceptions ): line =


model.OrderLine(orderid, sku, 10) try: with
unit_of_work.SqlAlchemyUnitOfWork() as uow:
product = uow.products.get(sku=sku) product.allocate(line )
time.sleep(0.2) uow.commit() excepto Excepción
como e: print( traceback.format_exc())Exceptions.append
(e)

Luego hacemos que nuestra prueba invoque esta asignación lenta dos veces, al mismo tiempo, usando subprocesos:

Una prueba de integración para el comportamiento de concurrencia (tests/integration/test_uow.py)

def test_concurrent_updates_to_version_are_not_allowed(postgres_session_factory):
sku, lote = random_sku(), random_batchref() session
= postgres_session_factory() insert_batch(session,
batch, sku, 100, eta=Ninguno, product_version=1) session.commit()

order1 , order2 = random_orderid(1), random_orderid(2)


excepciones = [] # tipo: List[Exception] try_to_allocate_order1 =
lambda: try_to_allocate(order1, sku, Exceptions ) try_to_allocate_order2 = lambda:
try_to_allocate(order2, sku, Exceptions ) thread1 = threading.Thread(target=try_to_allocate_order1)
thread2 = threading.Thread(target=try_to_allocate_order2) thread1.start() thread2.start()
thread1.join() thread2.join()

[[version]] =
session.execute( "SELECCIONE version_number DESDE productos DONDE sku=:sku",

2 time.sleep() funciona bien en nuestro caso de uso, pero no es la forma más confiable o eficiente de reproducir errores de
concurrencia. Considere usar semáforos o primitivas de sincronización similares compartidas entre sus subprocesos para
obtener mejores garantías de comportamiento.

Pruebas para nuestras reglas de integridad de datos | 109


Machine Translated by Google

dict(sku=sku),

) afirmar versión == 2
[excepción] = excepciones afirmar
'no se pudo serializar el acceso debido a una actualización simultánea' en str (excepción)

ordenes = lista(sesion.ejecutar(
"SELECCIONE orderid DE asignaciones"
"
ÚNASE lotes EN asignaciones.batch_id = lotes.id"
"
ÚNASE a order_lines EN asignaciones.orderline_id = order_lines.id"
" DONDE order_lines.sku=:sku",
dict(sku=sku),
))
afirmar len(pedidos) == 1 con
unit_of_work.SqlAlchemyUnitOfWork() como uow:
uow.session.execute('select 1')

Comenzamos dos subprocesos que producirán de manera confiable el comportamiento de concurrencia que
queremos: lectura1, lectura2, escritura1, escritura2.

Afirmamos que el número de versión se ha incrementado solo una vez.

También podemos verificar la excepción específica si lo deseamos.

Y verificamos dos veces que solo haya pasado una asignación.

Aplicación de reglas de simultaneidad mediante transacciones de base de datos


Niveles de aislamiento

Para que la prueba pase tal como está, podemos establecer el nivel de aislamiento de transacciones en nuestra sesión:

Establecer el nivel de aislamiento para la sesión (src/allocation/service_layer/unit_of_work.py)

DEFAULT_SESSION_FACTORY = sessionmaker(bind=create_engine( config.get_postgres_uri(),


aislamiento_nivel=" LECTURA REPETIBLE",

))

Los niveles de aislamiento de transacciones son complicados, por lo que vale la pena
dedicar tiempo a comprender la documentación de Postgres. 3

3 Si no está utilizando Postgres, deberá leer otra documentación. De manera molesta, las diferentes bases de datos tienen
definiciones bastante diferentes. SERIALIZABLE de Oracle es equivalente a REPEATABLE READ de Postgres, por
ejemplo.

110 | Capítulo 7: Agregados y límites de consistencia


Machine Translated by Google

Ejemplo de control de concurrencia pesimista: SELECCIONAR PARA ACTUALIZAR Hay

varias formas de abordar esto, pero mostraremos una. SELECCIONAR PARA ACTUALIZAR produce un
comportamiento diferente; dos transacciones concurrentes no podrán hacer una lectura en las mismas filas
al mismo tiempo:

SELECCIONAR PARA ACTUALIZAR es una forma de elegir una fila o filas para usar como candado (aunque
esas filas no tienen que ser las que actualice). Si dos transacciones intentan SELECCIONAR PARA
ACTUALIZAR una fila al mismo tiempo, una ganará y la otra esperará hasta que se libere el bloqueo. Este
es un ejemplo de control de concurrencia pesimista.

Así es como puede usar SQLAlchemy DSL para especificar FOR UPDATE en el momento de la consulta:

SQLAlchemy with_for_update (src/allocation/adapters/repository.py)


def get(self, sku):
return self.session.query(modelo.Producto)
\ .filter_by(sku=sku)
\ .with_for_update() \ .first ()

Esto tendrá el efecto de cambiar el patrón de concurrencia de

leer1, leer2, escribir1, escribir2 (error)


a

lectura1, escritura1, lectura2, escritura2 (éxito)

Algunas personas se refieren a esto como el modo de falla de "lectura-modificación-escritura". Lea "Antipatrones de
PostgreSQL: ciclos de lectura, modificación y escritura" para obtener una buena descripción general.

Realmente no tenemos tiempo para discutir todas las compensaciones entre LECTURA REPETIBLE y
SELECCIONAR PARA ACTUALIZAR, o bloqueo optimista versus pesimista en general. Pero si tiene una
prueba como la que mostramos, puede especificar el comportamiento que desea y ver cómo cambia. También
puede utilizar la prueba como base para realizar algunos experimentos de rendimiento.

Envolver
Las opciones específicas sobre el control de concurrencia varían mucho en función de las circunstancias
comerciales y las opciones de tecnología de almacenamiento, pero nos gustaría volver a llevar este capítulo
a la idea conceptual de un agregado: modelamos explícitamente un objeto como el punto de entrada principal
a algún subconjunto. de nuestro modelo, y como encargado de hacer cumplir las invariantes y reglas
comerciales que se aplican a todos esos objetos.

Elegir el agregado correcto es clave, y es una decisión que puede revisar con el tiempo. Puede leer más
sobre esto en varios libros de DDD. También te recomendamos estos tres

Resumen | 111
Machine Translated by Google

documentos en línea sobre el diseño efectivo de agregados por Vaughn Vernon (el autor del "libro
rojo").

La Tabla 7-1 tiene algunas ideas sobre las ventajas y desventajas de implementar el patrón Agregado.

Tabla 7-1. Agregados: las compensaciones

ventajas Contras

• Es posible que Python no tenga métodos públicos y privados "oficiales", pero • Otro nuevo concepto que pueden asumir los nuevos
tenemos la convención de guiones bajos, porque a menudo es útil tratar de indicar desarrolladores. Explicar entidades versus objetos
qué es para uso "interno" y qué es para uso de "código externo". de valor ya era una carga mental; ahora hay un
Elegir agregados es solo el siguiente nivel: le permite decidir cuáles de las clases tercer tipo de objeto de modelo de dominio?
de su modelo de dominio son las públicas y cuáles no. • Modelar nuestras • Cumplir rígidamente con la regla que modificamos
operaciones en torno a límites de coherencia explícitos nos ayuda a evitar problemas solo un agregado a la vez es un gran cambio mental.
de rendimiento con nuestro ORM. • Poner al agregado a cargo exclusivo de los

cambios de estado en sus modelos subsidiarios hace que sea más fácil razonar sobre • Tratar con la eventual consistencia entre los
el sistema y facilita el control de las invariantes. agregados puede ser complejo.

Resumen de límites de consistencia y agregados


Los agregados son sus puntos de entrada al modelo de dominio Al
restringir la cantidad de formas en que se pueden cambiar las cosas, hacemos que el sistema sea
más fácil de razonar.

Los agregados están a cargo de un límite de consistencia El


trabajo de un agregado es poder administrar nuestras reglas comerciales sobre invariantes tal como
se aplican a un grupo de objetos relacionados. Es trabajo del agregado verificar que los objetos dentro
de su mandato sean consistentes entre sí y con nuestras reglas, y rechazar cambios que infrinjan las
reglas.

Los agregados y los problemas de concurrencia van de


la mano Cuando pensamos en implementar estas verificaciones de consistencia, terminamos
pensando en transacciones y bloqueos. Elegir el agregado correcto tiene que ver tanto con el
rendimiento como con la organización conceptual de su dominio.

112 | Capítulo 7: Agregados y límites de consistencia


Machine Translated by Google

Resumen de la parte I

¿Recuerda la Figura 7-5, el diagrama que mostramos al comienzo de la Parte I para ver hacia dónde nos dirigíamos?

Figura 7-5. Un diagrama de componentes para nuestra aplicación al final de la Parte I

Así que ahí es donde estamos al final de la Parte I. ¿Qué hemos logrado? Hemos visto cómo construir un modelo

de dominio que es ejercitado por un conjunto de pruebas unitarias de alto nivel. Nuestras pruebas son documentación
viva: describen el comportamiento de nuestro sistema, las reglas que acordamos con las partes interesadas de
nuestro negocio, en un código agradable y legible. Cuando nuestros requisitos comerciales cambian, confiamos en
que nuestras pruebas nos ayudarán a probar la nueva funcionalidad, y cuando los nuevos desarrolladores se unen
al proyecto, pueden leer nuestras pruebas para comprender cómo funcionan las cosas.

Resumen de la Parte I | 113


Machine Translated by Google

Hemos desacoplado las partes de infraestructura de nuestro sistema, como la base de datos y los
controladores de API, para que podamos conectarlos al exterior de nuestra aplicación. Esto nos ayuda
a mantener nuestra base de código bien organizada y evita que construyamos una gran bola de barro.

Al aplicar el principio de inversión de dependencia y al usar patrones inspirados en puertos y


adaptadores como Repository y Unit of Work, hemos hecho posible hacer TDD tanto en velocidad alta
como baja y mantener una pirámide de prueba saludable. Podemos probar nuestro sistema de extremo
a extremo, y la necesidad de integración y pruebas de extremo a extremo se reduce al mínimo.

Por último, hemos hablado sobre la idea de los límites de consistencia. No queremos bloquear todo
nuestro sistema cada vez que hacemos un cambio, por lo que tenemos que elegir qué partes son
consistentes entre sí.

Para un sistema pequeño, esto es todo lo que necesita para jugar con las ideas del diseño basado en
dominios. Ahora tiene las herramientas para crear modelos de dominio independientes de la base de
datos que representan el lenguaje compartido de sus expertos comerciales. ¡Hurra!

A riesgo de exagerar el punto, nos hemos esforzado en señalar que


cada patrón tiene un costo. Cada capa de direccionamiento indirecto
tiene un precio en términos de complejidad y duplicación en nuestro
código y será confuso para los programadores que nunca antes han
visto estos patrones. Si su aplicación es esencialmente un contenedor
CRUD simple alrededor de una base de datos y no es probable que
sea nada más que eso en el futuro previsible, no necesita estos
patrones. Continúe y use Django, y ahórrese muchas molestias.

En la Parte II, nos alejaremos y hablaremos sobre un tema más amplio: si los agregados son nuestro
límite y solo podemos actualizar uno a la vez, ¿cómo modelamos procesos que cruzan los límites de
consistencia?

114 | Capítulo 7: Agregados y límites de consistencia


Machine Translated by Google

PARTE II

Arquitectura impulsada por eventos

Lamento haber acuñado hace mucho tiempo el término "objetos" para este tema porque hace que muchas
personas se concentren en la idea menor.

La gran idea es la "mensajería".... La clave para crear sistemas excelentes y que puedan crecer es mucho
más diseñar cómo se comunican sus módulos en lugar de cuáles deberían ser sus propiedades y
comportamientos internos.

—Alan Kay

Está muy bien poder escribir un modelo de dominio para administrar un solo bit de proceso comercial,
pero ¿qué sucede cuando necesitamos escribir muchos modelos? En el mundo real, nuestras aplicaciones
se ubican dentro de una organización y necesitan intercambiar información con otras partes del sistema.
Puede recordar nuestro diagrama de contexto que se muestra en la Figura II-1.

Ante este requisito, muchos equipos buscan microservicios integrados a través de API HTTP. Pero si no
tienen cuidado, terminarán produciendo el desorden más caótico de todos: la gran bola de barro
distribuida.

En la Parte II, mostraremos cómo las técnicas de la Parte I se pueden extender a los sistemas distribuidos.
Nos alejaremos para ver cómo podemos componer un sistema a partir de muchos componentes
pequeños que interactúan a través del paso de mensajes asincrónicos.

Veremos cómo nuestros patrones de capa de servicio y unidad de trabajo nos permiten reconfigurar
nuestra aplicación para que se ejecute como un procesador de mensajes asíncrono, y cómo los sistemas
controlados por eventos nos ayudan a desacoplar agregados y aplicaciones entre sí.
Machine Translated by Google

Figura II-1. Pero, ¿exactamente cómo se comunicarán todos estos sistemas entre sí?

Veremos los siguientes patrones y técnicas:

Eventos de dominio

Active flujos de trabajo que traspasen los límites de la coherencia.

Bus de mensajes
Proporcione una forma unificada de invocar casos de uso desde cualquier punto final.

CQRS
La separación de lecturas y escrituras evita compromisos incómodos en una arquitectura basada en eventos y
permite mejoras en el rendimiento y la escalabilidad.

Además, agregaremos un marco de inyección de dependencia. Esto no tiene nada que ver con la arquitectura
impulsada por eventos per se, pero arregla una gran cantidad de cabos sueltos.
Machine Translated by Google

CAPÍTULO 8

Eventos y el bus de mensajes

Hasta ahora hemos dedicado mucho tiempo y energía a un problema simple que podríamos haber resuelto
fácilmente con Django. Quizás se esté preguntando si el aumento de la capacidad de prueba y la expresividad
realmente valen la pena todo el esfuerzo.

En la práctica, sin embargo, encontramos que no son las características obvias las que ensucian nuestras
bases de código: es la sustancia pegajosa alrededor del borde. Son los informes, los permisos y los flujos de
trabajo los que afectan a un millón de objetos.

Nuestro ejemplo será un requisito de notificación típico: cuando no podemos asignar un pedido porque no
tenemos stock, debemos alertar al equipo de compras. Irán y solucionarán el problema comprando más
acciones, y todo irá bien.

Para una primera versión, el propietario de nuestro producto dice que podemos enviar la alerta por correo electrónico.

Veamos cómo resiste nuestra arquitectura cuando necesitamos conectar algunas de las cosas mundanas que
componen gran parte de nuestros sistemas.

Comenzaremos haciendo lo más simple y rápido, y hablaremos de por qué es exactamente este tipo de
decisión lo que nos lleva a la Gran Bola de Lodo.

Luego, mostraremos cómo usar el patrón de eventos de dominio para separar los efectos secundarios de
nuestros casos de uso y cómo usar un patrón de bus de mensajes simple para desencadenar comportamientos
basados en esos eventos. Mostraremos algunas opciones para crear esos eventos y cómo pasarlos al bus de
mensajes y, finalmente, mostraremos cómo se puede modificar el patrón de la Unidad de trabajo para conectar
los dos de forma elegante, como se muestra en la Figura 8-1. .

117
Machine Translated by Google

Figura 8-1. Eventos debidos a través del sistema

El código de este capítulo está en la rama


chapter_08_events_and_message_bus en GitHub:
git clone https://github.com/cosmicpython/code.git cd code git
checkout chapter_08_events_and_message_bus # o para
codificar, consulte el capítulo anterior: git checkout
chapter_07_aggregate

Evitar hacer un desastre


Asi que. Alertas por correo electrónico cuando nos quedamos sin existencias. Cuando tenemos nuevos
requisitos como los que realmente no tienen nada que ver con el dominio central, es muy fácil comenzar a
volcar estas cosas en nuestros controladores web.

Primero, evitemos ensuciar nuestros controladores web


Como un truco único, esto podría estar bien:

Simplemente golpéelo en el punto final, ¿qué podría salir mal? (src/asignación/puntos de entrada/ask_app.py)
@app.route("/asignar", métodos=['POST']) def
allocate_endpoint():
línea = modelo.OrderLine(

118 | Capítulo 8: Eventos y el bus de mensajes


Machine Translated by Google

solicitud.json['orderid'],
solicitud.json['sku'],
solicitud.json['cantidad'],

)
intente: uow = unit_of_work.SqlAlchemyUnitOfWork()
batchref = services.allocate(line, uow) excepto
(model.OutOfStock, services.InvalidSku) as e: send_mail( 'out of
stock', 'stock_admin@made.com', f '{línea.orderid} -
{línea.sku}'

) devuelve jsonify({'mensaje': str(e)}), 400

devuelve jsonify ({'referencia de lote': referencia de lote}), 201

…pero es fácil ver cómo podemos terminar rápidamente en un lío arreglando las cosas de esta manera. El
envío de correo electrónico no es el trabajo de nuestra capa HTTP y nos gustaría poder probar esta nueva
función de forma unitaria.

Y tampoco hagamos un lío de nuestro modelo


Suponiendo que no queremos poner este código en nuestros controladores web, porque queremos que sean
lo más delgados posible, podemos pensar en ponerlo directamente en la fuente, en el modelo:

El código de envío de correo electrónico en nuestro modelo tampoco es encantador (src/allocation/domain/model.py)

def allocate(self, línea: OrderLine) -> str:

intente: lote =
siguiente ( b para b en ordenados (auto. lotes) si b.can_allocate (línea)

) #...
excepto StopIteration:
email.send_mail('stock@made.com', f'Agotado para {line.sku}') raise
OutOfStock(f'Agotado para sku {line.sku}')

¡Pero eso es aún peor! No queremos que nuestro modelo tenga ninguna dependencia de problemas de
infraestructura como email.send_mail.

Esto de enviar correos electrónicos no es bienvenido y arruina el buen flujo limpio de nuestro sistema. Lo que
nos gustaría es mantener nuestro modelo de dominio enfocado en la regla "No puede asignar más cosas de
las que realmente están disponibles".

El trabajo del modelo de dominio es saber que no tenemos stock, pero la responsabilidad de enviar una alerta
pertenece a otra parte. Deberíamos poder activar o desactivar esta función, o cambiar a notificaciones por
SMS en su lugar, sin necesidad de cambiar las reglas de nuestro modelo de dominio.

Evitar ensuciar | 119


Machine Translated by Google

¡O la capa de servicio!
El requisito “Intente asignar algo de stock y envíe un correo electrónico si falla” es un ejemplo de orquestación
de flujo de trabajo: es un conjunto de pasos que el sistema debe seguir para lograr un objetivo.

Hemos escrito una capa de servicio para administrar la orquestación por nosotros, pero incluso aquí la función
se siente fuera de lugar:

Y en la capa de servicio, está fuera de lugar (src/allocation/service_layer/services.py)


asignar por defecto (
orderid: str, sku: str, qty: int, uow:
unit_of_work.AbstractUnitOfWork ) -> str: line
= OrderLine(orderid, sku, qty) with uow: product =
uow.products.get(sku=line.sku) if el producto es
Ninguno: aumentar InvalidSku(f'Invalid sku
{line.sku}') intente: batchref = product.allocate(line)
uow.commit() return batchref excepto
model.OutOfStock:

email.send_mail('stock@made.com', f'Agotado para {line.sku}') aumentar

¿Atrapar una excepción y volver a subirla? Podría ser peor, pero definitivamente nos está haciendo infelices.
¿Por qué es tan difícil encontrar un hogar adecuado para este código?

Principio de responsabilidad única


Realmente, esto es una violación del principio de responsabilidad única (SRP).1 Nuestro caso de uso es la
asignación. Nuestros métodos de punto final, función de servicio y dominio se denominan allocate, no
allocate_and_send_mail_if_out_of_stock.

Regla general: si no puede describir lo que hace su función sin


usar palabras como "entonces" o "y", es posible que esté violando
el SRP.

1 Este principio es la S en SOLID.

120 | Capítulo 8: Eventos y el bus de mensajes


Machine Translated by Google

Una formulación del SRP es que cada clase debe tener una sola razón para cambiar. Cuando
cambiamos de correo electrónico a SMS, no deberíamos tener que actualizar nuestra función allo
cate() , porque esa es claramente una responsabilidad separada.

Para resolver el problema, vamos a dividir la orquestación en pasos separados para que las
diferentes preocupaciones no se enreden.2 El trabajo del modelo de dominio es saber que no
tenemos existencias, pero la responsabilidad de enviar un alerta pertenece a otra parte. Deberíamos
poder activar o desactivar esta función, o cambiar a notificaciones por SMS en su lugar, sin necesidad
de cambiar las reglas de nuestro modelo de dominio.

También nos gustaría mantener la capa de servicio libre de detalles de implementación. Queremos
aplicar el principio de inversión de dependencia a las notificaciones para que nuestra capa de
servicio dependa de una abstracción, de la misma forma que evitamos depender de la base de datos
usando una unidad de trabajo.

¡Todos a bordo del autobús de mensajes!

Los patrones que vamos a presentar aquí son los eventos de dominio y el bus de mensajes.
Podemos implementarlos de varias maneras, así que mostraremos un par antes de decidirnos por
el que más nos guste.

El modelo registra eventos


Primero, en lugar de preocuparse por los correos electrónicos, nuestro modelo estará a cargo de
registrar eventos, hechos sobre cosas que han sucedido. Usaremos un bus de mensajes para
responder a eventos e invocar una nueva operación.

Los eventos son clases de datos

simples Un evento es un tipo de objeto de valor. Los eventos no tienen ningún comportamiento,
porque son estructuras de datos puras. Siempre nombramos eventos en el idioma del dominio y los
consideramos parte de nuestro modelo de dominio.

Podríamos almacenarlos en model.py, pero también podemos mantenerlos en su propio archivo


(este podría ser un buen momento para considerar refactorizar un directorio llamado dominio para
que tengamos dominio/modelo.py y dominio/eventos.py ):

2 A nuestro revisor técnico Ed Jung le gusta decir que el paso del control de flujo imperativo al basado en eventos
cambia lo que solía ser orquestación en coreografía.

¡Todos a bordo del autobús de mensajes! | 121


Machine Translated by Google

Clases de eventos (src/allocation/domain/events.py)

desde clases de datos importar clase de datos

evento de clase :
pasar

@dataclass
clase Agotado(Evento): sku: str

Una vez que tengamos una cantidad de eventos, nos resultará útil tener una clase principal que pueda
almacenar atributos comunes. También es útil para escribir sugerencias en nuestro bus de mensajes,
como verá en breve.

Las clases de datos también son excelentes para eventos de dominio.

La Modelo Plantea Eventos

Cuando nuestro modelo de dominio registra un hecho que sucedió, decimos que genera un evento.

Así es como se verá desde el exterior; si le pedimos a Product que asigne pero no puede, debería generar
un evento:

Pruebe nuestro agregado para generar eventos (tests/unit/test_product.py)

def test_records_out_of_stock_event_if_cannot_allocate(): lote = Lote('lote1',


'HORQUILLA PEQUEÑA', 10, eta=hoy) producto = Producto(sku="HORQUILLA
PEQUEÑA", lotes=[lote]) producto.allocate(OrderLine(' pedido1', 'TENEDOR
PEQUEÑO', 10))

asignación = producto.allocate(OrderLine('order2', 'SMALL-FORK', 1)) afirmar product.events[-1]


== events.OutOfStock(sku="SMALL-FORK") afirmar que la asignación es Ninguna

Nuestro agregado expondrá un nuevo atributo llamado .events que contendrá una lista de hechos
sobre lo que sucedió, en forma de objetos Event .

Así es como se ve el modelo por dentro:

El modelo genera un evento de dominio (src/allocation/domain/model.py)


producto de clase :

def __init__(self, sku: str, lotes: Lista[Lote], número_versión: int = 0):


self.sku = sku
self.lotes = lotes
self.version_number = version_number self.events =
[] # type: List[events.Event]

def allocate(self, línea: OrderLine) -> str:

122 | Capítulo 8: Eventos y el bus de mensajes


Machine Translated by Google

probar:
#...
excepto StopIteration:
self.events.append(events.OutOfStock(line.sku)) # raise
OutOfStock(f'Outofstock for sku {line.sku}') return Ninguno

Aquí está nuestro nuevo atributo .events en uso.

En lugar de invocar un código de envío de correo electrónico directamente, registramos esos


eventos en el lugar en que ocurren, usando solo el idioma del dominio.

También vamos a dejar de generar una excepción para el caso de falta de existencias. El evento
hará el trabajo que estaba haciendo la excepción.

De hecho, estamos abordando un olor a código que teníamos hasta ahora,


que es que estábamos usando excepciones para controlar el flujo. En
general, si está implementando eventos de dominio, no genere excepciones
para describir el mismo concepto de dominio. Como verá más adelante
cuando manejemos eventos en el patrón Unidad de trabajo, es confuso tener
que razonar sobre eventos y excepciones juntos.

El bus de mensajes asigna eventos a controladores Un

bus de mensajes básicamente dice: "Cuando veo este evento, debo invocar la siguiente función de
controlador". En otras palabras, es un sistema simple de publicación y suscripción. Los controladores
están suscritos para recibir eventos, que publicamos en el bus. Suena más difícil de lo que es, y
generalmente lo implementamos con un dict:

Bus de mensajes simple (src/allocation/service_layer/messagebus.py)


def manejador(evento: eventos.Evento): para manejador en MANEJADORES[tipo(evento)]: manejador(evento)

def send_out_of_stock_notification(event: events.OutOfStock):


email.send_mail( 'stock@made.com', f'Agotado para {event.sku}',

MANEJADORES = {
eventos.OutOfStock: [send_out_of_stock_notification],

} # tipo: Dict[Tipo[eventos.Evento], Lista[Llamable]]

¡Todos a bordo del autobús de mensajes! | 123


Machine Translated by Google

Tenga en cuenta que el bus de mensajes implementado no nos da


concurrencia porque solo se ejecutará un controlador a la vez. Nuestro
objetivo no es admitir subprocesos paralelos sino separar tareas
conceptualmente y mantener cada UoW lo más pequeño posible. Esto nos
ayuda a comprender el código base porque la “receta” sobre cómo ejecutar
cada caso de uso está escrita en un solo lugar. Consulte la siguiente barra lateral.

¿Esto es como el apio?

El apio es una herramienta popular en el mundo de Python para diferir partes de trabajo independientes
a una cola de tareas asincrónicas. El bus de mensajes que presentamos aquí es muy diferente, por lo
que la respuesta corta a la pregunta anterior es no; nuestro bus de mensajes tiene más en común con
una aplicación Node.js, un bucle de eventos de interfaz de usuario o un marco de actor.

Si tiene un requisito para mover el trabajo del hilo principal, aún puede usar nuestras metáforas basadas
en eventos, pero le sugerimos que use eventos externos para eso. Hay más discusión en la Tabla 11-1,
pero esencialmente, si implementa una forma de persistir eventos en un almacén centralizado, puede
suscribir otros contenedores u otros microservicios a ellos. Luego, ese mismo concepto de usar eventos
para separar responsabilidades entre unidades de trabajo dentro de un solo proceso/servicio puede
extenderse a múltiples procesos, que pueden ser diferentes contenedores dentro del mismo servicio o
microservicios totalmente diferentes.

Si nos sigue en este enfoque, su API para distribuir tareas son sus clases de eventos, o una
representación JSON de ellas. Esto le permite mucha flexibilidad a la hora de distribuir las tareas; no
necesariamente tienen que ser servicios de Python. La API de Celery para distribuir tareas es
esencialmente "nombre de función más argumentos", que es más restrictiva y solo para Python.

Opción 1: la capa de servicio toma eventos del


modelo y los coloca en el bus de mensajes
Nuestro modelo de dominio genera eventos y nuestro bus de mensajes llamará a los controladores correctos
cada vez que ocurra un evento. Ahora todo lo que necesitamos es conectar los dos. Necesitamos algo para
captar eventos del modelo y pasarlos al bus de mensajes: el paso de publicación.

La forma más sencilla de hacer esto es agregando algo de código en nuestra capa de servicio:

La capa de servicio con un bus de mensajes explícito (src/allocation/service_layer/services.py)

de . importar bus de mensajes


...

asignar por defecto (

124 | Capítulo 8: Eventos y el bus de mensajes


Machine Translated by Google

orderid: str, sku: str, qty: int, uow:


unit_of_work.AbstractUnitOfWork ) -> str: line =
OrderLine(orderid, sku, qty) with uow: product =
uow.products.get(sku=line.sku) if el producto es Ninguno:
aumentar InvalidSku(f'Invalid sku {line.sku}') intente:
batchref = product.allocate(line) uow.commit() return batchref

finalmente:
messagebus.handle(producto.eventos)

Mantenemos el try/finally de nuestra desagradable implementación anterior (todavía no nos hemos


deshecho de todas las excepciones, solo OutOfStock).

Pero ahora, en lugar de depender directamente de una infraestructura de correo electrónico, la


capa de servicio solo está a cargo de pasar eventos desde el modelo hasta el bus de mensajes.

Eso ya evita algunas de las fealdades que teníamos en nuestra implementación ingenua, y tenemos
varios sistemas que funcionan como este, en el que la capa de servicio recopila explícitamente eventos
de agregados y los pasa al bus de mensajes.

Opción 2: la capa de servicio genera sus propios eventos


Otra variante de esto que hemos usado es hacer que la capa de servicio se encargue de crear y generar
eventos directamente, en lugar de que el modelo de dominio los genere:

La capa de servicio llama a messagebus.handle directamente (src/allocation/service_layer/services.py)

asignar por defecto (


orderid: str, sku: str, qty: int, uow:
unit_of_work.AbstractUnitOfWork ) -> str: line =
OrderLine(orderid, sku, qty) with uow: product =
uow.products.get(sku=line.sku) if el producto es Ninguno:
aumentar InvalidSku(f'Invalid sku {line.sku}') batchref =
product.allocate(line) uow.commit()

si loteref es Ninguno:
messagebus.handle(events.OutOfStock(line.sku)) return loteref

Opción 2: La capa de servicio genera sus propios eventos | 125


Machine Translated by Google

Como antes, nos comprometemos incluso si fallamos en la asignación porque el código es más
simple de esta manera y es más fácil razonar: siempre nos comprometemos a menos que algo
salga mal. Confirmar cuando no hemos cambiado nada es seguro y mantiene el código
despejado.

Nuevamente, tenemos aplicaciones en producción que implementan el patrón de esta manera.


Lo que funcione para usted dependerá de las compensaciones particulares que enfrente, pero nos
gustaría mostrarle lo que creemos que es la solución más elegante, en la que ponemos la unidad de
trabajo a cargo de recopilar y generar eventos.

Opción 3: la UoW publica eventos en el bus de mensajes


El UoW ya tiene un intento/finalmente, y conoce todos los agregados actualmente en juego porque
proporciona acceso al repositorio. Por lo tanto, es un buen lugar para detectar eventos y pasarlos al
bus de mensajes:

La UoW se encuentra con el bus de mensajes (src/allocation/service_layer/unit_of_work.py)


clase AbstractUnitOfWork(abc.ABC):
...

def commit(self):
self._commit()
self.publish_events()

def publicar_eventos(self): for


product in self.products.seen: while
product.events: event =
product.events.pop(0)
messagebus.handle(event)

@abc.abstractmethod
def _commit(self):
aumentar NotImplementedError

...

clase SqlAlchemyUnitOfWork(AbstractUnitOfWork):
...

def _commit(self):
self.session.commit()

Cambiaremos nuestro método de confirmación para requerir un método privado ._commit() de


las subclases.

Después de confirmar, ejecutamos todos los objetos que nuestro repositorio ha visto y pasamos
sus eventos al bus de mensajes.

126 | Capítulo 8: Eventos y el bus de mensajes


Machine Translated by Google

Eso depende de que el repositorio realice un seguimiento de los agregados que se han
cargado utilizando un nuevo atributo, .seen, como verá en la siguiente lista.

¿Se pregunta qué sucede si uno de los controladores falla? Discutiremos


el manejo de errores en detalle en el Capítulo 10.

El repositorio rastrea los agregados que pasan a través de él (src/allocation/adapters/repository.py)


clase AbstractRepository(abc.ABC):

def __init__(self):
self.seen = set() # tipo: Set[modelo.Producto]

def add(self, producto: modelo.Producto):


self._add(producto)
self.seen.add(producto)

def get(self, sku) -> modelo.Producto:


producto = self._get(sku) si
producto:
self.seen.add(producto)
devolver producto

@abc.abstractmethod
def _add(self, producto: modelo.Producto):
aumentar NotImplementedError

@abc.abstractmethod
def _get(self, sku) -> modelo.Producto:
aumentar NotImplementedError

clase SqlAlchemyRepository(AbstractRepository):

def __init__(self, sesión):


super().__init__() self.session
= sesión

def _add(uno mismo, producto):


self.session.add(producto)

def _get(self, sku): return


self.session.query(modelo.Producto).filter_by(sku=sku).primero()

Para que la UoW pueda publicar nuevos eventos, debe poder preguntar al repositorio para
qué objetos del Producto se han utilizado durante esta sesión. Usamos

Opción 3: la UoW publica eventos en el bus de mensajes | 127


Machine Translated by Google

un conjunto llamado .seen para almacenarlos. Eso significa que nuestras implementaciones deben
llamar a super().__init__().

El método padre add() agrega cosas a .seen, y ahora requiere subclases para implementar ._add().

De manera similar, .get() delega a una función ._get() , para ser implementada por subclases, para
capturar los objetos vistos.

El uso de métodos ._underscorey() y subclases definitivamente no


es la única forma en que puede implementar estos patrones.
Pruebe el Ejercicio para el lector de este capítulo y experimente
con algunas alternativas.

Después de que la UoW y el repositorio colaboren de esta manera para realizar un seguimiento automático
de los objetos en vivo y procesar sus eventos, la capa de servicio puede estar totalmente libre de problemas
de manejo de eventos:

La capa de servicio vuelve a estar limpia (src/allocation/service_layer/services.py)

asignar por defecto (


orderid: str, sku: str, qty: int, uow:
unit_of_work.AbstractUnitOfWork ) -> str:
line = OrderLine(orderid, sku, qty) with uow: product
= uow.products.get(sku=line.sku) if el producto
es Ninguno: aumentar InvalidSku(f'Invalid sku
{line.sku}') batchref = product.allocate(line)
uow.commit()

devolver referencia de lote

También debemos recordar cambiar las falsificaciones en la capa de servicio y hacer que llamen a super()
en los lugares correctos, e implementar métodos de subrayado, pero los cambios son mínimos:

Las falsificaciones de la capa de servicio necesitan ajustes (tests/unit/test_services.py)

clase FakeRepository(repositorio.AbstractRepository):

def __init__(self, productos):


super().__init__()
self._products = set(products)

def _add(self, producto):


self._products.add(producto)

128 | Capítulo 8: Eventos y el bus de mensajes


Machine Translated by Google

def _get(self, sku):


return next((p por p en self._products if p.sku == sku), Ninguno)

...

clase FakeUnitOfWork(unidad_de_trabajo.AbstractUnitOfWork):
...

def _commit(self):
self.committed = True

Ejercicio para el lector


¿Estás encontrando todos esos métodos ._add() y ._commit() "súper asquerosos", en
palabras de nuestro amado crítico técnico Hynek? ¿"Te dan ganas de golpear a Harry en la
cabeza con una serpiente de peluche"? ¡Oye, nuestras listas de códigos solo pretenden ser
ejemplos, no la solución perfecta! ¿Por qué no vas a ver si puedes hacerlo mejor?

Una forma de composición sobre la herencia sería implementar una clase contenedora:

Un contenedor agrega funcionalidad y luego delega (src/adapters/repository.py)


la clase TrackingRepository:
visto: Conjunto[modelo.Producto]

def __init__(self, repositorio: AbstractRepository):


self.seen = set() # tipo: Set[modelo.Producto] self._repo
= repo

def add(self, producto: modelo.Producto):


self._repo.add(producto)
self.seen.add(producto)

def get(self, sku) -> modelo.Producto:


producto = self._repo.get(sku) if
producto:
self.seen.add(producto)
devolver producto

Al envolver el repositorio, podemos llamar a los métodos .add() y .get() reales , evitando
métodos extraños de subrayado.

Vea si puede aplicar un patrón similar a nuestra clase UoW para deshacerse también de
esos métodos Java-y _commit() . Puedes encontrar el código en GitHub.

Cambiar todo el ABC a escribir. El protocolo es una buena manera de obligarse a evitar el
uso de la herencia. ¡Cuéntanos si se te ocurre algo bonito!

Opción 3: la UoW publica eventos en el bus de mensajes | 129


Machine Translated by Google

Es posible que esté comenzando a preocuparse de que el mantenimiento de estas falsificaciones sea una carga
de mantenimiento. No hay duda de que es trabajo, pero en nuestra experiencia no es mucho trabajo. Una vez
que su proyecto está en funcionamiento, la interfaz para su repositorio y las abstracciones de UoW realmente no
cambian mucho. Y si está usando ABC, le ayudarán a recordar cuando las cosas se desincronicen.

Envolver
Los eventos de dominio nos brindan una forma de manejar los flujos de trabajo en nuestro sistema. A menudo,
al escuchar a nuestros expertos en el dominio, encontramos que expresan los requisitos de una manera causal
o temporal, por ejemplo, "Cuando tratamos de asignar existencias pero no hay ninguna disponible, entonces
debemos enviar un correo electrónico al equipo de compras".

Las palabras mágicas “Cuando X, entonces Y” muchas veces nos hablan de un evento que podemos concretar
en nuestro sistema. Tratar los eventos como cosas de primera clase en nuestro modelo nos ayuda a hacer que
nuestro código sea más comprobable y observable, y ayuda a aislar las preocupaciones.

Y la tabla 8-1 muestra las compensaciones tal como las vemos.

Tabla 8-1. Eventos de dominio: las ventajas y desventajas

ventajas Contras

• Un bus de mensajes nos brinda una buena • El bus de mensajes es algo adicional para entender; la

manera de separar responsabilidades cuando La implementación en la que la unidad de trabajo plantea eventos para nosotros es ordenada

tenemos que realizar varias acciones en respuesta a pero también mágica. No es obvio cuando llamamos a commit que también vamos a enviar
una solicitud. • Los controladores de eventos están correos electrónicos a las personas. • Además, ese código oculto de manejo de eventos se

muy bien desacoplados de la lógica de la aplicación ejecuta sincrónicamente,


"central", lo que facilita cambiar su implementación lo que significa que su función de capa de servicio no finaliza hasta que finalizan todos los
más adelante. • Los eventos de dominio son una controladores de cualquier evento. Eso podría causar problemas de rendimiento inesperados

excelente manera de modelar el mundo real y podemos en sus puntos finales web (es posible agregar procesamiento asincrónico, pero hace que las

usarlos como parte de nuestro lenguaje comercial cosas sean aún más confusas). • De manera más general, los flujos de trabajo basados en

cuando modelamos con las partes interesadas. eventos pueden ser confusos porque después

las cosas se dividen en una cadena de múltiples controladores, no hay un solo lugar en el

sistema donde pueda comprender cómo se cumplirá una solicitud.

• También se abre a la posibilidad de dependencias circulares entre sus controladores

de eventos y bucles infinitos.

Sin embargo, los eventos son útiles para algo más que enviar correos electrónicos. En el Capítulo 7 dedicamos
mucho tiempo a convencerlo de que debe definir agregados o límites donde garantizamos la consistencia. La
gente suele preguntar: "¿Qué debo hacer si necesito cambiar varios agregados como parte de una solicitud?"
Ahora tenemos las herramientas que necesitamos para responder a esa pregunta.

Si tenemos dos cosas que pueden aislarse transaccionalmente (p. ej., un pedido y un producto), podemos hacer
que eventualmente sean consistentes usando eventos. Cuando un

130 | Capítulo 8: Eventos y el bus de mensajes


Machine Translated by Google

se cancela el pedido, debemos encontrar los productos que se le asignaron y eliminar las asignaciones.

Resumen de eventos de dominio y bus de mensajes

Los eventos pueden ayudar con el principio de responsabilidad única. El


código se enreda cuando mezclamos varias preocupaciones en un solo lugar. Los eventos pueden ayudarnos a
mantener las cosas ordenadas al separar los casos de uso primarios de los secundarios.
También usamos eventos para la comunicación entre agregados, de modo que no necesitamos ejecutar transacciones
de ejecución prolongada que se bloquean en varias tablas.

Un bus de mensajes enruta los mensajes a los controladores


Puede pensar en un bus de mensajes como un dictado que mapea desde los eventos hasta sus consumidores. No
“sabe” nada sobre el significado de los eventos; es solo una pieza de infraestructura tonta para enviar mensajes por el
sistema.

Opción 1: la capa de servicio genera eventos y los pasa al bus de mensajes La forma más
sencilla de comenzar a usar eventos en su sistema es generarlos desde los controladores llamando a bus.handle

(some_new_event) después de confirmar su unidad de trabajo.

Opción 2: el modelo de dominio genera eventos, la capa de servicio los pasa al bus de mensajes
La lógica sobre cuándo generar un evento realmente debe vivir con el modelo, de modo que podamos mejorar el diseño
y la capacidad de prueba de nuestro sistema al generar eventos desde el modelo de dominio. Es fácil para nuestros
controladores recopilar eventos de los objetos del modelo después de confirmarlos y pasarlos al bus.

Opción 3: UoW recopila eventos de agregados y los pasa al bus de mensajes

Agregar bus.handle(aggregate.events) a cada controlador es molesto, por lo que podemos arreglarlo haciendo que
nuestra unidad de trabajo sea responsable de generar eventos generados por objetos cargados. Este es el diseño más
complejo y puede depender de la magia ORM, pero es limpio y fácil de usar una vez que está configurado.

En el Capítulo 9, veremos esta idea con más detalle a medida que construimos un flujo de trabajo más
complejo con nuestro nuevo bus de mensajes.

Resumen | 131
Machine Translated by Google
Machine Translated by Google

CAPÍTULO 9

Ir a la ciudad en el autobús de mensajes

En este capítulo, comenzaremos a hacer que los eventos sean más fundamentales para la estructura
interna de nuestra aplicación. Pasaremos del estado actual en la Figura 9-1, donde los eventos son un
efecto secundario opcional...

Figura 9-1. Antes: el bus de mensajes es un complemento opcional

133
Machine Translated by Google

…a la situación de la Figura 9-2, donde todo pasa por el bus de mensajes, y nuestra aplicación se ha
transformado fundamentalmente en un procesador de mensajes.

Figura 9-2. El bus de mensajes es ahora el principal punto de entrada a la capa de servicio.

El código de este capítulo se encuentra en la rama


chapter_09_all_messagebus en GitHub:

git clone https://github.com/cosmicpython/code.git cd code git


checkout chapter_09_all_messagebus # o para codificar,
consulte el capítulo anterior: git checkout
chapter_08_events_and_message_bus

134 | Capítulo 9: Ir a la ciudad en el autobús de mensajes


Machine Translated by Google

Un nuevo requisito nos lleva a una nueva arquitectura


Rich Hickey habla de software situado, es decir, software que se ejecuta durante largos períodos
de tiempo, gestionando un proceso del mundo real. Los ejemplos incluyen sistemas de gestión de
almacenes, programadores de logística y sistemas de nómina.

Este software es complicado de escribir porque suceden cosas inesperadas todo el tiempo en el
mundo real de objetos físicos y humanos poco confiables. Por ejemplo:

• Durante un inventario, descubrimos que tres COLCHONES SPRINGY han sido dañados por el
agua debido a una gotera en el techo. • A un envío de RELIABLE-FORK le falta la

documentación requerida y está retenido en la aduana durante varias semanas. Posteriormente,


tres RELIABLE-FORK fallan en las pruebas de seguridad y se destruyen. • La escasez mundial
de lentejuelas significa que no podemos fabricar nuestro próximo lote

de BRILLANTE-LIBRERÍA.

En este tipo de situaciones, aprendemos sobre la necesidad de cambiar las cantidades de lotes
cuando ya están en el sistema. Quizás alguien se equivocó en el número del manifiesto, o quizás
algunos sofás se cayeron de un camión. Luego de una conversación con el negocio,1 modelamos
la situación como en la Figura 9-3.

Figura 9-3. La cantidad de lote cambiada significa desasignar y reasignar

Un evento que llamaremos BatchQuantityChanged debería llevarnos a cambiar la cantidad en el


lote, sí, pero también a aplicar una regla comercial: si la nueva cantidad cae por debajo del total ya
asignado, debemos desasignar esos pedidos de ese lote. . Luego, cada uno requerirá una nueva
asignación, que podemos capturar como un evento llamado Allo cationRequired.

Tal vez ya esté anticipando que nuestros eventos y bus de mensajes internos pueden ayudar a
implementar este requisito. Podríamos definir un servicio llamado change_batch_quantity que sepa
cómo ajustar las cantidades de lote y también cómo desasignar cualquier línea de pedido en
exceso, y luego cada desasignación puede emitir un evento AllocationRequired que

1 El modelado basado en eventos es tan popular que se ha desarrollado una práctica llamada tormenta de eventos para facilitar la
recopilación de requisitos basados en eventos y la elaboración de modelos de dominio.

Una nueva exigencia nos lleva a una nueva arquitectura | 135


Machine Translated by Google

se puede reenviar al servicio de asignación existente , en transacciones separadas. Una vez más, nuestro bus
de mensajes nos ayuda a hacer cumplir el principio de responsabilidad única y nos permite tomar decisiones
sobre las transacciones y la integridad de los datos.

Imaginando un cambio de arquitectura: todo será un


Controlador de eventos

Pero antes de lanzarnos, piense hacia dónde nos dirigimos. Hay dos tipos de flujos a través de nuestro sistema:

• Llamadas API que son manejadas por una función de capa de servicio

• Eventos internos (que pueden generarse como un efecto secundario de una función de capa de servicio) y
sus controladores (que a su vez llaman a funciones de capa de servicio)

¿No sería más fácil si todo fuera un controlador de eventos? Si reconsideramos nuestras llamadas API como
eventos de captura, las funciones de la capa de servicio también pueden ser controladores de eventos, y ya no
necesitamos hacer una distinción entre controladores de eventos internos y externos:

• services.allocate() podría ser el controlador de un evento AllocationRequired


y podría emitir eventos asignados como su salida. •

services.add_batch() podría ser el controlador de un evento BatchCreated.2

Nuestro nuevo requisito se ajustará al mismo patrón:

• Un evento llamado BatchQuantityChanged puede invocar un controlador llamado


cambiar_cantidad_de_lote().
• Y los nuevos eventos AllocationRequired que puede generar también se pueden pasar a services.allocate() ,
por lo que no hay diferencia conceptual entre una nueva asignación proveniente de la API y una
reasignación que se activa internamente por una desasignación.

Todo suena como un poco demasiado? Trabajemos para lograrlo todo gradualmente. Seguiremos el flujo de
trabajo de refactorización preparatoria , también conocido como “Facilita el cambio; entonces haz el cambio fácil”:

1. Refactorizamos nuestra capa de servicio en controladores de eventos. Podemos acostumbrarnos a la idea


de que los eventos son la forma en que describimos las entradas al sistema. En particular, la función
services.allocate() existente se convertirá en el controlador de un evento llamado Allo cationRequired.

2 Si ha leído un poco sobre arquitecturas basadas en eventos, puede estar pensando: "¡Algunos de estos eventos
suenan más como comandos!" ¡Tenga paciencia con nosotros! Estamos tratando de introducir un concepto a la vez.
En el próximo capítulo, presentaremos la distinción entre comandos y eventos.

136 | Capítulo 9: Ir a la ciudad en el autobús de mensajes


Machine Translated by Google

2. Creamos una prueba de extremo a extremo que coloca los eventos BatchQuantityChanged en el sistema .
y busca los eventos asignados que salen.

3. Nuestra implementación será conceptualmente muy simple: un nuevo controlador para eventos Batch
CantidadChanged , cuya implementación emitirá eventos AllocationRequired , que a su vez serán
manejados exactamente por el mismo controlador para asignaciones que usa la API.

En el camino, haremos un pequeño cambio en el bus de mensajes y UoW, trasladando la responsabilidad de


poner nuevos eventos en el bus de mensajes al propio bus de mensajes.

Refactorización de funciones de servicio a controladores de mensajes

Comenzamos definiendo los dos eventos que capturan nuestras entradas API actuales—Asignación
Requerido y BatchCreated:

Eventos BatchCreated y AllocationRequired (src/allocation/domain/events.py)


@dataclass
class BatchCreated(Event):
ref: str sku: str qty: int eta:
Opcional[fecha] = Ninguno

...

@dataclass
class AllocationRequired (Evento):
orderid: str sku: str qty: int

Luego renombramos services.py a handlers.py; agregamos el controlador de mensajes existente para


send_out_of_stock_notification; y lo más importante, cambiamos todos los manejadores para que tengan las
mismas entradas, un evento y una UoW:

Los controladores y los servicios son lo mismo (src/allocation/service_layer/handlers.py)


def añadir_lote(
evento: eventos.BatchCreated, uow: unidad_de_trabajo.AbstractUnitOfWork
):
con uow:
producto = uow.products.get(sku=event.sku)
...

asignar por defecto (


event: events.AllocationRequired, uow: unit_of_work.AbstractUnitOfWork ) -> str:

Refactorización de funciones de servicio a controladores de mensajes | 137


Machine Translated by Google

línea = OrderLine(event.orderid, event.sku, event.qty)


...

def enviar_out_of_stock_notification( event:


events.OutOfStock, uow: unit_of_work.AbstractUnitOfWork,
):

email.send( 'stock@made.com',
f'Agotado para {event.sku}',
)

El cambio podría ser más claro como una diferencia:

Cambio de servicios a controladores (src/allocation/service_layer/handlers.py)

def añadir_lote(
- ref: str, sku: str, qty: int, eta: Optional[date], uow:
- unit_of_work.AbstractUnitOfWork event: events.BatchCreated,
+ uow: unit_of_work.AbstractUnitOfWork
):
con uow:
- producto = uow.products.get(sku=sku)
+ producto = uow.products.get(sku=event.sku)
...

asignar por defecto (


- orderid: str, sku: str, qty: int, uow:
- unit_of_work.AbstractUnitOfWork event:
+ events.AllocationRequired, uow: unit_of_work.AbstractUnitOfWork ) -> str: line =
OrderLine(orderid, sku, qty) line = OrderLine(event.orderid , evento.sku, evento.cantidad)
-
+
...

+
+def enviar_out_of_stock_notification( event:
+ events.OutOfStock, uow: unit_of_work.AbstractUnitOfWork,
+):
+ Enviar correo electrónico(

...

En el camino, hemos hecho que la API de nuestra capa de servicio sea más estructurada y más consistente. Era una
dispersión de primitivas, y ahora usa objetos bien definidos (vea la siguiente barra lateral).

138 | Capítulo 9: Ir a la ciudad en el autobús de mensajes


Machine Translated by Google

Desde Objetos de Dominio, pasando por Obsesión Primitiva, hasta


Eventos como interfaz

Algunos de ustedes pueden recordar “Desvinculación completa de las pruebas de la capa de servicio del dominio” en la
página 75, en el que cambiamos nuestra API de la capa de servicio de estar en términos de objetos de dominio a
primitivas. Y ahora nos estamos moviendo hacia atrás, ¿pero a diferentes objetos?
¿Lo que da?

En los círculos OO, la gente habla de la obsesión primitiva como un anti-patrón: evita las primitivas en las API públicas
y, en su lugar, envuélvelas con clases de valor personalizadas, dirían.
En el mundo de Python, muchas personas serían bastante escépticas al respecto como regla general. Cuando se aplica
sin pensar, ciertamente es una receta para una complejidad innecesaria.
Así que eso no es lo que estamos haciendo per se.

El paso de los objetos de dominio a los primitivos nos trajo un poco de desacoplamiento: nuestro código de cliente ya no
estaba acoplado directamente al dominio, por lo que la capa de servicio podría presentar una API que permanece igual
incluso si decidimos realizar cambios en nuestro modelo, y viceversa.

Entonces, ¿hemos retrocedido? Bueno, nuestros objetos del modelo de dominio central aún pueden variar libremente,
pero en su lugar, hemos acoplado el mundo externo a nuestras clases de eventos. También son parte del dominio, pero
la esperanza es que varíen con menos frecuencia, por lo que son un artefacto sensato para combinar.

¿Y qué nos hemos comprado? Ahora, cuando invocamos un caso de uso en nuestra aplicación, ya no necesitamos
recordar una combinación particular de primitivas, sino solo una clase de evento única que representa la entrada a
nuestra aplicación. Eso es conceptualmente bastante bueno. Además de eso, como verá en el Apéndice E, esas clases
de eventos pueden ser un buen lugar para realizar alguna validación de entrada.

El bus de mensajes ahora recopila eventos de la UoW Nuestros

controladores de eventos ahora necesitan una UoW. Además, a medida que nuestro bus de
mensajes se vuelve más central para nuestra aplicación, tiene sentido ponerlo explícitamente
a cargo de recopilar y procesar nuevos eventos. Había un poco de dependencia circular entre
la UoW y el bus de mensajes hasta ahora, por lo que será unidireccional:

Handle toma una UoW y administra una cola (src/allocation/service_layer/messagebus.py)

def manejar(evento: eventos.Evento, uow: unidad_de_trabajo.AbstractUnitOfWork):


cola = [evento]
while cola: evento
= cola.pop(0) para
controlador en MANEJADORES[tipo(evento)]:
controlador(evento, uow=uow)
cola.extend(uow.collect_new_events())

Refactorización de funciones de servicio a controladores de mensajes | 139


Machine Translated by Google

El bus de mensajes ahora pasa la UoW cada vez que se inicia.

Cuando comenzamos a manejar nuestro primer evento, iniciamos una cola.

Extraemos eventos desde el frente de la cola e invocamos a sus controladores (el dictamen MANEJADORES
no ha cambiado; aún asigna tipos de eventos a funciones de controlador).

El bus de mensajes pasa la UoW a cada controlador.

Después de que finaliza cada controlador, recopilamos cualquier evento nuevo que se haya generado y lo
agregamos a la cola.

En unit_of_work.py, publishing_events() se convierte en un método menos activo, col lect_new_events():

UoW ya no coloca eventos directamente en el bus (src/allocation/service_layer/unit_of_work.py)

-de . importar bus de mensajes


-

clase AbstractUnitOfWork(abc.ABC): @@
-23,13 +21,11 @@ clase AbstractUnitOfWork(abc.ABC):

def commit(self):
self._commit()
- self.publish_events()

- def publicar_eventos(auto): def


+ recolectar_nuevos_eventos(auto): for
producto en auto.productos.visto: while
producto.eventos: evento =
- producto.eventos.pop(0)
- mensajebus.manejar(evento) rendimiento
+ producto.eventos.pop(0 )

El módulo unit_of_work ahora ya no depende de messagebus.

Ya no publicamos_eventos automáticamente al confirmar. En su lugar, el bus de mensajes realiza un


seguimiento de la cola de eventos.

Y la UoW ya no coloca activamente eventos en el bus de mensajes; simplemente los pone a disposición.

140 | Capítulo 9: Ir a la ciudad en el autobús de mensajes


Machine Translated by Google

Nuestras pruebas también están escritas en términos de eventos

Nuestras pruebas ahora funcionan creando eventos y colocándolos en el bus de mensajes, en lugar
de invocar funciones de la capa de servicio directamente:

Las pruebas de controlador usan eventos (tests/unit/test_handlers.py)

clase TestAddBatch:

def test_for_new_product(self): uow =


FakeUnitOfWork() services.add_batch("b1",
-
"CRUNCHY-SILLÓN", 100, Ninguno, uow) messagebus.handle( events.BatchCreated("b1",
+ "CRUNCHY-SILLÓN", 100 , Ninguno), uow
+
+
) aseverar uow.products.get("CRUNCHY-ARMCHAIR") no es Ninguno aseverar
uow.committed

...

clase PruebaAsignar:

def test_returns_allocation(self): uow =


FakeUnitOfWork() services.add_batch("batch1",
-
"COMPLICATED-LAMP", 100, None, uow) result = services.allocate("o1", "COMPLICATED-
-
LAMP", 10, uow ) messagebus.handle( events.BatchCreated("batch1", "COMPLICATED-LAMP",
+ 100, None), uow
+
+
+ ) resultado = bus de mensajes.handle(
+ events.AllocationRequired("o1", "COMPLICATED-LAMP", 10), uow
+
) afirmar resultado == "lote1"

Un truco feo temporal: el bus de mensajes tiene que devolver resultados Nuestra API y

nuestra capa de servicio actualmente quieren saber la referencia del lote asignado cuando invocan
nuestro controlador allocate() . Esto significa que debemos implementar un truco temporal en nuestro
bus de mensajes para que pueda devolver eventos:

El bus de mensajes devuelve resultados (src/allocation/service_layer/messagebus.py)

def manejar(evento: eventos.Evento, uow: unidad_de_trabajo.AbstractUnitOfWork):


+ resultados = []
cola = [evento] while
cola: evento =
cola.pop(0) for handler in
MANEJADORES[tipo(evento)]: handler(evento,
-
uow=uow) resultados.append(handler(evento,
+ uow=uow ))

Refactorización de funciones de servicio a controladores de mensajes | 141


Machine Translated by Google

queue.extend(uow.collect_new_events())
+ devolver resultados

Es porque estamos mezclando las responsabilidades de lectura y escritura en nuestro sistema. Volveremos
para arreglar esta verruga en el Capítulo 12.

Modificando nuestra API para trabajar con eventos

El matraz cambia al bus de mensajes como una diferencia (src/allocation/entrypoints/ask_app.py)

@app.route("/allocate", métodos=['POST']) def


allocate_endpoint(): prueba: loteref =
services.allocate( request.json['orderid'],
- request.json['sku'], request .json['cantidad'],
- unidad_de_trabajo.SqlAlchemyUnitOfWork(),
-
-
-
+ event = events.AllocationRequired(
+ solicitud.json['orderid'], solicitud.json['sku'], solicitud.json['cantidad'],

+ ) resultados = messagebus.handle(evento, unidad_de_trabajo.SqlAlchemyUnitOfWork()) lotref =


+ resultados.pop(0) excepto InvalidSku como e:

En lugar de llamar a la capa de servicio con un montón de primitivas extraídas de la solicitud JSON...

Instanciamos un evento.

Luego lo pasamos al bus de mensajes.

Y deberíamos volver a una aplicación completamente funcional, pero que ahora está completamente
impulsada por eventos:

• Lo que solían ser funciones de capa de servicio ahora son controladores de

eventos. • Eso las hace iguales a las funciones que invocamos para manejar eventos internos generados
por nuestro modelo de dominio. • Usamos eventos como nuestra estructura de datos para capturar

entradas al sistema, así como


para la entrega de paquetes de trabajo internos.

• Toda la aplicación ahora se describe mejor como un procesador de mensajes o un procesador de


eventos, si lo prefiere. Hablaremos de la distinción en el próximo capítulo.

142 | Capítulo 9: Ir a la ciudad en el autobús de mensajes


Machine Translated by Google

Implementando nuestro nuevo requisito


Hemos terminado con nuestra fase de refactorización. A ver si realmente hemos “hecho fácil el cambio”.
Implementemos nuestro nuevo requisito, que se muestra en la Figura 9-4: recibiremos como entradas
algunos nuevos eventos BatchQuantityChanged y los pasaremos a un controlador, que a su vez podría
emitir algunos eventos AllocationRequired , y esos a su vez volverán a nuestros eventos existentes.
controlador para la reasignación.

Figura 9-4. Diagrama de secuencia para flujo de reasignación

Cuando divide cosas como esta en dos unidades de trabajo, ahora


tiene dos transacciones de base de datos, por lo que se está
abriendo a problemas de integridad: algo podría suceder que
significa que la primera transacción se completa pero la segunda
no. Deberá pensar si esto es aceptable y si necesita darse cuenta
cuando sucede y hacer algo al respecto. Consulte “Footguns” en
la página 226 para obtener más información.

Nuestro nuevo evento

El evento que nos dice que la cantidad de un lote ha cambiado es simple; solo necesita una referencia de
lote y una nueva cantidad:

Nuevo evento (src/allocation/domain/events.py)


@dataclass
class BatchQuantityChanged(Evento): ref: str

cantidad: int

Implementando nuestro nuevo requisito | 143


Machine Translated by Google

Prueba de conducción de un nuevo controlador

Siguiendo las lecciones aprendidas en el Capítulo 4, podemos operar a toda velocidad y


escribir nuestras pruebas unitarias al nivel más alto posible de abstracción, en términos de
eventos. Así es como podrían verse:

Pruebas de controlador para change_batch_quantity (tests/unit/test_handlers.py)


clase TestChangeBatchQuantity:

def prueba_cambios_cantidad_disponible(self): uow


= FakeUnitOfWork()
messagebus.handle( events.BatchCreated("batch1",
"ADORABLE-SETTEE", 100, None), uow

) [lote] = uow.products.get(sku="ADORABLE-SETTEE").lotes afirmar


lote.cantidad_disponible == 100

messagebus.handle(eventos.BatchQuantityChanged("batch1", 50), uow)

afirmar lote.cantidad_disponible == 50

def test_reallocates_if_necessary(self): uow =


FakeUnitOfWork() event_history =
[ events.BatchCreated("batch1",
"INDIFFERENT-TABLE", 50, None), events.BatchCreated("batch2",
"INDIFFERENT-TABLE", 50, date .today()), events.AllocationRequired("order1",
"INDIFFERENT-TABLE", 20), events.AllocationRequired("order2", "INDIFFERENT-
TABLE", 20),

] para e en event_history:
messagebus.handle(e, uow)
[batch1, batch2] = uow.products.get(sku="INDIFFERENT-TABLE").batches afirmar
lote1.cantidad_disponible == 10 afirmar lote2.cantidad_disponible == 50

messagebus.handle(eventos.BatchQuantityChanged("batch1", 25), uow)

# pedido1 o pedido2 se desasignarán, por lo que tendremos 25 - 20 afirmar


lote1.cantidad_disponible == 5 # y 20 se reasignarán al siguiente lote
afirmar lote2.cantidad_disponible == 30

El caso simple sería trivialmente fácil de implementar; solo modificamos una cantidad.

Pero si tratamos de cambiar la cantidad a menos de lo asignado, necesitaremos


desasignar al menos un pedido y esperamos reasignarlo a un nuevo lote.

144 | Capítulo 9: Ir a la ciudad en el autobús de mensajes


Machine Translated by Google

Implementación
Nuestro nuevo controlador es muy simple:

El controlador delega a la capa del modelo (src/allocation/service_layer/handlers.py)

def change_batch_quantity( event:


events.BatchQuantityChanged, uow: unit_of_work.AbstractUnitOfWork
):
con uow:
producto = uow.products.get_by_batchref(batchref=event.ref)
product.change_batch_quantity(ref=event.ref, qty=event.qty) uow.commit()

Nos damos cuenta de que necesitaremos un nuevo tipo de consulta en nuestro repositorio:

Un nuevo tipo de consulta en nuestro repositorio (src/allocation/adapters/repository.py)

clase AbstractRepository(abc.ABC):
...

def get(self, sku) -> modelo.Producto:


...

def get_by_batchref(self, loteref) -> modelo.Producto:


producto = self._get_by_batchref(batchref) si
producto:
self.seen.add(producto)
devolver producto

@abc.abstractmethod
def _add(self, producto: modelo.Producto):
aumentar NotImplementedError

@abc.abstractmethod
def _get(self, sku) -> modelo.Producto:
aumentar NotImplementedError

@abc.abstractmethod
def _get_by_batchref(self, loteref) -> modelo.Producto:
aumentar NotImplementedError
...

clase SqlAlchemyRepository(AbstractRepository):
...

def _get(self, sku):


return self.session.query(modelo.Producto).filter_by(sku=sku).primero()

def _get_by_batchref(self, batchref): return


self.session.query(model.Product).join(model.Batch).filter(
orm.lotes.c.referencia == lotref, ).primero()

Prueba de conducción de un nuevo controlador | 145


Machine Translated by Google

Y en nuestro FakeRepository también:

Actualización del repositorio falso también (tests/unit/test_handlers.py)

clase FakeRepository(repositorio.AbstractRepository):
...

def _get(self, sku):


return next((p por p en self._products if p.sku == sku), Ninguno)

def _get_by_batchref(self, loteref):


return next(( p
por p en self._products for b en p.lotes if b.reference
== loteref
), ninguno)

Estamos agregando una consulta a nuestro repositorio para que este caso
de uso sea más fácil de implementar. Siempre que nuestra consulta devuelva
un solo agregado, no estamos infringiendo ninguna regla. Si se encuentra
escribiendo consultas complejas en sus repositorios, es posible que desee
considerar un diseño diferente. Métodos como get_most_popular_products
o find_products_by_order_id en particular definitivamente activarían nuestro
sentido arácnido. El Capítulo 11 y el epílogo tienen algunos consejos sobre
la gestión de consultas complejas.

Un nuevo método en el modelo de dominio

Agregamos el nuevo método al modelo, que realiza el cambio de cantidad y la(s) desasignación(es)
en línea y publica un nuevo evento. También modificamos la función de asignación existente para
publicar un evento:

Nuestro modelo evoluciona para capturar el nuevo requisito (src/allocation/domain/model.py)


producto de clase :
...

def change_batch_quantity(self, ref: str, qty: int):


lote = siguiente(b para b en self.lotes si b.referencia == ref)
lote._cantidad_comprada = cantidad while lote.cantidad_disponible < 0:
línea = lote.desasignar_uno() self.events.append( eventos.AllocationRequired(line.
orderid, line.sku, line.qty)

)
...

lote de clase :
...

146 | Capítulo 9: Ir a la ciudad en el autobús de mensajes


Machine Translated by Google

def desalocate_one(self) -> OrderLine: return


self._allocations.pop()

Conectamos nuestro nuevo controlador:

El bus de mensajes crece (src/allocation/service_layer/messagebus.py)


MANEJADORES = {
events.BatchCreated: [handlers.add_batch],
events.BatchQuantityChanged: [handlers.change_batch_quantity],
events.AllocationRequired: [handlers.allocate], events.OutOfStock:
[handlers.send_out_of_stock_notification],

} # tipo: Dict[Tipo[eventos.Evento], Lista[Llamable]]

Y nuestro nuevo requisito está completamente implementado.

Opcionalmente: Controladores de eventos de pruebas unitarias aislados con un

Autobús de mensajes falsos

Nuestra prueba principal para el flujo de trabajo de reasignación es de extremo a extremo (consulte el código de
ejemplo en "Prueba de conducción de un nuevo controlador" en la página 144). Utiliza el bus de mensajes real y
prueba todo el flujo, donde el controlador de eventos BatchQuantityChanged activa la desasignación y emite nuevos
eventos AllocationRequired , que a su vez son manejados por sus propios controladores. Una prueba cubre una
cadena de múltiples eventos y controladores.

Dependiendo de la complejidad de su cadena de eventos, puede decidir que desea probar algunos controladores de
forma aislada unos de otros. Puede hacer esto usando un bus de mensajes "falso".

En nuestro caso, en realidad intervenimos modificando el métodopublish_events() en


FakeUnitOfWork y desacoplarlo del bus de mensajes real, en lugar de registrar los eventos que ve:

Bus de mensajes falsos implementado en UoW (tests/unit/test_handlers.py)


clase FakeUnitOfWorkWithFakeMessageBus(FakeUnitOfWork):

def __init__(self):
super().__init__()
self.eventos_publicados = [] # tipo: Lista[eventos.Evento]

def publicar_eventos(self): for


product in self.products.seen: while
product.events:
self.events_published.append(product.events.pop(0))

Ahora, cuando invocamos messagebus.handle() usando FakeUnitOfWorkWithFakeMes sageBus, solo ejecuta el


controlador para ese evento. Entonces podemos escribir una unidad más aislada

Opcionalmente: Controladores de eventos de pruebas unitarias aislados con un bus de mensajes falsos | 147
Machine Translated by Google

prueba: en lugar de verificar todos los efectos secundarios, solo verificamos que
BatchQuantityChanged conduce a AllocationRequired si la cantidad cae por debajo del total ya asignado:

Realización de pruebas de forma aislada (tests/unit/test_handlers.py)


def test_reallocates_if_necessary_isolated(): uow =
FakeUnitOfWorkWithFakeMessageBus()

# configuración de prueba
como antes event_history
= [ events.BatchCreated("batch1", "INDIFFERENT-TABLE", 50, None),
events.BatchCreated("batch2", "INDIFFERENT-TABLE", 50, date.today()),
events.AllocationRequired("order1", "INDIFFERENT-TABLE", 20),
events.AllocationRequired("order2", "INDIFFERENT-TABLE", 20),

] para e en event_history:
messagebus.handle(e, uow)
[batch1, batch2] = uow.products.get(sku="INDIFFERENT-TABLE").batches afirmar
lote1.cantidad_disponible == 10 afirmar lote2.cantidad_disponible == 50

messagebus.handle(eventos.BatchQuantityChanged("batch1", 25), uow)

# afirmar sobre nuevos eventos emitidos en lugar de efectos secundarios


posteriores [reasignación_evento] = uow.eventos_publicados afirmar
isinstance(reasignación_evento, eventos. AsignaciónRequerida) afirmar
reasignación_evento.orderid in {'pedido1', 'pedido2'} afirmar reasignación_evento.sku
== 'INDIFFERENT -MESA'

Si desea hacer esto o no, depende de la complejidad de su cadena de eventos. Decimos,


comience con pruebas de extremo a extremo y recurra a esto solo si es necesario.

148 | Capítulo 9: Ir a la ciudad en el autobús de mensajes


Machine Translated by Google

Ejercicio para el lector


Una excelente manera de forzarte a comprender realmente un código es refactorizarlo. En la discusión
sobre la prueba de controladores de forma aislada, usamos algo llamado FakeUnitOfWork
WithFakeMessageBus, que es innecesariamente complicado y viola el SRP.

Si cambiamos el bus de mensajes para que sea una clase,3 entonces construir un FakeMessageBus
es más sencillo:

Un bus de mensajes abstracto y sus versiones real y falsa.


clase AbstractMessageBus:
MANEJADORES: Dict[Type[events.Event], List[Calable]]

def handle(self, event: events.Event):


for handler in self.MANEJADORES[tipo(evento)]:
controlador(evento)

clase MessageBus(AbstractMessageBus):
MANEJADORES = {
eventos.OutOfStock: [send_out_of_stock_notification],

class FakeMessageBus(messagebus.AbstractMessageBus):
def __init__(self): self.events_published = [] # type:
List[events.Event] self.handlers = { events.OutOfStock: [lambda e:
self.events_published.append(e) ]

Entonces salte al código en GitHub y vea si puede hacer funcionar una versión basada en clases, y
luego escriba una versión de test_reallocates_if_necessary_isolated() anterior.

Usamos un bus de mensajes basado en clases en el Capítulo 13, si necesita más inspiración.

Envolver
Miremos hacia atrás a lo que hemos logrado y pensemos por qué lo hicimos.

3 La implementación “simple” en este capítulo utiliza esencialmente el propio módulo messagebus.py para implementar el
Patrón Singleton.

Resumen | 149
Machine Translated by Google

¿Qué hemos logrado?

Los eventos son clases de datos simples que definen las estructuras de datos para entradas y mensajes
internos dentro de nuestro sistema. Esto es bastante poderoso desde el punto de vista de DDD, ya que los
eventos a menudo se traducen muy bien al lenguaje comercial (busque tormenta de eventos si aún no lo ha
hecho).

Los controladores son la forma en que reaccionamos a los eventos. Pueden llamar a nuestro modelo o
llamar a servicios externos. Podemos definir múltiples controladores para un solo evento si queremos.
Los controladores también pueden generar otros eventos. Esto nos permite ser muy granulares sobre lo que
hace un controlador y realmente ceñirnos al SRP.

¿Por qué lo hemos logrado?


Nuestro objetivo continuo con estos patrones arquitectónicos es intentar que la complejidad de nuestra
aplicación crezca más lentamente que su tamaño. Cuando nos metemos de lleno en el bus de mensajes,
como siempre, pagamos un precio en términos de complejidad arquitectónica (consulte la Tabla 9-1), pero
nos compramos un patrón que puede manejar requisitos complejos casi arbitrariamente sin necesidad de
más. cambio conceptual o arquitectónico en la forma en que hacemos las cosas.

Aquí hemos agregado un caso de uso bastante complicado (cambiar la cantidad, desasignar, iniciar una
nueva transacción, reasignar, publicar una notificación externa), pero desde el punto de vista arquitectónico,
no ha habido ningún costo en términos de complejidad. Hemos agregado nuevos eventos, nuevos
controladores y un nuevo adaptador externo (para correo electrónico), todos los cuales son categorías
existentes de cosas en nuestra arquitectura que entendemos y sabemos cómo razonar, y que son fáciles de
explicar. recién llegados Cada una de nuestras partes móviles tiene un trabajo, están conectadas entre sí
de maneras bien definidas y no hay efectos secundarios inesperados.

Tabla 9-1. Toda la aplicación es un bus de mensajes: las ventajas y desventajas

ventajas Contras

• Los controladores y los servicios son • Un bus de mensajes sigue siendo una forma ligeramente impredecible de hacer las cosas desde el punto

lo mismo, así que es más simple. • de vista web. No sabes de antemano cuándo van a terminar las cosas.
Tenemos una buena estructura de datos • Habrá duplicidad de campos y estructura entre objetos modelo y eventos, lo que tendrá un costo de

para las entradas al sistema. mantenimiento. Agregar un campo a uno generalmente significa agregar un campo a al menos uno de
los otros.

Ahora, puede que se pregunte, ¿de dónde van a venir esos eventos BatchQuantityChanged ? La respuesta
se revela en un par de capítulos. Pero primero, hablemos de eventos versus comandos.

150 | Capítulo 9: Ir a la ciudad en el autobús de mensajes


Machine Translated by Google

CAPÍTULO 10

Comandos y controlador de comandos

En el capítulo anterior, hablamos sobre el uso de eventos como una forma de representar las entradas
de nuestro sistema y convertimos nuestra aplicación en una máquina de procesamiento de mensajes.

Para lograrlo, convertimos todas nuestras funciones de casos de uso en controladores de eventos.
Cuando la API recibe un POST para crear un nuevo lote, genera un nuevo evento BatchCreated y lo
maneja como si fuera un evento interno. Esto puede parecer contradictorio. Después de todo, el lote
aún no se ha creado; por eso llamamos a la API. Vamos a arreglar esa verruga conceptual
introduciendo comandos y mostrando cómo pueden ser manejados por el mismo bus de mensajes
pero con reglas ligeramente diferentes.

El código de este capítulo se encuentra en la rama chapter_10_commands


en GitHub:

git clone https://github.com/cosmicpython/code.git cd code git


checkout chapter_10_commands # o para codificar, consulte
el capítulo anterior: git checkout chapter_09_all_messagebus

Comandos y Eventos
Al igual que los eventos, los comandos son un tipo de mensaje: instrucciones enviadas por una parte
de un sistema a otra. Por lo general, representamos comandos con estructuras de datos tontas y
podemos manejarlos de la misma manera que los eventos.

Sin embargo, las diferencias entre comandos y eventos son importantes.

Los comandos son enviados por un actor a otro actor específico con la expectativa de que algo en
particular suceda como resultado. Cuando publicamos un formulario en un controlador de API,

151
Machine Translated by Google

están enviando un comando. Nombramos comandos con frases verbales de modo imperativo como
"asignar existencias" o "retrasar el envío".

Los comandos capturan la intención. Expresan nuestro deseo de que el sistema haga algo. Como
resultado, cuando fallan, el remitente necesita recibir información de error.

Los eventos son transmitidos por un actor a todos los oyentes interesados. Cuando publicamos
Lotes de cantidad cambiada, no sabemos quién lo recogerá. Nombramos eventos con frases
verbales en tiempo pasado como "pedido asignado a stock" o "envío retrasado".

A menudo usamos eventos para difundir el conocimiento sobre comandos exitosos.

Los eventos capturan hechos sobre cosas que sucedieron en el pasado. Dado que no sabemos
quién está manejando un evento, a los remitentes no debería importarles si los receptores tuvieron
éxito o fallaron. La tabla 10-1 resume las diferencias.

Tabla 10-1. Eventos versus comandos


Evento Dominio

Nombrada Pasado Modo imperativo

Manejo de errores Fallar independientemente Fallar ruidosamente

Enviado a Todos los oyentes un destinatario

¿Qué tipo de comandos tenemos en nuestro sistema en este momento?

Sacando algunos comandos (src/allocation/domain/commands.py)

Comando de clase :
pasar

@dataclass
class Asignar (Comando):
orderid: str sku:
str qty: int

@dataclass
class CreateBatch(Comando): ref: str
sku: str

qty: int eta:


Opcional[fecha] = Ninguno

@dataclass
class ChangeBatchQuantity(Command): ref: str
qty: int

commands.Allocate reemplazará events.AllocationRequired.

152 | Capítulo 10: Comandos y controlador de comandos


Machine Translated by Google

commands.CreateBatch reemplazará events.BatchCreated.

commands.ChangeBatchQuantity reemplazará events.BatchQuantityChanged.

Diferencias en el manejo de excepciones


Simplemente cambiar los nombres y los verbos está muy bien, pero eso no cambiará el comportamiento de
nuestro sistema. Queremos tratar eventos y comandos de manera similar, pero no exactamente iguales.
Veamos cómo cambia nuestro bus de mensajes:

Despachar eventos y comandos de manera diferente (src/allocation/service_layer/messagebus.py)

Mensaje = Unión[comandos.Comando, eventos.Evento]

def handle(mensaje: Mensaje, uow: unidad_de_trabajo.AbstractUnitOfWork): resultados = []


cola = [mensaje] while cola: mensaje = cola.pop(0) if isinstance(mensaje, eventos.Evento):
handle_event(mensaje, cola, uow) elif isinstance(mensaje, comandos.Comando):

cmd_result = handle_command(mensaje, cola, uow)


resultados.append(cmd_result) otra cosa: generar
excepción(f'{mensaje} no era un evento o comando')

devolver resultados

Todavía tiene un punto de entrada principal handle() que toma un mensaje, que puede ser un comando
o un evento.

Despachamos eventos y comandos a dos funciones auxiliares diferentes, que se muestran


Siguiente.

Así es como manejamos los eventos:

Los eventos no pueden interrumpir el flujo (src/allocation/service_layer/messagebus.py)

def handle_event( evento:


eventos.Evento, cola:
Lista[Mensaje], uow:
unidad_de_trabajo.AbstractUnitOfWork
):
para controlador en EVENT_HANDLERS[tipo(evento)]:

pruebe: logger.debug('manejar evento %s con controlador %s', evento, controlador)


controlador(evento, uow=uow) queue.extend(uow.collect_new_events()) excepto Excepción:

Diferencias en el manejo de excepciones | 153


Machine Translated by Google

logger.exception(' Evento de manejo de excepciones %s', evento)


continuar

Los eventos van a un despachador que puede delegar a varios controladores por evento.

Captura y registra errores, pero no les permite interrumpir el procesamiento de mensajes.

Y así es como hacemos los comandos:

Los comandos vuelven a generar excepciones (src/allocation/service_layer/messagebus.py)

def
manejar_comando( comando:
comandos.Comando, cola:
Lista[Mensaje], uow: unidad_de_trabajo.AbstractUnitOfWork
):
logger.debug(' comando de manejo %s', comando)
intente: controlador = COMMAND_HANDLERS[tipo(comando)]
resultado = controlador(comando, uow=uow)
queue.extend(uow.collect_new_events()) devolver
resultado

excepto Excepción:
logger.exception(' Comando de manejo de excepciones %s', comando)
aumentar

El despachador de comandos espera solo un controlador por comando.

Si se generan errores, fallan rápidamente y se propagarán.

el resultado devuelto es solo temporal; como se menciona en “Un truco feo temporal: el bus de
mensajes tiene que devolver resultados” en la página 141, es un truco temporal para permitir que
el bus de mensajes devuelva la referencia de lote para que la use la API. Arreglaremos esto en el
Capítulo 12.

También cambiamos el dictado único de MANEJADORES a otros diferentes para comandos y eventos.
Los comandos solo pueden tener un controlador, según nuestra convención:

Nuevos dictados de controladores (src/allocation/service_layer/messagebus.py)

EVENT_HANDLERS
= { eventos.OutOfStock: [handlers.send_out_of_stock_notification],
} # tipo: Dict[Tipo[eventos.Evento], Lista[Llamable]]

COMMAND_HANDLERS
= { commands.Allocate: handlers.allocate,
commands.CreateBatch: handlers.add_batch,
commands.ChangeBatchQuantity: handlers.change_batch_quantity, } # type:
Dict[Type[commands.Command], Callable]

154 | Capítulo 10: Comandos y controlador de comandos


Machine Translated by Google

Discusión: eventos, comandos y manejo de errores


Muchos desarrolladores se sienten incómodos en este punto y preguntan: “¿Qué sucede cuando un evento
no se procesa? ¿Cómo se supone que debo asegurarme de que el sistema esté en un estado consistente?
Si logramos procesar la mitad de los eventos durante messagebus.handle antes de que un error de falta
de memoria elimine nuestro proceso, ¿cómo mitigaremos los problemas causados por la pérdida de
mensajes?

Comencemos con el peor de los casos: fallamos en manejar un evento y el sistema queda en un estado
inconsistente. ¿Qué tipo de error causaría esto? A menudo, en nuestros sistemas, podemos terminar en
un estado inconsistente cuando solo se completa la mitad de una operación.

Por ejemplo, podríamos asignar tres unidades de DESIRABLE_BEANBAG al pedido de un cliente, pero
de alguna manera no logramos reducir la cantidad de existencias restantes. Esto causaría un estado
inconsistente: las tres unidades de stock están asignadas y disponibles, dependiendo de cómo se mire.
Más tarde, podríamos asignar esos mismos pufs a otro cliente, causando un dolor de cabeza para la
atención al cliente.

Sin embargo, en nuestro servicio de asignación, ya hemos tomado medidas para evitar que eso suceda.
Hemos identificado cuidadosamente los agregados que actúan como límites de coherencia y hemos
introducido una UoW que gestiona el éxito o el fracaso atómico de una actualización de un agregado.

Por ejemplo, cuando asignamos existencias a un pedido, nuestro límite de consistencia es el agregado del
Producto . Esto significa que no podemos sobreasignar accidentalmente: o se asigna una línea de pedido
particular al producto, o no lo es; no hay lugar para incoherencias.
estados de tienda.

Por definición, no requerimos que dos agregados sean consistentes de inmediato, por lo que si fallamos
en procesar un evento y actualizamos solo un agregado, nuestro sistema aún puede volverse consistente
eventualmente. No debemos violar ninguna restricción del sistema.

Con este ejemplo en mente, podemos comprender mejor la razón de dividir los mensajes en comandos y
eventos. Cuando un usuario quiere que el sistema haga algo, representamos su solicitud como un
comando. Ese comando debería modificar un solo agregado y tener éxito o fallar en su totalidad. Cualquier
otra contabilidad, limpieza y notificación que necesitemos hacer puede ocurrir a través de un evento. No
requerimos que los controladores de eventos funcionen correctamente para que el comando funcione
correctamente.

Veamos otro ejemplo (de un proyecto imaginario diferente) para ver por qué no.

Imagine que estamos construyendo un sitio web de comercio electrónico que vende artículos de lujo caros.
Nuestro departamento de marketing quiere recompensar a los clientes por visitas repetidas. Marcaremos
a los clientes como VIP después de que realicen su tercera compra, y esto les dará derecho a un
tratamiento prioritario y ofertas especiales. Nuestros criterios de aceptación para esta historia son los
siguientes:

Discusión: eventos, comandos y manejo de errores | 155


Machine Translated by Google

Dado un cliente con dos pedidos en su historial, cuando el


cliente realiza un tercer pedido, debe marcarse como VIP .

Cuando un cliente se convierte en VIP por primera vez


Entonces deberíamos enviarles un correo electrónico para felicitarlos.

Usando las técnicas que ya hemos discutido en este libro, decidimos que queremos construir
un nuevo agregado de historial que registre pedidos y pueda generar eventos de dominio
cuando se cumplen las reglas. Estructuraremos el código así:

Cliente VIP (código de ejemplo para un proyecto diferente)


Historial de clase : # Agregado

def __init__(self, id_cliente : int):


self.orders = set() # Set[HistorialEntrada]
self.customer_id = customer_id

def record_order(self, order_id: str, order_amount: int): entrada =


HistoryEntry(order_id, order_amount)

si entrada en self.orders:
devolver

self.orders.add(entrada)

if len(self.orders) == 3:

self.events.append( CustomerBecameVIP(self.customer_id)
)

def create_order_from_basket(uow, cmd: CreateOrder): with uow:


order = Order.from_basket(cmd.customer_id,
cmd.basket_items) uow.orders.add(order) uow.commit() # aumenta
OrderCreated

def update_customer_history(uow, evento: OrderCreated):


with uow:
history = uow.order_history.get(event.customer_id)
history.record_order(event.order_id, event.order_amount) uow.commit()
# aumenta CustomerBecameVIP

def congratulate_vip_customer(uow, evento: CustomerBecameVip):


con uow:
cliente = uow.clientes.get(evento.id_cliente)
email.send( cliente.dirección_email,

156 | Capítulo 10: Comandos y controlador de comandos


Machine Translated by Google

f'¡Felicitaciones {cliente.nombre}!'
)

El agregado Historial captura las reglas que indican cuándo un cliente se convierte en VIP. Esto nos coloca en un
buen lugar para manejar los cambios cuando las reglas se vuelvan más complejas en el futuro.

Nuestro primer controlador crea un pedido para el cliente y genera un evento de dominio
Pedido creado.

Nuestro segundo controlador actualiza el objeto Historial para registrar que se creó un pedido.

Finalmente, enviamos un correo electrónico al cliente cuando se convierte en VIP.

Usando este código, podemos ganar algo de intuición sobre el manejo de errores en un sistema controlado por eventos.

En nuestra implementación actual, generamos eventos sobre un agregado después de que persistimos nuestro estado
en la base de datos. ¿Qué pasaría si planteáramos esos eventos antes de persistir y cometiéramos todos nuestros
cambios al mismo tiempo? De esa manera, podríamos estar seguros de que todo el trabajo estaba completo. ¿No sería
eso más seguro?

Sin embargo, ¿qué sucede si el servidor de correo electrónico está ligeramente sobrecargado? Si todo el trabajo tiene
que completarse al mismo tiempo, un servidor de correo electrónico ocupado puede impedir que tomemos dinero para
los pedidos.

¿Qué sucede si hay un error en la implementación del agregado Historial ?


¿Deberíamos dejar de tomar su dinero solo porque no podemos reconocerlo como VIP?

Al separar estas preocupaciones, hemos hecho posible que las cosas fallen de forma aislada, lo que mejora la
confiabilidad general del sistema. La única parte de este código que debe completarse es el controlador de comandos
que crea un pedido. Esta es la única parte que le importa a un cliente, y es la parte que las partes interesadas de nuestro
negocio deberían priorizar.

Observe cómo hemos alineado deliberadamente nuestros límites transaccionales con el inicio y el final de los procesos
comerciales. Los nombres que usamos en el código coinciden con la jerga utilizada por las partes interesadas de
nuestro negocio, y los controladores que hemos escrito coinciden con los pasos de nuestros criterios de aceptación de
lenguaje natural. Esta concordancia de nombres y estructura nos ayuda a razonar sobre nuestros sistemas a medida
que crecen y se vuelven más complejos.

Discusión: eventos, comandos y manejo de errores | 157


Machine Translated by Google

Recuperación de errores sincrónicamente


Con suerte, lo hemos convencido de que está bien que los eventos fallen independientemente de los comandos
que los generaron. Entonces, ¿qué debemos hacer para asegurarnos de que podamos recuperarnos de los
errores cuando inevitablemente ocurran?

Lo primero que necesitamos es saber cuándo se ha producido un error, y para ello solemos confiar en los logs.

Veamos de nuevo el método handle_event de nuestro bus de mensajes:

Función de manejo actual (src/allocation/service_layer/messagebus.py)


def
handle_event( evento:
eventos.Evento, cola:
Lista[Mensaje], uow: unidad_de_trabajo.AbstractUnitOfWork
):
para controlador en EVENT_HANDLERS[tipo(evento)]:

intente: logger.debug('manejar evento %s con controlador %s', evento, controlador)


handler(evento, uow=uow) cola.extend(uow.collect_new_events()) excepto
Excepción: logger.exception(' Evento de manejo de excepción %s', evento)
continuar

Cuando manejamos un mensaje en nuestro sistema, lo primero que hacemos es escribir una línea de registro
para registrar lo que estamos a punto de hacer. Para nuestro caso de uso CustomerBecameVIP , los registros
podrían ser los siguientes:

Manejo del evento CustomerBecameVIP(customer_id=12345)


con el controlador <función felicitar_vip_cliente en 0x10ebc9a60>

Debido a que elegimos usar clases de datos para nuestros tipos de mensajes, obtenemos un resumen claramente
impreso de los datos entrantes que podemos copiar y pegar en un shell de Python para volver a crear el objeto.

Cuando ocurre un error, podemos usar los datos registrados para reproducir el problema en una prueba unitaria
o reproducir el mensaje en el sistema.

La reproducción manual funciona bien para los casos en los que necesitamos corregir un error antes de que
podamos volver a procesar un evento, pero nuestros sistemas siempre experimentarán algún nivel de fondo de
falla transitoria. Esto incluye cosas como contratiempos en la red, interbloqueos de tablas y breve tiempo de
inactividad causado por las implementaciones.

Para la mayoría de esos casos, podemos recuperarnos elegantemente volviendo a intentarlo. Como dice el
proverbio, "si al principio no tiene éxito, vuelva a intentar la operación con un período de retroceso
exponencialmente creciente".

158 | Capítulo 10: Comandos y controlador de comandos


Machine Translated by Google

Manejar con reintento (src/allocation/service_layer/


messagebus.py) desde tenacity import Retrying , RetryError , stop_after_attempt, wait_exponential

...

def
handle_event( evento:
eventos.Evento, cola:
Lista[Mensaje], uow: unidad_de_trabajo.AbstractUnitOfWork
):

para controlador en EVENT_HANDLERS[tipo(evento)]:

intente: para intento en Reintentar


( stop=stop_after_attempt(3),
wait=wait_exponential()
):

con intento:
logger.debug('manejar evento %s con controlador %s', evento, controlador)
controlador(evento, uow=uow) queue.extend(uow.collect_new_events())

excepto RetryError como retry_failure:


logger.error(
'Error al manejar el evento %s veces, ¡se rindió!,
retry_failure.last_attempt.attempt_number
)
Seguir

Tenacity es una biblioteca de Python que implementa patrones comunes para reintentar.

Aquí configuramos nuestro bus de mensajes para reintentar operaciones hasta tres veces, con
una espera entre intentos que aumenta exponencialmente.

Reintentar las operaciones que podrían fallar es probablemente la mejor forma de mejorar la resiliencia
de nuestro software. Una vez más, los patrones de Unidad de trabajo y Manejador de comandos
significan que cada intento comienza desde un estado consistente y no dejará las cosas a medio
terminar.

En algún momento, independientemente de la tenacidad, tendremos que dejar


de intentar procesar el mensaje. Construir sistemas confiables con mensajes
distribuidos es difícil, y tenemos que pasar por alto algunas partes engañosas.
Hay indicadores de más materiales de referencia en el epílogo.

Recuperación de errores sincrónicamente | 159


Machine Translated by Google

Envolver
En este libro decidimos introducir el concepto de eventos antes que el concepto de comandos, pero otras
guías suelen hacerlo al revés. Hacer explícitas las solicitudes a las que nuestro sistema puede responder
dándoles un nombre y su propia estructura de datos es algo bastante fundamental. A veces verá que la
gente usa el nombre Patrón de controlador de comandos para describir lo que estamos haciendo con
Eventos, Comandos y Bus de mensajes.

La tabla 10-2 analiza algunas de las cosas en las que debe pensar antes de embarcarse.

Tabla 10-2. Dividir comandos y eventos: las ventajas y desventajas

ventajas Contras

• Tratar los comandos y los eventos de manera diferente nos • Las diferencias semánticas entre comandos y eventos pueden ser sutiles.

ayuda a comprender qué cosas deben tener éxito y qué cosas Espere argumentos de bikeshedding sobre las diferencias.
podemos arreglar más adelante.

• CreateBatch es definitivamente un nombre menos confuso que • Estamos invitando expresamente al fracaso. Sabemos que a veces las

BatchCreated. Estamos siendo explícitos sobre la intención de cosas se estropean, y elegimos manejar eso haciendo que las fallas

nuestros usuarios, y explícito es mejor que implícito, ¿verdad? sean más pequeñas y más aisladas. Esto puede hacer que sea más difícil
razonar sobre el sistema y requiere una mejor supervisión.

En el Capítulo 11 hablaremos sobre el uso de eventos como un patrón de integración.

160 | Capítulo 10: Comandos y controlador de comandos


Machine Translated by Google

CAPÍTULO 11

Arquitectura impulsada por eventos: uso de eventos para


Integrar microservicios

En el capítulo anterior, en realidad nunca hablamos sobre cómo recibiríamos los eventos de "cantidad
de lote modificada" o, de hecho, cómo podríamos notificar al mundo exterior sobre las reasignaciones.

Tenemos un microservicio con una API web, pero ¿qué pasa con otras formas de hablar con otros
sistemas? ¿Cómo sabremos si, por ejemplo, se retrasa un envío o se modifica la cantidad?
¿Cómo le informaremos al sistema de almacén que se ha asignado un pedido y que debe enviarse
a un cliente?

En este capítulo, nos gustaría mostrar cómo la metáfora de los eventos puede extenderse para
abarcar la forma en que manejamos los mensajes entrantes y salientes del sistema. Internamente,
el núcleo de nuestra aplicación ahora es un procesador de mensajes. Hagamos un seguimiento de
eso para que también se convierta en un procesador de mensajes externo. Como se muestra en la
Figura 11-1, nuestra aplicación recibirá eventos de fuentes externas a través de un bus de mensajes
externo (usaremos las colas de publicación/suscripción de Redis como ejemplo) y publicará sus
salidas, en forma de eventos, allí también. .

161
Machine Translated by Google

Figura 11-1. Nuestra aplicación es un procesador de mensajes.

El código de este capítulo se encuentra en la rama


chapter_11_external_events en GitHub:

git clone https://github.com/cosmicpython/code.git cd code git


checkout chapter_11_external_events # o para codificar,
consulte el capítulo anterior: git checkout chapter_10_commands

Bola de barro distribuida y pensamiento en sustantivos


Antes de entrar en eso, hablemos de las alternativas. Hablamos regularmente con ingenieros que intentan
construir una arquitectura de microservicios. A menudo, están migrando desde una aplicación existente y su
primer instinto es dividir su sistema en sustantivos.

¿Qué sustantivos hemos introducido hasta ahora en nuestro sistema? Bueno, tenemos lotes de stock,
pedidos, productos y clientes. Por lo tanto, un intento ingenuo de dividir el sistema podría haberse parecido
a la figura 11-2 (observe que hemos nombrado nuestro sistema con el nombre Lotes, en lugar de Asignación).

162 | Capítulo 11: Arquitectura impulsada por eventos: uso de eventos para integrar microservicios
Machine Translated by Google

Figura 11-2. Diagrama de contexto con servicios basados en sustantivos

Cada "cosa" en nuestro sistema tiene un servicio asociado, que expone una API HTTP.

Trabajemos con un ejemplo de flujo de camino feliz en la Figura 11-3: nuestros usuarios visitan un sitio web y
pueden elegir entre los productos que están en stock. Cuando añaden un artículo a su cesta, reservamos algo de
stock para ellos. Cuando se completa un pedido, confirmamos la reserva, lo que hace que enviemos instrucciones
de despacho al almacén. Digamos también que si este es el tercer pedido del cliente, queremos actualizar el
registro del cliente para marcarlo como VIP.

bola de barro distribuida y pensamiento en sustantivos | 163


Machine Translated by Google

Figura 11-3. Flujo de comando 1

Podemos pensar en cada uno de estos pasos como un comando en nuestro sistema:
ReserveStock, ConfirmReservation, DispatchGoods, MakeCustomerVIP, etc.

Este estilo de arquitectura, donde creamos un microservicio por tabla de base de datos y tratamos
nuestras API HTTP como interfaces CRUD para modelos anémicos, es la forma inicial más común
para que las personas se acerquen al diseño orientado a servicios.

Esto funciona bien para sistemas que son muy simples, pero puede degradarse rápidamente en una
bola de lodo distribuida.

Para ver por qué, consideremos otro caso. A veces, cuando las existencias llegan al almacén,
descubrimos que los artículos se han dañado por el agua durante el tránsito. No podemos vender
sofás dañados por el agua, por lo que tenemos que tirarlos y solicitar más stock a nuestros socios.
También necesitamos actualizar nuestro modelo de stock, y eso podría significar que necesitamos
reasignar el pedido de un cliente.

¿Adónde va esta lógica?

Bien, el sistema de Almacén sabe que el stock se ha dañado, por lo que tal vez debería ser el
propietario de este proceso, como se muestra en la Figura 11-4.

164 | Capítulo 11: Arquitectura impulsada por eventos: uso de eventos para integrar microservicios
Machine Translated by Google

Figura 11-4. Flujo de comando 2

Esto también funciona, pero ahora nuestro gráfico de dependencia es un desastre. Para asignar existencias,
el servicio de Pedidos maneja el sistema de Lotes, que maneja el Almacén; pero con el fin de manejar los
problemas en el almacén, nuestro sistema de almacén impulsa los lotes, que impulsan los pedidos.

Multiplique esto por todos los otros flujos de trabajo que necesitamos proporcionar, y podrá ver cómo los
servicios se enredan rápidamente.

Manejo de errores en sistemas distribuidos


“Las cosas se rompen” es una ley universal de la ingeniería de software. ¿Qué sucede en nuestro sistema
cuando una de nuestras solicitudes falla? Digamos que ocurre un error de red justo después de que
tomamos el pedido de un usuario de tres MISBEGOTTEN-RUG, como se muestra en la figura 11-5.

Aquí tenemos dos opciones: podemos realizar el pedido de todos modos y dejarlo sin asignar, o podemos
negarnos a aceptar el pedido porque no se puede garantizar la asignación. El estado de falla de nuestro
servicio de lotes se ha disparado y está afectando la confiabilidad de nuestro servicio de pedidos.

Cuando hay que cambiar dos cosas juntas, decimos que están acopladas. Podemos pensar en esta
cascada de fallas como una especie de acoplamiento temporal: cada parte del sistema tiene que funcionar
al mismo tiempo para que cualquier parte funcione. A medida que el sistema se hace más grande, existe
una probabilidad exponencialmente creciente de que alguna parte se degrade.

Manejo de errores en sistemas distribuidos | 165


Machine Translated by Google

Figura 11-5. Flujo de comando con error

Conocimiento
Estamos usando el término acoplamiento aquí, pero hay otra forma de describir las relaciones entre
nuestros sistemas. Connascence es un término utilizado por algunos autores para describir los diferentes
tipos de acoplamiento.

La connascencia no es mala, pero algunos tipos de connascencia son más fuertes que otros. Queremos
tener una connascencia fuerte localmente, como cuando dos clases están estrechamente relacionadas,
pero una connascencia débil a distancia.

En nuestro primer ejemplo de una bola de barro distribuida, vemos Connascence of Execution: múltiples
componentes necesitan conocer el orden correcto de trabajo para que una operación sea exitosa.

Cuando pensamos en las condiciones de error aquí, estamos hablando de Connascence of Timing:
varias cosas tienen que suceder, una tras otra, para que la operación funcione.

Cuando reemplazamos nuestro sistema de estilo RPC con eventos, reemplazamos ambos tipos de
conciencia con un tipo más débil. Eso es Connascence of Name: varios componentes deben estar de
acuerdo solo en el nombre de un evento y los nombres de los campos que lleva.

Nunca podemos evitar por completo el acoplamiento, excepto si nuestro software no se comunica con
ningún otro software. Lo que queremos es evitar un acoplamiento inapropiado. Connascence proporciona
un modelo mental para comprender la fuerza y el tipo de acoplamiento inherente a los diferentes estilos
arquitectónicos. Lea todo sobre esto en connascence.io.

166 | Capítulo 11: Arquitectura impulsada por eventos: uso de eventos para integrar microservicios
Machine Translated by Google

La alternativa: desacoplamiento temporal usando


Mensajería asíncrona
¿Cómo conseguimos un acoplamiento adecuado? Ya hemos visto parte de la respuesta, que es que debemos
pensar en términos de verbos, no de sustantivos. Nuestro modelo de dominio se trata de modelar un proceso de
negocio. No es un modelo de datos estático sobre una cosa; es un modelo de un verbo.

Entonces, en lugar de pensar en un sistema de pedidos y un sistema de lotes, pensamos en un sistema de


pedidos y un sistema de asignación, y así sucesivamente.

Cuando separamos las cosas de esta manera, es un poco más fácil ver qué sistema debería ser responsable de
qué. Cuando pensamos en hacer un pedido, realmente queremos asegurarnos de que cuando hagamos un
pedido, se haga el pedido. Todo lo demás puede suceder más tarde, siempre que suceda.

Si esto te suena familiar, ¡debería serlo! La segregación de responsabilidades


es el mismo proceso por el que pasamos cuando diseñamos nuestros agregados
y comandos.

Al igual que los agregados, los microservicios deben ser límites de consistencia. Entre dos servicios, podemos
aceptar la consistencia eventual, y eso significa que no necesitamos depender de llamadas síncronas. Cada
servicio acepta comandos del mundo exterior y genera eventos para registrar el resultado. Otros servicios pueden
escuchar esos eventos para desencadenar los siguientes pasos en el flujo de trabajo.

Para evitar el antipatrón Distributed Ball of Mud, en lugar de llamadas a la API HTTP acopladas temporalmente,
queremos usar mensajería asíncrona para integrar nuestros sistemas.
Queremos que nuestros mensajes BatchQuantityChanged entren como mensajes externos de los sistemas
ascendentes, y queremos que nuestro sistema publique eventos asignados para que los escuchen los sistemas
descendentes.

¿Por qué es esto mejor? Primero, debido a que las cosas pueden fallar de forma independiente, es más fácil
manejar el comportamiento degradado: aún podemos tomar pedidos si el sistema de asignación está teniendo
un mal día.

En segundo lugar, estamos reduciendo la fuerza de acoplamiento entre nuestros sistemas. Si necesitamos
cambiar el orden de las operaciones o introducir nuevos pasos en el proceso, podemos hacerlo localmente.

La alternativa: desacoplamiento temporal mediante mensajería asíncrona | 167


Machine Translated by Google

Uso de un canal Pub/Sub de Redis para la integración


Veamos cómo funcionará todo concretamente. Necesitaremos alguna forma de sacar eventos de un
sistema y llevarlos a otro, como nuestro bus de mensajes, pero para servicios. Esta pieza de infraestructura
a menudo se denomina intermediario de mensajes. El papel de un corredor de mensajes es tomar mensajes
de los editores y entregarlos a los suscriptores.

En MADE.com, usamos Event Store; Kafka o RabbitMQ son alternativas válidas. Una solución liviana
basada en los canales pub/sub de Redis también puede funcionar bien, y debido a que Redis es mucho
más familiar para las personas, pensamos que lo usaríamos para este libro.

Estamos pasando por alto la complejidad que implica elegir la plataforma de


mensajería adecuada. Preocupaciones como el orden de los mensajes, el
manejo de fallas y la idempotencia necesitan ser pensadas. Para algunos
consejos, consulta “Footguns” en la página 226.

Nuestro nuevo flujo se parecerá a la Figura 11-6: Redis proporciona el evento BatchQuantityChanged que
inicia todo el proceso, y nuestro evento Allocated se vuelve a publicar en Redis al final.

Figura 11-6. Diagrama de secuencia para flujo de reasignación

168 | Capítulo 11: Arquitectura impulsada por eventos: uso de eventos para integrar microservicios
Machine Translated by Google

Prueba de manejo de todo usando una prueba de extremo a extremo

Así es como podríamos comenzar con una prueba de extremo a extremo. Podemos usar nuestra
API existente para crear lotes y luego probaremos los mensajes entrantes y salientes:

Una prueba de extremo a extremo para nuestro modelo pub/sub (tests/e2e/test_external_events.py)

def test_change_batch_quantity_leading_to_reallocation():
# comenzar con dos lotes y un pedido asignado a uno de ellos orderid, sku
= random_orderid(), random_sku() before_batch, later_batch =
random_batchref('old'), random_batchref('newer') api_client.post_to_add_batch(earlier_batch,
sku, qty =10, eta='2011-01-02') api_client.post_to_add_batch(later_batch, sku, qty=10,
eta='2011-01-02') respuesta = api_client.post_to_allocate(orderid, sku, 10) afirmar respuesta.
json()['batchref'] == lote_anterior

suscripción = redis_client.subscribe_to('line_allocated')

# cambiar la cantidad en el lote asignado para que sea menor que nuestro
pedido redis_client.publish_message('change_batch_quantity', { 'batchref':
before_batch, 'qty': 5
})

# espere hasta que veamos un mensaje que indique que el pedido ha sido
reasignado . mensajes.agregar(mensaje) imprimir(mensajes) datos =
json.loads(mensajes[-1]['datos']) afirmar datos['orderid'] == orderid afirmar
datos['batchref'] == later_batch

Puede leer la historia de lo que está sucediendo en esta prueba en los comentarios: queremos
enviar un evento al sistema que provoque la reasignación de una línea de pedido, y vemos
que la reasignación también aparece como un evento en Redis.

api_client es un pequeño ayudante que refactorizamos para compartir entre nuestros dos tipos de
prueba; envuelve nuestras llamadas a request.post.

redis_client es otro pequeño ayudante de prueba, cuyos detalles realmente no importan; su


trabajo es poder enviar y recibir mensajes de varios canales de Redis.
Usaremos un canal llamado change_batch_quantity para enviar nuestra solicitud para cambiar
la cantidad de un lote, y escucharemos otro canal llamado line_allocated para buscar la
reasignación esperada.

Prueba de manejo de todo usando una prueba de extremo a extremo | 169


Machine Translated by Google

Debido a la naturaleza asíncrona del sistema que se está probando, necesitamos usar la biblioteca de
tenacidad nuevamente para agregar un ciclo de reintento; primero, porque nuestro nuevo mensaje
line_allocated puede demorar un tiempo en llegar, pero también porque no será el único mensaje en
ese canal.

Redis es otro adaptador delgado alrededor de nuestro bus de mensajes

Nuestro oyente pub/sub de Redis (lo llamamos consumidor de eventos) es muy parecido a Flask: se traduce
del mundo exterior a nuestros eventos:

Escucha de mensajes de Redis simple (src/allocation/entrypoints/


redis_eventconsumer.py) r = redis.Redis(**config.get_redis_host_and_port())

def main():
orm.start_mappers()
pubsub = r.pubsub(ignore_subscribe_messages=True)
pubsub.subscribe('change_batch_quantity')

para m en pubsub.listen():
handle_change_batch_quantity(m)

def handle_change_batch_quantity(m):
logging.debug('handling %s', m) data =
json.loads(m['data']) cmd =
commands.ChangeBatchQuantity(ref=data['batchref'], qty=data[ 'cantidad'])
messagebus.handle(cmd, uow=unidad_de_trabajo.SqlAlchemyUnitOfWork())

main() nos suscribe al canal change_batch_quantity en carga.

Nuestro trabajo principal como punto de entrada al sistema es deserializar JSON, convertirlo en un
comando y pasarlo a la capa de servicio, como lo hace el adaptador Flask.

También construimos un nuevo adaptador descendente para hacer el trabajo opuesto: convertir eventos de
dominio en eventos públicos:

Publicador de mensajes de Redis simple (src/allocation/adapters/redis_eventpublisher.py)

r = redis.Redis(**config.get_redis_host_and_port())

def publicar(canal, evento: eventos.Evento):


logging.debug('publicar: canal=%s, evento=%s', canal, evento) r.publish(canal,
json.dumps(asdict(evento)))

170 | Capítulo 11: Arquitectura impulsada por eventos: uso de eventos para integrar microservicios
Machine Translated by Google

Tomamos un canal codificado aquí, pero también puede almacenar un mapeo entre clases/nombres de
eventos y el canal apropiado, permitiendo que uno o más tipos de mensajes vayan a diferentes canales.

Nuestro nuevo evento saliente


Así es como se verá el evento Asignado :

Nuevo evento (src/allocation/domain/events.py)


Clase
@dataclass asignada (evento):
orderid: str
sku: str qty: int
loteref: str

Captura todo lo que necesitamos saber sobre una asignación: los detalles de la línea de pedido y a qué lote se
asignó.

Lo agregamos al método allocate() de nuestro modelo (habiendo agregado una prueba primero, naturalmente):

Product.allocate() emite un nuevo evento para registrar lo que sucedió (src/allocation/domain/


model.py) class Product:
...
def allocate(self, línea: OrderLine) -> str:
...

lote.allocate(línea)
self.version_number += 1
self.events.append(events.Allocated(
orderid=línea.orderid, sku=línea.sku, qty=línea.qty,
loteref=lote.referencia,
))
volver lote.referencia

El controlador para ChangeBatchQuantity ya existe, por lo que todo lo que necesitamos agregar es un controlador
que publique el evento saliente:

El bus de mensajes crece (src/allocation/service_layer/messagebus.py)


MANEJADORES = {
eventos.Asignados: [handlers.publish_allocated_event],
events.OutOfStock: [handlers.send_out_of_stock_notification],
} # tipo: Dict[Tipo[eventos.Evento], Lista[Llamable]]

La publicación del evento utiliza nuestra función auxiliar del contenedor de Redis:

Prueba de manejo de todo usando una prueba de extremo a extremo | 171


Machine Translated by Google

Publicar en Redis (src/allocation/service_layer/handlers.py)

def publicar_asignado_evento(
event: events.Allocated, uow: unit_of_work.AbstractUnitOfWork,
):
redis_eventpublisher.publish('line_allocated', evento)

Eventos internos frente a eventos externos

Es una buena idea mantener clara la distinción entre eventos internos y externos.
Algunos eventos pueden provenir del exterior y algunos eventos pueden actualizarse y publicarse externamente, pero
no todos lo harán. Esto es particularmente importante si te involucras en el abastecimiento de eventos (aunque, en
gran medida, es un tema para otro libro).

Los eventos salientes son uno de los lugares en los que es importante aplicar
la validación. Consulte el Apéndice E para ver algunos ejemplos y filosofía de
validación.

Ejercicio para el lector


Una buena y simple para este capítulo: haga que el caso de uso principal allocate() también
pueda ser invocado por un evento en un canal de Redis, así como (o en lugar de) a través de la API.

Es probable que desee agregar una nueva prueba E2E y realizar algunos cambios en
redis_eventconsumer.py.

Envolver
Los eventos pueden provenir del exterior, pero también pueden publicarse externamente: nuestro controlador de
publicación convierte un evento en un mensaje en un canal de Redis. Usamos eventos para hablar con el mundo
exterior. Este tipo de desacoplamiento temporal nos da mucha flexibilidad en nuestras integraciones de aplicaciones,
pero como siempre, tiene un costo.

La notificación de eventos es buena porque implica un bajo nivel de acoplamiento y es bastante simple
de configurar. Sin embargo, puede volverse problemático si realmente hay un flujo lógico que se ejecuta
en varias notificaciones de eventos... Puede ser difícil ver dicho flujo ya que no está explícito en ningún
texto del programa... Esto puede hacer que sea difícil depurar y modificar.

—Martin Fowler, “¿Qué quiere decir con 'impulsado por eventos'”?

La tabla 11-1 muestra algunas compensaciones en las que pensar.

172 | Capítulo 11: Arquitectura impulsada por eventos: uso de eventos para integrar microservicios
Machine Translated by Google

Tabla 11-1. Integración de microservicios basada en eventos: las ventajas y desventajas

ventajas Contras

• Evita la gran bola de barro distribuida. • Los • Los flujos generales de información son más difíciles de ver. •

servicios están desacoplados: es más fácil cambiar La consistencia eventual es un nuevo concepto a tratar. •
servicios individuales y agregar otros nuevos.
Fiabilidad de los mensajes y opciones entre al menos una vez y como máximo
una vez que la entrega necesita pensar bien.

En términos más generales, si pasa de un modelo de mensajería síncrona a uno asíncrono,


también se abre una gran cantidad de problemas relacionados con la confiabilidad del mensaje
y la consistencia final. Sigue leyendo hasta “Footguns” en la página 226.

Resumen | 173
Machine Translated by Google
Machine Translated by Google

CAPÍTULO 12

Responsabilidad de consulta de comandos


Segregación (CQRS)

En este capítulo, vamos a comenzar con una idea bastante no controvertida: las lecturas (consultas)
y las escrituras (comandos) son diferentes, por lo que deben tratarse de manera diferente (o segregar
sus responsabilidades, por así decirlo). Entonces vamos a llevar esa idea tan lejos como podamos.

Si eres como Harry, todo esto parecerá extremo al principio, pero con suerte podemos argumentar
que no es totalmente irrazonable.

La figura 12-1 muestra dónde podríamos terminar.

El código de este capítulo se encuentra en la rama chapter_12_cqrs


en GitHub.

git clone https://github.com/cosmicpython/code.git cd code git


checkout chapter_12_cqrs # o para codificar, consulte el
capítulo anterior: git checkout chapter_11_external_events

Primero, sin embargo, ¿por qué molestarse?

175
Machine Translated by Google

Figura 12-1. Separando lecturas de escrituras

Los modelos de dominio son para escribir

Hemos dedicado mucho tiempo en este libro a hablar sobre cómo crear software que haga cumplir
las reglas de nuestro dominio. Estas reglas, o restricciones, serán diferentes para cada aplicación
y constituyen el núcleo interesante de nuestros sistemas.

En este libro, hemos establecido restricciones explícitas como "No puede asignar más stock del
que está disponible", así como restricciones implícitas como "Cada línea de pedido se asigna a un
solo lote".

Escribimos estas reglas como pruebas unitarias al principio del libro:

176 | Capítulo 12: Segregación de responsabilidad de consulta de comandos (CQRS)


Machine Translated by Google

Nuestras pruebas básicas de dominio (tests/unit/


test_batches.py) def test_allocating_to_a_batch_reduces_the_disponible_quantity():
lote = Lote("lote-001", "TABLA PEQUEÑA", cant. =20, eta=fecha.hoy()) línea = LíneaPedido('ref-
pedido', "TABLA PEQUEÑA", 2)

lote.asignar(línea)

afirmar lote.cantidad_disponible == 18

...

def test_cannot_allocate_if_available_smaller_than_required():
small_batch, large_line = make_batch_and_line("ELEGANT-LAMP", 2, 20) afirmar
small_batch.can_allocate(large_line) es falso

Para aplicar estas reglas correctamente, necesitábamos asegurarnos de que las operaciones fueran coherentes, por
lo que introdujimos patrones como Unidad de trabajo y Agregado que nos ayudan a realizar pequeñas porciones de
trabajo.

Para comunicar los cambios entre esos pequeños fragmentos, introdujimos el patrón de eventos de dominio para que
podamos escribir reglas como "Cuando el stock se dañe o se pierda, ajuste la cantidad disponible en el lote y reasigne
los pedidos si es necesario".

Toda esta complejidad existe para que podamos hacer cumplir las reglas cuando cambiamos el estado de nuestro
sistema. Hemos creado un conjunto flexible de herramientas para escribir datos.

Sin embargo, ¿qué pasa con las lecturas?

La mayoría de los usuarios no van a comprar tus muebles


En MADE.com tenemos un sistema muy parecido al servicio de asignación. En un día ajetreado, podemos procesar
cien pedidos en una hora, y tenemos un gran sistema retorcido para asignar existencias a esos pedidos.

Sin embargo, en ese mismo día ajetreado, podríamos tener cien vistas de productos por segundo.
Cada vez que alguien visita la página de un producto, o una página de listado de productos, debemos averiguar si el
producto todavía está en stock y cuánto tiempo nos llevará entregarlo.

El dominio es el mismo: nos preocupan los lotes de existencias, su fecha de llegada y la cantidad que aún está

disponible, pero el patrón de acceso es muy diferente. Por ejemplo, nuestros clientes no se darán cuenta si la consulta
está desactualizada unos segundos, pero si nuestro servicio de asignación es inconsistente, desordenaremos sus
pedidos. Podemos aprovechar esta diferencia haciendo que nuestras lecturas sean eventualmente consistentes para

que funcionen mejor.

la mayoría de los usuarios no van a comprar sus muebles | 177


Machine Translated by Google

¿Es realmente alcanzable la consistencia de lectura?

Esta idea de intercambiar consistencia por rendimiento pone nerviosos a muchos desarrolladores al principio,
así que hablemos rápidamente sobre eso.

Imaginemos que nuestra consulta "Obtener existencias disponibles" tiene 30 segundos de desactualización
cuando Bob visita la página de ASYMETRICAL-DRESSER. Mientras tanto, sin embargo, Harry ya ha comprado
el último artículo. Cuando intentemos asignar el pedido de Bob, obtendremos un error y tendremos que
cancelar su pedido o comprar más existencias y retrasar su entrega.

Las personas que han trabajado solo con almacenes de datos relacionales se ponen muy nerviosas con este
problema, pero vale la pena considerar otros dos escenarios para obtener cierta perspectiva.

Primero, imaginemos que Bob y Harry visitan la página al mismo tiempo. Harry se va a hacer café y, cuando
regresa, Bob ya ha comprado la última cómoda. Cuando Harry hace su pedido, lo enviamos al servicio de
asignación, y como no hay suficiente stock, tenemos que reembolsar su pago o comprar más stock y retrasar
su entrega.

Tan pronto como mostramos la página del producto, los datos ya están obsoletos. Esta información es clave
para comprender por qué las lecturas pueden ser inconsistentes de manera segura: siempre necesitaremos
verificar el estado actual de nuestro sistema cuando lleguemos a la asignación, porque todos los sistemas
distribuidos son inconsistentes. Tan pronto como tenga un servidor web y dos clientes, tiene el potencial de
datos obsoletos.

Bien, supongamos que resolvemos ese problema de alguna manera: creamos mágicamente una aplicación
web totalmente consistente donde nadie ve datos obsoletos. Esta vez, Harry llega primero a la página y
compra su tocador.

Desafortunadamente para él, cuando el personal del almacén intenta despachar sus muebles, estos se caen
de la carretilla elevadora y se rompen en un millón de pedazos. ¿Ahora que?

Las únicas opciones son llamar a Harry y reembolsar su pedido o comprar más existencias y retrasar la
entrega.

No importa lo que hagamos, siempre encontraremos que nuestros sistemas de software son inconsistentes
con la realidad, por lo que siempre necesitaremos procesos comerciales para hacer frente a estos casos
extremos. Está bien intercambiar rendimiento por consistencia en el lado de la lectura, porque los datos
obsoletos son esencialmente inevitables.

Podemos pensar que estos requisitos forman dos mitades de un sistema: el lado de lectura y el lado de
escritura, que se muestran en la tabla 12-1.

Para el lado de escritura, nuestros elegantes patrones arquitectónicos de dominio nos ayudan a evolucionar
nuestro sistema con el tiempo, pero la complejidad que hemos construido hasta ahora no compra nada para
leer datos. La capa de servicio, la unidad de trabajo y el modelo de dominio inteligente son simplemente inflados.

178 | Capítulo 12: Segregación de responsabilidad de consulta de comandos (CQRS)


Machine Translated by Google

Tabla 12-1. Leer versus escribir

Lado de lectura lado de escritura

Comportamiento
lectura sencilla Lógica empresarial compleja

Cacheabilidad Altamente cacheable No cacheable

La consistencia puede ser obsoleta Debe ser transaccionalmente consistente

Publicar/Redireccionar/Obtener y CQS

Si se dedica al desarrollo web, probablemente esté familiarizado con el patrón Publicar/Redireccionar/Obtener. En esta
técnica, un punto final web acepta un HTTP POST y responde con una redirección para ver el resultado. Por ejemplo,
podríamos aceptar un POST a /lotes para crear un nuevo lote y redirigir al usuario a /lotes/123 para ver su lote recién creado.

Este enfoque soluciona los problemas que surgen cuando los usuarios actualizan la página de resultados en su navegador
o intentan marcar una página de resultados. En el caso de una actualización, puede hacer que nuestros usuarios envíen
datos dos veces y, por lo tanto, compren dos sofás cuando solo necesitaban uno. En el caso de un marcador, nuestros
desafortunados clientes terminarán con una página rota cuando intenten OBTENER un punto final POST.

Ambos problemas ocurren porque estamos devolviendo datos en respuesta a una operación de escritura. Post/Redirect/Get
evita el problema al separar las fases de lectura y escritura de nuestra operación.

Esta técnica es un ejemplo simple de separación de consulta de comando (CQS). En CQS seguimos una regla simple: las
funciones deben modificar el estado o responder preguntas, pero nunca ambas cosas. Esto hace que el software sea más
fácil de razonar: siempre deberíamos poder preguntar: "¿Están encendidas las luces?" sin accionar el interruptor de la luz.

Al crear API, podemos aplicar la misma técnica de diseño devolviendo un


201 Creado o un 202 Aceptado, con un encabezado de Ubicación que
contenga el URI de nuestros nuevos recursos. Lo importante aquí no es el
código de estado que usamos, sino la separación lógica del trabajo en una
fase de escritura y una fase de consulta.

Como verá, podemos usar el principio de CQS para hacer que nuestros sistemas sean más rápidos y más escalables, pero
primero, corrijamos la violación de CQS en nuestro código existente. Hace mucho tiempo, introdujimos un punto final de

asignación que toma un pedido y llama a nuestra capa de servicio para asignar algunas existencias. Al final de la llamada,
devolvemos un 200 OK y la identificación del lote. Eso ha llevado a algunos feos defectos de diseño para que podamos

obtener los datos que necesitamos. Cambiémoslo para que devuelva un mensaje OK simple y, en su lugar, proporcionemos
un nuevo punto final de solo lectura para recuperar la asignación.
estado:

Publicar/Redireccionar/Obtener y CQS | 179


Machine Translated by Google

La prueba API hace un GET después del POST (tests/e2e/test_api.py)

@pytest.mark.usefixtures('postgres_db')
@pytest.mark.usefixtures('restart_api') def
test_happy_path_returns_202_and_batch_is_allocated(): orderid =
random_orderid() sku, otherku = random_sku(), random_sku('other')
earlybatch = random_batchref( 1) laterbatch = random_batchref(2)
otro lote = random_batchref (3) api_client.post_to_add_batch(laterbatch,
sku, 100, '2011-01-02') api_client.post_to_add_batch(earlybatch,
sku, 100, '2011-01-01') api_client.post_to_add_batch(otherbatch,
otherku, 100, Ninguno)

r = api_client.post_to_allocate(orderid, sku, qty=3) afirmar


r.status_code == 202

r = api_client.get_allocation(orderid) afirmar
r.ok afirmar r.json() == [ {'sku': sku, 'batchref':
earlybatch},

@pytest.mark.usefixtures('postgres_db')
@pytest.mark.usefixtures('restart_api') def
test_unhappy_path_returns_400_and_error_message():
unknown_sku, orderid = random_sku(), random_orderid() r =
api_client.post_to_allocate( orderid, unknown_sku, qty=20,
expect_success=False,

) afirmar r.status_code == 400


afirmar r.json()['mensaje'] == f'Sku no válido {unknown_sku}'

r = api_client.get_allocation(orderid) afirmar
r.status_code == 404

Vale, ¿cómo sería la aplicación Flask?

Punto final para ver asignaciones (src/allocation/entrypoints/ask_app.py)

desde vistas de importación de asignaciones


...

@app.route("/asignaciones/<id_pedido>", métodos=['GET']) def


asignaciones_view_endpoint(id_pedido):
uow = unit_of_work.SqlAlchemyUnitOfWork()
resultado = views.allocations(orderid, uow) si no
es el resultado: devuelve 'no encontrado', 404
devuelve jsonify(resultado), 200

180 | Capítulo 12: Segregación de responsabilidad de consulta de comando (CQRS)


Machine Translated by Google

Muy bien, un views.py, bastante justo; podemos mantener cosas de solo lectura allí, y será un view.py real, no como el de

Django, algo que sabe cómo crear vistas de solo lectura de nuestros datos...

Aférrense a su almuerzo, amigos


Hmm, entonces probablemente podamos simplemente agregar un método de lista a nuestro objeto de repositorio existente:

Las vistas hacen… ¿SQL sin procesar? (src/asignación/vistas.py)

de asignación.capa_de_servicio importar unidad_de_trabajo

asignaciones def (orderid: str, uow: unit_of_work.SqlAlchemyUnitOfWork):


con uow:
resultados = list(uow.session.execute( 'SELECT
ol.sku, b.reference'
'
DESDE asignaciones AS a'
'
UNIR lotes COMO b EN a.batch_id = b.id'
'
ÚNASE a order_lines AS ol ON a.orderline_id = ol.id'
' DONDE ol.orderid = :orderid',
dict(orderid=orderid)
))
devuelve [{'sku': sku, 'batchref': batchref} para sku, batchref en resultados]

¿Perdóneme? SQL sin procesar?

Si eres como Harry y te encuentras con este patrón por primera vez, te preguntarás qué diablos ha estado fumando Bob. ¿Estamos

procesando a mano nuestro propio SQL ahora y convirtiendo las filas de la base de datos directamente en dictados? ¿Después

de todo el esfuerzo que pusimos en construir un buen modelo de dominio? ¿Y qué pasa con el patrón Repositorio? ¿No está

destinado a ser nuestra abstracción en torno a la base de datos? ¿Por qué no reutilizamos eso?

Bueno, primero exploremos esa alternativa aparentemente más simple y veamos cómo se ve en la práctica.

Todavía mantendremos nuestra vista en un módulo views.py separado; hacer cumplir una distinción clara entre lecturas y

escrituras en su aplicación sigue siendo una buena idea. Aplicamos la separación de consultas de comandos y es fácil ver qué

código modifica el estado (los controladores de eventos) y qué código solo recupera el estado de solo lectura (las vistas).

Separar las vistas de solo lectura de los controladores de eventos y


comandos de modificación de estado probablemente sea una buena idea,
incluso si no desea utilizar CQRS completo.

Aférrense a su almuerzo, amigos | 181


Machine Translated by Google

Prueba de vistas CQRS


Antes de comenzar a explorar varias opciones, hablemos de las pruebas. Cualquiera que sea el
enfoque que decida elegir, probablemente necesitará al menos una prueba de integración. Algo como
esto:

Una prueba de integración para una vista (tests/integration/test_views.py)

def test_allocations_view(sqlite_session_factory):
uow = unidad_de_trabajo.SqlAlchemyUnitOfWork(sqlite_session_factory)
messagebus.handle(commands.CreateBatch('sku1batch', 'sku1', 50, None), uow)
messagebus.handle(commands.CreateBatch('sku2batch', 'sku2', 50, hoy ), uow)
messagebus.handle(commands.Allocate('order1', 'sku1', 20), uow)
messagebus.handle(commands.Allocate('order1', 'sku2', 20), uow) # agregar un falso lote y
ordene para asegurarnos de obtener los correctos
messagebus.handle(commands.CreateBatch('sku1batch-later', 'sku1', 50, today), uow)
messagebus.handle(commands.Allocate('otherorder', 'sku1', 30), uow)
messagebus.handle(commands.Allocate('otherorder', 'sku2', 10), uow)

aseverar vistas.allocaciones('order1', uow) == [ {'sku':


'sku1', 'batchref': 'sku1batch'}, {'sku': 'sku2',
'batchref': 'sku2batch'},
]

Realizamos la configuración para la prueba de integración utilizando el punto de entrada público


a nuestra aplicación, el bus de mensajes. Eso mantiene nuestras pruebas desvinculadas de
cualquier implementación/infraestructura sobre cómo se almacenan las cosas.

Alternativa "obvia" 1: usar el repositorio existente


¿Qué tal agregar un método auxiliar a nuestro repositorio de productos ?

Una vista simple que usa el repositorio (src/allocation/views.py)

desde asignación importación unidad_de_trabajo

asignaciones def (orderid: str, uow: unit_of_work.AbstractUnitOfWork):


with uow:
productos = uow.products.for_order(orderid=orderid) lotes =
[b para p en productos para b en p.lotes] return [ {'sku':
b.sku, 'batchref': b.reference} para b en lotes si orderid en
b.orderids

Nuestro repositorio devuelve objetos de Producto y necesitamos encontrar todos los productos
para los SKU en un orden determinado, por lo que crearemos un nuevo método auxiliar
llamado .for_order() en el repositorio.

182 | Capítulo 12: Segregación de responsabilidad de consulta de comando (CQRS)


Machine Translated by Google

Ahora tenemos productos, pero en realidad queremos referencias de lotes, por lo que obtenemos todos
los lotes posibles con una lista de comprensión.

Filtramos nuevamente para obtener solo los lotes para nuestro pedido específico. Eso, a su vez, depende
de que nuestros objetos Batch puedan decirnos qué ID de pedido ha asignado.

Implementamos eso último usando una propiedad .orderid :

Una propiedad posiblemente innecesaria en nuestro modelo (src/allocation/domain/model.py)


lote de clase :
...

@property
def orderids(self):
return {l.orderid for l in self._allocations}

Puede comenzar a ver que reutilizar nuestras clases de modelo de dominio y repositorio existentes no es tan
sencillo como podría haber asumido. Tuvimos que agregar nuevos métodos de ayuda a ambos, y estamos
haciendo un montón de bucles y filtros en Python, que es un trabajo que la base de datos haría de manera
mucho más eficiente.

Así que sí, en el lado positivo estamos reutilizando nuestras abstracciones existentes, pero en el lado negativo,
todo parece bastante torpe.

Su modelo de dominio no está optimizado para operaciones de lectura


Lo que estamos viendo aquí son los efectos de tener un modelo de dominio diseñado principalmente para
operaciones de escritura, mientras que nuestros requisitos para las lecturas suelen ser conceptualmente
bastante diferentes.

Esta es la justificación del arquitecto que se acaricia la barbilla para CQRS. Como dijimos antes, un modelo
de dominio no es un modelo de datos: estamos tratando de capturar la forma en que funciona el negocio: flujo
de trabajo, reglas sobre cambios de estado, mensajes intercambiados; preocupaciones sobre cómo reacciona
el sistema a los eventos externos y la entrada del usuario. La mayoría de estas cosas son totalmente
irrelevantes para las operaciones de solo lectura.

Esta justificación de CQRS está relacionada con la justificación del patrón del
modelo de dominio. Si está creando una aplicación CRUD simple, las lecturas
y las escrituras estarán estrechamente relacionadas, por lo que no necesita
un modelo de dominio o CQRS. Pero cuanto más complejo sea su dominio,
más probable es que necesite ambos.

Para hacer un punto sencillo, sus clases de dominio tendrán múltiples métodos para modificar el estado, y no
necesitará ninguno de ellos para operaciones de solo lectura.

su modelo de dominio no está optimizado para operaciones de lectura | 183


Machine Translated by Google

A medida que crece la complejidad de su modelo de dominio, se encontrará tomando más y más
decisiones sobre cómo estructurar ese modelo, lo que lo hace cada vez más difícil de usar para
operaciones de lectura.

Alternativa "Obvia" 2: Usar el ORM


Puede estar pensando, OK, si nuestro repositorio es torpe, y trabajar con Productos es torpe, entonces
al menos puedo usar mi ORM y trabajar con Lotes. ¡Para eso está!

Una vista simple que usa el ORM (src/allocation/views.py)

de asignación importación unidad_de_trabajo, modelo

asignaciones def (orderid: str, uow: unit_of_work.AbstractUnitOfWork):


with uow:
lotes = uow.session.query(model.Batch).join( model.OrderLine,
model.Batch._allocations ).filter( model.OrderLine.orderid
== orderid

) devuelve
[ {'sku': b.sku, 'batchref': b.batchref} para b en
lotes
]

Pero, ¿es realmente más fácil de escribir o comprender que la versión SQL sin procesar del ejemplo
de código en “Aguanta tu almuerzo, amigos” en la página 181? Puede que no se vea tan mal allí
arriba, pero podemos decirle que tomó varios intentos y mucho de excavar a través de los documentos
de SQLAlchemy. SQL es solo SQL.

Pero el ORM también puede exponernos a problemas de rendimiento.

SELECT N+1 y otras consideraciones de rendimiento


El llamado problema SELECT N+1 es un problema de rendimiento común con los ORM: al recuperar
una lista de objetos, su ORM a menudo realizará una consulta inicial para, por ejemplo, obtener todas
las ID de los objetos que necesita y luego emitirá una lista individual. consultas de cada objeto para
recuperar sus atributos. Esto es especialmente probable si hay alguna relación de clave externa en
sus objetos.

Para ser justos, debemos decir que SQLAlchemy es bastante bueno


para evitar el problema SELECT N+1 . No lo muestra en el ejemplo
anterior, y puede solicitar una carga ansiosa explícitamente para
evitarlo cuando se trata de objetos unidos.

184 | Capítulo 12: Segregación de responsabilidad de consulta de comandos (CQRS)


Machine Translated by Google

Más allá de SELECT N+1, es posible que tenga otras razones para querer desvincular la forma en que persiste los cambios
de estado de la forma en que recupera el estado actual. Un conjunto de tablas relacionales completamente normalizadas es
una buena manera de asegurarse de que las operaciones de escritura nunca dañen los datos. Pero la recuperación de
datos mediante muchas uniones puede ser lenta. En tales casos, es común agregar algunas vistas desnormalizadas, crear
réplicas de lectura o incluso agregar capas de almacenamiento en caché.

Es hora de saltar por completo al tiburón


En ese sentido: ¿lo hemos convencido de que nuestra versión de SQL sin formato no es tan extraña como parecía al
principio? ¿Quizás estábamos exagerando por efecto? Solo espera.

Entonces, razonable o no, esa consulta SQL codificada es bastante fea, ¿verdad? ¿Y si lo hiciéramos más bonito...?

Una consulta mucho más agradable (src/allocation/views.py)

asignaciones def (orderid: str, uow: unit_of_work.SqlAlchemyUnitOfWork):


con uow:
resultados = list(uow.session.execute(
'SELECCIONE sku, loteref DESDE asignaciones_vista DONDE orderid = :orderid',
dict(orderid=orderid)
))
...

…manteniendo un almacén de datos totalmente separado y desnormalizado para nuestro modelo de vista?

Je, je, je, sin claves foráneas, solo cadenas, YOLO (src/allocation/adapters/
orm.py) asignaciones_vista = Table( 'asignaciones_vista', metadatos, Columna ('orderid', Cadena (255)),
Columna ('sku' , Cadena (255)), Columna ('referencia de lote', Cadena (255)),

De acuerdo, las consultas SQL más atractivas no serían una justificación para nada realmente, pero crear una copia
desnormalizada de sus datos que esté optimizada para operaciones de lectura no es poco común, una vez que haya
alcanzado los límites de lo que puede hacer con los índices.

Incluso con índices bien ajustados, una base de datos relacional utiliza una gran cantidad de CPU
para realizar uniones. Las consultas más rápidas siempre serán SELECT * from mytable WHERE
key = :value.

Sin embargo, más que velocidad bruta, este enfoque nos compra escala. Cuando estamos escribiendo datos en una base
de datos relacional, debemos asegurarnos de obtener un bloqueo sobre las filas que estamos cambiando para que no
tengamos problemas de coherencia.

hora de saltar por completo al tiburón | 185


Machine Translated by Google

Si varios clientes están cambiando datos al mismo tiempo, tendremos condiciones de carrera extrañas. Sin embargo,
cuando estamos leyendo datos, no hay límite para la cantidad de clientes que pueden ejecutarse simultáneamente.
Por esta razón, las tiendas de solo lectura se pueden escalar horizontalmente
afuera.

Debido a que las réplicas de lectura pueden ser inconsistentes, no hay límite para
la cantidad que podemos tener. Si tiene dificultades para escalar un sistema con
un almacén de datos complejo, pregúntese si podría crear un modelo de lectura
más simple.

¡Mantener el modelo de lectura actualizado es el desafío! Las vistas de la base de datos (materializadas o no) y los
disparadores son una solución común, pero eso lo limita a su base de datos.
En su lugar, nos gustaría mostrarle cómo reutilizar nuestra arquitectura basada en eventos.

Actualización de una tabla de modelo de lectura mediante un controlador de eventos

Agregamos un segundo controlador al evento Allocated :

El evento asignado obtiene un nuevo controlador (src/allocation/service_layer/messagebus.py)

EVENT_HANDLERS
= { eventos. Asignados:
[ handlers.publish_allocated_event,
handlers.add_allocation_to_read_model
],

Así es como se ve nuestro código de modelo de vista de actualización:

Actualización sobre asignación (src/allocation/service_layer/


handlers.py) def add_allocation_to_read_model( event: events.Allocated, uow:
unit_of_work.SqlAlchemyUnitOfWork,
):
con uow:

uow.session.execute( 'INSERTAR EN vista_de_asignaciones (orderid, sku, loteref)'


'
VALORES (:orderid, :sku, :batchref)',
dict(orderid=evento.orderid, sku=evento.sku, loteref=evento.batchref)

) uow.commit()

Lo creas o no, ¡eso funcionará bastante bien! Y funcionará con las mismas pruebas de integración que el resto de
nuestras opciones.

Bien, también deberá manejar Desasignado:

186 | Capítulo 12: Segregación de responsabilidad de consulta de comando (CQRS)


Machine Translated by Google

Un segundo oyente para leer actualizaciones del modelo.

events.Deallocated:
[ handlers.remove_allocation_from_read_model,
handlers.realocate
],

...

def remove_allocation_from_read_model( event:


events.Deallocated, uow: unit_of_work.SqlAlchemyUnitOfWork,
):
con uow:

uow.session.execute( 'ELIMINAR DESDE vista_asignaciones'


' WHERE idpedido = :idpedido AND sku = :sku',

La figura 12-2 muestra el flujo entre las dos solicitudes.

Figura 12-2. Diagrama de secuencia para el modelo de lectura

hora de saltar por completo al tiburón | 187


Machine Translated by Google

En la Figura 12-2, puede ver dos transacciones en la operación POST/escritura, una para actualizar el modelo
de escritura y otra para actualizar el modelo de lectura, que puede usar la operación GET/read.

Reconstruyendo desde cero


“¿Qué sucede cuando se rompe?” debería ser la primera pregunta que nos hagamos como ingenieros.

¿Cómo lidiamos con un modelo de vista que no se ha actualizado debido a un error o una interrupción temporal?
Bueno, este es solo otro caso en el que los eventos y los comandos pueden fallar de forma independiente.

Si nunca actualizamos el modelo de vista y ASYMMETRICAL-DRESSER siempre estuvo en stock, eso sería
molesto para los clientes, pero el servicio de asignación aún fallaría y tomaríamos medidas para solucionar el
problema.

Sin embargo, reconstruir un modelo de vista es fácil. Como estamos usando una capa de servicio para actualizar
nuestro modelo de vista, podemos escribir una herramienta que haga lo siguiente:

• Consulta el estado actual del lado de escritura para averiguar qué está asignado actualmente. • Llama al

controlador add_allocate_to_read_model para cada elemento asignado.

Podemos usar esta técnica para crear modelos de lectura completamente nuevos a partir de datos históricos.

Cambiar la implementación de nuestro modelo de lectura es fácil


Veamos la flexibilidad que nuestro modelo basado en eventos nos ofrece en acción, al ver qué sucede si alguna
vez decidimos que queremos implementar un modelo de lectura mediante el uso de un motor de almacenamiento
totalmente independiente, Redis.

Sólo mira:

Los controladores actualizan un modelo de lectura de Redis (src/allocation/service_layer/handlers.py)

def agregar_asignación_a_leer_modelo(evento: eventos.Asignado, _):


redis_eventpublisher.update_readmodel(event.orderid, event.sku, event.batchref)

def remove_allocation_from_read_model(event: events.Deallocated, _):


redis_eventpublisher.update_readmodel(event.orderid, event.sku, Ninguno)

Los ayudantes en nuestro módulo Redis son ingeniosos:

Redis lee el modelo, lee y actualiza (src/allocation/adapters/redis_eventpublisher.py)


def update_readmodel(orderid, sku, batchref): r.hset(orderid, sku, batchref)

188 | Capítulo 12: Segregación de responsabilidad de consulta de comandos (CQRS)


Machine Translated by Google

def get_readmodel(orderid):
devuelve r.hgetall(orderid)

(Tal vez el nombre redis_eventpublisher.py sea un nombre inapropiado ahora, pero se entiende la idea).

Y la vista en sí cambia muy ligeramente para adaptarse a su nuevo backend:

Vista adaptada a Redis (src/allocation/views.py)


asignaciones def (orderid):
lotes = redis_eventpublisher.get_readmodel(orderid) return
[ {'batchref': b.decode(), 'sku': s.decode()} for s, b in batches.items()

Y exactamente las mismas pruebas de integración que teníamos antes aún pasan, porque están escritas en un
nivel de abstracción que está desacoplado de la implementación: la configuración coloca mensajes en el bus
de mensajes y las afirmaciones están en contra de nuestra vista.

Los controladores de eventos son una excelente manera de administrar las actualizaciones
de un modelo de lectura, si decide que necesita uno. También facilitan el cambio de la
implementación de ese modelo de lectura en una fecha posterior.

Ejercicio para el lector


Implemente otra vista, esta vez para mostrar la asignación de una sola línea de pedido.

Aquí, las compensaciones entre el uso de SQL codificado y el uso de un repositorio deberían
ser mucho más borrosas. Pruebe algunas versiones (tal vez incluyendo ir a Redis) y vea cuál
prefiere.

Envolver
La Tabla 12-2 propone algunos pros y contras para cada una de nuestras opciones.

Da la casualidad de que el servicio de asignación de MADE.com utiliza CQRS "completo", con un modelo de
lectura almacenado en Redis e incluso una segunda capa de caché proporcionada por Varnish.
Pero sus casos de uso son bastante diferentes de lo que hemos mostrado aquí. Para el tipo de servicio de
asignación que estamos creando, parece poco probable que necesite usar un modelo de lectura y controladores
de eventos separados para actualizarlo.

Pero a medida que su modelo de dominio se vuelve más rico y complejo, un modelo de lectura simplificado se
vuelve cada vez más atractivo.

Resumen | 189
Machine Translated by Google

Tabla 12-2. Compensaciones de varias opciones de modelo de vista

Opción ventajas Contras

Solo usa repositorios Enfoque simple y consistente. Espere problemas de rendimiento con patrones de consulta

complejos.

Utilice consultas Permite la reutilización de la configuración de la base de datos y las Agrega otro lenguaje de consulta con sus propias peculiaridades y

personalizadas con su ORM definiciones del modelo. sintaxis.

Usar SQL enrollado a mano Ofrece un control preciso sobre el rendimiento con una Los cambios en el esquema de la base de datos deben realizarse

sintaxis de consulta estándar. en sus consultas manuales y sus definiciones de ORM.

Los esquemas altamente normalizados aún pueden tener

limitaciones de rendimiento.

Cree tiendas de lectura Las copias de solo lectura son fáciles de escalar. Las vistas Técnica compleja. Harry siempre sospechará de tus gustos y motivos.
separadas con eventos se pueden construir cuando los datos cambian para que las

consultas sean lo más simples posible.

A menudo, sus operaciones de lectura actuarán sobre los mismos objetos conceptuales que su modelo
de escritura, por lo que usar el ORM, agregar algunos métodos de lectura a sus repositorios y usar
clases de modelo de dominio para sus operaciones de lectura está bien.

En nuestro ejemplo de libro, las operaciones de lectura actúan sobre entidades conceptuales bastante
diferentes a nuestro modelo de dominio. El servicio de asignación piensa en términos de lotes para un
solo SKU, pero los usuarios se preocupan por las asignaciones para un pedido completo, con múltiples
SKU, por lo que usar el ORM termina siendo un poco incómodo. Estaríamos bastante tentados de optar
por la vista de SQL sin formato que mostramos al principio del capítulo.

En esa nota, pasemos a nuestro capítulo final.

190 | Capítulo 12: Segregación de responsabilidad de consulta de comando (CQRS)


Machine Translated by Google

CAPÍTULO 13

Inyección de dependencia (y Bootstrapping)

La inyección de dependencia (DI) se mira con recelo en el mundo de Python. ¡Y nos las hemos arreglado
muy bien sin él hasta ahora en el código de ejemplo de este libro!

En este capítulo, exploraremos algunos de los puntos débiles en nuestro código que nos llevan a considerar
el uso de DI, y presentaremos algunas opciones sobre cómo hacerlo, dejando que usted elija cuál cree que
es más conveniente. pitónico.

También agregaremos un nuevo componente a nuestra arquitectura llamado bootstrap.py; estará a cargo de
la inyección de dependencia, así como de otras cosas de inicialización que a menudo necesitamos.
Explicaremos por qué este tipo de cosa se llama raíz de composición en los lenguajes orientados a objetos y
por qué el script de arranque está bien para nuestros propósitos.

La Figura 13-1 muestra cómo se ve nuestra aplicación sin un programa previo: los puntos de entrada realizan
una gran cantidad de inicializaciones y transferencias de nuestra dependencia principal, la UoW.

Si aún no lo ha hecho, vale la pena leer el Capítulo 3 antes de


continuar con este capítulo, particularmente la discusión de la gestión
de dependencias funcional versus orientada a objetos.

191
Machine Translated by Google

Figura 13-1. Sin bootstrap: los puntos de entrada hacen mucho

El código para este capítulo está en la rama


chapter_13_dependency_injection en GitHub:
clon de git https://github.com/cosmicpython/code.git código de
CD
git checkout chapter_13_dependency_injection # o
para codificar, consulte el capítulo anterior: git checkout
chapter_12_cqrs

La figura 13-2 muestra a nuestro iniciador asumiendo esas responsabilidades.

192 | Capítulo 13: Inyección de dependencia (y Bootstrapping)


Machine Translated by Google

Figura 13-2. Bootstrap se encarga de todo eso en un solo lugar

Dependencias implícitas versus explícitas


Dependiendo de su tipo particular de cerebro, es posible que tenga una ligera sensación de inquietud
en el fondo de su mente en este punto. Saquémoslo a la luz. Le mostramos dos formas de administrar
dependencias y probarlas.

Para nuestra dependencia de la base de datos, hemos creado un marco cuidadoso de dependencias
explícitas y opciones sencillas para anularlas en las pruebas. Nuestras principales funciones de
controlador declaran una dependencia explícita en la UoW:

Dependencias implícitas versus explícitas | 193


Machine Translated by Google

Nuestros controladores tienen una dependencia explícita de la UoW (src/allocation/service_layer/handlers.py)

asignar por defecto (


cmd: comandos.Asignar, uow: unidad_de_trabajo.AbstractUnitOfWork
):

Y eso facilita el intercambio de una UoW falsa en nuestras pruebas de capa de servicio:

Pruebas de capa de servicio contra una UoW falsa: (tests/unit/test_services.py)

uow = FakeUnitOfWork()
mensajebus.handle ([...], uow)

El propio UoW declara una dependencia explícita de la fábrica de sesiones:

La UoW depende de una fábrica de sesiones (src/allocation/service_layer/unit_of_work.py)

clase SqlAlchemyUnitOfWork(AbstractUnitOfWork):

def __init__(self, session_factory=DEFAULT_SESSION_FACTORY):


self.session_factory = session_factory
...

Lo aprovechamos en nuestras pruebas de integración para poder utilizar en ocasiones SQLite en lugar de Postgres:

Pruebas de integración contra una base de datos diferente (tests/integration/test_uow.py)

def test_rolls_back_uncommitted_work_by_default(sqlite_session_factory): uow =


unidad_de_trabajo.SqlAlchemyUnitOfWork(sqlite_session_factory)

Las pruebas de integración intercambian el valor predeterminado de Postgres session_factory por un SQLite
una.

¿No son las dependencias explícitas totalmente extrañas y Java-y?


Si está acostumbrado a la forma en que normalmente suceden las cosas en Python, pensará que todo esto es un
poco extraño. La forma estándar de hacer las cosas es declarar nuestra dependencia implícitamente simplemente
importándola, y luego, si alguna vez necesitamos cambiarla para las pruebas, podemos mono-parchear, como es
Correcto y Verdadero en lenguajes dinámicos:

Envío de correo electrónico como una dependencia normal basada en la importación (src/allocation/service_layer/handlers.py)

desde el correo electrónico de importación de los adaptadores de asignación , redis_eventpublisher


...

def enviar_out_of_stock_notification( event:


events.OutOfStock, uow: unit_of_work.AbstractUnitOfWork,
):
correo
electrónico.send( 'stock@made.com',

194 | Capítulo 13: Inyección de dependencia (y Bootstrapping)


Machine Translated by Google

f'Agotado para {event.sku}',


)

Importación codificada

Llama directamente al remitente de correo electrónico específico

¿Por qué contaminar el código de nuestra aplicación con argumentos innecesarios solo por el bien de nuestras
pruebas? mock.patch hace que el parcheo de monos sea fácil y agradable:

parche de punto simulado, gracias Michael Foord (tests/unit/test_handlers.py)

con mock.patch("allocation.adapters.email.send") como mock_send_mail:


...

El problema es que hemos hecho que parezca fácil porque nuestro ejemplo de juguete no envía un correo
electrónico real (email.send_mail solo imprime), pero en la vida real, terminaría teniendo que llamar a mock.patch
para cada prueba que podría causar una notificación de falta de existencias. Si ha trabajado en bases de código
con muchos simulacros utilizados para evitar efectos secundarios no deseados, sabrá lo molesto que se vuelve
ese modelo simulado.

Y sabrás que los simulacros nos acoplan estrechamente a la implementación. Al elegir monopatch
email.send_mail, estamos obligados a hacer la importación de correo electrónico, y si alguna vez queremos
hacer from email import send_mail, un refactor trivial, tendríamos que cambiar todos nuestros simulacros.

Así que es una compensación. Sí, declarar dependencias explícitas es innecesario, estrictamente hablando, y
usarlas haría que el código de nuestra aplicación fuera un poco más complejo. Pero a cambio, obtendríamos
pruebas que son más fáciles de escribir y administrar.

Además de eso, declarar una dependencia explícita es un ejemplo del principio de inversión de dependencia:
en lugar de tener una dependencia (implícita) de un detalle específico, tenemos una dependencia (explícita) de
una abstracción:

Explícito es mejor que implícito.


—El Zen de Python

La dependencia explícita es más abstracta (src/allocation/service_layer/


handlers.py) def send_out_of_stock_notification( event: events.OutOfStock, send_mail: Callable,

):

send_mail( 'stock@made.com',
f'Agotado para {event.sku}',
)

Pero si cambiamos para declarar todas estas dependencias explícitamente, ¿quién las inyectará y cómo? Hasta
ahora, realmente hemos estado lidiando con solo pasar el UoW:

¿No son las dependencias explícitas totalmente extrañas y Java-y? | 195


Machine Translated by Google

nuestras pruebas usan FakeUnitOfWork, mientras que los puntos de entrada de consumidores de eventos
de Flask y Redis usan la UoW real, y el bus de mensajes los pasa a nuestros controladores de comandos.
Si agregamos clases de correo electrónico reales y falsas, ¿quién las creará y las transmitirá?

Eso es cruft extra (duplicado) para Flask, Redis y nuestras pruebas. Además, poner toda la responsabilidad
de pasar las dependencias al controlador correcto en el bus de mensajes se siente como una violación del
SRP.

En su lugar, buscaremos un patrón llamado Composición raíz (un script de arranque para usted y para mí),1
y haremos un poco de "DI manual" (inyección de dependencia sin un marco). Consulte la Figura 13-3.
2

Figura 13-3. Bootstrapper entre los puntos de entrada y el bus de mensajes

Preparación de Manipuladores: DI Manual con Cierres y Parciales


Una forma de convertir una función con dependencias en una que esté lista para ser llamada más tarde con
esas dependencias ya inyectadas es usar cierres o funciones parciales para componer la función con sus
dependencias:

Ejemplos de DI usando cierres o funciones


parciales # función de asignación existente, con dependencia abstracta uow def allocate(

cmd: comandos.Asignar, uow: unidad_de_trabajo.AbstractUnitOfWork


):
línea = OrderLine(cmd.orderid, cmd.sku, cmd.qty) con
uow:
...

1 Debido a que Python no es un lenguaje OO "puro", los desarrolladores de Python no están necesariamente acostumbrados al concepto de

necesidad de componer un conjunto de objetos en una aplicación de trabajo. Simplemente elegimos nuestro punto de entrada y ejecutamos el
código de arriba a abajo.

2 Mark Seemann llama a esto Pure DI o, a veces, Vanilla DI.

196 | Capítulo 13: Inyección de dependencia (y Bootstrapping)


Machine Translated by Google

# script de arranque prepara UoW real

def bootstrap(..): uow


= unidad_de_trabajo.SqlAlchemyUnitOfWork()

# preparar una versión de allocate fn con dependencia UoW capturada en un cierre


allocate_composed = lambda cmd: allocate(cmd, uow)

# o, de manera equivalente (esto le da un seguimiento de pila más


agradable) def allocate_composed(cmd):
devolver asignar (cmd, uow)

# alternativamente con una


importación parcial functools
allocate_composed = functools.partial(allocate, uow=uow)

# más tarde en el tiempo de ejecución, podemos llamar a la función parcial, y tendrá #


el UoW ya vinculado allocate_composed(cmd)

La diferencia entre los cierres (lambdas o funciones con nombre) y func tools.partial es que los primeros

utilizan el enlace tardío de variables, lo que puede ser una fuente de confusión si alguna de las dependencias
es mutable.

Aquí está el mismo patrón nuevamente para el controlador send_out_of_stock_notification() , que tiene diferentes
dependencias:

Otro ejemplo de cierre y funciones parciales


def send_out_of_stock_notification( event:
events.OutOfStock, send_mail: Callable,
):

send_mail( 'stock@made.com',
...

# preparar una versión de send_out_of_stock_notification con dependencias sosn_composed


= lambda event: send_out_of_stock_notification(event, email.send_mail)

...
# más tarde, en tiempo
de ejecución: sosn_composed(event) # tendrá email.send_mail ya inyectado en

Una alternativa usando clases


Los cierres y las funciones parciales se sentirán familiares para las personas que han hecho un poco de
programación funcional. Aquí hay una alternativa usando clases, que puede atraer a otros.
Sin embargo, requiere reescribir todas nuestras funciones de controlador como clases:

Una Alternativa Usando Clases | 197


Machine Translated by Google

DI usando clases
# reemplazamos el viejo `def allocate(cmd, uow)` con:

clase AllocateHandler:

def __init__(self, uow: unidad_de_trabajo.AbstractUnitOfWork):


self.uow = uow

def __call__(self, cmd: comandos.Asignar):


línea = OrderLine(cmd.orderid, cmd.sku, cmd.qty) con
self.uow:
# resto del método del controlador como antes
...

# script de arranque prepara UoW real uow =


unit_of_work.SqlAlchemyUnitOfWork()

# luego prepara una versión de allocate fn con dependencias ya inyectadas allocate =


AllocateHandler(uow)

...
# más tarde en el tiempo de ejecución, podemos llamar a la instancia del controlador,
y tendrá # el UoW ya inyectado allocate(cmd)

La clase está diseñada para producir una función invocable, por lo que tiene un método de llamada .

Pero usamos el init para declarar las dependencias que requiere. Este tipo de cosas le resultará familiar si
alguna vez ha creado descriptores basados en clases, o un administrador de contexto basado en clases
que toma argumentos.

Utilice el que usted y su equipo se sientan más cómodos.

198 | Capítulo 13: Inyección de dependencia (y Bootstrapping)


Machine Translated by Google

Un guión de arranque
Queremos que nuestro script de arranque haga lo siguiente:

1. Declarar dependencias predeterminadas, pero permitirnos anularlas 2. Hacer

las cosas de "inicio" que necesitamos para iniciar nuestra aplicación 3. Inyectar

todas las dependencias en nuestros controladores 4. Devuélvenos el objeto

principal de nuestra aplicación, el mensaje autobús

Aquí hay un primer corte:

Una función de arranque (src/allocation/bootstrap.py)


def
bootstrap( start_orm: bool = True,
uow: unit_of_work.AbstractUnitOfWork = unit_of_work.SqlAlchemyUnitOfWork(), send_mail: Callable =
email.send, publicar: Callable = redis_eventpublisher.publish,

) -> bus de mensajes.MessageBus:

si start_orm:
orm.start_mappers()

dependencias = {'uow': uow, 'enviar_correo': enviar_correo, 'publicar': publicar}


manejadores_de_eventos_inyectados = { tipo_de_evento: [ inyectar_dependencias (manejador,
dependencias) para manejador en manejadores_de_eventos

] para event_type, event_handlers en handlers.EVENT_HANDLERS.items()

} manejadores_comandos_inyectados = {
tipo_de_comando : inyectar_dependencias(manejador, dependencias) para
tipo_de_comando, manejador en handlers.COMMAND_HANDLERS.items()
}

volver mensajebus.MessageBus(
uow = uow,
event_handlers=injected_event_handlers,
command_handlers=injected_command_handlers,
)

orm.start_mappers() es nuestro ejemplo de trabajo de inicialización que debe realizarse una vez al
comienzo de una aplicación. También vemos cosas como configurar el módulo de registro .

Un guión de Bootstrap | 199


Machine Translated by Google

Podemos usar los valores predeterminados del argumento para definir cuáles son los valores predeterminados normales/
de producción. Es bueno tenerlos en un solo lugar, pero a veces las dependencias tienen algunos efectos secundarios
en el momento de la construcción, en cuyo caso es posible que prefieras las predeterminadas.
ellos a Ninguno en su lugar.

Construimos nuestras versiones inyectadas de las asignaciones de controladores usando una función llamada
inject_dependencies(), que mostraremos a continuación.

Devolvemos un bus de mensajes configurado listo para usar.

Así es como inyectamos dependencias en una función de controlador al inspeccionarla:

DI mediante la inspección de firmas de funciones (src/allocation/bootstrap.py)


def inyectar_dependencias (controlador, dependencias):
params = inspeccionar.signature(handler).parameters
deps = { nombre: dependencia para nombre, dependencia
en dependencias.items() si nombre en params

} devolver mensaje lambda: controlador (mensaje, **deps)

Inspeccionamos los argumentos de nuestro controlador de comandos/eventos.

Los emparejamos por nombre con nuestras dependencias.

Los inyectamos como kwargs para producir un parcial.

DI aún más manual con menos magia Si encuentra

que el código de inspección anterior es un poco más difícil de asimilar, esta versión aún más
simple puede resultarle atractiva.

Harry escribió el código para inject_dependencies() como un primer corte de cómo hacer una
inyección de dependencia “manual”, y cuando lo vio, Bob lo acusó de sobrediseñar y escribir su
propio marco DI.

Honestamente, ni siquiera se le ocurrió a Harry que podrías hacerlo de manera más sencilla,
pero puedes, así:

Creación manual de funciones parciales en línea (src/allocation/bootstrap.py)


manejadores_de_eventos_inyectados
= { eventos. Asignados:
[ lambda e: manejadores.publish_allocated_event(e, publicar),
lambda e: manejadores.agregar_asignación_para_leer_modelo(e, uow),
],
eventos. Desasignado: [

200 | Capítulo 13: Inyección de dependencia (y Bootstrapping)


Machine Translated by Google

lambda e: handlers.remove_allocation_from_read_model(e, uow), lambda


e: handlers.reallocate(e, uow),
],
eventos.OutOfStock:
[ lambda e: handlers.send_out_of_stock_notification(e, send_mail)
]

} manejadores_comandos_inyectados = {
comandos.Asignar: lambda c: handlers.allocate(c, uow),
commands.CreateBatch: \ lambda c: handlers.add_batch(c, uow),
commands.ChangeBatchQuantity: \ lambda c:
handlers.change_batch_quantity(c, uow),

Harry dice que ni siquiera podía imaginar escribir tantas líneas de código y tener que buscar tantos
argumentos de función manualmente. Sin embargo, esta es una solución perfectamente viable, ya
que es solo una línea de código más o menos por controlador que agrega y, por lo tanto, no es una
carga de mantenimiento masiva, incluso si tiene docenas de controladores.

Nuestra aplicación está estructurada de tal manera que siempre queremos realizar la inyección de
dependencia en un solo lugar, el controlador funciona, por lo que esta solución supermanual y la
basada en inspect() de Harry funcionarán bien.

Si se da cuenta de que quiere hacer DI en más cosas y en diferentes momentos, o si alguna vez
entra en cadenas de dependencia (en las que sus dependencias tienen sus propias dependencias,
etc.), puede sacar algo de provecho de un “ verdadero marco DI.

En MADE, hemos usado Inject en algunos lugares, y está bien, aunque hace que Pylint no esté
contento. También puede consultar Punq, tal como lo escribió el propio Bob, o las dependencias del
equipo de DRY Python.

El bus de mensajes recibe controladores en tiempo de ejecución

Nuestro bus de mensajes ya no será estático; necesita tener los manejadores ya


inyectados. Así que pasamos de ser un módulo a una clase configurable:

MessageBus como clase (src/allocation/service_layer/messagebus.py)

clase MessageBus:

def

__init__( self, uow:


unidad_de_trabajo.AbstractUnitOfWork, event_handlers:
Dict[Type[events.Event], List[Callable]], command_handlers: Dict[Type[commands.Command], Callable],
):
self.uow = uow
self.event_handlers = event_handlers

Bus de mensajes recibe controladores en tiempo de ejecución | 201


Machine Translated by Google

self.command_handlers = command_handlers

def handle(self, mensaje: Mensaje):


self.queue = [mensaje] while
self.queue: mensaje = self.queue.pop(0)
if isinstance(mensaje,
eventos.Evento):
self.handle_event(mensaje) elif
isinstance(mensaje, comandos.Comando):
self.handle_command(mensaje) else:

generar excepción (f'{mensaje} no era un evento o comando')

El bus de mensajes se convierte en una clase...

…que recibe sus controladores ya inyectados de dependencia.

La función principal handle() es sustancialmente la misma, con solo unos pocos atributos y métodos
trasladados a sí mismo.

El uso de self.queue como este no es seguro para subprocesos, lo que podría ser un problema si está
utilizando subprocesos, ya que la instancia de bus es global en el contexto de la aplicación Flask tal como lo
hemos escrito. Sólo algo a tener en cuenta.

¿Qué más cambia en el autobús?

La lógica del controlador de eventos y comandos sigue siendo la misma (src/allocation/service_layer/messagebus.py)

def handle_event(self, event: events.Event):


para controlador en self.event_handlers[tipo(evento)]:

intente: logger.debug('manejar el evento %s con el controlador %s', event, handler)


handler(event) self.queue.extend(self.uow.collect_new_events()) excepto
Exception: logger.exception(' Evento de manejo de excepciones %s', evento)
continuar

def handle_command(self, command: commands.Command):


logger.debug('manejar comando %s', command) try:
handler = self.command_handlers[type(command)]
handler(command) self.queue.extend(self.
uow.collect_new_events()) excepto Excepción:
logger.exception(' Comando de manejo de excepciones
%s', comando) aumentar

202 | Capítulo 13: Inyección de dependencia (y Bootstrapping)


Machine Translated by Google

handle_event y handle_command son sustancialmente lo mismo, pero en lugar de


indexar en un dict estático EVENT_HANDLERS o COMMAND_HANDLERS , usan las
versiones en sí mismos.

En lugar de pasar una UoW al controlador, esperamos que los controladores ya tengan todas sus
dependencias, por lo que todo lo que necesitan es un solo argumento, el evento o comando específico.

Usando Bootstrap en nuestros puntos de entrada

En los puntos de entrada de nuestra aplicación, ahora simplemente llamamos a bootstrap.bootstrap() y


obtenemos un bus de mensajes que está listo para funcionar, en lugar de configurar una UoW y el resto:

Flask llama a bootstrap (src/allocation/entrypoints/ask_app.py)

-desde la asignación de vistas de


importación +desde la asignación de importación de arranque, vistas

aplicación = Flask(__name__)
-orm.start_mappers() +bus =
bootstrap.bootstrap()

@app.route("/add_batch", métodos=['POST']) @@ -19,8 +16,7


@@ def add_batch(): cmd =
commands.CreateBatch( request.json['ref'],
solicitud.json['sku'], solicitud.json['cantidad'], eta,

-
) uow = unidad_de_trabajo.SqlAlchemyUnitOfWork()
-
mensajebus.handle(cmd, uow) bus.handle(cmd) return 'OK',
+ 201

Ya no necesitamos llamar a start_orm(); las etapas de inicialización del script de arranque harán eso.

Ya no necesitamos construir explícitamente un tipo particular de UoW; los valores predeterminados del
script de arranque se encargan de ello.

Y nuestro bus de mensajes ahora es una instancia específica en lugar del módulo global.3

3 Sin embargo, sigue siendo global en el ámbito del módulo de la aplicación de matraz, si tiene sentido. Esto puede causar problemas
si alguna vez desea probar su aplicación Flask en proceso utilizando Flask Test Client en lugar de usar Docker como lo hacemos
nosotros. Vale la pena investigar las fábricas de aplicaciones Flask si te involucras en esto.

Uso de Bootstrap en nuestros puntos de entrada | 203


Machine Translated by Google

Inicializar DI en nuestras pruebas

En las pruebas, podemos usar bootstrap.bootstrap() con valores predeterminados anulados para obtener un bus de
mensajes personalizado. Aquí hay un ejemplo en una prueba de integración:

Anulación de los valores predeterminados de arranque (tests/integration/test_views.py)

@pytest.fixture
def sqlite_bus(sqlite_session_factory): bus =
bootstrap.bootstrap( start_orm=True,

uow=unit_of_work.SqlAlchemyUnitOfWork(sqlite_session_factory),
send_mail=lambda *argumentos: Ninguno, publicar=lambda *argumentos:
Ninguno,

) producir
bus clear_mappers()

def test_allocations_view(sqlite_bus):
sqlite_bus.handle(comandos.CreateBatch('sku1batch', 'sku1', 50, Ninguno))
sqlite_bus.handle(comandos.CreateBatch('sku2batch', 'sku2', 50, date.today()))
...
aseverar vistas.allocaciones('order1', sqlite_bus.uow) == [ {'sku':
'sku1', 'batchref': 'sku1batch'}, {'sku': 'sku2', 'batchref':
'sku2batch' },
]

Todavía queremos comenzar el ORM...

…porque vamos a utilizar una UoW real, aunque con una base de datos en memoria.

Pero no necesitamos enviar un correo electrónico o publicar, así que hacemos esos noops.

En nuestras pruebas unitarias, por el contrario, podemos reutilizar nuestra FakeUnitOfWork:

Bootstrap en prueba unitaria (tests/unit/test_handlers.py)

def bootstrap_test_app():
return

bootstrap.bootstrap( start_orm=False,
uow=FakeUnitOfWork(), send_mail=lambda *argumentos: Ninguno, publicar=lambda *argumentos: Ninguno,
)

No es necesario iniciar el ORM...

…porque el UoW falso no usa uno.

Queremos falsificar nuestro correo electrónico y adaptadores Redis también.

204 | Capítulo 13: Inyección de dependencia (y Bootstrapping)


Machine Translated by Google

Eso elimina un poco la duplicación, y hemos movido un montón de configuraciones y valores predeterminados
sensibles a un solo lugar.

Ejercicio para el Lector 1


Cambie todos los controladores para que sean clases según el ejemplo de uso de clases
DI y modifique el código DI del programa previo según corresponda. Esto le permitirá
saber si prefiere el enfoque funcional o el enfoque basado en clases cuando se trata de
sus propios proyectos.

Construyendo un adaptador "correctamente": un ejemplo resuelto


Para tener una idea real de cómo funciona todo, analicemos un ejemplo de cómo puede construir
"adecuadamente" un adaptador y hacer una inyección de dependencia para él.

Por el momento, tenemos dos tipos de dependencias:

Dos tipos de dependencias (src/allocation/service_layer/


messagebus.py) uow: unit_of_work.AbstractUnitOfWork, send_mail: Callable, publishing :
Callable,

El UoW tiene una clase base abstracta. Esta es la opción de peso pesado para declarar y administrar su
dependencia externa. Usaríamos esto para el caso cuando la dependencia es relativamente compleja.

Nuestro remitente de correo electrónico y editor de publicación/suscripción se definen como funciones.


Esto funciona bien para dependencias simples.

Estas son algunas de las cosas que nos inyectamos en el trabajo:

• Un cliente de sistema de archivos

S3 • Un cliente de almacenamiento de

clave/valor • Un objeto de sesión de solicitudes

La mayoría de estos tendrán API más complejas que no puede capturar como una sola función: lectura y
escritura, GET y POST, etc.

Aunque es simple, usemos send_mail como ejemplo para hablar sobre cómo podría definir una dependencia
más compleja.

Construcción de un adaptador "adecuadamente": un ejemplo resuelto | 205


Machine Translated by Google

Definir las implementaciones abstractas y concretas Imaginaremos una

API de notificaciones más genérica. Podría ser un correo electrónico, podría ser un SMS, podrían ser
publicaciones de Slack algún día.

Un ABC y una implementación concreta (src/allocation/adapters/notifications.py)


clase AbstractNotifications(abc.ABC):

@abc.abstractmethod
def send(self, destino, mensaje):
aumentar NotImplementedError

...

clase Notificaciones de correo electrónico (Notificaciones abstractas):

def __init__(self, smtp_host=DEFAULT_HOST, port=DEFAULT_PORT):


self.server = smtplib.SMTP(smtp_host, port=port) self.server.noop()

def send(self, destino, mensaje): msg = f'Asunto:


notificación de servicio de asignación\n{mensaje}'
self.server.sendmail( from_addr='allocations@example.com',
to_addrs=[destination],

mensaje=mensaje

Cambiamos la dependencia en el script de arranque:

Notificaciones en el bus de mensajes (src/allocation/bootstrap.py)


def
bootstrap( start_orm: bool =
True, uow: unit_of_work.AbstractUnitOfWork = unit_of_work.SqlAlchemyUnitOfWork(), send_mail:
- Callable = email.send, notificaciones: AbstractNotifications = EmailNotifications(), publicar: Callable
+ = redis_eventpublisher.publish,

) -> bus de mensajes.MessageBus:

Haz una versión falsa para tus pruebas

Trabajamos y definimos una versión falsa para pruebas unitarias:

Notificaciones falsas (tests/unit/test_handlers.py)


clase FakeNotifications(notificaciones.AbstractNotifications):

def __init__(self):
self.sent = defaultdict(list) # type: Dict[str, List[str]]

def enviar(auto, destino, mensaje):

206 | Capítulo 13: Inyección de dependencia (y Bootstrapping)


Machine Translated by Google

self.sent[destino].append(mensaje)
...

Y lo usamos en nuestras pruebas:

Las pruebas cambian ligeramente (tests/unit/test_handlers.py)

def test_sends_email_on_out_of_stock_error(self):
fake_notifs = FakeNotifications() bus =
bootstrap.bootstrap( start_orm=False,
uow=FakeUnitOfWork(), Notifications=fake_notifs,
publishing =lambda *args: Ninguno,

) bus.handle(commands.CreateBatch("b1", "POPULAR-CORTINAS", 9, Ninguno))


bus.handle(commands.Allocate("o1", "POPULAR-CORTINAS", 10)) assert
fake_notifs.sent[ 'stock@made.com'] == [ f"Agotado para CORTINAS-POPULARES",

Averigüe cómo probar la integración de la cosa real Ahora probamos la

cosa real, por lo general con una prueba de integración o de extremo a extremo. Hemos utilizado MailHog
como un servidor de correo electrónico real para nuestro entorno de desarrollo de Docker:

Configuración de Docker-compose con servidor de correo electrónico falso real (docker-compose.yml)

versión: "3"

servicios:

redis_pubsub:
construir:
contexto: .
archivo acoplable: archivo acoplable
imagen: asignación-imagen
...

api:
imagen: imagen de asignación
...

postgres:
imagen: postgres:9.6
...

redis:
imagen: redis: alpino
...

correo:

Construcción de un adaptador "adecuadamente": un ejemplo resuelto | 207


Machine Translated by Google

imagen: puertos mailhog/


mailhog :
- "11025:1025"
- "18025:8025"

En nuestras pruebas de integración, usamos la clase real EmailNotifications , hablando con el


Servidor MailHog en el clúster de Docker:

Prueba de integración para correo electrónico (tests/integration/test_email.py)

@pytest.fixture def
bus(sqlite_session_factory): bus =
bootstrap.bootstrap( start_orm=True,

uow=unidad_de_trabajo.SqlAlchemyUnitOfWork(sqlite_session_factory),
notificaciones=notificaciones.Notificaciones por correo electrónico(),
publicación=lambda *args: Ninguno,

) producir
bus clear_mappers()

def get_email_from_mailhog(sku): host,


port = map(config.get_email_host_and_port().get, ['host', 'http_port']) all_emails = request.get
(f'http://{host}:{port}/ api/v2/messages').json() devuelve el siguiente (m por m en all_emails['items']
si sku en str(m))

def test_out_of_stock_email(bus): sku =


random_sku()
bus.handle(commands.CreateBatch('batch1', sku, 9, None))
bus.handle(commands.Allocate('order1', sku, 10)) email =
get_email_from_mailhog (sku) afirmar correo electrónico['Raw']['De']
== 'asignaciones@ejemplo.com' afirmar correo electrónico['Raw']
['Para'] == ['stock@made.com'] afirmar f' Agotado para {sku}' en el
correo electrónico['Raw']['Data']

Usamos nuestro programa previo para crear un bus de mensajes que se comunique con la clase de notificaciones
reales.

Descubrimos cómo obtener correos electrónicos de nuestro servidor de correo electrónico "real".

Usamos el autobús para hacer nuestra configuración de prueba.

Contra todo pronóstico, esto realmente funcionó, ¡más o menos a la primera!

Y eso es todo.

208 | Capítulo 13: Inyección de dependencia (y Bootstrapping)


Machine Translated by Google

Ejercicio para el Lector 2


Podría hacer dos cosas para practicar con respecto a los adaptadores:

1. Intente cambiar nuestras notificaciones de correo electrónico a notificaciones de SMS usando Twilio,
por ejemplo, o notificaciones de Slack. ¿Puede encontrar un buen equivalente a MailHog para las
pruebas de integración?

2. De manera similar a lo que hicimos al pasar de send_mail a una clase de Notificaciones , intente
refactorizar nuestro redis_eventpublisher que actualmente es solo un Callable a algún tipo de
adaptador/clase base/protocolo más formal.

Resumen

Una vez que tenga más de un adaptador, comenzará a sentir mucho dolor al pasar las
dependencias manualmente, a menos que haga algún tipo de inyección de dependencia.

Configurar la inyección de dependencia es solo una de las muchas actividades típicas de configuración/
inicialización que debe realizar solo una vez al iniciar su aplicación. Reunir todo esto en un script de arranque
suele ser una buena idea.

El script de arranque también es bueno como un lugar para proporcionar una configuración predeterminada
razonable para sus adaptadores, y como un lugar único para anular esos adaptadores con falsificaciones para su
pruebas

Un marco de inyección de dependencia puede ser útil si necesita hacer DI en varios niveles, si tiene
dependencias encadenadas de componentes que necesitan DI, por ejemplo.

Este capítulo también presentó un ejemplo práctico de cómo cambiar una dependencia implícita/simple en un
adaptador "adecuado", factorizando un ABC, definiendo sus implementaciones reales y falsas, y pensando en
las pruebas de integración.

Resumen | 209
Machine Translated by Google

Resumen de DI y Bootstrap

En resumen:

1. Defina su API usando un ABC.

2. Implemente lo real.

3. Cree una falsificación y utilícela para pruebas de unidad/capa de servicio/controlador.

4. Encuentre una versión menos falsa que pueda poner en su entorno de Docker.

5. Pruebe la cosa "real" menos falsa.


6. ¡Beneficio!

Estos fueron los últimos patrones que queríamos cubrir, lo que nos lleva al final de la Parte II.
En el epílogo, trataremos de darle algunos consejos para aplicar estas técnicas en el Mundo
RealTM.

210 | Capítulo 13: Inyección de dependencia (y Bootstrapping)


Machine Translated by Google

Epílogo

¿Ahora que?
¡Uf! Hemos cubierto mucho terreno en este libro, y para la mayoría de nuestra audiencia todas estas
ideas son nuevas. Con eso en mente, no podemos esperar convertirlos en expertos en estas técnicas.
Todo lo que realmente podemos hacer es mostrarle las ideas generales y el código suficiente para que
pueda seguir adelante y escribir algo desde cero.

El código que mostramos en este libro no es un código de producción endurecido por la batalla: es un
conjunto de bloques de Lego con los que puedes jugar para hacer tu primera casa, nave espacial y
rascacielos.

Eso nos deja con dos grandes tareas. Queremos hablar sobre cómo comenzar a aplicar estas ideas de
verdad en un sistema existente, y debemos advertirle sobre algunas de las cosas que tuvimos que
omitir. Le hemos dado todo un nuevo arsenal de formas de dispararse en el pie, por lo que deberíamos
analizar algunos aspectos básicos de la seguridad con las armas de fuego.

¿Cómo llego allí desde aquí?


Lo más probable es que muchos de ustedes estén pensando algo como

esto: “Está bien, Bob y Harry, todo está muy bien, y si alguna vez me contratan para trabajar en un
nuevo servicio totalmente nuevo, sé qué hacer. Pero mientras tanto, estoy aquí con mi gran bola de
barro de Django, y no veo ninguna forma de llegar a su modelo agradable, limpio, perfecto, inmaculado
y simplista. No de aquí.

Te oimos. Una vez que ya has construido una gran bola de barro, es difícil saber cómo empezar a
mejorar las cosas. Realmente, tenemos que abordar las cosas paso a paso.

Lo primero es lo primero: ¿qué problema estás tratando de resolver? ¿Es demasiado difícil cambiar el
software? ¿Es el rendimiento inaceptable? ¿Tienes bichos extraños e inexplicables?

Tener un objetivo claro en mente lo ayudará a priorizar el trabajo que debe realizarse y, lo que es más
importante, comunicar las razones para hacerlo al resto del equipo.

211
Machine Translated by Google

Las empresas tienden a tener enfoques pragmáticos para la deuda técnica y la refactorización, siempre
que los ingenieros puedan presentar un argumento razonado para arreglar las cosas.

Realizar cambios complejos en un sistema suele ser más fácil de vender si lo


vincula al trabajo de funciones. ¿Quizás está lanzando un nuevo producto o
abriendo su servicio a nuevos mercados? Este es el momento adecuado para
gastar recursos de ingeniería en arreglar los cimientos. Con un proyecto de
seis meses para entregar, es más fácil argumentar a favor de tres semanas de
trabajo de limpieza. Bob se refiere a esto como impuesto a la arquitectura.

Separando responsabilidades enredadas


Al comienzo del libro, dijimos que la principal característica de una gran bola de lodo es la homogeneidad:
todas las partes del sistema se ven iguales, porque no hemos tenido claras las responsabilidades de cada
componente. Para arreglar eso, necesitaremos comenzar a separar las responsabilidades e introducir
límites claros. Una de las primeras cosas que podemos hacer es comenzar a construir una capa de servicio
(Figura E-1).

Figura E-1. Dominio de un sistema de colaboración

Este fue el sistema en el que Bob aprendió por primera vez cómo romper una bola de barro, y fue genial.
Había lógica en todas partes: en las páginas web, en los objetos de administrador, en los ayudantes, en
las clases de servicio gruesas que habíamos escrito para abstraer a los administradores y ayudantes, y en
los objetos de comando peludos que habíamos escrito para separar los servicios.

212 | Epílogo
Machine Translated by Google

Si está trabajando en un sistema que ha llegado a este punto, la situación puede parecer desesperada, pero
nunca es demasiado tarde para empezar a desmalezar un jardín demasiado grande. Finalmente, contratamos
a un arquitecto que sabía lo que estaba haciendo y nos ayudó a recuperar el control.

Comience por resolver los casos de uso de su sistema. Si tiene una interfaz de usuario, ¿qué acciones
realiza? Si tiene un componente de procesamiento de back-end, tal vez cada trabajo cron o trabajo de Celery
sea un caso de uso único. Cada uno de sus casos de uso debe tener un nombre imperativo: aplicar cargos
de facturación, limpiar cuentas abandonadas o generar orden de compra, por ejemplo.

En nuestro caso, la mayoría de nuestros casos de uso formaban parte de las clases de administrador y tenían
nombres como Crear espacio de trabajo o Eliminar versión del documento. Cada caso de uso se invocó
desde una interfaz web.

Nuestro objetivo es crear una sola función o clase para cada una de estas operaciones admitidas que se
ocupe de orquestar el trabajo a realizar. Cada caso de uso debe hacer lo siguiente:

• Iniciar su propia transacción de base de datos si es necesario

• Obtener los datos necesarios •

Verificar las condiciones previas (consulte el patrón de Asegurar en el Apéndice E) •

Actualizar el modelo de dominio • Conservar los cambios

Cada caso de uso debe tener éxito o fallar como una unidad atómica. Es posible que deba llamar a un caso
de uso desde otro. Está bien; simplemente anótelo e intente evitar transacciones de bases de datos de
ejecución prolongada.

Uno de los mayores problemas que tuvimos fue que los métodos de
administrador llamaban a otros métodos de administrador, y el acceso a los
datos podía ocurrir desde los propios objetos del modelo. Era difícil entender
lo que hacía cada operación sin emprender una búsqueda del tesoro a través
del código base. Reunir toda la lógica en un solo método y usar una UoW
para controlar nuestras transacciones hizo que el sistema fuera más fácil de razonar.

Epílogo | 213
Machine Translated by Google

Estudio de caso: superposición de un sistema demasiado

grande Hace muchos años, Bob trabajó para una empresa de software que había subcontratado la
primera versión de su aplicación, una plataforma de colaboración en línea para compartir archivos y
trabajar con ellos.

Cuando la empresa trajo el desarrollo interno, pasó por varias generaciones de desarrolladores, y cada ola
de nuevos desarrolladores agregó más complejidad a la estructura del código.

En esencia, el sistema era una aplicación ASP.NET Web Forms, construida con un ORM de NHibernate. Los
usuarios cargarían documentos en espacios de trabajo, donde podrían invitar a otros miembros del espacio
de trabajo a revisar, comentar o modificar su trabajo.

La mayor parte de la complejidad de la aplicación estaba en el modelo de permisos porque cada documento
estaba contenido en una carpeta, y las carpetas permitían permisos de lectura, escritura y edición, muy
parecido a un sistema de archivos de Linux.

Además, cada espacio de trabajo pertenecía a una cuenta y la cuenta tenía cuotas adjuntas a través de un
paquete de facturación.

Como resultado, cada operación de lectura o escritura en un documento tenía que cargar una enorme
cantidad de objetos de la base de datos para probar los permisos y las cuotas.
La creación de un nuevo espacio de trabajo involucró cientos de consultas a la base de datos a medida que configuramos
la estructura de permisos, invitamos a los usuarios y configuramos el contenido de muestra.

Parte del código para las operaciones estaba en controladores web que se ejecutaban cuando un usuario
hacía clic en un botón o enviaba un formulario; parte estaba en objetos de administrador que contenían
código para orquestar el trabajo; y parte de ella estaba en el modelo de dominio. Los objetos del modelo
hacían llamadas a la base de datos o copiaban archivos en el disco, y la cobertura de la prueba era pésima.

Para solucionar el problema, primero introdujimos una capa de servicio para que todo el código para crear
un documento o espacio de trabajo estuviera en un solo lugar y pudiera entenderse. Esto implicó extraer el
código de acceso a datos del modelo de dominio y colocarlo en controladores de comandos. Del mismo
modo, extrajimos el código de orquestación de los administradores y los controladores web y lo insertamos
en los controladores.

Los controladores de comandos resultantes eran largos y desordenados, pero comenzamos a introducir
orden en el caos.

Está bien si tiene duplicación en las funciones de casos de uso. No estamos


tratando de escribir un código perfecto; solo estamos tratando de extraer
algunas capas significativas. Es mejor duplicar algún código en algunos lugares
que tener funciones de casos de uso llamándose entre sí en una larga cadena.

214 | Epílogo
Machine Translated by Google

Esta es una buena oportunidad para extraer cualquier código de orquestación o de acceso a datos del modelo
de dominio y llevarlo a los casos de uso. También deberíamos tratar de sacar las preocupaciones de E/S (p. ej.,
enviar correos electrónicos, escribir archivos) fuera del modelo de dominio y llevarlos a las funciones de casos
de uso. Aplicamos las técnicas del Capítulo 3 sobre abstracciones para mantener nuestra unidad de controladores
comprobable incluso cuando están realizando operaciones de E/S.

Estas funciones de casos de uso serán principalmente sobre registro, acceso a datos y manejo de errores. Una
vez que haya realizado este paso, tendrá una idea de lo que realmente hace su programa y una forma de
asegurarse de que cada operación tenga un comienzo y un final claramente definidos.
Habremos dado un paso hacia la construcción de un modelo de dominio puro.

Lea Trabajar de manera efectiva con el código heredado de Michael C. Feathers (Prentice Hall) para obtener
orientación sobre cómo poner a prueba el código heredado y comenzar a separar responsabilidades.

Identificación de agregados y contextos acotados


Parte del problema con el código base en nuestro caso de estudio era que el gráfico de objetos estaba altamente
conectado. Cada cuenta tenía muchos espacios de trabajo y cada espacio de trabajo tenía muchos miembros,
todos los cuales tenían sus propias cuentas. Cada espacio de trabajo contenía muchos documentos, que tenían
muchas versiones.

No puedes expresar todo el horror de la cosa en un diagrama de clases. Por un lado, no había realmente una
sola cuenta relacionada con un usuario. En cambio, había una regla extraña que requería que enumeraras todas
las cuentas asociadas al usuario a través de los espacios de trabajo y tomaras la que tuviera la fecha de creación
más antigua.

Cada objeto en el sistema formaba parte de una jerarquía de herencia que incluía el objeto seguro y la versión.
Esta jerarquía de herencia se reflejó directamente en el esquema de la base de datos, de modo que cada
consulta tenía que unirse a 10 tablas diferentes y buscar en una columna discriminadora solo para indicar con
qué tipo de objetos estaba trabajando.

El código base facilitó el "punto" de su camino a través de estos objetos de esta manera:

usuario.cuenta.espacios de trabajo[0].documentos.versiones[1].propietario.cuenta.configuración[0];

Construir un sistema de esta manera con Django ORM o SQLAlchemy es fácil pero debe evitarse. Aunque es
conveniente, hace que sea muy difícil razonar sobre el rendimiento porque cada propiedad puede desencadenar
una búsqueda en la base de datos.

Epílogo | 215
Machine Translated by Google

Los agregados son un límite de consistencia. En general, cada caso de


uso debe actualizar un solo agregado a la vez. Un controlador obtiene
un agregado de un repositorio, modifica su estado y genera cualquier
evento que ocurra como resultado. Si necesita datos de otra parte del
sistema, está totalmente bien usar un modelo de lectura, pero evite
actualizar múltiples agregados en una sola transacción. Cuando
elegimos separar el código en diferentes agregados, estamos eligiendo
explícitamente hacerlos eventualmente consistentes entre sí.

Un montón de operaciones requerían que recorriéramos los objetos de esta manera, por ejemplo:

# Bloquear los espacios de trabajo de un usuario por falta de pago

def lock_account(usuario):
para el espacio de trabajo en user.account.workspaces:
espacio de trabajo.archive()

O incluso recurrir a colecciones de carpetas y documentos:

def lock_documents_in_folder(carpeta):

para doc en carpeta.documentos:


doc.archivo()

para niño en carpeta.niños:


lock_documents_in_folder(hijo)

Estas operaciones acabaron con el rendimiento, pero corregirlas significaba renunciar a nuestro gráfico de objeto
único. En cambio, comenzamos a identificar agregados ya romper los vínculos directos entre objetos.

Hablamos sobre el infame problema SELECT N+1 en el Capítulo 12, y


cómo podemos elegir usar diferentes técnicas al leer datos para
consultas en lugar de leer datos para comandos.

Principalmente hicimos esto reemplazando las referencias directas con identificadores.

216 | Epílogo
Machine Translated by Google

Antes de los agregados:

Después de modelar con agregados:

Epílogo | 217
Machine Translated by Google

Los enlaces bidireccionales suelen ser una señal de que sus agregados no son correctos.
En nuestro código original, un Documento conocía la Carpeta que lo contenía , y la
Carpeta tenía una colección de Documentos. Esto facilita atravesar el gráfico de objetos,
pero nos impide pensar correctamente en los límites de consistencia que necesitamos.
Separamos los agregados usando referencias en su lugar. En el nuevo modelo, un
Documento tenía referencia a su carpeta_principal pero no tenía forma de acceder
directamente a la carpeta.
Carpeta.

Si necesitábamos leer datos, evitamos escribir bucles y transformaciones complejos e intentamos reemplazarlos
con SQL directo. Por ejemplo, una de nuestras pantallas era una vista de árbol de carpetas y documentos.

Esta pantalla era increíblemente pesada en la base de datos, porque se basaba en bucles for anidados que
activaban un ORM de carga diferida.

Usamos esta misma técnica en el Capítulo 11, donde reemplazamos un bucle anidado
sobre objetos ORM con una consulta SQL simple. Es el primer paso en un enfoque CQRS.

Después de mucho rascarnos la cabeza, reemplazamos el código ORM con un procedimiento almacenado grande
y feo. El código se veía horrible, pero era mucho más rápido y ayudó a romper los vínculos entre la carpeta y el
documento.

Cuando necesitábamos escribir datos, cambiamos un solo agregado a la vez e introdujimos un


bus de mensajes para manejar eventos. Por ejemplo, en el nuevo modelo, cuando bloqueamos
una cuenta, primero podíamos consultar todos los espacios de trabajo afectados a través de
SELECT id FROM workspace WHERE account_id = ?.

Entonces podríamos lanzar un nuevo comando para cada espacio de trabajo:

para workspace_id en espacios de


trabajo: bus.handle(LockWorkspace(workspace_id))

Un enfoque basado en eventos para ir a los microservicios a través de


Patrón de estrangulador

El patrón Strangler Fig implica crear un nuevo sistema alrededor de los bordes de un sistema antiguo, mientras se
mantiene en funcionamiento. Las partes de la funcionalidad anterior se interceptan y reemplazan gradualmente,
hasta que el sistema anterior no hace nada y se puede apagar.

Cuando construimos el servicio de disponibilidad, usamos una técnica llamada interceptación de eventos para
mover la funcionalidad de un lugar a otro. Este es un proceso de tres pasos:

218 | Epílogo
Machine Translated by Google

1. Genere eventos para representar los cambios que ocurren en un sistema que desea reemplazar.

2. Cree un segundo sistema que consuma esos eventos y los use para construir su propio
modelo de dominio

3. Reemplace el sistema anterior por el nuevo.

Usamos la intercepción de eventos para pasar de la Figura E-2...

Figura E-2. Antes: fuerte acoplamiento bidireccional basado en XML-RPC

a la Figura E-3.

Figura E-3. Después: acoplamiento flexible con eventos asincrónicos (puede encontrar una
versión de alta resolución de este diagrama en cosmicpython.com)

Prácticamente, este fue un proyecto de varios meses. Nuestro primer paso fue escribir un modelo de
dominio que pudiera representar lotes, envíos y productos. Usamos TDD para construir un sistema de
juguetes que pudiera responder a una sola pregunta: "Si quiero N unidades de HAZARDOUS_RUG, ¿cuánto
tiempo tardarán en ser entregadas?"

Al implementar un sistema basado en eventos, comience con un "esqueleto


andante". La implementación de un sistema que solo registra su entrada nos
obliga a abordar todas las cuestiones de infraestructura y comenzar a trabajar
en producción.

Epílogo | 219
Machine Translated by Google

Estudio de caso: creación de un microservicio para reemplazar un


dominio MADE.com comenzó con dos monolitos: uno para la aplicación de comercio
electrónico frontend y otro para el sistema de cumplimiento backend.

Los dos sistemas se comunicaban a través de XML-RPC. Periódicamente, el sistema backend se


activaba y consultaba el sistema frontend para conocer nuevos pedidos. Cuando había importado todos
los pedidos nuevos, enviaba comandos RPC para actualizar los niveles de existencias.

Con el tiempo, este proceso de sincronización se hizo cada vez más lento hasta que, una Navidad,
tomó más de 24 horas importar los pedidos de un solo día. Bob fue contratado para dividir el sistema en
un conjunto de servicios basados en eventos.

Primero, identificamos que la parte más lenta del proceso era calcular y sincronizar el stock disponible.
Lo que necesitábamos era un sistema que pudiera escuchar eventos externos y mantener un total
actualizado de la cantidad de stock disponible.

Expusimos esa información a través de una API, de modo que el navegador del usuario pudiera
preguntar cuánto stock había disponible para cada producto y cuánto tardaría en enviarse a su dirección.

Cada vez que un producto se agotaba por completo, generamos un nuevo evento que la plataforma de
comercio electrónico podría usar para retirar un producto de la venta. Debido a que no sabíamos cuánta
carga necesitaríamos manejar, escribimos el sistema con un patrón CQRS.
Cada vez que cambiaba la cantidad de existencias, actualizábamos una base de datos de Redis con un
modelo de vista en caché. Nuestra API de Flask consultó estos modelos de vista en lugar de ejecutar el
modelo de dominio complejo.

Como resultado, podríamos responder a la pregunta "¿Cuánto stock hay disponible?" en 2 a 3


milisegundos, y ahora la API maneja con frecuencia cientos de solicitudes por segundo durante períodos
prolongados.

Si todo esto te suena un poco familiar, bueno, ¡ahora sabes de dónde vino nuestra aplicación de ejemplo!

Una vez que tuvimos un modelo de dominio de trabajo, pasamos a construir algunas piezas de
infraestructura. Nuestro primer despliegue de producción fue un sistema diminuto que podía recibir un
evento creado por lotes y registrar su representación JSON. Este es el "Hola mundo" de la arquitectura
impulsada por eventos. Nos obligó a implementar un bus de mensajes, conectar un productor y un
consumidor, crear una canalización de implementación y escribir un controlador de mensajes simple.

Dada una tubería de implementación, la infraestructura que necesitábamos y un modelo de dominio


básico, estábamos fuera. Un par de meses más tarde, estábamos en producción y prestando servicios reales
clientes.

220 | Epílogo
Machine Translated by Google

Convencer a sus partes interesadas para que prueben algo nuevo


Si está pensando en crear un nuevo sistema a partir de una gran bola de barro, probablemente esté
sufriendo problemas de confiabilidad, rendimiento, mantenibilidad o los tres simultáneamente. ¡Problemas
profundos e intratables requieren medidas drásticas!

Recomendamos el modelado de dominio como primer paso. En muchos sistemas demasiado grandes,
los ingenieros, los propietarios de productos y los clientes ya no hablan el mismo idioma. Las partes
interesadas del negocio hablan sobre el sistema en términos abstractos y centrados en el proceso,
mientras que los desarrolladores se ven obligados a hablar sobre el sistema tal como existe físicamente
en su estado salvaje y caótico.

Estudio de caso: el modelo de

usuario Mencionamos anteriormente que la cuenta y el modelo de usuario en nuestro primer sistema
estaban unidos por una "regla extraña". Este es un ejemplo perfecto de cómo las partes interesadas
en ingeniería y negocios pueden distanciarse.

En este sistema, las cuentas tenían como padres los espacios de trabajo y los usuarios eran miembros de los espacios de trabajo.

Los espacios de trabajo eran la unidad fundamental para la aplicación de permisos y cuotas. Si un
usuario se unió a un espacio de trabajo y aún no tenía una cuenta, lo asociaríamos con la cuenta que
poseía ese espacio de trabajo.

Esto fue desordenado y ad hoc, pero funcionó bien hasta el día en que el propietario del producto
solicitó una nueva función:

Cuando un usuario se une a una empresa, queremos agregarlo a algunos espacios de trabajo predeterminados para
la empresa, como el espacio de trabajo de recursos humanos o el espacio de trabajo de anuncios de la empresa.

Tuvimos que explicarles que no existía tal cosa como una empresa, y que no tenía ningún sentido que
un usuario se uniera a una cuenta. Además, una “empresa” puede tener muchas cuentas propiedad de
diferentes usuarios, y un nuevo usuario puede ser invitado a cualquiera de ellas.

Años de agregar trucos y soluciones alternativas a un modelo roto nos alcanzaron y tuvimos que
reescribir toda la función de administración de usuarios como un sistema completamente nuevo.

Averiguar cómo modelar su dominio es una tarea compleja que es el tema de muchos libros decentes por
derecho propio. Nos gusta usar técnicas interactivas como la tormenta de eventos y el modelado CRC,
porque los humanos son buenos para colaborar a través del juego.
El modelado de eventos es otra técnica que reúne a ingenieros y propietarios de productos para
comprender un sistema en términos de comandos, consultas y eventos.

Epílogo | 221
Machine Translated by Google

Visite www.eventmodeling.org y www.eventstorming.org para obtener


excelentes guías para el modelado visual de sistemas con eventos.

El objetivo es poder hablar sobre el sistema usando el mismo lenguaje ubicuo, para que puedan ponerse
de acuerdo sobre dónde radica la complejidad.

Hemos encontrado mucho valor en el tratamiento de problemas de dominio como kata TDD. Por ejemplo,
el primer código que escribimos para el servicio de disponibilidad fue el modelo de línea de pedido y lote.
Puede tratar esto como un taller a la hora del almuerzo o como un pico al comienzo de un proyecto.
Una vez que pueda demostrar el valor del modelado, es más fácil presentar el argumento para estructurar
el proyecto y optimizarlo para el modelado.

Estudio de caso: David Seddon sobre cómo dar pequeños


pasos Hola, soy David, uno de los revisores técnicos de este libro. He trabajado en varios
monolitos complejos de Django, por lo que he conocido el dolor que Bob y Harry han hecho
todo tipo de grandes promesas sobre el alivio.

Cuando estuve expuesto por primera vez a los patrones descritos aquí, estaba bastante emocionado. Ya había
utilizado con éxito algunas de las técnicas en proyectos más pequeños, pero aquí había un modelo para sistemas
mucho más grandes respaldados por bases de datos como en el que trabajo en mi trabajo diario. Así que comencé
a tratar de descubrir cómo podría implementar ese modelo en mi organización actual.

Elegí abordar un área problemática del código base que siempre me había molestado. Comencé implementándolo
como un caso de uso. Pero me encontré con preguntas inesperadas.
Había cosas que no había considerado mientras leía que ahora hacían difícil ver qué hacer. ¿Fue un problema si
mi caso de uso interactuaba con dos agregados diferentes?
¿Podría un caso de uso llamar a otro? ¿Y cómo iba a existir dentro de un sistema que seguía diferentes principios
arquitectónicos sin resultar en un desastre horrible?

¿Qué pasó con ese plan tan prometedor? ¿Realmente entendí las ideas lo suficientemente bien como para ponerlas
en práctica? ¿Era incluso adecuado para mi aplicación? Incluso si lo fuera, ¿alguno de mis colegas estaría de
acuerdo con un cambio tan importante? ¿Fueron estas buenas ideas para fantasear mientras seguía con la vida
real?

Me tomó un tiempo darme cuenta de que podía empezar poco a poco. No necesitaba ser purista ni acertar a la
primera: podía experimentar, encontrar lo que me funcionaba.

Y eso es lo que he hecho. He podido aplicar algunas de las ideas en algunos lugares.
He creado nuevas funciones cuya lógica empresarial se puede probar sin la base de datos o simulacros.
Y como equipo, hemos introducido una capa de servicio para ayudar a definir los trabajos que realiza el sistema.

222 | Epílogo
Machine Translated by Google

Si comienza a tratar de aplicar estos patrones en su trabajo, puede experimentar sentimientos similares
al principio. Cuando la buena teoría de un libro se encuentra con la realidad de su base de código,
puede ser desmoralizador.

Mi consejo es que se concentre en un problema específico y se pregunte cómo puede poner en práctica
las ideas relevantes, quizás de una manera inicialmente limitada e imperfecta. Es posible que
descubras, como lo hice yo, que el primer problema que elijas puede ser demasiado difícil; si es así,
pase a otra cosa. No intentes hervir el océano y no tengas demasiado miedo de cometer errores. Será
una experiencia de aprendizaje y puede estar seguro de que se está moviendo aproximadamente en
una dirección que otros han encontrado útil.

Entonces, si usted también siente dolor, pruebe estas ideas. No sienta que necesita permiso para
rediseñar todo. Solo busque un lugar pequeño para comenzar. Y sobre todo, hacerlo para solucionar
un problema concreto. Si tiene éxito en resolverlo, sabrá que hizo algo bien, y los demás también lo
sabrán.

Preguntas que nuestros revisores tecnológicos hicieron que nosotros no pudimos


trabajar en prosa

Aquí hay algunas preguntas que escuchamos durante la redacción que no pudimos encontrar un buen lugar para abordar
en otra parte del libro:

¿Tengo que hacer todo esto a la vez? ¿Puedo hacer un poco a la vez?
No, absolutamente puedes adoptar estas técnicas poco a poco. Si tiene un sistema existente, le recomendamos
crear una capa de servicio para tratar de mantener la orquestación en un solo lugar. Una vez que tenga eso, es
mucho más fácil insertar la lógica en el modelo y empujar las preocupaciones de borde como la validación o el
manejo de errores a los puntos de entrada.

Vale la pena tener una capa de servicio incluso si todavía tiene un ORM de Django grande y desordenado porque
es una forma de comenzar a comprender los límites de las operaciones.

La extracción de casos de uso romperá mucho de mi código existente; esta demasiado enredado
Solo copia y pega. Está bien causar más duplicación a corto plazo. Piense en esto como un proceso de varios
pasos. Su código está en mal estado ahora, así que cópielo y péguelo en un lugar nuevo y luego limpie y ordene
ese nuevo código.

Una vez que haya hecho eso, puede reemplazar los usos del código anterior con llamadas a su nuevo código y
finalmente eliminar el desorden. Arreglar grandes bases de código es un proceso complicado y doloroso. No espere
que las cosas mejoren instantáneamente, y no se preocupe si algunas partes de su aplicación siguen desordenadas.

¿Necesito hacer CQRS? Eso suena raro. ¿No puedo simplemente usar repositorios?
¡Por supuesto que puede! Las técnicas que presentamos en este libro están destinadas a facilitarle la vida. No son
una especie de disciplina ascética con la que castigarte a ti mismo.

Epílogo | 223
Machine Translated by Google

En nuestro primer sistema de estudio de caso, teníamos muchos objetos de View Builder que usaban repositorios
para obtener datos y luego realizaban algunas transformaciones para devolver modelos de lectura tonta. La ventaja
es que cuando se encuentra con un problema de rendimiento, es fácil reescribir un generador de vistas para usar
consultas personalizadas o SQL sin formato.

¿Cómo deberían interactuar los casos de uso en un sistema más grande? ¿Es un problema para uno llamar a otro?

Este podría ser un paso intermedio. Nuevamente, en el primer estudio de caso, teníamos controladores que
necesitarían invocar a otros controladores. Sin embargo, esto se vuelve realmente complicado y es mucho mejor usar
un bus de mensajes para separar estas preocupaciones.

Por lo general, su sistema tendrá una sola implementación de bus de mensajes y un grupo de subdominios que se
centran en un agregado o conjunto de agregados en particular.
Cuando su caso de uso haya terminado, puede generar un evento y un controlador en otro lugar
poder correr.

¿Es un olor a código para un caso de uso usar múltiples repositorios/agregados, y si es así, por qué?
Un agregado es un límite de consistencia, por lo que si su caso de uso necesita actualizar dos agregados
atómicamente (dentro de la misma transacción), entonces su límite de consistencia es incorrecto, estrictamente
hablando. Idealmente, debería pensar en pasar a un nuevo agregado que abarque todas las cosas que desea cambiar
al mismo tiempo.

Si en realidad está actualizando solo un agregado y usando los otros para acceso de solo lectura, entonces está
bien, aunque podría considerar construir un modelo de lectura/vista para obtener esos datos en su lugar; hace las
cosas más claras si cada caso de uso tiene un solo agregado.

Si necesita modificar dos agregados, pero las dos operaciones no tienen que estar en la misma transacción/UoW,
entonces considere dividir el trabajo en dos controladores diferentes y usar un evento de dominio para transportar
información entre los dos.
Puede leer más en estos documentos sobre diseño agregado de Vaughn Vernon.

¿Qué pasa si tengo un sistema de solo lectura pero con mucha lógica empresarial?
Los modelos de vista pueden tener una lógica compleja. En este libro, lo alentamos a separar sus modelos de lectura
y escritura porque tienen diferentes requisitos de consistencia y rendimiento. Principalmente, podemos usar una
lógica más simple para las lecturas, pero eso no siempre es cierto. En particular, los modelos de autorización y
permisos pueden agregar mucha complejidad a nuestro lado de lectura.

Hemos escrito sistemas en los que los modelos de vista necesitaban extensas pruebas unitarias. En esos sistemas,
separamos un generador de vistas de un buscador de vistas, como en la Figura E-4.

224 | Epílogo
Machine Translated by Google

Figura E-4. Un generador de vistas y un buscador de vistas (puede encontrar una versión de alta resolución de este
diagrama en cosmicpython.com)

+ Esto facilita la prueba del generador de vistas al proporcionarle datos simulados (por ejemplo, una lista de dictados).
"Fancy CQRS" con controladores de eventos es realmente una forma de ejecutar nuestra lógica de vista compleja cada vez
que escribimos para que podamos evitar ejecutarla cuando leemos.

¿Necesito crear microservicios para hacer esto?


¡Por Dios, no! Estas técnicas son anteriores a los microservicios por una década más o menos. Los agregados, los
eventos de dominio y la inversión de dependencias son formas de controlar la complejidad en sistemas grandes. Da
la casualidad de que cuando ha creado un conjunto de casos de uso y un modelo para un proceso comercial, moverlo
a su propio servicio es relativamente fácil, pero eso no es un requisito.

Estoy usando Django. ¿Todavía puedo hacer esto?


Tenemos un apéndice completo solo para usted: ¡ Apéndice D!

Epílogo | 225
Machine Translated by Google

escopetas
Vale, te hemos dado un montón de juguetes nuevos con los que jugar. Aquí está la letra pequeña.
Harry y Bob no recomiendan que copie y pegue nuestro código en un sistema de producción y reconstruya su plataforma
de negociación automatizada en Redis pub/sub. Por razones de brevedad y simplicidad, hemos tratado a mano muchos
temas complicados. Aquí hay una lista de cosas que creemos que debes saber antes de probar esto de verdad.

La mensajería confiable es difícil


Redis pub/sub no es confiable y no debe usarse como una herramienta de mensajería de propósito general. Lo
elegimos porque es familiar y fácil de ejecutar. En MADE, ejecutamos Event Store como nuestra herramienta de
mensajería, pero tenemos experiencia con RabbitMQ y Amazon EventBridge.

Tyler Treat tiene algunas publicaciones de blog excelentes en su sitio bravonewgeek.com; debería leer al menos “No
puede tener una entrega exactamente una vez” y “Lo que quiere es lo que no: comprensión de las ventajas y
desventajas en la mensajería distribuida”.

Elegimos explícitamente transacciones pequeñas y enfocadas que pueden fallar de forma independiente
En el Capítulo 8, actualizamos nuestro proceso para que la desasignación de una línea de pedido y la reasignación
de la línea sucedan en dos unidades de trabajo separadas. Necesitará monitoreo para saber cuándo fallan estas
transacciones y herramientas para reproducir eventos. Algo de esto se facilita mediante el uso de un registro de
transacciones como intermediario de mensajes (p. ej., Kafka o EventStore). También puede mirar el patrón de
Bandeja de salida.

No discutimos la idempotencia.
No hemos pensado realmente en lo que sucede cuando se vuelven a intentar los controladores. En la práctica, querrá
hacer que los controladores sean idempotentes para que llamarlos repetidamente con el mismo mensaje no haga
cambios repetidos en el estado. Esta es una técnica clave para generar confiabilidad, ya que nos permite volver a
intentar eventos de manera segura cuando fallan.

Hay mucho material bueno sobre el manejo de mensajes idempotentes, intente comenzar con "Cómo garantizar la
idempotencia en una aplicación DDD/CQRS eventualmente consistente" y "(Un) Confiabilidad en la mensajería".

Tus eventos necesitarán cambiar su esquema con el tiempo


Deberá encontrar alguna forma de documentar sus eventos y compartir el esquema con los consumidores. Nos gusta
usar el esquema JSON y el margen de beneficio porque es simple, pero existe otro estado de la técnica. Greg Young
escribió un libro completo sobre la gestión de sistemas basados en eventos a lo largo del tiempo: Control de versiones
en un sistema basado en eventos (Leanpub).

226 | Epílogo
Machine Translated by Google

Más lecturas obligatorias


Algunos libros más que nos gustaría recomendarte para ayudarte en tu camino:

• Arquitecturas limpias en Python de Leonardo Giordani (Leanpub), que se publicó en 2019, es


uno de los pocos libros anteriores sobre arquitectura de aplicaciones en Python. • Patrones de

integración empresarial por Gregor Hohpe y Bobby Woolf (Addison


Wesley Professional) es un buen comienzo para los patrones de mensajería.

• Monolith to Microservices de Sam Newman (O'Reilly), y el primer libro de Newman, Building


Microservices (O'Reilly). El patrón Strangler Fig se menciona como uno de los favoritos, junto
con muchos otros. Estos son buenos para consultar si está pensando en cambiar a
microservicios, y también son buenos en los patrones de integración y las consideraciones de
la integración basada en mensajería asíncrona.

Envolver
¡Uf! Esas son muchas advertencias y sugerencias de lectura; Esperamos no haberte asustado por
completo. Nuestro objetivo con este libro es brindarle el conocimiento y la intuición suficientes para
que pueda comenzar a construir algo de esto por sí mismo. Nos encantaría saber cómo te va y qué
problemas estás enfrentando con las técnicas en tus propios sistemas, así que ¿por qué no te pones
en contacto con nosotros en www.cosmicpython.com?

Epílogo | 227
Machine Translated by Google
Machine Translated by Google

APÉNDICE A

Diagrama y tabla de resumen

Así es como se ve nuestra arquitectura al final del libro:

229
Machine Translated by Google

La Tabla A-1 resume cada patrón y lo que hace.

Tabla A-1. Los componentes de nuestra arquitectura y lo que hacen todos

Capa Componente Descripción

Dominio Entidad Un objeto de dominio cuyos atributos pueden cambiar pero que

Define la lógica de negocio. tiene una identidad reconocible a lo largo del tiempo.

Objeto de valor Un objeto de dominio inmutable cuyos atributos lo definen

por completo. Es fungible con otros objetos idénticos.

Agregar Grupo de objetos asociados que tratamos como una unidad a

efectos de cambios de datos. Define y hace cumplir un límite de

consistencia.

Evento Representa algo que sucedió.

Dominio Representa un trabajo que el sistema debe realizar.

Capa de servicio Manipulador Recibe un comando o un evento y realiza lo que debe

Define los trabajos que debe realizar el sistema y organiza suceder.

diferentes componentes. unidad de trabajo Abstracción en torno a la integridad de los datos. Cada unidad de

trabajo representa una actualización atómica. Hace que los repositorios

estén disponibles. Realiza un seguimiento de los nuevos eventos en

los agregados recuperados.

Bus de mensajes Maneja comandos y eventos enrutándolos al controlador adecuado.


(interno)

Adaptadores (secundario) Repositorio Abstracción en torno al almacenamiento persistente.

Implementaciones concretas de una interfaz que va desde Cada agregado tiene su propio repositorio.

nuestro sistema hacia el mundo exterior (I/O).


Publicador de eventos Envía eventos al bus de mensajes externo.

Puntos de entrada (adaptadores primarios) Web Recibe solicitudes web y las traduce en comandos, pasándolas

Traduzca entradas externas en llamadas a la capa de servicio. al bus de mensajes interno.

Consumidor de eventos Lee eventos del bus de mensajes externo y los traduce en

comandos, pasándolos al bus de mensajes interno.

N/A Bus de Una pieza de infraestructura que utilizan los diferentes servicios

mensajes para intercomunicarse, vía eventos.

externo (agente
de mensajes)

230 | Apéndice A: Diagrama y tabla de resumen


Machine Translated by Google

APÉNDICE B

Una estructura de proyecto de plantilla

Alrededor del Capítulo 4, pasamos de tener todo en una carpeta a un árbol más estructurado, y
pensamos que podría ser interesante delinear las partes móviles.

El código para este apéndice está en la rama


appendix_project_structure en GitHub:
clon de git https://github.com/cosmicpython/code.git código
de cd git checkout appendix_project_structure

La estructura básica de carpetas se ve así:

árbol del proyecto

.
ÿÿÿ Dockerfile
ÿÿÿ Makefile ÿÿÿ
README.md
ÿÿÿ docker-compose.yml
ÿÿÿ licencia.txt ÿÿÿ mypy.ini
ÿÿÿ requisitos.txt ÿÿÿ src ÿ
ÿÿ asignación ÿ ÿÿÿ
__init__.py ÿ ÿ ÿÿÿ
Adaptadores
dominio ÿ ÿ ÿ ÿiqute
ÿÿÿ
__init__.py ÿ ÿ ÿ modelo.py

ÿÿÿ

ÿÿÿ

231
Machine Translated by Google

ÿ ÿÿÿ puntos de entrada ÿ ÿ ÿÿÿ __init__.py


ÿ

ÿ ÿfrasco_app.py
ÿ __init__.py
ÿ service_layer
setup.py
ÿ services.py
ÿÿÿ
ÿ ÿÿÿ
pruebas
ÿ ÿÿÿ
ÿÿÿ
conftest.py ÿeÿeÿ
ÿ test_orm.py
test_api.pyÿ test_repository.py
ÿÿÿ
ÿÿÿintegración
pytest.ini ÿÿÿ
ÿ ÿÿÿ
ÿÿÿ
unidad ÿÿÿ test_allocate.py
ÿ test_services.py
ÿÿÿ test_batches.py

ÿÿÿ
ÿ

ÿÿÿ

ÿÿÿ

ÿÿÿ

ÿÿÿ

Nuestro docker-compose.yml y nuestro Dockerfile son los bits principales de configuración para
los contenedores que ejecutan nuestra aplicación, y también pueden ejecutar las pruebas (para
CI). Un proyecto más complejo podría tener varios Dockerfiles, aunque hemos descubierto que
minimizar la cantidad de imágenes suele ser una buena idea.1

Un Makefile proporciona el punto de entrada para todos los comandos típicos que un desarrollador
(o un servidor de CI) podría querer ejecutar durante su flujo de trabajo normal: hacer compilación,
hacer prueba, etc.2 Esto es opcional. Podría simplemente usar docker-compose y pyt est
directamente, pero al menos, es bueno tener todos los "comandos comunes" en una lista en
algún lugar y, a diferencia de la documentación, un Makefile es un código, por lo que tiene menos
tendencia a convertirse en out. de la fecha.

Todo el código fuente de nuestra aplicación, incluido el modelo de dominio, la aplicación Flask y
el código de infraestructura, reside en un paquete de Python dentro de
mediante
src, 3 que
pip instalamos
install -e y el
archivo setup.py. Esto facilita las importaciones. Actualmente, la estructura dentro de este módulo
es totalmente plana, pero para un proyecto más complejo, esperaría aumentar una jerarquía de
carpetas que incluya modelo_dominio/, infraestructura/, servicios/ y api/.

1 La división de imágenes para producción y prueba a veces es una buena idea, pero tendemos a encontrar que ir más allá y
tratar de dividir diferentes imágenes para diferentes tipos de código de aplicación (por ejemplo, API web versus cliente pub/
sub) generalmente termina siendo más problemas de lo que vale; el costo en términos de complejidad y tiempos más largos de
reconstrucción/CI es demasiado alto. YMMV.

2 Una alternativa de Python puro a Makefiles es Invoke, vale la pena echarle un vistazo si todos en su equipo conocen Python.
(¡o al menos lo sabe mejor que Bash!).

3 “Testing and Packaging” de Hynek Schlawack brinda más información sobre las carpetas src.

232 | Apéndice B: Estructura de un proyecto de plantilla


Machine Translated by Google

Las pruebas viven en su propia carpeta. Las subcarpetas distinguen diferentes tipos de pruebas y le
permiten ejecutarlas por separado. Podemos mantener los accesorios compartidos (conftest.py) en la
carpeta principal de pruebas y anidar otros más específicos si lo deseamos. Este es también el lugar
para guardar pytest.ini.

Los documentos de pytest son realmente buenos en cuanto al diseño de prueba y la importabilidad.

Veamos algunos de estos archivos y conceptos con más detalle.

Env Vars, 12-Factor y Config, dentro y fuera


Contenedores

El problema básico que intentamos resolver aquí es que necesitamos diferentes ajustes de configuración para
lo siguiente:

• Ejecutar código o pruebas directamente desde su propia máquina de desarrollo, tal vez hablando con
puertos asignados desde contenedores Docker

• Ejecutándose en los propios contenedores, con puertos y nombres de host “reales” •

Diferentes entornos de contenedores (dev, staging, prod, etc.)

La configuración a través de variables de entorno como sugiere el manifiesto de 12 factores resolverá este
problema, pero en concreto, ¿cómo lo implementamos en nuestro código y nuestros contenedores?

config.py
Cada vez que nuestro código de aplicación necesite acceso a alguna configuración, la obtendrá de un archivo
llamado config.py. Aquí hay un par de ejemplos de nuestra aplicación:

Funciones de configuración de muestra (src/allocation/config.py)

importarnos _

def get_postgres_uri():
host = os.environ.get('DB_HOST', 'localhost') puerto =
54321 if host == 'localhost' else 5432 contraseña =
os.environ.get('DB_PASSWORD', 'abc123') usuario, db_name
= 'asignación ', 'asignación' devuelve f"postgresql://{usuario}:
{contraseña}@{host}:{puerto}/{db_name}"

Una estructura de proyecto de plantilla | 233


Machine Translated by Google

def get_api_url(): host =


os.environ.get('API_HOST', 'localhost') port = 5005 if host ==
'localhost' else 80 return f"http://{host}:{port}"

Usamos funciones para obtener la configuración actual, en lugar de constantes disponibles en el momento
de la importación, porque eso permite que el código del cliente modifique os.environ si es necesario.

config.py también define algunas configuraciones predeterminadas, diseñadas para funcionar cuando se
ejecuta el código desde la máquina local del desarrollador.4

Vale la pena mirar un elegante paquete de Python llamado environ-config si se cansa de ejecutar manualmente sus
propias funciones de configuración basadas en el entorno.

No permita que este módulo de configuración se convierta en un basurero


lleno de cosas vagamente relacionadas con la configuración y que luego se
importan por todas partes. Mantenga las cosas inmutables y modifíquelas
solo a través de variables de entorno. Si decide utilizar un script de arranque,
puede convertirlo en el único lugar (aparte de las pruebas) al que se importa
la configuración.

Configuración de Docker-Compose y contenedores


Usamos una herramienta ligera de orquestación de contenedores Docker llamada docker-compose. Su configuración
principal es a través de un archivo YAML (suspiro):5

archivo de configuración docker-compose (docker-compose.yml)

versión: "3"
servicios:

aplicación:
compilación:
contexto: . dockerfile: Dockerfile
depend_on: - entorno postgres :

- DB_HOST=postgres
- DB_CONTRASEÑA=abc123
- API_HOST=aplicación
- PYTHONDONTWRITEBYTECODE=1

4 Esto nos da una configuración de desarrollo local que “simplemente funciona” (tanto como sea posible). Es posible que prefiera fallar con
fuerza en las variables de entorno que faltan, especialmente si alguno de los valores predeterminados sería inseguro en producción.

5 Harry está un poco cansado de YAML. Está en todas partes y, sin embargo, nunca puede recordar la sintaxis o cómo se supone que debe
sangrar.

234 | Apéndice B: Estructura de un proyecto de plantilla


Machine Translated by Google

volúmenes: - ./
src:/src - ./tests:/tests
puertos: - "5005:80"

postgres:
imagen: postgres:9.6
entorno:
- POSTGRES_USER=asignación
- POSTGRES_CONTRASEÑA=abc123

puertos: - "54321:5432"

En el archivo docker-compose, definimos los diferentes servicios (contenedores) que necesitamos para
nuestra aplicación. Por lo general, una imagen principal contiene todo nuestro código y podemos usarla
para ejecutar nuestra API, nuestras pruebas o cualquier otro servicio que necesite acceso al modelo de
dominio.

Probablemente tendrá otros servicios de infraestructura, incluida una base de datos. En producción, es
posible que no use contenedores para esto; en su lugar, es posible que tenga un proveedor de nube,
pero docker-compose nos brinda una forma de producir un servicio similar para desarrollo o CI.

La estrofa de entorno le permite establecer las variables de entorno para sus contenedores, los nombres
de host y los puertos como se ve desde dentro del clúster de Docker. Si tiene suficientes contenedores
para que la información comience a duplicarse en estas secciones, puede usar el archivo_entorno en su
lugar. Usualmente llamamos al nuestro container.env.

Dentro de un clúster, docker-compose configura la red de modo que los contenedores estén disponibles
entre sí a través de nombres de host con el nombre de su servicio.

Sugerencia profesional: si está montando volúmenes para compartir carpetas de origen entre su máquina
de desarrollo local y el contenedor, la variable de entorno PYTHONDONTWRITEBYTECODE le dice a
Python que no escriba archivos .pyc, y eso le evitará tener millones de archivos de propiedad raíz
salpicados sobre su sistema de archivos local, siendo molesto eliminar y causando errores extraños del
compilador de Python además.

Montar nuestro código fuente y de prueba como volúmenes significa que no necesitamos reconstruir
nuestros contenedores cada vez que hacemos un cambio de código.

Una estructura de proyecto de plantilla | 235


Machine Translated by Google

La sección de puertos nos permite exponer los puertos desde el interior de los contenedores al mundo exterior6 ;
estos corresponden a los puertos predeterminados que configuramos en config.py.

Dentro de Docker, otros contenedores están disponibles a través de


nombres de host con el nombre de su servicio. Fuera de Docker, están
disponibles en localhost, en el puerto definido en la sección de puertos .

Instalar su fuente como un paquete


Todo el código de nuestra aplicación (todo excepto las pruebas, en realidad) vive dentro de una carpeta src:

La carpeta src

ÿÿÿ src ÿ
ÿÿÿ asignación ÿ ÿ ÿÿÿ
config.py ÿ ÿ ÿ
ÿÿÿ
...
ÿÿÿ
configuración.py

Las subcarpetas definen los nombres de los módulos de nivel superior. Puedes tener varios si quieres.

Y setup.py es el archivo que necesita para que sea instalable por pip, como se muestra a continuación.

Módulos instalables por pip en tres líneas (src/setup.py)

desde la configuración de importación de herramientas de configuración

setup( nombre='asignación',
versión='0.1',
paquetes=['asignación'],
)

Eso es todo lo que necesitas. packages= especifica los nombres de las subcarpetas que desea instalar como módulos de

nivel superior. La entrada del nombre es solo cosmética, pero es obligatoria. Para un paquete que en realidad nunca llegará
a PyPI, funcionará bien.7

6 En un servidor CI, es posible que no pueda exponer puertos arbitrarios de manera confiable, pero es solo una conveniencia para el desarrollador local.
Puede encontrar formas de hacer que estas asignaciones de puertos sean opcionales (por ejemplo, con docker-compose.override.yml).

7 Para obtener más consejos sobre setup.py, consulte este artículo sobre empaquetado de Hynek.

236 | Apéndice B: Estructura de un proyecto de plantilla


Machine Translated by Google

Dockerfile
Los Dockerfiles van a ser muy específicos del proyecto, pero aquí hay algunas etapas clave que esperará ver:

Nuestro Dockerfile (Dockerfile)

DESDE python: 3.8-alpino

EJECUTAR apk agregar --no-cache --virtual .build-deps gcc postgresql-dev musl-dev python3-dev EJECUTAR apk agregar
libpq

COPIAR requisitos.txt /tmp/ EJECUTAR


pip install -r /tmp/requisitos.txt

EJECUTAR apk del --no-cache .build-deps

EJECUTAR mkdir -p /src


COPIAR origen/ /origen/
EJECUTAR pip install -e /src
COPIAR pruebas/ /pruebas/

WORKDIR /src
ENV FLASK_APP=asignación/puntos de entrada/flask_app.py FLASK_DEBUG=1 PYTHONUNBUFFERED=1 ejecución
de matraz CMD --host=0.0.0.0 --port=80

Instalación de dependencias a nivel de sistema

Instalar nuestras dependencias de Python (es posible que desee separar su desarrollador de las
dependencias de producción; no lo hemos hecho aquí, por simplicidad)

Copiando e instalando nuestra fuente

Opcionalmente, configurar un comando de inicio predeterminado (probablemente anule esto mucho


desde la línea de comando)

Una cosa a tener en cuenta es que instalamos las cosas en el orden de la


frecuencia con la que es probable que cambien. Esto nos permite maximizar
la reutilización de caché de compilación de Docker. No puedo decirle cuánto
dolor y frustración subyace en esta lección. Para obtener este y muchos
otros consejos de mejora de Python Dockerfile, consulte "Empaquetado de
Docker listo para producción".

Una estructura de proyecto de plantilla | 237


Machine Translated by Google

Pruebas

Nuestras pruebas se mantienen junto con todo lo demás, como se muestra aquí:

Árbol de carpetas de pruebas

ÿÿÿ pruebas ÿÿÿ

conftest.py ÿÿÿ e2e ÿ

test_api.py ÿÿÿ integración


ÿÿÿ
ÿ ÿÿÿ test_orm.py
test_repository.py
ÿ

ÿÿÿ pytest.ini ÿÿÿ unidad ÿ

ÿalocate.py.
prueba_servicios.py
ÿÿ prueba_lotes.py
ÿÿÿ

ÿÿÿ

Nada particularmente inteligente aquí, solo una cierta separación de diferentes tipos de pruebas que es probable que
desee ejecutar por separado, y algunos archivos para accesorios comunes, configuración, etc.

No hay una carpeta src ni setup.py en las carpetas de prueba porque, por lo general, no hemos necesitado hacer que
las pruebas sean instalables por pip, pero si tiene dificultades con las rutas de importación, es posible que le resulte útil.

Envolver
Estos son nuestros componentes básicos:

• Código fuente en una carpeta src, pip-instalable usando setup.py • Algunas

configuraciones de Docker para activar un clúster local que refleja la producción hasta ahora
como sea posible

• Configuración a través de variables de entorno, centralizada en un archivo de Python llamado config.py, con valores
predeterminados que permiten que las cosas se ejecuten fuera de los contenedores

• Un Makefile para útiles comandos de línea de comandos, um,

Dudamos que alguien termine con exactamente las mismas soluciones que nosotros, pero esperamos que encuentre
algo de inspiración aquí.

238 | Apéndice B: Estructura de un proyecto de plantilla


Machine Translated by Google

APÉNDICE C

Intercambio de la infraestructura:
Hazlo todo con CSV

Este apéndice pretende ser una pequeña ilustración de los beneficios de los patrones Repositorio,
Unidad de trabajo y Capa de servicio. Está destinado a seguir desde el Capítulo 6.

Justo cuando terminamos de construir nuestra Flask API y la preparamos para su lanzamiento, el
negocio se nos acerca disculpándose, diciendo que no están listos para usar nuestra API y
preguntando si podemos construir algo que lea solo lotes y pedidos de un par de CSV y genera un
tercer CSV con asignaciones.

Por lo general, este es el tipo de cosas que podría hacer que un equipo maldiga, escupa y tome
notas para sus memorias. ¡Pero no nosotros! Oh no, nos hemos asegurado de que nuestras
preocupaciones de infraestructura estén bien desacopladas de nuestro modelo de dominio y capa de servicio.
Cambiar a CSV será una simple cuestión de escribir un par de clases nuevas de Repository y
UnitOfWork , y luego podremos reutilizar toda nuestra lógica de la capa de dominio y la capa de
servicio.

Aquí hay una prueba E2E para mostrarle cómo entran y salen los CSV:

Una primera prueba CSV (tests/e2e/test_csv.py)

def test_cli_app_reads_csvs_with_batches_and_orders_and_outputs_allocations(
hacer_csv
):
sku1, sku2 = referencia_aleatoria('s1'), referencia_aleatoria('s2')
lote1, lote2, lote3 = referencia_aleatoria('b1'), referencia_aleatoria('b2'), referencia_aleatoria('b3')
orden_ref = referencia_aleatoria('o' ) make_csv('lotes.csv', [ ['ref', 'sku', 'qty', 'eta'], [batch1, sku1,
100, ''], [batch2, sku2, 100, '2011-01 -01'], [lote3, sku2, 100, '2011-01-02'],

239
Machine Translated by Google

])
orders_csv = make_csv('orders.csv', [ ['orderid',
'sku', 'qty'], [order_ref, sku1, 3], [order_ref,
sku2, 12],

])

run_cli_script(orders_csv.parent)

Expected_output_csv = orders_csv.parent / 'allocations.csv' with


open(expected_output_csv) como f: filas = lista(csv.reader(f)) afirmar
filas == [ ['orderid', 'sku', 'qty', 'batchref '], [ref_pedido, sku1, '3',
lote1], [ref_pedido, sku2, '12', lote2],

Si se sumerge e implementa sin pensar en repositorios y todo ese jazz,


puede comenzar con algo como esto:

Un primer corte de nuestro lector/escritor CSV (src/bin/allocate-from-csv)


#!/usr/bin/env python
import csv import sys
from datetime import
datetime from pathlib import Path

del modelo de importación de asignación

def load_batches(batches_path): lotes


= [] with batches_path.open() as
inf: lector = csv.DictReader(inf) for fila
en lector:

if fila['eta']: eta =
datetime.strptime(fila['eta'], '%Y-%m-%d').date() else:

edad = ninguna

lotes.append(modelo.Lote( ref=fila['ref'],
sku=fila['sku'],
qty=int(fila['qty']), eta=eta

))
devolver lotes

def principal
(carpeta): lotes_ruta = Ruta (carpeta) / 'lotes.csv'

240 | Apéndice C: Intercambio de la infraestructura: hacer todo con CSV


Machine Translated by Google

orders_path = Ruta (carpeta) / 'orders.csv'


asignaciones_path = Ruta (carpeta) / 'allocations.csv'

lotes = cargar_lotes(ruta_lotes)

con orders_path.open() como inf, asignaciones_path.open('w') como outf:


lector = csv.DictReader(inf) escritor
= csv.escritor(outf)
escritor.escritor(['orderid', 'sku', 'batchref']) for fila en lector:
orderid, sku = fila['orderid'], fila['sku'] cantidad =
int(fila['cantidad']) línea = modelo.OrderLine(orderid,
sku, qty) lotref = modelo.allocate(línea, lotes)
escritor.writerow([línea.orderid, línea. sku, referencia
de lote])

si __nombre__ == '__principal__':
principal(sys.argv[1])

¡No se ve tan mal! Y estamos reutilizando nuestros objetos de modelo de dominio y nuestro servicio de dominio.

Pero no va a funcionar. Las asignaciones existentes también deben ser parte de nuestro almacenamiento
CSV permanente. Podemos escribir una segunda prueba para obligarnos a mejorar las cosas:

Y otro, con asignaciones existentes (tests/e2e/test_csv.py) def


test_cli_app_also_reads_existing_allocations_and_can_append_to_them(
hacer_csv
):
sku = referencia_aleatoria('s')
lote1, lote2 = referencia_aleatoria('b1'), referencia_aleatoria('b2')
pedido_anterior, pedido_nuevo = referencia_aleatoria('o1'),
referencia_aleatoria('o2') make_csv('lotes.csv', [ ['ref', 'sku', 'qty', 'eta'],
[batch1, sku, 10, '2011-01-01'], [batch2, sku, 10, '2011-01-02'] ,

])
make_csv('allocations.csv',
[ ['orderid', 'sku', 'qty', 'batchref'], [old_order,
sku, 10, lote1],
])
pedidos_csv = make_csv('pedidos.csv', [
['orderid', 'sku', 'qty'], [new_order,
sku, 7],
])

run_cli_script(orders_csv.parent)

salida_esperada_csv = pedidos_csv.parent / 'asignaciones.csv'

Intercambio de la infraestructura: haga todo con CSV | 241


Machine Translated by Google

con open(expected_output_csv) como f:


filas = lista(csv.reader(f)) afirmar filas
== [ ['orderid', 'sku', 'qty', 'batchref'],
[old_order, sku, '10' , lote1], [nuevo_pedido,
sku, '7', lote2],

Y podríamos seguir pirateando y agregando líneas adicionales a esa función load_batches , y algún
tipo de forma de rastrear y guardar nuevas asignaciones, ¡pero ya tenemos un modelo para hacerlo!
Se llama nuestros patrones Repositorio y Unidad de trabajo.

Todo lo que tenemos que hacer ("todo lo que tenemos que hacer") es volver a implementar esas
mismas abstracciones, pero con CSV subyacentes en lugar de una base de datos. Y como verá,
realmente es relativamente sencillo.

Implementación de un Repositorio y Unidad de Trabajo para CSVs


Este es el aspecto que podría tener un repositorio basado en CSV. Abstrae toda la lógica para leer
CSV desde el disco, incluido el hecho de que tiene que leer dos CSV diferentes (uno para lotes y otro
para asignaciones), y nos brinda solo la API familiar .list() , que proporciona la ilusión de una colección
en memoria de objetos de dominio:

Un repositorio que utiliza CSV como mecanismo de almacenamiento (src/allocation/service_layer/csv_uow.py)


clase CsvRepository(repositorio.AbstractRepository):

def __init__(auto, carpeta):


self._batches_path = Ruta (carpeta) / 'lotes.csv'
self._allocations_path = Ruta (carpeta) / 'asignaciones.csv' self._batches
= {} # tipo: Dict[str, model.Batch] self._load()

def get(self, referencia): return


self._batches.get(referencia)

def add(self, lote):


self._lotes[lote.referencia] = lote

def _load(self):
with self._batches_path.open() como f:
lector = csv.DictReader(f) for fila en
lector: ref, sku = fila['ref'], fila['sku'] qty
= int (fila['cantidad']) si fila['eta']: eta =
datetime.strptime(fila['eta'], '%Y-%m-
%d').date() else:

edad = ninguna

self._lotes[ref] = modelo.Lote(

242 | Apéndice C: Intercambio de la infraestructura: hacer todo con CSV


Machine Translated by Google

ref=ref, sku=sku, cantidad=cantidad, eta=eta


)
si self._allocations_path.exists() es falso:
volver
con self._allocations_path.open() como f: lector
= csv.DictReader(f) para fila en lector:
batchref, orderid, sku = fila['batchref'],
fila['orderid'], fila['sku' ] cant . = int(fila['cant.']) línea = modelo.OrderLine(orderid, sku,
qty) lote = self._batches[loteref] lote._asignaciones.add(línea)

def list(self):
return list(self._batches.values())

Y así es como se vería una UoW para CSV:

Una UoW para CSV: commit = csv.writer (src/allocation/service_layer/csv_uow.py)


clase CsvUnitOfWork(unidad_de_trabajo.AbstractUnitOfWork):

def __init__(self, carpeta):


self.lotes = CsvRepository(carpeta)

def commit(self):
with self.batches._allocations_path.open('w') como f: escritor
= csv.writer(f) escritor.writerow(['orderid', 'sku', 'qty',
'batchref' ]) para lote en self.batches.list():

para línea en lote._asignaciones:


escritor.escritor( [línea.ID de
pedido, línea.sku, línea.cantidad, lote.referencia]
)

def revertir (auto):


pasar

Y una vez que tengamos eso, nuestra aplicación CLI para leer y escribir lotes y asignaciones en
CSV se reduce a lo que debería ser: un poco de código para leer líneas de pedido y un poco de
código que invoca nuestra capa de servicio existente:

Intercambio de la infraestructura: haga todo con CSV | 243


Machine Translated by Google

Asignación con CSV en nueve líneas (src/bin/allocate-from-csv)


def main(carpeta):
orders_path = Path(carpeta) / 'orders.csv' uow =
csv_uow.CsvUnitOfWork(carpeta) with
orders_path.open() como f:
lector = csv.DictReader(f) for
fila en el lector: orderid, sku =
fila['orderid'], fila['sku'] cantidad = int(fila['cant'])
services.allocate(orderid, sku, qty , uow)

Ta-da! ¿Están impresionados o qué?


Mucho amor,

bob y harry

244 | Apéndice C: Intercambio de la infraestructura: hacer todo con CSV


Machine Translated by Google

APÉNDICE D

Repositorio y Unidad de Trabajo


Patrones con Django

Suponga que desea usar Django en lugar de SQLAlchemy y Flask. ¿Cómo podrían verse
las cosas? Lo primero es elegir dónde instalarlo. Lo ponemos en un paquete separado al
lado de nuestro código de asignación principal:

ÿÿÿ src ÿ
ÿÿÿ asignación ÿ ÿ ÿÿÿ
__init__.py ÿ__init__.py
adaptadores
ÿ ÿÿÿ ÿ ÿ ÿ ÿÿÿ

...
ÿÿÿ DjanGoProject ÿ ÿÿÿ
__init__.py
Asignado ÿ ÿ ÿÿÿ ÿ ÿ ÿÿÿ
APPS.py ÿ ÿ __init__.py
ÿÿÿÿ0001_initial.py
ÿ ÿÿÿ ÿ ÿ ÿ ÿ ÿ ÿ ÿ ÿ ÿÿ
ÿ models.py ÿ__init__.py
django_project
ÿ views.py
ÿ ÿ ÿÿÿÿ
ÿÿÿ
ÿ ÿÿÿ
settings.py
ÿ ÿ ÿ ÿÿÿ urls.py ÿ ÿ wsgi.py

ÿÿÿ

ÿÿÿ

ÿÿÿ

ÿÿÿ
gestionar.py
ÿÿÿ
ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ
setup.py ÿÿÿ pruebas ÿÿÿ

conftest.py ÿÿÿ e2e ÿ

ÿÿÿ
prueba_api.py

245
Machine Translated by Google

ÿÿÿ integración ÿ
ÿÿÿ test_repository.py
...

El código de este apéndice está en la rama appendix_django en


GitHub:
git clon https://github.com/cosmicpython/code.git cd código git
checkout appendix_django

Patrón de repositorio con Django


Usamos un complemento llamado pytest-django para ayudar con la administración de la base de datos de prueba.

Reescribir la primera prueba del repositorio fue un cambio mínimo: solo reescribió SQL sin formato con una llamada al
lenguaje Django ORM/QuerySet:

Primera prueba del repositorio adaptada (tests/integration/test_repository.py)


desde djangoproject.alloc importar modelos como django_models

@pytest.mark.django_db
def test_repository_can_save_a_batch():
lote = modelo.Lote("lote1", "JABONERA OXIDADA", 100, eta=fecha(2011, 12, 25))

repo = repositorio.DjangoRepository()
repo.add(lote)

[saved_batch] = django_models.Batch.objects.all() afirmar


Saved_batch.reference == lote.referencia afirmar
lote_guardado.sku == lote.sku afirmar lote_guardado.qty ==
lote._cantidad_comprada afirmar lote_guardado.eta == lote.eta

La segunda prueba es un poco más complicada ya que tiene asignaciones, pero aún está compuesta por código Django
de aspecto familiar:

La prueba del segundo repositorio es más complicada (tests/integration/test_repository.py)


@pytest.mark.django_db
def test_repository_can_retrieve_a_batch_with_allocations():
sku = "ESTATUA-
PONY" d_line = django_models.OrderLine.objects.create(orderid="order1", sku=sku, qty=12) d_b1 =
django_models.Batch.objects.create( reference="batch1", sku=sku , cantidad=100, eta=Ninguno

)
d_b2 = django_models.Lote.objetos.create( referencia="lote2",
sku=sku, cantidad=100, eta=Ninguno
)
django_models.Allocation.objects.create(line=d_line, lote=d_batch1)

246 | Apéndice D: Patrones de repositorio y unidad de trabajo con Django


Machine Translated by Google

repo = repositorio.DjangoRepository()
recuperado = repo.get("batch1")

esperado = modelo.Batch("batch1", sku , 100, eta=Ninguno)


afirmación recuperada == esperada # Lote.__eq__ solo compara la afirmación de
referencia recuperada.sku == esperada.sku afirmación recuperada._cantidad_comprada
== esperada._cantidad_comprada afirmación recuperada ._asignaciones == {

modelo.OrderLine("pedido1", sku, 12),


}

Así es como termina luciendo el repositorio real:

Un repositorio de Django (src/allocation/adapters/repository.py)

clase DjangoRepository(AbstractRepository):

def add(auto, lote):


super().add(lote)
self.update(lote)

actualización de definición (auto, lote):


django_models.Batch.update_from_domain(lote)

def _get(self, referencia): return


django_models.Batch.objects.filter(
referencia=referencia
).primero().a_dominio()

def list(self):
return [b.to_domain() for b in django_models.Batch.objects.all()]

Puede ver que la implementación se basa en los modelos de Django que tienen algunos métodos
personalizados para traducir hacia y desde nuestro modelo de dominio.1

Métodos personalizados en las clases ORM de Django para traducir a/desde nuestro
modelo de dominio

Esos métodos personalizados se ven así:

Django ORM con métodos personalizados para la conversión de modelos de dominio (src/djangoproject/alloc/models.py)

de modelos de importación django.db


de modelo de importación de asignación.dominio como modelo_dominio

clase Lote (modelos.Modelo):

1 La gente del proyecto DRY-Python ha creado una herramienta llamada mapeadores que parece que podría ayudar a minimizar el modelo
estándar para este tipo de cosas.

Repositorio y Unidad de Patrones de Trabajo con Django | 247


Machine Translated by Google

referencia = modelos.CharField(max_length=255) sku


= modelos.CharField(max_length=255) qty =
modelos.IntegerField() eta = modelos.DateField(en
blanco=Verdadero, nulo=Verdadero)

@staticmethod
def update_from_domain(lote: modelo_dominio.Lote):

prueba: b = Lote.objetos.get(referencia=lote.referencia)
excepto Batch.DoesNotExist: b
= Batch(referencia=lote.referencia) b.sku =
lote.sku b.qty = lote._cantidad_comprada b.eta =
lote.eta b.save()
b.allocation_set.set( Allocation.from_domain (l, b)
para l en lote._asignaciones

def to_domain(self) -> modelo_dominio.Lote: b =


modelo_dominio.Lote(
ref=self.reference, sku=self.sku, qty=self.qty, eta=self.eta

) b._allocations =
set( a.line.to_domain()
for a in self.allocation_set.all()

) volver b

clase OrderLine(modelos.Modelo):
#...

Para objetos de valor, objects.get_or_create puede funcionar, pero para entidades, probablemente necesite
un try-get/except explícito para manejar el upsert.2

Hemos mostrado el ejemplo más complejo aquí. Si decide hacer esto, ¡tenga en cuenta que habrá
repeticiones! Afortunadamente no es un modelo muy complejo.

Las relaciones también necesitan un manejo personalizado y cuidadoso.

2 @mr-bo-jangles sugirió que podría usar update_or_create, pero eso va más allá de nuestro Django-fu.

248 | Apéndice D: Patrones de repositorio y unidad de trabajo con Django


Machine Translated by Google

Como en el Capítulo 2, usamos inversión de dependencia. El


ORM (Django) depende del modelo y no al revés.

Patrón de unidad de trabajo con Django


Las pruebas no cambian demasiado:

Pruebas UoW adaptadas (tests/integration/test_uow.py)

def insert_batch(ref, sku, qty, eta):


django_models.Batch.objects.create(referencia=ref, sku=sku, cantidad=cantidad, eta=eta)

def get_allocated_batch_ref(orderid, sku):


devuelve
django_models.Allocation.objects.get( line__orderid=orderid,
line__sku=sku ).batch.reference

@pytest.mark.django_db(transacción=Verdadero)
def test_uow_can_retrieve_a_batch_and_allocate_to_it():
insert_batch('lote1', 'HIPSTER-WORKBENCH', 100, Ninguno)

uow = unidad_de_trabajo.DjangoUnitOfWork()
with uow: batch =
uow.batches.get(reference='batch1') line =
model.OrderLine('o1', 'HIPSTER-WORKBENCH', 10)
batch.allocate(line) uow. comprometerse()

lotref = get_allocated_batch_ref('o1', 'HIPSTER-WORKBENCH') afirmar


loteref == 'lote1'

@pytest.mark.django_db(transacción=Verdadero)
def test_rolls_back_uncommitted_work_by_default():
...

@pytest.mark.django_db(transacción=Verdadero)
def test_rolls_back_on_error():
...

Debido a que teníamos pequeñas funciones auxiliares en estas pruebas, los cuerpos principales
reales de las pruebas son prácticamente los mismos que con SQLAlchemy.

Se requiere pytest-django mark.django_db(transaction=True) para probar nuestros comportamientos


personalizados de transacción/reversión.

Repositorio y Unidad de Patrones de Trabajo con Django | 249


Machine Translated by Google

Y la implementación es bastante simple, aunque me tomó algunos intentos encontrar qué invocación de la
magia de transacción de Django funcionaría:

UoW adaptado para Django (src/allocation/service_layer/unit_of_work.py)


clase DjangoUnitOfWork(AbstractUnitOfWork):

def __enter__(self):
self.lotes = repositorio.DjangoRepository()
transacción.set_autocommit(False) return
super().__enter__()

def __exit__(self, *argumentos):


super().__exit__(*argumentos)
transacción.set_autocommit(Verdadero)

def commit(self):
para lote en self.batches.seen:
self.lotes.actualizar(lote)
transacción.commit()

def rollback(auto):
transacción.rollback()

set_autocommit(False) era la mejor manera de decirle a Django que dejara de confirmar


automáticamente cada operación ORM inmediatamente y que comenzara una transacción.

Luego usamos la reversión y las confirmaciones explícitas.

Una dificultad: debido a que, a diferencia de SQLAlchemy, no estamos instrumentando las instancias
del modelo de dominio en sí mismas, el comando commit() necesita pasar explícitamente por todos
los objetos que han sido tocados por cada repositorio y actualizarlos manualmente al ORM. .

API: las vistas de Django son adaptadores

El archivo views.py de Django termina siendo casi idéntico al antiguo ask_app.py, porque nuestra
arquitectura significa que es un envoltorio muy delgado alrededor de nuestra capa de servicio (que, por
cierto, no cambió en absoluto):

Aplicación Flask ÿ Vistas de Django (src/djangoproject/alloc/views.py)


os.environ['DJANGO_SETTINGS_MODULE'] = 'djangoproject.django_project.settings' django.setup()

@csrf_exempt
def add_batch(solicitud):
datos = json.loads(solicitud.cuerpo) eta
= datos['eta']

250 | Apéndice D: Patrones de repositorio y unidad de trabajo con Django


Machine Translated by Google

si eta no es Ninguno:
eta = datetime.fromisoformat(eta).date()
services.add_batch( data['ref'], data['sku'], data['qty'],
eta, unit_of_work.DjangoUnitOfWork() ,

) devuelve HttpResponse('OK', estado=201)

@csrf_exempt
def allocate(solicitud): data
= json.loads(request.body) try: batchref
= services.allocate( data['orderid'],
data['sku'], data['qty'],
unit_of_work.DjangoUnitOfWork
(),

)
excepto (model.OutOfStock, services.InvalidSku) como e:
devolver JsonResponse({'mensaje': str(e)}, estado=400)

devolver JsonResponse ({'batchref': loteref}, estado = 201)

¿Por qué fue todo tan difícil?


Está bien, funciona, pero se siente como un mayor esfuerzo que Flask/SQLAlchemy. ¿Porqué es eso?

La razón principal en un nivel bajo es que el ORM de Django no funciona de la misma manera. No
tenemos un equivalente del mapeador clásico SQLAlchemy, por lo que nuestro Active Record y nuestro
modelo de dominio no pueden ser el mismo objeto. En su lugar, tenemos que construir una capa de
traducción manual detrás del repositorio. Eso es más trabajo (aunque una vez hecho, la carga de
mantenimiento continuo no debería ser demasiado alta).

Debido a que Django está tan estrechamente acoplado a la base de datos, debe usar ayudantes como
pytest-django y pensar detenidamente en las bases de datos de prueba, desde la primera línea de código,
de una manera que no teníamos que hacer cuando comenzamos con nuestro modelo de dominio puro.

Pero en un nivel más alto, la única razón por la que Django es tan bueno es que está diseñado en torno
al punto óptimo de facilitar la creación de aplicaciones CRUD con un mínimo de repeticiones. Pero todo
el enfoque de nuestro libro se trata de qué hacer cuando su aplicación ya no es una simple aplicación
CRUD.

En ese punto, Django comienza a entorpecer más de lo que ayuda. Cosas como el administrador de
Django, que son tan increíbles cuando comienzas, se vuelven activamente peligrosas si el objetivo de tu
aplicación es construir un conjunto complejo de reglas y modelado en torno al flujo de trabajo de los
cambios de estado. El administrador de Django pasa por alto todo eso.

Repositorio y Unidad de Patrones de Trabajo con Django | 251


Machine Translated by Google

Qué hacer si ya tienes Django


Entonces, ¿qué debe hacer si desea aplicar algunos de los patrones de este libro a una aplicación de
Django? Diríamos lo siguiente:

• Los patrones Repositorio y Unidad de trabajo van a ser bastante trabajosos.


Lo principal que le comprarán a corto plazo son pruebas unitarias más rápidas, así que evalúe si
ese beneficio vale la pena en su caso. A más largo plazo, desacoplan su aplicación de Django y la
base de datos, por lo que si prevé querer migrar lejos de cualquiera de ellos, Repository y UoW son
una buena idea.

• El patrón de la capa de servicio puede ser de su interés si ve mucha duplicación en sus vistas.py.
Puede ser una buena manera de pensar en sus casos de uso por separado de sus puntos finales
web. • Teóricamente, aún puede hacer DDD y modelado de dominio con modelos Django, ya que

están estrechamente acoplados a la base de datos; las migraciones pueden ralentizarlo, pero no
debería ser fatal. Entonces, siempre que su aplicación no sea demasiado compleja y sus pruebas
no sean demasiado lentas, es posible que pueda obtener algo del enfoque de modelos gordos:
aplique tanta lógica a sus modelos como sea posible y aplique patrones como Entidad, Objeto de
valor y Agregado. Sin embargo, vea la siguiente advertencia.

Dicho esto, se dice en la comunidad de Django que las personas encuentran que el enfoque de modelos
pesados se encuentra con problemas de escalabilidad propios, particularmente en torno a la gestión de
interdependencias entre aplicaciones. En esos casos, hay mucho que decir sobre la extracción de una
lógica empresarial o una capa de dominio para ubicarla entre sus vistas y formularios y su models.py,
que luego puede mantener al mínimo posible.

Pasos a lo largo del camino


Supongamos que está trabajando en un proyecto de Django que no está seguro de que vaya a ser lo
suficientemente complejo como para garantizar los patrones que recomendamos, pero aún desea
implementar algunos pasos para hacer su vida más fácil, tanto en el a medio plazo y si quieres migrar a
alguno de nuestros patrones más adelante. Considera lo siguiente:

• Un consejo que hemos escuchado es poner un logic.py en cada aplicación de Django desde el primer
día. Esto le brinda un lugar para colocar la lógica de negocios y mantener sus formularios, vistas y
modelos libres de lógica de negocios. Puede convertirse en un trampolín para pasar más adelante
a un modelo de dominio completamente desacoplado y/o una capa de servicio.

• Una capa de lógica de negocios podría comenzar a trabajar con objetos de modelo de Django y solo
más tarde se desvincularía por completo del marco y trabajar en estructuras de datos simples de
Python.

252 | Apéndice D: Patrones de repositorio y unidad de trabajo con Django


Machine Translated by Google

• Para el lado de lectura, puede obtener algunos de los beneficios de CQRS poniendo lecturas en
un solo lugar, evitando llamadas ORM esparcidas por todo el lugar.

• Al separar módulos para lecturas y módulos para lógica de dominio, puede valer la pena
desvincularse de la jerarquía de aplicaciones de Django. Las preocupaciones comerciales los
atravesarán.

Nos gustaría agradecer a David Seddon y Ashia Zawaduk por hablar


sobre algunas de las ideas de este apéndice. Hicieron todo lo posible
para evitar que dijéramos algo realmente estúpido sobre un tema del
que no tenemos suficiente experiencia personal, pero es posible que
hayan fallado.

Para obtener más ideas y experiencias reales relacionadas con las aplicaciones existentes, consulte
el epílogo.

Repositorio y Unidad de Patrones de Trabajo con Django | 253


Machine Translated by Google
Machine Translated by Google

APÉNDICE E

Validación

Cada vez que enseñamos y hablamos sobre estas técnicas, una pregunta que surge una y otra vez es
“¿Dónde debo hacer la validación? ¿Pertenece eso a mi lógica de negocios en el modelo de dominio, o es
una preocupación de infraestructura?”

Como con cualquier pregunta arquitectónica, la respuesta es: ¡depende!

La consideración más importante es que queremos mantener nuestro código bien separado para que cada
parte del sistema sea simple. No queremos saturar nuestro código con detalles irrelevantes.

¿Qué es la validación, de todos modos?

Cuando las personas usan la palabra validación, generalmente se refieren a un proceso mediante el cual
prueban las entradas de una operación para asegurarse de que cumplan con ciertos criterios. Las entradas
que coinciden con los criterios se consideran válidas y las entradas que no lo hacen no son válidas.

Si la entrada no es válida, la operación no puede continuar pero debería salir con algún tipo de error. En otras
palabras, la validación consiste en crear condiciones previas. Encontramos útil separar nuestras condiciones
previas en tres subtipos: sintaxis, semántica y pragmática.

Sintaxis de validación

En lingüística, la sintaxis de una lengua es el conjunto de reglas que rigen la estructura de las oraciones
gramaticales. Por ejemplo, en inglés, la oración "Asignar tres unidades de TASTELESS-LAMP para ordenar
veintisiete" es gramaticalmente correcta, mientras que la frase "hat hat hat hat hat hat wibble" no lo es.
Podemos describir oraciones gramaticalmente correctas como bien formadas.

255
Machine Translated by Google

¿Cómo se relaciona esto con nuestra aplicación? Estos son algunos ejemplos de reglas sintácticas:

• Un comando Asignar debe tener un ID de pedido, un SKU y una cantidad. • Una cantidad es un

entero positivo. • Un SKU es una cadena.

Estas son reglas sobre la forma y la estructura de los datos entrantes. Un comando Asignar sin SKU o ID de pedido
no es un mensaje válido. Es el equivalente de la frase "Asignar tres a".

Tendemos a validar estas reglas en el borde del sistema. Nuestra regla general es que un manejador de mensajes
siempre debe recibir solo un mensaje que esté bien formado y que contenga toda la información requerida.

Una opción es poner su lógica de validación en el tipo de mensaje en sí:

Validación en la clase de mensaje (src/allocation/commands.py) de


la importación de esquemas Y, Esquema, Uso

@dataclass
class Asignar (Comando):

_schema =
Schema({ 'orderid':
int, sku: str, qty:
And(Use(int), lambda n: n > 0) },
ignore_extra_keys=True)

orderid: str sku:


str qty: int

@classmethod
def from_json(cls, datos):
datos = json.loads(datos)
return cls(**_schema.validate(datos))

La biblioteca de esquemas nos permite describir la estructura y la validación de nuestros mensajes de una
manera declarativa agradable.

El método from_json lee una cadena como JSON y la convierte en nuestro tipo de mensaje.

Sin embargo, esto puede volverse repetitivo, ya que necesitamos especificar nuestros campos dos veces, por lo que
es posible que deseemos introducir una biblioteca auxiliar que pueda unificar la validación y declaración de nuestros
tipos de mensajes:

256 | Apéndice E: Validación


Machine Translated by Google

Una fábrica de comandos con esquema (src/allocation/commands.py)


def comando(nombre, **campos):
esquema = Esquema(Y(Usar(json.cargas), campos), ignore_extra_keys=True) cls =
make_dataclass(nombre, campos.claves()) cls.from_json = lambda s: cls
(**schema.validate(s)) devuelve cls

def mayor_que_cero(x):
devuelve x > 0

cantidad = Y(Usar(int), mayor_que_cero)

Asignar = comando
( orderid = int, sku
= str, qty = cantidad

AddStock =
comando( sku=str,
qty=cantidad

La función de comando toma un nombre de mensaje, más kwargs para los campos de la carga útil del
mensaje, donde el nombre del kwarg es el nombre del campo y el valor es el analizador.

Usamos la función make_dataclass del módulo dataclass para crear dinámicamente nuestro tipo de
mensaje.

Parcheamos el método from_json en nuestra clase de datos dinámica.

Podemos crear analizadores reutilizables para cantidad, SKU, etc. para mantener las cosas SECAS.

Declarar un tipo de mensaje se convierte en una sola línea.

Esto se produce a expensas de perder los tipos en su clase de datos, así que tenga en cuenta esa
compensación.

La ley de Postel y el patrón del lector tolerante


La ley de Postel, o el principio de robustez, nos dice: “Sé liberal en lo que aceptas y conservador en lo que
emites”. Creemos que esto se aplica particularmente bien en el contexto de la integración con nuestros otros
sistemas. La idea aquí es que debemos ser estrictos cuando enviamos mensajes a otros sistemas, pero lo
más indulgentes posible cuando recibimos mensajes de otros.

Validación | 257
Machine Translated by Google

Por ejemplo, nuestro sistema podría validar el formato de un SKU. Hemos estado usando SKU
inventados como UNFORGIVING- COSHION y MISBEGOTTEN-POUFFE. Estos siguen un patrón
simple: dos palabras, separadas por guiones, donde la segunda palabra es el tipo de producto y la
primera palabra es un adjetivo.

A los desarrolladores les encanta validar este tipo de cosas en sus mensajes y rechazar cualquier cosa
que parezca un SKU no válido. Esto causa problemas horribles en el futuro cuando algún anarquista
lanza un producto llamado COMFY-CHAISE-LONGUE o cuando un problema con el proveedor resulta
en un envío de CHEAP-CARPET-2.

Realmente, como sistema de asignación, no es de nuestro incumbencia cuál podría ser el formato de
un SKU. Todo lo que necesitamos es un identificador, por lo que simplemente podemos describirlo como
una cadena. Esto significa que el sistema de adquisiciones puede cambiar el formato cuando lo desee,
y no nos importará.

Este mismo principio se aplica a los números de pedido, los números de teléfono de los clientes y mucho
más. En su mayor parte, podemos ignorar la estructura interna de las cadenas.

Del mismo modo, a los desarrolladores les encanta validar los mensajes entrantes con herramientas
como JSON Schema o crear bibliotecas que validen los mensajes entrantes y los compartan entre
sistemas. Esto también falla la prueba de robustez.

Imaginemos, por ejemplo, que el sistema de compras agrega nuevos campos al mensaje Change
BatchQuantity que registran el motivo del cambio y el correo electrónico del usuario responsable del
cambio.

Dado que estos campos no son importantes para el servicio de asignación, simplemente deberíamos
ignorarlos. Podemos hacerlo en la biblioteca de esquemas pasando la palabra clave arg
ignore_extra_keys=True.

Este patrón, mediante el cual extraemos solo los campos que nos interesan y hacemos una validación
mínima de ellos, es el patrón Tolerant Reader.

Validar lo menos posible. Lea solo los campos que necesita y no especifique
demasiado su contenido. Esto ayudará a que su sistema se mantenga sólido
cuando otros sistemas cambien con el tiempo. Resista la tentación de compartir
definiciones de mensajes entre sistemas: en su lugar, facilite la definición de
los datos de los que depende. Para obtener más información, consulte el
artículo de Martin Fowler sobre el patrón Tolerant Reader.

258 | Apéndice E: Validación


Machine Translated by Google

¿Postel siempre tiene la razón?

Mencionar a Postel puede ser bastante estimulante para algunas personas. Te dirán que
Postel es la razón precisa por la que todo en Internet está roto y no podemos tener cosas
bonitas. Pregúntele a Hynek sobre SSLv3 algún día.

Nos gusta el enfoque de Tolerant Reader en el contexto particular de la integración basada


en eventos entre los servicios que controlamos, porque permite la evolución independiente
de esos servicios.

Si está a cargo de una API que está abierta al público en Internet, puede haber buenas
razones para ser más conservador con respecto a las entradas que permite.

Validación en el borde
Anteriormente, dijimos que queremos evitar saturar nuestro código con detalles irrelevantes. En
particular, no queremos codificar a la defensiva dentro de nuestro modelo de dominio. En su lugar,
queremos asegurarnos de que se sepa que las solicitudes son válidas antes de que nuestro modelo de
dominio o los controladores de casos de uso las vean. Esto ayuda a que nuestro código se mantenga
limpio y mantenible a largo plazo. A veces nos referimos a esto como validación en el borde del sistema.

Además de mantener su código limpio y libre de comprobaciones y afirmaciones interminables, tenga


en cuenta que los datos no válidos que deambulan por su sistema son una bomba de relojería; cuanto
más profundo se vuelve, más daño puede causar y menos herramientas tienes para responder.

En el Capítulo 8, dijimos que el bus de mensajes era un gran lugar para poner preocupaciones
transversales, y la validación es un ejemplo perfecto de eso. Así es como podríamos cambiar nuestro
bus para realizar la validación por nosotros:

Validación
clase MessageBus:

def handle_message(self, nombre: str, cuerpo: str):

intente: tipo_mensaje = siguiente(mt para mt en EVENT_HANDLERS si mt.__name__ ==


nombre) mensaje = tipo_mensaje.from_json(cuerpo) self.handle([mensaje]) excepto
StopIteration: raise KeyError(f" Nombre de mensaje desconocido {nombre}" ) excepto
ValidationError como e: logging.error( f'mensaje no válido de tipo {nombre}\n' f'{cuerpo}\n'
f'{e}'

) subir e

Validación | 259
Machine Translated by Google

Así es como podríamos usar ese método desde nuestro punto final de Flask API:

API muestra errores de validación (src/allocation/ask_app.py)


@app.route("/change_quantity", method =['POST']) def
change_batch_quantity(): try:
bus.handle_message('ChangeBatchQuantity',
request.body)
excepto ValidationError como e:
devuelve bad_request(e)
excepto excepciones . InvalidSku como
e: devuelve jsonify({'message': str(e)}), 400

def bad_request(e: ValidationError): devuelve


e.code, 400

Y así es como podríamos conectarlo a nuestro procesador de mensajes asíncrono:

Errores de validación al manejar mensajes de Redis (src/allocation/redis_pubsub.py)


def handle_change_batch_quantity(m, bus: messagebus.MessageBus):

prueba: bus.handle_message('ChangeBatchQuantity', m)
excepto ValidationError:
print('Omitir mensaje inválido')
excepto excepciones . InvalidSku as e:
print(f'No se puede cambiar el stock por el sku faltante {e}')

Tenga en cuenta que nuestros puntos de entrada se refieren únicamente a cómo obtener un mensaje
del mundo exterior y cómo informar el éxito o el fracaso. Nuestro bus de mensajes se encarga de
validar nuestras solicitudes y enrutarlas al controlador correcto, y nuestros controladores se centran
exclusivamente en la lógica de nuestro caso de uso.

Cuando recibe un mensaje no válido, por lo general no hay mucho que pueda
hacer más que registrar el error y continuar. En MADE usamos métricas para
contar la cantidad de mensajes que recibe un sistema y cuántos de ellos se
procesan correctamente, se omiten o no son válidos. Nuestras herramientas
de monitoreo nos alertarán si vemos picos en la cantidad de mensajes
incorrectos.

Validación de la semántica
Mientras que la sintaxis se ocupa de la estructura de los mensajes, la semántica es el estudio del
significado de los mensajes. La oración "Deshacer los perros de los puntos suspensivos cuatro" es
sintácticamente válida y tiene la misma estructura que la oración "Asignar una tetera para pedir cinco",
pero no tiene sentido.

Podemos leer este blob JSON como un comando Allocate pero no podemos ejecutarlo con éxito
porque no tiene sentido:

260 | Apéndice E: Validación


Machine Translated by Google

Un mensaje sin sentido


{
"orderid": "superman",
"sku": "cigoto", "cantidad":
-1
}

Tendemos a validar las preocupaciones semánticas en la capa del controlador de mensajes con una especie de
programación basada en contratos:

Condiciones previas (src/allocation/ensure.py)


"""
Este módulo contiene condiciones previas que aplicamos a nuestros controladores.
"""

mensaje de clase no procesable (excepción):

def __init__(self, mensaje):


self.message = mensaje

clase ProductNotFound(Mensaje no procesable):


""""
Esta excepción se genera cuando intentamos realizar una acción en un producto que
no existe en nuestra base de datos.
""""

def __init__(auto, mensaje):


super().__init__(mensaje)
self.sku = mensaje.sku

def product_exists(evento, uow):


producto = uow.products.get(event.sku) si el
producto es Ninguno: aumentar
ProductNotFound(evento)

Usamos una clase base común para los errores que significan que un mensaje no es válido.

El uso de un tipo de error específico para este problema hace que sea más fácil informar y manejar el
error. Por ejemplo, es fácil asignar ProductNotFound a un 404 en Flask.

product_exists es una condición previa. Si la condición es Falsa, generamos un error.

Esto mantiene limpio y declarativo el flujo principal de nuestra lógica en la capa de servicio:

Asegurar llamadas en servicios (src/allocation/services.py)


# servicios.py

desde la importación de asignación asegurar

Validación | 261
Machine Translated by Google

def allocate(evento, uow): línea


= mode.OrderLine(event.orderid, event.sku, event.qty) with uow:

asegurar.product_exists(uow, evento)

producto = uow.products.get(line.sku)
product.allocate(line) uow.commit()

Podemos extender esta técnica para asegurarnos de que aplicamos los mensajes de manera idempotente.
Por ejemplo, queremos asegurarnos de no insertar un lote de stock más de una vez.

Si nos piden que creemos un lote que ya existe, registraremos una advertencia y continuaremos con el
siguiente mensaje:

Genera la excepción SkipMessage para eventos ignorables (src/allocation/


services.py) clase SkipMessage (Excepción):
""""

Esta excepción se genera cuando no se puede procesar un mensaje, pero no hay un


comportamiento incorrecto. Por ejemplo, podemos recibir el mismo mensaje varias veces, o
podemos recibir un mensaje que ahora está desactualizado.
""""

def __init__(yo, razón): yo.razón


= razón

def lote_es_nuevo(yo, evento, uow):


lote = uow.batches.get(event.batchid) si el lote
no es Ninguno: aumentar SkipMessage(f"El lote
con id {event.batchid} ya existe")

La introducción de una excepción SkipMessage nos permite manejar estos casos de forma genérica en
nuestro bus de mensajes:

El bus ahora sabe cómo omitir (src/allocation/messagebus.py)

clase MessageBus:

def handle_message(self, mensaje):


probar:
...
excepto SkipMessage como e:
logging.warn(f"Omitiendo el mensaje {message.id} porque {e.reason}")

Hay un par de trampas a tener en cuenta aquí. Primero, debemos asegurarnos de que estamos usando la
misma UoW que usamos para la lógica principal de nuestro caso de uso. De lo contrario, nos exponemos a
irritantes errores de concurrencia.

262 | Apéndice E: Validación


Machine Translated by Google

En segundo lugar, debemos tratar de evitar poner toda nuestra lógica empresarial en estas comprobaciones
de condiciones previas. Como regla general, si una regla puede probarse dentro de nuestro modelo de dominio,
entonces debe probarse en el modelo de dominio.

Validación de la pragmática
La pragmática es el estudio de cómo entendemos el lenguaje en contexto. Después de analizar un mensaje y
comprender su significado, aún debemos procesarlo en contexto. Por ejemplo, si recibe un comentario en una
solicitud de extracción que dice: "Creo que esto es muy valiente", puede significar que el revisor admira su
coraje, a menos que sea británico, en cuyo caso, está tratando de decirle que lo que estás haciendo es
increíblemente arriesgado y que solo un tonto lo intentaría. El contexto lo es todo.

Resumen de validación

Validación significa diferentes cosas para diferentes personas Cuando


hable de validación, asegúrese de tener claro lo que está validando. Nos resulta útil pensar en la sintaxis, la
semántica y la pragmática: la estructura de los mensajes, el significado de los mensajes y la lógica comercial
que rige nuestra respuesta a los mensajes.

Validar en el borde cuando sea posible


Validar los campos obligatorios y los rangos de números permitidos es aburrido, y queremos mantenerlo fuera
de nuestra base de código agradable y limpia. Los controladores siempre deben recibir solo mensajes válidos.

Solo valida lo que requieres


Use el patrón Tolerant Reader: lea solo los campos que su aplicación necesita y no especifique demasiado su
estructura interna. Tratar los campos como cadenas opacas le otorga mucha flexibilidad.

Dedique tiempo a escribir ayudantes para la validación


Tener una buena forma declarativa de validar los mensajes entrantes y aplicar condiciones previas a sus
controladores hará que su base de código sea mucho más limpia. Vale la pena invertir tiempo para hacer que el
código aburrido sea fácil de mantener.

Ubique cada uno de los tres tipos de validación en el lugar correcto. La


validación de la sintaxis puede ocurrir en las clases de mensajes, la validación de la semántica puede ocurrir en
la capa de servicio o en el bus de mensajes, y la validación de la pragmática pertenece al modelo de dominio.

Validación | 263
Machine Translated by Google

Una vez que haya validado la sintaxis y la semántica de sus comandos


en los bordes de su sistema, el dominio es el lugar para el resto de su
validación. La validación de la pragmática suele ser una parte central de
las reglas de su negocio.

En términos de software, la pragmática de una operación suele estar gestionada por el modelo
de dominio. Cuando recibimos un mensaje como "asignar tres millones de unidades de
SCARCE-CLOCK al pedido 76543", el mensaje es sintácticamente válido y semánticamente
válido, pero no podemos cumplir porque no tenemos el stock disponible.

264 | Apéndice E: Validación


Machine Translated by Google

Índice

simbolos patrones inspirados en puertos y adaptadores, 114


@abc.abstractmethod, 32 poner en carpeta, 68
Patrón agregado, 98
agregados sobre, 98
actuando como límites
Clases base abstractas (ABC )
de consistencia, 155 y resumen de límites de
ABC para el repositorio, 32
consistencia, 111 cambiando múltiples agregados
definición para notificaciones, 206
en una solicitud,
cambio a escritura. Protocolo, 129 uso de 130
escritura pato y protocolos en lugar de,
elegir un agregado, 99-102 ejercicio
33
para el lector, 104
usando para puertos,
Historia agregada registrando órdenes y generando
38 métodos abstractos, 32
eventos de dominio, 156 identificando agregados y
abstracciones, 41-54
contextos acotados, 215-218
estado de abstracción para ayudar a la capacidad
de prueba, 43-45 AbstractRepository, función de
un agregado = un repositorio, 102 concurrencia
servicio dependiendo de, 64 AbstractUnitOfWork,
optimista con números de versión, 105-108 rendimiento
85 elección de la abstracción correcta, 46-47
y, 104
dependencias explícitas son más abstractas, 195
implementación de la abstracción elegida, 47-53 pruebas
Producto agregado, 95 pros
de borde a borde con falsificaciones y inyección de
y contras o compensaciones, 112
dependencia, 49-51 no usar mock.patch para
consulta sobre el repositorio que devuelve un solo
pruebas, 51 simplificar la interfaz entre la lógica
agregado, 146 generación de eventos sobre, 157
de negocios y la E/S, 53 usar para reducir el
repositorio que realiza un seguimiento de los agregados
acoplamiento, 42 adaptadores construir adaptador y
que pasan a través de él, 127 prueba de reglas de
hacer inyección de dependencia para él, 205-210
integridad de datos, 109-111 prueba Producto
definir implementaciones abstractas y concretas ÿ
objeto para suscitar eventos, 122
mentaciones, 206 definidas, 37 Django vistas, 250 ejercicio
para el lector, 209
UoW recopilando eventos y pasándolos al bus de
mensajes, 131 asignar asignación de servicios
contra todos los lotes con, 99 pasar a ser un método en

Agregado de productos, 101

265
Machine Translated by Google

Evento asignado, 171 inicializando la inyección de dependencia en las pruebas,


Evento de asignación requerida, 137 204

pasando a services.allocate, 136 usando en puntos de entrada, 203


Antipatrón de dominio anémico, 70 usando para construir un bus de mensajes que habla con
API clase de notificación, 208

agregar API para agregar un lote, 78 contextos acotados, 102

Vistas de Django como adaptadores, 250 identificación de agregados y, 215-218 concepto


prueba de extremo a extremo de la API de de producto y, 101 abstracciones de lógica de
asignación, 57 modificación de la API para trabajar con negocios que simplifican la interfaz con E/S
eventos, 142 uso del repositorio directamente en el punto final de la API, desordenada, 53 separación del estado en el código,
36 47 capa de lógica de negocios, 6 invariantes de
sin patrón de Unidad de trabajo, hablando reglas de negocios, concurrencia y bloqueos, 97
directamente con tres capas, 81 servicios de invariantes, restricciones, y coherencia, 97
aplicación, 66 arquitectura, diagrama y tabla de resumen,

229-230

mensajería asíncrona, desacoplamiento temporal con, 167


operaciones atómicas, 81
C
Herramienta Celery,
Unidad de trabajo como abstracción, 93 usando
124 pruebas del controlador
Unidad de trabajo para agrupar operaciones en unidades change_batch_quantity para,
atómicas, 91-92
144 implementación, delegación del controlador a la
capa del modelo, 145 coreografía, 121 clases,
B uso de inyección de dependencia, 197 mapeo clásico, 28
Patrón de bola de barro, 42 bola cierres
de barro distribuida y pensamiento en sustantivos,
162-165 separando responsabilidades, 212

evento BatchCreated, 137 services.add_batch como inyección de dependencia usando, 196


controlador para, 136 lotes diferencia de funciones parciales, 197 cohesión,
alta, entre elementos acoplados, 41 colaboradores, 84
colecciones, 98

asignar contra todos los lotes utilizando el servicio de


dominio, 99 pedir al Producto que asigne contra, 100 Patrón de controlador de comandos,
cantidades de lotes cambiadas significa desasignar y segregación de responsabilidad de consulta de comando 160
reasignar, 135 colección de, 99 (CQRS), 175-190
construyendo vistas de solo lectura en nuestros datos, 181
cambiando la implementación del modelo de lectura para usar
Implementación del evento Redis, 188

BatchQuantityChanged, 143 copia desnormalizada de sus datos optimizada para


invocando el controlador change_batch_quantity, operaciones de lectura, 185 modelo de dominio no
136 optimizado para operaciones de lectura, 183 modelos de
Bernhardt, Gary, 47 dominio para escritura, 176 CQRS completo versus

arranque, 191 guión de opciones más simples,


arranque, capacidades de, 199 cambio de dependencia
de notificaciones en guión de arranque, 206 inyección de 189

dependencia y recapitulación de arranque, Publicar/Redireccionar/Obtener patrón y CQS, 179 lado


de lectura y lado de escritura, 178 lecturas, 177
209 consistencia de, 178

inyección de dependencia con, 196

266 | Índice
Machine Translated by Google

reconstruir el modelo de vista desde cero, 188 reproducción del comportamiento con la función time.sleep,
SELECT N+1 y otros problemas de rendimiento, 184 vista 109 conciencia, 166 consistencia, 96 logro de
simple usando el repositorio existente, 182 vistas de consistencia de lectura, 178 lecturas eventualmente consistentes,

prueba, 182 compensaciones para las opciones del modelo 177


de vista, 189 actualización de la tabla del modelo de lectura
usando el controlador de eventos, 186 vista que usa el ORM,
184 mandamientos, 151-160 límites de coherencia, 95, 98 agregados
que actúan como, 155 microservicios
como, 167 resumen, 112 restricciones,

96
flujo de comando para reservar stock, confirmar

reservar, despachar mercancías y hacer cliente VIP, administrador de contexto,


163 82 unidad de trabajo inicial como, 84
flujo de comandos cuando el almacén sabe que el Unidad de trabajo y, 85-88 flujo

stock está dañado, 164 flujo de comandos con de control, uso de excepciones para, 123
error, 165 lógica del controlador de comandos en el acoplamiento, 41 evitar acoplamiento inapropiado,
bus de mensajes, 202 eventos versus, 151-152 eventos, 166 desventajas de, 42 lógica de dominio junto con
comandos y manejo de errores, 155-157 E/S, 45 cascada de fallas como acoplamiento
temporal, 165 en pruebas que usan simulacros, 51
reducir mediante la abstracción de los detalles, 42
recuperarse de errores sincrónicamente, separar lo que quiere hacer de cómo hacerlo, 46
158 desacoplamiento temporal mediante mensajería
manejo de excepciones, 154 asíncrona, 167 compensación entre comentarios de diseño y,
manejadores para, 154 en nuestro 73

sistema ahora, 152 salida del


programa como lista de comandos, 46 división de
comandos y eventos, compensaciones,
160 CQRS (ver segregación de responsabilidad de consulta de
confirma comando)
método de confirmación, CQS (separación de consulta de comando), 179
87 pruebas explícitas para, Envoltorio CRUD alrededor de una base de datos, 114
89 explícito versus implícito, 90 CSV sobre arquitectura SMTP, 96

diagrama de componentes al final de la primera parte, 113 CSVs, haciendo todo con, 239-244
composición sobre herencia en TrackingRepoÿ
clase contenedora de historia, 129 D
raíz de composición, 191, 196
acceso a datos, aplicando el principio de inversión de
concurrencia, 97 agregados y
dependencia a, 25 problemas de integridad de datos
problemas de concurrencia, 112 permitiendo el mayor
que surgen de dividir la operación en dos UoW, 143 pruebas
grado de, 98 aplicación de reglas usando transacciones
para, 109-111
de base de datos,
110

prueba de integración para, 109


almacenamiento de datos, patrón de repositorio y 23
no proporcionada por la implementación del bus de bases de datos
mensajes, 124 concurrencia optimista con números
Sesión de adición de SQLAlchemy para Unidad de
de versión, 105-108 ejemplo de concurrencia pesimista, Trabajo, 86
SELECCIONAR
asignaciones de prueba persistieron en la base de datos,
59 transacciones de prueba contra la base de datos real, 90
PARA ACTUALIZAR, 111
Estado de gestión del patrón de unidad de trabajo para 82
clases de datos

Índice | 267
Machine Translated by Google

eventos, 121 aplicación de patrones a la aplicación Django,


uso para tipos de mensajes, 158 252 pasos a lo largo del camino, 252
uso para objetos de valor, 15 instalación, 245
desasignación de servicio, construcción (ejercicio), ejemplo ORM, 28
66 dependencias dependencias abstractas de la capa Patrón de depósito con, 246-249
de servicio, 68 pruebas, 68 dependencias circulares Patrón de unidad de trabajo con, 249-250
entre manejadores de eventos, 130 dependiendo usando, dificultad de, 251 vistas son
de abstracciones, 64 de extremo a extremo pruebas de adaptadores, 250
borde con inyección de dependencia, 49-51 Entorno de desarrollo de Docker con servidor de correo

manteniendo todas las dependencias de dominio en electrónico falso real, 207 diseño controlado por dominio

funciones de dispositivo, 76 ninguna en el modelo de (DDD), 5, 7 (ver también modelo de dominio; modelado de
dominio, 25 dependencias de capa de servicio real en dominio)
tiempo de ejecución, Patrón agregado, 98
contextos acotados, 102

elección del agregado correcto, referencias en,


111
68 dominio, definido, 6

dependencia de la capa de servicio en UoW abstracto, patrón de repositorio y, 33


89 Patrón de eventos de dominio, 117
UoW ya no depende del bus de mensajes, excepciones de dominio, 20 idioma
139 de dominio, 9 capa de dominio que
cadenas de dependencia, 201 se desacopla por completo de la capa
inyección de dependencia, 191-198 de servicio, 75-77 pruebas que se trasladan a la capa
mediante la inspección de firmas de funciones, 200 de servicio, 72 motivos, 73 modelo de dominio, 26-31
dependencias explícitas son mejores que
dependencias implícitas, 194 dependencias
implícitas frente a explícitas, 193 creación manual de decidir si escribir pruebas en contra, 73
funciones parciales en línea, Métodos ORM personalizados de Django para conversión,
200 247 correo electrónico enviando código, evitando,
DI manual con cierres o funciones parciales, 196 119 eventos desde, pasando al bus de mensajes en la
resumen de DI y bootstrap, 209 usando clases, capa de servicio, 124 carpeta para, 67 obteniendo
197 usando el marco DI, 201 principio de inversión de beneficios del modelo enriquecido, 70 invariantes,
dependencia, 26, 94 declarando dependencia explícita restricciones y consistencia, 96 manteniendo un pequeño

como ejemplo de, núcleo de pruebas escritas en contra, 79 nuevo método


en, change_batch_quantity,

195

ORM depende del modelo de datos, 28


diccionarios diccionario de hash a rutas, 48 para 146

operaciones de sistema de archivos, 46 no optimizado para operaciones de lectura, 183


persistente, 24 eventos de generación , 122 eventos
MANIPULADORES de dictados para comandos y de generación y capa de servicio pasándolos al bus
eventos, 154 de mensajes, 131
estructura de directorios, poner proyecto en carpetas,
67 compensaciones como un diagrama,
Bola de barro distribuida antipatrón y pensamiento 39 traduciendo a la forma ORM normal de
en sustantivos, 162-165 evitación, 167 la base de datos relacional, el modelo depende de
ORM, 27

Django, 245-253 ORM depende del modelo, 28

268 | Índice
Machine Translated by Google

usar hojas de cálculo en lugar de, 96 escribir arquitectura basada en eventos que

datos, 176 escribir pruebas en contra, 75 va a microservicios a través del patrón Strangler, 218-220

modelado de dominio, 5-21 uso de eventos para integrar microservicios, 161-173


lenguaje de dominio, 9
funciones para servicios de dominio, 19-22 modelos eventos

de dominio de pruebas unitarias, 10-19 clases de cambio de esquema a lo largo del tiempo,
datos para objetos de valor, 15 objetos y 226 comandos versus, 151-153 eventos,

entidades de valor, 17 servicios de dominio, comandos y manejo de errores, 155-157 interno versus
19, 66 función para, 19 adaptadores controlados , 68 externo, 172 división de comandos y eventos,

digitación pato, 33 para puertos, 38 compensaciones,

160

eventos y el bus de mensajes, 117-130 eventos de


dominio y recapitulación del bus de mensajes, 131 eventos
Y de generación de modelos de dominio, 122 eventos como
clases de datos simples, 121 eventos que fluyen a través
Pruebas E2E (ver pruebas de extremo a
del sistema, 117 eventos de asignación de bus de mensajes
extremo) carga ansiosa, 184 pruebas de
a controladores,
extremo a extremo, 49-51
123
Diseño agregado efectivo (Vernon), 111 alertas por correo
pros y contras o compensaciones, 130
electrónico, envío cuando no hay existencias, 118-120
pruebas de extremo a extremo con el objetivo de una registrar eventos, 121 enviar alertas por

prueba por función, 79 desacoplamiento de la capa de correo electrónico cuando no hay existencias, 118-120

servicio del dominio, 78 de asignación de API, 57


evitar estropear el modelo de dominio, 119 evitar
reemplazo con pruebas unitarias, 53 __enter__ y __exit__
estropear los controladores web,
métodos mágicos, 86, 87
118

fuera de lugar en la capa de servicio, 120 violando


el principio de responsabilidad única, 120 capa de servicio

entidades generando sus propios eventos, 125 capa de servicio

definido, 17 con bus de mensajes explícitos, 124 transformando nuestra


aplicación en procesador de mensajes, 133-150 arquitectura
igualdad de identidad, 18
imaginada, todo será un controlador de eventos, 136
objetos de valor versus, 21
implementará el nuevo requisito, 143-144
puntos de entrada, 68 método
__eq__magic, 18 operadores de
igualdad, implementación en entidades, 18 manejo de errores
contando como una característica, 79 eventos, comandos y,
155-157 en sistemas distribuidos, 165- 167 errores, recuperando
de forma síncrona, 158 modificando API para trabajar con eventos, 142 nuevo
requisito y nueva arquitectura,
135

Evans, Eric, 98 refactorización de funciones de servicio para

controladores de controladores de mensajes, 137 pirateo temporal,

bus de mensajes que devuelve resultados, 141 prueba


eventos imaginaron una arquitectura en la que todo es un
de manejo de un nuevo controlador, 144 pruebas
controlador de eventos, 136 en el bus de mensajes,
escritas en términos de eventos, 141 unidad de prueba
202 administrar actualizaciones para leer el modelo, 189
de controladores de eventos con bus de mensajes
actualizar la tabla del modelo de lectura usando, 186
falsos, 147
tormenta de eventos, 135

Índice | 269
Machine Translated by Google

toda la aplicación como bus de mensajes, compensaciones, primer corte de la aplicación, 58-60
150 introducción de la capa de servicio y el repositorio falso
UoW publica eventos en el bus de mensajes, 126 lecturas para realizar pruebas unitarias, 61-63 poner el

eventualmente consistentes, 177 manejo de excepciones, proyecto en carpetas, 67 beneficios de la capa de


diferencias para eventos y comandos, 153 excepciones que servicio, 68 dependencias de la capa de servicio, 68
expresan conceptos de dominio, 20 uso para flujo de control, pros y contras de la capa de servicio, 70 función típica
123 eventos externos, 124, 161-173 programación extrema (XP), de la capa de servicio , 63 poner el punto final de la API
exhortación escuchar el código, 74 delante de asignar

servicio de dominio, 57
Fowler, Martín, 52, 172
Freeman, Steve, 53 años

Núcleo funcional, shell imperativo (FCIS), 47 funciones, 21 para


servicios de dominio, 20 capa de servicio, 63

fingiendo _

FakeNotifications para pruebas unitarias, 206


FakeRepository, 61
GRAMO

agregando la función de dispositivo activado,

76 nuevo tipo de consulta activado, 146 Publicación "Complejidad global, simplicidad local", 39 Método
usando para probar unitariamente la capa de servicio, mágico __gt__, 20
62 falsificaciones versus simulacros, 52

FakeSession, usando para probar unitariamente la capa H


de servicio, 62 handlers
FakeUnitOfWork para pruebas de capa de servicio,
manejadores de eventos y comandos en el bus de
87
mensajes, 202 nuevos dicts HANDLER para comandos
falsificación de E/S en pruebas de extremo a y
extremo, 49 ajuste de falsificaciones en la capa de servicio eventos, 154
para llamar a super e implementar métodos de
método mágico __hash__, 18 hash de
subrayado, 128
un archivo, 43 diccionario de hash a
sistemas de
rutas, 46 elevación de E/S, 53
archivos que escriben código para sincronizar la fuente y el tar.
obtener directorios, 43-45
elegir la abstracción correcta, 46-47 implementar
yo

la abstracción elegida, 47-53


E/S
funciones fixture, manteniendo todas las dependencias del
dominio, 76 desentrañar detalles de la lógica del programa, 47 lógica de

Marco matraz, 25 dominio estrechamente acoplada, 45 simplificar la interfaz con


lógica de negocios usando abstracciones, 53 manejo de
Punto final de API, 36
mensajes idempotentes, 226 igualdad de identidad
arranque de llamada en puntos de entrada, 203
(entidades), 18 compromisos implícitos versus explícitos, 90 importar
punto final para ver asignaciones, 180
dependencias, 194 herencia, evitar el uso de con clase contenedora,
Flask API y capa de servicio, 55-70
aplicación que delega a la capa de servicio, 64
conexión de la aplicación al mundo real, 57 tipos
diferentes de servicios, 66 pruebas de extremo a
129
extremo para rutas felices y no felices, 65 condiciones de
pruebas de integración
error que requieren comprobaciones de la base de
para comportamiento de concurrencia, 109
datos, 60 prueba de extremo a extremo de la primera API,
57-58 unidad de trabajo de conducción de pruebas con,
84 interfaces, Python y, 37

270 | Índice
Machine Translated by Google

invariantes mapeo de eventos a controladores, 123


invariantes, concurrencia y bloqueos, 97 ahora recopila eventos de UoW, 139 ahora el
invariantes, restricciones y consistencia, 96 proteger principal punto de entrada a la capa de servicio,
mientras se permite la concurrencia, 98 adaptadores 134

internos, 68 niveles de aislamiento (transacción), 110 pros y contras o compensaciones, 130


recapitulación, 131 Redis pub/sub listener
como adaptador delgado alrededor, 170

j resultados devueltos en pirateo temporal,


141 capa de servicio generando eventos y llamando
Jung, Ed, 53
a messagebus.handle, 125 capa de servicio con bus de
mensajes explícitos, 124 Unidad de trabajo que
publica eventos en, 126 Unidad de prueba de
jugo k , 41
controladores de eventos con bus de mensajes falsos,
147 Aplicación completa como, compensaciones, 150
Cableado de nuevos controladores de eventos, 147
L arquitectura en capas, 25 Patrón de bus de mensajes, 117 Mensajería asíncrona,
casos de estudio, capas de un sistema cubierto, desacoplamiento temporal con,
214
bloqueos en tablas de bases de datos, 97

bloqueo optimista, 105, 107 bloqueo


pesimista, 107 167

Escuela londinense versus TDD de estilo clásico, 52 manejo de mensajes idempotentes, 226 la
mensajería confiable es difícil, 226 usar el

M canal pub/sub de Redis para microserÿ


integración de vicios, 168
métodos mágicos
contextos acotados de microservicios
que permiten el uso del modelo de dominio
y, 102 integración basada en
con Python idiomático, 20 __enter__ y
__exit__, 86 __eq__, 18 __hash__, 18 eventos, 161-173 Ball of Mud distribuida y
pensamiento en sustantivos, 162-165 manejo de
errores en sistemas distribuidos, 165-167

Objetos MagicMock, 52
mapeadores, 28 corredores
desacoplamiento temporal usando mensajería
de mensajes, 168 bus de
asíncrona, 167 pruebas con prueba de
mensajes bus de mensajes
extremo a extremo, 169-172 compensaciones,
abstractos y sus versiones real y falsa, 149
173 usando el canal pub/sub de Redis para la
integración, 168 enfoque basado en eventos, usando
antes, Message Buse como complemento opcional,
133 el patrón Strangler, 218 -220 producto mínimo
viable, 24 método mock.patch, 51, 195 burlándose
Celery y, 124
evitando el uso de mock.patch, 51 no te burles de lo
manejadores dados de clase en tiempo de
que no tienes, 88 simulacros versus falsificaciones, 52
ejecución, 201 despachando eventos y comandos de
pruebas sobresimuladas, trampas de, 53 "simulacros Aren't
manera diferente, 153 la lógica del controlador
Stubs" (Fowler), 52 modelo (dominio), 6
de eventos y comandos permanece igual, 202
personalizándose con valores predeterminados
de arranque anulados, 204 manejador publicando
eventos salientes, 171 método handle_event, 158
handle_event con reintentos, 158

Índice | 271
Machine Translated by Google

N patrones inspirados en puertos y adaptadores, 114

tuplas con nombre, 16 poner en carpeta con adaptadores, 68

(ver también clases de Publicar/Redireccionar/Obtener patrón,

datos) sustantivos, división del sistema en, 162-165 179 separación de consulta de comando (CQS), 179
postgresql
Anti-Patrones: Ciclos de Lectura-Modificación-Escritura,
O
111
vecindarios de objetos, 84 documentación para niveles de aislamiento de
composición orientada a objetos, 53
transacciones, 110 gestión de problemas de
principios de diseño orientado a objetos, 21
concurrencia, 105
mapeadores relacionales de objetos (ORM), 27
Nivel de aislamiento de transacciones SERIALIZABLE,
asociación de lotes correctos con objetos de 105
producto, 103 ejemplo de Django ORM, 28
flujo de trabajo de refactorización preparatoria, 136
Django, métodos personalizados para traducir
primitivas que se mueven de objetos de dominio
hacia/desde el modelo de dominio, 247 ORM
a, en la capa de servicio, 139 obsesión primitiva, 139
depende del modelo de datos, 28 prueba del
ORM, 29 función orm.start_mappers, 199 patrón de
repositorio y, 36 Problema de rendimiento
Objeto del producto, 95
SELECT N+1, 184 vista simple usando ORM, 184
actuando como límite de consistencia, 155
SQLAlchemy, el modelo depende de ORM, 27
solicitando al producto que asigne en sus lotes,
100 código para, 100 capa de servicio
usando, 103 dos transacciones que intentan
actualizar simultáneamente, 105 números de
arquitectura de cebolla, 25
versión implementados en, 107
concurrencia optimista con números de versión, 105-108
orquestación, 61 cambio a coreografía, 121 uso de
servicio de aplicación, 66 capa de orquestación (ver capa de
Objeto ProductRepository, 103 proyectos
servicio)
que se organizan en carpetas, 67
estructuras de proyecto de plantilla,
231-238 protocolos, clases base abstractas,
Patrón de bandeja de salida, 226
tipificación pato y 33 bus de mensajes del sistema de
publicación-suscripción como controladores suscritos
P para recibir eventos,
inyección de
dependencia de funciones parciales con,
196 diferencia de cierres, 197 creación 123
manual en línea, 200 patrones, decidir si paso de publicación, 124
necesita usarlos, 114 utilizando el canal pub/sub de Redis para microserÿ
integración de vicios, 168
PEP 544 protocolos, 33 Charla de PyCon sobre Mocking Pitfalls, 53
límites de coherencia de pytest @pytest.skip, 104 accesorios, 45
rendimiento y, 95, 112 impacto del uso de complemento pytest-django, 246, 251
agregados, 99, 104 ignorancia de persistencia, argumento de sesión, 30
27 compensaciones, 38 concurrencia pesimista, 107
ejemplo, SELECCIONAR PARA ACTUALIZAR,
111 puertos definidos, 37

Q
consultas, 175

272 | Índice
Machine Translated by Google

(ver también segregación de responsabilidad de consulta separando responsabilidades, 212 estudio


de comando) preguntas de revisores técnicos, 223-226 de caso, sistema de capas cubierto de maleza,
214
reintentos

R mensaje bus handle_event con, 158 control de


concurrencia optimista y, 107
modo de falla de lectura-modificación-escritura,
función de servicio de reasignación 111 , diagrama Biblioteca de tenacidad para, 159
Rhodes, Brandon, 53
de secuencia de reasignación 91 para flujo, prueba
retrocesos, 86 pruebas
143 aislada usando bus de mensajes falsos,
explícitas para, 89 método
de retroceso, 87
148

Canal de publicación/suscripción de Redis, uso para la integración de


microservicios, 168 modelo de publicación/suscripción de prueba,
169 publicación de evento saliente, 171 Costuras S ,

53 adaptadores secundarios, 68
Redis como adaptador delgado alrededor del bus de Marinero, Mark, entrada de blog, 26
mensajes, 170 Consultas SELECT * FROM WHERE, 185
Redis, cambiando la implementación del modelo de lectura para usar, instrucción SELECCIONAR PARA ACTUALIZAR, 107
188 repositorios agregando el método de lista al objeto del ejemplo de control de concurrencia pesimista con, 111
repositorio existente, 181
SELECT N+1, 184
funciones de servicio

Repositorio basado en CSV, 242 nuevo convirtiéndolos en controladores de eventos, 136


tipo de consulta en nuestro repositorio, 145 un agregado refactorización a controladores de mensajes, 137
= un repositorio, 102 repositorio que realiza un capa de servicio, 55-70 beneficios de, 68 beneficios para
seguimiento de los agregados que pasan a través de él, 127 el desarrollo basado en pruebas, 79 conexión de

función de capa de servicio según el repositorio abstracto, nuestra aplicación al mundo real, 57 dependencias de,
64 vista simple usando el repositorio existente, 182 68 dependencias reales en tiempo de ejecución, 68 pruebas, 68
diferencia entre el servicio de dominio y, 66 pruebas de capa de
dominio que se trasladan a, 72 razones para, 73 prueba de
Clase contenedora TrackerRepository, 129 extremo a extremo de la API de asignación, prueba de rutas
Unidad de Trabajo colaboradora, 83 felices e infelices, 65 condiciones de error que requieren

Patrón de repositorio, 23, 31-39 e comprobaciones de la base de datos en la aplicación Flask, 60


ignorancia persistente, compensaciones, 38 construcción primer corte de Flask aplicación, 58-60

de un repositorio falso para pruebas, 37


ORM y, 36 resumen

de puntos importantes, 39 el repositorio


más simple posible, 32 probar el repositorio
recuperando un objeto complejo, 34 probar el repositorio
guardando un objeto, Aplicación Flask que delega a, 64
desde objetos de dominio a primitivos a eventos como interfaz,
34 139 desacoplamiento completo del dominio, 75-77
compensaciones, introducción y uso de FakeRepository para realizar pruebas
33 repositorio típico, 36 usando unitarias, 61-66
el repositorio directamente en el punto final de la API,
36 bus de mensajes como punto de entrada principal,
con Django, 246-249 134 pros y contras o compensaciones, 70 poner el
recursos, lecturas obligatorias adicionales, 227 responsabilidades proyecto en carpetas, 67
del código, 46

Índice | 273
Machine Translated by Google

generar eventos y pasarlos al bus de mensajes, 131 generar usar directamente en el punto final de API, 31
sus propios eventos, 125 enviar alertas por correo usar DSL para especificar PARA ACTUALIZAR, 111 partes
electrónico cuando se agoten, 120 evitar, tomar eventos del interesadas, convencer para probar algo nuevo, 221-223
modelo y colocarlos
estado

abstracción para ayudar a la capacidad de prueba,


en bus de mensajes, 124 43-45 separación de la lógica en el programa, 47
totalmente libre de problemas de manejo de eventos, 128 almacenamiento, 23 (ver también repositorios; Patrón de
ajustando falsificaciones para llamar a super e implementar repositorio) permanente, UoW proporciona un punto de
métodos de subrayado, 128 función de servicio típica, 63 entrada a,
usando objetos de Producto, 103 usando Unidad de Trabajo 84

en, 88 usando, pirámide de prueba y, 72 escribiendo la mayor Patrón de estrangulador, yendo a microservicios vía, 219-220
parte de las pruebas contra, 79 pruebas de escritura contra,
74 servicios de capa de servicio frente a servicios de dominio, stubbing, simulacros y stubs, 52 super
19 servicios función, 128 ajuste de falsificaciones en la
capa de servicio para llamar, 128 ejecución síncrona de
código de manejo de eventos,
130

servicio de aplicación y servicio de dominio, 66 pruebas de


capa de servicio solo usando servicios, 77 objeto de sesión, T
93 conjunto, repositorio falso como envoltura, 37 abstracciones
acoplamiento temporal, 165
simplificadas, 46 principio de responsabilidad única (SRP), 120
desacoplamiento temporal mediante mensajería asíncrona, 167
patrón Singleton, implementación de messagebus.py, 149 software
situado, 135 sitio de intercambio de pila de ingeniería de software,
Biblioteca de tenacidad, 159
52 hojas de cálculo, utilizando en lugar de modelo de dominio, dobles de prueba

simulacros versus falsificaciones,


52 simulacros versus stubs, 52

uso de listas para construir, 51


"Desarrollo basado en pruebas: eso no es lo que
96
Queríamos decir", 53
objetos espía, 51
desarrollo basado en pruebas (TDD), 71-79 beneficios
de la capa de servicio para, 79 clásico versus
Generación de SQL para objetos de modelo de dominio, 27 escuela de Londres, 52 decidir qué tipos de pruebas
ayudantes para Unidad de trabajo, 85
escribir, 73 pruebas de capa de dominio que se trasladan
Patrón ORM y Repositorio como resumen
a la capa de servicio,
ciones delante de, 36 SQL 72
sin procesar en vistas, 181
desacoplar completamente la capa de servicio del
prueba de repositorio para recuperar objetos complejos, dominio, 75-77 agregar el servicio faltante, 76 llevar
34 prueba de repositorio para guardar un objeto,
la mejora a las pruebas E2E, 78 mantener todas
34
las dependencias del dominio en funciones de
Sesión de base de accesorios, 76 engranaje alto y bajo, 74 pirámide
datos SQLAlchemy para Unidad de trabajo, 86
de prueba con capa de servicio agregada, 72 prueba
no burlarse, 88 sintaxis pirámide, examinando, 71 tipos de pruebas, reglas
declarativa, el modelo depende de ORM,
generales para, 80 pruebas unitarias operando a nivel inferior,
27
actuando directamente sobre el modelo, 71
mapeo ORM explícito con SQLAlchemy
Objetos de mesa, 28
problema SELECT N+1 y, 184
Objeto de sesión, 93

274 | Índice
Machine Translated by Google

prueba pruebas explícitas para el comportamiento de compromiso/reversión,

del estado de abstracción para ayudar a la capacidad de 89

prueba, 43-45 después de implementar la abstracción confirmaciones explícitas versus implícitas, 90


elegida, 47-53 bus de mensajes falsos implementado en UoW, 147
evitar el uso de parches simulados, 51-53 deshacerse de los métodos de subrayado en la clase UoW,
pruebas de borde a borde con falsificaciones e 129

inyección de dependencia, 49-51 prueba de administrando el estado de la base de


extremo a extremo del modelo pub/sub, 169 sesión datos, el bus de mensajes 82 ahora recopila eventos de
de base de datos falsa en la capa de servicio, 62 UoW UoW, 139

falso para servicio pruebas de capa, 87 para reglas de modificación para conectar eventos de dominio y bus de
integridad de datos, 109-111 prueba de integración mensajes, 117 pros y contras o compensaciones,
para la vista CQRS, 182 prueba de integración para 93 resumen de puntos importantes, 94 división de
anular los valores predeterminados de arranque, 204 operaciones en dos UoW, 143 prueba de conducción
pruebas de integración para el comportamiento de con pruebas de integración, 84 ordenar pruebas de
reversión, 89 árbol de carpetas de pruebas, 238 pruebas integración, 92
escritas en términos de eventos, 141

UoW y repositorio de productos, 103


pruebas de controlador para change_batch_quantity, UoW recolectando eventos de agregados y pasándolos al
144 bus de mensajes, 131
manejadores de eventos de pruebas unitarias con UoW para CSV, 243

bus de mensajes falsos, 147 Unidad de UoW gestionando el éxito o el fracaso de la actualización
trabajo con pruebas de integración, 84 pruebas de agregada, 155
limpieza, 92 pruebas unitarias para bootstrap, 204 UoW publica eventos en el bus de mensajes, 126 usando
pruebas unitarias con falsificaciones en la capa de UoW en la capa de servicio, 88 usando UoW para agrupar
servicio, 62 función time.sleep, 109 reproducción del múltiples operaciones en unidades atómicas, 91-92
comportamiento de concurrencia con,
ejemplo de cambio de cantidad de lote, 91 ejemplo
109 de función de reasignación, 91 con Django,
actas 249-250 sin, API hablando directamente a tres capas,
concurrente, intentando actualizar el Producto,
105 81

simular una transacción lenta, 109 pruebas unitarias,


Unidad de trabajo y, 93 uso 62 (consulte también desarrollo basado en pruebas;
para hacer cumplir las reglas de concurrencia, 110 pruebas) de modelos de dominio, 10-19 pruebas unitarias

sugerencias de tipo, 11, 15 que reemplazan las pruebas de un extremo a otro, 53


función unittest.mock, 52 UoW (consulte patrón de unidad de

U trabajo) capa de caso de uso (ver capa de servicio)

subrayar los métodos que se


evitan mediante la implementación de la clase contenedora
TrackingRepository, 129 ajustar las falsificaciones en
la capa de servicio para implementar, 128 validación V , 255-264

objetos de valor definidos,


Patrón de unidad de trabajo, 32, 81-93 y 16 y entidades, 17

su administrador de contexto, 85 UoW entidades versus, 21

falso para pruebas, 87 UoW real matemática con, 16

usando sesión SQLAlchemy, 86 beneficios de usar, 93 usando clases de datos

colaboración con repositorio, 83 para, 15


Amigo, Rob, 39

Vernon, Vaughn, 111

Índice | 275
Machine Translated by Google

números de versión vista simple que usa el repositorio, 182 probar vistas
opciones de implementación para, 107 en la CQRS, 182 compensaciones para las opciones de modelo
tabla de productos, implementando bloqueo optimista, 105 de vista, 189 actualizar la tabla de modelo de lectura
usando el controlador de eventos, 186
puntos de vista

Vistas de Django como adaptadores, 250


mantener un almacén de datos desnormalizado totalmente
separado para el modelo de vista, 185 solo lectura, 180
Controladores web W , enviar alertas por correo electrónico a través
reconstruir el modelo de vista desde cero, 188 vista simple que
de, evitar, 118
usa el ORM, 184

276 | Índice
Machine Translated by Google

Sobre los autores


Harry Percival pasó algunos años profundamente infeliz como consultor de gestión.
Pronto redescubrió su verdadera naturaleza geek y tuvo la suerte de encontrarse con un grupo de fanáticos de
XP, trabajando en ser pionero en la hoja de cálculo Resolver One, lamentablemente desaparecida. Trabajó en
PythonAnywhere LLP, difundiendo el evangelio de TDD en todo el mundo en charlas, talleres y conferencias.
Ahora está en MADE.com.

Bob Gregory es un arquitecto de software con sede en el Reino Unido con MADE.com. Ha estado construyendo
sistemas controlados por eventos con diseño controlado por dominio durante más de una década.

Colofón
El animal de la portada de Architecture Patterns with Python es una pitón birmana (Python bivitattus). Como
era de esperar, la pitón birmana es originaria del sudeste asiático. Hoy vive en selvas y pantanos en el sur de
Asia, Myanmar, China e Indonesia; también es invasivo en los Everglades de Florida.

Las pitones birmanas son una de las especies de serpientes más grandes del mundo. Estos constrictores
carnívoros nocturnos pueden crecer hasta 23 pies y 200 libras. Las hembras son más grandes que los machos.
Pueden poner hasta cien huevos en una nidada. En la naturaleza, las pitones birmanas viven un promedio de
20 a 25 años.

Las marcas en una pitón birmana comienzan con una mancha de color marrón claro en forma de flecha en la
parte superior de la cabeza y continúan a lo largo del cuerpo en rectángulos que se destacan contra sus
escamas de color canela. Antes de que alcancen su tamaño completo, lo que lleva de dos a tres años, las
pitones birmanas viven en los árboles cazando pequeños mamíferos y aves. También nadan durante largos
períodos de tiempo, hasta 30 minutos sin aire.

Debido a la destrucción del hábitat, la pitón de Birmania tiene un estado de conservación Vulnerable. Muchos
de los animales de las portadas de O'Reilly están en peligro de extinción; todos ellos son importantes para el
mundo.

La ilustración en color es de Jose Marzan, basada en un grabado en blanco y negro de la Encyclopedie


D'Histoire Naturelle. Las fuentes de la portada son URW Typewriter y Guardian Sans. La fuente del texto es
Adobe Minion Pro; la fuente del encabezado es Adobe Myriad Condensed; y la fuente del código es Ubuntu
Mono de Dalton Maag.
Machine Translated by Google

Hay mucho más de


donde vino esto.
Experimente libros, videos, cursos de
capacitación en línea en vivo y más de O'Reilly
y nuestros más de 200 socios, todo en un solo lugar.

Obtenga más información en oreilly.com/online-learning

registrada
O'Reilly
O'Reilly
Media,
O'Reilly
marca
Media,
©2019
|175
Inc.
una
Inc.
de
es

También podría gustarte