Skip to content

Resolver & Action Returns

Defining Return Behaviors and Introducing Resolvers for EventFlow Bindings

Version: Draft 0.1 Date: December 2024 Status: To Be Evaluated


Executive Summary

This proposal introduces a comprehensive system for defining return behaviors in EventFlow bindings, including a new Resolver binding type. It establishes clear conventions for how guards, actions, and resolvers communicate their outputs to the flow runtime, with explicit context writing through the -> syntax.

Core Philosophy

What a binding writes to context should be visible in the flow file.

Resolvers gather data. Actions do work. Both are explicit about their outputs.

Key Features

  1. Resolver Binding Type - New ~ prefixed binding for data fetching without side effects
  2. Explicit Context Writing - -> syntax shows what variables are written to context
  3. Unified Attribute Syntax - PHP attributes mirror flow file syntax (minus line prefix symbols)
  4. Guard Error Info - Inline error messages in both flow and PHP attributes
  5. Message Injection - Optional access to error messages in guards for logging
  6. Event Emission Policy - Actions cannot emit events; events are flow-level concerns

Motivation & Problem Statement

Current Situation

EventFlow guards return bool, actions return void or context changes, but:

  1. Hidden outputs - When an action modifies context, it's not visible in the flow file
  2. No data fetching abstraction - Actions mix side effects with pure data retrieval
  3. Inconsistent patterns - Different approaches for similar problems
  4. Testing gaps - No way to access error metadata from guards in tests

Example: Hidden Context Modification

flow
on :checkout from @customer (api)
  calculate totals          // What does this write to context?
  ? $total > 1000           // Where did $total come from?
    apply bulk discount

The flow file doesn't reveal that calculate totals writes $total, $tax, and $subtotal to context.

Example: Mixed Responsibilities

php
#[Action('prepare checkout data')]
class PrepareCheckoutDataAction {
    public function __invoke(array $context): array {
        // This fetches data (no side effect) - should it be an action?
        $customer = $this->customerService->find($context['customer_id']);
        $shipping = $this->shippingService->calculate($context['address']);

        return [
            'customer_tier' => $customer->tier,
            'shipping_cost' => $shipping->cost,
        ];
    }
}

This "action" has no side effects - it's purely data fetching. Should it be treated the same as an action that sends emails or charges payments?

Goals

  1. Explicit outputs - Flow file shows what each binding writes to context
  2. Clear responsibilities - Resolvers for data, actions for work
  3. Consistent syntax - PHP attributes mirror flow syntax
  4. Better testing - Access to all binding metadata

Proposed Solution

1. Resolver Binding Type

Resolvers are a new binding type for data fetching without side effects.

Characteristics:

  • Symbol: ~ (tilde)
  • Return: array (values to write to context)
  • Side effects: NONE (no DB writes, no API calls that modify state, no emails)
  • Idempotent: YES (calling multiple times produces same result)
  • Responsibility: Does not take responsibility - just provides data

Flow Syntax:

flow
on :checkout from @customer (api)
  // Resolvers - data preparation (no responsibility)
  ~ resolve customer loyalty tier -> $loyalty_tier, $loyalty_points
  ~ resolve shipping options -> $shipping_methods, $shipping_cost
  ~ fetch next shipping date -> $next_shipping_date

  // Guards - validation
  ? cart is not empty | 400 cart cannot be empty
  ? shipping is available | 400 not available until $next_shipping_date

  // Actions - business logic (takes responsibility)
  calculate totals -> $total, $tax, $subtotal
  ! reserve inventory -> $reservation_id
  ! charge payment -> $transaction_id, $payment_status

PHP Binding (attribute mirrors flow syntax, minus ~ symbol):

php
#[Resolver('resolve customer loyalty tier -> $loyalty_tier, $loyalty_points')]
#[RequiresContext('$customer_id')]
class CustomerLoyaltyTierResolver
{
    public function __construct(
        private LoyaltyService $loyaltyService
    ) {}

    public function __invoke(array $context): array
    {
        $tier = $this->loyaltyService->getTier($context['customer_id']);

        return [
            'loyalty_tier' => $tier->name,
            'loyalty_points' => $tier->points,
        ];
    }
}

2. Action Return Types

Actions can either return void (side effect only) or array (context changes).

Void Action (Side Effect Only):

flow
! send confirmation email
php
#[Action('send confirmation email')]
#[RequiresContext('$customer_email', '$order_id')]
class SendConfirmationEmailAction
{
    public function __invoke(array $context): void
    {
        $this->mailer->send($context['customer_email'], ...);
        // No return - this action only has side effects
    }
}

Context-Returning Action:

flow
calculate totals -> $total, $tax, $subtotal
php
#[Action('calculate totals -> $total, $tax, $subtotal')]
#[RequiresContext('$items', '$loyalty_tier')]
class CalculateTotalsAction
{
    public function __invoke(array $context): array
    {
        $subtotal = array_sum(array_column($context['items'], 'price'));
        $tax = $subtotal * 0.18;

        return [
            'subtotal' => $subtotal,
            'tax' => $tax,
            'total' => $subtotal + $tax,
        ];
    }
}

3. Guard Return Types & Error Info

Guards return bool. Error information is specified inline using the | syntax.

Flow Syntax:

flow
? cart is not empty | 400 cart cannot be empty
? shipping is available | 400 not available until $next_shipping_date

PHP Binding (attribute includes error info):

php
#[Guard('cart is not empty | 400 cart cannot be empty')]
#[RequiresContext('$cart')]
class CartNotEmptyGuard
{
    public function __invoke(array $context): bool
    {
        return !empty($context['cart']?->items);
    }
}

Message Injection (Optional):

Guards can optionally receive the error message for logging purposes. The message is read-only and cannot be modified by the guard.

php
#[Guard('cart is not empty | 400 cart cannot be empty')]
#[RequiresContext('$cart')]
class CartNotEmptyGuard
{
    public function __construct(
        private LoggerInterface $logger
    ) {}

    public function __invoke(array $context, string $message): bool
    {
        $result = !empty($context['cart']?->items);

        if (!$result) {
            $this->logger->warning("Guard failed: {$message}");
        }

        return $result;
    }
}

Dynamic Error Messages:

For dynamic error messages that depend on computed values, use a resolver to fetch the data first:

flow
on :checkout from @customer (api)
  ~ resolve next shipping date -> $next_shipping_date
  ? shipping is available | 400 not available until $next_shipping_date

The $next_shipping_date is resolved first, then used in the guard's error message via variable interpolation.


Symbol System

SymbolTypeRequiredExample
~ResolverYES~ resolve customer tier -> $tier
!ActionOPTIONAL! send email or send email
?GuardYES? cart is not empty
=AssertionYES= order is in #paid
->ReturnsYES (for context writing)calculate totals -> $total
|Error InfoOPTIONAL (guards only)? cart not empty | 400 error

Symbol in Attributes

Line prefix symbols (~, !, ?, =) are NOT included in PHP attributes - the attribute type itself indicates the binding type:

Flow SyntaxPHP Attribute
~ resolve tier -> $tier#[Resolver('resolve tier -> $tier')]
calculate totals -> $total#[Action('calculate totals -> $total')]
! send email#[Action('send email')]
? cart not empty | 400 error#[Guard('cart not empty | 400 error')]

Resolver vs Action Comparison

AspectResolver (~)Action (!)
PurposeProvide dataDo work
ResponsibilityDoes NOT takeTakes
Side effectsNONEMAY have
DB readYesYes
DB writeNoYes
API readYesYes
API writeNoYes
Send emailNoYes
Emit eventsNoNo
Context writingYes (->)Yes (->)
Return typearrayvoid or array
Flow visibilityExplicit (->)Explicit (->)
IdempotentYESMay not be

When to Use Which?

Use Resolver when:

  • Fetching data from database
  • Calling external APIs for information (read-only)
  • Computing derived values
  • No permanent changes result from the call

Use Action when:

  • Sending emails or notifications
  • Writing to database
  • Calling external APIs that modify state
  • Processing payments
  • Any operation where "something happens"

Event Emission Policy

Actions CANNOT emit events. Event emission is a flow-level concern.

Rationale:

  1. Flow files should show the complete event flow
  2. Hidden event emissions make flows unpredictable
  3. Aligns with implicit states proposal philosophy
  4. Maintains "documentation that executes" principle

Correct Pattern:

flow
on :process_order from @system
  check for fraud -> $fraud_detected

  ? $fraud_detected
    emit :fraud_alert to @security
    order moves to #fraud_review

  otherwise
    order moves to #processing

Incorrect Pattern:

php
// DON'T DO THIS
#[Action('check for fraud')]
class CheckForFraudAction {
    public function __invoke(array $context): void {
        if ($this->detectFraud($context)) {
            // Hidden event emission - BAD!
            $this->eventBus->emit('fraud_alert', $context);
        }
    }
}

PHP Attributes

Attribute Reference

AttributePatternDescription
#[Resolver('pattern -> $var1, $var2')]Pattern with returnsResolver definition
#[Action('pattern')]Pattern onlyVoid action
#[Action('pattern -> $var1')]Pattern with returnsContext-returning action
#[Guard('condition')]Condition onlyGuard without error info
#[Guard('condition | status message')]Condition with errorGuard with error info
#[RequiresContext('$var1', '$var2')]Variable namesRequired context (from CONTEXT_DEPENDENCIES_PROPOSAL)

Simplified Attributes

Because the attribute string contains all necessary information, these attributes are NOT needed:

  • #[WritesToContext] - Encoded in -> $var1, $var2
  • #[ReturnsContext] - Encoded in -> $var1, $var2
  • #[ErrorInfo] - Encoded in | status message

Complete Example

flow
machine: @order

scenario: checkout flow

  given:
    @customer is logged in
    cart has items

  on :checkout from @customer (api)
    // Resolvers - data preparation (no responsibility)
    ~ resolve customer loyalty tier -> $loyalty_tier, $loyalty_points
    ~ resolve shipping options -> $shipping_methods, $shipping_cost
    ~ resolve next shipping date -> $next_shipping_date

    // Guards - validation
    ? cart is not empty | 400 cart cannot be empty
    ? shipping is available | 400 not available until $next_shipping_date

    // Actions - business logic (takes responsibility)
    calculate totals -> $total, $tax, $subtotal
    ! reserve inventory -> $reservation_id
    ! charge payment -> $transaction_id, $payment_status

    ? $payment_status is "success"
      ! send confirmation email
      $order_id becomes uuid()
      order moves to #paid
      emit :order_confirmed to @customer

    otherwise
      ! release inventory
      order moves to #payment_failed
      emit :payment_failed to @customer

  expect:
    = order is in #paid
    = @customer received :order_confirmed

Testing

Unit Testing Actions

php
// #[Action('calculate totals -> $total, $tax, $subtotal')]
$action = new CalculateTotalsAction();
$result = $action([
    'items' => [['price' => 100], ['price' => 200]],
    'loyalty_tier' => 'gold',
]);

$this->assertEquals(300, $result['subtotal']);
$this->assertEquals(54, $result['tax']);
$this->assertEquals(354, $result['total']);

Unit Testing Resolvers

php
// #[Resolver('resolve customer loyalty tier -> $loyalty_tier, $loyalty_points')]
$resolver = new CustomerLoyaltyTierResolver($loyaltyService);
$result = $resolver(['customer_id' => 'cust-123']);

$this->assertEquals('gold', $result['loyalty_tier']);
$this->assertEquals(1500, $result['loyalty_points']);

Unit Testing Guards

php
// #[Guard('cart is not empty | 400 cart cannot be empty')]
$guard = new CartNotEmptyGuard();

// Logic test
$this->assertFalse($guard(['cart' => null]));
$this->assertTrue($guard(['cart' => ['items' => ['x']]]));

// Error info test (via reflection on attribute)
$errorInfo = GuardMetadata::getErrorInfo(CartNotEmptyGuard::class);
$this->assertEquals(400, $errorInfo->status);
$this->assertEquals('cart cannot be empty', $errorInfo->message);

Implementation Requirements

New Components

ComponentFilePurpose
Resolver attributesrc/Attributes/Resolver.phpResolver binding definition
GuardMetadatasrc/Metadata/GuardMetadata.phpExtract error info from guard attributes
ResolverMetadatasrc/Metadata/ResolverMetadata.phpExtract returns from resolver attributes
ActionMetadatasrc/Metadata/ActionMetadata.phpExtract returns from action attributes

TextMate Grammar Updates

Add ~ as resolver prefix in eventflow.tmLanguage.json:

json
{
  "match": "^(\\s*)(~)\\s+(.+?)\\s*(->)\\s*(.+)$",
  "captures": {
    "2": { "name": "keyword.operator.resolver.eventflow" },
    "3": { "name": "entity.name.function.resolver.eventflow" },
    "4": { "name": "keyword.operator.returns.eventflow" },
    "5": { "name": "variable.other.context.eventflow" }
  }
}

Documentation Updates

FileChanges
docs/reference/php-binding.mdAdd Resolver binding documentation
docs/guide/guards.mdAdd inline error syntax documentation
docs/guide/actions.mdAdd -> returns syntax documentation
docs/reference/language/symbols.mdAdd ~ and -> symbols
docs/reference/language/line-prefixes.mdAdd resolver prefix

Relationship to Other Proposals

CONTEXT_DEPENDENCIES_PROPOSAL

This proposal extends the context dependencies proposal:

  • #[RequiresContext] defines inputs
  • -> syntax (this proposal) defines outputs
  • Together they provide complete binding contracts

IMPLICIT_STATES_V2_PROPOSAL

The "actions cannot emit events" policy aligns with implicit states:

  • Events should be visible in flow files
  • State transitions should be explicit
  • No hidden behavior in bindings

Migration Path

Phase 1: Add Resolver Support

  1. Implement #[Resolver] attribute
  2. Add ~ prefix parsing to flow parser
  3. Add -> returns syntax for all binding types

Phase 2: Migrate Existing Patterns

Existing "data fetching" actions can be migrated to resolvers:

Before:

php
#[Action('prepare checkout data')]
class PrepareCheckoutDataAction { }

After:

php
#[Resolver('prepare checkout data -> $customer_tier, $shipping_cost')]
class PrepareCheckoutDataResolver { }

Phase 3: Enhanced Tooling

bash
# Suggest resolver vs action based on side effect analysis
$ eventflow binding:analyze PrepareCheckoutDataAction

Analysis: No side effects detected
Recommendation: Convert to Resolver

Summary

This proposal introduces:

  1. Resolver binding type (~) - For side-effect-free data fetching
  2. Explicit returns syntax (->) - Shows what bindings write to context
  3. Unified attribute syntax - PHP attributes mirror flow syntax
  4. Guard error info (|) - Inline error messages with optional injection
  5. Event emission policy - Actions cannot emit events

Benefits

BenefitHow
Visible outputs-> syntax shows context writes in flow files
Clear responsibilitiesResolvers fetch, actions do work
Consistent syntaxFlow and PHP use same patterns
Better testingAll metadata accessible via reflection
Predictable flowsNo hidden event emissions

Next Steps

  1. Gather feedback on proposal
  2. Implement Resolver attribute and parsing
  3. Add -> syntax to flow parser
  4. Update TextMate grammar for syntax highlighting
  5. Create documentation and examples

Released under the MIT License.