Inversion of Control & Dependencies
Code that creates its own dependencies is tied to them. You cannot replace, test, or reconfigure it without major changes. Inversion of control reverses this: a component states what it needs and is given it, so it depends on abstractions, not concrete types. The result is code that is testable, swappable, and clear about what it relies on.
IoC and dependency injection are tools, not goals. The goal is the Dependency Inversion Principle: high-level policy (your business logic) should not depend on low-level details (a specific database, HTTP client, or third-party SDK). Both should depend on an abstraction. When a service is given an interface instead of creating a concrete class itself, you can test it with a fake, swap the implementation, and see its dependencies in the constructor.
This matters most for things that change often or need isolating: databases, external providers (Veriff, Sumsub, Stripe), clocks, and randomness. Depending on an interface for these lets us test business and security logic in a repeatable way, and keep volatile integrations behind a layer we control.
Depend on abstractions
- AlwaysInject dependencies through the constructor instead of creating them inside a class. Then collaborators can be replaced and seen at a glance.
- DoDepend on narrow interfaces that express what you need, not on concrete classes or third-party SDK types directly.
- DoPut external systems — databases, payment and KYC providers, email, time, randomness — behind interfaces you own, so business logic does not bind to a vendor or a side effect.
- DoRegister lifetimes on purpose in the DI container (singleton, scoped, transient) and match them to whether the service holds state.
- ConsiderKeeping interfaces small and role-based (what the consumer needs) rather than copying an implementation's whole surface.
- Do notUse a service locator or static accessors to grab dependencies at runtime. That hides dependencies and defeats the point of IoC.
public class Onboarding {
public Result Approve(Guid id) {
var screening = new VeriffClient(apiKey); // newed up inside
...
}
}
Onboarding is now tied to Veriff and a live API key. You cannot unit-test the approval logic without calling the real provider, and swapping or mocking it means editing this class.
public class Onboarding(IScreeningProvider screening) {
public Result Approve(Guid id) { var r = screening.Check(id); ... }
}
// startup: services.AddScoped();
The approval rule depends only on an interface. Tests inject a fake to cover the fail-closed path, and the provider can be swapped in one place without touching the logic.
Keep the seams clean
- DoLet dependencies flow inward. The domain defines the interfaces; infrastructure implements them. Then the core depends on nothing volatile.
- DoWire concrete implementations in one composition root (startup). Keep construction concerns out of business code.
- ConsiderInjecting a clock or time abstraction and a randomness source, so time- and randomness-dependent logic is testable and repeatable.
- Do notCreate a captive dependency: a longer-lived service holding a shorter-lived one (for example, a singleton holding a scoped DbContext). It causes subtle, dangerous bugs.
- Do notInject the whole container or a large 'services' bag. That is a hidden service locator inside a constructor.
Self-review checklist
- AskAre this class's dependencies visible in its constructor, or hidden inside its methods?
- AskCould I unit-test this logic without a real database or third-party call?
- AskDoes my business logic depend on an abstraction, or on a concrete vendor SDK?
- AskAre the registered lifetimes right — no singleton secretly holding scoped state?