Skip to content

Instantly share code, notes, and snippets.

@adham90
Last active January 10, 2026 09:46
Show Gist options
  • Select an option

  • Save adham90/2f4e846a6eca812979afa58dbb59e5d1 to your computer and use it in GitHub Desktop.

Select an option

Save adham90/2f4e846a6eca812979afa58dbb59e5d1 to your computer and use it in GitHub Desktop.
Riffing on Rails: AI Agent Planning Guide

Riffing on Rails: AI Agent Planning Guide

A code-based design technique for exploring, validating, and communicating domain models in Rails applications.

What is Riffing?

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.

Core Philosophy

  • 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

Riff File Structure

A riff file is a single .rb file that sketches out your domain model. It contains:

  1. Problem Statement (as a comment at the top)
  2. Model Definitions (classes with associations)
  3. Key Methods (interface sketches)
  4. Controller Actions (to visualize the flow)
  5. View Snippets (optional, as comments or ERB-like pseudocode)

Basic Template

# 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
# end

Riffing Rules & Guidelines

1. Start with a Problem Statement

Always 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.

2. Use Plain Rails Conventions

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
end

3. Use Namespacing to Show Relationships

Namespace 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
end

4. Sketch Attributes as Comments

Don'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]
end

5. Write Interface-First Methods

Sketch 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
end

6. Include Controller Sketches for Flow

Controllers 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
end

7. Add View Snippets When Helpful

Sometimes 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
# end

Riffing Patterns

Pattern 1: Polymorphic Records

When 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
end

Pattern 2: Module Namespaces for Domain Grouping

Group 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
end

Pattern 3: Template/Build Pattern

For 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
end

Pattern 4: Self-Masking for Polymorphic Interfaces

When 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
end

The Riffing Process

Step 1: Open a Blank File

Create a new .rb file. Don't open your Rails app — work in isolation.

Step 2: Write the Problem Statement

Describe what you're building in 1-5 sentences at the top of the file.

Step 3: Start with the Core Models

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
end

Step 4: Play with Names

Rename 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
end

Step 5: Add Details Incrementally

Layer 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
end

Step 6: Listen to the Code

When 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

Step 7: Revise and Iterate

Keep refining. Move things around. Delete and restart if needed.


Timing Guidelines

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

Making Riffs Executable (Optional)

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

Anti-Patterns to Avoid

❌ Don't: Write Full Migrations

# 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

❌ Don't: Implement Full Business Logic

# 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

❌ Don't: Over-Engineer Early

# 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)) }
end

❌ Don't: Get Lost in Edge Cases

Focus 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

Example Riff: Forum Spam Detection

# 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

Summary

  1. Start with a problem statement — Know what you're building
  2. Use a single scratch file — Stay focused and isolated
  3. Write nearly-runnable Ruby — Leverage Rails conventions
  4. Focus on structure — Names, relationships, interfaces
  5. Iterate rapidly — Add, remove, refine
  6. Listen to the code — Let it guide you to better designs
  7. Stop when you have clarity — You don't need a perfect design

Resources


This guide is based on Kasper Timm Hansen's riffing technique. Adapted for AI agent use.

@adham90
Copy link
Author

adham90 commented Jan 10, 2026

init

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment