Flex Engineers
4 min read

Dokan: Building a Multi-Vendor Marketplace with One Identity, Three Roles

MongoDBExpressReactReduxMarketplace DevelopmentEcommerce Website Development

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. Our team 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, the team 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 at the time), the team 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

LayerChoiceWhy
APIExpress + MongooseFast to iterate on a document model that was still being figured out (roles, nested reviews/comments)
AuthCustom JWT + bcrypt, no third-party auth serviceFull control over the role-resolution logic described above; auth needed to return three optional IDs, not a single claim
FrontendReact 17 + Redux + redux-thunkAsync auth flows (signup → JWT → role dispatch) mapped cleanly onto thunks
UIMUI v5 + Material-UI v4 (mid-migration)Started on @material-ui/core, migrated screens incrementally to @mui/material rather than a risky big-bang rewrite
Stylingstyled-components + MUI's makeStylesLegacy 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.