ecosystem-wikicómo extender los templates sin romper sus convenciones
‹ todas las recipes

Añadir una feature con entidad propia

template-api
último cambio 2026-09-25

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/:

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

@Module({
  controllers: [PetTagController, PublicPetProfileController],
  providers: [PetAccessService, CreatePetTagUseCase, RegeneratePetTagUseCase /* ... */],
  exports: [CreatePetTagUseCase], // lo usa PetsModule al crear una mascota
})
export class PetTagsModule {}

Checklist