DEV Community

Cover image for Clean Architecture: Building Type-Safe AWS Service Wrappers in NestJS
Noni Gopal Sutradhar Rinku
Noni Gopal Sutradhar Rinku

Posted on

Clean Architecture: Building Type-Safe AWS Service Wrappers in NestJS

If you've worked with the AWS SDK in a large Node.js application, you know it can quickly lead to "SDK spaghetti"—direct imports of bulky services, messy configuration, and difficult unit testing.

NestJS gives us the perfect tools (Modules and Providers) to enforce clean architecture by building lightweight, type-safe wrappers around external tools like the AWS SDK.

Here is the step-by-step guide to encapsulating the S3 SDK into a reusable, testable NestJS service.

1. Why We Need a Wrapper for the AWS SDK

Directly importing and using new S3Client(...) inside your controllers or business services creates problems:

Poor Testability: You can't mock or spy on the native SDK client easily.

Lack of Type Safety: Relying on the SDK's internal return types can make refactoring difficult.

Tight Coupling: Your business logic is directly coupled to AWS implementation details.

The solution is to define a clear TypeScript interface and inject a dedicated wrapper service.

2. Step 1: Define the TypeScript Interface

Start by defining exactly what your business application needs. We don't want to expose all 100+ S3 methods, only the ones we use (e.g., upload and getSignedUrl).

// src/aws/s3/s3.interface.ts

export interface S3File {
  key: string;
  url: string;
}

export interface IS3Service {
  /** Uploads a file buffer to S3. */
  uploadFile(
    fileName: string, 
    fileBuffer: Buffer, 
    mimeType: string
  ): Promise<S3File>;

  /** Gets a temporary signed URL for viewing a private file. */
  getTemporaryUrl(key: string): Promise<string>;
}

// Token for Dependency Injection (ensures type safety during injection)
export const S3_SERVICE_TOKEN = 'IS3Service';
Enter fullscreen mode Exit fullscreen mode

3. Step 2: Implement the S3 Wrapper Service

Now, we create the NestJS Provider that implements our interface and handles the direct communication with the native AWS SDK client.

// src/aws/s3/s3.service.ts

import { Injectable } from '@nestjs/common';
import { 
  S3Client, 
  PutObjectCommand, 
  GetObjectCommand 
} from '@aws-sdk/client-s3';
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

import { IS3Service, S3File } from './s3.interface';

@Injectable()
export class S3Service implements IS3Service {
  private s3Client: S3Client;
  private readonly bucketName = process.env.AWS_S3_BUCKET_NAME;
  private readonly region = process.env.AWS_REGION;

  constructor() {
    this.s3Client = new S3Client({ region: this.region });
  }

  async uploadFile(fileName: string, fileBuffer: Buffer, mimeType: string): Promise<S3File> {
    const key = `uploads/${Date.now()}-${fileName}`;

    const command = new PutObjectCommand({
      Bucket: this.bucketName,
      Key: key,
      Body: fileBuffer,
      ContentType: mimeType,
    });

    try {
      await this.s3Client.send(command);
      const url = `https://${this.bucketName}.s3.${this.region}.amazonaws.com/${key}`;
      return { key, url };
    } catch (error) {
      console.error("S3 Upload Error:", error);
      throw new Error('Failed to upload file to S3.');
    }
  }

  async getTemporaryUrl(key: string): Promise<string> {
    const command = new GetObjectCommand({
      Bucket: this.bucketName,
      Key: key,
    });

    return getSignedUrl(this.s3Client, command, { expiresIn: 3600 }); // 1 hour expiration
  }
}
Enter fullscreen mode Exit fullscreen mode

4. Step 3: Create a Dedicated NestJS Module

Encapsulate the service into a dedicated module to manage configuration and ensure reusability. We use the S3_SERVICE_TOKEN for the export, ensuring we inject against the interface, not the concrete class.

// src/aws/s3/s3.module.ts

import { Module } from '@nestjs/common';
import { S3Service } from './s3.service';
import { S3_SERVICE_TOKEN } from './s3.interface';

@Module({
  providers: [
    {
      // Use the token for injection
      provide: S3_SERVICE_TOKEN,
      useClass: S3Service,
    },
  ],
  exports: [S3_SERVICE_TOKEN], // Export only the token
})
export class S3Module {}
Enter fullscreen mode Exit fullscreen mode

5. Step 4: Using the Type-Safe Service

Now, any other module (like UsersModule or ProductsModule) that needs S3 access simply imports S3Module and injects the service using the token. This is clean and powerful.

// src/products/products.service.ts

import { Injectable, Inject } from '@nestjs/common';
import { IS3Service, S3_SERVICE_TOKEN } from '../aws/s3/s3.interface';

@Injectable()
export class ProductsService {
  // Inject against the Interface Token!
  constructor(
    @Inject(S3_SERVICE_TOKEN) private readonly s3Service: IS3Service,
  ) {}

  async createProductAttachment(fileBuffer: Buffer, fileName: string) {
    // Business logic...

    // Clean, type-safe call to the wrapper
    const s3Result = await this.s3Service.uploadFile(
      fileName, 
      fileBuffer, 
      'image/jpeg'
    );

    // Save s3Result.url to your database
    return { url: s3Result.url, message: 'File uploaded successfully.' };
  }
}
Enter fullscreen mode Exit fullscreen mode

Conclusion: Testability and Maintainability Gains

By using this wrapper pattern, you have decoupled your application from the AWS SDK.

Testability: When testing ProductsService, you can easily provide a MockS3Service that implements IS3Service but returns fake data, never touching the network or the real AWS client.

Maintainability: If you ever switch from S3 to Google Cloud Storage or another provider, you only need to create one new GCSProvider class that implements IS3Service—your entire application remains untouched.

This is how professional NestJS applications handle external services: clean, encapsulated, and fully type-safe. Feel free to share your thoughts.

Top comments (0)