DEV Community

Cover image for From Idea to Production: How I Built a Decoupled Chatbot Ordering Engine
Dillibe Chisom Okorie
Dillibe Chisom Okorie

Posted on

From Idea to Production: How I Built a Decoupled Chatbot Ordering Engine

Micro-merchants on Instagram and WhatsApp lose sales every day because they can't reply fast enough and they can't afford a full e-commerce setup.

So I built Byte-to-Bite: a conversational food-ordering engine where customers browse a menu, build a cart, and pay securely — all inside a chat window. No login wall. No heavy frontend. Just architecture.

Here's the full breakdown using the STAR pattern.


Situation: The problem with micro-commerce

Small vendors operating on Instagram and WhatsApp are doing real business but they're bottlenecked by manual replies. A customer DMs asking for a menu, waits, builds an order in a back-and-forth thread, then sends payment to a personal account.

The goal was a headless, automated agent. Customers get a live menu, a real cart, a custom invoice, and a secure checkout, without ever touching a login form.


Task: Three hard architecture problems

  1. Stateless HTTP forgets you. The backend had to act as a robust Finite State Machine (FSM) to track exactly where each user was in the checkout pipeline across requests.
  2. Cross-layer identity. Data had to flow cleanly between Next.js, an Express API, and Paystack without dropping the tracking thread.
  3. Concurrent isolation. Multiple shoppers had to build independent carts simultaneously with zero data leakage between sessions.

Action: How the three layers talk to each other

1. The Frontend Passport

On first load, the browser generates a UUID via the Web Crypto API:

const deviceId = window.crypto.randomUUID();
localStorage.setItem('x-device-id', deviceId);
Enter fullscreen mode Exit fullscreen mode

Every outgoing Axios request injects this as a custom header:

axios.defaults.headers.common['x-device-id'] = localStorage.getItem('x-device-id');
Enter fullscreen mode Exit fullscreen mode

No cookies. No registration. The client is its own passport.

2. The Headless Brain (FSM in TypeScript)

The backend reads the incoming device ID, pulls the user's MongoDB session, and routes the request through an FSM using strict union types:

type UserState = 'IDLE' | 'CHOOSING_MENU' | 'AWAITING_PAYMENT';
Enter fullscreen mode Exit fullscreen mode

This eliminates runtime string typos and makes invalid state transitions impossible at compile time. Each state maps to a dedicated controller function — clean, modular, testable.

3. The Payment Bridge (Paystack + EventBus)

When a user types PAY, the backend compiles a checkout payload using environment variable abstraction:

const callbackUrl = process.env.PAYSTACK_CALLBACK_URL;
Enter fullscreen mode Exit fullscreen mode

The device ID gets tucked into Paystack's transaction metadata. When payment clears, Paystack fires a server-to-server webhook. The backend verifies the HMAC signature, then fires an internal eventBus emission:

eventBus.emit('payment:confirmed', { deviceId });
// → resets MongoDB user state to 'IDLE'
Enter fullscreen mode Exit fullscreen mode

Result: What production looks like

  • Concurrent isolation: stress-tested at 50 simultaneous sessions — zero data leakage
  • Environment parity: zero manual code changes between local dev and production

I chose to explicitly extends Document on the cart interface rather than using InferSchemaType. It's more verbose, but the explicitness of knowing exactly what methods are available on each document was worth the tradeoff for me. Curious whether others have a strong opinion here.


👉 Test the Live Demo Here (Running in Paystack Test Mode — feel free to use the dummy test cards to complete an order!)

💻 GitHub Source Code

What's next

Implementing Cron jobs to clear pending transactions after 24 hours.

When you build payment loops, do you lean on webhook-driven event buses, short polling, or WebSockets? I'd genuinely like to know — there are real tradeoffs I'm still thinking through.

Top comments (1)

Collapse
 
harjjotsinghh profile image
Harjot Singh

Decoupling the ordering engine from the chatbot is the right call and it's the part people skip - they wire the LLM straight into order state and end up with a system where a bad parse mutates real orders. Treating the chatbot as just an intent/NLU front-end and keeping the ordering engine as a deterministic service it talks to (validated commands, not raw model output) is what makes it production-safe. The model proposes "add 2 large pizzas"; the engine validates and commits. That boundary is the whole ballgame.

The "idea to production" framing is what caught me, because that last mile (the engine being deployable, the state being durable, the integration glue actually wired) is where most chatbot demos die before they're real products. That gap is exactly what I work on with Moonshift - a multi-agent pipeline that takes a prompt all the way to a deployed SaaS on your own GitHub + Vercel, with the boring-but-critical parts (state, auth, deploy) wired as verified defaults and a verify layer gating each step, same propose-then-validate boundary you used between the bot and the engine. Multi-model routing keeps a full build ~$3 flat, first run's free no card. Nice architecture. How are you handling a misparsed order at the boundary - hard validation reject, or a confirm-back step before the engine commits?