Picks up where Hello World left you: a my-app/ directory with greet, scenarios, and three test lenses. The greet boundary is small — params in, string out — but the shape is the shape every boundary follows. This tutorial walks two larger ones, both wrapping an external HTTP API, both with their own scenarios and halts. By the end you'll have walked the full registration, identity, halt, and scenario surface end to end.
A boundary is the framework's unit of work. Each one is a class that mixes in Wanderland::Boundary, declares its identity (who it speaks as when it signs a crossing), declares its contract (what scopes it requires of the caller, what capabilities it provides, what input shape it expects), and implements #call(input). The runtime treats each boundary as a first-class addressable entity: registered under a symbol, looked up by route or by chain composition, traced into the audit log on every invocation, signed when an identity is declared.
A single boundary :name, ... declaration buys a lot. Registration into the global manifest. Dispatch from named routes. Identity threading into outbound crossings. Halt-signal propagation through the chain walker. Scenario-driven testing under three lenses. Inspection through /inspect/boundary/:name. The boundary itself stays small — usually a #call body that reads input, does the work, and returns a hash or a Signal. Everything else is the runtime's responsibility.
Boundaries that wrap external services follow a recurring shape. The #call body validates required input (often via input.find_or_fail("params.key")), makes the upstream call, and either returns the parsed body on success or returns Signal.halt(status:, error:) on a failure mode the caller should know about. The two boundaries this tutorial walks are both that shape, against the same upstream, with the second adding query-param plumbing and an extra halt for upstream errors.
The example API is babynames.netstudy.in — no auth, JSON in JSON out.
Two:
GET /api/name/{name} — returns one record: { name, meaning, origin, gender, numerology, source }.GET /api/names?origin={origin}&gender={gender} — returns { count, results: [...] }.You'll wrap each in its own boundary. The first lookup proves the call shape; the second adds query-param plumbing and an upstream-error halt.
lib/my_app/boundaries/baby_lookup.rb# frozen_string_literal: true
require "net/http"
require "json"
module MyApp
module Boundaries
class BabyLookup
include Wanderland::Boundary
IDENTITY = Wanderland::Identity.new(
id: "boundary:baby_lookup",
name: "BabyLookup",
roles: [:boundary],
type: :service,
scopes: [:read]
).freeze
boundary :baby_lookup,
identity: IDENTITY,
requirements: [:read],
capabilities: [:baby_lookup],
description: "Fetch one baby name from babynames.netstudy.in",
input_shape: { "params" => true }
ENDPOINT = "https://babynames.netstudy.in/api/name"
def call(input)
name = input.find_or_fail("params.name")
uri = URI.parse("#{ENDPOINT}/#{URI.encode_www_form_component(name)}")
response = Net::HTTP.get_response(uri)
case response.code.to_i
when 200 then JSON.parse(response.body)
when 404 then Wanderland::Signal.halt(status: 404, error: "no name '#{name}'")
else Wanderland::Signal.halt(status: 502, error: "upstream #{response.code}")
end
end
end
end
end
Anatomy:
include Wanderland::Boundary brings the registration DSL, logging, and tracing.IDENTITY declares who this boundary is when it signs a crossing. The id becomes the from_addr on every crossing the boundary emits; the scopes become the requirements the caller must hold elsewhere in the system.boundary :baby_lookup, ... registers the class under the symbol :baby_lookup. The route config (next step) refers to that symbol; nothing else does.requirements: — scopes the caller must hold to invoke this. enforce_denials checks this against the caller's identity.capabilities: — what this boundary claims to provide. Other boundaries can read it from context.input_shape: — declarative validation. { "params" => true } says "params must be present, any shape." Replace with a deeper hash to validate per-key.input.find_or_fail("params.name") digs the dot-path, strips strings, and on a missing or blank value throws a halt that the boundary's invoker catches and renders. Defaults to Signal.halt(status: 400, error: "name is required"). Override with signal:, status:, or error: for denied/auth/upstream-shaped failures.Signal.ok) or an explicit Signal.halt(...). Halts carry a status the HTTP adapter renders as the response code; the CLI adapter prints them to stderr and exits non-zero.Edit config.yml:
routes:
/hello:
method: get
boundary: echo
name: hello
/greet/:name:
method: get
boundary: greet
name: greet
/baby/:name:
method: get
boundary: baby_lookup
name: baby_lookup
The :name segment becomes a path capture; the engine stuffs it into params["name"] before the boundary runs.
HTTP:
bundle exec rake server
curl -s http://localhost:9294/baby/aarav | python3 -m json.tool
# {
# "name": "Aarav",
# "meaning": "Peaceful",
# "origin": "Hindu",
# "gender": "Boy",
# "numerology": 2,
# "source": "https://babynames.netstudy.in"
# }
curl -i http://localhost:9294/baby/zzzzz
# HTTP/1.1 404 Not Found
# {"error":"no name 'zzzzz'","status":404}
CLI:
bundle exec wanderland --type cli config.yml baby_lookup name=aarav
bundle exec wanderland --type cli config.yml baby_lookup name=zzzzz
echo $? # 404
Same boundary, two adapters. adapter_http derives params["name"] from the path capture; adapter_cli derives it from the key=value argv tail. Everything between — the boundary, the halt, the response shape — is one code path.
lib/my_app/boundaries/babies_by_origin.rbThe list endpoint takes filters. origin= is required; gender= is optional and constrained.
# frozen_string_literal: true
require "net/http"
require "json"
module MyApp
module Boundaries
class BabiesByOrigin
include Wanderland::Boundary
IDENTITY = Wanderland::Identity.new(
id: "boundary:babies_by_origin",
name: "BabiesByOrigin",
roles: [:boundary],
type: :service,
scopes: [:read]
).freeze
boundary :babies_by_origin,
identity: IDENTITY,
requirements: [:read],
capabilities: [:babies_by_origin],
description: "List baby names by origin (and optional gender)"
ENDPOINT = "https://babynames.netstudy.in/api/names"
# for the purposes of this API only...
VALID_GENDERS = %w[male female unisex].freeze
def call(input)
origin = input.find_or_fail("params.origin")
gender = input.find("params.gender")
query = { origin: origin }
query[:gender] = gender if VALID_GENDERS.include?(gender)
uri = URI.parse(ENDPOINT)
uri.query = URI.encode_www_form(query)
response = Net::HTTP.get_response(uri)
return Wanderland::Signal.halt(status: 502, error: "upstream #{response.code}") unless response.code == "200"
JSON.parse(response.body)
end
end
end
end
Two new things over baby_lookup:
find is the lenient sibling of find_or_fail: missing or blank returns nil instead of throwing. The optional gender= filter reads through find, then the whitelist check decides whether it crosses the wire — so a typo on the request doesn't reach the upstream.502; downstream observability (X-Wanderland-Trace, the audit slot) sees the same crossing either way.Add the route:
/babies:
method: get
boundary: babies_by_origin
name: babies_by_origin
Try it:
curl -s 'http://localhost:9294/babies?origin=Indian&gender=female' | python3 -m json.tool
curl -i 'http://localhost:9294/babies'
# HTTP/1.1 400 Bad Request
# {"error":"origin is required","status":400}
bundle exec wanderland --type cli config.yml babies_by_origin origin=Indian gender=female
One scenarios block, three lenses (--type test, X-Wanderland-Verify, Scenario.rspec_describe!) — same as Hello World. The new boundaries hit a live API, so use matches: and count: to assert on shape rather than exact values.
scenarios:
baby_lookup:
aarav:
description: "Known name returns full record"
input:
params:
name: aarav
expected:
name: { matches: "(?i:^aarav$)" }
gender: { matches: "(?i:male|female|unisex)" }
origin: { matches: "." }
missing_name:
description: "Empty name halts with 400"
input:
params:
name: ""
expected:
status: 400
error: "name is required"
babies_by_origin:
indian_female:
description: "Filtered list returns count + results"
input:
params:
origin: Indian
gender: female
expected:
count: { gte: 1 }
results:
first:
origin: { matches: "(?i:indian)" }
missing_origin:
description: "Missing origin halts with 400"
input:
params: {}
expected:
status: 400
error: "origin is required"
A halt scenario's expected: block is matched against the halt's payload (status, error, and any extra fields the boundary stamped). The 400 status itself is the halt indicator — scenarios don't assert on _type_addr because Scenario#run reads crossing["result"], which projects the halt's payload but strips the type address. The error string here is what find_or_fail produces by default; if a boundary overrides the message, the scenario asserts on the override. The full ShapeMatcher vocabulary lives at Shape Matcher.
bundle exec wanderland --type test config.yml --route baby_lookup
bundle exec wanderland --type test config.yml --route babies_by_origin
curl -s -H 'X-Wanderland-Verify: aarav' http://localhost:9294/baby/aarav | python3 -m json.tool
curl -s -H 'X-Wanderland-Verify: route' 'http://localhost:9294/babies?origin=Hindu&gender=female'
bundle exec rspec
The probe form (X-Wanderland-Verify) and CLI form (--wanderland-verify=) both run live against the upstream. A failing scenario surfaces as a 422 from HTTP and a non-zero exit + halt payload from CLI.
Every lens above hits the real API. That's fine for a tutorial and acceptable for a smoke check, but a scenario that fails because babynames.netstudy.in is down isn't telling you anything about your code. Two options when this matters:
:net: driver that returns canned responses keyed by URL, mount it ahead of Net::HTTP, and read it from the boundary via runtime.storage.read(":net:GET:..."). The boundary stays adapter-shaped; the test substitutes the network.Net::HTTP.get_response behind a method on a small client class injected via the runtime. Stub the client in spec_helper.rb for unit-style coverage; leave the live API for a tagged integration scenario.Both keep the boundary's call body and scenario contract intact. Pick the one that matches how often you expect the upstream to wobble.
my-app/
├── Gemfile
├── Gemfile.lock
├── Rakefile
├── config.ru
├── config.yml
├── lib/
│ ├── my_app.rb
│ └── my_app/
│ └── boundaries/
│ ├── greet.rb
│ ├── baby_lookup.rb
│ └── babies_by_origin.rb
└── spec/
├── spec_helper.rb
└── scenarios_spec.rb
Two new boundaries, two new routes, four new scenarios. The spec file from Hello World picks them up unchanged — Scenario.rspec_describe! reads whatever the config now declares.
when_shape, serves, output_shape, custom BASE_DEFAULT_WHEN recovery).client, effective_mime, and renderer caps reach your boundary's input.Signal.halt, Signal.denied, and how the chain walker decides what runs after a halt.expected:, including custom matcher registration.