In my experience, discussions on this topic can easily get out of hand with abstract language and technical jargon. So I will anchor this discussion with an example webservice.
Let's say I have an API with CRUD endpoints, that saves objects down to a database. If this API is simple enough, I might not bother with fancy designs or tests at all... so let's say that there's some business logic on the update, where if an object is updated in a certain way, we'll reject the update and email an administrator.
Then, the core / only interesting part of this service is this function:
public bool UpdateThingy(Thingy newVersion) {
var currentVersion = _database.GetThingy(newVersion.Id);
if (TransitionIsInvalid(currentVersion, newVersion)) {
_emailer.SendEmail($"Invalid update attempted: {newVersion.Id}");
return false;
}
_database.UpdateThingy(newVersion);
return true;
}
private bool TransitionIsInvalid(Thingy oldVersion, Thingy newVersion)
{ ... }
Now, I want to write a test for this method that doesn't involve spinning up local IIS or talking to a real database. So I use Dependency Injection:
public class ThingyManager {
private readonly IDatabase _database;
private readonly IEmailer _emailer;
public ThingyManager(IDatabase database, IEmailer emailer) {
_database = database;
_emailer = emailer;
}
public bool UpdateThingy(Thingy newVersion)
{ ... }
}
My actual webservice will look something like
public class ThingyService : Api {
private readonly ThingyManager _manager;
public ThingyService() {
_manager = new ThingyManager(new Database(), new Emailer());
}
[ApiEndpoint]
UpdateThingy(Thingy newVersion) {
if (_manager.UpdateThingy(newVersion)) {
return Status(200);
}
else {
return Status(400);
}
}
}
And my tests can look like this, if I like Moq and FluentAssertions:
[TestMethod]
public void ThingyManager_AcceptValidChange() {
// ARRANGE
var mockDB = new Mock<IDatabase>();
var oldVersion = ...; // Some normal intermediate state
var newVersion = ...;
mockDB.Setup(db => db.GetThingy(newVersion.Id)).Returns(oldVersion);
var manager = new ThingyManager(mockDB.Object, null);
// ACT
var success = manager.UpdateThingy(newVersion);
// ASSERT: success from manager, DB method was called
success.Should().Be(true);
mockDB.Verify(db => db.UpdateThingy(newVersion));
}
[TestMethod]
public void ThingyManager_RejectInvalidChange() {
// ARRANGE
var mockDB = new Mock<IDatabase>();
var oldVersion = ...; // A final state, that shouldn't be updated
var newVersion = ...;
var mockMailer = new Mock<IEmailer>();
mockDB.Setup(db => db.GetThingy(newVersion.Id)).Returns(oldVersion);
var manager = new ThingyManager(mockDB.Object, mockMailer.Object);
// ACT
var success = manager.UpdateThingy(newVersion);
// ASSERT: failure from manager, email sent, no update made
success.Should().Be(false);
mockMailer.Verify(mailer => mailer.SendEmail(It.IsAny<string>()));
mockDB.Verify(db => db.UpdateThingy(It.IsAny<Thingy>()), Times.Never);
}
That's all pretty routine, right? And in my opinion, it is good enough. There are plenty of tweaks we could make without changing the substance... We could add a parameterless constructor overload to the manager class, which instantiates a real DB connection and a real emailer. We could add an IConfig dependency for the email from/to/subject, and inject that too. We may need to slap IDisposable on some of these classes, and forward Dispose() calls to the IDatabase, to ensure the connection is closed at some point. All of those are details, and I am handwaving them away for now.
We could also take the next step and add an Inversion of Control container. Like Autofac, SimpleInjector, Unity, DryIoc, Ninject...
The first step to using IoC is to register dependencies with it. Somehow, the container needs to know that when I ask for an IEmailer, I want an Emailer. This can be done with config files, in code, or via reflection magic on the assembly. Having set that up, I can change the webservice to something like:
public class ThingyService : Api {
private readonly ThingyManager _manager;
public ThingyService() {
_manager = IOC.Get<ThingyManager>();
}
}
What have I gained?
- I don't have to use the word
newanymore. - If I add a new dependency to the manager, depending on how I configured the container, it may be able to discover and inject that dependency automatically.
- Depending on how I configured the container, it may be calling
Dispose()on my behalf at the appropriate times.
What have I lost?
- I can no longer see the call to my manager's constructor. I have to infer how and when it is called, and with what arguments, by analyzing my IoC setup.
- I have to maintain my IoC configuration. If I've gone the reflection route, then I should pretty much never have to touch this... but only pretty much never. Eventually something will go wrong; hopefully I haven't forgotten how it works by that point.
What haven't I changed?
- I haven't made it any easier to write my tests. I still have to define how I want my dependencies to behave (return this data, return that data, throw this error, etc), and I still want to inspect them after the action, so I'm still explicitly passing all the relevant dependencies into the manager's constructor.
If you have a very large application, with very many dependencies, with telescoping constructors, intricate lifetime requirements, multiple assemblies with many implementations for the various dependency interfaces, you may find it worthwhile to use an IoC container.
For the types of small-to-midsize, line-of-business applications I write, I have often found myself wishing I had used some form of Dependency Injection. I have never found myself wishing I had used Inversion of Control.