Quick Start
Install
bundle add dials
bin/rails generate dials:install
bin/rails db:migrateThe generator creates one migration (a single append-only dials table — current state, attributed history, and the cache's version counter are the same rows) and config/initializers/dials.rb.
Declare your first dials
# config/initializers/dials.rb
require "dials/active_record"
Dials.configure do |config|
config.store = :active_record
end
Dials.define do
dial :checkout_fee_bps, default: 250,
type: :integer,
minimum: 1,
maximum: 10_000,
unit: "bps",
description: "Fee charged on checkout, in basis points.",
dimensions: { market: { enum: %w[KE NG BD] } }
dial :signups_enabled, default: true,
type: :boolean,
description: "Global kill switch for new signups."
endEach dial takes a key and:
| Option | Required | Meaning |
|---|---|---|
default: | yes | the code default — what the dial serves until an operator overrides it |
type: | yes | :boolean, :integer, :float, :string, or :json |
| constraints | no | JSON Schema keywords for the type — minimum:/maximum: for numbers, min_length:/max_length:/pattern: for strings, enum: for any type, properties:/required: for :json |
validate: | no | callable — the escape hatch for rules a schema cannot express |
dimensions: | no | the dial's dimensions (see below) |
label: / unit: / description: | no | metadata for the admin surface you build |
A key, a default:, and a type: make a complete declaration — everything else is opt-in.
The constraints are deliberately not a bespoke vocabulary: they are JSON Schema keywords, snake_cased for Ruby. If you've written minimum / maximum / enum / pattern in a JSON Schema or an OpenAPI spec, you already know this API — and because the constraints are the standard's, a declaration can hand its rules to any JSON Schema tooling via definition.to_json_schema (see the API Reference).
A dial with no dimensions: is global-only: it can never hold per-scope values, which is exactly what you want for a kill switch.
Declaring a dial generates its methods: the bare dial name to read (Dials.checkout_fee_bps(market: "KE")), adjust_<key> to write, clear_<key> to remove an override. They are real methods, defined at declaration time — respond_to?, tab completion, and grep all work. (One consequence: a dial can't be named after a Dials method like :store or :cache — that raises at boot.)
Read
Dials.signups_enabled # global-only dial: no scope
Dials.checkout_fee_bps(market: "KE") # varied dial: scope requiredThe scope must name every dimension the dial declares — a missing or unknown dimension raises Dials::InvalidScope immediately, in development, not quietly in production.
Reads cost a hash lookup. No query runs per read — see Caching.
Write
# Global override (applies wherever nothing more specific exists):
Dials.adjust_checkout_fee_bps(300, actor: current_admin)
# Per-market override:
Dials.adjust_checkout_fee_bps(120, actor: current_admin, market: "BD")
# Remove overrides (each layer falls back to the one below):
Dials.clear_checkout_fee_bps(actor: current_admin, market: "BD")
Dials.clear_checkout_fee_bps(actor: current_admin)Every write is attributed — there is no anonymous mutation path the app didn't declare. actor: takes any object; strings are first-class (actor: "console", no User model needed), and apps without user identity can declare a fallback once (config.default_actor) instead of passing it per write. (Attribution is also why actor is a reserved dimension name.) Values are validated against the declared type and schema before anything is stored.
Dynamic access
When the dial key arrives at runtime — an admin surface iterating the registry, a console one-liner — use the key-taking primitives that the generated methods delegate to:
Dials.get(:checkout_fee_bps, market: "KE")
Dials.set(:checkout_fee_bps, 120, scope: { market: "BD" }, actor: current_admin)
Dials.clear(:checkout_fee_bps, scope: { market: "BD" }, actor: current_admin)History
Dials.changes(key: :checkout_fee_bps)
# => newest-first array of ChangeRecord(key, scope, action, old_value,
# new_value, actor_type, actor_id, actor_label, created_at)In tests
Dials::Testing.with_overrides(checkout_fee_bps: 1_000) do
# every read of :checkout_fee_bps returns 1_000, for any scope
endAnd one line of hygiene in rails_helper.rb when using transactional specs:
RSpec.configure { |config| config.before { Dials.reload! } }See Testing with Dials for why.
Next
- The Dial Model — the resolution layers and why the database stores only overrides
- Dimensions and Scopes — dimensions, enums, and the exact-scope rule
- Retrofit a Constant — adopting dials in an existing app