@contract-kit/application
v0.1.2
Published
Use case builder for contract-kit - framework-agnostic
Maintainers
Readme
@contract-kit/application
Use case builder for Contract Kit - framework-agnostic commands and queries
This package provides a clean way to define use cases (commands and queries) that are framework-agnostic and work with any Standard Schema library.
Installation
npm install @contract-kit/application
# Use with your preferred Standard Schema library
npm install zodTypeScript Requirements
This package requires TypeScript 5.0 or higher for proper type inference.
Concepts
Commands vs Queries
- Commands - Write operations with side effects (create, update, delete)
- Queries - Read-only operations (get, list, search)
This separation helps with:
- Clear intent in your code
- Easier testing (queries are pure functions)
- CQRS-style architecture if needed
Use Cases
A use case encapsulates a single business operation with:
- A unique name for identification
- Input schema for validation
- Output schema for type safety
- Optional domain events it may emit
Usage
Creating a Use Case Factory
// application/use-case.ts
import { createUseCaseFactory } from "@contract-kit/application";
import type { AppCtx } from "./ctx";
export const useCase = createUseCaseFactory<AppCtx>();Defining Commands
// application/todos/create.ts
import { z } from "zod";
import { useCase } from "../use-case";
const CreateTodoInput = z.object({
title: z.string().min(1),
description: z.string().optional(),
});
const CreateTodoOutput = z.object({
id: z.string(),
title: z.string(),
completed: z.boolean(),
});
export const createTodo = useCase
.command("todos.create")
.input(CreateTodoInput)
.output(CreateTodoOutput)
.run(async ({ ctx, input }) => {
const todo = await ctx.ports.db.todos.create({
id: crypto.randomUUID(),
title: input.title,
description: input.description,
completed: false,
});
return {
id: todo.id,
title: todo.title,
completed: todo.completed,
};
});Defining Queries
// application/todos/get.ts
import { z } from "zod";
import { useCase } from "../use-case";
const GetTodoInput = z.object({
id: z.string(),
});
const GetTodoOutput = z.object({
id: z.string(),
title: z.string(),
description: z.string().nullable(),
completed: z.boolean(),
}).nullable();
export const getTodo = useCase
.query("todos.get")
.input(GetTodoInput)
.output(GetTodoOutput)
.run(async ({ ctx, input }) => {
const todo = await ctx.ports.db.todos.findById(input.id);
if (!todo) {
return null;
}
return {
id: todo.id,
title: todo.title,
description: todo.description,
completed: todo.completed,
};
});Emitting Domain Events
Use cases can declare which domain events they may emit:
// application/todos/complete.ts
import { z } from "zod";
import { useCase } from "../use-case";
import { TodoCompletedEvent } from "@/domain/events";
const CompleteTodoInput = z.object({
id: z.string(),
});
const CompleteTodoOutput = z.object({
success: z.boolean(),
});
export const completeTodo = useCase
.command("todos.complete")
.input(CompleteTodoInput)
.output(CompleteTodoOutput)
.emits([TodoCompletedEvent])
.run(async ({ ctx, input }) => {
const todo = await ctx.ports.db.todos.update(input.id, {
completed: true,
completedAt: new Date(),
});
if (!todo) {
return { success: false };
}
// Publish the domain event
await ctx.ports.eventBus.publish({
type: "todo.completed",
payload: {
todoId: todo.id,
completedAt: todo.completedAt.toISOString(),
},
});
return { success: true };
});Application Context
Your application context provides access to ports (dependencies):
// application/ctx.ts
import type { PortsContext } from "@contract-kit/ports";
import type { AppPorts } from "@/lib/ports";
export interface AppCtx extends PortsContext<AppPorts> {
user: { id: string; role: string } | null;
now: () => Date;
requestId: string;
}Using with Next.js Adapter
Use cases integrate directly with @contract-kit/next:
// app/api/todos/[id]/route.ts
import { getTodoContract } from "@/contracts/todos";
import { getTodo } from "@/application/todos/get";
import { router } from "@/lib/server";
export const GET = router
.route(getTodoContract)
.useCase(getTodo, {
mapInput: ({ path }) => ({ id: path.id }),
mapOutput: (result) => result,
status: 200,
});API Reference
createUseCaseFactory<Ctx>()
Creates a use case factory with a specific context type.
const useCase = createUseCaseFactory<AppCtx>();useCase.command(name)
Starts building a command use case.
useCase
.command("todos.create")
.input(InputSchema)
.output(OutputSchema)
.emits([DomainEvent]) // Optional
.run(async ({ ctx, input }) => { ... });useCase.query(name)
Starts building a query use case.
useCase
.query("todos.get")
.input(InputSchema)
.output(OutputSchema)
.run(async ({ ctx, input }) => { ... });Use Case Definition
The resulting use case definition includes:
type UseCaseDef = {
name: string; // Unique identifier
kind: "command" | "query";
input: StandardSchema; // Input validation schema
output: StandardSchema; // Output validation schema
emits: DomainEvent[]; // Domain events (metadata only)
run: (args) => Promise<Output>;
};Testing Use Cases
Use cases are easy to test because they're just functions:
import { createTodo } from "@/application/todos/create";
describe("createTodo", () => {
it("creates a todo", async () => {
const mockDb = {
todos: {
create: vi.fn().mockResolvedValue({
id: "1",
title: "Test",
completed: false,
}),
},
};
const ctx = {
ports: { db: mockDb },
user: { id: "user-1", role: "user" },
};
const result = await createTodo.run({
ctx,
input: { title: "Test" },
});
expect(result).toEqual({
id: "1",
title: "Test",
completed: false,
});
expect(mockDb.todos.create).toHaveBeenCalled();
});
});Related Packages
@contract-kit/core- Core contract definitions@contract-kit/ports- Ports & Adapters pattern@contract-kit/domain- Domain modeling@contract-kit/next- Next.js integration@contract-kit/server- Hexagonal framework
License
MIT
