ASP.NET Core  

Creating a "Pooled" Dependency Injection Lifetime in C# 13

 

Overview

 It promotes the Inversion of Control (IoC) principle, enabling a clean separation of concerns, improved testability, and easier code maintenance. Dependency Injection (DI) is a foundational pillar of modern ASP.NET Core application architecture. With ASP.NET Core, you can inject dependencies with a lightweight, extensible container that supports three standard service lifetimes:

  • Transient: An instance of the service is created every time it is requested. This is suitable for lightweight, stateless applications.
  • Scoped: A single instance is created per HTTP request or per scope. This is ideal for services that maintain state throughout a single operation.
  • Singleton: Use singletons for stateless services that are more expensive to instantiate or manage shared state safely across threads.

These lifetimes are sufficient for most scenarios, but you may need a hybrid behavior: a service that avoids frequent allocations like a singleton but does not live for the entire application lifetime. This is particularly relevant in high-throughput systems where performance, memory allocation, and garbage collection pressure must be tightly controlled.

To address this need, .NET introduces the concept of object pooling through the ObjectPool<T> and ObjectPoolProvider APIs in the Microsoft.Extensions.ObjectPool namespace. Object pooling allows you to reuse object instances efficiently by maintaining a pool of pre-allocated objects, thereby reducing memory pressure and improving performance for frequently used resources.

In this article, we’ll go beyond the default service lifetimes and demonstrate how to implement a custom "Pooled" lifetime registration. We’ll explore how to integrate ObjectPool<T> with the ASP.NET Core DI system, taking advantage of new features in C# 13 and .NET 9 to build a robust, performant, and reusable solution. You'll learn how to:

  • Utilize object pooling to define a custom service lifetime.
  • Cleanly register and configure pooled services.
  • Pooling in multithreaded environments should follow best practices.
  • You should avoid common pitfalls such as thread safety issues and misuse of objects.

As a result of this course, you will be able to create performant services with hybrid lifetime behavior that bridge the gap between transient and singleton design patterns.

 

 

 

 

When to Use Object Pooling?

When creating and destroying objects frequently would result in excessive resource consumption and memory pressure, object pooling is an effective performance optimisation technique. We maintain a pool of reusable objects and serve them on demand instead of instantiating new objects repeatedly.

When the following conditions are met, use object pooling:

  • Object creation is expensive: A pool can significantly reduce CPU and memory overhead if an object involves non-trivial setup (e.g., allocating large arrays, loading configuration, or initialising resources).
  • Objects are short-lived and used frequently: Pooling can prevent constant allocation and garbage collection cycles when a particular type is repeatedly required during the application's lifespan, but only for short bursts (e.g., during each request, batch operation, or parsing cycle).
  • Objects that are thread-safe or can be reset easily: The objects should be stateless, thread-safe, or able to be safely reset to a clean state before reuse in order to ensure consistency and prevent unpredictable behavior.

Common Real-World Examples

Object pooling is highly effective in the following use cases:

  • StringBuilder instances: Creating new StringBuilder instances each time can be wasteful, especially in tight loops and logging.
  • Memory buffers (e.g., byte[] arrays): Network I/O, file I/O, and serialization processes use memory buffers for network I/O, file I/O, and serialization. The reuse of buffers reduces GC pressure and maintains throughput.
  • Parsers or serializer: When handling data streams or messages repeatedly, objects such as JsonSerializer, XmlReader, or custom parsers can benefit from pooling.

Best Practices

  • Reset before reuse: When returning objects to the pool, ensure they are reset to a known state before reuse. This prevents data leaks.
  • Avoid pooling complex dependencies: Objects with deep dependency trees or significant shared states should not be pooled unless explicitly designed to be so.
  • Benchmark before adopting: To validate object pools' benefits, benchmark their performance before and after introducing one.

In the right context-especially for high-throughput, memory-sensitive applications-object pooling can yield significant performance gains with minimal trade-offs.

 

 

 

 

 

Step-by-Step: Implementing Object Pooling in ASP.NET Core with DI

The use of object pooling is a proven strategy for reducing memory allocations and garbage collection overhead in .NET applications. Let's walk through how to set up and use object pooling in a clean, reusable manner using Microsoft.Extensions.ObjectPool.

Step 1: Install the Required NuGet Package

It is necessary to install Microsoft's official object pooling library before you can get started:

dotnet add package Microsoft.Extensions.ObjectPool

or if using NuGet Package Manager Console

NuGet\Install-Package Microsoft.Extensions.ObjectPool

In .NET applications, this package provides the foundational interfaces and default implementations for managing object pools.

Step 2: Define the Pooled Service

Suppose we have a service that performs intensive in-memory string operations using StringBuilder, which is expensive to allocate repeatedly.

using System.Text;

namespace PooledDI.Core.Services;
public class StringProcessor
{
    private readonly StringBuilder _builder = new();

    public string Process(string input)
    {
        _builder.Clear();
        _builder.Append(input.ToUpperInvariant());
        return _builder.ToString();
    }
}

Why Pool It?
When the StringBuilder object is used repeatedly at scale, internal buffers are allocated, which can be expensive. Pooling StringProcessor reduces these allocations and improves performance.

Step 3: Create a Custom PooledObjectPolicy<T>

An object pool must know how to create and reset objects. This is achieved through a custom PooledObjectPolicy<t> implementation:</t>

using Microsoft.Extensions.ObjectPool;
using PooledDI.Core.Services;

namespace PooledDI.Core.Policies;

public class StringProcessorPolicy : PooledObjectPolicy<StringProcessor>
{
    public override StringProcessor Create() => new StringProcessor();

    public override bool Return(StringProcessor obj)
    {
      
        return true;
    }



}

Best Practice
Ensure sensitive or inconsistent data is cleaned or reset before returning an object to the pool.

Step 4: Register the Pooled Service with Dependency Injection

Although ASP.NET Core's built-in DI container doesn't provide a "pooled" lifetime, we can achieve the same effect by injecting an ObjectPool<t>.</t>

In order to encapsulate the logic of registration, create an extension method as follows:

using Microsoft.Extensions.ObjectPool;

namespace PooledDI.Api.Services;

public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddPooled<TService, TPolicy>(this IServiceCollection services)
        where TService : class
        where TPolicy : PooledObjectPolicy<TService>, new()
    {
        services.AddSingleton<ObjectPoolProvider, DefaultObjectPoolProvider>();
        services.AddSingleton<ObjectPool<TService>>(sp =>
        {
            var provider = sp.GetRequiredService<ObjectPoolProvider>();
            return provider.Create(new TPolicy());
        });

        return services;
    }
}

Register your pooled service in Program.cs  as follows:

builder.Services.AddPooled<StringProcessor, StringProcessorPolicy>();

Best Practice

Create a singleton pool to ensure consistent reuse across requests, while consumers can be scoped or transient.

Step 5: Consume the Pooled Service

Pooled objects should be injected into services where they are needed. Get() to fetch an object and Return() to add it back to the pool.

using Microsoft.Extensions.ObjectPool;

namespace PooledDI.Core.Services;

public class ProcessingService
{
    private readonly ObjectPool<StringProcessor> _pool;

    public ProcessingService(ObjectPool<StringProcessor> pool)
    {
        _pool = pool;
    }

    public string Execute(string input)
    {
        var processor = _pool.Get();

        try
        {
            return processor.Process(input);
        }
        finally
        {
            _pool.Return(processor);
        }
    }
}

Last but not least, register the service that consumes the data:

builder.Services.AddScoped<ProcessingService>();

Best Practice

When an exception occurs, always use a try-finally block to ensure the object is returned to the pool.

With just a few steps, you've implemented efficient object pooling

 In ASP.NET Core, Microsoft.Extensions.ObjectPool provides:

  • Allotments for heavy or frequently used services have been reduced.
  • Implemented a clean, extensible integration pattern with the DI container.
  • Performance-sensitive applications benefit from improved throughput.

 

Best Practices for Using Object Pooling in .NET

When implemented thoughtfully and correctly, object pooling can yield significant performance improvements. To ensure your implementation is safe, efficient, and maintainable, follow these best practices:

Avoid Mutable Shared State

Why it Matters

If the object's internal state is not reset, it can lead to data leaks, race conditions, and unpredictable behavior.

Best Practice

If the object maintains state (such as a StringBuilder or buffer), clear or reset it explicitly before returning it to the pool.

using System.Text;
 
namespace PooledDI.Core.Services;

public sealed class ZiggyProcessor
{
    private readonly StringBuilder _sb = new();

    public void Append(string value) => _sb.Append(value);

    public string Finish()
    {
        var result = _sb.ToString();
        _sb.Clear();              
        return result;
    }

  

    internal void Reset() => _sb.Clear();
}

Use Policies to Reset Objects Cleanly

Why it Matters

Pooled object policies give you the ability to control how objects are cleaned up before reuse.

Best Practice

Finish() should implement reset logic to ensure the object is returned to a safe, known state.

     public string Finish()
    {
        var result = _sb.ToString();
        _sb.Clear();              
        return result;
    }

Pool Only Performance-Critical or Expensive Objects

Why it Matters

Using pooling for cheap, lightweight objects may actually hurt performance due to its complexity and overhead.

Best Practice

Limit object pooling to high-cost objects that are instantiated frequently, such as large buffers, parsers, serializers, or reusable builders. Don't pool trivial data types or objects that are rarely used.

Ensure Thread Safety

Why it Matters

A pooled object that isn't thread-safe can cause race conditions or data corruption if multiple threads access it concurrently.

Best Practice

  • In most cases, pooled objects should be used in single-threaded, isolated scopes (e.g., for HTTP requests).
  • Ensure that shared objects are thread-safe or use locking mechanisms carefully if they must be shared across threads.

Use DefaultObjectPoolProvider

Why it Matters

Due to its efficient internal data structures, the DefaultObjectPoolProvider is optimized for high-throughput scenarios.

Best Practice

Use the DefaultObjectPoolProvider unless you have a very specific requirement for a custom implementation. It provides excellent performance for typical workloads out of the box.

builder.Services.AddSingleton<ObjectPoolProvider, DefaultObjectPoolProvider>(); 

Bonus Tips

  • Benchmark: Verify the performance gains of your application before and after introducing object pooling.
  • Monitor: Consider rethinking the pooling strategy if pooled objects are rarely reused or leak memory.
  • Combine: Use performance profilers like dotTrace or PerfView to understand hotspots in object allocation.

In ASP.NET Core applications, you can safely integrate object pooling to optimize resource utilization, reduce garbage collection pressure, and improve throughput by adhering to these best practices.

 

 

Advanced Technique: Pooling Services That Implement Interfaces

As in real-world applications, services are often registered by interface to provide abstraction, testability, and flexibility. But how can object pooling be integrated into this model?

You will learn how to wrap pooled objects behind an interface, enabling clean dependency injection while still benefiting from reuse and memory efficiency.

Step 1: Define a Service Interface

Defining an interface for your service contract is the first step:

namespace PooledDI.Core.Interfaces;
public interface IStringProcessor
{
    string Process(string input);
}

The interface allows you to inject the service rather than a concrete class, which is ideal for unit testing and clean coding.

Step 2: Create a Wrapper That Uses Object Pooling

A class implementing the desired interface needs to wrap the pool access logic since object pooling manages a concrete type (e.g., StringProcessor).

using Microsoft.Extensions.ObjectPool;
using PooledDI.Core.Interfaces;

namespace PooledDI.Core.Services;

public class PooledStringProcessor : IStringProcessor
{
    private readonly ObjectPool<StringProcessor> _pool;

    public PooledStringProcessor(ObjectPool<StringProcessor> pool)
    {
        _pool = pool;
    }

    public string Process(string input)
    {
        var processor = _pool.Get();
        try
        {
            return processor.Process(input);
        }
        finally
        {
            _pool.Return(processor);
        }
    }
}

Best Practice
If an exception occurs, always wrap pooled object access in a try-finally block to ensure it is returned to the pool.

Step 3: Register the Interface Wrapper with DI

The wrapper implementation should be registered as the concrete type for your interface:

builder.Services.AddScoped<IStringProcessor, PooledStringProcessor>();

The pool itself should also be registered using the utility method you used earlier or manually:

builder.Services.AddPooled<StringProcessor, StringProcessorPolicy>();

Why This Matters

  • Testability: Your classes depend on IStringProcessor instead of the pooled implementation, making them easier to test.
  • Encapsulation: By encapsulating the object pooling logic, consumers remain unaware of the object pooling logic.
  • Reuse with Safety: A wrapper ensures that pooled objects are properly managed throughout their lifecycle.

Optional: Factory-Based Approach for Complex Cases

For most scenarios, the wrapper approach shown above is the best method for managing pooled objects or adding lazy resolution. If you need to manage more than one type of pooled object or introduce lazy resolution, you can inject a factory or delegate for the interface.

For scalable enterprise .NET applications, wrapping pooled objects behind interfaces maintains clean architecture, testability, and performance.

Summary

With the introduction of modern language features in C# 13 and the continued evolution of .NET 9, developing memory-efficient, high-performance applications has never been simpler. The built-in Dependency Injection (DI) container in ASP.NET Core does not natively support a "pooled" object lifetime, but the Microsoft.Extensions.ObjectPool package fills that void.

You can benefit from object pooling by integrating it into your service architecture in the following ways:

  • Memory allocations for expensive or frequently-used objects should be reduced.
  • Performance-critical workloads can improve throughput and responsiveness.
  • Maintain control over the object lifecycle, ensuring safe reusability through PooledObjectPolicy<t>.</t>

In a number of scenarios, this technique excels, including:

  • Manipulating strings with StringBuilder
  • In-memory data transformation
  • Our parsers and serializers are tailored to your needs
  • Using computational helpers or reusable buffers

In conjunction with solid architectural patterns (such as DI and interface-based design), object pooling can become a powerful optimisation tool.

Final Thoughts

  • Consider pooling for objects that are expensive, short-lived, and reusable.
  • Reset and cleanup logic should always be implemented properly.
  • For clean, testable code, wrap pooled services behind interfaces.
  • Reuse objects efficiently and thread-safely with DefaultObjectPoolProvider.

With these principles, you can build highly efficient .NET applications that scale gracefully under load without sacrificing code clarity or maintainability. I have uploaded the code to my GitHub Repository. If you have found this article useful please click the like button.

Capgemini is a global leader in consulting, technology services, and digital transformation.