In my previous post, I introduced KitchenAsty — an open-source, self-hosted restaurant ordering and management platform. People asked about the architecture, so this post dives into the core design decisions: how the real-time system works, how the database handles guest orders and price changes, how settings resolve from multiple sources, and how a data-driven automation pipeline lets restaurant owners set up custom workflows without writing code.
1. Real-Time Architecture: Rooms, Not Broadcasts
A restaurant system has two audiences that need live updates simultaneously: kitchen staff watching for incoming orders, and customers tracking their delivery. Broadcasting everything to everyone would be wasteful and insecure.
Socket.IO's room abstraction solves this cleanly. There are two room types:
-
kitchen— a single shared room. Every kitchen display client joins it. When a new order arrives or any order changes status, the event goes here. -
order:{orderId}— one room per active order. The customer's browser or mobile app joins their specific order room. They only receive updates about their order.
// Client joins on mount
socket.emit('join:kitchen'); // kitchen display
socket.emit('join:order', id); // customer order tracking
// Server emits to both rooms on status change
io.to('kitchen').emit('order:statusUpdate', order);
io.to(`order:${order.id}`).emit('order:statusUpdate', order);
This means 50 customers tracking 50 orders generate 50 targeted messages, not 50 broadcasts. The kitchen display gets every update because it's in the shared room.
On the kitchen display itself, updates are applied optimistically — the UI updates immediately on button click, and the Socket.IO event from the server reconciles in the background. If the status moves the order off the Kanban board (e.g., "delivered"), the socket handler removes it from local state:
s.on('order:statusUpdate', (data) => {
setOrders((prev) => {
if (!KITCHEN_STATUSES.includes(data.status)) {
return prev.filter((o) => o.id !== data.id); // gone from board
}
return prev.map((o) => o.id === data.id ? { ...o, status: data.status } : o);
});
});
2. Database: Snapshots, Not References
The most important database design decision was this: order items store a snapshot of the menu data at the time of purchase, not just a foreign key.
model OrderItem {
menuItemId String
menuItem MenuItem @relation(...)
name String // "Margherita Pizza" — frozen at order time
unitPrice Float // 12.99 — frozen at order time
subtotal Float
}
model OrderItemOption {
name String // "Extra Cheese" — frozen
value String // "Yes" — frozen
priceModifier Float // 2.50 — frozen
}
The foreign key to MenuItem is still there for analytics (which menu items generate the most revenue), but the name, unitPrice, and priceModifier fields are what the order actually uses. If the restaurant changes the price of a pizza next week, existing order records aren't affected.
This matters more than you'd think. Restaurants update prices frequently — seasonal menus, promotions, cost adjustments. Without snapshots, your revenue reports lie and customer receipts change retroactively.
Guest checkout with optional customer
Orders support both registered customers and guests through an optional relationship:
model Order {
customerId String? // null for guest orders
customer Customer?
guestName String? // fallback fields for guests
guestEmail String?
guestPhone String?
}
The auth middleware on the order creation endpoint uses optionalAuth — it attaches the user if a token is present but doesn't block the request otherwise. The controller checks: if there's a logged-in customer, link the order; if not, store the guest fields.
Self-referential categories
Menus need nested categories (e.g., "Drinks" > "Hot Drinks" > "Coffees"). Rather than a flat list with a separate hierarchy table, the schema uses a self-referential adjacency list:
model Category {
parentId String?
parent Category? @relation("CategoryTree", fields: [parentId], references: [id])
children Category[] @relation("CategoryTree")
}
One query with include: { children: true } gives you the full tree. Simple to query, simple to render, and Prisma's type system ensures you can't accidentally create orphans.
3. Settings: DB-First with Env Var Fallback
Restaurant owners configure their system through the admin panel (stored in the database). Developers configure it through environment variables during deployment. Both need to work, and the admin panel should win when both are set.
The pattern looks like this for email configuration:
async function getMailConfig() {
// Start with env var defaults
let host = process.env.SMTP_HOST || 'localhost';
let port = parseInt(process.env.SMTP_PORT || '1025');
try {
// DB settings override env vars
const settings = await prisma.siteSettings.findUnique({
where: { id: 'default' }
});
const mail = settings?.mailSettings || {};
if (mail.smtpHost) host = mail.smtpHost;
if (mail.smtpPort) port = mail.smtpPort;
// ...
} catch {
// DB unavailable — env vars are the fallback
}
}
The entire site configuration lives in a single database row — SiteSettings with id: 'default'. JSON columns hold grouped settings (general, orders, mail, payments, reservations, reviews, advanced). This avoids a key-value settings table and keeps related settings together.
Two details that took some thought:
Caching — The mail transporter is cached for 5 minutes to avoid a database round-trip on every email. When settings are updated through the admin API, invalidateMailCache() forces a rebuild on the next send.
Secret masking — API keys and passwords are masked in API responses (sk_l...4x8f). When the admin submits the settings form, if a field still looks masked, the server preserves the existing database value instead of overwriting it with the mask string:
function preserveIfMasked(newVal, existingVal) {
if (isMasked(newVal)) return existingVal;
return newVal;
}
This means the frontend never holds real secrets in memory — it only ever sees masked values.
4. Auth: Composable Middleware, Two User Types
Staff and customers share the same JWT structure but are treated as distinct principals:
interface JwtPayload {
id: string;
email: string;
type: 'staff' | 'customer';
role?: 'SUPER_ADMIN' | 'MANAGER' | 'STAFF';
}
Access control is built from three composable middleware functions:
authenticate // require any valid JWT
requireStaff // require type === 'staff'
requireRole(...roles) // require specific role(s)
These compose at the route level:
router.post('/', optionalAuth, createOrder); // guests OK
router.get('/my-orders', authenticate, listCustomerOrders);
router.get('/', authenticate, requireStaff, listOrders);
router.patch('/:id/status', authenticate, requireStaff, updateOrderStatus);
The optionalAuth variant is key for guest checkout — it attaches the user if present but doesn't reject anonymous requests.
Staff invitation uses single-use tokens with a 7-day expiry stored in the database. When a new staff member accepts an invitation, the token is marked as used to prevent replay.
5. Automation: Data-Driven Event Pipeline
This was the most interesting piece to build. Restaurant owners need custom workflows — "email the customer when their order is confirmed", "send an SMS when the order is ready for pickup", "notify the manager via webhook when a bad review comes in" — without touching code.
The pipeline has three layers:
Layer 1 — EventEmitter as internal bus. Controllers emit domain events after mutations:
// After creating an order
appEvents.emit('order.created', { order });
// After changing order status
appEvents.emit('order.statusChanged', { order, previousStatus });
Layer 2 — DB-driven rule matching. When an event fires, the system queries for active automation rules that match:
async function processRules(event, data) {
const rules = await prisma.automationRule.findMany({
where: { event, isActive: true },
});
for (const rule of rules) {
if (matchesConditions(rule.conditions, data)) {
for (const action of rule.actions) {
executeAction(action, data);
}
}
}
}
Conditions support dot-notation paths, so a rule can match on order.status === 'CONFIRMED' or order.type === 'DELIVERY'.
Layer 3 — Action execution. Three action types: email, sms, and webhook. Templates use {{dot.path}} interpolation:
{
"type": "email",
"to": "customer",
"subject": "Your order #{{order.orderNumber}} is confirmed!",
"body": "Hi {{order.customer.name}}, we're preparing your order."
}
The "to": "customer" field resolves dynamically — it walks data.order.customer.email, then falls back to data.order.guestEmail. This works transparently for both registered and guest orders.
Everything is stored as JSON in the AutomationRule model, so restaurant owners create and manage rules through the admin panel without deployments.
6. Shared Types: Thin by Design
The packages/shared package is intentionally minimal — it exports as const arrays that serve as both runtime values and TypeScript types:
export const ORDER_STATUSES = [
'pending', 'confirmed', 'preparing', 'ready',
'out_for_delivery', 'delivered', 'picked_up', 'cancelled',
] as const;
export type OrderStatus = (typeof ORDER_STATUSES)[number];
The package does not re-export Prisma types. The admin and storefront apps import from @kitchenasty/shared without taking a transitive dependency on Prisma or any server-side code. This keeps frontend bundles clean and avoids the common monorepo trap where everything depends on everything.
Shared response shapes (ApiResponse<T>, PaginatedResponse<T>) ensure the API contract is consistent across all endpoints without a code generation step.
What I'd Do Differently
A few things I'd reconsider if starting over:
- tRPC instead of REST — type-safe API calls without hand-written fetch wrappers. The shared types package partially solves this, but tRPC would eliminate the gap entirely.
-
Structured logger from day one — the codebase currently uses raw
console.*calls. Addingpinoorwinstonafter the fact means touching every file that logs. This is an open issue if you want to help. - Component tests for the frontends — the backend has 330+ tests but the React apps have zero unit tests. Integration coverage through Playwright helps, but component-level tests would catch more regressions.
Try It / Contribute
- Live demo: demo.kitchenasty.com (resets every 2 hours)
- GitHub: github.com/mighty840/kitchenasty
- Docs: kitchenasty.com
If any of these patterns are interesting to you, there are good first issues and help wanted issues covering accessibility, i18n, test coverage, and more. Happy to answer questions in the comments or in GitHub Discussions.
Top comments (0)