Public Methods != Public API
I love designing and building APIs. Usually those APIs are in the form of REST web services. A lot of care goes into the interface of a web service because it's how your application is presented to the outside world. But what about the interfaces of your internal code?
Help Your Developers
In the same way that a clean, easy to understand web service can help the world interact with your application, a well designed interface to a Ruby class helps your application's current and future developers work more effectively.
Define Your Public API
The most common mistake in defining the public API for a class is to assume that the public API already exists as the collection of all public methods. Active Record models are the biggest offender.
When you define an Active Record model, you automatically get 537 public class methods and 292 public instance methods for free. Even a vanilla Ruby class inherits 177 public class methods and 101 public instance methods.
Using inherited public methods as part of your public API limits your code's flexibility as those methods may (read: "will") change or disappear in the future.
If your public API isn't defined by the collection of public methods, how is it defined?
Your public API is the collection of methods you agree to support.
It's a contract between you and future developers that any public API method is well supported and predictable. That means…
Testing and Documentation
Having defined how any public API method should behave, that behavior needs to be covered with tests. Decide what arguments the method will accept, what action it will take and what value it will return. Be sure to document these points, whether in code comments or more formal documentation.
Why Is This Important?
With thorough unit testing and documentation, future developers are empowered to refactor public API methods without fear.
Consider the case where the ORM of an application must be swapped out for another. This can be a terrifying ordeal, but with a well defined public API, each method of each model can simply be rewritten to suit the new ORM. Your tests change minimally and code outside of the models remains completely untouched.
The process of defining a good public API forces you to name your methods carefully. If each method is to be supported indefinitely into the future, naming should be descriptive, erring on the side of verbosity.
Defining a good public API also pushes you to shrink your boundaries. In the same way that a good web service consists of a small collection of clear, simple and powerful endpoints, you should seek to minimize the number of methods in your public API.
Finding the right balance is an exercise in trial and error. Too many and your public API becomes hard to maintain. Too few and your methods become too complex.
With a small set of public API methods, testing becomes easier and faster.
Having thoroughly unit tested and documented your public API methods, you can safely anticipate the behavior of those methods in other areas of your test suite. Your public API methods become the boundary lines of your class. This allows you to stub out those methods when testing elsewhere.
In a Rails application, you might see basic user authentication like this:
class ApplicationController < ActionController::Base before_action :authenticate attr_accessor :current_user private def authenticate authenticate_or_request_with_http_basic do |email, password| if user = User.find_by(email: email) self.current_user = user if user.authenticate(password) end end end end
This is not bad code. It works well and is not terribly difficult to understand. The problem is that Active Record provides the
find\_by method and Active Model's secure password library provides the
authenticate method. These are implementation details that are subject to change and are out of your control.
Instead, hide your implementation details behind a public API method that you control:
class ApplicationController < ActionController::Base before_action :authenticate attr_accessor :current_user private def authenticate authenticate_or_request_with_http_basic do |email, password| self.current_user = User.authenticate(email, password) end end end class User < ActiveRecord::Base has_secure_password # Find a user by given email and authenticate against given password. # Return the user if found and authenticated. # Return nil if the user is not found. # Return nil if the user is found but cannot be authenticated. def self.authenticate(email, password) user = find_by(email: email) user && user.authenticate(password) ? user : nil end end
Of course, your new
User.authenticate method will be covered by unit tests (not pictured) so if you ever decide to switch to Mongoid or different crypto in the future, your controller and its tests are still good to go!