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.

An app/interactors directory for the stereotypical rails blog app would have interactors with names like CreatePost, UpdatePost, 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.

Activity Streams

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, params and 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

If 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 Organizer object.

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.

Photo of Jon Stokes

Jon is a founder of Ars Technica, and a former Wired editor. When he’s not developing code for Collective Idea clients, he still keeps his foot in the content world via freelancing and the occasional op-ed.

Comments

  1. messenger@brownbox.me
    guillermo haas-thompson
    January 18, 2017 at 2:56 AM

    I ran across your article after implementing an activity stream based on a Railcasts episode because I didn’t want to use a gem. I have never used an Interactor but this was a very interesting article. I would be curious to see how you deal with not just the recording of an activity, but the display. It looks like you are still using public_activity, but are really dealing with one issue I encountered but not another.
    Ryan wrote a very simple method added to application_controller so that each controller method only gains one additional line of code to create an activity event. The largest volume of code comes in dealing with all the partials involved in displaying the resulting activity stream to the user.
    Note: If you forego all the partials, you can easily just present the results, but without additional processing the usefulness is diminished. I’d love to see how you would deal with this side of implementing an activity stream.

  2. James
    June 11, 2019 at 3:14 AM

    Hi, just wondering if in your example of using the CreatePost in the controller, you either reversed the usage order in the controller, if if the implementation of your methods are incorrect. It would appear that your call method does the save and that would not parallel the initial example of how the controller was supposed to work. Maybe I am missing something. Thanks!

  3. James
    June 11, 2019 at 3:16 AM

    …then again, I think I see how you changed the location of the activities now. I think I get it now. Thanks