Introduction

I’ve spent many years writing Rails applications.

Like most Rails developers, I naturally wrote service objects returning ActiveRecord models.

It felt obvious. After all, Rails revolves around ActiveRecord.

Why return anything else?

Over time, I started noticing a recurring pattern.

Many services weren’t returning models because callers actually needed models. They were returning models simply because that’s what the service already had.

That small distinction changed the way I design part of my applications.


ActiveRecord Is a Great Persistence Layer

Let’s start with something important.

This isn’t an anti-ActiveRecord article.

I love ActiveRecord. It’s one of the reasons Rails remains so productive.

Need to create a record?

user = User.create!(...)

Need to update it?

user.update!(...)

Need associations?

user.posts

It’s difficult to beat that developer experience.

The problem starts when persistence leaks into parts of the application that don’t actually care about persistence.


Returning Models Couples Everything

Imagine a service like this.

class CreateOrder
  def call(...)
    order = Order.create!(...)

    # More business logic...

    order
  end
end

Seems harmless.

Now imagine the consumers.

order = CreateOrder.new.call(...)

Notifier.deliver(order)
Analytics.track(order)
InvoiceGenerator.generate(order)

Every consumer now receives a mutable ActiveRecord object, and the whole persistence layer comes with it.

Any of them can write:

order.update!(status: "archived")

The service never meant to allow that.

The worst coupling, though, is the invisible kind. Say Notifier reaches for the customer to build its message:

class Notifier
  def self.deliver(order)
    mail(to: order.customer.email)
  end
end

That innocent order.customer fires a SQL query the caller never asked for. One call, one extra query, easy to miss.

But the same operation usually runs over a collection somewhere else, and there the hidden access turns into a textbook N+1:

recent_orders.each do |order|
  Notifier.deliver(order)   # order.customer => one query per order
end

The service only wanted to communicate the result of an operation. Instead, it handed every caller a live handle to the database.


Returning Data Instead

Nowadays I often ask myself a simple question.

What does the caller actually need?

Usually the answer isn’t an ActiveRecord model. It’s something much smaller.

Maybe it’s:

  • an id
  • a total
  • a status
  • the customer’s email

Or some combination of those.

Instead of returning a model, I return a dedicated object.

OrderSummary = Data.define(:id, :total, :status, :customer_email)

The service still does its database work, then hands back only the result:

def call(...)
  order = Order.create!(...)

  OrderSummary.new(
    id: order.id,
    total: order.total,
    status: order.status,
    customer_email: order.customer.email
  )
end

The caller receives exactly what it needs. Nothing more, nothing less.

OrderSummary is immutable and carries no connection to the database. There is no update!, no lazy association, no callback hiding behind a reader. And the association access now lives in one place, the service, where you control eager loading, instead of being scattered across every consumer.


An Unexpected Side Effect

One thing surprised me: the consumers became much easier to test.

The service itself still talks to the database. It calls Order.create!, so its own test needs a record. That part doesn’t change.

What changes is everything downstream.

When Notifier expects an ActiveRecord Order, testing it usually means a factory, a saved record, maybe a couple of associations:

order = create(:order, :with_customer)

expect { Notifier.deliver(order) }.to change { deliveries.size }.by(1)

When Notifier expects an OrderSummary, the test is plain Ruby:

summary = OrderSummary.new(
  id: 1, total: 42, status: "paid", customer_email: "buyer@example.com"
)

expect { Notifier.deliver(summary) }.to change { deliveries.size }.by(1)

No database. No transaction. No callbacks. Just objects.

That doesn’t sound like much until your test suite starts growing.


Better Boundaries

Returning data also makes boundaries more explicit.

If another object needs additional information, it has to ask for it deliberately. That’s a good thing.

Dependencies become visible. APIs become clearer.

The service defines what it promises to return instead of exposing an implementation detail.

I’ve found this particularly useful when a service becomes reusable across different parts of an application.


The Trade-Off

This isn’t free.

You now maintain a mapping between the model and the data object. When Order gains a field a caller needs, you have to expose it on OrderSummary too. The two can drift apart if you’re not paying attention.

For a small object returned by a meaningful operation, I find that cost easy to accept. The mapping is explicit and lives in one place. But it is a real cost, and it’s exactly why I don’t do this everywhere.


This Doesn’t Replace ActiveRecord

I’m not advocating replacing ActiveRecord with value objects everywhere. That would simply move complexity elsewhere.

CRUD screens? Return models.

Forms? Return models.

Administrative interfaces? Return models.

But when a service performs a business operation, I increasingly prefer asking:

What is the result of this operation?

instead of:

Which model did I manipulate?

The answer is often very different.


Final Thoughts

One of the things I appreciate most about Ruby is that it encourages small objects with clear responsibilities.

Rails sometimes nudges us toward thinking in terms of models. That’s perfectly fine.

But not every object in an application has to expose persistence.

Sometimes all another part of the system needs is a few pieces of information. Returning exactly that has made many of my services simpler, easier to test and easier to evolve.

It’s not a revolutionary technique. It’s just a small design choice that, over the years, has quietly paid for itself many times.

Have comments or want to discuss this topic?

Send an email to ~bounga/public-inbox@lists.sr.ht