VinniZP avatar

target-be-architecture

Target backend architecture for Lingx. CQRS-lite with event-driven patterns, module organization, an

提供方 VinniZP|开源

Lingx Backend Architecture

CQRS-lite + Event-Driven architecture for scalable translation management.

Why CQRS-lite for Lingx?

ChallengeCQRS Solution
Read/Write asymmetryWorkbench reads many translations, writes one at a time
Async operationsAI translation, imports, webhooks via EventBus
Real-time collaborationEvents broadcast changes to connected users
Audit trailEvents capture who changed what and when

Directory Structure

apps/api/src/
├── modules/                    # Domain modules
│   ├── project/
│   │   ├── commands/           # CreateProject, UpdateProject
│   │   ├── queries/            # GetProject, ListProjects
│   │   ├── events/             # ProjectCreated, ProjectUpdated
│   │   ├── handlers/           # Command & Event handlers
│   │   └── project.repository.ts
│   ├── translation/
│   │   ├── commands/           # UpdateTranslation, BulkImport
│   │   ├── queries/            # GetTranslations, SearchKeys
│   │   ├── events/             # TranslationUpdated, KeyCreated
│   │   ├── handlers/
│   │   └── translation.repository.ts
│   └── collaboration/          # Real-time features
│       ├── commands/           # JoinRoom, FocusKey
│       ├── queries/            # GetPresence
│       ├── events/             # UserJoined, UserFocused
│       └── handlers/
├── shared/
│   ├── cqrs/                   # Bus implementations
│   │   ├── command-bus.ts
│   │   ├── query-bus.ts
│   │   └── event-bus.ts
│   ├── domain/                 # Base classes, errors
│   └── container/              # Awilix DI setup
├── routes/                     # Thin HTTP layer
├── workers/                    # Event consumers (BullMQ)
└── websocket/                  # Real-time connections

Core Concepts

Command/Query Separation

┌─────────────────────────────────────────────────────────────┐
│  HTTP Request                                                │
└─────────────────────┬───────────────────────────────────────┘
                      │
        ┌─────────────┴─────────────┐
        ▼                           ▼
┌───────────────┐           ┌───────────────┐
│   Command     │           │    Query      │
│   (Write)     │           │    (Read)     │
└───────┬───────┘           └───────┬───────┘
        │                           │
        ▼                           ▼
┌───────────────┐           ┌───────────────┐
│ CommandHandler│           │ QueryHandler  │
│ - Validate    │           │ - Fetch data  │
│ - Execute     │           │ - Transform   │
│ - Emit Event  │           │ - Cache       │
└───────┬───────┘           └───────────────┘
        │
        ▼
┌───────────────┐
│   EventBus    │ ──► Real-time sync
│               │ ──► Audit log
│               │ ──► Webhooks
└───────────────┘

Data Flow

  1. Route receives HTTP request, validates input
  2. Route creates Command or Query object
  3. Bus dispatches to appropriate Handler
  4. Handler executes logic via Repository
  5. Handler emits Event (commands only)
  6. EventBus triggers side effects

Quick Reference

Command Pattern

// modules/translation/commands/update-translation.command.ts
export class UpdateTranslationCommand {
  constructor(
    public readonly keyId: string,
    public readonly language: string,
    public readonly value: string,
    public readonly userId: string
  ) {}
}

// modules/translation/handlers/update-translation.handler.ts
export class UpdateTranslationHandler implements ICommandHandler<UpdateTranslationCommand> {
  constructor(
    private repo: TranslationRepository,
    private eventBus: EventBus
  ) {}

  async execute(cmd: UpdateTranslationCommand): Promise<Translation> {
    const translation = await this.repo.update({
      keyId: cmd.keyId,
      language: cmd.language,
      value: cmd.value,
    });

    await this.eventBus.publish(new TranslationUpdatedEvent(translation, cmd.userId));

    return translation;
  }
}

Query Pattern

// modules/translation/queries/get-translations.query.ts
export class GetTranslationsQuery {
  constructor(
    public readonly branchId: string,
    public readonly options: { search?: string; page?: number }
  ) {}
}

// modules/translation/handlers/get-translations.handler.ts
export class GetTranslationsHandler implements IQueryHandler<GetTranslationsQuery> {
  constructor(private repo: TranslationRepository) {}

  async execute(query: GetTranslationsQuery): Promise<PaginatedResult<Translation>> {
    return this.repo.findByBranch(query.branchId, query.options);
  }
}

Event Pattern

// modules/translation/events/translation-updated.event.ts
export class TranslationUpdatedEvent implements IDomainEvent {
  public readonly occurredAt = new Date();

  constructor(
    public readonly translation: Translation,
    public readonly userId: string
  ) {}
}

// Event handlers (side effects)
// - RealTimeSyncHandler: Broadcast via WebSocket
// - AuditLogHandler: Record change history
// - WebhookHandler: Notify external systems

Thin Route

// routes/translations/handlers.ts
export function createTranslationHandlers(container: AwilixContainer) {
  return {
    async updateTranslation(request: FastifyRequest, reply: FastifyReply) {
      const commandBus = container.resolve<CommandBus>('commandBus');

      const result = await commandBus.execute(
        new UpdateTranslationCommand(
          request.params.keyId,
          request.body.language,
          request.body.value,
          request.userId
        )
      );

      return toTranslationDto(result);
    },
  };
}

Decision Tree

Where does this code belong?

Is it a write operation that changes state?
  └─ Command + CommandHandler

Is it a read operation returning data?
  └─ Query + QueryHandler

Is it a side effect of a write (notify, log, sync)?
  └─ Event + EventHandler

Is it HTTP parsing/response formatting?
  └─ Route handlers

Is it database access?
  └─ Repository

Documentation

DocumentPurpose
cqrs-overview.mdCQRS-lite concepts
commands.mdCommand patterns
queries.mdQuery patterns
events.mdEvent patterns, real-time
modules.mdModule organization
routes.mdThin HTTP layer
repositories.mdData access
dependency-injection.mdAwilix setup
migration-guide.mdService → CQRS migration
error-handling.mdError flow and patterns
events-best-practices.mdEvent design guidelines

Anti-Patterns

❌ Business logic in routes

// BAD
app.post('/translations', async (request) => {
  const existing = await prisma.key.findUnique({ where: { id } });
  if (!existing) throw new Error('Key not found');
  const result = await prisma.translation.update({ ... });
  await redis.publish('translation:updated', result); // Side effect in route
  return result;
});

✅ Route delegates to command

// GOOD
app.post('/translations', async (request) => {
  const result = await commandBus.execute(
    new UpdateTranslationCommand(request.params.id, request.body)
  );
  return toDto(result);
});

❌ Handler without event emission

// BAD - No way to react to changes
async execute(cmd: UpdateTranslationCommand) {
  return this.repo.update(cmd);
}

✅ Handler emits event

// GOOD - Enables real-time sync, audit, webhooks
async execute(cmd: UpdateTranslationCommand) {
  const result = await this.repo.update(cmd);
  await this.eventBus.publish(new TranslationUpdatedEvent(result, cmd.userId));
  return result;
}

Real-time Collaboration

Events enable real-time features:

// Event handlers for real-time sync
@EventHandler(TranslationUpdatedEvent)
export class RealTimeSyncHandler {
  constructor(private wsServer: WebSocketServer) {}

  async handle(event: TranslationUpdatedEvent) {
    this.wsServer.broadcast(`branch:${event.translation.branchId}`, {
      type: 'translation:updated',
      data: event.translation,
    });
  }
}

@EventHandler(UserFocusedKeyEvent)
export class PresenceSyncHandler {
  async handle(event: UserFocusedKeyEvent) {
    this.wsServer.broadcast(`branch:${event.branchId}`, {
      type: 'presence:focus',
      userId: event.userId,
      keyId: event.keyId,
    });
  }
}

Benefits

  1. Testable - Mock buses in tests, no HTTP/database setup
  2. Scalable - Events can be processed by workers
  3. Real-time ready - Events broadcast changes instantly
  4. Auditable - Events capture full history
  5. Decoupled - Handlers don't know about each other

Sources: