Skip to content

Domain Entity Lifecycle

Summary

All domain entities follow a consistent lifecycle pattern that enforces encapsulation, internal identity generation, and controlled mutation through behavior methods.

Core Principles

  1. Encapsulation: Entity state is private. External code can read via getters but cannot modify directly.
  2. Internal Identity: id, createdAt, and updatedAt are generated internally when omitted.
  3. Persistence Fallback Identity: BasePersistenceEntity also configures a database-side uuidv7() default so manual SQL inserts can omit id and still align with app-generated identifiers.
  4. Behavior-Driven Mutation: Entities expose methods for allowed state changes, not generic setters.
  5. Immutable by Default: Some entities are naturally immutable (e.g., NotificationLog, UserAgency).

Entity Styles

Two entity styles exist in the codebase:

  • Props-based (e.g. Agency): Extends BaseEntity<TProps>, stores domain state in _props, uses base getPlainObject, getPropertyKeys, and update. Recommended for new entities.
  • Legacy (e.g. User, Client): Uses private fields instead of _props, implements its own lifecycle methods. Will be migrated to props-based over time.

BaseEntity

Props-based entities extend BaseEntity<T> which provides:

typescript
abstract class BaseEntity<T extends BaseProps> {
  private _id: string;
  private _createdAt: Date;
  private _updatedAt: Date;
  protected _props: T;

  constructor(props: T & BaseEntityProps) {
    this._id = props.id ?? generateId();
    this._createdAt = props.createdAt ?? new Date();
    this._updatedAt = props.updatedAt ?? new Date();
    this._props = props;
  }

  get id(): string;
  get createdAt(): Date;
  get updatedAt(): Date;

  protected touch(): void; // Updates _updatedAt

  /** Full entity as plain object (DTOs, serialization). */
  public getPlainObject(): ObjectEntity<T>;

  /** All property keys without duplicates. */
  public getPropertyKeys(): EntityProps<T>[];

  /** Override to restrict updatable props. Default: all domain props. */
  protected getUpdatableKeys(): (keyof T)[];

  /** Generic update: applies only getUpdatableKeys(), then touch(). */
  public update(props: Partial<T>): void;

  /** Factory for new entities — id/timestamps auto-generated. */
  static new<E>(props: T, base?: BaseEntityProps): E;

  /** Factory for rehydration from persistence — base fields required. */
  static rehydrate<E>(props: T & Required<BaseEntityProps>): E;
}
  • getPlainObject(): Returns the complete entity as a plain object (spread of _props plus id, createdAt, updatedAt).
  • getPropertyKeys(): Returns all keys without duplicates.
  • getUpdatableKeys(): Override to exclude immutable props (e.g. ownerId). Base default: all domain props.
  • update(props): Applies only keys in getUpdatableKeys(), then updates updatedAt. Subclasses keep type-safe *UpdateProps and delegate to super.update(props).

Entity Creation Patterns

BaseEntity provides two static factory methods via polymorphic this typing, so every props-based subclass inherits them with correct types automatically:

typescript
// Brand-new entity — id and timestamps auto-generated
static new<E>(props: T, base?: BaseEntityProps): E;

// Reconstruct from persistence — all base fields required
static rehydrate<E>(props: T & Required<BaseEntityProps>): E;

Create (New Entity)

Use static new() — no need to pass id, createdAt, or updatedAt:

typescript
const agency = Agency.new({
  name: 'Acme',
  ownerId: userId,
  isActive: true,
});
// Return type is Agency, props typed as AgencyProps

Legacy entities still use create() until migrated to the props-based style.

Rehydrate (From Persistence)

Use static rehydrate() when loading from database — all base fields are required:

typescript
const agency = Agency.rehydrate({
  id: 'existing-uuid',
  name: 'Acme',
  ownerId: userId,
  isActive: true,
  createdAt: existingDate,
  updatedAt: existingDate,
});

Mutation Rules

Mutable Entities

Entities with allowed mutations expose behavior methods:

EntityAllowed Mutations
Userupdate({ firstName?, lastName? }), activate(), deactivate(), linkGoogleAccount(googleId)
Clientupdate({ firstName?, lastName? }), activate(), deactivate(), linkGoogleAccount(googleId)
Agencyupdate({ name?, oauthCredentials?, isActive?, ... }), updateOAuthCredentials(credentials), activate(), deactivate()
UserVerificationTokenmarkAsUsed(at?)
ClientVerificationTokenmarkAsUsed(at?)
RefreshTokenrevoke(at?)

Props-Based Entity Example: Agency

Agency restricts updatable props (e.g. ownerId is immutable):

typescript
export type AgencyUpdateProps = Partial<Omit<AgencyProps, 'ownerId'>>;

export class Agency extends BaseEntity<AgencyProps> {
  protected getUpdatableKeys(): (keyof AgencyProps)[] {
    return ['name', 'oauthCredentials', 'isActive'];
  }

  public update(props: AgencyUpdateProps): void {
    super.update(props);
  }
}

Immutable Entities

These entities have no mutation methods:

  • UserAgency — represents a relationship; create-only
  • NotificationLog — represents an event; create-only

Computed Properties

Entities may expose computed getters that derive state:

typescript
class UserVerificationToken {
  get isUsed(): boolean {
    return this._usedAt !== null;
  }
  get isExpired(): boolean {
    return this._expiresAt <= new Date();
  }
}

class RefreshToken {
  get isValid(): boolean {
    return this._revokedAt === null && this._expiresAt > new Date();
  }
  get isRevoked(): boolean {
    return this._revokedAt !== null;
  }
  get isExpired(): boolean {
    return this._expiresAt <= new Date();
  }
}

Mapper Pattern

Mappers explicitly use entity getters for persistence mapping:

typescript
class UserMapper extends BaseMapper<User, UserPersistence> {
  protected toDomainProps(persistence: UserPersistence): UserRehydrateProps {
    return {
      id: persistence.id,
      email: persistence.email,
      // ... all fields explicitly mapped
    };
  }

  protected toPersistenceProps(domain: User): Partial<UserPersistence> {
    return {
      email: domain.email,
      firstName: domain.firstName,
      // ... use getters, not object spread
    };
  }
}

Anti-Patterns to Avoid

  1. Recreating entities for updates: Don't do new Entity({ ...old, field: newValue }). Use behavior methods.
  2. Spreading entity state: Don't do { ...entity }. Use explicit getters.
  3. Passing generated IDs/timestamps: Let the entity generate them internally.

Persistence ID Fallback

BasePersistenceEntity keeps the application-side @BeforeInsert() UUIDv7 generation for normal writes, and also declares a database default of uuidv7() on the primary key column. This allows manual SQL inserts to omit id while preserving the same UUID version expected by the app.

Because entity metadata alone does not change the live schema, any table that extends BasePersistenceEntity still needs a migration to apply the new column default in PostgreSQL.

Example: Before vs After

Before (Anti-Pattern)

typescript
const activeUser = new User({
  ...user,
  isActive: true,
  updatedAt: new Date(),
});

After (Correct)

typescript
user.activate();
await userRepo.save(user);

Change Log

  • 2026-02-27: Added static new() and static rehydrate() to BaseEntity via polymorphic this typing; props-based subclasses inherit them automatically.
  • 2026-02-27: Added props-based BaseEntity with getPlainObject, getPropertyKeys, getUpdatableKeys, update; documented two entity styles.
  • 2026-02-26: Introduced entity lifecycle pattern with encapsulation and behavior methods.
  • 2026-03-09: Documented the database-side uuidv7() fallback on BasePersistenceEntity primary keys for manual SQL inserts.