Speak the domain’s language.
SweetTalk is an opt-in language for Domain-Driven Design. Its words expand into SugarORM schemas and changesets and Cold Brew jobs, so the domain keeps its own vocabulary and Caramel keeps one way to store and run it.
| Contract | Value |
|---|---|
| Needs | A Caramel application that requires caramel/sweet_talk, and PostgreSQL for its specs |
| You will have | A domain in app/domain/ with its own words |
| Status | Experimental: new in 0.13.0, until an application has run it in production |
| Proved by | frappe check · frappe db diff · frappe corretto |
Prefer to see it built first? Build a box office follows one domain in order, from the first command to passing specs. This guide lists every word and rule.
SweetTalk is new in Caramel 0.13.0. Until an application has run it in production, it is experimental, and its upgrade notes say how to move the names. The examples are excerpts of the tea shop, the tea house and the tenant shop that its specs run against PostgreSQL, in the framework’s spec/sweet_talk/support/ and spec/sweet_talk_tenancy/support/; the action is from the Box Office path. Accepted in ADRs 0030, 0031, 0033 and 0034 to 0036.
The guide in seven stages
25 sections · read in order the first time
- 1 · Start
- 2 · Draw boundaries
- 3 · Model
- 4 · Query and decide
- 5 · React
- 6 · Operate
- 7 · Limits
Stage 1 · Start
Opt in
Let Frappé start a domain. It adds require "caramel/sweet_talk" after require "caramel" and require "../app/domain/*" to config/application.cr, writes app/domain/shop.cr with that context, and a spec in spec/domain/. A plain application compiles none of SweetTalk.
frappe make domain Shop Ordering --means="choosing teas and checking out"Expect: Prints Generated app/domain/shop.cr and Generated spec/domain/shop_spec.cr. It also prints Generated config/application.cr when it adds the requires, and Generated .claude/skills/sweettalk/SKILL.md when the application has no SweetTalk skill.
The skill lists the rules agents most often break. Each ends with a link to its section here, so the guide stays the one source. An existing skill, edited or not, is left alone.
A multi-tenant application passes --central for a context every tenant shares: Share a domain between tenants.
By hand, require SweetTalk after Caramel:
require "caramel"
require "caramel/sweet_talk"Then include SweetTalk in the module that holds your domain. The words exist only there: a context or has anywhere else is an ordinary call.
module TeaShop
include SweetTalk
context Catalogue, means: "the teas on our shelves" do
# values, aggregates and projections
end
endFind the Domain-Driven Design patterns
21 of the 25 patterns below have a SweetTalk word. The table names the word and the section that states its rules. A pattern SweetTalk lacks says so.
| Pattern | In SweetTalk | Rules |
|---|---|---|
| Bounded context | context, a module with its own vocabulary. | Draw the contexts |
| Ubiquitous language | means: on every noun, and SweetTalk.glossary, which lists each with its kind and context. | Give every noun a meaning |
| Entity | entity, which lives inside one aggregate and is saved with it. | Declare aggregates and entities |
| Value object | value: a number or a string with its bounds, or a value with parts, stored one column per part (<attribute>_<part>). | Name the values, values with parts |
| Aggregate | aggregate, a root with an id that only its commands change. Its entities reach the database only through it: a context that names Rows does not compile. | Declare aggregates and entities, commands |
| Repository | The aggregate’s own create, find, find? and all, and the queries finds and counts declare: filtered by attributes, states and specifications, sorted with order:, limited with first:, or narrowed by a block that never names Rows and always returns aggregates. | Find, count and retire aggregates |
| Factory | factory. | Make aggregates with factories |
| Domain service | service, which changes at most one aggregate. | Name operations with services |
| Domain event | event and emit. Each event records who acted and what caused it. | Follow events with policies, Record who acted |
| Event handler (policy) | whenever, run as a Cold Brew job. | Follow events with policies |
| Side-effect-free function | the, a derived value computed on every read, and plain Crystal methods in an aggregate’s or an entity’s body. | Guard the lifecycle and the invariants |
| Specification | specification, a rule stated once in where: and read both in Crystal, as a predicate such as order.astray?, and in PostgreSQL, by satisfying:. | Name rules with specifications |
| Lifecycle (state machine) | moves, checked in Crystal and held in PostgreSQL by a CHECK constraint on the state column. A PostgreSQL enum type is not used, because ALTER TYPE cannot drop a value. | Guard the lifecycle and the invariants |
| Process manager or saga | process, with deadline and compensate. | Run processes |
| Read model | projection, queried with finds and counts. | Keep read models |
| Transactional outbox | Built in: a command enqueues its policies’, projections’ and processes’ jobs in its own transaction, and publishes its notification with pg_notify, which PostgreSQL delivers when that transaction commits. | Follow events with policies |
| Conformist | follows:, which adopts another context’s types as they are. | Draw the contexts |
| Anticorruption layer | border Other, which translates another context’s events, values and ids into this one’s. Code in it may also read Other:: directly, or call Other.price_of, which draws a dependency edge. border Cards, outside: "…" is the border with a system outside the program, the only code that may name Caramel::Outbound or HTTP::Client, a check on the code as written. | Draw the contexts, Translate outside systems at a border |
| Open-host service | The services a context lists in publishes: in one binary, a set of calls other contexts may make. | Publish a language |
| Published language | publishes, the types and services other contexts may name; any other name of the context does not compile outside it. | Publish a language |
| Shared kernel | Not planned. central: true is tenancy, not a shared kernel. | Share a domain between tenants |
| Partnership | Not planned. Two contexts may react to each other’s events, but their dependency edges may not form a cycle. | Draw the contexts |
| Customer/supplier | Not planned. A supplier’s promise to its customers is what it publishes. | Publish a language |
| Distillation (core, supporting and generic subdomains) | subdomain: on context, carried by every glossary term. The label changes no rule the compiler checks. | Mark the core domain |
| Event sourcing | Not planned. State lives in rows, and there is no event log. | Not covered yet |
Some differences from the patterns as usually described are deliberate:
- The repository lives on the aggregate. The aggregate already meets the repository’s rules: it reaches roots only, offers a collection’s interface (
create,find,find?,all), and names its queries in domain terms withfinds, whose keys are attributes and states the compiler checks, never SQL or SugarORM’s query syntax; a separate repository word would add a second name for the same thing (ADR 0030 decision 2, ADR 0031 decision 3). - One aggregate per transaction, services included. A command, a service, a policy and a process step each change at most one aggregate, so each lock and each invariant stays its aggregate’s own; work across aggregates is a process, or a policy that reacts to an event (ADR 0030 decision 5, ADR 0031 decision 5).
- Events belong to their aggregate. An aggregate emits only its own events and an entity emits none, because the aggregate guards the invariants its entities share; an entity changes through its aggregate’s commands (ADR 0030 decision 2).
- Contexts are modules of one binary. Contexts call each other in the same process and react to each other’s events through Cold Brew jobs, so there is no RPC or HTTP transport between them (ADR 0030, Reasons).
central: trueis tenancy. It shares a context’s rows between the tenants of a multi-tenant application; it shares no types between contexts, so it is not a shared kernel (ADR 0033 decision 2).- State lives in rows. An event drives its policies, projections and processes and is not kept, because SweetTalk expands into SugarORM tables and keeps state in PostgreSQL; there is no event store, and none is planned; Not covered yet says what that leaves out (ADR 0030 decisions 4 and 6).
Specs run the domain against real PostgreSQL. Each Corretto example rolls back, and drain_queue! runs the jobs a command enqueued at once, so a spec of a policy, a projection or a process waits for no worker; drain_queue!(db, include_scheduled: true) runs a deadline whose time has not come.
Give every noun a meaning
Every context, value, aggregate, entity, event, projection, specification, service and process carries means:. A noun without one does not compile, and SweetTalk.glossary lists every entry with its kind, its context and that context’s subdomain, so the ubiquitous language is part of the program.
The same word may mean different things in two contexts:
| Noun | Means |
|---|---|
Catalogue::Tins | “a count of tins on the shelf” |
Fulfilment::Tins | “tins of one tea packed in a parcel” |
Stage 2 · Draw boundaries
Draw the contexts
A context is a bounded context: a module with its own vocabulary. follows: adopts another context’s types as they are. A border is the one place where a context may name a context it does not follow, and it translates that context’s values and ids into its own. Here each context reacts to the other’s event:
context Ordering, follows: Catalogue, means: "choosing teas and checking out" do
# Ordering may name Catalogue::Tea anywhere.
border Fulfilment do
whenever Fulfilment::Parcel::Dispatched do |dispatched|
Order.find(Order::Id.new(dispatched.order)).ship
end
# …
end
end
context Fulfilment, means: "packing and dispatching parcels" do
value Tins, Int32, at_least: 1, means: "tins of one tea packed in a parcel"
# …
border Ordering do
whenever Ordering::Order::Placed do |placed|
packings = placed.lines.map do |line|
Parcel::Packing.new(line.tea.raw, Tins.new(line.quantity))
end
address = placed.deliver_to
label = Label.new(street: address.street, city: address.city,
postcode: address.postcode.raw)
Parcel.pack placed.order_id.raw, packings, label
end
# …
end
endThe context map is checked when the program compiles:
| Condition | Outcome |
|---|---|
follows: B | draws a dependency edge to B |
Anything in border B other than the event a whenever names | draws a dependency edge to B |
A whenever that reacts to one of B’s events | draws an event edge |
Naming B::, or calling B.price_of, in a context that follows B | allowed anywhere, unless B publishes and leaves the name out |
Naming B::, or calling B.price_of, inside border B | allowed; a call draws a dependency edge too |
| A foreign name or call anywhere else | fails compilation |
| An edge to an unknown context | fails compilation |
| A cycle of dependency edges, such as Ordering and Fulfilment following each other | fails compilation |
| Ordering and Fulfilment reacting to each other’s events | allowed |
For example, a foreign name outside a border stops the build with “Shop::Ordering names Catalogue:: outside a border: move that code into border Catalogue do … end, or declare follows: Catalogue”.
SweetTalk.context_map returns the edges.
Mark the core domain
subdomain: on a context says which part of the business it serves:
| Value | Use for |
|---|---|
:core | what sets the business apart |
:supporting | what the core needs but could be built by anyone |
:generic | a problem many businesses share |
It is optional and changes no rule, because no check can measure how carefully a core is modelled. Every glossary term carries its context’s subdomain, so SweetTalk.glossary.select { |term| term.subdomain == "core" } lists the core domain’s language. Any other value does not compile.
context Pricing, subdomain: :supporting, means: "what each tea costs" do
# …
end
context Billing, follows: Pricing, subdomain: :core,
means: "charging members for their teas" do
# …
endPublish a language
An upstream context can promise less than all of its model. publishes lists the types and services other contexts may name: its published language, and, in one binary, its open-host service. Naming a type publishes what it contains, so publishing Tea would publish Tea::Id and its commands too. publishes comes after the names it lists.
context Pricing, subdomain: :supporting, means: "what each tea costs" do
value Cents, Int64, at_least: 0, means: "an amount of money"
aggregate Tea, means: "one tea and its price" do
# …
end
service price_of(tea : Tea::Id), returns: Cents, means: "what one tea costs today" do
Tea.find(tea).price
end
publishes Cents, Tea::Id, price_of
end
context Billing, follows: Pricing, subdomain: :core,
means: "charging members for their teas" do
service bill(tea : Pricing::Tea::Id), returns: Bill::Id,
means: "bill a member for one tea at today's price" do
Bill.open(Pricing.price_of(tea)).id
end
end| Condition | Outcome |
|---|---|
| A context names a type or service the publisher does not list, whether it follows the publisher or reads it in a border | fails compilation at the line that names it |
publishes lists a name the context does not declare | fails compilation |
| The publisher is declared after the context that names it | fails compilation; the program’s checks find the name, without a line |
| A context reacts to an event of a publishing context | allowed: events stay open to every context that may name them, because a follower reacts to them and a reaction depends on an event’s fields |
| A context does not publish | keeps the rules above |
For example, “Shop::Ordering names Catalogue::Tea, which Catalogue does not publish; it publishes Cents, Tea::Id, price_of: use one of those, or publish Tea in Catalogue”.
Translate outside systems at a border
A payment gateway or a carrier has a model of its own. border Cards, outside: "…" declares a module Cards in the context: the only code in it that may name Caramel::Outbound or HTTP::Client, and the place that turns the system’s answers into the context’s values and refusals.
The check reads the code as written, as the Rows:: and Other:: checks do, so an alias or a method defined outside the context escapes it. outside: is the line its glossary entry shows, and the context map gains an outside edge.
An outside system emits no events; a webhook arrives through an action, which runs a command.
Call the system from a whenever, after the command that needs it commits, so that a slow answer never holds an aggregate’s row lock.
Specs fake it at the wire with Corretto.stub_wire, as the Corretto reference’s “Third parties are faked at the wire” states. Read the Corretto reference →
| Condition | Outcome |
|---|---|
Caramel::Outbound or HTTP::Client named outside a border …, outside: | fails compilation |
An outside: border named like a context | fails compilation |
An outside: border holding a whenever | fails compilation |
whenever Bill::Opened do |opened|
reference = Cards.charge(opened.bill_id.raw, opened.amount)
Bill.find(opened.bill_id).settle reference
end
compensate Bill::Opened do |opened, refusal|
Bill.find(opened.bill_id).decline
end
border Cards, outside: "the card gateway we charge through" do
CHARGES = "https://cards.example/v1/charges"
def self.charge(bill : Int64, amount : Pricing::Cents) : Reference
headers = HTTP::Headers{"Content-Type" => "application/json"}
body = {bill: bill, amount: amount.raw}.to_json
response = Caramel::Outbound.post(CHARGES, headers, body)
case response.status_code
when 200 then Reference.new(JSON.parse(response.body)["id"].as_s)
when 402 then raise SweetTalk::Refusal.new("The card was declined.")
else raise "Cards answered #{response.status_code}: #{response.body}"
end
end
endStage 3 · Model
Name the values
value Tins, Int32, at_least: 0, means: "a count of tins on the shelf"
value Cents, Int64, at_least: 0, means: "an amount of money"
value Quantity, Int32, within: 1..10, means: "tins of one tea in one order"A value wraps an Int32, an Int64 or a String, or has parts. An integer value takes at_least:, at_most:, within: and times:. It is validated when it is built and by every arithmetic result. + and - take the same value or an integer, and * takes an integer. times: Quantity on Cents would let * take a quantity. A value is stored as a column of its raw type and rebuilt, and so validated again, when it is loaded.
PostgreSQL checks the bounds too. Every column that stores Tins gets a CHECK constraint, such as check_catalogue_teas_stock, so a write that goes around SweetTalk, through SQL or another service, is refused when it is written. Bounds are therefore integer literals. database_check: false keeps a value’s bounds in Crystal only, where they may name a constant.
| Condition | Outcome |
|---|---|
A build or arithmetic result outside the bounds, such as @stock -= tins below zero | refuses with “Tins must be at least 0” |
+ or - of tins and cents | fails compilation |
* of Cents by a Quantity without times: Quantity | fails compilation |
Name strings and values with parts
value Email, String, matches: /\A[^@\s]+@[^@\s]+\z/, length: 3..254,
means: "an address we can write to"
value Postcode, String, matches: /\A[A-Z0-9][A-Z0-9 ]{1,8}[A-Z0-9]\z/,
means: "a postal code, in capitals"
value Address, means: "where a parcel goes" do
part street : String
part city : String
part postcode : Postcode
never "An address needs a street and a city." { street.blank? || city.blank? }
endA string value takes matches: and length:, and has no arithmetic. It is stored as a text column. Its pattern is checked in Crystal only, because PostgreSQL reads regular expressions differently.
| Condition | Outcome |
|---|---|
Email.new("ada"), a string that does not match the pattern | refuses with “Email must match /\A[^@\s]+@[^@\s]+\z/” before anything is written |
| A write around SweetTalk with a length outside 3..254 | refuses, by the CHECK check_ordering_orders_receipt_to_length_3_254 |
A value with parts is built with one keyword per part, as in Address.new(street: "1 Leaf Lane", city: "Camellia", postcode: Postcode.new("CA1 1TE")). It checks its never and always when it is built, and equals another address with the same parts. A part is a column type or a value that wraps one. has deliver_to : Address stores one column per part: deliver_to_street, deliver_to_city and deliver_to_postcode. A part’s value brings its constraints to its column.
Declare aggregates and entities
An aggregate is a root with an id, such as Order::Id, and a repository: find, find?, all, and create unless it has a factory. An entity lives inside one aggregate and is saved with it. Given to another aggregate’s create or factory, it is stored again as that aggregate’s own row, and the first aggregate keeps its own.
aggregate Order, means: "what a member buys at one checkout" do
has member : Account
has receipt_to : Email
has deliver_to : Address
has lines : Array(Line)
has late_parcel : Bool = false
moves open >> placed >> shipped, open >> cancelled, placed >> cancelled
never "An order needs at least one tea." { placed? && lines.empty? }
entity Line, means: "one tea in an order, its price when added, and how many tins" do
has tea : Catalogue::Tea
has price : Catalogue::Cents
has quantity : Quantity
the cost : Catalogue::Cents = price * quantity.to_i
end
the total : Catalogue::Cents = lines.sum(&.cost)
event Placed, lines : Array(Line), deliver_to : Address, means: "a member checked out"
# …
to add(tea : Catalogue::Tea, quantity : Quantity) do
refuse "Teas can be added only while the order is open." unless open?
refuse "#{tea.name} is already in this order." if lines.any? { |line| line.tea == tea.id }
@lines << Line.new(tea, tea.price, quantity)
end
to place do
placed!
emit Placed, lines: lines, deliver_to: deliver_to
end
endEach aggregate, entity, projection and process is a SugarORM schema with its own changeset, and every write goes through that changeset. has chooses the storage from the type:
has type | Stored as |
|---|---|
String, Int32, Int64, Bool, Float64 or Time | A column |
| A value that wraps a number or a string | A column of its raw type |
| A value with parts | One column per part |
| Another aggregate | A foreign key; its getter returns that aggregate’s id: line.tea is a Catalogue::Tea::Id. Load the tea with Catalogue::Tea.find when you need it. |
One of the application’s own SugarORM schemas, as in has member : Account | A foreign key; its getter returns a SweetTalk::RecordId(Account), and order.member.load reads the account |
An Array of the aggregate’s own entity | A child table, loaded with preload |
has name : Type = expression gives an attribute a default, as in has held : Tins = Tins.zero. The column gets a SQL default when the expression is a literal, as in has late_parcel : Bool = false.
| Condition | Outcome |
|---|---|
A has with any other type | fails compilation |
A has of a Hash | fails compilation, with a pointer to the entity form |
A context that names Rows | fails compilation |
The schemas live in each context’s Rows module, so every change goes through a command. Table names take English plurals. The tea shop’s tables are catalogue_teas, ordering_orders, ordering_order_lines, ordering_sales, ordering_holds, fulfilment_parcels and fulfilment_parcel_packings. They are ordinary schemas, so frappe db diff derives their migrations.
Guard the lifecycle and the invariants
moves lists one or more chains of states, and the first state is the initial one. moves open >> placed >> shipped, open >> cancelled, placed >> cancelled stores the state and adds open?, placed?, shipped? and cancelled?, and the private movers placed!, shipped! and cancelled!. the is a derived value, computed on every read and never stored.
| Condition | Outcome |
|---|---|
A mover such as shipped! is called when the current state does not come just before it in a chain | refuses with “Order cannot become shipped while cancelled” |
A command leaves a never condition true | refuses |
A command leaves an always condition false | refuses |
PostgreSQL holds the lifecycle too. The state column stores each state as moves spells it, such as out_for_delivery, and a CHECK constraint such as check_ordering_orders_state_ef9524ce refuses any other text, so a write around SweetTalk cannot leave a row no state names. The eight hex digits change with the list of states, so frappe db diff replaces the constraint when you add or remove one, and frappe migrate stops, naming the constraint, while a row still holds a state you removed.
Change state with commands
to verb(arg : Type) do … end is a command, and it runs as one transaction:
- load the aggregate with its row locked through SugarORM’s
lock, so commands on one aggregate run one after another and the body sees the last committed state; - run the body;
- check every
neverandalways; - save the root through its changeset, then its entities;
- dispatch the queued events.
| Condition | Outcome |
|---|---|
refuse "message" | raises SweetTalk::Refusal; the transaction rolls back and the aggregate reloads what is stored, so neither the table nor the object keeps half a change |
Another aggregate’s command, a create or a factory runs while a command runs | raises SweetTalk::Boundary and rolls the command back |
create and a factory check the invariants before they insert. Each command, factory and service is a domain span in its trace, named like Ordering::Order#place; a refused one is marked failed.
A command changes one aggregate. A command may read another aggregate it is given: add copies the tea’s price into the line, so repricing the tea later does not change the order.
Make aggregates with factories
factory verb(args) do … end makes an aggregate. Arguments named like attributes set them, and the body runs as the aggregate’s first change and may emit. The aggregate is checked, saved and its events dispatched in one transaction, and each event names the new aggregate’s id.
factory open(member : Account, receipt_to : Email, deliver_to : Address) do
emit Opened, receipt_to: receipt_to
endOrdering::Order.open(account, Email.new("ada@example.com"), address) opens an order.
| Condition | Outcome |
|---|---|
| A factory leaves an attribute unset and no default covers it | fails compilation |
A factory named like a repository method, such as factory find | fails compilation |
| An aggregate with a factory | has no create, so every order starts with Opened |
Stage 4 · Query and decide
Find, count and retire aggregates
finds placed_for(member : Account), where: {member: member, state: :placed}
finds newest_for(member : Account, count : Int32), where: {member: member},
order: {id: :desc}, first: count
counts placed_count_for(member : Account), where: {member: member, state: :placed}
to forget do
refuse "Only a cancelled order can be forgotten." unless cancelled?
remove
endThe aggregate is its own repository: find, find?, all, create or its factory, and the queries it declares. finds defines a query that returns aggregates.
| Option | Meaning |
|---|---|
where: | Keeps the aggregates whose columns equal the expressions: a list matches any of its members and a range its span. |
state: | Takes a state or a list of states. |
satisfying: | Adds a specification. |
order: | order: {id: :desc} sorts by id or by attributes stored in one column, and id breaks ties. |
first: | Returns at most that many. |
counts | Takes where: and satisfying: and returns how many, in one statement, without loading them. |
Each part is optional, but a finds gives at least one.
A context checks every query when it closes, whether or not anything calls it.
When a query needs more than that, give finds a block instead. It gets the aggregate’s live rows, with removed ones and other tenants’ already left out, and narrows them with SugarORM’s where, where_null, order_by, limit and offset; the aggregates are built from what it returns, so a caller cannot tell the two kinds apart. It is the one place a column name or a SQL fragment appears in a domain, so keep it for what where: cannot say.
finds holding(tea : Catalogue::Tea::Id) do |orders|
orders.where("id IN (SELECT order_id FROM ordering_order_lines WHERE tea_id = ?)", tea.raw)
endfind, find?, all and every finds read one statement per table: the roots, then each list of entities through preload. So placed_for runs two queries however many orders it returns. Code outside the domain, such as an action, reads through these queries too, and does not name Rows.
remove in a command retires the aggregate. Its row stays with removed_at set, so the lines and projection rows that name it stay valid. find, find?, all, every query and the command’s row lock skip it.
| Condition | Outcome |
|---|---|
| A query names a key, state or order the aggregate lacks | fails compilation |
| A query filters on a list of entities | fails compilation |
| A query passes a value of the wrong type | fails compilation |
A query passes an argument finds does not take | fails compilation |
A finds block does more than narrow its rows: it counts, locks, deletes or names Rows or SugarORM | fails compilation |
find of a removed or missing aggregate | raises “There is no Order 7.” |
Name rules with specifications
A specification is a rule an aggregate satisfies or not, stated once and read twice: by Crystal on an aggregate in memory, and by PostgreSQL in a query. It lives in the context, after its aggregate, and joins the glossary.
specification Astray, on: Order, where: {state: :cancelled, late_parcel: true},
means: "a cancelled order whose parcel left anyway"
to forget do
refuse "Only a cancelled order can be forgotten." unless cancelled?
refuse "An order whose parcel left cannot be forgotten." if astray?
remove
endAstray is read in four ways:
| Call | Reads the rule |
|---|---|
order.astray? | In Crystal, for never, always, guards and methods |
Ordering::Astray.satisfied_by?(order) | In Crystal, on an aggregate in memory |
Order.all(satisfying: Astray) | The same rule in one query |
satisfying: Astray on a finds or a counts | The same rule in one query |
where: takes what finds takes, or earlier specifications on the same aggregate joined by &, | and !, as in where: !Astray. In PostgreSQL ! reads as IS NOT TRUE, so an order whose column is NULL fails a rule and passes its negation in Crystal and in PostgreSQL alike.
A rule PostgreSQL cannot read stays a never, an always or a method: one over a list of entities, such as lines.empty?, or over a derived value made with the. A specification takes no arguments; a finds takes them, and adds a specification with satisfying:.
Name operations with services
A service is an operation that belongs to no one aggregate. It lives in a context, carries means: and joins the glossary.
service requote(order : Order::Id), returns: Catalogue::Cents,
means: "what an order would cost at today's prices" do
Order.find(order).lines.sum do |line|
Catalogue::Tea.find(line.tea).price * line.quantity.to_i
end
endCall it on its context: Ordering.requote(order.id). To change two aggregates, emit an event and react to it in a whenever.
| Condition | Outcome |
|---|---|
| A service changes a second aggregate | raises SweetTalk::Boundary and rolls both back |
A service anywhere but a context | fails compilation |
returns: omitted | defaults to Nil |
Stage 5 · React
Follow events with policies
event declares one of the aggregate’s events, and emit queues one from a command with its facts; an aggregate emits only its own. An event holds its subject’s id (placed.order_id) and its fields as they were when it was emitted: column types, values, ids and an Array of the aggregate’s entity. No getter on an event loads an aggregate, so a handler reads what happened, not what is true now.
whenever in a context or a border is a policy, run as a Cold Brew job. The command enqueues one job per policy in its own transaction, so a job is never lost and never sent for a rolled-back change. A handler changes one aggregate too. With each: it runs once per element of a list in the event, each in its own job: the Sales projection below counts one order line per job.
- A job carries the event as JSON.
- A job is named after its owner (a context and its border, a projection or a process) and the event, never after a line, because
caramel_jobsstores the name. - The command also publishes
{"subject": id}onsweettalk.CONTEXT.AGGREGATE.EVENTfor live pages, delivered on commit and at most once.
| Condition | Outcome |
|---|---|
| One line’s job fails | the other lines’ jobs still run |
Two whenever blocks for one event in one owner | fails compilation |
| A rename whose old job name another job has or claims | fails compilation |
| A refusal, or a handler that changes a second aggregate | fails its job at the first attempt, unless a compensate answers the refusal; the command that emitted the event stays committed |
| Any other handler error | follows Cold Brew’s retry rules |
Record who acted
Every event records event_id, a UUID, occurred_at, actor, a String?, cause_id, the event whose handler emitted it, and flow_id, shared by every event one command leads to: the request’s trace id when it ran in one. Its jobs carry all five. No command takes an actor: wrap the call in SweetTalk.acting instead.
SweetTalk.acting("member:7") { order.place }Each policy, compensation and process step runs as its event’s actor, so the Cancelled event a refused hold leads to still names member:7. A deadline acts as deadline Hold.lapse.
A field may be nilable. cancel without a reason sends reason as JSON null:
event Cancelled, lines : Array(Line), reason : String?,
means: "the order was given up, and why"
to cancel(reason : String? = nil) do
unless cancelled?
cancelled!
emit Cancelled, lines: lines, reason: reason
end
end| Condition | Outcome |
|---|---|
An event field named event_id, occurred_at, actor, cause_id, flow_id or like the subject’s id | fails compilation |
An event field typed as a union of other types, such as String | Int32 | fails compilation |
A retried job carries the same event_id. A handler that sends mail or calls an API can key that side effect on it, so a retry can tell the side effect already happened.
Keep read models
A projection is a table with one row per key. Inside a whenever block, Sales[tea] reads the row locked and saves it when the handler returns. When the row is missing, an upsert on the key’s unique index inserts it with its defaults, so two handlers that insert one key at once keep one row.
projection Sales, means: "tins sold of one tea" do
key tea : Catalogue::Tea
has sold : Int32 = 0
whenever Order::Placed, each: :lines do |placed, line|
Sales[line.tea].sold += line.quantity.to_i
end
endA projection declares queries as an aggregate does. finds takes where:, order: and first:, or a block that narrows its rows, and counts takes where:. The keys are the projection’s attributes, its key included: where: {tea: tea} compares the tea’s id. A projection has no lifecycle and no specifications, so it takes neither state: nor satisfying:, and every row counts, since a projection removes none.
finds best_sellers(count : Int32), order: {sold: :desc}, first: count
counts sold_any, where: {sold: 1..}Sales.best_sellers(10) reads the ten best-selling teas in one statement, and Sales.sold_any counts the teas that sold at least one tin without loading them.
Run processes with deadlines and compensation
A process is a workflow across aggregates. It keeps one row per key, with attributes, a lifecycle and deadlines, in a table such as ordering_holds. Hold holds the tins of one tea for one placed order until the parcel leaves, and gives them back when the order is cancelled or the parcel is late.
process Hold, means: "tins of one tea held on the shelf for one placed order" do
key slot : Slot
has tins : Int32 = 0
moves waiting >> holding >> taken, waiting >> refused, waiting >> released,
holding >> released
whenever Order::Placed, each: :lines,
key: Slot.new(order: placed.order_id.raw, tea: line.tea.raw) do |placed, line|
if waiting?
Catalogue::Tea.find(line.tea).hold Catalogue::Tins.new(line.quantity.to_i)
@tins = line.quantity.to_i
holding!
arm lapse
end
end
compensate Order::Placed do |placed, line, refusal|
refused!
Order.find(placed.order_id).cancel refusal.message
end
# whenever Order::Cancelled releases the tins; Fulfilment::Parcel::Dispatched takes them
deadline lapse, after: 7.days do
Order.find(Order::Id.new(slot.order)).cancel "not dispatched within 7 days" if holding?
end
end| Word | What it does |
|---|---|
whenever Event, key: expression | A step. Its job inserts the row when it is missing, locks it and runs the block on it. A step changes at most one aggregate; its own row does not count. Attributes other than the key are nilable or have a literal default, because a row starts from its key alone. |
compensate | Runs when its whenever is refused. The handler’s work has already rolled back; the compensation gets the same arguments and the refusal, and runs in its own transaction. Here a tea that cannot be held cancels its order with “Only 1 tins of Gyokuro free.”. A refusal in the compensation fails the job. A context or a border may compensate its own whenever too: Ordering’s border notes a parcel that left after its order was cancelled. |
arm lapse, disarm lapse | arm lapse sets lapse_due and enqueues a job for that time, and disarm lapse clears it. Cold Brew cannot cancel a job, so every arm and disarm counts up an arming counter: a stale deadline job finds a newer arming and does nothing. |
arm lapse, after: 2.days | Arms the deadline for another span than its own. |
A process may react to another context’s events outside a border. Hold follows Fulfilment::Parcel::Dispatched, which draws an event edge from Ordering to Fulfilment.
Stage 6 · Operate
Rename without moving data
A name SweetTalk stores, such as a table, a channel or a job name, comes from a domain name. When the model finds a better word, renamed_from: keeps the table and the channel the old name used, so no data moves.
aggregate Blend, renamed_from: Tea, means: "one tea we sell" do
# still stored in catalogue_teas, still announced on sweettalk.catalogue.tea.*
endA job’s name follows its owner and the event’s path as written. When a rename changes it, SweetTalk declares Cold Brew’s renamed_from on the job with the old name, so jobs queued before the rename keep running.
These are the names SweetTalk stores, which a deployed application depends on:
| Stored name | Form |
|---|---|
| Tables | ordering_orders and ordering_order_lines, with their check_…, index_… and fk_… names |
| Job class names | TeaShop::Ordering::Sales::WhenOrderPlaced and TeaShop::Ordering::Hold::LapseDeadline |
| Channels | sweettalk.<context>.<aggregate>.<event>, behind a tenant’s t<id>: prefix |
| Event metadata keys | id, at, by, cause and flow |
renamed_from: keeps a table, a channel and queued jobs across a rename. While SweetTalk is experimental, a minor release may still change any of these names.
Share a domain between tenants
In a multi-tenant application, name the tenant model before the first context. It is the model the routes’ tenant block names, and SugarORM refuses another.
module TenantShop
include SweetTalk
tenant Account
context Catalogue, central: true, means: "the teas every tenant sells" do
aggregate Tea, means: "one tea on every tenant's shelf" do
has name : String
end
end
context Ordering, follows: Catalogue, means: "choosing teas and checking out" do
# aggregates, projections and processes that belong to one tenant
end
endEvery context belongs to the tenant unless it says central: true. Its aggregates, entities, projections and processes get a column named after the model, account_id here, and SugarORM scopes their reads, locks, keys and upserts by the bound tenant, so a projection or a process keeps one row per tenant per key. A policy, projection or process step runs in the tenant its event was emitted in, a deadline in the tenant that armed it, and a notification goes out on the tenant’s channel. A central context’s rows are shared by every tenant.
A tenanted aggregate may reference a central one, as an order line names a shared tea.
| Condition | Outcome |
|---|---|
| A tenanted aggregate references a central one | allowed |
A domain without tenant in a program that requires caramel/tenancy, or tenant without that require, after a context, twice, or naming anything but a SugarORM schema | fails compilation |
central: with anything but true, or without caramel/tenancy | fails compilation |
A has in a central context that points at a tenanted aggregate or a tenanted schema of the application, whose rows every tenant would then see | fails compilation |
A whenever in a tenanted context for a central context’s event, which carries no tenant (a central context may follow a tenanted one’s events, and its handler runs in the event’s tenant) | fails compilation |
An event channel over 42 characters, which leaves 21 for a tenant’s t<id>: prefix, or a tenant index name over PostgreSQL’s 63 bytes | fails compilation |
A has that would store account_id, or an entity of an aggregate named like the tenant model | fails compilation |
A domain that already holds data follows Add tenancy to existing data in the multi-tenancy guide; frappe db diff names every SweetTalk table it halts on. Read the multi-tenancy guide
Call the domain from actions
An action’s contract may declare a value or an aggregate’s id as a field. The field binds from the type the value wraps, with that type’s grammar and JSON type, and the value’s refusal becomes the field’s error, so a form shows it beside its input. The example is the Box Office path’s: Seats is a value within 1..8, Email a string value, and The Tempest a show. Build a box office →
module App::Holds
struct Create < App::ApplicationAction
contract do
field show : BoxOffice::Seating::Show::Id
field guest : BoxOffice::Seating::Email
field seats : BoxOffice::Seating::Seats
end
def handle(contract : Contract)
show = BoxOffice::Seating::Show.find(contract.show)
show.hold(contract.guest, contract.seats)
redirect_to("/shows/#{show.id.raw}/holds/#{show.issued}")
end
end
endAn id binds from an Int64 and may bind a route parameter, as :show does. A value with parts is not a field: declare one field per part and build the value in handle. The contract reference says what else a field value may take.
| Condition | Outcome |
|---|---|
The route post "/shows/:show/holds", App::Holds::Create with seats=9 from a JSON client | answers 422 with {"errors":{"seats":["Seats must be between 1 and 8"]}}, before handle runs |
/shows/abc/holds, since an id binds from an Int64 | answers 404 |
A refusal that handle does not rescue | answers 422 the way a failed contract does, with its message under _base |
SweetTalk::Missing, which find raises for an id that names no aggregate | answers 404 |
SweetTalk::Boundary, a mistake in the code | answers 500 |
A refusal answers each client as follows:
| Client | Gets |
|---|---|
| JSON client | {"errors":{"_base":["The Tempest is sold out."]}} |
| Browser or htmx request | contract_failure_page(contract), so a form that re-renders itself on a failed contract shows the refusal too |
| Any other client | Plain text that starts with ERR INVALID:422 |
Override render_refusal(contract, refusal) to answer otherwise. Neither a refusal nor SweetTalk::Missing is reported to Crema as an error.
Stage 7 · Limits
What does not compile
Each mistake below stops the build. Most fail with a message that names the sentence and its fix; a row that says so fails with Crystal’s own message, such as an undefined method. scripts/check sweet-talk-compilation runs one fixture per mistake, and the website check holds this table to that list.
| Condition | Outcome |
|---|---|
A noun without means: | fails compilation |
A subdomain: other than :core, :supporting or :generic | fails compilation |
A foreign name or call outside a border, a border or follows: to an unknown context, or a cycle of dependency edges | fails compilation |
A name or call another context does not publish, a publishes of a name the context does not declare, or of an expression | fails compilation |
Caramel::Outbound or HTTP::Client outside a border with an outside system, or such a border named like a context, holding a whenever, or without its outside: text | fails compilation |
emit of another aggregate’s event | fails compilation |
| A mover the lifecycle does not declare | fails compilation, with Crystal’s own message |
| A lifecycle whose check name would pass PostgreSQL’s 63 bytes | fails compilation |
the without a type | fails compilation |
A value that wraps anything but Int32, Int64 or String, or has no parts | fails compilation |
A value that takes an option its raw type does not, such as matches: on an integer or at_least: on a string, database_check: true, or database_check: false on a value with parts | fails compilation |
A bound that is not an integer literal, unless the value takes database_check: false | fails compilation |
Two different values mixed in arithmetic, or multiplied without times: | fails compilation, with Crystal’s own message |
A word out of its place: whenever or service in an aggregate, key outside a projection or a process, deadline outside a process, specification inside an aggregate, or compensate in a projection | fails compilation |
has or part with a type that cannot be stored, a has of a Hash, or an attribute or key whose constraint or index name would pass PostgreSQL’s 63 bytes | fails compilation |
| An event whose channel would pass PostgreSQL’s 63 characters | fails compilation |
| Two declarations whose names give one table | fails compilation |
A context that names Rows, or code outside the domain that reads an aggregate’s rows | fails compilation |
A projection or a process without key, a process step without key:, or a process attribute without a literal default | fails compilation |
Two whenever blocks for one event in one place, or each: naming a field that is not a list | fails compilation |
A handler that reads an aggregate from an event, as in placed.order | fails compilation, with Crystal’s own message |
| A factory that leaves an attribute unset, or named like a repository method | fails compilation |
create on an aggregate that has a factory | fails compilation, with Crystal’s own message |
A query on an unknown attribute, state or order, on a list of entities, with a value of the wrong type, with an argument it does not take, with nothing, with where: or order: that is not a tuple, or with a direction other than :asc or :desc | fails compilation |
A finds with a block and an option, a block of more than one expression, or a block that does more than narrow its rows | fails compilation |
A counts with order: or a block | fails compilation |
A query named all or like another query | fails compilation |
A specification on an unknown attribute or state, on a list of entities or a derived value, with a block, without on:, outside a context, whose predicate the aggregate already has, or whose where: is neither a tuple nor specifications joined by &, | and ! | fails compilation |
satisfying: with anything but a specification on that aggregate, or satisfying: and state: on a projection | fails compilation |
A compensate without its whenever | fails compilation |
arm of a deadline the process lacks | fails compilation |
| An event field named like its metadata, or typed as a union of other types | fails compilation |
renamed_from: naming its own name, or a rename whose old job name another job has | fails compilation |
| A value with parts as a contract field | fails compilation, with Crystal’s own message |
With caramel/tenancy, the mistakes listed under Share a domain between tenants | fails compilation |
Not covered yet
SweetTalk covers the domain.
- Resources, representations and authorisation are the application’s: an action calls the domain, and SweetTalk answers only its refusals.
- String patterns and the
neverrules of values and aggregates are checked in Crystal only. - Refusal messages are the domain’s English, even with
caramel/i18n. - There is no event log: a projection keeps its rows, not the events that built them, so a projection added later starts empty and cannot be rebuilt from past events.
- A process’s rows are never removed, so its table grows with every key it has seen.
- A central context’s event reaches no tenanted handler, and the tenant column is always named after the tenant model (ADR 0033, deferred).
Prove it
Check the domain before it touches data.
frappe check
frappe db diff --name create_shop
frappe migrate
frappe correttoPassing: frappe check prints ✓ Type check passed. frappe db diff prints Wrote and the path of the migration it derived; review it. frappe migrate prints Applied 1 migrations., then The database matches the declared schema. frappe corretto runs the specs, spec/domain/ included, against PostgreSQL and prints Corretto: 1 of 1 workers passed.