Keeping Domain Models Cohesive with Collaborators

Jared Carroll ·

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.

Bank Accounts and CSV Parsers

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.


And here’s the above CSV file of accounts.


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.


Then, a homepage view with a file upload form.


And finally, a simple controller to handle the file upload request.


After this basic implementation, our test is still failing.

Great. We can now move down to the domain model.

Isolating Non-Domain Responsibilities

Before we jump into the Account unit test, let’s consider two possible CSV importing implementations:

  • Account parses the CSV file and imports the accounts
  • Account asks another object to parse the CSV file and import the accounts

In 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.


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.


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.


Let’s go back down to the domain model and test drive this CsvAccountImporter.


And here’s a possible implementation in order to get this test passing.


Our integration test should now be passing.

Hiding Implementation Details with Default Arguments

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.


Then, we’ll set a new CsvAccountImporter object as the default account importer argument.


Now our controller knows less and Account‘s collaborator is much more obvious.

Leaner Models

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.