skip to content

Composition & Delegation

Building behavior by holding collaborators and forwarding to them instead of extending one, across association, aggregation and ownership. Interviewers want concrete reasons to prefer it.

on this pageshow

questions

10

Two modules each reference the other, forming a cycle in the dependency graph. What does a cycle actually cost you, and which languages refuse to build one versus merely letting it hurt later?

level: middleimportance: must knowfreq 54%

answer

  1. SCC condensation: the cycle is the real unit
  2. No topological order, no bottom-up build or test
  3. Go: import cycle = hard error, no flag
  4. Python: partially initialised module, entry-point dependent
  5. C# forbids assembly cycles; Java only in JPMS

basics

~20 s

A cycle fuses its members into one unit: everything in a strongly connected component must be built, tested, versioned and understood together. Go rejects import cycles at compile time with no override; Java and C# allow package-level cycles freely; Python allows the import and fails at runtime on a partially initialised module.

solid answer

~60 s

Formally, take the strongly connected components of the dependency graph: they collapse to a DAG, and the component - not the file - is the real unit of build, test and release. Acyclic means a topological order exists, so you can compile and reason bottom-up. Where languages diverge is the enforced granularity: - **Go**: import cycles are a hard compile error with no escape hatch. The idiomatic fix is to move the interface to the consumer, which structural typing makes free. - **Python**: circular imports are allowed; the second import returns a partially initialised module, so `from x import y` at top level raises ImportError while `import x` plus late attribute access survives - a failure that depends on entry point. - **Java and C#**: class and package cycles compile fine; only Java's JPMS forbids cyclic `requires`, and only .NET forbids circular *assembly* references - which is why C# solutions split into projects far earlier than Java splits packages. - **Rust**: modules inside a crate may be mutually recursive, but circular *crate* dependencies are rejected, so "extract a trait crate" is a standard move.

code

go · 6 lines
go
// package a imports b; package b imports a
$ go build ./...
import cycle not allowed
package example/a
        imports example/b
        imports example/a

go deeper

for a junior

Know what a cycle is, why it stops you from building or testing a unit on its own, and the three ways out: extract, invert, merge.

for a middle

Bring the SCC framing and at least two languages with different enforcement points, including the Python partial-initialisation failure mode.

for a senior

Discuss enforced granularity as a design force - why .NET solutions fragment into projects, why Go codebases grow consumer-side interfaces - and how to enforce acyclicity in a language that will not.

for a principal

Treat the dependency graph as an architectural asset: choose where boundaries are defended, accept SCCs where they are honest, and put automation on the line so drift fails a build rather than a review.

## A dependency graph, and what a cycle does to it Draw a node per unit (class, file, package, crate, assembly) and an edge from A to B when A needs B to compile or run. If that graph is acyclic, it has a **topological order**: some unit depends on nothing, some unit depends only on that, and so on. Every useful property follows from that order. You can build bottom-up. You can test a unit with only its dependencies present. You can read a unit and know the set of things it can be affected by. You can release a leaf without releasing its dependents. A cycle destroys all of that at once, for every member of the cycle. The right formal object is the **strongly connected component**: the maximal set of nodes each reachable from the others. Condense each SCC to a single node and the graph becomes a DAG again - which tells you exactly what a cycle costs. The SCC is now the true unit. Three classes in a cycle are, for build, test, comprehension, release and reuse purposes, one class with three names. You cannot extract one for reuse, you cannot understand one without the others, you cannot mock across the boundary because there is no boundary, and a change to any of them rebuilds all of them. That is why cycles are worth arguing about even when the compiler is indifferent - and compilers differ wildly in how indifferent they are. ## Who enforces acyclicity, and at what granularity **Go** enforces it at the package level, absolutely. `import cycle not allowed` is a build failure and there is no flag, annotation or ordering trick that permits it. That single decision shapes Go codebases: because you cannot patch over a cycle, you must break it, and the standard way is to move the interface to the consumer. Go's structural interfaces make that cost-free - the provider does not import the consumer to satisfy its interface - so the cycle usually disappears entirely rather than being routed around. The price is real too: you sometimes create packages that exist only to be depended upon by both sides, and `internal/` packages to keep them private. **Python** allows the cycle and defers the pain to runtime. Importing a module inserts a partially initialised module object into `sys.modules` before its body finishes executing, so a circular import may succeed or fail depending on *which* module was entered first and *how* you imported. `from b import helper` at the top of `a.py` fails with "cannot import name ... from partially initialized module" when `b` is midway through importing `a`; the same dependency written as `import b` and used later inside a function body works, because the attribute is resolved after both bodies have run. The result is a defect class whose reproduction depends on the entry point - a script, a test runner and a web server can each see a different outcome. **Java** compiles mutually referential classes and packages without complaint; javac resolves the cycle within a compilation unit set. Only the module system (JPMS) forbids cyclic `requires` between named modules, so enforcement exists but at a granularity most projects never reach. Tooling fills the gap - ArchUnit rules, jdepend, Spring Modulith's verification - which tells you the constraint is considered valuable even where the language does not impose it. **C#/.NET** is the interesting middle: inside an assembly, cycles among classes and namespaces are free, but a circular *assembly* reference is a hard error. So the enforced unit is the deployment artefact. This measurably changes design habits - .NET solutions tend to split into many projects precisely because that is the only line the compiler will defend, whereas a Java or Kotlin codebase can accumulate package cycles inside one jar indefinitely. **C++** adds a dimension the others do not have: physical versus logical dependency. Headers that include each other need include guards and forward declarations; the pimpl idiom removes a *compile-time* edge while keeping the runtime relationship, purely to stop a header change rebuilding the world. Here a cycle's dominant cost is build time, and the tools to break it are physical. **Rust** enforces at the crate level - cargo rejects a circular crate dependency - while permitting mutually recursive modules within a crate. That draws the same line .NET draws, at the unit of publication. ## Breaking a cycle Three moves cover nearly everything, and they are language-independent even though the pressure to use them is not. **Extract**: pull the shared thing both sides need into a new unit both depend on, turning A-B into A-C and B-C. **Invert**: replace one edge with an edge to an abstraction, so the arrow reverses (which language mechanics make this cheap or expensive is a separate question in this topic). **Merge**: if two units genuinely cannot be separated, admit the SCC and make it one unit with one name, which at least stops pretending there is a boundary. The move to avoid is the local patch - a late import inside a function in Python, a forward declaration in C++, a setter-based wiring step in Java - which silences the symptom and leaves the SCC in place.

  • Java's compiler accepts package cycles. Why do teams still enforce acyclicity with tools like ArchUnit or Spring Modulith?
    Because the costs are architectural, not compilational. A cycle means the packages cannot be understood, tested, extracted or released independently, and it is the mechanism by which a modular codebase quietly becomes one lump. Since javac will not defend the boundary, the test suite has to: a rule that fails the build on a new cycle is cheap and catches the drift on the day it happens.
  • A Python circular import is fixed by moving the import inside a function. Has the cycle gone away?
    No. The graph still has the cycle; you have only delayed resolution until after both module bodies have executed, so the ImportError no longer triggers. Nothing about independent testing, reuse or comprehension improved, and the next top-level import of the same pair fails again. Treat it as a stopgap and follow it with an extract or an inversion.
  • Is a cycle between two classes in the same package as bad as one between packages?
    Much less so. Mutually recursive classes inside one module are often legitimate - a node and its visitor, a parser and its AST - because the module is already the unit of build, release and comprehension, so the SCC does not grow beyond an existing boundary. The damage scales with the boundary the cycle crosses: package, artefact, team, repository.

saying these in an interview costs you the question

  • Saying a cycle is fine because the compiler accepts it - Go, Rust and .NET reject it at their respective granularities precisely because acceptance is not the same as harmless.
  • Treating a Python function-level import as a fix rather than as deferral of the symptom.
  • Believing a forward declaration in C++ removes the dependency; it removes the compile-time edge only.
  • Breaking a cycle by merging two units into one by default, when extracting the shared part would have preserved a genuine boundary.
  • Not knowing that .NET enforces at assembly level while Java enforces only at JPMS module level, then assuming both ecosystems have the same habits.

context

open as a page

Modelling notations mark a part whose lifetime is owned by its whole (UML's filled diamond, composite aggregation) differently from a part that is merely shared and can outlive the whole (the hollow diamond). In which languages is that distinction actually enforced by the compiler or runtime, and where is it only a comment?

level: middleimportance: should knowfreq 62%

basics

~20 s

Only where the language has ownership semantics. C++ value members and Rust's move-and-Drop destroy the part with the whole. Swift's ARC leaks if you model it wrong. Under Java, C#, Python or Go a tracing collector frees whatever is unreachable, so the diamond is documentation.

open as a page

Several languages ship built-in help for making one object stand in for another: Kotlin's `by` clause, Scala 3's `export`, Go struct embedding, Ruby's Forwardable/SimpleDelegator, and Python's `__getattr__`. Compare what each of them actually generates, and what each one cannot do.

level: middleimportance: should knowfreq 38%

basics

~20 s

All of them generate forwarding, not delegation. Kotlin by, Scala 3 export and Go embedding emit compile-time stubs that call the inner object; Ruby's Forwardable metaprograms named methods; Python's __getattr__ forwards at run time but is skipped by implicit special-method lookup.

open as a page

The usual textbook claim is that an inheritance relationship is fixed when the class is written, while a contained part can be swapped at run time. In which languages is the first half of that claim false, and what does changing the relationship at run time actually cost?

level: middleimportance: should knowfreq 34%

basics

~20 s

In prototype and dynamic-class languages the link itself is mutable: JavaScript's Object.setPrototypeOf, Python reassigning obj.class or Cls.bases, Ruby's per-object extend and reopened classes. The cost is deoptimization and cache invalidation — and, unlike swapping a part, it changes dispatch for everything reaching that class.

open as a page

An association is navigable in both directions: an order knows its customer and the customer knows their orders. What does maintaining that two-way link actually cost, and why is the same shape far harder in some languages than in others?

level: seniorimportance: should knowfreq 46%

basics

~20 s

Two costs: an invariant (both ends must always agree, so one mutator owns both writes) and a reclamation cost that depends on the runtime. Java, C# and Go collect the cycle for free; Swift and C++ shared_ptr leak unless one end is weak; Rust rejects the shape and pushes you to Weak or index tables.

open as a page

Take a whole-part relationship where the part is owned by the whole and dies with it, and assume the in-memory expression of that ownership is already settled. The same claim still gets restated in three other places: the persistence mapping's delete rules (JPA's `cascade` and `orphanRemoval`, Django's mandatory `on_delete` argument, Entity Framework Core's required-versus-optional dependents), the disposal chain (C# `using`/`IDisposable`, Java try-with-resources, Python context managers), and the serialization format (embedding the part's body versus emitting its id). What concretely breaks when those three disagree, and which one do you make authoritative?

level: seniorimportance: should knowfreq 30%

basics

~20 s

Ownership is restated in the mapping's delete rules, in who closes the part, and in whether the wire format embeds it or references it by id. Three engines enforce those independently, so they drift. Make one authoritative and derive the others.

open as a page

Distinguish forwarding — an object holding another and calling the same method on it — from true delegation as the term is used in prototype-based languages. Name languages that actually provide the latter, and explain what the difference changes for the wrapped object's own self-calls.

level: seniorimportance: should knowfreq 40%

basics

~20 s

Forwarding hands the call to another object whose self is itself. True delegation re-binds self to the original receiver, so the delegate's self-calls land back on the outer object. JavaScript prototypes, Self and Ruby's included modules delegate; Kotlin's by and Go embedding only forward.

open as a page

Extending a type takes its entire public surface, including members its author adds in a later release, whereas containing it means you list what you re-expose. What are the mechanical consequences of taking the whole surface, and how do different languages guard that seam when the parent grows a new member?

level: principalimportance: should knowfreq 30%

basics

~20 s

Subtyping copies every current and future parent member into your surface, so a later addition can collide with a member you already had. Kotlin requires the override keyword and reports an accidental override; C# demands new and warns otherwise; Go turns depth-equal promotions into a call-site error; Rust has no promotion at all.

open as a page

A common rule of thumb is to depend in the direction of stability. What actually makes one dependency target more stable than another, and how much of that stability is a property of your ecosystem's packaging rather than of the code you depend on?

level: principalimportance: nice to knowfreq 34%

basics

~20 s

Stability is how costly change is for the target: many incoming dependents and few outgoing ones make it hard to change, which is why stable things should be abstract. But packaging decides blast radius: npm and Cargo can hold two major versions in one process, a Java classpath cannot, so the identical edge is far more volatile on the JVM.

open as a page

A wrapper that forwards every call is still not the object it wraps: the inner object's own code, the callbacks it registers, and identity or type tests all see the inner one. Describe this split-identity problem (sometimes called self-schizophrenia) and how far different languages let you close the gap.

level: principalimportance: nice to knowfreq 26%

basics

~20 s

Wrapping creates two objects with one intended identity. The inner object's self-calls, the this it hands to callbacks, and identity or type checks all bypass the wrapper. Languages only narrow the gap: Objective-C NSProxy and Ruby BasicObject proxies impersonate well, Go offers nothing, and the durable fix is passing the outer identity in.

open as a page