Voltar para Artigos
Back-end6 min de leitura

Arquitetura de Uploads no Node.js: Multer, AWS S3 e Strategy Pattern

Como abstrair o upload de arquivos usando Padrão Strategy. Processamento com Multer local (tmp), injeção de dependência para alternar entre Disco e AWS S3, upload com stream e segurança contra arquivos maliciosos.

12 de agosto de 2026

Salvar a foto de perfil do usuário na pasta public do servidor parece prático até você escalar para múltiplas instâncias ou serverless (onde o disco é efêmero) e perceber que as imagens sumiram. O upload robusto exige duas fases: 1) receber temporariamente no servidor (Multer) e 2) enviar para o destino final (S3). Para não acoplar a aplicação ao S3, usamos o Strategy Pattern.

1. Recebendo o arquivo com Multer

O Express não sabe ler multipart/form-data nativamente. O Multer faz esse parse e salva o arquivo temporariamente na pasta tmp.

upload.ts
import multer from 'multer';
import crypto from 'crypto';
import { resolve } from 'path';

const tmpFolder = resolve(__dirname, '..', '..', 'tmp');

export const uploadConfig = {
  tmpFolder,
  uploadsFolder: resolve(tmpFolder, 'uploads'), // Destino final se for Disco Local
  
  multer: {
    storage: multer.diskStorage({
      destination: tmpFolder,
      filename(req, file, callback) {
        // Adiciona um hash para evitar colisão de nomes iguais
        const fileHash = crypto.randomBytes(10).toString('hex');
        const fileName = `${fileHash}-${file.originalname}`;

        return callback(null, fileName);
      },
    }),
    limits: {
      fileSize: 5 * 1024 * 1024, // 5MB limit
    },
    // Segurança: bloquear arquivos que não são imagens
    fileFilter: (req, file, callback) => {
      const allowedMimes = ['image/jpeg', 'image/pjpeg', 'image/png', 'image/webp'];
      if (allowedMimes.includes(file.mimetype)) {
        callback(null, true);
      } else {
        callback(new Error('Formato de arquivo inválido.'));
      }
    },
  },
};

2. O Padrão Strategy (IStorageProvider)

O fileFilter do Multer valida o MIME type declarado pelo browser — mas isso é facilmente falsificado. Para arquivos críticos, valide também os primeiros bytes do arquivo (magic bytes) após o upload: um JPEG sempre começa com FF D8 FF. O nome gerado com randomBytes(10) evita colissões e impede que um usuário sobrescreva o arquivo de outro com um nome igual ao original.

O Use Case não deve importar a AWS SDK. Ele apenas conhece a interface IStorageProvider. Assim, podemos rodar localmente no Disco, e em produção no S3, alterando apenas uma variável de ambiente na Injeção de Dependência.

IStorageProvider.ts
export interface IStorageProvider {
  saveFile(file: string, folder: string): Promise<string>;
  deleteFile(file: string, folder: string): Promise<void>;
}

3. Implementação para AWS S3

O driver S3 lê o arquivo temporário gerado pelo Multer, envia para o bucket na AWS usando o SDK v3 e apaga o arquivo temporário local. O arquivo temporário no tmp é sempre excluído ao final — seja o upload bem-sucedido ou não (idealmente via middleware finally). Sem isso, o disco do servidor enche gradualmente com uploads incompletos. Na produção com múltiplas instâncias, considere também um cron job para limpar arquivos órfãos no tmp com mais de 1 hora.

S3StorageProvider.ts
import fs from 'fs';
import path from 'path';
import mime from 'mime';
import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
import { uploadConfig } from '../../../config/upload';
import type { IStorageProvider } from '../../../domain/providers/IStorageProvider';

export class S3StorageProvider implements IStorageProvider {
  private client: S3Client;

  constructor() {
    this.client = new S3Client({
      region: process.env.AWS_REGION,
      credentials: {
        accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
        secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
      },
    });
  }

  async saveFile(file: string, folder: string): Promise<string> {
    const originalPath = path.resolve(uploadConfig.tmpFolder, file);
    const ContentType = mime.getType(originalPath);
    
    if (!ContentType) throw new Error('File type not found');

    // Lê o arquivo como Stream ou Buffer
    const fileContent = await fs.promises.readFile(originalPath);

    await this.client.send(
      new PutObjectCommand({
        Bucket: process.env.AWS_BUCKET,
        Key: `${folder}/${file}`,
        ACL: 'public-read', // Depende da sua política do bucket
        Body: fileContent,
        ContentType,
      })
    );

    // Remove o arquivo temporário do servidor local
    await fs.promises.unlink(originalPath);

    return file;
  }

  async deleteFile(file: string, folder: string): Promise<void> {
    await this.client.send(
      new DeleteObjectCommand({
        Bucket: process.env.AWS_BUCKET,
        Key: `${folder}/${file}`,
      })
    );
  }
}

Upload de arquivos grandes e Streams: Se o arquivo for muito grande (ex: vídeos), ler inteiro para a memória (readFile) pode estourar a RAM do servidor Node.js. Nesses casos, prefira enviar via Stream (fs.createReadStream) ou passe a utilizar Presigned URLs: seu backend gera uma URL do S3 com permissão de escrita temporária, e o frontend (React) faz o upload pesado diretamente para a Amazon, tirando o gargalo do servidor.

Conclusão

O padrão de receber no tmp e processar com o Strategy é a base de um sistema flexível. O DiskStorageProvider (usando fs.promises.rename) serve perfeitamente para o dev testar localmente. O S3StorageProvider escala para milhões de arquivos. O Use Case apenas orquestra: await this.storage.saveFile(avatarFileName, 'avatars');, completamente alheio a qual driver está sendo usado sob os panos.