Guards

Guards are reusable validation rules that halt service execution when conditions aren't met. They provide a declarative way to enforce preconditions with rich, API-friendly error responses.

Why Guards?

Instead of scattering validation logic throughout services:

# Without guards - repetitive and verbose
def call
  return failure("User required", type: ValidationError) unless user
  return failure("User must be active", type: ValidationError) unless user.active?
  # ... business logic ...
end

Use guards for clean, declarative validation:

# With guards - clear and reusable
def call
  enforce_presence!(user: user)
  enforce_truthy!(on: user, check: :active)
  # ... business logic ...
end

Built-in Guards

Servus includes four guards by default:

PresenceGuard

Validates that all values are present (not nil or empty):

# Single value
enforce_presence!(user: user)

# Multiple values - all must be present
enforce_presence!(user: user, account: , device: device)

# Works with strings, arrays, hashes
enforce_presence!(email: email)           # fails if nil or ""
enforce_presence!(items: cart.items)      # fails if nil or []
enforce_presence!(data: response.body)    # fails if nil or {}

Error: "user must be present (got nil)" or "email must be present (got \"\")"

TruthyGuard

Validates that attribute(s) on an object are truthy:

# Single attribute
enforce_truthy!(on: user, check: :active)

# Multiple attributes - all must be truthy
enforce_truthy!(on: user, check: [:active, :verified, :confirmed])

# Conditional check
if check_truthy?(on: subscription, check: :valid?)
  process_subscription
end

Error: "User.active must be truthy (got false)"

FalseyGuard

Validates that attribute(s) on an object are falsey:

# Single attribute - user must not be banned
enforce_falsey!(on: user, check: :banned)

# Multiple attributes - all must be falsey
enforce_falsey!(on: post, check: [:deleted, :hidden, :flagged])

# Conditional check
if check_falsey?(on: user, check: :suspended)
  allow_action
end

Error: "User.banned must be falsey (got true)"

StateGuard

Validates that an attribute matches an expected value or one of several allowed values:

# Single expected value
enforce_state!(on: order, check: :status, is: :pending)

# Multiple allowed values - any match passes
enforce_state!(on: , check: :status, is: [:active, :trial])

# Conditional check
if check_state?(on: order, check: :status, is: :shipped)
  send_tracking_email
end

Errors:

  • Single value: "Order.status must be pending (got shipped)"
  • Multiple values: "Account.status must be one of active, trial (got suspended)"

Guard Methods

Each guard defines two methods on Servus::Guards:

  • Bang method (!) - Throws on failure, halts execution
  • Predicate method (?) - Returns boolean, continues execution
# Bang method - use for preconditions that must pass
enforce_presence!(user: user)  # throws :guard_failure if nil

# Predicate method - use for conditional logic
if check_truthy?(on: , check: :premium)
  apply_premium_discount
else
  apply_standard_rate
end

Creating Custom Guards

Define guards by inheriting from Servus::Guard:

# app/guards/sufficient_balance_guard.rb
class SufficientBalanceGuard < Servus::Guard
  http_status 422
  error_code 'insufficient_balance'

  message 'Insufficient balance: need %<required>s, have %<available>s' do
    message_data
  end

  def test(account:, amount:)
    .balance >= amount
  end

  private

  def message_data
    {
      required: kwargs[:amount],
      available: kwargs[:account].balance
    }
  end
end

This automatically defines enforce_sufficient_balance! and check_sufficient_balance? methods.

Guard DSL

http_status - HTTP status code for API responses (default: 422)

http_status 422  # Unprocessable Entity
http_status 403  # Forbidden
http_status 400  # Bad Request

error_code - Machine-readable error code for API clients

error_code 'insufficient_balance'
error_code 'daily_limit_exceeded'
error_code 'account_locked'

message - Human-readable error message with optional interpolation

# Static message
message 'Amount must be positive'

# With interpolation (uses Ruby's % formatting)
message 'Balance: %<current>s, Required: %<required>s' do
  { current: .balance, required: amount }
end

The message block has access to all kwargs passed to the guard via kwargs.

test - The validation logic (must return boolean)

def test(account:, amount:)
  .balance >= amount
end

Message Templates

Guards support multiple message template formats:

String with Interpolation

message 'Insufficient balance: need %<required>s, have %<available>s' do
  { required: amount, available: .balance }
end

I18n Symbol

message :insufficient_balance
# Looks up: I18n.t('guards.insufficient_balance')
# Falls back to: "Insufficient balance" (humanized)

Inline Translations

message(
  en: 'Insufficient balance',
  es: 'Saldo insuficiente',
  fr: 'Solde insuffisant'
)

Dynamic Proc

message -> { "Limit exceeded for #{limit_type} transfers" }

Error Handling

When a bang guard fails, it throws :guard_failure with a GuardError. Services automatically catch this and return a failure response:

class TransferService < Servus::Base
  def call
    enforce_state!(on: , check: :status, is: :active)
    # If guard fails, execution stops here
    # Service returns: Response(success: false, error: GuardError)

    transfer_funds
    success(transfer: transfer)
  end
end

The GuardError includes all metadata:

error = guard.error
error.message     # "Account.status must be active (got suspended)"
error.code        # "invalid_state"
error.http_status # 422

Naming Convention

Guard class names are converted to method names by stripping the Guard suffix and converting to snake_case:

Class Name Bang Method Predicate Method
SufficientBalanceGuard enforce_sufficient_balance! check_sufficient_balance?
ValidAmountGuard enforce_valid_amount! check_valid_amount?
AuthorizedGuard enforce_authorized! check_authorized?

The built-in guards follow this pattern: TruthyGuard -> enforce_truthy! / check_truthy?.

Rails Auto-Loading

In Rails, guards in app/guards/ are automatically loaded. Files must follow the *_guard.rb naming convention:

app/guards/

Configuration

Disable built-in guards if you want to define your own (you can have both):

Servus.configure do |config|
  config.include_default_guards = false        # Default: true
  config.guards_dir             = 'app/guards' # Default: 'app/guards'
end

Testing Guards

Test guards in isolation:

RSpec.describe Servus::Guards::TruthyGuard do
  let(:user_class) do
    Struct.new(:active, :verified, keyword_init: true) do
      def self.name
        'User'
      end
    end
  end

  describe '#test' do
    it 'passes when attribute is truthy' do
      user = user_class.new(active: true)
      guard = described_class.new(on: user, check: :active)
      expect(guard.test(on: user, check: :active)).to be true
    end

    it 'fails when attribute is falsey' do
      user = user_class.new(active: false)
      guard = described_class.new(on: user, check: :active)
      expect(guard.test(on: user, check: :active)).to be false
    end
  end

  describe '#error' do
    it 'returns GuardError with correct metadata' do
      user = user_class.new(active: false)
      guard = described_class.new(on: user, check: :active)
      error = guard.error

      expect(error.code).to eq('must_be_truthy')
      expect(error.message).to include('User', 'active', 'false')
      expect(error.http_status).to eq(422)
    end
  end
end

Test guards in service integration:

RSpec.describe TransferService do
  it 'fails when account is not active' do
    result = described_class.call(
      from_account: ,
      to_account: recipient,
      amount: 100
    )

    expect(result).to be_failure
    expect(result.error.code).to eq('invalid_state')
  end
end