Mediator
The mediator package (@mediator) lets the parts of a MoonBit application
talk through messages instead of direct calls. It follows the mediator pattern
of Foundatio.Mediator, with
one important difference: registration is explicit, and the compiler is the
registry. There is no handler discovery, no text key, and no runtime lookup.
Import moonrockz/moon-lander/mediator as @mediator. The package is pure and
synchronous, so it has the same API and the same behavior on wasm, wasm-gc,
js, and native. Each mbt check block on this page runs as a test with
mise run test.
One send, start to finish
A caller sends a request through a sender value. The middleware that the application attached runs in attach order, then the one handler runs. The reply goes back out through the same middleware in reverse order.
| Part | Value | Make it with |
|---|---|---|
| Sender | Sender[C, Req, Res]: one handler for one request type |
@mediator.sender(handler) |
| Topic | Topic[C, Ev]: one or more handlers for one event type |
@mediator.topic(handler), then add |
| Middleware | Middleware[C, Req, Res]: one wrapper step |
@mediator.middleware(wrap), before, after, finally_ |
| Error | MediatorError: a refusal, or the failures of a publish |
raise @mediator.Rejected(reason=...); publish raises PublishFailed |
Send a request
A handler is a function that takes a context value and a request, and gives a
reply. @mediator.sender takes the handler and gives a Sender[C, Req, Res]
value. Keep the value, and send each request through it.
| Type parameter | Meaning |
|---|---|
C |
The context value that the caller gives to each send |
Req |
The request type. A request of another type does not compile |
Res |
The reply type of the handler |
///|
struct Ping(String)
///|
test "send a ping" {
let ping : @mediator.Sender[Unit, Ping, String] = @mediator.sender((_, req) => {
let Ping(text) = req
"Pong: " + text
})
assert_eq(ping.send((), Ping("Hello")), "Pong: Hello")
}
A sender calls its one handler one time for each send. No other handler runs. When the application does not build a sender for a request type, no value can send that request type, so the gap is a compile error and not a runtime error.
Publish an event
A topic sends one event to many handlers. @mediator.topic takes the first
event handler and gives a Topic[C, Ev] value, so a topic always has at least
one handler. Topic::add gives a new topic with one more handler at the end.
Topic::publish calls each handler one time, in add order. An event has no
reply: publish gives Unit. A caller that needs a reply uses a sender.
///|
struct Joined(String)
///|
test "publish to two handlers" {
let log = []
let joined : @mediator.Topic[Unit, Joined] = @mediator.topic((_, ev) => {
let Joined(name) = ev
log.push("welcome " + name)
})
let all = joined.add((_, ev) => {
let Joined(name) = ev
log.push("count " + name)
})
all.publish((), Joined("Ann"))
assert_eq(log, ["welcome Ann", "count Ann"])
}
The handlers of a topic are independent reactions to one event, so a failing
handler does not stop the others. This is the Foundatio.Mediator contract.
Publish calls every handler, also after a handler raises. After the last
handler, publish raises one MediatorError::PublishFailed that holds the
number of handlers and each failure, in add order. A failure is a
PublishFailure record with the 1-based position of its handler and the
error that the handler raised, unchanged. Publish raises PublishFailed also
when only one handler fails, so a caller has one error shape to handle.
| Handlers that raise | Handlers that run | Publish gives |
|---|---|---|
| None | All, in add order | () |
| One or more | All, in add order | PublishFailed(handlers~, failures~) after the last handler |
///|
suberror CountError {
CountFull(String)
}
///|
test "one handler fails and the others still run" {
let log = []
let joined : @mediator.Topic[Unit, Joined] = @mediator.topic((_, ev) => {
let Joined(name) = ev
log.push("welcome " + name)
})
let all = joined
.add((_, ev) => {
let Joined(name) = ev
raise CountFull(name)
})
.add((_, ev) => {
let Joined(name) = ev
log.push("audit " + name)
})
try all.publish((), Joined("Ann")) catch {
@mediator.PublishFailed(handlers~, failures~) as error => {
assert_eq(handlers, 3)
assert_eq(failures.length(), 1)
assert_eq(failures[0].position, 2)
assert_true(failures[0].error is CountFull("Ann"))
assert_eq(
error.message(),
"Publish failed: 1 of 3 handlers raised an error (position 2)",
)
}
other => fail("publish raised another error: \{other}")
} noraise {
_ => fail("publish did not raise")
}
assert_eq(log, ["welcome Ann", "audit Ann"])
}
A topic whose later steps must not run after a failure uses one handler that calls the steps in order.
Middleware
A middleware wraps the next step of a send or a publish. It gets next, the
context value, and the request or event. It can run code before and after
next, change the request or the reply, give a reply without a call to next,
or catch the error of next.
Cross-cutting work such as logs, validation, and timing goes in middleware, not
in handlers.
Sender::use_ gives a new sender with one more middleware, and Topic::use_
does the same for a topic. The value that you start from does not change. A
topic middleware wraps the whole publish: its next runs all handlers. The
first middleware that you attach is the outermost step, so it runs first on the
way in and last on the way out.
| Constructor | Runs |
|---|---|
@mediator.middleware(wrap) |
wrap(next, ctx, req), which gives the reply |
@mediator.before(hook) |
hook(ctx, req) before the next step |
@mediator.after(hook) |
hook(ctx, req, reply) after the next step gives a reply |
@mediator.finally_(hook) |
hook(ctx, req) after the next step, also when it raises |
///|
test "log middleware around a send" {
let log = []
let ping : @mediator.Sender[Unit, Ping, String] = @mediator.sender((_, req) => {
log.push("handler")
let Ping(text) = req
"Pong: " + text
})
let logged = ping
.use_(
@mediator.middleware((next, ctx, req) => {
log.push("log in")
let reply = next(ctx, req)
log.push("log out")
reply
}),
)
.use_(@mediator.after((_, _, reply) => log.push("reply " + reply)))
assert_eq(logged.send((), Ping("Hello")), "Pong: Hello")
assert_eq(log, ["log in", "handler", "reply Pong: Hello", "log out"])
}
Middleware is typed like the sender: a Middleware[C, Req, Res] fits only a
sender with the same three types. To log every message type, make one
middleware value for each sender.
The context value
Each send and each publish takes a context value of type C. The mediator
gives that exact value to each middleware and to the handler. It does not copy
the value, change it, or keep it after the call. The mediator does not know
what C holds, so a context can be Unit, or a record that holds the services
of the application.
///|
struct Services {
greeting : String
}
///|
struct AppContext {
services : Services
}
///|
test "a handler reads the services from the context" {
let ping : @mediator.Sender[AppContext, Ping, String] = @mediator.sender((
ctx,
req,
) => {
let Ping(text) = req
ctx.services.greeting + ": " + text
})
let ctx = { services: { greeting: "Pong", }, }
assert_eq(ping.send(ctx, Ping("Hello")), "Pong: Hello")
}
Errors
A missing handler is a compile error, and there is no lookup and no I/O, so a
send raises no error of its own. A handler or a middleware can raise an error.
send raises the same error and gives no reply, unless a middleware catches
it. publish collects the errors of its handlers into one PublishFailed. The
mediator does not turn an error into a reply.
MediatorError holds the failures that belong to the mediator pattern:
| Case | Raised by | message gives |
|---|---|---|
Rejected(reason~) |
A handler or a middleware that refuses a request, for example after validation | Request rejected: <reason> |
PublishFailed(handlers~, failures~) |
publish, when one or more handlers raise |
Publish failed: 2 of 3 handlers raised an error (positions 2, 3) |
MediatorError::message gives the text for people. It holds only the reason,
or the counts and the handler positions. It never holds a request, an event, or
the text of a wrapped error, because each one can hold secrets. Read each
wrapped error from failures. An application error from a send passes through
as itself. A topic middleware wraps the whole publish, so it gets the one
PublishFailed from its next and can catch it.
///|
test "a validation middleware rejects a request" {
let ping : @mediator.Sender[Unit, Ping, String] = @mediator.sender((_, req) => {
let Ping(text) = req
"Pong: " + text
})
let checked = ping.use_(
@mediator.before((_, req) => {
let Ping(text) = req
if text == "" {
raise @mediator.Rejected(reason="the ping text is empty")
}
}),
)
try checked.send((), Ping("")) catch {
@mediator.Rejected(_) as error =>
assert_eq(error.message(), "Request rejected: the ping text is empty")
other => fail("send raised another error: \{other}")
} noraise {
reply => fail("send gave a reply: \{reply}")
}
assert_eq(checked.send((), Ping("Hello")), "Pong: Hello")
}