-
Notifications
You must be signed in to change notification settings - Fork 0
Guide 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.
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) │
│ │
└───────────────────────────────────────────────────────────────────┘
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: getUserhandlers.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>.
}
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>.
}
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 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.
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) { ... }
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 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.
| Event | When Triggered |
|---|---|
ApplicationStarted |
After Application-Start completes |
ApplicationStopping |
Before Application-End runs |
| 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 |
| Event | When Triggered |
|---|---|
ClientConnected |
TCP client connects |
DataReceived |
Data received from client |
ClientDisconnected |
TCP client disconnects |
| Event | When Triggered |
|---|---|
RepositoryChanged |
Item created, updated, or deleted in repository |
State transition events are emitted automatically when the <Accept> action successfully transitions a state field. These events enable reactive programming around state changes.
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.
(* 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>.
}
(* 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>.
}
| 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 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.
Beyond built-in events, you can define and emit your own domain 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>.
}
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>.
}
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>.
}
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
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
(* 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 *)
}
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>.
}
- Application Lifecycle - Startup and shutdown events
- HTTP Services - Contract-first HTTP routing
- File System - File system events
Fundamentals
- The Basics
- Feature Sets
- Actions
- Variables
- Type System
- Control Flow
- Error Handling
- Computations
- Dates
- Concurrency
Runtime & Events
I/O & Communication
Advanced