Examples

Serverless Messaging


A request that waits for slow work makes the caller pay for every second of it, and a service that calls three others directly fails whenever any one of them does. Messaging breaks both couplings: the work is written down, the caller is answered, and whoever does the work picks it up when it can. This page takes apart an architecture built on the two messaging components, a queue and a topic, used on their own and chained together.


The Architecture

Requests enter at domain and reach api, which never does the work itself. It puts jobs on jobs and renders, and announces events on events. Everything to the right of api happens after it has already answered.

ComponentWhat it does
apiA function on a hostname that accepts work and returns at once
jobsA queue of short jobs, each handled by one invocation of worker
rendersA queue of heavy jobs, worked by the renderer container
workerA function invoked once per job, which announces the result
rendererA long-running container that polls its queue and scales with the backlog
eventsA topic: every event published to it is copied to each subscribed queue
audit, alertsOne queue per subscriber, alerts filtered to high-severity events
auditor, alerterThe functions that react to those events
domainA domain you have verified, and the hostname api answers on
{
  "version": "2.0",
  "platform": "aws",
  "components": [
    { "type": "Network", "name": "network" },
    {
      "type": "Function",
      "name": "api",
      "runtime": "python3.12",
      "handler": "app.api",
      "source": {
        "git": {
          "url": "github.com/you/app",
          "commands": ["zip app.zip app.py"],
          "artifact": "app.zip"
        }
      }
    },
    {
      "type": "Function",
      "name": "worker",
      "runtime": "python3.12",
      "handler": "app.worker",
      "source": {
        "git": {
          "url": "github.com/you/app",
          "commands": ["zip app.zip app.py"],
          "artifact": "app.zip"
        }
      }
    },
    {
      "type": "Function",
      "name": "auditor",
      "runtime": "python3.12",
      "handler": "app.auditor",
      "source": {
        "git": {
          "url": "github.com/you/app",
          "commands": ["zip app.zip app.py"],
          "artifact": "app.zip"
        }
      }
    },
    {
      "type": "Function",
      "name": "alerter",
      "runtime": "python3.12",
      "handler": "app.alerter",
      "source": {
        "git": {
          "url": "github.com/you/app",
          "commands": ["zip app.zip app.py"],
          "artifact": "app.zip"
        }
      }
    },
    {
      "type": "Container",
      "name": "renderer",
      "source": {
        "git": { "url": "github.com/you/app", "ref": "main" }
      }
    },
    { "type": "Queue", "name": "jobs" },
    { "type": "Queue", "name": "renders" },
    { "type": "Topic", "name": "events" },
    { "type": "Queue", "name": "audit" },
    { "type": "Queue", "name": "alerts" },
    { "type": "ImportedDomain", "name": "domain",
      "zone_name": "example.com", "zone_id": "Z0EXAMPLE123456" }
  ],
  "connections": [
    { "from": "domain", "to": "api", "host": "api.example.com" },
    { "from": "network", "to": "renderer" },
    { "from": "api", "to": "jobs" },
    { "from": "jobs", "to": "worker" },
    { "from": "api", "to": "renders" },
    { "from": "renders", "to": "renderer",
      "min_tasks": 1, "max_tasks": 3 },
    { "from": "api", "to": "events" },
    { "from": "worker", "to": "events" },
    { "from": "renderer", "to": "events" },
    { "from": "events", "to": "audit" },
    { "from": "events", "to": "alerts",
      "filter": { "severity": ["high"] } },
    { "from": "audit", "to": "auditor" },
    { "from": "alerts", "to": "alerter" }
  ]
}

The four functions are four handlers in one file, app.py, built into one zip and started at different entry points. The container is a short polling loop with a Dockerfile beside it. It declares no ports, so nothing can route a request to it, and it is the only workload on the network because it is the only one that is a long-running service.


The Wiring

Every connection here points in the direction a message travels, and each one grants exactly one capability. api → jobs injects JOBS_QUEUE_URL and permission to send to that queue and no other. api → events does the same for the topic with EVENTS_TOPIC_ARN, which allows publishing and nothing else. Neither lets api read anything back, so the sender cannot see who consumes its messages, which is the point.

jobs → worker is the queue on its own: point-to-point work. Each message invokes worker once with the message in its event, and the message is deleted when the function returns. If it raises or times out, the message comes back and is retried, up to the queue’s max_attempts, which defaults to five. After that it moves to a dead-letter queue every Queue has, where it is kept for 14 days instead of being lost. A queue feeds exactly one consumer, so two functions can never race for the same job.

renders → renderer is the same contract with a container on the other end. The container receives the queue’s address and permission to receive, delete, and extend messages, and its own code runs the loop: fetch a batch, process each message, delete it when done. A message not deleted within processing_timeout, 300 seconds by default, is delivered again. min_tasks keeps one worker warm and max_tasks lets the platform add up to three while messages are waiting. Extra workers are removed only after the queue has been idle for ten minutes, so scaling never stops one mid-job.

events → audit and events → alerts are the topic: fan-out. A topic delivers only to queues, so every subscriber gets its own copy, its own retries, and its own dead-letter queue. A slow or failing alerter cannot hold back auditor. The message lands on each queue exactly as it was published, and the filter on alerts delivers only events whose JSON body has severity set to high. The filtering happens before the queue, so alerter is never invoked for anything it would ignore.

worker → events and renderer → events are where the two meet. When a job finishes, its consumer publishes job.completed or render.completed to the same topic api publishes to. The finished work reaches the audit trail and, if it was urgent, the alerts, and neither worker knows those subscribers exist. A queue moves work forward one step; a topic tells everyone that a step happened.


The Decisions Behind It

A queue for work, a topic for facts. The test is how many parties should act on one message. A job, such as “render this scene”, should be done once, so it goes on a queue and exactly one consumer takes it. An event, such as “a render finished”, is a fact that any number of parties may care about, so it goes to a topic. Putting a job on a topic means it runs once per subscriber, and putting an event on a queue means the second interested party has to be wired into the first.

A function or a container behind a queue. Both consume the same way, and demand decides between them, as it does in Scheduled Jobs. A function costs nothing between messages and suits short work that arrives in bursts, but every message pays for an invocation and none may outrun the function’s timeout. A container suits work that is heavy, long, or steady: it holds a warm process, takes a batch per call, and has no ceiling but processing_timeout, which a job may extend while it runs. The price is that min_tasks: 1 keeps a worker on the clock while the queue is empty.

Filter at the subscription rather than in code. alerter could receive every event and return early for the ones it does not want. Filtering on the events → alerts connection instead means the subscriber’s intent is in the file where it is reviewed, its queue only ever holds work it will do, and it costs nothing for events it would have skipped. A message that is not JSON never matches a filter, so publishers in this architecture always send JSON.

Answer first, work later. api returns as soon as a message is accepted, before any job has run. The caller gets an acknowledgement and an id rather than a result, and the work survives a crash in any consumer because it is held by the queue until it is done. The alternative, calling worker and renderer directly, gives the caller a result and couples its latency and its failures to every service in the chain.

Adding a reaction to an event is a new queue, a new consumer, and two connections. Nothing that publishes changes: not api, not worker, not renderer, not their code. That is what a topic buys, and it is why the subscribers here are separate components rather than branches in one handler.


The Limits

Delivery is at least once and unordered. A consumer will occasionally see the same message twice, and two messages sent in sequence may arrive the other way round. Every consumer here has to be safe to run twice on the same input, usually by keying its work on the message id. A job that must happen in strict order is not a good fit for a plain queue.

The caller does not get the result. Once work is asynchronous, the answer has to live somewhere the caller can look for it later, which is usually a row in a Database or a KeyValueStore, or a file in a Bucket keyed by the id api returned. When the caller genuinely needs the answer in the same request, the work belongs behind that request instead, as it does in The Containerized Web App.

A chain is harder to follow than a call. One request here produces log lines in up to four places, so carrying the message id through every step is what makes a failure traceable. Messages that keep failing do not vanish, but they do pile up quietly in a dead-letter queue, which is worth watching. And a queue feeds exactly one consumer while a container works exactly one queue: a second kind of work means a second queue and a second worker, which is usually the design you wanted anyway.