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:
- Specificity: More specific rules (more matching criteria) win
- Priority: Lower priority numbers win when specificity is equal
- 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
-
Use meaningful key prefixes:
hr.leave_policy
,finance.overtime_rules
,benefits.insurance_tiers
-
Leverage JSON schema validation: Each module should define comprehensive schemas
-
Create module-specific helper methods: Add convenience methods for common operations
-
Use consistent scoping patterns: Define standard context fields that work across modules
-
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
- Enable caching: Use the built-in two-level caching system for production deployments
- Use appropriate indexes: The generator creates optimal indexes for common queries
- Customize cache behavior: Override cache control methods for fine-grained performance tuning
- Limit scoping rule complexity: Simpler rules = faster matching
- Use database-level constraints: Leverage PostgreSQL JSONB indexes for complex queries
- 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
andscoping_rules
against schemas - Override
resolve_config_data_schema
andresolve_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.