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

¿Métodos en la entidad, o fuera?

template-api
último cambio 2026-09-25

De las 13 entidades de petid-api, siete tienen métodos y seis no. No es descuido: el corte es deliberado y bastante nítido.

Con comportamiento Datos puros
Session · isActive(), isRevoked() Device
PetTag · isActive, canNotify() Diagnosis
ContactRequest · isValid(), isValidFor() Profile
Pet · assertAccessibleBy() Report
User · isEmailVerified(), isAdmin() Resource
Tenant · isDeleted() Settings
Appointment · isPending

La regla

Método en la entidad cuando la respuesta depende de varios campos suyos, o del tiempo, y la pregunta se repite. Si es entity.campo === X, va en quien pregunta.

Mira los tres casos que más claramente la cumplen:

// Depende del tiempo, y encapsula un número que no debería andar suelto
canNotify(now: Date): boolean {
  if (!this.lastNotifiedAt) return true;
  return now.getTime() - this.lastNotifiedAt.getTime() > NOTIFY_COOLDOWN_MS;
}

// Combina tres condiciones que nadie debería recomponer a mano
isValidFor(userId: string, hashedCode: string, now: Date): boolean

// Es la regla de acceso entera, en un único sitio
assertAccessibleBy(requester: User): void

canNotify es el mejor ejemplo de por qué merece la pena: la ventana de 10 minutos es una regla de negocio. Si viviera en el use case, el día que cambie hay que buscarla; viviendo en la entidad, hay un sitio y el nombre lo explica.

Y ahora el contraste: Report no tiene ni un método. No porque sea menos importante —es la entidad central del historial clínico— sino porque no hay ninguna pregunta sobre un reporte cuya respuesta no sea leer un campo. Añadirle métodos por simetría sería ruido.

Dos matices que se ven en el código

La entidad decide, pero no busca. Nunca carga nada ni llama a un repositorio. Quien la carga es otro:

// application/access/pet-access.service.ts
const pet = await this.pets.findById(petId);   // el service busca
if (!pet) throw new PetNotFoundError(petId);   // el service traduce el vacío
pet.assertAccessibleBy(requester);             // la entidad decide

assert* frente a is*. Dos formas distintas y no son intercambiables:

Si te encuentras escribiendo if (!pet.isAccessibleBy(user)) throw new ... en varios sitios, eso quería ser un assert.

Cómo son las entidades por dentro

export class PetTag {
  readonly id: string;
  readonly publicCode: string;
  readonly revokedAt: Date | null;

  constructor(p: { id: string; publicCode: string; revokedAt: Date | null /* ... */ }) {
    this.id = p.id;
    // ...
  }
}