These are the font sizes and colours used in this talk

class Foo
  def bar
    @ivar == CONSTANT
  end
end

(Plain text)

Element

Objects talking to objects

Gavin Morrice

aka Bodacious

We're hiring!

Objects talking to objects

  • Fundamental ideas in OOP
  • Process in complicated systems
  • Anti-patterns to these ideas

I think it's really awesome that I have tools for building things that reflect the way I think and how I understand the world

a [programming] language of description that serves as an interface between the models in the human mind and those in computing hardware

OOP lets us create code that is modelled on how we understand the the real world... and the real world is made up of things—we call those objects

Identity

State

Behaviour

What is an object?

What is an object?

Identity

What is an object?

Identity

class Kettle
end

kettle_a = Kettle.new
kettle_b = Kettle.new

puts kettle_a.object_id # => 3096
puts kettle_b.object_id # => 3104

kettle_a.equal?(kettle_b) # => false

What is an object?

State

What is an object?

State

class Kettle
  def initialize
    @water_level = 0
    @temperature = 0
    @heating = false
  end

  def on?
    @heating
  end

  def water_level
    @water_level
  end

  def temperature
    @temperature
  end
end

What is an object?

Behaviour

What is an object?

Behaviour

class Kettle
  TARGET_TEMP_C = 100

  def initialize
    @water_level = 0
    @temperature = 0
    @heating = false
    @heating_element = HeatingElement.new
  end

  def on?
    @heating
  end

  def water_level
    @water_level
  end

  def temperature
    @temperature
  end

  def boiled?
    temperature == TARGET_TEMP_C
  end

  def fill(volume)
    @water_level += volume
  end

  def empty(volume)
    @water_level -= volume
  end

  def turn_on
    turn_off if water_level < MIN_WATER_LEVEL

    heat

    sleep 0.5 until temperature >= TARGET_TEMPERATURE

    turn_off
  end

  def turn_off
    heating_element.turn_off
  end
    
  protected
    
  def heat
    heating_element.heat
  end

  def heating_element
    @heating_element
  end
end

Every component ...should be able to present itself in a meaningful way for observation and manipulation.

Everything in Ruby is an Object

hash == { foo: 'bar' }
hash.equal?({foo:'bar'}) # => false

hash.empty? # => false 

hash.transform_keys!(&:to_s)

Identity

State

Behaviour

If one part of the system works differently from all the rest, that part will require additional effort to control.

Recursive design

Recursive design

@kettle = Kettle.new

@heating_element = @kettle.heating_element 

@coil = @heating_element.coil

# ...

Messaging

Summary

Objects have three characteristics

  • Identity
  • State
  • Behaviour

We can combine objects to make more complicated objects

Objects collaborate to become more than they are separately

OOP is a powerful paradigm that allows us to build complex things out of simple things, just like in the real world

4 Core Principles of OOP

4 Core Principles of OOP

Abstraction

Encapsulation

Extension

Polymorphism

Abstraction

Capturing the essence of something by ignoring irrelevant detail

Abstraction

Distilling something down to its essential characteristics, stripping away all non-essential aspects.

class Kettle
  # ...
  
  def add_water(volume) ... end

  def pour_water(volume) ... end

  def boiled? ... end

  def water_level ... end
  
  def on_button ... end

  def steam_sensor ... end

  def power_supply ... end
  
  def lid ... end
  
  def heating_element ... end
  
  def electric_contacts  ... end 
  
  # ...
end

Abstraction

Distilling something down to its essential characteristics, stripping away all non-essential aspects.

class Kettle
  # ...
  
  def add_water(volume) ... end

  def pour_water(volume) ... end

  def boiled? ... end

  def water_level ... end
  
  def on_button ... end

  def steam_sensor ... end

  def power_supply ... end
  
  def lid ... end
  
  def heating_element ... end
  
  def electric_contacts  ... end 
  
  # ...
end
class Kettle
  def fill(volume)
    # ...
  end

  def empty(volume)
    # ...
  end
  
  def boiled?
    # ...
  end

  def heat
    # ...
  end
end

Abstraction

Distilling something down to its essential characteristics, stripping away all non-essential aspects.


  def fill(volume)
    # ...
  end

  def empty(volume)
    # ...
  end
  
  def boiled?
    # ...
  end

  def heat
    # ...
  end

Abstraction

Distilling something down to its essential characteristics, stripping away all non-essential aspects.

class WaterHeater
  def fill(volume)
    # ...
  end

  def empty(volume)
    # ...
  end
  
  def boiled?
    # ...
  end

  def heat
    # ...
  end
end

Abstraction

kettle = Kettle.new 

kettle.turn_on

until kettle.temperature < 100
  sleep(1)
end

kettle.turn_off

Abstraction

Distilling something down to its essential characteristics, stripping away all non-essential aspects.

kettle = Kettle.new
kettle.boil!

Polymorphism

Defining interactions based on what objects do, rather than what they are.

Polymorphism

Defining interactions based on what objects do, rather than what they are.

class TeaMaker
  def initialize(milk_bottle: MilkBottle.oldest,
                 tea_jar:,
                 sugar_bowl:)
    @milk_bottle = milk_bottle
    @tea_jar = tea_jar
    @sugar_bowl = sugar_bowl
    @spoon = Spoon.new
    @kettle = Kettle.new
  end

  def make_tea(cup:, milk_volume: 0, sugars: 0)
    @kettle.fill_water(cup.fill_volume)
    @kettle.boil

    cup.add(@tea_jar.take)
    cup.add(@kettle.empty_water(cup.fill_volume))
    cup.add(@milk_bottle.empty(milk_volume))
    cup.add(@sugar_bowl.take(sugars))
    @spoon.stir(cup)
    cup
  end
end

Polymorphism

Defining interactions based on what objects do, rather than what they are.

class TeaMaker
  def initialize(milk_source: MilkBottle.oldest,
		         water_heater: Kettle.new,
                 tea_source:,
                 sugar_source:)
    @milk_source = milk_source
    @tea_source = tea_source
    @water_heater = water_heater
    @sugar_source = sugar_source
    @spoon = Spoon.new
  end

  def make_tea(cup:, milk_volume: 0, sugars: 0)
    @water_heater.fill_water(cup.fill_volume)
    @water_heater.boil

    cup.add(@tea_source.take)
    cup.add(@water_heater.empty_water(cup.fill_volume))
    cup.add(@milk_bottle.empty(milk_volume))
    cup.add(@sugar_bowl.take(sugars))
    @spoon.stir(cup)
    cup
  end
end

Polymorphism

class TeaMaker
  def initialize(milk_source: MilkBottle.oldest,
                 stirrer: Spoon.new,
                 tea_source:,
                 water_heater:,
                 sugar_source:)
    @milk_source = milk_source
    @stirrer = stirrer
    @tea_source = tea_source
    @water_heater = water_heater
    @sugar_source = sugar_source
  end

  def make_tea(cup:, milk_volume: 0, sugars: 0)
    @water_heater.fill_water(cup.fill_volume)
    @water_heater.boil

    cup.add(@tea_source.take)
    cup.add(@kettle.empty_water(cup.fill_volume))
    cup.add(@milk_bottle.empty(milk_volume))
    cup.add(@sugar_bowl.take(sugars))
    @stirrer.stir(cup)
    cup
  end
end
TeaMaker.new({
  tea_source: TeaJar.main,
  water_heater: Pot.new
})

Extension

Adding new behaviour to an existing abstraction by building on top of it, without altering the original

Extension

Adding new behaviour to an existing abstraction by building on top of it, without altering the original.

Extension

Adding new behaviour to an existing class or abstraction to create a new specialised version, without altering the original.

class Kettle
  def fill(volume)
    # ...
  end

  def empty(volume)
    # ...
  end

  def heat
    # ...
  end
  
  def boiled?
    # ...
  end
end

Extension

class Kettle
  def fill(volume)
    # ...
  end

  def empty(volume)
    # ...
  end

  def heat
    # ...
  end
  
  def boiled?
    # ...
  end
end
class KettleWithTemperatureDisplay < Kettle
  def initialize
    super
    @temperature_display = TemperatureDisplay.new
  end
  def temperature
    temperature_display.temperature
  end
end
class KettleWithTemperatureDisplay
  extend Forwardable

  def_delegators :@kettle, :fill, 
  	:empty, :heat, :boiled?

  def_delegator :@temperature_display, 
  	:temperature
  
  def initialize
    @kettle = Kettle.new
    @temperature_display = TemperatureDisplay.new
  end
end

Extension

class Kettle
  def fill(volume)
    # ...
  end

  def empty(volume)
    # ...
  end

  def heat
    # ...
  end
  
  def boiled?
    # ...
  end
end
class KettleWithTemperatureDisplay < Kettle
  def initialize
    super
    @temperature_display = TemperatureDisplay.new
  end
  def temperature
    temperature_display.temperature
  end
end
class KettleWithTemperatureDisplay
  extend Forwardable

  def_delegators :@kettle, :fill, 
  	:empty, :heat, :boiled?

  def_delegator :@temperature_display, 
  	:temperature
  
  def initialize
    @kettle = Kettle.new
    @temperature_display = TemperatureDisplay.new
  end
end

Encapsulation

Hiding the information about an object's internal state and workings, exposing only a tightly controlled interface to the outside world.

Encapsulation

Hiding the information about an object's internal state and workings, exposing only a tightly controlled interface to the outside world.

Encapsulation

class TeaMaker 
  BOILING_TEMP_C = 100
  
  def make_tea
    # ... 
    kettle.heating_element.heat!
    sleep(1) until kettle.internal_temp == BOILING_TEMP_C
    kettle.heating_element.turn_off!
    # ...
    end
  end
end

Encapsulation

class TeaMaker 
  BOILING_TEMP_C = 100
  
  def make_tea
    # ... 
    kettle.heating_element.heat!
    sleep(1) until kettle.internal_temp == BOILING_TEMP_C
    kettle.heating_element.turn_off!
    # ...
    end
  end
end
class TeaMaker 
  def make_tea
    # ... 
    kettle.boil!
    # ...
  end
end

4 Core Principles of OOP

Abstraction

Encapsulation

Extension

Polymorphism

4 Core Principles of OOP

Abstraction

Encapsulation

Extension

Polymorphism

Lets us hide details; Keeps code manageable, but also flexible 

Lets us use multiple objects in new and different ways. Allows for cleaner code and maximal flexibility

Lets us introduce new behaviour; Allows for reuse without disruption

Lets us hide implementation and protect internal consistency; Allows for flexibility, robustness, and safety

The promise of OOP

If you follow these principles, the system you build should be maximally scalable, flexible, and robust; while minimising the cost of future change and disruption

???

Where is process defined in a complex/complicated system?

🖥️🤔

class UserSubscriberService
end
class UserSubscriberService
  def self.call
  end
end
class UserSubscriberService
  PROMO_DISCOUNT_PERCENT = Rational(85, 100) # 15% OFF
  PLAN_NAME = 'premium'

  def self.call(user)
    plan = Plan.find_by(name: PLAN_NAME)
    user.subscription.plan = plan
    user.subscription.amount = plan.amount * PROMO_DISCOUNT_PERCENT

    payment = user.subscription.payments.create(
      payment_source: user.credit_card
    )

    if payment.persisted?
      user.account.increment(:credit_balance, plan.credits)
      user.subscription.status = 'active'
      user.subscription.subscribed_at = Time.now
    else
      # 
    end
  end
end
class UserSubscriberService
  PROMO_DISCOUNT_PERCENT = Rational(85, 100) # 15% OFF
  PLAN_NAME = 'premium'

  def self.call(user)
    plan = Plan.find_by(name: PLAN_NAME)
    user.subscription.plan = plan
    user.subscription.amount = plan.amount * PROMO_DISCOUNT_PERCENT

    payment = user.subscription.payments.create(
      payment_source: user.credit_card
    )

    if payment.persisted?
      user.account.increment(:credit_balance, plan.credits)
      user.subscription.status = 'active'
      user.subscription.subscribed_at = Time.now

      return Result.new(:success)
    else
      return Result.new(:failure, "Payment failed")
    end
  end
end

Service Objects

class UserSubscriberService
  PROMO_DISCOUNT_PERCENT = Rational(85, 100) # 15% OFF
  PLAN_NAME = 'premium'

  def self.call(user)
    plan = Plan.find_by(name: PLAN_NAME)
    user.subscription.plan = plan
    user.subscription.amount = plan.amount * PROMO_DISCOUNT_PERCENT

    payment = user.subscription.payments.create(
      payment_source: user.credit_card
    )

    if payment.persisted?
      user.account.increment(:credit_balance, plan.credits)
      user.subscription.status = 'active'
      user.subscription.subscribed_at = Time.now

      return Result.new(:success)
    else
      return Result.new(:failure, "Payment failed")
    end
  end
end

State?

Behaviour?

Identity?

Service Objects

class UserSubscriberService
  PROMO_DISCOUNT_PERCENT = Rational(85, 100) # 15% OFF
  PLAN_NAME = 'premium'

  def self.call(user)
    plan = Plan.find_by(name: PLAN_NAME)
    user.subscription.plan = plan
    user.subscription.amount = plan.amount * PROMO_DISCOUNT_PERCENT

    payment = user.subscription.payments.create(
      payment_source: user.credit_card
    )

    if payment.persisted?
      user.account.increment(:credit_balance, plan.credits)
      user.subscription.status = 'active'
      user.subscription.subscribed_at = Time.now

      return Result.new(:success)
    else
      return Result.new(:failure, "Payment failed")
    end
  end
end

Centalised control

Object

Object

Object

Procedure

Object

Object

Object

Object

Object

Object

Distributed control

Object

Object

Object

Caller

Object

Object

Object

Object

Distributed control

Object

Object

Object

Caller

Object

Object

Object

Object

In OOP, process is not centrally defined; but emerges as the result of objects talking to objects

class UserSubscriberService
  PROMO_DISCOUNT_PERCENT = Rational(85, 100) # 15% OFF
  PLAN_NAME = 'premium'

  def self.call(user)
    plan = Plan.find_by(name: PLAN_NAME)
    user.subscription.plan = plan
    user.subscription.amount = plan.amount * PROMO_DISCOUNT_PERCENT

    payment = user.subscription.payments.create(
      payment_source: user.credit_card
    )

    if payment.persisted?
      user.account.increment(:credit_balance, plan.credits)
      user.subscription.status = 'active'
      user.subscription.subscribed_at = Time.now

      return Result.new(:success)
    else
      return Result.new(:failure, "Payment failed")
    end
  end
end

Many dependencies

Object

Object

Object

Procedure

Object

Object

Object

Object

Object

Object

Few dependencies

Object

Object

Object

Caller

Object

Object

Object

Object

class UserSubscriberService
  PROMO_DISCOUNT_PERCENT = Rational(85, 100) # 15% OFF
  PLAN_NAME = 'premium'

  def self.call(user)
    plan = Plan.find_by(name: PLAN_NAME)
    user.subscription.plan = plan
    user.subscription.amount = plan.amount * PROMO_DISCOUNT_PERCENT

    payment = user.subscription.payments.create(
      payment_source: user.credit_card
    )

    if payment.persisted?
      user.account.increment(:credit_balance, plan.credits)
      user.subscription.status = 'active'
      user.subscription.subscribed_at = Time.now

      return Result.new(:success)
    else
      return Result.new(:failure, "Payment failed")
    end
  end
end

Object

Object

Object

Procedure

Object

Object

Object

Object

Object

Object

1 degree

2 degrees

3 degrees

Object

Object

Object

Caller

Object

Object

Object

Object

class UserSubscriberService
  PROMO_DISCOUNT_PERCENT = Rational(85, 100) # 15% OFF
  PLAN_NAME = 'premium'

  def self.call(user)
    plan = Plan.find_by(name: PLAN_NAME)
    user.subscription.plan = plan
    user.subscription.amount = plan.amount * PROMO_DISCOUNT_PERCENT

    payment = user.subscription.payments.create(
      payment_source: user.credit_card
    )

    if payment.persisted?
      user.account.increment(:credit_balance, plan.credits)
      user.subscription.status = 'active'
      user.subscription.subscribed_at = Time.now

      return Result.new(:success)
    else
      return Result.new(:failure, "Payment failed")
    end
  end
end

Object

Procedure

Property A

Property B

Method A

Object

Caller

Property A

Property B

Method A

"Real OOP"
—Alan Kay

Build like nature builds

Anaemic domain model

Anaemic domain model

... that there is hardly any behavior on these [domain] objects, making them little more than bags of getters and setters. ... Instead there are a set of service objects which capture all the domain logic, carrying out all the computation and updating the model objects with the results. These services live on top of the domain model and use the domain model for data.

 

Anaemic domain model

class UserSubscriberService
  PROMO_DISCOUNT_PERCENT = Rational(85, 100) # 15% OFF
  PLAN_NAME = 'premium'

  def self.call(user)
    plan = Plan.find_by(name: PLAN_NAME)
    user.subscription.plan = plan
    user.subscription.amount = plan.amount * PROMO_DISCOUNT_PERCENT

    payment = user.subscription.payments.create(
      payment_source: user.credit_card
    )

    if payment.persisted?
      user.account.increment(:credit_balance, plan.credits)
      user.subscription.status = 'active'
      user.subscription.subscribed_at = Time.now

      return Result.new(:success)
    else
      return Result.new(:failure, "Payment failed")
    end
  end
end

class User < ApplicationRecord
  has_many :subscriptions
  has_one :account
end

class Account < ApplicationRecord
end

class SubscriptionPlan < ApplicationRecord
end

class Subscription < ApplicationRecord
  belongs_to :plan
  belongs_to :user
  has_many :payments
end

class Payment < ApplicationRecord
end

Some tips for using Procedure Objects

1. Don't.

2. Keep domain knowledge out of them

Some tips for using Procedure Objects

2. Keep domain knowledge out of them

class UserSubscriberService
  def self.call(user:, plan:, email_service: EmailSender.new)
    if Subscription.subscribe_user_to_plan(user:, plan:)
      email_service.send_email(user, :subscription_successful)
	  Result.new(:success)
    else 
 	  Result.new(:failure, "Failed to add subscription")
    end
  end
end

Some tips for using Procedure Objects

1. Don't.

2. Keep domain knowledge out of them

3. Do not call them from other procedure objects

Some tips for using Procedure Objects

3. Do not call them from other procedure objects

class SubscriptionsController < ApplicationController
  def create
    result = UserSubscriberService.call(user: current_user
    									subscription_plan_name: params[:plan_name],
      									discount_code: params[:discount_code],
      								    cadence_months: params[:cadence])
    if result.success?
      redirect_to welcome_url
    else
      render :new, assigns: { result: result }, status: :unprocessable_entity
    end
  end
end

Putting it all together

OOP is a really powerful paradigm that allows us to build scalable, complex systems that are robust and resilient to change

Patterns that step out of this paradigm should be treated with scepticism

Process should not be centrally defined, but should emerge through objects talking to objects

Thank you!

(Slides)

Objects talking to objects III

By Gavin Morrice

Objects talking to objects III

Exploring what makes OOP so great in Ruby

  • 90