The kitchen comes alive
With the Waiter announcing orders, the café needs more humans. All three follow the same pattern: an inbox (SQS + dead-letter queue), a rule on the town square, a worker function, their own memory, and their own voice.
A reusable "Inbox" gene
Every listening human needs the same parts, so we write the gene once:
// infra/shared/inbox.ts
import { Construct } from "constructs";
import { Duration } from "aws-cdk-lib";
import * as sqs from "aws-cdk-lib/aws-sqs";
import * as events from "aws-cdk-lib/aws-events";
import * as targets from "aws-cdk-lib/aws-events-targets";
import * as lambda from "aws-cdk-lib/aws-lambda";
import { SqsEventSource } from "aws-cdk-lib/aws-lambda-event-sources";
export interface InboxProps {
eventBus: events.IEventBus;
listensFor: events.EventPattern; // what this human wants to hear
worker: lambda.IFunction; // who reads the letters
maxWorkers: number; // lifestyle: how many copies may read at once
}
export class Inbox extends Construct {
public readonly queue: sqs.Queue;
public readonly problemLetters: sqs.Queue;
constructor(scope: Construct, id: string, props: InboxProps) {
super(scope, id);
this.problemLetters = new sqs.Queue(this, "ProblemLetters", { retentionPeriod: Duration.days(14) });
this.queue = new sqs.Queue(this, "Queue", {
visibilityTimeout: Duration.seconds(60), // longer than the worker's timeout
deadLetterQueue: { queue: this.problemLetters, maxReceiveCount: 3 },
});
new events.Rule(this, "Listen", {
eventBus: props.eventBus,
eventPattern: props.listensFor,
targets: [new targets.SqsQueue(this.queue)],
});
props.worker.addEventSource(new SqsEventSource(this.queue, {
batchSize: 10,
reportBatchItemFailures: true,
maxConcurrency: Math.max(2, props.maxWorkers), // SQS event source concurrency cap (minimum 2)
}));
// Pain receptor: any problem letter needs a human to look at it
this.problemLetters.metricApproximateNumberOfMessagesVisible().createAlarm(this, "ProblemLettersAlarm", {
threshold: 1,
evaluationPeriods: 1,
alarmDescription: `${id}: letters are failing repeatedly`,
});
}
}
The Chef's worker
When EventBridge delivers to SQS, each message body is the whole event; the order is in detail.
// services/chef/src/senses/new-order.inbox.ts
import type { SQSEvent, SQSBatchResponse } from "aws-lambda";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, PutCommand } from "@aws-sdk/lib-dynamodb";
const db = DynamoDBDocumentClient.from(new DynamoDBClient({}));
export const handler = async (event: SQSEvent): Promise<SQSBatchResponse> => {
const batchItemFailures: { itemIdentifier: string }[] = [];
for (const record of event.Records) {
try {
const order = JSON.parse(record.body).detail; // the Waiter's announcement
await db.send(new PutCommand({
TableName: process.env.TABLE_NAME,
Item: { pk: `TICKET#${order.orderId}`, sk: "TICKET", status: "COOKING", items: order.items, tableNumber: order.tableNumber },
ConditionExpression: "attribute_not_exists(pk)", // hearing it twice won't cook twice
}));
} catch (err: any) {
if (err.name === "ConditionalCheckFailedException") continue; // duplicate — already cooking
batchItemFailures.push({ itemIdentifier: record.messageId }); // retry only this letter
}
}
return { batchItemFailures };
};
When the dish is ready, kitchen staff call POST /kitchen/tickets/{orderId}/ready; that handler updates the ticket to READY and announces OrderReady (same voice pattern as the Waiter, with source cafe.chef).
The Chef's DNA (abridged)
// services/chef/infra/chef.ts — inside the constructor
const memory = new dynamodb.Table(this, "Memory", { /* pk/sk, on-demand, PITR — as for the Waiter */ });
const newOrderWorker = new nodejs.NodejsFunction(this, "NewOrderWorker", {
/* runtime, ARM, memory from profile, tracing */
entry: path.join(__dirname, "../src/senses/new-order.inbox.ts"),
environment: { TABLE_NAME: memory.tableName },
});
memory.grantReadWriteData(newOrderWorker);
new Inbox(this, "Inbox", {
eventBus: props.eventBus,
listensFor: { source: ["cafe.waiter"], detailType: ["OrderPlaced"] },
worker: newOrderWorker,
maxWorkers: PROFILES[props.lifestyle].workerConcurrency,
});
const markReady = new nodejs.NodejsFunction(this, "MarkReady", { /* … */ entry: path.join(__dirname, "../src/senses/mark-ready.http.ts") });
memory.grantReadWriteData(markReady);
props.eventBus.grantPutEventsTo(markReady);
props.api.addRoutes({
path: "/kitchen/tickets/{orderId}/ready",
methods: [apigw.HttpMethod.POST],
integration: new HttpLambdaIntegration("MarkReadyIntegration", markReady),
});
The Cashier and the Messenger
Both follow the same recipe:
| Human | Inbox listens for | Worker does | Also has |
|---|---|---|---|
| Cashier | cafe.waiter / OrderPlaced |
Creates a bill (amount from the event) | POST /payments → charges via the payment provider (hands with timeout and idempotency key) → announces PaymentCompleted or PaymentFailed |
| Messenger | cafe.chef / OrderReady and cafe.cashier / PaymentCompleted |
Sends the customer a message (in the pilot: logs it, or publishes to an SNS topic for SMS/email) and records it with a TTL | Nothing else — a happy, focused human |
The Messenger's rule listens to two humans at once:
listensFor: {
source: ["cafe.chef", "cafe.cashier"],
detailType: ["OrderReady", "PaymentCompleted"],
},
Wire them into the society
// infra/cafe-stack.ts — after the Waiter
new Chef(this, "Chef", { eventBus: townSquare, api: frontDoor, lifestyle: props.lifestyle });
new Cashier(this, "Cashier", { eventBus: townSquare, api: frontDoor, lifestyle: props.lifestyle });
new Messenger(this, "Messenger", { eventBus: townSquare, lifestyle: props.lifestyle });
Deploy, place an order, mark it ready, pay — and follow the order through each human's logs and the X-Ray service map.
Notice what never happened: the Waiter didn't change when the Chef, Cashier and Messenger joined. That's the society working as intended — new humans join by listening, not by rewiring.
Going further: hire a manager
If the café later needs strict rules ("never start cooking until payment is authorised; refund if the kitchen can't cook"), replace part of this choreography with a Step Functions manager running the order saga, as shown in the manager.