Dependency Injection: 7 Rules for Maintainable Business Software
Dependency injection is a design approach in which a part of an app receives the services it needs instead of creating or locating them itself. An order workflow might receive a stock reader and a message sender. This makes the links clearer. Teams can test and change a part without tightly binding the whole app together.
This guide explains seven rules for using the dependency injection pattern in business software. It covers boundaries, contracts, setup, lifetimes, testing, resource ownership, and handover. A document-release example shows what the pattern improves and what it does not solve.
A simple example of the idea
Imagine a report service that needs a clock and a file store. If it creates those tools internally, a test may depend on the real date and a real storage service. That makes some behavior harder to control.
Instead, the app can supply a clock and store when it creates the report service. A test can provide fixed time and a safe fake store. Production can provide the intended real clock and store.
The report service still owns its report rules. It simply no longer decides how the whole app sets up its supporting tools. This distinction helps keep business logic separate from setup.
A service container can automate that setup. But the core idea does not require one. For a small app, clear setup may be enough. Choose the amount of tooling from the size and complexity of the object graph.
1. Make dependencies visible
A visible dependency tells a maintainer what a part needs. Supply those needs through a clear constructor or supported entry point. That is easier to inspect than hidden global lookups.
For example, a release workflow may need a document store, a permission checker, and an event writer. A teammate should be able to see those links without tracing calls across the entire app.
Next, review parts with many dependencies. A long list can suggest that one class owns several unrelated jobs. Adding injection does not repair that design by itself.
Also avoid using a general service locator as a hidden shortcut. If every method asks a global container for any service it wants, the links become hard to see again. The official .NET dependency injection guidelines discuss this and other setup pitfalls.
Keep the business question simple: can a maintainer tell what this part relies on and why? That is more useful than counting how many interfaces the project has.
2. Put contracts at useful boundaries
A contract states what a service does and what its results mean. It can be an interface, a callable function, or another clear language-level boundary.
For a file store, the contract might define saving, reading, missing files, and failed writes. The workflow should know whether a successful call proves durable storage or only a queued request.
Next, avoid creating an interface for every class automatically. Some parts have one simple version and no useful replacement boundary. Extra layers can make the app harder to follow without adding value.
Use a boundary when the version varies, an outside system needs isolation, or a test needs a controlled substitute. For instance, a payment gateway or a time source often has a clearer reason than a tiny local formatting helper.
Oxen’s contract testing guide explains shared expectations at service boundaries. Apply the same discipline to fakes: they should represent the real contract rather than return whatever makes a test pass.
3. Keep app wiring in one clear setup layer
The setup layer connects each service version to the code that uses it. Teams sometimes call this the composition root. It gives maintainers one clear place to understand the main app wiring.
For example, live use may connect the report service to a cloud store. A local test environment may use a temporary store. The report rules should not need scattered environment checks to choose between them.
Next, validate the setup early. Missing setup entries and invalid links should fail in a controlled startup or test path where possible. Discovering them only when a customer opens a rarely used screen is harder to support.
Also keep factories focused. A factory can create an object when the app has a genuine runtime need, but it should not become an unexplained route to every other service.
Record environment-specific choices without embedding broad secrets in public code. The pattern organizes setup. It does not make setup safe automatically.
4. Match lifetimes to the work
A lifetime states how long an instance exists and who shares it. A short-lived helper, a per-request unit of work, and a shared process-level service have different needs.
The official .NET service lifetime guide describes transient, scoped, and singleton setup entries. These are concrete framework rules. Other frameworks may use different scopes or conventions, so check the actual container.
For a web app, per-request state should not accidentally live in a shared singleton. Otherwise, unrelated requests can become coupled or private state can leak between users. A thread-safe container does not make every created service thread-safe.
Next, inspect lifetime mismatches. A long-lived object holding a short-lived dependency can keep it alive far beyond its intended scope. The system needs a supported setup pattern for that situation, not a hidden global reference.
Also define the scope of background jobs. A worker processing several jobs should not automatically share one request-like unit of work across all of them. Give each job the resources and cleanup boundary its task needs.
5. Use test doubles for clear purposes
A fake, stub, or mock can replace a dependency in a test. The goal is to control a relevant condition. That might be time, a failed write, or a delayed response.
For example, a fixed clock can prove that a release record uses the intended timestamp. A failing file store can prove that the workflow does not mark a missing document as released.
Next, test business behavior rather than every internal call. A test that merely mirrors the exact wiring can become brittle when a safe refactor changes internal details.
Our regression testing guide covers protecting observable behavior. Keep important success and failure cases stable while allowing the code structure to improve.
Also test the real adapter. A fake store cannot prove that cloud credentials, serialization, or a production endpoint work. Pair focused tests with a suitable integration check at the boundary.
6. Define ownership of resources
Some dependencies hold files, connections, or other resources that need cleanup. State who creates, owns, and disposes of them. Do not let several consumers each assume they should close the same shared object.
For instance, a container-managed service may have a lifecycle the container owns. A manually created resource may need clear cleanup by its creator. Follow the actual framework’s rules rather than mixing the two models casually.
Next, test failure during setup and use. A part that fails after opening a resource still needs a cleanup path. Repeated failures should not leak handles or keep growing memory use.
Also avoid making every object a singleton to reduce setup cost. Shared mutable state can create correctness and support problems that outweigh a small setup saving. Measure an expensive resource before choosing a more complex lifetime.
A good review can trace one request or job from start to finish and cleanup. If that path is unclear, injection has not yet produced a maintainable resource model.
7. Keep the wiring explainable in live use
Support staff need to know which version and setup a running app uses. A clean interface is not enough if the deployment connects it to the wrong service.
For example, a report process should not accidentally use a test store in live use. Startup checks and safe environment indicators can help detect that mismatch without exposing secrets.
Next, log useful part and operation context. Oxen’s observability engineering guide explains how traces and service signals support diagnosis. Keep private payloads out of ordinary logs.
Also document how to add a new version. A maintainer should know the contract, setup location, lifetime, tests, and operating needs. The pattern should make change easier to review, not merely possible.
Finally, ask another developer to explain the object graph for one workflow. If the setup relies on hidden conventions or reflection that nobody understands, improve the documentation or simplify the wiring.
A document-release example
Imagine an app that releases an approved document to a customer portal. This is a sample design, not a client result. The workflow receives a document store, access checker, clock, and release-record writer.
First, the workflow checks whether the user may release that document. It asks the store to save the accepted version and records the result under the defined success rule.
Next, a test provides a fixed clock and a store that rejects the write. The workflow should return a failure and avoid recording a successful release. Another test uses a valid store and checks the accepted version reference.
Then a real-adapter test runs against a suitable test storage service. It verifies the saved file and the contract’s missing-file behavior. This catches mismatches that the fake could not reveal.
Finally, the deployment check confirms the intended store and access setup. The team traces resource cleanup after both success and failure. Each layer has a clear job, and the workflow does not need to create those tools internally.
A simple lifetime test
Create two safe test requests with different user context. Run them close together and check that each workflow uses only its own private data. A shared helper should not retain one user’s state for the next request.
Next, make the first request fail after it opens a resource. The second request should still work under the agreed isolation rule. Check cleanup with the tool your team uses to inspect connections or handles.
Then run the same test through the production setup code. A unit test with hand-built objects cannot catch every error in container setup or lifetime choice.
Common mistakes that weaken the pattern
One mistake is injecting a whole container into every part. That preserves hidden lookup behavior under a new name. Prefer clear needs at the relevant boundary.
Another is creating so many layers that a simple action becomes hard to trace. A layer should earn its place through a useful contract, a real choice, or a need to isolate outside work.
Also avoid confusing replaceability with safety. A new version can satisfy a type while changing business semantics. Review behavior and test the real boundary before switching it in live use.
Start with one difficult-to-test or tightly coupled workflow. Improve its boundaries and setup, then assess the result. A staged refactor can show value without rewriting the whole app.
Frequently Asked Questions
Does dependency injection require a container?
No. A program can supply dependencies clearly. A container can help with larger object graphs and lifetimes. Use it when that extra complexity has a clear purpose.
Should every class use an interface?
No. Put contracts at useful boundaries. Avoid extra layers that do not support meaningful choice, testing, or ownership.
Does the dependency injection pattern make shared services thread-safe?
No. A container’s safe setup and lookup do not make the created objects safe for concurrent use. Shared state needs its own design.
Make links clearer, not more hidden
Dependency injection helps when it exposes useful contracts, setup, and ownership. Choose lifetimes deliberately and test both substitutes and real adapters. The practical goal is code the team can change and support with confidence.
Improve the structure of your business app
Oxen Softwares can help refactor app boundaries, strengthen tests, and build maintainable connections to the services your software relies on.