[Forward-Looking Conceptual Knowledge]
> Inter-Canister Calls — Async Actor Messages
Just as every public function inside a canister is an async message, calls between canisters are async messages too. One canister sends a message to another, awaits the reply, then continues. No shared memory, no direct function calls — only message passing.
// Canister A — calling another canister asynchronously
import CanisterB "canister:bkyz2-fyaaa-aaaaa-qaaaq-cai";
actor CanisterA {
// Inter-canister call — an async message to Canister B
public shared func pingB() : async Text {
let reply = await CanisterB.hello("from A");
return reply;
};
};Async
The caller doesn't block. It sends the message and awaits — meanwhile it can handle other work.
Addressed
Every canister has a unique canister ID. Calls are routed to the target canister by that ID.
No shared state
Each canister keeps its own state. Communication happens only through messages, never shared memory.
> Query vs Update Calls Between Canisters
The same rules apply across canister boundaries. Query calls are read-only, fast, and free — but they can only be triggered directly by users, not by another canister. Update calls can write state, cost cycles, and can be triggered by other canisters.
QUERY
Read-only. Free and fast. Only callable directly by users — a canister cannot call another canister's query.
UPDATE
Writable. Costs cycles. Callable by other canisters — the primary channel for inter-canister communication.
[Why This Matters]
> Principals and Caller Identification
Every canister has a principal — a unique identity. When canister A calls canister B, B can see the caller's principal. This lets you verify who is calling and only accept requests from trusted principals.
// Only accept calls from a trusted principal
import Principal "mo:base/Principal";
actor Ledger {
let owner : Principal = Principal.fromText("aaaaa-aa");
public shared ({ caller }) func transfer() : async Bool {
// Reject anyone who is not the trusted owner
if (caller != owner) {
return false;
};
// ... perform the transfer
return true;
};
};[Security First]
> Cycles — The Cost of Inter-Canister Calls
Every inter-canister call costs cycles — the network's compute and storage cost. The more calls and the more data, the higher the cost. Understanding this helps you design efficient architectures and avoid unnecessary cross-canister round trips.
Every call costs
Send the message, process it, return the reply — each step consumes cycles.
Data volume affects cost
The more data transferred, the higher the cost. Keep cross-canister messages lean.
Batch and cache
Combine related operations and cache frequent reads to reduce cross-canister round trips.
> Best Practices
Loose coupling, clear interfaces
Treat each canister like a service with a stable public API. Change internals freely, but keep the message contract stable.
Error handling and retries
Inter-canister calls can fail or time out. Handle errors explicitly and design idempotent operations so retries are safe.
Trusted principals only
Every call carries a caller principal. Verify it before acting — never assume a message is legitimate just because it arrived.
Versioning and upgrades
Canisters upgrade independently. Version your interfaces and plan migrations so a change in one canister never breaks another.
> When to Split vs Stay on One Canister
Caffeine runs on a single canister — the right choice for the vast majority of apps. Splitting is a forward-looking decision you consider only once your project outgrows a single canister.
Stay on one canister
Caffeine runs on a single canister. For most apps this is the right call — one actor, one state, no cross-canister latency, simpler upgrades.
Split when you outgrow it
When state, compute, or team boundaries exceed one canister, splitting becomes a forward-looking architectural decision — not a default.
Design for interfaces
Whether you split now or later, keep canisters loosely coupled with clear interfaces so the boundary stays cheap to change.
[Caffeine's Position]
> The Three Things to Remember
- Inter-canister calls are async actor messages — no shared memory, only message passing.
- Always verify the caller principal — only accept calls from trusted principals.
- Caffeine runs on a single canister — this section is forward-looking conceptual knowledge.
