Testable and Maintainable code with Dependency Injection and Containers
Frameworks provide a tool for rapid application development, but often accrue technical debt as rapidly as they allow you to create functionality.
Technical debt is created when maintainability isn't a purposeful focus of the developer. Future changes and debugging becomes costly due to lack of unit testing and structure.
Here's how to begin structuring your code to achieve testability and maintainability - and save you time.
We'll cover (loosely)
- DRY
- Dependency Injection
- Interfaces
- Containers
Let's start with some contrived but typical code. This might be a model class in any given framework.
class User {
public function getCurrentUser()
{
$user_id = $_SESSION['user_id'];
$user = App::db->select('user')
->where('id', $user_id)
->limit(1)
->get();
if ( $user->num_results() > 0 )
{
return $user->row();
}
return FALSE;
}
}
This code will work, but needs improvement:
- This isn't testable.
- We're relying on the $_SESSION global variable. Unit-testing frameworks often rely on the command-line, where $_SESSION and many other global variables aren't available.
- We're relying on the database connection. Ideally, actual database connections should be avoided in a unit-test. Testing is about code, not about data.
- This code isn't as maintainable as it could be. For instance, if we change the data source, we'll need to change the database code in every instance of
App::db
used. Also, what about instances where we don't want just the current user's information?
So, let's get down to how we can improve this.
Keep code DRY
The function retrieving the current user is unnecessary in this simple context. This is a contrived example, but in the spirit of DRY principles, the first optimization I'm choosing to make is to generalize this method.
class User {
public function getUser($user_id)
{
$user = App::db->select('user')
->where('id', $user_id)
->limit(1)
->get();
if ( $user->num_results() > 0 )
{
return $user->row();
}
return FALSE;
}
}
This provides a method we can use across our entire application. We can pass in the current user at the time of the call, rather than passing that functionality off to the model. Code is more modular and maintainable when it doesn't rely on other functionalities (such as the session global variable).
Dependency Injection
Let's increase our testability and maintainability by adding some Dependency Injection.
class User {
public function __construct($db_connection)
{
$this->_db = $db_connection;
}
public function getUser($user_id)
{
$user = $this->_db->select('user')
->where('id', $user_id)
->limit(1)
->get();
if ( $user->num_results() > 0 )
{
return $user->row();
}
return FALSE;
}
}
Now the dependencies of our User model are provided for. Our class no longer assumes a certain database connection nor relies on any global objects.
At this point, our class is basically testable. We can pass in a data-source of our choice (mostly) and a user id, and test the results of that call. We can also switch out separate database connections (Assuming the both implement the same methodologies for retrieving data). Cool.
We can still do much better. Here's where it gets interesting.
Interfaces
To improve this further, we can define and implement an interface. Consider this code.
interface iUserData {
public function getUser($user_id);
}
class MysqlUser implements iUserData {
public function __construct($db_conn)
{
$this->_db = $db_conn;
}
public function getUser($user_id)
{
$user = $this->_db->select('user')->where('id', $user_id)->limit(1)->get();
if ( $user->num_results() > 0 )
{
return $user->row();
}
return FALSE;
}
}
class User {
public function __construct(iUserData $userdata)
{
$this->_db = $userdata;
}
public function getUser($user_id)
{
return $this->_db->getUser($user_id);
}
}
There's a few things happening here.
- First we define an interface for our user data source. This defines the addUser() method.
- Next, we implement that interface using MySQL. We accept a database connection object, and use it to grab a user from the database.
- Lastly, we enforce the use of a class implementing our iUserData interface in our User model. This guarantees that the data source will always have a getUser() method available.
What is the result of this?
- Our code is now fully testable. For the User class, we can easily mock the datasource. (Testing the implementations of the datasource would be the job of a separate unit test).
- Our code is much more maintainable.
- We can create ANY data source. ArrayUser, MongoDbUser, CouchDbUser, MemoryUser, etc.
- We can easily pass any data source to our User object if we need to - If you decide to ditch SQL, you can just create an implementation (for instance MongoDbUser) and pass that into your User model.
But, we can still do better!
Containers
Consider the usage of our current code:
// In some controller
$user = new User( new MysqlUser( App::db->getConnection('mysql') ) );
$user_id = App::session('user_id');
$currentUser = $user->getUser($user_id);
Our final step will be to introduce containers. In the above code, we need to create a bunch of objects just to get our current user. This code might be littered across your application. If you need to switch from MySQL to MongoDB, you'll need to edit wherever this code appears. (And it's not DRY). Containers can fix this.
A container simply "contains" an object or functionality. It's similar to a registry in your application. We can use a container to automatically instantiate a new User object with all needed dependencies.
// Somewhere in a configuration file
App->set('container', new Container());
App::container->User = function() {
return new User( new MysqlUser( App::db->getConnection('mysql') ) );
}
// Now, in some controller, we can simply do this:
$currentUser = App::container->User->getUser( App::session('user_id') );
The Results
- We have kept our code DRY.
- We can switch out our User model from using MySQL to any other data source in ONE location. That is vastly more maintainable.
I'm sure you've noticed that we've added much more code in the name of maintainability and testability. A strong argument can be made against this implementation - We are increasing the complexity. Indeed, this requires a deeper knowledge of code, both for the main author and for collaborators of a project.
Whatever the cost of explanation and understanding, however, the extra complexity decreases technical debt.
- The code is vastly more maintainable - making changes possible in ONE location, rather than several.
- Being able to unit test will reduce bugs in code by a large margin.
Doing the extra-work up front will save time later.