Eudoro — engineering case study by Kaleem Ahmed

A multi-vendor marketplace for personalized products, where the customization requirements are part of checkout instead of a chat thread.

Last updated 2026-08-19.

Built with TypeScript, Express, Next.js 15, MongoDB, Prisma, RabbitMQ, Redis, Docker, AWS.

Overview

Eudoro is a multi-vendor marketplace for personalized products. Items that do not exist until a buyer supplies the thing that makes them personal: a photo, a name, a date, an engraving, a set of colours.

Structurally it is a commerce platform. Buyers, sellers, shops, catalogues, carts, payments, shipments, wallets, payouts, admin oversight. That part is familiar.

What makes it a different product is the customization engine. On an ordinary marketplace a product is a fixed SKU and the only variables are quantity and maybe size. On Eudoro a product carries a seller-defined schema of customization fields, and a purchase is not valid until the buyer has satisfied that schema. The customization data travels with the order all the way to the seller's production queue and into the order's permanent history.

Everything else in the system exists to make that one flow work end to end without anyone falling back to WhatsApp.

The whole product comes out of one observation, which the Problem tab tells properly: in this trade, the person with the least information is the one who has to start every conversation. Eudoro turns that around. The seller writes down once what a product needs, and the platform does the asking, every time, for free.

It is a marketplace rather than a single-seller store because the problem is the seller's problem, and there are a lot of sellers with it. Multi-vendor is also what forces most of the actual engineering. A cart can hold items from several shops, so one payment has to fan out into per-seller obligations. Money cannot go straight to the seller, because the platform has to take it, hold it, split it, track a commission and release it on a condition. Almost everything worth talking about technically, meaning order splitting, the wallet, payouts and seller verification, is downstream of that one decision.

Problem

I found this problem by being on the wrong end of it. I wanted to buy something customized, and the way you do that is you find the artist on Instagram and open a DM. That chat window turned out to be the entire commerce system.

It went the way it always goes. I send a photo. A day later they tell me the resolution is too low and ask for a better one. I send the name I want on it. They ask how it should be spelled, what colour, where on the product, and whether I want it before a particular date. Four exchanges in and we have not talked about money yet.

What stuck with me was not that it was slow. It was noticing that I could not have moved any faster than I did, because I had no way of knowing what they needed until they asked me. Every single round trip had to be started by them. And they were not doing this once, they were doing it with every customer at the same time, in an app that has no concept of an order.

That is the whole problem in one sentence: the person with the least information is the one who has to start every conversation. Everything technical in this project is a consequence of moving that job from the seller to a schema.

Once you see it from their side it gets worse. Their day is a chat app with forty open threads, and each thread is an order in some undefined state. Somewhere in there is a photo that belongs to a mug and a spelling that belongs to a keychain, and the only index is scrolling. Money arrives as a UPI notification with a name that may or may not match the person in the thread. Then the address gets typed into Shiprocket by hand, one character at a time, for every order.

None of that is the work. The work is making the thing. All of it is overhead that pulls them away from the craft, and here is the part that actually bothered me: it scales linearly with success. Ten orders a week is manageable. Forty is not, and forty is what happens when they get good. Their own workflow punishes them for growing, so the ceiling on their business is not demand or skill, it is how many DM threads one person can hold in their head.

And none of the tools involved know about each other. There is no system here, just a chain of apps with a human copying between them.

Instagram (discovery)
   -> WhatsApp / DM (requirements gathering, negotiation)
      -> UPI / payment link (money, with no link to the conversation)
         -> notebook or memory (order management)
            -> production
               -> Shiprocket, entered by hand (shipping)
                  -> DM again (tracking updates)

Every arrow in that chain is a manual copy-paste by the seller, and every box has no memory of the previous one. The photo in step two does not travel to step five. The payment in step three does not know which conversation it belongs to. When the buyer asks "where is my order", the only place that answer exists is in a person's memory.

Seller problemWhat it actually costs
Requirements arrive incompleteMultiple round trips per order before work can start
No structure to customization dataSeller must remember which asset belongs to which buyer
No order recordNo history, no status, no way to answer "where is my order"
Payment disconnected from orderReconciling who paid for what is manual
Manual shipping entryAddress re-typed into Shiprocket per order, transcription errors
Overhead scales with successMore orders means proportionally more admin, not more craft time
Buyer problemWhat it actually costs
Does not know what to provideCannot self-serve, waits on the seller to ask
Advance payment to a strangerPure trust, no escrow, no recourse
No order state"Is it made yet?" is a question you have to ask a human
Sends personal photos over DMNo sense of where that data goes
No unified experienceEach seller's process is different and improvised

The buyer's half is worth sitting with too, because it is the reason the money design turned out the way it did. You are sending a photograph of someone you love to a stranger's phone number, then transferring money to them in advance, with nothing in return but a promise in a chat window. There is no escrow, no order page, no way to check. If it goes wrong your only recourse is to keep messaging the same person who has your money.

Most people do it anyway, because the work is good and they want the thing. But that trust is being extended by the buyer every single time, and nothing in the workflow earns it. That is what the wallet and the delivery-gated payout exist to fix, and it is why they are not a nice-to-have feature bolted on the side.

Solution

The transformation is one sentence: the marketplace holds the state that used to live in a chat log and in the seller's head.

BEFORE   Instagram -> WhatsApp -> payment link -> memory -> Shiprocket by hand

AFTER    one marketplace that holds the state previously kept
         in a chat log and in the seller's head

That sounds abstract, so the rest of this is the long version: what actually got built, for whom, and which conversation each piece deletes. It is written as three journeys, because that is how it was built. The buyer's path, the seller's path, and the admin's path all had to work before any of it was worth anything.

What follows

Every other feature on this page is downstream of one idea, so it is worth being precise about it before anything else.

On a normal store, a product is a fixed thing. A mug is a mug. The only questions are how many and which size, and those are dropdowns someone decided on years ago. On Eudoro a product carries a schema that its own seller wrote, and a purchase is not valid until a buyer has satisfied it.

SELLER writes the schema            BUYER fills it in

products                            order_item_customization
  customizationFields[]      <-->     fieldKey  ("photo")
    fieldKey  "photo"                 value     <the upload>
    fieldType image
    required  true
    rules     min resolution
    sequence  1
    priceModifier  -

fieldKey is the join. That is the entire mechanism, and it is the reason this is a different product rather than another storefront. The seller is not choosing from a list of customization options I thought of. They are describing their own product, in their own terms, and the platform enforces whatever they describe.

The field types cover what this trade actually needs. Text for a name, a quote, a message, a dedication. Image for the photo the whole gift is built around. Colour for a finish. File for anything else. And boolean for the things that are a yes or no with a price attached, like gift wrapping. Each field carries whether it is required, its validation rules, the order it appears in the form, and optionally a price modifier, so a wrapped mug costs more than an unwrapped one without the seller maintaining two products.

Building it this way was the decision that made everything else harder and the product better. A fixed set of customization options would have been a week of work. A seller-authored schema meant the validation had to be generic, the cart had to handle identity it could not predict, and the database had to hold shapes I would never see. It also meant a seller can sell something I never imagined.

One more detail that only matters once you think about fraud. Validation runs in two places: live over HTTP while the buyer fills the form, so the error appears where the mistake was made, and again over RabbitMQ during checkout re-validation, so a browser tab left open for two days cannot smuggle a stale or edited submission past the payment step.

The buyer's job used to be to open a DM and wait to be told what was needed. Now it is to fill in a form that already knows.

Finding something worth buying

Shops, search, filtering and category navigation, with shop and product pages server-rendered so a seller's work is actually indexable. Reviews sit on the shop, so a buyer deciding whether to trust a stranger with a photograph of their family has something to go on besides a follower count. Wishlist and offers for the ones they are not ready to buy yet.

The form that replaces the DM thread

The product page shows exactly what this item needs, in the order the seller chose, with their own instructions attached. Upload the photo. Type the name, the quote, the message. Choose whether it is a gift, whether it should be wrapped, whether a letter goes in the box. Every field is validated as it is filled, against rules the seller wrote, so nobody finds out two days later that the image was too small.

A cart that understands two of the same thing are not the same thing

Adding the same mug twice with different names on it produces two lines, because they are two different physical objects that have to be made separately. Adding it twice with the same name merges into one line with quantity two. The identity is derived from the product, the selected options and the customization payload together, with the keys sorted first so it does not matter what order the form was filled in.

A checkout that does not trust the browser

At checkout the server throws away every price-relevant field the client sent and recomputes: real current price, real stock, real commission, and the coupon's actual value looked up from the code rather than the discount amount the client claims. Editing the cart in devtools changes what you see and nothing about what you are charged.

Paying a stranger, safely

Payment goes through Razorpay for a server-computed amount. The platform holds the money. The seller does not get it when you pay, they get it after the order is confirmed delivered. That hold is the single thing that makes buying from an artist you found on the internet a reasonable act.

Knowing where the order is without asking anyone

An order page with a real status that changes because an event changed it. Carrier tracking webhooks flow back into the order, and notifications go out on the transitions that matter. "Is it made yet" stops being a question you ask a human.

Talking to the seller when you genuinely need to

Real-time chat, scoped to the shop and the order, so the conversation has context instead of being a bare DM. Conversations close themselves after a grace period. The point was never to remove the human contact, it was to remove the forty messages that were only ever there to collect data a form could have collected.

There is also a small recognition layer, badges on the buyer side, which is the least technically interesting thing here and one of the more effective. People come back to a place that acknowledges they were there.

This is the side the product was actually built for, and it is where most of the work went.

Getting in the door

Describing a product properly, once

An order that arrives ready to make

Accept, and the logistics happen without you

Getting paid, which is the part I rewrote the most

Running the business

Order analytics on their own sales, chat with their buyers scoped per order, and notifications on the events that need a human: a new order, a delivery, a payout moving.

Payout approval is deliberately manual for this first version. A human at the platform looks at each one before money leaves. That is slower than it could be, and for an MVP moving real money between strangers it is the correct trade: the automation I would need to trust is the automation I have not proven yet.

A two-sided marketplace has a third side, and pretending otherwise is how you end up with a fraud problem and no tooling to see it. The admin console is the least glamorous of the three apps and the one that makes the other two safe.

Deciding who gets to sell

Seller verification with GST checks. An account can register on its own, but it cannot take a stranger's money until a person has approved it.

Approving money before it moves

Every payout passes through an admin. Approve it, reject it with a reason, or mark it failed and let it stay retryable. Each transition writes an audit record, so the question "who released this and when" always has an answer.

Moderation and oversight

Platform-wide visibility across sellers, shops, products and orders, so a bad listing, a disputed order or a seller behaving badly is something a person can actually see and act on rather than something that surfaces when a buyer complains publicly.

Alerts for the things that should be impossible

A scheduled reconciliation job asserts states that cannot exist under correct behaviour, such as a locked balance exceeding a total balance, or money sitting too long in a pre-payout state. When it finds one it alerts an admin. Correctness on the money paths is monitored rather than assumed.

The platform's own books

Commission and platform earnings recorded per order as its own concern, so what the platform made is a record rather than a calculation someone runs at the end of the month.

Three of the things above are three separate applications, and that is a decision rather than an accident. The storefront is public, needs to be indexed, and has to be fast for someone who has never visited before. The seller dashboard is dense, private, and should never appear in a search result. The admin console is an internal tool with a third set of concerns again. Building one app with role switching would mean every page carrying the union of all three, and it would mean shipping admin code to every buyer's browser and relying on a conditional to hide it.

Server state and client state are not the same thing

React Query holds everything that lives on the server: products, orders, shops, payout history. The hard problems there are caching, invalidation and refetching, not storing values. Zustand holds the state that genuinely is not server state, chiefly the cart, which exists in the browser long before it reaches the server and is read from several places at once.

Server components take the short route

A client component has to call the public domain, because a browser cannot resolve a Docker service name. A server component rendering inside the network can call the gateway directly. Using the public URL there would send the request out to the internet, through Nginx and TLS, and back to a service that was two hops away the whole time.

The cart identity code

Sorting the customization keys before serialising them into the cart item ID is one line and it is load-bearing. JavaScript object key order follows insertion order, so without it the same customization typed in a different sequence produces a different identity, and the buyer stares at two identical lines in their cart wondering what went wrong.

None of this makes the seller better at their craft. That was never the point. The measure I care about is whether the fortieth order in a week costs them the same amount of admin as the fourth, because that is the thing the old workflow could never do, and it is why their ceiling used to be how many chat threads one person could hold in their head.

This took more than eight months, alone. Most of that was not writing features. It was the parts that only matter when they are wrong: making the money paths safe to run twice, working out what a cart item's identity actually is, deciding what the platform should refuse to do automatically. The features above are the visible half.

Features

The Solution tab tells this as a story. This one is the reference: what exists, grouped, with the mechanic rather than the headline. If a line sounds like it is doing something specific, it is.

Marketplace and discovery

Customization engine

Cart and pricing

Discounts and coupons

Payments

Orders

Shipping and delivery

Wallet and payouts

Admin, verification and moderation

Communication and notifications

Authentication and accounts

Platform, observability and reliability

Partially implemented

Planned

My Role

I designed and built the backend architecture and worked across the full stack. Solo, from first commit to deploy.

This was built to design and operate a system with real production concerns, not to demonstrate a framework. Every service, every frontend, the schema, the infrastructure and the deployment are mine.

AreaWhat I built
System architectureService decomposition and boundaries, ownership rules, the synchronous and asynchronous split, and the event-driven order pipeline.
BackendTwelve Express services in an Nx and pnpm monorepo: gateway, auth, seller, product, order, payment, delivery, notification, chat, payout-wallet and admin, plus shared packages for middleware, messaging, error handling, metrics and database access.
FrontendThree Next.js 15 App Router apps (buyer storefront, seller dashboard, admin console) with React Query for server state and Zustand for client state.
DatabaseA 29-model Prisma schema on MongoDB, including the customization template and submission models the product is built around.
EventsRabbitMQ chains for order creation, fulfilment, notifications and wallet operations, with idempotent consumers and retry handling on every money path.
PaymentsRazorpay, server-authoritative pricing, HMAC webhook verification, refunds, and the payment-to-order event boundary.
ShippingShiprocket behind a circuit breaker, with shipment creation, AWB generation and webhook-driven tracking.
MoneyThe seller ledger, its concurrency control, the payout state machine, and scheduled reconciliation.
AuthOTP flows, cookie sessions, role-based access control, and internal service-to-service authentication.
Real-timeSocket.IO chat with conversation lifecycle management.
InfrastructureDocker Compose, Nginx reverse proxy and TLS, deployment to a single EC2 instance with tiered per-service resource limits.
ObservabilityPrometheus metrics, Grafana dashboards, Loki log aggregation, correlation ID propagation.
Testing78 test files, Playwright UI smoke specs, and purpose-built integration, event and money-reconciliation check scripts.

It did not start in the shape it is in now. The honest evolution is worth stating, because the interesting decisions are the ones that changed.

1. Monolith-shaped start

A single Prisma schema and a shared database client. Traces are still visible: the legacy root prisma singleton still exists alongside the newer per-service client factory.

2. Split into services along domain lines

Auth, product, order, payment and the rest became separate Express applications behind a gateway.

3. Synchronous order creation became asynchronous

Originally the checkout request created the order inline. It now happens on the payment.succeeded event. This is the single most important architectural change in the project.

4. A dedicated logger-service was built, then deleted

Replaced by Prometheus, Grafana, Loki and Promtail. Deleting working code I had written was harder than adding more, and it was still the right call.

5. Money-path hardening

An audit pass found a TOCTOU race in the wallet lock and an unguarded balance decrement. Both were fixed with conditional atomic updates.

Try It

The platform is live and you can sign in as a buyer, a seller or an admin. Reading about a customization engine is much less convincing than watching it refuse your checkout.

PersonaAppPortWhat they do
Buyeruser-ui3000Browse shops, customize products, checkout, track orders, chat with sellers
Sellerseller-ui3001Onboard, define products and their customization schemas, accept orders, manage shipments, request payouts
Adminadmin-ui3002Verify sellers, oversee orders, approve payouts, monitor platform health

Worth clicking through, in this order

Test logins for all three roles are on the credentials page. They are shared accounts, so expect other people's test data in there.

Architecture

A pragmatic microservices architecture. Services are independently containerized and deployable, communication is split between synchronous REST and asynchronous RabbitMQ events, and individual services can be scaled horizontally on their own. The system deliberately shares some infrastructure and data access for simplicity, while maintaining logical service ownership boundaries.

   user-ui :3000        seller-ui :3001        admin-ui :3002
        \                     |                      /
         --------------  Nginx (TLS, proxy)  --------------
                              |
                     api-gateway :8080
             CORS . rate limiting . correlation IDs . routing
                              |
   +------+------+------+-----+-----+------+------+------+
  auth  product seller order payment delivery notification
            chat    payout-wallet    admin
   +------+------+------+-----+-----+------+------+------+
                              |
              +---------------+---------------+
        MongoDB Atlas       Redis          RabbitMQ
        (Prisma ORM)   (sessions, OTP,   (async events)
                        rate limits,
                        job queue)
                              |
              Razorpay . Shiprocket . ImageKit . SMTP

     Prometheus . Grafana . Loki . Promtail

A single request crosses a fixed set of edge concerns before it ever reaches a service. The gateway holds no database connection and no business logic, so the list below is the entire surface it adds.

Browser
   |  HTTPS
   v
Nginx
   |
   v
API Gateway
   |
   +-- Helmet
   +-- CORS
   +-- Morgan
   +-- Cookies
   +-- Correlation ID
   +-- Metrics
   +-- Rate limiting
   +-- Internal-route protection
   |
   v
Path router
   |
   v
Target microservice

The split between synchronous and asynchronous communication is the spine of the architecture, and the rule is a single sentence: synchronous when the caller needs the answer to make its next decision, asynchronous when the caller's job is finished and what happens next is somebody else's responsibility.

Synchronous callWhy it must be synchronous
Frontend to gateway to any serviceThe user is waiting for a response
payment-service to product-serviceCart re-validation before creating a payment session. You cannot charge someone before confirming price and stock
payment-service to order-serviceOrder lookups during payment handling
delivery-service to ShiprocketShipment creation needs the AWB number back
Any service to auth verificationThe request cannot be authorised later
EventWhy it must be asynchronous
payment.succeeded to order creationThe payment is already final. Order creation must not be able to fail the payment
orders.order.created to notifications, wallet, admin earningsThree unrelated side effects, none of which should delay the others or the order
orders.order.accepted to Shiprocket shipmentShiprocket is a flaky third party. Its downtime must not block order acceptance
Delivery and tracking updates to order projectionWebhook-driven, arrives whenever the carrier says so
Payout and wallet events to notificationsPure side effect

Payment and order creation are deliberately decoupled

Checkout creates a verified payment. An asynchronous event creates the order. Taking money is irreversible, so it is kept as small as possible, and everything retryable lives outside it. If order creation fails, the message is retried and the payment was never at risk. That inversion, payment first and order as a consequence, is the single most important design decision in the system.

Money is released on delivery, not on payment

The wallet is credited when the order is confirmed delivered. That hold is the platform's core value to a buyer paying in advance, and it makes the wallet a state machine rather than a counter.

Third-party failure is contained

Shiprocket sits behind a circuit breaker in its own service. When it is down, shipment creation degrades and retries. Checkout and orders are unaffected.

The gateway is deliberately thin

It holds no database connection and no business logic. Only routing, CORS, rate limiting, correlation IDs and upstream timeout policy. It fronts everything, so the less it does, the fewer ways the platform can fail.

One monorepo, shared contracts

Event payload types, middleware, error handling and the database client live in packages/ and are imported by every service, so a contract change surfaces as a compile error rather than a runtime surprise.

Ownership is the governing principle

A service writes its own models freely. Reading another service's model is tolerated only as a documented non-mutating projection. Writing another service's model is debt. That rule is what makes the boundaries mean something even where the database is shared.

Services

Twelve backend services. The rule that governs all of them: a service writes its own models freely, reading another service's model is tolerated only as a documented non-mutating projection, and writing another service's model is debt.

ServiceOwnsMust never own
api-gatewayNothing. No database connection at all.Any business logic or data
authUsers, credentials, sessions, OTP, addresses, seller registrationProduct, order, or money data
sellerShops, seller settings, seller-facing viewsBuyer identity, product catalogue truth
productProducts, images, inventory, categories, shop reviewsOrders, payments
orderOrders, order items, order status, delivery projectionPayment records, wallet balances
paymentNo data at all. Owns the Razorpay conversation and emits events.Orders, products, any persisted state
deliveryShipments, tracking, Shiprocket integrationOrder status truth. It projects into order.
notificationNotification records and delivery channelsAnything it notifies about
chatConversations, messages, participantsOrders, products
payout-walletPayouts, admin earningsProduct or order truth
adminAdmin actions, verification, platform oversightDirect manipulation of domain data
analyticsScaffolded, not part of the running systemNot applicable

Three of them are worth going into properly, because they carry the decisions the rest of the system inherits.

API Gateway · port 8080 · no database

Auth Service

Product Service · the customization engine

Decisions

Every row here cost something. The tradeoff column is the honest half.

DecisionReasonTradeoff
Microservices over a monolithDomains have genuinely different operational profiles: stateful WebSocket chat, a flaky third-party shipping dependency, a correctness-critical payment webhook.No cross-service transactions, so eventual consistency must be designed for explicitly. Debugging spans processes. Higher operational surface than the traffic requires.
Monorepo on Nx and pnpmShared event contracts and middleware are compile-time checked across services, and task orchestration allows running a subset locally.Shared code makes coupling easy. Boundaries rest on convention rather than physical separation.
Express over a heavier frameworkSmall, well understood, and the services are thin. Nothing in the domain needed a framework's opinions.More wiring written by hand: error handling, validation and middleware ordering are mine to get right.
MongoDB over SQLCustomization schemas are seller-authored and arbitrary. Forcing them into relational tables means a migration per new field type, or an EAV table. Orders are naturally aggregate documents.Denormalisation is easy to get wrong, and the database will not enforce consistency for you.
Prisma as the ORMTyped access to a 29-model schema, with the generated client shared through packages/ so every service speaks the same types.An abstraction layer over a database whose flexibility was the reason it was chosen.
RabbitMQ over synchronous HTTPDurability if a consumer is down, fan-out to independent consumers, and retries with dead-lettering on money paths.At-least-once delivery makes idempotency mandatory everywhere, not optional.
Topic exchanges over direct queuesNew consumers subscribe to an existing event without the publisher changing, which is what keeps the fan-out on order.created cheap.Routing keys become an interface, and a typo in one is a silent no-op rather than an error.
Kafka not chosenRouted messaging to a handful of consumers at modest volume. No need for replay or partitioned ordering.Loses the event log and replay if the system later wants event sourcing.
Kubernetes not chosenOne instance, one operator, no autoscaling requirement. Compose does everything the deployment actually needs.Rolling deploys and self-healing are manual, and moving later is real work.
Redis for ephemeral stateOTPs, sessions and rate-limit counters are naturally expiring and must be shared across processes. TTL replaces cleanup jobs.Nothing durable can live there. Deliberately treated as a cache, never a database.
Order created from a payment eventTaking money is irreversible, creating an order is retryable. Separating them means a failed write never produces a charged customer with no order.A brief window where a payment exists and its order does not, requiring idempotency and reconciliation.
Wallet credited on deliveryThe hold is what makes advance payment safe for the buyer, which is the core trust problem the product solves.Requires reversal logic for returns and chargebacks, and a ledger with real concurrency control.
Server-side pricingThe client can send anything. Only server-derived prices are trustworthy.An extra cross-service validation round trip in the checkout path.
Three frontends, not oneDifferent rendering needs (public SEO versus private dashboard), and admin code never ships to a buyer's browser.Component duplication across apps unless shared packages are used rigorously.

"Why not make everything asynchronous?"

Because some callers cannot proceed without an answer. You cannot create a payment session without knowing the real price and that the stock exists. Making that asynchronous would mean charging someone and hoping.

"Why not make everything synchronous?"

Because then every downstream failure becomes an upstream failure. If notification delivery is synchronous inside order creation, an SMTP outage fails orders. The asynchronous boundary is what keeps unrelated failures unrelated.

"What happens if order-service is down when a payment succeeds?"

Nothing is lost. The webhook verifies the payment and publishes to a durable queue. The message waits. When order-service comes back it consumes, checks idempotency on the payment ID, and creates the order. The buyer's payment was valid the entire time.

"Why three frontends when it's one domain?"

The storefront is public and needs server rendering for SEO. The dashboard is behind auth and should never be indexed. The admin console is an internal operations tool. Building one app means every page carries the union of three sets of requirements, and admin code ships to every buyer's browser.

Constraints that shaped all of it

Challenges

Seven things that broke, or would have. Each one is written as what went wrong first, because that is the part worth remembering.

1. A payment that succeeds and an order that does not exist

2. Two payout requests locking the same money

3. A resilience mechanism that manufactured fake successes

4. Reversing money that was already paid out

5. Cart identity for products that are never identical

6. Deploying twelve services onto one small instance

7. Removing a service I had already built

Workflows

One chain runs the whole business, from a buyer opening a product to a seller getting paid.

                    CHECKOUT
                       |
                       v
              Payment Service
                       |
                validates cart
                       |
                  Redis session
                       |
                       v
                    Razorpay
                       |
                       v
             payment.succeeded
                       |
                       v
                    RabbitMQ
                       |
                       v
                Order Service
                       |
                 creates order
                       |
                 order.created
                       |
                       v
                Seller accepts
                       |
                order.accepted
                       |
                       v
              Delivery Service
                       |
                   Shiprocket
                       |
                tracking updates
                       |
                       v
                order.delivered
                       |
                       v
           Payout Wallet Service
                       |
                 credit seller
                       |
                 payout request
                       |
                       v
                Admin approval

Customization. The seller authors a schema, the buyer supplies data conforming to it. Validation is enforced on the server, so an order cannot exist without the information needed to fulfil it.

Seller defines field schema on product
        |
Buyer opens product, fills customization form
        |
Server-side validation against the product's schema
        |
Valid submission stored with the cart item

Checkout and payment. Nothing the client sends about price is trusted. The payment is complete and verified before any order exists.

Cart -> server re-validates price, stock, customization, coupon
     -> Razorpay order created for the server-computed amount
     -> buyer pays
     -> webhook verified (HMAC, constant-time)
     -> payment.succeeded published

Order fulfilment. One payment becomes several orders, one per seller, all-or-nothing.

payment.succeeded
     -> idempotency check on payment ID
     -> cart grouped by shop
     -> one order per shop + customizations, committed atomically
     -> orders.order.created published
            |-> notification-service   (buyer + seller notified)
            |-> payout-wallet-service  (earnings recorded)
            +-> admin-service          (platform earnings)

Delivery. Shipping is triggered by the platform, and carrier status flows back to every party that needs it.

Seller accepts order -> orders.order.accepted
     -> delivery-service creates Shiprocket shipment (circuit breaker)
     -> AWB generated
     -> carrier webhooks -> delivery.status.changed
            |-> order-service          (status projection)
            |-> notification-service   (buyer updates)
            +-> payout-wallet-service  (reversal if returned/RTO/lost)

Wallet and payout. Money is held until delivery, locked atomically when requested, and moved through a guarded state machine with an audit record on every transition.

Order delivered -> seller wallet credited (never at payment time)
        |
Seller requests payout -> balance locked atomically
        |
   Pending --approve--> Approved --pay--> Paid
        |                   |
     reject            mark failed -> back to Approved, retryable
        |
   Rejected

Scheduled reconciliation asserts impossible states -> alerts admin

Data Model

Two of the 29 models carry the entire customization feature, and the relationship between them is the thing worth understanding.

products
  +-- customizationFields: customization_field_template[]  <- SELLER defines the schema
        . fieldKey        (e.g. "engraving_text")
        . fieldType       (CustomFieldType enum)
        . required
        . validation rules
        . sequence
        . price modifier
  + customizationPreview
  + customizationInstructions

order_item_customization                                   <- BUYER's submitted values
  . fieldKey  ---------- matches customization_field_template.fieldKey
  . value

The seller authors a schema. The buyer supplies data conforming to it. fieldKey is the join between the two. That is the whole idea, and it should take fifteen seconds to draw on a whiteboard.

A worked example makes the shape obvious. A custom mug at ₹499 carries a required text field "Name" capped at 30 characters, a required image field "Photo" with a maximum size, and a boolean "Gift Wrap" with a ₹50 price modifier. None of those are columns anyone migrated for. The seller created them in the dashboard, and the platform enforces them at checkout.

This is the reason MongoDB was the right call rather than the lazy one. Seller-authored arbitrary schemas in a relational database mean either a migration per new field type or an EAV table, and both are worse than a document.

Validation runs in two places, deliberately

For the rest of the schema, ownership is the governing rule: a service writes its own models freely, reading another service's model is tolerated only as a documented non-mutating projection, and writing another service's model is debt. The full ownership matrix is on the Services tab.

Security

Authentication

OTP-verified registration, sessions in HTTP-only cookies so tokens are unreachable from JavaScript, with a centralised cookie helper keeping flags consistent. Short-lived access tokens paired with longer-lived refresh tokens limit the damage window if an access token leaks.

Downstream verification

Services verify the JWT themselves rather than calling auth-service on every request. Calling auth per request would make it a synchronous dependency of every other service and a single point of failure for the entire platform.

Authorization

Role-based middleware shared across all services, so buyer, seller and admin checks behave identically everywhere rather than being reimplemented per controller. A separate class of internal routes handles service-to-service calls under internal-service authentication, and the gateway rejects external attempts on them with a 403.

Rate limiting

Tiered at the gateway, with higher limits for authenticated users and stricter limits on authentication endpoints. Counters live in Redis so limits hold across gateway instances rather than per process.

OTP anti-abuse

Redis-backed cooldown between requests, a failed-attempt counter, a temporary lock after repeated failures, and a spam lock after excessive OTP requests. The OTP itself carries a TTL, so expiry needs no cleanup job.

Server-side validation

Prices, stock, commissions, coupon values and customization submissions are all recomputed or verified server-side. Client input is never authoritative on anything that affects money or fulfilment. The browser cart is a UI convenience, the server cart is the contract.

Webhook security

Payment webhooks are verified by HMAC over the raw request body, compared in constant time so signature verification cannot be attacked through timing.

Idempotency

Order creation is keyed on the payment ID. Refunds and wallet operations carry idempotency handling. Every money-path consumer is safe to run twice, which at-least-once delivery guarantees will happen.

Concurrency safety

Wallet balance changes use conditional atomic updates that fold their eligibility check into the write, preventing double-locking and negative balances.

Transport and headers

TLS terminated at Nginx, Helmet security headers on both gateway-origin and service responses, and a CORS allowlist restricted to known frontend origins.

Failure isolation

Third-party shipping calls sit behind a circuit breaker and degraded responses are treated as failures rather than successes. A configurable upstream timeout at the gateway, defaulting to 30 seconds, prevents one hung service from stalling the platform.

Auditability

Money-affecting admin actions write audit records. Money-path code logs payment, order, seller, payout and event identifiers by convention, and correlation IDs tie a request across every service it touches.

Reliability monitoring

Scheduled reconciliation asserts financial invariants and alerts on impossible states, such as a locked balance exceeding a total balance. A read-only consistency script sweeps orders, shipments, refunds, wallet and payouts.

Environment validation

Services fail fast at boot on missing or invalid configuration rather than failing on the first request that needs it.

Infra

Three Next.js frontends, an API gateway and eleven backend services, on one EC2 instance behind Nginx, via Docker Compose.

Nginx

Terminates TLS and reverse-proxies to the gateway, with explicit rewrite rules guarding against path-prefix duplication. It is also the outer boundary if the gateway itself goes down.

Managed backing services

MongoDB runs on Atlas. Redis and RabbitMQ are managed rather than containerised, so stateful infrastructure is not tied to the application host's lifecycle.

Resource discipline

Per-service CPU and memory limits tiered by criticality, from 0.05 to 0.3 CPU and 128 to 512MB. product-service and user-ui run two replicas, lower-traffic services run one. The gateway and frontends wait on service_healthy for their dependencies before starting.

Build strategy

Services are built surgically: one service without its dependency tree, then that container recreated, because a full-stack rebuild exhausts memory on a small instance.

Observability stack

Promtail ships container logs, Loki stores and indexes them, Prometheus scrapes per-service metrics endpoints, and Grafana queries both. Log collection does not depend on application code being healthy, which is the whole reason the custom logger service was deleted.

CI

GitHub Actions running lint, test and build across the affected project graph.

CategoryTechnology
LanguagesTypeScript, Node.js
FrontendNext.js 15 App Router, React 19, Tailwind, React Query, Zustand, Jotai, React Hook Form, TanStack Table
BackendExpress
DatabaseMongoDB Atlas, Prisma ORM
MessagingRabbitMQ (amqplib), topic exchanges
Ephemeral stateRedis (ioredis)
Background jobsBullMQ, node-cron
Real-timeSocket.IO
Payments and shippingRazorpay, Shiprocket
Media and emailImageKit, Nodemailer, EJS
Resilienceopossum circuit breaker
InfrastructureDocker, Docker Compose, Nginx, AWS EC2
ObservabilityPrometheus, Grafana, Loki, Promtail, OpenTelemetry
TestingJest, Playwright, custom integration and reconciliation scripts
ToolingNx, pnpm workspaces, ESLint, GitHub Actions

What's Next

Deployed and live at eudoro.art. The roadmap below is ordered by what actually improves the system, not by what is easiest.

Near-term

Long-term

Solo engineering project across architecture, backend, frontend, infrastructure and deployment. A friend is working on marketing and distribution.