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
- Resolver Binding Type - New
~prefixed binding for data fetching without side effects - Explicit Context Writing -
->syntax shows what variables are written to context - Unified Attribute Syntax - PHP attributes mirror flow file syntax (minus line prefix symbols)
- Guard Error Info - Inline error messages in both flow and PHP attributes
- Message Injection - Optional access to error messages in guards for logging
- 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:
- Hidden outputs - When an action modifies context, it's not visible in the flow file
- No data fetching abstraction - Actions mix side effects with pure data retrieval
- Inconsistent patterns - Different approaches for similar problems
- Testing gaps - No way to access error metadata from guards in tests
Example: Hidden Context Modification
on :checkout from @customer (api)
calculate totals // What does this write to context?
? $total > 1000 // Where did $total come from?
apply bulk discountThe flow file doesn't reveal that calculate totals writes $total, $tax, and $subtotal to context.
Example: Mixed Responsibilities
#[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
- Explicit outputs - Flow file shows what each binding writes to context
- Clear responsibilities - Resolvers for data, actions for work
- Consistent syntax - PHP attributes mirror flow syntax
- 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:
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_statusPHP Binding (attribute mirrors flow syntax, minus ~ symbol):
#[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):
! send confirmation email#[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:
calculate totals -> $total, $tax, $subtotal#[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:
? cart is not empty | 400 cart cannot be empty
? shipping is available | 400 not available until $next_shipping_datePHP Binding (attribute includes error info):
#[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.
#[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:
on :checkout from @customer (api)
~ resolve next shipping date -> $next_shipping_date
? shipping is available | 400 not available until $next_shipping_dateThe $next_shipping_date is resolved first, then used in the guard's error message via variable interpolation.
Symbol System
| Symbol | Type | Required | Example |
|---|---|---|---|
~ | Resolver | YES | ~ resolve customer tier -> $tier |
! | Action | OPTIONAL | ! send email or send email |
? | Guard | YES | ? cart is not empty |
= | Assertion | YES | = order is in #paid |
-> | Returns | YES (for context writing) | calculate totals -> $total |
| | Error Info | OPTIONAL (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 Syntax | PHP 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
| Aspect | Resolver (~) | Action (!) |
|---|---|---|
| Purpose | Provide data | Do work |
| Responsibility | Does NOT take | Takes |
| Side effects | NONE | MAY have |
| DB read | Yes | Yes |
| DB write | No | Yes |
| API read | Yes | Yes |
| API write | No | Yes |
| Send email | No | Yes |
| Emit events | No | No |
| Context writing | Yes (->) | Yes (->) |
| Return type | array | void or array |
| Flow visibility | Explicit (->) | Explicit (->) |
| Idempotent | YES | May 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:
- Flow files should show the complete event flow
- Hidden event emissions make flows unpredictable
- Aligns with implicit states proposal philosophy
- Maintains "documentation that executes" principle
Correct Pattern:
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 #processingIncorrect Pattern:
// 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
| Attribute | Pattern | Description |
|---|---|---|
#[Resolver('pattern -> $var1, $var2')] | Pattern with returns | Resolver definition |
#[Action('pattern')] | Pattern only | Void action |
#[Action('pattern -> $var1')] | Pattern with returns | Context-returning action |
#[Guard('condition')] | Condition only | Guard without error info |
#[Guard('condition | status message')] | Condition with error | Guard with error info |
#[RequiresContext('$var1', '$var2')] | Variable names | Required context (from CONTEXT_DEPENDENCIES_PROPOSAL) |
Simplified Attributes
Because the attribute string contains all necessary information, these attributes are NOT needed:
- Encoded in#[WritesToContext]-> $var1, $var2- Encoded in#[ReturnsContext]-> $var1, $var2- Encoded in#[ErrorInfo]| status message
Complete Example
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_confirmedTesting
Unit Testing Actions
// #[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
// #[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
// #[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
| Component | File | Purpose |
|---|---|---|
| Resolver attribute | src/Attributes/Resolver.php | Resolver binding definition |
| GuardMetadata | src/Metadata/GuardMetadata.php | Extract error info from guard attributes |
| ResolverMetadata | src/Metadata/ResolverMetadata.php | Extract returns from resolver attributes |
| ActionMetadata | src/Metadata/ActionMetadata.php | Extract returns from action attributes |
TextMate Grammar Updates
Add ~ as resolver prefix in eventflow.tmLanguage.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
| File | Changes |
|---|---|
docs/reference/php-binding.md | Add Resolver binding documentation |
docs/guide/guards.md | Add inline error syntax documentation |
docs/guide/actions.md | Add -> returns syntax documentation |
docs/reference/language/symbols.md | Add ~ and -> symbols |
docs/reference/language/line-prefixes.md | Add 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
- Implement
#[Resolver]attribute - Add
~prefix parsing to flow parser - Add
->returns syntax for all binding types
Phase 2: Migrate Existing Patterns
Existing "data fetching" actions can be migrated to resolvers:
Before:
#[Action('prepare checkout data')]
class PrepareCheckoutDataAction { }After:
#[Resolver('prepare checkout data -> $customer_tier, $shipping_cost')]
class PrepareCheckoutDataResolver { }Phase 3: Enhanced Tooling
# Suggest resolver vs action based on side effect analysis
$ eventflow binding:analyze PrepareCheckoutDataAction
Analysis: No side effects detected
Recommendation: Convert to ResolverSummary
This proposal introduces:
- Resolver binding type (
~) - For side-effect-free data fetching - Explicit returns syntax (
->) - Shows what bindings write to context - Unified attribute syntax - PHP attributes mirror flow syntax
- Guard error info (
|) - Inline error messages with optional injection - Event emission policy - Actions cannot emit events
Benefits
| Benefit | How |
|---|---|
| Visible outputs | -> syntax shows context writes in flow files |
| Clear responsibilities | Resolvers fetch, actions do work |
| Consistent syntax | Flow and PHP use same patterns |
| Better testing | All metadata accessible via reflection |
| Predictable flows | No hidden event emissions |
Next Steps
- Gather feedback on proposal
- Implement Resolver attribute and parsing
- Add
->syntax to flow parser - Update TextMate grammar for syntax highlighting
- Create documentation and examples