Skip to content

Feature: opt-in emitEvents per entity — generate a transactional outbox + lifecycle EntitySubscriber #79

Description

@cultron

Summary

Add an opt-in, per-entity emitEvents capability to .apsorc that makes the generator emit a transactional outbox + a TypeORM EntitySubscriber (and a relay scaffold), so every create/update/delete on that entity writes a durable event in the same DB transaction as the state change — a generated "no silent writes" guarantee.

{
  "name": "Product",
  "emitEvents": true,        // <-- opt in (per entity; or a global default)
  "fields": [ ... ]
}

Motivation

"Every state change emits an event, and the event stream is complete" is a cross-cutting invariant for event-driven / payments / audit systems. Doing it correctly requires the transactional outbox pattern (the dual-write problem: a naive post-commit publish can be lost if the process dies between commit and publish — unacceptable for a ledger).

Today each service hand-rolls this: an outbox table, a TypeORM subscriber or a NestJS interceptor, and a relay. We've built exactly that by hand in a NestJS + TypeORM service and will need the same spine in several more services in the same codebase. It's generic plumbing that belongs in the generator, the same way CRUD/DTOs/idempotency do.

Critically, the durability lever is same-transaction emission, which a generated EntitySubscriber can do cleanly (its afterInsert/afterUpdate/afterRemove hooks receive event.manager — the active transaction's manager) but hand-written HTTP interceptors cannot (they fire post-commit at the request boundary).

Proposed generation

When an entity opts in (emitEvents: true), generate (into autogen/, so it regenerates):

  1. An OutboxEvent table (once per service): id, type (e.g. entity.action), payload (json), status (pending/published/failed), attempts, created_at, publishedAt (nullable timestamptz).
  2. A generic EntitySubscriber scoped to the opted-in entities. In afterInsert/afterUpdate/afterRemove it writes an OutboxEvent row via event.manager (same transaction), deriving type from the entity name + operation and the payload from the entity. It must skip its own bookkeeping tables (the outbox table, etc.) to avoid recursion.
  3. A relay stub that publishes pending rows (left as an extension point — delivery/signing/retry is app-specific).

Keep app semantics out of the generator (extension points)

The mechanism is generic; the contract is not. Please don't hardcode event naming/payload shape. Expose hooks so the app maps the generic CRUD event to its own contract, e.g.:

  • a configurable type format (default entity.action; let the app override the taxonomy),
  • a payload transform hook (e.g. to serialize to a public/Stripe-shaped object with prefixed ids),
  • the relay/delivery left to the app.

In our case the generated outbox + subscriber would be the durable spine, and our extension layer would supply the Stripe-shaped naming/serialization and the webhook delivery.

Design considerations / open questions

  • Granularity: per-entity flag vs a global emitEvents default with per-entity opt-out.
  • Recursion guard: the subscriber must ignore the outbox table and any idempotency/bookkeeping tables.
  • Dedup: events should carry a stable id so consumers can dedupe (at-least-once delivery).
  • Non-entity-manager writes: raw QueryBuilder deletes/updates bypass subscribers — document that, or also offer the interceptor variant for request-context enrichment.
  • Multi-DB / multiple DataSources: subscriber registration per DataSource.

Prior art / why us

This mirrors the standard transactional-outbox pattern (Debezium/outbox, microservice patterns). We'd happily migrate our hand-rolled NestJS+TypeORM spine (interceptor + outbox + relay) onto a generated capability and dogfood it.

Environment

  • @apso/cli/0.16.1
  • language: typescript (NestJS + TypeORM)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions