Picks up where Writing Boundaries left you: a my-app/ directory with baby_lookup and babies_by_origin calling Net::HTTP.get_response directly. Both boundaries work; both pass their scenarios against the live API. The test suite, however, depends on the upstream being available — a CI run during a babynames.netstudy.in outage is a red CI run, regardless of whether anything about your code has changed. Section 8 of that tutorial named two paths out of that dependency; this tutorial walks the adapter pattern that lands both.
An adapter wraps an external dependency — HTTP today, eventually Redis, S3, message brokers, anything outside the runtime that a boundary reaches out to — behind a uniform Wanderland-shaped facade. A boundary declares which adapters it needs as part of registration; at call time it pulls the configured instance from the runtime and uses it through that facade. The instance the boundary receives carries a mode — live, replay, or mock — decided by config. The boundary's call body is identical across all three. A scenario that runs against the live upstream and a scenario that runs from a recorded cassette execute the same Ruby; only the adapter underneath is different.
The point of the layer is to put external dependencies under the same kind of control the rest of the runtime already has. Storage::Registry sits between boundaries and storage drivers — sqlite in dev, in-memory in tests, something else in production — and the boundary above doesn't see which driver answered. The adapter layer is that idea applied to outbound calls. Decisions about which upstream is real, which is recorded, and which is stubbed move out of boundary code and into one config block.
A few practical consequences fall out of the arrangement.
The first is that scenarios become deterministic without changes to the boundary. A boundary's behavior under a 200, a 404, an empty result list, or a timeout is the same problem regardless of whether the upstream actually produces those shapes on demand. Replay mode reads a previously-recorded response from a cassette file, so the same call returns the same bytes every run. Mock mode lets a scenario declare exactly the response shape it wants to test — useful for the cases the live API won't reliably produce, like a 503 or a malformed JSON body.
The second is that recording sessions become a one-curl-per-scenario activity. Run a real request through the live adapter with --wanderland-record-to <dir> and the response writes to a cassette file as a side effect. Hit each scenario's endpoint once and the fixture set is built. Re-recording a single interaction is a single deletion and a single curl; the rest of the cassettes stay frozen, and a git diff shows exactly which interaction drifted.
The third is that the upstream's flakiness stops being your problem. A babynames.netstudy.in outage is annoying, but no longer red. The cassettes are checked into the repo; the suite runs offline against frozen interactions. The only thing that can fail is the boundary actually breaking against a known input.
Three pieces wire together. The boundary declares adapters: [:http] in its contract — one symbol per logical adapter it expects to find on the runtime registry. config.yml declares the adapters that exist for this site under an adapters: block, each with a mode and any mode-specific configuration (a cassette directory for replay, hand-seeded mocks for mock). At boot, boot_mount_adapters runs as a slot in the boot chain, builds an instance from each entry, and mounts it on runtime.adapters under the name from the config.
At call time, input.adapter(:http) returns the mounted instance. The boundary calls .get(url) or .post(url, body:) and receives a uniform response hash: { "status" => Integer, "body" => String, "headers" => Hash }. Mode is invisible to the caller; the call site looks the same in production as in a unit test.
The reference Adapters enumerates the registry, modes, cassettes, and the escape hatch in detail. This tutorial walks the path: port the boundaries, exercise each mode, and see how the pieces fit together by running them.
baby_lookup to the http adapterEdit lib/my_app/boundaries/baby_lookup.rb:
# frozen_string_literal: true
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 },
adapters: [:http]
ENDPOINT = "https://babynames.netstudy.in/api/name"
def call(input)
name = input.find_or_fail("params.name")
response = input.adapter(:http).get("#{ENDPOINT}/#{URI.encode_www_form_component(name)}")
case response["status"]
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["status"]}")
end
end
end
end
end
Anatomy of the change:
require "net/http" is gone. The adapter owns the HTTP gem.adapters: [:http] in the registration tells the runtime this boundary expects an adapter named :http to be mounted. The contract is introspectable through /inspect/boundary/:name.input.adapter(:http) returns the configured instance. The boundary doesn't see mode, cassette path, or any of the registry plumbing.{ "status" => Integer, "body" => String, "headers" => Hash } — the same in all three modes.Wanderland::Adapters::UnknownAdapterError at the call site with the list of adapters that did mount, so the failure is loud and named.babies_by_originSame shape, two requests of note: find (lenient sibling of find_or_fail) for the optional gender filter, and URI.encode_www_form for the query string built before the call:
# frozen_string_literal: true
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)",
adapters: [:http]
ENDPOINT = "https://babynames.netstudy.in/api/names"
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)
url = "#{ENDPOINT}?#{URI.encode_www_form(query)}"
response = input.adapter(:http).get(url)
return Wanderland::Signal.halt(status: 502, error: "upstream #{response["status"]}") unless response["status"] == 200
JSON.parse(response["body"])
end
end
end
end
config.ymlAdd the adapters: block:
adapters:
http:
mode: live
This is enough to boot. The boot_mount_adapters slot reads this block and mounts a configured Wanderland::Adapters::HTTP on the runtime registry. Run the existing scenarios:
bundle exec wanderland --type test config.yml
# baby_lookup
# ✓ aarav — Known name returns full record
# ✓ missing_name — Empty name halts with 400
# babies_by_origin
# ✓ indian_female — Filtered list returns count + results
# ✓ missing_origin — Missing origin halts with 400
Live mode is transparent. Same scenarios, same passes; the call goes through the adapter facade, then Net::HTTP, then the upstream.
Replay needs cassettes. Generate them by running a live request with --wanderland-record-to. The cassette directory lives under spec/fixtures/ so the !Fixture resolver can lift cassettes back as mocks in section 6:
bundle exec wanderland --type cli config.yml baby_lookup name=aarav \
--wanderland-record-to spec/fixtures/cassettes
bundle exec wanderland --type cli config.yml babies_by_origin origin=Indian gender=female \
--wanderland-record-to spec/fixtures/cassettes
Each successful upstream call lands as a YAML file under spec/fixtures/cassettes/http/<digest>.yml. One file per (method, url, body, headers) interaction, keyed by a SHA-1 of the canonical request shape — full digest rule lives at Adapters: Cassettes.
The flag works in HTTP mode too. Pointing curl at the running server with the equivalent header records the same interactions:
curl -s -H "X-Wanderland-Record-To: spec/fixtures/cassettes" \
http://localhost:9294/baby/aarav | python3 -m json.tool
Inspect what landed:
ls spec/fixtures/cassettes/http/
# 9e5c8879cb8265eae3b71928a68c2f4b7f0497dd.yml
# 8d6117b98eb7a35e2964808705335157e7d483fd.yml
cat spec/fixtures/cassettes/http/9e5c8879cb8265eae3b71928a68c2f4b7f0497dd.yml
---
request:
method: GET
url: https://babynames.netstudy.in/api/name/aarav
body:
headers: {}
response:
status: 200
body: |-
{
"id": 711,
"name": "Aarav",
"gender": "male",
"origin": "Sanskrit",
"meaning": "Peaceful, calm",
"numerology": 3,
"lucky_colors": ["Blue", "White"],
"traits": ["Wise", "Gentle", "Thoughtful"],
"source": "https://babynames.netstudy.in"
}
headers:
content-type: application/json; charset=utf-8
cf-cache-status: DYNAMIC
server: cloudflare
YAML's literal-block scalar (body: |-) keeps the response body readable as itself instead of as an escaped one-line string. Hand-edit a cassette when the upstream's contract changes and you'd rather follow than re-record — the body block is the body, no escaping in the way. The |- form (with the trailing dash) is YAML for "literal block, trim the final newline"; Psych emits it when the recorded body doesn't itself end in \n.
Flip mode to replay. Two ways: rewrite config.yml for a permanent change, or set the env override for a one-off session.
Static (in config.yml):
adapters:
http:
mode: replay
cassette_dir: spec/fixtures/cassettes
Or leave the !Env default in place from section 7 and flip at invocation time:
WANDERLAND_HTTP_MODE=replay bundle exec wanderland --type test config.yml
# 8 scenarios, 8 passed, 0 failed
Either way, every request is served from the cassette directory. Take the network down and the suite still passes. A request the cassettes don't cover raises CassetteMissError with the file path the adapter looked for, so a test that drifts from its fixtures fails loudly instead of silently hitting the network.
A common workflow: re-record a single scenario by deleting its cassette and re-running with --wanderland-record-to spec/fixtures/cassettes while back on mode: live. The single missing interaction lands; the rest stay frozen, and a git diff shows exactly what changed in the upstream's response. Rinse and flip back to replay.
Replay covers the "what does this boundary do against the real upstream" case. Mock covers the "what does this boundary do when the upstream returns a shape we can't reliably get on demand" case — a 503, a malformed body, a 200 with an empty results array, an upstream that has drifted from what we recorded. Mocks are scenario-scoped: declared in scenario YAML, registered into the adapter just before dispatch, cleared right after.
Three ways to declare a mock. Pick the one that matches what you're testing.
When you want to assert exactly the response shape — usually for an edge case the live upstream won't reliably produce — declare it inline. Sane defaults fill in the request fields you don't care about (method: GET, body: null, headers: {}):
scenarios:
baby_lookup:
upstream_unavailable:
description: "Upstream 503 surfaces as 502 to the caller"
mocks:
http:
- request: { url: https://babynames.netstudy.in/api/name/aarav }
response: { status: 503, body: "", headers: {} }
input:
params:
name: aarav
expected:
status: 502
error: "upstream 503"
request.url is the only required field. The runner registers this on the runtime's HTTP adapter at the top of the dispatch, the boundary's input.adapter(:http).get(...) returns the canned response, and the runner clears the registration in an ensure so the next scenario starts fresh.
!FixtureWhen you want to assert the boundary handles a specific real response shape — a particular known-good interaction you don't want to keep re-fetching from the live API — point at a recorded cassette. The cassette and the mock share the same envelope shape, so !Fixture can lift it directly:
scenarios:
baby_lookup:
aarav_from_cassette:
description: "Lift the recorded cassette as a mock via !Fixture"
mocks:
http:
- !Fixture cassettes/http/9e5c8879cb8265eae3b71928a68c2f4b7f0497dd
input:
params:
name: aarav
expected:
name: { matches: "(?i:^aarav$)" }
origin: "Sanskrit"
!Fixture resolves at scenario-load time, returns the full {request:, response:} envelope from the cassette file, and the mock loader registers it on the adapter the same way the inline form does. The cassette dir is auto-searched under fixture_dirs, so cassettes/http/<digest> finds the file at spec/fixtures/cassettes/http/<digest>.yml.
!MergeWhen you want a recorded shape with one or two fields changed — a real response with a drifted origin, a real response truncated, a real response with a header missing — combine !Fixture with !Merge. The Merge tag takes an array of maps and deep-merges them right-over-left:
scenarios:
baby_lookup:
aarav_with_drift:
description: "Cassette + !Merge overlay to drift the upstream response"
mocks:
http:
- !Merge
- !Fixture cassettes/http/9e5c8879cb8265eae3b71928a68c2f4b7f0497dd
- response:
body: |-
{"name":"Aarav","origin":"DRIFTED"}
input:
params:
name: aarav
expected:
origin: "DRIFTED"
The first array element is the base — the full cassette envelope. The second is the overlay — only the fields that should win. The merger recurses into nested hashes (so response: { body: ... } only replaces the body, leaving status and headers from the cassette intact). Arrays at the same key replace whole; first/last/at[n] semantics are a future extension.
Mocks are consumed by the HTTP adapter when its mode is mock. To run a scenario with mocks, flip the mode at invocation:
WANDERLAND_HTTP_MODE=mock bundle exec wanderland --type test config.yml \
--route baby_lookup
# baby_lookup
# ✗ aarav — exception: no mock registered for GET https://...
# ✓ missing_name — Empty name halts with 400
# ✓ upstream_unavailable — Upstream 503 surfaces as 502 to the caller
# ✓ aarav_from_cassette — Lift the recorded cassette as a mock via !Fixture
# ✓ aarav_with_drift — Cassette + !Merge overlay to drift the upstream response
Mock mode is strict by design: a request the scenario didn't mock raises CassetteMissError. The aarav scenario has no mocks block and so fails under mock mode — that's why the suite-wide modes are mode-specific. Live and replay are for the broad coverage; mock is for the edge cases that have their own scenarios. The --scenario filter is the natural companion when mixing them locally.
!Fixture lifts a recorded cassette as-is — cheapest, no upstream drift since it's the bytes you saw. Use when you're testing how the boundary handles a known good response.
!Merge keeps the cassette's bulk and changes a few fields — ideal for "what if the upstream's response had this one field different." Hand-editing the cassette achieves the same thing, but !Merge keeps the original file pristine and the variation visible at the scenario level.
Inline envelope writes the response from scratch — best for shapes the upstream wouldn't produce on demand (5xx errors, malformed bodies). The defaults keep the envelope short.
Shape that comes up early: production runs live, CI runs replay, dev defaults to live and flips to replay for an offline run. The !Env tag resolves at config-load time, before any boot slot runs:
adapters:
http:
mode: !Env { name: WANDERLAND_HTTP_MODE, default: live }
cassette_dir: spec/cassettes
Two argument shapes. The shorthand !Env WANDERLAND_HTTP_MODE raises if the variable isn't set — useful when there's no sensible default and a missing var is a misconfiguration the operator should see immediately. The hash form !Env { name: ..., default: ... } falls back to default: when the var is absent, which is what you want for mode: here.
Set the override and run:
WANDERLAND_HTTP_MODE=replay bundle exec wanderland --type test config.yml
# 8 scenarios, 8 passed, 0 failed (offline)
!Env reads the process environment at the moment config.yml is loaded. The value is then static for the lifetime of the runtime — no live re-reads, no surprises mid-request. For values that need to be visible to boundaries at request time (through context.env), the boot-time env_snapshot slot captures an allowlist-filtered subset of ENV; that's a separate mechanism from !Env, which is for config-time substitution.
There's also an ERB path that runs on the raw config string before YAML parses it: <%= WANDERLAND_HTTP_MODE %> interpolates ENV directly. ERB is the right tool when you need string-level interpolation across the file (a port number, a path prefix); !Env is the right tool inside structured YAML where you want a typed default and the rest of the resolver chain.
The facade returns a uniform hash. When a boundary needs Net::HTTP behavior the facade doesn't expose — streaming, multipart uploads, custom timeouts, connection reuse — the escape hatch reaches the bare client:
def call(input)
http = input.adapter(:http)
response = http.get(url) # facade — uniform hash
client = http.client # Net::HTTP handle for the host of the last request
# Bare-client work below this line.
end
In :live mode, #client is the Net::HTTP instance for the host of the last request, after that request has gone out. In :replay and :mock modes it's nil — there's no underlying client. Boundaries that reach for #client couple themselves to Net::HTTP; the trade is intentional and visible in the diff.
my-app/
├── Gemfile
├── Gemfile.lock
├── Rakefile
├── config.ru
├── config.yml
├── lib/
│ ├── my_app.rb
│ └── my_app/
│ └── boundaries/
│ ├── greet.rb
│ ├── baby_lookup.rb # ported
│ └── babies_by_origin.rb # ported
└── spec/
├── spec_helper.rb
├── scenarios_spec.rb
└── cassettes/
└── http/
├── 4ab9c1...json
└── c2d8e0...json
The boundary files are smaller. The cassettes live next to the spec files and commit cleanly. Mocks land in the scenario YAML, scoped to the case they verify.
client, effective_mime, and renderer caps reach a boundary's input via the announce-adapter pattern (separate from the runtime adapter registry covered here)./inspect/diagnostics.