Painless Activity Streams in Rails
An easier way to track events
The Late Train by Alex Becerra is licensed under CC BY 2.0
Whether you’re using one of the many third-party analytics tools for tracking user events or you’re implementing an activity stream on a social site with something like public_activity, at some point in your rails career you’re going to want the server to log events when certain parts of your business logic code fire.
Most tutorials and README files for server-side event tracking libraries will have you track events in your controllers, but sprawling controller methods that do too many things – update state, implement business logic, and track events – are one of the worst aspects of rails programming.
Thankfully, there’s a better way.
We’re big fans of the interactor gem here at Collective Idea, because it lets us move business logic out of our controllers and models, and into POROs (Plain Old Ruby Objects) with clear names and responsibilities.
app/interactors directory for the stereotypical rails blog app would have interactors with names like
CreateUserComment, and so on.
Those object names look like event names of the type that we might track with an analytics service like Intercom or Mixpanel, or surface to users as part of an activity stream. That makes these interactors not only the ideal place to implement business logic, but also a perfect site for tracking events.
For some apps interactors may seem like overkill, but once your controller methods start to take on event tracking responsibilities, you’ll find that moving everything into interactors can clean up your
app/controllers directory just as dramatically as the presenter pattern can clean up your models and views.
Note: I actually have my own twist on
interactor, called troupe, which is basically the
interactor gem with contracts added. In this example I’m going to use
troupe-flavored interactors because I find them to be a lot easier and more comfortable to read.
Consider a blog engine where we’d like to use the
public_activity gem to add a WordPress-style dashboard that features an activity stream. We’d probably have a
PostsController#create method that looks like this:
# POST /posts def create @post = Post.new(post_params) if @post.save redirect_to @post, notice: 'Post was successfully created.' else render :new end end
That’s not too bad, and it’s not exactly crying out for an interactor. But what happens when we add activity tracking to it?
Before I continue, I should not that I very much dislike the way that
public_activity recommends that you add a model hook that pulls the
current\_user in from a controller in order to tie the model change record to a particular user. (If you don’t know what I’m talking about, don’t look it up because it’s gross and you shouldn’t see it.) It’s so much better to create activities with a bit of code in the relevant interactor than to use hooks, which you should actually stay away from anyway in your models if you can avoid them.
Let’s take a look at a way that we might add activity tracking directly to the controller above:
def create @post = Post.new(post_params) if @post.save @post.create_activity( action: :create, user: current_user ) redirect_to @post, notice: 'Post was successfully created.' else render :new end end
This isn’t too bad – it’s just an extra method call. But let’s rewrite the above with an interactor and take a look at the result.
First, the controller method:
def create create_post = CreatePost.call(user: current_user, params: post_params) if create_post.success? redirect_to create_post.post, notice: 'Post was successfully created.' else render :new end end
That looks nicer already. Now let’s check out the interactor:
class CreatePost include Troupe expects :params, :user provides :post do Post.new(params) end def call context.fail!(error: post.errors) unless post.save end after do post.create_activity( action: :create, owner: user ) if context.success? end end
The object above is readable and encapsulates all of the logic around creating posts and updating the activity stream in a single source file. Yeah, it’s more code, and it’s probably overkill for this toy example, but when we begin adding more and more logic to the
Post creation process, having all of that logic broken out into a single file or a collection of discrete but related files begins to make a ton of sense.
It’s clear to anyone reading the above code that the interactor expects two input objects,
user, and that it introduces a new
post object into the interactor context.
The main body of the interactor checks for a successful
save, and then logs the activity by tying it to the current user. If the post doesn’t save, then the interactor fails, so that the controller knows to take the sad path.
Maintaining and Testing
CreatePost starts to bloat by taking on too much responsibility,
interactor makes it easier to split it up into multiple interactors that are chained together with an
An organizer for a more complex post creation process might look something like the following:
class PublishPost include Interactor::Organizer organize CreatePost, CreateActivity, EmailPostAuthor, WritePostToCDN, BustPartialCaches end
Nobody in their right mind would want to cram such things into a controller. And trying to use hooks on the
Post model would be a nightmare. It’s far better to break all of the functions up into separate POROs that you can reason about and test individually. Also, the controller method would look the same (except you’d call
PublishPost instead of
CreatePost, of course).
Speaking of tests, in addition to making the app easier to maintain as it grows, the other big advantage to the interactor-based approach is that it makes testing easier. You can skip writing unit tests for controllers, and just write a unit tests for the interactors. These test files cleanly encapsulate and document all of the business logic in a way that’s easy for newcomers to the app to find and read.
So, go forth and track events on the server side without cluttering up your controllers and models. It’s easy, and once you get the hang of it you might be able to decrease your reliance on third-party tracking tools and begin taking back your own data.