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
# Install dependencies
pnpm install
# Copy environment variables
cp .env.example .env
# Start development server
pnpm dev
# Start Temporal worker (in another terminal)
pnpm worker:devCreate 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-dsnsrc/
├── 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
| 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 |
// 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);
});
};// 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
}),
});// 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);
};// 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)));
};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);
}),
);
});| 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 |
Errors use class-level metadata for HTTP status, log level, and Sentry reporting. Each error class represents ONE specific failure mode.
// 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,
},
) {}// 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()
}),
})
);| 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
httpStatusto the client - Derives the error
codefrom the HTTP status (e.g., 404 -> "not_found") - Logs at the class-level
logLevel - Reports to Sentry only if class-level
report: true
- Public docs:
GET /v1/docs - Internal docs (requires basic auth):
GET /v1/docs/all