Use when writing background jobs or async operations - enforces thin job wrappers (3-5 lines) that delegate to models using _later/_now naming pattern
/plugin marketplace add ZempTime/zemptime-marketplace/plugin install vanilla-rails@zemptime-marketplaceThis skill inherits all available tools. When active, it can use any tool Claude has access to.
Jobs are thin wrappers (3-5 lines). ALL business logic lives in models.
# Model concern - WHERE THE LOGIC LIVES
module Card::ClosureNotifications
extend ActiveSupport::Concern
included do
after_update :notify_watchers_later, if: :just_closed?
end
# _later: Enqueues the job
def notify_watchers_later
Card::ClosureNotificationJob.perform_later(self)
end
# _now: Contains ALL business logic
def notify_watchers_now
watchers.each do |watcher|
CardMailer.closure_notification(watcher, self).deliver_now
Notification.create!(user: watcher, card: self, action: 'closed')
end
end
private
def just_closed?
saved_change_to_status? && closed?
end
end
# Job - ONLY delegates (3 lines)
class Card::ClosureNotificationJob < ApplicationJob
def perform(card)
card.notify_watchers_now
end
end
Testability: Test _now synchronously (no job infrastructure needed)
Reusability: Call _now in console, tests, anywhere
Debuggability: Stack traces point to model, not job framework
| Method | Purpose |
|---|---|
action_later | Enqueues job |
action_now | Actual logic (called by job, ALWAYS create for testing) |
action | No async version |
Flow: Callback → _later → enqueue job → job calls _now → logic executes
If you see ANY of these, you're doing it wrong:
retry_on)_later/_now namingretry_on/discard_on_now method ("I don't need it")ALL of these mean: Move logic to model. Job should only delegate.
| Wrong | Right | Why |
|---|---|---|
| Logic in job | Logic in model | Jobs = thin wrappers |
perform(card_id) then Card.find | perform(card) | Let ActiveJob serialize |
| 20+ line job | 3-5 line job | Logic belongs in domain model |
send_notifications | send_notifications_later | Naming shows async intent |
| Job has conditionals | Model has conditionals | Domain logic in domain model |
class Card::ClosureNotificationJob < ApplicationJob
def perform(card_id, closer_id)
card = Card.find(card_id)
card.watchers.each do |watcher|
CardMailer.closure_notification(watcher, card).deliver_now
Notification.create!(user: watcher, card: card, action: 'closed')
end
card.update!(last_notification_sent_at: Time.current)
end
end
Problems: 12 lines of logic, re-queries by ID, hard to test, not reusable, no _later/_now
# Job (3 lines)
class Card::ClosureNotificationJob < ApplicationJob
def perform(card)
card.notify_watchers_now
end
end
# Model (where logic belongs)
def notify_watchers_later
Card::ClosureNotificationJob.perform_later(self)
end
def notify_watchers_now
watchers.each do |watcher|
CardMailer.closure_notification(watcher, self).deliver_now
Notification.create!(user: watcher, card: self, action: 'closed')
end
update_column(:last_notification_sent_at, Time.current)
end
Benefits: Job is 3 lines, testable without jobs, reusable in console
| Excuse | Reality |
|---|---|
| "Jobs are meant to contain async work logic" | Jobs are infrastructure. Models contain business logic. |
| "Notification logic belongs in notification job" | Domain logic belongs in domain models, not infrastructure. |
| "Models shouldn't know about email delivery" | Models orchestrate their domain. Mailers handle delivery details. |
| "This follows separation of concerns" | Concern = business vs infrastructure, not job vs model. |
| "The _later/_now pattern adds indirection" | It adds clarity and reusability. Worth it. |
| "Most Rails apps structure jobs this way" | We follow vanilla Rails: rich models, thin everything else. |
| "30 lines is small for a job" | 30 lines is huge. Jobs should be 3-5 lines. |
| "Keeps models thin" | Models should be rich. Jobs should be thin. |
| "This spans multiple models, no natural home" | Primary model orchestrates. See multi-model example. |
| "This is a utility job, no model exists" | Use class methods on relevant model. See cleanup example. |
| "Error handling belongs in jobs" | Use ActiveJob retries. Domain errors in models. |
| "I don't need _now for this" | You need it for testing. Always create _now. |
| "This calls external APIs, not domain logic" | API integration IS domain logic. Model orchestrates. |
class User::DigestJob < ApplicationJob
def perform(user); user.send_digest_now; end
end
def send_digest_now
cards = boards.flat_map { |b| b.cards.mine(self) }
DigestMailer.send(self, cards).deliver_now
end
class Session::CleanupJob < ApplicationJob
def perform; Session.cleanup_expired_now; end
end
def self.cleanup_expired_now
where("created_at < ?", 30.days.ago).delete_all
end
class Card::SyncJob < ApplicationJob
retry_on ExternalAPI::Error, wait: 5.minutes
def perform(card); card.sync_to_external_system_now; end
end
def sync_to_external_system_now
ExternalAPI.update_task(external_id, attributes)
rescue ExternalAPI::Error => e
errors.add(:base, "Sync failed: #{e.message}")
raise
end
STOP if you're thinking:
ALL of these mean: You're about to write a fat job. Stop. Put logic in model.
Good job checklist:
_later method_now method with logic (ALWAYS, even if only async path)If ANY checkbox fails, refactor: move logic to model.
Build robust backtesting systems for trading strategies with proper handling of look-ahead bias, survivorship bias, and transaction costs. Use when developing trading algorithms, validating strategies, or building backtesting infrastructure.