Introduction
Lazy injection defers object creation until first use. Async initialization adds another layer — 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 — use it only when the resource is expensive and may not be needed.
Summary
Constructors cannot be async — 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