Project

servus

0.0
No release in over 3 years
A gem for managing service objects.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
 Dependencies
 Project Readme

Servus Gem

Servus is a gem for creating and managing service objects. It includes:

  • A base class for service objects
  • Generators for core service objects and specs
  • Support for schema validation
  • Support for error handling
  • Support for logging

Generators

Service objects can be easily created using the rails g servus:service namespace/service_name [*params] command. For sake of consistency, use this command when generating new service objects.

Generate Service

$ rails g servus:service namespace/do_something_helpful user
=>    create  app/services/namespace/do_something_helpful/service.rb
      create  spec/services/namespace/do_something_helpful/service_spec.rb
      create  app/schemas/services/namespace/do_something_helpful/result.json
      create  app/schemas/services/namespace/do_something_helpful/arguments.json

Destroy Service

$ rails d servus:service namespace/do_something_helpful
=>    remove  app/services/namespace/do_something_helpful/service.rb
      remove  spec/services/namespace/do_something_helpful/service_spec.rb
      remove  app/schemas/services/namespace/do_something_helpful/result.json
      remove  app/schemas/services/namespace/do_something_helpful/arguments.json

Arguments

Service objects should use keyword arguments rather than positional arguments for improved clarity and more meaningful error messages.

# Good ✅
class Services::ProcessPayment::Service < Servus::Base
  def initialize(user:, amount:, payment_method:)
    @user = user
    @amount = amount
    @payment_method = payment_method
  end
end

# Bad ❌
class Services::ProcessPayment::Service < Servus::Base
  def initialize(user, amount, payment_method)
    @user = user
    @amount = amount
    @payment_method = payment_method
  end
end

Directory Structure

Each service belongs in its own namespace with this structure:

  • app/services/service_name/service.rb - Main class/entry point
  • app/services/service_name/support/ - Service-specific supporting classes

Supporting classes should never be used outside their parent service.

app/services/
├── process_payment/
│   ├── service.rb
│   └── support/
│       ├── payment_validator.rb
│       └── receipt_generator.rb
├── generate_report/
│   ├── service.rb
│   └── support/
│       ├── report_formatter.rb
│       └── data_collector.rb

Methods

Every service object must implement:

  • An initialize method that sets instance variables
  • A parameter-less call instance method that executes the service logic
class Services::GenerateReport::Service < Servus::Base
  def initialize(user:, report_type:, date_range:)
    @user = user
    @report_type = report_type
    @date_range = date_range
  end

  def call
    data = collect_data
    if data.empty?
      return failure("No data available for the selected date range")
    end

    formatted_report = format_report(data)
    success(formatted_report)
  end

  private

  def collect_data
		# Implementation details...
	end

  def format_report(data)
		# Implementation details...
	end
end

Inheritance

  • Every main service class (service.rb) must inherit from Servus::Base
  • Supporting classes should NOT inherit from Servus::Base
# Good ✅
class Services::NotifyUser::Service < Servus::Base
	# Service implementation
end

class Services::NotifyUser::Support::MessageBuilder
	# Support class implementation (does NOT inherit from BaseService)
end

# Bad ❌
class Services::NotifyUser::Support::MessageBuilder < Servus::Base
	# Incorrect: support classes should not inherit from Base class
end

Call Chain

Always use the class method call instead of manual instantiation. The call method:

  1. Initializes an instance of the service using provided keyword arguments
  2. Calls the instance-level call method
  3. Handles schema validation of inputs and outputs
  4. Handles logging of inputs and results
# Good ✅
result = Services::ProcessPayment::Service.call(
  amount: 50,
  user_id: 123,
  payment_method: "credit_card"
)

# Bad ❌ - bypasses logging and other class-level functionality
service = Services::ProcessPayment::Service.new(
  amount: 50,
  user_id: 123,
  payment_method: "credit_card"
)
result = service.call

When services call other services, always use the class-level call method:

def process_order
# Good ✅
  payment_result = Services::ProcessPayment::Service.call(
    amount: @order.total,
    payment_method: @payment_details
  )

# Bad ❌
  payment_service = Services::ProcessPayment::Service.new(
    amount: @order.total,
    payment_method: @payment_details
  )
  payment_result = payment_service.call
end

Responses

The Servus::Base provides standardized response methods:

  • success(data) - Returns success with data as a single argument
  • failure(message, **options) - Logs error and returns failure response
  • error!(message) - Logs error and raises exception
def call
	# Return failure with message
	return failure("Order is not in a pending state") unless @order.pending?

    # Do something important

	# Process and return success with single data object
    success({
        order_id: @order.id,
        status: "processed",
        timestamp: Time.now
    })
end

All responses are Servus::Support::Response objects with a success? boolean attribute and either data (for success) or error (for error) attributes.

Service Error Returns and Handling

By default, the failure(...) method creates an instance of ServiceError and adds it to the response type's error attribute. Standard and custom error types should inherit from the ServiceError class and optionally implement a custom api_error method. This enables developers to choose between using an API-specific error or generic error message in the calling context.

# Called from within a Service Object
class SomeServiceObject::Service < Servus::Base
	def call
		# Return default ServiceError with custom message
		failure("That didn't work for some reason")
		#=> Response(false, nil, ApplicationService::Support::Errors::ServiceError("That didn't work for some reason"))
		#
		# OR
		#
		# Specify ServiceError type with custom message
		failure("Custom message", type: Servus::Support::Errors::NotFoundError)
		#=> Response(false, nil, ApplicationService::Support::Errors::NotFoundError("Custom message"))
		#
		# OR
		#
		# Specify ServiceError type with default message
		failure(type: Servus::Support::Errors::NotFoundError)
		#=> Response(false, nil, ApplicationService::Support::Errors::NotFoundError("Record not found"))
		#
		# OR
		#
		# Accept all defaults
		failure
		#=> Response(false, nil, ApplicationService::Support::Errors::ServiceError("An error occurred"))
	end
end

# Error handling in parent context
class SomeController < AppController
	def controller_action
	  result = SomeServiceObject::Service.call(arg: 1)
	
	  return if result.success?
	
	  # If you just want the error message
	  bad_request(result.error.message)
	
	  # If you want the API error
	  service_object_error(result.error.api_error)
	end
end

rescue_from for service errors

Services can configure default error handling using the rescue_from method.

class SomeServiceObject::Service < Servus::Base
  class SomethingBroke < StandardError; end
  class SomethingGlitched < StandardError; end

  # Rescue from standard errors and use custom error
  rescue_from 
    SomethingBroke, 
    SomethingGlitched, 
    use: Servus::Support::Errors::ServiceUnavailableError # this is optional

  def call
    do_something
  end

  private

  def do_something
    make_and_api_call
    rescue Net::HTTPError => e
      raise SomethingGlitched, "Whoaaaa, something went wrong! #{e.message}"
    end
  end
end
result = SomeServiceObject::Service.call
# Failure response
result.error.class
=> Servus::Support::Errors::ServiceUnavailableError
result.error.message
=> "[SomeServiceObject::Service::SomethingGlitched]: Whoaaaa, something went wrong! Net::HTTPError (503)"
result.error.api_error
=> { code: :service_unavailable, message: "[SomeServiceObject::Service::SomethingGlitched]: Whoaaaa, something went wrong! Net::HTTPError (503)" }

The rescue_from method will rescue from the specified errors and use the specified error type to create a failure response object with the custom error. It helps eliminate the need to manually rescue many errors and create failure responses within the call method of a service object.

Controller Helpers

Service objects can be called from controllers using the run_service and render_service_object_error helpers.

run_service

run_service calls the service object with the provided parameters and set's an instance variable @result to the result of the service object if the result is successful. If the result is not successful, it will pass the result to error to the render_service_object_error helper. This allows for easy error handling in the controller for repetetive usecases.

class SomeController < AppController
  # Before
  def controller_action
    result = Services::SomeServiceObject::Service.call(my_params)
    return if result.success?
    render_service_object_error(result.error.api_error)
  end

  # After
  def controller_action_refactored
    run_service Services::SomeServiceObject::Service, my_params
  end
end

render_service_object_error

render_service_object_error renders the error of a service object. It expects a hash with a message key and a code key from the api_error method of the service error. This is all setup by default for a JSON API response, thought the method can be overridden if needed to handle different usecases.

# Behind the scenes, render_service_object_error calls the following:
#
#  error = result.error.api_error
#  => { message: "Error message", code: 400 }
# 
#  render json: { message: error[:message], code: error[:code] }, status: error[:code]

class SomeController < AppController
  def controller_action
    result = Services::SomeServiceObject::Service.call(my_params)
    return if result.success?

    render_service_object_error(result.error.api_error)
  end
end

Schema Validation

Service objects support two methods for schema validation: JSON Schema files and inline schema declarations.

1. File-based Schema Validation

Every service can have corresponding schema files in the centralized schema directory:

  • app/schemas/services/service_name/arguments.json - Validates input arguments
  • app/schemas/services/service_name/result.json - Validates success response data

Example arguments.json:

{
  "type": "object",
  "required": ["user_id", "amount", "payment_method"],
  "properties": {
    "user_id": { "type": "integer" },
    "amount": {
      "type": "integer",
      "minimum": 1
    },
    "payment_method": {
      "type": "string",
      "enum": ["credit_card", "paypal", "bank_transfer"]
    },
    "currency": {
      "type": "string",
      "default": "USD"
    }
  },
  "additionalProperties": false
}

Example result.json:

{
  "type": "object",
  "required": ["transaction_id", "status"],
  "properties": {
    "transaction_id": { "type": "string" },
    "status": {
      "type": "string",
      "enum": ["approved", "pending", "declined"]
    },
    "receipt_url": { "type": "string" }
  }
}

2. Inline Schema Validation

Alternatively, schemas can be declared directly within the service class using ARGUMENTS_SCHEMA and RESULT_SCHEMA constants.

class Services::ProcessPayment::Service < Servus::Base
  ARGUMENTS_SCHEMA = {
	  type: "object",
	  required: ["user_id", "amount", "payment_method"],
	  properties: {
	    user_id: { type: "integer" },
	    amount: {
	      type: "integer",
	      minimum: 1
	    },
	    payment_method: {
	      type: "string",
	      enum: ["credit_card", "paypal", "bank_transfer"]
	    },
	    currency: {
	      type: "string",
	      default: "USD"
	    }
	  },
	  additionalProperties: false
	}

  RESULT_SCHEMA = {
	  type: "object",
	  required: ["transaction_id", "status"],
	  properties: {
	    transaction_id: { type: "string" },
	    status: {
	      type: "string",
	      enum: ["approved", "pending", "declined"]
	    },
	    receipt_url: { type: "string" }
	  }
	}
end

These schemas use JSON Schema format to enforce type safety and input/output contracts. For detailed information on authoring JSON Schema files, refer to the official specification at: https://json-schema.org/specification.html

Schema Resolution

The validation system follows this precedence:

  1. Checks for inline schema constants (ARGUMENTS_SCHEMA or RESULT_SCHEMA)
  2. Falls back to JSON files if no inline schema is found
  3. Returns nil if neither exists

Schema Caching

Both file-based and inline schemas are automatically cached:

  • First validation request loads and caches the schema
  • Subsequent validations use the cached version
  • Cache can be cleared using SchemaValidation.clear_cache!