Dependencies
Dependencies should be obvious from reading the type signatures.
The Principle
Every function and class should declare exactly what it needs. No singletons. No magic injection. If it's not in the signature, it shouldn't exist.
Friday at 4pm
You deployed at noon. Everything was fine. Then at 4pm your phone starts going off.
The error is "failed to resolve dependency for ApiServer." Stack trace points into the DI container. Nothing useful. You dig through registration files. You stare at the decorator. Then you find it:
container.register("LOGER", Logger); // ← one letter offNot "LOGGER". "LOGER". The container accepted it without complaint on startup. It always does. It resolves dependencies at runtime, when someone actually calls the service, so there was no error at boot. The error waited until a specific code path triggered it. On a Friday. In production. Four hours after you deployed and stopped watching the dashboards.
The fix took thirty seconds. The search took two hours.
This is the DI framework tax. Not hypothetical. Documented. Filed as a post-mortem with a "mitigations: linting rules" section that nobody followed up on.
The alternative is boring: put your dependencies in the type signature. The compiler catches LOGER in zero milliseconds.
Singletons Are Poison
They hide dependencies
Look at this class and tell me what it needs to run:
// The Lie: looks like no dependencies
class User {
async getUser(id: string) {
return Database.instance.query("..."); // Where did this come from?
}
}
// The Truth: depends on Database
class User {
constructor(private db: Database) {}
async getUser(id: string) {
return this.db.query("...");
}
}// The Lie: invisible global state
static DATABASE: OnceCell<Database> = OnceCell::new();
struct User;
impl User {
async fn get_user(&self, id: &str) -> Result<User, Error> {
DATABASE.get().unwrap().query("...").await // Hidden dependency
}
}
// The Truth: explicit dependency
struct User { db: Database }
impl User {
async fn get_user(&self, id: &str) -> Result<User, Error> {
self.db.query("...").await
}
}# The Lie: looks like no dependencies
class User:
async def get_user(self, user_id: str):
return await Database.instance.query("...") # Where did this come from?
# The Truth: depends on Database
class User:
def __init__(self, db: Database) -> None:
self._db = db
async def get_user(self, user_id: str) -> User:
return await self._db.query("...")The first version looks simple. Zero constructor parameters. You can spin it up anywhere with new User() and it just works. Until you need to test it, or move it, or change what database it talks to. Then you find out it secretly depends on two global singletons you can't swap without touching every test in the suite.
The second version has two constructor parameters. Exactly as complex as it actually is.
They make testing a nightmare
Two tests running in parallel both mutate the same singleton. Race condition. Flaky tests. Passes locally, fails in CI, passes again when you re-run it. You spend an hour in Slack writing "works on my machine" while your colleagues add you to their mental list of people whose PRs need extra scrutiny. This is the singleton tax: paid in debugging time, paid in CI flakiness, paid every sprint.
// Singleton hell: shared mutable state
test("creates user", async () => {
Database.instance.setMode("test");
await userService.create({ name: "Alice" });
// Test running in parallel just changed the mode 💥
});
// Isolated: each test owns its instance
test("creates user", async () => {
const db = new TestDatabase();
const service = new User(db);
await service.create({ name: "Alice" });
});// Singleton hell
#[tokio::test]
async fn test_creates_user() {
DATABASE.get().unwrap().set_mode(Mode::Test); // Other tests see this!
user_service.create(User { name: "Alice" }).await.unwrap();
}
// Isolated
#[tokio::test]
async fn test_creates_user() {
let service = User::new(TestDatabase::new());
service.create(User { name: "Alice" }).await.unwrap();
}# Singleton hell: shared mutable state
def test_creates_user():
Database.instance.set_mode("test")
user_service.create({"name": "Alice"})
# Another test just changed the mode 💥
# Isolated: each test owns its instance
def test_creates_user_isolated():
service = User(MemDatabase())
service.create({"name": "Alice"})The sin is specific. Mutable state. Shared between tests. Invisible from the type signatures that depend on it.
export const logger = new Logger(...) is not this. The import is at the top of the file. It doesn't change between tests. You never mock it because there's nothing to swap. The Three Kinds of Dependencies covers those.
They create coupling you can't see
Change the singleton's interface and you're grepping the entire codebase hoping you got them all. The compiler can't help because the dependency is invisible. Invisible dependencies are the cockroaches of software architecture: everywhere, impossible to count, surviving every refactor.
Why DI Frameworks Are Worse
At least singletons are honest. They're in the code. You can grep for them.
DI frameworks give you runtime magic: string-keyed registrations, decorator-based injection, containers resolving graphs at startup. The framework accepted "LOGER" silently. The framework always accepts typos silently. That's the design. Everything is checked at runtime, when users are using the thing, not at compile time, when you could actually do something about it.
// DI Framework: errors at runtime
@Injectable()
class User {
constructor(
@Inject("DATABASE") private db: any, // ← type safety lost
@Inject("LOGER") private logger: any, // ← typo, fails at runtime on Friday
) {}
}
// Explicit: errors at compile time
class User {
constructor(
private db: Database,
private logger: Logger,
) {}
}
const userService = new User(db, logger);
// ↑ wrong type? Compiler catches it immediately// Rust's type system makes DI frameworks unnecessary.
// Dependencies go in the struct; the compiler checks everything.
struct User { db: Database }
impl User {
fn new(db: Database) -> Self { Self { db } }
}
// All checked at compile time# Bad: DI frameworks add runtime magic with no type safety.
# Good: explicit __init__, wired in main().
class User:
def __init__(self, db: Database) -> None:
self._db = db
def main() -> None:
db = Database(load_config().db_url)
user_service = User(db)
# Wrong type here? mypy catches it immediately.With explicit dependencies the graph is in the code. You can read it. You can trace it. You can break it and get a compiler error instead of a Friday incident.
The Three Kinds of Dependencies
Not all dependencies are the same. Treat them differently.
Global/Infrastructure: Static or Module-level
These are genuinely global. Everyone needs them. They don't change behavior between tests. If you're never mocking it, you don't need to inject it.
// logger.ts
export const logger = new Logger({ level: process.env.LOG_LEVEL });
// user-service.ts
import { logger } from "./logger";
class User {
constructor(private db: Database) {}
async getUser(id: string) {
logger.info("Fetching user", { id });
return this.db.query("...");
}
}
// Tests don't mock the logger. It's infrastructure.static LOGGER: OnceLock<Logger> = OnceLock::new();
pub fn logger() -> &'static Logger { LOGGER.get_or_init(Logger::new) }
struct User { db: Database }
impl User {
async fn get_user(&self, id: &str) -> Result<User, Error> {
logger().info(&format!("Fetching user: {}", id));
self.db.query("...").await
}
}
// Tests don't mock the logger. It's infrastructure.import logging
logger = logging.getLogger(__name__) # module-level, never mocked
class User:
def __init__(self, db: Database) -> None:
self._db = db
async def get_user(self, user_id: str) -> User:
logger.info("Fetching user", extra={"user_id": user_id})
return await self._db.query("...")If you're never mocking it in tests, it doesn't need to be injected.
Instance Lifecycle: Constructor Parameters
These dependencies live as long as the instance lives. They define what the instance can do. Put them in the constructor.
// A ChatClient needs a WebSocket for its entire lifetime
class ChatClient {
constructor(
private ws: WebSocket,
private userId: string,
) {}
async sendMessage(text: string) {
await this.ws.send({ userId: this.userId, text });
}
}struct ChatClient {
ws: WebSocket,
user_id: String,
}
impl ChatClient {
async fn send_message(&self, text: &str) -> Result<(), Error> {
self.ws.send(&Message { user_id: &self.user_id, text }).await
}
}# A ChatClient needs a WebSocket for its entire lifetime
class ChatClient:
def __init__(self, ws: WebSocket, user_id: str) -> None:
self._ws = ws
self._user_id = user_id
async def send_message(self, text: str) -> None:
await self._ws.send({"user_id": self._user_id, "text": text})Operation-specific: Method Parameters
These are only needed for a specific operation. Pass them when you call the method. Don't store them on the instance.
class User {
constructor(
public id: string,
public email: string,
) {}
async updateEmail(
newEmail: string,
store: Store,
tx: Transaction,
): Promise<Result<void, UpdateError>> {
const validation = validateEmail(newEmail);
if (validation.type === "Err") return validation;
this.email = newEmail;
return store.saveUser(this, tx);
}
}
user.updateEmail("new@example.com", store, transaction);
// ↑ clear that this operation needs a store and transactionimpl User {
async fn update_email(
&mut self,
new_email: String,
store: &Store,
tx: &Transaction,
) -> Result<(), UpdateError> {
validate_email(&new_email)?;
self.email = new_email;
store.save_user_tx(self, tx).await
}
}@dataclass
class User:
id: str
email: str
async def update_email(
self, new_email: str, store: Store, tx: Transaction
) -> None:
validate_email(new_email) # raises on invalid
self.email = new_email
await store.save_user_tx(self, tx)
# ↑ clear that this operation needs a store and transactionDomain Logic Belongs in Domain Objects
The Store is not a brain. It's a filing cabinet.
This gets violated constantly. Someone needs to save a user, so validation goes in store.saveUser. Then someone needs to save an order, so different validation goes in store.saveOrder. Six months later the Store has more business logic than your actual domain objects and you're adding a banned_at check in three different places because nobody knows where the canonical rule lives.
// ❌ Bad: logic in the Store
class Store {
async saveUser(user: User): Promise<Result<void, SaveError>> {
if (!user.email.includes("@"))
return { type: "Err", err: { type: "InvalidEmail" } };
await this.db.query("INSERT INTO users ...", user);
return { type: "Ok", data: undefined };
}
// Store accumulates all validation rules for all types. God object.
}
// ✅ Good: logic in the domain object
class User {
async save(store: Store): Promise<Result<void, SaveError>> {
if (!this.email.includes("@"))
return { type: "Err", err: { type: "InvalidEmail" } };
return store.persistUser(this);
}
}
class Store {
async persistUser(user: User): Promise<Result<void, SaveError>> {
// Simple insert, no business logic
}
}// ❌ Bad: logic in the Store
impl Store {
async fn save_user(&self, user: &User) -> Result<(), SaveError> {
if !user.email.contains("@") {
return Err(SaveError::InvalidEmail);
}
self.db.insert_user(user).await
// Store accumulates all validation rules. God object.
}
}
// ✅ Good: logic in the domain object
impl User {
async fn save(&self, store: &Store) -> Result<(), SaveError> {
if !self.email.contains("@") {
return Err(SaveError::InvalidEmail);
}
store.persist_user(self).await
}
}
impl Store {
async fn persist_user(&self, user: &User) -> Result<(), SaveError> {
// Simple insert, no business logic
}
}# ❌ Bad: logic in the Store
class Store:
async def save_user(self, user: User) -> None:
if "@" not in user.email:
raise InvalidEmailError(user.email)
await self._db.insert_user(user)
# Store accumulates all validation rules. God object.
# ✅ Good: logic in the domain object
@dataclass
class User:
id: str
email: str
async def save(self, store: Store) -> None:
if "@" not in self.email:
raise InvalidEmailError(self.email)
await store.persist_user(self)
class Store:
async def persist_user(self, user: User) -> None:
... # Simple insert, no business logicThe Store reads and writes. It doesn't know what makes a User valid any more than a filing cabinet knows what makes a contract legal. Business rules live in business objects.
This means the object's own invariants. Not every rule that touches it. If User ends up with twenty methods, that's not a dependency problem. That's a struct problem.
Practical Wiring
All dependencies get constructed in one place. One file. That's it.
// main.ts - The only place where wiring happens
async function main() {
const config = loadConfig();
const db = new Database(config.dbUrl);
await db.connect();
const cache = new Cache(config.redisUrl);
await cache.connect();
const users = new User(db, cache);
const orders = new Order(db);
const payments = new Payment(db, config.stripeKey);
const app = new App(users, orders, payments);
await app.listen(config.port);
}
main().catch(err => {
logger.error("Failed to start", { error: err });
process.exit(1);
});// main.rs
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let config = load_config()?;
let db = Database::connect(&config.db_url).await?;
let cache = Cache::connect(&config.redis_url).await?;
let users = User::new(db.clone(), cache.clone());
let orders = Order::new(db.clone());
let payments = Payment::new(db.clone(), config.stripe_key);
let app = App::new(users, orders, payments);
app.listen(config.port).await?;
Ok(())
}# main.py: the only place where wiring happens
import asyncio
import logging
logger = logging.getLogger(__name__)
async def main() -> None:
config = load_config()
db = await Database.connect(config.db_url)
cache = await Cache.connect(config.redis_url)
users = User(db, cache)
orders = Order(db)
payments = Payment(db, config.stripe_key)
app = App(users, orders, payments)
await app.listen(config.port)
if __name__ == "__main__":
asyncio.run(main())No magic. No runtime resolution. If it doesn't compile, you fix it before deploying.
When This Doesn't Apply
Frameworks with their own lifecycle. NestJS, Next.js, Axum. They have opinions about wiring. Work within their constraints. Keep individual classes explicit even if the framework handles top-level construction.
Plugin systems. If you're building an extension point where third parties provide implementations at runtime, dynamic resolution is the point. That's the exception, not a reason to DI-framework your whole app.
Circular dependencies. If A depends on B depends on A, you have an architecture problem. The answer is introducing an interface or an event bus, not a DI container that resolves cycles through magic. The cycle is telling you something.
"Actually..."
You're thinking
“Doesn't passing everything through constructors get unwieldy?”
Ten constructor parameters means you have a class that hasn't been split yet. That's the smell. Not the explicit dependencies. The fact that one class is doing ten things. Split it. The constructor then tells you exactly what each piece needs, which is the point.
You're thinking
“What about interface-based dependency injection?”
Yes. Use it. No framework required.
interface Database {
query(sql: string): Promise<Result<Row[], QueryError>>;
}
class User {
constructor(private db: Database) {}
// Can pass real Database or TestDatabase
}trait Database {
async fn query(&self, sql: &str) -> Result<Vec<Row>, QueryError>;
}
struct User<D: Database> {
db: D,
}
// Can use real Database or TestDatabasefrom typing import Protocol
class Database(Protocol):
async def query(self, sql: str) -> list[Row]: ...
class User:
def __init__(self, db: Database) -> None:
self._db = db
# Can pass real Database or MemDatabase.
# structural subtyping, no explicit registration neededInterfaces for swappability. Explicit constructors for wiring. No container, no string keys, no decorators needed.
You're thinking
“How do I handle optional dependencies?”
Put them in the type.
class User {
constructor(
private db: Database,
private cache?: Cache,
) {}
async getUser(id: string): Promise<Result<User, GetUserError>> {
if (this.cache) {
const cached = await this.cache.get(id);
if (cached.type === "Ok") return cached;
}
return this.db.findUser(id);
}
}struct User {
db: Database,
cache: Option<Cache>,
}
impl User {
async fn get_user(&self, id: &str) -> Result<User, GetUserError> {
if let Some(cache) = &self.cache {
if let Ok(user) = cache.get(id).await {
return Ok(user);
}
}
self.db.find_user(id).await
}
}class User:
def __init__(self, db: Database, cache: Cache | None = None) -> None:
self._db = db
self._cache = cache
async def get_user(self, user_id: str) -> User:
if self._cache is not None:
cached = await self._cache.get(user_id)
if cached is not None:
return cached
return await self._db.find_user(user_id)The type says it's optional. The compiler enforces that you handle both cases. No magic needed.
You didn't follow this and got a DI framework. Here's what you bought:
Runtime errors where you should have compile-time errors. The "LOGER" incident is not bad luck. It's the expected behavior of a system that defers checking until someone runs the code. That someone is eventually a user, on a Friday, when you're not watching.
Flaky tests from singleton state leaking between parallel runs. Not always. Just sometimes. Just enough that you spend the first twenty minutes of every debugging session wondering if it's real or a test artifact.
A dependency graph that lives in the container's memory, not in your codebase. You can't grep it. You can't read it. You find out what depends on what by breaking something and watching what fails.
Put dependencies in the type signature. Wire everything in main. The graph is then right there in the code, checked at compile time, readable by anyone who joins the project in six months and has never seen your container configuration.
If it's not in the type signature, it's hiding from you. And things that hide from you bite you in production.