-
Notifications
You must be signed in to change notification settings - Fork 0
TheDependencyGraphIsNotTheCallGraph
The dependency graph is not the call graph in the presence of DependencyInversion.
Consider the following call graph (example from Boundaries)
Sweeper
|
v
UserRepository
The Sweeper
class collects users who haven't paid their bills and "sweeps" them out of the system. It relies on a UserRepository
to get the users and update them.
If the dependency on UserRepository
is StaticallyResolvable to an object that talks to a particular database, then the dependency graph is identical to the call graph. The Sweeper
depends (indirectly) on that database, and no other database will satisfy it.
Even if UserRepository
is designed to Abstract away the details of the particular database it uses, the fact that the dependency is statically resolvable will lead to increased coupling between the Sweeper and UserRepository as DevelopmentTime elapses, due to KranzsFirstCorollary. Programmers' MentalModel will be "the Sweeper calls the UserRepository" and so if the sweeper needs to do a new thing with users, the low-level stuff will get delegated to the repository. The UserRepository
will thus become less abstract, less simple, and more bespoke. If it is Reused by many callers, it will inevitably accrete more functionality (probably not Orthogonal to the existing) and turn into a Mess.
Suppose, however, that we break the dependency using an Interface that satisfies a well-defined Contract with convenient AlgebraicProperties. The result may surprise you!
Sweeper
|
v
SqlDatabase (interface)
Yes, I claim that a SqlDatabase
Interface is more abstract, more general, and more loosely coupled than the UserRepository
. Consider: the SQL standard is unaware of our user model, agnostic to the types of operations we may want to perform, implemented by a variety of RDBMSes, and specifiable by ContractTests based on an Oracle (the standard). If that isn't loose coupling, I don't know what is.
Consider that the SqlDatabase
interface will likely reach a point of maturity after which it will never have to change. The ability to start, commit, and rollback transactions, and execute statements, is pretty much all we need. Very different from the UserRepository
, where every new operation and query needs a new method.
If we can obtain a SQL database that is fast enough to run inside our unit tests (and sqlite
probably is), replacing the real database with that fast Fake is a convenient, realistic, and confidence-building way to test the Sweeper
. This solves the problem Gary pointed out in Boundaries—that mocking a bespoke interface like UserRepository
forces you to use MessageBasedVerification, which makes the tests Brittle.
Now the dependency graph looks like this:
Sweeper sqlite postgres
\ / /
v v v
SqlDatabase
Three very different pieces of software, collaborating via a shared abstraction.
We now have two call graphs: one for tests, and one for prod:
Test______
| \
v v
Sweeper -> sqlite
Sweeper -> postgres
Note that I'm simplifying the Sweeper by not showing its internals or purely functional dependencies. There would likely be some reusable code that constructs a user model or a list of users from database output, constructs SQL queries, etc. The sweeper does not need to have hardcoded SQL strings in it, or deal directly with the raw data coming from the database!
In fact, the code for the sweeper that depends on SqlDatabase
can be made just as simple and readable as the sweeper that depends on UserRepository
. Observe:
// Using userRepository
const users = await userRepo.unpaid(now)
// Using SqlDatabase. unpaidUsers() is a pure function that constructs a SQL
// query.
const users = await sql(unpaidUsers(now))
The SqlDatabase
version above has an additional benefit, which is that unpaidUsers
is very easy to test. The same is possibly not true of the UserRepository
, since we have to mock the database it calls and assert on the query our mock received.
What if our application later needs to switch to a NoSQL database? Well, that's probably going to entail a rewrite of large chunks of the code no matter what you do. In general, NoSQL requires a completely different way of organizing and accessing data. You cannot abstract away the difference between NoSQL and SQL without introducing horrible ExecutionTime inefficiencies.