Hewing Your First Distributed Service
In six steps you'll go from Hello World to a distributed key-value store — learning actors, supervision, and wire types along the way.
Step 1: Hello World
Every Hew program starts with a main function. Let's begin with the basics: bindings, types, and functions.
fn main() {
let greeting = "Hello, Hew!";
println(greeting);
var count = 0;
count = count + 1;
let pi: f64 = 3.14159;
let active: bool = true;
println(f"count={count}, pi={pi}");
}let creates an immutable binding; var creates a mutable one. Hew's core types include Int, f64, string, and bool.
Functions can take parameters and return values:
fn add(a: Int, b: Int) -> Int {
a + b
}
fn greet(name: String) -> String {
f"Hello, {name}!"
}
fn main() {
let sum = add(3, 4);
println(sum);
let msg = greet("world");
println(msg);
}Compile and run with hew build hello.hew -o hello && ./hello. Now let's move on to the feature that makes Hew special: actors.
Step 2: Actors & Messages
An actor is an isolated, single-threaded state machine with a mailbox. Actors never share mutable state — they communicate exclusively through messages. This gives you compile-time data-race freedom without locks.
Define an actor with the actor keyword. Message handlers are marked with receive fn; internal helpers use plain fn. Callers invoke handlers as direct method calls on the actor reference — no .send() or .ask() needed.
actor Counter {
let count: Int;
// Fire-and-forget: no return type, caller does not await
receive fn increment() {
count = count + 1;
}
// Fire-and-forget with a parameter
receive fn add(n: Int) {
count = count + n;
}
// Request-response: has return type, caller must await
receive fn get_count() -> Int {
count
}
}
fn main() {
let counter = spawn Counter;
// Fire-and-forget — direct method calls, no await needed
counter.increment();
counter.increment();
counter.add(10);
// Request-response — await the reply
let n = await counter.get_count();
println(f"count = {n}");
}spawn creates a new actor and returns an ActorRef. Fire-and-forget calls like counter.increment() enqueue a message and return immediately. Each actor processes one message at a time, so there are never data races on count.
Step 3: Request-Response Patterns
Fire-and-forget is great for commands, but sometimes you need a reply. A receive fn with a return type creates a request-response handler. Callers await the method call to get the result — no .ask() needed.
actor Calculator {
let ops: Int;
receive fn compute(op: String, a: Int, b: Int) -> Int {
ops = ops + 1;
if op == "add" { a + b }
else if op == "mul" { a * b }
else if op == "div" { a / b }
else { 0 }
}
}
fn main() {
let calc = spawn Calculator;
let sum = await calc.compute("add", 10, 20);
println(f"Sum: {sum}");
let product = await calc.compute("mul", 3, 7);
println(f"Product: {product}");
}The caller awaits the method call to get the result. Under the hood, the message is sent to the actor, processed, and the reply is returned — all with direct method-call syntax. For error handling, Hew provides Result<T, E> with the ? operator for propagation.
Step 4: Supervision & Fault Tolerance
In production services, crashes are inevitable. Hew follows Erlang's "let it crash" philosophy: instead of defensive error handling everywhere, you define supervisors that automatically restart failed actors.
actor Worker {
let id: Int;
receive fn process(job: string) {
println(f"Worker {id} processing: {job}");
}
}
supervisor WorkerPool {
child w1: Worker
restart(permanent)
budget(5, 60.seconds)
strategy(one_for_one);
child w2: Worker
restart(permanent);
child w3: Worker
restart(transient);
}Key concepts:
- one_for_one: only the crashed child is restarted — siblings keep running.
- permanent: always restart, regardless of exit reason.
- transient: restart only on abnormal exit (crashes), not on normal shutdown.
- temporary: never restart — once stopped, it's gone.
- budget(n, period): if the supervisor exceeds n restarts in period, the failure escalates to the parent supervisor.
Supervisors form a tree. Your top-level supervisor owns mid-level supervisors, which own workers. A crash in a leaf actor is contained and recovered automatically — your service stays up.
Step 5: Wire Types & Networking
Actors on the same machine communicate via in-process messages. But distributed services need to talk over the network. Hew's wire type and wire enum give you network-ready serialization with built-in schema evolution.
wire type PutRequest {
key: String @1;
value: String @2;
ttl: i32 @3 optional;
}
wire type GetRequest {
key: String @1;
}
wire type GetResponse {
found: bool @1;
value: String @2 optional;
}
wire enum RequestType {
Get;
Put;
Delete;
}Schema evolution rules:
- Never reuse a tag number — deleted fields should have their tags reserved.
- New fields must be optional (
optional) or have defaults so old readers can still decode. - Encoding is automatic — Hew generates efficient binary serialization (HBF) from wire definitions.
- Use
hew wire check --against <schema>in CI to catch breaking changes.
Wire types implement the Send trait, so they can be used directly as actor messages — both locally and across the network.
Step 6: Building a Distributed Key-Value Store
Let's tie everything together. We'll build a key-value store with a frontend actor that accepts client requests and a backend actor that stores data.
Message protocol
wire type PutMsg {
key: String @1;
value: String @2;
}
wire type GetMsg {
key: String @1;
}
type GetResult {
found: bool;
value: String;
}Backend actor
actor KVBackend {
let store: HashMap<String, String>;
receive fn put(key: String, value: String) {
store.insert(key, value);
}
receive fn get(key: String) -> GetResult {
match store.get(key) {
Some(val) => GetResult { found: true, value: val },
None => GetResult { found: false, value: "" },
}
}
}Frontend actor
actor KVFrontend {
let backend: KVBackend;
receive fn handle_put(key: String, value: String) {
backend.put(key, value);
}
receive fn handle_get(key: String) -> GetResult {
await backend.get(key)
}
}Supervision tree
supervisor KVSupervisor {
child backend: KVBackend
restart(permanent)
budget(10, 60.seconds)
strategy(one_for_one);
child frontend: KVFrontend
restart(permanent);
}Putting it all together
fn main() {
let backend = spawn KVBackend;
let frontend = spawn KVFrontend(backend: backend);
// Store some data — fire-and-forget
frontend.handle_put("lang", "hew");
frontend.handle_put("version", "0.5");
// Retrieve data — await for response
sleep_ms(100);
let result = await frontend.handle_get("lang");
if result.found {
println(f"Found: {result.value}");
} else {
println("Not found");
}
}You now have a complete service: wire types define the protocol, actors handle concurrency without locks, and supervisors keep everything running through failures. Named actors use direct method calls; lambda actors use the <- send operator. From here, you can add more backends for sharding, plug in network listeners for remote clients, or grow the supervision tree as your service scales.
Next steps
- Actor Model — deep dive into message passing, lambda actors, and structured concurrency
- Type System — generics, capabilities, and pattern matching
- Standard Library — built-in types and IO