Living Systems Handbook microservices as humans Bipin Singh
Pilot: Café Sukoon

The kitchen comes alive

2 min readChapter 29 of 34By Bipin Singh

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.

Key idea

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.

Bipin Singh
Written by Bipin Singh

Senior Full-Stack Engineer · AI & AWS. I design and build serverless systems on AWS — and love explaining them simply.

Work with me