Complete Guide to Amazon SQS and Amazon SNS with MassTransit
A friendly, step-by-step guide to messaging in .NET using Amazon SQS, Amazon SNS, and MassTransit — queues, topics, consumers, retries, and dead-letter handling.
A post office and a notice board
Imagine your family runs a small sweet shop.
When a customer places an order, one cousin needs to pack the box. You do not want two cousins packing the same box, and you do not want the box forgotten. So you drop a little slip into a letterbox. Whoever is free picks the slip, packs that one order, and throws the slip away. Each slip is handled exactly once. That letterbox is Amazon SQS — a queue.
But sometimes news needs to reach many people at once. Say the shop got a new batch of fresh laddoos. You want to tell the billing cousin, the delivery cousin, and the cousin who updates the website — all of them. Phoning each one is slow. Instead you pin one notice on a board. Everyone who cares walks past and reads it. That notice board is Amazon SNS — a topic.
MassTransit is the helpful manager who sets up the letterboxes and the notice board, writes the slips neatly, hands each slip to the right cousin, and tries again if a cousin drops one. You just say what happened. The manager does the messy work.
Let us learn how SQS, SNS, and MassTransit fit together in .NET, step by step.
Why use messaging at all?
When one service calls another directly over HTTP, both must be awake at the exact same moment. If the second one is slow or down, the first one is stuck waiting.
This is called temporal coupling — everything must be up together. Messaging breaks the chain. The Order Service writes "an order was placed" and moves on. The other services read the news when they are ready. Nobody waits on anybody.
SQS and SNS — who does what?
These two AWS services are simple on their own. The magic is using them together.
| Feature | Amazon SQS | Amazon SNS |
|---|---|---|
| Shape | Queue (a line of messages) | Topic (a notice board) |
| Who reads a message | Exactly one consumer | Every subscriber gets a copy |
| Good for | Commands and one-worker tasks | Events many services care about |
| Message waits? | Yes, until read and deleted | No, it is pushed out immediately |
| Common pairing | The mailbox each service owns | Fans an event out to many mailboxes |
The classic pattern is SNS in front of SQS. You publish one event to an SNS topic. SNS copies that event into each subscribed SQS queue. Each service then reads from its own queue at its own pace. You get fan-out from SNS and safe, durable storage from SQS.
Where MassTransit fits
You could call the AWS SDK directly. But then you must create topics, create queues, wire up subscriptions, set permissions, serialize messages to JSON, poll for new messages, delete handled ones, and retry on errors. That is a lot of careful code.
MassTransit does all of that for you. You define a message (a plain C# class), a consumer (a class that handles it), and a little configuration. When the bus starts, MassTransit creates the SNS topics, the SQS queues, and the subscriptions automatically.
From your code to AWS, the MassTransit way
Steps
Define message
A plain C# record like OrderPlaced.
Write consumer
A class that does the work.
Configure bus
Tell MassTransit to use Amazon SQS.
Bus creates topics & queues
Topics, queues, subscriptions made for you.
Messages flow
Publish and consume with a few lines.
Step 1 — Install the packages
You need MassTransit and its Amazon SQS transport. Install them with the .NET CLI.
// Run these in your project folder:
// dotnet add package MassTransit
// dotnet add package MassTransit.AmazonSQS
// A note on licensing:
// From MassTransit v9 onward, MassTransit is a commercial,
// source-available product. There is a free evaluation license
// for local development, and small companies (under 1 million USD
// gross annual revenue) may qualify for a free license.
// Check the current terms before shipping to production.Step 2 — Define a message
A message is just a plain class or record. Keep it simple. It should describe what happened, not how to handle it. Past-tense names work well for events.
namespace SweetShop.Contracts;
// An event: "this already happened."
public record OrderPlaced
{
public Guid OrderId { get; init; }
public string CustomerEmail { get; init; } = string.Empty;
public decimal Total { get; init; }
public DateTime PlacedAtUtc { get; init; }
}Put your message contracts in a shared project so both the sender and the receivers can reference the exact same type. MassTransit matches messages to topics using the .NET type name, so both sides must agree.
Step 3 — Write a consumer
A consumer is a class that handles one message type. It implements IConsumer<T>. MassTransit calls your Consume method whenever a matching message arrives in the queue.
using MassTransit;
using SweetShop.Contracts;
public class SendWelcomeEmailConsumer : IConsumer<OrderPlaced>
{
private readonly ILogger<SendWelcomeEmailConsumer> _logger;
public SendWelcomeEmailConsumer(ILogger<SendWelcomeEmailConsumer> logger)
{
_logger = logger;
}
public async Task Consume(ConsumeContext<OrderPlaced> context)
{
OrderPlaced order = context.Message;
_logger.LogInformation(
"Sending email for order {OrderId} to {Email}",
order.OrderId, order.CustomerEmail);
// Do the real work here: send the email, call an API, etc.
await Task.CompletedTask;
}
}If this method throws, MassTransit does not delete the message. It becomes visible again later and is retried. That safety net is one of the biggest reasons to use a message broker.
Step 4 — Configure the bus
Now wire MassTransit to Amazon SQS in your Program.cs. This is where you give your AWS region and credentials, and register your consumers.
using MassTransit;
builder.Services.AddMassTransit(x =>
{
// Register every consumer in this assembly.
x.AddConsumer<SendWelcomeEmailConsumer>();
x.UsingAmazonSqs((context, cfg) =>
{
cfg.Host("eu-west-1", h =>
{
h.AccessKey(builder.Configuration["AWS:AccessKey"]);
h.SecretKey(builder.Configuration["AWS:SecretKey"]);
});
// Let MassTransit create queues, topics, and subscriptions,
// then attach the right consumers to each receive endpoint.
cfg.ConfigureEndpoints(context);
});
});When the app starts, MassTransit looks at your consumers, works out which messages they handle, and builds the matching SNS topics and SQS queues in your AWS account. You did not touch the AWS console once.
Step 5 — Publish and send
There are two ways to put a message into the system, and the difference matters.
- Publish sends an event to a topic. Every subscriber gets a copy. Use this for "something happened" events like
OrderPlaced. - Send delivers a command to one specific queue. Only that queue's consumer handles it. Use this for "please do this one thing" commands.
// PUBLISH: fan an event out to everyone who cares.
public class OrderService
{
private readonly IPublishEndpoint _publishEndpoint;
public OrderService(IPublishEndpoint publishEndpoint)
{
_publishEndpoint = publishEndpoint;
}
public async Task PlaceOrderAsync(Guid orderId, string email, decimal total)
{
// ... save the order to the database first ...
await _publishEndpoint.Publish(new OrderPlaced
{
OrderId = orderId,
CustomerEmail = email,
Total = total,
PlacedAtUtc = DateTime.UtcNow
});
}
}The publisher does not know who is listening. Tomorrow you can add a loyalty-points service that subscribes to OrderPlaced, and the Order Service never changes. That is the power of pub-sub.
| Operation | Goes to | Who handles it | Use it for |
|---|---|---|---|
Publish | An SNS topic | Every subscriber | Events: "OrderPlaced", "UserRegistered" |
Send | One SQS queue | One consumer | Commands: "ShipOrder", "ChargeCard" |
Handling failures: retries and dead letters
Things go wrong. A network blips. A database is briefly locked. MassTransit lets you retry before giving up. You add a retry policy to the consumer's endpoint.
cfg.ReceiveEndpoint("email-service", e =>
{
// Try 3 more times, waiting 5 seconds between tries.
e.UseMessageRetry(r => r.Interval(3, TimeSpan.FromSeconds(5)));
// Turn on the SQS redrive policy so poison messages
// move to a dead-letter queue instead of looping forever.
e.ConfigureConsumer<SendWelcomeEmailConsumer>(context);
});If a message still fails after all retries, you do not want it stuck forever, blocking the queue. SQS supports a dead-letter queue (DLQ): a side mailbox where hopeless messages are moved. You can inspect them later, fix the bug, and replay them. MassTransit also has its own _error and _skipped queues for faulted and unhandled messages.
The life of a failing message
Steps
Arrives
Message lands in the SQS queue.
Consumer throws
Something goes wrong while handling it.
Retry a few times
MassTransit waits and tries again.
Still failing
All retries used up.
Dead-letter queue
Moved aside for a human to inspect.
Testing locally with LocalStack
You do not want to touch real AWS every time you run the app on your laptop. LocalStack is a tool that fakes AWS services on your own machine. MassTransit supports it with one method call.
cfg.Host("us-east-1", h =>
{
// Point at LocalStack instead of real AWS.
h.AccessKey("test");
h.SecretKey("test");
h.Config(new AmazonSimpleNotificationServiceConfig
{
ServiceURL = "http://localhost:4566"
});
h.Config(new AmazonSQSConfig
{
ServiceURL = "http://localhost:4566"
});
});Newer MassTransit versions also offer a LocalstackHost() helper that sets this up for you. Either way, your tests run fast, cost nothing, and never depend on a real AWS account.
Idempotency: handle the same message twice safely
Standard SQS promises at-least-once delivery. Most of the time a message arrives once. But if a consumer crashes after doing the work but before telling SQS it finished, the message comes back and is handled again.
So your consumers should be idempotent — handling the same message twice has the same result as handling it once. A common way is to remember which message IDs you have already processed, often using the Inbox Pattern. Before doing the work, check "have I seen this message ID before?" If yes, skip it.
public async Task Consume(ConsumeContext<OrderPlaced> context)
{
Guid messageId = context.MessageId ?? Guid.Empty;
// Inbox check: have we already handled this exact message?
if (await _inbox.AlreadyProcessedAsync(messageId))
return; // safe to ignore the duplicate
// ... do the real work ...
await _inbox.MarkProcessedAsync(messageId);
}For strict ordering and built-in deduplication, AWS also offers FIFO queues and topics (their names end in .fifo). They keep messages in order and remove duplicates within a five-minute window, at the cost of lower throughput. Use them only when order truly matters, like financial steps that must run one after another.
Scoping topics and queues
There is only one SNS and SQS namespace per AWS account. If many services share the account, their names can clash. MassTransit lets you add a scope prefix so each service's topics and queues stay tidy and separate.
cfg.Host("eu-west-1", h =>
{
h.AccessKey(accessKey);
h.SecretKey(secretKey);
h.Scope("sweet-shop", scopeTopics: true);
});Now all topics and queues start with sweet-shop, so they will not collide with another team's OrderPlaced topic. This is a small setting that saves big headaches in shared accounts.
Putting it all together
Here is the full picture for our sweet shop, from a customer order to several services reacting.
The Order Service does its own job, saves the order, and publishes once. Email and Shipping each get their own copy in their own queue and work at their own pace. If Shipping is briefly down, its messages wait safely in SQS until it wakes up. Nothing is lost.
Common mistakes to avoid
- Publishing before saving. If you publish
OrderPlacedbut the database save then fails, you have lied to everyone. Save first, or use the Outbox Pattern so the save and the publish happen together. - Forgetting idempotency. At-least-once delivery means duplicates will happen one day. Plan for it from the start.
- No dead-letter queue. Without a DLQ, one poison message can be retried forever and clog the line. Always set a redrive policy.
- Sharing message types loosely. Keep contracts in a shared library so the sender and receiver use the exact same type name. MassTransit routes by type.
- Heavy work inside Consume. Keep consumers quick. If the work is long, hand it off so the message can be acknowledged and the queue keeps moving.
Quick recap
- Amazon SQS is a queue. Each message is read by exactly one consumer, then deleted.
- Amazon SNS is a topic, like a notice board. A published message is copied to every subscriber.
- The strong combo is SNS in front of SQS: publish once, fan out to many service queues.
- MassTransit creates the topics, queues, and subscriptions for you and routes messages to consumer classes.
- Use Publish for events ("something happened") and Send for commands ("do this one thing").
- Add retries and a dead-letter queue so failures are handled gracefully, not lost or looped.
- SQS is at-least-once, so make consumers idempotent, often with the Inbox Pattern.
- Test locally with LocalStack so you never depend on real AWS while coding.
- MassTransit v9+ is commercial and source-available — check the license before you ship.
References and further reading
- Amazon SQS/SNS Configuration — MassTransit Documentation
- Amazon SQS Quick Start — MassTransit
- Complete Guide to Amazon SQS and Amazon SNS With MassTransit — Milan Jovanović
- MassTransit RabbitMQ and Azure Service Bus: Is It Worth a Commercial License — Anton DevTips
Related Patterns
Event-Driven Microservices with Azure Service Bus in .NET
A friendly, step-by-step guide to building event-driven microservices in .NET using Azure Service Bus topics, subscriptions, and the ServiceBusProcessor.
The Outbox Pattern in .NET: Never Lose a Message Again
Learn the Outbox Pattern in .NET with simple, real-life examples. Save your data and your messages in one transaction so a broker outage can never lose an event. Includes EF Core code, diagrams, and best practices.
The Inbox Pattern in .NET: Handle Each Message Exactly Once
Learn the Inbox Pattern in .NET to stop duplicate messages from causing double charges and double emails. Simple real-life examples, EF Core code, diagrams, and how it pairs with the Outbox Pattern.
Simple Messaging in .NET with Redis Pub/Sub: A Beginner's Guide
Learn Redis Pub/Sub in .NET with StackExchange.Redis using simple words, a real-life analogy, clean async C# code, diagrams, and when to use it safely.
Using MassTransit with RabbitMQ and Azure Service Bus in .NET
Learn how MassTransit lets one set of .NET code run on both RabbitMQ and Azure Service Bus, with simple consumers, publishers, and config examples.
Implementing the Saga Pattern with MassTransit in .NET
Learn the Saga pattern in .NET with MassTransit state machines — states, events, correlation, persistence, retries, and compensation, explained in simple, friendly steps.