I’ve spent years building Rails applications where configuration management became increasingly complex as systems grew. The challenge of maintaining consistent, flexible configurations across multiple environments and tenants led me to develop a comprehensive set of patterns that address these common pain points.
Pattern 1: Runtime Configuration Updates with Cache Invalidation
Runtime configuration changes require careful handling to ensure consistency across all application instances. I designed this pattern to handle updates seamlessly without requiring application restarts.
class RuntimeConfigurationManager
include ActiveSupport::Callbacks
define_callbacks :configuration_change
def initialize
@config_store = {}
@subscribers = []
@mutex = Mutex.new
end
def update_configuration(key, value)
@mutex.synchronize do
old_value = @config_store[key]
run_callbacks :configuration_change do
@config_store[key] = value
Rails.cache.delete("runtime_config:#{key}")
broadcast_change(key, old_value, value)
end
end
end
def get_configuration(key, default = nil)
Rails.cache.fetch("runtime_config:#{key}", expires_in: 2.minutes) do
@config_store.fetch(key, default)
end
end
def subscribe_to_changes(&block)
@subscribers << block
end
private
def broadcast_change(key, old_value, new_value)
change_event = {
key: key,
old_value: old_value,
new_value: new_value,
changed_at: Time.current
}
@subscribers.each { |subscriber| subscriber.call(change_event) }
ActionCable.server.broadcast(
'configuration_changes',
change_event
)
end
end
Pattern 2: Hierarchical Configuration with Inheritance
Managing configurations across different scopes requires a hierarchical approach. This pattern implements configuration inheritance from global to environment-specific to tenant-specific levels.
class HierarchicalConfigurationManager
HIERARCHY_LEVELS = %w[global environment tenant user].freeze
def initialize
@configurations = {}
HIERARCHY_LEVELS.each { |level| @configurations[level] = {} }
end
def set_configuration(level, key, value, scope_id = nil)
validate_level!(level)
scope_key = scope_id ? "#{level}:#{scope_id}" : level
@configurations[scope_key] ||= {}
@configurations[scope_key][key] = value
invalidate_resolved_cache(key)
end
def get_configuration(key, context = {})
cache_key = build_context_cache_key(key, context)
Rails.cache.fetch(cache_key, expires_in: 10.minutes) do
resolve_configuration_value(key, context)
end
end
def get_configuration_source(key, context = {})
HIERARCHY_LEVELS.reverse.each do |level|
scope_key = build_scope_key(level, context)
config_set = @configurations[scope_key]
if config_set && config_set.key?(key)
return {
level: level,
scope: context[level.to_sym],
value: config_set[key]
}
end
end
nil
end
private
def resolve_configuration_value(key, context)
HIERARCHY_LEVELS.reverse.each do |level|
scope_key = build_scope_key(level, context)
config_set = @configurations[scope_key]
return config_set[key] if config_set && config_set.key?(key)
end
nil
end
def build_scope_key(level, context)
scope_id = context[level.to_sym]
scope_id ? "#{level}:#{scope_id}" : level
end
def build_context_cache_key(key, context)
context_parts = HIERARCHY_LEVELS.map do |level|
"#{level}:#{context[level.to_sym] || 'default'}"
end
"hierarchical_config:#{key}:#{context_parts.join(':')}"
end
def validate_level!(level)
unless HIERARCHY_LEVELS.include?(level)
raise ArgumentError, "Invalid configuration level: #{level}"
end
end
def invalidate_resolved_cache(key)
Rails.cache.delete_matched("hierarchical_config:#{key}:*")
end
end
Pattern 3: Configuration Validation with Schema Definition
Ensuring configuration integrity requires robust validation. This pattern implements schema-based validation with detailed error reporting.
class ConfigurationValidator
def initialize
@schemas = {}
@custom_validators = {}
end
def define_schema(key, schema)
@schemas[key] = schema
end
def add_custom_validator(name, &block)
@custom_validators[name] = block
end
def validate(key, value)
schema = @schemas[key]
return ValidationResult.new(true) unless schema
errors = []
validate_against_schema(value, schema, [], errors)
ValidationResult.new(errors.empty?, errors)
end
def validate!(key, value)
result = validate(key, value)
unless result.valid?
raise ConfigurationValidationError, result.errors.join(', ')
end
end
private
def validate_against_schema(value, schema, path, errors)
case schema[:type]
when 'string'
validate_string(value, schema, path, errors)
when 'integer'
validate_integer(value, schema, path, errors)
when 'boolean'
validate_boolean(value, schema, path, errors)
when 'array'
validate_array(value, schema, path, errors)
when 'object'
validate_object(value, schema, path, errors)
when 'custom'
validate_custom(value, schema, path, errors)
end
end
def validate_string(value, schema, path, errors)
unless value.is_a?(String)
errors << "#{path.join('.')} must be a string"
return
end
if schema[:min_length] && value.length < schema[:min_length]
errors << "#{path.join('.')} must be at least #{schema[:min_length]} characters"
end
if schema[:max_length] && value.length > schema[:max_length]
errors << "#{path.join('.')} must be no more than #{schema[:max_length]} characters"
end
if schema[:pattern] && !value.match?(Regexp.new(schema[:pattern]))
errors << "#{path.join('.')} does not match required pattern"
end
end
def validate_integer(value, schema, path, errors)
unless value.is_a?(Integer)
errors << "#{path.join('.')} must be an integer"
return
end
if schema[:minimum] && value < schema[:minimum]
errors << "#{path.join('.')} must be at least #{schema[:minimum]}"
end
if schema[:maximum] && value > schema[:maximum]
errors << "#{path.join('.')} must be no more than #{schema[:maximum]}"
end
end
def validate_array(value, schema, path, errors)
unless value.is_a?(Array)
errors << "#{path.join('.')} must be an array"
return
end
if schema[:items]
value.each_with_index do |item, index|
validate_against_schema(item, schema[:items], path + [index], errors)
end
end
end
def validate_object(value, schema, path, errors)
unless value.is_a?(Hash)
errors << "#{path.join('.')} must be an object"
return
end
schema[:properties]&.each do |property, property_schema|
if value.key?(property)
validate_against_schema(value[property], property_schema, path + [property], errors)
elsif property_schema[:required]
errors << "#{path.join('.')}.#{property} is required"
end
end
end
def validate_custom(value, schema, path, errors)
validator_name = schema[:validator]
validator = @custom_validators[validator_name]
unless validator
errors << "Unknown custom validator: #{validator_name}"
return
end
result = validator.call(value)
unless result
errors << "#{path.join('.')} failed custom validation: #{validator_name}"
end
end
end
class ValidationResult
attr_reader :errors
def initialize(valid, errors = [])
@valid = valid
@errors = errors
end
def valid?
@valid
end
def invalid?
!@valid
end
end
Pattern 4: Rollback Mechanism with Version Control
Configuration changes need rollback capabilities for rapid recovery from problematic updates. This pattern implements versioned configurations with rollback support.
class VersionedConfigurationManager
def initialize
@current_version = 1
@versions = { 1 => {} }
@version_metadata = { 1 => { created_at: Time.current, created_by: 'system' } }
end
def create_snapshot(created_by, description = nil)
new_version = @current_version + 1
@versions[new_version] = deep_copy(@versions[@current_version])
@version_metadata[new_version] = {
created_at: Time.current,
created_by: created_by,
description: description,
parent_version: @current_version
}
@current_version = new_version
cleanup_old_versions
new_version
end
def set_configuration(key, value, user_id, auto_snapshot: true)
create_snapshot(user_id, "Updated #{key}") if auto_snapshot
@versions[@current_version][key] = {
value: value,
updated_at: Time.current,
updated_by: user_id
}
invalidate_cache(key)
end
def get_configuration(key, version = nil)
target_version = version || @current_version
config_data = @versions[target_version]&.dig(key)
config_data ? config_data[:value] : nil
end
def rollback_to_version(target_version, user_id)
unless @versions.key?(target_version)
raise ArgumentError, "Version #{target_version} does not exist"
end
rollback_version = create_snapshot(user_id, "Rollback to version #{target_version}")
@versions[rollback_version] = deep_copy(@versions[target_version])
# Clear all cached configurations
Rails.cache.delete_matched('versioned_config:*')
rollback_version
end
def get_version_history(limit = 10)
@version_metadata
.sort_by { |version, _| -version }
.first(limit)
.map do |version, metadata|
{
version: version,
created_at: metadata[:created_at],
created_by: metadata[:created_by],
description: metadata[:description],
configuration_count: @versions[version].size
}
end
end
def diff_versions(version_a, version_b)
config_a = @versions[version_a] || {}
config_b = @versions[version_b] || {}
all_keys = (config_a.keys + config_b.keys).uniq
differences = {}
all_keys.each do |key|
value_a = config_a[key]&.dig(:value)
value_b = config_b[key]&.dig(:value)
if value_a != value_b
differences[key] = {
version_a_value: value_a,
version_b_value: value_b,
change_type: determine_change_type(value_a, value_b)
}
end
end
differences
end
def export_configuration(version = nil)
target_version = version || @current_version
config_data = @versions[target_version]
{
version: target_version,
exported_at: Time.current,
metadata: @version_metadata[target_version],
configurations: config_data.transform_values { |data| data[:value] }
}
end
def import_configuration(exported_data, user_id)
new_version = create_snapshot(user_id, "Imported configuration")
exported_data[:configurations].each do |key, value|
@versions[new_version][key] = {
value: value,
updated_at: Time.current,
updated_by: user_id
}
end
Rails.cache.delete_matched('versioned_config:*')
new_version
end
private
def deep_copy(object)
Marshal.load(Marshal.dump(object))
end
def cleanup_old_versions
# Keep last 50 versions
if @versions.size > 50
versions_to_remove = @versions.keys.sort.first(@versions.size - 50)
versions_to_remove.each do |version|
@versions.delete(version)
@version_metadata.delete(version)
end
end
end
def determine_change_type(old_value, new_value)
return 'added' if old_value.nil? && !new_value.nil?
return 'removed' if !old_value.nil? && new_value.nil?
return 'modified' if old_value != new_value
'unchanged'
end
def invalidate_cache(key)
Rails.cache.delete("versioned_config:#{@current_version}:#{key}")
end
end
Pattern 5: Feature Flag Integration
Feature flags require special configuration handling for gradual rollouts and A/B testing. This pattern provides feature flag management with percentage-based rollouts.
class FeatureFlagManager
def initialize
@flags = {}
@rollout_strategies = {}
@user_overrides = {}
end
def define_flag(name, options = {})
@flags[name] = {
enabled: options[:enabled] || false,
description: options[:description],
created_at: Time.current,
rollout_percentage: options[:rollout_percentage] || 0,
target_groups: options[:target_groups] || [],
prerequisites: options[:prerequisites] || []
}
end
def flag_enabled?(name, context = {})
flag = @flags[name]
return false unless flag
# Check user-specific overrides first
user_id = context[:user_id]
if user_id && @user_overrides.dig(name, user_id)
return @user_overrides[name][user_id]
end
# Check prerequisites
return false unless prerequisites_met?(flag[:prerequisites], context)
# Check if flag is globally enabled
return true if flag[:enabled]
# Check percentage-based rollout
if flag[:rollout_percentage] > 0
return percentage_rollout_enabled?(name, flag[:rollout_percentage], context)
end
# Check target groups
if flag[:target_groups].any? && context[:user_groups]
return (flag[:target_groups] & context[:user_groups]).any?
end
false
end
def set_flag_state(name, enabled, options = {})
flag = @flags[name]
raise ArgumentError, "Flag #{name} not found" unless flag
flag[:enabled] = enabled
flag[:rollout_percentage] = options[:rollout_percentage] if options.key?(:rollout_percentage)
flag[:target_groups] = options[:target_groups] if options.key?(:target_groups)
# Clear cache for this flag
Rails.cache.delete_matched("feature_flag:#{name}:*")
broadcast_flag_change(name, flag)
end
def set_user_override(flag_name, user_id, enabled)
@user_overrides[flag_name] ||= {}
@user_overrides[flag_name][user_id] = enabled
Rails.cache.delete("feature_flag:#{flag_name}:user:#{user_id}")
end
def remove_user_override(flag_name, user_id)
@user_overrides[flag_name]&.delete(user_id)
Rails.cache.delete("feature_flag:#{flag_name}:user:#{user_id}")
end
def get_flag_status(name, context = {})
flag = @flags[name]
return nil unless flag
{
name: name,
enabled: flag_enabled?(name, context),
flag_config: flag,
evaluation_reason: determine_evaluation_reason(name, context),
context: context
}
end
def list_flags_for_context(context)
@flags.map do |name, _|
{
name: name,
enabled: flag_enabled?(name, context),
status: get_flag_status(name, context)
}
end
end
def gradual_rollout(flag_name, target_percentage, duration_minutes)
flag = @flags[flag_name]
raise ArgumentError, "Flag #{flag_name} not found" unless flag
current_percentage = flag[:rollout_percentage]
step_size = (target_percentage - current_percentage).to_f / duration_minutes
Thread.new do
duration_minutes.times do |minute|
new_percentage = current_percentage + (step_size * (minute + 1))
set_flag_state(flag_name, false, rollout_percentage: new_percentage.round(2))
sleep(60) # Wait one minute
end
end
end
private
def prerequisites_met?(prerequisites, context)
prerequisites.all? { |prereq| flag_enabled?(prereq, context) }
end
def percentage_rollout_enabled?(flag_name, percentage, context)
return false unless context[:user_id]
# Use consistent hashing for stable rollout
hash_input = "#{flag_name}:#{context[:user_id]}"
hash_value = Digest::MD5.hexdigest(hash_input).to_i(16)
rollout_bucket = hash_value % 100
rollout_bucket < percentage
end
def determine_evaluation_reason(flag_name, context)
flag = @flags[flag_name]
user_id = context[:user_id]
# Check evaluation order and return reason
if user_id && @user_overrides.dig(flag_name, user_id)
return 'user_override'
end
unless prerequisites_met?(flag[:prerequisites], context)
return 'prerequisites_not_met'
end
return 'globally_enabled' if flag[:enabled]
if flag[:rollout_percentage] > 0
return percentage_rollout_enabled?(flag_name, flag[:rollout_percentage], context) ?
'percentage_rollout' : 'percentage_rollout_excluded'
end
if flag[:target_groups].any? && context[:user_groups]
return (flag[:target_groups] & context[:user_groups]).any? ?
'target_group' : 'target_group_excluded'
end
'default_disabled'
end
def broadcast_flag_change(flag_name, flag_config)
ActionCable.server.broadcast(
'feature_flags',
{
event: 'flag_updated',
flag_name: flag_name,
config: flag_config,
timestamp: Time.current
}
)
end
end
Pattern 6: Multi-Tenant Configuration Isolation
Multi-tenant applications require isolated configuration management to prevent cross-tenant data leakage. This pattern ensures complete tenant isolation while maintaining performance.
class MultiTenantConfigurationManager
def initialize
@tenant_configs = {}
@shared_configs = {}
@tenant_isolation_validator = TenantIsolationValidator.new
end
def set_tenant_configuration(tenant_id, key, value, options = {})
validate_tenant_access!(tenant_id, options[:current_user])
@tenant_configs[tenant_id] ||= {}
@tenant_configs[tenant_id][key] = {
value: value,
updated_at: Time.current,
updated_by: options[:current_user]&.id,
encrypted: options[:encrypted] || false
}
if options[:encrypted]
@tenant_configs[tenant_id][key][:value] = encrypt_value(value, tenant_id)
end
invalidate_tenant_cache(tenant_id, key)
audit_configuration_change(tenant_id, key, value, options[:current_user])
end
def get_tenant_configuration(tenant_id, key, options = {})
validate_tenant_access!(tenant_id, options[:current_user])
cache_key = "tenant_config:#{tenant_id}:#{key}"
Rails.cache.fetch(cache_key, expires_in: 5.minutes) do
config_data = @tenant_configs.dig(tenant_id, key)
if config_data
value = config_data[:value]
value = decrypt_value(value, tenant_id) if config_data[:encrypted]
value
else
# Fall back to shared configuration if no tenant-specific config
get_shared_configuration(key)
end
end
end
def set_shared_configuration(key, value, options = {})
validate_admin_access!(options[:current_user])
@shared_configs[key] = {
value: value,
updated_at: Time.current,
updated_by: options[:current_user]&.id,
applies_to_new_tenants: options[:applies_to_new_tenants] || true
}
# Invalidate cache for all tenants
Rails.cache.delete_matched("tenant_config:*:#{key}")
Rails.cache.delete("shared_config:#{key}")
end
def get_shared_configuration(key)
Rails.cache.fetch("shared_config:#{key}", expires_in: 10.minutes) do
@shared_configs.dig(key, :value)
end
end
def bulk_update_tenant_configurations(updates, options = {})
validate_admin_access!(options[:current_user])
results = {}
updates.each do |tenant_id, config_updates|
results[tenant_id] = {}
config_updates.each do |key, value|
begin
set_tenant_configuration(tenant_id, key, value, options)
results[tenant_id][key] = { success: true }
rescue => e
results[tenant_id][key] = { success: false, error: e.message }
end
end
end
results
end
def export_tenant_configuration(tenant_id, options = {})
validate_tenant_access!(tenant_id, options[:current_user])
tenant_config = @tenant_configs[tenant_id] || {}
exported_config = tenant_config.transform_values do |config_data|
value = config_data[:value]
# Don't export encrypted values in plain text
if config_data[:encrypted]
'[ENCRYPTED]'
else
value
end
end
{
tenant_id: tenant_id,
exported_at: Time.current,
exported_by: options[:current_user]&.id,
configuration: exported_config
}
end
def import_tenant_configuration(tenant_id, imported_data, options = {})
validate_tenant_access!(tenant_id, options[:current_user])
imported_data[:configuration].each do |key, value|
next if value == '[ENCRYPTED]' # Skip encrypted placeholders
set_tenant_configuration(tenant_id, key, value, options)
end
audit_bulk_import(tenant_id, imported_data, options[:current_user])
end
def get_tenant_configuration_schema(tenant_id, options = {})
validate_tenant_access!(tenant_id, options[:current_user])
tenant_config = @tenant_configs[tenant_id] || {}
shared_config = @shared_configs
schema = {}
# Build schema from tenant-specific configurations
tenant_config.each do |key, config_data|
schema[key] = {
source: 'tenant',
type: determine_value_type(config_data[:value]),
encrypted: config_data[:encrypted],
last_updated: config_data[:updated_at]
}
end
# Add shared configurations that don't have tenant overrides
shared_config.each do |key, config_data|
next if schema.key?(key)
schema[key] = {
source: 'shared',
type: determine_value_type(config_data[:value]),
encrypted: false,
last_updated: config_data[:updated_at]
}
end
schema
end
def isolate_tenant_data(tenant_id)
# Remove all cached data for the tenant
Rails.cache.delete_matched("tenant_config:#{tenant_id}:*")
# Archive current configuration
archived_config = @tenant_configs[tenant_id]
if archived_config
ConfigurationArchive.create!(
tenant_id: tenant_id,
configuration_data: archived_config,
archived_at: Time.current,
reason: 'tenant_isolation'
)
end
# Remove tenant configuration
@tenant_configs.delete(tenant_id)
true
end
private
def validate_tenant_access!(tenant_id, current_user)
unless @tenant_isolation_validator.can_access_tenant?(current_user, tenant_id)
raise SecurityError, "Access denied for tenant #{tenant_id}"
end
end
def validate_admin_access!(current_user)
unless @tenant_isolation_validator.admin_user?(current_user)
raise SecurityError, "Admin access required"
end
end
def encrypt_value(value, tenant_id)
# Use tenant-specific encryption key
encryption_key = derive_tenant_key(tenant_id)
ActiveSupport::MessageEncryptor.new(encryption_key).encrypt_and_sign(value)
end
def decrypt_value(encrypted_value, tenant_id)
encryption_key = derive_tenant_key(tenant_id)
ActiveSupport::MessageEncryptor.new(encryption_key).decrypt_and_verify(encrypted_value)
end
def derive_tenant_key(tenant_id)
# Generate deterministic but unique key for each tenant
key_material = "#{Rails.application.secret_key_base}:tenant:#{tenant_id}"
Digest::SHA256.digest(key_material)[0, 32]
end
def invalidate_tenant_cache(tenant_id, key)
Rails.cache.delete("tenant_config:#{tenant_id}:#{key}")
end
def audit_configuration_change(tenant_id, key, value, user)
ConfigurationAuditLog.create!(
tenant_id: tenant_id,
configuration_key: key,
new_value: value.is_a?(String) ? value : value.to_json,
changed_by: user&.id,
changed_at: Time.current,
change_type: 'update'
)
end
def audit_bulk_import(tenant_id, imported_data, user)
ConfigurationAuditLog.create!(
tenant_id: tenant_id,
configuration_key: 'BULK_IMPORT',
new_value: "Imported #{imported_data[:configuration].size} configurations",
changed_by: user&.id,
changed_at: Time.current,
change_type: 'bulk_import'
)
end
def determine_value_type(value)
case value
when String then 'string'
when Integer then 'integer'
when Float then 'float'
when TrueClass, FalseClass then 'boolean'
when Array then 'array'
when Hash then 'object'
else 'unknown'
end
end
end
class TenantIsolationValidator
def can_access_tenant?(user, tenant_id)
return false unless user
# Check if user belongs to the tenant or is an admin
user.admin? || user.tenant_memberships.exists?(tenant_id: tenant_id)
end
def admin_user?(user)
user&.admin? || false
end
end
Pattern 7: Configuration Change Auditing
Comprehensive audit trails ensure compliance and debugging capabilities. This pattern provides detailed tracking of all configuration changes with searchable history.
class ConfigurationAuditLogger
def initialize
@audit_storage = ConfigurationAuditStorage.new
@notification_service = ConfigurationNotificationService.new
end
def log_change(change_event)
audit_entry = create_audit_entry(change_event)
@audit_storage.store(audit_entry)
# Send notifications for critical changes
if critical_change?(change_event)
@notification_service.notify_critical_change(audit_entry)
end
audit_entry
end
def get_audit_trail(filters = {})
@audit_storage.search(filters)
end
def get_configuration_history(key, options = {})
filters = { configuration_key: key }
filters[:tenant_id] = options[:tenant_id] if options[:tenant_id]
filters[:limit] = options[:limit] || 50
@audit_storage.search(filters)
end
def get_user_activity(user_id, options = {})
filters = { changed_by: user_id }
filters[:from_date] = options[:from_date] if options[:from_date]
filters[:to_date] = options[:to_date] if options[:to_date]
@audit_storage.search(filters)
end
def generate_compliance_report(date_range)
filters = {
from_date: date_range[:start],
to_date: date_range[:end]
}
audit_entries = @audit_storage.search(filters)
{
report_generated_at: Time.current,
date_range: date_range,
total_changes: audit_entries.count,
changes_by_type: group_by_change_type(audit_entries),
changes_by_user: group_by_user(audit_entries),
critical_changes: audit_entries.select { |entry| entry[:critical] },
security_events: audit_entries.select { |entry| entry[:security_related] }
}
end
def detect_unusual_activity