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)inUser::Creationsignifies that a user was successfully created and provides theuserobject in the payload.
- Example:
Solid::Failure: Indicates a failed operation. Similar toSuccess, it can carry a type and a payload, often containing error messages or invalid objects.- Example:
Failure(:invalid_input, input: input)inUser::Registrationwhen 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)inUser::Creationwhen an ActiveRecorduserobject fails to persist due to validation errors.
- Example:
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
endSolid::Process instances also provide convenience methods to check the outcome:
process.success?andprocess.success?(:type)process.failure?andprocess.failure?(:type)process.outputorprocess.resultto access theSolid::Outputobject.- Dynamic predicate methods like
process.user_created?orprocess.invalid_input?are also available due to theSolid::Output.mixinmodule.
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 aSolid::Failure, the subsequent steps in the chain within therollback_on_failureblock are skipped, and theFailureis propagated. If it's aSolid::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 aSolid::Failure(or raises anActiveRecord::Rollbackexception), 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
endSolid::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_frommethod takes one or more exception classes and a block or a method name to handle them. - Inside the handler, you can call
Success!orFailure!to set the process's output. Note that callingSuccess!orFailure!more than once within a single process execution will raise aSolid::Process::Erroras 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
endSolid::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
callmethod is executed, theinputattributes are validated. If the input is invalid, the process immediately returns aFailure(: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.
- Example: In
- Dependency Validation: Similar to input, dependencies can also be validated. If dependencies are invalid, the process returns
Failure(:invalid_dependencies, dependencies: dependencies).- Example:
UserCreationWithDepsdefines arepository_interfacevalidation to ensure the injectedrepositoryobject responds tocreate!andexists?.
- Example:
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