The dangerous request is not the first one. It is the same request arriving again, because the sender did not hear the first answer, or heard it and retried anyway. Payment providers do this. Queues do this. A browser does this when a person hits the button twice. If the system treats the second copy as new work, you charge twice, ship twice, or write two ledger lines for one stake.
Assume the duplicate
I design the handler so the second delivery is a success that does nothing. The first delivery does the work and stores a key: the provider’s event id, or a key the client sent, unique in the database. The second delivery finds the key and returns the same result. No second charge. No second email. No second row.
A unique constraint is the part I trust. A check in application code, done a moment before the insert, loses the race when two workers wake up together. The database does not lose that race. The loser gets a conflict and turns it into “already done”.
| First arrival | Second arrival | |
|---|---|---|
| Lookup | Key is new | Key is already stored |
| Write | One row, one side effect | No new row |
| Reply | Accepted | The same accepted |
| If the process dies mid-way | The outbox still owes the message | It does not owe a second one |
The message leaves with the write, or not at all
Sending the email, or the queue message, inside the request and before the commit is how you get a message for a transaction that rolled back. I write the outgoing message in the same transaction as the business row, and a separate worker publishes it. If the process dies, the row and the message are either both there or both absent. The worker can retry the publish. The handler must not retry the charge.
A void is a new entry, not an edit
When something has to be undone, I do not overwrite the original line. An offsetting entry says what changed and leaves the first line readable. A week later you can still answer what was believed at 14:02. Editing the row answers only what is believed now, and it answers without a witness.
If a retry can make the operation happen twice, the operation is not finished.
Name the key before you write the handler
The key is not “a hash of the whole request” unless you have checked what the sender changes between retries. A payment provider will resend the same event id with a new delivery timestamp. If the timestamp is inside the key, the second delivery looks new, and you have built the bug you meant to prevent. I use the id the sender promises is stable. If the sender does not promise one, I require the caller to send one, and I reject the call that arrives without it.
Then I write the failure down in the same place as the success. A conflict is not an error page for the sender. It is the expected answer to a retry. The sender should be able to stop. If I answer 500, they will try a third time, and a fourth, and the incident becomes a queue of identical work.
I do not consider this done because the happy path returned 200. The test posts the same payload again and asserts one side effect. Then it kills the worker between the commit and the publish and asserts that the message still leaves, once. Those two tests are shorter than the incident they replace. I start there.
