Wiring
Registered consumers plus declarative matching on tags, events, and not — one event, N consumers, zero call-site coupling.
Wiring is Dotyc's differentiating stage: an event does not have one destination, it has consumers. You do not write an event → adapters table; you register consumers with match criteria, and every ingested event is dispatched to all that match.
import { createWiring } from 'dotyc/ingest'
import { posthog } from '@dotyc/posthog'
const wiring = createWiring(catalog)
.use(posthog({ apiKey: env.POSTHOG_KEY }), { tags: ['feature', 'marketing'] })
.use(usageCounter, { events: ['feature_used'] })
.use(traceLogger, { tags: ['trace'], not: { events: ['heartbeat'] } })
.use(debugLog) // no criteria = everythingDeclarative matchers, no predicates
Matchers are 100% declarative: tags, events, and not for exclusions. There are no predicate functions by design — routing that is pure data is serializable and introspectable, which means the routing table of your product can be read, reviewed, and even generated into documentation. A matches: (event) => … closure would make the wiring opaque forever.
Both tags and events are typed by the literal unions inferred from the catalog — a typo in a matcher is a compile error, not a silently dead route.
Scale by intention, not by event
Tags make the wiring grow sub-linearly with your catalog. You declare intentions once — "product analytics consumes feature and marketing", "the trace logger consumes trace" — and events opt in by their tags:
// New event in the catalog — tagged 'feature'
const exportUsed = defineEvent('export_used', {
tags: ['feature'],
properties: v.object({ format: v.string() }),
})That event is immediately consumed by PostHog above. Zero wiring edits, zero call-site edits. Routing is declared on the ingest side only — the emitter never knows it exists.
One event, N consumers
The canonical example, and the reason wiring exists:
const wiring = createWiring(catalog)
.use(posthog({ apiKey: env.POSTHOG_KEY }), { tags: ['feature'] }) // product analytics
.use(usageCounter, { events: ['feature_used'] }) // billing / quotasA single feature_used event feeds product analytics and the account's usage counter. Same definition, same emitting code, two consumers. Without this, teams maintain two code paths — a track() call for analytics and a separate service call for the counter — that drift apart. With it, the business logic that drives the product and the analytics that measure it share one catalog.
Fan-out semantics: pure, weak-ordered, by design
Two decisions define the dispatch model:
- Pure fan-out. Every matching consumer receives the event. There is no ordered chain, no consumer transforming the event for the next, no blocking between consumers. Order-based coupling is a bug factory; Dotyc rules it out structurally.
- Isolation. Consumers run in parallel; a throwing adapter affects neither its siblings nor the emitter's response. Failures surface through the dispatcher's
onError.
If an event matches no consumer, you get a warning in dev, silence in prod — or an error with strict: true on the dispatcher.
Environments
There is no dedicated environment feature — the wiring is plain TypeScript, and plain TypeScript already solves it:
const wiring = createWiring(catalog)
.use(posthog({ apiKey: env.POSTHOG_KEY }), { tags: ['feature', 'marketing'] })
if (process.env.NODE_ENV === 'development') {
wiring.use(debugLog)
}Two wiring files (wiring.dev.ts / wiring.prod.ts) work just as well. Explicit code over configuration magic.
Ingest
The first-party server brick: schema resolution, strict validation, verified identity, durable persistence, acks, then pure parallel fan-out — a pure core (createDispatcher) plus a fetch-standard handler (toHandler).
Delivery guarantees
Two delivery classes declared per event — best-effort for the noise, durable at-least-once for business facts — plus a trusted source rule for anything with monetary consequence.