DEV Community

LunarDrift
LunarDrift

Posted on

The Translate Button Is Declarative; the Community Workflow Is Not

A translation button can make multilingual community chat look solved. The user clicks, translated text appears, and the implementation seems almost too easy to count as serious engineering.

Then production asks the less photogenic questions:

  • What if the message is deleted while translation is running?
  • Does a retry create two competing translations?
  • Can moderators inspect the original wording?
  • What happens when a technically correct translation misses community context?
  • Which part of the system is allowed to perform the external effect?

This tension is familiar to developers working through a high-level UI or declarative framework: if the component does most of the visible work, where is my engineering judgment?

It is in the transition rules, effect boundaries, and recovery behavior. We can express those rules as pure functions while still acknowledging that translation, persistence, and human escalation are imperative operations.

In this tutorial, we will build that boundary for a Tencent RTC Social Messaging experience. Tencent RTC describes social messaging scenarios including 1-to-1 chat, group discussion, communities, rich media, and live-room chat: https://trtc.io/solutions/social-messaging

TUIChat also supports on-demand text-message translation, subject to the content types, languages, and edition limits documented for the integration: https://trtc.io/document/60772

We will not invent an SDK method or replace the official setup instructions. Instead, we will build the application-owned workflow surrounding that documented translation capability.

The behavior contract

Our design has six rules:

Situation Required behavior
A member requests translation Record the request before starting the external operation
The source is not eligible Reject locally without invoking translation
Translation fails Keep the original visible and offer an explicit retry
The source changes or disappears Discard the late result
Meaning is disputed Preserve the machine output and open human review
A correction is supplied Label it as a human correction rather than rewriting history

Translation is reader-initiated. The application does not automatically send every community message for processing.

Moderation and translation are also separate decisions. Translating a blocked message must not become a way to bypass the source-message moderation policy.

Use a functional core around imperative work

The architecture is a small event-driven pipeline:

user command
    |
    v
pure decision function
    |
    v
append domain event
    |
    v
imperative worker --> TUIChat translation integration
    |
    v
append result event
    |
    v
pure projection --> visible UI state
Enter fullscreen mode Exit fullscreen mode

The pure code answers questions such as “is this request valid?” and “what should the UI show?”

The imperative shell performs operations that can fail:

  • persisting an event;
  • invoking translation;
  • reading the current message revision;
  • notifying a moderator;
  • retrying work.

That separation is more useful than arguing whether the application is “functional” or “imperative.” It tells us exactly where nondeterminism enters the system.

Create the local project

The workflow can be reproduced without a live chat environment first:

mkdir community-translation-boundary
cd community-translation-boundary
npm init -y
npm install -D typescript tsx vitest @types/node
npx tsc --init
mkdir -p src test
Enter fullscreen mode Exit fullscreen mode

Add these scripts to package.json:

{
  "scripts": {
    "test": "vitest run",
    "demo": "tsx src/demo.ts"
  }
}
Enter fullscreen mode Exit fullscreen mode

Represent provenance, not just translated text

Create src/domain.ts:

export type ModerationVerdict = "allowed" | "blocked" | "pending";

export type MessageSnapshot = {
  messageId: string;
  revision: number;
  kind: "text" | "other";
  text: string;
  moderation: ModerationVerdict;
  deleted: boolean;
};

export type TranslationEvent =
  | {
      type: "TranslationRequested";
      requestId: string;
      messageId: string;
      sourceRevision: number;
      targetLanguage: string;
      requestedBy: string;
    }
  | { type: "TranslationStarted"; requestId: string }
  | {
      type: "TranslationCompleted";
      requestId: string;
      translatedText: string;
    }
  | {
      type: "TranslationFailed";
      requestId: string;
      reason: "provider_unavailable" | "unsupported" | "unknown";
    }
  | { type: "TranslationDiscardedAsStale"; requestId: string }
  | {
      type: "HumanReviewRequested";
      requestId: string;
      requestedBy: string;
      note?: string;
    }
  | {
      type: "HumanCorrectionAdded";
      requestId: string;
      correctedText: string;
      reviewerId: string;
    };

export type TranslationView = {
  requestId?: string;
  status:
    | "idle"
    | "queued"
    | "running"
    | "ready"
    | "failed"
    | "stale"
    | "under_review";
  machineText?: string;
  humanCorrection?: string;
  failureReason?: string;
};

export function project(events: TranslationEvent[]): TranslationView {
  return events.reduce<TranslationView>((view, event) => {
    switch (event.type) {
      case "TranslationRequested":
        return { requestId: event.requestId, status: "queued" };
      case "TranslationStarted":
        return { ...view, status: "running" };
      case "TranslationCompleted":
        return {
          ...view,
          status: "ready",
          machineText: event.translatedText
        };
      case "TranslationFailed":
        return {
          ...view,
          status: "failed",
          failureReason: event.reason
        };
      case "TranslationDiscardedAsStale":
        return { ...view, status: "stale" };
      case "HumanReviewRequested":
        return { ...view, status: "under_review" };
      case "HumanCorrectionAdded":
        return {
          ...view,
          status: "ready",
          humanCorrection: event.correctedText
        };
    }
  }, { status: "idle" });
}
Enter fullscreen mode Exit fullscreen mode

The source message is deliberately absent from TranslationView. It remains in the chat record and is rendered independently. A translation is an additional view, never a replacement.

That distinction matters for moderators, bilingual readers, and anyone challenging a translation.

Admit requests with a pure decision

Add this to src/domain.ts:

export type TranslationPolicy = {
  supportedLanguages: ReadonlySet<string>;
  translationEnabled: boolean;
};

export type RequestTranslation = {
  requestId: string;
  targetLanguage: string;
  requestedBy: string;
};

export type Decision =
  | { accepted: true; event: TranslationEvent }
  | {
      accepted: false;
      reason:
        | "translation_disabled"
        | "message_deleted"
        | "text_only"
        | "moderation_not_allowed"
        | "unsupported_language";
    };

export function decideTranslationRequest(
  message: MessageSnapshot,
  command: RequestTranslation,
  policy: TranslationPolicy
): Decision {
  if (!policy.translationEnabled) {
    return { accepted: false, reason: "translation_disabled" };
  }

  if (message.deleted) {
    return { accepted: false, reason: "message_deleted" };
  }

  if (message.kind !== "text") {
    return { accepted: false, reason: "text_only" };
  }

  if (message.moderation !== "allowed") {
    return { accepted: false, reason: "moderation_not_allowed" };
  }

  if (!policy.supportedLanguages.has(command.targetLanguage)) {
    return { accepted: false, reason: "unsupported_language" };
  }

  return {
    accepted: true,
    event: {
      type: "TranslationRequested",
      requestId: command.requestId,
      messageId: message.messageId,
      sourceRevision: message.revision,
      targetLanguage: command.targetLanguage,
      requestedBy: command.requestedBy
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

Do not treat the test language set in this tutorial as Tencent RTC's current support matrix. Populate production configuration from the languages, content restrictions, and edition requirements listed in the official TUIChat translation documentation.

Put TUIChat behind an application port

Create src/worker.ts:

import type {
  MessageSnapshot,
  TranslationEvent
} from "./domain.js";

export interface TranslationPort {
  translate(input: {
    requestId: string;
    text: string;
    targetLanguage: string;
  }): Promise<{ translatedText: string }>;
}

export interface MessageReader {
  get(messageId: string): Promise<MessageSnapshot | undefined>;
}

export interface EventWriter {
  appendOnce(
    idempotencyKey: string,
    event: TranslationEvent
  ): Promise<boolean>;
}

export async function runTranslation(
  requested: Extract<TranslationEvent, { type: "TranslationRequested" }>,
  messages: MessageReader,
  translator: TranslationPort,
  events: EventWriter
): Promise<void> {
  const claimed = await events.appendOnce(
    `started:${requested.requestId}`,
    { type: "TranslationStarted", requestId: requested.requestId }
  );

  // Another worker already owns or completed this request.
  if (!claimed) return;

  const source = await messages.get(requested.messageId);

  if (
    !source ||
    source.deleted ||
    source.revision !== requested.sourceRevision
  ) {
    await events.appendOnce(`stale:${requested.requestId}`, {
      type: "TranslationDiscardedAsStale",
      requestId: requested.requestId
    });
    return;
  }

  try {
    const result = await translator.translate({
      requestId: requested.requestId,
      text: source.text,
      targetLanguage: requested.targetLanguage
    });

    // Re-read after the external operation.
    const current = await messages.get(requested.messageId);

    if (
      !current ||
      current.deleted ||
      current.revision !== requested.sourceRevision
    ) {
      await events.appendOnce(`stale:${requested.requestId}`, {
        type: "TranslationDiscardedAsStale",
        requestId: requested.requestId
      });
      return;
    }

    await events.appendOnce(`completed:${requested.requestId}`, {
      type: "TranslationCompleted",
      requestId: requested.requestId,
      translatedText: result.translatedText
    });
  } catch {
    await events.appendOnce(`failed:${requested.requestId}`, {
      type: "TranslationFailed",
      requestId: requested.requestId,
      reason: "provider_unavailable"
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

TranslationPort is our interface, not a claimed Tencent RTC API name.

For production, use the setup and invocation path documented for your TUIChat platform and version, then normalize its successful and failed outcomes into this port. Keep eligibility checks aligned with the official documentation rather than hard-coding assumptions from this sample.

If your chosen TUIChat integration owns the translation UI internally, the same domain model can govern the surrounding controls: whether the action is offered, what fallback is shown, and how human review is recorded.

Reproduce the stale-result race

Create test/translation.test.ts:

import { describe, expect, it } from "vitest";
import {
  decideTranslationRequest,
  project,
  type MessageSnapshot,
  type TranslationEvent
} from "../src/domain.js";
import { runTranslation } from "../src/worker.js";

describe("community translation boundary", () => {
  it("does not send a blocked message for translation", () => {
    const message: MessageSnapshot = {
      messageId: "m-1",
      revision: 1,
      kind: "text",
      text: "source text",
      moderation: "blocked",
      deleted: false
    };

    const decision = decideTranslationRequest(
      message,
      {
        requestId: "r-1",
        targetLanguage: "es",
        requestedBy: "member-7"
      },
      {
        translationEnabled: true,
        supportedLanguages: new Set(["es"])
      }
    );

    expect(decision).toEqual({
      accepted: false,
      reason: "moderation_not_allowed"
    });
  });

  it("discards a result when the source changes in flight", async () => {
    let message: MessageSnapshot = {
      messageId: "m-2",
      revision: 1,
      kind: "text",
      text: "original",
      moderation: "allowed",
      deleted: false
    };

    const recorded: TranslationEvent[] = [];
    const keys = new Set<string>();

    await runTranslation(
      {
        type: "TranslationRequested",
        requestId: "r-2",
        messageId: "m-2",
        sourceRevision: 1,
        targetLanguage: "es",
        requestedBy: "member-7"
      },
      {
        async get() {
          return message;
        }
      },
      {
        async translate() {
          message = { ...message, revision: 2, text: "edited" };
          return { translatedText: "resultado antiguo" };
        }
      },
      {
        async appendOnce(key, event) {
          if (keys.has(key)) return false;
          keys.add(key);
          recorded.push(event);
          return true;
        }
      }
    );

    expect(recorded.map(event => event.type)).toEqual([
      "TranslationStarted",
      "TranslationDiscardedAsStale"
    ]);

    expect(project(recorded).status).toBe("stale");
  });
});
Enter fullscreen mode Exit fullscreen mode

Run it:

npm test
Enter fullscreen mode Exit fullscreen mode

The second test is more valuable than a happy-path screenshot. It proves that a late result cannot attach itself to a different source revision.

In a database-backed event store, enforce appendOnce with a unique constraint on the idempotency key. An in-memory Set is sufficient only for this local reproduction.

Make the human handoff visible

Machine translation demonstrates useful message conversion. It does not demonstrate reliable understanding of sarcasm, local terminology, moderation intent, or a community's social history.

Do not hide that limitation behind a generic “AI-powered” label. Give readers two controls next to the translated view:

  1. Show original — always available.
  2. Request human review — records who requested review and why.

A review command can append this event:

const reviewEvent: TranslationEvent = {
  type: "HumanReviewRequested",
  requestId: "r-2",
  requestedBy: "member-7",
  note: "This phrase is a project name, not an instruction."
};
Enter fullscreen mode Exit fullscreen mode

The review UI should display machine output and any human correction as separate fields. Do not silently replace one with the other.

Also avoid sending the entire surrounding conversation to an assistant by default. If reviewers need context, let a person deliberately select the relevant messages and show what will be shared. Translation consent is not automatic consent to export a complete thread into another processing workflow.

Failure behavior belongs in the interface

Translation is temporarily unavailable

Keep the original message visible. Render a retry action, but create a new request ID for the retry so operators can distinguish attempts.

The target language is unsupported

Reject before invoking the external boundary. Refresh the configured language allowlist when the product documentation or your enabled edition changes.

Two workers receive the same request

Only one TranslationStarted event should win. A durable unique idempotency key prevents duplicate effects.

The process stops after translation succeeds

If it crashes before recording TranslationCompleted, the outcome is uncertain. Do not blindly present a second result as though nothing happened. Prefer an integration-supported idempotency mechanism when available; otherwise record the retry as a separate attempt and keep the ambiguity observable.

A moderator blocks the source during translation

Revision checks alone may not capture a moderation change. In production, re-read both source revision and current moderation verdict before publishing the result.

Human reviewers disagree

Do not reduce this to last-write-wins. Store corrections with reviewer identity and review status, then let your community policy determine who can resolve the dispute.

Release checklist

Before connecting this workflow to a real community, verify that:

  • [ ] Translation is initiated by a visible member action.
  • [ ] The original message remains accessible.
  • [ ] Blocked, pending, deleted, and unsupported content never reaches the translation effect.
  • [ ] Supported languages and edition requirements match the current TUIChat documentation.
  • [ ] Duplicate workers cannot start duplicate requests silently.
  • [ ] A result is checked against the current source before display.
  • [ ] Failure does not remove or overwrite the original.
  • [ ] Machine output is labelled distinctly from a human correction.
  • [ ] Members can request human review.
  • [ ] Context beyond the selected message is not shared implicitly.
  • [ ] Logs contain request IDs and state transitions, not unnecessary message content.

The skill is owning the boundary

Using a component, SDK, or declarative UI does not make the work less real. Reimplementing translation from scratch would not automatically make it more rigorous.

The durable engineering skill is deciding which facts are authoritative, which operations may fail, and what users see when reality does not follow callback order. Pure functions make those decisions reviewable. Imperative adapters connect them to the world. A reliable social product needs both.


Disclosure: I have a relationship with Tencent RTC. Official Tencent RTC documentation was used as the implementation reference for this article.

Top comments (0)