Skip to main content

Overview

Contract Kit is designed to support clean architecture patterns, particularly hexagonal (ports and adapters) architecture. This separation of concerns makes your code more testable, maintainable, and adaptable.

Architectural Layers

1. Domain Layer

Pure business logic with no external dependencies:
// domain/entities/todo.ts
import { entity } from "contract-kit";
import { z } from "zod";

export const Todo = entity("Todo")
  .props(z.object({
    id: z.string(),
    title: z.string(),
    completed: z.boolean().default(false),
    createdAt: z.date().default(() => new Date()),
  }))
  .methods((self) => ({
    complete() {
      return self.with({ completed: true });
    },
    updateTitle(title: string) {
      return self.with({ title });
    },
  }))
  .build();

export type Todo = typeof Todo.Type;

// domain/value-objects/todo-title.ts
import { valueObject } from "contract-kit";
import { z } from "zod";

export const TodoTitle = valueObject("TodoTitle")
  .schema(
    z.string()
      .min(1, "Title cannot be empty")
      .max(100, "Title too long")
  )
  .brand();

export type TodoTitle = typeof TodoTitle.Type;

2. Application Layer (Use Cases)

Orchestrates business logic and coordinates domain objects:
// application/use-cases/create-todo.ts
import { createUseCaseFactory } from "contract-kit";

const useCase = createUseCaseFactory<AppCtx>();

export const createTodo = useCase
  .command("todos.create")
  .input(z.object({
    title: z.string(),
    completed: z.boolean().optional(),
  }))
  .output(z.object({
    id: z.string(),
    title: z.string(),
    completed: z.boolean(),
  }))
  .run(async ({ ctx, input }) => {
    // Business logic here
    const todo = await ctx.ports.db.todos.create({
      id: crypto.randomUUID(),
      title: input.title,
      completed: input.completed ?? false,
      createdAt: new Date(),
    });

    // Emit event
    await ctx.ports.eventBus.publish("todo.created", todo);

    return todo;
  });

3. Ports (Interfaces)

Define what your application needs from the outside world:
// ports/db-port.ts
import { definePorts } from "contract-kit";

export const ports = definePorts({
  db: {
    todos: {
      findById: async (_id: string) => {
        throw new Error("Not implemented");
      },
      findAll: async () => {
        throw new Error("Not implemented");
      },
      create: async (_data: CreateTodoData) => {
        throw new Error("Not implemented");
      },
      update: async (_id: string, _data: UpdateTodoData) => {
        throw new Error("Not implemented");
      },
      delete: async (_id: string) => {
        throw new Error("Not implemented");
      },
    },
  },
  cache: {
    get: async (_key: string) => {
      throw new Error("Not implemented");
    },
    set: async (_key: string, _value: string, _ttl?: number) => {
      throw new Error("Not implemented");
    },
  },
  eventBus: {
    publish: async (_event: string, _data: unknown) => {
      throw new Error("Not implemented");
    },
  },
});

4. Providers (Adapters)

Concrete implementations of ports:
// providers/drizzle-todos-provider.ts
import { db } from "./drizzle";
import { todos } from "./schema";

export const drizzleTodosProvider = {
  findById: async (id: string) => {
    const results = await db.select().from(todos).where(eq(todos.id, id));
    return results[0] ?? null;
  },
  findAll: async () => {
    return db.select().from(todos);
  },
  create: async (data) => {
    const [todo] = await db.insert(todos).values(data).returning();
    return todo;
  },
  // ... other methods
};

5. Infrastructure Layer (Contracts & Routes)

HTTP contracts and route handlers:
// contracts/todo-contracts.ts
export const getTodoContract = todos
  .get("/api/todos/:id")
  .path(z.object({ id: z.string() }))
  .response(200, TodoSchema);

// routes/todo-routes.ts
export const GET = server.route(getTodoContract).handle(async ({ ctx, path }) => {
  const result = await getTodo.run({
    ctx,
    input: { id: path.id },
  });

  return { status: 200, body: result };
});

Hexagonal Architecture

The Hexagon

                    ┌─────────────────┐
                    │   Application   │
                    │   (Use Cases)   │
                    └────────┬────────┘

            ┌────────────────┼────────────────┐
            │                                  │
        ┌───▼───┐                          ┌───▼───┐
        │ Ports │                          │ Ports │
        └───┬───┘                          └───┬───┘
            │                                  │
    ┌───────▼───────┐              ┌──────────▼──────────┐
    │   Providers   │              │   Providers        │
    │  (Database)   │              │  (HTTP/Events)     │
    └───────────────┘              └─────────────────────┘

Benefits

  1. Testability: Mock ports for unit tests
  2. Flexibility: Swap implementations easily
  3. Independence: Core logic doesn’t depend on frameworks
  4. Maintainability: Clear separation of concerns

Dependency Injection

Defining Dependencies

import { definePorts } from "contract-kit";

export const ports = definePorts({
  // Database
  db: {
    todos: drizzleTodosProvider,
    users: drizzleUsersProvider,
  },
  // Caching
  cache: redisProvider,
  // Email
  mail: resendProvider,
  // Authentication
  auth: betterAuthProvider,
  // Logging
  logger: pinoProvider,
});

Using Dependencies

export const GET = server.route(getTodo).handle(async ({ ctx, path }) => {
  // Access through ports
  const todo = await ctx.ports.db.todos.findById(path.id);

  // Log
  ctx.ports.logger.info("Todo fetched", { id: path.id });

  return { status: 200, body: todo };
});

Use Cases (Commands & Queries)

Commands (Mutations)

import { createUseCaseFactory } from "contract-kit";

const useCase = createUseCaseFactory<AppCtx>();

export const createTodo = useCase
  .command("todos.create")
  .input(CreateTodoSchema)
  .output(TodoSchema)
  .run(async ({ ctx, input }) => {
    // Validate business rules
    if (input.title.toLowerCase().includes("spam")) {
      throw new Error("Invalid title");
    }

    // Create todo
    const todo = await ctx.ports.db.todos.create(input);

    // Side effects
    await ctx.ports.eventBus.publish("todo.created", todo);
    await ctx.ports.cache.set(`todo:${todo.id}`, JSON.stringify(todo));

    return todo;
  });

Queries (Reads)

import { createUseCaseFactory } from "contract-kit";

const useCase = createUseCaseFactory<AppCtx>();

export const getTodo = useCase
  .query("todos.get")
  .input(z.object({ id: z.string() }))
  .output(TodoSchema)
  .run(async ({ ctx, input }) => {
    // Try cache first
    const cached = await ctx.ports.cache.get(`todo:${input.id}`);
    if (cached) {
      return JSON.parse(cached);
    }

    // Fallback to database
    const todo = await ctx.ports.db.todos.findById(input.id);
    if (!todo) {
      throw new Error("Todo not found");
    }

    // Update cache
    await ctx.ports.cache.set(`todo:${todo.id}`, JSON.stringify(todo));

    return todo;
  });

Testing

Unit Testing Use Cases

import { describe, it, expect } from "bun:test";
import { createTodo } from "./create-todo";

describe("createTodo", () => {
  it("should create a todo", async () => {
    // Mock ports
    const mockPorts = {
      db: {
        todos: {
          create: async (data) => ({ ...data, id: "123" }),
        },
      },
      eventBus: {
        publish: async () => {},
      },
    };

    const result = await createTodo.run({
      ctx: { ports: mockPorts },
      input: { title: "Test", completed: false },
    });

    expect(result.id).toBe("123");
    expect(result.title).toBe("Test");
  });
});

Integration Testing Routes

import { describe, it, expect } from "bun:test";
import { server } from "./server";

describe("GET /api/todos/:id", () => {
  it("should return a todo", async () => {
    const request = new Request("http://localhost:3000/api/todos/123");
    const handler = server.api();
    const response = await handler(request);

    expect(response.status).toBe(200);
    const body = await response.json();
    expect(body.id).toBe("123");
  });
});

Best Practices

Domain entities and value objects should have no external dependencies.
Always access external services through ports, never directly.
Use different use cases for reads (queries) and writes (commands).
Each use case should do one thing well. Create multiple small use cases instead of large ones.
Unit test use cases with mocked ports. Integration test routes with real dependencies.

Next Steps