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:
- Validate configuration before startup
- Read ports installed by earlier providers
- Return new or replacement ports
- Register
startandstoplifecycle hooks
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.