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.