3 min read

Looping Design Principle: Events Over Callbacks

When I started building the base framework for Looping, which I call Envoy, I defined a set of design principles to guide its development. This is something I highly recommend doing—it refines your thinking, helps you avoid settling on implementations that feel “off,” and encourages simplicity, abstraction, and reusability for long-term maintainability.

Before diving into code, let’s step back and look at Looping’s stack.

Looping's Software Stack

Over the years, I’ve worked with many languages and frameworks, but I keep coming back to Ruby—it simply brings me joy to write. Looping is a single-page application (SPA) with a Rails backend and a Vue frontend. While I’m cautious about SPA overuse, don’t personally like React, and see the appeal of Web Components, I wanted to experiment with Vue early on—and I ended up really enjoying it.

Events Over Callbacks

Each Looping design principle has a short, memorable title followed by a more detailed explanation. In this case, the idea is to explicitly avoid callbacks, but what does that actually mean in practice?

It’s really about defining the proper role of models in the application—what they should and shouldn’t do:

Models are for schemas, validations, and normalizing/generating attribute values. Callbacks aren’t used. Model actions are triggered by events.

Here’s what that looks like in Looping’s ApplicationRecord:

# Purpose: Parent class for all models in the application.
class ApplicationRecord < ActiveRecord::Base
  primary_abstract_class

  after_create do
    publish(:create)
  end

  after_update do
    publish(:update)
  end

  after_destroy do
    publish(:delete)
  end

  def publish(action)
    EventPublisherService.call({ object: self, action: action })
  end

  def as_event
    PresenterService.call({ object: self, presenter: 'Event' }).value!
  end

  def as_json(_ = nil)
    PresenterService.call({ object: self, presenter: 'JSON' }).value!
  end
end

If you’re yelling, “Wait, those are callbacks!”—yes, they are. But these are the only callbacks used in Looping. Events still need to be published, and this ensures every model in the system consistently emits them.

To be clear, Looping doesn’t currently use an event bus or distributed system, but this setup makes that potential future transition much easier by embracing the pattern. Right now, events are serialized into a standardized JSON structure and processed asynchronously by a Sidekiq EventJob, which routes them downstream.

Since all models inherit from ApplicationRecord, this applies to everything—including User:

# == Schema Information
#
# Table name: users
#
#  id            :uuid             not null, primary key
#  deleted_at    :datetime
#  email         :string           not null
#  name          :string
#  password_hash :string           not null
#  created_at    :datetime         not null
#  updated_at    :datetime         not null
#
# Indexes
#
#  index_users_on_email  (email) UNIQUE
#
class User < ApplicationRecord
  has_many :memberships, dependent: :destroy
  has_many :teams, through: :memberships

  has_many :organizations, through: :teams

  has_many :preferences, as: :owner, dependent: :destroy

  attr_accessor :password, :password_confirmation

  validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :email, presence: true
  validates :email, uniqueness: true

  validates :password, confirmation: true
  validates :password, length: { minimum: 4, allow_blank: true }
  validates :password, presence: { on: :create }
  validates :password_confirmation, presence: { if: ->(user) { user.password.present? } }

  before_save :encrypt_password

  def encrypt_password
    return if password.blank?

    self.password_hash = BCrypt::Password.create(password)
  end
end

Avoiding Callbacks in Models

One of the main reasons I avoid callbacks is that they make code harder to follow. Callbacks create implicit side effects, which can be difficult to trace when debugging or making changes. A model might seem to work fine in isolation, but once callbacks start chaining together, understanding the full execution flow becomes a challenge.

This is why I limit callbacks to publishing events only. Instead of relying on callbacks to trigger business logic, I prefer explicitly invoking service objects that handle specific tasks. This approach keeps behavior predictable and easier to reason about.

Avoiding Business Logic in Models

Just as callbacks can make code harder to follow, embedding business logic directly in models leads to bloated “god objects” that are difficult to read, maintain, and test.

Instead, I structure business logic into small, composable service objects, each with a clear and limited purpose. This makes the system:

  • Explicit – Each service does one thing and is easy to understand.
  • Easier to test – Isolated service objects can be tested independently.
  • Easier to update – Changes to one business rule don’t require modifying deeply entangled models.

By keeping models focused on data representation and validation, and delegating behavior to service objects, Looping remains maintainable, scalable, and developer-friendly.


In future posts, I’ll dive deeper into Looping’s Envoy framework, event-driven patterns, and approach to composable services.