Sign in

Kasper Timm Hansen

@kaspth.com
1.8K followers 365 following 735 posts

@spinel.coop • rv.dev • gem.coop core team • kaspth.com • former Rails core

PostsRepliesMedia
Kasper Timm Hansen @kaspth.com · 08/09/2026
000
Kasper Timm Hansen @kaspth.com · 08/07/2026
a nice rv.dev win: I just dug up an old app that was on ruby 3.4.5, a ruby version I'd deleted. `rv ci` installed ruby and all gems in < 10 seconds.
4.0.5 ~/code/kaspth/oaken-demo oaken * rv ci
Archive /Users/kasperhansen/.cache/rv/ruby-v0/tarballs/49e7048cddefe3c4.tar.gz already exists, skipping download.
Installed Ruby version 3.4.5 to /Users/kasperhansen/.local/share/rv/rubies
4 gems already installed in /Users/kasperhansen/.local/share/rv/rubies/ruby-3.4.5/lib/ruby/gems/3.4.0, skipping installation. Use --force if you want to install these gems again.
68 gems installed to /Users/kasperhansen/.local/share/rv/rubies/ruby-3.4.5/lib/ruby/gems/3.4.0:
 - 1.6s fetching 68 gems from gem servers (47 cached, 21 downloaded), 0 from git repos, 0 from local paths
 - 0.5s unpacking 68 gems from gem servers
 - 5.7s compiling 10 native extensions (0 cached)
 - 7.7s total
0151
Kasper Timm Hansen @kaspth.com · 02/04/2026
Is this too much indirection? Context: since Associated Objects let you write smaller objects, having Mailers with one action becomes common. And then the Mailer becomes mostly plumbing.
class User::EmailVerification < ActiveRecord::AssociatedObject
  has_mailer to: :user, subject: "Verify your email and sign in"
end
ActiveRecord::AssociatedObject.extend Module.new {
  def has_mailer(to: nil, subject: nil, process: nil, &block)
    # Use `class_eval` to subclass, so `Mailer#name` isn't nil.
    class_eval <<~RUBY
      class Mailer < ApplicationMailer
        helper_method def #{attribute_name} = params.fetch(:#{attribute_name})
        helper_method delegate :#{record.model_name.element}, to: :#{attribute_name}
      end
    RUBY

    mailer = const_get(:Mailer)
    mailer.default template_path: name.underscore, template_name: "mailer"

    process ||= -> { mail to: ActionMailer::Address(public_send(to)), subject: } if to && subject
    process ? mailer.define_method(:mailer, &process) : mailer.class_eval(&block)

    define_method(:mailer) { self.class::Mailer.with(self.class.attribute_name => self).mailer }
    delegate :deliver_later, to: :mailer
  end
}
250
Kasper Timm Hansen @kaspth.com · 05/03/2026
#edbspillet
En donation til FSTB
0185
Kasper Timm Hansen @kaspth.com · 26/01/2026
Had yet another idea that I still haven't gotten far with but it intrigues me because I've always found state machines done poorly. So uhh here's some notes on that for later…
# Impromptu state machine for states that don't need to be represented forever?
# E.g. info.orchestrate_into :pending, key: 1 # or a date?
class Operation < ApplicationRecord
end

# Track moves on a given record? E.g. order -> pending -> shipping and back
class Statement::Move < ApplicationRecord
end
110
Kasper Timm Hansen @kaspth.com · 26/01/2026
I've been playing around with an idea of deferred columns, here a text column that could be large so we don't want to fetch it upfront. The fetching is not super optimized yet (it's in a belongs_to so we can't inject extra query conditions at the call site).
  default_scope { select(*column_names.without("versions_contents")) }

  def append(envelope)
    versions_contents << envelope
    save!
  end

  def versions_contents
    read_unloaded_attribute __method__
  end
class ApplicationRecord < ActiveRecord::Base
  primary_abstract_class

  def read_unloaded_attribute(key)
    read_attribute(key) || write_attribute(key, self.class.where(id:).pick(key))
  end
end
110
Kasper Timm Hansen @kaspth.com · 26/01/2026
I've also created a DigestedIO class to help compute a checksum while writing to a StringIO. It's in app/models/digested_io.rb with an inflect.acronym "IO" in inflections.rb.
class DigestedIO < Data.define(:buffer, :digest)
  def initialize(buffer: StringIO.new, digest: Digest::MD5.new) = super
  delegate :string, to: :buffer
  delegate :hexdigest, to: :digest

  def consume(enumerable, &)
    enumerable.inject(self) { |buf, value| buf << yield(value) }
  end

  def <<(chunk)
    buffer << chunk
    digest << chunk
    self
  end
end  def rebuild
    io = DigestedIO.new.consume(batched_versions, &:line)

    self.contents = io.string
    self.checksum = io.hexdigest
    self.envelope = envelope_from(versions.pluck(:ref))
    save!
    self
  end
100
Kasper Timm Hansen @kaspth.com · 26/01/2026
I've also been playing around with integrated job discards to help when batching multiple jobs each trying to rebuild a resource. E.g. is the updated_at I was enqueued with still current? Then perform, otherwise someone else has "claimed" the lock so we assume another job will run.
class ApplicationJob < ActiveJob::Base
  class_attribute :discard_by_stale_value_proc
  attr_accessor :discard_value

  # performs :rebuild, wait: 1.minute, discard_stale: :updated_at
  # wonder if we just use cache_version by default?
  def self.discard_stale(discard_attribute)
    self.discard_by_stale_value_proc = discard_attribute.to_proc
  end

  def serialize
    if value = current_discard_value
      super.merge("discard_value" => ActiveJob::Arguments.serialize_argument(value))
    else
      super
    end
  end

  def deserialize(job_data)
    super
    self.discard_value = ActiveJob::Arguments.send :deserialize_argument, job_data["discard_value"]
  end

  before_perform :discard_by_stale_value, if: :discard_by_stale_value_proc

  private
    def discard_by_stale_value
      p discard_value >= current_discard_value
      throw :abort unless discard_value >= current_discard_value
    end

    def current_discard_value
      discard_by_stale_value_proc&.call(arguments.first)
    end
end
110
Kasper Timm Hansen @kaspth.com · 26/01/2026
Another big win is using github.com/kaspth/oaken from the start to help shape the app. It's also proven to be a great onboarding tool, here's from the README. Note bin/sq in there too!
README

Clone the repo, cd into it and run bin/setup to get started:

bin/setup
Options:

--reset resets the database via bin/rails db:reset
--dev runs bin/dev to boot the development server.
Tip

bin/sq lets you query a running server a little more easily, click the link for samples.
Dependencies

homebrew, we're assuming a working Homebrew installation.
puma-dev, set it up and run puma-dev link from the root dir to setup accessing the app via http:peak.test.
Quick Start

We're using Oaken to use code as documentation for our domain model.

So what you see in db/seeds is both what gets run via db:prepare in bin/setup (which runs db:seed), and also the data we test with.

To get up to speed, start reading db/seeds
100
Kasper Timm Hansen @kaspth.com · 26/01/2026
There's already been fun stuff spilling out of peak 🏔️ Here's an idea for a server-query binstub convention that you could: - grab txt responses from to help make a TUI - pipe to jq or hq for JSON or HTML queries
#!/usr/bin/env sh

# run a server-query like this:
# bin/sq versions

# forwards rest arguments onto curl, so stuff like `bin/sq versions --head` works.
curl http://peak.test/$1 ${@: 2}
120
Kasper Timm Hansen @kaspth.com · 30/12/2025
So if Redis and Kredis was an option it'd probably look like this, sharing for inspiration 😊
class StructureUpdatedJob < ApplicationJob
  def self.debounce_perform_later(structure, wait: 1.minute)
    debounce_for(structure, wait) { set(wait:).perform_later(structure) }
  end

  def self.debounce_for(structure, wait)
    debouncer = Kredis.counter "#{name.underscore}:#{structure.cache_key}", expires_in: wait
    yield if debouncer.increment == 1 # `increment` returns 1 on the first increment in an expiry period.
  end
end
130
Kasper Timm Hansen @kaspth.com · 15/12/2025
Ha, I've always found [] a little too annoying when I've tried it. Looking at this again I realized `attribute` already defines methods on a module, so it could probably just be something like this.
module ActiveModel::Attributes
  def self.define_on(owner, *strings, **attributes)
    owner.include self
    strings.index_with(:string).chain(attributes).each { owner.attribute _1, _2 }
  end
end

class Post
  ActiveModel::Attributes.define_on self, :name, :summary, starts_on: :date
end
000
Kasper Timm Hansen @kaspth.com · 10/12/2025
I do love how condensedly cursed you can make ruby code
  # include ActiveModel::Attributes(:name, :summary, starts_on: :date)
  def self.Attributes(*strings, **attributes) = Module.new do
    extend ActiveSupport::Concern; include ActiveModel::Attributes; included do
      strings.index_with(:string).chain(attributes).each { attribute _1, _2 }
    end
  end
4171
Kasper Timm Hansen @kaspth.com · 01/09/2025
I'm sad I can't make it to the @xoruby.com events that @jremsikjr.bsky.social is putting on, so what if you went instead 😄 September 6th Chicago • 13th Atlanta • 20th New Orleans October 11th, Portland • 18th San Diego • 25th Austin Tickets xoruby.com/events
A US map showing the locations in the post with the text "XO Ruby is hitting the road!"
085
Kasper Timm Hansen @kaspth.com · 10/08/2025
When Open Source goes well, you don't necessarily hear that much from users because things are fine, so I like to track gem downloads just to see what's going on. Looks like Associated Objects & Performs have both doubled, Stashed Redirects have been picking up, and Oaken's almost quadrupled!
active_record-associated_object 51,992 downloads
active_job-performs 39,849 downloads
oaken 29,492 downloads
action_controller-stashed_redirects 7,035 downloads
081
Kasper Timm Hansen @kaspth.com · 07/08/2025
Another thing, sometimes I've also just cached the object instance directly like this (like for defining to_partial_path). Then you're relying on the block capturing the local state and just returning that directly.
module PointFactory
  REFERENCE_SYSTEM = 4326

  def self.build(...) = nil
  
  raw_point_factory = RGeo::Geographic.spherical_factory(srid: REFERENCE_SYSTEM)
  define_singleton_method(:raw_point_factory) { raw_point_factory }
end
010
Kasper Timm Hansen @kaspth.com · 13/07/2025
Just gotta add some special effects so it looks great
Superman and Lois flying
110
Kasper Timm Hansen @kaspth.com · 20/06/2025
I'm really proud of how oaken holds up in practice so far. @thomascannon.me's been a big adopter, and last week I went in and found some extra places to trim things.
A pull request showing about 70 lines saved by slimming the seeds down.
251
Kasper Timm Hansen @kaspth.com · 20/04/2025
I'm working on an overhaul of Oaken's internals with more cohesiveness & flexibility. Had the idea to compare with friends and see file and line of code count of their /lib directory. Oaken's gonna grow but this feels pretty good still!
| gem                | files | loc  |
|--------------------|-------|------|
| activerecord-8.0.2 | 8     | 822  |
| factory_bot-6.5.1  | 52    | 1883 |
| fabrication-2.31.0 | 30    | 1141 |
| test_data-0.3.2    | 44    | 1297 |
| oaken-0.7.1        | 10    | 265  |
280
Kasper Timm Hansen @kaspth.com · 16/04/2025
Gonzo sitting crosslegged on a stool wearing a red suit and looking stunned.
020
Kasper Timm Hansen @kaspth.com · 12/04/2025
.@garrettdimon.com attended my recent Action View specific deep-dive and had this to say:
Kasper: How'd you like the deep-dive, was it helpful for what you're looking into with ERB + partials?

Garrett: Definitely. It helped provide a lot of context about how and why things are the way they are. The Rails documentation and source code is great for showing how something can be done, but there’s always a tinge of doubt in my head about whether I’m using a feature as intended or not.
100
Kasper Timm Hansen @kaspth.com · 11/04/2025
I'm speaking at #railsconf this July, and I'm showing off my unique approach to leveling yourself up — you deserve genuine durable skills. So come learn how: yes, you can work on Rails & any other gem.
A promotional image of my RailsConf talk Yes, You Can Work on Rails &
any other Gem
0121
Kasper Timm Hansen @kaspth.com · 25/02/2025
#railsconf
Fresh Willem Dafoe on the left with text "Ok I'll submit to the CFP"
Exhausted Willem Dafoe on the right with text "5 min into writing"
0140
Kasper Timm Hansen @kaspth.com · 08/01/2025
This is cool! The programmer in me wishes I could just configure it like this, maybe not healthy 😂
t ->/Applications/WezTerm.app
o ->
  1 ->/Applications/1password.app
  a ->/Applications/Arc.app
  c ->/Applications/Google Chrome.app
  s ->/Applications/Safari.app
  e ->/System/Applications/Mail.app
  z ->/Applications/Slack.app
  i ->/System/Applications/Music.app
  n ->/Applications/Notion.app
  t ->/Applications/WezTerm.app
  m ->/System/Applications/Messages.app
  o ->/Applications/Obsidian.app
r ->
  e ->raycast://extensions/raycast/emoji-symbols/search-emoji-symbols
  p ->raycast://confetti
  c ->raycast://extensions/raycast/system/open-camera
  v ->raycast://extensions/raycast/clipboard-history/clipboard-history
s ->
  l ->raycast://extensions/raycast/system/lock-screen
  s ->raycast://extensions/raycast/system/sleep
  a ->/Users/mikker/Applications/DarkMode.app
w ->
  f ->raycast://extensions/raycast/window-management/maximize
  h ->raycast://extensions/raycast/window-management/left-half
  l ->raycast://extensions/raycast/window-management/right-half
  o ->raycast://extensions/raycast/window-management/last-two-thirds
  n ->raycast://extensions/raycast/window-management/first-third
  - ->raycast://extensions/raycast/window-management/center
  = ->raycast://extensions/raycast/window-management/maximize-height
110
Kasper Timm Hansen @kaspth.com · 21/12/2024
Yeah, I'm thinking I'd be something like this and then you get both `record_path` and `record_url`. Or do a `resolve` for each record and then pass them straight without needing the `record_url` helpers? Hm.
direct :record do |record|
  case record
  when  then [:account_member, record]
  # …
  else
    url_for(record)
  end
end

resolve("Accounts::Member") { [:account_member] }
# …
010
Kasper Timm Hansen @kaspth.com · 20/12/2024
Now that more people are catching onto my gems, I'm finding it validating to see these numbers going up bit by bit.
active_record-associated_object 0.8.3 25,623 DOWNLOADS
active_job-performs 0.3.2 19,418 DOWNLOADS
oaken 0.7.1 7,958 DOWNLOADS
180
Kasper Timm Hansen @kaspth.com · 16/12/2024
I probably should cut 1.0 of performs, it's ridiculously stable.
VERSIONS:
0.3.2 December 16, 2024 (11.5 KB)
0.3.1 May 27, 2024 (10.5 KB)
0.3.0 November 04, 2023 (9.5 KB)
0.2.0 January 12, 2023 (9 KB)
0.1.1 September 27, 2022 (8.5 KB)
000
Kasper Timm Hansen @kaspth.com · 16/12/2024
I've been polishing up the github.com/kaspth/activ... README. You can now see all the benefits and more praise quotes now that people are seeing this in practice — I'm really proud of where this is going! 🚀
ActiveJob::Performs
ActiveJob::Performs adds a performs class method to make the model + job loop vastly more conventional. You use it like this:

class Post < ApplicationRecord
  performs :publish
  # Or `performs def publish`!

  def publish
    # Some logic to publish a post
  end
end
Then we build a job for the instance method and define a post.publish_later instance method, and more:

class Post < ApplicationRecord
  class Job < ApplicationJob; end # We build a general Job class to share configuration between method jobs.

  # Individual method jobs inherit from the `Post::Job` defined above.
  class PublishJob < Job
    # We generate the required `perform` method passing in the `post` and calling `publish` on it.
    def perform(post, *, **) = post.publish(*, **)
  end

  # On Rails 7.1, where `ActiveJob.perform_all_later` exists, we also generate
  # a bulk method to enqueue many jobs at once. So you can do this:
  #
  #   Post.unpublished.in_batches.each(&:publish_later_bulk)
  def self.publish_later_bulk
    ActiveJob.perform_all_later all.map { PublishJob.new(_1) }
  end

  # We generate `publish_later` to wrap the job execution forwarding arguments and options.
  def publish_later(*, **) = PublishJob.perform_later(self, *, **)

  def publish
    # Some logic to publish a post.
  end
endBenefits

Conventional Jobs: they'll now mostly call instance methods like publish_later -> publish.
Follows Rails' internal conventions: this borrows from ActionMailbox::InboundEmail#process_later calling process and ActionMailer::Base#deliver_later calling deliver.
Clarity & less guess work: the _later methods standardize how you call jobs throughout your app, so you can instantly tell what's happening.
Less tedium: getting an instance method run in the background is just now a performs call with some potential configuration.
Fewer files to manage: you don't have to dig up something in app/jobs just to learn almost nothing from the boilerplate in there.
Remaining jobs stand out: app/jobs is way lighter, so any jobs in there that don't fit the performs pattern now stand out way more.
More consolidated logic: sometimes Job classes house model-level logic, but now it's all the way out in app/jobs instead of app/models, huh?
Tip

On that last point, performs does put more logic back within your Active Records, so if you need further encapsulation to prevent them growing too large, consider checking out active_record-associated_object.Praise from people

Here's what @claudiob had to say after using ActiveJob::Performs:

I’ve been using active_job-performs for the last month and I love it love it love it!!

Your thought process behind it is so thorough. I have a bunch of jobs now attached to models and my app/jobs folder… is empty!!

This saves me a lot of mental hoops, I don’t have to switch between files anymore, everything is self-contained. Thank you!!!
From @andycroll in a writeup about launching UsingRails:

I’ve also adopted a couple of gems—with exceptional Rails-level taste and author pedigree—that I hadn’t used in anger before, including active_job-performs from Kasper […]. Would recommend both.
And @nshki after trying it:

Spent some time playing with @kaspth's ActiveRecord::AssociatedObject and ActiveJob::Performs and wow! The conventions these gems put in place help simplify a codebase drastically. I particularly love ActiveJob::Performs—it helped me refactor out all ApplicationJob classes I had and keep important context in the right domain model.
194
Kasper Timm Hansen @kaspth.com · 13/11/2024
@garrettdimon.com you're here in spirit 😄
Cans of Garrett popcorn
170
Kasper Timm Hansen @kaspth.com · 10/11/2024
I find bio writing tough, but now it's hopefully a little more inviting ✨
Full-stack Rails consultant, who's interested in clear teams and concise Ruby👨‍🍳

• former Rails core
• try riffing 🎸 https://github.com/kaspth/riffing-on-rails
• 💎 https://rubygems.org/profiles/kaspth
+ services + newsletter + more: https://kaspth.omg.lol
1150