Skip to content

Guide Events

Kris Simon edited this page Mar 21, 2026 · 4 revisions

Events

ARO is fundamentally event-driven. Feature sets respond to events rather than being called directly. This chapter explains how events work and how to build event-driven applications.

Event-Driven Architecture

In ARO, feature sets are triggered by events, not called directly:

┌───────────────────────────────────────────────────────────────────┐
│                           Event Bus                               │
├───────────────────────────────────────────────────────────────────┤
│                                                                   │
│  HTTPRequest ──────► (listUsers: User API)  [via operationId]    │
│                                                                   │
│  FileCreated ──────► (Process: FileCreated Handler)              │
│                                                                   │
│  ClientConnected ──► (Handle: ClientConnected Handler)           │
│                                                                   │
│  RepositoryChanged ► (Audit: user-repository Observer)           │
│                                                                   │
└───────────────────────────────────────────────────────────────────┘

Event Types

HTTP Events (Contract-First)

ARO uses contract-first HTTP development. Routes are defined in openapi.yaml, and feature sets are named after operationId values:

openapi.yaml:

openapi: 3.0.3
info:
  title: User API
  version: 1.0.0

paths:
  /users:
    get:
      operationId: listUsers
    post:
      operationId: createUser
  /users/{id}:
    get:
      operationId: getUser

handlers.aro:

(* Triggered by GET /users - matches operationId *)
(listUsers: User API) {
    Retrieve the <users> from the <repository>.
    Return an <OK: status> with <users>.
}

(* Triggered by POST /users *)
(createUser: User API) {
    Extract the <data> from the <request: body>.
    Create the <user> with <data>.
    Return a <Created: status> with <user>.
}

(* Triggered by GET /users/123 *)
(getUser: User API) {
    Extract the <id> from the <pathParameters: id>.
    Retrieve the <user> from the <repository> where id = <id>.
    Return an <OK: status> with <user>.
}

File System Events

Triggered by file system changes:

(* File created *)
(Process New File: FileCreated Handler) {
    Extract the <path> from the <event: path>.
    Read the <content> from the <file: path>.
    Process the <result> from the <content>.
    Return an <OK: status> for the <processing>.
}

(* File modified *)
(Reload Config: FileModified Handler) {
    Extract the <path> from the <event: path>.
    Read the <config> from the <file: path> when <path> is "./config.json".
    Publish as <app-config> <config> when <path> is "./config.json".
    Return an <OK: status> for the <reload>.
}

(* File deleted *)
(Log Deletion: FileDeleted Handler) {
    Extract the <path> from the <event: path>.
    Log "File deleted: ${path}" to the <console>.
    Return an <OK: status> for the <logging>.
}

Socket Events

Triggered by TCP connections:

(* Client connected *)
(Handle Connection: ClientConnected Handler) {
    Extract the <client-id> from the <event: connectionId>.
    Extract the <address> from the <event: remoteAddress>.
    Log "Client connected: ${address}" to the <console>.
    Return an <OK: status> for the <connection>.
}

(* Data received *)
(Process Data: DataReceived Handler) {
    Extract the <data> from the <event: data>.
    Extract the <connection> from the <event: connection>.
    Process the <response> from the <data>.
    Send the <response> to the <connection>.
    Return an <OK: status> for the <processing>.
}

(* Client disconnected *)
(Handle Disconnect: ClientDisconnected Handler) {
    Extract the <client-id> from the <event: connectionId>.
    Log "Client disconnected: ${client-id}" to the <console>.
    Return an <OK: status> for the <cleanup>.
}

Repository Observers

Repository observers automatically react to changes in repositories. They enable reactive programming patterns where code responds to data mutations without explicit coupling.

Naming Pattern:

(Feature Name: {repository-name} Observer)

The {repository-name} must match a repository name (ending in -repository).

Trigger Conditions:

Change Type When Triggered
created New item stored via Store action
updated Existing item replaced via Store action (matching ID)
deleted Item removed via Delete action

Event Payload Fields:

Field Type Description
event: repositoryName String Repository name (e.g., "user-repository")
event: changeType String "created", "updated", or "deleted"
event: entityId String? ID of entity (if has "id" field)
event: newValue Any? New value (nil for deletes)
event: oldValue Any? Previous value (nil for creates)
event: timestamp Date When the change occurred

Example: Audit Logging

(Audit Changes: user-repository Observer) {
    Extract the <changeType> from the <event: changeType>.
    Extract the <entityId> from the <event: entityId>.
    Extract the <repositoryName> from the <event: repositoryName>.

    Compute the <message> from "[AUDIT] " + <repositoryName> + ": " + <changeType> + " (id: " + <entityId> + ")".
    Log <message> to the <console>.

    Return an <OK: status> for the <audit>.
}

Example: Change Tracking

(Track Updates: user-repository Observer) {
    Extract the <changeType> from the <event: changeType>.
    Extract the <oldValue> from the <event: oldValue>.
    Extract the <newValue> from the <event: newValue>.

    Compute the <message> from "User changed: " + <changeType>.
    Log <message> to the <console>.

    (* Compare old and new values for updates *)
    Return an <OK: status> for the <tracking>.
}

Multiple observers can respond to the same repository—they all execute independently and concurrently.

Handling Events

Handler Naming

Event handlers include "Handler" in the business activity:

(Feature Name: EventName Handler)

Examples:

(Index Content: FileCreated Handler) { ... }
(Reload Config: FileModified Handler) { ... }
(Echo Data: DataReceived Handler) { ... }
(Log Connection: ClientConnected Handler) { ... }

Accessing Event Data

Use Extract to get event data:

(Process Upload: FileCreated Handler) {
    Extract the <path> from the <event: path>.
    Extract the <filename> from the <event: filename>.

    Read the <content> from the <file: path>.
    Transform the <processed> from the <content>.
    Store the <processed> into the <processed-repository>.

    Return an <OK: status> for the <processing>.
}

Multiple Handlers

Multiple handlers can respond to the same event:

(* Handler 1: Log the file *)
(Log Upload: FileCreated Handler) {
    Extract the <path> from the <event: path>.
    Log "File uploaded: ${path}" to the <console>.
    Return an <OK: status> for the <logging>.
}

(* Handler 2: Index the file *)
(Index Upload: FileCreated Handler) {
    Extract the <path> from the <event: path>.
    Read the <content> from the <file: path>.
    Store the <index-entry> into the <search-index>.
    Return an <OK: status> for the <indexing>.
}

(* Handler 3: Notify admin *)
(Notify Upload: FileCreated Handler) {
    Extract the <path> from the <event: path>.
    Send the <notification> to the <admin-channel>.
    Return an <OK: status> for the <notification>.
}

All handlers execute independently when the event is emitted.

Built-in Events

Application Events

Event When Triggered
ApplicationStarted After Application-Start completes
ApplicationStopping Before Application-End runs

File Events

Event When Triggered
FileCreated File created in watched directory
FileModified File modified in watched directory
FileDeleted File deleted in watched directory
FileRenamed File renamed in watched directory

Socket Events

Event When Triggered
ClientConnected TCP client connects
DataReceived Data received from client
ClientDisconnected TCP client disconnects

Repository Events

Event When Triggered
RepositoryChanged Item created, updated, or deleted in repository

State Transition Events

State transition events are emitted automatically when the Accept action successfully transitions a state field. These events enable reactive programming around state changes.

StateObserver Pattern

Feature sets become state observers when their business activity matches the pattern:

(Feature Name: fieldName StateObserver)                      (* All transitions on field *)
(Feature Name: fieldName StateObserver<from_to_target>)      (* Specific transition only *)

The fieldName filters which field's transitions to observe. The optional <from_to_target> filter restricts to a specific transition.

Example: Audit Logging (All Transitions)

(* Observe all status changes *)
(Audit Order Status: status StateObserver) {
    Extract the <orderId> from the <transition: entityId>.
    Extract the <fromState> from the <transition: fromState>.
    Extract the <toState> from the <transition: toState>.

    Compute the <message> from "[AUDIT] Order ${orderId}: ${fromState} -> ${toState}".
    Log <message> to the <console>.

    Return an <OK: status> for the <audit>.
}

Example: Shipping Notification (Specific Transition)

(* Notify ONLY when order ships (paid -> shipped) *)
(Send Shipping Notice: status StateObserver<paid_to_shipped>) {
    Extract the <order> from the <transition: entity>.
    Extract the <email> from the <order: customerEmail>.
    Extract the <tracking> from the <order: trackingNumber>.

    Send the <notification> to the <email> with {
        subject: "Your order has shipped!",
        body: "Track your package: ${tracking}"
    }.

    Return an <OK: status> for the <notification>.
}

Transition Data Fields

Field Type Description
transition: fieldName String The field that changed (e.g., "status")
transition: objectName String The object type (e.g., "order")
transition: fromState String Previous state value
transition: toState String New state value
transition: entityId String? ID from object's "id" field, if present
transition: entity Object Full object after transition

Multiple Observers

Multiple observers can react to the same transition:

(* Observer 1: Audit all transitions *)
(Log Transitions: status StateObserver) {
    Log "State changed" to the <console>.
    Return an <OK: status> for the <logging>.
}

(* Observer 2: Only on draft -> placed *)
(Notify Placed: status StateObserver<draft_to_placed>) {
    Send the <webhook> to the <order-service>.
    Return an <OK: status> for the <notification>.
}

(* Observer 3: Only on shipped -> delivered *)
(Track Delivery: status StateObserver<shipped_to_delivered>) {
    Increment the <delivery-counter> by 1.
    Return an <OK: status> for the <analytics>.
}

All matching observers execute independently when a transition occurs.

Custom Domain Events

Beyond built-in events, you can define and emit your own domain events:

Emitting Events

Use the Emit action to publish custom events:

(createUser: User API) {
    Extract the <data> from the <request: body>.
    Create the <user> with <data>.
    Store the <user> in the <user-repository>.

    (* Emit a domain event *)
    Emit a <UserCreated: event> with <user>.

    Return a <Created: status> with <user>.
}

Handling Custom Events

Handle custom events using the Handler pattern:

(* Send welcome email when user is created *)
(Send Welcome: UserCreated Handler) {
    Extract the <user> from the <event: user>.
    Extract the <email> from the <user: email>.
    Send the <welcome-email> to the <email-service> with <user>.
    Return an <OK: status> for the <notification>.
}

(* Update analytics when user is created *)
(Track Signup: UserCreated Handler) {
    Extract the <user> from the <event: user>.
    Send the <signup-event> to the <analytics-service> with <user>.
    Return an <OK: status> for the <analytics>.
}

Event Chains

Handlers can emit additional events, creating processing chains:

(* OrderPlaced triggers inventory reservation *)
(Reserve Stock: OrderPlaced Handler) {
    Extract the <order> from the <event: order>.
    Update the <inventory> for the <order: items>.
    Emit an <InventoryReserved: event> with <order>.
    Return an <OK: status> for the <reservation>.
}

(* InventoryReserved triggers payment processing *)
(Process Payment: InventoryReserved Handler) {
    Extract the <order> from the <event: order>.
    Send the <charge> to the <payment-gateway> with <order>.
    Emit a <PaymentProcessed: event> with <order>.
    Return an <OK: status> for the <payment>.
}

Circular Event Chain Detection

The ARO compiler detects circular event chains at compile time. If handlers form a cycle where events trigger each other indefinitely, the compiler reports an error:

error: Circular event chain detected: OrderPlaced → InventoryReserved → OrderPlaced
  hint: Event handlers form an infinite loop that will exhaust resources
  hint: Consider breaking the chain by using different event types or adding termination conditions

Example of a circular chain (will not compile):

(* BAD: Creates infinite loop *)
(Handle Alpha: EventAlpha Handler) {
    Emit the <EventBeta: event> for the <trigger>.
    Return an <OK: status> for the <handler>.
}

(Handle Beta: EventBeta Handler) {
    Emit the <EventAlpha: event> for the <trigger>.  (* Triggers Alpha again! *)
    Return an <OK: status> for the <handler>.
}

Breaking cycles:

  • Use different event types that don't loop back
  • Design linear workflows where each step moves forward
  • Move repeated logic into a single handler

Long-Running Applications

For applications that need to stay alive to process events (servers, file watchers, etc.), use the Keepalive action:

(Application-Start: File Watcher) {
    Log "Starting file watcher..." to the <console>.

    (* Start watching a directory *)
    Watch the <directory: "./uploads"> as <file-monitor>.

    (* Keep the application running to process file events *)
    Keepalive the <application> for the <events>.

    Return an <OK: status> for the <startup>.
}

The Keepalive action:

  • Blocks execution until a shutdown signal is received (SIGINT/SIGTERM)
  • Allows the event loop to process incoming events
  • Enables graceful shutdown with Ctrl+C

Best Practices

Keep Handlers Focused

(* Good - single responsibility *)
(Log File Upload: FileCreated Handler) {
    Extract the <path> from the <event: path>.
    Log "Uploaded: ${path}" to the <console>.
    Return an <OK: status> for the <logging>.
}

(* Avoid - too many responsibilities *)
(Handle File: FileCreated Handler) {
    (* Don't do logging, indexing, notifications, and analytics in one handler *)
}

Handle Events Idempotently

Events may be delivered multiple times:

(Process File: FileCreated Handler) {
    Extract the <path> from the <event: path>.

    (* Check if already processed *)
    Retrieve the <existing> from the <processed-files> where path = <path>.

    (* Already processed - skip *)
    Return an <OK: status> for the <idempotent> when <existing> is not empty.

    (* Process file *)
    Read the <content> from the <file: path>.
    Transform the <processed> from the <content>.
    Store the <processed> into the <processed-files>.
    Return an <OK: status> for the <processing>.
}

CrawlPage Event Deduplication

The runtime provides built-in deduplication for CrawlPage events. If the same URL is emitted multiple times (e.g., because several pages link to the same target), only the first emission triggers the handler — subsequent ones are silently dropped.

(Crawl Page: Web Crawler) {
    Extract the <url> from the <event: url>.
    Fetch the <page> from <url>.
    Extract the <links> from the <page: links>.

    (* Duplicate URLs are automatically skipped *)
    For each <link> in <links> {
        Emit a <CrawlPage: event> with <link>.
    }

    Return an <OK: status>.
}

The deduplication store is bounded to 100 000 URLs using FIFO eviction, so long-running crawls cannot exhaust memory. When the limit is reached, the oldest-seen URL is forgotten, making it eligible to be crawled again.

If your use-case requires explicit control (larger cap, per-session reset, or revisiting URLs), track visited state yourself with a repository:

(Crawl Page: Web Crawler) {
    Extract the <url> from the <event: url>.

    (* Explicit dedup via repository *)
    Retrieve the <seen> from the <visited-urls> where url = <url>.
    Return an <OK: status> for the <skip> when <seen> is not empty.

    Store the <visited> with { url: <url> } into the <visited-urls>.
    Fetch the <page> from <url>.
    (* ... *)
}

Next Steps

Clone this wiki locally