ContextualConfig

A Ruby gem for context-aware configuration management. ContextualConfig provides a flexible framework for managing configurations that can be applied based on contextual rules, priorities, and scoping. Perfect for complex applications requiring dynamic configuration resolution.

Features

  • Context-Aware Matching: Define rules to match configurations based on runtime context
  • Priority-Based Resolution: Handle conflicts through configurable priority systems
  • Schema Validation: Optional JSON schema validation for configuration data
  • Rails Integration: Built-in Rails generators and ActiveRecord concerns
  • Flexible Scoping: Support for complex scoping rules including timing constraints
  • STI Support: Single Table Inheritance support for different configuration types

Installation

Add this line to your application's Gemfile:

gem 'contextual_config'

And then execute:

$ bundle install

Or install it yourself as:

$ gem install contextual_config

Quick Start

1. Generate a Configuration Table

rails generate contextual_config:configurable_table YourConfig
rails db:migrate

2. Create Your Model

class YourConfig < ApplicationRecord
  include ContextualConfig::Concern::Configurable
  include ContextualConfig::Concern::Lookupable
  include ContextualConfig::Concern::SchemaDrivenValidation # Optional
end

3. Create Configuration Records

# Global configuration
YourConfig.create!(
  key: "notification_settings",
  config_data: { email_enabled: true, sms_enabled: false },
  scoping_rules: {}, # Empty rules = applies to everyone
  priority: 100
)

# Department-specific override
YourConfig.create!(
  key: "notification_settings", 
  config_data: { email_enabled: true, sms_enabled: true },
  scoping_rules: { department_id: "engineering" },
  priority: 50 # Higher priority (lower number)
)

# Time-based configuration
YourConfig.create!(
  key: "notification_settings",
  config_data: { email_enabled: false, sms_enabled: false },
  scoping_rules: { 
    timing: { 
      start_date: "2024-12-25", 
      end_date: "2024-12-26" 
    } 
  },
  priority: 10 # Highest priority
)

4. Lookup Configurations

# Find best matching configuration
context = { 
  department_id: "engineering", 
  current_date: Date.today 
}

config = YourConfig.find_applicable_config(
  key: "notification_settings", 
  context: context
)

# Use the configuration
if config
  settings = config.config_data
  puts "Email enabled: #{settings['email_enabled']}"
  puts "SMS enabled: #{settings['sms_enabled']}"
end

Core Concepts

Configuration Structure

Each configuration record contains:

  • key: Identifier for the configuration type
  • config_data: The actual configuration payload (JSONB)
  • scoping_rules: Rules defining when this config applies (JSONB)
  • priority: Lower numbers = higher priority (integer)
  • deleted_at: Soft delete timestamp (datetime, null for active)
  • type: For Single Table Inheritance (string, optional)

Context Matching

Configurations are matched based on context:

context = {
  department_id: "sales",
  employee_level: "senior", 
  current_date: Date.today,
  location_country: "US"
}

# This will match configs where scoping_rules contain matching values
config = YourConfig.find_applicable_config(
  key: "expense_policy", 
  context: context
)

Scoping Rules

Scoping rules define when configurations apply:

# Simple department scoping
scoping_rules: { department_id: "engineering" }

# Multiple criteria
scoping_rules: { 
  department_id: "sales", 
  employee_level: "senior" 
}

# Time-based rules
scoping_rules: { 
  timing: { 
    start_date: "2024-01-01", 
    end_date: "2024-12-31" 
  } 
}

# Global rule (matches everything)
scoping_rules: {}

Priority Resolution

When multiple configurations match:

  1. Specificity: More specific rules (more matching criteria) win
  2. Priority: Lower priority numbers win when specificity is equal
  3. Order: First match wins when priority and specificity are equal

Advanced Usage

Schema Validation

Define JSON schemas for your configuration data:

class PayrollConfig < ApplicationRecord
  include ContextualConfig::Concern::Configurable
  include ContextualConfig::Concern::Lookupable
  include ContextualConfig::Concern::SchemaDrivenValidation

  def self.resolve_config_data_schema(_instance = nil)
    @_config_data_schema ||= {
      "type" => "object",
      "properties" => {
        "base_salary" => { "type" => "number", "minimum" => 0 },
        "currency" => { "type" => "string", "enum" => ["USD", "EUR", "SAR"] },
        "overtime_rate" => { "type" => "number", "minimum" => 1.0 }
      },
      "required" => ["base_salary", "currency"]
    }
  end
end

Custom Matchers

Extend the ContextualMatcher for custom logic:

# In your application
module MyApp
  class CustomMatcher < ContextualConfig::Services::ContextualMatcher
    private_class_method

    def self.evaluate_location_rule(rule_value, context_value)
      # Custom location matching logic
      allowed_locations = rule_value.is_a?(Array) ? rule_value : [rule_value]
      allowed_locations.include?(context_value)
    end
  end
end

Module-Specific Extensions

ContextualConfig is designed to be highly extensible for modular applications. Modules can add their own fields and customize the gem's behavior in several ways:

1. Additional Database Columns

Add module-specific columns to your configuration table:

# In migration
def change
  create_table :configurations do |t|
    # Standard ContextualConfig columns (from generator)
    t.string :key, null: false
    t.jsonb :config_data, null: false, default: {}
    t.jsonb :scoping_rules, null: false, default: {}
    t.integer :priority, null: false, default: 100
    t.datetime :deleted_at
    t.text :description
    t.string :type

    # Custom module-specific columns
    t.string :module_name
    t.string :created_by_user_id
    t.timestamp :effective_from
    t.timestamp :expires_at
    t.text :approval_notes
    t.jsonb :audit_log, default: {}

    t.timestamps null: false
  end
end

2. Module-Specific Configuration Models

Create specialized models for different modules using STI:

# Base configuration
class BaseConfiguration < ApplicationRecord
  include ContextualConfig::Concern::Configurable
  include ContextualConfig::Concern::Lookupable
  include ContextualConfig::Concern::SchemaDrivenValidation
end

# HR Module configurations
class HR::PolicyConfiguration < BaseConfiguration
  def self.resolve_config_data_schema(_instance = nil)
    {
      'type' => 'object',
      'properties' => {
        'leave_approval_workflow' => { 'type' => 'string' },
        'probation_period_days' => { 'type' => 'integer', 'minimum' => 30, 'maximum' => 365 },
        'performance_review_frequency' => { 'type' => 'string', 'enum' => ['quarterly', 'biannual', 'annual'] }
      }
    }
  end

  def requires_manager_approval?
    config_data['leave_approval_workflow'] == 'manager_required'
  end
end

# Finance Module configurations
class Finance::PayrollConfiguration < BaseConfiguration
  def self.resolve_config_data_schema(_instance = nil)
    {
      'type' => 'object',
      'properties' => {
        'overtime_rate' => { 'type' => 'number', 'minimum' => 1.0, 'maximum' => 3.0 },
        'tax_calculation_method' => { 'type' => 'string', 'enum' => ['standard', 'accelerated', 'deferred'] },
        'accounting_code' => { 'type' => 'string' },
        'cost_center_allocation' => {
          'type' => 'object',
          'properties' => {
            'department_percentage' => { 'type' => 'number' },
            'project_percentage' => { 'type' => 'number' }
          }
        }
      }
    }
  end

  def calculate_overtime_pay(base_salary, hours)
    base_rate = base_salary / 160 # assuming 160 hours per month
    overtime_multiplier = config_data['overtime_rate'] || 1.5
    base_rate * overtime_multiplier * hours
  end
end

# Benefits Module configurations
class Benefits::InsuranceConfiguration < BaseConfiguration
  def self.resolve_scoping_rules_schema(_instance = nil)
    {
      'type' => 'object',
      'properties' => {
        # Standard scoping
        'department_id' => { 'type' => 'string' },
        'employee_level' => { 'type' => 'string' },

        # Benefits-specific scoping
        'family_size' => { 'type' => 'integer', 'minimum' => 1, 'maximum' => 10 },
        'age_group' => { 'type' => 'string', 'enum' => ['under_30', '30_to_50', 'over_50'] },
        'employment_tenure_months' => { 'type' => 'integer', 'minimum' => 0 },
        'previous_claims_count' => { 'type' => 'integer', 'minimum' => 0 }
      }
    }
  end

  def self.find_for_employee_insurance(employee, family_size: 1)
    context = {
      department_id: employee.department_id.to_s,
      employee_level: employee.level,
      family_size: family_size,
      age_group: employee.age_group,
      employment_tenure_months: employee.tenure_in_months
    }

    find_applicable_config(key: 'insurance_policy', context: context)
  end
end

3. Extended JSONB Schema

Modules can extend the config_data schema to include their specific fields:

class MultiModuleConfiguration < ApplicationRecord
  include ContextualConfig::Concern::Configurable
  include ContextualConfig::Concern::Lookupable
  include ContextualConfig::Concern::SchemaDrivenValidation

  def self.resolve_config_data_schema(_instance = nil)
    {
      'type' => 'object',
      'properties' => {
        # Core fields
        'description' => { 'type' => 'string' },

        # HR Module fields
        'hr_approval_required' => { 'type' => 'boolean' },
        'hr_notification_emails' => { 
          'type' => 'array', 
          'items' => { 'type' => 'string', 'format' => 'email' }
        },

        # Finance Module fields
        'accounting_code' => { 'type' => 'string' },
        'budget_impact' => { 'type' => 'number' },

        # Benefits Module fields
        'benefits_integration' => {
          'type' => 'object',
          'properties' => {
            'medical_coverage_level' => { 'type' => 'string', 'enum' => ['basic', 'premium', 'executive'] },
            'vacation_carryover_allowed' => { 'type' => 'boolean' }
          }
        },

        # Attendance Module fields
        'attendance_tracking' => {
          'type' => 'object',
          'properties' => {
            'flexible_hours_enabled' => { 'type' => 'boolean' },
            'remote_work_percentage' => { 'type' => 'number', 'minimum' => 0, 'maximum' => 100 }
          }
        }
      },
      'additionalProperties' => false
    }
  end
end

4. Module Registration Pattern

Create a registry system for modules:

# config/initializers/contextual_config_modules.rb
module ContextualConfig
  class ModuleRegistry
    @modules = {}

    def self.register(module_name, &block)
      config = ModuleConfig.new
      block.call(config) if block_given?
      @modules[module_name] = config
    end

    def self.get(module_name)
      @modules[module_name]
    end

    def self.all
      @modules
    end
  end

  class ModuleConfig
    attr_accessor :model_class, :default_priority, :schema_file, :key_prefix
  end
end

# Register modules
ContextualConfig::ModuleRegistry.register(:hr) do |config|
  config.model_class = HR::PolicyConfiguration
  config.default_priority = 50
  config.key_prefix = 'hr'
  config.schema_file = Rails.root.join('config/schemas/hr_policies.json')
end

ContextualConfig::ModuleRegistry.register(:finance) do |config|
  config.model_class = Finance::PayrollConfiguration  
  config.default_priority = 25
  config.key_prefix = 'finance'
  config.schema_file = Rails.root.join('config/schemas/finance_payroll.json')
end

5. Best Practices for Module Extensions

  1. Use meaningful key prefixes: hr.leave_policy, finance.overtime_rules, benefits.insurance_tiers

  2. Leverage JSON schema validation: Each module should define comprehensive schemas

  3. Create module-specific helper methods: Add convenience methods for common operations

  4. Use consistent scoping patterns: Define standard context fields that work across modules

  5. Plan for schema evolution: Design schemas that can evolve as modules add new features

STI (Single Table Inheritance)

Use STI for different configuration types:

class BaseConfig < ApplicationRecord
  include ContextualConfig::Concern::Configurable
  include ContextualConfig::Concern::Lookupable
end

class PayrollConfig < BaseConfig
  validates :key, uniqueness: { scope: :type }
end

class NotificationConfig < BaseConfig
  validates :key, uniqueness: { scope: :type }
end

Real-World Examples

Example 1: Payroll Configuration System

# Global overtime policy (lowest priority)
PayrollConfig.create!(
  key: 'overtime_policy',
  config_data: {
    overtime_rate: 1.5,
    overtime_threshold_hours: 8,
    weekend_rate_multiplier: 2.0,
    max_overtime_hours_per_month: 40
  },
  scoping_rules: {},
  priority: 100,
  description: 'Standard company overtime policy'
)

# Engineering department gets better rates
PayrollConfig.create!(
  key: 'overtime_policy',
  config_data: {
    overtime_rate: 1.75,
    overtime_threshold_hours: 8,
    weekend_rate_multiplier: 2.2,
    max_overtime_hours_per_month: 60
  },
  scoping_rules: { department_id: 'engineering' },
  priority: 50,
  description: 'Engineering overtime policy'
)

# Senior engineers get premium rates
PayrollConfig.create!(
  key: 'overtime_policy',
  config_data: {
    overtime_rate: 2.0,
    overtime_threshold_hours: 8,
    weekend_rate_multiplier: 2.5,
    max_overtime_hours_per_month: 80
  },
  scoping_rules: { 
    department_id: 'engineering',
    employee_level: 'senior'
  },
  priority: 25,
  description: 'Senior engineering overtime policy'
)

# Holiday season enhanced rates (highest priority)
PayrollConfig.create!(
  key: 'overtime_policy',
  config_data: {
    overtime_rate: 2.5,
    overtime_threshold_hours: 6,
    weekend_rate_multiplier: 3.0,
    max_overtime_hours_per_month: 100
  },
  scoping_rules: {
    timing: {
      start_date: '2024-12-01',
      end_date: '2024-12-31'
    }
  },
  priority: 10,
  description: 'Holiday season enhanced overtime'
)

# Usage in payroll calculation
def calculate_employee_overtime(employee, hours_worked)
  context = {
    department_id: employee.department_id.to_s,
    employee_level: employee.level,
    current_date: Date.current
  }

  policy = PayrollConfig.find_applicable_config(
    key: 'overtime_policy',
    context: context
  )

  return 0 unless policy

  base_rate = employee.hourly_rate
  overtime_rate = policy.config_data['overtime_rate']

  base_rate * overtime_rate * hours_worked
end

Example 2: Dynamic Working Hours Policy

# Standard working hours
WorkingHoursConfig.create!(
  key: 'working_hours',
  config_data: {
    daily_hours: 8,
    weekly_hours: 40,
    break_minutes: 60,
    flexible_hours: false,
    remote_work_allowed: false
  },
  scoping_rules: {},
  priority: 100
)

# Senior staff get reduced hours
WorkingHoursConfig.create!(
  key: 'working_hours',
  config_data: {
    daily_hours: 7,
    weekly_hours: 35,
    flexible_hours: true,
    remote_work_allowed: true
  },
  scoping_rules: { employee_level: 'senior' },
  priority: 50
)

# Individual employee exception
WorkingHoursConfig.create!(
  key: 'working_hours',
  config_data: {
    daily_hours: 6,
    weekly_hours: 30,
    flexible_hours: true,
    remote_work_allowed: true
  },
  scoping_rules: { employee_id: 'EMP001' },
  priority: 10
)

Example 3: Benefits Configuration by Demographics

class Benefits::InsuranceConfig < ApplicationRecord
  include ContextualConfig::Concern::Configurable
  include ContextualConfig::Concern::Lookupable
  include ContextualConfig::Concern::SchemaDrivenValidation

  def self.resolve_config_data_schema(_instance = nil)
    {
      'type' => 'object',
      'properties' => {
        'medical_premium' => { 'type' => 'number', 'minimum' => 0 },
        'dental_included' => { 'type' => 'boolean' },
        'vision_included' => { 'type' => 'boolean' },
        'family_coverage_multiplier' => { 'type' => 'number', 'minimum' => 1.0 },
        'max_annual_coverage' => { 'type' => 'number', 'minimum' => 0 }
      }
    }
  end

  def self.resolve_scoping_rules_schema(_instance = nil)
    {
      'type' => 'object',
      'properties' => {
        'age_group' => { 'type' => 'string', 'enum' => ['under_30', '30_to_50', 'over_50'] },
        'family_size' => { 'type' => 'integer', 'minimum' => 1, 'maximum' => 10 },
        'employment_tenure_months' => { 'type' => 'integer', 'minimum' => 0 },
        'employee_level' => { 'type' => 'string' }
      }
    }
  end
end

# Young employees get basic coverage
Benefits::InsuranceConfig.create!(
  key: 'health_insurance',
  config_data: {
    medical_premium: 200,
    dental_included: false,
    vision_included: false,
    family_coverage_multiplier: 2.0,
    max_annual_coverage: 50000
  },
  scoping_rules: { age_group: 'under_30' },
  priority: 50
)

# Families get enhanced coverage
Benefits::InsuranceConfig.create!(
  key: 'health_insurance',
  config_data: {
    medical_premium: 150, # Discounted rate for families
    dental_included: true,
    vision_included: true,
    family_coverage_multiplier: 1.5,
    max_annual_coverage: 100000
  },
  scoping_rules: { family_size: 3 }, # 3 or more family members
  priority: 30
)

Performance Considerations

Caching System

ContextualConfig includes a sophisticated two-level caching system for optimal performance:

1. Candidates Cache

Caches the database query results (list of potential configurations) to avoid repeated database hits for the same key/context combinations.

2. Results Cache

Caches the final matched configuration after processing all matching logic, providing the fastest possible lookups for repeated requests.

Cache Configuration

# Enable caching globally
ContextualConfig.configure do |config|
  config.cache_enabled = true
  config.cache_ttl = 300 # 5 minutes
  config.cache_store = Rails.cache
end

# Fine-grained cache control in your models
class CustomConfig < ApplicationRecord
  include ContextualConfig::Concern::Configurable
  include ContextualConfig::Concern::Lookupable

  protected

  # Override to disable candidate caching for specific scenarios
  def self.cache_candidates?(key:, context:)
    # Only cache for stable contexts, not time-sensitive ones
    !context.key?(:current_timestamp)
  end

  # Override to disable result caching for specific scenarios  
  def self.cache_results?(key:, context:)
    # Cache results except for admin users who need real-time data
    context[:user_role] != 'admin'
  end

  # Override to customize candidate selection
  def self.lookup_candidates(key:, context:)
    # Custom logic for fetching candidates
    active.where(key: key.to_s, department: context[:department]).order_by_priority
  end
end

Benchmarks

Based on testing with the JisrHR application:

  • Average lookup time: ~3ms per configuration lookup (uncached)
  • Cached lookup time: ~0.1ms per configuration lookup
  • 100 concurrent lookups: Completed in ~0.3 seconds (uncached), ~0.03 seconds (cached)
  • Database indexes: Automatically created by the generator for optimal performance
  • Memory usage: Minimal - configurations are loaded on-demand with intelligent caching

Optimization Tips

  1. Enable caching: Use the built-in two-level caching system for production deployments
  2. Use appropriate indexes: The generator creates optimal indexes for common queries
  3. Customize cache behavior: Override cache control methods for fine-grained performance tuning
  4. Limit scoping rule complexity: Simpler rules = faster matching
  5. Use database-level constraints: Leverage PostgreSQL JSONB indexes for complex queries
  6. Monitor cache hit rates: Use logging to monitor cache effectiveness

Production Deployment

# config/initializers/contextual_config.rb
if Rails.env.production?
  # Configure the gem for production use
  ContextualConfig.configure do |config|
    config.cache_enabled = true
    config.cache_ttl = 300 # 5 minutes in seconds
    config.cache_store = Rails.cache
    config.enable_logging = true
    config.logger = Rails.logger
  end
end

Global Configuration

ContextualConfig provides global configuration options:

# Configure the gem
ContextualConfig.configure do |config|
  config.cache_enabled = true           # Enable caching
  config.cache_ttl = 300                # Cache TTL in seconds
  config.cache_store = Rails.cache      # Cache store to use
  config.default_priority = 100         # Default priority for configs
  config.enable_logging = true          # Enable gem logging
  config.logger = Rails.logger          # Logger to use
  config.timing_evaluation_enabled = true # Enable timing rule evaluation
end

# Access current configuration
config = ContextualConfig.configuration
puts "Cache enabled: #{config.cache_enabled?}"

# Reset configuration (useful for testing)
ContextualConfig.reset_configuration!

API Reference

Concerns

ContextualConfig::Concern::Configurable

Provides basic configuration functionality:

  • Validations for key, priority, is_active
  • Scopes: active, order_by_priority
  • Database expectations for required columns

ContextualConfig::Concern::Lookupable

Provides configuration lookup methods:

  • find_applicable_config(key:, context:) - Find best match
  • find_all_applicable_configs(context:) - Find all matches

Protected Methods for Customization:

  • lookup_candidates(key:, context:) - Override to customize candidate selection for specific keys
  • lookup_all_candidates(context:) - Override to customize candidate selection for all configurations
  • cache_candidates?(key:, context:) - Override to control candidate caching behavior
  • cache_results?(key:, context:) - Override to control result caching behavior

ContextualConfig::Concern::SchemaDrivenValidation

Provides JSON schema validation:

  • Validates config_data and scoping_rules against schemas
  • Override resolve_config_data_schema and resolve_scoping_rules_schema

Services

ContextualConfig::Services::ContextualMatcher

Core matching logic:

  • find_best_match(candidates:, context:) - Find single best match
  • find_all_matches(candidates:, context:) - Find all matches

Generators

contextual_config:configurable_table

Generate migration for configuration table:

rails generate contextual_config:configurable_table ModelName
rails generate contextual_config:configurable_table Finance::Config --table-name=finance_configs

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/bazinga012/contextual_config.

License

The gem is available as open source under the terms of the MIT License.

Development

After checking out the repo, run bin/setup to install dependencies. Then, run rake spec 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.