Saltar al contenido
Framework Ecosystems LabsUn contrato, muchos ecosistemas, la misma prueba.

Clase 064 — Repositorio y dominio#

⬅️ 063 · 📚 Parte 4 · 🎓 Clases · 065 ➡️ Parte 4 — Datos · Nivel 🔴 avanzado · Pista datosClase construida — 4 implementaciones verificadas contra contrato.json.

🎯 Objetivo#

Escribir reglas de negocio que no saben que hay base de datos — y comprobarlo, no prometerlo.

🧩 La situación#

Un proyecto con tareas y tres reglas:

  1. No se cierra un proyecto con tareas pendientes.
  2. No se añaden tareas a un proyecto cerrado.
  3. No hay dos tareas con el mismo título en el mismo proyecto.

<!-- generado: fichas -->

📖 Las palabras que esta clase define#

Si alguna de estas no te dice nada todavía, esta es la clase donde se aprende. Las definiciones viven en el glosario, que reúne las del programa entero.

Palabra Qué significa
Repositorio Una interfaz que el dominio usa para guardar y recuperar sin saber cómo. Su prueba de fuego es que la implementación en memoria y la real sean intercambiables — y que el dominio no importe nada del ORM.

🧰 Las piezas de esta clase, una por una#

Antes del código: qué es cada framework, qué versión se está usando y qué hace falta para ejecutarlo. Todo lo de esta sección sale de los archivos reales del repositorio —el catálogo, la receta de arranque y el manifiesto de dependencias de cada ecosistema—, así que no puede quedarse desactualizado sin que la validación lo detecte.

Framework Qué es Desde Licencia Quién lo mantiene
Prisma ORM mapeador objeto-relacional de JavaScript/TypeScript (TypeScript) 2021 Apache-2.0 proyecto independiente
SQLAlchemy mapeador objeto-relacional de Python (Python) 2006 MIT proyecto independiente
Hibernate ORM mapeador objeto-relacional de JVM (Java) 2001 LGPL-2.1-or-later proyecto independiente
Entity Framework Core mapeador objeto-relacional de .NET (C#) 2016 MIT proyecto independiente

🔧 Prisma ORM#

Esquema propio del que se genera un cliente tipado. Un lenguaje más que aprender, a cambio de tipos exactos.

Preparar sus dependencias, dentro de su directorio:

pnpm install --silent --ignore-scripts
pnpm exec prisma generate

Arrancarla suelta, sin el verificador:

PORT=3000 node server.mjs

Qué hay dentro de su directorio:

Archivo Qué es
dominio.mjs código JavaScript (módulo ES)
ejecutar.json la receta que usa el verificador: qué hace falta, cómo se prepara y cómo arranca
package.json manifiesto de Node.js: nombre, tipo de módulo y dependencias con su rango de versión
pnpm-lock.yaml archivo de bloqueo: la versión exacta de cada dependencia y de sus dependencias
pnpm-workspace.yaml raíz de instalación propia, y la prohibición de ejecutar scripts al instalar
prisma/schema.prisma esquema de Prisma: el modelo de datos del que se genera el cliente
repositorios.mjs código JavaScript (módulo ES)
server.mjs código JavaScript (módulo ES)

🔧 SQLAlchemy#

Separa explícitamente el constructor de consultas del mapeador, de modo que se puede bajar de nivel sin abandonarlo.

Arrancarla suelta, sin el verificador:

PORT=3000 python -m uvicorn main:app --host 127.0.0.1 --port 3000

Qué hay dentro de su directorio:

Archivo Qué es
dominio.py código Python
ejecutar.json la receta que usa el verificador: qué hace falta, cómo se prepara y cómo arranca
main.py código Python
requirements.txt dependencias de Python, una por línea, con versión fijada

🔧 Hibernate ORM#

El mapeador objeto-relacional de referencia en Java y el origen de buena parte del vocabulario del campo, incluido el problema de la consulta N+1.

Preparar sus dependencias, dentro de su directorio:

mvn -q -B package -DskipTests

Arrancarla suelta, sin el verificador:

PORT=3000 java -jar target/clase-064-1.0.0.jar --server.port=3000

Qué hay dentro de su directorio:

Archivo Qué es
ejecutar.json la receta que usa el verificador: qué hace falta, cómo se prepara y cómo arranca
pom.xml manifiesto de Maven: el proyecto, su Java, sus dependencias y cómo se empaqueta
src/main/java/labs/Aplicacion.java código Java
src/main/java/labs/Dominio.java código Java
src/main/resources/application.properties configuración de Spring Boot: lo que se ajusta sin tocar el código

🔧 Entity Framework Core#

Mapeador con migraciones y consultas integradas en el lenguaje. El contraste con Dapper ilustra el compromiso entre abstracción y control.

Preparar sus dependencias, dentro de su directorio:

dotnet build -c Release --nologo -v quiet

Arrancarla suelta, sin el verificador:

PORT=3000 dotnet run -c Release --no-build --urls http://127.0.0.1:3000

Qué hay dentro de su directorio:

Archivo Qué es
Clase064.csproj proyecto de .NET: el marco de destino y las dependencias
Dominio.cs código C#
Program.cs código C#
ejecutar.json la receta que usa el verificador: qué hace falta, cómo se prepara y cómo arranca

Si alguna cadena de herramientas no está en tu máquina, node scripts/doctor.mjs dice cuál falta y con qué comando se instala. No hace falta tenerlas todas: el verificador ejecuta lo que encuentra y declara lo que omitió.

<!-- fin generado: fichas -->

🌐 Las implementaciones — el código a la vista#

Las cuatro tienen la misma anatomía en tres piezas, y conviene tenerla en la cabeza antes de leer el código:

  1. El dominio — un archivo que no importa nada del ORM ni del framework web.
  2. El puerto — una interfaz de cuatro métodos: dame un proyecto, guárdame un proyecto, dame el siguiente identificador de proyecto y el de tarea.
  3. Dos adaptadores — uno contra el ORM, para el servicio; otro en memoria, para las pruebas. El dominio no distingue uno de otro.

Y las cuatro exponen dos rutas que comprueban que eso es verdad en lugar de afirmarlo: /dominio lee su propio archivo de dominio y mira sus imports, y /pruebas-del-dominio ejecuta las tres reglas contra el repositorio en memoria, sin base de datos.

Prisma · dominio.mjs, repositorios.mjs y server.mjs#

El dominio, entero, en tres reglas:

  /** REGLA 2 y REGLA 3. */
  anadirTarea(id, titulo) {
    if (this.cerrado) throw new ReglaRota("PROYECTO_CERRADO");
    if (this.tareas.some((t) => t.titulo === titulo)) throw new ReglaRota("TITULO_REPETIDO");
    const tarea = new Tarea(id, titulo);
    this.tareas.push(tarea);
    return tarea;
  }
  /** REGLA 1. */
  cerrar() {
    if (this.pendientes() > 0) throw new ReglaRota("QUEDAN_PENDIENTES");
    this.cerrado = true;
  }

El proyecto es la raíz: nadie toca una tarea sin pasar por él. Esa es la razón de que las reglas puedan vivir ahí. Si el resto del código pudiera añadir tareas por su cuenta, «no se añaden tareas a un proyecto cerrado» sería una recomendación en lugar de una regla.

Los dos repositorios:

export class RepositorioEnMemoria {
  constructor() {
    this.proyectos = new Map();
    this.siguiente = 1;
    this.siguienteTarea = 1;
  }
  async porId(id) {
    const fila = await this.prisma.proyecto.findUnique({
      where: { id },
      include: { tareas: { orderBy: { id: "asc" } } },
    });
    if (!fila) return null;
    return new Proyecto(
      fila.id,
      fila.nombre,
      fila.cerrado,
      fila.tareas.map((t) => new Tarea(t.id, t.titulo, t.hecha)),
    );
  }

Esa última construcción es toda la clase. Devuelve una entidad del dominio, no una fila de Prisma. Es la línea que separa un repositorio de verdad de uno decorativo: si devolviera el objeto de Prisma, el dominio dependería de Prisma igual que antes y no se habría ganado nada — solo una capa más de indirección.

El manejador solo traduce fallos a HTTP:

    proyecto.anadirTarea(await repositorio.siguienteIdTarea(), String(peticion.body?.titulo ?? ""));
function responderRegla(error, respuesta) {
  if (!(error instanceof ReglaRota)) throw error;
  respuesta.status(error.codigo === "NO_EXISTE" ? 404 : 409).json({ code: error.codigo });
}

No sabe cuáles son las reglas ni en qué orden se comprueban. Compáralo con la clase 039, donde la validación vivía en el manejador: allí cambiar de framework web obligaba a reescribirla; aquí no la toca.

Y la comprobación que hace honesta a la clase:

  const importados = texto
    .split(String.fromCharCode(10))
    .filter((linea) => linea.startsWith("import "));
  const prohibidas = ["prisma", "express"];

Se miran los imports, no cualquier mención. El propio comentario del archivo dice «no importa Prisma», y buscar la palabra suelta daría un falso positivo: lo que importa es de qué depende el módulo, no de qué habla. Prometer un dominio limpio en un README no cuesta nada; comprobarlo, sí.

SQLAlchemy · dominio.py y main.py#

Aquí el puerto está escrito como tal, con la construcción que Python ofrece para eso:

class Repositorio(Protocol):
    """Lo unico que el dominio necesita. Tres metodos.

    Cualquier cosa que sepa hacer esto le vale — y por eso hay dos.
    """

Protocol es tipado estructural: RepositorioEnMemoria no hereda de nada y aun así lo cumple, porque tiene los métodos. No hay que registrar nada ni declarar la conformidad.

Y una separación que las otras tres no hacen tan visible:

class FilaProyecto(Base):
    """El modelo de PERSISTENCIA, distinto del de dominio.

    Se parece a `Proyecto` porque este caso es sencillo, y no tiene por que
    parecerse: es el mapeador quien traduce entre los dos.
    """

Dos clases con casi los mismos campos. Parece duplicación y es la línea de corte: FilaProyecto puede ganar una columna de auditoría, un índice o un __table_args__ sin que Proyecto se entere, y Proyecto puede ganar una regla sin tocar el esquema.

            return Proyecto(
                fila.id,
                fila.nombre,
                fila.cerrado,
                [Tarea(t.id, t.titulo, t.hecha) for t in sorted(fila.tareas, key=lambda t: t.id)],
            )

Hibernate · Dominio.java y Aplicacion.java#

El puerto, como interfaz de toda la vida:

    public interface Repositorio {
        Proyecto porId(long id);

        Proyecto guardar(Proyecto proyecto);

        long siguienteIdProyecto();

        long siguienteIdTarea();
    }

Y el adaptador, con el mapeo explícito:

            List<Dominio.Tarea> lista = fila.tareas.stream()
                    .sorted((a, b) -> Long.compare(a.id, b.id))
                    .map(t -> new Dominio.Tarea(t.id, t.titulo, t.hecha))
                    .toList();
            return new Proyecto(fila.id, fila.nombre, fila.cerrado, lista);

Merece un aviso, porque es el ecosistema donde más se confunde: Spring Data ya te da un JpaRepository y a eso también se le llama «repositorio». No es lo mismo. JpaRepository devuelve entidades JPA —objetos gestionados, con carga perezosa y ciclo de vida atado a la sesión—, así que quien lo use depende de JPA. Aquí RepositorioJpa usa Proyectos extends JpaRepository por dentro y devuelve entidades del dominio por fuera. El repositorio de Spring Data es el detalle; el puerto es la interfaz de arriba.

Entity Framework Core · Dominio.cs y Program.cs#

interface IRepositorio
{
    Task<Proyecto?> PorIdAsync(long id);
    Task<Proyecto> GuardarAsync(Proyecto proyecto);
    Task<long> SiguienteIdProyectoAsync();
    Task<long> SiguienteIdTareaAsync();
}

Y aquí el argumento en una sola línea de configuración:

constructor.Services.AddScoped<IRepositorio, RepositorioEfCore>();
// El manejador pide la INTERFAZ. Cambiar `RepositorioEfCore` por
// `RepositorioEnMemoria` en esta línea dejaría el servicio entero funcionando
// sin base de datos — que es, literalmente, lo que hace `/pruebas-del-dominio`.

El contenedor de la clase 002 aplicado a algo que se nota: una palabra cambia todo el almacenamiento del servicio, y nada más se entera.

        return new Proyecto(fila.Id, fila.Nombre, fila.Cerrado, tareas);

El mismo mapeo por cuarta vez. Cuatro lenguajes, cuatro ORM, cuatro sintaxis — y la misma frontera dibujada en el mismo sitio.

Lo que esto cuesta, dicho sin adornos: cada entidad se escribe dos veces y hay que mantener el mapeo. Es un precio real, y por eso esta arquitectura no es gratis ni es siempre la correcta. Se paga cuando las reglas son muchas y valen más que el esquema. En un CRUD que traduce formularios a filas, es puro sobrecoste — y la clase 060 ya mostró que a veces la respuesta correcta es bajar, no subir.

🧮 El contrato#

Petición Respuesta
GET /dominio menciona_orm: false, reglas: 3
POST /proyectos 201
POST /proyectos/1/tareas 201
POST /proyectos/1/cerrar 409 QUEDAN_PENDIENTES
POST …/tareas/1/terminar pendientes: 0
POST /proyectos/1/cerrar cerrado: true
POST /proyectos/1/tareas 409 PROYECTO_CERRADO
POST /proyectos/2/tareas "repetida" ×2 409 TITULO_REPETIDO
GET /pruebas-del-dominio 3 de 3, uso_base_de_datos: false

📖 Los dos casos que hacen honesta esta clase#

El primero y el último no comprueban comportamiento: comprueban la arquitectura.

GET /dominio lee su propio código#

const importados = texto.split(…).filter((linea) => linea.startsWith("import "));
const prohibidas = ["prisma", "express"];

Se lee el archivo del dominio y se miran sus imports. Si alguien añadiera import { PrismaClient } para «resolverlo rápido», el contrato fallaría.

Fíjate en que mira los imports, no cualquier mención: el propio comentario del archivo dice «no importa Prisma», y buscar la palabra suelta daba un falso positivo — de hecho lo dio al escribir esta clase. Lo que importa es de qué depende el módulo, no de qué habla.

GET /pruebas-del-dominio ejecuta las reglas sin base de datos#

const memoria = new RepositorioEnMemoria();

Las tres reglas, contra un Map. Sin motor, sin esquema, sin transacción, sin limpiar tablas. Es el argumento entero de la clase, y se ejecuta de verdad en lugar de afirmarse en un README.

📖 Por qué el dominio puede tener las reglas#

Porque el proyecto es la raíz y nadie toca una tarea sin pasar por él.

proyecto.anadirTarea(id, titulo);   // única puerta

Si el resto del código pudiera insertar tareas por su cuenta —un repositorio con guardarTarea(), un INSERT desde un manejador—, «no se añaden tareas a un proyecto cerrado» sería una recomendación, no una regla.

Esa es la idea de agregado: un grupo de objetos con una entrada única y una frontera dentro de la cual las invariantes siempre se cumplen [evans-ddd]. Y tiene una consecuencia práctica que se nota enseguida: el repositorio guarda agregados, no tablas. Por eso guardar(proyecto) escribe también sus tareas.

⚠️ El repositorio que no sirve de nada#

def query(self):
    return self.sesion.query(FilaProyecto)   # devuelve el ORM hacia fuera

Un repositorio que expone consultas del ORM no esconde nada: el dominio sigue dependiendo de él, las pruebas siguen necesitando una base, y encima hay una clase más.

La prueba de fuego es la que aplica esta clase: si la interfaz se puede implementar con un diccionario, está bien puesta. Aquí se puede, y por eso hay dos implementaciones.

De ahí también que el repositorio devuelva entidades del dominio, no filas:

return new Proyecto(fila.Id, fila.Nombre, fila.Cerrado, tareas);  // no `fila`

Esa línea es la frontera. Devolver fila habría ahorrado veinte líneas y anulado la clase entera.

🔬 Dos modelos, no uno#

Cada implementación tiene Proyecto (dominio) y FilaProyecto (persistencia), y en este caso se parecen mucho.

Que se parezcan ahora no significa que sobre uno. Se separan cuando:

El coste es real —dos clases y una traducción— y se paga por adelantado. Con cuatro campos y ninguna regla, no vale la pena: eso es la clase 053.

🔬 Comparación#

ORM Cómo se inyecta el repositorio Dominio realmente limpio
Prisma a mano, en el módulo
SQLAlchemy a mano, con un Protocol como contrato
Hibernate @Service + inyección de Spring : Dominio.java no importa jakarta ni springframework
EF Core AddScoped<IRepositorio, RepositorioEfCore>

La fila de Hibernate merece un matiz: aquí el dominio está limpio porque las entidades JPA son otras clases. En la clase 054, donde la entidad era la del dominio, las anotaciones vivían encima de ella. La diferencia entre las dos clases es exactamente esa: aquí hay dos modelos.

Y en EF Core la inyección hace visible el argumento:

constructor.Services.AddScoped<IRepositorio, RepositorioEfCore>();

Cambiar esa línea por RepositorioEnMemoria deja el servicio entero funcionando sin base de datos. Es un cambio de una palabra.

⚠️ Errores frecuentes#

✅ Verificación#

node scripts/run-class.mjs 064

🧪 Reto de transferencia#

Añade una cuarta regla —un proyecto no admite más de tres tareas— y comprueba cuántos archivos hay que tocar: uno. Después escribe su caso en /pruebas-del-dominio y verás que se ejecuta sin base de datos. Repite el ejercicio en la clase 053 y cuenta la diferencia.

🔗 Enlaces#

Fuentes#