Skip to content

Servus 1.0.0 — inline-only schemas, shared fragments, and a smaller surface - #54

Merged
sebscholl merged 9 commits into
mainfrom
feature/schema-compilation
Aug 27, 2026
Merged

Servus 1.0.0 — inline-only schemas, shared fragments, and a smaller surface#54
sebscholl merged 9 commits into
mainfrom
feature/schema-compilation

Conversation

@sebscholl

Copy link
Copy Markdown
Contributor

Why

One thesis: a service's behaviour should be readable from the file that implements it.

Servus had accumulated indirection that worked against that. Schemas could live in three places, two of them outside the service. Two helpers wrapped .call and diverted control flow on failure — one by throwing, one by raising — so the same operation had different calling conventions depending on where you stood. And the emits DSL's own documentation argued against a pattern it fully supported.

Under agentic engineering the cost of that indirection went up. Generated code is most reliable when the contract is stated where the work happens, not resolved reflectively from a constant name or a sibling file.

What changed

Schemas are inline-only. Constant-based (ARGUMENTS_SCHEMA) and mirror-directory JSON schemas are no longer resolved. Only the schema DSL.

Shared fragments replace the duplication that would cause. A registry (Servus::Schema) holds reusable fragments referenced by standard JSON Schema $ref:

Servus::Schema.register('core', { '$defs' => { 'amount' => { 'type' => 'integer' } } })

schema arguments: { properties: { fee: { '$ref' => '#/core/$defs/amount' } } }

Compilation is lazy, memoized, and triggered on first read — so validation, the test example builders, have_schema, and application code all see resolved refs. Lookups never return nil; an unregistered key raises, naming the key, the schema being compiled, the ref chain, and the nearest match. The registry is standalone, so an app can register contracts with no service behind them and emit the lot via compile_all as one JSON asset.

call! and run_service! are removed. Both read like ordinary method calls while hiding a non-local jump.

emits gained no syntax, but lost a bad instruction. Multiple events per trigger, each with its own payload, always worked — the DSL's @note just told people not to. Now documented.

Correctness fixes riding along

Each is the same failure class the release targets — something that looks declared but validates nothing:

  • schema arguments: nil raised nothing and silently unvalidated the service. Now raises.
  • Subclasses didn't inherit schemas.
  • The validator cache was keyed by a namespace-derived path with the last segment dropped, so A::B::Service and A::B::Other shared a schema.
  • emits skipped payload validation entirely when no Event class was registered — bypassing require_event_payload_schema on exactly the events with no schema.
  • The gem never required deep_dup/pluralize from ActiveSupport; the test suite masked it, a non-Rails consumer would not have.
  • The service generator ignored config.services_dir while the event and guard generators honoured theirs.

Notable findings

json-schema v5 does not support draft-07. It tops out at draft-06 and defaults to it — the docs claimed draft-04. A draft-07 $schema raises at any position, which is why the compiler strips $schema/$id from spliced fragments.

SimpleCov (added here, opt-in via COVERAGE=1) found two guards that review misseddeep_freeze never ran on arrays, so freezing stopped at hashes for any fragment with required/enum; and strip_metadata's non-Hash guard never ran, though a $ref can resolve to an array or a bare boolean schema.

Verification

  • 889 examples, 0 failures; RuboCop clean across 96 files with no added disables
  • Schema subsystem at 100% line and branch coverage
  • Docs build clean, including the new Shared Schemas page
  • 379 refs to one fragment compile to a single cache entry in <1ms

Upgrading

The CHANGELOG carries the full migration. The one thing that fails silently: a service whose only schema was a constant or JSON file now runs unvalidated with no signal. Enumerate with:

grep -rn 'ARGUMENTS_SCHEMA\|RESULT_SCHEMA\|FAILURE_SCHEMA' app/ lib/
find app/schemas -name '*.json'

Pairing the upgrade with require_service_arguments_schema = true turns that silence into a loud failure.

🤖 Generated with Claude Code

sebscholl and others added 9 commits August 26, 2026 16:42
Schema resolution had three tiers: the `schema` DSL, ARGUMENTS_SCHEMA /
RESULT_SCHEMA / FAILURE_SCHEMA constants, and mirror-directory JSON files.
Two of them put a service's contract somewhere other than the service. Both
are removed — inputs and outputs are now declared in the file that implements
the service.

Inline-only declaration would mean duplicating every shared shape, so this
also adds a registry of reusable fragments referenced by standard JSON Schema
$ref. A service referencing a shared type still declares it explicitly; it
just names it once.

The registry is standalone — no coupling to Base or Event — so an app can
register contracts with no service behind them (controller request/response
shapes) and emit the lot via compile_all as a single JSON asset.

Compilation is lazy and memoized, triggered on first read rather than first
validation, so validation, the example builders, `have_schema`, and
application code all see resolved refs without knowing compilation exists.
Lookups never return nil: an unregistered key raises, naming the key, the
schema being compiled, the ref chain, and the nearest match.

Three correctness fixes ride along, each the same failure class this release
targets — a contract that looks declared but validates nothing:

  - `schema arguments: nil` raises instead of silently declaring nothing
  - subclasses inherit their parent's schemas
  - the validator cache is keyed by class, not a namespace-derived path that
    let sibling services share a schema

Also fixes missing ActiveSupport requires (deep_dup, pluralize) that the test
suite masked but a non-Rails consumer would have hit.

BREAKING CHANGE: constant- and file-based schemas are no longer resolved.
A service whose only schema was a constant or JSON file now runs unvalidated.
See the CHANGELOG for the migration and enforcement recommendation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A review pass for over-engineering found the resolution logic proportionate
but the scaffolding around it inflated.

Dead code: Schema#unknown_key_error and #suggestion_for were orphaned when
that logic moved onto UnknownKeyError.for. Nothing called them. RuboCop does
not flag unused private methods and the specs passed via the live path.

Schema::Error carried ref, resolution_path, context, a headline reader, and a
with_context copy protocol — roughly half the file — and no production code
read any of it. Only the message is ever consumed. The copy protocol existed
to make decoration idempotent, but both rescue sites wrap a single foreign
call rather than the recursion around it, so an error is already decorated
exactly once on its way out. The compiler now builds the message directly.
Messages are byte-identical, resolution path included.

Uncalled API removed: Ref#to_s, Schema.registered?, and the four raw_*_schema
readers, none of which had a caller in lib/. Declaration generated two methods
per schema kind where one was used.

Kept deliberately: deep_freeze and the Cache mutex. Both turn silent
corruption into a loud failure, which is the stance of this release.

No behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Coverage found two guards in the schema code that production traffic hits
constantly but no spec exercised:

  - deep_freeze's Array branch never ran, so freezing stopped at hashes for
    any fragment containing `required`, `enum`, or `anyOf` — which is most
    of them. The guard we kept deliberately was only half working.
  - strip_metadata's non-Hash guard never ran. A $ref can resolve to an
    array, or to a bare true/false, which draft-06 accepts as a schema.

The schema subsystem is now fully covered on lines and branches.

A config audit alongside it found `services_dir` was dead: the service
generator hardcoded `app/services/` while the event and guard generators
honoured theirs, so the generator docs promised a setting that did nothing.
The generator now honours it. Nothing else became irrelevant from the 1.0
schema work — `schemas_dir` was the only casualty and was already removed.

Also covers `Servus.configure`, the block-yielding API every doc example
uses and no spec touched, and the ExampleExtractor path for arrays whose
items carry a scalar example.

SimpleCov is opt-in via COVERAGE=1 so the default run is unaffected, and
coverage/ is now ignored at any depth rather than only at the repo root.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Was sitting untracked in the working tree; committing separately from the
1.0.0 schema work so it can be reverted on its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both wrapped `.call` and diverted on failure — `call!` threw :guard_failure to
unwind the outer service, `run_service!` raised. Each read like an ordinary
method call while hiding a non-local jump, and together they meant the same
operation had two calling conventions depending on whether you were inside a
service or at the controller boundary.

Neither saw much adoption, and both worked against reading a service's control
flow off the page — the same argument that made schemas inline-only in this
release. `.call` plus an explicit `return` or `raise` costs a line and makes
the jump visible.

Callers of `call!` should note the shape change: it returned `data`, so its
return value becomes `result.data`. Callers of `run_service!` should note it
also assigned `@result`, which a bare `.call` does not.

`run_service` and `render_service_error` are unaffected.

BREAKING CHANGE: Servus::Base#call! and
Servus::Helpers::ControllerHelpers#run_service! are removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three changes to the emits DSL, none to its syntax.

The emitter skipped payload validation entirely when no Event class was
registered for the emitted name, which also skipped
require_event_payload_schema. The one flag whose job is to make a missing
payload schema loud was silently bypassed on precisely the events that had no
schema at all. With the flag on it now raises SchemaRequiredError naming the
service and the event; with the flag off, the default, nothing changes.

The trigger list was a local array rebuilt on every emits call and is now the
frozen EMISSION_TRIGGERS constant, which the empty-emission hashes also derive
from so their keys cannot drift from the triggers actually accepted. The
method's prose said the trigger was :error where the code requires :error!.

Finally, documentation. Multiple events per trigger, each with its own payload
builder, has always worked — but the DSL's own @note argued against it, so the
capability was effectively hidden. That note is gone, replaced by an example
showing the pattern, and site/features/event-bus.md gains a section on it plus
one on schema enforcement and its loaded-Event-class precondition.

Deliberately not addressed: emissions do not cross a subclass boundary. That is
only reachable by inheriting one service from another, which the framework does
not endorse — services are composed, not subclassed.

Adds the missing spec/servus/events/emitter_spec.rb mirror; emitter.rb is now
fully covered on lines and branches.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Generated services carried a commented-out schema block, which meant the
common path was a service with no contract at all — the opposite of what this
release is arguing for. The declaration is now live code.

What the generator can know, it fills in: every parameter is a required
argument. What it cannot know, it leaves open: property types are empty
schemas, which accept anything. So a fresh service enforces argument presence
and nothing more until someone types it. Guessing `type: 'string'` for every
parameter would have been worse than saying nothing — a generated service that
raises on first call because the scaffold asserted something false.

The declaration is not gated behind --no-docs. A schema is part of the
service's behaviour, not commentary on it.

Also fixes a pre-existing template bug while in the file: continuation lines in
`initialize` were joined at four spaces inside a six-space body, so every
generated service with more than one parameter had misaligned assignments.

Adds the 1.0.0 announcement draft.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An Event class chose its own execution mode per declaration, and sync was the
default. A sync invocation ran inline inside the emitting service's after_call,
before its result reached the caller — so a reaction that had nothing to do with
that caller could slow it down, and an exception in the reaction propagated out
of a service that had already succeeded. Events exist to decouple; reacting
synchronously re-couples the reaction's latency and failure to the emitter.

The docs already carried a "prefer async invocation" warning. This makes it the
behaviour rather than the advice.

`invoke` is renamed to `enqueue`, because that is now what it does. The rename
stops at the verb: Invocation, Event.invocations_for and Router#resolve keep
their names. An Invocation is still an invocation — asynchrony is how it runs,
not what it is — and Router#resolve returning Array<Invocation> is the contract
custom routers build against, of which there are real examples in the wild.
Invocation#execute does become #enqueue, since that method's meaning changed.

The migration is a chain of three errors, each pointing at the next step: a
raising `invoke` stub explains the rename, `enqueue` rejects the now-meaningless
`async:` option, and Invocation raises AsyncBackendMissingError when ActiveJob
is absent. `async: false` raises rather than being ignored — it asked for
behaviour that no longer exists, and silently doing the opposite is worse than
refusing.

Two problems surfaced by putting call_async on the event path are fixed here
rather than left to bite: enqueueing an anonymous service raised
`NoMethodError: undefined method 'demodulize' for nil`, and call_async's blanket
rescue converted a service's own ValidationError into a misleading
"Failed to enqueue".

Events now require ActiveJob, making them Rails-only for the moment; a job
adapter for other hosts comes later. The core — services, schemas, guards, the
bus itself — still works without Rails.

The suite never had call_async defined at all, because the railtie's
on_load(:active_job) hook needs a Rails::Application boot. It is now extended in
spec_support, matching what a Rails app gets.

BREAKING CHANGE: Servus::Event.invoke is renamed to enqueue, the async: option
is removed, and event invocation no longer runs inline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sebscholl
sebscholl merged commit f8846d4 into main Aug 27, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant