Callbacks
Preview ecosystem guide: Grant is not part of the Amber 2.0.0-beta.2 core web-app release gate. Its package version, API, and platform support may change independently. Do not add a personal fork as a default dependency.
Callbacks are methods that get called at certain moments of an object's lifecycle. They allow you to trigger logic before or after alterations to your model's state.
Available Callbacks
Create Callbacks
class User < Grant::Base
before_validation :set_defaults # 1. First callback
# validations run here # 2. Validations
after_validation :process_validated_data # 3. After validation
before_save :before_save_tasks # 4. Before save (create or update)
before_create :before_create_tasks # 5. Before create specifically
# INSERT happens here # 6. Database insert
after_create :after_create_tasks # 7. After create
after_save :after_save_tasks # 8. After save (create or update)
after_commit :after_commit_tasks # 9. After transaction commits
end
Update Callbacks
class Product < Grant::Base
before_validation :normalize_data # 1. First callback
# validations run here # 2. Validations
after_validation :process_changes # 3. After validation
before_save :before_save_tasks # 4. Before save
before_update :before_update_tasks # 5. Before update specifically
# UPDATE happens here # 6. Database update
after_update :after_update_tasks # 7. After update
after_save :after_save_tasks # 8. After save
after_commit :after_commit_tasks # 9. After transaction commits
end
Destroy Callbacks
class Comment < Grant::Base
before_destroy :cleanup_associations # 1. Before destroy
# DELETE happens here # 2. Database delete
after_destroy :log_deletion # 3. After destroy
after_commit :notify_deletion # 4. After transaction commits
end
Callback Registration
Method Symbols
class Article < Grant::Base
before_save :sanitize_content
after_create :publish_to_feed
private def sanitize_content
self.content = Sanitizer.clean(content)
end
private def publish_to_feed
FeedService.publish(self) if published?
end
end
Blocks
class Order < Grant::Base
before_save do
self.total = calculate_total
end
after_create do
OrderMailer.confirmation(self).deliver_later
end
end
Conditional Callbacks
class Post < Grant::Base
# With symbol conditions
before_save :update_slug, if: :title_changed?
after_create :notify_subscribers, if: :published?
# With proc conditions
before_destroy :archive_content,
if: ->(post : Post) { post.views > 1000 }
# Multiple conditions
after_save :clear_cache,
if: :published?,
unless: :draft?
end
Common Callback Patterns
Data Normalization
class User < Grant::Base
before_validation :normalize_fields
column email : String
column phone : String?
column name : String
private def normalize_fields
self.email = email.downcase.strip
self.phone = phone.try(&.gsub(/\D/, ""))
self.name = name.split.map(&.capitalize).join(" ")
end
end
Setting Defaults
class Document < Grant::Base
before_create :set_defaults
column uuid : String
column version : Int32
column status : String
private def set_defaults
self.uuid ||= UUID.random.to_s
self.version ||= 1
self.status ||= "draft"
end
end
Generating Tokens
class Session < Grant::Base
before_create :generate_token
column token : String
column expires_at : Time
private def generate_token
loop do
self.token = Random::Secure.hex(32)
break unless Session.exists?(token: token)
end
self.expires_at = 24.hours.from_now
end
end
Slug Generation
class Article < Grant::Base
before_save :generate_slug
column title : String
column slug : String
private def generate_slug
return unless title_changed?
base_slug = title.downcase.gsub(/[^a-z0-9]+/, "-")
self.slug = base_slug
counter = 1
while Article.exists?(slug: slug)
self.slug = "#{base_slug}-#{counter}"
counter += 1
end
end
end
Audit Trails
class AuditableModel < Grant::Base
after_create :log_create
after_update :log_update
after_destroy :log_destroy
private def log_create
AuditLog.create!(
model: self.class.name,
record_id: id,
action: "create",
user_id: Current.user_id,
changes: attributes.to_json
)
end
private def log_update
return unless changes.any?
AuditLog.create!(
model: self.class.name,
record_id: id,
action: "update",
user_id: Current.user_id,
changes: changes.to_json
)
end
end
Cache Management
class Product < Grant::Base
after_save :clear_cache
after_destroy :clear_cache
private def clear_cache
Cache.delete("product:#{id}")
Cache.delete("category:#{category_id}:products")
end
end
Halting Execution
Throwing :abort
class Order < Grant::Base
before_save :check_inventory
private def check_inventory
if total_items > available_stock
errors.add(:items, "Insufficient inventory")
throw :abort # Halts execution
end
end
end
Preventing Destruction
class User < Grant::Base
before_destroy :prevent_admin_deletion
private def prevent_admin_deletion
if admin? && User.where(admin: true).count == 1
errors.add(:base, "Cannot delete the last admin")
throw :abort
end
end
end
Transaction Callbacks
after_commit
Runs after the database transaction successfully commits:
class Order < Grant::Base
after_commit :send_confirmation, on: :create
after_commit :update_inventory, on: :update
private def send_confirmation
# Safe to send email - transaction committed
OrderMailer.confirmation(self).deliver_later
end
private def update_inventory
# Safe to call external services
InventoryService.sync(self)
end
end
after_rollback
Runs if the database transaction is rolled back:
class Payment < Grant::Base
after_rollback :log_failure
private def log_failure
Log.error { "Payment #{id} failed: #{errors.full_messages}" }
end
end
Performance Considerations
Keep Callbacks Fast
class Post < Grant::Base
# Bad: Synchronous external call
after_create :notify_external_service
private def notify_external_service
HTTPClient.post("https://api.example.com/webhook", body: to_json)
end
# Good: Queue for background processing
after_create :queue_notification
private def queue_notification
NotificationJob.perform_later(self.id)
end
end
Use Conditional Callbacks
class User < Grant::Base
# Only run expensive callbacks when necessary
after_save :sync_to_crm, if: :crm_fields_changed?
private def crm_fields_changed?
(changes.keys & ["email", "name", "company"]).any?
end
end
Skipping Callbacks
# Skip callbacks when needed
user.save(skip_callbacks: true)
# Bulk operations skip callbacks
User.update_all(active: false)
user.update_columns(name: "New") # Direct SQL, no callbacks
Best Practices
1. Keep Callbacks Simple
# Good: Single responsibility
before_save :normalize_email
before_save :hash_password
before_save :set_defaults
# Bad: Doing too much
before_save :do_everything
2. Use Appropriate Callback
# Good: after_commit for external services
after_commit :send_email
# Bad: after_save might run even if rolled back
after_save :send_email
3. Consider Service Objects
# Instead of complex callbacks
class User < Grant::Base
after_create :setup_user_account
private def setup_user_account
UserAccountSetupService.new(self).perform
end
end
class UserAccountSetupService
def initialize(@user : User)
end
def perform
create_profile
send_welcome_email
assign_default_role
end
end