Composing Grammar Modules
A fixture's vocabulary comes from three places: the steps it declares itself, the steps of its base classes (derive from CritterStackFixture and the whole event-sourcing grammar binds with no further code), and grammar modules composed in with [IncludeGrammars]. This page covers the module route: parameterizing a module, letting composed grammars cooperate at runtime, and the shipped HTTP lane (CritterStackHttpFixture) that is built from exactly these pieces.
The compile-time discipline holds throughout. The generator reads every module's steps from the type symbol, so an unmatched step is still a compile error, type captures still resolve at compile time (BOBCAT011/012), and an ambiguous step is still BOBCAT013.
Parameterized modules
Constructor arguments after the module type flow to the module's construction:
[IncludeGrammars(typeof(HttpGrammars), "/api/wallet")]
public class CreditWallet : CritterStackFixture;The module is constructed once per scenario, like every module. The rules:
- Attribute literals bind positionally to the constructor's value parameters, in declaration order. Attribute arguments are constants and
typeofonly — a CLR restriction that is also the design: anything richer (a configured resource, a delegate) should reach the module from the scenario by type, through service injection or scenario state. - Constructor parameters the literals do not cover are resolved like step parameters:
IStepContext, any registeredITestResource, and services from the scenario's DI scope —[FromRootService]/[FromKeyedServices]work on a module constructor too. A module whose constructor asks for any of these is constructed by the first step that uses it, inside the scenario scope; a literal-only module is constructed when the plan is built. Either way it is one instance per scenario. - Trailing optional parameters may be omitted.
Arguments no public constructor can take are a compile error, BOBCAT019, naming the mismatch.
[IncludeGrammars] is discovered on the fixture and its base classes, so a shipped abstract fixture can carry its modules and a derived fixture composes them by deriving alone. Declaring the same module type on a derived fixture re-parameterizes it — the most-derived declaration wins, the way a derived step hides a base one.
One instance per module type
The same module type twice on one fixture is a compile error, BOBCAT018. Two instances of one vocabulary — two HttpGrammars bound to different prefixes — would make every one of its step texts match twice, which is exactly the ambiguity BOBCAT013 exists to close, and Gherkin has no section construct to scope the collision away. A fixture that genuinely needs two HTTP surfaces declares two thin module subclasses with distinct step texts ("When the wallet API receives …" / "When the admin API receives …") — one line each, and the spec reads better for it.
Scenario state
Composed grammars are separate instances, so they cannot cooperate through fixture fields. The sanctioned channel is the typed per-scenario blackboard on IStepContext:
context.SetState(new TrackedExecution(...)); // the acting grammar publishes its capture
if (context.TryGetState<TrackedExecution>(out var execution)) { ... } // optional read
var execution = context.GetState<TrackedExecution>(); // required readOne entry per CLR type, alive for exactly one scenario bracket — a retry attempt starts blank, and nothing leaks between scenarios. Two grammars from two packages cooperate by agreeing only on the capture type: no reference between them, no shared base class. GetState<T>() on a missing entry throws with "no step in this scenario produced a T — did you mean to add a When … step?", which is a far better failure than a silently-null field.
The Critter Stack vocabulary already rides it: every act step publishes its TrackedExecution (the tracked session, the events the current stream gained, or the captured failure), the Given steps publish the ScenarioStream being arranged, and every store assertion reads the published capture — which is what lets a different grammar's act feed Then {event} is emitted unchanged.
What "the events the act appended" means when no stream was arranged (issue #319)
With a ScenarioStream published, it is that stream's delta across the act: fetch before, fetch after, take the tail. That is the right answer whenever the scenario named a stream.
A slice whose act creates a stream has no name to give — the id is the handler's to mint, which is the ordinary shape for an automation triggered by an upstream flow. That case used to yield the empty list, so Then {event} is emitted reported
Expected a LedgerRegistered event, but the emitted events were: []while the store held a complete stream. The message reads as "the handler did nothing", which is the most misleading thing it could have said, and it made a whole slice shape unspecifiable: the only way through was to make the handler derive its stream id from the act's payload so the scenario could name it in advance — a test harness dictating a design decision.
So with no stream bracketed, the appended events are whatever the store issued during the act, wherever it put them: a sequence floor taken before, queried after. Two whole-store reads, paid only by the case whose alternative was an assertion that could not fail. Suites with no event store at all — the message-only and HTTP lanes — take neither, and still see the empty list.
One consequence worth knowing: Then no events are emitted in a scenario that arranges no stream used to be vacuously true. It now means what it says.
The document lane: DocumentGrammars
The shipped Critter Stack vocabulary assumed event sourcing. Measured on a real document-backed Wolverine application — Storage.Insert, [Entity], a revisioned document, no event store — exactly four of the ten shipped steps applied, and all four were the messaging and HTTP halves. Nothing could arrange a document, assert one, or say it was gone, so such a project had to write a private grammar before it could write its first scenario (issue #270).
DocumentGrammars is that lane, as a module:
[FixtureTitle("Shipments")]
[IncludeGrammars(typeof(DocumentGrammars))]
[IncludeGrammars(typeof(HttpGrammars))]
public class ShipmentsFixture : CritterStackFixture;Three steps:
Given documents of type Shipment
| Id | Origin | Destination | Status |
| 11111111-1111-1111-1111-111111111111 | Dallas | Austin | Booked |
Then the Shipment with id "11111111-1111-1111-1111-111111111111" has
| Origin | Status |
| Dallas | Booked |
Then no Shipment exists with id "33333333-3333-3333-3333-333333333333"The assertion compares only the columns the row names — the rule #241 established for events, followed rather than replaced with a second convention. A document has fields the scenario does not care about, and demanding a column for each makes the table say things the scenario does not mean. The arrange is partial for the same reason; a column matching nothing is still refused by name.
Three things worth knowing:
{document}is a capture word of its own, beside{type}/{aggregate}/{command}/{event}/{readmodel}/{message}. It resolves a type name exactly as the others do, but it stamps no Event Modeling role — a document-backed application has no stream, and putting an aggregate or read model on the canvas for it would describe nothing. Inert by construction: the emitter switches on the role words and lets this one fall through, as it does{type}.- The base class above is for the messaging vocabulary, not for event sourcing.
CritterStackFixture's stream steps simply go unused;Then {message} is sentand the refusal checks work with no stream at all.DocumentGrammarsitself derives fromFixture, so a project that only wants documents composes it onto a bare fixture and references no event-sourcing vocabulary. - Store-agnostic, like everything else here. The steps reach the store through
JasperFx.Events.Documents, soBobcat.CritterStackstill references no Marten, no Polecat and no Fisher. Nothing extra has to be registered either: on every Critter Stack store the concrete store object is bothIEventStoreandIDocumentSessionFactory, so the document steps resolve what the event steps already resolve.
DocumentStores is the public helper behind them — LoadAsync(store, type, id) closes the generic LoadAsync<T> over a runtime Type, which every grammar taking a type-name capture otherwise has to reflect for itself. Use it rather than hand-rolling, for the same reason as RecordBuilding below.
Not covered: saga state. A Wolverine saga is a different storage surface from a document, and asserting one needs its own vocabulary rather than a {document} in disguise. Tracked separately.
Building an object from a table row
A grammar that takes a StepTable almost always has to turn each row into an object, and Bobcat.CritterStack.RecordBuilding is that conversion — the same one every shipped grammar uses, and public API for exactly this reason (issue #272):
[Given("shipments exist")]
public void GivenShipments(StepTable rows)
{
foreach (var shipment in RecordBuilding.BuildAll(typeof(Shipment), rows,
"Given shipments exist", partial: true))
{
// …
}
}Build takes one header → cell map; BuildAll does one object per row. Records land on their primary constructor, a settable-property object is the fallback, and cells convert with the same rules a Gherkin literal uses everywhere else in Bobcat.
Two arguments are worth passing rather than defaulting:
step— the step text, so a failure names the step the reader has to go and fix, not just the type.partial—truewhen the step is arranging history andfalsewhen it is performing an act. AGivennames the fields the behaviour depends on and leaves the rest of a six-field event alone; a command's fields are the scenario's input, so a missing one is a spec that tests something other than what it says (issue #241).
Use it rather than hand-rolling. A private bindRow in each module is how the type coercion, the treatment of a blank cell, and the message when a column matches nothing diverge once per consumer — the divergence #241 spent effort removing from the shipped grammars. Sharing the helper means a hand-written grammar and a shipped one fail the same way over the same table, including the "the column [Wieght] matches nothing on 'Shipment'" typo message, the empty-unmatched-cell rule that lets one table carry rows of several shapes, and the trailing-optional rule that lets a column be omitted when the constructor has a default.
The HTTP lane: CritterStackHttpFixture
The shipped grammar's When {command} is received dispatches over the message bus, which only binds when the command is a bus-visible message. The recommended default for an HTTP slice is collapsed — the endpoint is the handler, appending in one transaction and returning an honest status — and that shape needs an HTTP act. CritterStackHttpFixture is that lane, built as an assembly of existing pieces: the store vocabulary via its CritterStackFixture base, the HttpGrammars module composed on the class, and the tracked capture flowing between them over scenario state. It declares no steps of its own.
[IncludeGrammars(typeof(HttpGrammars), "/api/wallet")] // optional: bind a route prefix
public class CreditWallet : CritterStackHttpFixture;Given no events for Wallet "8f1c…"
When CreditWallet is posted to "/credit"
| WalletId | Amount |
| 8f1c… | 25 |
Then the response is 200
And WalletCredited is emitted
And the WalletSummary read model contains
| Balance |
| 25 |The HTTP steps:
When {command} is posted to {string}— builds the command record from the (at most one) table row of body fields, POSTs it as JSON to the prefixed route, inside Wolverine's tracked session — so the step returns only when everything the call caused (cascades, local queues drained) has landed, and every store assertion below it reads what the call did. The JSON goes through the application's own serializer, so the spec's wire shape is the application's wire shape.Then the response is {int}— the status assertion. This is the HTTP lane's refusal vocabulary: an HTTP guard refuses with ProblemDetails/400, not an exception, so the caught-exception semantics ofThen validation fails with …do not map.Then the response is 400composed withThen no events are emitteddescribes the sad path.
HttpGrammars' constructor takes (routePrefix, hostResource, storeName, timeoutInMilliseconds), all optional — name the host resource when a suite registers several.
The transport
The suite must register a test resource implementing Bobcat.Runtime.IHttpResource — the seam that carries the call. Bobcat.Alba's AlbaResource (both forms) implements it over the in-memory TestServer, so the usual Critter Stack wiring is already enough:
runner.Suite.AddResource(new AlbaResource<Program>());Bobcat.CritterStack itself still references no Alba and no ASP.NET: the grammar sees only the IHttpResource contract, the same delegate-shaped composition that keeps WhenTracked free of HTTP dependencies. Any other way of reaching the application — a real socket, a gRPC-web bridge — plugs in by implementing the same two-record contract (SpecHttpRequest in, SpecHttpResponse out; status codes are never asserted by the transport).
The {command} capture still resolves at compile time and still stamps the Event Modeling slice — an HTTP-driven scenario and a bus-driven one tagged with the same @slice: fold into one slice descriptor, because a slice is a behaviour, not a transport.
The slice learns it is reached over HTTP (issue #258)
A scenario that acts through is posted to stamps its slice with TriggerKind.Http and a TriggerOrigin carrying the route, the verb and a POST /wallets/credit label — so the canvas draws the HTTP glyph and the route on a slice nobody annotated. The route is the module's routePrefix plus the one in the step, because that is the route the application actually serves.
This is the only trigger kind Gherkin settles on its own, and the reason is worth stating: is posted to is the HTTP grammar's own sentence, so a scenario using it is reached over HTTP — a compile-time fact, which is the bar every other role on the descriptor is held to. When {command} is received is not the equivalent for MessageHandler: it dispatches an ordinary command as readily as it does a message a handler subscribes to, so a kind read off it would be a guess. That slot stays null for a source that knows — Wolverine's derived one does.
Two honest limits. A routePrefix resolved from the scenario rather than from an [IncludeGrammars] literal has no compile-time route, so the kind is still stamped and the route is left off — a route missing its prefix is a wrong route, which is worse on a canvas than no route. And the code-first Specification twin stamps no trigger kind: it records the roles a scenario resolved, not the grammar step that resolved them.