A code-based design technique for exploring, validating, and communicating domain models in Rails applications.
Riffing is a low-fidelity, high-speed design technique where you write nearly-runnable Ruby code in a single scratch file to explore and prove out your domain model design before committing to full implementation.
Riffing is to development what wireframing is to UI design.
- Structure first, business logic second — Focus on names, relationships, and interfaces before implementation details
- Defer correctness temporarily — Stay high-level to explore possibilities without getting lost in implementation
- Listen to the code — Let the emerging design reveal issues and improvements
- Move fast, iterate often — Toss ideas, refine them, scrap what doesn't work, try alternatives
A riff file is a single .rb file that sketches out your domain model. It contains:
- Problem Statement (as a comment at the top)
- Model Definitions (classes with associations)
- Key Methods (interface sketches)
- Controller Actions (to visualize the flow)
- View Snippets (optional, as comments or ERB-like pseudocode)
# PROBLEM STATEMENT:
# [Describe the feature/system you're designing in 1-3 sentences]
# [List any key requirements or constraints]
# ============================================
# DOMAIN MODELS
# ============================================
class User
has_many :things
# key attributes: name, email
end
class Thing
belongs_to :user
has_many :sub_things
# key attributes: title, status
end
class Thing::SubThing
belongs_to :thing
# key attributes: value, position
end
# ============================================
# KEY INTERFACES
# ============================================
class Thing
def do_something
# sketch the logic here
end
end
# ============================================
# CONTROLLERS (optional)
# ============================================
class ThingsController
def create
@thing = Current.user.things.create!(thing_params)
end
end
# ============================================
# VIEWS (optional, as comments)
# ============================================
# app/views/things/show.html.erb
# @thing.sub_things.each do |sub|
# render sub
# endAlways begin with a clear, concise description of what you're trying to build.
# Spotify Mix Playlists: Assuming we have a history of played songs for a user,
# we have song recommendations via nearest neighbor search,
# and we have categorizations (genre, mood, era, instrumental/vocal, cultural/regional, theme),
# let system admins create mix templates based on music categorizations
# and then generate refreshable custom playlists for each user.Prefer standard Rails classes and methods. The goal is to stay high-level:
# GOOD - Standard Rails associations
class User
has_many :playlists
has_one :history
end
class History
has_many :listens
has_many :tracks, through: :listens
end
# AVOID - Getting too implementation-specific too early
class User
has_many :playlists, -> { order(created_at: :desc).limit(10) },
class_name: "Playlist::UserPlaylist",
foreign_key: :owner_id
endNamespace models to indicate ownership and conceptual grouping:
# Clear ownership through namespacing
class User
has_one :timeline
end
class User::Timeline
has_many :items
end
class User::Timeline::Item
belongs_to :timeline
end
# Join models nested under their parent
class Track::Category::Categorization
belongs_to :track
belongs_to :category
endDon't write full migrations — just note the key attributes:
class Post
belongs_to :user
# title, string
# content, text
# published_at, datetime
# status, enum: [:draft, :published, :archived]
endSketch out the public interface before worrying about implementation:
class Mix::Template
has_many :categories
def build_for(user)
# Get tracks from user's history matching our categories
from_own_history = user.history.tracks
.ordered_by_popularity
.joins(:categories)
.where(categories:)
.limit(100)
.flat_map { |track| [track, track.nearest(10)] }
.uniq
.first(100)
# Fall back to popular tracks if not enough from history
if from_own_history.size >= 100
from_own_history
else
Track.ordered_by_popularity
.joins(:categories)
.where(categories:)
.limit(100)
.flat_map { |track| [track, track.nearest(10)] }
.including(from_own_history)
.uniq
.first(100)
end
end
endControllers help you understand the user interaction flow:
class Users::FavoritesController
def create
Current.user.favorite(find_record)
end
def destroy
Current.user.unfavorite(find_record)
end
private
def find_record
SignedGlobalID.find(params[:id], only: [Post, Comment], for: :user_favorites)
end
endSometimes seeing the view clarifies the model:
# app/views/user/timelines/show.html.erb
# @items.each do |item|
# if Current.user.favorite?(item)
# button_to unfavorite_item_path(item), method: :delete
# else
# button_to favorite_item_path(item)
# end
# endWhen something can apply to multiple types:
class User::Favorite
belongs_to :user
belongs_to :record, polymorphic: true # Can favorite Posts, Comments, etc.
end
class User
has_many :favorites
def favorite(record)
favorites.create!(record:)
end
def favorite?(record)
favorites.exists?(record:)
end
endGroup related concepts:
module Spam
module Detectors
def self.check(post)
Check.new(post, Abstract.detectors)
end
class Check
def initialize(post, detectors)
@detectors = detectors.map { _1.new(post:) }
end
def score
@detectors.sum(&:score) / @detectors.size
end
end
class Abstract < Struct.new(:post, :max_hits, keyword_init: true)
def initialize(post:, max_hits: 1) = super
singleton_class.attr_reader :detectors
@detectors = []
def self.inherited(detector) = detectors << detector
def score
hits / max_hits.to_f
end
end
end
end
class Spam::Detectors::Content::LinkCount < Spam::Detectors::Abstract
def hits
post.content.scan(/https?:.*? /).size
end
def max_hits = 10
endFor generating instances from templates:
class Mix::Template
has_many :categories
def build_for(user)
# Return tracks for this user based on template
end
end
class Mix::Build
belongs_to :template
belongs_to :user
has_many :tracks, through: :links
def regenerate
update! tracks: template.build_for(user)
end
end
class Mix::Build::Link
belongs_to :build
belongs_to :track
endWhen you need different objects to respond to the same interface:
class Kanban::Column
has_many :rows
def column = self # Mask as a row for positioning
def new_adjacent_position
rows.pick(:position) - positioning_fragment
end
end
class Kanban::Column::Row
belongs_to :column
def reposition_onto(cursor)
# cursor can be both a Column and a Row
update! column: cursor.column, position: cursor.new_adjacent_position
end
def new_adjacent_position
position + positioning_fragment
end
endCreate a new .rb file. Don't open your Rails app — work in isolation.
Describe what you're building in 1-5 sentences at the top of the file.
Begin with the obvious models and their relationships:
class Board
has_many :columns
end
class Column
belongs_to :board
has_many :cards
end
class Card
belongs_to :column
endRename things as you go. Better names emerge through iteration:
# Started with:
class Column
def re_place(row)
end
end
# Evolved to:
class Row
def reposition_onto(new_column)
end
endLayer in complexity as needed:
# First pass
class User
has_many :posts
end
# Second pass
class User
has_many :posts
has_many :favorites
def favorite(record)
favorites.create!(record:)
end
end
# Third pass
class User
has_many :posts
has_many :favorites
def favorite(record)
favorites.create!(record:)
end
def unfavorite(record)
favorites.find_by(record:)&.destroy
end
def favorite?(record)
favorites.exists?(record:)
end
endWhen something feels wrong, it probably is. Common signals:
- Hard to name — The concept might be unclear or misplaced
- Too many parameters — You might be missing a model
- Duplicated logic — Extract a new class or method
- Awkward associations — Reconsider the relationships
Keep refining. Move things around. Delete and restart if needed.
| Session Type | Duration | Best For |
|---|---|---|
| Quick exploration | 15-30 min | Simple features, single model additions |
| Standard riff | 30-60 min | New features, subsystems |
| Deep dive | 1-2 hours | Complex systems, major refactors |
Stop when:
- You're hitting diminishing returns
- You have enough clarity to start implementation
- You need to sleep on it
For testing interfaces, you can make your riff runnable:
# At the top of your riff file:
require "bundler/inline"
gemfile do
source "https://rubygems.org"
gem "rails"
gem "sqlite3"
end
require "active_record"
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
ActiveRecord::Schema.define do
create_table :users do |t|
t.string :name
end
create_table :posts do |t|
t.references :user
t.string :title
end
end
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
end
# Now your models work:
class User < ApplicationRecord
has_many :posts
end
class Post < ApplicationRecord
belongs_to :user
end
# Test it:
user = User.create!(name: "Test")
user.posts.create!(title: "Hello")
puts user.posts.count # => 1# BAD - Too detailed
create_table :posts do |t|
t.string :title, null: false, limit: 255
t.text :content
t.integer :status, default: 0
t.references :user, foreign_key: true, index: true
t.timestamps
end# GOOD - Just note the attributes
class Post
belongs_to :user
# title, content, status (enum)
end# BAD - Too much implementation detail
class Order
def process_payment
PaymentGateway.configure(api_key: ENV['STRIPE_KEY'])
customer = PaymentGateway::Customer.find_or_create(email: user.email)
payment_intent = customer.create_payment_intent(
amount: total_cents,
currency: 'usd',
payment_method: payment_method_id,
confirm: true
)
# ... 50 more lines
end
end# GOOD - Sketch the interface
class Order
def process_payment
# Create payment intent with Stripe
# Confirm payment
# Update order status
update!(status: :paid, paid_at: Time.current)
end
end# BAD - Premature abstraction
class BasePolicy
def initialize(user, record)
@user = user
@record = record
end
end
class PostPolicy < BasePolicy
class Scope < BasePolicy::Scope
def resolve
scope.where(user: @user)
end
end
end# GOOD - Simple and direct
class Post
scope :visible_to, ->(user) { where(user:).or(where(public: true)) }
endFocus on the happy path first. Note edge cases as comments:
class Subscription
def renew
# TODO: Handle failed payments
# TODO: Handle plan changes mid-cycle
create_next_period!
charge_customer!
end
end# PROBLEM STATEMENT:
# Spam post detection: Run a forum post through a series of configurable checks
# to give it a spam score, and flag the post when it crosses the threshold,
# with details on what led to the score.
class Post
belongs_to :user
# title, content
end
class User
has_many :posts
end
# ============================================
# SPAM DETECTION SYSTEM
# ============================================
module Spam
module Detectors
def self.check(post)
Check.new(post, Abstract.detectors)
end
class Check
def initialize(post, detectors)
@detectors = detectors.map { _1.new(post:) }
end
def score
@detectors.sum(&:score) / @detectors.size
end
def flagged?
score > 0.7
end
def details
@detectors.map { |d| [d.class.name, d.score] }.to_h
end
end
class Abstract < Struct.new(:post, keyword_init: true)
singleton_class.attr_reader :detectors
@detectors = []
def self.inherited(detector) = detectors << detector
def max_hits = 1
def score
hits / max_hits.to_f
end
end
end
end
# ============================================
# CONTENT DETECTORS
# ============================================
class Spam::Detectors::Content::LinkCount < Spam::Detectors::Abstract
def hits = post.content.scan(/https?:/).size
def max_hits = 10
end
class Spam::Detectors::Content::Dictionary < Spam::Detectors::Abstract
SPAM_WORDS = %w[viagra casino lottery winner free money]
def hits
post.content.downcase.scan(Regexp.union(SPAM_WORDS)).uniq.size
end
def max_hits = SPAM_WORDS.size
end
# ============================================
# ACCOUNT DETECTORS
# ============================================
class Spam::Detectors::Account::PostVelocity < Spam::Detectors::Abstract
def hits
post.user.posts.where(created_at: 1.hour.ago..).count >= 50 ? 1 : 0
end
end
class Spam::Detectors::Account::NewAccount < Spam::Detectors::Abstract
def hits
post.user.created_at > 1.day.ago ? 1 : 0
end
end
# ============================================
# CONTROLLER
# ============================================
class Post::SpamDetectionsController
def create
@detection = Spam::Detectors.check(@post)
if @detection.flagged?
@post.update!(flagged: true, spam_score: @detection.score)
end
end
end- Start with a problem statement — Know what you're building
- Use a single scratch file — Stay focused and isolated
- Write nearly-runnable Ruby — Leverage Rails conventions
- Focus on structure — Names, relationships, interfaces
- Iterate rapidly — Add, remove, refine
- Listen to the code — Let it guide you to better designs
- Stop when you have clarity — You don't need a perfect design
- GitHub: kaspth/riffing-on-rails
- RailsConf 2024 Talk
- The Bike Shed Episode 433
- Riffing YouTube Sessions
This guide is based on Kasper Timm Hansen's riffing technique. Adapted for AI agent use.
init