Skip to content

Instantly share code, notes, and snippets.

@jch
Last active December 21, 2025 07:06
Show Gist options
  • Select an option

  • Save jch/7e4f0c1c63459a89a59b9b5797c1afed to your computer and use it in GitHub Desktop.

Select an option

Save jch/7e4f0c1c63459a89a59b9b5797c1afed to your computer and use it in GitHub Desktop.
Architecting Rails Applications: Lessons from 37signals and Fizzy

Youtube transcript summary through Claude 4.5 https://www.youtube.com/watch?v=dvPXFnX60cg

Architecting Rails Applications: Lessons from 37signals and Fizzy

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.

The Vanilla Rails Philosophy

"Vanilla Rails" at 37signals means two things:

  1. Prefer Rails defaults over third-party dependencies - Use Rails view helpers instead of ViewComponent, Minitest instead of RSpec
  2. 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?

Domain Model at the Center

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
  # ...
end

One line. The business logic lives in the domain model where it belongs, not scattered across service layers.

The Service Object Controversy

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
end

Why 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
end

One or two lines of domain logic:

# app/controllers/drops/streams_controller.rb
def create
  @card.send_back_to_triage
end

Multiple operations:

# app/controllers/bolts_controller.rb
def update
  @bolt.update(bolt_params)
  @bolt.accesses.revise(granted: params[:granted], revoked: params[:revoked])
end

Domain operations without clear entity ownership:

# app/controllers/sessions_controller.rb
def create
  Signup.create_identity(email, password)
  # Not SignupService.call - use semantic names
end

Note 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."

Concerns: The Misunderstood Hero

Rails concerns get a bad rap, but 37signals uses them strategically in two ways:

1. Organizing Large Domain Models

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...
end

Each 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.

2. Code Reuse Across Models

# 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
end

Both 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.

Concerns + Object Composition

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
end

Template 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
end

The 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: Use Them Wisely

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."

What This Looks Like in Practice

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
end

Simple 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)
end

Multiple 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
end

Jobs (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.

The Takeaway

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:

  1. Domain model first - Put business logic in domain entities, not service layers
  2. Controllers orchestrate - Keep them thin, exercising domain behavior directly
  3. Concerns organize - Slice large APIs into cohesive modules (traits/roles)
  4. Objects compose - Use OOP patterns underneath clean interfaces
  5. Callbacks for events - Model actual domain events, not control flow
  6. Plain Ruby objects welcome - Active Record and POROs coexist naturally
  7. Semantic naming - Signup.create_identity beats SignupService.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."

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment