construct-or-inject
Decide whether a class should construct its own dependencies, receive built instances, or receive factories (newable vs injectable, instance vs Provider<T>). Use when designing or reviewing constructo
SKILL.mdclaude-skills/construct-or-inject/SKILL.md
GitHubConstruct, Inject, or Take a Factory
Every class with dependencies answers one question per dependency: who knows how to construct it? There are exactly four honest answers:
- Construct it yourself — call the dependency's constructor in your own
__init__. - Inject the instance — the caller builds it and passes in the finished object.
- Inject a factory — the caller passes a recipe (any callable); you invoke it when needed.
- Method argument — it isn't a dependency at all, it's runtime data; it arrives per-call.
Forwarding the dependency's constructor params through your own signature is not on this list — it is the failure mode (see Smells).
Definitions
- Newable (Hevery) / stable dependency (Seemann): an object whose identity is its data. Cheap, concrete, no meaningful alternative implementation, never mocked.
Money,Email,nn.Linear(256, 1024),nn.LayerNorm(d). Construct these freely, anywhere. - Injectable (Hevery) / volatile dependency (Seemann): an object that does things — a service with behavior that callers might swap, configure, or mock.
Database,PaymentProcessor, an attention layer, a descriptor extractor. Obtain these through the constructor. - Instance injection: receive
T, already built. - Factory injection: receive
Callable[..., T](Guice'sProvider<T>). Each call yields a fresh, independent instance. - Wiring time vs operation time: injectables are assembled once into a graph at startup; newables are created constantly during operation from runtime data. In torch this split is physical:
__init__is wiring time (modules),forward()is operation time (tensors).
The two caste rules (Hevery):
- Injectables may ask for other injectables in their constructor, but not for newables. A
PaymentProcessormust not take aCreditCardin its constructor — the card is runtime data and arrives as a method argument. Violation name: constructor over-injection. - Newables may hold other newables, but not injectables. A
Uservalue object holding aDatabasereference can no longer be constructed in a test without the whole service graph.
The Decision Table
| Situation | Verdict |
|---|---|
| Dependency is standard, value-like, and its config is fully derived from your own params | Construct it yourself |
| One volatile collaborator, lifetime matches yours | Inject the instance |
| N independent copies needed | Inject a factory |
| Created lazily / conditionally / at times only you know about | Inject a factory |
| Long-lived consumer needs short-lived dependencies (per-request, per-rollout) | Inject a factory |
| Object derives from runtime data (a batch, a request, user input) | Method argument — never constructor state |
The one-line guideline (Guice's T vs Provider<T>): one collaborator → inject the instance; many, or construction at times you control → inject a factory.
Parameter Ownership (assisted injection)
When you inject a factory, split the dependency's constructor params by who owns them:
- Structural params the consumer owns — passed by the consumer at factory-call time. These are the params derived from the consumer's own config (e.g.
dimmust equal the consumer'shidden_dim). Single source of truth, no duplicate-and-assert. - Flavor params the consumer has no business knowing — bound by the caller into the factory (
functools.partial, a lambda, a config-built closure).
AttnFactory = Callable[[int], nn.Module] # dim -> attention block
class PerceiverResampler(nn.Module):
def __init__(self, num_layers: int, hidden_dim: int, num_latents: int,
attn_factory: AttnFactory):
super().__init__()
self.latents = nn.Parameter(torch.empty(num_latents, hidden_dim))
self.layers = nn.ModuleList(attn_factory(hidden_dim) for _ in range(num_layers))
resampler = PerceiverResampler(6, 256, 8,
attn_factory=partial(LightGlueAttn, num_heads=4, flash=False))
The resampler never learns num_heads, flash, or dropout exist. When the attention layer grows a tenth knob, the resampler's signature does not change.
This is exactly java.util.concurrent.ThreadPoolExecutor's ThreadFactory: the pool's call is newThread(Runnable r) — the pool passes the one argument it owns (the work); naming, daemon flag, and priority are bound inside the factory by whoever configured it.
When Injected Parts Must Agree (the Enforcement Ladder)
Injection creates cross-module contracts: an instance-injected encoder must emit tokens of the width the consumer's stack expects. The knowledge of that dim now exists in two places. There is a ladder of enforcement, and each rung moves who holds authority over the value:
Rung 0 — comment. obs_encoder: nn.Module, # -> B,N,hidden. Contract in prose, authority nowhere. The failure is a shape error at first forward, three frames deep in a matmul (mat1 and mat2 shapes cannot be multiplied), not at the wiring line where the mistake lives. Indefensible — the next rung is nearly free.
Rung 1 — assert against an exposed attribute.
def __init__(self, hidden_dim, obs_encoder, ref_encoder, attn_factory):
assert obs_encoder.out_dim == hidden_dim, f"{obs_encoder.out_dim=} != {hidden_dim=}"
assert ref_encoder.out_dim == hidden_dim
Requires a protocol: every injectable of this kind exposes out_dim: int (detectron2 precedent: backbones expose output_shape() -> ShapeSpec and consumers build against it). Fails at wiring time with a message that names the mistake.
Rung 2 — derive instead of duplicate.
def __init__(self, obs_encoder, ref_encoder, attn_factory):
hidden_dim = obs_encoder.out_dim # single source of truth
assert ref_encoder.out_dim == hidden_dim # irreducible — see below
self.layers = nn.ModuleList(attn_factory(hidden_dim) for _ in range(L))
One parameter gone, one duplicate eliminated. The remaining assert is irreducible in this topology: an agreement between two independently constructed objects can only be checked, never derived.
Rung 3 — invert construction so disagreement is unrepresentable.
def __init__(self, hidden_dim, obs_encoder_factory, ref_encoder_factory, attn_factory):
self.obs_encoder = obs_encoder_factory(hidden_dim) # consumer owns the dim,
self.ref_encoder = ref_encoder_factory(hidden_dim) # passes it to everyone
Parameter Ownership applied consistently — mismatch is impossible to write.
Choosing the rung:
- Rung 3 for lightweight blocks (attention layers, FFNs, norms) — this is ordinary factory injection.
- Rung 1–2 for heavyweight, pretrained, or shared modules (backbones, extractors). Factory-injecting these forces presets into
lambda d: prebuilt— which ignoresd, silently defeating the enforcement while adding indirection. When the one-collaborator→instance rule collides with enforcement, instance + assert is the standard resolution; prefer rung 2 (derive) so only the irreducible assert remains. - There is no higher rung in Python. Type-level impossibility needs the dim in the type (dependent types —
ObsEncoder[256]vsVerifier[384]).jaxtyping+beartypegive runtime-checked shape annotations onforward(Float[Tensor, "b n {self.dim}"]) — better ergonomics for rung 1, not static proof. "Structurally impossible" is only reachable by changing construction topology: single source (rung 2), single owner (rung 3), or a single lexical variable threaded through one preset (the composition root's free mitigation). - The only indefensible configuration is two independent authorities and no check — rung 0.
This refines the duplicate-and-assert smell: it is a smell when the consumer could own the param (rung 3 is available — take it). When instances must be injected, the assert is not the smell; the duplicate is — derive it away (rung 2).
Test Cases
Apply the table. Verdicts below each case.
Test 1 — N copies of a configurable block
class PerceiverResampler(nn.Module):
def __init__(self, num_layers, hidden_dim, ...):
# needs num_layers independently-weighted attention layers
Verdict: factory injection. Multiple independent instances, created in a loop only the consumer knows about. Passing one built instance would force cloning; passing (num_heads, dropout, flash, ...) makes the wrapper a middle man.
Test 2 — exactly one collaborator
class GligenBlock(nn.Module):
def __init__(self, gated_mha: nn.Module): # uses exactly one
self.attn = gated_mha
Verdict: instance injection. One collaborator, lifetime matches. The block should not know how to build a gated MHA.
Test 3 — sub-objects with fully derived config
class TransformerEncoderLayer(nn.Module):
def __init__(self, d_model, nhead, dim_feedforward):
self.linear1 = nn.Linear(d_model, dim_feedforward) # dims derived
self.linear2 = nn.Linear(dim_feedforward, d_model)
Verdict: construct it yourself. nn.Linear is a newable (tiny frozen ctor: in, out, bias) and its config is a pure function of the wrapper's own params. Injecting the linears would let callers pass mismatched dims — injection here adds an error class. This is nn.Transformer's legitimate side.
Test 4 — forwarding a bag of constructor params
class TransformerLayer(nn.Module):
def __init__(self, *args, **kwargs): # LightGlue, real code
self.self_attn = SelfBlock(*args, **kwargs)
self.cross_attn = CrossBlock(*args, **kwargs)
Verdict: smell — middle man. The wrapper's signature is invisibly coupled to its children's. Every new child knob is shotgun surgery (or worse, silently swallowed by **kwargs). Replace with explicit factory injection or explicit owned construction; never a catch-all kwargs relay.
Test 5 — constructing a module inside forward()
def forward(self, x):
proj = nn.Linear(x.shape[-1], self.dim) # operation-time construction
return proj(x)
Verdict: caste violation, mechanically punished. An injectable constructed at operation time: its parameters were never registered at wiring time, the optimizer never sees them, and they silently don't train (fresh random weights every call). Torch enforces Hevery's rule for you.
Test 6 — runtime data in the constructor
class Verifier(nn.Module):
def __init__(self, sample_batch): # constructor over-injection
self.scale = sample_batch.std()
Verdict: method argument. Tensors are newables of the purest kind — per-batch runtime data. Anything derived from a batch belongs in forward() (or an explicit fit()/calibration step), not in wiring.
Test 7 — prototype + deepcopy
decoder = nn.TransformerDecoder(decoder_layer, num_layers=6)
# internally: copy.deepcopy(decoder_layer) x 6
Verdict: a third way with a gotcha. PyTorch dodges the factory by cloning a prototype instance — but deepcopy copies initialized weights, so all six layers start identical. Factory injection gives independent inits for free. Prefer the factory; if you accept the prototype pattern, know what you signed up for.
Test 8 — a "newable" that turned volatile
class ResNet(nn.Module):
def __init__(self, block, layers, norm_layer=nn.BatchNorm2d): # torchvision, real code
...
norm = norm_layer(planes) # consumer passes the params it owns
Verdict: defaulted factory hole. BatchNorm was "the" norm until GroupNorm/SyncBN users needed to swap it. torchvision punched a factory-shaped hole, defaulted to the old behavior — callers who don't care see a simple constructor; callers who do get the hole. This is the standard retrofit when a construct-it-yourself call turns out to be a variation point.
Case Studies — Torch
torchvision ResNet(block=Bottleneck, layers=[3,4,6,3], norm_layer=...). The "torchvision-simple" API is factory injection: block and norm_layer are callables; ResNet invokes block(inplanes, planes, stride, downsample, ...) passing the structural params it owns. timm generalizes the convention (block_fn=, act_layer=, norm_layer=) — mature vision libraries punched factory holes exactly where users demanded variation, and kept owned construction everywhere else.
nn.Transformer — both the example and the cautionary tale. Constructing its own nn.Linears is correct by the table (derived dims, stable newable). But it also hard-wired the FFN shape, activation, and attention internals — then the field wanted SwiGLU, RoPE, GQA, LoRA-wrapped and quantized linears, and the answer was "you can't." The ecosystem abandoned it and rewrote the block. The boundary it drew was right by 2017's knowledge and wrong by 2022's: variation points are discovered empirically.
nn.TransformerDecoder(layer, num_layers) — prototype + deepcopy instead of a factory; all clones start with identical weights (Test 7).
LightGlue TransformerLayer(*args, **kwargs) — the middle-man relay in the wild (Test 4).
The torch/DI isomorphism. __init__ = wiring time, modules = injectables; forward() = operation time, tensors = newables. Torch mechanically enforces both directions: modules built in forward don't get registered (Test 5); tensors baked into __init__ are stale runtime data (Test 6).
Case Studies — Java (and one from C++)
Guice T vs Provider<T>. Declare a dependency as T → the container builds one and hands it over. Declare Provider<T> (interface with one method, get()) when you need many instances, lazy/conditional construction, or per-request lifetimes inside a long-lived object. The guideline in The Decision Table is this API's documentation distilled.
Guice AssistedInject. Formalizes Parameter Ownership: some constructor params are bound by the container at wiring time; the ones marked @Assisted are supplied by the consumer at get()-time. attn_factory(dim) with partial-bound flavor params is AssistedInject in Python clothes.
ThreadPoolExecutor(..., ThreadFactory). The pool creates threads at times only it knows about (load spikes) — so it takes a factory, never (name_prefix, daemon, priority) params. newThread(Runnable r): consumer passes what it owns, factory binds the rest.
DataSource vs DriverManager.getConnection(url, user, password). A connection pool injects a connection factory (DataSource), not the connection params. Who knows the password is the factory's configuration problem, not the pool's.
C++ STL allocators. std::vector<T, Allocator> takes an allocation strategy, not allocation parameters — the same split, enforced at the type level.
Smells
- Middle-man relay: your
__init__accepts(d_model, nhead, dropout, bias, flash, ...)only to forward them to a sub-object's constructor. Your signature is now coupled to a dependency's; every new knob is shotgun surgery. Catch-all**kwargsdoesn't fix this — it hides it, and typos stop being errors. - Duplicate-and-assert: the same
dimpassed both to the wrapper and inside the factory binding, with an assert to keep them equal. Move the param to factory-call time — the wrapper passes the value it owns (Parameter Ownership). Exception: when the dependency must be instance-injected (heavyweight/pretrained), the assert is correct and the duplicate is the smell — see the Enforcement Ladder, rung 2. - Constructor over-injection: runtime data (a batch, a request) in a constructor. Move to the method.
- Newable holding an injectable: a value object with a service reference — untestable without the service graph.
- Pre-emptive injection: injecting
nn.LayerNorm, the FFN, every projection "for flexibility" nobody asked for. Indirection has a cost; you can no longer see what the object is made of. Construct the definitional parts; punch a defaulted factory hole when a variation point is discovered, not before. - Prologue collaborator: a function takes a collaborator only to compute its real inputs before its work starts. Narrowing the signature seems to push that computation into the caller; it doesn't have to. Worked example:
references/case-study-prologue-collaborators.md.
The Time Dimension
Caste membership is empirical, not metaphysical. nn.Linear sat firmly in the newable caste until LoRA and quantization made people want to substitute it. The rule with its time dimension:
Construct what is definitional and derived; inject what is variational — knowing you will sometimes discover a "definitional" part was variational, and the fix is one more defaulted factory hole (
norm_layer=nn.BatchNorm2d).
The Limit: Constructor Over-Injection
Injection has a limit, and it is cohesion, not a hard count. Seemann's rule of thumb: past ~4 injected dependencies the smell flips — the class is injecting at too fine a grain and is missing intermediate abstractions. The cure is not to start constructing things internally; it is to group collaborators that always travel together (Seemann: facade services).
Warning signs:
- The constructor reads as a parts list of leaf modules rather than a statement of what the class is parameterized by.
- Several injected deps are only ever used together, in a fixed pattern — a pipeline fragment masquerading as independent parts.
- The wrapper checks dimension/compatibility contracts between injected deps (
assert ref_extractor.dim == proj.in_features) — cross-module invariants it shouldn't own. - Every call site repeats the same multi-object assembly.
# BAD — seven leaf injections; dim contracts live in the wrapper; every caller assembles everything
class Verifier(nn.Module):
def __init__(self, hidden_dim, ffn, fpn, ref_extractor,
attn_factory, resampler, pool):
self.proj = nn.Linear(ref_dim_from_somewhere, hidden_dim) # whose dim?
...
The recipe:
- Find the clusters. Deps that only function as a unit are a missing class.
fpn + level_proj + poolis an observation tokenizer;ref_extractor + proj + resampleris a reference tokenizer. - Each facade owns its derived glue. The cluster class constructs its own projections/norms because their dims derive from the collaborator it holds. This needs collaborators to expose their output dims — give extractors an
out_dimattribute (detectron2 precedent: backbones exposeoutput_shape(), consumers derive their own layers from it). - Re-apply the decision table at the new grain. The top class now injects 2–4 coarse modules. Precedent: detectron2's
GeneralizedRCNN(backbone, proposal_generator, roi_heads)— top-level models aggregate a few coarse modules, never ten leaf modules. - Add a composition root. Pair the honest injected constructor with preset builders that own assembly — torchvision's split between
ResNet(block=...)(fully injected, nobody suffers) andresnet50()(the preset). Experiments edit presets; the class never changes.
# GOOD — stratified: three injection points; dim derived from the encoder (rung 2
# of the Enforcement Ladder), facades own their internal dim contracts
class Verifier(nn.Module):
def __init__(self, obs_encoder: nn.Module, # (image, proposals) -> B,N,out_dim
ref_encoder: nn.Module, # (image) -> B,K,out_dim
attn_factory: AttnFactory):
...
hidden_dim = obs_encoder.out_dim # single source of truth
assert ref_encoder.out_dim == hidden_dim
self.layers = nn.ModuleList(attn_factory(hidden_dim) for _ in range(L))
self.logit_proj = nn.Linear(hidden_dim, 1) # derived newable — construct
def verifier_dift(hidden_dim=256) -> Verifier: # composition root / preset:
return Verifier( # one lexical hidden_dim, threaded
obs_encoder=ObsEncoder(DiftFpn(...), hidden_dim, pool="attn"),
ref_encoder=RefEncoder(M2FExtractor(...), hidden_dim, num_latents=8),
attn_factory=partial(LightGlueAttn, num_heads=4, flash=False))
The axes of variation don't disappear — they stratify. Which extractor is an ObsEncoder-level question; how tokens mix is a Verifier-level question. Each layer carries 2–3 axes instead of one layer carrying seven.
Early-stage corollary. Feeling "everything is an axis of variation" at design time is normal and is not a reason to inject everything. The cost asymmetry decides: promoting a constructed part to injected later is one defaulted parameter (cheap, mechanical, no call sites break); carrying a speculative injection is a recurring cost at every config, every reader, every wiring. So: inject only the axes actively being varied right now; construct the rest with explicit hyperparams; promote on demand. "I might want to vary it someday" is the pre-emptive-injection smell wearing a research-code costume.
When Applying This Skill
- List every dependency the class touches; tag each newable or injectable (would any test or second caller substitute it? is its config fully derived from yours?).
- Newables with derived config → construct in
__init__; no parameters for them in your signature. - Injectables, exactly one, matching lifetime → instance parameter. If it must dimensionally agree with you or with other injected parts, require an exposed
out_dim-style attribute, then derive or assert (Enforcement Ladder — never rung 0). - Injectables, many copies or consumer-controlled timing → factory parameter; split its params by ownership (structural → call-time, flavor → bound by caller).
- Anything derived from runtime data → method argument.
- Hunt the smells: forwarded ctor params,
**kwargsrelays, duplicate-and-assert dims, modules built inforward, tensors in__init__. - Count the injection points — past ~4, look for deps that only travel together and group each cluster into a facade module that owns its derived glue (see The Limit); then add a preset builder as the composition root.
- Don't pre-emptively inject — inject only axes actively being varied; add defaulted factory holes when variation is demanded, defaulting to current behavior.
Related Skills
- separate-mechanism-policy — this skill is mechanism/policy applied to object construction: which implementation a dependency gets is a policy decision that belongs to the caller, and a factory parameter is how mechanism receives it. Its identity-vs-choice-point test is the same boundary as definitional-vs-variational here.
- fold-knowledge-into-data — where the bound half of a factory's params should live. A registry mapping names to classes/partials (its Case Study 4, and the
OPTIMIZERStable of partials) is a table of factories: the config layer declares the flavor params as data, and the consuming class stays stupid.