Ruby - Factory Method pattern

Subscribe to receive new articles. No spam. Quality content.

Hey! I wrote couple posts about patterns (strategy, decorator and template). This list can not be complete without Factory Method pattern.

Here is how Wiki describes this pattern:

the factory method pattern is a creational pattern that uses factory methods to deal with the problem of creating objects without having to specify the exact class of the object that will be created.

From this description we see that it's a creational pattern and using it we will be able to instantiate objects of some classes.

Let's go through this pattern by example. It will be similar to one we had in dependency injection.

Our goal to develop report generator which will support different file formats. We know that at the beginning it should support at least two file formats: csv and json.

Today I want to show how to go from the most basic implementation to Factory Method pattern.

So, the most basic implementation will look like this:

class ReportGenerator
  def self.generate(data, type)
    return "" if (data.empty? || !['csv', 'json'].include?(type))

    if type == 'csv'
      result = data.keys.join(",") + "\n"
      result += data.values.join(",") + "\n"      
    elsif type == 'json'
      result = data.to_json
    end

    result
  end
end

report_data = { foo_key: "foo", bar_key: "bar", baz_key: "baz" }

ReportGenerator.generate(report_data, 'json') # => {"foo_key":"foo","bar_key":"bar","baz_key":"baz"}
ReportGenerator.generate(report_data, 'csv') 
# => foo_key,bar_key,baz_key
# => foo,bar,baz

To understand how to make this code more flexible we should understand why this code is bad. Yes, it works, but class ReportGenerator has at least couple problems:

  1. It breaks Single Responsibility principle because it's responsible for a couple things: it defines supported formats and contains logic of formatting as well.
  2. Code with if type == 'csv' and elsif type == 'json' looks ugly and all method looks like a 'rocket' because of nesting. Good code usually have flat structure.
  3. In two places in this class we have explicit definition of csv and json (in if..elsif and at the beginning on checking available types). It means that if we want to add new format - we will need to change code in two places.

Let's say we want to support new format - tsv (tab separated value). In this case code gets even more complex and ugly:

class ReportGenerator
  def self.generate(data, type)
    return "" if (data.empty? || !['csv', 'tsv', 'json'].include?(type))

    if type == 'csv'
      result = data.keys.join(",") + "\n"
      result += data.values.join(",") + "\n"
    elsif type == 'tsv'
      result = data.keys.join("\t") + "\n"
      result += data.values.join("\t") + "\n"          
    elsif type == 'json'
      result = data.to_json
    end

    result
  end
end

Now, when we understand problem of this code, let's do a first step and pull out formatting of data into separate classes:

class CsvFormatter
  def format(data)
    return "" if data.empty?

    result = data.keys.join(",") + "\n"
    result += data.values.join(",") + "\n"
  end        
end

class JsonFormatter
  def format(data)
    return "" if data.empty?

    data.to_json
  end
end

So we have 2 formatters which have the same interface - respond to format(data).

Using these classes our generator can get rid from one of its responsibilities. Because it's not responsible for formatting now:

class ReportGenerator
  def self.generate(data, type)
    return "" if (data.empty? || !['csv', 'json'].include?(type))

    if type == 'csv'
      result = CsvFormatter.new.format(data)
    elsif type == 'json'
      result = JsonFormatter.new.format(data)
    end

    result
  end
end

This code looks better, but still it's not perfect yet. Class still responsible for defining report type and contains this ugly if..elsif.

It's time to use Factory Method, which will be responsible for creating object which will format data.

Let's see how it might look like:

class Formatter
  def self.for(type)
    case type
    when 'csv'
      CsvFormatter.new
    when 'json'
      JsonFormatter.new
    else
      raise 'Unsupported type of report'
    end
  end
end

Using Formatter.for('json') we can easily get an instance of class JsonFormatter and call .format(data) to get formatted data.

If we call Formatter.for('csv') we will get an instance of CsvFormatter. Notice that both these classes have the same interface: respond to .format(data).

Now let's combine all these parts together - use Formatter.for and ReportGenerator:

class CsvFormatter
  def format(data)
    return "" if data.empty?

    result = data.keys.join(",") + "\n"
    result += data.values.join(",") + "\n"
  end        
end

class JsonFormatter
  def format(data)
    return "" if data.empty?

    data.to_json
  end
end

class Formatter
  def self.for(type)
    case type
    when 'csv'
      CsvFormatter.new
    when 'json'
      JsonFormatter.new
    else
      raise 'Unsupported type of report'
    end
  end
end

class ReportGenerator
  def self.generate(data, type)
    Formatter.for(type).format(data)
  end
end

report_data = { foo_key: "foo", bar_key: "bar", baz_key: "baz" }
ReportGenerator.generate(report_data, 'json') # => {"foo_key":"foo","bar_key":"bar","baz_key":"baz"}

This code does the same work as initial implementation, but let's see benefits we have now:

  1. Class ReportGenerator responds to only one action - it generates report depending on format it accepts.
  2. Class Formatter, and method for - it's a factory which returns formatter for required type (csv and json).
  3. All formatters respond to .format(data) and we can easily call Formatter.for(type).format(data). We don't care which formatter we will get -it will work anyway. This method is a typical command which we send to object.
  4. There is no any nesting and if..else. Code has flat structure.

If we want to support new format - it's easy to do so. We just create formatter and add it to factory:

class CsvFormatter
  def format(data)
    return "" if data.empty?

    result = data.keys.join(",") + "\n"
    result += data.values.join(",") + "\n"
  end        
end

class JsonFormatter
  def format(data)
    return "" if data.empty?

    data.to_json
  end
end

class TsvFormatter
  def format(data)
    return "" if data.empty?

    result = data.keys.join("\t") + "\n"
    result += data.values.join("\t") + "\n"
  end
end

class Formatter
  def self.for(type)
    case type
    when 'csv'
      CsvFormatter.new
    when 'json'
      JsonFormatter.new
    when 'tsv'
      TsvFormatter.new
    else
      raise 'Unsupported type of report'
    end
  end
end

class ReportGenerator
  def self.generate(data, type)
    Formatter.for(type).format(data)
  end
end

report_data = { foo_key: "foo", bar_key: "bar", baz_key: "baz" }
ReportGenerator.generate(report_data, 'tsv')

Check that we didn't change any code in ReportGenerator but it has got a support of tsv format. It's a Open/Closed principle of SOLID object-oriented design.

This principle tells:

software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification

That's exactly what we have. We don't have to change code of ReportGenerator to add support of new format.

I really recommend to start using Factory Method because it's one of the most popular patterns and it makes code more flexible.

By this example we've seen how factory for formatters allowed to improve our code and make it more flexible and extendable.

Thanks for reading!

Subscribe to receive new articles. No spam. Quality content.

Comments