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.
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.
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.
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.
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.