Incomplete, this templates serves as a testing space for theories.
A complete-but-approachable backend starter for Express + TypeScript. It demonstrates clean architecture, dependency injection, real auth, database access, messaging, and testing—without getting lost in framework magic.
This README is a full guide for beginners and future you. It shows what each part does, how to run it, how to build features, and how to grow it into production.
- What You Get
- Quickstart (Local)
- Project Structure
- How the App Boots
- Environment Files (Multi-env)
- Auth (Better Auth)
- Database (Postgres + Drizzle)
- Migrations (Drizzle + Better Auth)
- Messaging (RabbitMQ)
- Worker
- HTTP Layer (Controllers, Routes, Middleware)
- Dependency Injection (DI)
- Testing
- Lint / Format / Typecheck
- Docker
- Style Guide
- Build a Feature (Step-by-step)
- Troubleshooting
- Production Readiness
- TypeScript-first: strict settings + ESM-ready build.
- Validated env: typed, validated config via Zod (
src/config/env.ts). - Structured logging: Pino with pretty logs in development.
- Real auth: Better Auth with sessions + bearer + JWT (see details below).
- Database: Postgres + Drizzle ORM, migrations included.
- Messaging: RabbitMQ publisher + worker example.
- DI example: controllers/services wired in
createApp. - Tests: unit, integration (DB), and e2e (HTTP).
- Docker: production Dockerfile + local Compose.
- Lint/format: ESLint v9 + Prettier.
- Copy env template
cp .env.example .env
- Generate a secret for Better Auth
openssl rand -base64 32
Paste it into BETTER_AUTH_SECRET in .env.
- Start Postgres + RabbitMQ
docker compose up -d db rabbitmq
- Install dependencies
npm install
- Generate migrations and migrate (Drizzle)
npm run db:generate
npm run db:migrate
- Generate Better Auth tables
SKIP_ENV_VALIDATION=true npm run auth:generate
SKIP_ENV_VALIDATION=true npm run auth:migrate
Note: DATABASE_URL must be set so the CLI can connect. The skip flag only bypasses app runtime validation.
- Start the dev server
npm run dev
See USAGE_GUIDE.md for a complete walkthrough with curl examples,
responses, and a full feature build guide.
template/
├── compose.yaml
├── Dockerfile
├── drizzle/ # SQL migrations
├── eslint.config.js
├── package.json
├── tsconfig.json
├── src/
│ ├── server.ts # server startup
│ ├── worker.ts # RabbitMQ worker example
│ ├── auth/
│ │ └── auth.ts
│ ├── config/
│ │ ├── env.ts
│ │ └── logger.ts
│ ├── http/
│ │ ├── app.ts
│ │ ├── routes.ts
│ │ ├── routes/
│ │ │ ├── auth.routes.ts
│ │ │ ├── health.routes.ts
│ │ │ └── notes.routes.ts
│ │ ├── controllers/
│ │ │ ├── auth.controller.ts
│ │ │ └── notes.controller.ts
│ │ └── middleware/
│ │ ├── error.ts
│ │ ├── requestId.ts
│ │ └── requireSession.ts
│ ├── app/
│ │ ├── dto/
│ │ │ └── notes.ts
│ │ └── services/
│ │ └── notes.service.ts
│ ├── domain/
│ │ ├── entities/
│ │ │ └── note.ts
│ │ ├── events/
│ │ │ └── eventPublisher.ts
│ │ └── repositories/
│ │ └── noteRepository.ts
│ ├── infra/
│ │ ├── db/
│ │ │ ├── index.ts
│ │ │ ├── pool.ts
│ │ │ └── schema.ts
│ │ ├── events/
│ │ │ ├── noopEventPublisher.ts
│ │ │ └── rabbitmqEventPublisher.ts
│ │ ├── queue/
│ │ │ └── rabbitmq.ts
│ │ └── repos/
│ │ ├── inMemory/
│ │ │ └── noteRepository.ts
│ │ └── postgres/
│ │ └── noteRepository.ts
│ └── shared/
│ └── errors.ts
└── tests/
├── unit/
├── integration/
└── e2e/
The app starts in src/server.ts:
- Load env via
src/config/env.ts - Create the Express app with
createApp() - Attach middleware + routes
- Start listening on
PORT
Key entry points:
- HTTP:
src/http/app.ts - Worker:
src/worker.ts
The app auto-loads env files in this order:
.env.env.{NODE_ENV}.env.local(overrides).env.{NODE_ENV}.local(overrides)
This lets you keep production secrets separate from local dev.
We use Better Auth with:
- Cookie sessions for browsers
- Bearer tokens for mobile/CLI
- JWTs for service-to-service calls
Auth endpoints are mounted under /api/auth/*:
POST /api/auth/sign-up/emailPOST /api/auth/sign-in/emailGET /api/auth/get-sessionPOST /api/auth/sign-out
We also provide a simple app endpoint:
GET /api/v1/me→ returnsreq.session
- Sessions are stored in the database.
- Cookies are
httpOnly,securein production, andSameSite=Laxby default. - Use this for any web frontend on the same domain.
- Better Auth returns
set-auth-tokenin response headers. - Store that token in your client, send it as:
Authorization: Bearer <token> requireSessionworks with cookies or bearer tokens.
- JWTs are short-lived and are meant for services, not browsers.
- Get a token using the Better Auth client or
GET /api/auth/token. - Validate via
/api/auth/jwksif needed.
AUTH_SESSION_EXPIRES_IN=604800 # seconds (7 days)
AUTH_SESSION_UPDATE_AGE=86400 # seconds (1 day)
AUTH_SESSION_FRESH_AGE=86400 # seconds (1 day)
AUTH_JWT_EXPIRATION=15m # duration string
Better Auth uses the Postgres public schema by default. To see tables:
psql postgresql://app:app@localhost:5432/app
\dt
Better Auth is backend-first. There is no official admin UI, but community tools exist. You can always query the database directly or build your own admin module.
- Schema:
src/infra/db/schema.ts - Client:
src/infra/db/index.ts - Migrations:
drizzle/ - Repository:
src/infra/repos/postgres/noteRepository.ts
Switch note persistence:
NOTES_REPOSITORY=postgres # or memory
Generate migration files from schema changes:
npm run db:generate
Apply migrations:
npm run db:migrate
Generate auth tables:
SKIP_ENV_VALIDATION=true npm run auth:generate
Apply auth migrations:
SKIP_ENV_VALIDATION=true npm run auth:migrate
The template includes a minimal event publisher and a worker example.
- Publisher:
src/infra/events/rabbitmqEventPublisher.ts - Client:
src/infra/queue/rabbitmq.ts - Worker:
src/worker.ts
If RABBITMQ_URL is not set, we fallback to a no-op publisher.
The worker is a separate process that listens for events.
Build and run:
npm run build
npm run worker
The worker is intentionally small. It’s a teaching example for:
- how to connect to RabbitMQ
- how to process events
- how to log and shut down gracefully
Controllers call services and return responses. Example:
NotesControllervalidates input and callsNotesService
Services contain business logic. Example:
NotesServicehandles validation, persistence, and publishing events
requestIdadds a request ID to every requestrequireSessionensures the user is authenticatederrorHandlerhandles errors consistently
createApp accepts dependencies so you can:
- swap repositories (memory vs postgres)
- swap publishers (rabbitmq vs noop)
- test controllers/services in isolation
This keeps your code clean and makes it easier to test.
npm run test:unit
npm run test:integration
npm run test:e2e
npm run lint
npm run format
npm run typecheck:tests
Build/run the API + dependencies locally:
docker compose up --build
compose.yamldefines the services you want to run together (API, Postgres, RabbitMQ).dbuses a named volume so your data persists.apidepends ondbandrabbitmqso it starts after them.
See STYLE_GUIDE.md for coding conventions used throughout this template.
Example: Add a Tasks feature.
-
Domain
- Create
src/domain/entities/task.ts - Define the
Tasktype
- Create
-
Repository interface
- Add
src/domain/repositories/taskRepository.ts
- Add
-
Infra implementation
- Add
src/infra/repos/postgres/taskRepository.ts - Add
src/infra/repos/inMemory/taskRepository.ts
- Add
-
Database schema
- Update
src/infra/db/schema.ts - Run:
npm run db:generate npm run db:migrate
- Update
-
Service
- Create
src/app/services/tasks.service.ts
- Create
-
Controller
- Create
src/http/controllers/tasks.controller.ts
- Create
-
Routes
- Create
src/http/routes/tasks.routes.ts - Add it to
src/http/routes.ts
- Create
-
Tests
- Unit test service logic
- Integration test the repository
- E2E test the HTTP endpoints
That’s the full path from domain → infra → HTTP.
If your Docker cache is corrupted or the platform is wrong, Postgres may crash. Try:
DOCKER_DEFAULT_PLATFORM=linux/amd64 docker compose up --build
Or override the image:
POSTGRES_IMAGE=postgres:18.1-bookworm docker compose up --build
Make sure DATABASE_URL is set, then run:
SKIP_ENV_VALIDATION=true npm run auth:migrate
See PRODUCTION_GAPS.md for everything missing before a real production launch.
This template is a starting point, not a framework. It gives you realistic defaults and clear examples so you can grow it safely as your app gets more complex.