Servus 1.0.0 — inline-only schemas, shared fragments, and a smaller surface - #54
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
.calland 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 theemitsDSL'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 theschemaDSL.Shared fragments replace the duplication that would cause. A registry (
Servus::Schema) holds reusable fragments referenced by standard JSON Schema$ref: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 viacompile_allas one JSON asset.call!andrun_service!are removed. Both read like ordinary method calls while hiding a non-local jump.emitsgained no syntax, but lost a bad instruction. Multiple events per trigger, each with its own payload, always worked — the DSL's@notejust 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: nilraised nothing and silently unvalidated the service. Now raises.A::B::ServiceandA::B::Othershared a schema.emitsskipped payload validation entirely when no Event class was registered — bypassingrequire_event_payload_schemaon exactly the events with no schema.deep_dup/pluralizefrom ActiveSupport; the test suite masked it, a non-Rails consumer would not have.config.services_dirwhile the event and guard generators honoured theirs.Notable findings
json-schemav5 does not support draft-07. It tops out at draft-06 and defaults to it — the docs claimed draft-04. A draft-07$schemaraises at any position, which is why the compiler strips$schema/$idfrom spliced fragments.SimpleCov (added here, opt-in via
COVERAGE=1) found two guards that review missed —deep_freezenever ran on arrays, so freezing stopped at hashes for any fragment withrequired/enum; andstrip_metadata's non-Hash guard never ran, though a$refcan resolve to an array or a bare boolean schema.Verification
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:
Pairing the upgrade with
require_service_arguments_schema = trueturns that silence into a loud failure.🤖 Generated with Claude Code