Providers

Providers are infrastructure adapters. They install concrete ports for databases, caches, mail, auth, logging, jobs, and other external services while your handlers and use cases depend only on ctx.ports.

How providers fit

Ports are the interface your application code uses. Providers are how production infrastructure fills those interfaces. Read Ports first if you want the dependency boundary before the provider lifecycle.

import { createNextServer } from "@contract-kit/next";
import { redisProvider } from "@contract-kit/provider-redis";
import { loggerPinoProvider } from "@contract-kit/provider-logger-pino";
import { ports } from "./ports";

export const server = await createNextServer({
  ports,
  providers: [loggerPinoProvider, redisProvider],
  createContext: ({ ports }) => ({
    requestId: crypto.randomUUID(),
    ports,
  }),
});

Provider-installed ports are available in createContext, route handlers, hooks, and server.ports. Swapping Redis for an in-memory cache in tests should not require changes to application code.

Writing a provider

Use createProvider when you want a reusable adapter. A provider can:

import { createProvider } from "@contract-kit/ports";
import { z } from "zod";

type LoggerPort = {
  info(message: string, meta?: Record<string, unknown>): void;
  error(message: string, meta?: Record<string, unknown>): void;
};

const LoggerConfigSchema = z.object({
  LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
});

export const loggerProvider = createProvider<
  unknown,
  typeof LoggerConfigSchema,
  { logger: LoggerPort }
>({
  name: "logger",
  config: {
    schema: LoggerConfigSchema,
    envPrefix: "LOG_",
  },
  setup({ config }) {
    const level = config?.LEVEL ?? "info";

    return {
      ports: {
        logger: {
          info: (message, meta) => console.info(message, { level, ...meta }),
          error: (message, meta) => console.error(message, { level, ...meta }),
        },
      },
      start: () => {
        console.info("logger ready");
      },
      stop: () => {
        console.info("logger stopped");
      },
    };
  },
});

The envPrefix strips the prefix before validation. For example, LOG_LEVEL=debug becomes { LEVEL: "debug" }.

Setup order

Providers run in the order you pass them to the server. Each provider sees base ports plus ports returned by earlier providers.

export const server = await createNextServer({
  ports,
  providers: [
    loggerPinoProvider, // installs ctx.ports.logger
    redisProvider,      // can see ctx.ports.logger during setup
  ],
  createContext: ({ ports }) => ({ ports }),
});

When two providers return the same port key, the later provider wins. Use that deliberately for environment-specific overrides, not accidentally.

Lifecycle

setup runs during server creation. start runs after all providers have contributed ports. stop runs when the server is stopped.

export const cacheProvider = createProvider<
  unknown,
  typeof CacheConfigSchema,
  { cache: CachePort }
>({
  name: "cache",
  config: { schema: CacheConfigSchema, envPrefix: "CACHE_" },
  async setup({ config }) {
    const client = await connectToCache(config);

    return {
      ports: {
        cache: {
          get: (key) => client.get(key),
          set: (key, value, ttlSeconds) => client.set(key, value, ttlSeconds),
          del: (key) => client.del(key),
          exists: (key) => client.exists(key),
        },
      },
      async stop() {
        await client.close();
      },
    };
  },
});

Built-in providers

Database — Drizzle + Turso

bun add @contract-kit/provider-drizzle-turso
import { createDrizzleTursoProvider } from "@contract-kit/provider-drizzle-turso";
import { sqliteTable, text } from "drizzle-orm/sqlite-core";

const todos = sqliteTable("todos", {
  id: text("id").primaryKey(),
  title: text("title").notNull(),
});

export const dbProvider = createDrizzleTursoProvider({
  schema: { todos },
});

Requires TURSO_DB_URL and TURSO_DB_AUTH_TOKEN.

Cache — Redis

bun add @contract-kit/provider-redis
import { redisProvider } from "@contract-kit/provider-redis";

const cached = await ctx.ports.cache.get("todo:123");
if (cached) return { status: 200, body: JSON.parse(cached) };

const todo = await ctx.ports.db.todos.findById("123");
await ctx.ports.cache.set("todo:123", JSON.stringify(todo), 60);
return { status: 200, body: todo };

Requires REDIS_URL. Provides get, set, del, exists, and the underlying Redis client.

Mail — Resend or SMTP

bun add @contract-kit/provider-mail-resend
bun add @contract-kit/provider-mail-smtp
import { mailResendProvider } from "@contract-kit/provider-mail-resend";
import { mailSmtpProvider } from "@contract-kit/provider-mail-smtp";

await ctx.ports.mailer.send({
  to: "user@example.com",
  subject: "Welcome",
  html: "<h1>Hello</h1>",
});

Use Resend with RESEND_API_KEY and RESEND_FROM, or SMTP with MAIL_HOST, MAIL_PORT, MAIL_USER, MAIL_PASS, and MAIL_FROM.

Logger — Pino

bun add @contract-kit/provider-logger-pino pino
import { loggerPinoProvider } from "@contract-kit/provider-logger-pino";

ctx.ports.logger.info("Todo created", { todoId: todo.id });
ctx.ports.logger.error("Failed to create todo", { error });

Configured with LOG_LEVEL, LOG_FORMAT, LOG_SERVICE, and LOG_TIMESTAMP.

Rate limiting — Upstash

bun add @contract-kit/provider-rate-limit-upstash
import { upstashRateLimitProvider } from "@contract-kit/provider-rate-limit-upstash";

const result = await ctx.ports.rateLimit.hit({
  key: `ip:${ip}`,
  limit: 100,
  windowSec: 60,
});

Requires UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN.

Event bus — In memory

bun add @contract-kit/provider-event-bus-memory
import { domainEvent } from "@contract-kit/domain";
import { createInMemoryEventBus } from "@contract-kit/provider-event-bus-memory";
import { z } from "zod";

const todoCreated = domainEvent(
  "todo.created",
  z.object({ todoId: z.string() }),
);

const eventBus = createInMemoryEventBus();

eventBus.subscribe(todoCreated, async (payload) => {
  console.log("Todo created:", payload.todoId);
});

Auth — BetterAuth

bun add @contract-kit/provider-auth-better-auth
import { createAuthBetterAuthProvider } from "@contract-kit/provider-auth-better-auth";
import { auth } from "./auth";

export const authProvider = createAuthBetterAuthProvider(auth);

const user = await ctx.ports.auth.requireUser(req);

Jobs — Inngest

bun add @contract-kit/provider-inngest
import { inngestProvider } from "@contract-kit/provider-inngest";

Testing

For tests, pass mock ports directly instead of using production providers.

const testPorts = definePorts({
  db: {
    todos: {
      findById: async (id: string) => ({ id, title: "Test", completed: false }),
      create: async (data) => ({ id: "1", ...data }),
    },
  },
  cache: {
    get: async () => null,
    set: async () => {},
    del: async () => 0,
    exists: async () => false,
  },
});

Handlers and use cases still receive ctx.ports, so the production and test code paths stay the same.