Most Rust actor frameworks prioritize flexibility. They let you send any message to any actor, handle errors however you want, and change system behavior at runtime. I built acton-reactive around the opposite principle: intentional constraints serve users better. By encoding invariants in the type system (actors can't start without handlers, read-only operations are distinct from mutations, supervision policies are compile-time decisions), the framework eliminates entire error classes before runtime. This approach comes from building for myself: I've written Erlang systems and expect OTP patterns, I build desktop applications that need IPC, and I write CLI tools that should follow platform conventions. The result trades some flexibility for guarantees: if your code compiles, critical classes of runtime errors cannot occur.
Constraints Over Flexibility
Reactive systems fail in predictable ways. An actor receives a message it can't handle. A handler tries to mutate state it can only read. A supervision tree restarts children in the wrong order. Traditional actor frameworks detect these errors at runtime, and you discover them in production logs.
The core architectural decision in acton-reactive: an actor's lifecycle state becomes part of its type. An actor in the Idle state can register handlers but cannot process messages. An actor in the Started state processes messages but cannot register new handlers. This pattern, called type-state, makes the transition compiler-enforced:
let mut actor = runtime.new_actor::<MyState>();
actor.mutate_on::<PingMsg>(|actor, ctx| {
actor.model.counter += 1; // Mutable access to actor state
Reply::ready()
});
let handle = actor.start().await; // Consumes Idle, returns Started
// actor.mutate_on(...); // Compile error: actor was moved
This constraint eliminates attempting to modify handler configuration after an actor starts. In flexible frameworks, developers must remember to configure handlers before starting, and violations appear as runtime surprises. Here, the compiler prevents the mistake.
The pattern extends to message handling. Read-only handlers (act_on) and mutable handlers (mutate_on) are distinct at the type level. The naming is intentional: actors "act on" messages, which is also where the framework gets its name:
actor.act_on::<QueryMsg>(|actor, ctx| {
// actor: &ManagedActor - immutable reference
let result = actor.model.query_data();
Reply::ready()
});
actor.mutate_on::<UpdateMsg>(|actor, ctx| {
// actor: &mut ManagedActor - mutable reference
actor.model.update_data(ctx.message.value);
Reply::ready()
});
Read-only handlers execute concurrently, perfect for queries. Mutable handlers execute sequentially, preserving state consistency. The separation isn't documentation; it's enforced by Rust's borrow checker. A read-only handler that tries to mutate state fails at compile time, not when a race condition manifests in production.
The trade-off: you cannot dynamically choose between concurrent and sequential handling based on runtime conditions. This flexibility loss is intentional. Systems needing runtime handler selection have different invariants than systems needing compile-time guarantees.
Designing for Myself
Constraints work because they match how I already think. I designed acton-reactive for three contexts I work in regularly.
When I'm writing Erlang-style systems, I expect OTP supervision semantics. The Open Telecom Platform provides battle-tested patterns for fault-tolerant systems where supervisors automatically restart failed actors. I think in terms of restart policies and supervision strategies. Acton-reactive mirrors these patterns exactly:
let config = ActorConfig::new(Ern::with_root("worker")?, None, None)?
.with_restart_policy(RestartPolicy::Transient) // Restart only on abnormal termination
.with_supervision_strategy(SupervisionStrategy::OneForOne); // Restart only failed child
If you know Erlang's gen_server and supervisor modules, the concepts transfer directly. The naming, semantics, and termination handling match OTP conventions.
When I'm building desktop applications, I need IPC without complexity. I want actors to expose interfaces across process boundaries, following platform conventions like XDG Base Directory Specification on Linux. The expose_for_ipc() builder method addresses this:
let mut service = runtime.new_actor_with_name::<PriceService>("prices".to_string());
service
.act_on::<GetPrice>(|actor, ctx| {
let price = actor.model.get_price(&ctx.message.item);
ctx.reply_envelope().send(price).await;
Reply::ready()
})
.expose_for_ipc() // Creates socket at XDG_RUNTIME_DIR/acton/prices.sock
.start().await;
The framework handles socket creation, path management, and serialization. External processes send messages via Unix domain sockets; the actor receives them through the same handler mechanism. Handlers are agnostic to message origin.
When I'm writing CLI tools, I expect zero-configuration defaults that follow platform norms. The framework provides sensible defaults (XDG-compliant paths, JSON serialization, automatic cleanup) while allowing override when needed.
Each context shaped different parts of the API. The common thread: design decisions grounded in actual workflows, not abstract principles.
Encoding Invariants in Types
Once I know what I expect from an API, I can encode those expectations in types.
Three patterns illustrate how the type system enforces invariants that documentation cannot.
Macro minimalism: The #[acton_actor] and #[acton_message] macros expand to standard trait implementations, nothing more:
#[acton_actor]
struct MyState { counter: usize }
// Expands to: impl Default, impl Debug, compile-time Send+Sync assertion
This avoids the "magic" problem common in macro-heavy frameworks. Developers can read the expanded code, understand what constraints the framework requires, and reason about their types without hidden behavior. In TypeScript or Python, you'd achieve similar transparency through explicit interface implementation rather than decorators that generate invisible code.
Builder pattern guarantees: The actor builder ensures required configuration happens before starting. The Default implementation guarantees every Idle actor has valid channels and a handle. The start() method consumes self, preventing use-after-start. These aren't runtime checks. They're type system guarantees.
Zero-cost type safety: Type-state transitions compile to no-ops. Converting ManagedActor<Idle, State> to ManagedActor<Started, State> is a field-by-field move with no runtime overhead. The PhantomData marker (a zero-size type that exists only at compile time) provides type safety without runtime representation.
These patterns share a philosophy: encode domain knowledge in types, let the compiler enforce invariants, and ensure abstractions optimize away.
Design Lessons
These aren't just Rust patterns. They're transferable design principles.
1. Encode sequences in types to prevent temporal coupling. When operations must occur in sequence (configure before start, authenticate before request), make each state a distinct type exposing only valid operations. In TypeScript, builder patterns returning new types approximate this; in Go, sentinel errors can enforce ordering.
2. Prefer transparent macros over code generation. Macros that derive standard traits create less debugging friction than those generating complex code. Users should be able to inspect expansion and maintain mental models.
3. Design state transitions as data moves. Type-level distinctions optimize away only if the underlying data is identical. PhantomData markers and zero-size types provide safety without runtime cost.
4. Design for specific workflows over generic flexibility. Know who you're building for. Addressing specific workflows creates better UX than providing generic mechanisms users must configure.
5. Constraints are features when they eliminate errors. If a constraint prevents an entire error class, the lost flexibility rarely matters in practice.
Trade-offs
Dynamic handler registration is impossible. Once an actor starts, you cannot add handlers. This prevents plugin architectures where handlers load dynamically, a common pattern in extensible applications.
Type-state transitions are one-way. An actor cannot return from Started to Idle for reconfiguration. Systems needing reconfigurable actors must stop and restart them.
Read-only/mutable separation is strict. Handlers that read usually but mutate occasionally must use mutate_on, accepting sequential execution even when mutation doesn't occur.
Supervision mirrors OTP strictly. Systems needing custom restart logic (exponential backoff, circuit breakers) must implement it outside the supervision system.
These aren't bugs. They're design choices. Each constraint enables compile-time guarantees.
Closing
This constraint-based philosophy means encoding domain invariants in types, letting the compiler prevent errors, and accepting that some flexibility disappears.
This is for you if: your system has clear invariants (actor lifecycle, concurrency rules, supervision policies) and you value preventing entire error classes over runtime flexibility.
This isn't for you if: you need dynamic plugin architectures, runtime handler reconfiguration, or custom fault-tolerance patterns beyond OTP semantics.
Explore acton-reactive at github.com/GovCraft/acton-reactive. If your system has clear invariants, consider encoding them in types rather than documenting them.
The acton-reactive crate provides the actor primitives discussed in this article. Check out the documentation for examples of type-state patterns, supervision setup, and IPC integration.
Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.