Multi-Tenant SaaS Architecture

The three approaches to multi-tenancy

ApproachHowProCon
Separate DB per tenantEach tenant has own MongoDB instanceFull isolation, easy backupExpensive, hard to operate at scale
Schema-per-tenantEach tenant has own collection prefixGood isolationCollection explosion, migration hell
Shared DB + tenant_idAll tenants in same collections, every doc has tenantIdSimple, cheap, scales wellApplication-layer isolation enforcement required

Chosen approach: Shared DB + tenantId discrimination. At the scale of 30-35 stores, separate databases would cost 30x MongoDB Atlas fees with zero performance benefit.

Row-level isolation implementation

Every DB query at the service layer includes tenantId as a mandatory filter. This is enforced in a base service class, not per-route:

// Base service — all operations scoped to tenant
class BaseService {
  constructor(model, tenantId) {
    this.model = model;
    this.tenantId = tenantId;
  }

  async findOne(filter, projection = {}) {
    // tenantId guard is ALWAYS injected — cannot be bypassed by caller
    return this.model.findOne({ ...filter, tenantId: this.tenantId }, projection);
  }

  async find(filter, options = {}) {
    return this.model.find(
      { ...filter, tenantId: this.tenantId },
      options.projection,
      options
    );
  }
}

// Usage in route handler
const orderService = new OrderService(Order, req.user.tenantId);
const orders = await orderService.find({ status: 'PENDING' });
// ↑ impossible to see another tenant's orders
Interview Q&A

Q: What happens if a developer forgets to include tenantId in a query?

A: The BaseService pattern makes it structurally impossible — the tenantId is injected by the service constructor, not the caller. To bypass it, a developer would have to explicitly use the raw Mongoose model. Code reviews catch any direct model access. We also have MongoDB-level access controls — the API user doesn't have admin credentials, limiting blast radius.

RBAC — Role-Based Access Control

Roles in Zoober

RoleCan doCannot do
ADMINEverything within their tenantCross-tenant operations
MERCHANTManage own store, view orders, update stockAccess other stores, rider management
RIDERView assigned orders, update delivery statusCatalog, payments, user data
CUSTOMERBrowse, order, track own ordersAny management actions

Permission matrix enforcement

// Permissions map
const PERMISSIONS = {
  'order:read': ['ADMIN', 'MERCHANT', 'RIDER', 'CUSTOMER'],
  'order:update-status': ['ADMIN', 'MERCHANT', 'RIDER'],
  'catalog:write': ['ADMIN', 'MERCHANT'],
  'user:manage': ['ADMIN'],
  'rider:assign': ['ADMIN', 'MERCHANT'],
};

// Middleware factory
const authorize = (...permissions) => (req, res, next) => {
  const { role } = req.user; // role from JWT claims
  const hasPermission = permissions.every(p =>
    PERMISSIONS[p]?.includes(role)
  );
  if (!hasPermission) return res.status(403).json({ error: 'Forbidden' });
  next();
};

// Route usage
router.patch('/orders/:id/status',
  authenticate,                         // verify JWT
  authorize('order:update-status'),     // check role
  orderController.updateStatus
);

7-Stage Order State Machine

Order lifecycle state machine: [PENDING] ──────────────────────────────────────▶ [CANCELLED] │ ▲ │ merchant confirms │ any stage ▼ │ [CONFIRMED] ─────────────────────────────────────────▶┤ │ │ │ kitchen starts │ ▼ │ [PREPARING] ─────────────────────────────────────────▶┤ │ │ │ ready for pickup │ ▼ │ [READY] ──────────────────────────────────────────────┤ │ │ │ rider picks up │ ▼ │ [PICKED_UP] ─────────────────────────────────────────▶┤ │ │ │ rider delivers │ ▼ │ [DELIVERED] ─────────────────────────────────────────▶┤ │ │ │ customer confirms / auto-confirm after 15min │ ▼ │ [COMPLETED] Also: [FAILED] for payment failures ──┘

State machine implementation with guards

const VALID_TRANSITIONS = {
  PENDING:    ['CONFIRMED', 'CANCELLED'],
  CONFIRMED:  ['PREPARING', 'CANCELLED'],
  PREPARING:  ['READY', 'CANCELLED'],
  READY:      ['PICKED_UP', 'CANCELLED'],
  PICKED_UP:  ['DELIVERED'],
  DELIVERED:  ['COMPLETED'],
  COMPLETED:  [],
  CANCELLED:  [],
  FAILED:     [],
};

async function transitionOrder(orderId, newStatus, actor, tenantId) {
  // Optimistic locking — read current version
  const order = await Order.findOne({ _id: orderId, tenantId });
  if (!order) throw new NotFoundError('Order not found');

  // Guard: is this transition valid?
  if (!VALID_TRANSITIONS[order.status].includes(newStatus)) {
    throw new BadRequestError(
      `Cannot transition from ${order.status} to ${newStatus}`
    );
  }

  // Atomic update with version check (optimistic locking)
  const updated = await Order.findOneAndUpdate(
    { _id: orderId, __v: order.__v, tenantId }, // version check
    {
      $set: { status: newStatus, [`timestamps.${newStatus.toLowerCase()}`]: new Date() },
      $push: { statusHistory: { status: newStatus, actor, at: new Date() } },
      $inc: { __v: 1 }, // increment version
    },
    { new: true }
  );

  if (!updated) throw new ConflictError('Order was modified concurrently');

  // Emit WebSocket event to all relevant clients
  broadcastOrderUpdate(updated);
  return updated;
}
Interview Q&A

Q: Why is idempotency important in the state machine?

A: WebSocket connections can drop and reconnect. A rider app might send "PICKED_UP" twice if the first acknowledgment was lost. Without idempotency, that would cause a double transition. With the __v optimistic locking check, the second identical update hits the same version number — the findOneAndUpdate finds no document matching both _id AND __v, returns null, and we throw a ConflictError (which the client ignores since the state is already correct).

Geospatial Queries

2dsphere index setup

// Mongoose schema with GeoJSON
const StoreSchema = new Schema({
  name: String,
  tenantId: { type: Schema.Types.ObjectId, ref: 'Tenant', required: true },
  location: {
    type: {
      type: String,
      enum: ['Point'],
      required: true,
    },
    coordinates: {
      type: [Number], // [longitude, latitude] — GeoJSON order!
      required: true,
    },
  },
  deliveryRadius: Number, // meters
  isActive: Boolean,
});

// 2dsphere index required for $near/$geoWithin queries
StoreSchema.index({ location: '2dsphere' });
// Compound index for filtered queries
StoreSchema.index({ tenantId: 1, isActive: 1, location: '2dsphere' });
// Query: find stores within 5km of user
const stores = await Store.find({
  tenantId: req.user.tenantId,
  isActive: true,
  location: {
    $near: {
      $geometry: { type: 'Point', coordinates: [userLng, userLat] },
      $maxDistance: 5000, // 5km in meters
    },
  },
}).limit(20).select('name location deliveryRadius imageUrl');
// Results sorted by distance ascending (nearest first) — automatic with $near
Interview Q&A

Q: What's the difference between $near and $geoWithin?

A: $near finds documents sorted by distance from a point — perfect for "stores near me" (returns nearest first). $geoWithin finds documents within a shape (polygon, circle) without sorting — perfect for "is this delivery address within our zone?" The 2dsphere index accelerates both. We use $near for store discovery and $geoWithin with delivery zones (polygon shapes per rider zone).

Token-Bucket Rate Limiting

Algorithm explained

Token Bucket Algorithm: Bucket capacity: 100 tokens Refill rate: 10 tokens/second Time 0s: [██████████] 100 tokens (full) Request: [-1 token] Time 0s: [█████████ ] 99 tokens 10 requests arrive: Time 0.1s: [████████ ] 90 tokens Burst: 100 requests in 0.5s: Time 0.5s: [ ] 0 tokens → 429 Too Many Requests Refill: 5s pass → +50 tokens Time 5.5s: [█████ ] 50 tokens
// Redis-based token bucket implementation
async function checkRateLimit(key, capacity, refillRate, ttl) {
  const now = Date.now();
  const refillKey = `rl:${key}:refill`;
  const countKey = `rl:${key}:count`;

  const [count, lastRefill] = await redis.mget(countKey, refillKey);
  const tokens = parseInt(count ?? capacity);
  const last = parseInt(lastRefill ?? now);

  // Add tokens based on elapsed time
  const elapsed = (now - last) / 1000;
  const newTokens = Math.min(capacity, tokens + elapsed * refillRate);

  if (newTokens < 1) {
    return { allowed: false, remaining: 0 };
  }

  // Atomic decrement
  const pipeline = redis.multi();
  pipeline.set(countKey, Math.floor(newTokens - 1), 'EX', ttl);
  pipeline.set(refillKey, now, 'EX', ttl);
  await pipeline.exec();

  return { allowed: true, remaining: Math.floor(newTokens - 1) };
}