Feedback on my Reputation Canister — ZenDB + State Machine + RBAC in Motoko

Hey ICP devs! :waving_hand:

I’m building KeneshICP — a decentralized escrow marketplace for car parts in Kyrgyzstan. 4 canisters on mainnet (escrow, marketplace, feed, reputation), Motoko + ZenDB, Spring Boot orchestrator. Would love your feedback on my reputation canister — specifically around the ZenDB pattern and the getReputationScore implementation.

import Principal "mo:base/Principal";
import Text "mo:base/Text";
import Time "mo:base/Time";
import Blob "mo:base/Blob";
import Array "mo:base/Array";
import Float "mo:base/Float";

import ZenDB "mo:zendb";

persistent actor Reputation {

    func assertNotAnonymous(caller : Principal) : ?Text {
        if (Principal.isAnonymous(caller)) {
            ?"Error: Anonymous callers not allowed"
        } else { null }
    };

    var admin : Principal = Principal.fromText("aaaaa-aa");

    public type Review = {
        id : Text;
        reviewer : Principal;
        reviewee : Principal;
        escrowOrderId : Text;
        rating : Nat;
        comment : Text;
        createdAt : Time.Time;
    };

    public type ReputationScore = {
        averageRating : Float;
        totalReviews : Nat;
        ratings : [Nat]; // distribution: [5star, 4star, 3star, 2star, 1star]
    };

    // ZenDB setup
    // NOTE: Update canister ID after first deploy
    let zendb_store = ZenDB.newStableStore(Principal.fromText("st3ai-hqaaa-aaaae-qkh4a-cai"), null);
    transient let db = ZenDB.launchDefaultDB(zendb_store);

    transient let ReviewSchema : ZenDB.Schema = #Record([
        ("id", #Text),
        ("reviewer", #Principal),
        ("reviewee", #Principal),
        ("escrowOrderId", #Text),
        ("rating", #Nat),
        ("comment", #Text),
        ("createdAt", #Int),
    ]);

    transient let candify : ZenDB.Candify<Review> = {
        to_blob = func(r : Review) : Blob = to_candid (r);
        from_blob = func(blob : Blob) : ?Review = from_candid (blob);
    };

    transient let reviews = switch (db.createCollection<Review>("reviews", ReviewSchema, candify, null)) {
        case (#ok(col)) col;
        case (#err(_)) { assert false; loop {} };
    };

    // ===== PUBLIC METHODS =====

    // Leave a review (after a completed deal)
    public shared ({ caller }) func leaveReview(
        id : Text,
        reviewee : Principal,
        escrowOrderId : Text,
        rating : Nat,
        comment : Text,
    ) : async Text {
        switch (assertNotAnonymous(caller)) { case (?msg) return msg; case (null) {} };

        if (rating < 1 or rating > 5) return "Error: Rating must be 1-5";
        if (caller == reviewee) return "Error: Cannot review yourself";

        // Check for duplicate review on same order
        switch (reviews.search(
            ZenDB.QueryBuilder()
                .Where("reviewer", #eq(#Principal(caller)))
                .And("escrowOrderId", #eq(#Text(escrowOrderId)))
                .Limit(1),
        )) {
            case (#ok(results)) {
                if (results.documents.size() > 0) return "Error: Already reviewed this order";
            };
            case (#err(_)) {};
        };

        let review : Review = {
            id = id;
            reviewer = caller;
            reviewee = reviewee;
            escrowOrderId = escrowOrderId;
            rating = rating;
            comment = comment;
            createdAt = Time.now();
        };

        switch (reviews.insert(review)) {
            case (#ok(_)) "Review submitted!";
            case (#err(e)) "Error: " # e;
        };
    };

    // Get all reviews for a user
    public query func getUserReviews(user : Principal) : async [Review] {
        switch (reviews.search(ZenDB.QueryBuilder())) {
            case (#ok(results)) {
                let all = Array.map<(ZenDB.Types.DocumentId, Review, [ZenDB.Types.TextMatch]), Review>(
                    results.documents,
                    func((_, r, _) : (ZenDB.Types.DocumentId, Review, [ZenDB.Types.TextMatch])) : Review = r,
                );
                Array.filter<Review>(all, func(r : Review) : Bool { r.reviewee == user });
            };
            case (#err(_)) [];
        };
    };

    // Get reputation score for a user
    public query func getReputationScore(user : Principal) : async ReputationScore {
        switch (reviews.search(ZenDB.QueryBuilder())) {
            case (#ok(results)) {
                let all = Array.map<(ZenDB.Types.DocumentId, Review, [ZenDB.Types.TextMatch]), Review>(
                    results.documents,
                    func((_, r, _) : (ZenDB.Types.DocumentId, Review, [ZenDB.Types.TextMatch])) : Review = r,
                );
                let userReviews = Array.filter<Review>(all, func(r : Review) : Bool { r.reviewee == user });

                if (userReviews.size() == 0) {
                    return { averageRating = 0.0; totalReviews = 0; ratings = [0, 0, 0, 0, 0] };
                };

                var total : Nat = 0;
                var count5 : Nat = 0;
                var count4 : Nat = 0;
                var count3 : Nat = 0;
                var count2 : Nat = 0;
                var count1 : Nat = 0;

                for (r in userReviews.vals()) {
                    total += r.rating;
                    switch (r.rating) {
                        case (5) count5 += 1;
                        case (4) count4 += 1;
                        case (3) count3 += 1;
                        case (2) count2 += 1;
                        case (1) count1 += 1;
                        case (_) {};
                    };
                };

                {
                    averageRating = Float.fromInt(total) / Float.fromInt(userReviews.size());
                    totalReviews = userReviews.size();
                    ratings = [count5, count4, count3, count2, count1];
                };
            };
            case (#err(_)) {
                { averageRating = 0.0; totalReviews = 0; ratings = [0, 0, 0, 0, 0] };
            };
        };
    };

    // ===== ADMIN =====

    public shared ({ caller }) func initAdmin() : async Text {
        if (admin != Principal.fromText("aaaaa-aa")) return "Admin already set";
        admin := caller;
        "Admin set to " # Principal.toText(caller);
    };

    public query func getAdmin() : async Principal {
        admin;
    };

    // Admin: delete inappropriate review
    public shared ({ caller }) func deleteReview(id : Text) : async Text {
        if (caller != admin) return "Error: Only admin can delete reviews";

        switch (reviews.delete(
            ZenDB.QueryBuilder().Where("id", #eq(#Text(id))),
        )) {
            case (#ok(_)) "Review deleted.";
            case (#err(e)) "Error: " # e;
        };
    };

};

Key Design Decisions:

  1. One review per escrow order
  // Duplicate check: same reviewer + same order                                                                                                                                                     
┃  reviews.search(                                                                                                                                                                                      
┃      ZenDB.QueryBuilder()                                                                                                                                                                             
┃          .Where("reviewer", #eq(#Principal(caller)))                                                                                                                                                  
┃          .And("escrowOrderId", #eq(#Text(escrowOrderId)))                                                                                                                                             
┃          .Limit(1)                                                                                                                                                                                    
┃  )                                                                                                                                                                                                    
┃                                            

A user can only review a deal they participated in, and only once. The escrowOrderId links the review to a completed transaction.
  1. Self-review prevention + 1-5 validation
 if (caller == reviewee) return "Error: Cannot review yourself";                                                                                                                                    
 if (rating < 1 or rating > 5) return "Error: Rating must be 1-5";    
  1. getReputationScore — computed on-chain
public query func getReputationScore(user : Principal) : async ReputationScore {                                                                                                                   
┃      // 1. Scan ALL reviews (could be slow at scale?)                                                                                                                                                 
┃      // 2. Filter by reviewee                                                                                                                                                                         
┃      // 3. Calculate: average + distribution [5★,4★,3★,2★,1★]                                                                                                                                         
┃  }                                                                                                                                                                                                    
┃           

This scans the entire collection. Currently fine for small datasets, but I'm worried about scalability. Should I add a materialized view or pre-computed aggregates?
  1. ZenDB Pattern

    • persistent actor for upgrade survival
    • stable let for the store, transient let for db/collections (recreated on upgrade)
    • Schema + Candify + createCollection — the mandatory ZenDB trio

Questions for the Community

  1. getReputationScore scalability — scanning all reviews on every call. Is there a better pattern in Motoko? Pre-computed counters? Trie-based aggregation?
  2. ZenDB vs HashMap — I chose ZenDB for persistence, but for a small canister like reputation, would stable var reviews : [Review] + manual serialization be simpler?
  3. RBAC pattern — I use assertNotAnonymous(caller) → ?Text and chain it in every public method. Is there a cleaner way (decorator/middleware pattern) in Motoko?
  4. General architecture feedback — anything you’d do differently?

Full project: 4 canisters (escrow state machine, marketplace with inter-canister calls, feed, reputation) + Spring Boot orchestrator + Redis caching + rate limiting. Open to all feedback!