@contract-kit/ports
v0.1.1
Published
Framework-agnostic port definitions for contract-kit - standardize outbound dependencies like db, mailer, cache
Maintainers
Readme
@contract-kit/ports
Ports & Adapters pattern for Contract Kit
This package provides a simple way to define and type your application's outbound dependencies (database, mailer, cache, event bus, etc.) following the hexagonal architecture pattern.
Installation
npm install @contract-kit/portsTypeScript Requirements
This package requires TypeScript 5.0 or higher for proper type inference.
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
Adapters
An adapter is an implementation of a port. You can swap adapters without changing your application code:
- Production: Real database adapter
- Testing: In-memory mock adapter
- Development: Local file-based adapter
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;
}
// Define your ports
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 || 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,
},
};
}Wiring Up in Next.js
// lib/server.ts
import { createNextRouter } from "@contract-kit/next";
import { prismaDbAdapter } from "@/adapters/db/prisma";
import { resendMailerAdapter } from "@/adapters/mailer/resend";
import { redisCache } from "@/adapters/cache/redis";
export const router = createNextRouter({
createContext: async (req) => ({
ports: {
db: prismaDbAdapter,
mailer: resendMailerAdapter,
cache: redisCache,
eventBus: eventBusAdapter,
},
user: await getUser(req),
requestId: crypto.randomUUID(),
now: () => new Date(),
}),
});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");
});
});API Reference
definePorts(ports)
A typed identity function that captures the shape of your ports object.
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
Dependency Inversion - Your application code depends on abstractions (ports), not implementations (adapters)
Testability - Easily swap real adapters for test doubles
Flexibility - Change implementations without touching business logic
Type Safety - Full TypeScript support for all port interfaces
Clean Architecture - Clear separation between application logic and infrastructure
Related Packages
@contract-kit/core- Core contract definitions@contract-kit/application- Use case definitions@contract-kit/domain- Domain modeling@contract-kit/server- Hexagonal framework
License
MIT
