Skip to content

jackblatch/hono-effect-starter

Repository files navigation

Hono Effect Starter

A production-ready TypeScript backend starter kit built with:

  • Hono - Fast web framework
  • Effect - Type-safe functional programming
  • Drizzle ORM - Type-safe database queries
  • Temporal - Durable workflow execution
  • Sentry - Error tracking and logging

Quick Start

# Install dependencies
pnpm install

# Copy environment variables
cp .env.example .env

# Start development server
pnpm dev

# Start Temporal worker (in another terminal)
pnpm worker:dev

Environment Variables

Create a .env file with the following variables:

# Core
ENVIRONMENT=local
LOG_LEVEL=info

# Database (MySQL/PlanetScale)
DB_CONNECTION_STRING=mysql://user:password@host/database

# Auth
JWT_SECRET=your-secret-key

# API Docs (optional)
API_DOCS_USERNAME=admin
API_DOCS_PASSWORD=admin

# Temporal
TEMPORAL_HOST=localhost:7233
TEMPORAL_NAMESPACE=default

# Sentry (optional)
SENTRY_DSN=your-sentry-dsn

Project Structure

src/
├── index.ts                    # Main entry point
├── env.ts                      # Environment configuration
├── db/
│   ├── database.ts             # Drizzle ORM connections
│   ├── schema.ts               # Schema aggregator
│   └── schema/                 # Table schemas
├── routes/
│   └── v1/
│       ├── health/             # Health check endpoints
│       ├── docs/               # API documentation
│       └── items/              # Example CRUD routes
├── domains/
│   └── item/                   # Example domain
│       ├── repository.ts       # Data access layer
│       ├── service.ts          # Service layer
│       └── errors.ts           # Domain errors
├── temporal/
│   ├── index.ts                # Workflow registry
│   ├── worker.ts               # Temporal worker
│   ├── service.ts              # Temporal client service
│   └── queues/                 # Workflow definitions
├── middleware/                 # Hono middleware
├── services/                   # Shared services
├── lib/                        # Shared libraries
└── utils/                      # Utility functions

Scripts

Command Description
pnpm dev Start development server
pnpm build Build for production
pnpm start Start production server
pnpm worker:dev Start Temporal worker (dev)
pnpm worker:prod Start Temporal worker (prod)
pnpm test Run tests
pnpm lint Run ESLint
pnpm typecheck Run TypeScript type check
pnpm db:push Push schema to database

Key Patterns

Route Pattern

// src/routes/v1/items/create.ts
import { describeRoute, validator } from "hono-openapi";
import { Schema, Effect, Layer } from "effect";

// 1. Define schemas (snake_case for API)
const requestSchema = Schema.standardSchemaV1(
    Schema.Struct({ name: Schema.String })
);

// 2. Business logic in run() for testing
export const run = (args: { user: User | null; name: string }) => {
    return Effect.gen(function* () {
        if (!args.user) {
            return yield* Effect.fail(new AuthorizationHeaderMissing({
                message: "Authentication required",
                httpStatus: 401,
                logLevel: "warning",
                report: false,
            }));
        }
        const repo = yield* ItemRepoService;
        return yield* repo.create({ name: args.name });
    });
};

// 3. Handler wires layers and runs Effect
export const _createItem = (app: Hono) => {
    app.post("/", validator("json", requestSchema), async (c) => {
        const layers = Layer.provideMerge(createItemRepoLayer(), createDefaultLayers({ db: Database.primary }));
        const result = await Effect.runPromise(run({ ... }).pipe(Effect.provide(layers), Effect.either));
        return c.json(unwrapEither(result), 201);
    });
};

Repository Pattern

// src/domains/item/repository.ts
export const createItemRepository = () => ({
    getById: (args: { id: string }) =>
        Effect.gen(function* () {
            const db = yield* DatabaseService;
            // ... query logic
        }),
    create: (args: { name: string }) =>
        Effect.gen(function* () {
            const db = yield* DatabaseService;
            const idService = yield* IdService;
            const id = yield* idService.generatePrimaryKeyId({ table: "item" });
            // ... insert logic
        }),
});

Temporal Workflow Pattern

// src/temporal/queues/example-queue/example-workflow/workflow.ts
import { proxyActivities } from "@temporalio/workflow";
import type * as activities from "./activities";

const { processDataActivity } = proxyActivities<typeof activities>({
    startToCloseTimeout: "5 minutes",
    retry: { maximumAttempts: 3 },
});

export const ExampleWorkflow = async (input: Input): Promise<Output> => {
    return await processDataActivity(input);
};

Activity Pattern

// src/temporal/queues/example-queue/example-workflow/activities/process-data.ts
// Business logic in run() for testability
export const run = (input: Input) => {
    return Effect.gen(function* () {
        // ... business logic
    });
};

// Activity export (Temporal calls this)
export const processDataActivity = async (input: Input): Promise<Output> => {
    return await Effect.runPromise(run(input).pipe(Effect.provide(EnvLayer)));
};

Testing

Tests use @effect/vitest (not vitest directly):

import { describe, expect, it } from "@effect/vitest";
import { Effect, Exit, Layer } from "effect";

describe("create-item", () => {
    it.effect("should create item successfully", () =>
        Effect.gen(function* () {
            const mockLayer = Layer.succeed(ItemRepoService, mockRepo);
            const result = yield* Effect.exit(run({ ... }).pipe(Effect.provide(mockLayer)));
            expect(Exit.isSuccess(result)).toBe(true);
        }),
    );
});

Naming Conventions

Context Convention Example
Files kebab-case get-items.ts
API snake_case created_at
TypeScript camelCase createdAt
Route handlers _ prefix _createItem
Services PascalCase + Service ItemRepoService
Errors PascalCase + Error ItemNotFoundError

Error Handling

Errors use class-level metadata for HTTP status, log level, and Sentry reporting. Each error class represents ONE specific failure mode.

Defining Errors

// src/domains/item/errors.ts
import { ErrorUtils } from "@/utils/error";

// HTTP errors - include httpStatus for API responses
export class ItemNotFoundError extends ErrorUtils.createTaggedError<
    "ItemNotFoundError",
    { itemId: string }
>("ItemNotFoundError", {
    httpStatus: 404,
    logLevel: "warning",
    report: false,
}) {}

export class ItemFetchError extends ErrorUtils.createTaggedError<
    "ItemFetchError",
    { itemId: string }
>("ItemFetchError", {
    httpStatus: 500,
    logLevel: "error",
    report: true,
}) {}

// Worker errors - no httpStatus (for Temporal, startup, etc.)
export class TemporalConnectionError extends ErrorUtils.createWorkerError(
    "TemporalConnectionError",
    {
        logLevel: "error",
        report: true,
    },
) {}

Using Errors

// Instance only needs message, sourceError (optional), and domain fields
return (
    yield *
    Effect.fail(
        new ItemNotFoundError({
            message: "Item not found",
            itemId: "item_123",
        }),
    )
);

// sourceError accepts raw unknown - automatically normalized
return (
    yield *
    Effect.tryPromise({
        try: () => db.select().from(item).where(eq(item.id, id)),
        catch: (error) =>
            new ItemFetchError({
                message: "Failed to fetch item",
                itemId: id,
                sourceError: error, // No need to wrap with ErrorUtils.createSourceError()
            }),
    })
);

Error Metadata

Field Location Description
httpStatus Class-level HTTP status code (only for createTaggedError)
logLevel Class-level Log level: debug, info, warning, error, fatal
report Class-level Whether to report to Sentry
message Instance Human-readable error message
sourceError Instance Original error (accepts unknown, auto-normalized)

The error middleware automatically:

  • Returns the class-level httpStatus to the client
  • Derives the error code from the HTTP status (e.g., 404 -> "not_found")
  • Logs at the class-level logLevel
  • Reports to Sentry only if class-level report: true

API Documentation

  • Public docs: GET /v1/docs
  • Internal docs (requires basic auth): GET /v1/docs/all

About

A production-ready TypeScript api starter kit built with Effect, Hono, Temporal, Drizzle, PlanetScale

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors