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.