Loading learning content...
Every object in a software system leads a life—it is born, it performs its duties, and eventually it must die. For most objects, this lifecycle is trivial: they're created, used, and garbage collected when no longer referenced. But for objects that manage external resources—database connections, file handles, network sockets, graphics contexts, hardware devices—this lifecycle becomes critical infrastructure.
Mismanaging these lifecycles doesn't merely waste memory; it causes connection pool exhaustion, file descriptor leaks, database timeouts, and ultimately production outages. Understanding the lifecycle phases of resource-holding objects is therefore not academic trivia—it's essential knowledge for building reliable systems.
By the end of this page, you will understand the three fundamental phases of object lifecycle (creation, use, disposal), why each phase presents distinct challenges for resource management, and how proper lifecycle design prevents the resource leaks that plague production systems.
Object lifecycle management divides an object's existence into three distinct phases. While this seems obvious, the devil lies in the details—particularly the transitions between phases and what guarantees each phase provides.
Phase 1: Creation (Birth)
During creation, an object is instantiated and acquires the resources it needs to function. This phase establishes the object's invariants—the conditions that must remain true throughout the object's lifetime. For resource-holding objects, creation typically includes:
A properly created object should be immediately usable. If creation can fail (and for resource-acquiring objects, it often can), the object should either not exist or exist in a clearly invalid state. Half-initialized objects are a major source of bugs.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
// Well-designed creation: object is fully usable or not created at allclass DatabaseConnection { private connection: Connection; private readonly connectionString: string; private readonly timeout: number; // Private constructor - creation only via factory method private constructor(connection: Connection, connectionString: string, timeout: number) { this.connection = connection; this.connectionString = connectionString; this.timeout = timeout; } // Factory method that handles creation phase properly static async create(connectionString: string, timeout: number = 30000): Promise<DatabaseConnection> { // Validate inputs before attempting resource acquisition if (!connectionString) { throw new Error("Connection string is required"); } if (timeout <= 0) { throw new Error("Timeout must be positive"); } // Acquire the external resource // If this fails, no object is created - no cleanup needed const connection = await createDatabaseConnection(connectionString, { timeout, autoReconnect: false }); // Only create object after successful resource acquisition return new DatabaseConnection(connection, connectionString, timeout); } // Object is immediately usable after creation async query(sql: string): Promise<QueryResult> { return this.connection.execute(sql); }} // Usage: either we get a working connection or an error// No half-initialized states possibletry { const conn = await DatabaseConnection.create("postgresql://..."); // conn is guaranteed to be usable here} catch (error) { // Creation failed, no cleanup needed - no connection object exists}Phase 2: Use (Active Life)
During the use phase, the object performs its intended functions. For resource-holding objects, this typically means mediating access to the underlying resource—executing queries through a database connection, reading/writing through a file handle, or sending/receiving through a network socket.
The use phase must maintain several properties:
The most insidious bug in lifecycle management is using an object after its resources have been released. This can cause crashes, data corruption, or silent failures depending on the language and resource type. Proper lifecycle design makes use-after-disposal impossible or immediately detectable.
Phase 3: Disposal (Death)
Disposal releases the resources the object holds and transitions it to a non-functional state. For resource-holding objects, disposal is not optional—failing to dispose properly creates resource leaks that accumulate until the system fails.
Disposal must guarantee:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546
class DatabaseConnection { private connection: Connection | null; private disposed: boolean = false; // ... creation code ... // Disposal must be idempotent and safe async dispose(): Promise<void> { // Already disposed - idempotent, safe to call multiple times if (this.disposed) { return; } // Mark as disposed FIRST - prevents concurrent use during disposal this.disposed = true; // Release the resource with error handling if (this.connection) { try { await this.connection.close(); } catch (error) { // Log but don't throw - disposal should complete console.error("Error closing connection:", error); } finally { // Clear reference even if close failed this.connection = null; } } } // All methods check disposed state async query(sql: string): Promise<QueryResult> { if (this.disposed) { throw new Error("Cannot use disposed connection"); } if (!this.connection) { throw new Error("Connection is not available"); } return this.connection.execute(sql); } // Property to check lifecycle state get isDisposed(): boolean { return this.disposed; }}Understanding lifecycle phases is only half the battle. The transitions between phases are where most bugs occur. A well-designed resource-holding class must explicitly model these transitions and enforce valid state progressions.
The valid lifecycle is a one-way journey: Created → In Use → Disposed. Objects cannot return to earlier states. Attempting to use a disposed object or dispose an object mid-operation are the errors we must prevent.
The Critical Invariants
Each lifecycle state has invariants that must hold:
| State | Resource Invariants | Usage Invariants |
|---|---|---|
| Creating | Resources being acquired | Object not yet returned to caller |
| Ready | All resources acquired and valid | Object can receive method calls |
| In Use | Resources actively being used | Method execution in progress |
| Error | Resources may be in unknown state | Recovery or disposal needed |
| Disposing | Resources being released | No new operations allowed |
| Disposed | All resources released | Any method call throws exception |
Violating these invariants is the root cause of resource management bugs. For example:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101
// Explicit state machine for lifecycle managementenum ConnectionState { Creating = "CREATING", Ready = "READY", InUse = "IN_USE", Disposing = "DISPOSING", Disposed = "DISPOSED"} class ManagedConnection { private state: ConnectionState = ConnectionState.Creating; private connection: Connection | null = null; private activeOperations: number = 0; // State transition: Creating → Ready private async initialize(connectionString: string): Promise<void> { this.assertState(ConnectionState.Creating, "initialize"); try { this.connection = await createConnection(connectionString); this.transitionTo(ConnectionState.Ready); } catch (error) { // No state to transition to - object creation fails throw error; } } // State transition: Ready → InUse → Ready async query<T>(sql: string): Promise<T> { this.assertState(ConnectionState.Ready, "query"); this.transitionTo(ConnectionState.InUse); this.activeOperations++; try { const result = await this.connection!.execute<T>(sql); return result; } finally { this.activeOperations--; if (this.activeOperations === 0) { this.transitionTo(ConnectionState.Ready); } } } // State transition: Ready → Disposing → Disposed async dispose(): Promise<void> { // Idempotent - already disposed if (this.state === ConnectionState.Disposed) { return; } // Cannot dispose during active operations if (this.state === ConnectionState.InUse) { throw new Error("Cannot dispose connection with active operations"); } this.assertState(ConnectionState.Ready, "dispose"); this.transitionTo(ConnectionState.Disposing); try { if (this.connection) { await this.connection.close(); this.connection = null; } } finally { // Always reach Disposed state, even on error this.transitionTo(ConnectionState.Disposed); } } private assertState(expected: ConnectionState, operation: string): void { if (this.state !== expected) { throw new Error( `Cannot perform ${operation} in state ${this.state}. Expected: ${expected}` ); } } private transitionTo(newState: ConnectionState): void { const validTransitions: Record<ConnectionState, ConnectionState[]> = { [ConnectionState.Creating]: [ConnectionState.Ready], [ConnectionState.Ready]: [ConnectionState.InUse, ConnectionState.Disposing], [ConnectionState.InUse]: [ConnectionState.Ready], [ConnectionState.Disposing]: [ConnectionState.Disposed], [ConnectionState.Disposed]: [] }; if (!validTransitions[this.state].includes(newState)) { throw new Error( `Invalid state transition: ${this.state} → ${newState}` ); } this.state = newState; } get currentState(): ConnectionState { return this.state; }}Creating resource-holding objects is surprisingly complex. The fundamental challenge is that resource acquisition can fail—connections time out, files are locked, memory is exhausted—and we must ensure failures leave no dangling resources.
The Half-Initialization Problem
Consider an object that acquires multiple resources during construction. If the first acquisition succeeds but the second fails, we have a problem: the constructor throws an exception, so no disposal method can be called, but the first resource is already allocated. This is the half-initialization problem, and it plagues naive implementations.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293
// ANTI-PATTERN: Resource leak on partial failureclass DangerousDataProcessor { private inputFile: FileHandle; private outputFile: FileHandle; private database: Connection; // This constructor is DANGEROUS async constructor(inputPath: string, outputPath: string, dbString: string) { // What if outputFile acquisition fails? // inputFile is leaked forever! this.inputFile = await openFile(inputPath, 'r'); this.outputFile = await openFile(outputPath, 'w'); // ← Failure here leaks inputFile this.database = await connectDatabase(dbString); // ← Failure here leaks both files }} // CORRECT PATTERN: All-or-nothing factory methodclass SafeDataProcessor { private inputFile: FileHandle; private outputFile: FileHandle; private database: Connection; private constructor( inputFile: FileHandle, outputFile: FileHandle, database: Connection ) { this.inputFile = inputFile; this.outputFile = outputFile; this.database = database; } static async create( inputPath: string, outputPath: string, dbString: string ): Promise<SafeDataProcessor> { // Track successfully acquired resources let inputFile: FileHandle | null = null; let outputFile: FileHandle | null = null; let database: Connection | null = null; try { // Acquire resources one by one, tracking each inputFile = await openFile(inputPath, 'r'); outputFile = await openFile(outputPath, 'w'); database = await connectDatabase(dbString); // All succeeded - create the object return new SafeDataProcessor(inputFile, outputFile, database); } catch (error) { // Cleanup any resources that were successfully acquired const cleanupErrors: Error[] = []; if (database) { try { await database.close(); } catch (e) { cleanupErrors.push(e as Error); } } if (outputFile) { try { await outputFile.close(); } catch (e) { cleanupErrors.push(e as Error); } } if (inputFile) { try { await inputFile.close(); } catch (e) { cleanupErrors.push(e as Error); } } // Report cleanup errors if any if (cleanupErrors.length > 0) { console.error("Cleanup errors during failed creation:", cleanupErrors); } // Re-throw the original error throw error; } } async dispose(): Promise<void> { // Dispose in reverse order of acquisition // Even if one fails, continue with others await this.safeClose(this.database); await this.safeClose(this.outputFile); await this.safeClose(this.inputFile); } private async safeClose(resource: Closable): Promise<void> { try { await resource.close(); } catch (error) { console.error("Error during cleanup:", error); } }}Many languages address this with two-phase construction: the constructor only initializes simple fields, while a separate 'initialize' or 'connect' method performs resource acquisition. This makes factory methods easier to implement since the object exists and can have cleanup called on it. However, it requires discipline to always call the initialization method and check the 'initialized' state.
During active use, resource-holding objects must maintain the illusion that their underlying resources are always available and valid. But external resources can fail unexpectedly—connections are dropped, files are deleted by other processes, network partitions occur. Robust use-phase design must account for these realities.
Keeping Resources Valid
Well-designed resource objects implement several strategies to ensure use-phase reliability:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110
class ResilientConnection { private connection: Connection | null = null; private disposed: boolean = false; private lastUseTime: number = Date.now(); private failureCount: number = 0; private readonly maxFailures: number = 3; private readonly staleThresholdMs: number = 30000; private readonly connectionString: string; // Health check before critical operations private async ensureHealthy(): Promise<void> { if (this.disposed) { throw new Error("Connection has been disposed"); } // Check for stale connection const now = Date.now(); const timeSinceLastUse = now - this.lastUseTime; if (timeSinceLastUse > this.staleThresholdMs) { // Connection may be stale - verify with ping await this.verifyConnection(); } // Circuit breaker check if (this.failureCount >= this.maxFailures) { throw new Error("Circuit breaker open - too many failures"); } } private async verifyConnection(): Promise<void> { if (!this.connection) { await this.reconnect(); return; } try { // Lightweight health check await this.connection.ping(); } catch { // Connection dead - try to reconnect await this.reconnect(); } } private async reconnect(): Promise<void> { // Close old connection if it exists if (this.connection) { try { await this.connection.close(); } catch { // Ignore errors closing dead connection } this.connection = null; } // Attempt reconnection this.connection = await createConnection(this.connectionString); this.failureCount = 0; // Reset circuit breaker on successful reconnect } async query<T>(sql: string): Promise<T> { // Pre-operation health check await this.ensureHealthy(); try { const result = await this.executeWithTimeout( () => this.connection!.execute<T>(sql), 30000 // 30 second timeout ); // Successful operation this.lastUseTime = Date.now(); this.failureCount = 0; return result; } catch (error) { // Track failure for circuit breaker this.failureCount++; // Check if error is recoverable if (this.isRecoverableError(error)) { // Try once more after reconnection await this.reconnect(); return this.connection!.execute<T>(sql); } throw error; } } private async executeWithTimeout<T>( operation: () => Promise<T>, timeoutMs: number ): Promise<T> { return Promise.race([ operation(), new Promise<T>((_, reject) => setTimeout(() => reject(new Error("Operation timed out")), timeoutMs) ) ]); } private isRecoverableError(error: unknown): boolean { const message = (error as Error)?.message?.toLowerCase() ?? ""; return message.includes("connection reset") || message.includes("connection closed") || message.includes("network error"); }}Disposal is where lifecycle management either succeeds or fails. Unlike creation and use, disposal has one overriding concern: resources must be released, no matter what. A disposal method that can leave resources unreleased—due to exceptions, partial execution, or race conditions—is a bug, not a feature.
The Cardinal Rules of Disposal
| Rule | Rationale | Violation Consequence |
|---|---|---|
| Idempotent | Disposal may be called multiple times (by multiple code paths) | Double-free crashes, resource corruption |
| Non-throwing | Errors during disposal cannot prevent resource release | Resource leaks when exceptions abort cleanup |
| Complete | All resources must be released, not just some | Slow leaks that accumulate over time |
| Final | After disposal, object must be unusable | Use-after-free/close bugs |
| Ordered | Resources released in reverse order of acquisition | Dependency errors (releasing X before Y that depends on X) |
The 'Non-Throwing' Problem
The non-throwing requirement creates a dilemma: what if cleanup actually fails? For example, flushing buffered writes to a file might fail due to a full disk. This is a real error that the caller might want to know about.
The solution is to separate logical close (committing/flushing) from physical close (releasing handles):
This pattern puts the caller in control: if they care about flush failures, they flush explicitly. Disposal then just cleans up handles.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162
class BufferedWriter { private buffer: Buffer[] = []; private fileHandle: FileHandle | null; private disposed: boolean = false; // Explicit flush - can fail, caller can handle async flush(): Promise<void> { if (this.disposed) { throw new Error("Writer has been disposed"); } if (this.buffer.length > 0 && this.fileHandle) { // This may throw - disk full, permissions, etc. await this.fileHandle.write(Buffer.concat(this.buffer)); this.buffer = []; } } // Dispose - releases resources, never throws async dispose(): Promise<void> { if (this.disposed) { return; // Idempotent } this.disposed = true; // Best-effort flush (swallow errors) if (this.buffer.length > 0 && this.fileHandle) { try { await this.fileHandle.write(Buffer.concat(this.buffer)); } catch (error) { // Log but don't throw - disposal must complete console.warn("Failed to flush buffer during disposal:", error); } this.buffer = []; } // Release file handle (swallow errors) if (this.fileHandle) { try { await this.fileHandle.close(); } catch (error) { console.warn("Failed to close file during disposal:", error); } this.fileHandle = null; } }} // Correct usage patternasync function processFile(writer: BufferedWriter): Promise<void> { try { await writer.write(data); await writer.write(moreData); // Explicit flush - if this fails, we know about it await writer.flush(); } finally { // Dispose always runs, always succeeds (swallows errors) await writer.dispose(); }}When disposing multiple resources, continue disposal even if one fails. Collect all errors and log them (or provide an error aggregation callback). The alternative—stopping at the first error—leaves subsequent resources leaked.
A resource-holding object exists, but who is responsible for disposing it? This question of ownership is critical and must be explicitly defined. Unclear ownership leads to either resource leaks (nobody disposes) or double-dispose bugs (multiple code paths try to dispose).
Ownership Patterns
| Pattern | Description | When to Use |
|---|---|---|
| Creator Owns | The code that creates the resource is responsible for disposing it | Default pattern; most common for local resources |
| Caller Owns | Resource is passed to a method; caller retains ownership | When method uses but doesn't consume the resource |
| Callee Owns | Resource is passed to a method; method takes ownership | Factory patterns, builders, 'take' semantics |
| Container Owns | Collection/parent object owns all contained resources | Composite patterns, object graphs |
| Pool Owns | Pool creates and disposes; clients borrow and return | Connection pools, thread pools, object pools |
Making Ownership Explicit
Ownership should be documented and enforced. Modern languages provide various mechanisms:
unique_ptr vs shared_ptr vs raw pointers signal ownershipFor languages without ownership types, use consistent naming:
createConnection() — returns owned resource, caller must disposegetConnection() — returns borrowed resource, pool owns ittakeConnection() — transfers ownership to caller12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091
// Pattern 1: Creator Ownsasync function processData(): Promise<void> { // We create it, we dispose it const connection = await DatabaseConnection.create("..."); try { await connection.query("SELECT ..."); } finally { await connection.dispose(); }} // Pattern 2: Caller Owns (Borrowed reference)class DataProcessor { // Takes borrowed connection - does NOT dispose async process(connection: DatabaseConnection): Promise<ProcessResult> { // Use connection, do not dispose return connection.query("SELECT ..."); }} // Caller provides and disposesasync function main(): Promise<void> { const connection = await DatabaseConnection.create("..."); try { const processor = new DataProcessor(); await processor.process(connection); // Borrowed } finally { await connection.dispose(); // Caller disposes }} // Pattern 3: Callee Owns (Ownership transfer)class ConnectionBuilder { private connection: DatabaseConnection | null = null; async setConnection(connection: DatabaseConnection): Promise<void> { this.connection = connection; } // Takes ownership - connection will be disposed when builder is disposed async takeConnection(connection: DatabaseConnection): Promise<void> { // Dispose old if we had one if (this.connection) { await this.connection.dispose(); } this.connection = connection; // Take ownership } async dispose(): Promise<void> { if (this.connection) { await this.connection.dispose(); this.connection = null; } }} // Pattern 4: Pool Ownsclass ConnectionPool { private pool: DatabaseConnection[] = []; private inUse: Set<DatabaseConnection> = new Set(); // Borrow from pool - do NOT dispose async acquire(): Promise<DatabaseConnection> { const conn = this.pool.pop() ?? await DatabaseConnection.create("..."); this.inUse.add(conn); return conn; } // Return to pool - pool decides whether to dispose or reuse async release(connection: DatabaseConnection): Promise<void> { if (!this.inUse.has(connection)) { throw new Error("Connection not from this pool"); } this.inUse.delete(connection); if (this.pool.length < this.maxSize) { this.pool.push(connection); // Reuse } else { await connection.dispose(); // Pool size exceeded, dispose } } async disposeAll(): Promise<void> { // Pool owns, pool disposes all for (const conn of [...this.pool, ...this.inUse]) { await conn.dispose(); } this.pool = []; this.inUse.clear(); }}Let's examine how lifecycle management applies to common resource types. Each resource type has specific creation, use, and disposal considerations.
Creation Challenges
Use Challenges
Disposal Challenges
We've explored the fundamental lifecycle phases of resource-holding objects. Let's consolidate the essential insights:
What's Next:
Now that we understand the three lifecycle phases, we'll examine the critical question: When does disposal happen? The next page explores deterministic vs non-deterministic cleanup—the profound difference between knowing exactly when resources are released and hoping the garbage collector eventually handles it.
You now understand the three-phase lifecycle model for resource-holding objects. This foundation is essential for everything that follows—from the Disposable pattern to connection pooling to memory management. Next, we'll explore when cleanup happens, comparing deterministic and non-deterministic approaches.