Skip to content

Guards & Conditions

Guards are conditions that control whether actions execute. They're marked with the ? prefix.

Basic Guards

A guard is a condition that must be true for the indented actions to run:

flow
on :checkout from @customer (api)
  ? cart is not empty
    order moves to #awaiting_payment
    emit :payment_request to @payment

If cart is not empty is false, the indented actions are skipped.

Multiple Guards (AND)

Multiple ? lines are combined with AND logic - all must pass:

flow
on :checkout from @customer (api)
  ? cart is not empty
  ? user is logged in
  ? payment method is valid
    order moves to #awaiting_payment

All three conditions must be true for the action to execute.

OR Guards

Use ?? for OR conditions - any one passing is enough:

flow
on :access_admin from @user (api)
  ? user is admin
  ?? user is super_admin
  ?? user has admin_override
    show admin panel

Any one of these conditions passing will allow access.

Combined Logic

You can combine AND and OR:

flow
on :checkout from @customer (api)
  ? cart is not empty        // AND
  ? total is greater than 0  // AND
  ?? user has override       // OR
    process checkout

Reads as: (cart is not empty AND total > 0) OR user has override

Complex Guard Example

flow
on :process_refund from @customer (api)
  ? order exists
  ? order belongs to @customer

  ? days since purchase < 7
    full refund
    $refund_amount becomes $order_total
    order moves to #fully_refunded

  ? days since purchase < 30
    partial refund (50%)
    $refund_amount becomes $order_total * 0.5
    order moves to #partially_refunded

  ? days since purchase < 90
  ?? customer is premium
    partial refund (25%)
    $refund_amount becomes $order_total * 0.25
    order moves to #partially_refunded

  otherwise
    refund denied
    emit :refund_rejected to @customer

Default Case (Otherwise)

When no guards pass, use otherwise for the default case:

flow
on :categorize_order from @system (api)
  ? $total > 1000
    $priority becomes "high"
    emit :alert to @vip_team

  ? $total > 100
    $priority becomes "medium"

  otherwise
    $priority becomes "low"

You can also use an empty ?:

flow
? $total > 1000
  $priority becomes "high"
? $total > 100
  $priority becomes "medium"
?
  $priority becomes "low"

Both are equivalent - otherwise is more readable, empty ? is more concise.

Guard Patterns

State Guards

flow
? order is in #pending
  process order

? order is not in #cancelled
  allow modifications

Context Guards

flow
? $total is greater than 100
  apply free shipping

? $items is not empty
  show cart summary

? $retry_count is less than 3
  retry operation

Actor Guards

flow
? @customer is logged in
  show account details

? @customer has verified email
  allow password reset

Comparison Guards

flow
? $total equals 0
? $total is 0
? $total is equal to 0
  show empty cart

? $count is greater than 10
? $count is less than 5
? $count is at least 3
? $count is at most 100

Collection Guards

flow
? $items contains "Gift Card"
  apply gift card rules

? $items is empty
  show empty message

? $tags is not empty
  process tags

Time-Based Guards

flow
? created more than 24 hours ago
  mark as expired

? updated within last 1 hour
  skip refresh

? days since purchase < 7
  allow full refund

Nested Guards

Guards can be nested for complex logic:

flow
on :process_order from @system (api)
  ? order is valid

    ? payment is authorized
      ? inventory is available
        order moves to #confirmed
        emit :prepare_shipment to @warehouse

      otherwise
        order moves to #backordered
        emit :backorder_notice to @customer

    otherwise
      order moves to #payment_failed
      emit :payment_retry to @customer

  otherwise
    order moves to #invalid
    emit :validation_error to @customer

Guard Execution Order

Guards are evaluated top-to-bottom. The first matching guard block executes:

flow
on :apply_discount from @customer (api)
  ? $total > 500
    $discount becomes 50      // First check

  ? $total > 100
    $discount becomes 20      // Second check

  otherwise
    $discount becomes 0       // Default

If $total is 600:

  • First guard passes → $discount becomes 50
  • Subsequent guards are not checked

Guards Without Actions

Sometimes you just want to validate:

flow
on :checkout from @customer (api)
  ? cart is empty
    emit :error to @customer
    // Implicit: don't continue

  // If we get here, cart is not empty
  order moves to #processing

Inline Error Responses

For API endpoints, guards can include inline error responses. When the guard fails, the error response is returned immediately:

flow
on :checkout from @customer (api)
  ? cart is not empty | 400 cart cannot be empty
  ? user is logged in | 401 please log in first
  ? payment method is valid | 400 invalid payment method

  order moves to #awaiting_payment
  reply 201 with:
    | id | $order_id |

The syntax is:

? <condition> | <http_status> <error_message>

When a guard with an inline error fails:

  • HTTP Status: Set in the response header (e.g., 400, 401, 403)
  • Response Body: {"message": "<error_message>"}

Variable Interpolation

Error messages can include context variables:

flow
? $quantity is at most $max_quantity | 400 cannot order more than $max_quantity items
? $total is under $limit | 400 order total $total exceeds limit of $limit

When to Use Inline vs Full Reply

Use inline errors for simple validation failures:

flow
? cart is not empty | 400 cart cannot be empty
? user is logged in | 401 please log in first

Use full reply blocks when you need richer error responses:

flow
? cart total exceeds $max_limit
  reply 400 with:
    | error   | LIMIT_EXCEEDED      |
    | message | order exceeds limit |
    | limit   | $max_limit          |
    | current | $cart_total         |

Complete Example

flow
on :checkout from @customer (api)
  // Simple validations with inline errors
  ? cart is not empty | 400 cart cannot be empty
  ? user is logged in | 401 please log in first

  // Complex validation with full reply
  ? $total exceeds $spending_limit
    reply 403 with:
      | error   | SPENDING_LIMIT_EXCEEDED |
      | message | you have exceeded your spending limit |
      | limit   | $spending_limit |
      | total   | $total |

  // Happy path
  $order_id becomes uuid()
  order moves to #awaiting_payment
  reply 201 with:
    | id     | $order_id     |
    | status | current_state |

Guards in Given Blocks

Guards in given: blocks set up test conditions:

flow
scenario: premium checkout

  given:
    @customer is logged in
    @customer is premium member
    cart has items
    $total: number is 500

  on :checkout from @customer (api)
    ? @customer is premium member
      $discount becomes 10%
      $total decreases by $discount

  expect:
    = $total equals 450

Natural Language Flexibility

EventFlow accepts natural variations:

flow
// All equivalent
? cart is not empty
? cart is non-empty
? cart has items

// All equivalent
? $total is greater than 100
? $total > 100
? $total exceeds 100

Use what reads most naturally for your context.

Best Practices

Be Explicit

flow
// Good - clear conditions
? order is in #pending
? payment is authorized
  process order

// Avoid - unclear
? everything is ok
  do stuff

Use Otherwise for Completeness

flow
// Good - all cases handled
? status is active
  process active
? status is pending
  process pending
otherwise
  handle unknown status

// Risky - what if status is something else?
? status is active
  process active
? status is pending
  process pending
flow
// Related conditions together
? payment is authorized
? payment amount matches order total
? payment currency is valid
  complete payment

Released under the MIT License.