Calling Cloud Services
Create a cloud SDK client once at startup and hand it to services through a provider token, instead of calling new in a handler.
{ provide: S3_CLIENT, useFactory: (c: ConfigService) => new S3Client({ region: c.getOrThrow('AWS_REGION') }), inject: [ConfigService] }A cloud SDK client is an ordinary object. new S3Client({ region }) gives you something that resolves credentials, keeps an HTTP agent and holds a pool of keep-alive sockets. You want exactly one of them per process, created at startup, shared by every service that talks to S3.
Giving a foreign object a token
A provider is anything Nest's container can build and then hand to a constructor. A token is the key it is filed under. For a class you wrote, the class is its own token: providers: [ReportsService] is shorthand for { provide: ReportsService, useClass: ReportsService }. Identical — the short form just repeats the class twice for you.
An SDK client is not a class you wrote, so you choose the token yourself (a string or a Symbol) and build the value with useFactory. The inject array lists what the factory needs, in the order its parameters appear.
When the factory runs
Factories run once, while Nest builds the module graph at bootstrap. That is before the HTTP server listens, so before any middleware, guard or route handler exists. If the factory is async, bootstrap waits for it to resolve. Providers are singletons by default, so the client is built once per process, not once per request.
What trips people up
A provider is private to the module that declares it until that module exports the token. And the client owns sockets: implement onModuleDestroy and call client.destroy(), or a test run and a shutting-down pod will both hang waiting for the event loop to empty.
Example
// src/aws/aws.module.ts
import { Module, OnApplicationShutdown } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { S3Client } from '@aws-sdk/client-s3';
// The token. A string works ('S3_CLIENT'); a Symbol cannot collide with
// a string some library happens to use.
export const S3_CLIENT = Symbol('S3_CLIENT');
Module({
providers: [
{
provide: S3_CLIENT, // the key the container files the value under
useFactory: (config: ConfigService) =>
new S3Client({
region: config.getOrThrow<string>('AWS_REGION'),
// No credentials property: the SDK's default chain reads env vars,
// then the EC2/ECS/EKS role. Hard-coded keys are how they leak.
}),
inject: [ConfigService], // positional arguments for the factory above
},
],
// Without this line the token resolves only inside AwsModule.
exports: [S3_CLIENT],
})
export class AwsModule {}
// ConfigService is injectable here because ConfigModule.forRoot was called
// with { isGlobal: true } in AppModule.When to use it
- Share one S3Client across every service in the app instead of constructing one per request.
- Read the region and bucket from ConfigService while still building the client at startup.
- Swap the real SDK client for a stub in unit tests by overriding its token.
- Close SDK sockets on shutdown so a pod terminates instead of waiting out its grace period.
More examples
Injecting the client, and the provider shorthand spelled out
The class-provider shorthand and its longhand side by side. Reach for the longhand when the token and the implementation differ — swapping ReportsService for a FakeReportsService in one environment, for instance. Note the asymmetry: ReportsService needs no @Inject() because its own class is the token, while the S3 client does because a Symbol carries no type information.
// src/reports/reports.service.ts
import { Inject, Injectable } from '@nestjs/common';
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { S3_CLIENT } from '../aws/aws.module';
@Injectable()
export class ReportsService {
// @Inject() is required because the token is a Symbol, not a class.
constructor(@Inject(S3_CLIENT) private readonly s3: S3Client) {}
async saveCsv(key: string, csv: string): Promise<void> {
await this.s3.send(
new PutObjectCommand({
Bucket: 'acme-reports',
Key: key,
Body: csv,
ContentType: 'text/csv',
}),
);
}
}
// src/reports/reports.module.ts
import { Module } from '@nestjs/common';
import { AwsModule } from '../aws/aws.module';
import { ReportsService } from './reports.service';
@Module({
imports: [AwsModule], // brings S3_CLIENT into scope
// Shorthand:
providers: [ReportsService],
// Longhand for the exact same line:
// providers: [{ provide: ReportsService, useClass: ReportsService }],
})
export class ReportsModule {}A wrapper provider that owns the client's lifetime
When the client needs cleanup or you want to hide the SDK's Command vocabulary, make the provider a class instead of a factory. The class is its own token, so consumers inject it by type with no @Inject(). onModuleDestroy is the hook that stops a Jest or Vitest run from hanging for thirty seconds after the last test.
// src/aws/sqs.client.ts
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { SQSClient, SendMessageCommand } from '@aws-sdk/client-sqs';
@Injectable()
export class SqsGateway implements OnModuleDestroy {
private readonly logger = new Logger(SqsGateway.name);
private readonly client: SQSClient;
constructor(private readonly config: ConfigService) {
this.client = new SQSClient({
region: this.config.getOrThrow<string>('AWS_REGION'),
});
}
// Callers pass domain data. They never see a Command object, so the SDK
// stays swappable and the call is trivial to stub in a test.
async publish(queueUrl: string, body: unknown): Promise<string | undefined> {
const out = await this.client.send(
new SendMessageCommand({ QueueUrl: queueUrl, MessageBody: JSON.stringify(body) }),
);
return out.MessageId;
}
// Runs when the enclosing module is torn down: app.close(), a failed
// bootstrap, or the end of a test module's life.
onModuleDestroy(): void {
this.client.destroy(); // releases keep-alive sockets
this.logger.log('SQS client destroyed');
}
}Replacing the client with a stub in a test
The reason to put the client behind a token in the first place. Because the test module provides the same token, nothing in ReportsService changes and no AWS call is made. Asserting on command.input also pins down the request shape, which is where most S3 bugs actually live.
// test/reports.service.spec.ts
import { describe, expect, it, vi } from 'vitest'; // Nest 12 scaffolds Vitest for ESM projects; Jest for CJS
import { Test } from '@nestjs/testing';
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { S3_CLIENT } from '../src/aws/aws.module';
import { ReportsService } from '../src/reports/reports.service';
describe('ReportsService', () => {
it('writes the CSV under the given key', async () => {
const send = vi.fn().mockResolvedValue({ ETag: '"abc"' });
const moduleRef = await Test.createTestingModule({
providers: [
ReportsService,
// useValue takes the object as-is: no factory, no network, no credentials.
{ provide: S3_CLIENT, useValue: { send } as unknown as S3Client },
],
}).compile();
await moduleRef.get(ReportsService).saveCsv('2026/q1.csv', 'a,b\n1,2\n');
const command = send.mock.calls[0][0] as PutObjectCommand;
expect(command.input.Key).toBe('2026/q1.csv');
expect(command.input.ContentType).toBe('text/csv');
});
});
Discussion