Youtube transcript summary through Claude 4.5 https://www.youtube.com/watch?v=dvPXFnX60cg
How vanilla Rails, domain-driven design, and strategic simplicity power modern Rails apps
When 37signals launched Fizzy (their take on a Kanban tool) last week and open-sourced the entire codebase, they gave the Rails community a rare gift: a working example of how they architect production applications. Jorge Manrubia from 37signals recently walked through their approach in a detailed video, revealing patterns that challenge common wisdom about service objects, concerns, and Rails architecture.
This is part of Jorge's "Code I Like" series on the 37signals dev blog, where he's been documenting these architectural principles. The central article, "Vanilla Rails is plenty," anchors the discussion with expanded pieces on specific aspects of the approach.
"Vanilla Rails" at 37signals means two things:
- Prefer Rails defaults over third-party dependencies - Use Rails view helpers instead of ViewComponent, Minitest instead of RSpec
- Don't add architectural abstractions beyond what Rails and Ruby provide - No service layers, no custom base classes, just models, controllers, and concerns
This isn't dogma—it's pragmatism. Rails already provides powerful abstractions. Why add more?
The core principle: place your domain model at the center of your application. This is straight from Eric Evans' Domain-Driven Design—a book that heavily influenced DHH and the Rails philosophy, written in the early 2000s before Rails even existed.
Jorge emphasizes that the 37signals approach "is not that radical or revolutionary." It's about making your code evoke the problem you're trying to solve. In a Kanban tool, the domain model should reflect the behaviors and nouns used when discussing the product.
In Fizzy, when you drag a card to the "Triage" column, the controller does this:
# app/controllers/drops/streams_controller.rb
# POST /cards/7/drops - dropping card #7 into triage column
def create
@card.send_back_to_triage
# ...
endOne line. The business logic lives in the domain model where it belongs, not scattered across service layers.
Here's where things get spicy. Service objects in DDD are meant to orchestrate domain entities, not contain business logic. Jorge credits this understanding to David Heinemeier Hansson and Jeffrey Hardy, emphasizing this is an approach learned at 37signals, not invented by him.
If you follow DDD properly, a service object would look like this:
# Anti-pattern - just wrapping a single method call
class CardSendBackToTriageService
def initialize(card)
@card = card
end
def call
@card.send_back_to_triage
end
endWhy create a whole class to wrap one line? The Rails controller already does that orchestration job. Service objects and controllers fulfill the exact same role at different layers—connecting the external world with your domain model.
The real problem: When developers use service objects to hold business logic instead of domain entities, they create anemic domain models—empty data holders with no behavior. This anti-pattern was highlighted by Martin Fowler in his seminal 2003 article "Anemic Domain Models" (written before Rails was even published) and discussed in every DDD book Jorge has read.
The result: a flat list of small operations with poor code reuse and tight coupling between services. "It's going to be very, very messy," Jorge warns.
37signals' approach:
Most common case (simple CRUD):
# app/controllers/comments_controller.rb
def update
@comment.update(comment_params)
# Pretty vanilla scaffolding Rails code
endOne or two lines of domain logic:
# app/controllers/drops/streams_controller.rb
def create
@card.send_back_to_triage
endMultiple operations:
# app/controllers/bolts_controller.rb
def update
@bolt.update(bolt_params)
@bolt.accesses.revise(granted: params[:granted], revoked: params[:revoked])
endDomain operations without clear entity ownership:
# app/controllers/sessions_controller.rb
def create
Signup.create_identity(email, password)
# Not SignupService.call - use semantic names
endNote the semantic naming: Signup.create_identity, not SignupService.call. More readable, more intentional. "We prefer to use more semantic terms," Jorge explains. "That sounds better for us."
Rails concerns get a bad rap, but 37signals uses them strategically in two ways:
Central entities like Card and Bolt have lots of behavior. Concerns slice that behavior into cohesive units—what Jorge calls "traits or roles":
# app/models/card.rb
class Card < ApplicationRecord
include Triageable
include Postponable
include Stallable
# ...existing code...
endEach concern groups related behavior—scopes, associations, and methods—making large APIs maintainable. The Triageable concern keeps all triage-related logic together.
High-level module cohesion example:
# app/models/concerns/card/triageable.rb
module Card::Triageable
extend ActiveSupport::Concern
included do
scope :triage, -> { active.where.not(column: nil) }
end
def triage?
active? && column.present?
end
# ...existing code...
end"It's very, very handy that they are together, right?" Jorge notes. "It makes sense for me as a human who is trying to alter the system or trying to understand the system. That's cohesiveness of the kind you want to see in your codebase."
Important: Not all behavior goes in concerns. Central entities organize primary behavior in the main class file. Concerns handle secondary traits or roles. Smaller models like Identity might only mix in two concerns—that's totally fine.
# app/models/concerns/notifiable.rb
module Notifiable
extend ActiveSupport::Concern
included do
after_create :notify_recipients_later
end
def notify_recipients
Notifier.for(self).notify_recipients
end
endBoth Mention and Event include Notifiable, sharing notification logic without inheritance. At the controller layer, concerns like CardScoped inject common before_action filters across multiple controllers.
Critical insight: Concerns don't replace object-oriented design—they complement it.
"Sometimes when folks talk about concerns, they kind of oppose concerns to other techniques as if they were opposing alternatives," Jorge explains. "Should you use concerns or should you use object composition? The thing is that concerns are a fantastic way of organizing large API surfaces in a cohesive way, but concerns play very well with object-orientation techniques."
The Notifiable concern provides a clean interface, but delegates to specialized objects:
# app/models/concerns/notifiable.rb
def notify_recipients
Notifier.for(self).notify_recipients
end
# app/models/notifier.rb - Template method pattern
class Notifier
def self.for(source)
"#{source.class}Notifier".constantize.new(source)
end
def notify_recipients
# Uses recipients template method defined by subclasses
end
end
# app/models/mention_notifier.rb
class MentionNotifier < Notifier
def recipients
# Mention-specific logic for who gets notified
end
endTemplate method pattern, object hierarchy, everything hidden behind a high-level interface organized with a concern. "Both legs are important," Jorge emphasizes. "Concerns for organizing the code and proper object-oriented techniques and patterns for trying to get the code organized, understandable, maintainable."
Another example from Card::Stallable:
# app/models/concerns/card/stallable.rb
# ...existing code...
after_update :detect_activity_spikes_later
def detect_activity_spikes
ActivitySpikeDetector.new(self).detect
endThe concern provides the interface. ActivitySpikeDetector handles the complexity. "You really need to use additional systems of objects to keep your code maintainable because you don't want to see concerns with thousands of lines of code," Jorge notes.
Callbacks are controversial because they introduce indirection. "You don't want to orchestrate complex flows with callbacks," Jorge cautions. "You can find yourself in complex debugging sessions trying to understand what's going on."
But the conclusion is not "never use callbacks." The conclusion is: use callbacks when they're the best tool for the job.
# app/models/concerns/card/stallable.rb
after_update :detect_activity_spikes_later
# app/models/concerns/notifiable.rb
after_create :notify_recipients_later"Forget about Rails, forget about the specifics of the technology," Jorge suggests. "If as a human you're saying 'I want to detect an activity spike whenever the card changes' or 'I want to notify folks whenever a notifiable object changes,' then callback is a perfect fit."
Callbacks are fantastic when they're the right tool. They ensure behavior runs at the right time without explicit orchestration. If you introduce a new way of commenting on a card, the activity detection system keeps working automatically.
Explicit orchestration example:
# app/models/concerns/card/triageable.rb
def send_back_to_triage
# ...existing code...
track_event(:card_sent_back_to_triage) # Explicit tracking
end"The fact that we have callbacks available does not mean that we use callbacks all the time," Jorge clarifies. "We are mindful about when to use callbacks. But when they are the right tool, they are an amazing tool to have and to leverage."
Jorge walks through actual Fizzy code to show these principles in action:
Controller orchestration (most typical):
# app/controllers/drops/streams_controller.rb
# User drags card #7 to triage column
# POST /cards/7/drops
def create
@card.send_back_to_triage
# Single line exercising domain logic
endSimple CRUD (direct Active Record):
# app/controllers/comments_controller.rb
def create
@comment = @card.comments.create(comment_params)
# Totally fine for simple operations
end
def update
@comment.update(comment_params)
endMultiple operations:
# app/controllers/bolts_controller.rb
def update
@bolt.update(bolt_params)
@bolt.accesses.revise(granted: params[:granted], revoked: params[:revoked])
# Two lines - still fine in controller
# Could extract to service object if you want
# Could extract to form object if you want
# But please don't implement domain logic in those objects
endJobs (thin, exercising domain model):
# app/jobs/notify_recipients_job.rb
def perform(notifiable)
notifiable.notify_recipients
# Same pattern - thin boundary, thick domain model
end
# app/jobs/detect_activity_spikes_job.rb
def perform(card)
card.detect_activity_spikes
# Exercising domain logic from system boundaries
end"This is like a controller to us," Jorge explains about jobs. "We want to see like a thin job just invoking some domain entity code. Same if we were in a Rails console, same if we were in a script. That's what we want to see."
No service layer. No form objects unless they genuinely simplify things. Just thin controllers and jobs exercising a rich domain model.
Jorge emphasizes repeatedly: "I think the credit should go to David [Heinemeier Hansson] and other amazing programmers in the company such as Jeffrey Hardy. I'm just trying to articulate what I understand the 37signals approach is."
37signals' approach isn't revolutionary—it's disciplined application of established principles:
- Domain model first - Put business logic in domain entities, not service layers
- Controllers orchestrate - Keep them thin, exercising domain behavior directly
- Concerns organize - Slice large APIs into cohesive modules (traits/roles)
- Objects compose - Use OOP patterns underneath clean interfaces
- Callbacks for events - Model actual domain events, not control flow
- Plain Ruby objects welcome - Active Record and POROs coexist naturally
- Semantic naming -
Signup.create_identitybeatsSignupService.call
"It's hard to offer precise advice about how to proceed because we are balancing a lot of things when we are designing software," Jorge admits. "Designing software is very hard. But I think that's a good general idea."
The genius is in what they don't do: add layers of abstraction that provide little value while increasing complexity. "We care about reducing solutions to their essence," Jorge notes when discussing service objects as potential boilerplate.
Rails gives you powerful tools. Learn to use them well before adding more.
The full Fizzy source code is available on GitHub with complete commit history and pull requests. Want to see these patterns in action? Clone it and explore. For more on these topics, read Jorge's "Code I Like" series on the 37signals dev blog, starting with "Vanilla Rails is plenty."