Loading learning content...
Entities are not static data structures—they're living objects with lifespans. A customer is born when they register, evolves as they update their profile and accumulate orders, and may eventually be deactivated or deleted. An order is created when placed, passes through payment and shipping, and either completes successfully or gets cancelled.
This lifecycle matters because different operations are valid at different stages. You can't ship an unpaid order. You can't modify a delivered order. You can't reactivate a permanently deleted account.
Understanding and managing entity lifecycle is essential for building systems that correctly model real-world processes. It's the bridge between the static structure of entities and the dynamic reality of business operations.
By the end of this page, you'll understand the complete entity lifecycle including creation, state transitions, persistence considerations, soft vs hard deletion, event sourcing basics, and temporal modeling. You'll be able to design entities that correctly reflect how domain objects evolve over time.
Every entity passes through distinct lifecycle phases. Understanding these phases helps you design appropriate operations for each stage:
| Phase | Description | Key Characteristics | Common Operations |
|---|---|---|---|
| Transient | Entity created but not yet persisted | In-memory only, no persistence identity assigned by DB (if using sequences), fully mutable | Validation, setting initial state, preparing for first save |
| Persistent (New) | Just persisted for the first time | Has persistence identity, tracked by ORM, initial state recorded | First save, establishing relationships |
| Persistent (Managed) | Loaded from storage, actively managed | Changes tracked, can be modified, will be synchronized with storage | Business operations, state transitions, updates |
| Detached | Loaded previously, no longer managed by ORM session | Has identity, but changes aren't tracked automatically | Transfer between layers, serialization |
| Removed | Marked for deletion, pending commit | Still exists in memory, will be deleted on transaction commit | Cleanup operations, cascade handling |
| Archived/Soft-Deleted | Logically removed but data preserved | Hidden from normal queries, retrievable for audit/history | Audit trails, data retention compliance |
Phase transitions in practice:
1234567891011121314151617181920212223242526272829303132
// Entity lifecycle demonstration // 1. TRANSIENT: Created in memoryconst order = Order.create(customerId, shippingAddress);// order exists in memory, not in database// Has application-generated ID (if using UUIDs) // 2. PERSISTENT (NEW): First saveawait orderRepository.save(order);// order now exists in storage// Changes from this point are tracked // 3. PERSISTENT (MANAGED): Normal operationsorder.addLine(product, quantity);order.confirmPayment(transactionId);await orderRepository.save(order);// Changes are flushed to storage // 4. DETACHED: Sent to another layerconst orderDTO = orderMapper.toDTO(order);// orderDTO is just data, no longer tracked// Modifications to orderDTO don't affect database // 5. RE-ATTACHED: Loaded back for more workconst managedOrder = await orderRepository.findById(orderId);managedOrder.ship(trackingNumber);await orderRepository.save(managedOrder);// Back in managed state, changes tracked // 6. REMOVED: Deletionawait orderRepository.delete(managedOrder);// Entity marked for removal, deleted on commitThe exact semantics of transient/managed/detached depend on your ORM (Hibernate, EF Core, Prisma, etc.). Pure DDD focuses on domain phases (created, active, archived), while ORM lifecycle phases (transient, managed, detached) are technical implementation details. Don't conflate them.
Entity creation is a significant domain event. It's not just object construction—it's the birth of a domain concept. Several patterns help manage this effectively:
Order.create(customerId, address) — Simple, works well for straightforward creation.OrderFactory.createStandardOrder(...) — Useful when creation logic is complex or needs dependencies.Order.builder().customerId(...).address(...).build() — Useful for entities with many optional parameters.OrderingService.placeOrder(...) — When creation involves multiple aggregates or external validation.123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119
// PATTERN 1: Factory Method on Entity// Best for simple, self-contained creation class Order { static create( customerId: CustomerId, shippingAddress: Address ): Order { const order = new Order( OrderId.generate(), customerId, shippingAddress ); order.raise(new OrderCreated(order.id)); return order; }} // Usageconst order = Order.create(customerId, address); // PATTERN 2: Factory Class// Best when creation needs external dependencies class OrderFactory { constructor( private readonly pricingService: PricingService, private readonly inventoryChecker: InventoryChecker, private readonly taxCalculator: TaxCalculator ) {} async createOrder( customerId: CustomerId, items: OrderItemRequest[], shippingAddress: Address ): Promise<Order> { // Validate inventory availability await this.inventoryChecker.ensureAvailable(items); // Get current pricing const pricedItems = await this.pricingService .getPricing(items); // Calculate tax const tax = await this.taxCalculator .calculate(pricedItems, shippingAddress); // Create the order with all computed values const order = Order.createWithPricing( OrderId.generate(), customerId, shippingAddress, pricedItems, tax ); order.raise(new OrderCreated(order.id, order.total)); return order; }} // PATTERN 3: Builder Pattern// Best for many optional parameters class CustomerBuilder { private id: CustomerId = CustomerId.generate(); private email?: Email; private name?: string; private address?: Address; private tier: CustomerTier = CustomerTier.STANDARD; withEmail(email: Email): this { this.email = email; return this; } withName(name: string): this { this.name = name; return this; } withAddress(address: Address): this { this.address = address; return this; } withTier(tier: CustomerTier): this { this.tier = tier; return this; } build(): Customer { // Validate required fields if (!this.email) { throw new ValidationError("Email is required"); } if (!this.name) { throw new ValidationError("Name is required"); } return new Customer( this.id, this.email, this.name, this.address ?? Address.empty(), this.tier ); }} // Usageconst customer = new CustomerBuilder() .withEmail(new Email("alice@example.com")) .withName("Alice Smith") .withTier(CustomerTier.PREMIUM) .build();Entity creation is often a significant business event. Consider raising domain events like OrderCreated, CustomerRegistered, AccountOpened. These events can trigger welcome emails, audit logging, or downstream workflows.
After creation, entities evolve through state transitions. Managing these transitions correctly is crucial for maintaining consistency and expressing business rules.
State transition principles:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116
class Order { private _status: OrderStatus; private _events: DomainEvent[] = []; private _history: StateChange[] = []; // Transition with full validation and event raising confirmPayment( transactionId: PaymentTransactionId, paidBy: UserId, timestamp: Date = new Date() ): void { // 1. VALIDATE PRECONDITIONS this.assertStatus(OrderStatus.PENDING_PAYMENT); this.assertHasItems(); // 2. CAPTURE PREVIOUS STATE (for history/audit) const previousStatus = this._status; // 3. MAKE THE TRANSITION this._status = OrderStatus.PAID; this._paymentTransactionId = transactionId; this._paidAt = timestamp; // 4. RECORD HISTORY (for audit trail) this._history.push({ from: previousStatus, to: OrderStatus.PAID, timestamp, actor: paidBy, reason: `Payment confirmed: ${transactionId}` }); // 5. RAISE DOMAIN EVENT this.raise(new OrderPaid({ orderId: this._id, transactionId, amount: this._total, paidAt: timestamp })); } // Transition that may fail tryShip( trackingNumber: TrackingNumber, shippedBy: UserId ): Result<void, ShipmentError> { // Some transitions might fail for business reasons // beyond simple precondition checks if (this._status !== OrderStatus.PAID) { return Result.failure( new ShipmentError.NotPaid(this._id) ); } if (this._items.some(i => !i.isInStock)) { return Result.failure( new ShipmentError.ItemsNotInStock( this._items.filter(i => !i.isInStock) ) ); } // Perform the transition this._status = OrderStatus.SHIPPED; this._trackingNumber = trackingNumber; this._shippedAt = new Date(); this.raise(new OrderShipped({ orderId: this._id, trackingNumber, shippedAt: this._shippedAt })); return Result.success(undefined); } // Helper for status validation private assertStatus(expected: OrderStatus): void { if (this._status !== expected) { throw new InvalidOrderStateError({ orderId: this._id, currentStatus: this._status, expectedStatus: expected, operation: new Error().stack?.split('\n')[2] ?? 'unknown' }); } } private assertHasItems(): void { if (this._items.length === 0) { throw new OrderHasNoItemsError(this._id); } } // For event handling private raise(event: DomainEvent): void { this._events.push(event); } // Repository calls this after save clearEvents(): DomainEvent[] { const events = [...this._events]; this._events = []; return events; }} // StateChange interface for audit trailinterface StateChange { from: OrderStatus; to: OrderStatus; timestamp: Date; actor: UserId; reason: string;}When a transition spans multiple aggregates or services (e.g., order payment affects inventory, loyalty points, and shipping), use the Saga pattern. The entity transition is one step in a larger choreographed or orchestrated process.
Entities must be persisted to survive beyond the current process. The relationship between entity identity and persistence identity requires careful consideration:
| Strategy | When Identity Assigned | Pros | Cons |
|---|---|---|---|
| Application UUID | Before persistence | Entity always has identity; works distributed; simple code | UUIDs larger than integers; less readable |
| Database Sequence | After INSERT | Compact IDs; readable (Order #1234) | Entity lacks identity until saved; requires DB roundtrip |
| Hi-Lo Algorithm | Application reserves range from DB | Compact IDs; batch-friendly; fewer roundtrips | Complex configuration; gaps in sequences |
| Natural Key | Inherent in data (SSN, email) | Meaningful IDs; matches business language | Can change; uniqueness issues; privacy concerns |
| Composite Key | Combination of fields | Natural for relationship entities | Complex equality; harder to reference |
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667
// PATTERN 1: Application-generated UUID (Recommended) class OrderRepository { async save(order: Order): Promise<void> { // Order already has ID - simple upsert logic const exists = await this.db.order.findUnique({ where: { id: order.id.value } }); if (exists) { await this.db.order.update({ where: { id: order.id.value }, data: this.toPersistence(order) }); } else { await this.db.order.create({ data: this.toPersistence(order) }); } } async findById(id: OrderId): Promise<Order | null> { const row = await this.db.order.findUnique({ where: { id: id.value }, include: { lines: true } }); if (!row) return null; return this.toDomain(row); }} // PATTERN 2: Database-generated IDs (Legacy/Special Cases) interface TransientOrder { // No ID field - not yet persisted customerId: CustomerId; items: OrderItem[]; shippingAddress: Address;} interface PersistedOrder extends TransientOrder { id: OrderId; // Has ID after persistence} class LegacyOrderRepository { async create(order: TransientOrder): Promise<PersistedOrder> { // Database generates ID const row = await this.db.order.create({ data: { // id is auto-generated customerId: order.customerId.value, shippingAddress: JSON.stringify(order.shippingAddress) } }); // Now we have the ID return { ...order, id: OrderId.of(row.id.toString()) }; }} // Note: This pattern is more complex because the entity// must handle having or not having an ID. Avoid if possible.UUIDs simplify code significantly and work in distributed systems. If you need human-readable identifiers (Order #1234), use a separate display ID that's generated from a sequence. Internal identity can still be a UUID.
How do entities end their lifecycle? The choice between soft and hard deletion has significant implications for data integrity, compliance, and system behavior.
deletedAt or isDeleted flag1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889
// SOFT DELETE IMPLEMENTATION class Customer { private _id: CustomerId; private _status: CustomerStatus; private _deletedAt: Date | null = null; private _deletionReason: string | null = null; // Soft delete method deactivate(reason: string, deletedBy: UserId): void { if (this._status === CustomerStatus.DELETED) { throw new CustomerAlreadyDeletedError(this._id); } this._status = CustomerStatus.DELETED; this._deletedAt = new Date(); this._deletionReason = reason; this.raise(new CustomerDeactivated({ customerId: this._id, reason, deletedBy, deletedAt: this._deletedAt })); } // Restore method (undo soft delete) reactivate(reactivatedBy: UserId): void { if (this._status !== CustomerStatus.DELETED) { throw new CustomerNotDeletedError(this._id); } this._status = CustomerStatus.ACTIVE; this._deletedAt = null; this._deletionReason = null; this.raise(new CustomerReactivated({ customerId: this._id, reactivatedBy, reactivatedAt: new Date() })); } get isDeleted(): boolean { return this._status === CustomerStatus.DELETED; }} // Repository handles filteringclass CustomerRepository { // Default: only active customers async findById(id: CustomerId): Promise<Customer | null> { const row = await this.db.customer.findFirst({ where: { id: id.value, status: { not: 'DELETED' } // Filter soft-deleted } }); return row ? this.toDomain(row) : null; } // Explicit: include deleted (for admin/audit) async findByIdIncludingDeleted( id: CustomerId ): Promise<Customer | null> { const row = await this.db.customer.findUnique({ where: { id: id.value } }); return row ? this.toDomain(row) : null; } // Soft delete async softDelete(customer: Customer): Promise<void> { await this.db.customer.update({ where: { id: customer.id.value }, data: { status: 'DELETED', deletedAt: new Date() } }); } // Hard delete (use with caution) async hardDelete(id: CustomerId): Promise<void> { await this.db.customer.delete({ where: { id: id.value } }); }}| Use Soft Delete When | Use Hard Delete When |
|---|---|
| Data retention is legally required (GDPR data portability) | Data must be permanently removed (GDPR right to erasure) |
| Entity is referenced by other entities | Self-contained data with no references |
| Undo/restore functionality is needed | No restoration requirement |
| Audit trail must be preserved | Audit logged separately |
| Historical reporting needed | No historical requirements |
| Accidental deletion is a concern | Deletion is a final, deliberate action |
GDPR requires both data portability (keeping data) and right to erasure (deleting data). You may need soft delete for some data and hard delete capabilities for others. Personal data especially may require true deletion. Consult legal and compliance teams.
Sometimes knowing the current state isn't enough—you need to know what the state was at any point in time. This is temporal modeling, and it's essential for auditing, compliance, and business intelligence.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141
// PATTERN: Audit Log (Most Common) interface AuditEntry { id: string; entityType: string; entityId: string; action: 'CREATE' | 'UPDATE' | 'DELETE'; timestamp: Date; userId: string; previousState: Record<string, any> | null; newState: Record<string, any>; changedFields: string[];} class AuditingRepository<T extends Entity> { constructor( private readonly innerRepository: Repository<T>, private readonly auditLog: AuditLogService ) {} async save(entity: T, userId: UserId): Promise<void> { const existing = await this.innerRepository.findById(entity.id); const action = existing ? 'UPDATE' : 'CREATE'; const previousState = existing ? this.toSnapshot(existing) : null; const newState = this.toSnapshot(entity); // Save entity await this.innerRepository.save(entity); // Record audit await this.auditLog.record({ id: crypto.randomUUID(), entityType: entity.constructor.name, entityId: entity.id.toString(), action, timestamp: new Date(), userId: userId.value, previousState, newState, changedFields: this.detectChanges(previousState, newState) }); }} // PATTERN: Event Sourcing (Advanced) interface DomainEvent { eventId: string; aggregateId: string; eventType: string; timestamp: Date; data: Record<string, any>; version: number;} class OrderEventSourced { private _id: OrderId; private _version: number = 0; private _status: OrderStatus; private _uncommittedEvents: DomainEvent[] = []; // Commands cause events confirmPayment(transactionId: string): void { this.applyEvent({ eventType: 'OrderPaid', data: { transactionId, paidAt: new Date() } }); } // Events change state private applyEvent(event: Omit<DomainEvent, 'eventId' | 'aggregateId' | 'timestamp' | 'version'>): void { const fullEvent: DomainEvent = { ...event, eventId: crypto.randomUUID(), aggregateId: this._id.value, timestamp: new Date(), version: this._version + 1 }; this.mutate(fullEvent); this._uncommittedEvents.push(fullEvent); } private mutate(event: DomainEvent): void { switch (event.eventType) { case 'OrderPaid': this._status = OrderStatus.PAID; break; case 'OrderShipped': this._status = OrderStatus.SHIPPED; break; // ... other events } this._version = event.version; } // Reconstitute from event history static fromHistory(events: DomainEvent[]): OrderEventSourced { const order = new OrderEventSourced(); for (const event of events) { order.mutate(event); } return order; }} // Event-sourced repositoryclass EventSourcedOrderRepository { async save(order: OrderEventSourced): Promise<void> { const events = order.getUncommittedEvents(); // Append events to event store await this.eventStore.append( order.id.value, events, order.version - events.length // Expected version ); order.markEventsAsCommitted(); } async findById(id: OrderId): Promise<OrderEventSourced | null> { const events = await this.eventStore.getEvents(id.value); if (events.length === 0) return null; return OrderEventSourced.fromHistory(events); } // Time travel: get state at any point async findAtTime(id: OrderId, asOf: Date): Promise<OrderEventSourced | null> { const events = await this.eventStore.getEvents( id.value, { until: asOf } ); if (events.length === 0) return null; return OrderEventSourced.fromHistory(events); }}Event sourcing provides complete history and powerful audit capabilities, but increases complexity. Querying current state requires replaying events (mitigated by projections). Use it when history is a first-class requirement, not just an afterthought.
Entity lifecycle transitions often need to trigger reactions: send notifications, update read models, sync with external systems. Domain events are the clean way to handle this.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132
// Domain Events for Lifecycle Transitions abstract class DomainEvent { readonly occurredAt: Date = new Date(); abstract readonly eventType: string;} class CustomerRegistered extends DomainEvent { readonly eventType = 'CustomerRegistered'; constructor( readonly customerId: CustomerId, readonly email: Email, readonly name: string ) { super(); }} class CustomerDeactivated extends DomainEvent { readonly eventType = 'CustomerDeactivated'; constructor( readonly customerId: CustomerId, readonly reason: string, readonly deactivatedBy: UserId ) { super(); }} // Entity raises eventsclass Customer { private _domainEvents: DomainEvent[] = []; static register(email: Email, name: string): Customer { const customer = new Customer( CustomerId.generate(), email, name ); // Raise lifecycle event customer.addEvent(new CustomerRegistered( customer.id, email, name )); return customer; } deactivate(reason: string, by: UserId): void { this._status = CustomerStatus.DEACTIVATED; this.addEvent(new CustomerDeactivated( this._id, reason, by )); } private addEvent(event: DomainEvent): void { this._domainEvents.push(event); } popEvents(): DomainEvent[] { const events = [...this._domainEvents]; this._domainEvents = []; return events; }} // Event Handlers React to Lifecycle Events class CustomerRegisteredHandler { constructor( private readonly emailService: EmailService, private readonly analyticsService: AnalyticsService ) {} async handle(event: CustomerRegistered): Promise<void> { // Send welcome email await this.emailService.sendWelcome( event.email, event.name ); // Track in analytics await this.analyticsService.track({ event: 'customer_registered', customerId: event.customerId.value, timestamp: event.occurredAt }); }} class CustomerDeactivatedHandler { constructor( private readonly subscriptionService: SubscriptionService, private readonly notificationService: NotificationService ) {} async handle(event: CustomerDeactivated): Promise<void> { // Cancel subscriptions await this.subscriptionService.cancelAll( event.customerId ); // Notify admin await this.notificationService.notifyAdmins({ type: 'customer_deactivated', customerId: event.customerId.value, reason: event.reason, timestamp: event.occurredAt }); }} // Dispatcher (in Application/Infrastructure layer)class DomainEventDispatcher { private handlers = new Map<string, EventHandler[]>(); register(eventType: string, handler: EventHandler): void { const handlers = this.handlers.get(eventType) ?? []; handlers.push(handler); this.handlers.set(eventType, handlers); } async dispatch(event: DomainEvent): Promise<void> { const handlers = this.handlers.get(event.eventType) ?? []; await Promise.all( handlers.map(h => h.handle(event)) ); }}Dispatch domain events after the transaction commits successfully. If you dispatch before commit and the transaction rolls back, you've sent events for changes that didn't persist. Many frameworks provide hooks for post-commit event dispatch.
We've comprehensively covered the entity lifecycle from creation through archival. Let's consolidate the key insights:
Module complete:
You've now mastered entities in Domain-Driven Design. You understand:
This foundation prepares you for the next DDD building block: Value Objects, which complement entities by representing concepts defined by their attributes rather than identity.
Congratulations! You've completed the Entities module. You now have a comprehensive understanding of how to model, design, and manage entities in Domain-Driven Design. You're ready to continue your DDD journey with Value Objects and Aggregates.