A few years ago, I started building with Ruby on Rails. Having come from the JavaScript world where programming conventions are scattered across hundreds of sub-communities, it was a bit jarring to see such strong and unified opinions shared across so many Rails devs. As I became more integrated into the community, I realized that this narrow set of "right ways" to do things is a feature; not a bug.
On average, I have found Rails devs to be rooted in stronger programming fundamentals than many other communities, and that's because of this shared agreement that there are a few "good" ways to do things and trying to reinvent the wheel is valuable time wasted that could have been spent solving business problems with code.
I've always viewed code as a means to solve problems, so I quickly got onboard with this philosophy.
But in the Rails community, there is one idea that doesn't seem to have a clear answer, and that is the question of, "Do I need an app/services directory in my app?"
In this post, my goal is to explore this question and better understand why we might need a services layer in our apps, how that relates to OOP (object oriented programming) and DDD (domain driven design), and why Rails devs are so passionate about answering this question.
A Motivating Example: Personal Finance
I've written enough software in the personal finance domain where I think using this domain as our ongoing example for this post makes sense. For those unfamiliar, personal finance is built on a few main concepts:
- User
- Account - a financial account, but NOT in the accounting sense. We're not talking strictly chart-of-accounts here, but rather, a more generic concept of a "bank account" or "credit card account". An Account has a balance and many transactions.
- Transaction - a movement of money between one account and another (whether internal or external)
Through these three entities alone, we have plenty of examples to demonstrate and explore why we may, or may not need an app/services layer in our Rails app.
What is business logic?
In most web applications, there are a few large categories of logic that often become intermingled and confused. My aim in this post is to keep these distinct and demonstrate why clearly understanding the boundary between them can help you make smarter decisions when organizing and naming your code:
- Business Logic - this is the logic that if the entire application ceased to exist, would still be relevant to the business. Think of business rules such as the Account cannot be negative, a maximum of 3 users can be associated with an Account, or something even more obscure such as, "If an Account reaches a balance less than $100, change its status and send the user a low balance warning notification"
- Application Logic - think of this as the "everything else" of your app. When you create an Account via the ORM, send a confirmation email to the user via an external API client, respond to the client via HTTP, and render a confirmation view in the UI, all of this is application-level logic. This is often split even further into granular categories such as UI logic, persistence logic, infra logic (external services), and others; many of which are cleanly separated in a Rails application by default (think views, controllers, models, etc.)
We care about this distinction because Rails, and many other web frameworks have clear, opinionated answers for how to deal with app logic, but less clear opinions on business logic. The purpose of a web framework is to give you opinions on how to solve common problems (like HTTP responses) and the flexible building blocks to solve business related problems as you see fit.
Our goal in this post is to understand our "less bad" options for solving the latter.
Rich Domain Models vs. Service Objects
At the heart of our question of where to put business logic lies an ongoing debate that can be overly simplified as "The 37 Signals way" vs. "The Stitch Fix way".
I simplify it with this framing because engineers from each of these companies have both published thoughtful and reasonable approaches to the inevitable challenge of organizing business logic in a way that can be easily maintained over time.
Below, I'll outline some of the core tenets of each approach to build the map in our heads which will then allow us to debate the merits of each.
Approach #1: Rich Domain Models
Jorge Manrubia, a principal engineer at 37 Signals has a wonderful series written called Code I Like. This series, along with many of DHH's writings provide a well rounded overview of how 37 Signals thinks about business logic and DDD (domain driven design). While I'd highly recommend reading this series slowly, some core ideas laid out in it include:
- Business logic can, and should be intermingled with ActiveRecord models
- Both persistence logic (application-level) and domain logic (business) should exist in
app/models, and with POROs (plain ole' Ruby objects) + Concerns alone, we can express any level of complexity in our code cleanly - Push Rails as far as it will go, and embrace the "sharp knives" it provides
This approach is often called the "fat models, skinny controllers" method, although Jorge outlines in Vanilla Rails is Plenty how at 37 Signals, they actively work to keep models readable through usage of Concerns and POROs.
In the context of our personal finance domain, this approach might lead to code that looks something like the following.
Our Rails controller is simple, and calls into the domain layer through intention-revealing methods like account.withdraw.
# app/controllers/withdrawals_controller.rb
class WithdrawalsController < ApplicationController
def create
@account = Account.find(params[:account_id])
@account.withdraw!(amount: params[:amount])
redirect_to @account, notice: "Withdrawal successful"
rescue Account::InsufficientFundsError, ArgumentError => e
redirect_to @account, alert: e.message
end
endThe implementation of this involves several pieces of business logic:
- A user cannot withdraw more than the account is worth
- A withdrawal request must be positive
- A withdrawal creates a transaction that is consistently updated alongside the account balance
In the Fat Model approach, this business logic is mixed in with persistence logic directly in the model:
# app/models/account.rb
class Account < ApplicationRecord
include Notifiable
has_many :transactions
# Business logic: an account can never be overdrawn
validates :balance, numericality: { greater_than_or_equal_to: 0 }
class InsufficientFundsError < StandardError; end
def withdraw!(amount:)
amount = BigDecimal(amount.to_s)
# Business logic: withdrawals must be positive and covered by balance
raise ArgumentError, "Amount must be positive" unless amount.positive?
raise InsufficientFundsError, "Not enough funds" if amount > balance
# Application + business logic: orchestrate the DB writes atomically
transaction do
transactions.create!(amount: -amount, kind: :withdrawal)
decrement!(:balance, amount)
end
# Application logic: fire off side effects after the domain change
notify(event: :withdrawal_completed, amount: amount)
end
endAnd finally, to illustrate how Concerns are used to organize code, we have the Notifiable concern which is a generic interface where different notifiers can be called from the withdrawal method. As you can see, the domain model simply calls into a generic notify method, which could easily be configured to use a certain type of provider based on a user preference or configuration.
# app/models/concerns/notifiable.rb
module Notifiable
extend ActiveSupport::Concern
def notify(event:, **payload)
notification_channels.each do |channel|
channel.deliver(recipient: self, event: event, payload: payload)
end
end
private
def notification_channels
[EmailChannel.new] # could add SmsChannel, PushChannel, etc.
end
endAs you're reading this, you might be wondering, "But what happens when Account has a hundred other methods with growing complexity?".
It's a great question, and we'll revisit this example with a solution to it later in this post.
Approach #2: Service Objects
On the other side of the debate, Dave Copeland, author of Sustainable Rails Development and former director of engineering at Stitch Fix has written about a "simpler" approach called Service Objects that aims to use Rails for what it is (a web framework) and move all business logic to the app/services directory.
A service object is simpler than it sounds. It's nothing more than:
- A PORO
- That lives in
app/servicesdirectory - Which usually has a single method (and single responsibility) that executes some business + application logic
- And is stateless, usually named after a verb (such as
WidgetCreation)
Copeland also adds a few other requirements such as method parameters being value objects, methods returning rich result structs, and dependent service objects made available through the constructor or private methods, but the bullets above describe the essence of a service object.
Copeland's argument is that most teams find it simpler to "keep database tables in app/models and everything else in app/services" and there is less ambiguity when scaling a development team.
Rather than trying to come up with the perfect method name and Concern for withdrawing from an account and notifying the user, service objects say that you give a reasonable class name to each responsibility and leave the Account model alone; unaware of the business logic going on around it.
Here's what our previous code might look like in a Service Object world:
# app/controllers/withdrawals_controller.rb
class WithdrawalsController < ApplicationController
def create
@account = Account.find(params[:account_id])
WithdrawFunds.new(account: @account, amount: params[:amount]).call
redirect_to @account, notice: "Withdrawal successful"
rescue WithdrawFunds::InsufficientFundsError, ArgumentError => e
redirect_to @account, alert: e.message
end
endThe controller stays largely the same. The only difference is that rather than calling account.withdraw, we call WithdrawFunds.new. Someone like Jorge from 37 Signals would argue this is a big semantic difference and account.withdraw is much cleaner while someone like Dave Copeland would argue that the service object is clear and there is no reason to overcomplicate the semantics.
Moving into the model, you can see with service objects, we've reduced this to persistence concerns only. There isn't even a withdraw method at all!
# app/models/account.rb
class Account < ApplicationRecord
has_many :transactions
# Business logic: an account can never be overdrawn
validates :balance, numericality: { greater_than_or_equal_to: 0 }
endYou might notice a bit of tension here though. We have business logic in the model still? Wasn't the "account can't be overdrawn" rule supposed to live in the service object?
As Copeland notes in his RailsConf talk, this is a tradeoff he's willing to make, because ActiveRecord validations are powerful and there is no need to reinvent them. As you can tell, Copeland sits on the "keep it simple and practical" side of the argument, while the 37 Signals way highlights the value of beautiful code and domain expressiveness.
Moving on to the final implementations, you can see that both our withdrawal logic and Concern have moved into service objects that clearly describe what they do, and are called by the controller and other service objects:
# app/services/withdraw_funds.rb
class WithdrawFunds
class InsufficientFundsError < StandardError; end
def initialize(account:, amount:)
@account = account
@amount = BigDecimal(amount.to_s)
end
def call
validate!
# Application logic: orchestrate the DB writes atomically
@account.transaction do
@account.transactions.create!(amount: -@amount, kind: :withdrawal)
@account.decrement!(:balance, @amount)
end
# Application logic: delegate side effects to another service
SendNotification.new(
recipient: @account,
event: :withdrawal_completed,
payload: { amount: @amount }
).call
end
private
def validate!
# Business logic: withdrawals must be positive and covered by balance
raise ArgumentError, "Amount must be positive" unless @amount.positive?
raise InsufficientFundsError, "Not enough funds" if @amount > @account.balance
end
end
# app/services/send_notification.rb
class SendNotification
def initialize(recipient:, event:, payload: {})
@recipient = recipient
@event = event
@payload = payload
end
def call
channels.each do |channel|
channel.deliver(recipient: @recipient, event: @event, payload: @payload)
end
end
private
def channels
[EmailChannel.new] # could add SmsChannel, PushChannel, etc.
end
endAdding Complexity to Rich Domain Models
Now that you've seen each approach with a simple example, I want to return to the original 37 Signals approach and explore the scenario where:
- Codebase has grown in complexity
- The account withdraw method has grown dramatically in size
- The Account object now has hundreds of other methods on it such as
account.deposit,account.close,account.add_member, etc.
In this scenario, any developer could foreshadow that our account.rb file will become so big that other devs won't easily find what they're looking for and future changes to the domain will require edits to code that is shared across a lot of use-cases. How do we manage this complexity?
I'm going to outline one way that might be seen at a company like 37 Signals, and if you pay attention, you might notice some overlap between our Service Objects approach.
Take a look at some refactored code, which retains the account.withdraw method, but moves its complexity elsewhere in app/models.
# app/models/account.rb
class Account < ApplicationRecord
include Notifiable
include Withdrawable
has_many :transactions
# Business logic: an account can never be overdrawn
validates :balance, numericality: { greater_than_or_equal_to: 0 }
end
# app/models/concerns/withdrawable.rb
module Withdrawable
extend ActiveSupport::Concern
class InsufficientFundsError < StandardError; end
def withdraw!(amount:)
Withdrawal.new(source: self, amount: amount).call
end
end
# app/models/withdrawal.rb
class Withdrawal
def initialize(source:, amount:)
@source = source
@amount = BigDecimal(amount.to_s)
end
def call
validate!
# ...additional business logic: daily limits, fraud checks,
# fee calculation, hold periods, etc.
@source.transaction do
record_transaction
debit_balance
# ...application logic: audit logging, ledger entries, etc.
end
@source.notify(event: :withdrawal_completed, amount: @amount)
end
private
def validate!
# Business logic: withdrawals must be positive and covered by balance
raise ArgumentError, "Amount must be positive" unless @amount.positive?
raise Withdrawable::InsufficientFundsError, "Not enough funds" if @amount > @source.balance
end
def record_transaction
@source.transactions.create!(amount: -@amount, kind: :withdrawal)
end
def debit_balance
@source.decrement!(:balance, @amount)
end
endIn this refactor, we have replaced all the noise in our Account model with a single include Withdrawable line, while retaining the account.withdraw interface that callers interact with.
In this refactor, we've essentially implemented a Withdrawal "service object" while retaining our Account as the Facade and the model.
Two very different philosophies; yet similar looking code, right?
Why both approaches are right
Both approaches described above aim to adopt DDD principles in the clearest and simplest way possible.
Both approaches answer the question, "Where do I put business logic?"
And I think for these reasons, either approach is perfectly suitable for your team, so long as you commit to the approach and implement it consistently.
Pros and Cons of Rich Domain Models
The 37 Signals approach cares deeply about semantics. It challenges the developer to deeply understand their domain and boldly name it. In such an approach, Concerns become thoughtful descriptors of what an object can do, or as I like to think of as "capabilities". An Account can be Withdrawable, Notifiable, Closeable, and Shareable (joint account owners). These are all natural names for the Concerns you could add to the Account domain model to add methods.
Furthermore, this approach is pragmatic about the size and nature of most applications. Rather than enforcing a dogmatic rule about never allowing controllers to interact directly with domain models, it embraces the idea that some parts of an application are too simple to need indirection while other parts benefit tremendously with a barrier between the controller and business logic via a well-named model method. It approaches the application from the standpoint of "just enough" abstraction.
On the flip side, I find the 37 Signals approach to be ambiguous in many areas and often less desirable for a large team with a wide range of skillsets and seniority.
Take for example a snippet from Jorge's post, Vanilla Rails is Plenty:
We don’t use services as first-class architectural artifacts in the DDD sense (stateless, named after a verb), but we have many classes that exist to encapsulate operations. We don’t call those services and they don’t receive special treatment. We usually prefer to present them as domain models that expose the needed functionality instead of using a mere procedural syntax to invoke the operation.
While there is a ton of nuance to this statement that Jorge later explains, it's not immediately clear to most developers where the line between service objects and "services" in the 37 Signals sense is drawn.
To many developers, this sounds like, "We use services; we just don't call them that".
To any dev working at 37 Signals there is a shared understanding what a service is, but I'd bet at a much larger team size, gaining that shared understanding would be more difficult.
Pros and Cons of Service Objects
The appeal to the Service Object approach endorsed by Dave Copeland is simplicity.
Rather than trying to delineate between "Service Object" and "Service Model", a team can lay out a blanket rule that "anything in app/services is a service object, which has XYZ properties and requirements".
We can clearly state that a service object:
- Doesn't store state
- Uses instance methods
- Have 1 (or few) public methods
- Receives value objects as method params
- Methods return rich struct results
- Dependent services instantiated via constructor or private methods
The 37 Signals approach with Rich Domain Models is not as clear and explicit as this. It requires a high average competency level among devs to read between the lines.
This service object approach is consumable and useable by many levels of programmers.
On the flip side, this approach runs the risk of service overuse; something that the 37 Signals approach is less prone to.
When all app logic lives in a single directory, code can become overly procedural, models become anemic, and the DDD a team strived for isn't quite as expressive as they may have wanted.
Conclusion
In my opinion, the app/services debate is a bit petty on the surface, but acts as a window into proper domain-driven design. Both approaches end up looking similar, yet vary greatly in philosophy.
While potentially a naive opinion, I think if you're on the fence between the two approaches, its worth keeping a few things in mind:
- Pick one and stay consistent above everything else
- The 37 Signals approach is more intellectually engaging and IMO results in a cleaner domain due to the amount of thought it requires to implement well
- The Service Object approach is much easier to onboard to and explain to other developers
As many wise developers have said, programming is about tradeoffs. And I believe this debate is one of tradeoffs.