Appearance
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
- Encapsulation: Entity state is private. External code can read via getters but cannot modify directly.
- Internal Identity:
id,createdAt, andupdatedAtare generated internally when omitted. - Persistence Fallback Identity:
BasePersistenceEntityalso configures a database-sideuuidv7()default so manual SQL inserts can omitidand still align with app-generated identifiers. - Behavior-Driven Mutation: Entities expose methods for allowed state changes, not generic setters.
- 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): ExtendsBaseEntity<TProps>, stores domain state in_props, uses basegetPlainObject,getPropertyKeys, andupdate. 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
_propsplusid,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 updatesupdatedAt. Subclasses keep type-safe*UpdatePropsand delegate tosuper.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 AgencyPropsLegacy 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:
| Entity | Allowed Mutations |
|---|---|
User | update({ firstName?, lastName? }), activate(), deactivate(), linkGoogleAccount(googleId) |
Client | update({ firstName?, lastName? }), activate(), deactivate(), linkGoogleAccount(googleId) |
Agency | update({ name?, oauthCredentials?, isActive?, ... }), updateOAuthCredentials(credentials), activate(), deactivate() |
UserVerificationToken | markAsUsed(at?) |
ClientVerificationToken | markAsUsed(at?) |
RefreshToken | revoke(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-onlyNotificationLog— 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
- Recreating entities for updates: Don't do
new Entity({ ...old, field: newValue }). Use behavior methods. - Spreading entity state: Don't do
{ ...entity }. Use explicit getters. - 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()andstatic rehydrate()to BaseEntity via polymorphicthistyping; 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 onBasePersistenceEntityprimary keys for manual SQL inserts.