# api-endpoint > Clean Architecture API patterns for Hono endpoints following Route → Handler → Service → Factory → Interface pattern with multi-tenant support - Author: Vassili Bo's - Repository: atzentis/atzentis-dev - Version: 20260126003424 - Stars: 0 - Forks: 0 - Last Updated: 2026-02-07 - Source: https://github.com/atzentis/atzentis-dev - Web: https://mule.run/skillshub/@@atzentis/atzentis-dev~api-endpoint:20260126003424 --- --- name: api-endpoint description: Clean Architecture API patterns for Hono endpoints following Route → Handler → Service → Factory → Interface pattern with multi-tenant support allowed-tools: Read, Write, Bash --- # API Endpoint Skill Clean Architecture patterns for building API endpoints in Atzentis projects using Hono 4.0+ with Zod OpenAPI. ## When to Use This skill activates when you need to: - Create new API endpoints - Implement handlers, services, or factories - Add routes to Hono applications - Handle multi-tenant database access - Implement error handling patterns ## Architecture Pattern (5-Layer) ``` Route → Handler → Service → Factory → Interface ↓ ↓ ↓ ↓ ↓ OpenAPI Validate Business Data Types Schema + Auth Logic Transform ``` - **Route**: OpenAPI schema definition (path, method, request/response schemas) - **Handler**: HTTP request/response handling (thin layer, delegates to service) - **Service**: Business logic, transactions, orchestration - **Factory**: Data transformation (API ↔ DB ↔ Entity) - **Interface**: TypeScript types and entities ## Module Structure ``` modules/{domain}/{resource}/ ├── index.ts # Router + middleware composition ├── schemas.ts # All Zod schemas (centralized) │ ├── routes/ # OpenAPI route definitions │ ├── create-{resource}.ts │ ├── list-{resources}.ts │ ├── get-{resource}.ts │ ├── update-{resource}.ts │ └── delete-{resource}.ts │ ├── handlers/ # HTTP handlers (1:1 with routes) │ ├── create-{resource}.ts │ ├── list-{resources}.ts │ ├── get-{resource}.ts │ ├── update-{resource}.ts │ └── delete-{resource}.ts │ ├── services/ │ └── {resource}-service.ts # Business logic │ ├── factories/ │ └── {resource}-factory.ts # Data transformation │ └── interfaces/ └── {resource}-interface.ts # Type definitions ``` ## Route Definition ```typescript // routes/create-workspace.ts import { createRoute, z } from "@hono/zod-openapi"; import { createWorkspaceSchema, workspaceResponseSchema } from "../schemas.js"; export const createWorkspaceRoute = createRoute({ path: "/workspaces", method: "post", tags: ["Workspaces"], summary: "Create workspace", description: "Create a new workspace for the tenant", request: { body: { content: { "application/json": { schema: createWorkspaceSchema, }, }, }, }, responses: { 201: { description: "Workspace created successfully", content: { "application/json": { schema: workspaceResponseSchema, }, }, }, 400: { description: "Invalid input" }, 401: { description: "Unauthorized" }, 409: { description: "Workspace already exists" }, 500: { description: "Internal server error" }, }, }); export type CreateWorkspaceRoute = typeof createWorkspaceRoute; ``` ## Handler Implementation ```typescript // handlers/create-workspace.ts import type { AppRouteHandler } from "@/types/openapi.js"; import type { CreateWorkspaceRoute } from "../routes/create-workspace.js"; import { createWorkspace } from "../services/workspace-service.js"; import { formatWorkspaceResponse } from "../factories/workspace-factory.js"; import { WorkspaceExistsError } from "@/libs/core/errors.js"; import { logger } from "@/libs/core/logger.js"; export const createWorkspaceHandler: AppRouteHandler< CreateWorkspaceRoute > = async (c) => { const tenantDb = c.get("tenantDb"); const sessionUser = c.get("user"); const body = c.req.valid("json"); try { const result = await createWorkspace(tenantDb, body, sessionUser); const response = formatWorkspaceResponse(result.workspace); logger.info("Workspace created", { workspaceId: result.workspace.id, userId: sessionUser.id, }); return c.json(response, 201); } catch (error) { if (error instanceof WorkspaceExistsError) { return c.json(error.toJSON(), error.status); } logger.error( "Failed to create workspace", { userId: sessionUser.id }, error as Error ); return c.json( { code: "INTERNAL_ERROR", message: "Failed to create workspace", status: 500, }, 500 ); } }; ``` ## Service Layer (with Transactions) ```typescript // services/workspace-service.ts import { nanoid } from "nanoid"; import { eq } from "drizzle-orm"; import type { Database } from "@/types/database.js"; import { workspaces } from "@/db/schema.js"; import { WorkspaceExistsError } from "@/libs/core/errors.js"; import { createAuditLog, AuditActionType } from "@/libs/core/audit.js"; import type { CreateWorkspaceInput, WorkspaceEntity, SessionUser, } from "../interfaces/workspace-interface.js"; export async function createWorkspace( db: Database, input: CreateWorkspaceInput, sessionUser: SessionUser ): Promise<{ workspace: WorkspaceEntity }> { const now = new Date(); // TRANSACTION: Wrap all mutations const result = await db.transaction(async (tx) => { // Check for duplicates const existing = await tx.query.workspaces.findFirst({ where: eq(workspaces.name, input.name), }); if (existing) { throw new WorkspaceExistsError(); } // Create workspace const [workspace] = await tx .insert(workspaces) .values({ id: nanoid(12), name: input.name, description: input.description || null, userId: sessionUser.id, createdAt: now, updatedAt: now, }) .returning(); return workspace; }); // Audit log OUTSIDE transaction await createAuditLog({ userId: sessionUser.id, action: AuditActionType.WORKSPACE_CREATE, resource: "workspace", resourceId: result.id, }); return { workspace: toWorkspaceEntity(result) }; } ``` ## Factory (Data Transformation) ```typescript // factories/workspace-factory.ts import type { WorkspaceEntity } from "../interfaces/workspace-interface.js"; // DB record → Entity export function toWorkspaceEntity(dbRecord: any): WorkspaceEntity { return { id: dbRecord.id, name: dbRecord.name, description: dbRecord.description, userId: dbRecord.userId, createdAt: dbRecord.createdAt, updatedAt: dbRecord.updatedAt, }; } // Entity → API response export function formatWorkspaceResponse(entity: WorkspaceEntity) { return { id: entity.id, name: entity.name, description: entity.description, createdAt: entity.createdAt.toISOString(), updatedAt: entity.updatedAt.toISOString(), }; } ``` ## Interface (Types) ```typescript // interfaces/workspace-interface.ts export interface WorkspaceEntity { id: string; name: string; description: string | null; userId: string; createdAt: Date; updatedAt: Date; } export interface CreateWorkspaceInput { name: string; description?: string; } export interface SessionUser { id: string; name: string; role: string; } ``` ## Centralized Schemas ```typescript // schemas.ts import { z } from "@hono/zod-openapi"; // Request schemas export const createWorkspaceSchema = z.object({ name: z.string().min(1).max(100), description: z.string().max(500).optional(), }); export const updateWorkspaceSchema = z.object({ name: z.string().min(1).max(100).optional(), description: z.string().max(500).optional(), }); // Response schemas export const workspaceResponseSchema = z.object({ id: z.string(), name: z.string(), description: z.string().nullable(), createdAt: z.string().datetime(), updatedAt: z.string().datetime(), }); export const workspaceListResponseSchema = z.object({ items: z.array(workspaceResponseSchema), }); // Error schema export const errorResponseSchema = z.object({ code: z.string(), message: z.string(), status: z.number(), }); ``` ## Module Router ```typescript // index.ts import { createRouter } from "@/libs/core/factory.js"; import { requireAuth } from "@/middlewares/auth.js"; import { createWorkspaceRoute } from "./routes/create-workspace.js"; import { listWorkspacesRoute } from "./routes/list-workspaces.js"; import { getWorkspaceRoute } from "./routes/get-workspace.js"; import { createWorkspaceHandler } from "./handlers/create-workspace.js"; import { listWorkspacesHandler } from "./handlers/list-workspaces.js"; import { getWorkspaceHandler } from "./handlers/get-workspace.js"; const router = createRouter(); // Apply auth middleware router.use("/workspaces/*", requireAuth); router.use("/workspaces", requireAuth); // Register routes router.openapi(createWorkspaceRoute, createWorkspaceHandler); router.openapi(listWorkspacesRoute, listWorkspacesHandler); router.openapi(getWorkspaceRoute, getWorkspaceHandler); export default router; ``` ## Multi-Tenant Pattern Always use `c.get('tenantDb')` for database access: ```typescript export const getWorkspaceHandler: AppRouteHandler = async ( c ) => { const tenantDb = c.get("tenantDb"); // ✅ Tenant-specific DB const { id } = c.req.valid("param"); const workspace = await getWorkspaceById(tenantDb, id); if (!workspace) { return c.json( { code: "NOT_FOUND", message: "Workspace not found", status: 404 }, 404 ); } return c.json(formatWorkspaceResponse(workspace)); }; ``` ## Error Classes ```typescript // libs/core/errors.ts export class AppError extends Error { code: string; status: number; constructor(code: string, message: string, status: number) { super(message); this.code = code; this.status = status; } toJSON() { return { code: this.code, message: this.message, status: this.status, }; } } export class WorkspaceExistsError extends AppError { constructor() { super("WORKSPACE_EXISTS", "A workspace with this name already exists", 409); } } export class WorkspaceNotFoundError extends AppError { constructor() { super("WORKSPACE_NOT_FOUND", "Workspace not found", 404); } } ``` ## Response Formats ```typescript // Single item (POST/GET) return c.json(formatWorkspaceResponse(workspace), 201); // → { id, name, description, createdAt, updatedAt } // List (GET) return c.json({ items: workspaces.map(formatWorkspaceResponse) }); // → { items: [{...}, {...}] } // Error return c.json(error.toJSON(), error.status); // → { code: 'WORKSPACE_NOT_FOUND', message: '...', status: 404 } ``` ## Critical Rules 1. **Always use `c.get('tenantDb')`** — Never access shared DB for tenant data 2. **Always throw AppError classes** — Never return manual JSON errors 3. **Always use structured logger** — Never use console.log 4. **Always validate with Zod** — Never trust raw input 5. **Handlers delegate to services** — No business logic in handlers 6. **Transactions for mutations** — Wrap all write operations 7. **Audit logs outside transactions** — Log after commit ## Layer Rules | Layer | Responsibility | Max Lines | Can Import | |-------|---------------|-----------|------------| | Route | OpenAPI specs, middleware | ~50 | Schemas | | Handler | Validation, auth, response | ~80 | Services, Factories | | Service | Business logic, transactions | ~200 | Factories, DB | | Factory | Data transformation | ~100 | Interfaces | | Interface | Type definitions | ~50 | Nothing | ## Pagination Pattern ```typescript // schemas.ts export const paginationQuerySchema = z.object({ page: z.coerce.number().int().positive().default(1), limit: z.coerce.number().int().positive().max(100).default(20), sortBy: z.enum(['name', 'createdAt', 'updatedAt']).default('createdAt'), sortOrder: z.enum(['asc', 'desc']).default('desc'), }); // handler const query = c.req.valid('query'); const page = query.page ?? 1; const limit = query.limit ?? 20; const offset = (page - 1) * limit; const result = await listWorkspaces(tenantDb, { limit, offset }); return c.json({ items: result.items, pagination: { page, limit, total: result.total, hasMore: result.hasMore, }, }); // service import { sql, desc, asc } from 'drizzle-orm'; export async function listWorkspaces( db: Database, options: { limit: number; offset: number } ) { const [items, countResult] = await Promise.all([ db.query.workspaces.findMany({ limit: options.limit, offset: options.offset, orderBy: desc(workspaces.createdAt), }), db .select({ count: sql`count(*)` }) .from(workspaces), ]); return { items: items.map(toWorkspaceEntity), total: countResult[0]?.count || 0, hasMore: options.offset + items.length < (countResult[0]?.count || 0), }; } ``` ## Filtering Pattern ```typescript // schemas.ts export const workspaceFilterSchema = z.object({ search: z.string().optional(), status: z.enum(['active', 'archived']).optional(), userId: z.string().optional(), }); // handler const filters = c.req.valid('query'); const result = await listWorkspaces(tenantDb, { ...filters, limit, offset }); // service import { and, or, ilike, eq, isNull } from 'drizzle-orm'; export async function listWorkspaces( db: Database, options: ListWorkspacesOptions ) { const conditions = []; // Search filter if (options.search) { conditions.push(ilike(workspaces.name, `%${options.search}%`)); } // Status filter if (options.status) { conditions.push(eq(workspaces.status, options.status)); } // User filter if (options.userId) { conditions.push(eq(workspaces.userId, options.userId)); } // Exclude soft-deleted conditions.push(isNull(workspaces.deletedAt)); const whereClause = conditions.length > 0 ? and(...conditions) : undefined; const items = await db.query.workspaces.findMany({ where: whereClause, limit: options.limit, offset: options.offset, }); return { items: items.map(toWorkspaceEntity) }; } ``` ## Bulk Operations Pattern ```typescript // schemas.ts export const bulkCreateWorkspacesSchema = z.object({ workspaces: z.array(createWorkspaceSchema).min(1).max(50), }); // handler export const bulkCreateWorkspacesHandler: AppRouteHandler< BulkCreateWorkspacesRoute > = async (c) => { const tenantDb = c.get('tenantDb'); const sessionUser = c.get('user'); const { workspaces } = c.req.valid('json'); try { const result = await bulkCreateWorkspaces(tenantDb, workspaces, sessionUser); return c.json({ items: result.workspaces }, 201); } catch (error) { logger.error('Bulk create failed', { count: workspaces.length }, error as Error); return c.json({ code: 'BULK_CREATE_FAILED', message: 'Failed to create workspaces', status: 500, }, 500); } }; // service (with transaction) export async function bulkCreateWorkspaces( db: Database, inputs: CreateWorkspaceInput[], sessionUser: SessionUser ): Promise<{ workspaces: WorkspaceEntity[] }> { const now = new Date(); const results = await db.transaction(async (tx) => { const workspaces = []; for (const input of inputs) { // Check duplicates const existing = await tx.query.workspaces.findFirst({ where: eq(workspaces.name, input.name), }); if (existing) { throw new WorkspaceExistsError(); } // Insert const [workspace] = await tx .insert(workspaces) .values({ id: nanoid(12), name: input.name, description: input.description || null, userId: sessionUser.id, createdAt: now, updatedAt: now, }) .returning(); workspaces.push(workspace); } return workspaces; }); // Audit log outside transaction await createAuditLog({ userId: sessionUser.id, action: AuditActionType.WORKSPACES_BULK_CREATE, resource: 'workspace', metadata: { count: results.length }, }); return { workspaces: results.map(toWorkspaceEntity) }; } ``` ## Quick References See `references/` for detailed patterns: - `route-pattern.md` - Route definition patterns and OpenAPI specs - `handler-pattern.md` - Complete handler structure - `service-pattern.md` - Service layer patterns - `factory-pattern.md` - Factory patterns - `error-handling.md` - Error handling standards