Action Developer Guide
This guide explains how to extend ARO with custom actions. Actions are the fundamental building blocks that implement ARO verbs like <Extract>, <Create>, <Return>, etc.
Table of Contents
- Understanding Actions
- The ActionImplementation Protocol
- Action Roles
- Descriptors
- Execution Context
- Step-by-Step: Creating a Custom Action
- Best Practices
- Examples
- Testing Actions
- Troubleshooting
Understanding Actions
In ARO, every statement follows the Action-Result-Object pattern:
<Verb> the <result> from/to/with/for the <object>.
For example:
<Extract> the <user-id> from the <request: parameters>.
<Create> the <user> with <user-data>.
<Return> an <OK: status> with <response>.
Each verb maps to an action implementation that:
- Receives structured information about the statement
- Executes business logic
- Returns a result to be bound to a variable
The ActionImplementation Protocol
public protocol ActionImplementation: Sendable {
/// The semantic role of this action
static var role: ActionRole { get }
/// The verbs that trigger this action
static var verbs: Set<String> { get }
/// Valid prepositions for object clauses
static var validPrepositions: Set<Preposition> { get }
/// Required initializer
init()
/// Execute the action
func execute(
result: ResultDescriptor,
object: ObjectDescriptor,
context: ExecutionContext
) async throws -> any Sendable
}
Key Points
- Sendable: Actions must be thread-safe
- Static properties: Define metadata at compile time
- Async/throws: Actions can be async and may throw errors
- Returns
any Sendable: Results must be sendable across concurrency domains
Action Roles
Actions are categorized by semantic role:
| Role | Description | Example Verbs |
|---|---|---|
request |
Request data from external sources | Extract, Retrieve, Fetch, Query |
own |
Create or modify owned data | Create, Compute, Transform, Validate |
response |
Send results or responses | Return, Respond, Reply |
export |
Export or publish data | Store, Publish, Log, Send |
public enum ActionRole: String, Sendable {
case request
case own
case response
case export
}
Choose the role that best describes your action's primary purpose.
Descriptors
ResultDescriptor
Information about the result variable:
public struct ResultDescriptor: Sendable {
public let identifier: String // Variable name to bind (e.g., "user-id")
public let typeHint: String? // Optional type (e.g., "JSON")
public let article: String // "a", "an", or "the"
}
ObjectDescriptor
Information about the object clause:
public struct ObjectDescriptor: Sendable {
public let preposition: Preposition // from, to, into, with, for
public let sourceType: SourceType // variable, literal, repository, etc.
public let identifier: String // Source identifier
public let qualifier: String? // Qualifier after ":" (e.g., "body" in "request: body")
public let condition: Condition? // Optional where clause
}
Source Types
public enum SourceType: String, Sendable {
case variable // Reference to a bound variable
case literal // Literal string value
case repository // Repository reference
case service // Service reference
case event // Event data reference
case request // HTTP request data
case file // File reference
}
Prepositions
public enum Preposition: String, Sendable {
case from // Source of data
case to // Destination for data
case into // Storage target
case with // Additional data/parameters
case forPrep // Purpose or target
case via // Method or channel
case at // Location reference
case on // Target for operations
}
Execution Context
The ExecutionContext provides access to runtime services:
public protocol ExecutionContext: AnyObject, Sendable {
// Variable Management
func resolve<T: Sendable>(_ name: String) -> T?
func require<T: Sendable>(_ name: String) throws -> T
func bind(_ name: String, value: any Sendable)
func exists(_ name: String) -> Bool
// Service Access
func service<S>(_ type: S.Type) -> S?
// Repository Access
func repository<T>(named: String) -> (any Repository<T>)?
// Response Management
func setResponse(_ response: Response)
func getResponse() -> Response?
// Event Emission
func emit(_ event: any RuntimeEvent)
// Metadata
var featureSetName: String { get }
var executionId: String { get }
}
Variable Operations
// Get optional value
let name: String? = context.resolve("user-name")
// Get required value (throws if not found)
let userId: String = try context.require("user-id")
// Bind a new variable
context.bind("result", value: computedValue)
// Check existence
if context.exists("optional-param") {
// ...
}
Service Access
// Get a registered service
guard let httpClient = context.service(HTTPClientService.self) else {
throw ActionError.serviceNotFound("HTTPClientService")
}
Event Emission
// Emit domain events
context.emit(UserCreatedEvent(userId: newUser.id))
Step-by-Step: Creating a Custom Action
Step 1: Define Your Action
import ARORuntime
public struct EmailAction: ActionImplementation {
// 1. Define the semantic role
public static let role: ActionRole = .export
// 2. Define verbs that trigger this action
public static let verbs: Set<String> = ["Email", "Mail"]
// 3. Define valid prepositions
public static let validPrepositions: Set<Preposition> = [.to, .with]
// 4. Required initializer
public init() {}
// 5. Implement execute
public func execute(
result: ResultDescriptor,
object: ObjectDescriptor,
context: ExecutionContext
) async throws -> any Sendable {
// Implementation here
}
}
Step 2: Implement the Execute Method
public func execute(
result: ResultDescriptor,
object: ObjectDescriptor,
context: ExecutionContext
) async throws -> any Sendable {
// Get required service
guard let emailService = context.service(EmailService.self) else {
throw ActionError.serviceNotFound("EmailService")
}
// Get the email content (from result identifier)
let content: EmailContent = try context.require(result.identifier)
// Get the recipient from the object
let recipient: String
switch object.sourceType {
case .variable:
recipient = try context.require(object.identifier)
case .literal:
recipient = object.identifier
default:
throw ActionError.invalidObjectSource(object.sourceType)
}
// Perform the action
let sendResult = try await emailService.send(
content: content,
to: recipient
)
// Emit event for observability
context.emit(EmailSentEvent(
recipient: recipient,
messageId: sendResult.messageId
))
// Return the result
return sendResult
}
Step 3: Register Your Action
// In your application setup
ActionRegistry.shared.register(EmailAction.self)
Step 4: Use in ARO
(Send Welcome Email: User Onboarding) {
<Create> the <email-content> with {
subject: "Welcome to our platform!",
body: "Thanks for signing up..."
}.
<Extract> the <user-email> from the <user: email>.
<Email> the <email-content> to the <user-email>.
<Return> an <OK: status> for the <email>.
}
Best Practices
1. Single Responsibility
Each action should do one thing well:
// Good: Focused action
public struct HashPasswordAction: ActionImplementation { ... }
// Bad: Action doing too much
public struct UserManagementAction: ActionImplementation { ... }
2. Fail Fast with Descriptive Errors
Validate inputs early:
public func execute(...) async throws -> any Sendable {
// Validate required services
guard let service = context.service(MyService.self) else {
throw ActionError.serviceNotFound("MyService")
}
// Validate required variables
let input: InputType = try context.require(result.identifier)
// Validate preposition
guard Self.validPrepositions.contains(object.preposition) else {
throw ActionError.executionFailed(
"Invalid preposition '\(object.preposition)' for \(Self.verbs.first ?? "action")"
)
}
// ... proceed with execution
}
3. Use Strong Types
Leverage Swift's type system:
// Define domain types
public struct EmailContent: Sendable {
let subject: String
let body: String
let attachments: [Attachment]
}
// Use in action
let content: EmailContent = try context.require(result.identifier)
4. Emit Events for Observability
// Emit events for significant operations
context.emit(PaymentProcessedEvent(
amount: amount,
currency: currency,
transactionId: result.id
))
5. Handle Cleanup
For actions that allocate resources:
public func execute(...) async throws -> any Sendable {
let connection = try await openConnection()
defer { connection.close() }
return try await connection.execute(query)
}
6. Document Your Action
/// Sends an email using the configured email service.
///
/// Usage in ARO:
/// ```aro
/// <Email> the <content> to the <recipient>.
/// ```
///
/// Requirements:
/// - EmailService must be registered
/// - Result must be EmailContent type
/// - Object must be a string (email address)
public struct EmailAction: ActionImplementation { ... }
Examples
Example 1: Data Transformation Action
public struct ParseJSONAction: ActionImplementation {
public static let role: ActionRole = .own
public static let verbs: Set<String> = ["Parse", "Decode"]
public static let validPrepositions: Set<Preposition> = [.from]
public init() {}
public func execute(
result: ResultDescriptor,
object: ObjectDescriptor,
context: ExecutionContext
) async throws -> any Sendable {
// Get source string
let jsonString: String = try context.require(object.identifier)
// Parse JSON
guard let data = jsonString.data(using: .utf8) else {
throw ActionError.executionFailed("Invalid UTF-8 string")
}
let parsed = try JSONSerialization.jsonObject(with: data)
// Bind result
context.bind(result.identifier, value: parsed)
return parsed
}
}
Usage:
<Parse> the <config> from the <json-string>.
Example 2: External API Action
public struct WeatherAction: ActionImplementation {
public static let role: ActionRole = .request
public static let verbs: Set<String> = ["Weather", "Forecast"]
public static let validPrepositions: Set<Preposition> = [.forPrep]
public init() {}
public func execute(
result: ResultDescriptor,
object: ObjectDescriptor,
context: ExecutionContext
) async throws -> any Sendable {
guard let httpClient = context.service(HTTPClientService.self) else {
throw ActionError.serviceNotFound("HTTPClientService")
}
// Get city from object
let city: String
switch object.sourceType {
case .variable:
city = try context.require(object.identifier)
case .literal:
city = object.identifier
default:
throw ActionError.invalidObjectSource(object.sourceType)
}
// Make API call
let url = "https://api.weather.com/v1/forecast?city=\(city)"
let response = try await httpClient.get(url: url)
// Bind and return
context.bind(result.identifier, value: response)
return response
}
}
Usage:
<Weather> the <forecast> for the <city>.
Example 3: Repository Action
public struct FindAction: ActionImplementation {
public static let role: ActionRole = .request
public static let verbs: Set<String> = ["Find", "Lookup"]
public static let validPrepositions: Set<Preposition> = [.from]
public init() {}
public func execute(
result: ResultDescriptor,
object: ObjectDescriptor,
context: ExecutionContext
) async throws -> any Sendable {
// Get repository
guard let repo: any Repository<Any> = context.repository(
named: object.identifier
) else {
throw ActionError.repositoryNotFound(object.identifier)
}
// Apply condition if present
let items: [Any]
if let condition = object.condition {
items = try await repo.find(where: condition)
} else {
items = try await repo.findAll()
}
context.bind(result.identifier, value: items)
return items
}
}
Usage:
<Find> the <users> from the <user-repository> where status = "active".
Testing Actions
Unit Testing
import XCTest
@testable import ARORuntime
final class EmailActionTests: XCTestCase {
var mockContext: MockExecutionContext!
var mockEmailService: MockEmailService!
override func setUp() {
mockEmailService = MockEmailService()
mockContext = MockExecutionContext()
mockContext.registerService(mockEmailService)
}
func testSendsEmail() async throws {
// Arrange
let content = EmailContent(subject: "Test", body: "Hello")
mockContext.bind("email-content", value: content)
let result = ResultDescriptor(
identifier: "email-content",
typeHint: nil,
article: "the"
)
let object = ObjectDescriptor(
preposition: .to,
sourceType: .literal,
identifier: "test@example.com",
qualifier: nil,
condition: nil
)
// Act
let action = EmailAction()
_ = try await action.execute(
result: result,
object: object,
context: mockContext
)
// Assert
XCTAssertEqual(mockEmailService.sentEmails.count, 1)
XCTAssertEqual(mockEmailService.sentEmails[0].recipient, "test@example.com")
}
func testThrowsWhenServiceMissing() async {
// Arrange
mockContext = MockExecutionContext() // No service registered
let result = ResultDescriptor(identifier: "content", typeHint: nil, article: "the")
let object = ObjectDescriptor(
preposition: .to,
sourceType: .literal,
identifier: "test@example.com",
qualifier: nil,
condition: nil
)
// Act & Assert
let action = EmailAction()
await XCTAssertThrowsError(
try await action.execute(result: result, object: object, context: mockContext)
) { error in
XCTAssertEqual(error as? ActionError, .serviceNotFound("EmailService"))
}
}
}
Integration Testing
func testEmailActionIntegration() async throws {
// Setup real application
let app = Application(programs: [])
app.register(service: RealEmailService(config: testConfig))
ActionRegistry.shared.register(EmailAction.self)
// Execute feature set that uses email action
let program = try compile("""
(Send Test Email: Test) {
<Create> the <content> with { subject: "Test", body: "Hello" }.
<Email> the <content> to "test@example.com".
<Return> an <OK: status> for the <email>.
}
""")
let result = try await app.execute(featureSet: "Send Test Email", from: program)
XCTAssertEqual(result.status, .ok)
}
Troubleshooting
Common Issues
Action not found
Error: No action registered for verb 'MyVerb'
Solution: Ensure you've called ActionRegistry.shared.register(MyAction.self)
Service not found
Error: Service 'MyService' not found in context
Solution: Register the service with the application before running
Variable not found
Error: Variable 'my-var' not found in context
Solution: Ensure the variable is bound before accessing it
Type mismatch
Error: Expected 'String' but found 'Int'
Solution: Check that bound values match expected types
Debugging Tips
- Enable logging: Add logging to your action's execute method
- Check registration order: Services must be registered before actions that use them
- Inspect context state: Print bound variables during development
- Use breakpoints: Set breakpoints in execute() to inspect runtime state
Summary
Creating custom actions involves:
- Implementing
ActionImplementationprotocol - Defining role, verbs, and valid prepositions
- Implementing async
executemethod - Registering with
ActionRegistry - Using in ARO code with the defined verbs
Follow best practices for maintainable, testable actions that integrate well with the ARO runtime.