Skip to content

Designing a service

Designing an ankka service means deciding which things in the domain are entities, what each one guarantees, and how the rest of the system learns about their changes. The component kinds are fixed, so most design decisions are about where a rule lives and what may be eventually consistent. This page describes the concepts that drive those decisions, then applies them to a checkout system.

The entity is the consistency boundary

An entity is one thing with an id, and the runtime guarantees that exactly one instance of the service handles that id at a time, processing its commands one after another. Inside one entity there are no races: a handler sees the current state, decides, and its effect is applied before the next command is read. That is the only place in ankka where a check and the change it guards happen atomically.

So the first design question for any rule is: which one entity can enforce it?

  • "A checked-out cart cannot change" is about one cart. The cart entity enforces it.
  • "Stock of a product cannot go below zero" is about one product. A stock entity per product enforces it.
  • "An order's total must not exceed the customer's credit" spans an order and a customer. No single entity sees both, so it cannot be enforced by one handler. It becomes a process with a step that reserves credit on the customer entity and a step that confirms the order — a workflow.

Size entities by their rules, not by their nouns. An entity that holds too much serializes unrelated changes through one queue; a single "all products" entity makes every stock change wait for every other. An entity that holds too little cannot enforce the rule that justified it. Two further consequences of one-at-a-time processing:

  • Avoid hot entities. Every command to one id is handled in sequence on one instance. A global counter or a single "registry" entity becomes the throughput limit of the whole service.
  • Never keep an unbounded list in an entity to make listing easy. The state grows forever and every command pays to load it. Listing is a job for a view.

Commands, events and state

A command is a request to change something, and it can be refused. An event is a fact that has happened, and it cannot. An event sourced entity's command handler validates the command against the current state and persists events; its event handler applies an event to the state and does nothing else.

Design the events as the durable record of the domain. They outlive the code that wrote them, they are replayed every time the entity is loaded, and views and consumers read them. Name them in the past tense after what happened in the domain (ItemAdded, PaymentAuthorised), not after the command that caused them, and put in them everything that is needed to apply them without recomputing — including anything that is not repeatable, such as the time or an identifier generated by the handler.

Choose between the two entity kinds by whether that history is worth keeping:

Choose When
Event sourced entity the history matters; other components react to individual changes; you want to add read models later from the full record
Key value entity only the current value matters and nobody reacts to how it got there: settings, preferences, a cache

When unsure, choose event sourced. A read model that did not exist when the events were written can still be built from them later; a key value entity's past values are gone.

Read models are views, and views lag

An entity answers questions about itself, by id. Any other question — every order for a customer, every cart containing a product, the ten largest accounts — is answered by a view: a table of rows, one per source id, maintained from the source's changes and queried with SQL. This split between the side that decides and the side that reads is the CQRS pattern, and ankka builds it in.

A view is updated after the change it reflects, usually within a fraction of a second. So:

  • Read your own write from the entity, not the view. After a command succeeds, a query to the same entity sees the change. A view may not yet.
  • Never make a decision on view data that must be exact. A view is right for "show the customer their orders" and wrong for "refuse the order if the customer has three open ones"; the second is an entity's or a workflow's job.
  • Shape each view for its query. A view is cheap. Several views over the same events, each shaped for one screen or one API, are better than one general view queried in complicated ways.
  • A view reads one source. It cannot join two entities' changes into one row. A screen that needs both reads two views, or the entity that owns the rule copies what it needs into its own events.
  • A handful of known ids is not a view. When the caller already holds the ids, fan the entity queries out with invokeAsync and collect them; a view is for questions whose answer is which ids.

Consistency and delivery states the guarantees precisely.

Reacting to change with consumers

When something should happen because something else changed — send an email when an order ships, update another component, tell another service — write a consumer over the source. The entity stays unaware of what reacts to it, which keeps its handlers free of side effects and lets reactions be added without touching it.

A reaction that is itself several steps, or that must be undone if a later step fails, is a consumer that starts a workflow: the consumer's only job is then to start it under an id derived from the source, so a redelivered change addresses the workflow already running.

Consumers are delivered each change at least once. After a crash, a change may be delivered again. Make every reaction safe to repeat: check state before acting, derive ids deterministically from the source (an email keyed by order id and event, not a random id), and let the target entity refuse a duplicate.

Processes that span entities are workflows

A process that touches several entities, or calls something outside the service, and that must either complete or be undone, is a workflow. Each step does one piece of work and says what happens next. The runtime records every transition before the next step starts, so a workflow that crashes resumes at the step it was on rather than starting again.

Design a workflow around what can fail:

  • Every step may run more than once. A step that crashed after acting but before its transition was recorded is run again. Make each step's call idempotent — pass the workflow id as an idempotency key to a payment provider, have the stock entity recognise a reservation it already made.
  • Declare recovery, do not improvise it. A step's timeout, how many times it is retried, and the step to fail over to when retries run out are settings. The failover step is where compensation happens: it reads what the workflow accumulated and undoes it.
  • Keep the workflow's state to what the process needs. It is the process's memory, not a copy of the entities it coordinates.

Time is a timer

"Cancel an unpaid order after thirty minutes", "remind the customer tomorrow", "expire this hold" are timers. A component schedules a named timer with a delay and the call to make; the runtime stores it in the database and makes the call when it is due, retrying until the handler reports success. Scheduling again under the same name replaces the timer, which makes "extend the deadline" a single call.

The handler runs at least once and possibly after the situation has changed: the order may have been paid a second before the timer fired. A timed action checks the current state through the component client and reports success when there is nothing left to do. A handler that returned an error for work that no longer applies would be retried forever.

A workflow step can also pause with a timeout, which is simpler when the deadline belongs to that workflow's own process.

Agents are components with sessions and tools

An agent is a component whose handler describes a model call: instructions, the message, the tools the model may use, the guardrails to apply. The runtime runs the loop. Design decisions for an agent are about what it may see and do:

  • Tools are the agent's reach. A tool is a function the model may call, usually one that calls another component. Give an agent read-only tools where possible. A tool that changes something should call an entity command, so the entity's rules still apply to what the model asked for.
  • The session is the memory. Conversation history is stored per session id, as an event sourced entity. Agents that share a session id share a conversation, which is how several specialists collaborate on one task. Requests to one session are handled one at a time, so two turns never interleave.
  • Guardrails check input and output. Use an input guardrail for anything that must never reach the model, and an output guardrail for anything that must never be stored or returned.
  • Put multi-agent coordination in a workflow when it takes minutes or costs money. A workflow journals each agent's answer, so a crash does not pay for the same model calls twice.

Agents and sessions explains the model in full, and Designing with agents covers when an agent is the right component, how to design its tools and sessions, and how to plan for its failures and cost.

The edge is an endpoint with an ACL

HTTP endpoints are the only way into a service from outside. An endpoint turns a request into component calls and back, and every endpoint states an access control list: deny everyone, allow everyone, allow requests that satisfy a predicate, or authenticate the caller and read who they are. In Scala acl is abstract, so an endpoint without a decision does not compile. In Python an endpoint that does not set acl allows everyone, so set it on every endpoint deliberately.

Keep endpoints thin. Validation of domain rules belongs in the entity, which returns a refusal with an error code the endpoint turns into the right HTTP status. Cross-entity checks that are only there to catch mistakes — "does this customer exist?" — belong in the endpoint, which can query before calling; checks that must hold belong in an entity or a workflow.

Deploying a service does not make it reachable from outside the cluster. Exposing it is a separate decision, and it changes who can reach an endpoint, never who is allowed to call it.

Service boundaries

A service is a unit of deployment, scaling and data ownership. Within one service, components call each other through the component client, which addresses any registered component by id. Across services, there is no component client: services talk over HTTP, through each other's endpoints, or through broker topics, with a consumer producing on one side and a view or consumer subscribing on the other.

Each service has its own database and must never share it. That makes the boundary real: a service cannot read another's events or rows, so what crosses the boundary is exactly what one side publishes.

Draw boundaries where these differ:

  • Ownership and release cadence. A team that deploys on its own schedule owns its own service.
  • Scaling. A part of the system with very different load, such as an agent-heavy assistant beside a transactional core, can be scaled on its own.
  • Consistency. Anything that must be consistent with something else belongs in the same service, and usually the same entity. Across a service boundary everything is eventually consistent.

Start with fewer, larger services. Splitting one later means publishing events that were internal; merging two means migrating a database.

Publish events deliberately. An entity's events are its internal record and change as the domain does. What another service sees should be a separate, stable message type that a consumer produces from those events, so the domain can change without breaking the services that listen.

Schema evolution is part of the design

Events, state and view rows are stored as JSON under a manifest, a name for the type. Stored data is read by every future version of the service, and during a rolling update two versions run at once. So:

  • Adding a new event type, or a new field with a default, is safe.
  • Renaming or removing a field, or changing its type, breaks reading data that already exists.
  • The manifest names and field names are the contract. A Python service and a Scala service reading the same journal agree on field names exactly.
  • Wire names of handlers and component ids are protocol too. Timers and in-flight calls address them.

Serialization and evolution covers the rules, and Handlers and wire names covers naming.

Worked example: a checkout

A shop needs customers to build a basket, check out and pay, have stock reserved so nothing is oversold, have unpaid orders cancelled after thirty minutes, see their order history, get help from an assistant, and tell the warehouse what to ship. Here is how each requirement maps onto components.

Requirement Component Why this one
A customer adds and removes items; a placed order cannot change order event sourced entity The rule is about one order. Events such as ItemAdded, OrderPlaced, OrderPaid and OrderCancelled are the history other components react to.
Stock of a product never goes below zero stock event sourced entity, one per product The rule is about one product, so each product's entity serializes its own reservations. One entity for all stock would serialize the whole shop.
Checkout reserves stock for every line, takes payment, then confirms — or releases what it reserved checkout workflow, id = order id The process spans several stock entities, the order and an external payment provider, and must complete or be compensated.
Payment is authorised by an external provider a step of the checkout workflow The step calls the provider over HTTP with the order id as its idempotency key, so a retried step cannot charge twice. Its failover step releases the stock.
An unpaid order is cancelled after thirty minutes a timer named after the order, and an order-timeouts timed action Scheduled when the order is placed. The handler asks the order for its state and cancels only if it is still unpaid; otherwise it reports done.
A customer sees their orders orders-by-customer view over the order's events A question across orders. Rows lag slightly, which is fine for a list.
A customer's saved address and preferences customer-profile key value entity Only the current value matters.
The warehouse is told what to ship shipment-publisher consumer over order events, producing to an orders-to-ship topic The warehouse is another service. The consumer publishes a stable ShipmentRequested message rather than the order's internal events. The warehouse must tolerate a duplicate.
A support assistant answers questions about orders support agent, session = the customer's conversation id Its tools are read-only: look up an order by id, list the customer's orders from the view. Cancelling goes through the order entity's cancel command, so the order's own rules decide whether it is allowed.
Customers and staff call the service a public orders endpoint that authenticates customers, and a separate admin endpoint with a stricter ACL Each endpoint has one ACL, so different audiences get different endpoints.

Some decisions this table implies:

  • The order does not reserve stock itself. An order handler cannot call the stock entities; it can only persist events about the order. Coordination is the workflow's job, and the order entity only records the outcome (OrderPaid, OrderCancelled) when the workflow tells it.
  • The workflow id is the order id. Starting the checkout twice for one order addresses the same workflow. Its start command sees from the workflow's state that checkout is already under way and refuses or ignores the second request, so a double-clicked button does not start two payments.
  • Cancellation has one door. The timer, the assistant and the admin endpoint all cancel through the same order command. The rule for when an order may be cancelled is written once.
  • The view is not used for decisions. The assistant lists orders from the view, but a cancellation reads the order entity, which is always current.
  • The warehouse is a separate service because another team deploys it and it scales with shipments, not checkouts. Everything else stays in one service, because it must be consistent together.

Build the pieces with the guides: event sourced entities, workflows, timers, views, consumers, broker topics, agents and HTTP endpoints.