back to blog

Badge System Evolution: Event-Driven Architecture (Part 2)

Written by Namit Jain·December 14, 2025·5 min read

Badge System Evolution (Part 2)

Event-Driven Badge Evaluation

In Part 1, we built a badge system that could track progress and award achievements. It workedbut it came with a big design flaw:

Every feature had to manually update badges.

That’s not just annoying. It’s risky.


The Problem with Manual Badge Updates

Right now, badge logic is scattered all over the codebase.

app.post('/submit-quiz', (req, res) => {
  // Quiz logic...
  updateProgress(userId, "quiz_master", 1);
  updateProgress(userId, "accuracy_streak", 1);
});

This creates several problems:

  • ❌ You must remember to update badges everywhere
  • ❌ New features can accidentally skip badge logic
  • ❌ Adding new badges means editing existing business code
  • ❌ Badge logic and feature logic are tightly coupled

In short: the system depends on humans remembering thingsand that never ends well.


The Core Idea: Let Events Drive Badges

Instead of telling the badge system what to do, we tell it what happened.

“A quiz was completed.” “A user logged in.” “A referral signup occurred.”

The badge system listens to those events and decides what (if anything) should happen.

Imperative vs Event-Driven

Before (Imperative):

submitQuiz();
updateBadgeA();
updateBadgeB();
updateBadgeC();

After (Event-Driven):

submitQuiz();
emitEvent("quiz_completed");

That’s it. Everything else happens automatically.


Event-Driven Badge Flow

graph TB A[User Action] --> B[Emit Event] B --> C[Event Bus] C --> D[Badge Evaluator] D --> E{Award or Progress?} E -->|Award| F[Award Badge] E -->|Progress| G[Update Progress] F --> H[Notify User] G --> H H --> I[Persist to DB]

The feature code only emits events. The badge system handles the rest.


Why This Architecture Is Better

  1. Loose coupling Feature code knows nothing about badges.

  2. Centralized logic All badge evaluation lives in one place.

  3. Easy to extend Add new badges without touching existing features.

  4. Hard to forget Events fire automaticallybadge evaluation always happens.


Emitting Events

Whenever something meaningful happens, emit an event:

events.emit("quiz_completed", {
  userId,
  quizId,
  score: 85,
  totalQuestions: 10
});

That’s the only responsibility of the feature.


The Badge Evaluator

The badge system listens for events and evaluates relevant badges.

class BadgeEvaluator {
  constructor(db) {
    this.db = db;
    this.listeners = {};
  }

  register(eventType, badgeId, evaluator) {
    if (!this.listeners[eventType]) {
      this.listeners[eventType] = [];
    }
    this.listeners[eventType].push({ badgeId, evaluator });
  }

  async evaluateEvent(eventType, userId, data) {
    const evaluators = this.listeners[eventType] || [];

    for (const { badgeId, evaluator } of evaluators) {
      try {
        const result = await evaluator(userId, data);

        if (result.shouldAward) {
          await this.awardBadge(userId, badgeId, result.metadata);
        } else if (result.progress !== undefined) {
          await this.updateProgress(userId, badgeId, result.progress);
        }
      } catch (err) {
        console.error(`Badge failed: ${badgeId}`, err);
      }
    }
  }
}

Each badge gets its own evaluatorsmall, focused, testable.


Badge Evaluators (Examples)

First Quiz Badge

function firstQuizEvaluator(userId, eventData) {
  const completedCount = db.query(
    "SELECT COUNT(*) FROM user_quizzes WHERE user_id = ?",
    [userId]
  );

  return {
    shouldAward: completedCount === 1,
    metadata: { quizId: eventData.quizId }
  };
}

Progress Badge (Quiz Master – 10 quizzes)

function quizMasterEvaluator(userId) {
  const progress = getProgress(userId, "quiz_master") + 1;

  return {
    shouldAward: progress >= 10,
    progress: Math.min(progress, 10)
  };
}

Threshold Badge (Accuracy Expert)

function accuracyExpertEvaluator(userId, eventData) {
  const accuracy =
    (eventData.score / eventData.totalQuestions) * 100;

  return {
    shouldAward: accuracy >= 85,
    metadata: { accuracy }
  };
}

Each evaluator:

  • Does one thing
  • Knows nothing about routes or controllers
  • Can be tested in isolation

Wiring Everything Together

const evaluator = new BadgeEvaluator(db);

evaluator.register("quiz_completed", "first_quiz", firstQuizEvaluator);
evaluator.register("quiz_completed", "quiz_master", quizMasterEvaluator);
evaluator.register("quiz_completed", "accuracy_expert", accuracyExpertEvaluator);

And in your route:

app.post("/submit-quiz", async (req, res) => {
  // Quiz logic...

  await evaluator.evaluateEvent("quiz_completed", userId, {
    quizId,
    score,
    totalQuestions
  });

  res.json({ success: true });
});

No badge logic. No coupling. Clean and safe.


Advanced Patterns

Streak-Based Badges

function dailyWarriorEvaluator(userId) {
  const streak = getLoginStreak(userId);

  return {
    shouldAward: streak >= 7,
    progress: Math.min(streak, 7)
  };
}

Conditional Badges (Weekend-Only)

function weekendWarriorEvaluator() {
  const today = new Date().getDay();
  const isWeekend = today === 0 || today === 6;

  if (!isWeekend) return { shouldAward: false };

  // Weekend logic...
}

Badges can decide when not to run.


Database Adjustments

To support event-driven evaluation:

ALTER TABLE badges
ADD COLUMN trigger_events JSON;

ALTER TABLE user_badges
ADD COLUMN evaluation_metadata JSON;

Example badge definition:

INSERT INTO badges (id, name, trigger_events, target)
VALUES ('quiz_master', 'Quiz Master', '["quiz_completed"]', 10);

Performance Considerations

Multiple badges per event can be expensive.

Optimizations:

  • Cache user state per event
  • Evaluate badges in parallel
  • Batch DB writes
async evaluateEvent(eventType, userId, data) {
  const userState = await this.loadUserState(userId);

  const results = await Promise.all(
    this.getBadgesForEvent(eventType).map(badge =>
      badge.evaluate(userState, data)
    )
  );

  await this.batchPersist(userId, results);
}

Testing Becomes Easier

Unit Test (Single Badge)

test("awards first quiz badge", async () => {
  const result = await firstQuizEvaluator("user1", {
    quizId: "q1"
  });

  expect(result.shouldAward).toBe(true);
});

Integration Test (Event Flow)

test("quiz completion triggers multiple badges", async () => {
  await evaluator.evaluateEvent("quiz_completed", "user1", {
    score: 9,
    totalQuestions: 10
  });

  expect(awardBadge).toHaveBeenCalledWith("user1", "first_quiz");
});

Key Takeaways (Part 2)

  • Emit events, don’t update badges manually
  • Badge logic belongs in one place
  • Evaluators should be small and isolated
  • Events make systems safer and easier to extend
  • This architecture scales with complexity

In Part 3, we’ll build on this foundation with:

  • Badge prerequisites
  • Badge dependencies
  • Notifications and user feedback

Event-driven systems turn badges from fragile add-ons into powerful engagement tools.


This is Part 2 of a series on building scalable badge systems. Part 1 covers the foundation with basic and progress-tracking badges.