Skip to main content

Overview

@contract-kit/ports provides a simple way to define and type your application’s outbound dependencies (database, mailer, cache, event bus, etc.) following the hexagonal architecture pattern (also known as ports and adapters).
Prefer the contract-kit meta package for new projects. It re-exports @contract-kit/ports along with core, client, application, and more.

Installation

npm install @contract-kit/ports

Concepts

Ports

A port is an interface that your application uses to interact with the outside world. Examples include:
  • Database access
  • Email sending
  • Caching
  • Event publishing
  • External API calls
Ports define what your application needs without specifying how those needs are fulfilled.

Adapters

An adapter is an implementation of a port. You can swap adapters without changing your application code:
  • Production: Real database adapter (PostgreSQL, MySQL, etc.)
  • Testing: In-memory mock adapter
  • Development: Local file-based adapter
This separation enables:
  • Testability - Easy to swap real implementations for test doubles
  • Flexibility - Change implementations without touching business logic
  • Dependency Inversion - Application depends on abstractions, not concrete implementations

Usage

Defining Ports

// lib/ports.ts
import { definePorts } from "@contract-kit/ports";

// Database adapter interface
interface DbAdapter {
  todos: {
    findById(id: string): Promise<Todo | null>;
    create(data: CreateTodoData): Promise<Todo>;
    update(id: string, data: UpdateTodoData): Promise<Todo | null>;
    delete(id: string): Promise<boolean>;
    list(filter?: TodoFilter): Promise<Todo[]>;
  };
  users: {
    findById(id: string): Promise<User | null>;
    findByEmail(email: string): Promise<User | null>;
  };
}

// Email adapter interface
interface MailerAdapter {
  send(options: {
    to: string;
    subject: string;
    body: string;
  }): Promise<void>;
}

// Cache adapter interface
interface CacheAdapter {
  get<T>(key: string): Promise<T | null>;
  set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>;
  delete(key: string): Promise<void>;
}

// Event bus adapter interface
interface EventBusAdapter {
  publish(event: DomainEvent): Promise<void>;
  subscribe(type: string, handler: (event: DomainEvent) => void): () => void;
}

// Create adapter instances (shown in later sections)
// These would be your actual implementations
const dbAdapter: DbAdapter = /* implementation */;
const mailerAdapter: MailerAdapter = /* implementation */;
const cacheAdapter: CacheAdapter = /* implementation */;
const eventBusAdapter: EventBusAdapter = /* implementation */;

// Define your ports with actual adapter instances
export const ports = definePorts({
  db: dbAdapter,
  mailer: mailerAdapter,
  cache: cacheAdapter,
  eventBus: eventBusAdapter,
});

export type AppPorts = typeof ports;

Application Context

Use PortsContext to type your application context:
// lib/ctx.ts
import type { PortsContext } from "@contract-kit/ports";
import type { AppPorts } from "./ports";

export interface AppCtx extends PortsContext<AppPorts> {
  user: { id: string; role: string } | null;
  requestId: string;
  now: () => Date;
}

Using Ports in Use Cases

// application/todos/create.ts
import { z } from "zod";
import { useCase } from "../use-case";

export const createTodo = useCase
  .command("todos.create")
  .input(z.object({ title: z.string() }))
  .output(z.object({ id: z.string(), title: z.string() }))
  .run(async ({ ctx, input }) => {
    // Access ports through ctx.ports
    const todo = await ctx.ports.db.todos.create({
      id: crypto.randomUUID(),
      title: input.title,
      completed: false,
    });

    // Use other ports
    await ctx.ports.cache.delete("todos:list");
    
    return { id: todo.id, title: todo.title };
  });

Creating Adapters

Production Adapters

// adapters/db/prisma.ts
import { prisma } from "@/lib/prisma";
import type { DbAdapter } from "@/lib/ports";

export const prismaDbAdapter: DbAdapter = {
  todos: {
    findById: (id) => prisma.todo.findUnique({ where: { id } }),
    create: (data) => prisma.todo.create({ data }),
    update: (id, data) => prisma.todo.update({ where: { id }, data }),
    delete: async (id) => {
      await prisma.todo.delete({ where: { id } });
      return true;
    },
    list: (filter) => prisma.todo.findMany({ where: filter }),
  },
  users: {
    findById: (id) => prisma.user.findUnique({ where: { id } }),
    findByEmail: (email) => prisma.user.findUnique({ where: { email } }),
  },
};

Test Adapters

// adapters/db/memory.ts
import type { DbAdapter } from "@/lib/ports";

export function createMemoryDbAdapter(): DbAdapter {
  const todos = new Map<string, Todo>();
  const users = new Map<string, User>();

  return {
    todos: {
      findById: async (id) => todos.get(id) ?? null,
      create: async (data) => {
        todos.set(data.id, data);
        return data;
      },
      update: async (id, data) => {
        const todo = todos.get(id);
        if (!todo) return null;
        const updated = { ...todo, ...data };
        todos.set(id, updated);
        return updated;
      },
      delete: async (id) => {
        return todos.delete(id);
      },
      list: async (filter) => {
        return Array.from(todos.values()).filter((t) =>
          filter?.completed === undefined || t.completed === filter.completed
        );
      },
    },
    users: {
      findById: async (id) => users.get(id) ?? null,
      findByEmail: async (email) =>
        Array.from(users.values()).find((u) => u.email === email) ?? null,
    },
  };
}

Adapter Examples

Redis Cache Adapter

// adapters/cache/redis.ts
import { Redis } from "ioredis";
import type { CacheAdapter } from "@/lib/ports";

export function createRedisCacheAdapter(redis: Redis): CacheAdapter {
  return {
    get: async (key) => {
      const value = await redis.get(key);
      return value ? JSON.parse(value) : null;
    },
    set: async (key, value, ttlSeconds) => {
      const serialized = JSON.stringify(value);
      if (ttlSeconds) {
        await redis.setex(key, ttlSeconds, serialized);
      } else {
        await redis.set(key, serialized);
      }
    },
    delete: async (key) => {
      await redis.del(key);
    },
  };
}

Email Adapter

// adapters/mailer/resend.ts
import { Resend } from "resend";
import type { MailerAdapter } from "@/lib/ports";

export function createResendMailerAdapter(apiKey: string): MailerAdapter {
  const resend = new Resend(apiKey);

  return {
    send: async ({ to, subject, body }) => {
      await resend.emails.send({
        from: "[email protected]",
        to,
        subject,
        html: body,
      });
    },
  };
}

Wiring Up in Next.js

// lib/server.ts
import { createNextServer } from "@contract-kit/next";
import { prismaDbAdapter } from "@/adapters/db/prisma";
import { createResendMailerAdapter } from "@/adapters/mailer/resend";
import { createRedisCacheAdapter } from "@/adapters/cache/redis";
import { redis } from "@/lib/redis";

export const server = await createNextServer({
  ports: {},
  createContext: async (req) => ({
    ports: {
      db: prismaDbAdapter,
      mailer: createResendMailerAdapter(process.env.RESEND_API_KEY!),
      cache: createRedisCacheAdapter(redis),
      eventBus: eventBusAdapter,
    },
    user: await getUser(req),
    requestId: crypto.randomUUID(),
    now: () => new Date(),
  }),
  onUnhandledError: () => ({ status: 500, body: { message: "Error" } }),
});

Testing with Mock Ports

// __tests__/todos.test.ts
import { createTodo } from "@/application/todos/create";
import { createMemoryDbAdapter } from "@/adapters/db/memory";

describe("createTodo", () => {
  it("creates a todo", async () => {
    const db = createMemoryDbAdapter();
    const cache = {
      get: vi.fn(),
      set: vi.fn(),
      delete: vi.fn(),
    };

    const ctx = {
      ports: { db, cache, mailer: mockMailer, eventBus: mockEventBus },
      user: { id: "user-1", role: "user" },
      requestId: "req-1",
      now: () => new Date("2024-01-01"),
    };

    const result = await createTodo.run({
      ctx,
      input: { title: "Test Todo" },
    });

    expect(result.title).toBe("Test Todo");
    expect(cache.delete).toHaveBeenCalledWith("todos:list");
  });
});

Using Providers

Contract Kit includes several pre-built provider packages that implement common ports:
import { createNextServer } from "@contract-kit/next";
import { drizzleTursoProvider } from "@contract-kit/provider-drizzle-turso";
import { redisProvider } from "@contract-kit/provider-redis";
import { resendMailProvider } from "@contract-kit/provider-mail-resend";

export const server = await createNextServer({
  ports: {},
  providers: [
    drizzleTursoProvider,
    redisProvider,
    resendMailProvider,
  ],
  providerEnv: process.env,
  createContext: async ({ ports }) => ({
    // Providers automatically wire up ports
    db: ports.db,
    cache: ports.cache,
    mailer: ports.mailer,
  }),
  onUnhandledError: () => ({ status: 500, body: { message: "Error" } }),
});
See Providers for available provider packages.

API Reference

definePorts(ports)

A typed identity function that captures the shape of your ports object. Pass actual adapter instances, not type assertions.
// Create your adapter instances first
const dbAdapter: DbAdapter = createDbAdapter();
const mailerAdapter: MailerAdapter = createMailerAdapter();

const ports = definePorts({
  db: dbAdapter,
  mailer: mailerAdapter,
});

// Export the type for use elsewhere
export type AppPorts = typeof ports;

PortsContext<P>

A type helper for context objects that carry ports.
interface AppCtx extends PortsContext<AppPorts> {
  // Additional context properties
  user: User | null;
}

// Equivalent to:
interface AppCtx {
  ports: AppPorts;
  user: User | null;
}

Benefits

  1. Dependency Inversion - Your application code depends on abstractions (ports), not implementations (adapters)
  2. Testability - Easily swap real adapters for test doubles
  3. Flexibility - Change implementations without touching business logic
  4. Type Safety - Full TypeScript support for all port interfaces
  5. Clean Architecture - Clear separation between application logic and infrastructure

Common Patterns

Environment-Based Adapters

// lib/adapters.ts
import { createMemoryDbAdapter } from "@/adapters/db/memory";
import { prismaDbAdapter } from "@/adapters/db/prisma";

export function createDbAdapter() {
  if (process.env.NODE_ENV === "test") {
    return createMemoryDbAdapter();
  }
  return prismaDbAdapter;
}

Composite Adapters

// adapters/db/composite.ts
export function createCompositeDbAdapter(
  primary: DbAdapter,
  fallback: DbAdapter
): DbAdapter {
  return {
    todos: {
      findById: async (id) => {
        try {
          return await primary.todos.findById(id);
        } catch {
          return await fallback.todos.findById(id);
        }
      },
      // ... other methods
    },
  };
}

Caching Decorator

// adapters/db/cached.ts
export function withCache(
  db: DbAdapter,
  cache: CacheAdapter
): DbAdapter {
  return {
    todos: {
      findById: async (id) => {
        const cached = await cache.get<Todo>(`todo:${id}`);
        if (cached) return cached;
        
        const todo = await db.todos.findById(id);
        if (todo) {
          await cache.set(`todo:${id}`, todo, 3600);
        }
        return todo;
      },
      // ... other methods
    },
  };
}

Best Practices

Define only the operations your application needs. Don’t expose the entire database or service API.
Create factory functions for adapters that need configuration or initialization.
Create simple in-memory implementations for fast, reliable unit tests.
Separate database, cache, email, etc. into different port interfaces for clear boundaries.

Next Steps