Cuscuz
Cuscuz is a gem for writing business logic objects, similar to interactors, services, operations, use cases, mutations, and commands. It relies heavily on the Literal gem to provide input and output validation. Cuscuz was extracted from our project pikoin, a simple personal finance tracker.
Installation
Install the gem and add to the application's Gemfile by executing:
bundle add cuscuz --source "https://beta.gem.coop/@gunbolt"
Or put in your Gemfile:
gem "cuscuz", source: "https://beta.gem.coop/@gunbolt"
If bundler is not being used to manage dependencies, install the gem by executing:
gem install cuscuz --source "https://beta.gem.coop/@gunbolt"
Usage
TL;DR:
class UpdateRecord < Cuscuz::Operation
prop :id, Integer
prop :attributes, Hash
Success = Result.success(record: Record)
Failure = Result.failure(reason: String, record: Record)
def call
record = Record.find(@id)
if record.update(@attributes)
Success[record:]
else
Failure[reason: "Invalid record", record:]
end
end
end
result = UpdateRecord.call(id: 1, attributes: {title: "Updated Record"})
result.success? # => true
result.failure? # => false
result.record # => #<Record id=1 ...>
result.class # => UpdateUser::Success
case result
in success: true, record:
...
in failure: true, reason:, record:
...
end
case result
in UpdateRecord::Success(record:)
...
in UpdateRecord::Failure(reason:, record:)
...
end
Defining inputs
Cuscuz uses Literal::Properties to enable input validation for operations. You can read the Literal documentation to understand the full API, but here are some basic usage examples:
# required keyword argument of type Integer
prop :id, Integer
# required keyword argument of type Integer or String
prop :id, _Union(Integer, String)
# optional keyword argument of type Symbol
prop :type, _String?
# optional keyword argument of type custom type
prop :user, _Nilable(User)
# optional keyword argument with default value
prop :priority, Integer, default: 5
# array
prop :values, Array
# advanced array
prop :posts, _Array(Post)
# hash
prop :attributes, Hash
# advanced hash
prop :options, _Hash(Symbol, _Union(String, Symbol, Integer, _Boolean))
# Rails relations
prop :posts, ActiveRecord::Relation(Post)
If you do not provide the correct arguments to the operation, meaningful errors will be raised:
class Sum < Cuscuz::Operation
prop :a, Integer
prop :b, Integer
# ...
end
Sum.call(a: "2", b: 3)
# Type mismatch (Literal::TypeError)
#
# Sum#initialize (from )
# a:
# Expected: Integer
# Actual (String): "2"
Sum.call(a: 2)
# missing keyword: :b (ArgumentError)
The inputs can be accessed via instance variables:
class Sum < Cuscuz::Operation
prop :a, Integer
prop :b, Integer
# ...
def call
puts @a
puts @b
# ...
end
end
Sum.call(a: 5, b: 4)
# => 5
# => 4
Defining the outputs
Cuscuz operations are required to return either a Cuscuz::Result::Success or
a Cuscuz::Result::Failure. They are subclasses of Literal::Data, similar to
Ruby’s native Data class, but with support for property validation through
Literal features. You can define them using Result.success and
Result.failure:
class Divide < Cuscuz::Operation
prop :a, Integer
prop :b, Integer
Success = Result.success(value: Integer)
Failure = Result.failure(reason: String)
def call
if b == 0
Failure[reason: "Cannot divide by zero"]
else
Success[value: a / b]
end
end
end
result = Divide.call(a: 10, b: 5)
result.success? # => true
result.failure? # => false
result.value # => 2
result.class # => Divide::Success
result = Divide.call(a: 10, b: 0)
result.success? # => false
result.failure? # => true
result.reason # => "Cannot divide by zero"
result.class # => Divide::Failure
The result object provides the #success? and #failure? methods, along with
accessors for its properties.
To instantiate results, you can use either the .new or .[] method:
Success[value: 2] == Success.new(value: 2) # => true
If you do not provide the correct arguments, an error will be raised:
Success[value: "2"]
# Type mismatch (Literal::TypeError)
#
# Divide::Success#initialize (from )
# value:
# Expected: Integer
# Actual (String): "2"
Success[] # missing keyword: :value (ArgumentError)
If your operation does not return a Cuscuz::Result::Success or
Cuscuz::Result::Failure, an error will be raised:
class MyOperation < Cuscuz::Operation
def call
return "2"
end
end
MyOperation.call
# Cuscuz::InvalidResultTypeError:
# The operation returned a `String`, it was expected a
# `Cuscuz::Result::Success` or `Cuscuz::Result::Failure`
You can define multiple types of successes and/or failures:
class CreateUser < Cuscuz::Operation
prop :attributes, Hash
ReadyUserResult = Result.success(user:)
PendingApprovalResult = Result.success(user:)
InvalidAttributesResult = Result.failure(user:)
BannedUserResult = Result.failure(user:)
def call
user = User.new(@attributes)
if banned_emails.include?(user.email)
return BannedUserResult[user:]
end
if user.save
if user.workspace.needs_approval?
PendingApprovalResult[user:]
else
ReadyUserResult[user:]
end
else
InvalidAttributesResult[user:]
end
end
private
def banned_emails = ...
end
Executing the operation
Define an instance method named #call inside your operation to implement your
business logic.
You can execute the operation using the .call and .call! methods. When
using .call!, an error will be raised if the operation fails. For example:
class InviteUser < Cuscuz::Operation
prop :role, String
Success = Result.success
Failure = Result.failure
def call
if @role == "admin"
Success[]
else
Failure[]
end
end
end
InviteUser.call(role: "admin") # InviteUser::Success
InviteUser.call(role: "member") # InviteUser::Failure
InviteUser.call!(role: "admin") # InviteUser::Success
InviteUser.call!(role: "member") # raises Cuscuz::FailedOperationError("`InviteUser` execution failed")
Handle operation result
You can handle the operation result using #success? / #failure? or pattern
matching. For example:
# success?/failure?
result = CreateUser.call(attributes: user_attributes)
if result.success?
puts result.user
else
puts result.reason
end
# Pattern matching (deconstructing keys)
case CreateUser.call(attributes: user_attributes)
in success: true, user:
puts user
in failure: true, reason:
puts reason
end
# Pattern matching (alternative)
case CreateUser.call(attributes: user_attributes)
in CreateUser::Succcess(user:)
puts user
in CreateUser::Failure(reason:)
puts reason
end
Development
After checking out the repo, run bin/setup to install dependencies. Then, run rake test to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and the created tag, and push the .gem file to rubygems.org.
Contributing
Bug reports and pull requests are welcome on Codeberg at https://codeberg.com/gunbolt/cuscuz. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.
Contributions must not include content generated by large language models or other probabilistic tools, including but not limited to Copilot, Claude or ChatGPT. This policy covers code, documentation, pull requests, issues, comments, and any other contributions to the Gunbolt projects.
License
The gem is available as open source under the terms of the MIT License.
Code of Conduct
Everyone interacting in the Cuscuz project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.