El patrón Repository en NestJS: una colección que, casualmente, vive en una base de datos
El punto de partida canónico de un módulo de NestJS con TypeORM es el que documenta el propio framework: el módulo declara TypeOrmModule.forFeature([Order]) y el servicio recibe el repositorio por inyección. @Injectable() export class OrderService { constructor( @InjectRepository(Order) private readonly repo: Repository , ) {} } A partir de ahí el servicio dispone de find , findOne , save y delete , y puede escribir la primera regla de negocio sin más andamiaje. Es el camino con menos fricción y el mejor documentado, y por eso es el que aparece en la mayoría de las bases de código. El código que acaba viviendo dentro de ese servicio tiene esta forma: async confirm(orderId: string): Promise { const order = await this.repo.findOne({ where: { id: orderId, status: 'pending' }, relations: { lines: true }, }); if (!order) throw new NotFoundException('Order not found'); if (order.lines.length === 0) { throw new BadRequestException('Cannot confirm an order without lines'); } order.status = 'confirmed'; return this.repo.save(order); } El método es correcto: hace lo que promete, es legible y puede sostener años de producción sin incidentes. Lo que interesa analizar no es su comportamiento, sino su superficie de acoplamiento. Dentro hay una regla de negocio -un pedido no se puede confirmar si no tiene líneas- y conviene medir qué conocimiento del motor de persistencia quedó incorporado en ella. El inventario es más largo de lo que parece a simple vista: - La regla depende de cómo se cargó la fila. El invariante se evalúa sobre order.lines , y esa colección solo existe si la consulta pidió la relación explícitamente. Sirelations: { lines: true } desaparece en un refactor,order.lines llega vacío, la comprobación se dispara cuando no debe y el invariante queda invertido sin que nada falle: no hay error de compilación, no hay excepción, no hay traza. La corrección de la regla es una propiedad de la consulta, no de la regla. - La condición de negocio está expresada en vocabulario de tabla. "Pendiente" no es un concepto del dominio en este código; es el string 'pending' comparado contra una columna dentro de un objetowhere . - El control de flujo lo dicta la API del ORM. El primer if existe porquefindOne devuelvenull ; es una decisión de TypeORM, no del negocio. - La semántica de escritura es implícita. save resuelve por sí mismo si la operación es unINSERT o unUPDATE según el estado de la clave primaria. El servicio hereda esa ambigüedad. - La clase de negocio es la definición del esquema. Order -donde vivirán el cálculo del total y las transiciones de estado- es la misma clase que lleva los decoradores@Column que describen la tabla. Los cuatro primeros puntos son molestias de acoplamiento: incómodas, pero locales y reversibles. El quinto es de otra naturaleza. No es una consecuencia de cómo se escribió este método, sino de una decisión estructural -que el modelo de negocio y el modelo de persistencia sean el mismo objeto- que el proyecto adoptó sin deliberarla, por seguir el camino documentado. Ese acoplamiento no tiene coste observable mientras el módulo se mantenga en operaciones de una sola entidad sobre una sola tabla. Se vuelve medible cuando aparecen tres condiciones que casi cualquier dominio real acaba cumpliendo: que la entidad acumule invariantes propios, que una consulta con significado de negocio se necesite desde más de un lugar, y que haga falta verificar una regla sin depender de la base de datos. Cada una de las tres produce un coste distinto, y conviene examinarlas por separado. Tres costes del acoplamiento 1. La entidad de negocio es la definición de la tabla La clase Order atiende a dos consumidores con requisitos incompatibles. El negocio necesita un objeto que solo pueda existir en estados válidos y que exprese sus conceptos con precisión. El ORM necesita un objeto que refleje la fila y que él pueda construir sin conocer nada del negocio. Cuando ambos consumidores comparten la misma clase, el resultado es este: @Entity('order') export class Order { @PrimaryGeneratedColumn('uuid') id: string; @Column() customerId: string; @ManyToOne(() => Customer) customer: Customer; @Column({ type: 'varchar', default: 'pending' }) status: string; @Column({ type: 'numeric', precision: 12, scale: 2 }) total: string; @OneToMany(() => OrderLine, (line) => line.order) lines: OrderLine[]; @CreateDateColumn() createdAt: Date; @UpdateDateColumn() updatedAt: Date; @DeleteDateColumn() deletedAt: Date | null; } La contaminación va en las dos direcciones, y hay una tercera consecuencia que no es de tipos sino de construcción. De la persistencia hacia el dominio. createdAt , updatedAt y deletedAt son requisitos de infraestructura -auditoría y borrado lógico- que ninguna regla de negocio consulta, pero que forman parte del tipo con el que trabaja todo el módulo. Más relevante: customerId y customer son dos representaciones del mismo hecho conviviendo en el mismo objeto, sin garantía de que estén sincronizadas. Y customer está declarada como Customer , no como Customer | undefined , aunque su valor sea undefined siempre que la consulta no haya pedido la relación. El tipo afirma algo que el valor no cumple, y strictNullChecks no puede detectarlo porque la propiedad está declarada como presente. Del dominio hacia la persistencia. total está tipado como string porque el driver de PostgreSQL devuelve las columnas numeric como cadena para no perder precisión en el number de JavaScript. Es una decisión correcta del driver y una filtración para el negocio: el importe de un pedido es un número, y aquí cualquier cálculo sobre él exige convertirlo primero, en cada sitio donde se calcule. Corregirlo dentro de la propia clase es posible con un transformer de columna, pero eso deja la representación de un concepto de negocio condicionada a lo que el ORM sabe serializar. El constructor no puede exigir nada. La documentación de TypeORM lo establece explícitamente: los argumentos del constructor de una entidad deben ser opcionales, porque el ORM instancia la clase al materializar cada fila y desconoce esos argumentos. La consecuencia es que la clase no puede garantizar su propio invariante en construcción. new Order() -sin cliente, sin líneas y sin estado- es válido para el compilador y para el ORM. El invariante "un pedido confirmado tiene al menos una línea" no puede ser una propiedad del tipo; solo puede ser una comprobación repetida en cada método que la necesite, que es exactamente lo que hacía confirm() en el ejemplo anterior. La clase queda estructuralmente incapacitada para sostener un estado siempre válido. 2. Las consultas no tienen nombre "Los pedidos pendientes de un cliente" es un concepto del negocio: tiene una definición, y esa definición puede cambiar. En el código no existe como unidad. Existe como un objeto literal replicado en cada sitio que lo necesita: // order.service.ts where: { customerId, status: 'pending' } // notification.service.ts where: { customerId, status: In(['pending', 'awaiting_payment']) } // report.service.ts where: { customerId, status: 'pending' }, relations: { lines: true } Las tres pretenden expresar el mismo concepto y las tres divergen. Ninguna está marcada como canónica, así que leyendo el código no hay forma de saber cuál es la definición correcta, y el compilador acepta las tres: la divergencia es semántica, no de tipos. La tercera, además, devuelve entidades con otra forma, porque relations cambia qué llega poblado; una regla que dependa de order.lines se comporta distinto según el punto de entrada. El coste se cobra cuando la definición de "pendiente" cambia: es proporcional al número de copias, y no hay procedimiento fiable para enumerarlas -buscar el literal 'pending' falla en cuanto alguien lo extrajo a una constante o lo recibió como parámetro-. 3. La regla no se puede verificar sin base de datos Para ejecutar un test sobre "no se puede reservar más unidades de las que hay en stock" hacen falta cuatro elementos ajenos a la regla: una instancia de PostgreSQL, el esquema migrado, fixtures que dejen la fila en el estado inicial y un mecanismo de aislamiento entre casos. El test cubre entonces mucho más de lo que pretende. Cuando falla, la causa puede estar en la regla, en el mapeo de columnas, en una migración pendiente o en el estado que dejó otro caso: la señal no localiza el defecto. Y el ciclo pasa del orden de los milisegundos al de los segundos, multiplicado por el número de casos -una regla de stock tiene bastantes: el límite exacto, uno por encima, existencias en cero, dos reservas concurrentes-. El efecto de segundo orden es el más caro. Una suite lenta se ejecuta con menos frecuencia, y los casos límite incómodos de montar tienden a no escribirse: el coste no está solo en los tests que tardan, sino en los que no llegan a existir. Los tres costes tienen el mismo origen: no hay ninguna frontera entre el objeto que expresa el negocio y el objeto que describe la fila. Antes de proponer una, conviene revisar qué ofrece el ecosistema, porque las herramientas disponibles no atacan todas el mismo problema. Estado del arte: qué resuelve cada opción El camino documentado de NestJS. @InjectRepository(Order) inyecta un Repository construido por el DataSource . Lo que resuelve es el cableado: la conexión, el pool, el ciclo de vida y la inyección. Es una solución completa para ese problema, y no pretende ser otra cosa. Sobre la propiedad del modelo no se pronuncia: el tipo que entrega es el mismo que define la tabla. Active Record. TypeORM ofrece la alternativa de heredar de BaseEntity , con lo que la clase adquiere sus propias operaciones de persistencia: const order = await Order.findOneBy({ id }); await order.save(); Elimina la ceremonia de la inyección y, a cambio, lleva el acoplamiento al máximo posible: la clase de negocio no solo describe la tabla, además sabe conectarse a ella. Los tres costes de la sección anterior se mantienen y se les suma la imposibilidad de instanciar la clase fuera de un con
Comments
No comments yet. Start the discussion.