Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,26 @@
## [0.5.1] - 2026-05-25

### Added

- **Conditional emission on `emits`**: The `emits` macro now accepts `if:` and `unless:` options
to gate whether an event fires at runtime. When the condition is not met, the event is completely
skipped — no payload is built, no validation runs, and nothing reaches the bus. Both options accept
a lambda/proc (receives the `result` object) or a Symbol naming a private instance method.
Fully backwards compatible — existing `emits` declarations without conditions are unaffected.

```ruby
emits :large_transfer_event, on: :success, if: ->(result) { result.data[:transferred] > 100 }
emits :standard_transfer_event, on: :success, unless: ->(result) { result.data[:transferred] > 100 }
emits :vip_transfer_event, on: :success, if: :vip_sender?
```

- **`failure_message_when_negated` on `emit_event` matcher**: `expect { }.not_to emit_event(:name)`
now produces a clear failure message when the event was unexpectedly emitted.

- **`with:` option moved to `**options`**: The `emits` macro signature is now
`emits(event_name, on:, **options, &block)` — `with:`, `if:`, and `unless:` are all uniform
keyword options. No change to calling code; `emits :name, on: :success, with: :method` still works.

## [0.5.0] - 2026-05-07

### Breaking Changes
Expand Down
1 change: 0 additions & 1 deletion gem/Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ source 'https://rubygems.org'
# Specify your gem's dependencies in servus.gemspec
gemspec

gem 'active_model_serializers'
gem 'activesupport'
gem 'json-schema'
gem 'rake', '~> 13.0'
Expand Down
12 changes: 1 addition & 11 deletions gem/Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
PATH
remote: .
specs:
servus (0.5.0)
active_model_serializers (~> 0.10.0)
servus (0.5.1)
activesupport (>= 8.0)
json-schema (~> 5)

Expand All @@ -25,11 +24,6 @@ GEM
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
active_model_serializers (0.10.15)
actionpack (>= 4.1)
activemodel (>= 4.1)
case_transform (>= 0.2)
jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
activejob (8.1.3)
activesupport (= 8.1.3)
globalid (>= 0.3.6)
Expand Down Expand Up @@ -58,8 +52,6 @@ GEM
base64 (0.3.0)
bigdecimal (3.3.1)
builder (3.3.0)
case_transform (0.2)
activesupport
concurrent-ruby (1.3.6)
connection_pool (3.0.2)
crass (1.0.6)
Expand All @@ -82,7 +74,6 @@ GEM
json-schema (5.1.1)
addressable (~> 2.8)
bigdecimal (~> 3.1)
jsonapi-renderer (0.2.2)
language_server-protocol (3.17.0.5)
lint_roller (1.1.0)
logger (1.7.0)
Expand Down Expand Up @@ -212,7 +203,6 @@ PLATFORMS

DEPENDENCIES
actionpack (>= 8.0)
active_model_serializers
activejob
activerecord
activesupport
Expand Down
2 changes: 0 additions & 2 deletions gem/lib/servus.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@
require 'active_support'
require 'active_support/core_ext/class/attribute'
require 'active_support/core_ext/hash/indifferent_access'
require 'active_model_serializers'

# Servus namespace
module Servus; end

Expand Down
77 changes: 67 additions & 10 deletions gem/lib/servus/events/emitter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,15 @@ def self.emit_result_events!(instance, result)
# Declares an event that this service will emit.
#
# Events are automatically emitted when the service completes with the specified
# trigger condition (:success, :failure, or :error). Use the `with` option to
# provide a custom payload builder, or pass a block.
# trigger condition (:success, :failure, or :error). Use the `with` option or a
# block to provide a custom payload builder. Use `if` or `unless` to gate emission
# on a runtime condition.
#
# @param event_name [Symbol] the name of the event to emit
# @param on [Symbol] when to emit (:success, :failure, or :error)
# @param with [Symbol, nil] optional instance method name for building the payload
# @param on [Symbol] when to emit (:success, :failure, or :error!)
# @option options [Symbol, nil] :with instance method name for building the payload
# @option options [Proc, Symbol, nil] :if condition proc or method name; event only emits when truthy
# @option options [Proc, Symbol, nil] :unless condition proc or method name; event only emits when falsy
# @yield [result] optional block for building the payload
# @yieldparam result [Servus::Support::Response] the service result
# @yieldreturn [Hash] the event payload
Expand Down Expand Up @@ -67,6 +70,22 @@ def self.emit_result_events!(instance, result)
# end
# end
#
# @example Conditional emission with if: lambda
# class CreateUser < Servus::Base
# emits :premium_user_created, on: :success, if: ->(result) { result.data[:plan] == :premium }
# end
#
# @example Conditional emission with unless: method reference
# class CreateUser < Servus::Base
# emits :user_created, on: :success, unless: :suppressed?
#
# private
#
# def suppressed?(result)
# result.data[:suppressed]
# end
# end
#
# @note Best Practice: Services should typically emit ONE event per trigger
# that represents their core concern. Multiple downstream reactions should
# be coordinated by Event classes, not by emitting multiple events
Expand All @@ -87,18 +106,15 @@ def self.emit_result_events!(instance, result)
#
# @see Servus::Events::Bus
# @see Servus::Event
def emits(event_name, on:, with: nil, &block)
def emits(event_name, on:, **options, &block)
valid_triggers = %i[success failure error!]

unless valid_triggers.include?(on)
raise ArgumentError, "Invalid trigger: #{on}. Must be one of: #{valid_triggers.join(', ')}"
end

@event_emissions ||= { success: [], failure: [], error!: [] }
@event_emissions[on] << {
event_name: event_name,
payload_builder: block || with
}
@event_emissions[on] << build_emission(event_name, options, block)
end

# Returns all event emissions declared for this service.
Expand All @@ -116,6 +132,17 @@ def event_emissions
def emissions_for(trigger)
event_emissions[trigger] || []
end

private

def build_emission(event_name, options, block)
{
event_name: event_name,
if_condition: options[:if],
unless_condition: options[:unless],
payload_builder: block || options[:with]
}
end
end

# Emits events for a specific trigger with the given result.
Expand All @@ -126,14 +153,44 @@ def emissions_for(trigger)
# @api private
def emit_events_for(trigger, result)
self.class.emissions_for(trigger).each do |emission|
next unless emission_condition_met?(emission, result)

payload = build_event_payload(emission, result)
validate_event_payload!(emission[:event_name], payload)
Servus::Events::Bus.emit(emission[:event_name], payload)
end
end

# Instance methods for emitting events during service execution
private

# Returns true when all declared conditions on the emission pass.
#
# @param emission [Hash] the emission configuration
# @param result [Servus::Support::Response] the service result
# @return [Boolean]
# @api private
def emission_condition_met?(emission, result)
if_condition = emission[:if_condition]
unless_condition = emission[:unless_condition]

return false if if_condition && !evaluate_emission_condition(if_condition, result)
return false if unless_condition && evaluate_emission_condition(unless_condition, result)

true
end

# Evaluates a single emission condition — either a Proc/lambda or a Symbol method reference.
#
# Both forms receive the result object so conditions can inspect result.data,
# result.error, result.success?, etc.
#
# @param condition [Proc, Symbol] the condition to evaluate
# @param result [Servus::Support::Response] the service result
# @return [Object] truthy or falsy value
# @api private
def evaluate_emission_condition(condition, result)
condition.is_a?(Proc) ? condition.call(result) : send(condition, result)
end

# Validates the payload against the Event class's schema registered for the event.
#
Expand Down
5 changes: 5 additions & 0 deletions gem/lib/servus/testing/matchers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ module Matchers
"got: #{@matching_event[:payload].inspect}"
end
end

failure_message_when_negated do
"expected event :#{@event_name} not to be emitted, but it was.\n" \
"Payload: #{@matching_event[:payload].inspect}"
end
end

# Matcher for asserting service invocation
Expand Down
2 changes: 1 addition & 1 deletion gem/lib/servus/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module Servus
VERSION = '0.5.0'
VERSION = '0.5.1'
end
1 change: 0 additions & 1 deletion gem/servus.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ Gem::Specification.new do |spec|
spec.require_paths = ['lib']

# Uncomment to register a new dependency of your gem
spec.add_dependency 'active_model_serializers', '~> 0.10.0'
spec.add_dependency 'activesupport', '>= 8.0'
spec.add_dependency 'json-schema', '~> 5'

Expand Down
Loading