Learn · emhcl and .em.hcl · 6 of 7

Method facts: say it in the model

Older models kept these facts in descriptions, comments, or hotspots. Now each fact has a typed block or attribute.

After this page, you can:

  • say which block records each method fact
  • add a stream identity and a lifecycle to an aggregate
  • record evidence, decisions, and hotspots

Method facts are optional. A model without them stays valid. Add them when a team needs the model to say more than the flow shows. The method behind them is in Review a model.

FactSyntaxCore code
The field that names one streamaggregate identityEM018
The states of a stream and the moves between themlifecycle, state, transition, invalid_transitionEM017
Where the value of a field comes fromfield references, field_sourceEM202
The fields that link or deduplicate outside factscorrelation_key, deduplication_keynone
Where a fact comes from, and how certain it isevidence_source, evidencenone
A choice that shapes the model, and its reasondecisionnone
The kind of an open questionhotspot kind, category, options, concernsnone

All of these facts are optional. A model without them stays valid. The v0.8.0 specification defines the rules. The method facts example uses all of them in one booking model. Scenario cases, empty history, and dispatch assertions are on the Scenarios page.

Stream identity

A stream is the history of the Events of one aggregate instance. Use identity when one field type tells one stream from another, for example a booking ID:

board { workflows = [] } bounded_context "booking" { field_type "booking_id" { type = "UUID" id_attribute = true } aggregate "booking" { identity = field_type.booking_id } event "booking_opened" { aggregate = aggregate.booking fields = [field_type.booking_id] } }

Each Event of the aggregate must have a top-level field of the identity field type. A fields entry or a field block with that type counts. A field named booking_id with the type "UUID" does not count. An Event without such a field is EM018.

Lifecycle

Use a lifecycle when a stream moves through business states and some Commands are valid only in some states. It is a top-level block for one aggregate:

board { workflows = [workflow.open_booking, workflow.cancel_booking] } bounded_context "booking" { aggregate "booking" { } event "booking_opened" { aggregate = aggregate.booking } event "booking_canceled" { aggregate = aggregate.booking } } lifecycle "booking" { aggregate = aggregate.booking.booking state "open" { path = "main" } state "canceled" { end = true path = "cancel" } transition "open_booking" { command = command.open_booking.open_booking events = [event.booking.booking_opened] before = [] after = state.open } transition "cancel_booking" { command = command.cancel_booking.cancel_booking events = [event.booking.booking_canceled] before = [state.open] after = state.canceled } invalid_transition "cancel_canceled_booking" { command = command.cancel_booking.cancel_booking reason = "A canceled booking cannot be canceled again." before = [state.canceled] } } state_change "open_booking" { screen "booking_form" { to = [command.open_booking] } command "open_booking" { to = [event.booking.booking_opened] } } state_change "cancel_booking" { screen "booking_details" { to = [command.cancel_booking] } command "cancel_booking" { to = [event.booking.booking_canceled] } }
  • aggregate, command, and events use the qualified forms.
  • before = [] states that the transition starts the stream.
  • end = true states that the stream ends in this state. path is "main", "cancel", "failure", or "compensation".
  • An invalid_transition states a Command that is not valid in each before state. Write the reason in business words. Each invalid transition is a source for a state_violation scenario.

The validator reports EM017 in these cases:

  • a second lifecycle of one aggregate
  • a lifecycle without a state
  • an empty events list
  • an empty before list on an invalid transition
  • one state and Command pair that is both valid and invalid
  • a transition Event of another aggregate

A lifecycle adds no flow edge and no scenario. The validator does not compare it with the edges or the scenarios.

Outside its lifecycle, write state.booking.open, transition.booking.cancel_booking, or invalid_transition.booking.cancel_canceled_booking.

Field references and field sources

A field reference names one field of an element: the element reference, then .field., then the field name. Add .field.<name> again for each subfield, for example command.open_booking.field.address.field.city. A field reference names a field definition, not a value. It has no index such as [0].

Use a field_source block in a workflow when a reader must know where the value of a field comes from:

board { workflows = [workflow.open_booking] } bounded_context "booking" { field_type "guest_name" { type = "String" } event "booking_opened" { fields = [field_type.guest_name] } } state_change "open_booking" { screen "booking_form" { to = [command.open_booking] field "name" { type = field_type.booking.guest_name } } command "open_booking" { fields = [field_type.booking.guest_name] to = [event.booking.booking_opened] } field_source { inputs = [screen.booking_form.field.name] target = command.open_booking.field.guest_name } field_source { inputs = [command.open_booking.field.guest_name] target = event.booking.booking_opened.field.guest_name } }

Elements of the same workflow use the local form. Events use the qualified form. The source follows the edges: each input element must connect to the target element by one canonical edge of the workflow. A field of an Event or a Command takes one input. A field of a Read Model, a Screen, or a Processor can take several. Write the rule of a converted or derived value in description. The language stores the rule as text and does not run it.

The validator reports EM202 in these cases:

  • an empty inputs list
  • an input without a canonical edge to the target
  • several inputs for an Event or Command field
  • a second source for the same target

The language does not infer a source from equal names.

Correlation and deduplication keys

An outside fact often arrives without your own ID, and it can arrive twice. Use the two key flags on the fields of that fact:

board { workflows = [] } bounded_context "payments" { external = true event "payment_received" { field "booking_reference" { type = "String" correlation_key = true } field "payment_reference" { type = "String" deduplication_key = true } } }

correlation_key = true states that the value links the outside fact to an entity of your system. deduplication_key = true states that a second delivery with the same value records no new fact. Write the flags on field and subfield blocks. On a field_type, they are EM000. The flags add no diagnostic and no scenario rule. To state the duplicate behavior, write a business_duplicate scenario.

Evidence

Use evidence when a reader must know where a rule comes from. An evidence_source names a document, code, an application, or a person. An evidence record states that the source supports one model part:

board { workflows = [] } bounded_context "payments" { external = true event "payment_received" { field "payment_reference" { type = "String" } } } evidence_source "payment_api_docs" { kind = "document" location = "https://payments.example.com/docs/webhooks" } evidence "payment_event_is_documented" { certainty = "stated" location = "section Webhooks" source = evidence_source.payment_api_docs on = event.payments.payment_received } evidence "payment_reference_is_unique" { description = "No source states that a payment reference is unique." certainty = "assumed" on = event.payments.payment_received.field.payment_reference }

certainty is "stated" when the source says it clearly, "implied" when it follows from the source, and "assumed" when no source gives it. A "stated" or "implied" record without source is EM000. Several records can attach to one target. Evidence does not change validity or a diagnostic.

Decisions

Use a decision when the team makes a choice that a future reader can question. It holds one statement and one reason. Omit on for a decision about the whole model:

board { workflows = [workflow.open_bookings] } bounded_context "booking" { event "booking_opened" { } event "booking_canceled" { } } state_view "open_bookings" { readmodel "open_bookings" { question = "Which bookings are open?" from = [event.booking.booking_opened, event.booking.booking_canceled] } } decision "bookings_use_one_currency" { statement = "All amounts are in euros." reason = "The hotel accepts payments only in euros." } decision "one_open_bookings_list" { kind = "accepted_debt" signal = "EM403" statement = "The open bookings list reads two events." reason = "The guest sees one list. A split gives two lists with one purpose." on = readmodel.open_bookings.open_bookings }

kind is "choice" or "accepted_debt". If you omit it, the kind is "choice". Use accepted debt when the team keeps a shape that a judgment signal reports, and name that code in signal. Only accepted debt can have a signal. On a choice, signal is EM000.

A decision does not change a diagnostic. The model above still reports EM403 with the same severity. A tool can show the decision next to the signal.

Kinds of hotspots

A hotspot can state what kind of open point it is. Use kind = "assumption" when you filled a gap and a person must confirm it. Use kind = "request" when another part of the model must change. If you omit kind, the hotspot is a question.

board { workflows = [workflow.cancel_booking] } bounded_context "booking" { event "booking_canceled" { } } state_change "cancel_booking" { screen "booking_details" { to = [command.cancel_booking] } command "cancel_booking" { to = [event.booking.booking_canceled] } } hotspot "refund_rule" { kind = "assumption" category = "rule" question = "Does the guest lose the deposit after a cancellation?" options = ["The guest loses the deposit.", "The guest gets the deposit back."] on = command.cancel_booking.cancel_booking } hotspot "late_cancellation_scenario" { kind = "request" question = "Which scenario states a cancellation on the day of the booking?" concerns = "scenario" on = workflow.cancel_booking }

category is "action", "data", "next", "rule", "example", "view", "who", or "time". options lists the answers that you offer. A request must have concerns, the kind of model part that must change. Another kind must not have it. Both errors are EM000. Only status controls EM406.

What a record can point at

A hotspot, an evidence record, and a decision each name one attach target in on. All three accept a catalog item, a workflow, an owner, an actor, and a workflow-qualified element. The new forms are in this table:

TargetExampleRecords
Lifecycle parttransition.booking.cancel_bookinghotspot, evidence, decision
Fieldevent.payments.payment_received.field.payment_referencehotspot, evidence, decision
Decisiondecision.one_open_bookings_listhotspot, evidence
Hotspothotspot.refund_ruleevidence

A target kind that the record does not accept is EM101. For example, a decision cannot point at a hotspot. A target that is not declared is EM102.

See RFC 0007 for the reasons behind each fact.

Check yourself

1

An aggregate sets identity = field_type.booking_id. An Event has a field booking_id with the type "UUID". What does the validator say?

Answer

It reports EM018. The field must use the identity field type. A field with the same name and the type "UUID" does not count.

2

The team keeps a shape that EM403 reports. Where do you write the reason?

Answer

In a decision with kind = "accepted_debt" and signal = "EM403". The decision does not change the severity.

3

Are the method facts required?

Answer

No. All of them are optional.