
Replacing a background-job system sounds scary because the jobs do real work — sending email, dispatching webhooks, re-fetching content. Get the migration wrong and you drop a customer's email or fire a webhook twice. It doesn't have to be scary. When I moved every polling Hangfire job in my .NET app to RabbitMQ, the whole thing stayed boring because of one pattern: the thin consumer . This post is that pattern, plus the four details around it that actually matter. :::info Disclosure: I'm the founder of SavePosty, a read-it-later app, and this is a pattern from a real migration in our own .NET codebase — not a sponsored post. ::: The pattern: a thin shell in front of unchanged logic The mistake people make is treating a job-system migration as a rewrite . It isn't. Your job classes already work — they're tested, they handle the edge cases, they're battle-worn. The only thing changing is how the job gets triggered . So I made the consumer do as close to nothing as possible: receive a message, deserialize it, and hand off to the class that already exists. public class SendEmailConsumer : IConsumer<SendEmailMessage> { private readonly SendEmailJob _job; // the SAME class Hangfire used public SendEmailConsumer(SendEmailJob job) => _job = job; public async Task Consume(ConsumeContext<SendEmailMessage> ctx) { // no business logic here — just hand off await _job.Execute(ctx.Message.EmailId); } } \ The consumer owns transport ; the job owns behavior . Because no logic moves, my existing tests still cover the important part, and a rollback is a one-line revert. I used this identical shell for every job I migrated — and that repetition is the point. A migration made of small, near-identical diffs is one you can actually review and ship. Queue topology: one queue per job, one DLQ per queue Give each job its own queue, and give each queue its own dead-letter queue. Don't share. \ A publisher sends a message to an exchange; the exchange routes it to the job's queue; the consumer picks it up. When a message can't be processed and exhausts its retries, it lands in that queue's DLQ instead of vanishing into a log line. Per-job isolation means a poison message in webhookdispatch never backs up sendemail . Migrate retry semantics first This is the single easiest thing to get wrong, so do it before anything else. Map your Hangfire retry attribute to the consumer's retry config explicitly — don't assume the defaults match. \ [AutomaticRetry(Attempts = 0)] on the old job becomes an explicit "no retries" on the consumer. Get this mapping wrong in one direction and you hammer a failing downstream forever; wrong in the other and you silently drop messages that should have been retried. Decide the policy per job, write it down, and only then wire the consumer. The rollout, step by step Here's the order that kept each job's migration to a small, safe diff: \ Add a thin consumer per job — it just delegates to the existing class. Map retry semantics first — [AutomaticRetry(Attempts = 0)] → MaxRetries = 0 . A dead-letter queue for every queue — exhausted messages land somewhere, not nowhere. Leave the old HangfireJobId column nullable — no migration, no breaking change, trivial rollback. Swap the publisher — IBackgroundJobClient → IMessagePublisher . Now a job can publish the next message itself (sending an email can publish a WebhookDispatch ), which is awkward in Hangfire and natural on a broker. What you give up: per-message inspection I'd be lying if I sold this as pure upside. Here's the real cost. \ Hangfire's dashboard let me open a single failed job and read the exact exception. RabbitMQ's management view gives me DLQ counts — how many messages are dead-lettered, not the full story of each one without going and reading the queue. If your debugging workflow leaned on opening one bad job at a time, plan for that gap before you cut over. I traded the pretty per-job screen for structured logs plus DLQ replay, and that was an adjustment worth naming up front. The takeaway A job-system migration feels risky because the jobs are load-bearing. The thin-consumer pattern de-risks it by refusing to touch the load-bearing part: logic stays put, transport changes, and you migrate one small diff at a time. Retry parity first, a DLQ per queue, a nullable column for safety — and you can move an entire app's background work without a scary weekend. \ \
View original source — Hacker Noon ↗


