Skip to content
carameldocs · 0.16.0
Browse documentation
Guides / SweetTalk

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.

ContractValue
NeedsA Caramel application that requires caramel/sweet_talk, and PostgreSQL for its specs
You will haveA domain in app/domain/ with its own words
StatusExperimental: new in 0.13.0, until an application has run it in production
Proved byfrappe 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.

Experimental A minor release may change the names it stores.

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.

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.

Terminal · inside your application
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.

Agents

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:

config/application.cr · excerpt
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.

app/domain/tea_shop.cr · opening
module TeaShop
  include SweetTalk

  context Catalogue, means: "the teas on our shelves" do
    # values, aggregates and projections
  end
end

Find 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.

PatternIn SweetTalkRules
Bounded contextcontext, a module with its own vocabulary.Draw the contexts
Ubiquitous languagemeans: on every noun, and SweetTalk.glossary, which lists each with its kind and context.Give every noun a meaning
Entityentity, which lives inside one aggregate and is saved with it.Declare aggregates and entities
Value objectvalue: 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
Aggregateaggregate, 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
RepositoryThe 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
Factoryfactory.Make aggregates with factories
Domain serviceservice, which changes at most one aggregate.Name operations with services
Domain eventevent 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 functionthe, 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
Specificationspecification, 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 sagaprocess, with deadline and compensate.Run processes
Read modelprojection, queried with finds and counts.Keep read models
Transactional outboxBuilt 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
Conformistfollows:, which adopts another context’s types as they are.Draw the contexts
Anticorruption layerborder 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 serviceThe services a context lists in publishes: in one binary, a set of calls other contexts may make.Publish a language
Published languagepublishes, the types and services other contexts may name; any other name of the context does not compile outside it.Publish a language
Shared kernelNot planned. central: true is tenancy, not a shared kernel.Share a domain between tenants
PartnershipNot planned. Two contexts may react to each other’s events, but their dependency edges may not form a cycle.Draw the contexts
Customer/supplierNot 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 sourcingNot 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 with finds, 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: true is 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:

NounMeans
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:

app/domain/tea_shop.cr · two contexts
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
end

The context map is checked when the program compiles:

ConditionOutcome
follows: Bdraws a dependency edge to B
Anything in border B other than the event a whenever namesdraws a dependency edge to B
A whenever that reacts to one of B’s eventsdraws an event edge
Naming B::, or calling B.price_of, in a context that follows Ballowed anywhere, unless B publishes and leaves the name out
Naming B::, or calling B.price_of, inside border Ballowed; a call draws a dependency edge too
A foreign name or call anywhere elsefails compilation
An edge to an unknown contextfails compilation
A cycle of dependency edges, such as Ordering and Fulfilment following each otherfails compilation
Ordering and Fulfilment reacting to each other’s eventsallowed

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:

ValueUse for
:corewhat sets the business apart
:supportingwhat the core needs but could be built by anyone
:generica 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.

app/domain/tea_house.cr · subdomains
context Pricing, subdomain: :supporting, means: "what each tea costs" do
  # …
end

context Billing, follows: Pricing, subdomain: :core,
  means: "charging members for their teas" do
  # …
end

Publish 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.

app/domain/tea_house.cr · a published language
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
ConditionOutcome
A context names a type or service the publisher does not list, whether it follows the publisher or reads it in a borderfails compilation at the line that names it
publishes lists a name the context does not declarefails compilation
The publisher is declared after the context that names itfails compilation; the program’s checks find the name, without a line
A context reacts to an event of a publishing contextallowed: 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 publishkeeps 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 →

ConditionOutcome
Caramel::Outbound or HTTP::Client named outside a border …, outside:fails compilation
An outside: border named like a contextfails compilation
An outside: border holding a wheneverfails compilation
app/domain/tea_house.cr · an outside system
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
end

Stage 3 · Model

Name the values

app/domain/tea_shop.cr · 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.

ConditionOutcome
A build or arithmetic result outside the bounds, such as @stock -= tins below zerorefuses with “Tins must be at least 0”
+ or - of tins and centsfails compilation
* of Cents by a Quantity without times: Quantityfails compilation

Name strings and values with parts

app/domain/tea_shop.cr · string and composite values
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? }
end

A 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.

ConditionOutcome
Email.new("ada"), a string that does not match the patternrefuses with “Email must match /\A[^@\s]+@[^@\s]+\z/” before anything is written
A write around SweetTalk with a length outside 3..254refuses, 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.

app/domain/tea_shop.cr · an aggregate
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
end

Each 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 typeStored as
String, Int32, Int64, Bool, Float64 or TimeA column
A value that wraps a number or a stringA column of its raw type
A value with partsOne column per part
Another aggregateA 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 : AccountA foreign key; its getter returns a SweetTalk::RecordId(Account), and order.member.load reads the account
An Array of the aggregate’s own entityA 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.

ConditionOutcome
A has with any other typefails compilation
A has of a Hashfails compilation, with a pointer to the entity form
A context that names Rowsfails 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.

ConditionOutcome
A mover such as shipped! is called when the current state does not come just before it in a chainrefuses with “Order cannot become shipped while cancelled”
A command leaves a never condition truerefuses
A command leaves an always condition falserefuses

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:

  1. 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;
  2. run the body;
  3. check every never and always;
  4. save the root through its changeset, then its entities;
  5. dispatch the queued events.
ConditionOutcome
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 runsraises 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.

app/domain/tea_shop.cr · a factory
factory open(member : Account, receipt_to : Email, deliver_to : Address) do
  emit Opened, receipt_to: receipt_to
end

Ordering::Order.open(account, Email.new("ada@example.com"), address) opens an order.

ConditionOutcome
A factory leaves an attribute unset and no default covers itfails compilation
A factory named like a repository method, such as factory findfails compilation
An aggregate with a factoryhas no create, so every order starts with Opened

Stage 4 · Query and decide

Find, count and retire aggregates

app/domain/tea_shop.cr · queries and a removal
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
end

The aggregate is its own repository: find, find?, all, create or its factory, and the queries it declares. finds defines a query that returns aggregates.

OptionMeaning
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.
countsTakes 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.

app/domain/tea_shop.cr · a query with a block
finds holding(tea : Catalogue::Tea::Id) do |orders|
  orders.where("id IN (SELECT order_id FROM ordering_order_lines WHERE tea_id = ?)", tea.raw)
end

find, 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.

ConditionOutcome
A query names a key, state or order the aggregate lacksfails compilation
A query filters on a list of entitiesfails compilation
A query passes a value of the wrong typefails compilation
A query passes an argument finds does not takefails compilation
A finds block does more than narrow its rows: it counts, locks, deletes or names Rows or SugarORMfails compilation
find of a removed or missing aggregateraises “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.

app/domain/tea_shop.cr · a specification
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
end

Astray is read in four ways:

CallReads 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 countsThe 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.

app/domain/tea_shop.cr · a service
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
end

Call it on its context: Ordering.requote(order.id). To change two aggregates, emit an event and react to it in a whenever.

ConditionOutcome
A service changes a second aggregateraises SweetTalk::Boundary and rolls both back
A service anywhere but a contextfails compilation
returns: omitteddefaults 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_jobs stores the name.
  • The command also publishes {"subject": id} on sweettalk.CONTEXT.AGGREGATE.EVENT for live pages, delivered on commit and at most once.
ConditionOutcome
One line’s job failsthe other lines’ jobs still run
Two whenever blocks for one event in one ownerfails compilation
A rename whose old job name another job has or claimsfails compilation
A refusal, or a handler that changes a second aggregatefails its job at the first attempt, unless a compensate answers the refusal; the command that emitted the event stays committed
Any other handler errorfollows 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.

Inside an action · order is a loaded Ordering::Order
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:

app/domain/tea_shop.cr · a nilable fact
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
ConditionOutcome
An event field named event_id, occurred_at, actor, cause_id, flow_id or like the subject’s idfails compilation
An event field typed as a union of other types, such as String | Int32fails 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.

app/domain/tea_shop.cr · a projection
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
end

A 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.

app/domain/tea_shop.cr · projection queries
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.

app/domain/tea_shop.cr · a process
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
WordWhat it does
whenever Event, key: expressionA 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.
compensateRuns 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 lapsearm 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.daysArms 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.

app/domain/tea_shop.cr · Tea renamed to Blend
aggregate Blend, renamed_from: Tea, means: "one tea we sell" do
  # still stored in catalogue_teas, still announced on sweettalk.catalogue.tea.*
end

A 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 nameForm
Tablesordering_orders and ordering_order_lines, with their check_…, index_… and fk_… names
Job class namesTeaShop::Ordering::Sales::WhenOrderPlaced and TeaShop::Ordering::Hold::LapseDeadline
Channelssweettalk.<context>.<aggregate>.<event>, behind a tenant’s t<id>: prefix
Event metadata keysid, 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.

app/domain/tenant_shop.cr · a domain shared by tenants
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
end

Every 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.

ConditionOutcome
A tenanted aggregate references a central oneallowed
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 schemafails compilation
central: with anything but true, or without caramel/tenancyfails 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 seefails 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 bytesfails compilation
A has that would store account_id, or an entity of an aggregate named like the tenant modelfails 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 →

app/actions/holds/create.cr
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
end

An 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.

ConditionOutcome
The route post "/shows/:show/holds", App::Holds::Create with seats=9 from a JSON clientanswers 422 with {"errors":{"seats":["Seats must be between 1 and 8"]}}, before handle runs
/shows/abc/holds, since an id binds from an Int64answers 404
A refusal that handle does not rescueanswers 422 the way a failed contract does, with its message under _base
SweetTalk::Missing, which find raises for an id that names no aggregateanswers 404
SweetTalk::Boundary, a mistake in the codeanswers 500

A refusal answers each client as follows:

ClientGets
JSON client{"errors":{"_base":["The Tempest is sold out."]}}
Browser or htmx requestcontract_failure_page(contract), so a form that re-renders itself on a failed contract shows the refusal too
Any other clientPlain 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.

Read the contract reference →

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.

ConditionOutcome
A noun without means:fails compilation
A subdomain: other than :core, :supporting or :genericfails compilation
A foreign name or call outside a border, a border or follows: to an unknown context, or a cycle of dependency edgesfails compilation
A name or call another context does not publish, a publishes of a name the context does not declare, or of an expressionfails 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: textfails compilation
emit of another aggregate’s eventfails compilation
A mover the lifecycle does not declarefails compilation, with Crystal’s own message
A lifecycle whose check name would pass PostgreSQL’s 63 bytesfails compilation
the without a typefails compilation
A value that wraps anything but Int32, Int64 or String, or has no partsfails 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 partsfails compilation
A bound that is not an integer literal, unless the value takes database_check: falsefails 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 projectionfails 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 bytesfails compilation
An event whose channel would pass PostgreSQL’s 63 charactersfails compilation
Two declarations whose names give one tablefails compilation
A context that names Rows, or code outside the domain that reads an aggregate’s rowsfails compilation
A projection or a process without key, a process step without key:, or a process attribute without a literal defaultfails compilation
Two whenever blocks for one event in one place, or each: naming a field that is not a listfails compilation
A handler that reads an aggregate from an event, as in placed.orderfails compilation, with Crystal’s own message
A factory that leaves an attribute unset, or named like a repository methodfails compilation
create on an aggregate that has a factoryfails 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 :descfails 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 rowsfails compilation
A counts with order: or a blockfails compilation
A query named all or like another queryfails 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 projectionfails compilation
A compensate without its wheneverfails compilation
arm of a deadline the process lacksfails compilation
An event field named like its metadata, or typed as a union of other typesfails compilation
renamed_from: naming its own name, or a rename whose old job name another job hasfails compilation
A value with parts as a contract fieldfails compilation, with Crystal’s own message
With caramel/tenancy, the mistakes listed under Share a domain between tenantsfails 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 never rules 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.

Terminal · inside your application
frappe check
frappe db diff --name create_shop
frappe migrate
frappe corretto

Passing: 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.