Multi-tenant SaaS architecture, RBAC, the 7-stage order state machine, geospatial queries, and event-driven patterns.
| Approach | How | Pro | Con |
|---|---|---|---|
| Separate DB per tenant | Each tenant has own MongoDB instance | Full isolation, easy backup | Expensive, hard to operate at scale |
| Schema-per-tenant | Each tenant has own collection prefix | Good isolation | Collection explosion, migration hell |
| Shared DB + tenant_id | All tenants in same collections, every doc has tenantId | Simple, cheap, scales well | Application-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.
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
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.
| Role | Can do | Cannot do |
|---|---|---|
| ADMIN | Everything within their tenant | Cross-tenant operations |
| MERCHANT | Manage own store, view orders, update stock | Access other stores, rider management |
| RIDER | View assigned orders, update delivery status | Catalog, payments, user data |
| CUSTOMER | Browse, order, track own orders | Any management actions |
// 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
);
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;
}
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).
// 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
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).
// 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) };
}