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
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
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
Keep port interfaces simple
Define only the operations your application needs. Don’t expose the entire database or service API.
Use factories for adapters
Create factory functions for adapters that need configuration or initialization.
Test with in-memory adapters
Create simple in-memory implementations for fast, reliable unit tests.
Separate database, cache, email, etc. into different port interfaces for clear boundaries.
Next Steps