Ruby on Rails is an amazing and mature framework, which many of us instinctively turn into whenever we need to get something simple up and running as soon as possible. However, many will also agree that Rails simplicity is also its biggest flaw. As your application grows, your sleek-and-fast Rails app suddenly become messy and disorganized. I like the MVC pattern, but when the number of dependencies between different entities grows, you need something more than that.
Publisher & Subscriber
One approach I really wanted to try out for some time was the pub/sub pattern. In this pattern, some objects publish special messages (with an optional payload), and other subscribed objects are listening to them. Whenever a subscribed object hears a message it is interested in, it can do something with it. whisper gem adds pub/sub capabilities to Ruby objects, so why not try using that in our Rails app.
Let’s take a look at this hypothetical Rails controller which does the following:
- When a
Postis created, it sends an email notification to its author
- If the post has 300 words or more, it creates a
This simple implementation looks OK, but it violates one common principle — the Single Responsibility Principle (SRP). Why? Well, our
PostsController is now responsible not only for creating posts, but also sending emails and creating other objects (
FeaturedPost). And if you’re planning to write a test for that controller, you should probably test all those additional things too.
PostsController sending emails? This ain’t good.
BTW, for the rest of the article, I’m going to skip the
def post_params part of the controller, in order to save you time reading.
So let’s try to implement a service object that will create a
Post , and nothing else — and then, depending on the result of that creation, it’s going to publish some sort of
Ok, so if
post.save returns true, we broadcast
post_created string, together with the
Post object we just created — but if
post.save returns false, we broadcast
post_not_created string. Sounds easy so far. Let’s hook it up into our
This looks promising, but we’re missing something important here. Every Rails controller should have some sort of
render , right? Let’s use the messages that
Services::Post::Create broadcasts, and let’s add that back to our controller. We’re going to use the
on method that comes from whisper.
Better, but there are still two pieces missing. Our original controller was also sending email notification and creating a
FeaturedPost object, and this controller does not. And we don’t want to put that inside that controller, because this is exactly what we’re trying to avoid.
But what if we could tell someone to listen for that
post_created message, and then, when that message is broadcasted, send an email notification, and create a
pub/sub subscriptions to the rescue. Let’s create two listeners objects.
Ok, so now we have two objects.
Listeners::Mailer will be responsible for sending an email notification to the author of that post, and
Listeners::FeaturedPost will be responsible for creating a
FeaturedPost but only when the post has more than 300 words.
Note how the methods defined inside those listeners are named exactly the same as the message string that
Services::Post::Create broadcasts. This is very important because this connects a
Listener with our
Ok, let’s add those
Listeners to our controller.
So what we did here is we’ve subscribed both listeners (using a
subscribe method) to our
Services::Post::Createservice object, so that any message broadcasted by
Services::Post::Create will be passed down to those listeners. And if that message happens to be
post_created , then both
Listeners::FeaturedPost#post_created methods are going to be executed.
Voilà 🎉 Our app now does the same thing as it did at the beginning of this article.
I really like using this pattern because it’s really simple to start with and it really helps you spread responsibilities across many smaller objects.
If you look at the
PostsController now, you will notice it’s responsible for only one thing. One could think it's a creation of a
Post , but that’s not true. If you look closely, you will notice that
PostsController does not create
Postobjects per se — instead, it runs some service, nothing else. So in practice, the controller becomes a layer that converts incoming HTTP requests into a
call method call on some service class. This drastically simplifies writing tests.
Services::Post::Create is a small & compact object which does one thing only — it tries to persist w post in a database and broadcasts a proper message depending on the result. Same with both
Listeners — each one is responsible for one small tiny piece of our business logic, and nothing else. This makes those object really easy to test too.
Want more? Here are a few recommendations
There are a few things you might want to consider when using this pattern.
- Use Ruby constants to store messages
post_created message string must match the method name inside your listeners' classes. If you make a typo in one of the places, it might be hard to catch what’s wrong. That’s why I recommend using constants to store those string.
Then, make sure you publish them through your service class.
Next, make sure your controller uses them too.
And last but not least — use them to define methods inside your listeners
That way, if you make a typo in a constant name, your app will immediately throw a
NameError: uninitialized constant error at you :)
2. Don’t be afraid to use other service objects inside your listeners
In our implementation,
Listeners::Mailer is actually responsible for sending emails, and
Listeners::FeaturedPost is responsible for creating
FeaturedPosts . I think it’s OK, but I think it would be even more kosher to maybe have a separate service object to do just that, and use listeners as a layer that only turns incoming pub/sub messages into other services.
This is how this could look like
I really hope some of you will find this pattern interesting, and you will give it a try. I highly recommend it. If you have any questions, please let me know in the comments below.