async initialization
lazy injection
dependency injection
asynchronous programming
software development

How to perform async initalization of lazy injection

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

Lazy injection defers object creation until first use. Async initialization adds another layer because the object needs an await to become ready (database connection, HTTP client warm-up, configuration fetching). The challenge is that constructors cannot be async. The solution patterns are: Lazy<Task<T>> in C#, factory functions that return promises in JavaScript/TypeScript, and @PostConstruct with async providers in frameworks like NestJS or Spring.

The Problem

typescript
1// Constructor cannot be async
2class DatabaseService {
3    private connection: Connection;
4
5    constructor() {
6        // WRONG: cannot await in constructor
7        // this.connection = await createConnection();
8    }
9}
csharp
1// Same problem in C#
2public class CacheService {
3    private readonly RedisConnection _connection;
4
5    public CacheService() {
6        // Cannot await here
7        // _connection = await RedisConnection.ConnectAsync("localhost");
8    }
9}

C#: Lazy<Task<T>> Pattern

csharp
1public class CacheService
2{
3    private readonly Lazy<Task<IDatabase>> _database;
4
5    public CacheService(string connectionString)
6    {
7        _database = new Lazy<Task<IDatabase>>(async () =>
8        {
9            var connection = await ConnectionMultiplexer.ConnectAsync(connectionString);
10            return connection.GetDatabase();
11        });
12    }
13
14    public async Task<string> GetAsync(string key)
15    {
16        var db = await _database.Value;  // Initialized on first use
17        return await db.StringGetAsync(key);
18    }
19}

Lazy<Task<T>> creates the task only on first access to .Value. The Task runs once and subsequent accesses return the cached result.

Thread-Safe Async Lazy

csharp
1public class AsyncLazy<T>
2{
3    private readonly Lazy<Task<T>> _inner;
4
5    public AsyncLazy(Func<Task<T>> factory)
6    {
7        _inner = new Lazy<Task<T>>(factory, LazyThreadSafetyMode.ExecutionAndPublication);
8    }
9
10    public Task<T> Value => _inner.Value;
11
12    public TaskAwaiter<T> GetAwaiter() => Value.GetAwaiter();
13}
14
15// Usage
16public class ApiClient
17{
18    private readonly AsyncLazy<HttpClient> _client;
19
20    public ApiClient()
21    {
22        _client = new AsyncLazy<HttpClient>(async () =>
23        {
24            var token = await FetchAuthTokenAsync();
25            var client = new HttpClient();
26            client.DefaultRequestHeaders.Authorization =
27                new AuthenticationHeaderValue("Bearer", token);
28            return client;
29        });
30    }
31
32    public async Task<string> GetDataAsync(string url)
33    {
34        var client = await _client;  // Works with await directly
35        var response = await client.GetStringAsync(url);
36        return response;
37    }
38}

Registering with DI Container

csharp
1// In Startup.cs / Program.cs
2services.AddSingleton<AsyncLazy<IDatabase>>(sp =>
3    new AsyncLazy<IDatabase>(async () =>
4    {
5        var config = sp.GetRequiredService<IConfiguration>();
6        var connection = await ConnectionMultiplexer.ConnectAsync(
7            config.GetConnectionString("Redis"));
8        return connection.GetDatabase();
9    })
10);
11
12// Inject and use
13public class ProductService
14{
15    private readonly AsyncLazy<IDatabase> _db;
16
17    public ProductService(AsyncLazy<IDatabase> db) => _db = db;
18
19    public async Task<Product> GetProduct(int id)
20    {
21        var db = await _db;
22        var json = await db.StringGetAsync($"product:{id}");
23        return JsonSerializer.Deserialize<Product>(json);
24    }
25}

TypeScript/JavaScript: Factory Function Pattern

typescript
1class DatabaseService {
2    private connection: Connection;
3
4    private constructor(connection: Connection) {
5        this.connection = connection;
6    }
7
8    static async create(): Promise<DatabaseService> {
9        const connection = await createConnection({
10            host: 'localhost',
11            database: 'mydb'
12        });
13        return new DatabaseService(connection);
14    }
15
16    async query(sql: string): Promise<any[]> {
17        return this.connection.query(sql);
18    }
19}
20
21// Usage
22const db = await DatabaseService.create();
23const users = await db.query('SELECT * FROM users');

Lazy Initialization with Caching

typescript
1function lazyAsync<T>(factory: () => Promise<T>): () => Promise<T> {
2    let promise: Promise<T> | null = null;
3    return () => {
4        if (!promise) {
5            promise = factory();
6        }
7        return promise;
8    };
9}
10
11// Usage
12const getDatabase = lazyAsync(async () => {
13    console.log('Connecting...');  // Only runs once
14    return await createConnection({ host: 'localhost' });
15});
16
17// First call triggers connection
18const db1 = await getDatabase();
19// Second call returns cached promise
20const db2 = await getDatabase();
21// db1 === db2

NestJS: Async Providers

typescript
1// database.module.ts
2@Module({
3    providers: [
4        {
5            provide: 'DATABASE_CONNECTION',
6            useFactory: async (): Promise<Connection> => {
7                const connection = await createConnection({
8                    type: 'postgres',
9                    host: 'localhost',
10                    database: 'mydb',
11                });
12                return connection;
13            },
14        },
15    ],
16    exports: ['DATABASE_CONNECTION'],
17})
18export class DatabaseModule {}
19
20// user.service.ts
21@Injectable()
22export class UserService {
23    constructor(
24        @Inject('DATABASE_CONNECTION')
25        private readonly connection: Connection,
26    ) {}
27
28    async findAll(): Promise<User[]> {
29        return this.connection.getRepository(User).find();
30    }
31}

NestJS resolves async providers before the module is ready, so by the time UserService is constructed, the connection is established.

Java/Spring: @PostConstruct and @Lazy

java
1@Service
2@Lazy  // Defers creation until first injection
3public class CacheService {
4
5    private RedisCommands<String, String> commands;
6
7    @PostConstruct
8    public void init() throws Exception {
9        RedisClient client = RedisClient.create("redis://localhost:6379");
10        StatefulRedisConnection<String, String> connection = client.connect();
11        this.commands = connection.sync();
12    }
13
14    public String get(String key) {
15        return commands.get(key);
16    }
17}

For truly async initialization in Spring:

java
1@Service
2public class ApiClientService {
3
4    private final CompletableFuture<WebClient> clientFuture;
5
6    public ApiClientService() {
7        this.clientFuture = CompletableFuture.supplyAsync(() -> {
8            String token = fetchAuthToken();  // Blocking but runs in ForkJoinPool
9            return WebClient.builder()
10                .defaultHeader("Authorization", "Bearer " + token)
11                .build();
12        });
13    }
14
15    public Mono<String> getData(String url) {
16        return Mono.fromFuture(clientFuture)
17            .flatMap(client -> client.get().uri(url).retrieve().bodyToMono(String.class));
18    }
19}

Common Pitfalls

  • Race conditions on initialization: Without Lazy or a single-promise cache, two concurrent callers may both trigger initialization. Use Lazy<Task<T>> (C#) or cache the promise (JS) to ensure the factory runs exactly once.
  • Exception caching: If the async factory throws, Lazy<Task<T>> caches the failed task. Subsequent calls return the same exception without retrying. Implement retry logic or use LazyThreadSafetyMode.PublicationOnly to allow re-execution on failure.
  • Blocking the constructor: Using .Result or .Wait() on an async call in a constructor deadlocks in environments with a synchronization context (ASP.NET, UI threads). Always use the async lazy pattern instead.
  • Forgetting disposal: Lazy-initialized resources (connections, clients) still need cleanup. Implement IDisposable/IAsyncDisposable and dispose in the container's lifecycle.
  • Over-lazy initialization: If the resource is always needed and the startup delay is acceptable, initialize eagerly. Lazy initialization adds complexity, so use it only when the resource is expensive and may not be needed.

Summary

  • Constructors cannot be async, so use factory patterns or lazy wrappers instead
  • C#: Lazy<Task<T>> or custom AsyncLazy<T> for thread-safe deferred initialization
  • JavaScript/TypeScript: static create() factory or cached promise pattern
  • NestJS: async useFactory providers resolve before module initialization
  • Spring: @Lazy + @PostConstruct for deferred sync init, CompletableFuture for async
  • Cache the initialization promise/task to prevent duplicate execution

Course illustration
Course illustration

All Rights Reserved.