When new, wide-eyed engineers first start out writing maintainable, readable, extensible {insert-everything-good}ible software, they will quickly stumble upon the concepts of Dependency Injection (DI) and interfaces. Combined with interface programming, DI prescribes software engineers to inject dependencies into a class via a constructor or setter method. For example, you might inject 2 operands '1' and '2' into an instance of the Add class:
class Add {
// ...
Add(IntClass op1, IntClass op2) {
this.op1 = op1;
this.op2 = op2;
}
// ...
};
IntClass is an interface, and Add doesn't care what the implementation of it is. It just cares that it exposes the methods that it wants:
class Add {
// ...
int execute() {
return this.op1.getVal() + this.op2.getVal();
}
// ...
};
Add cares that IntClass exposes getVal() which should return an int, but doesn't care how it's implemented. Now if you want to write a unit test for Add but you don't want to make it test IntClass as well (that'd be more of an integration test), then you could create a mock implementation that implements the IntClass interface, and pass that in for the unit test. Voila! Extensible code - delicious, yes?
But the problem here is that if you want to add more functionality to Add, then you'll have to add more dependencies. Maybe you want to log the operands that are passed in - then you could pass in a logger. And if you want to publish a metric when there's an exception, you'd add another parameter to the Add constructor to accept that. In your unit test you'd mock those out as well, and boom, you've still got a single piece of logic being tested and if the unit test fails, you know exactly the spot to go fix the bug.
class Add {
// ...
Add(IntClass op1, IntClass op2, ILogger logger, IMetrics metrics) {
this.op1 = op1;
this.op2 = op2;
this.logger = logger;
this.metrics = metrics;
}
// ...
};
Suddenly, you realize you're on your way to dependency hell.
So how do you solve this? Well, if you're already half way to hell, it can be tough. But I had a coworker today tell me that there's a way to prevent it - and that if you enjoy moving code around, you can refactor and fix it too even if you're already there.
The pic above shows the mental model to use to stop yourself from walking slowly but surely deeper into dependency hell.
BL = Business Logic
DTO = Data Transfer Object
Think of your class as a chunk of business logic. Without fail, all it's doing is taking some data from one place, doing operations with it, and then taking the result and writing it somewhere else. That's it! The key takeaway is that you want to separate the parts that are reading something from the parts where you're doing operations, and also separate the parts where you're doing operations from the parts where you're writing something. You can connect the parts together using DTOs, which allows the BL layer to not know anything about the read or write logic - encapsulation FTW!
In the example above, you could keep the original constructor with just the 2 operands as input. Now if you needed to add logging and metric publishing, instead of adding those as dependencies, you would add another BL class for logging and another BL class for metric publishing. Those would run before/after the Add class, and they would either output or take as input a DTO which is either input to or output from the Add class.
The next problem is what happens if you need to add some logic in the middle of the operations for Add class. Maybe you want to square the result too? In that case you could do what god intended for all of us - add another layer of indirection!
interface IOperator {
int execute() = 0;
};
class Add : public IOperator {
// ...
Add(IntClass op1, IntClass op2, ILogger logger, IMetrics metrics) {
this.op1 = op1;
this.op2 = op2;
this.logger = logger;
this.metrics = metrics;
}
int execute() {
// ...
}
};
class AddAndSquare : public IOperator {
// ...
AddAndSquare(IntClass op1, IntClass op2, ILogger logger, IMetrics metrics) {
this.op1 = op1;
this.op2 = op2;
this.logger = logger;
this.metrics = metrics;
}
int execute() {
return (this.op1.getVal() + this.op2.getVal())^2;
}
};
Create an interface IOperator, have Add implement it and add then create a separate implementation of IOperator which does the squaring you wanted. Now if you want, you can factor out the common code between the 2 IOperator implementations, but the end goal is solved by creating another implementation - and you've extended the code rather than modifying it. Yeah I guess you could think of the IOperator addition as a modification? But whatever. English is hard. And besides, you haven't modified the behavior of Add class, which is the important part).
What do you think? Does it help to think of DI in this way? Can you think of some code at work that could use a thought model like this?
Comments
Post a Comment