@davetron5000

Your Service Layer
Needn't be Fancy,
It Just Needs to Exist

David Copeland (he/him)

CTO, Mood Health


class Widget < ApplicationRecord
  belongs_to :manufacturer
  validates  :price,
              numericality: { greater_than: 0 }
end
          

class WidgetsController < ApplicationController
  def create
    @widget = Widget.new(widget_params)
    if @widget.save
      redirect_to widgets_path
    else
      render :new
    end
  end
end
          

class WidgetsController < ApplicationController
  def create
    @widget = Widget.new(widget_params)
    if @widget.save
      PreOrderJob.perform_later(@widget.id)
      redirect_to widgets_path
    else
      render :new
    end
  end
end
          

class WidgetsController < ApplicationController
  def create
    @widget = Widget.new(widget_params)
    if @widget.save
      if @widget.price > 1_000
        AdminMailer.new_widget(@widget)
      else
        PreOrderJob.perform_later(@widget.id)
      end
      redirect_to widgets_path
    else
      render :new
    end
  end
end
          

class WidgetsController < ApplicationController
  def create
    @widget = Widget.new(widget_params)
    if @widget.save
      if @widget.manufacturer.legacy?
        SalesNotification.create!(widget: @widget)
      elsif @widget.price > 1_000
        AdminMailer.new_widget(@widget)
      else
        PreOrderJob.perform_later(@widget.id)
      end
      redirect_to widgets_path
    else
      render :new
    end
  end
end
          

Controllers

  • Don't instantiate
  • Don't call their methods
  • Methods take no arguments
  • Return value is ignored

Testing Controllers

  • System test (slow, flaky)
  • Integration test (cumbersome, inaccurate simulation)

class Widget < ActiveRecord::Base
  after_create do
    if self.manufacturer.legacy?
      SalesNotification.create!(widget: self)
    elsif self.price > 1_000
      AdminMailer.new_widget(self)
    else
      PreOrderJob.perform_later(self.id)
    end
  end
end
          

Two Problems

  • All other tests have to know about this and adapt.
  • “Created widget on website” and “inserted row into WIDGETS table” are different things.

Treat Rails for what it is

not what you would like it to be


class Widget < ActiveRecord::Base
  after_create do
    if self.manufacturer.legacy?
      SalesNotification.create!(widget: self)
    elsif self.price > 1_000
      AdminMailer.new_widget(self)
    else
      PreOrderJob.perform_later(self.id)
    end
  end
end
          

Active Records

are models of database tables

after_create

allows running code after certain database operations

A controller

is for responding to HTTP

A mailer

is for sending email

A job

is for running code in the background

«???»

is for your app's business logic

Fat Model, Skinny Controller
Skinny Model, Skinny Controller
SOLID
Hexagons
Uncle Bob's nonsense

Fat Model, Skinny Controller
Skinny Model, Skinny Controller
SOLID
Hexagons
Uncle Bob's nonsense

We're talking about where code is supposed to go

How does Rails organize the code in your app?

Your App is Organized by

Architectural Component

 


> ls app
assets/           # Assets like CSS or Images
channels/         # Channels for Action Cable
controllers/      # HTTP
helpers/          # View helpers
jobs/             # Background jobs
mailers/          # Sending email
models/           # Database Access
views/            # HTML views
          

You Would Not Want This


> ls app
assets/           # Assets like CSS or Images and business logic
channels/         # Channels for Action Cable and business logic
controllers/      # HTTP                      and business logic
helpers/          # View helpers              and business logic
jobs/             # Background jobs           and business logic
mailers/          # Sending email             and business logic
models/           # Database Access           and business logic
views/            # HTML views                and business logic
          

Business logic

  • Not knowable in advance
  • Complex, easy to misremember or forget
  • Changes frequently

This Makes It Hard to Manage


> ls app
assets/           # Assets like CSS or Images and business logic
channels/         # Channels for Action Cable and business logic
controllers/      # HTTP                      and business logic
helpers/          # View helpers              and business logic
jobs/             # Background jobs           and business logic
mailers/          # Sending email             and business logic
models/           # Database Access           and business logic
views/            # HTML views                and business logic
          

Why do we do this?


> ls app
assets/           # Assets like CSS or Images
channels/         # Channels for Action Cable
controllers/      # HTTP
helpers/          # View helpers
jobs/             # Background jobs
mailers/          # Sending email
models/           # Database Access           and business logic
views/            # HTML views
          
Active Record is the M in MVC - the model - which is the layer of the system responsible for representing business data and logic. Active Record facilitates the creation and use of business objects whose data requires persistent storage to a database.
– Rails Guide

app/models

contains models of data

bin/rails g model

creates a thing to manage data in a database

Treat Rails for What it Is

Manage business logic as an architectural component

Though…it is more like a layer or seam

Of note

Not everyone is “in business”

“Service Layer”

The Best Term for Most Teams

  • Parts of Rails don't sound like “services”
  • Service could mean anything else

Your Service Layer Needn't be Fancy, It Just Needs to Exist


class WidgetsController < ApplicationController
  def create
    @widget = Widget.new(widget_params)
    if @widget.save
      if @widget.manufacturer.legacy?
        SalesNotification.create!(widget: @widget)
      elsif @widget.price > 1_000
        AdminMailer.new_widget(@widget)
      else
        PreOrderJob.perform_later(@widget.id)
      end
      redirect_to widgets_path
    else
      render :new
    end
  end
end
          

Ruby Language™ to the rescue!

class

def


class
  def
#
#
#
#
#
#
#
#
#
#
#
  end
end
          

class
  def              (widget_params)
    widget = Widget.new(widget_params)
    if widget.save
      if widget.manufacturer.legacy?
        SalesNotification.create!(widget: widget)
      elsif widget.price > 1_000
        AdminMailer.new_widget(widget)
      else
        PreOrderJob.perform_later(widget.id)
      end
    end
    widget
  end
end
          

class WidgetCreation
  def create_widget(widget_params)
    widget = Widget.new(widget_params)
    if widget.save
      if widget.manufacturer.legacy?
        SalesNotification.create!(widget: widget)
      elsif widget.price > 1_000
        AdminMailer.new_widget(widget)
      else
        PreOrderJob.perform_later(widget.id)
      end
    end
    widget
  end
end
          

Awesome Rails Feature

mkdir

> mkdir app/services
          


> ls app
assets/           # Assets like CSS or Images
channels/         # Channels for Action Cable
controllers/      # HTTP                     
helpers/          # View helpers             
jobs/             # Background jobs          
mailers/          # Sending email            
models/           # Database Access          
services/         # Business Logic           
views/            # HTML views               
          


> ls app
assets/           # Assets like CSS or Images and nothing else
channels/         # Channels for Action Cable and nothing else
controllers/      # HTTP                      and nothing else
helpers/          # View helpers              and nothing else
jobs/             # Background jobs           and nothing else
mailers/          # Sending email             and nothing else
models/           # Database Access           and nothing else
services/         # Business Logic            and nothing else
views/            # HTML views                and nothing else
          

class WidgetsController < ApplicationController
  def create
    @widget = WidgetCreation.new.create_widget(widget_params)
    if @widget.valid?
      redirect_to widgets_path
    else
      render :new
    end
  end
end
          

True Facts

  • WidgetsController is stable.
  • Widget is also stable.
  • Tests that require rows in WIDGETS are stable.
  • Classes that use Widget or its instances are stable.

Changes to Widget Creation Are Now Well-Isolated

More True Facts

  • We have not abstracted away Rails with hexagons or clean architects
  • We have not banned features of Rails

Why you should trust me

  • Have done this before
  • Have done this “at scale” (i.e. multiple teams of more than a few people)
  • Have seen codebases grow for years
  • Was accountable for team output

Some things to reconcile

  • Validations are in the Active Record…aren't they business logic?
  • tHe COdE iS uGLy aNd i HAtE iT
  • But does it scale?

Validations are a form of business logic

ActiveModel::Validations is extremely well designed. We should not abandon it

No business logic in Active Records

other than Validations

(remember ActiveModel::Validations exists)

This is called a “tradeoff”, and it's a good one to make

Manifesto Against Code I Find Ugly and Gross

Doctrine
Against Code I Find Ugly and Gross

Do you want a part of your dev process where:

  • everyone argues about what code is beautiful?
  • the person in charge sets the standard of beauty and the team spends time meeting it?

Widget.create inserts rows into the WIDGETS database table

It just does.

Elsewhere in the multi-verse where it was called WidgetsTable.insert_new_row

More True Facts

  • It's OK if code is not “object-oriented”
  • It's OK if someone calls your code “procedural”

But Does it Scale?

Yes

Over time:

  • You will have lots of services
  • They will be complex (necessarily)
  • But…

You will see your app for what it really does

Even the least-fancy service layer will make it obvious how to keep itself organized

Strategies to Manage This

New services for
new use-cases


class SalesTeamNotifier
  def send_daily_notifications
  end
end
          

New public methods for tightly-related concepts


class WidgetCreation
  def create_widget(…)
  end

  def import_widget_from_csv(…)
  end
end
          

Private methods to
share implementation internally


class WidgetCreation
  def create_widget(…)
    notify_admins(…)
  end

  def import_widget_from_csv(…)
    notify_admins(…)
  end

private

  def notify_admins(…)
    # code to share
  end
end
          

Extract new services to
externalize implementation
(or facilitate testing)


class WidgetCreation

private

  def notify_admins(…)
    AdminNotifier.notify_on_new_widget(…)
  end
end
          

Namespaces to group concepts you discover


class Widgets::Creation
  # …
end

class Widgets::Archival
  # …
end

class Widgets::Reporting
  # …
end
          

None of this works without tests, BTW!

If you want to, you can still:

  • Use OO features like mixins, delegation, and inheritance
  • Use pure functions and whatever monads are
  • Require a debate about the use of colons or hashrockets before shipping

But you probably won't want to

Treat your app for what it is

not what you might imagine it could be

Treat Rails for what it is

not what you would like it to be

Don't put business logic in your active records

(except for Validations)

Your Service Layer Needn't be Fancy, It Just Needs to Exist

I am not hiring

But I have a Book

sustainable-rails.com

50% off with code SERVICELAYER

Are you from India, 日本, Mexico, 香港, Україна, or any African nation? Use one of these codes intead:

https://bit.ly/davetron5000-ppp