1

I am inheriting an api decision in an SDK I am writing where I am required to fetch domain objects (entries) from the server like this:

blogEntries = client.content_type('blog').entries

As you can see, the setter for the content_type property here is parameterised. To implement this design my Client class has a method like this that sets the @content_type instance variable before passing it on to other objects:

def content_type(content_type_uid)
  @content_type = content_type_uid
  // do something with @content_type
end

Now elsewhere in the class when I need to fetch back the content_type I can no longer call an attr_reader method like configuration.content_type because it conflicts with the method above. Now that forces me to have a separate getter method called get_content_type which is really non-idiomatic ruby.

How do I work myself out of this conflicting situation where instead of a conventional setter like content_type= I have a setter with a different signature? What kind of trade-off would make most sense?

2 Answers2

3

Ideally, you should avoid having getters or setters altogether. The fundamental tenet of OO is behavioral abstraction, your objects should do stuff, not just "store" stuff.

If you absolutely must have getters and setters, the best thing to do would be to actually stick to standard Ruby conventions:

def content_type=(content_type_uid)
  @content_type = content_type_uid
  # do something with @content_type
end

If that is not possible, you could try to "overload" the method based on arity:

def content_type(content_type_uid = getter = true)
  return @content_type if getter
  @content_type = content_type_uid
  # do something with @content_type
end

If you do this, you should look up how to document this method as two distinct "overloaded" methods in whatever documentation system you are using. E.g. YARD has an @overload tag:

# @overload content_type
#   Gets the content type
#   @return [ContentType] The content type
# @overload content_type(content_type_uid)
#   Sets the content type to +content_type_uid+
#   @param content_type_uid [ContentType::Uid] The UID of the content type to set
def content_type(content_type_uid = getter = true)
  return @content_type if getter
  @content_type = content_type_uid
  # do something with @content_type
end

Or, you could use YARD's support for synthetic attributes/methods:

#   Sets the content type to +content_type_uid+
#   @param content_type_uid [ContentType::Uid] The UID of the content type to set
def content_type(content_type_uid = getter = true)
  return @content_type if getter
  @content_type = content_type_uid
  # do something with @content_type
end

# !attribute [r] content_type
#   Gets the content type
#   @return [ContentType] The content type

Note however, that this "overloading" has a significant disadvantage: while you can represent it in documentation, there is no way to represent it in the language. So, when you (as I often do) use reflection to explore some unfamiliar API, you will only see one method with an optional parameter with no indication of the fact that that one method is really two different methods in disguise:

method(:content_type).parameters
#=> [[:opt, :content_type_uid]]

Normally, an optional parameter is used to supply, well, optional arguments, not switch between two completely different behaviors.

Jörg W Mittag
  • 101,921
  • 24
  • 218
  • 318
3

The answer to this question might be in the answer to What is an Anti-Corruption layer, and how is it used?.

A quote of a quote from the accepted answer:

Above example is based on how Anticorruption Layer is explained at c2 wiki:

If your application needs to deal with a database or another application whose model is undesirable or inapplicable to the model you want within your own application, use an AnticorruptionLayer to translate to/from that model and yours.

You've got a class with funky behavior. It might be a good idea to create a wrapping class that "fixes" the weird behavior and hides the client:

class ClientWrapper
  attr_reader :content_type

  CONTENT_TYPE_BLOG = 'blog'

  def initialize(client)
    @client = client
  end

  def blog_entries
    @content_type = CONTENT_TYPE_BLOG
    @client.content_type(@content_type).entries
  end
end

Now you can do:

client = ClientWrapper.new SomeClient.new
blog_entries = client.blog_entries
puts client.content_type # -> 'blog'

As an alternative, maybe you need to package the content type and data together, along with an Anti-Corruption Layer:

class ClientWrapper
  CONTENT_TYPE_BLOG = 'blog'

  def initialize(client)
    @client = client
  end

  def blog_entries
    ClientData.new CONTENT_TYPE_BLOG, @client.content_type(CONTENT_TYPE_BLOG).entries
  end
end

class ClientData
  attr_reader :content_type, :data

  def initialize(content_type, data)
    @content_type = content_type
    @data = data
  end
end

Now using this looks like:

client = ClientWrapper.new SomeClient.new
blog_entries = client.blog_entries
puts blog_entries.content_type # -> 'blog'
puts blog_entries.data

My Ruby is a tad rusty, but you can create your own version of the each method on the client data using a subclass:

class ClientArrayData < ClientData
  def each(&block)
    if data.respond_to? :each
      data.each block
    else
      block.call data
    end
  end
end

Or just add the method right in the ClientData class. Then you can use it similar to other iterable objects:

client.blog_entries.each do |entry|
  # Do stuff with `entry`
end
Greg Burghardt
  • 34,276
  • 8
  • 63
  • 114