| name | reviewing-motoko |
| description | Design review for existing Motoko backends — judging whether code is well designed, not how to write it. Covers invariants encoded in types (variants over Text, correlated fields, case _ smells), stable vs transient state discipline under enhanced migration, file structure (types.mo/lib/mixins/main.mo), and severity-ranked findings (Blocker/Warning/Note). Use when reviewing, auditing, critiquing, or refactoring existing .mo files, or when asked for code-review feedback on a Motoko canister. Do NOT use for writing new Motoko code or fixing compiler errors (writing-motoko), authoring migration files (migrating-motoko-actors), or deployment. |
| license | Apache-2.0 |
| compatibility | moc >= 1.11.2, core >= 2.6.0, mops >= 3.0.0 |
| metadata | {"title":"Reviewing Motoko","category":"Motoko"} |
Reviewing Motoko
This skill judges whether Motoko code is well designed. It does not teach the language — load writing-motoko for syntax, mo:core APIs, and mechanical rules, and migrating-motoko-actors before changing anything that is stable state.
Four questions decide a review, in this order — the first three because each can invalidate the next, the fourth last because it is the least consequential:
- Is every invariant the code relies on encoded in a type? Where the type system can rule out a bad state, leaving that to a runtime check or a comment is a defect, not a style preference.
- Is state declared with the right persistence? Under enhanced migration, stable and transient are separate worlds. Blurring them produces state that silently stops updating.
- Does the code live in the right files?
types.mo / lib/ / mixins/ / main.mo is a contract about what may depend on what, not a folder convention.
- Does it read like Motoko? Contextual dot notation, inferred implicits, no annotation the call already supplies, no classes.
mops check --fix handles most of this; only what it cannot see is the reviewer's job.
Procedure
Run the mechanical pass first — do not spend review attention on what the compiler already reports.
mops check --fix
Its style auto-fixes (M0236 dot notation, M0237 redundant implicits, M0223 redundant instantiation) only fire where [moc] args enables -W for them; writing-motoko's references/project-setup.md has the setup. Where they are off, the Conventions findings below are all yours to make by hand.
Then read in this order — outside in, because a misplaced file makes every type inside it suspect:
mops.toml — is there a [canisters.*.migrations] chain? If not, finding B1 below outranks everything else.
- Structure — the file layout, then each file's imports.
types.mo — every type declaration. This is where most findings are.
- State — the actor fields in
main.mo, and what each include passes. See references/state-and-persistence.md.
- The API boundary — every
public shared / public query function: its parameter types, its return type, its authorization.
- Bodies — last, and only for the smells under Maintainability below.
Severity
Report findings with one of three levels. Do not inflate: a reviewer who calls everything a blocker gets ignored.
| Level | Meaning |
|---|
| Blocker | Can corrupt or silently lose state, or lets a caller reach an unauthorized action. |
| Warning | An invariant the type system could enforce is left to runtime, a comment, or a convention. |
| Note | Readability, duplication, or naming. Fix while you are in the file; do not gate on it. |
State the concrete failure, not the rule. "status : Text accepts "complete", and the three call sites spell it "completed"" beats "prefer variants over strings".
Type encoding
The rule: if a value has a fixed set of shapes, the type must have that set of shapes. Full recipes, including how to carry each change through a migration, are in references/type-encoding.md.
T1. Text for a closed set of values — Warning, Blocker if it gates authorization
The most common defect in generated Motoko. Every producer and consumer agrees on spelling by convention and the compiler checks nothing — no exhaustiveness, no error on a typo, no error when a new state is added and half the call sites are not updated.
public type Task = { id : Nat; var status : Text }; // WRONG
public type TaskStatus = { #open; #claimed : Principal; #done : { at : Time.Time } };
Variants are shared, so this reaches the frontend as a discriminated union too. Flag Text (or Nat, or Bool) whenever the code, a comment, or a doc lists the permitted values — == "..." comparisons, a switch on a Text with a case _, role names held as Text. Keep Text for genuinely open sets, and for strings an external system owns.
T2. Correlated fields — Warning
Two fields that are only valid together belong in one variant. A Bool plus an optional payload has four representable combinations and two meaningful ones.
// WRONG — { hasDeadline = true; deadline = null } compiles and means nothing
{ hasDeadline : Bool; deadline : ?Time.Time }
// RIGHT
{ deadline : { #none; #due : Time.Time } } // or simply `deadline : ?Time.Time`
If a ?T already says it, drop the Bool — do not introduce a variant that only restates ?T.
T3. Wrong cardinality — Note, Warning when callers loop defensively
Use the type that matches the real count. A [T] that is always empty-or-one makes every caller write a loop or an if size == 1; a ?T tells them there is nothing to iterate.
T4. Errors as Text — Warning
Result<Ok, Text> forces every caller to string-match to tell one failure from another. Make Err a variant with the data each case needs. See the Result section of writing-motoko for the full rule, including when to return ?T and when to trap instead.
T5. var fields leaking into the API — Blocker (it does not compile) or Warning (it compiles but over-shares)
A record with a var field is not shared, so it cannot be returned from a public function. Where a project already has the internal/view split (Task internal, TaskView shared), check that:
- no public signature mentions the internal type;
- the view is narrower than the internal record, not a mechanical field-for-field copy. A view that re-exports an internal
Principal list, an audit trail, or another user's data is an information leak that compiles.
T6. case _ swallowing variants — Warning
A catch-all arm is what turns "adding a variant tag is a compile error everywhere it matters" into "adding a variant tag silently takes the default branch". Keep case _ only where the arms are genuinely uniform and the set is expected to grow (e.g. mapping many tags to one label). Anywhere a new tag would need new behaviour, enumerate the tags.
T7. Transparent aliases sold as safety — Note
type UserId = Principal is a documentation alias: any Principal still type-checks where a UserId is expected. That is fine, and usually the right trade. Just do not treat it as an enforced distinction — and if two different id types are genuinely being confused at call sites, a one-field record ({ userId : Principal }) or a variant tag (#user(Principal)) is what actually enforces it, at the cost of changing the Candid shape.
State and persistence
Under enhanced migration (EM) the actor's stable fields are declared type-only and every initial value comes from the migration chain. That separation is the point: it is what makes stable state impossible to initialize from something transient. Details in references/state-and-persistence.md.
B1. No enhanced migration — Blocker
No [canisters.<name>.migrations] chain in mops.toml means pre-EM persistence, where stable fields carry inline initializers — so a stable field can be initialized from a transient one:
transient let defaultQuota = 100;
let quota = defaultQuota; // stable; the initializer runs ONCE, on first install
Change defaultQuota to 250, deploy, and quota is still 100 — forever, no warning. Point at migrating-motoko-actors for the conversion; everything below assumes EM.
S1. Inline initializer on a stable field — Blocker
Under EM this is M0250 / M0014, so it will not compile — but it appears constantly in code copied from pre-EM examples and in half-finished refactors. The fix is never to add transient: move the initial value into the pending migration's NewActor.
S2. transient misused — Warning both ways
Ask of every actor and mixin field: if this is lost on upgrade, is the app still correct?
- Missing
transient: caches, derived indexes, rate-limit counters, and anything rebuildable are being persisted. They then have to be migrated forever, and a stale cache survives the upgrade that was supposed to clear it.
- Wrongly
transient: durable domain data silently resets to its initializer on every upgrade. Look for transient on anything a user would expect to still be there after a deploy.
S3. var actor field shared with a mixin — Blocker
var parameters pass by value, so the mixin mutates a copy and the actor's field never changes. Wrap it in a record and pass the record. Also flag a fresh record literal at the include site (include Api({ var n = state.n })) — each mixin then gets its own copy. See writing-motoko.
S4. Stable state inside a mixin block — Blocker
Every bare let/var at the top of a mixin is implicitly stable and traps at runtime with IC0503. State comes in as a parameter; constants go in a module; only transient is allowed inline.
S5. Migration hygiene — Blocker
- A migration file importing anything other than
mo:core/... — the chain replays forever, so a project import makes it wrong the moment that type changes. (caffeineai-lints catches this.)
- More than one pending migration in a build, or an edit to a migration that predates this build.
- A stable field in
main.mo that no migration in the chain supplies (M0254 / M0267).
An identity migration body is a Warning — the change was stable-compatible, so delete the file. Full table.
Structure
Layers, with dependencies pointing one way only: main.mo → mixins/ → lib/ → types.mo.
| File | Holds | Must not hold |
|---|
types.mo | Type declarations; small pure helpers on those types | State, endpoints, business rules |
lib/*.mo | Domain logic as stateless modules, state as parameters | Public endpoints, actor fields |
mixins/*.mo | Public endpoints: authorize, delegate, map to a view | Domain logic, stable state |
main.mo | State declarations and includes | Any public method, any logic |
migrations/ | The frozen chain, one module per file | Project imports |
- A1 — Blocker/Warning: a public method in
main.mo. Move it to a mixin.
- A2 — Warning: a monolithic file. One
.mo holding types, state, logic, and endpoints together.
- A3 — Warning: domain logic in a mixin body. If an endpoint's body is more than authorize → delegate → map, the middle belongs in
lib/.
- A4 — Warning: wrong dependency direction —
lib/ importing a mixin, or types.mo importing either.
- A5 — Note: a mixin that is not a feature. Mixins split by feature (
mixins/Bookings.mo), not by verb (mixins/Getters.mo).
- A6 — Warning: over-broad state injection. A mixin receiving state it never touches widens what a change can break; pass only the slices it uses.
Maintainability
Most of this is caught in the last, bodies pass. The exception is M1, the authorization blocker — it is found during the API-boundary pass (step 5), not with the body smells below.
- M1 — Blocker: a
public shared function that does not check caller. Every state change and every read of another user's data needs authorization on the backend.
- M2 — Warning:
Nat subtraction that is not provably non-negative at the operation. It traps.
- M3 — Warning: a trap where the caller could have recovered. Traps roll back the message and reach the client as an opaque reject; caller-fixable failures belong in a
Result error variant.
- M4 — Note: duplicated internal→view mapping. One
toView per type, in lib/ or types.mo.
- M5 — Note: magic values — bare literals for limits, quotas, or durations. Name them in a module.
- M6 — Note: a comment asserting an invariant the type could carry. Every "must be non-empty" / "only set when …" comment is a type-encoding finding in disguise. Where Motoko cannot express the invariant, keep the comment but validate the value once at the boundary rather than re-checking it at every use.
Conventions
Least consequential of the four axes, and mops check --fix fixes most of it where the -W flags are on — so raise these only after the passes above, and only where the tooling cannot. writing-motoko has the rules; this is what to look for.
- C1 — Note: a module-function call where the function takes
self — List.add(list, x), Principal.toText(p). M0236, auto-fixed.
- C2 — Note: an implicit passed explicitly (
map.add(Text.compare, k, v)), or a type instantiation inference already resolved. M0237 / M0223, auto-fixed.
- C3 — Warning: type annotations on an inline
func passed as a call argument — xs.filter(func(x : Nat) : Bool { x > 1 }). No diagnostic catches this, so it is the reviewer's job: the call already fixes both types, and the annotation duplicates them so they can drift. Write xs.filter(func x = x > 1). Instantiate the call (map<In, Out>) when M0098 demands it — never the lambda. One exception: : async () on an async callback is load-bearing.
- C4 — Blocker: a
class, or any object holding functions, used as actor state. Functions are not stable, so it fails with M0131 (declared stable but has non-stable type). Outside state a class still is not the idiom here — a module taking self plus dot notation gets the same call sites and stays stable.
Refactoring
Design fixes in Motoko are not free: changing a type that is reachable from a stable actor field requires a migration, and the chain is one-way. Before proposing a type change, establish whether that type is stable state.
mops check --fix first, so the mechanical noise is gone.
- Batch the stable-state type changes — one pending migration per build, so they land together.
- Write the migration (
migrating-motoko-actors). For Text → variant, decide explicitly what an unrecognized string becomes rather than inventing an #unknown tag.
- Move code before changing it — relocating, then editing, keeps each diff reviewable.
mops check --fix until clean, then mops build once.
Reporting
Group findings by severity, blockers first. Per finding: file:line, the level, the concrete failure it permits, and the change. Suppress findings the compiler already reports.
Blocker src/backend/mixins/Tasks.mo:34 (M1)
`claimTask` never reads `caller` — any principal can claim any task.
Warning src/backend/types.mo:12 (T1)
`Task.status : Text` is a closed set, so the comparison at Tasks.mo:51
spells it "complete" and is never true. Stable state — needs a migration.
If nothing is wrong, say so plainly. Do not manufacture findings to fill a report.
Additional References
- references/type-encoding.md — recipes per invariant, and the migration each one needs
- references/state-and-persistence.md — why EM matters, the stable/transient question, migration hygiene
writing-motoko — language mechanics, mo:core APIs, Result, dot notation
- mops tooling: Load
mops-cli for mops.toml configuration and mops check/mops build details
migrating-motoko-actors — writing the migration a type change requires
troubleshooting-motoko-migrations — compatibility diagnostics that do not match the source