Skip to content

Guide Events

Kris Simon edited this page Dec 31, 2025 · 2 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>.
}

Next Steps

Clone this wiki locally