Backend dependencies
Because every app compiles into the same actor, one app can receive another's
backend function as a direct, typed Motoko function reference. The call itself
has no inter-canister transport or cycle transfer. The exported signature may
be synchronous, async, or async*; any real await or failure inside the
provider keeps its normal Motoko semantics.
The provider body can still perform its own approved outbound calls or spend shared canister cycles; direct dependency wiring grants none of that authority to the consumer.
This is one of the strongest arguments for Neutron's single-actor design — and it needs a strict contract so it does not become an escape hatch.
The two-sided declaration
Provider: export function by function
A provider marks an Init method with /*internal:apps*/:
public func /*internal:apps*/lookup_destination(
args : { principal : Principal }
) : ContactResult { … };
The function stays private — it is not a public actor method and does not appear
in Candid. expose: "apps" only makes it eligible to be wired.
Consumer: declare exactly what it uses
"dependencies": {
"contacts": {
"app": "contacts",
"min_version": 100,
"functions": ["lookup_destination"]
}
}
The alias is local. Limits: at most 32 dependencies, 64 functions each.
Consumer: name the type locally
The assembler derives the app_calls group automatically — no constructor token
is authored:
public type AppBackendEnvironment = {
app_calls : {
contacts : {
lookup_destination : ({ principal : Principal }) -> ContactResult;
};
};
};
public class Init(env : AppBackendEnvironment) {
public func /*query*/resolve(p : Principal) : ContactResult {
env.app_calls.contacts.lookup_destination({ principal = p });
};
};
What the compiler does
Before assembly, dependency resolution:
- validates provider minimum versions;
- validates that each requested function exists, is
internal, and is markedexpose: "apps"; - lets final actor compilation type-check the generated function record against the consumer's local Motoko type;
- rejects cycles;
- produces a deterministic provider-first order.
The assembler then creates one exact consumer-specific function record and initializes the provider before the consumer.
It never passes provider modules, provider
Initinstances, provider memory, or undeclared functions.
The consumer gets a record of function references and nothing else. There is no
route from a declared dependency to the provider's private state. An async*
reference remains actor-local and runs inline until its code reaches a real
await; it is not silently converted into a synchronous call.
The version contract
A provider at or above min_version is accepted, provided every requested
function remains exposed and type-compatible.
The obligation on providers:
Later provider releases must preserve exported functions compatibly. A breaking change means a new function name, not a changed signature.
Nested acyclic chains are supported — A depends on B which depends on C.
Lifecycle
| Event | Behaviour |
|---|---|
| Install | Dependencies are install-time authority, approved in the install dialog. They never open per-call dialogs. |
| Upgrade | The consumer keeps working as long as the provider still exposes compatible functions. |
| Provider uninstall | Rejected while any consumer declares it. |
What the user sees
Dependencies appear as kernel-attested facts in the install disclosure:
- exposing an internal function to declared app consumers (on the provider side);
- using exact backend functions from a minimum provider version (on the consumer side).
Both are level-2 facts. The user is told exactly which functions, from which app, at which minimum version.
Frontend tools are a different thing
Do not confuse backend dependencies with cross-app frontend tool calls.
| Backend dependency | Frontend tool call | |
|---|---|---|
| Where | Inside the Motoko actor | Between browser iframes |
| Approved | Once, at install | Per call or per session |
| Typed by | Motoko structural types | draft-07 JSON Schema |
| Cost | An actor-local typed call | A brokered, audited round trip |
| Discoverable | No | Yes, with consent |
An app can use both. See Message bus.
A worked example
The first-party apps demonstrate the pattern. Contacts owns contact memory,
validates IC and native-chain destinations, and exposes a small set of read-only
backend functions. Wallet declares exactly contacts_discover_v1 to
discover compatible payment destinations — without ever receiving Contacts'
memory or the other exports.
That is the shape to aim for: a provider exports a narrow, read-oriented domain function over its own private state; the consumer declares exactly that function and nothing more.