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.
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.
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 problem | What it actually costs |
|---|---|
| Requirements arrive incomplete | Multiple round trips per order before work can start |
| No structure to customization data | Seller must remember which asset belongs to which buyer |
| No order record | No history, no status, no way to answer "where is my order" |
| Payment disconnected from order | Reconciling who paid for what is manual |
| Manual shipping entry | Address re-typed into Shiprocket per order, transcription errors |
| Overhead scales with success | More orders means proportionally more admin, not more craft time |
| Buyer problem | What it actually costs |
|---|---|
| Does not know what to provide | Cannot self-serve, waits on the seller to ask |
| Advance payment to a stranger | Pure 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 DM | No sense of where that data goes |
| No unified experience | Each 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.
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.
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.
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 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
| Area | What I built |
|---|---|
| System architecture | Service decomposition and boundaries, ownership rules, the synchronous and asynchronous split, and the event-driven order pipeline. |
| Backend | Twelve 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. |
| Frontend | Three Next.js 15 App Router apps (buyer storefront, seller dashboard, admin console) with React Query for server state and Zustand for client state. |
| Database | A 29-model Prisma schema on MongoDB, including the customization template and submission models the product is built around. |
| Events | RabbitMQ chains for order creation, fulfilment, notifications and wallet operations, with idempotent consumers and retry handling on every money path. |
| Payments | Razorpay, server-authoritative pricing, HMAC webhook verification, refunds, and the payment-to-order event boundary. |
| Shipping | Shiprocket behind a circuit breaker, with shipment creation, AWB generation and webhook-driven tracking. |
| Money | The seller ledger, its concurrency control, the payout state machine, and scheduled reconciliation. |
| Auth | OTP flows, cookie sessions, role-based access control, and internal service-to-service authentication. |
| Real-time | Socket.IO chat with conversation lifecycle management. |
| Infrastructure | Docker Compose, Nginx reverse proxy and TLS, deployment to a single EC2 instance with tiered per-service resource limits. |
| Observability | Prometheus metrics, Grafana dashboards, Loki log aggregation, correlation ID propagation. |
| Testing | 78 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.
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.
Auth, product, order, payment and the rest became separate Express applications behind a gateway.
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.
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.
An audit pass found a TOCTOU race in the wallet lock and an unguarded balance decrement. Both were fixed with conditional atomic updates.
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.
| Persona | App | Port | What they do |
|---|---|---|---|
| Buyer | user-ui | 3000 | Browse shops, customize products, checkout, track orders, chat with sellers |
| Seller | seller-ui | 3001 | Onboard, define products and their customization schemas, accept orders, manage shipments, request payouts |
| Admin | admin-ui | 3002 | Verify sellers, oversee orders, approve payouts, monitor platform health |
Test logins for all three roles are on the credentials page. They are shared accounts, so expect other people's test data in there.
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 call | Why it must be synchronous |
|---|---|
| Frontend to gateway to any service | The user is waiting for a response |
| payment-service to product-service | Cart re-validation before creating a payment session. You cannot charge someone before confirming price and stock |
| payment-service to order-service | Order lookups during payment handling |
| delivery-service to Shiprocket | Shipment creation needs the AWB number back |
| Any service to auth verification | The request cannot be authorised later |
| Event | Why it must be asynchronous |
|---|---|
| payment.succeeded to order creation | The payment is already final. Order creation must not be able to fail the payment |
| orders.order.created to notifications, wallet, admin earnings | Three unrelated side effects, none of which should delay the others or the order |
| orders.order.accepted to Shiprocket shipment | Shiprocket is a flaky third party. Its downtime must not block order acceptance |
| Delivery and tracking updates to order projection | Webhook-driven, arrives whenever the carrier says so |
| Payout and wallet events to notifications | Pure side effect |
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.
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.
Shiprocket sits behind a circuit breaker in its own service. When it is down, shipment creation degrades and retries. Checkout and orders are unaffected.
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.
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.
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.
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.
| Service | Owns | Must never own |
|---|---|---|
| api-gateway | Nothing. No database connection at all. | Any business logic or data |
| auth | Users, credentials, sessions, OTP, addresses, seller registration | Product, order, or money data |
| seller | Shops, seller settings, seller-facing views | Buyer identity, product catalogue truth |
| product | Products, images, inventory, categories, shop reviews | Orders, payments |
| order | Orders, order items, order status, delivery projection | Payment records, wallet balances |
| payment | No data at all. Owns the Razorpay conversation and emits events. | Orders, products, any persisted state |
| delivery | Shipments, tracking, Shiprocket integration | Order status truth. It projects into order. |
| notification | Notification records and delivery channels | Anything it notifies about |
| chat | Conversations, messages, participants | Orders, products |
| payout-wallet | Payouts, admin earnings | Product or order truth |
| admin | Admin actions, verification, platform oversight | Direct manipulation of domain data |
| analytics | Scaffolded, not part of the running system | Not applicable |
Three of them are worth going into properly, because they carry the decisions the rest of the system inherits.
Every row here cost something. The tradeoff column is the honest half.
| Decision | Reason | Tradeoff |
|---|---|---|
| Microservices over a monolith | Domains 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 pnpm | Shared 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 framework | Small, 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 SQL | Customization 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 ORM | Typed 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 HTTP | Durability 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 queues | New 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 chosen | Routed 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 chosen | One 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 state | OTPs, 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 event | Taking 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 delivery | The 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 pricing | The client can send anything. Only server-derived prices are trustworthy. | An extra cross-service validation round trip in the checkout path. |
| Three frontends, not one | Different 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. |
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.
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.
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.
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.
Seven things that broke, or would have. Each one is written as what went wrong first, because that is the part worth remembering.
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
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.
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.
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.
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.
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.
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.
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.
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.
Payment webhooks are verified by HMAC over the raw request body, compared in constant time so signature verification cannot be attacked through timing.
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.
Wallet balance changes use conditional atomic updates that fold their eligibility check into the write, preventing double-locking and negative balances.
TLS terminated at Nginx, Helmet security headers on both gateway-origin and service responses, and a CORS allowlist restricted to known frontend origins.
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.
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.
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.
Services fail fast at boot on missing or invalid configuration rather than failing on the first request that needs it.
Three Next.js frontends, an API gateway and eleven backend services, on one EC2 instance behind Nginx, via Docker Compose.
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.
MongoDB runs on Atlas. Redis and RabbitMQ are managed rather than containerised, so stateful infrastructure is not tied to the application host's lifecycle.
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.
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.
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.
GitHub Actions running lint, test and build across the affected project graph.
| Category | Technology |
|---|---|
| Languages | TypeScript, Node.js |
| Frontend | Next.js 15 App Router, React 19, Tailwind, React Query, Zustand, Jotai, React Hook Form, TanStack Table |
| Backend | Express |
| Database | MongoDB Atlas, Prisma ORM |
| Messaging | RabbitMQ (amqplib), topic exchanges |
| Ephemeral state | Redis (ioredis) |
| Background jobs | BullMQ, node-cron |
| Real-time | Socket.IO |
| Payments and shipping | Razorpay, Shiprocket |
| Media and email | ImageKit, Nodemailer, EJS |
| Resilience | opossum circuit breaker |
| Infrastructure | Docker, Docker Compose, Nginx, AWS EC2 |
| Observability | Prometheus, Grafana, Loki, Promtail, OpenTelemetry |
| Testing | Jest, Playwright, custom integration and reconciliation scripts |
| Tooling | Nx, pnpm workspaces, ESLint, GitHub Actions |
Deployed and live at eudoro.art. The roadmap below is ordered by what actually improves the system, not by what is easiest.
Solo engineering project across architecture, backend, frontend, infrastructure and deployment. A friend is working on marketing and distribution.