DEV Community

Cover image for Simple pub/sub pattern (OOP) using pure Ruby
Pavel Tkachenko
Pavel Tkachenko

Posted on

Simple pub/sub pattern (OOP) using pure Ruby

Pub/Sub is a popular pattern for reducing coupling and increasing system modularity. In the Publish-Subscribe model, subscribers await for events they are interested in, and get notified of any event generated by a publisher that matches their registered interest.

To simplify, imagine a radio station (publisher). You (subscriber) tune into the wave of this radio station (subscribe) and start to sing along when you hear a Blink-192 band song (react to event).

In details

This is a very convenient pattern when you have many parts of the program that must respond differently to an event. For example, imagine a site where user is registered. In this case, an email should be sent, the service of collecting information on the user in social networks should start and many other things. If everything is done in one place, then you will get high coupling and this monster will be hard to maintain. In the pub/sub pattern, you have a service that creates a user and broadcasts a message to everyone who is interested in user creation. All other services isolated from each other will perform the necessary work as soon as they receive the event they need.

Our simple pubsub solution

There are a lot of solutions providing Publish-Subscribe capabilities. From small libraries (One of them a wisper gem), to huge projects like RabbitMQ or Kafka. But to understand how it works, we will build our simple personal solution and investigate.

Our demo

We are building our own Walmart. Let's start with three classes (Item, Checkout and Printer). When cashier scans an item, it should be added to checkout and immediatelly printed. Read the code below carefully. Here is the classical solution. We inject Printer as dependency to our Checkout and call print on it every time item is added. What problem we have here? Checkout needs to now how Printer works, that it has print method. When you have a lot of classes and interactions, that can be problematic.

class Item
  attr_reader :code, :title

  def initialize(code:, title:)
    @code = code
    @title = title
  end

  def to_s
    "#{@code} #{@title}"
  end
end

class Checkout
  attr_reader :items

  def initialize(printer: Printer.new)
    @printer = printer
    @items = []
  end

  def add(item)
    @items << item
    @printer.print(item)
  end
end

class Printer
  def print(message)
    puts "[#{Time.now}] #{message}"
  end
end

How we can solve this problem with pub/sub pattern. First, let's introduce Publisher module.

module Publisher
  def subscribe(subscribers)
    @subscribers ||= [] # if @subscribers is nil, we initialize it as empty array, else we do nothing
    @subscribers += subscribers
  end

  def broadcast(event, *payload)
    @subscribers ||= [] # @subscribers is nil, we can't do each on it
    @subscribers.each do |subscriber|
      # If event is :item_added occured with payload item itself
      # we send method :item_added to subscriber and bypass payload as argument if subscriber
      # responds to it.
      subscriber.public_send(event.to_sym, *payload) if subscriber.respond_to?(event)
    end
  end
end

And that's it. Let's refactor our business logic to work with Publisher module

class Checkout
  include Publisher

  attr_reader :items

  def initialize(subscribers:)
    @items = []
    subscribe(subscribers)
  end

  def add(item)
    @items << item
    broadcast(:item_added, item)
  end
end

class Printer
  def item_added(item)
    print(item)
  end

  private

  def print(message)
    puts "[#{Time.now}] #{message}"
  end
end

Let's check it

item1 = Item.new(code: "DRP", title: "Dr.Pepper")
item2 = Item.new(code: "CCL", title: "Coca-Cola")

checkout = Checkout.new(subscribers: [Printer.new])

checkout.add(item1)
checkout.add(item2)

#=> [2020-07-16 12:08:49 +0600] DRP Dr.Pepper
#=> [2020-07-16 12:08:49 +0600] CCL Coca-Cola

And now our Checkout class doesn't know anything about Printer implementation, it just sends event :item_added to it. You can say that it is looking more complex, but you will start to collect benefits with scaling the system. Imaging we add SoundManager class, which works with our sound card.

class SoundManager
  def item_added(_)
    beep!
  end

  def beep!
    print "\a"
  end
end

item = Item.new(code: "DRP", title: "Dr.Pepper")
checkout = Checkout.new(subscribers: [Printer.new, SoundManager.new])
checkout.add(item)

#=> [2020-07-16 12:08:49 +0600] DRP Dr.Pepper
# And you should hear "Beep" sound!

Again, our Checkout class doesn't know anything about SondManager implementation and internal logic. You just send event to it and SoundManager knows how to handle it!

You can find full final code here: gist.github.com

Pros and Cons of pub/sub

  • πŸ‘ Less coupling between classes
  • πŸ‘ High level of scalability
  • πŸ‘ Compact system tests for each isolated module
  • πŸ‘ Easy to implement

  • πŸ‘Ž Full process logic is not visible (You won’t be able to understand what happened with Checkout#add method call by simply looking at the class)

  • πŸ‘Ž You still need full integration tests to check system as a whole

Conclusion

We built the simplest example of such an architecture. There are many more complex solutions, for example, when Publisher needs to make sure that the subscriber received an event. Or when you need to send events asynchronously or after some time. It's a huge topics covering message brokers, data bus, queues, etc. If you are interested in it, I can go further and provide deeper tutorials.

Top comments (2)

Collapse
 
mxldevs profile image
MxL Devs

Is there a way to avoid holding a reference to the printer? For example say I wanted an unknown number of printers to receive the broadcast and independently perform a print task.

Would this new change still be considered publish-subscribe pattern?

Collapse
 
pashagray profile image
Pavel Tkachenko • Edited

Yes! You may need to introduce Event Bus. It's like a huge highway, where publishers post events. Subscribers listen to that Event Bus, rather than Publishers.

EventBus