Ports and Adapters
How abstract interfaces decouple domain logic from infrastructure implementations
The ports and adapters pattern (also known as hexagonal architecture) is central to tx-agent-kit's design. Ports define abstract capability contracts. Adapters implement those contracts with concrete infrastructure.
Ports: defining contracts
A port is a type alias that describes what a capability does, without specifying how it does it:
// packages/core/src/domains/organization/ports/organization-repository.port.ts
import type { OrganizationRecord } from "../domain/organization.js"
export type OrganizationRepositoryPort = {
readonly findById: (id: string) => Effect.Effect<OrganizationRecord | null>
readonly create: (input: CreateOrganizationInput) => Effect.Effect<OrganizationRecord>
readonly update: (id: string, input: UpdateOrganizationInput) => Effect.Effect<OrganizationRecord>
readonly listByUser: (userId: string) => Effect.Effect<ReadonlyArray<OrganizationRecord>>
readonly delete: (id: string) => Effect.Effect<void>
}
export type CreateOrganizationInput = {
readonly name: string
}Port rules
Ports in tx-agent-kit follow strict conventions enforced by structural invariant checks.
Port files must use export type instead of export interface. This is a mechanical enforcement. The structural invariant script checks for export interface in any file under ports/ and fails the build if found.
// WRONG - will fail structural invariant check
export interface OrganizationRepositoryPort {
findById(id: string): Effect.Effect<OrganizationRecord | null>
}
// CORRECT - use export type
export type OrganizationRepositoryPort = {
readonly findById: (id: string) => Effect.Effect<OrganizationRecord | null>
}Record types come from domain/. Port files import OrganizationRecord and other domain types from the domain/ directory. They never define their own entity types.
Ports import only domain/ and ports/. A port file cannot import from application/, adapters/, packages/infra/db, or any infrastructure package.
Adapters: concrete implementations
Adapters implement port contracts using specific technologies. In tx-agent-kit, the primary adapters are Drizzle-based repository implementations in packages/infra/db/src/repositories/:
// packages/infra/db/src/repositories/organizations.ts
import { eq } from "drizzle-orm"
import { organizations } from "../schema.js"
import type { OrganizationRepositoryPort } from "@tx-agent-kit/core/domains/organization/ports/organization-repository.port.js"
export const makeOrganizationRepository = (db: DrizzleClient): OrganizationRepositoryPort => ({
findById: (id) =>
Effect.tryPromise(() =>
db
.select()
.from(organizations)
.where(eq(organizations.id, id))
.then((rows) => rows[0] ?? null),
),
create: (input) =>
Effect.tryPromise(() =>
db
.insert(organizations)
.values({
id: generateId(),
name: input.name,
})
.returning()
.then((rows) => rows[0]!),
),
// ... other methods
})Why adapters live in packages/infra/db
In many hexagonal architecture implementations, adapters live next to the domain. tx-agent-kit takes a different approach: persistence adapters live in packages/infra/db/src/repositories/.
This decision is deliberate. First, packages/core stays free of drizzle-orm. If repositories lived in core, core would need a dependency on the database library. Second, all database code is colocated: schema, migrations, repositories, effect-schemas, and factories all live together in packages/infra/db. Third, the structural invariant enforces it. The check script verifies that packages/core/src/domains/*/repositories/ does not exist.
Wiring ports to adapters
The connection between abstract ports and concrete adapters happens through Effect's layer system in the application bootstrap:
// apps/api - layer wiring
import { OrganizationRepository } from "@tx-agent-kit/core/domains/organization/ports/organization-repository.port.js"
import { makeOrganizationRepository } from "@tx-agent-kit/db/repositories/organizations.js"
const OrganizationRepositoryLive = Layer.succeed(
OrganizationRepository,
makeOrganizationRepository(dbClient),
)Route handlers and application services depend on the abstract OrganizationRepository tag. The layer system provides the concrete Drizzle implementation at runtime.
Testing with ports
Ports make testing straightforward. You can create in-memory implementations for unit tests:
// Test with an in-memory adapter
const makeInMemoryOrgRepo = (): OrganizationRepositoryPort => {
const store = new Map<string, OrganizationRecord>()
return {
findById: (id) => Effect.succeed(store.get(id) ?? null),
create: (input) => {
const org: OrganizationRecord = {
id: `org-${store.size + 1}`,
name: input.name,
billingEmail: null,
isSubscribed: false,
subscriptionStatus: "inactive",
createdAt: new Date("2024-01-01"),
updatedAt: new Date("2024-01-01"),
}
store.set(org.id, org)
return Effect.succeed(org)
},
// ... other methods
}
}This in-memory implementation can be used in unit tests without any database setup, making tests fast and deterministic.
Non-repository ports
Not all ports are repositories. Ports can define any external capability:
// Email notification port
export type EmailPort = {
readonly send: (to: string, subject: string, body: string) => Effect.Effect<void>
}
// Clock port (for deterministic time)
export type ClockPort = {
readonly now: () => Effect.Effect<Date>
}
// ID generation port
export type IdGeneratorPort = {
readonly generate: () => Effect.Effect<string>
}Each of these can have a production adapter (SMTP client, Date.now wrapper, UUID generator) and a test adapter (recording mock, fixed time, sequential IDs).