Dotyc
Concepts

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 = everything

Declarative 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 / quotas

A 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.

On this page