
Are your handlers idempotent?
One of the most important principles when designing command handlers is idempotency — ensuring that no matter how many times a command is processed, the resulting system state remains consistent and unchanged after the first successful execution.
Let’s send welcome email to user after account is activated
Assume we have a common scenario: after a user registers in the system, he receives an email with a verification link. Once the user clicks the link, we want to activate his account and send him a welcome email. The email sending operation is triggered asynchronously from the command handler.
Let’s take a look at the example api method below:
[HttpPost("activate")]
public async Task<ActionResult> ActivateAccount([FromBody] ActivateAccountRequest request)
{
var user = await context.Users.SingleOrDefault(u => u.UserId == request.UserId);
if(user is null)
{
logger.LogError("User with id {UserId} not found", request.UserId);
}
user.SetStatusToActive();
await context.SaveChangesAsync();
await bus.Send(new SendAccountActivatedEmail()
{
UserId = user.UserId
});
}
This endpoint retrieves user entry from database, sets the account status as active and in the end it sends a command to send welcome email to the user.
Now let’s take a look at the command handler:
public async Task Handle(SendAccountActivatedEmail command)
{
var user = await context.Users.SingleOrDefault(u => u.UserId == command.UserId);
if(user is null)
{
logger.LogError("User with id {UserId} not found", command.UserId);
return;
}
await emailSender.SendInvitationEmail(user.UserId);
}
The command handler just gets a command, retrieves user entity from the database and it tries to send the welcome email.
What’s the problem here?
This handler will send an email every time it receives the message. But what happens if the user clicks the verification link multiple times? Or if the message is retried by the messaging infrastructure due to a transient failure?
The user could end up receiving multiple welcome emails — a poor experience and a clear sign that our system doesn’t behave consistently under repeated execution.
Can we fix?
That’s where idempotency comes in - no matter how many times the same command is executed result will be always the same. In this scenario we want the welcome email to be sent excatly once. Let’s adjust the implementation to make that guarantee.
Step 1: Fix the API Endpoint
First we’ll change the endpoint to check if the account is already active before attempting to activate it or sending the message.
[HttpPost("activate")]
public async Task<ActionResult> ActivateAccount([FromBody] ActivateAccountRequest request)
{
var user = await context.Users.SingleOrDefault(u => u.UserId == request.UserId);
if(user is null)
{
logger.LogError("User with id {UserId} not found", request.UserId);
return BadRequest();
}
if (user.IsActive)
{
await logger.Warning("User account is already active.", request.UserId);
return Conflict();
}
user.SetStatusToActive();
await context.SaveChangesAsync();
await bus.Send(new SendAccountActivatedEmail()
{
UserId = user.UserId
});
}
Now, if the user account is already active, the endpoint will short-circuit and avoid sending another command.
But that’s only half of the story.
Step 2: Make the Command Handler Idempotent
We also need to prevent the email from being sent more than once, even if the message is reprocessed due to retries or duplicates.
To achieve this, we can introduce a new entity to track whether the welcome email was already sent:
public class AccountActivatedEmail
{
[Key]
public Guid UserId { get; set; }
public DateTimeOffset? SentAt { get; set; }
public bool Sent { get; set; }
public bool Failed { get; set; }
[ForeignKey(nameof(UserId))]
public User User { get; set; }
public void MarkAsSent()
{
SentAt = DateTimeOffset.Now;
Sent = true;
}}
public void MarkAsFailed()
{
Failed = true;
}}
}
Note that UserId is PK here so it guarantees uniqness.
Now our handler should look like:
public async Task Handle(SendAccountActivatedEmail command)
{
var user = await context.Users.SingleOrDefaultAsync(u => u.UserId == command.UserId);
if(user is null)
{
logger.LogError("User with id {UserId} not found", command.UserId);
return;
}
var accountActivatedEmail = new AccountActivatedEmail { UserId = command.UserId };
context.AccountActivatedEmail.Add(accountActivatedEmail);
try
{
await context.SaveChangesAsync();
}
catch (DbUpdateException)
{
logger.LogWarning("Account activated email already sent for user {UserId}. Skipping...", command.UserId);
return;
}
try
{
await emailSender.SendInvitationEmail(command.UserId);
}
catch(Exception e)
{
logger.LogError(e, "Error occurred while sending account activated email to user {UserId}", command.UserId)
accountActivatedEmail.MarkAsFailed();
await context.SaveChangesAsync();
return;
}
accountActivatedEmail.MarkAsSent();
await context.SaveChangesAsync();
}
Thanks to the saving AccountActivatedEmail entity we make sure there can be only one entity per user. Database will enforce uniqueness so if the command is duplicated or reprocessed the DbUpdateException will be thrown preventing from sending multiple welcome email to the same person.
- If the AccountActivatedEmail entity already exists (i.e., the email was already sent), the database will reject the insert.
- We catch the DbUpdateException and skip the sending logic.
- We only send the email once, even if the command is reprocessed.
- We can safely retry if there was error while sending the email.
- We improved observability
Conclusion
Whenever you’re designing command handlers, idempotency should be top of mind. In systems with asynchronous processing, retries and duplicates are common. Without idempotency, this can lead to unintended side effects.
To avoid this:
- Check the system state before performing an action.
- Leverage the database to enforce uniqueness.
- Record the fact that an action was completed, so it won’t be repeated.
While this simple example is centered around a welcome email, the pattern applies broadly and should be kept in mind when dealing with asynchronous processing.