Wanderland

Writing Boundaries

Frontmatter

diataxis: tutorial
style: ASD-STE100
status: active

What we build

This tutorial 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 its shape is the shape every boundary follows. Here we walk two larger boundaries. Both wrap an external HTTP API, and both carry their own scenarios and halts. By the end you will have walked the full registration, identity, halt, and scenario surface end to end.

What a boundary is

A boundary is the framework's unit of work. Each one is a class that mixes in Wanderland::Boundary. It declares its identity — who it speaks as when it signs a crossing. It declares its contract: the scopes it requires of the caller, the capabilities it provides, and the input shape it expects. It implements #call(input).

The runtime treats each boundary as a first-class addressable entity. The runtime registers it under a symbol and looks it up by route or by chain composition. It traces every call into the audit log, and signs the boundary's crossings when it declares an identity.

A single boundary :name, ... declaration buys a lot:

The boundary itself stays small. A #call body reads input, does the work, and returns a hash or a Signal. The runtime handles everything else.

Boundaries that wrap external services follow a recurring shape. The #call body validates required input, often through input.find_or_fail("params.key"). It makes the upstream call. On success it returns the parsed body. On a failure the caller should know about, it returns Signal.halt(status:, error:). The two boundaries in this tutorial both follow that shape against the same upstream. The second one adds query-param plumbing and an extra halt for upstream errors.

The example API is babynames.netstudy.in — no auth, JSON in JSON out.

1. Pick the endpoints

Two:

You will wrap each endpoint in its own boundary. The first lookup proves the call shape. The second adds query-param plumbing and an upstream-error halt.

2. Add 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:

3. Wire the route

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 puts it into params["name"] before the boundary runs.

4. Try it

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.

5. Add lib/my_app/boundaries/babies_by_origin.rb

The list endpoint takes filters. It requires origin=. The gender= filter 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:

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

6. Add scenarios

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"

The runtime matches a halt scenario's expected: block against the halt's payload — status, error, and any extra fields the boundary stamped. The 400 status itself is the halt indicator. Scenarios do not assert on _type_addr. 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.

7. Run the lenses

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.

8. Live calls in scenarios

Every lens above hits the real API. That is fine for a tutorial, and acceptable for a smoke check. But a scenario that fails because babynames.netstudy.in is down tells you nothing about your code. You have two options when this matters:

Both keep the boundary's call body and scenario contract intact. Pick the one that matches how often you expect the upstream to wobble.

9. Directory shape now

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.

Next steps