Ruby - Factory Method pattern
- 30 October 2016
- Patterns Design
- Ruby - Factory Method pattern
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:
- It breaks Single Responsibility principle because it's responsible for a couple things: it defines supported formats and contains logic of formatting as well.
- Code with
if type == 'csv'
andelsif type == 'json'
looks ugly and all method looks like a 'rocket' because of nesting. Good code usually have flat structure. - 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:
- Class
ReportGenerator
responds to only one action - it generates report depending on format it accepts. - Class
Formatter
, and methodfor
- it's a factory which returns formatter for required type (csv and json). - All formatters respond to
.format(data)
and we can easily callFormatter.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. - 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!