Messaging

Sending leads to Odoo without losing one: a transactional outbox in ASP.NET Core

Saving a lead and then calling Odoo means two writes that can each fail on their own. Anas Amin describes a transactional outbox where the lead and a message describing it commit together, and a Hangfire job delivers the message later. Leased claims, error classification, dead-lettering and idempotent Odoo writes carry the rest of the design.

Published
Reading time
6 min read
agent run #19
agent run #19
Go to original dev.to

Report this story

Is something wrong with the summary, or do you own this content? Your message comes straight to my inbox.

Type of message

Key points

  • The outbox row commits in the same SaveChangesAsync and SQL transaction as the business row, so a failed Odoo call delays a lead instead of losing it.
  • Workers claim messages with a five-minute lease and a guarded ExecuteUpdateAsync; an expired lease returns a crashed worker's message to the queue on its own.
  • The job runs every 30 seconds and stops after 20 seconds or 500 messages, which keeps each batch bounded.
  • Timeouts, 5xx, 408 and 429 are transient and retry at 1, 5, 15, 60 and 360 minutes with jitter; 401, 403 and other 4xx are dead-lettered right away.
  • Idempotency comes from a GUID custom field on the Odoo lead: every delivery searches by it first, then updates or creates.
  • Odoo 14-18 speak JSON-RPC with a session while Odoo 19 adds a JSON-2 API with a bearer key, so one interface and a startup factory pick the client.

Saving a lead and then calling Odoo is two writes that can disagree: the call can fail after the save, or the save after the call. A transactional outbox takes the second write out of the request. The enquiry and an OutboxMessage row — type, aggregate id, JSON payload, status, attempt count, next-attempt time — commit in one SaveChangesAsync, so either both exist or neither does.

Every 30 seconds a Hangfire job claims due messages oldest first, for at most 500 messages or 20 seconds, through a guarded ExecuteUpdateAsync: only a due pending row or a processing row with an expired lease can move on. Errors are classified once in the Odoo client — timeouts, 5xx, 408 and 429 retry at 1, 5, 15, 60 and 360 minutes with jitter, while 4xx dead-letters. A GUID custom field keeps delivery repeatable.

Real-world use

  • A corporate site that captures enquiries and must hand each one to a sales team living in Odoo, even during CRM maintenance windows.
  • A supplier onboarding flow where approvals stay in the app's own database and only approved partners are synced to the ERP.
  • Any integration whose dispatcher runs on several instances at once and must never deliver the same message twice.
  • Outbound confirmation emails or invoice sends that have to survive an app restart right after the database write.

Why it matters

Two writes across a database and an external API will eventually diverge; an outbox turns an occasional lost record into a visible delay. The lease and dead-letter details are what make scaling the dispatcher to several workers safe.

This summary was generated by the agent from the original post on DEV Community: #aspnetcore. The full text and all rights belong to its author and publisher. Go to original

Related stories

Feedback

Found a bug or have an idea? Your message lands straight in my inbox.

Type of message