Managing Object Lifecycle with Constructors and IDisposable in C#

Why Object Lifecycle Management Matters

When you create objects in C#, you're responsible for both their birth and death. Constructors handle initialization, setting up the object with the right state. Destructors and the IDisposable pattern handle cleanup, ensuring resources don't leak.

Proper lifecycle management prevents memory leaks, file handle exhaustion, and connection pool depletion. These issues often show up only in production under load, making them expensive to fix. Understanding how to initialize and clean up objects correctly saves you from debugging nightmares later.

You'll learn how to write effective constructors, implement the dispose pattern properly, and avoid common mistakes that lead to resource leaks. We'll also cover when you need destructors and when you don't.

Constructor Fundamentals

Constructors are special methods that run when you create an object. They initialize fields, validate parameters, and set up the object's initial state. Every class has at least one constructor, even if you don't write one explicitly.

You'll use constructors to enforce invariants, meaning rules that must always be true about your object. For example, a BankAccount might require a non-negative initial balance. The constructor ensures this rule holds from the moment the object exists.

Constructor chaining lets you reuse initialization logic across multiple constructors. One constructor does the real work while others delegate to it with different parameter combinations. This pattern reduces code duplication and makes your initialization logic easier to maintain.

DatabaseConnection.cs
public class DatabaseConnection
{
    private readonly string _connectionString;
    private readonly int _timeout;
    private readonly bool _pooling;

    // Primary constructor with all parameters
    public DatabaseConnection(string connectionString, int timeout, bool pooling)
    {
        if (string.IsNullOrWhiteSpace(connectionString))
            throw new ArgumentException("Connection string required",
                nameof(connectionString));

        if (timeout < 0)
            throw new ArgumentOutOfRangeException(nameof(timeout),
                "Timeout cannot be negative");

        _connectionString = connectionString;
        _timeout = timeout;
        _pooling = pooling;
    }

    // Delegates to primary constructor with defaults
    public DatabaseConnection(string connectionString, int timeout)
        : this(connectionString, timeout, pooling: true)
    {
    }

    // Delegates with more defaults
    public DatabaseConnection(string connectionString)
        : this(connectionString, timeout: 30)
    {
    }
}

The primary constructor contains all validation logic and field initialization. The other constructors simply call it with sensible defaults. This ensures validation happens in one place, reducing the chance of bugs when you add new validation rules later.

Implementing the IDisposable Pattern

When your class holds resources that need explicit cleanup, you implement IDisposable. This interface has one method, Dispose, which releases resources immediately rather than waiting for the garbage collector. File handles, database connections, and network sockets all need timely cleanup.

The standard dispose pattern handles both managed and unmanaged resources correctly. Managed resources are other .NET objects, often themselves IDisposable. Unmanaged resources are operating system handles that the garbage collector doesn't track. You'll clean up managed resources only when explicitly disposing, but unmanaged resources must be cleaned up in both Dispose and the finalizer.

The disposing parameter in the pattern indicates whether Dispose was called explicitly or from a finalizer. When true, you can safely access managed objects. When false (called from finalizer), other managed objects may already be collected, so you only clean up unmanaged resources.

FileLogger.cs
public class FileLogger : IDisposable
{
    private StreamWriter? _writer;
    private bool _disposed;

    public FileLogger(string filePath)
    {
        _writer = new StreamWriter(filePath, append: true);
    }

    public void Log(string message)
    {
        if (_disposed)
            throw new ObjectDisposedException(nameof(FileLogger));

        _writer?.WriteLine($"{DateTime.Now:yyyy-MM-dd HH:mm:ss} - {message}");
        _writer?.Flush();
    }

    protected virtual void Dispose(bool disposing)
    {
        if (_disposed)
            return;

        if (disposing)
        {
            // Clean up managed resources
            _writer?.Dispose();
            _writer = null;
        }

        // Clean up unmanaged resources here if any

        _disposed = true;
    }

    public void Dispose()
    {
        Dispose(disposing: true);
        GC.SuppressFinalize(this);
    }
}

The Dispose method calls the protected Dispose(bool) overload with true, indicating explicit disposal. GC.SuppressFinalize tells the garbage collector to skip the finalizer since we've already cleaned up. The disposing check prevents double disposal, which could cause exceptions.

Automatic Disposal with Using Statements

The using statement guarantees Dispose gets called even if exceptions occur. This pattern wraps try-finally blocks in a cleaner syntax, making it impossible to forget cleanup. You should use this pattern whenever working with IDisposable objects.

C# 8 introduced using declarations that automatically dispose at the end of the enclosing scope. This reduces nesting and makes code more readable when you need multiple disposable objects. The compiler generates the same try-finally structure behind the scenes.

Program.cs
// Traditional using statement
public void ProcessFile(string path)
{
    using (var logger = new FileLogger("process.log"))
    {
        logger.Log("Starting file processing");

        using (var reader = new StreamReader(path))
        {
            string content = reader.ReadToEnd();
            logger.Log($"Read {content.Length} characters");
        }

        logger.Log("Completed file processing");
    }
    // Both logger and reader are disposed here
}

// Modern using declaration (C# 8+)
public void ProcessFileModern(string path)
{
    using var logger = new FileLogger("process.log");
    logger.Log("Starting file processing");

    using var reader = new StreamReader(path);
    string content = reader.ReadToEnd();
    logger.Log($"Read {content.Length} characters");

    logger.Log("Completed file processing");

    // Disposed in reverse order: reader, then logger
}

Using declarations dispose objects in reverse order of declaration at the end of the method or block. This matches the intuitive understanding that dependencies created later should be cleaned up first. The compiler ensures this happens even if your method returns early or throws exceptions.

Try It Yourself

This complete example demonstrates a resource manager that tracks file operations. You'll see constructor initialization, proper disposal patterns, and how using statements ensure cleanup happens correctly.

The ResourceTracker maintains a list of opened files and closes them all when disposed. This pattern is common in applications that manage multiple resources and need to ensure cleanup happens in the right order.

Steps:

  1. dotnet new console -n LifecycleDemo
  2. cd LifecycleDemo
  3. Replace Program.cs with the code below
  4. Create LifecycleDemo.csproj as shown
  5. dotnet run
Program.cs
using System;
using System.Collections.Generic;
using System.IO;

public class ResourceTracker : IDisposable
{
    private readonly List<StreamWriter> _openFiles = new();
    private bool _disposed;

    public ResourceTracker()
    {
        Console.WriteLine("ResourceTracker created");
    }

    public void OpenFile(string path)
    {
        if (_disposed)
            throw new ObjectDisposedException(nameof(ResourceTracker));

        var writer = new StreamWriter(path, append: true);
        _openFiles.Add(writer);
        Console.WriteLine($"Opened file: {path}");
    }

    public void WriteToAll(string message)
    {
        if (_disposed)
            throw new ObjectDisposedException(nameof(ResourceTracker));

        foreach (var writer in _openFiles)
        {
            writer.WriteLine(message);
            writer.Flush();
        }
        Console.WriteLine($"Wrote to {_openFiles.Count} files");
    }

    protected virtual void Dispose(bool disposing)
    {
        if (_disposed)
            return;

        if (disposing)
        {
            Console.WriteLine($"Disposing {_openFiles.Count} open files");
            foreach (var writer in _openFiles)
            {
                writer.Dispose();
            }
            _openFiles.Clear();
        }

        _disposed = true;
    }

    public void Dispose()
    {
        Dispose(disposing: true);
        GC.SuppressFinalize(this);
    }
}

// Usage example
using var tracker = new ResourceTracker();
tracker.OpenFile("log1.txt");
tracker.OpenFile("log2.txt");
tracker.WriteToAll($"Entry at {DateTime.Now}");
Console.WriteLine("Work completed, disposing resources...");
// Dispose called automatically here

This program demonstrates the complete lifecycle. The constructor initializes the tracker, files are opened and written to, then the using declaration ensures all files get closed properly when the scope ends. Watch the console output to see the disposal sequence.

LifecycleDemo.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>

Output:

ResourceTracker created
Opened file: log1.txt
Opened file: log2.txt
Wrote to 2 files
Work completed, disposing resources...
Disposing 2 open files

The output shows the complete lifecycle from creation through disposal. Notice that disposal happens automatically at the end of the using declaration, even though we didn't explicitly call Dispose. This reliability makes using statements essential for resource management.

Common Pitfalls and How to Avoid Them

Many developers struggle with the dispose pattern because it has subtle rules that aren't obvious. Forgetting GC.SuppressFinalize when you have no finalizer is harmless but wasteful. Forgetting it when you do have a finalizer causes the finalizer to run unnecessarily, hurting performance. The fix is simple: always call GC.SuppressFinalize in Dispose if your class has a finalizer, and understand that without a finalizer, it's optional but doesn't hurt.

Accessing disposed objects causes ObjectDisposedException, but detecting this requires tracking disposal state. The solution is adding a _disposed field and checking it at the start of every public method. This prevents confusing errors when someone accidentally uses an object after disposal. Your methods should fail fast with a clear exception rather than exhibiting strange behavior or silently doing nothing.

Constructor exceptions are particularly tricky because Dispose won't be called if construction fails. If you allocate resources early in the constructor and then throw an exception, those resources leak. The solution is wrapping resource allocation in try-catch blocks within the constructor itself, or delaying resource allocation until after all validation completes. For complex initialization, consider a factory method that handles cleanup on failure.

Finalizers should almost never be used in modern C#. They run on a separate thread at an unpredictable time, making debugging nearly impossible. They also keep objects alive longer, pressuring the garbage collector. Unless you're wrapping unmanaged resources and SafeHandle doesn't work for your scenario, skip the finalizer entirely. If you do need one, make it do as little as possible and only clean up unmanaged resources.

Forgetting to dispose IDisposable fields is a common source of resource leaks. If your class holds IDisposable objects as fields, your class must also implement IDisposable and dispose those fields in your Dispose method. This creates a chain of responsibility where disposal cascades through your object graph. The using statement helps at method scope, but for fields, you need explicit disposal logic in your containing class.

Frequently Asked Questions (FAQ)

What's the difference between a constructor and a destructor in C#?

Constructors initialize objects when they're created, setting up initial state and allocating resources. Destructors (finalizers) run when the garbage collector reclaims memory, but you can't control when that happens. For deterministic cleanup, implement IDisposable instead of relying on destructors.

When should I implement IDisposable in my classes?

Implement IDisposable when your class directly owns unmanaged resources like file handles, database connections, or network sockets. Also implement it if your class holds other IDisposable objects that need cleanup. This ensures resources are released promptly rather than waiting for garbage collection.

Should I always call GC.SuppressFinalize in Dispose?

Only call GC.SuppressFinalize if your class has a finalizer (destructor). If you properly dispose all resources in Dispose, there's no need for the finalizer to run. This improves performance by removing the object from the finalization queue. Without a finalizer, GC.SuppressFinalize does nothing.

Can I have multiple constructors in a single class?

Yes, you can define multiple constructors with different parameter lists. This is called constructor overloading. Use constructor chaining with the this keyword to avoid duplicating initialization logic. One constructor should contain the main logic, and others delegate to it with different parameter combinations.

What happens if an exception occurs in a constructor?

If a constructor throws an exception, the object is never fully created and the constructor doesn't complete. Any resources allocated before the exception must be cleaned up in the constructor itself, since Dispose won't be called. The finalizer will run, but only if you got far enough to register it.

Back to Articles