Dotyc
Concepts

Adapters

The consumers: a minimal name + handle() contract, an optional lifecycle, guaranteed isolation — and six conventional roles that turn a wiring into an app's event backbone.

Adapters are the last stage: the consumers of your events. Official adapters cover analytics providers; custom adapters are how your own business logic subscribes to the catalog. Both implement the same interface.

The interface

The design rule: the full lifecycle is available, the minimum is sufficient. Only name and handle are required.

interface DotycAdapter {
  name: string
  handle(events: ValidatedEvent[], ctx: IngestContext): Promise<void>  // batch-aware
  setup?(): Promise<void>
  flush?(): Promise<void>
  shutdown?(): Promise<void>
  identify?(identity: Identity): Promise<void>  // identity transitions (native alias/merge)
}
  • handle receives a batch of validated events. Batch-aware by default: one POST from the emitter carries N events, and a provider adapter can forward them in one network call instead of N.
  • setup / flush / shutdown exist for serious adapters that manage buffering or connections. On serverless, flush is called at the end of the invocation.
  • identify is the dedicated channel for identity transitions — see Identity.

What an adapter can rely on

Guarantees from the dispatcher shape how adapters are written:

  • Only conforming data. Validation at ingest is strict; an event that fails its current catalog schema is never dispatched. The one nuance: events from older clients arrive validated against their schema version and flagged via event.schema.status ('legacy' or 'unknown') — check the flag if your adapter cares. See Schema evolution.
  • Total isolation. Adapters run in parallel; a throw affects neither the other adapters nor the emitter's response. Errors surface through the dispatcher's onError. Write handle naturally and let failures propagate — they are contained and reported for you.
  • Retry follows the event's delivery class. For a best-effort event, a failed handle is reported, not replayed. For a durable event, the dispatcher retries this adapter with backoff from the outbox until success or dead-letter — other adapters are unaffected. At-least-once delivery means a durable event can arrive twice: adapters with side effects should be idempotent, and every event carries a stable eventId (ULID) for exactly that.

Conventional roles

A convention, not an API: adapters tend to fall into six roles, and thinking in roles keeps a full app's wiring down to a handful of readable use() lines. An event can feed several roles at once.

RoleConsumesTypical destinations
domainbusiness facts → projections/counters in your DB (usage, quotas)your app's DB, Stripe usage records
analyticsproduct/marketing trackingPostHog, Matomo, Plausible
observabilityperformance, errors, monitoringSentry, Datadog, logs
engagementevents that trigger communication, plus identitiesResend, Loops, Customer.io, CRM
archiveeverything, raw — for future analysisS3, ClickHouse, BigQuery
audittrusted + durable events → immutable log (who did what)append-only store
debugeverything, in devconsole
wiring
  .use(usageCounter,  { tags: ['domain'] })
  .use(posthog(cfg),  { tags: ['feature', 'marketing'] })
  .use(sentryPerf,    { tags: ['trace'] })
  .use(loops(cfg),    { tags: ['lifecycle'] })
  .use(warehouse(s3))                          // archive: everything
  .use(auditLog,      { tags: ['audit'] })
Guardrail for the domain role: domain adapters do projections — counters, derived state — not event sourcing. Dotyc is not Kafka, and not your app's transactional bus. The boundary: if losing an event would break a transactional invariant of your app (not just skew a counter), that job belongs to your database or queue, not to an adapter.

Official adapters

Provider adapters ship as separate packages and are configured via a factory:

import { posthog } from '@dotyc/posthog'

wiring.use(posthog({ apiKey: env.POSTHOG_KEY }), { tags: ['feature', 'marketing'] })

PostHog and Matomo lead the roadmap; Plausible, Amplitude, Mixpanel, Segment, and a console/debug adapter are candidates. Adapter packages may also carry provider-specific niceties — @dotyc/posthog translating identify into PostHog's native identify/alias, for instance.

Client-native provider features — autocapture, session replay, heatmaps — are out of scope by design. Dotyc routes the declared events of your catalog; those capabilities live in the provider's own SDK, installed alongside.

Custom adapters: business logic on the catalog

This is the payoff of the whole architecture. A minimal adapter is a five-line object:

import type { DotycAdapter } from 'dotyc/ingest'
import { db } from '@acme/db'

export const usageCounter: DotycAdapter = {
  name: 'usage-counter',
  async handle(events) {
    for (const event of events) {
      await db.incrementAccountUsage(event.identity.accountId, event.name)
    }
  },
}

Wire it next to your analytics:

wiring.use(usageCounter, { tags: ['domain'] })

Target use cases: account usage counting, billing quotas, internal notifications, writing to your own database. Anywhere your product needs to react to the same events your analytics measure, a custom adapter keeps both on one definition — no duplicated tracking paths, no drift.

What handle() receives

Each event in the batch is a ValidatedEvent:

  • eventId — stable ULID, generated at emission: your idempotency key.
  • name, properties (validated, computed fields applied), tags.
  • identity — resolved, verified when the ingest could verify it.
  • timestamp.
  • schema: { hash, status } — with status: 'current' | 'legacy' | 'unknown'.
  • meta — such as the emitting source ('client' | 'server').

By the time it reaches an adapter, everything upstream — schema resolution, validation, identity resolution, enrichment, durable persistence — is done.

On this page