Skip to content

Extensions Since 3.0.0

Extensions allow you to expand base service functionality through the Stroma hook system. Define custom behavior that runs before or after service execution stages.

Create extensions in the app/services/application_service/extensions directory.

Quick start

Generate extension

shell
rails generate servactory:extension StatusActive

This creates app/services/application_service/extensions/status_active/dsl.rb.

Connect extension

ruby
module ApplicationService
  class Base < Servactory::Base
    extensions do
      before :actions, ApplicationService::Extensions::StatusActive::DSL
    end
  end
end

Use in service

ruby
class PostsService::Create < ApplicationService::Base
  input :user, type: User
  input :title, type: String

  status_active! :user

  make :create_post

  private

  def create_post
    # ...
  end
end

Creating extensions

Using generator

shell
rails generate servactory:extension MyExtension

Options:

OptionDefaultDescription
--pathapp/services/application_service/extensionsOutput directory
--namespaceApplicationServiceBase namespace

Examples:

shell
# Basic
rails generate servactory:extension Auditable

# Nested namespace
rails generate servactory:extension Admin::AuditTrail

# Custom path
rails generate servactory:extension MyExtension --path=lib/extensions

Extension structure

ruby
module ApplicationService
  module Extensions
    module MyExtension
      module DSL
        def self.included(base)
          base.extend(ClassMethods)
          base.include(InstanceMethods)
        end

        module ClassMethods
          private

          def my_extension!(value)
            stroma.settings[:actions][:my_extension][:value] = value
          end
        end

        module InstanceMethods
          private

          def call!(**)
            value = self.class.stroma.settings[:actions][:my_extension][:value]

            if value.present?
              # Before logic
            end

            super

            # After logic
          end
        end
      end
    end
  end
end

Module structure explanation

ModulePurpose
DSLEntry point module, connected via hooks
ClassMethodsDSL methods called at class definition time
InstanceMethodsRuntime methods called during service execution
  • base.extend(ClassMethods) — adds class-level configuration methods
  • base.include(InstanceMethods) — adds instance-level runtime behavior

File organization

For complex extensions, split into separate files:

extensions/my_extension/
├── dsl.rb              # Main DSL module with self.included
├── class_methods.rb    # ClassMethods module
└── instance_methods.rb # InstanceMethods module

Connecting extensions

Hooks: before and after

ruby
class ApplicationService::Base < Servactory::Base
  extensions do
    before :actions, ApplicationService::Extensions::Authorization::DSL
    after :actions, ApplicationService::Extensions::Publishable::DSL
  end
end

Available hook keys

Hooks can be attached to these stages (in execution order):

KeyDescription
:configurationService configuration
:infoService info
:contextContext setup
:inputsInput processing
:internalsInternal attributes
:outputsOutput processing
:actionsAction execution

Most extensions use :actions — the main execution point.

Multiple extensions

ruby
class ApplicationService::Base < Servactory::Base
  extensions do
    # Before hooks (execute in order)
    before :actions, ApplicationService::Extensions::Authorization::DSL
    before :actions, ApplicationService::Extensions::StatusActive::DSL

    # After hooks (execute in order)
    after :actions, ApplicationService::Extensions::Publishable::DSL
    after :actions, ApplicationService::Extensions::PostCondition::DSL
  end
end

Execution order

  1. before hooks execute in declaration order
  2. Service actions (make methods)
  3. after hooks execute in declaration order

Understanding super

Extensions form a call chain. super passes execution to the next module:

ruby
def call!(**)
  # Before logic (runs first)
  settings = self.class.stroma.settings[:actions][:my_extension]
  fail!(message: "Not configured") if settings[:required] && settings[:value].blank?

  super  # Calls next extension or service actions

  # After logic (runs after service completes)
  Rails.logger.info("Service completed: #{self.class.name}")
end
Patternsuper placementUse case
BeforeLogic before superValidation, authorization
AfterLogic after superLogging, publishing
AroundWrap superTransactions, timing
Short-circuitSkip superCaching, early return

Organizing complex extensions

For extensions with complex logic, isolate it into dedicated Tools classes instead of adding methods to the extension module. This pattern is used internally by Servactory.

File structure:

extensions/authorization/
├── dsl.rb
└── tools/
    └── permission_checker.rb
ruby
module ApplicationService
  module Extensions
    module Authorization
      module DSL
        def self.included(base)
          base.extend(ClassMethods)
          base.include(InstanceMethods)
        end

        module ClassMethods
          private

          def authorize_with(method_name)
            stroma.settings[:actions][:authorization][:method_name] = method_name
          end
        end

        module InstanceMethods
          private

          def call!(incoming_arguments: {}, **)
            method_name = self.class.stroma.settings[:actions][:authorization][:method_name]

            if method_name.present?
              # PORO class for extension logic, not a Servactory service
              Tools::PermissionChecker.check!(self, incoming_arguments, method_name)
            end

            super
          end
        end
      end
    end
  end
end
ruby
module ApplicationService
  module Extensions
    module Authorization
      module Tools
        class PermissionChecker
          def self.check!(...)
            new(...).check!
          end

          def initialize(context, arguments, method_name)
            @context = context
            @arguments = arguments
            @method_name = method_name
          end

          def check!
            authorized = @context.send(@method_name, @arguments)

            return if authorized

            @context.fail!(
              :unauthorized,
              message: "Not authorized to perform this action"
            )
          end
        end
      end
    end
  end
end

Benefits:

  • Logic is isolated in dedicated classes
  • No method pollution in extension modules
  • Easy to test each Tool in isolation
  • Scales well for complex extensions

Stroma settings

Extensions store configuration in Stroma settings.

Key structure

stroma.settings[:registry_key][:extension_name][:setting_key]
LevelDescriptionExample
registry_keyHook target:actions
extension_nameExtension identifier:authorization
setting_keySpecific setting:method_name

Writing settings

In ClassMethods:

ruby
def authorize_with(method_name)
  stroma.settings[:actions][:authorization][:method_name] = method_name
end

Reading settings

In InstanceMethods:

ruby
def call!(**)
  method_name = self.class.stroma.settings[:actions][:authorization][:method_name]
  # ...
  super
end

Auto-vivification

Nested objects are created automatically on first access:

ruby
# This works without explicit initialization
stroma.settings[:actions][:my_extension][:enabled] = true
stroma.settings[:actions][:my_extension][:options] = { timeout: 30 }

Extension patterns

Before pattern

Validate or check conditions before service execution.

ruby
module ApplicationService
  module Extensions
    module Authorization
      module DSL
        def self.included(base)
          base.extend(ClassMethods)
          base.include(InstanceMethods)
        end

        module ClassMethods
          private

          def authorize_with(method_name)
            stroma.settings[:actions][:authorization][:method_name] = method_name
          end
        end

        module InstanceMethods
          private

          def call!(incoming_arguments: {}, **)
            method_name = self.class.stroma.settings[:actions][:authorization][:method_name]

            if method_name.present?
              authorized = send(method_name, incoming_arguments)

              unless authorized
                fail!(
                  :unauthorized,
                  message: "Not authorized to perform this action"
                )
              end
            end

            super
          end
        end
      end
    end
  end
end
ruby
class PostsService::Delete < ApplicationService::Base
  input :post, type: Post
  input :user, type: User

  authorize_with :user_can_delete?

  make :delete_post

  private

  def user_can_delete?(args)
    args[:user].admin? || args[:post].author_id == args[:user].id
  end

  def delete_post
    inputs.post.destroy!
  end
end

Around pattern

Wrap service execution in a context.

ruby
module ApplicationService
  module Extensions
    module Transactional
      module DSL
        def self.included(base)
          base.extend(ClassMethods)
          base.include(InstanceMethods)
        end

        module ClassMethods
          private

          def transactional!(transaction_class: nil)
            stroma.settings[:actions][:transactional][:enabled] = true
            stroma.settings[:actions][:transactional][:class] = transaction_class
          end
        end

        module InstanceMethods
          private

          def call!(**)
            settings = self.class.stroma.settings[:actions][:transactional]
            enabled = settings[:enabled]

            unless enabled
              super
              return
            end

            transaction_class = settings[:class]

            fail!(message: "Transaction class not configured") if transaction_class.nil?

            transaction_class.transaction { super }
          end
        end
      end
    end
  end
end
ruby
class OrdersService::Create < ApplicationService::Base
  transactional! transaction_class: ActiveRecord::Base

  input :user, type: User
  input :items, type: Array

  output :order, type: Order

  make :create_order
  make :create_line_items
  make :charge_payment

  private

  def create_order
    outputs.order = Order.create!(user: inputs.user)
  end

  def create_line_items
    inputs.items.each do |item|
      outputs.order.line_items.create!(item)
    end
  end

  def charge_payment
    PaymentsService::Charge.call!(amount: outputs.order.total_amount)
  end
end

After pattern

Process results after service execution.

ruby
module ApplicationService
  module Extensions
    module Publishable
      module DSL
        def self.included(base)
          base.extend(ClassMethods)
          base.include(InstanceMethods)
        end

        module ClassMethods
          private

          def publishes(event_name, with: nil, event_bus: nil)
            stroma.settings[:actions][:publishable][:configurations] ||= []
            stroma.settings[:actions][:publishable][:configurations] << {
              event_name:,
              payload_method: with,
              event_bus:
            }
          end
        end

        module InstanceMethods
          private

          def call!(**)
            super

            configurations = self.class.stroma.settings[:actions][:publishable][:configurations] || []

            configurations.each do |config|
              event_name = config[:event_name]
              payload_method = config[:payload_method]
              event_bus = config[:event_bus]

              payload = payload_method.present? ? send(payload_method) : {}
              event_bus.publish(event_name, payload)
            end
          end
        end
      end
    end
  end
end
ruby
class UsersService::Create < ApplicationService::Base
  publishes :user_created, with: :user_payload, event_bus: EventPublisher

  input :email, type: String
  input :name, type: String

  output :user, type: User

  make :create_user

  private

  def create_user
    outputs.user = User.create!(email: inputs.email, name: inputs.name)
  end

  def user_payload
    { user_id: outputs.user.id, email: outputs.user.email }
  end
end

Rescue pattern

Handle errors and perform cleanup.

ruby
module ApplicationService
  module Extensions
    module Rollbackable
      module DSL
        def self.included(base)
          base.extend(ClassMethods)
          base.include(InstanceMethods)
        end

        module ClassMethods
          private

          def on_rollback(method_name)
            stroma.settings[:actions][:rollbackable][:method_name] = method_name
          end
        end

        module InstanceMethods
          private

          def call!(**)
            super
          rescue StandardError => e
            raise e if e.is_a?(Servactory::Exceptions::Success)

            method_name = self.class.stroma.settings[:actions][:rollbackable][:method_name]

            send(method_name) if method_name.present?

            raise
          end
        end
      end
    end
  end
end
ruby
class PaymentsService::Process < ApplicationService::Base
  on_rollback :cleanup_resources

  input :order, type: Order
  input :payment_method, type: PaymentMethod

  output :payment, type: Payment

  make :reserve_inventory
  make :charge_payment
  make :confirm_order

  private

  def reserve_inventory
    InventoryService::Reserve.call!(items: inputs.order.items)
  end

  def charge_payment
    result = PaymentsService::Charge.call!(
      payment_method: inputs.payment_method,
      amount: inputs.order.total_amount
    )
    outputs.payment = result.payment
  end

  def confirm_order
    inputs.order.confirm!
  end

  def cleanup_resources
    InventoryService::Release.call!(items: inputs.order.items)
    PaymentsService::Refund.call!(payment: outputs.payment) if outputs.payment.present?
  end
end

Migration from 2.x

WARNING

Servactory::DSL.with_extensions(...) is deprecated and will be removed in future releases. Please migrate to the new extensions do block syntax.

Syntax changes

ruby
module ApplicationService
  class Base
    include Servactory::DSL.with_extensions(
      ApplicationService::Extensions::StatusActive::DSL
    )
  end
end
ruby
module ApplicationService
  class Base < Servactory::Base
    extensions do
      before :actions, ApplicationService::Extensions::StatusActive::DSL
    end
  end
end

Settings storage changes

Aspect2.x3.0
Storageattr_accessor (class instance variable)stroma.settings[:key][:ext][:setting]
Accessself.class.send(:var)self.class.stroma.settings[:key][:ext][:setting]
InheritanceManual handlingAutomatic deep copy

Extension code changes

ruby
module ClassMethods
  private

  attr_accessor :status_active_model_name

  def status_active!(model_name)
    self.status_active_model_name = model_name
  end
end

module InstanceMethods
  private

  def call!(**)
    super

    model_name = self.class.send(:status_active_model_name)
    # ...
  end
end
ruby
module ClassMethods
  private

  def status_active!(model_name)
    stroma.settings[:actions][:status_active][:model_name] = model_name
  end
end

module InstanceMethods
  private

  def call!(**)
    model_name = self.class.stroma.settings[:actions][:status_active][:model_name]
    # ...
    super
  end
end