AWS SDK client initialization patterns for this project. Lazy singletons, conditional credentials, DocumentClient wrappers...
AWS SDK clients initialized lazily as singletons. Shared across Lambda invocations. Conditional credentials for frontend vs backend.
Module-level null variable, getter function creates on first call:
// From frontend/lib/aws/clients.ts
let dynamoClient: DynamoDBClient | null = null;
export function getDynamoDBClient(): DynamoDBClient {
if (!dynamoClient) {
dynamoClient = new DynamoDBClient({
region: process.env.AWS_REGION || "us-east-1",
...(process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY
? {
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
},
}
: {}),
});
}
return dynamoClient;
}
Why: Lambda reuses containers. Singleton persists across invocations. Saves ~50ms per call.
Frontend needs explicit credentials. Backend uses IAM role:
// Spread pattern - adds credentials only if env vars exist
...(process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY
? {
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
},
}
: {})
Frontend: Set AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY
Backend: Omit env vars, Lambda role auto-used
Use DocumentClient for auto JSON marshalling:
// From functions/lib/pipeline/StateManager.ts
constructor() {
const client = new DynamoDBClient({
region: process.env.AWS_REGION || "us-east-1",
});
this.docClient = DynamoDBDocumentClient.from(client, {
marshallOptions: {
convertEmptyValues: false,
removeUndefinedValues: true,
},
});
}
Options used:
convertEmptyValues: false - Don't convert empty strings to NULLremoveUndefinedValues: true - Strip undefined propertiesWhen singleton not needed (class-scoped):
// From functions/lib/agents/EmailFetcherAgent.ts
constructor(private costTracker: CostTracker) {
// Direct initialization - scoped to class instance
const dynamoClient = new DynamoDBClient({
region: process.env.AWS_REGION || "us-east-1",
});
this.dynamodb = DynamoDBDocumentClient.from(dynamoClient);
}
When to use:
Singleton with fallback for testing:
// From functions/lib/gmail/token-storage.ts
let client: DynamoDBClient | null = null;
function getClient(): DynamoDBClient {
if (!client) {
client = new DynamoDBClient({
region: process.env.AWS_REGION || "us-east-1",
});
}
return client;
}
// Use in functions
export async function getStoredToken(): Promise<TokenData | null> {
const response = await getClient().send(
new GetItemCommand({
TableName: TABLE_NAME,
Key: marshall({ userId }),
})
);
// ...
}
Pattern: Private getter, public functions use it. Easy to mock for tests.
// frontend/lib/aws/clients.ts exports these
getSFNClient() // Step Functions
getLambdaClient() // Lambda invocation
getDynamoDBClient() // DynamoDB operations
All follow same pattern:
us-east-1 fallbackAlways use environment variable with fallback:
region: process.env.AWS_REGION || "us-east-1"
Terraform sets AWS_REGION in Lambda env vars.
Local dev defaults to us-east-1.
Lambda containers persist between invocations. Module-level variables survive:
Invocation 1: dynamoClient = null -> create new client
Invocation 2: dynamoClient exists -> reuse (faster)
Invocation 3: dynamoClient exists -> reuse (faster)
[Container recycled]
Invocation 4: dynamoClient = null -> create new client
Don't recreate clients in handler functions. Use singletons.
Don't: Create client in every handler call
// BAD - new client per invocation
export async function handler() {
const client = new DynamoDBClient({ region: "us-east-1" });
// ...
}
Don't: Hardcode credentials
// BAD - credentials in code
new DynamoDBClient({
credentials: {
accessKeyId: "AKIAIOSFODNN7EXAMPLE",
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
},
});
Don't: Hardcode region
// BAD - hardcoded region
new DynamoDBClient({ region: "us-east-1" });
// GOOD - from env with fallback
new DynamoDBClient({ region: process.env.AWS_REGION || "us-east-1" });
Don't: Mix raw client and DocumentClient
// BAD - inconsistent marshalling
const client = new DynamoDBClient({});
await client.send(new PutItemCommand({
Item: { id: { S: "123" } } // Manual marshalling
}));
const docClient = DynamoDBDocumentClient.from(client);
await docClient.send(new PutCommand({
Item: { id: "123" } // Auto marshalling
}));
// GOOD - pick one and stick with it
// Use DocumentClient unless you need low-level control
frontend/lib/aws/clients.ts - Frontend singleton exportsfunctions/lib/gmail/token-storage.ts - Private getter patternfunctions/lib/pipeline/StateManager.ts - DocumentClient wrapper with optionsfunctions/lib/agents/EmailFetcherAgent.ts - Class instance patternModule singleton (frontend/lib/aws/clients.ts):
Private getter (functions/lib/gmail/token-storage.ts):
Class instance (functions/lib/agents/EmailFetcherAgent.ts):
Inline DynamoDBDocumentClient: