During the EMA project at Servant, we integrated multiple AWS services to handle notifications, file uploads, and messaging. Here's what I learned about building reliable, scalable integrations with AWS.
AWS SES for Email Notifications
Amazon Simple Email Service (SES) provided our email infrastructure. Here's how we implemented a robust email service:
// email.service.ts
import { Injectable } from '@nestjs/common';
import { SESClient, SendEmailCommand } from '@aws-sdk/client-ses';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class EmailService {
private sesClient: SESClient;
private fromEmail: string;
constructor(private configService: ConfigService) {
this.sesClient = new SESClient({
region: this.configService.get('AWS_REGION'),
credentials: {
accessKeyId: this.configService.get('AWS_ACCESS_KEY_ID'),
secretAccessKey: this.configService.get('AWS_SECRET_ACCESS_KEY'),
},
});
this.fromEmail = this.configService.get('SES_FROM_EMAIL');
}
async sendReferralNotification(
to: string,
referralData: ReferralNotificationData
): Promise<void> {
const htmlBody = this.generateReferralEmailHtml(referralData);
const textBody = this.generateReferralEmailText(referralData);
const command = new SendEmailCommand({
Source: this.fromEmail,
Destination: {
ToAddresses: [to],
},
Message: {
Subject: {
Data: `New Referral: ${referralData.motherName}`,
Charset: 'UTF-8',
},
Body: {
Html: {
Data: htmlBody,
Charset: 'UTF-8',
},
Text: {
Data: textBody,
Charset: 'UTF-8',
},
},
},
});
try {
await this.sesClient.send(command);
console.log(`Email sent successfully to ${to}`);
} catch (error) {
console.error('Failed to send email:', error);
// Don't throw - we don't want email failures to break the app
// Instead, log to monitoring service
this.logToMonitoring(error);
}
}
private generateReferralEmailHtml(data: ReferralNotificationData): string {
return `
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: Arial, sans-serif; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background-color: #4A90E2; color: white; padding: 20px; }
.content { padding: 20px; background-color: #f5f5f5; }
.footer { padding: 10px; text-align: center; color: #666; }
.priority-high { color: #E74C3C; font-weight: bold; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>New Referral Received</h1>
</div>
<div class="content">
<p><strong>Mother's Name:</strong> ${data.motherName}</p>
<p><strong>Priority:</strong>
<span class="${data.priority === 'HIGH' ? 'priority-high' : ''}">
${data.priority}
</span>
</p>
<p><strong>Referred By:</strong> ${data.referredBy}</p>
<p><strong>Notes:</strong> ${data.notes}</p>
<p>
<a href="${data.portalUrl}"
style="background-color: #4A90E2; color: white; padding: 10px 20px;
text-decoration: none; border-radius: 5px;">
View in Portal
</a>
</p>
</div>
<div class="footer">
<p>EMA Portal - Supporting Mothers and Families</p>
</div>
</div>
</body>
</html>
`;
}
}AWS SNS for SMS Notifications
For time-sensitive notifications, we used SNS to send SMS messages:
// sms.service.ts
import { Injectable } from '@nestjs/common';
import { SNSClient, PublishCommand } from '@aws-sdk/client-sns';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class SmsService {
private snsClient: SNSClient;
constructor(private configService: ConfigService) {
this.snsClient = new SNSClient({
region: this.configService.get('AWS_REGION'),
credentials: {
accessKeyId: this.configService.get('AWS_ACCESS_KEY_ID'),
secretAccessKey: this.configService.get('AWS_SECRET_ACCESS_KEY'),
},
});
}
async sendUrgentAlert(
phoneNumber: string,
message: string
): Promise<void> {
// Format phone number to E.164 format
const formattedNumber = this.formatPhoneNumber(phoneNumber);
const command = new PublishCommand({
PhoneNumber: formattedNumber,
Message: message,
MessageAttributes: {
'AWS.SNS.SMS.SenderID': {
DataType: 'String',
StringValue: 'EMAPortal',
},
'AWS.SNS.SMS.SMSType': {
DataType: 'String',
StringValue: 'Transactional', // For important, time-sensitive messages
},
},
});
try {
const response = await this.snsClient.send(command);
console.log(`SMS sent successfully. MessageId: ${response.MessageId}`);
} catch (error) {
console.error('Failed to send SMS:', error);
throw new Error(`SMS delivery failed: ${error.message}`);
}
}
private formatPhoneNumber(phoneNumber: string): string {
// Remove all non-numeric characters
const cleaned = phoneNumber.replace(/\D/g, '');
// Add +1 for US numbers if not present
if (!cleaned.startsWith('1') && cleaned.length === 10) {
return `+1${cleaned}`;
}
if (!cleaned.startsWith('+')) {
return `+${cleaned}`;
}
return cleaned;
}
async sendBatchAlerts(
recipients: Array<{ phoneNumber: string; message: string }>
): Promise<void> {
const promises = recipients.map(({ phoneNumber, message }) =>
this.sendUrgentAlert(phoneNumber, message)
.catch(error => ({
phoneNumber,
error: error.message,
}))
);
const results = await Promise.allSettled(promises);
// Log any failures
results.forEach((result, index) => {
if (result.status === 'rejected') {
console.error(
`Failed to send SMS to ${recipients[index].phoneNumber}:`,
result.reason
);
}
});
}
}AWS S3 for File Uploads
File uploads required careful handling, especially for large files. We implemented multipart uploads:
// file-upload.service.ts
import { Injectable } from '@nestjs/common';
import {
S3Client,
PutObjectCommand,
GetObjectCommand,
DeleteObjectCommand
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { ConfigService } from '@nestjs/config';
import { v4 as uuidv4 } from 'uuid';
@Injectable()
export class FileUploadService {
private s3Client: S3Client;
private bucketName: string;
constructor(private configService: ConfigService) {
this.s3Client = new S3Client({
region: this.configService.get('AWS_REGION'),
credentials: {
accessKeyId: this.configService.get('AWS_ACCESS_KEY_ID'),
secretAccessKey: this.configService.get('AWS_SECRET_ACCESS_KEY'),
},
});
this.bucketName = this.configService.get('S3_BUCKET_NAME');
}
async uploadFile(
file: Express.Multer.File,
folder: string = 'documents'
): Promise<{ url: string; key: string }> {
// Validate file type and size
this.validateFile(file);
// Generate unique filename
const fileExtension = file.originalname.split('.').pop();
const key = `${folder}/${uuidv4()}.${fileExtension}`;
const command = new PutObjectCommand({
Bucket: this.bucketName,
Key: key,
Body: file.buffer,
ContentType: file.mimetype,
Metadata: {
originalName: file.originalname,
uploadedAt: new Date().toISOString(),
},
});
try {
await this.s3Client.send(command);
const url = `https://${this.bucketName}.s3.${
this.configService.get('AWS_REGION')
}.amazonaws.com/${key}`;
return { url, key };
} catch (error) {
console.error('S3 upload error:', error);
throw new Error(`File upload failed: ${error.message}`);
}
}
async getSignedDownloadUrl(key: string, expiresIn: number = 3600): Promise<string> {
const command = new GetObjectCommand({
Bucket: this.bucketName,
Key: key,
});
try {
const url = await getSignedUrl(this.s3Client, command, { expiresIn });
return url;
} catch (error) {
console.error('Failed to generate signed URL:', error);
throw new Error(`Could not generate download URL: ${error.message}`);
}
}
async deleteFile(key: string): Promise<void> {
const command = new DeleteObjectCommand({
Bucket: this.bucketName,
Key: key,
});
try {
await this.s3Client.send(command);
console.log(`File deleted: ${key}`);
} catch (error) {
console.error('Failed to delete file:', error);
throw new Error(`File deletion failed: ${error.message}`);
}
}
private validateFile(file: Express.Multer.File): void {
const maxSize = 10 * 1024 * 1024; // 10MB
const allowedTypes = [
'application/pdf',
'image/jpeg',
'image/png',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
];
if (file.size > maxSize) {
throw new Error('File size exceeds 10MB limit');
}
if (!allowedTypes.includes(file.mimetype)) {
throw new Error('File type not allowed');
}
}
}Integration in Controllers
Here's how these services work together in a controller:
// referrals.controller.ts
@Controller('referrals')
export class ReferralsController {
constructor(
private referralsService: ReferralsService,
private emailService: EmailService,
private smsService: SmsService,
private fileUploadService: FileUploadService,
) {}
@Post()
@UseInterceptors(FilesInterceptor('documents', 5))
async createReferral(
@Body() createReferralDto: CreateReferralDto,
@UploadedFiles() files: Express.Multer.File[],
) {
// Create the referral
const referral = await this.referralsService.create(createReferralDto);
// Upload any attached documents
if (files && files.length > 0) {
const uploadPromises = files.map(file =>
this.fileUploadService.uploadFile(file, `referrals/${referral.id}`)
);
const uploadedFiles = await Promise.all(uploadPromises);
await this.referralsService.attachDocuments(
referral.id,
uploadedFiles
);
}
// Send email notification
await this.emailService.sendReferralNotification(
createReferralDto.coordinatorEmail,
{
motherName: referral.motherName,
priority: referral.priority,
referredBy: referral.user.firstName,
notes: referral.notes,
portalUrl: `${process.env.PORTAL_URL}/referrals/${referral.id}`,
}
);
// Send SMS for urgent cases
if (referral.priority === 'URGENT') {
await this.smsService.sendUrgentAlert(
createReferralDto.coordinatorPhone,
`URGENT: New referral for ${referral.motherName}. Check portal immediately.`
);
}
return referral;
}
}Best Practices
- Error Handling: AWS operations can fail. Always handle errors gracefully
- Retry Logic: Implement exponential backoff for transient failures
- Monitoring: Use CloudWatch to track service usage and errors
- Cost Management: Be aware of pricing - SES, SNS, and S3 costs add up
- Security: Never commit AWS credentials. Use environment variables or IAM roles
Conclusion
AWS services like SES, SNS, and S3 provide powerful building blocks for full-stack applications. The key is implementing them with proper error handling, monitoring, and cost awareness.
These patterns have served me well across multiple projects and can scale from small applications to enterprise systems.

Jason Cochran
Sofware Engineer | Cloud Consultant | Founder at Strataga
27 years of experience building enterprise software for oil & gas operators and startups. Specializing in SCADA systems, field data solutions, and AI-powered rapid development. Based in Midland, TX serving the Permian Basin.