The problem: one person, three hats
"Dokan" is the Bengali word for "shop," and that's exactly what the project set out to model: a marketplace where the line between buyer and seller is blurry by design. A customer today might open their own storefront tomorrow, and that same person might also want to pick up delivery jobs on the side. Most e-commerce boilerplates assume you're either a Customer model or a Seller model, full stop. I wanted a single identity that could hold all three roles simultaneously and switch between them without logging out.
That constraint — one account, many personas — ended up driving almost every architectural decision in the backend, not just the UI.
Splitting identity from role
Instead of a User schema packed with role-specific fields, I split the data model into two documents:
- User — the auth record: email, hashed password, phone, and a pointer to a
Person. - Person — the actual profile: name, address, NID number, and three independent identifiers —
customerId,sellerId,deliveryId— each populated only when that role is activated.
// model/Person.js
const personSchema = new mongoose.Schema({
firstName: { type: String, required: [true, "Please Enter Your Name"] },
lastName: String,
dob: Date,
gender: { type: String, required: [true, "Select Your Gender"] },
contact: { type: Number, required: [true, "Enter Your Contact Number"] },
dokan: { type: [Dokan], required: false, default: [] },
address: { type: String, required: [true, "Please Enter a Address"] },
sellerId: { type: String, default: "" },
deliveryId: { type: String, default: "" },
customerId: { type: String, default: "" },
nidNumber: { type: Number, required: [true, "Enter Your NID Number"] },
});
Activating a role (say, opening a storefront) is just an update to Person, not a new account or a schema migration. The JWT issued at login carries the user ID only — role context is resolved server-side by looking up the Person, and the client decides which dashboard to render based on which of the three IDs came back non-empty.
Role switching as a first-class UI interaction
On the frontend, the account menu isn't a settings page — it's a role switcher. If a Person has a sellerId, a "DOKAN Holder" option appears in the profile dropdown alongside Customer and Delivery, and picking one routes to a completely different dashboard without a re-login:
{customerId && (
<MenuItem onClick={() => ProfileTypeHandler("customer")}>
<IconButton color="primary"><ShoppingCart /></IconButton>
<p>Customer</p>
</MenuItem>
)}
{sellerId && (
<MenuItem onClick={() => ProfileTypeHandler("seller")}>
<IconButton color="primary"><Storefront /></IconButton>
<p>DOKAN Holder</p>
</MenuItem>
)}
{deliveryId && (
<MenuItem onClick={() => ProfileTypeHandler("delivery")}>
<IconButton color="primary"><Telegram /></IconButton>
<p>Deliver</p>
</MenuItem>
)}
The active role is tracked in Redux (store/actions/theme.js / auth.js) rather than the URL, so the CustomerDashboard, SellerDashboard, and DeliveryDashboard containers are genuinely separate component trees that share only the auth slice.
Compensating transactions instead of a saved cart of bugs
Opening a Dokan (a seller storefront) touches two collections: it creates a Dokan document and then patches the owning Person with the new dokan reference and a generated sellerId. Without a transaction, a crash between those two writes leaves an orphaned shop with no owner. Rather than reach for Mongo's multi-document transactions (overkill for a single-node dev setup, and this predates my more recent replica-set work), I hand-rolled a compensating action:
module.exports.addDokan = async function (id, name, email) {
const newDokan = await this.createDokan(name);
try {
const updatedPerson = await Person.updatePerson(id, {
dokan: newDokan._id,
sellerId: email + "seller",
});
return { result: true, userId: id, dokan: newDokan.name, message: `...` };
} catch (error) {
// roll back the orphaned Dokan if attaching it to the Person fails
await this.deleteDokan(newDokan._id);
throw error;
}
};
It's a small pattern, but it's the difference between "the seller onboarding flow occasionally leaves garbage in the database" and "it doesn't." Every model file (model/Dokans.js, model/Users.js, model/Persons.js) follows the same shape: a thin, exported function per operation that returns a consistent { result, value, message } envelope instead of throwing raw Mongoose errors up to the controller. That made the controller layer (controller/dokanController.js, controller/authController.js) almost boringly linear — check result, respond 200 or 400, done.
Stack and structure
| Layer | Choice | Why |
|---|---|---|
| API | Express + Mongoose | Fast to iterate on a document model that was still being figured out (roles, nested reviews/comments) |
| Auth | Custom JWT + bcrypt, no third-party auth service | Full control over the role-resolution logic described above; auth needed to return three optional IDs, not a single claim |
| Frontend | React 17 + Redux + redux-thunk | Async auth flows (signup → JWT → role dispatch) mapped cleanly onto thunks |
| UI | MUI v5 + Material-UI v4 (mid-migration) | Started on @material-ui/core, migrated screens incrementally to @mui/material rather than a risky big-bang rewrite |
| Styling | styled-components + MUI's makeStyles | Legacy of the incremental MUI migration — noted as tech debt, not left in by accident |
The product catalog itself is intentionally embedded rather than referenced: Product documents nest their Review and Comment schemas directly, and each Dokan embeds its Product array:
const dokanSchema = new mongoose.Schema({
name: { type: String, required: [true, "Enter Your Dokan Name"] },
products: { type: [Product], default: [] },
});
For a marketplace where a storefront's products are almost always read together with the storefront (browsing a Dokan's page, rendering its product grid), embedding avoids a join-like $lookup on every page view — a deliberate trade against write-side flexibility, made because the read pattern was clear from the UI mockups before a line of backend code was written.
Design-first, not code-first
Before the first Express route existed, the project had a full UI/UX pass — welcome screen, signup, login, and seller dashboard mockups — that defined the three-role navigation before it was implemented. That ordering mattered: the Person schema's three optional role IDs exist because the mockups already showed a single account menu with three possible destinations. The data model followed the interaction design, not the other way around.
Takeaway
Dokan is a small project, but it's a useful example of taking a seemingly simple product requirement — "a user can be a buyer and a seller" — and tracing it all the way through the stack: a data model that separates identity from role, an API layer with consistent error envelopes and manual rollback for multi-step writes, and a frontend where role-switching is a Redux state change rather than a page reload. None of it is exotic engineering, and that's the point — the right amount of structure for a project's actual complexity, not more.