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)
}handlereceives 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/shutdownexist for serious adapters that manage buffering or connections. On serverless,flushis called at the end of the invocation.identifyis 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. Writehandlenaturally and let failures propagate — they are contained and reported for you. - Retry follows the event's delivery class. For a
best-effortevent, a failedhandleis reported, not replayed. For adurableevent, 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 stableeventId(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.
| Role | Consumes | Typical destinations |
|---|---|---|
| domain | business facts → projections/counters in your DB (usage, quotas) | your app's DB, Stripe usage records |
| analytics | product/marketing tracking | PostHog, Matomo, Plausible |
| observability | performance, errors, monitoring | Sentry, Datadog, logs |
| engagement | events that trigger communication, plus identities | Resend, Loops, Customer.io, CRM |
| archive | everything, raw — for future analysis | S3, ClickHouse, BigQuery |
| audit | trusted + durable events → immutable log (who did what) | append-only store |
| debug | everything, in dev | console |
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'] })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.
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,verifiedwhen the ingest could verify it.timestamp.schema: { hash, status }— withstatus: '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.