bloccs · 01 — Your first network
Mix.install([
{:bloccs, "~> 0.9"}
])
What this notebook builds
bloccs turns a TOML manifest into a running Broadway supervision tree. This notebook runs the whole loop — parse → validate → compile → run — on the smallest graph there is: one pure node that greets a name.
entry ─▶ [greeter] ─▶ exit
(pure)
You will:
- Register two versioned schemas that describe the payloads.
-
Write a node manifest (
hello_node.bloccs) and a network manifest (hello.bloccs). - Define the node module — a pure core that computes and an effect shell that emits.
- Compile the network in-process, start it, push a message, and read the reply.
Everything runs in the BEAM inside this notebook. No external services, no files to clone.
Vocabulary in one paragraph
A node has typed input and output ports; each port carries a versioned
schema (Name@1). A node splits into a pure core (transform/2, no
effects, deterministic) and an effect shell (execute/2, the only place
effects happen). A network wires nodes together with edges and
exposes named entry/exit ports. The manifest is the source of truth; the
runtime is generated from it.
1 · Register the schemas
Schemas declare the shape of what flows between ports. They live in a
:persistent_term-backed registry, so registering them is all the setup the
schema layer needs.
Bloccs.Schema.register("Name@1", name: :string)
Bloccs.Schema.register("Greeting@1", message: :string)
2 · Write the manifests
A network manifest references its nodes by file path, and use Bloccs.Node
(next section) reads the node manifest at compile time. The macro needs a
literal path, so this notebook writes both manifests to a fixed directory
under /tmp and points at them by their absolute paths.
dir = "/tmp/bloccs_first_network"
File.mkdir_p!(dir)
File.write!(Path.join(dir, "hello_node.bloccs"), """
[node]
id = "hello"
version = "0.1.0"
kind = "transform"
[doc]
intent = "Greet a name. The smallest possible node: pure, no effects."
[ports.in]
greeting = { schema = "Name@1" }
[ports.out]
reply = { schema = "Greeting@1" }
[effects]
[contract]
pure_core = "BloccsNotebook.Hello.transform/2"
effect_shell = "BloccsNotebook.Hello.execute/2"
""")
File.write!(Path.join(dir, "hello.bloccs"), """
[network]
id = "hello"
version = "0.1.0"
runtime = "beam"
[nodes]
greeter = { use = "hello_node.bloccs" }
[expose]
in = { entry = "greeter.greeting" }
out = { exit = "greeter.reply" }
[supervision]
strategy = "one_for_one"
""")
File.ls!(dir)
The node declares an empty [effects] block — it touches nothing outside
itself, so the pure core is the whole story. The [contract] lines bind the
manifest to the two functions defined next; the network exposes greeter‘s
ports as entry (in) and exit (out).
3 · Define the node
use Bloccs.Node reads the manifest at compile time, validates it, and confirms
the pure_core/effect_shell functions exist with the right arity. The
generated runtime calls back into this module, so the manifest path must point
at the file written above.
-
transform/2is the pure core: a name in, a greeting out. No IO, no effects, fully deterministic — the part you unit-test in isolation. -
execute/2is the effect shell: here it only emits the pure result to thereplyport. In a node that declared effects, this is where HTTP/DB calls would live, reached throughctx.effects.
defmodule BloccsNotebook.Hello do
use Bloccs.Node, manifest: "/tmp/bloccs_first_network/hello_node.bloccs"
def transform(req, _ctx) do
case req[:name] || req["name"] do
name when is_binary(name) and name != "" ->
{:ok, %{message: "Hello, #{String.capitalize(name)}!"}}
_ ->
{:error, :invalid_name}
end
end
def execute(reply, _ctx), do: {:emit, :reply, reply}
end
4 · Parse → validate → compile → run
This is the loop bloccs runs every time. Each step is a function you can call directly:
-
Parser.parse_network/1turns the TOML into a typed network struct (and pulls in each referenced node manifest). -
Validator.validate_network/1checks that ports are declared, edges schema-match, and the contract functions exist — failures here happen before anything starts, not in production. -
Compiler.compile_and_load/1generates a Broadway supervisor for the network and loads it into memory. (The CLI’smix bloccs.compilewrites the same source to_build/bloccs_generated/for review; in-process we skip the file step.) -
The returned module is a plain OTP
Supervisor—start_link/1boots the tree.
{:ok, network} = Bloccs.Parser.parse_network("/tmp/bloccs_first_network/hello.bloccs")
:ok = Bloccs.Validator.validate_network(network)
{:ok, sup} = Bloccs.Compiler.compile_and_load(network)
{:ok, _pid} = sup.start_link([])
network.id
Push a message, read the reply
Router.register_sink/4 subscribes this process to an exposed output port, so
the reply lands in the notebook’s mailbox. Producer.push/2 feeds a payload
into an input port. The message flows through the compiled Broadway pipeline and
comes back out greeter.reply.
Bloccs.Router.register_sink(:hello, :greeter, :reply, self())
Bloccs.Producer.push(
Bloccs.Router.producer_name(:hello, :greeter, :greeting),
%{name: "ada"}
)
receive do
{:bloccs_sink, :hello, :greeter, :reply, %{message: message}} -> message
after
2_000 -> "(no reply within 2s)"
end
You should see "Hello, Ada!" — a name pushed into a TOML-described network,
processed by a pure core, emitted by an effect shell, and routed back to you.
Try it
-
Push a different name, or an empty string (
%{name: ""}) — the pure core returns{:error, :invalid_name}and nothing reaches the sink. -
Open
/tmp/bloccs_first_network/hello.bloccsand changeid; re-run from section 4 and watch the network boot under the new name.
Where next
-
02_events_webhook— the same loop at realistic scale: HTTP + DB effects, retry, timeout, branching, and coverage. -
03_routing_primitives— filter, split, and merge, each as a small runnable network. -
04_aggregation_primitives— batch, join, and rate. - The library guides — the manifest reference and the architecture behind the generated Broadway tree.