Añadir una feature con entidad propia
Este es el recorrido largo: una feature que guarda algo nuevo en la base de datos. Si lo que vas a añadir reutiliza entidades que ya existen, no es este — ve al recorrido sin entidad.
Como ejemplo real: la chapa QR de petid-api (pet-tags). Una mascota tiene una chapa con un
código público; quien la escanea ve un perfil público, y al dueño le llega un aviso. Toca las cuatro
capas y sirve de plantilla para cualquier otra.
El orden importa
Se construye de dentro hacia afuera: dominio primero, presentación al final. Si empiezas por el controller acabas modelando el dominio para que le encaje al endpoint, que es justo al revés.
1 · Schema y migración
Antes que el código. La tabla, sus índices y sus restricciones.
model PetTag {
id String @id @default(uuid())
petId String
publicCode String @unique
revokedAt DateTime? @db.Timestamptz
revokedReason String?
lastNotifiedAt DateTime?
createdAt DateTime @default(now())
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
@@index([petId, revokedAt])
@@map("pet_tags")
}
Los comentarios de invariantes van en el schema, no solo en el código. Aquí, que solo puede haber una chapa activa por mascota lo sostiene un índice único parcial que Prisma no sabe expresar, así que vive en el SQL de la migración y el schema lo dice en un comentario.
2 · domain/ — la entidad y su contrato
Dos ficheros obligatorios y una carpeta de errores.
La entidad (domain/pet-tags/pet-tag.entity.ts): campos readonly, un constructor que recibe
un objeto, y los métodos que respondan preguntas sobre su propio estado. Nada más — no busca, no
guarda, no sabe de Prisma.
const NOTIFY_COOLDOWN_MS = 10 * 60 * 1000;
export class PetTag {
readonly id: string;
readonly publicCode: string;
readonly revokedAt: Date | null;
// ...
get isActive(): boolean {
return this.revokedAt === null;
}
/** Un aviso por ventana de enfriamiento, no uno por visita. */
canNotify(now: Date): boolean {
if (!this.lastNotifiedAt) return true;
return now.getTime() - this.lastNotifiedAt.getTime() > NOTIFY_COOLDOWN_MS;
}
}
Qué merece ser método y qué no tiene su propia entrada: métodos en la entidad.
El repositorio (domain/pet-tags/pet-tag.repository.ts): la interfaz más su Symbol. Declara
las consultas que el negocio necesita, con nombres del negocio — no findAll genéricos:
export interface PetTagRepository {
/** Solo la chapa activa. */
findActiveByPetId(petId: string): Promise<PetTag | null>;
findByPublicCode(publicCode: string): Promise<PetTag | null>;
create(tag: PetTag): Promise<PetTag>;
revoke(id: string, revokedAt: Date, reason: string | null): Promise<void>;
}
export const PET_TAG_REPOSITORY = Symbol("PetTagRepository");
Los errores (domain/pet-tags/errors/): uno por fichero.
Ver errores de dominio.
3 · application/ — los use cases
Uno por operación, con un único método execute(). El más simple no tiene ni reglas:
@Injectable()
export class CreatePetTagUseCase {
constructor(
@Inject(PET_TAG_REPOSITORY) private readonly petTags: PetTagRepository,
) {}
async execute(petId: string): Promise<PetTag> {
const tag = new PetTag({
id: randomUUID(),
petId,
publicCode: generatePetTagCode(),
revokedAt: null,
revokedReason: null,
lastNotifiedAt: null,
createdAt: new Date(),
});
return this.petTags.create(tag);
}
}
Un use case puede inyectar a otro, y es lo preferido. RegeneratePetTagUseCase revoca la actual
y llama a CreatePetTagUseCase en vez de duplicar la creación:
async execute(petId: string, requester: User, reason: RevokeReason | null) {
await this.petAccess.assertPetAccessible(petId, requester);
const current = await this.petTags.findActiveByPetId(petId);
if (current) {
// Antes de crear la nueva: el índice único parcial solo admite una
// activa por mascota, así que el orden inverso fallaría.
await this.petTags.revoke(current.id, new Date(), reason);
}
return this.createPetTagUC.execute(petId);
}
Si la operación tiene efectos que pueden fallar sin invalidarla —un push, un email— eso no va aquí: se emite un evento. Ver evento o llamada directa.
Una función suelta como generatePetTagCode() también vive en application/, sin clase ni
inyección, cuando es pura y no depende de nada.
4 · infrastructure/ — el adaptador
Dos ficheros en infrastructure/persistence/pet-tags/:
- El mapper:
toDomain(row)convierte la fila de Prisma en entidad. Aquí es de una línea (new PetTag(row)) y aun así existe, porque es la costura donde pondrás la traducción el día que la fila y la entidad dejen de coincidir. - El repositorio Prisma: implementa la interfaz del dominio. Es el único sitio del repo
donde se puede importar de
generated/prisma.
Se registra en persistence.module.ts atando el símbolo a la clase:
{ provide: PET_TAG_REPOSITORY, useClass: PrismaPetTagRepository }
5 · presentation/ — controller, DTOs, docs y módulo
- DTOs en
dtos/: uno de entrada por operación que reciba body, uno de salida. - Controller: inyecta use cases, nunca repositorios. Fino, sin lógica.
*.docs.ts: toda la anotación de Swagger, fuera del controller. Ver Swagger y wiring.*.module.ts: declara controllers y use cases, y exporta solo lo que otros módulos consumen.
@Module({
controllers: [PetTagController, PublicPetProfileController],
providers: [PetAccessService, CreatePetTagUseCase, RegeneratePetTagUseCase /* ... */],
exports: [CreatePetTagUseCase], // lo usa PetsModule al crear una mascota
})
export class PetTagsModule {}
Checklist
- Migración con sus índices, y los invariantes comentados en el schema
- Entidad con campos
readonly, sin dependencias - Interfaz de repositorio +
Symbol, con nombres del negocio - Errores de dominio, uno por fichero
- Un use case por operación; componen entre ellos si hace falta
- Mapper de persistencia + repositorio Prisma, registrados por símbolo
- DTOs, controller fino,
.docs.ts, módulo con susexports -
grep -rl "REPOSITORY" src/presentation/sigue vacío