Dependency between classes
Open in the interactive app: practice, quiz, mock interview →In one minute
- A dependency is the weakest relationship between two classes: one class uses another for the length of a call, as a parameter, a local variable or a return value, and keeps no reference afterwards.
- In UML it is a dashed arrow with an open head, pointing from the client (the user) to the supplier (the thing used). It says "if the supplier changes, the client may have to change", and nothing more.
- The test that separates it from association is a single question: does the client store the other object in a field? If yes, it is association (or aggregation or composition). If no, it is a dependency.
- Prefer a dependency over a stored reference when the collaborator is only needed by one operation. Shorter lifetimes mean fewer shared-state bugs, simpler tests and, on the machine, objects that can live on the stack.
- Agents tend to promote everything to a field "for convenience": a request context, a tenant id, a clock reading. Review new fields on long-lived objects and ask whether they should be parameters instead.
How it works
Four ways one class can know another
Class relationships sit on a scale of how tightly two objects are bound:
- Dependency.
Checkout.pay(gateway)usesgatewayduring the call and forgets it. - Association.
Orderholds acustomerfield; the order knows its customer for as long as the order exists. - Aggregation.
Teamholds a list ofPlayers, but players exist independently and can move teams. - Composition.
Invoiceowns itsLineItems; they are created with it and die with it.
Inheritance and interface realisation are a different axis (is-a rather than uses-a). This lesson is about the first rung: the relationship that lasts only for a method call.
What a dependency looks like in code
class ReceiptPrinter:
def print_receipt(self, order: Order, fmt: CurrencyFormat) -> Receipt:
lines = [fmt.render(item.price_cents) for item in order.items]
total = TaxCalculator(order.region).apply(order.subtotal())
return Receipt(lines, fmt.render(total))ReceiptPrinter depends on four classes and holds none of them. Order and CurrencyFormat arrive as parameters, TaxCalculator is created as a local, and Receipt is returned. When print_receipt returns, the printer is exactly as it was before. Call it a thousand times with a thousand orders and nothing leaks between calls.
UML can label the arrow with a stereotype to say how the supplier is used: «use» is the general case, «create» or «instantiate» for a local constructed inside, «call» for a static or module function. In practice most diagrams skip the dependency arrows entirely, because there are so many of them; drawing them is worth it only when the dependency crosses a package or layer boundary that matters.
Why "weakest" matters
Every relationship is a reason to change. A dependency is the narrowest one: the client relies on the supplier's signature only during one call. An association adds lifetime (who creates the stored object, who releases it), state (the field can be stale) and concurrency (two threads may use the shared reference at once). When a collaborator is used by one method, storing it as a field buys nothing and adds all three.
The reverse mistake also exists. If five methods of a class all take the same repo parameter, the class is really associated with a repository, and making that explicit with a constructor-injected field reads better. The Dependency Inversion lesson covers how such a stored collaborator should be typed (an interface the client owns) and where it should be wired. The question here is only whether it should be stored at all.
Field or parameter: the decision rule
- Needed by one method, varies per call (the current user, a request deadline, today's exchange rate): parameter.
- Needed by most methods, fixed for the object's life (a database pool, a logger): field.
- Created and discarded inside one method (a parser, a buffer, a calculator built from the input): local.
- Varies per call but the object is shared across threads: definitely a parameter. Storing it is a data race.
Reviewing agent-written code
A model asked to "pass the tenant id through to the report" often sets self.tenant_id = tenant_id in a handler and reads it three methods later. It works in a single-threaded test. In a server where one ReportService instance handles concurrent requests, two tenants overwrite each other's field. Look for request-scoped values (user, tenant, deadline, trace id, now) being stored on objects created once at startup, and ask for them to be passed down instead.
The same shape elsewhere
Dependency versus association is the difference between a stateless request and a session. An HTTP call that carries everything it needs in its headers is a dependency between client and server; a sticky session that stores state on one server is an association, with its lifetime, failover and staleness problems. The same choice appears in a function that takes a database transaction as an argument versus one that reads a transaction stored in a global.
Down the stack
Layer 1The contract
A dependency promises two testable things. First, the client compiles against the supplier's interface, so a change to the supplier's signature may break the client's build. Second, the client holds no reference after the call returns: calling the method with object A and then with object B leaves no trace of A in the client. The second property is what makes the method safe to call concurrently on a shared instance, provided the method touches no other shared state.
Layer 2The mechanism
Even the weakest relationship is visible to the toolchain. In Go, using a type as a parameter means importing its package, and the compiler forbids import cycles, so a dependency between packages is a hard edge in the build graph. In Java, the client's class file holds a symbolic reference to the supplier in its constant pool; the JVM resolves it lazily, typically the first time the call actually touches the supplier class, so an unused dependency rarely costs loading time. Python executes import statements when the module loads, which is why a dependency used only in type hints is often moved under if TYPE_CHECKING: to avoid circular import errors at runtime. TypeScript's import type is erased entirely by the compiler and emits no require.
So a parameter dependency costs a build-graph edge but no runtime state, while a field costs both the edge and a slot in every instance.
Layer 3The machine
On amd64, Go's register-based calling convention (since Go 1.17) passes the first several integer and pointer arguments in registers, so a dependency passed as a pointer parameter costs roughly a register move. The bigger difference is escape analysis. A value created as a local and not stored anywhere that outlives the call can stay on the goroutine's stack and is freed when the frame pops, at no GC cost. The moment you assign it to a field of a heap object, it escapes: the compiler allocates it on the heap (tens of nanoseconds), and the garbage collector must trace that pointer on every cycle until the field is cleared. go build -gcflags=-m prints which values escape. HotSpot's C2 compiler does a similar analysis and can replace a non-escaping local object with plain registers (scalar replacement). In CPython every object is heap-allocated anyway, but a stored reference still keeps the object alive, which is how a cache of "last request" on a singleton turns into a memory leak.
Why it's built this way
- Code that uses another type must know its shape, so every use is at least a compile-time edge.
- Remembering an object is more than using it: it adds lifetime, state and sharing, so modelling languages separate "uses" from "has".
- Lifetime is what the runtime pays for: stack frames are free to discard, heap objects need a collector.
- So the cheapest, safest relationship is the one that ends when the call ends, and designs should default to it and promote to a field only when several operations need the same collaborator.
Diagram
classDiagram
class ReceiptPrinter {
+print_receipt(order, fmt) Receipt
}
class Order {
-customer: Customer
-items: List~LineItem~
}
ReceiptPrinter ..> Order : use
ReceiptPrinter ..> CurrencyFormat : use
ReceiptPrinter ..> TaxCalculator : create
ReceiptPrinter ..> Receipt : returns
Order --> Customer : association
Order *-- LineItem : compositionWorked example
A reporting service runs one ReportService instance per process with 16 worker threads, handling about 300 requests per second across 2,000 tenants. A change adds tenant-specific currency formatting. The generated code stores self.tenant = request.tenant at the start of generate() and reads it in format_totals() two calls later.
Each report takes about 40 ms, but the field is read only 20 microseconds after it is written, before the slow query runs. At 300 requests per second, the expected number of other requests that start inside a 20 microsecond window is 300 times 0.00002, or 0.006. So roughly one report in 170 picked up another tenant's value: about 1.8 wrong reports per second, around 150,000 a day. Nothing failed in tests, which ran one request at a time, and the bug was found when a customer saw a competitor's name on a PDF.
The fix turned the association back into a dependency: format_totals(tenant, totals) takes the tenant as a parameter, and generate() passes it down. The field disappeared, the race disappeared, and a unit test now calls format_totals with two tenants on two threads to pin the behaviour. A follow-up lint rule flags assignments to self.* inside request handlers on singleton services.
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Parameter dependency | No stored state, thread-safe by default, visible at the call site | Long parameter lists if overused | Collaborator varies per call or is used by one method |
| Local dependency (created inside) | Caller needs to know nothing | Hard to substitute in tests; hides construction cost | Cheap, deterministic helpers built from the input |
| Association (field) | Short call sites; collaborator configured once | Lifetime and sharing questions; staleness | Most methods use it and it is fixed for the object's life |
| Context object passed down | Bundles several per-request values | Becomes a grab bag that every function depends on | Request-scoped data such as deadline, user, trace id |
Pitfalls in production
- Request-scoped values stored on singleton objects, causing cross-request leaks. Fix: pass them as parameters or in a per-request context object; never assign them to fields of shared instances.
- Locals that construct expensive collaborators per call (a new HTTP client, a regex compiled in a loop). Fix: hoist genuinely reusable, immutable resources into fields; the rule is about state, not about constructing everything inline.
- Hidden dependencies through globals or static calls (
Config.get(),time.now()), which no signature shows. Fix: make them parameters so the dependency appears where it is used. - Circular imports triggered by type-only dependencies in Python. Fix:
if TYPE_CHECKING:imports or moving the shared type into a lower module. - Dependency diagrams with every arrow drawn, which nobody can read. Fix: draw dependencies only across package or layer boundaries.
Leadership lens
- In design review, ask of every new field on a long-lived object: who sets it, when, and can two callers see different values? If the answer involves "per request", it should be a parameter.
- Track package-level dependencies, not class-level ones. A tool that fails the build on a forbidden import edge is worth more than a class diagram that goes stale.
- Treat "the singleton remembered the last request" as a class of incident and add the lint rule that prevents it; the bug is invisible to single-threaded tests, so review alone will miss it.
- Encourage parameters over ambient state in AI-assisted code. Explicit inputs make generated code easier to review, test and reason about.
Check yourself
A
Mailerclass has a methodsend(message, smtp_client)and no fields. What is the relationship betweenMailerandSmtpClient, and how would it change if the client moved to the constructor?Show answer
It is a dependency, because the client is used only during the call. Moving it to a constructor-stored field makes it an association, which adds lifetime and sharing concerns.
Why can a Go value created as a local inside a method often avoid heap allocation, while the same value stored in a struct field cannot?
Show answer
Escape analysis keeps a value on the stack when no reference outlives the call. Storing it in a heap object's field makes it reachable after the frame is gone, so it must be heap-allocated and traced by the GC.
Five methods of a service each take the same
repoparameter. Is that a good use of dependency?Show answer
Probably not. The service is effectively associated with the repository for its whole life, so a constructor-injected field states that more honestly and shortens every call site.
Why does a request-scoped value stored on a shared object pass unit tests but fail in production?
Show answer
Tests usually run one request at a time, so the field is never overwritten mid-request. Under concurrency, another request writes the field between set and read.
Watch
- UML Class Diagram Tutorial (Lucid Software): the notation for association, aggregation, composition and dependency arrows, with examples.
- Dependency Injection, The Best Pattern (CodeAesthetic): what happens when a used-by-one-call collaborator is promoted to an injected one, and why.
Free resources
- Dependency (UML) (Wikipedia)): the formal definition and the standard stereotypes such as use, create and call.
- Class diagram (Wikipedia): where dependency sits among association, aggregation, composition and generalisation.
- Coupling (Wikipedia)): the classic coupling levels, from data coupling to content coupling.
- Law of Demeter (Wikipedia): a rule that limits which objects a method should depend on.
- Inversion of Control Containers and the Dependency Injection pattern (Martin Fowler): for when a dependency does become a stored collaborator.