express-oop-framework
v0.1.2
Published
Production-grade Express + Drizzle OOP framework — type-safe routers, Better Auth, opt-in Redis/RabbitMQ broker, module generator
Maintainers
Readme
express-oop-framework
Production-grade, developer-first Node.js framework (ESM) wrapped around Express + Drizzle ORM (Postgres) + Zod + Pino.
- Custom JWT auth (bcrypt) + Google/GitHub/Facebook social login — no auth vendor
- Type-safe routers — Zod schemas validated and inferred into handlers
- Layered by design — Router → Service (
BaseService,this.db) → Drizzle, enforced by convention + scaffolding - Opt-in events — Redis/RabbitMQ broker, safe no-op when disabled
- Scaffolding CLIs —
inita full project, generate modules in one command
Requires Node >= 20 and a Postgres database.
Install
npm install express-oop-frameworkScaffold a new project (recommended)
npx express-framework init my-api
cd my-api
npm install
npm run db:push # needs Postgres + DATABASE_URL in .env (a .env is created for you)
npm run dev # http://localhost:3000init --here scaffolds into the current directory; --force allows non-empty
ones. You get a runnable API: server entry, validated config, the framework's
users table, an empty modules/ directory waiting for your first module, and
a .env with a fresh random JWT_SECRET. Generate a module with
npx express-framework <name> — it lands in src/modules/<name> with Zod
schemas, a BaseService subclass and an AppRouter, and the CLI prints the
app.mountRouter(...) line for src/index.ts.
Resulting structure:
my-api/
├── package.json / tsconfig.json / drizzle.config.ts / .env(.example) / README.md
└── src/
├── index.ts # server — wires App, auth, broker, services
├── config/
│ ├── env.ts # validated env (DATABASE_URL, JWT_SECRET, …)
│ ├── db.ts # Drizzle client + Db type
│ └── schema.ts # tables (re-exports framework users table)
└── modules/ # one folder per domain — generate with the CLI
└── <name>/
├── <name>.schema.ts # Zod input schemas (inferred into handlers)
├── <name>.service.ts # business logic — BaseService subclass (this.db)
└── <name>.router.ts # URLs + middlewares → service (AppRouter)Where each concern lives:
| Concern | Location |
|---|---|
| Schema (Zod input/output) | *.schema.ts in each module |
| Service (business logic + Drizzle queries) | *.service.ts in each module — extends BaseService<Db>, uses this.db |
| Router (URLs + Zod validation) | *.router.ts in each module — new AppRouter<Db>('/prefix') |
| Middleware / guards | framework Middleware / BaseMiddleware subclasses + auth guards, attached per route via use: [] |
| Server wiring | src/index.ts (App bootstrapper: middleware order, error handler, listen) |
Use in your project (manual setup)
Minimal app (src/index.ts):
import { App, createDb } from 'express-oop-framework';
import { createAuth, createAuthGuards, createAuthRouter, createResetEmailSender } from 'express-oop-framework/auth';
import * as schema from './schema.js';
const { pool, db } = createDb(process.env.DATABASE_URL!, schema);
const auth = createAuth({
db,
jwtSecret: process.env.JWT_SECRET!, // openssl rand -base64 32 (min 32 chars)
jwtExpiresIn: '7d',
baseURL: 'http://localhost:3000', // public API URL (OAuth callbacks)
frontendURL: 'http://localhost:3000',
// google: { clientId: '...', clientSecret: '...' },
// github: { clientId: '...', clientSecret: '...' },
// facebook: { clientId: '...', clientSecret: '...' },
sendResetPassword: createResetEmailSender(null), // null = log reset links (dev)
});
const guards = createAuthGuards(auth);
const app = new App(db, {
auth: { router: createAuthRouter({ auth, guards, frontendURL: 'http://localhost:3000' }) },
services: { auth }, // exposed to every handler as ctx.services.auth
// broker: { type: 'redis', redisUrl: 'redis://localhost:6379' }, // opt-in
});
const server = app.listen(3000, () => console.log('Listening on :3000'));
process.on('SIGTERM', () => void (async () => {
server.close();
await app.close(); // closes broker connections
await pool.end();
process.exit(0);
})());Your schema (src/schema.ts) — re-export the auth tables plus your own:
export * from 'express-oop-framework/schema';
import { pgTable, uuid, varchar, integer, timestamp } from 'drizzle-orm/pg-core';
export const products = pgTable('products', {
id: uuid('id').defaultRandom().primaryKey(),
name: varchar('name', { length: 255 }).notNull(),
price: integer('price').notNull().default(0),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});Then npm run db:push (with drizzle-kit + a drizzle.config.ts pointing at your
schema) to create tables, including the framework's users table.
Configuration
All env vars are validated with Zod at startup — bad config crashes fast with a
clear message. Copy .env.example to .env and fill it in.
| Variable | Default | Description |
|---|---|---|
| NODE_ENV | development | development \| production \| test |
| PORT | 3000 | HTTP port |
| DATABASE_URL | — (required) | Postgres connection string |
| JWT_SECRET | — (required, ≥ 32 chars) | HS256 signing secret. Generate: openssl rand -base64 32 |
| JWT_EXPIRES_IN | 7d | Token lifetime (passed to jsonwebtoken) |
| API_URL | http://localhost:{PORT} | Public base URL of the API — OAuth callback URLs are built from this |
| FRONTEND_URL | http://localhost:3000 | Password-reset links + social ?format=redirect target |
| BROKER_TYPE | none | none \| redis \| rabbitmq (event broker is opt-in) |
| REDIS_URL / RABBITMQ_URL | — | Set the one matching BROKER_TYPE |
| GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET | — | Only configured providers are enabled |
| GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET | — | |
| FACEBOOK_CLIENT_ID / FACEBOOK_CLIENT_SECRET | — | |
| SMTP_HOST / SMTP_PORT / SMTP_USER / SMTP_PASS / SMTP_FROM | (FROM: [email protected]) | When SMTP_HOST is unset, reset links are logged to the console instead of emailed |
Auth
Password auth + Google/GitHub/Facebook social login, served under /auth/*.
Tokens are HS256 JWTs ({ sub, email, role }) sent as Authorization: Bearer <token>.
Responses never include password hashes or reset tokens.
| Method + path | Protection | Body | Returns |
|---|---|---|---|
| POST /auth/signup | rate-limited (10 req / 15 min) | { email, password (min 8), name? } | { user, token } |
| POST /auth/signin | rate-limited | { email, password } | { user, token } |
| GET /auth/me | Bearer token required | — | current user |
| GET /auth/session | optional token | — | { user } or { user: null } |
| POST /auth/forgot-password | rate-limited | { email } | Always { success: true } (never reveals if the email exists) |
| POST /auth/reset-password | rate-limited | { token, password (min 8) } | { success: true } (tokens expire after 1 hour) |
| GET /auth/providers | — | — | { providers: [...] } (enabled social providers) |
| GET /auth/social/:provider | — | — | 302 redirect to the provider's login page |
| GET /auth/social/:provider/callback | — | ?code=…&state=… | { user, token } — or with ?format=redirect, a 302 to {FRONTEND_URL}/auth/social/callback?token=…&provider=… |
Protecting your own routes
import { AppRouter } from 'express-oop-framework';
// guards = createAuthGuards(auth) in src/index.ts
export const adminRouter = new AppRouter('/admin');
adminRouter.get('/dashboard', {
use: [guards.requireAuth], // 401 without a valid token
handler: ({ user }) => ({ hello: user!.email }),
});
adminRouter.delete('/users/:id', {
use: [guards.requireAuth, guards.requireRole('admin')], // 403 on role mismatch
schema: { params: z.object({ id: z.string().uuid() }) },
handler: ({ params }) => ({ deleted: params.id }),
});requireAuthrejects missing/invalid tokens with 401.optionalAuthattachesuserwhen a valid token is present but never rejects.requireRole(...roles)must come afterrequireAuth.- Roles come from the
rolecolumn on theuserstable (default'user').
Social login setup
- Create an OAuth app with the provider (Google / GitHub / Facebook).
- Set the matching
{GOOGLE,GITHUB,FACEBOOK}_CLIENT_ID/SECRETenv vars. - Register this redirect URI in the provider console:
{API_URL}/auth/social/{google|github|facebook}/callback - Start login at
GET /auth/social/:provider— the framework handles state (CSRF protection, 10-minute expiry), the code exchange, and account linking (by provider identity, then by email, else creates a new user).
Notes: the provider must share an email address or login fails with a clear 400 —
in particular, Facebook only returns an email when the app is granted the email
permission. Only providers with credentials set appear in GET /auth/providers.
Scaffold modules
npx express-framework product # src/modules/product/{schema,service,router}.ts
npx express-framework order --with-events # + ctx.broker event publishing exampleIn the starter layout (this repo) the router is auto-registered at the anchors in
src/framework/app.ts; in consumer projects mount it manually:
import { orderRouter } from './modules/order/order.router.js';
app.mountRouter(orderRouter);Framework reference
The layered pattern:
- Router — URL prefix + Zod schemas (
{ params?, query?, body? }) +use: []middlewares → service. No business logic or SQL. Whatever the handler returns is sent as JSON (unless you already usedresdirectly, e.g.res.redirect()). Destructure what you need:{ db, services, body, query, params, user, broker, error }. - Service — domain rules + Drizzle queries. Extends
BaseService<Db>and gets the db client through the constructor, so queries run onthis.db. Plain classes — unit-test directly:new OrderService(fakeDb).list({ page: 1, limit: 20 }). - Middleware — class-based (subclass
BaseMiddleware) or functional, attached per route viause: [].
Declare the db type once — new AppRouter<Db>('/orders') — and every route gets a
fully typed ctx.db (ctx.services too, via the second generic). No as never
casts anywhere:
import { AppRouter } from 'express-oop-framework';
export const orderRouter = new AppRouter<Db>('/orders');
orderRouter.get('/', {
schema: listOrderSchema, // { query: z.object({...}) } — inferred into the handler
handler: ({ db, query }) => new OrderService(db).list(query),
});
orderRouter.post('/', {
use: [guards.requireAuth, guards.requireRole('admin')],
schema: createOrderSchema,
handler: async ({ db, body }) => ({ success: true, data: await new OrderService(db).create(body) }),
});A service is a plain class extending BaseService:
export class OrderService extends BaseService<Db> {
async list(query: { page: number; limit: number }) {
const items = await this.db.select().from(orders)
.limit(query.limit).offset((query.page - 1) * query.limit);
return { items, page: query.page, limit: query.limit };
}
}Container — new App(db, { services: { auth, mailer } }) publishes the db
client and services to every handler as ctx.db / ctx.services. Standalone
(skipping App), attach them where you mount routers:
AppRouter.mountRoutes(expressApp, [orderRouter, authRouter], { db, services });Middleware: class-based middleware subclasses BaseMiddleware, implements
handle() and reuses this.req / this.res / this.next() plus helpers like
this.unauthorized() / this.forbidden(); a single instance is safe to share
(each request runs on a lightweight clone). Functional alternative:
new Middleware(async ({ req, res, next }) => { … }), or
Middleware.fromHandler(expressHandler) to reuse plain Express middleware.
Event broker is disabled by default (ctx.broker is a safe no-op, no
connections). Enable with new App(db, { broker: { type: 'redis', redisUrl } })
(or 'rabbitmq' + rabbitmqUrl), pass your own IEventBroker instance, or use
MemoryEventBroker in tests. Always await app.close() on shutdown.
Rate limiting: a global limiter (200 req / 15 min) applies to all routes;
password auth endpoints get a strict 10 req / 15 min limiter. Build your own with
createRateLimiter({ windowMs, max, message }) and attach via use: [].
Errors: thrown HttpErrors become { error, details } with their status
code; Zod validation failures become 400; anything else becomes a logged 500.
Observability: Pino request logging (pretty in development), GET /health
returns { status: 'ok', uptime }.
Database workflow
npm run db:generate # SQL migrations from schema → drizzle/
npm run db:migrate # apply migrations
npm run db:push # push schema directly (dev)drizzle.config.ts points at your schema file. Your schema should re-export
express-oop-framework/schema so the users table is managed alongside yours.
Developing this package
npm install
cp .env.example .env # set DATABASE_URL + JWT_SECRET
npm run db:push
npm run dev # starter app in src/ (tsx watch)
npm run typecheck
npm run build # dist/ (published library) + bin/ (CLI)
npm run build:starter && npm start # run compiled starter
npm pack --dry-run # inspect what gets publishedSee SKILL.md for the full contributor/agent spec (API reference, conventions, troubleshooting).
Troubleshooting
- Boot crashes on env (
DATABASE_URL is required,JWT_SECRET must be at least 32 chars):.envmissing or not loaded — check the file exists and you're running from the project root. - 401 on every request: missing/expired
Authorization: Bearer <token>, or the server'sJWT_SECRETdiffers from the one that issued the token. - Social login 400 "did not share an email address": grant the email scope/permission
(Facebook:
emailpermission) or use a provider account with a verified email. - Social callback 400 (state):
API_URLdoesn't match the registered callback URL, or more than ~10 minutes passed during login. - 429 on auth routes: strict auth rate limiter (10 req / 15 min per IP).
- Reset token rejected: tokens expire after 1 hour; request a new one via
/forgot-password.
License
MIT
