Vibe Coding vs Spec Coding: Same Refund Feature, Built Twice
Vibe coding is intoxicating. You describe what you want in plain language, the AI writes the code, and ten minutes later you have a working endpoint. I was sold β until I shipped a refund feature that way and spent the next two weeks patching bugs that a 90-minute spec would have prevented entirely. This is the side-by-side, using the exact same requirement, so you can see where the gap opens up. The requirement An e-commerce platform needs an order refund feature. The PM's brief: Support full and partial refunds Call the payment gateway (Stripe-style) to reverse the charge Track refund status: pending, processing, succeeded, failed Support agents trigger refunds through an internal tool Simple enough. Both paths start here. Path A: vibe coding The prompt: "Build me an order refund API in Node.js. Support full and partial refunds. Call a payment gateway to reverse the charge. Track refund status. Use Express and Postgres." Sixty seconds later: a clean RefundController with createRefund and getRefundStatus . It validates the order exists, checks the amount against the order total, calls paymentGateway.refund() , saves the result. The code looks professional. The happy path works. Ship it. Bug #1: the double refund A support agent clicks refund, the page hangs for a second, they click again. Two refunds go through. No idempotency check. Fix prompt: "Add a check to prevent duplicate refunds for the same order." The AI adds a query: if a refund exists for this order, reject. Works β until it doesn't. Bug #2: partial refund overflow A $200 order. Support issues $50, then $80, then $100. Total refunded: $230. The duplicate check only catches exact duplicates, not cumulative amounts. Fix prompt: "Track cumulative refund amounts and reject refunds that would exceed the order total." The AI adds a SUM(amount) query β but it's not in a transaction with the insert, so two concurrent partials can both pass the check. Bug #3: gateway timeout The gateway times out. The refund row sits at processing forever. Support can't retry β the duplicate check blocks them. Did the money actually leave? Nobody knows. Fix prompt: "Add retry logic for gateway timeouts." The AI adds a retry loop: no exponential backoff, no idempotency key on the gateway call, no cap. The retry can now create a duplicate charge on the gateway side . Bug #4: the race condition Two agents process refunds for the same order simultaneously. Both pass the cumulative check (neither refund is committed yet), both hit the gateway, both succeed. The customer is refunded twice. Fix prompt: "Add lockingβ¦" Four patches in, each reasonable in isolation, and the architecture is a patchwork: no state machine, no documented invariants, no tests for how the patches interact. The real cost The first version took 10 minutes. The four patches took two weeks β investigation, testing, support escalations, and one manual reconciliation against gateway records. The "fast" approach wasn't fast. It front-loaded the dopamine and back-loaded the pain. Path B: spec coding Same requirement. Same AI. Different starting point β 90 minutes writing this before any code: # Feature: Order Refund Processing ## Goal Process refunds safely: no over-refund, no duplicate processing, correct gateway reconciliation. ## Non-Goals - Customer self-service refund portal (future phase) - Refund reason analytics - Automated approval rules ## State Machine pending β processing β succeeded pending β processing β failed β pending (retry) Only ONE refund may be "processing" per order at any time. ## Acceptance Criteria Given an order with total $200 and $0 previously refunded When a support agent requests a $50 refund Then a refund record is created with status "pending" And the gateway is called with an idempotency key And on gateway success, status moves to "succeeded" And the refundable balance is now $150. Given an order with total $200 and $150 already refunded When a support agent requests a $75 refund Then the request is rejected with "exceeds refundable balance" And no gateway call is made. Given a refund in "processing" state When another refund request arrives for the same order Then the request is rejected with "refund already in progress" And no gateway call is made. Given a refund in "processing" state When the gateway times out Then the status remains "processing" And a background job retries with exponential backoff And the retry uses the SAME idempotency key And after 3 failures, status moves to "failed" And an alert goes to the payments team. ## Edge Cases - Concurrency: SELECT FOR UPDATE on the order row before checking refundable balance - Idempotency: each refund attempt gets a UUID, passed to the gateway as the idempotency key - Precision: all amounts in cents (integer), no floats - Reconciliation: nightly job compares local records against the gateway settlement report ## Rollback Plan - Feature flag: refund_processing_v2 - Rollback disables new refunds; in-flight ones continue via th
Vibe coding is intoxicating. You describe what you want in plain language, the AI writes the code, and ten minutes later you have a working endpoint. I was sold β until I shipped a refund feature that way and spent the next two weeks patching bugs that a 90-minute spec would have prevented entirely. This is the side-by-side, using the exact same requirement, so you can see where the gap opens up. The requirement An e-commerce platform needs an order refund feature. The PM's brief: - Support full and partial refunds - Call the payment gateway (Stripe-style) to reverse the charge - Track refund status: pending, processing, succeeded, failed - Support agents trigger refunds through an internal tool Simple enough. Both paths start here. Path A: vibe coding The prompt: "Build me an order refund API in Node.js. Support full and partial refunds. Call a payment gateway to reverse the charge. Track refund status. Use Express and Postgres." Sixty seconds later: a clean RefundController with createRefund and getRefundStatus . It validates the order exists, checks the amount against the order total, calls paymentGateway.refund() , saves the result. The code looks professional. The happy path works. Ship it. Bug #1: the double refund A support agent clicks refund, the page hangs for a second, they click again. Two refunds go through. No idempotency check. Fix prompt: "Add a check to prevent duplicate refunds for the same order." The AI adds a query: if a refund exists for this order, reject. Works β until it doesn't. Bug #2: partial refund overflow A $200 order. Support issues $50, then $80, then $100. Total refunded: $230. The duplicate check only catches exact duplicates, not cumulative amounts. Fix prompt: "Track cumulative refund amounts and reject refunds that would exceed the order total." The AI adds a SUM(amount) query β but it's not in a transaction with the insert, so two concurrent partials can both pass the check. Bug #3: gateway timeout The gateway times out. The refund row sits at processing forever. Support can't retry β the duplicate check blocks them. Did the money actually leave? Nobody knows. Fix prompt: "Add retry logic for gateway timeouts." The AI adds a retry loop: no exponential backoff, no idempotency key on the gateway call, no cap. The retry can now create a duplicate charge on the gateway side. Bug #4: the race condition Two agents process refunds for the same order simultaneously. Both pass the cumulative check (neither refund is committed yet), both hit the gateway, both succeed. The customer is refunded twice. Fix prompt: "Add lockingβ¦" Four patches in, each reasonable in isolation, and the architecture is a patchwork: no state machine, no documented invariants, no tests for how the patches interact. The real cost The first version took 10 minutes. The four patches took two weeks β investigation, testing, support escalations, and one manual reconciliation against gateway records. The "fast" approach wasn't fast. It front-loaded the dopamine and back-loaded the pain. Path B: spec coding Same requirement. Same AI. Different starting point β 90 minutes writing this before any code: # Feature: Order Refund Processing ## Goal Process refunds safely: no over-refund, no duplicate processing, correct gateway reconciliation. ## Non-Goals - Customer self-service refund portal (future phase) - Refund reason analytics - Automated approval rules ## State Machine pending β processing β succeeded pending β processing β failed β pending (retry) Only ONE refund may be "processing" per order at any time. ## Acceptance Criteria Given an order with total $200 and $0 previously refunded When a support agent requests a $50 refund Then a refund record is created with status "pending" And the gateway is called with an idempotency key And on gateway success, status moves to "succeeded" And the refundable balance is now $150. Given an order with total $200 and $150 already refunded When a support agent requests a $75 refund Then the request is rejected with "exceeds refundable balance" And no gateway call is made. Given a refund in "processing" state When another refund request arrives for the same order Then the request is rejected with "refund already in progress" And no gateway call is made. Given a refund in "processing" state When the gateway times out Then the status remains "processing" And a background job retries with exponential backoff And the retry uses the SAME idempotency key And after 3 failures, status moves to "failed" And an alert goes to the payments team. ## Edge Cases - Concurrency: SELECT FOR UPDATE on the order row before checking refundable balance - Idempotency: each refund attempt gets a UUID, passed to the gateway as the idempotency key - Precision: all amounts in cents (integer), no floats - Reconciliation: nightly job compares local records against the gateway settlement report ## Rollback Plan - Feature flag: refund_processing_v2 - Rollback disables new refunds; in-flight ones continue via the background job - Additive schema only β no migration rollback needed Then the prompt: "Implement the refund feature described in this spec. Follow the state machine exactly. Use SELECT FOR UPDATE for concurrency control. Include the idempotency key in all gateway calls. All amounts in cents." [paste spec] The output is structurally different The AI generates, in the first version: - processRefund wrapped in a transaction withSELECT FOR UPDATE - Cumulative balance check inside the transaction β no race window - Idempotency UUID minted at creation, passed to every gateway call - Background retry with exponential backoff, capped at 3 attempts - State transitions that match the spec's machine exactly Every bug from Path A is pre-handled. Double refund? The lock plus the idempotency key. Overflow? Balance check in the same transaction as the insert. Timeout? Same-key retry that the gateway treats as safe. Same AI. Same capability. Dramatically different output β because the input was dramatically different. The AI didn't get smarter; it got better constraints. The honest math | Vibe coding | Spec coding | | |---|---|---| | Time to first version | 10 min | ~2.5 hours | | Production bugs | 4 (one involving real money) | 0 in this scenario | | Total time to stable | ~2 weeks | ~half a day | Vibe coding is great for prototypes, internal tools, and anything where a bug costs you a shrug. The moment money, state machines, or concurrency enter the picture, the 90 minutes you "save" by skipping the spec gets repaid at loan-shark interest. Adapted from the full case study on Spec Coding. The site maintains free spec templates and a browser-based spec packet generator for exactly this workflow. Top comments (0)
Comments
No comments yet. Start the discussion.