Lawhive Framework
FrameworkEvents

Adapters

Composable adapters for event storage, logging, and delivery

Adapters are composable handlers that process events during publishing. All adapters in a client participate in the same context, ensuring atomicity across operations.

How Adapters Work

When you publish an event, all configured adapters receive the event batch within the same context (typically a database transaction):

Built-in Adapters

Event Store Adapter

Persists events to a database table for audit and replay.

Prisma Schema

The event store requires an Event table. Add this to your Prisma schema:

model Event {
  id String @id @default(uuid())

  timestamp     DateTime
  type          EventType
  key           String
  aggregateId   String?
  aggregateType String?
  payload       Json

  @@index([aggregateType, aggregateId])
  @@index([key])
}

enum EventType {
  aggregate
  ephemeral
}

The aggregateType and aggregateId fields link aggregate events to their entity, enabling the History API. Indexing these fields is important for efficient history queries.

Usage

import { createEventStoreAdapter } from "@lawhive/framework/adapters"

const eventStoreAdapter = createEventStoreAdapter({
  storage: {
    createMany: async (records, ctx) => {
      const results = await ctx.transaction.event.createManyAndReturn({
        data: records,
      })
      return { ids: results.map((r) => r.id) }
    },
  },
})

Storage Interface

type EventStorage<TContext> = {
  createMany: (
    records: EventRecord[],
    ctx: TContext,
  ) => Promise<{ ids: string[] }>
}

type EventRecord = {
  id?: string
  timestamp: Date
  type: "aggregate" | "ephemeral"
  key: string
  aggregateId?: string
  aggregateType?: string
  payload: unknown
}

Logger Adapter

Logs events during development and debugging:

import { createLoggerAdapter } from "@lawhive/framework/adapters"

const loggerAdapter = createLoggerAdapter({
  level: "debug",
  includePayload: true,
  prefix: "[events]",
})

Options:

OptionTypeDefaultDescription
level"debug" | "info" | "warn" | "error""info"Log level
includePayloadbooleanfalseInclude event payload in logs
prefixstring"[event]"Prefix for log messages
logfunctionconsole[level]Custom log function

Outbox Adapter

Implements the transactional outbox pattern for reliable delivery. See Outbox Pattern for details.

Composing Adapters

Chain multiple adapters together:

import { composeAdapters } from "@lawhive/framework/adapters"

const productionAdapter = composeAdapters([
  createLoggerAdapter({ level: "info" }),
  createEventStoreAdapter({ storage: eventStorage }),
  createOutboxAdapter({ storage: outboxStorage }),
])

const client = createEventClient(router, {
  adapters: [productionAdapter],
  createContext: async (fn) => prisma.$transaction((tx) => fn({ tx })),
})

Conditional Adapters

Filter which events go to specific adapters:

import { createConditionalAdapter } from "@lawhive/framework/adapters"

// Only aggregate events go to the outbox
const aggregateOnlyOutbox = createConditionalAdapter(
  createOutboxAdapter({ storage: outboxStorage }),
  (event) => event.type === "aggregate",
)

Creating Custom Adapters

Adapters implement a simple interface:

import type { EventAdapter } from "@lawhive/framework"

type MyContext = {
  transaction: PrismaTransactionClient
  userId: string
}

const myAdapter: EventAdapter<MyContext> = {
  name: "my-adapter",
  onPublish: async ({ events, ctx }) => {
    for (const event of events) {
      await ctx.transaction.customTable.create({
        data: {
          eventKey: event.key,
          userId: ctx.userId,
          payload: event.payload,
        },
      })
    }
  },
}

Context Pattern

The createContext callback wraps all adapter calls:

const client = createEventClient(router, {
  adapters: [eventStoreAdapter, outboxAdapter],
  createContext: async (fn) => {
    // Everything runs in this transaction
    return prisma.$transaction(async (tx) => {
      const ctx = {
        transaction: tx,
        logger: createLogger(),
        requestId: getRequestId(),
      }
      return fn(ctx)
    })
  },
})

This ensures:

  • All adapters see the same context
  • Database operations are atomic
  • Rollback on any failure

Noop Adapter

For testing or conditional inclusion:

import { createNoopAdapter } from "@lawhive/framework/adapters"

const adapters = [
  createEventStoreAdapter({ storage }),
  isProduction ? createOutboxAdapter({ storage }) : createNoopAdapter(),
]