EventEngine::Definition

The plain-Ruby event-definition contract for the EventEngine pipeline.

EventEngine is a schema-first event pipeline: domain events are declared with a Ruby DSL, compiled into a canonical schema, and emitted through generated helper methods that hand each event to a publisher. This gem holds the plain-Ruby foundation of that pipeline — the EventEngine::EventDefinition DSL, the shared schema value objects, and the build step that turns a set of definitions into a committed helper file — with no Rails dependency.

Lightweight domain-pack gems depend on this contract; the full dispatch, registry, and Rails-engine machinery lives in event_engine and is wired in at runtime.

Installation

Requires Ruby >= 3.2.0.

Add the gem to your Gemfile:

gem "event_engine-event_definition"

Then run bundle install, and require it:

require "event_engine/definition"

A worked example

The data you want to capture

Say a lead just signed up in your Rails app. You have the record in hand:

lead = Lead.create!(
  email:   "ada@example.com",
  name:    "Ada Lovelace",
  company: "Analytical Engines",
  source:  "webinar"
)
# => #<Lead id: 42, email: "ada@example.com", name: "Ada Lovelace",
#           company: "Analytical Engines", source: "webinar", created_at: …>

You want to announce that a lead was created so the rest of the system — analytics, CRM sync, a welcome email — can react. Done ad hoc, every place that raises this "event" builds its own hash: one sends { email: … }, another { email_address: …, lead: 42 }, a third forgets source. The keys drift, fields go missing, and every consumer has to defend against all of it.

Defining the event fixes the shape once, so every producer emits the same data in the same format and every consumer can rely on it — with the names checked and the shape fingerprinted so it can't change silently.

Declare the event

class LeadCreated < EventEngine::EventDefinition
  event_name :lead_created   # the event's identity
  event_type :domain         # how you classify it
  domain     :marketing      # the bounded context it belongs to

  input :lead                # the object the event is built from

  required_payload :lead_id, from: :lead, attr: :id
  required_payload :email,   from: :lead, attr: :email
  required_payload :name,    from: :lead, attr: :name
  optional_payload :company, from: :lead, attr: :company
  optional_payload :source,  from: :lead, attr: :source
end

Read each payload line as: "the event carries lead_id, and its value comes from lead.id." The input :lead names the object you hand in; each from: points back at that input, and each attr: is the attribute read off it.

Generate the pack (a build step)

The helper doesn't exist until you generate it. event_engine-event_definition ships a rake task for that. Configure it once, then run it whenever a definition changes:

# Rakefile (in your domain pack)
require "event_engine/definition"
load "tasks/event_definition.rake"

EventEngine::Definition.configure do |config|
  config.definitions_path = "app/event_definitions"             # where your EventDefinitions live
  config.helper_path      = "lib/generated/marketing_events.rb" # where to write the helper
  config.root_module      = "MarketingEvents"                   # the module that wraps the helpers
end
$ rake event_definition:generate

The task loads every definition under definitions_pathno Rails required — and writes two files you commit:

  • a MarketingEvents module with one typed method per event, and
  • a schema.json alongside it — the committed contract downstream consumers read.

This is a build-time step; your app never runs it while serving requests. If your events declare a subject, register them too: config.subject_registry = EventEngine::SubjectRegistry.define { subject :lead }.

Generating inside a Rails app

The path above is right for a pack gem, whose lib/ a host never autoloads. If you generate into a Rails app instead, keep the helper out of the autoload paths. Rails 7.1+ autoloads lib by default, so Zeitwerk would expect lib/generated/marketing_events.rb to define Generated::MarketingEvents and raise on eager load — which means the app boots in development and fails in production:

Zeitwerk::NameError: expected file lib/generated/marketing_events.rb
to define constant Generated::MarketingEvents

Add the directory to the ignore list:

# config/application.rb
config.autoload_lib(ignore: %w[assets tasks generated])

The helper is meant to be required explicitly, not autoloaded — it registers the pack on load, so it needs to be required once at boot:

# config/initializers/event_engine.rb
require Rails.root.join("lib/generated/marketing_events")

The generated helper looks like this — note that it registers itself:

require "event_engine/definition"

module MarketingEvents
  def self.schema_path
    File.expand_path("schema.json", __dir__)
  end

  def self.lead_created(lead:, event_version: nil, occurred_at: nil, ...)
    EventEngine::Definition.publisher.publish(
      :lead_created, domain: :marketing, inputs: { lead: lead }, ...
    )
  end

  EventEngine::Definition.register_pack(self)
end

Pack self-registration

Requiring a generated pack registers it, so consumers can discover every pack's schema without being told about each one:

EventEngine::Definition.packs             # => [MarketingEvents, SalesEvents]
EventEngine::Definition.pack_schema_paths # => ["/…/marketing/schema.json", "/…/sales/schema.json"]

This is what lets event_engine's catalog task find your events with no per-pack configuration. register_pack is idempotent, so requiring a pack more than once is safe. EventEngine::Definition.reset_packs! clears the registry (useful in tests).

Emit the event (at runtime)

With the helper generated, producing the event anywhere in your app is one call. You hand it the whole lead; the contract decides what is captured off it:

MarketingEvents.lead_created(lead: lead)

Every lead_created event, from anywhere in the app, carries exactly the shape you declared:

{
  lead_id: 42,
  email:   "ada@example.com",
  name:    "Ada Lovelace",
  company: "Analytical Engines",
  source:  "webinar"
}

Where the work happens: this gem records the from:/attr: mapping and forwards the raw lead under inputs:. Reading lead.id, lead.email, … to build that payload is done by the publisher the event_engine runtime supplies — see How it fits.

With event_engine installed you wire nothing — it registers EventEngine::DefinitionPublisher at boot and the helper just works.

Without it, the default publisher raises PublisherNotConfigured. Assign your own — any object with #publish(event_name, domain:, inputs:, **envelope):

EventEngine::Definition.publisher = MyPublisher.new

More examples

See docs/examples.md for an event built from multiple inputs (an order and its customer) and a lifecycle that generates one event per step (started / completed / failed).

The DSL reference

Declarations, field by field

Declaration Required to be valid? What it does
event_name :x Yesschema raises without it The event's unique, snake_case identity.
event_type :x Yesschema raises without it A classification symbol you choose (e.g. :domain, :product). Not enumerated by this gem.
domain :x No The bounded context the event belongs to. Keys the event in the registry and scopes the generated helpers.
subject :x No The aggregate the event is about. If set, it must be registered in a SubjectRegistry when the definitions are compiled.
input :x Declares an input the event is built from.
optional_input :x Same, but the input may be omitted at the call site.
required_payload :name, from:, attr: Declares a payload field in the emitted event.
optional_payload :name, from:, attr: Same, but the field is not a guaranteed part of the payload.

Inputs vs. payload

These are two different layers:

  • Inputs are the arguments you hand the event — the whole objects your code already has. Each becomes a keyword argument on the generated helper.

    • input :leadlead: is required at the call site.
    • optional_input :campaigncampaign: defaults to nil and may be omitted.
  • Payload fields are the flat data the event carries, described as a mapping off those inputs (from: = which input, attr: = which attribute on it).

    • required_payload → the field is a guaranteed part of the payload.
    • optional_payload → the field may be absent.
    • The required flag is stored in the schema and is part of the event's fingerprint, so changing it changes the contract.

Reserved names

Compilation rejects definitions that use names owned by the event envelope:

  • Payload field names may not be any of: event_name, event_type, event_version, occurred_at, created_at, updated_at, published_at, metadata, idempotency_key, attempts, dead_lettered_at, aggregate_type, aggregate_id, aggregate_version.
  • Input names may not collide with the envelope keys: event_version, occurred_at, metadata, idempotency_key, aggregate_type, aggregate_id, aggregate_version.

A payload field must also have a from: that references a declared input, and each event name must be snake_case.

Inspecting the compiled schema

Every definition compiles to an EventEngine::EventDefinition::Schemas::Schema value object:

schema = LeadCreated.schema

schema.event_name       # => :lead_created
schema.required_inputs  # => [:lead]
schema.fingerprint      # => "…sha256 of the event's structure…"
schema.to_h             # => plain data hash (JSON-safe)
schema.to_ruby          # => a Ruby source string that rebuilds the Schema

The fingerprint is a stable hash of the event's structure (name, type, inputs, payload fields) — incidental fields like domain don't change it, so a matching fingerprint means a matching contract.

How it fits with event_engine

This gem defines and generates; event_engine runs. The seam between them is the publisher port (EventEngine::Definition.publisher):

this gem                                    event_engine (host runtime)
────────────────────────────────           ──────────────────────────────
EventDefinition DSL                          registers as the publisher
        │ compile                            ────────────────────►
        ▼                                    reads the raw inputs via the
generated helper  ──publish(event)──►        schema's from:/attr: mapping,
+ committed schema.json                       then routes it to a processor

A domain pack depends only on event_engine-event_definition to declare its events and build its helper file. In an app that also has event_engine installed, event_engine assigns EventEngine::DefinitionPublisher to EventEngine::Definition.publisher at boot, so calling a generated helper hands the event to the full runtime with no wiring in the host.

Nothing in this gem knows how events are processed — that decision lives entirely in event_engine, declared in its rules file. A definition describes what an event carries, never what happens to it.

Namespacing

Public API sits at EventEngine::EventDefinition (the DSL you subclass) and EventEngine::Definition (configuration, the publisher port, the pack registry).

This gem's internals live under EventEngine::Definition::*Definition::SchemaRegistry, Definition::EventSchema — deliberately, so they do not collide with event_engine's own classes of the same name. Both gems are loaded into one EventEngine namespace in a host, and a shared require path means whichever gem wins $LOAD_PATH silently shadows the other.

Development

After checking out the repo, run bin/setup to install dependencies. Then:

  • rake test — run the test suite
  • bin/console — an interactive prompt with the gem loaded

License

Available as open source under the terms of the MIT License.