Keep Jobs Thin
Jobs should be thin wrappers that receive records and call model methods. All business logic belongs in the model layer.
Why
- Testability: Model methods can be unit tested without job infrastructure
- Reusability: The same logic can be called sync or async
- Debuggability: Easier to trace issues when logic isn't buried in jobs
- Consistency: Models are the single source of truth for domain logic
Bad: Business Logic in Jobs
class ProcessOrderJob < ApplicationJob
def perform(order)
return if order.processed?
order.transaction do
order.line_items.each do |item|
item.product.decrement!(:stock, item.quantity)
end
order.update!(
status: :processing,
processed_at: Time.current
)
order.payments.pending.each(&:capture!)
end
OrderMailer.confirmation(order).deliver_later
WebhookService.notify(:order_processed, order)
end
endProblems:
- Can't test processing logic without jobs
- Can't process synchronously when needed
- Logic is hidden from model/domain layer
Good: Jobs Delegate to Models
# app/jobs/process_order_job.rb
class ProcessOrderJob < ApplicationJob
discard_on ActiveJob::DeserializationError
def perform(order)
order.process
end
end
# app/models/order.rb
class Order < ApplicationRecord
def process
return if processed?
transaction do
decrement_stock
mark_as_processing
capture_payments
end
send_confirmation
notify_webhooks
end
def process_later
ProcessOrderJob.perform_later(self)
end
private
def decrement_stock
line_items.each do |item|
item.product.decrement!(:stock, item.quantity)
end
end
def mark_as_processing
update!(status: :processing, processed_at: Time.current)
end
def capture_payments
payments.pending.each(&:capture!)
end
def send_confirmation
OrderMailer.confirmation(self).deliver_later
end
def notify_webhooks
WebhookService.notify(:order_processed, self)
end
endJob Responsibilities
Jobs should ONLY:
- Receive arguments (records, simple values)
- Call a single model method
- Handle job-specific concerns (retries, discards, queues)
class Card::ActivitySpike::DetectionJob < ApplicationJob
discard_on ActiveJob::DeserializationError
def perform(card)
card.detect_activity_spikes # Single method call
end
end
class Notification::Bundle::DeliverJob < ApplicationJob
include SmtpDeliveryErrorHandling # Job concern for retries
queue_as :backend
discard_on ActiveJob::DeserializationError
def perform(bundle)
bundle.deliver # Single method call
end
endWhen Jobs Can Have More Logic
Batch Operations
Jobs that process collections may have iteration logic:
class DeleteUnusedTagsJob < ApplicationJob
def perform
Tag.unused.find_each do |tag|
tag.destroy!
end
end
endBut even here, consider a class method:
# Better: model class method
class Tag < ApplicationRecord
def self.delete_unused
unused.find_each(&:destroy!)
end
end
class DeleteUnusedTagsJob < ApplicationJob
def perform
Tag.delete_unused
end
endKeyword Arguments
Jobs can accept keyword arguments alongside records:
class Mention::CreateJob < ApplicationJob
discard_on ActiveJob::DeserializationError
def perform(record, mentioner:)
record.create_mentions(mentioner: mentioner)
end
endJob Naming Convention
Jobs should be namespaced to mirror the model they operate on:
| Model/Concern | Job |
|---|---|
Card::Accessible |
Card::CleanInaccessibleDataJob |
Card::Stallable |
Card::ActivitySpike::DetectionJob |
Storage::Totaled |
Storage::MaterializeJob |
Notification::Bundle |
Notification::Bundle::DeliverJob |
Webhook::Delivery |
Webhook::DeliveryJob |
Rules
- Jobs call one method on the received record
- All business logic lives in models
- Jobs handle only job-specific concerns (queues, retries, error handling)
- Namespace jobs to mirror model structure
- Always include
discard_on ActiveJob::DeserializationError