02/28/26

Building Event-Driven Microservices on AWS: Encore vs. the DIY Stack

SNS, SQS, dead-letter queues, IAM policies, or one line of TypeScript

6 Min Read

The typical stack for event-driven microservices on AWS is Express for the HTTP layer, BullMQ or a custom SNS/SQS wrapper for messaging, Docker for containerization, and Terraform for the infrastructure. Each piece is well-understood on its own, but the surface area when they're all running together is where the time goes.

A single pub/sub topic on AWS requires an SNS topic, an SQS queue, a dead-letter queue for failed messages, an SNS-to-SQS subscription, IAM policies for both the publishing and consuming services, and security group rules if the services are in a VPC. That's one topic. Most event-driven backends have three to ten, each with the same set of resources multiplied across services that each need their own Dockerfile, ECS task definition, database, and IAM role.

The infrastructure surface ends up larger than the application code. Teams avoid event-driven architecture not because the pattern is hard, but because the infrastructure tax on AWS makes it hard to justify for anything short of a dedicated platform team.

The DIY Stack for Three Event-Driven Services

Here's what it takes to build a backend where an orders service publishes events, a notifications service sends alerts, and a billing service processes charges. All on AWS, using the tools AI agents reach for by default.

Application code (orders service, abbreviated):

// orders/src/index.ts import express from "express"; import { SNSClient, PublishCommand } from "@aws-sdk/client-sns"; // Manual SNS client setup — topic ARN comes from environment variables const sns = new SNSClient({}); const app = express(); app.use(express.json()); app.post("/orders", async (req, res) => { const order = await db.query( "INSERT INTO orders (customer_id, total) VALUES ($1, $2) RETURNING *", [req.body.customerId, req.body.total] ); await sns.send(new PublishCommand({ TopicArn: process.env.ORDER_CREATED_TOPIC_ARN, Message: JSON.stringify({ orderId: order.rows[0].id, customerId: req.body.customerId, total: req.body.total, }), })); res.json(order.rows[0]); });

Application code (notifications consumer, abbreviated):

// notifications/src/worker.ts — manual SQS polling loop import { SQSClient, ReceiveMessageCommand, DeleteMessageCommand } from "@aws-sdk/client-sqs"; const sqs = new SQSClient({}); const QUEUE_URL = process.env.NOTIFICATION_QUEUE_URL; // Long-polling loop that manually manages message acknowledgment async function poll() { while (true) { const result = await sqs.send(new ReceiveMessageCommand({ QueueUrl: QUEUE_URL, MaxNumberOfMessages: 10, WaitTimeSeconds: 20, })); for (const msg of result.Messages ?? []) { const event = JSON.parse(JSON.parse(msg.Body!).Message); await sendNotification(event); await sqs.send(new DeleteMessageCommand({ QueueUrl: QUEUE_URL, ReceiptHandle: msg.ReceiptHandle, })); } } }

That's just two services. Add a billing consumer and you're at about 300 lines of application code, plus Express boilerplate, database connection setup, and error handling.

Infrastructure code (abbreviated):

# terraform/sns.tf resource "aws_sns_topic" "order_created" { name = "order-created" } # terraform/sqs_notifications.tf resource "aws_sqs_queue" "notifications_dlq" { name = "notifications-dlq" message_retention_seconds = 1209600 } resource "aws_sqs_queue" "notifications" { name = "notifications-queue" redrive_policy = jsonencode({ deadLetterTargetArn = aws_sqs_queue.notifications_dlq.arn maxReceiveCount = 3 }) visibility_timeout_seconds = 60 } resource "aws_sns_topic_subscription" "notifications" { topic_arn = aws_sns_topic.order_created.arn protocol = "sqs" endpoint = aws_sqs_queue.notifications.arn } resource "aws_sqs_queue_policy" "notifications" { queue_url = aws_sqs_queue.notifications.id policy = jsonencode({ Statement = [{ Effect = "Allow" Principal = { Service = "sns.amazonaws.com" } Action = "sqs:SendMessage" Resource = aws_sqs_queue.notifications.arn Condition = { ArnEquals = { "aws:SourceArn" = aws_sns_topic.order_created.arn } } }] }) } # ... repeat for billing service queue # ... plus IAM roles for each service # ... plus ECS task definitions, Dockerfiles, docker-compose with LocalStack

For two subscribers on one topic, the Terraform is about 120 lines just for the messaging infrastructure. The full setup with three ECS services, Dockerfiles, VPC, load balancer, IAM roles, and CI/CD is 600+ lines.

Plus a docker-compose.yml with LocalStack or ElasticMQ to emulate SNS/SQS locally, which never behaves quite like the real thing.

The Same Backend with Encore

// orders/orders.ts import { api } from "encore.dev/api"; import { SQLDatabase } from "encore.dev/storage/sqldb"; import { Topic } from "encore.dev/pubsub"; // Declares a PostgreSQL database. Encore provisions RDS on AWS. const db = new SQLDatabase("orders", { migrations: "./migrations" }); // Declares a Pub/Sub topic. Encore provisions SNS + SQS per subscriber. export const orderCreated = new Topic<OrderEvent>("order-created", { deliveryGuarantee: "at-least-once", }); interface OrderEvent { orderId: string; customerId: string; total: number; } // Defines a type-safe API endpoint. export const create = api( { expose: true, method: "POST", path: "/orders" }, async (req: { customerId: string; total: number }): Promise<Order> => { const order = await db.queryRow<Order>` INSERT INTO orders (customer_id, total) VALUES (${req.customerId}, ${req.total}) RETURNING id, customer_id as "customerId", total`; await orderCreated.publish({ orderId: order!.id, customerId: req.customerId, total: req.total, }); return order!; } );
// notifications/notifications.ts import { Subscription } from "encore.dev/pubsub"; import { orderCreated } from "../orders/orders"; // Subscribes to the order-created topic. Encore handles delivery and retries. const _ = new Subscription(orderCreated, "send-notification", { handler: async (event) => { await sendNotification(event.customerId, event.orderId); }, });
// billing/billing.ts import { Subscription } from "encore.dev/pubsub"; import { orderCreated } from "../orders/orders"; // Second subscriber on the same topic. Each gets its own SQS queue. const _ = new Subscription(orderCreated, "process-charge", { handler: async (event) => { await chargeCustomer(event.customerId, event.total); }, });

Three services. One topic with two subscribers. About 50 lines of meaningful code.

On deploy, Encore.ts's compiler provisions: one SNS topic, two SQS queues with dead-letter queues, SNS-to-SQS subscriptions, queue policies, IAM policies scoped per service, and three Fargate services. All from three new Topic() / new Subscription() declarations.

Local Pub/Sub That Matches Production

With the DIY stack, you need LocalStack or ElasticMQ to emulate SNS/SQS locally. Neither is a perfect simulation. Message ordering, retry behavior, and dead-letter routing work differently in the emulator than in production.

encore run gives you pub/sub with production-equivalent delivery semantics out of the box. Messages are delivered with at-least-once guarantees, retry behavior matches production, and distributed traces in the local dashboard show the full event flow: the publish, each subscriber's processing, and any retries.

Distributed trace showing event flow across services

Adding a Subscriber Is One Declaration

With the Terraform stack, adding a third subscriber to order-created means: a new SQS queue, dead-letter queue, SNS subscription, queue policy, IAM policies, ECS service definition, Dockerfile, and CI/CD updates. About 100 lines of new infrastructure code.

With Encore:

const _ = new Subscription(orderCreated, "new-subscriber", { handler: async (event) => { // handle it }, });

One declaration, a handler function, and a git push.

Why Teams Avoid Event-Driven Architecture

Event-driven patterns are well-understood architecturally. Teams don't avoid them because the patterns are unclear. They avoid them because the infrastructure cost of each event flow on AWS is high enough to make teams think twice.

When a new topic and two subscribers cost 200 lines of Terraform, teams build synchronous request chains instead. They call the notification service directly from orders, creating tight coupling, because the alternative is a week of infrastructure work.

When the infrastructure cost of a pub/sub topic is new Topic(...) and each subscriber is new Subscription(...), the architecture decision is made on architectural merits. You decouple when decoupling makes sense, not when the infrastructure budget allows.

Ready to escape the maze of complexity?

Encore Cloud is the development platform for building robust type-safe distributed systems with declarative infrastructure.