Engineering Atlas › Low-Level Design

Dependency between classes

12 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

Four ways one class can know another

Class relationships sit on a scale of how tightly two objects are bound:

  1. Dependency. Checkout.pay(gateway) uses gateway during the call and forgets it.
  2. Association. Order holds a customer field; the order knows its customer for as long as the order exists.
  3. Aggregation. Team holds a list of Players, but players exist independently and can move teams.
  4. Composition. Invoice owns its LineItems; 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

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

  1. Code that uses another type must know its shape, so every use is at least a compile-time edge.
  2. Remembering an object is more than using it: it adds lifetime, state and sharing, so modelling languages separate "uses" from "has".
  3. Lifetime is what the runtime pays for: stack frames are free to discard, heap objects need a collector.
  4. 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 : composition

Worked 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

OptionStrengthsWeaknessesChoose when
Parameter dependencyNo stored state, thread-safe by default, visible at the call siteLong parameter lists if overusedCollaborator varies per call or is used by one method
Local dependency (created inside)Caller needs to know nothingHard to substitute in tests; hides construction costCheap, deterministic helpers built from the input
Association (field)Short call sites; collaborator configured onceLifetime and sharing questions; stalenessMost methods use it and it is fixed for the object's life
Context object passed downBundles several per-request valuesBecomes a grab bag that every function depends onRequest-scoped data such as deadline, user, trace id

Pitfalls in production

Leadership lens

Check yourself

  1. A Mailer class has a method send(message, smtp_client) and no fields. What is the relationship between Mailer and SmtpClient, 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.

  2. 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.

  3. Five methods of a service each take the same repo parameter. 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.

  4. 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

Free resources

Open in the interactive app: practice, quiz, mock interview →