<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: EzeFrancis</title>
    <description>The latest articles on DEV Community by EzeFrancis (@theyclonedbjorn).</description>
    <link>https://dev.to/theyclonedbjorn</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1103917%2F7da6f386-f684-4511-a4a6-58ea6b45b949.jpeg</url>
      <title>DEV Community: EzeFrancis</title>
      <link>https://dev.to/theyclonedbjorn</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/theyclonedbjorn"/>
    <language>en</language>
    <item>
      <title>Six decisions behind a cross-border transfer engine</title>
      <dc:creator>EzeFrancis</dc:creator>
      <pubDate>Mon, 02 Nov 2026 19:24:47 +0000</pubDate>
      <link>https://dev.to/theyclonedbjorn/six-decisions-behind-a-cross-border-transfer-engine-4d14</link>
      <guid>https://dev.to/theyclonedbjorn/six-decisions-behind-a-cross-border-transfer-engine-4d14</guid>
      <description>&lt;p&gt;I built a backend that moves money between currencies. The product is deliberately small: two currencies (NGN and CAD), mocked FX and webhook providers, and a handful of endpoints. The point was the layer underneath, where payment software fails: rounding errors, balances that drift from their history, retries that double-send, and customers who never know the money arrived.&lt;/p&gt;

&lt;p&gt;This post covers six decisions I would defend in a design review, what each one costs, and what went wrong along the way. The code and the README are &lt;a href="https://github.com/BjornOnGit/transfer-engine" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Money is never a float
&lt;/h2&gt;

&lt;p&gt;In JavaScript, 0.1 + 0.2 is 0.30000000000000004. In a ledger, that bug compounds. So amounts travel as strings, all arithmetic goes through one small module built on decimal.js, and the database stores them as &lt;code&gt;NUMERIC&lt;/code&gt;. A unit test fails against a float implementation, which is how I know the rule is enforced and not just intended.&lt;/p&gt;

&lt;p&gt;The cost is ceremony: nobody gets to write a + b on money. I think that is the right price, because funnelling every calculation through one module is the only way a rule like this survives a team.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. An append-only, double-entry ledger
&lt;/h2&gt;

&lt;p&gt;Every movement of money is a set of ledger entries that net to zero. Entries are never updated or deleted; a correction is a new, offsetting entry. That makes the system auditable by construction, and any balance can be rebuilt by replaying entries.&lt;/p&gt;

&lt;p&gt;I also keep a cached balance on each account, updated in the same database transaction as the entries, because summing a growing table on every request is a poor trade. The cost is that it is a second copy of the truth. A scheduled job that compares the cache with the ledger is the first thing on my “next” list, and the README says so plainly.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr63hr2nlhnjuv9r3cp02.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr63hr2nlhnjuv9r3cp02.png" alt="SQL query snippet 1" width="800" height="430"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flrsthi8rsz75wcf7tqm3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flrsthi8rsz75wcf7tqm3.png" alt="SQL query snippet 2" width="800" height="430"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Cross-currency transfers need four entries, not two
&lt;/h2&gt;

&lt;p&gt;Sending 500 NGN to a CAD account is not one debit and one credit. The two sides are different currencies and different amounts. I use a pool account per currency as the counterparty:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Figjhg9wtyazmjcyx2794.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Figjhg9wtyazmjcyx2794.png" alt="currency pool pair table" width="336" height="194"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The first two entries net to zero in NGN and the last two net to zero in CAD, so each currency balances on its own. The 0.46 is 500 multiplied by the rate locked when the transfer was created, rounded to two decimals. That rate is tied to the transfer with a unique constraint, so it can never be re-locked: the price shown is the price charged.&lt;/p&gt;

&lt;p&gt;The simpler option was a single debit and credit with a conversion note on the side. I rejected it because then no currency’s ledger actually balances. The cost is real: a pool’s balance goes negative in one currency and positive in the other, which represents the platform’s open position, and there is no liquidity management on top of it yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Lock the rows, then prove the lock works
&lt;/h2&gt;

&lt;p&gt;Two requests try to spend the same 100 at the same moment. Both read the balance, both see enough money, both proceed. The fix is pessimistic locking: inside the ledger transaction, I lock every account involved with &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt; before checking the balance, so the second request waits and then sees the true balance. Locks are always taken in account-id order, which prevents a deadlock when A is paying B while B is paying A.&lt;/p&gt;

&lt;p&gt;I chose pessimistic locking over optimistic retries because correctness matters more than throughput on a hot account, and because the failure mode is simple to reason about. The cost is that a very busy account becomes a serialization point.&lt;/p&gt;

&lt;p&gt;The part I care about most is the test. Two transfers of 60 against a balance of 100: exactly one must succeed. Five transfers of 30: exactly three. Then I deleted the lock and watched both go red. A test you have never seen fail has not proven anything.&lt;/p&gt;

&lt;p&gt;One accident helped. My database was on Neon and my Redis on Redis Cloud, about 190 milliseconds per round trip. That made every transaction slow and every race wide, which is exactly what you want when you are trying to catch one.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvalrctu20qeemi6hktuc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvalrctu20qeemi6hktuc.png" alt="concurrency failed" width="800" height="381"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq7seyle504kez2ik1qi8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq7seyle504kez2ik1qi8.png" alt="concurrency passed" width="800" height="176"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Idempotency has two layers
&lt;/h2&gt;

&lt;p&gt;Clients retry and networks time out, and a retried “send money” request must not send money twice. Each transfer request carries an &lt;code&gt;Idempotency-Key&lt;/code&gt;. A short Redis lock ( &lt;code&gt;SET NX&lt;/code&gt; with a 30-second expiry) ensures only one request with that key is processed at a time. A Postgres record stores the response. A concurrent duplicate waits for the first to finish and gets the identical response; a later duplicate gets the stored response straight away, and the same key with a different payload is rejected.&lt;/p&gt;

&lt;p&gt;Two details worth pointing out. Keys are namespaced by user, so one customer cannot collide with another. And I store &lt;code&gt;4xx&lt;/code&gt; responses but not &lt;code&gt;5xx&lt;/code&gt; ones: a &lt;code&gt;5xx&lt;/code&gt; means “we don’t know what happened,” and the client should be able to retry. The cost is that stored keys have no expiry job yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Notifications are data, not fire-and-forget
&lt;/h2&gt;

&lt;p&gt;“Money sent, no alert received” is a support ticket waiting to happen. So each notification is a row with a status, an attempt count, and the last error, created before the job is queued. A worker sends it. On failure, it retries with exponential backoff (1, 2, 4, and 8 seconds, five attempts), and after the last attempt the row becomes &lt;code&gt;dead_letter&lt;/code&gt;, and the job stays in BullMQ’s failed set for manual follow-up. “Did they get notified?” becomes a query instead of an escalation.&lt;/p&gt;

&lt;p&gt;Two consequences. Delivery is at least once, so receivers should deduplicate. And a notification failure never fails the transfer, because by then the money has already moved: failing the request would invite a retry of something that already happened.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxwk6parw5m2w3q9u1p5w.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxwk6parw5m2w3q9u1p5w.jpg" alt="notification body" width="799" height="170"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq769kuu3klwwm3xvr2kg.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq769kuu3klwwm3xvr2kg.jpg" alt="notification test" width="799" height="170"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it runs
&lt;/h2&gt;

&lt;p&gt;During development, the code runs against Neon (PostgreSQL) and Redis Cloud. The target deployment is one EC2 instance in a public subnet, with RDS PostgreSQL and ElastiCache Redis in private subnets, each reachable only from the instance’s security group. BullMQ is a library: its queues live in Redis, and the worker is simply a second process next to the API.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2a8ovz7x832wgi5q56xm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2a8ovz7x832wgi5q56xm.png" alt="Infra diagram" width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Smaller calls that mattered
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The state machine is enforced twice: a pure module in the code and a &lt;code&gt;CHECK&lt;/code&gt; constraint in the database. Status changes are compare-and-set, and the &lt;code&gt;funds_moved&lt;/code&gt; update happens inside the ledger transaction, so money and status cannot disagree.&lt;/li&gt;
&lt;li&gt;Redis holds only what can be recomputed: the rate cache, short locks, and queue state. If it vanished, no financial record would be lost.&lt;/li&gt;
&lt;li&gt;Plain SQL through pg instead of an ORM, because transactions, locks, and constraints should be visible in code review.&lt;/li&gt;
&lt;li&gt;Tests run against real PostgreSQL and Redis, with failure injection: one test makes the ledger throw halfway and checks that nothing persisted.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What went wrong during development
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;I seeded demo balances straight into the cached column, so they have no ledger entries behind them and cannot reconcile. I caught it while writing the balance-reconstruction function. The lifecycle test now funds its account through the ledger, and a real deposit flow is on the list.&lt;/li&gt;
&lt;li&gt;A timing test failed because 190-millisecond round trips swamped a 200-millisecond retry delay. I fixed it by making the delay dominate the noise instead of loosening the assertion.&lt;/li&gt;
&lt;li&gt;A test teardown deleted rows in the wrong order, hit a foreign key, and hung the whole run because the connections never closed. Teardown now closes everything in a finally block.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I would build next
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;A reconciliation job that checks every cached balance against the ledger and alerts on drift.&lt;/li&gt;
&lt;li&gt;A transactional outbox for notifications, which closes the window where Redis is down and a delivery row exists with no queued job.&lt;/li&gt;
&lt;li&gt;A recovery job for transfers left between states by a crash.&lt;/li&gt;
&lt;li&gt;Quote-then-confirm FX, where the stored rate expiry finally gets enforced.&lt;/li&gt;
&lt;li&gt;A real FX provider with a spread model.&lt;/li&gt;
&lt;li&gt;A per-attempt notification log, signed webhooks, and real push and SMS providers.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The full README, with the diagrams and every trade-off above, is &lt;a href="https://github.com/BjornOnGit/transfer-engine" rel="noopener noreferrer"&gt;here&lt;/a&gt;. 73 tests pass. If you had decided any of these differently, I would like to hear why.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>backend</category>
      <category>fintech</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Building a Delivery Dispatch Backend: The Decisions That Actually Mattered</title>
      <dc:creator>EzeFrancis</dc:creator>
      <pubDate>Mon, 02 Nov 2026 14:53:22 +0000</pubDate>
      <link>https://dev.to/theyclonedbjorn/building-a-delivery-dispatch-backend-the-decisions-that-actually-mattered-35ao</link>
      <guid>https://dev.to/theyclonedbjorn/building-a-delivery-dispatch-backend-the-decisions-that-actually-mattered-35ao</guid>
      <description>&lt;p&gt;Most backend portfolio projects are CRUD with extra steps. I wanted to build something with actual teeth, a system where the interesting work isn’t the endpoints but the handful of places where a naive implementation would quietly break under real conditions.&lt;/p&gt;

&lt;p&gt;So I built a scaled-down version of what sits behind an app like Uber Eats or Chowdeck: a backend that coordinates customers, vendors, and riders through the full order lifecycle, live rider matching, atomic assignment under concurrency, idempotent payment webhooks, and automatic reassignment when nobody’s available.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffo197xo9egq1y8u9ctwl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffo197xo9egq1y8u9ctwl.png" alt="Dispatch backend architecture" width="799" height="327"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This article isn’t a tour of every endpoint. It’s a discussion of questions I had to answer with code and why I answered them the way I did.&lt;/p&gt;

&lt;h2&gt;
  
  
  “What happens when two orders want the same rider at the same instant?”
&lt;/h2&gt;

&lt;p&gt;This is the core of the whole dispatch problem. A customer’s order gets accepted by a vendor, and the system needs to find the nearest available rider and lock them in. Still, if two vendors accept orders within milliseconds of each other, and the pool of available riders near both is the same three people, you will get a collision eventually.&lt;/p&gt;

&lt;p&gt;The naive fix would be to “check if the rider is available, then assign them.” But that has a race condition baked directly into its two steps. Both requests can pass the check before either one writes.&lt;/p&gt;

&lt;p&gt;I used two atomic Redis operations instead of application-level locking:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SET order:lock:{orderId} riderId NX EX 10&lt;/code&gt;: an atomic compare-and-set with a self-expiring TTL, so a crashed process doesn’t leave an order permanently locked.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SREM riders:available riderId&lt;/code&gt;: Redis’s set-remove command returns exactly how many elements it removed. Only the caller that gets &lt;code&gt;1&lt;/code&gt; back actually claimed the rider. Everyone else, even if they got there “at the same time” from the application’s perspective, gets &lt;code&gt;0&lt;/code&gt; and moves to the next candidate.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwvjf0dp6cwpiwqhka4zj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwvjf0dp6cwpiwqhka4zj.png" alt="rider availability test snippet" width="622" height="114"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  “What happens when a payment provider retries a webhook?”
&lt;/h2&gt;

&lt;p&gt;Payment providers don’t trust a single delivery attempt. If they don’t get a fast &lt;code&gt;200&lt;/code&gt; response back, they resend, sometimes more than once. Any webhook handler that isn’t explicitly idempotent will eventually double-process a payment.&lt;/p&gt;

&lt;p&gt;I handle this by inserting the event’s &lt;code&gt;event_id&lt;/code&gt; into a table with a &lt;code&gt;UNIQUE&lt;/code&gt; constraint inside &lt;em&gt;the same database transaction&lt;/em&gt; as the status update. A duplicate delivery hits that constraint; the transaction rolls back cleanly, and the response is still a &lt;code&gt;200&lt;/code&gt; (so the provider stops retrying), so it just reports &lt;code&gt;duplicate: true&lt;/code&gt; instead of reprocessing.&lt;/p&gt;

&lt;p&gt;This is a different idempotency mechanism from the one on order creation, which uses a client-supplied &lt;code&gt;Idempotency-Key&lt;/code&gt; cached in Redis. That’s deliberate: order creation is client-driven (the client decides what counts as “the same request,” useful when it can’t tell if a timed-out request actually succeeded), while the webhook is server-driven (the event’s own identity is authoritative, and the guarantee has to survive concurrent delivery, which a Redis cache with a TTL doesn’t guarantee as strongly as a database constraint plus a row lock). Using one pattern everywhere would have been simpler to write and wrong for at least one of the two cases.&lt;/p&gt;

&lt;h2&gt;
  
  
  “Why mock the payment gateway?”
&lt;/h2&gt;

&lt;p&gt;I mocked the payment provider rather than integrating a real sandbox (Paystack, Flutterwave), and I want to be upfront about why, because it’s a deliberate trade-off, not a shortcut.&lt;/p&gt;

&lt;p&gt;The engineering that actually matters here, which is the HMAC signature validation, the idempotent processing logic above, and the payment state machine, is identical regardless of which provider sits behind it. A real integration would have meant a provider account, API keys, and a public tunnel just to receive callbacks during development. None of that demonstrates additional backend design skill; it’s setup friction. Swapping in a real provider later touches exactly two functions, initiation and the webhook handler, because the transaction logic and schema were built provider-agnostic from the start.&lt;/p&gt;

&lt;h2&gt;
  
  
  “What happens when no rider is available at all?”
&lt;/h2&gt;

&lt;p&gt;This is the failure mode that’s easy to leave as an afterthought. If dispatch can’t find anyone, the order can’t just sit in limbo forever.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd1snwuy8myj8yfdzath7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd1snwuy8myj8yfdzath7.png" alt="rider reassignment worker" width="800" height="246"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When a vendor accepts an order, a delayed timeout job gets scheduled alongside the immediate dispatch attempt. If dispatch doesn’t find anyone, the timeout fires later and hands the order to a background worker that retries matching with a fixed backoff. If every retry is exhausted and still nobody’s available, the system writes a notification for both the customer and the vendor, telling them to cancel instead of leaving the order stuck with no visibility into why.&lt;/p&gt;

&lt;p&gt;Keeping this as a separate background worker, rather than retrying inline on the request, matters for a boring but important reason: a slow reassignment retry should never block a customer’s “place order” request. The two run as entirely separate processes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I’d do differently with more time
&lt;/h2&gt;

&lt;p&gt;Being specific about scope boundaries is part of showing the work, not hiding gaps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A real payment gateway, once there’s an actual need to move money&lt;/li&gt;
&lt;li&gt;Routing-API-based ETAs, since the current matching is straight-line distance, not drive time&lt;/li&gt;
&lt;li&gt;Push notifications instead of the current DB-polled notification table&lt;/li&gt;
&lt;li&gt;Rate limiting on auth and the webhook endpoint&lt;/li&gt;
&lt;li&gt;Structured, correlated logging across the API and both workers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Full architecture diagrams, the AWS deployment shape, and the complete decision log are in the project’s README &lt;a href="https://github.com/BjornOnGit/dispatch-backend" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>backend</category>
      <category>softwareengineering</category>
      <category>systemdesign</category>
    </item>
  </channel>
</rss>
