Skip to content

Instantly share code, notes, and snippets.

@givigier
Last active June 17, 2025 14:40
Show Gist options
  • Select an option

  • Save givigier/0ca62d3223584b90986f8d1f2c4a3a01 to your computer and use it in GitHub Desktop.

Select an option

Save givigier/0ca62d3223584b90986f8d1f2c4a3a01 to your computer and use it in GitHub Desktop.
Solid Process

Error Handling

Explicit Success and Failure Outcomes

Solid::Process uses Solid::Output (which is aliased from Solid::Result) to represent the outcome of a process. This allows for explicit handling of both successful and unsuccessful executions.

  • Solid::Success: Indicates a successful operation. It can carry a type (a symbol) and a payload (a hash).
    • Example: Success(:user_created, user: user) in User::Creation signifies that a user was successfully created and provides the user object in the payload.
  • Solid::Failure: Indicates a failed operation. Similar to Success, it can carry a type and a payload, often containing error messages or invalid objects.
    • Example: Failure(:invalid_input, input: input) in User::Registration when input validations fail.
    • Example: Failure(:email_already_taken) when a user tries to register with an email that already exists.
    • Example: Failure(:invalid_record, **user.errors.messages) in User::Creation when an ActiveRecord user object fails to persist due to validation errors.

Usage in Processes

Processes like User::Registration, User::Creation, and Account::OwnerCreation demonstrate this pattern:

class User::Registration < Solid::Process
  # ...
  def call(attributes)
    user = User.new(attributes)

    return Failure(:invalid_user, user:) if user.invalid? # Explicit Failure

    # ...
    Success(:user_registered, user: user) # Explicit Success
  end
end

Solid::Process instances also provide convenience methods to check the outcome:

  • process.success? and process.success?(:type)
  • process.failure? and process.failure?(:type)
  • process.output or process.result to access the Solid::Output object.
  • Dynamic predicate methods like process.user_created? or process.invalid_input? are also available due to the Solid::Output.mixin module.

Chaining Operations with and_then and rollback_on_failure

The Solid::Process pattern encourages breaking down complex operations into smaller, manageable steps, which are then chained using and_then. The rollback_on_failure block, when ActiveRecord is present, ensures atomicity.

  • and_then: This method facilitates a functional pipeline. If a previous step returns a Solid::Failure, the subsequent steps in the chain within the rollback_on_failure block are skipped, and the Failure is propagated. If it's a Solid::Continue, the pipeline continues.
  • rollback_on_failure: This method wraps a series of operations in a database transaction. If any step within this block results in a Solid::Failure (or raises an ActiveRecord::Rollback exception), the entire transaction is rolled back. This is crucial for maintaining data consistency.

Example from User::Registration (Intermediate Usage):

class User::Registration < Solid::Process
  # ...
  def call(attributes)
    rollback_on_failure { # Transaction starts here
      Given(attributes)
        .and_then(:create_user) # If create_user fails, rollback and skip subsequent steps
        .and_then(:create_user_account)
        .and_then(:create_user_inbox)
        .and_then(:create_user_token)
    }
      .and_then(:send_email_confirmation) # This step is outside the transaction and won't be rolled back
      .and_expose(:user_registered, [:user])
  end

  private

  def create_user(email:, password:, password_confirmation:, **)
    user = User.create(email:, password:, password_confirmation:)

    return Continue(user:) if user.persisted?

    input.errors.merge!(user.errors)

    Failure(:invalid_input, input:) # Returning Failure stops the chain in rollback_on_failure
  end
end

Rescuing Exceptions with rescue_from

Solid::Process includes ActiveSupport::Rescuable, allowing you to define handlers for specific exceptions. This provides a clean way to convert unexpected errors into Solid::Output (Success or Failure) results.

  • The rescue_from method takes one or more exception classes and a block or a method name to handle them.
  • Inside the handler, you can call Success! or Failure! to set the process's output. Note that calling Success! or Failure! more than once within a single process execution will raise a Solid::Process::Error as the output is designed to be set only once.

Example from Division process:

class Division < Solid::Process
  # ...
  rescue_from ZeroDivisionError do |error|
    Failure!(:zero_division_error, error: error) # Handles ZeroDivisionError
  end

  rescue_from NumeratorIsZeroError do
    Success!(:division_completed, result: 0) # Handles NumeratorIsZeroError
  end

  rescue_from NanNumberError, with: :nan_number_error # Uses a private method as handler

  def call(attributes)
    # ...
    raise ZeroDivisionError if number2.zero? && !number1.zero?
    # ...
  end

  private

  def nan_number_error(error)
    Failure!(:nan_number_error, error: error)
  end
end

Input and Dependency Validation

Solid::Process uses Solid::Input for defining and validating inputs and dependencies. Solid::Input includes ActiveModel::Validations, offering a comprehensive set of validation helpers.

  • Input Validation: Before the call method is executed, the input attributes are validated. If the input is invalid, the process immediately returns a Failure(:invalid_input, input: input).
    • Example: In User::Creation, validates :email, presence: true, format: {with: ::URI::MailTo::EMAIL_REGEXP} ensures the email is present and in a valid format.
  • Dependency Validation: Similar to input, dependencies can also be validated. If dependencies are invalid, the process returns Failure(:invalid_dependencies, dependencies: dependencies).
    • Example: UserCreationWithDeps defines a repository_interface validation to ensure the injected repository object responds to create! and exists?.
class UserCreationWithDeps < Solid::Process
  dependencies do
    attribute :repository, default: ::User

    validate :repository_interface # Custom validation for dependencies

    def repository_interface
      repository.respond_to?(:create!) or errors.add(:repository, message: "must respond to :create!")
      repository.respond_to?(:exists?) or errors.add(:repository, message: "must respond to :exists?")
    end
  end

  input do
    # ...
    validates :email, presence: true, format: {with: TestUtils::EMAIL_REGEX} # Built-in ActiveModel validation
  end
  # ...
end

Instrumentation / Observability

Observability is a key aspect of building robust and maintainable applications. Solid::Process provides built-in instrumentation to help you understand the flow of your business processes, especially in complex scenarios involving nested processes and potential errors.

The primary mechanism for instrumentation is the Event Logging system, which captures detailed information about each step of a process's execution.

Event Logging with Solid::Process::EventLogs::BasicLoggerListener

The Solid::Process::EventLogs::BasicLoggerListener is a default listener that integrates with Solid::Result's event logging capabilities. It's designed to provide clear and structured logs of your process executions to a logger (by default, ActiveSupport::Logger.new($stdout)).

How it Works

The Solid::Result gem, which Solid::Process leverages, has a built-in event logging system. When a Solid::Process is executed, it records each significant step as an event. The BasicLoggerListener subscribes to these events and formats them into readable log messages.

Key aspects of the logging:

  • Process Identification: Each top-level and nested process is identified with a unique ID and its class name. This helps in tracing the execution path through complex orchestrations.
  • Step-by-Step Execution: Every Given, Continue, Success, and Failure outcome within a process is logged, indicating the type of result and the method from which it originated.
  • Hierarchical Logging: For nested processes, the logs are indented to visually represent the call stack, making it easy to understand the parent-child relationships between processes.
  • Exception Handling: If an exception occurs during a process's execution, the listener captures and logs the exception message, its class, and a cleaned backtrace, pinpointing the source of the error within your application code (and silencing internal solid-process traces for clarity).

Configuration

You can configure the BasicLoggerListener to use a different logger instance or a custom backtrace cleaner:

# config/initializers/solid_process_event_logs.rb (example for Rails)
Solid::Process::EventLogs::BasicLoggerListener.configure do |config|
  # Configure a custom logger (e.g., Rails.logger)
  config.logger = Rails.logger

  # Configure a custom backtrace cleaner (if ActiveSupport::BacktraceCleaner doesn't suffice)
  # config.backtrace_cleaner = MyCustomBacktraceCleaner.new
end

Example Log Output

Consider the Account::OwnerCreation process, which involves nested calls to User::Creation and User::Token::Creation. A successful execution would generate logs similar to this:

#0 Account::OwnerCreation
 * Given(uuid:, owner:)
  #1 User::Creation
   * Given(uuid:, name:, email:, password:, password_confirmation:)
   * Continue() from method: validate_email_uniqueness
   * Continue(user:) from method: create_user
    #2 User::Token::Creation
      * Given(user:, executed_at:)
      * Continue() from method: validate_token_existence
      * Continue(token:) from method: create_token_if_not_exists
      * Success(:token_created, token:)
   * Continue(token:) from method: create_user_token
   * Success(:user_created, user:, token:)
 * Continue(user:, user_token:) from method: create_owner
 * Continue(account:) from method: create_account
 * Continue() from method: link_owner_to_account
 * Success(:account_owner_created, user:, account:)

If an exception were to occur, for instance, during User::Token::Creation, the log would look something like this (simplified):

#0 Account::OwnerCreation
 * Given(uuid:, owner:)
  #1 User::Creation
   * Given(uuid:, name:, email:, password:, password_confirmation:)
   * Continue() from method: validate_email_uniqueness
   * Continue(user:) from method: create_user
    #2 User::Token::Creation
      * Given(user:, executed_at:)
      * Continue() from method: validate_token_existence

Exception:
  Runtime breaker activated (USER_TOKEN_CREATION) (RuntimeBreaker::Interruption)

Backtrace:
  app/models/user/token/creation.rb:28:in `create_token_if_not_exists'
  app/models/user/token/creation.rb:15:in `call'
  app/models/user/creation.rb:61:in `create_user_token'
  app/models/user/creation.rb:30:in `call'
  app/models/account/owner_creation.rb:32:in `create_owner'
  app/models/account/owner_creation.rb:21:in `call'

Benefits of Event Logging

  • Debugging: Quickly identify the exact step and method where an issue occurred, whether it's an unexpected outcome or an unhandled exception.
  • Understanding Flow: Gain a clear understanding of how complex processes execute, including nested calls and conditional logic.
  • Auditing: Provides a trace of operations performed, which can be useful for auditing purposes in production environments.
  • Performance Analysis (Basic): While not a dedicated performance monitoring tool, the logs can give you a high-level overview of the sequence of operations, which can sometimes hint at bottlenecks.

Custom Instrumentation

While Solid::Process provides robust built-in logging, you can also integrate with other instrumentation tools or build custom listeners by hooking into Solid::Result's event system (if exposed publicly by Solid::Result).

At its core, Solid::Process executes within a Solid::Result.event_logs block:

# From lib/solid/process/caller.rb
::Solid::Result.event_logs(name: self.class.name) do
  # ... process logic ...
end

This means that any listener configured for Solid::Result's event logs will automatically receive events from Solid::Process executions. You can extend the Solid::Result::EventLogs::Listener module to create your own custom listeners that respond to on_finish (when a process completes) and before_interruption (when an exception is about to be raised and potentially rescued).

For more advanced instrumentation needs, consider:

  • ActiveSupport::Notifications: If you need more fine-grained control over what events are broadcasted and listened to, you can manually instrument your process methods using ActiveSupport::Notifications.instrument.
  • APM Tools: For production environments, integrating with Application Performance Monitoring (APM) tools (e.g., New Relic, Datadog) is recommended. These tools often have agents that can automatically detect and report on method calls, database queries, and errors within your Ruby application, including processes built with Solid::Process.

By leveraging the built-in event logging and understanding the underlying mechanisms, you can effectively instrument your Solid::Process based applications for enhanced observability and easier debugging.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment