The Three Basic Rules for a Good Design

Objective


To learn how to apply basic object oriented design principles

Problem


Write a program that:

  1. Loads a set of employee records from a flat file
  2. Sends a greetings email to all employees whose birthday is today

The flat file is a sequence of records, separated by newlines; these are the first few lines:

last_name, first_name, date_of_birth, email
Doe, John, 1982/10/08, john.doe@foobar.com
Ann, Mary, 1975/09/11, mary.ann@foobar.com

The greetings email contains the following text:

Subject: Happy birthday!

Happy birthday, dear <first_name>!

where first_name is the place holder for first name.

Object Oriented Design Basic Principles


We will apply the following three basic principles:

  1. Separate things that change from things that stays the same. Encapsulate what varies behind a well-defined interface.
  2. Program to interfaces, not implementations. This exploits polymorphism.
  3. Depend on abstractions. Do not depend on concrete classes.

High Level Steps


  1. Read data.txt file
  2. Check if date of birth is today
  3. Send greetings email if today is the person's birthday

Let's read a CSV file that contains data.

file_name = "data.txt"

text = open(file_name)

print text.read

Refine the step 1.

1. Read data.txt CSV file
    Skip the header
2. Check if date of birth is today
3. Send greetings email if today is birthday

Here is our simple program that we can start playing with:

require 'csv'

file_name = "#{Dir.pwd}/data.txt"

data = CSV.read(file_name, {headers: true}) 

data.each do |x|
  p x
end

Refine the step 2

1. Read data.txt CSV file
    Skip the header
2. Check if date of birth is today
    Retrieve the third column
    Remove the spaces at the ends
    Check if month and date is the same as today's month and date
    If yes, return the person's first name
3. Send greetings email if today is birthday

Refine the step 3

1. Read data.txt CSV file
    Skip the header
2. Check if date of birth is today
    Retrieve the third column
    Remove the spaces at the ends
    Check if month and date is the same as today's month and date
    If yes, return the person's first name
3. Send greetings email
    Use Gmail, Sendgrid, Pony etc.

Before


The program written that does not apply the 3 design principles looks like this:

require 'csv'

file_name = "#{Dir.pwd}/data.txt"

data = CSV.read(file_name, {headers: true}) 

data.each do |x|
  birth_date = x[2].strip!
  month = birth_date[5..6]
  day = birth_date[8..9]  

  if (month.to_i == Date.today.month) and (day.to_i == Date.today.day)
    p x[1].strip

    email = <<EMAIL_TEXT
Subject: Happy birthday!

    Happy birthday, dear #{x[1].strip}!
EMAIL_TEXT

    p email
  end
end

Analysis


This program has the following responsibilities:

  1. Parsing CSV file
  2. Checking if today is the birthday of a person
  3. Sending email

Let's make a list of things that can change.

  • Input data source
  • How greetings is sent

Things that stays the same are:

  • Logic to find if someone's birthday is today.

Step 1


The PersonFileStore class will have records method that will return a list of Person objects. The code that applies what we did in analysis now looks like this:

require 'csv'

# This is a domain object
# This object has no dependency on other objects. It is agnostic to storage mechanism
class Person 
  attr_reader :first_name

  def initialize(first_name, last_name, date_of_birth, email)
    @first_name = first_name
    @last_name = last_name
    @date_of_birth = date_of_birth
    @email = email
  end

  def birth_day
    @date_of_birth[8..9]  
  end

  def birth_month
    @date_of_birth[5..6]
  end
end  

# This class knows how to parse the CSV file to create Person objects
# The direction of dependency is from PersonFileStore to the domain object
class PersonFileStore
  def initialize(file)
    @file = file
  end

  def records
    result = []
    data = CSV.read(@file, {headers: true}) 
    data.each do |x|
      person = Person.new(x[1], x[0], x[2].strip!, x[3])

      result << person
    end    
    result
  end
end

# This section of the code is not yet cleaned up.
pfs = PersonFileStore.new("#{Dir.pwd}/data.txt")
records = pfs.records

records.each do |person|
  month = person.birth_month
  day = person.birth_day

  if (month.to_i == Date.today.month) and (day.to_i == Date.today.day)

    email = <<EMAIL_TEXT
Subject: Happy birthday!

    Happy birthday, dear #{person.first_name}!
EMAIL_TEXT

    p email
  end

end

Step 2


Let's extract the logic to find if anyone has a birthday today.

# Person and PersonFileStore classes is same as before.

# This class encapsulates the logic to find out if the birthday is today or not.
# It has no dependency on other objects
class BirthDay
  def initialize(month, day)
    @month = month
    @day = day
  end

  def today?
    (@month.to_i == Date.today.month) and (@day.to_i == Date.today.day)
  end
end

pfs = PersonFileStore.new("#{Dir.pwd}/data.txt")
records = pfs.records

records.each do |person|
  month = person.birth_month
  day = person.birth_day

  birth_day = BirthDay.new(month, day)

  if birth_day.today?

    email = <<EMAIL_TEXT
Subject: Happy birthday!

    Happy Birthday, Dear #{person.first_name}!
EMAIL_TEXT

    p email
  end

end

Step 3


Let's extract sending greeting.

# Person, PersonFileStore and Birthday classes is same as before.

# Sending email to the console output is encapsulated within the send interface
class GreetingConsole
  def initialize(message, email)
    @message = message
    @email = email
  end

  def send
    p "Sending email to : #{email}"
    p @message    
  end
end

# The following code is the client code
pfs = PersonFileStore.new("#{Dir.pwd}/data.txt")
records = pfs.records

records.each do |person|
  month = person.birth_month
  day = person.birth_day

  birth_day = BirthDay.new(month, day)

  if birth_day.today?
    message = <<EMAIL_TEXT
Subject: Happy Birthday!

    Happy Birthday, Dear #{person.first_name}!
EMAIL_TEXT
    # Client is tied to a specific implementation of sending an email message
    # This needs to change to GreetingEmail.new(message), greeting.send to send email greeting
    greeting = GreetingConsole.new(message)
    greeting.send
  end

end

Step 4


Let's add an in-memory data source and make it work.

class PersonMemoryStore

  def records
    result = []

    person = Person.new('Bugs', 'Bunny', '1982/10/06', 'bbunny@rubyplus.com')
    result << person

    person = Person.new('Daffy', 'Duck', '1975/09/11', 'dduck@rubyplus.com')          
    result << person

    result
  end

end

# The following code is the client code
pfs = PersonMemoryStore.new
records = pfs.records

records.each do |person|
  month = person.birth_month
  day = person.birth_day

  birth_day = BirthDay.new(month, day)

  if birth_day.today?
    message = <<EMAIL_TEXT
Subject: Happy Birthday!

    Happy Birthday, Dear #{person.first_name}!
EMAIL_TEXT
    # Client is tied to a specific implementation of sending an email message
    # This needs to change to GreetingEmail.new(message), greeting.send to send email greeting
    greeting = GreetingConsole.new(message)
    greeting.send
  end

end

Notice that the PersonMemoryStore has the same interface as the PersonFileStore class. In a real project, we could use SQLite in-memory database.

Step 5


Let's add a different way to send email by using Pony gem.

require 'pony'

# Sending a real email using Pony gem
class GreetingPony
  def initialize(message, email)
    @message = message
    @email = email
  end

  def send
    Pony.mail(:to => @email, :from => 'admin@rubyplus.com', :subject => 'Happy Birthday!', :body => @message)
  end
end

After


The channel folder has greeting_console.rb and greeting_pony.rb classes. The GreetingConsole class looks like this:

# Sending email to the console output is encapsulated within the send interface
class GreetingConsole
  def initialize(message, email)
    @message = message
    @email = email
  end

  def send
    p "Sending email to : #{@email}"
    p "Subject : Happy Birthday!"
    p @message    
  end
end

Here is the GreetingPony class:

require 'pony'

# Sending a real email using Pony gem
class GreetingPony
  def initialize(message, email)
    @message = message
    @email = email
  end

  def send
    Pony.mail(:to => @email, :from => 'admin@rubyplus.com', :subject => 'Happy Birthday!', :body => @message)
  end
end

The domain folder contains the BirthDay and Person classes. Here is the BirthDay class:

require 'date'

# This class encapsulates the logic to find out if the birthday is today or not.
# It has no dependency on other objects
class BirthDay
  def initialize(month, day)
    @month = month
    @day = day
  end

  def today?
    (@month.to_i == Date.today.month) and (@day.to_i == Date.today.day)
  end
end

Here is the Person class:

# This is a domain object
# This object has no dependency on other objects. It is agnostic to storage mechanism
class Person 
  attr_reader :first_name, :email

  def initialize(first_name, last_name, date_of_birth, email)
    @first_name = first_name
    @last_name = last_name
    @date_of_birth = date_of_birth
    @email = email
  end

  def birth_day
    @date_of_birth[8..9]  
  end

  def birth_month
    @date_of_birth[5..6]
  end
end  

The source folder contains person_file_store.rb and person_memory_store.rb.

require 'csv'
require_relative '../domain/person'

# This class knows how to parse the CSV file to create Person objects
# The direction of dependency is from PersonFileStore to the domain object
class PersonFileStore
  def initialize(file)
    @file = file
  end

  def records
    result = []
    data = CSV.read(@file, {headers: true}) 
    data.each do |x|
      person = Person.new(x[1], x[0], x[2].strip!, x[3])

      result << person
    end    
    result
  end
end

You can see that we need the require_relative statement, since it has dependency on the Person domain object. The PersonMemoryStore class looks like this:

require_relative '../domain/person'

#  This class provides in-memory implementation of the data source interface
#  Useful in writing tests
class PersonMemoryStore

  def records
    result = []

    person = Person.new('Bugs', 'Bunny', '1982/10/08', 'bbunny@rubyplus.com')
    result << person

    person = Person.new('Daffy', 'Duck', '1975/09/11', 'dduck@rubyplus.com')          
    result << person

    result
  end

end

Visual Representation


Layered Design

You can download the final refactored code that has a better design here: Ruby Greeter

Summary


We separated the input data source that can change into it's own source folder. We encapsulated it behind a well-defined interface. We did the same for different ways to send birthday greetings by moving all the relevant classes to the channel folder. We depend on the send method for greeting delivery and records method for data source, so we program to the interface. The glue code in main.rb that uses the classes in the channel, domain and source folders depends on concrete classes. You can use dependency injection and vary the input source and the channel to make them depend on abstractions instead of concrete classes.

Reference


The birthday greetings kata


Related Articles


Software Compatibility Best Practices

I spoke to some of the most talented and experienced software developers. I have created a guide that is filled with valuable insights and actionable ideas to boost developer productivity.

You will gain a better understanding of what's working well for other developers and how they address the software compatibility problems.

Get the Guide Now