1.4.1

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