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
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}
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
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
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
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
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
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
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
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:
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