As an application matures, classes begin to take on more and more responsibilities. Eventually a class’s main responsibility starts to become obscured. You can prevent overwhelming your classes by introducing collaborators to help them fulfill their responsibilities. In this post, we’ll look at an example of using a collaborator to prevent non-domain responsibilities from creeping into a domain model.
Our sample app is a banking web app. Currently it has a single domain model: Account
. Account
represents both checking and savings accounts. Each account has a name, an account type (checking or savings), and a balance.
Our users want to be able to import accounts from a CSV file. Let’s start with a failing integration test.
spec/requests/importing_accounts_spec.rb
And here’s the above CSV file of accounts.
spec/fixtures/accounts.csv
Running this gives us our first failing test.
For simplicity, we’ll skip directly unit testing our view and controller, and instead move straight to the implementation.
First, we define some routes.
config/routes.rb
Then, a homepage view with a file upload form.
app/views/accounts/index.html.haml
And finally, a simple controller to handle the file upload request.
app/controllers/accounts_controller.rb
After this basic implementation, our test is still failing.
Great. We can now move down to the domain model.
Before we jump into the Account
unit test, let’s consider two possible CSV importing implementations:
Account
parses the CSV file and imports the accountsAccount
asks another object to parse the CSV file and import the accountsIn our domain, the Account
model represents bank accounts. CSV parsing has nothing to do with banking. If we changed from CSV to plaintext, it would be strange to have to change Account
. Also, if you were investigating a bug with CSV account importing, where would you look? The Account
model is probably your first choice, but CSV importing is most likely going to be hard to find amongst Account
‘s other responsibilities. Having a separate object dedicated to CSV account importing makes it more obvious to other developers as to where this behavior exists. Let’s take this latter approach.
spec/models/account_spec.rb
In this test, we introduce and stub out an account importer object. An account importer will take a file and return the total number of accounts imported from the file.
Let’s get this test passing by having Account
tell the account importer to import the file.
app/models/account.rb
Our unit test is passing, but our integration test is still failing.
It’s failing because we’re not passing an account importer to Account::import
.
app/controllers/account_controller.rb
Let’s go back down to the domain model and test drive this CsvAccountImporter
.
spec/models/csv_account_importer_spec.rb
And here’s a possible implementation in order to get this test passing.
app/models/csv_account_importer.rb
Our integration test should now be passing.
Since our tests are now green, it’s time to refactor. In our controller, we explicitly passed the Account
‘s collaborator, a CsvAccountImporter
. But a controller shouldn’t know how Account
imports CSV accounts. We can prevent this implementation detail from leaking out of our domain model by using Ruby’s default arguments.
First, we’ll delete the CSV account importer from the controller.
app/controllers/accounts_controller.rb
Then, we’ll set a new CsvAccountImporter
object as the default account importer argument.
app/models/account.rb
Now our controller knows less and Account
‘s collaborator is much more obvious.
Every codebase usually has that one massive class. It weighs in at over 1000 lines; its tests are twice as long and have no organization whatsoever. Since it contains so much behavior, it’s changed on almost every commit. Developers fear changing it because of merge conflicts caused from other developers also changing it. Collaborators are a simple way to break apart these unmaintainable classes. Don’t be afraid to add another class. Remember that your motivation is clarity, not flexibility.