DEV Community

Noah Chen
Noah Chen

Posted on

Modeling Product Feedback as a State Machine in TypeScript

A small domain model for keeping feedback status, roadmap visibility, release events, and customer notifications consistent.

Product feedback looks like a list until people begin making decisions about it.

A new request is reviewed. A reviewed problem may be planned, declined, or left open for more evidence. Planned work becomes active. A shipped change needs a release note and a notification to the people who cared about it.

If these transitions are implemented as arbitrary string updates, contradictory states appear quickly:

  • a declined request remains on the “In Progress” roadmap;
  • an item moves from shipped back to new without explanation;
  • users are notified before a release is public;
  • two processes write different statuses for the same request;
  • a popular request is treated as automatically planned.

A small state machine makes those rules explicit. This article builds one in TypeScript without adding a state-machine library.

Define States Around Decisions

The first temptation is to mirror every UI label. A better starting point is to model meaningful product decisions.

export type FeedbackStatus =
  | "new"
  | "reviewing"
  | "planned"
  | "in_progress"
  | "shipped"
  | "declined";
Enter fullscreen mode Exit fullscreen mode

These states answer different questions:

  • new: captured but not triaged;
  • reviewing: understood well enough to evaluate;
  • planned: accepted as intended work, without necessarily promising a date;
  • in_progress: active delivery work exists;
  • shipped: a released change addresses the problem;
  • declined: the team made a decision not to pursue it.

The names are less important than their meaning. If two teammates interpret planned differently, TypeScript cannot save the roadmap.

Document the contract first: which decision each state communicates internally and publicly.

Make Legal Transitions Explicit

Not every move should be allowed.

const allowedTransitions: Record<
  FeedbackStatus,
  readonly FeedbackStatus[]
> = {
  new: ["reviewing", "declined"],
  reviewing: ["planned", "declined"],
  planned: ["reviewing", "in_progress", "declined"],
  in_progress: ["planned", "shipped"],
  shipped: [],
  declined: ["reviewing"],
};

export function canTransition(
  from: FeedbackStatus,
  to: FeedbackStatus,
): boolean {
  return allowedTransitions[from].includes(to);
}
Enter fullscreen mode Exit fullscreen mode

This is deliberately conservative.

A declined request can reopen when new evidence appears. Planned work can return to review if assumptions change. Work in progress can return to planned if delivery pauses.

shipped is terminal in this model. That does not mean software can never regress. It means a shipped outcome remains historical fact. If a regression or new problem appears, create a new record rather than rewriting history.

Your product may need different rules. The important thing is to make exceptions deliberate instead of allowing any status string to replace any other.

A Transition Needs Context

Changing a status is a product decision, not only a database update.

export interface TransitionCommand {
  feedbackId: string;
  from: FeedbackStatus;
  to: FeedbackStatus;
  actorId: string;
  reason: string;
  occurredAt: Date;
  releaseId?: string;
}
Enter fullscreen mode Exit fullscreen mode

The reason field preserves why the decision changed. It can feed an internal audit log and a shorter public explanation.

The optional releaseId becomes required when something moves to shipped. That rule can live in validation:

export class InvalidFeedbackTransition extends Error {}

export function validateTransition(command: TransitionCommand): void {
  if (!canTransition(command.from, command.to)) {
    throw new InvalidFeedbackTransition(
      `Cannot move feedback from ${command.from} to ${command.to}`,
    );
  }

  if (!command.reason.trim()) {
    throw new InvalidFeedbackTransition(
      "A status change requires a reason",
    );
  }

  if (command.to === "shipped" && !command.releaseId) {
    throw new InvalidFeedbackTransition(
      "Shipped feedback must reference a release",
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Requiring a reason may feel strict for a small team. In practice, a single sentence is enough. The small cost now prevents a much larger reconstruction later.

Keep Votes Out of the Transition Rules

Votes are evidence, not authority.

This is an anti-pattern:

if (feedback.voteCount >= 20) {
  feedback.status = "planned";
}
Enter fullscreen mode Exit fullscreen mode

The rule ignores customer segment, severity, strategy, cost, confidence, and the possibility that several high-vote requests describe the same underlying problem.

Votes belong in the decision context:

export interface FeedbackEvidence {
  voteCount: number;
  affectedAccountIds: string[];
  segments: string[];
  examples: Array<{
    source: "email" | "support" | "discord" | "github" | "board";
    summary: string;
  }>;
}
Enter fullscreen mode Exit fullscreen mode

An application service can present this evidence to the decision-maker. It should not silently turn popularity into a roadmap promise.

Separate Persistence From Domain Logic

The state machine should not know whether data lives in Postgres, a document database, or an API.

export interface FeedbackRecord {
  id: string;
  status: FeedbackStatus;
  version: number;
}

export interface FeedbackRepository {
  getById(id: string): Promise<FeedbackRecord | null>;
  save(
    record: FeedbackRecord,
    expectedVersion: number,
  ): Promise<void>;
}
Enter fullscreen mode Exit fullscreen mode

The version enables optimistic concurrency. Without it, two people can review the same request, make different decisions, and overwrite each other.

export interface DomainEvent {
  type: string;
  aggregateId: string;
  occurredAt: Date;
  payload: Record<string, unknown>;
}

export interface EventOutbox {
  append(event: DomainEvent): Promise<void>;
}
Enter fullscreen mode Exit fullscreen mode

Now the application service can enforce transitions and record an event:

export async function transitionFeedback(
  command: Omit<TransitionCommand, "from">,
  repository: FeedbackRepository,
  outbox: EventOutbox,
): Promise<void> {
  const current = await repository.getById(command.feedbackId);

  if (!current) {
    throw new Error("Feedback record not found");
  }

  const transition: TransitionCommand = {
    ...command,
    from: current.status,
  };

  validateTransition(transition);

  await repository.save(
    {
      ...current,
      status: command.to,
      version: current.version + 1,
    },
    current.version,
  );

  await outbox.append({
    type: "feedback.status_changed",
    aggregateId: current.id,
    occurredAt: command.occurredAt,
    payload: {
      from: current.status,
      to: command.to,
      actorId: command.actorId,
      reason: command.reason,
      releaseId: command.releaseId,
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

In production, the record update and outbox insert should share one database transaction. Otherwise a process can save the new state and crash before creating the event.

Derive Roadmap Visibility Instead of Storing It Twice

If roadmap columns directly mirror feedback status, derive them from the state machine.

export type RoadmapColumn =
  | "open"
  | "planned"
  | "in_progress"
  | "shipped";

export function roadmapColumnFor(
  status: FeedbackStatus,
): RoadmapColumn | null {
  switch (status) {
    case "reviewing":
      return "open";
    case "planned":
      return "planned";
    case "in_progress":
      return "in_progress";
    case "shipped":
      return "shipped";
    case "new":
    case "declined":
      return null;
  }
}
Enter fullscreen mode Exit fullscreen mode

This prevents the roadmap and feedback record from disagreeing.

Some products need a separate visibility setting. A private customer request might be planned without appearing publicly. In that case, store visibility as an independent decision:

export interface PublicFeedbackView {
  isPublic: boolean;
  publicTitle?: string;
  publicExplanation?: string;
}
Enter fullscreen mode Exit fullscreen mode

Do not overload status to represent privacy.

Notify on a Published Outcome, Not Just a Status

Moving feedback to shipped does not necessarily mean users should receive an email immediately. The team may still be preparing documentation or a changelog entry.

Model publication as a separate event:

export interface ChangelogPublished {
  type: "changelog.published";
  aggregateId: string;
  occurredAt: Date;
  payload: {
    releaseId: string;
    feedbackIds: string[];
  };
}
Enter fullscreen mode Exit fullscreen mode

A notification worker can consume that event:

export async function handleChangelogPublished(
  event: ChangelogPublished,
  subscriptions: SubscriptionRepository,
  notifications: NotificationQueue,
): Promise<void> {
  const recipients = await subscriptions.listUniqueRecipients(
    event.payload.feedbackIds,
  );

  for (const recipient of recipients) {
    await notifications.enqueue({
      deduplicationKey:
        `${event.payload.releaseId}:${recipient.userId}`,
      userId: recipient.userId,
      releaseId: event.payload.releaseId,
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

The deduplication key matters when one person voted on several requests addressed by the same release. It also makes retrying the event consumer safer.

Add Invariants Before Adding More States

Tests should describe behavior, not merely lines of code.

import { describe, expect, it } from "vitest";

describe("feedback transitions", () => {
  it("does not let popularity create a commitment", () => {
    expect(canTransition("new", "planned")).toBe(false);
  });

  it("lets new evidence reopen a declined problem", () => {
    expect(canTransition("declined", "reviewing")).toBe(true);
  });

  it("preserves shipped outcomes as history", () => {
    expect(canTransition("shipped", "in_progress")).toBe(false);
  });

  it("requires a release before marking feedback shipped", () => {
    expect(() =>
      validateTransition({
        feedbackId: "fb_123",
        from: "in_progress",
        to: "shipped",
        actorId: "user_42",
        reason: "Released scheduled reports",
        occurredAt: new Date(),
      }),
    ).toThrow("must reference a release");
  });
});
Enter fullscreen mode Exit fullscreen mode

Useful invariants include:

  • every status change records an actor and reason;
  • shipped feedback references a release;
  • a public roadmap view is derived consistently;
  • publishing one release sends at most one notification per person;
  • duplicate requests retain their original authors and evidence;
  • votes never trigger a product commitment automatically.

These rules provide more value than adding a dozen finely named states.

Build It or Adopt It?

This model is small enough to implement internally. The question is whether the surrounding workflow is also something your team wants to own.

A production feedback system eventually needs authentication, public and private boards, voting, comments, duplicate handling, roadmap views, changelog publishing, subscriptions, email delivery, moderation, and export.

If your requirements are narrow, building only the state machine may be the right choice. A spreadsheet may be even better when volume is low.

I have been evaluating FeedLog as one existing implementation of the broader workflow. It connects feedback boards, roadmap states, releases, and notifications, while keeping its AI-assisted duplicate detection optional.

The project is self-hostable, which is useful only if your team accepts the operational responsibility. You still need to manage PostgreSQL, backups, upgrades, authentication settings, storage, and an email provider for real notifications.

For developers, the better evaluation surface is FeedLog’s GitHub repository. The code and deployment configuration show what the application currently implements and which services it expects, rather than asking you to infer the architecture from a feature page.

Keep the Machine Smaller Than the Problem

A state machine is a tool for making decisions explicit. It should not become a substitute for product judgment.

Start with the few states that change what the team communicates or does next. Require context for transitions. Keep votes in the evidence layer. Derive public views where possible. Emit reliable events for the work that must follow a decision.

Most importantly, model the complete loop.

Capturing a request without preserving the decision creates a list. Recording a decision without connecting a release creates a roadmap. Shipping a release without notifying the original participants leaves the relationship unfinished.

The state machine earns its complexity when it helps the team move from evidence to decision to outcome without losing the people who supplied the evidence.

Top comments (0)