Aztec Template Composition
What is this?
Aztec Template Composition is a contract-reuse mechanism for Noir/Aztec contracts. It lets a contract — the host — absorb the full callable surface of one or more template contracts, as if the template functions had been written directly in the host.
No new Noir syntax is introduced. The mechanism operates entirely at macro level, using Noir’s comptime system to capture function wrappers in the template crate and replay them into the host at codegen time. The key trick is that a template contract is processed by #[aztec] twice: once in its own crate (where names are in scope and wrappers are generated and captured), and once as pre-generated code replayed into the host. This is what allows function bodies to cross the crate boundary without new language features.
A practical consequence: turning an existing Aztec contract into a reusable template requires only adding #[contract_template("my_id")] above its existing #[aztec] annotation. The contract continues to compile and deploy as before, and simultaneously becomes composable by any host.
The result looks like inheritance from the outside: a host composed from a TokenTemplate exposes transfer(), mint(), balance_of(), and the rest of the token surface, even though none of those appear in the host’s source file.
In practice
// ── Template crate ──────────────────────────────────────────────────────────
#[contract_template("aip20_token")] // step 1: register — capture wrappers into global registry
#[aztec] // step 2: also compile as a real contract (type-checks bodies)
pub contract TokenTemplate {
#[external("public")]
fn transfer(from: AztecAddress, to: AztecAddress, amount: u128) {
// body captured here, in template scope where all names resolve
self.internal._decrease_private_balance(from, amount, MAX_NOTES);
self.enqueue_self.increase_public_balance_internal(to, amount);
}
#[external("public")]
#[view]
fn balance_of_public(owner: AztecAddress) -> u128 {
self.storage.public_balances.at(owner).read()
}
// ... full AIP-20 surface
}
// ── Host crate ───────────────────────────────────────────────────────────────
#[aztec(AztecConfig::new().compose("aip20_token"))] // step 3: replay captured wrappers into host codegen
pub contract Amm {
// Only AMM-specific logic is written here.
// transfer(), balance_of_public(), and the rest of the AIP-20 surface
// are NOT written here — yet all appear in the compiled Amm ABI and dispatch table.
#[external("private")]
fn swap(amount_in: u128, min_out: u128) {
// can call composed token internals directly
self.internal._decrease_private_balance(self.msg_sender(), amount_in, MAX_NOTES);
// ...
}
}
The template compiles and deploys as a standalone contract. The host compiles to a fully self-contained contract that includes every composed function as if written there. The two share no runtime relationship — the template address is not referenced anywhere in the host’s bytecode.
Why it matters
Before template composition, building a token variant — say, a pausable token or a capped token — meant copying the entire token contract and modifying it. Every variant was its own full codebase: thousands of lines duplicated, diverging over time, each requiring independent audits and maintenance. Two variants meant two copies; ten variants meant ten copies. The combinatorial surface grew with every new dimension of variation (pausable + capped + mintable-by-role), and any fix to the underlying token logic had to be propagated manually across all copies.
Template composition may change the economics of variation. The canonical token implementation could live once, as a template. A pausable variant might be a host contract that composes the token template and overrides the transfer internals to add a pause check — a few dozen lines, not a few thousand. A capped variant might override _increase_total_supply. A role-gated mint variant might override mint_to_public. Each variant could be expressed only as its delta from the base, with the base shared and maintained in one place.
This might also shift how contracts are designed in the first place. A contract authored with composition in mind could expose deliberate override points — virtual hooks at the boundaries where behavior is expected to vary. A token that marks _before_transfer as virtual may become an extensible primitive rather than a fixed implementation. DEX liquidity accounting, fee-on-transfer mechanics, transfer restrictions, and compliance hooks could become composable layers rather than forks — though how far this pattern extends in practice remains to be seen.
The mental model: merge-and-replay, not parent chains
Unlike Solidity’s is-based inheritance, which builds a parent chain and dispatches calls through it, template composition is a flat merge:
- Each composed template’s function wrappers are captured in the template crate, where names are in scope.
- Those wrappers are replayed into the host’s codegen output, appearing as if written there.
- The host’s codegen sees the union of all template functions plus its own, and generates dispatch, call interfaces, and ABI from that union.
The compiled host contract is entirely self-contained. There is no parent contract address, no DELEGATECALL chain, and no dispatch indirection — the host’s bytecode already includes every composed function directly.
What this is not
- Not siloed storage. Templates have no private storage slots. All storage is declared and owned by the host; composed functions access it by name alongside any other host or template function.
- Not a dispatch chain. There is no C3 linearization, no MRO, and no concept of ordering between composed templates.
- Not
superchaining. Overrides are full replacements. There is no way to call the original template implementation from a host override. - Not compiler-enforced initialization. Templates document a required initializer call; nothing prevents a host from skipping it.
- Not abstract contracts. Every template is a valid, deployable Noir contract. There is no mechanism to prevent standalone deployment.
- Not private scope. Composed function bodies are flat-copied into the host. There is no visibility barrier between the host and its composed templates.
- Not an interface or ABI standard. Composing a token template into a contract does not make that contract discoverable or callable as a token by external parties. Callers need the full contract class ID and private method commitments of the specific deployed host — there is no “I know this contract implements ARC-20 so I can interact with its token surface” mechanism. Composition is a code-reuse tool, not a runtime interface declaration.
From a template writer’s perspective
When writing a template, the key mental shift is: your function bodies will execute inside a contract you do not control. This has direct consequences:
What you own:
- Your function logic and the wrappers generated from it.
- Constants and helpers declared as
#[contract_library_method]— these migrate via typed reference and always resolve back to your implementation.
What the host owns:
- All storage. Your functions reference storage fields by name; the host must declare them with matching names and types. If the host gets this wrong, your composed body fails at compile time inside the host, not inside your crate.
Crucially, if two composed templates reference the same storage field name (and the host declares it), both templates will read and write the same slot — there is no per-template ownership of storage. In Solidity this situation is governed by the inheritance line: a
balancedeclared in baseAis only directly modified byA’s functions. In template composition there is no such line; two unrelated templates sharing a field name are silently sharing a slot. This is a feature when intentional (e.g. a mint template and a transfer template both operating on the sametotal_supply) and a bug when accidental — which is why namespace-prefixed field names are strongly recommended. - All global name bindings. Any unqualified symbol your body references (other than
#[contract_library_method]helpers) is resolved in the host’s scope at replay time. This is intentional: it lets the host configure behavior by providing or importing the right binding, but it means you cannot rely on module-level globals from your own crate. - The initializer call. Your template can publish an
#[internal]initializer and document that the host must call it from its constructor — but nothing enforces this. Uninitialized storage is a silent runtime failure.
Patterns to follow:
- Use
#[contract_library_method]for any constant or helper that is an implementation detail of your template and must not be configurable by the host. - Use unqualified global references for values you expect the host to provide or customize.
- Prefix all your function and storage names to avoid collisions when multiple templates are composed together.
- Document every storage field, required import, and initializer call as part of your template’s host contract.
Antipatterns to avoid:
- Referencing module-level globals from your own crate in composable function bodies — they will not resolve in host scope.
- Assuming the host called your initializer — document it and fail loudly in the initializer itself if preconditions can be checked.
- Using generic names (
value,counter,admin) for storage fields or functions — collisions with the host or other templates are a hard compile error. - Marking a function
#[template_virtual]without documenting what the expected override contract is — the host has no type-level guarantee that its replacement is semantically compatible.
Template Composition vs Solidity Inheritance
The table below compares every major Solidity inheritance concept against what template composition provides:
IMPLEMENTED— supportedBY_DESIGN— intentionally handled differentlyOUT_OF_SCOPE— concept does not map onto this model
| # | Concept | Solidity is inheritance |
Aztec template composition | Status |
|---|---|---|---|---|
| 1 | Reuse mechanism | contract B is A copies A’s ABI and dispatch into B via a parent chain |
AztecConfig::new().compose("template_id") replays pre-generated wrappers into host codegen |
IMPLEMENTED |
| 2 | Multiple inheritance | contract C is A, B with C3 linearization to resolve ordering |
.compose("a").compose("b") — flat union; no ordering needed because there is no dispatch chain |
IMPLEMENTED |
| 3 | Transitive inheritance | Grandparent functions are automatically available | Composing a template recursively injects its own transitive dependencies (flattened) | IMPLEMENTED |
| 4 | External surface reuse | Inherited public/external functions appear in child ABI | Composed externals appear in host ABI and dispatch exactly like host-authored functions | IMPLEMENTED |
| 5 | Internal function reuse | internal functions are accessible in subcontracts |
Composed internals are available via self.internal._fn() in host or other composed bodies |
IMPLEMENTED |
| 6 | Library/helper constants | pure/view functions or constants defined in base, inherited by child |
#[contract_library_method] functions migrate via typed reference (f.as_typed_expr()); template-owned constants do not require host redeclaration |
IMPLEMENTED |
| 7 | Constructor chaining | Base constructor called with Base(args) syntax; child chain is automatic and compiler-enforced |
No automatic chaining exists; templates publish a named init internal (e.g. _initialize_token) that the host constructor is expected to call manually. Skipping it silently produces uninitialized storage — there is no compile-time enforcement |
OUT_OF_SCOPE |
| 8 | Virtual functions | virtual keyword marks overridable functions |
#[template_virtual] attribute on template function; host declares override in config |
IMPLEMENTED |
| 9 | Overriding a function | override keyword in subcontract; replaces dispatch chain entry |
override_template("template_id", "fn_name") in AztecConfig; host provides a replacement function body |
IMPLEMENTED |
| 10 | Overriding internal helpers | Same as externals: override keyword |
override_internal_template("template_id", "fn_name") in AztecConfig |
IMPLEMENTED |
| 11 | Abstract contracts | abstract keyword; contract cannot be deployed; subcontract must implement all virtuals before deployment is allowed |
Every template must be a valid, compilable, deployable Noir contract — the language has no concept of an unimplemented function, so there is no mechanism to prevent a template from being deployed standalone | OUT_OF_SCOPE |
| 12 | Collision detection | C3 linearization resolves or rejects ambiguous same-name functions | Any duplicate function selector in the merged surface is a hard compile error, direct or transitive | IMPLEMENTED |
| 13 | Storage inheritance | Storage layout is inherited; child can add slots | Host fully owns storage; templates document required field names/types; host declares them manually | BY_DESIGN |
| 14 | Storage slot ordering | Defined by inheritance order (parent slots first) | Defined entirely by the host’s Storage struct field order |
BY_DESIGN |
| 15 | super calls |
super.fn() calls parent implementation from override |
There is no parent — composition is a flat merge with no hierarchy, so super has no referent |
OUT_OF_SCOPE |
| 16 | Dispatch chain / linearization | MRO (C3) produces a deterministic call-through chain for super |
No dispatch chain exists; all functions land in a single flat surface; C3/MRO has no meaning in a flat merge | OUT_OF_SCOPE |
| 17 | private visibility propagation |
private functions are not accessible in subcontracts |
All composed function bodies are flat-copied into host scope; there is no private barrier in the copy model | OUT_OF_SCOPE |
| 18 | Modifiers / modifier inheritance | Modifiers defined in base are usable/overridable in child | Aztec uses function-level attributes (#[only_self], #[authorize_once]); no modifier chain concept |
OUT_OF_SCOPE |
| 19 | Global name resolution | Base-scope names auto-resolve in child due to shared scope | Composed function bodies resolve names in host scope; template module globals are not auto-inherited — use #[contract_library_method] for template-owned constants |
BY_DESIGN |
| 20 | Event struct inheritance | Child inherits parent events implicitly | Template event structs are automatically replayed into host codegen; no manual host redeclaration needed | IMPLEMENTED |
Why these differences?
Storage is host-owned (rows 13–14)
In Solidity, a base contract’s state variables are allocated in the child’s storage layout automatically. In Noir/Aztec, the comptime API does not currently expose a way to inject fields into an existing TypeDefinition. Until that upstream capability lands, hosts must manually declare the storage fields that template bodies reference.
The side effect of this design is that hosts get explicit control over slot ordering, which is preferable for upgrade-safety in production contracts.
Note on Solidity storage “ownership”: Solidity’s
privateandinternalvisibility on state variables is a language-level access control, not a storage isolation guarantee. Any code with access to the contract’s storage — including inline assembly — can read or write any slot by raw index regardless of declared visibility. The “inheritance” of storage is therefore also a language facility: the compiler allocates slots and enforces access rules, but there is no EVM mechanism that actually siloes a base contract’s slots from the child. Template composition takes the same pragmatic view: the host owns the full storage layout and declares every field explicitly, with no illusion of encapsulation below the language level.
No super, no dispatch chain (rows 15–16)
Template composition is a flat merge — there is no parent contract, no hierarchy, and no chain to traverse. super has no referent because the concept of a “parent implementation” does not exist in this model. C3 linearization and MRO are equally inapplicable for the same reason.
If a host needs to reuse part of a template function’s logic alongside its own override, the pattern is to extract that logic into a #[contract_library_method] helper in the template and call it directly from the host’s override body.
Global name resolution in host scope (row 19)
Composed function bodies are captured as raw token streams in the template crate and replayed in the host. At replay time, identifiers are resolved in the host module’s scope, not the template’s. This gives two distinct patterns depending on whether the template author wants the value to be fixed or host-configurable.
Option A — host-configurable global. The template references an unqualified symbol in its body and documents it as a host requirement. The host decides the value, either by importing it from the template crate or declaring its own:
// template body
#[external("public")]
fn use_config() -> u32 {
FOO_CONFIG // resolved in host scope at replay time
}
// host: import the template's own default
use my_template::FOO_CONFIG;
// host: or declare a local override
pub global FOO_CONFIG: u32 = 99;
Option B — template-owned constant, not configurable by the host. The template wraps the constant in a #[contract_library_method] function. These migrate via a typed reference (f.as_typed_expr()), so the call resolves back to the original template function and is never re-resolved in host scope:
// template: constant the host cannot override
#[contract_library_method]
fn _fee_denominator() -> u32 { 10_000 }
#[external("public")]
fn compute_fee(amount: u32) -> u32 {
amount / _fee_denominator() // always calls the template's own function
}
The key difference: raw globals are a seam the host is expected to fill; #[contract_library_method] functions are opaque to the host and carry their implementation across the crate boundary intact.
Open design points
Virtual #[contract_library_method] functions
Currently, #[contract_library_method] functions are not subject to the virtual/override mechanism. They migrate via typed reference and always resolve to the original template implementation — the host has no way to replace them.
There is an open question whether this should change. A template author may want to expose a helper as overridable (e.g. a fee formula used internally by composed bodies) while still keeping it out of the external ABI. The virtual/override machinery today only covers #[external] and #[internal] functions, so supporting virtual library methods would require extending that mechanism to a third function kind.
Whether this is worth the added complexity — and whether the right surface is a new attribute, an extension of override_template, or something else — is not yet decided.
Multi-layer override semantics
The override mechanism is currently single-level: a host can replace a virtual function from a directly composed template, but there is no concept of an override chain. If a mid-template overrides a function from one of its own composed templates, that override is baked in — a host composing the mid-template cannot then override the same function again.
Case 1 — mid locks the override in. The host has no way to replace fee_bps further:
// base_template: declares a virtual function
#[contract_template("base_template")]
#[aztec]
pub contract BaseTemplate {
#[external("public")]
#[template_virtual]
#[view]
fn fee_bps() -> u16 { 30 }
}
// mid_template: overrides and bakes in the value — not re-declared as virtual
#[contract_template("mid_template")]
#[aztec(AztecConfig::new()
.compose("base_template")
.override_template("base_template", "fee_bps")
)]
pub contract MidTemplate {
#[external("public")]
#[view]
fn fee_bps() -> u16 { 20 } // baked in — host cannot touch this anymore
}
// host: override_template("mid_template", "fee_bps") would fail —
// fee_bps is no longer virtual in mid_template's surface
#[aztec(AztecConfig::new().compose("mid_template"))]
pub contract Host { }
Case 2 — mid re-exposes the override as virtual. The host must then override both layers explicitly:
// mid_template: overrides base but re-declares virtuality
#[contract_template("mid_template")]
#[aztec(AztecConfig::new()
.compose("base_template")
.override_template("base_template", "fee_bps")
)]
pub contract MidTemplate {
#[external("public")]
#[template_virtual] // host can still replace this
#[view]
fn fee_bps() -> u16 { 20 }
}
// host must cover both virtual sources of fee_bps:
// - override_template("base_template", "fee_bps") for the base entry
// - override_template("mid_template", "fee_bps") for mid's entry
#[aztec(AztecConfig::new()
.compose("mid_template")
.override_template("base_template", "fee_bps")
.override_template("mid_template", "fee_bps")
)]
pub contract Host {
#[external("public")]
#[view]
fn fee_bps() -> u16 { 5 }
}
Whether multi-layer override semantics should be introduced (and how precedence and conflict resolution would work across layers) is an open question.
Override targeting transitive templates
Today, override_template("id", "fn") requires "id" to be a template that the host directly composes. Overriding a function that comes from a transitive dependency (a template composed by a composed template) is not supported — the host must also directly compose that template to target it.
Using mid_template from the example above (which transitively brings in base_template), the following is an antipattern that will fail:
// ANTIPATTERN: host only composes mid_template, but tries to override
// fee_bps from base_template — a transitive dependency it never directly composed
#[aztec(AztecConfig::new()
.compose("mid_template")
.override_template("base_template", "fee_bps") // compile error: base_template not directly composed
)]
pub contract Host {
#[external("public")]
#[view]
fn fee_bps() -> u16 { 5 }
}
The fix is to also directly compose base_template:
#[aztec(AztecConfig::new()
.compose("mid_template")
.compose("base_template") // now directly composed
.override_template("base_template", "fee_bps") // valid
)]
pub contract Host {
#[external("public")]
#[view]
fn fee_bps() -> u16 { 5 }
}
Whether this restriction should be relaxed, and how that would interact with collision detection and override precedence, is undecided.
Template ID last-writer-wins and implementation selection
When two crates register a template under the same ID, the later registration silently wins. This becomes a meaningful design question in diamond-like composition graphs where two templates share a common dependency registered under the same ID but with different implementations.
Consider a host A that composes both B and D, where B internally composes C (a token template) and D internally composes C' — a different implementation registered under the same template ID "token":
A
├── B (composes "token" → C)
└── D (composes "token" → C')
C and C' share the same template ID. Because they are both meant to implement the same interface (e.g. a token standard), we can reasonably expect them to expose the same external method names — but this is a convention, not a guarantee. Currently only the external ABI of the token is being standardized; internal method names, helper signatures, and storage field names are not part of any enforced contract between C and C'.
For example, the aip20_token template today standardizes its external surface:
// standardized external ABI (callable from outside)
fn transfer_private_to_private(from, to, amount, _nonce)
fn transfer_private_to_public(from, to, amount, _nonce)
fn transfer_public_to_public(from, to, amount, _nonce)
fn transfer_public_to_private(from, to, amount, _nonce)
fn mint_to_private(to, amount)
fn mint_to_public(to, amount)
fn burn_private(from, amount, _nonce)
fn burn_public(from, amount, _nonce)
fn balance_of_public(owner) -> u128
fn total_supply() -> u128
But a template that composes aip20_token — say, an AMM that calls into the token’s internal helpers to move balances — depends on internal method names that are not standardized:
// NOT standardized — internal helpers used by composed bodies
self.internal._increase_private_balance(to, amount)
self.internal._decrease_private_balance(account, amount, max_notes)
self.internal._increase_public_balance(to, amount)
self.internal._decrease_public_balance(from, amount)
If an alternative token implementation C' renames _increase_private_balance to _credit_private(to, amount), any template that composed C and called self.internal._increase_private_balance will fail to compile against C' — even though both implementations are valid aip20_token tokens from the external ABI perspective.
When A flattens the full transitive graph, both C and C' contribute functions under "token" — but the registry can only hold one. The last-writer-wins rule determines which implementation survives, silently, based on registration order.
This surfaces two related open questions:
- Should the host have an explicit mechanism to declare which implementation of
"token"it wants — selectingCorC'by intent rather than by accident of load order — and should conflicting registrations under the same ID be a hard error unless the host resolves the conflict explicitly? - If two implementations are expected to be substitutable under the same template ID, should the template ID carry a stronger contract standardization — not just the external ABI but also internal method signatures and storage shape — so that substitutability is verifiable at compose time rather than assumed by convention?
If the language allowed it
The current implementation works within Noir’s existing comptime constraints. Several aspects of the developer experience and safety guarantees could improve significantly if the language gained the following capabilities:
Storage field injection (TypeDefinition::add_field)
The single largest ergonomics gap. Today, every host must manually copy the required storage field declarations from template documentation into its own Storage struct. If Noir exposed TypeDefinition::add_field at comptime, compose could inject template-required fields automatically — eliminating a whole class of silent runtime failures from missing or mismatched storage, and making template adoption a true one-liner.
Nicer composition syntax
.compose("aip20_token") as a string argument inside AztecConfig::new() is functional but not particularly readable, especially when multiple templates are composed or overrides are declared. A dedicated language construct — for example a mixin keyword or an is-like annotation with template semantics — would make the intent clearer at a glance:
// hypothetical — not current syntax
#[aztec]
#[mixin(aip20_token, amm_template)]
pub contract Amm { ... }
Hygienic cross-module Quoted
Currently, function bodies are captured as raw token streams and replayed in host scope, so all name resolution happens in the host. If Noir supported hygienic Quoted that preserved the originating crate’s identity for certain references, template-owned globals could be used directly in composable bodies without requiring #[contract_library_method] workarounds.
Compile-time compatibility validation
There is currently no dedicated pass that checks whether a host satisfies a template’s requirements before codegen. Errors surface as generic type or name-resolution failures deep inside composed bodies. A Noir macro API that could inspect a host’s storage, imports, and declared overrides at compose time would allow templates to emit clear, actionable diagnostics instead of opaque compiler errors.
Sealed globals
A pub global declared in a template crate is always re-resolved in host scope at replay time — it is inherently host-configurable. The only current way to have a template-owned constant that the host cannot override is to wrap it in a #[contract_library_method] function, which migrates via typed reference and always resolves back to the original implementation. This works, but forces every constant to be expressed as a zero-argument function call (_fee_denominator()) rather than a direct name reference (FEE_DENOMINATOR). A language-level annotation on pub global that opts it into typed-reference migration — analogous to what #[contract_library_method] does for functions — would eliminate this boilerplate.
Known issues
#[immutables] compatibility
The aztec-immutables-macro (#[immutables]) is a separate Wonderland library that commits deploy-time constants into the contract’s address via salt derivation. It is unrelated to template composition, but the two mechanisms interact in non-obvious ways when a template uses Immutables::init(context) in composable function bodies.
External and internal composed functions resolve all names in host scope. Immutables::init(context) in a composed external or internal body will call the host’s Immutables::init(), using the host’s struct layout. At runtime, the host’s instance.salt was derived from the host’s deployment — so the verification is self-consistent. This works, subject to the same host-requirement discipline as storage fields: the host must declare an #[immutables] pub struct Immutables { ... } that includes all fields the composed bodies access, merge fields from all composed templates into that single struct, and deploy with deployWithImmutables.
#[contract_library_method] functions are a different story. CLMs migrate via typed reference (f.as_typed_expr()), so they call through to the original template function and execute in the template crate’s scope. Any Immutables::init(context) inside a CLM body calls the template’s Immutables::init(), using the template’s struct layout for hash computation:
poseidon2_hash([actual_salt, template_field_0, ...]) == instance.salt
But instance.salt is the host’s salt, derived from the host’s merged Immutables struct. Unless the host’s struct is byte-for-byte identical to the template’s — same fields, same order, nothing added — the assertion fails silently at runtime. There is no compile-time signal.
Consequence: templates must not use Immutables::init() inside #[contract_library_method] functions. The salt-commitment pattern is fundamentally incompatible with CLMs across the crate boundary.