Package Exports
- @spfn/core
- @spfn/core/client
- @spfn/core/codegen
- @spfn/core/db
- @spfn/core/route
- @spfn/core/scripts
- @spfn/core/server
Readme
@spfn/core
Core framework for building type-safe backend APIs with Next.js and Hono
⚠️ Alpha Release: SPFN is currently in alpha. APIs may change. Use
@alphatag for installation.
Installation
Recommended: Create New Project
npx spfn@alpha create my-appAdd to Existing Next.js Project
cd your-nextjs-project
npx spfn@alpha initManual Installation
npm install @spfn/core hono drizzle-orm postgres @sinclair/typeboxQuick Start
1. Define a Contract
// src/server/routes/users/contract.ts
import { Type } from '@sinclair/typebox';
export const getUsersContract = {
method: 'GET' as const,
path: '/',
query: Type.Object({
page: Type.Optional(Type.Number()),
limit: Type.Optional(Type.Number()),
}),
response: Type.Object({
users: Type.Array(Type.Object({
id: Type.Number(),
name: Type.String(),
email: Type.String(),
})),
total: Type.Number(),
}),
};2. Create a Route
// src/server/routes/users/index.ts
import { createApp } from '@spfn/core/route';
import { getUsersContract } from './contract.js';
import { getRepository } from '@spfn/core/db';
import { users } from '../../entities/users.js';
const app = createApp();
app.bind(getUsersContract, async (c) => {
const { page = 1, limit = 10 } = c.query;
// Get repository singleton - automatically cached
const repo = getRepository(users);
const result = await repo.findPage({
pagination: { page, limit }
});
return c.json(result);
});
export default app;3. Start Server
npm run spfn:dev
# Server starts on http://localhost:8790Architecture Pattern
SPFN follows a layered architecture that separates concerns and keeps code maintainable:
┌─────────────────────────────────────────┐
│ Routes Layer │ HTTP handlers, contracts
│ - Define API contracts (TypeBox) │
│ - Handle requests/responses │
│ - Thin handlers │
└──────────────┬──────────────────────────┘
│
┌──────────────▼──────────────────────────┐
│ Service Layer │ Business logic
│ - Orchestrate operations │
│ - Implement business rules │
│ - Use repositories │
└──────────────┬──────────────────────────┘
│
┌──────────────▼──────────────────────────┐
│ Repository Layer │ Data access
│ - CRUD operations │
│ - Custom queries │
│ - Extend base Repository │
└──────────────┬──────────────────────────┘
│
┌──────────────▼──────────────────────────┐
│ Entity Layer │ Database schema
│ - Table definitions (Drizzle) │
│ - Type inference │
│ - Schema helpers │
└─────────────────────────────────────────┘Complete Example: Blog Post System
1. Entity Layer - Define database schema
// src/server/entities/posts.ts
import { pgTable, text } from 'drizzle-orm/pg-core';
import { id, timestamps } from '@spfn/core/db';
export const posts = pgTable('posts', {
id: id(),
title: text('title').notNull(),
slug: text('slug').notNull().unique(),
content: text('content').notNull(),
status: text('status', {
enum: ['draft', 'published']
}).notNull().default('draft'),
...timestamps(),
});
export type Post = typeof posts.$inferSelect;
export type NewPost = typeof posts.$inferInsert;2. Repository Layer - Data access with custom methods
// src/server/repositories/posts.repository.ts
import { eq } from 'drizzle-orm';
import { Repository } from '@spfn/core/db';
import { posts } from '../entities';
import type { Post } from '../entities';
export class PostRepository extends Repository<typeof posts>
{
async findBySlug(slug: string): Promise<Post | null>
{
return this.findOne(eq(this.table.slug, slug));
}
async findPublished(): Promise<Post[]>
{
const results = await this.db
.select()
.from(this.table)
.where(eq(this.table.status, 'published'))
.orderBy(this.table.createdAt);
return results;
}
}3. Service Layer - Business logic (Function-based pattern)
// src/server/services/posts.ts
import { getRepository } from '@spfn/core/db';
import { ValidationError, DatabaseError, NotFoundError } from '@spfn/core';
import { posts } from '../entities';
import { PostRepository } from '../repositories/posts.repository';
import type { NewPost, Post } from '../entities';
/**
* Create a new post
*/
export async function createPost(data: {
title: string;
content: string;
}): Promise<Post> {
try {
// Get repository singleton
const repo = getRepository(posts, PostRepository);
// Business logic: Generate slug from title
const slug = generateSlug(data.title);
// Validation: Check if slug already exists
const existing = await repo.findBySlug(slug);
if (existing) {
throw new ValidationError('Post with this title already exists', {
fields: [{
path: '/title',
message: 'A post with this title already exists',
value: data.title
}]
});
}
// Create post
return await repo.save({
...data,
slug,
status: 'draft',
});
} catch (error) {
// Re-throw ValidationError as-is
if (error instanceof ValidationError) {
throw error;
}
// Wrap unexpected errors
throw new DatabaseError('Failed to create post', 500, {
originalError: error instanceof Error ? error.message : String(error)
});
}
}
/**
* Publish a post
*/
export async function publishPost(id: string): Promise<Post> {
try {
const repo = getRepository(posts, PostRepository);
const post = await repo.update(id, { status: 'published' });
if (!post) {
throw new NotFoundError('Post not found');
}
return post;
} catch (error) {
if (error instanceof NotFoundError) {
throw error;
}
throw new DatabaseError('Failed to publish post', 500, {
originalError: error instanceof Error ? error.message : String(error)
});
}
}
/**
* Get all published posts
*/
export async function getPublishedPosts(): Promise<Post[]> {
const repo = getRepository(posts, PostRepository);
return repo.findPublished();
}
/**
* Helper: Generate URL-friendly slug from title
*/
function generateSlug(title: string): string {
return title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
}4. Routes Layer - HTTP API
// src/server/routes/posts/contracts.ts
import { Type } from '@sinclair/typebox';
export const createPostContract = {
method: 'POST' as const,
path: '/',
body: Type.Object({
title: Type.String(),
content: Type.String(),
}),
response: Type.Object({
id: Type.String(),
title: Type.String(),
slug: Type.String(),
}),
};
export const listPostsContract = {
method: 'GET' as const,
path: '/',
response: Type.Array(Type.Object({
id: Type.String(),
title: Type.String(),
slug: Type.String(),
})),
};// src/server/routes/posts/index.ts
import { createApp } from '@spfn/core/route';
import { Transactional } from '@spfn/core/db';
import { createPost, getPublishedPosts } from '../../services/posts';
import { createPostContract, listPostsContract } from './contracts';
const app = createApp();
// POST /posts - Create new post (with transaction)
app.bind(createPostContract, Transactional(), async (c) => {
const body = await c.data();
const post = await createPost(body);
// ✅ Auto-commit on success, auto-rollback on error
return c.json(post, 201);
});
// GET /posts - List published posts (no transaction needed)
app.bind(listPostsContract, async (c) => {
const posts = await getPublishedPosts();
return c.json(posts);
});
export default app;Why This Architecture?
✅ Separation of Concerns
- Each layer has a single responsibility
- Easy to locate and modify code
✅ Testability
- Test each layer independently
- Mock dependencies easily
✅ Reusability
- Services can be used by multiple routes
- Repositories can be shared across services
✅ Type Safety
- Types flow from Entity → Repository → Service → Route
- Full IDE autocomplete and error checking
✅ Maintainability
- Add features without breaking existing code
- Clear boundaries prevent coupling
Layer Responsibilities
| Layer | Responsibility | Examples |
|---|---|---|
| Entity | Define data structure | Schema, types, constraints |
| Repository | Data access | CRUD, custom queries, joins |
| Service | Business logic | Validation, orchestration, rules |
| Routes | HTTP interface | Contracts, request handling |
Best Practices
Entity Layer:
- ✅ Use schema helpers:
id(),timestamps() - ✅ Export inferred types:
Post,NewPost - ✅ Use TEXT with enum for status fields
Repository Layer:
- ✅ Extend
Repository<typeof table>for custom methods - ✅ Use
getRepository(table)orgetRepository(table, CustomRepo) - ✅ Add domain-specific query methods
- ✅ Return typed results
Service Layer:
- ✅ Use function-based pattern (export async functions)
- ✅ Get repositories via
getRepository()(singleton) - ✅ Implement business logic and validation
- ✅ Throw descriptive errors
- ✅ Keep functions focused and small
Routes Layer:
- ✅ Keep handlers thin (delegate to services)
- ✅ Define contracts with TypeBox
- ✅ Use
Transactional()middleware for write operations - ✅ Use
c.data()for validated input - ✅ Return
c.json()responses
Core Modules
📁 Routing
File-based routing with contract validation and type safety.
Key Features:
- Automatic route discovery (
index.ts,[id].ts,[...slug].ts) - Contract-based validation with TypeBox
- Type-safe request/response handling
- Method-level middleware control (skip auth per HTTP method)
🗄️ Database & Repository
Drizzle ORM integration with repository pattern and pagination.
Guides:
Choosing a Repository Pattern
SPFN offers two repository patterns. Choose based on your needs:
Global Singleton (getRepository)
import { getRepository } from '@spfn/core/db';
const repo = getRepository(users);- ✅ Simple API, minimal setup
- ✅ Maximum memory efficiency
- ⚠️ Requires manual
clearRepositoryCache()in tests - ⚠️ Global state across all requests
- 📝 Use for: Simple projects, prototypes, single-instance services
Request-Scoped (getScopedRepository + RepositoryScope()) ⭐ Recommended
import { getScopedRepository, RepositoryScope } from '@spfn/core/db';
// Add middleware once (in server setup)
app.use(RepositoryScope());
// Use in routes/services
const repo = getScopedRepository(users);- ✅ Automatic per-request isolation
- ✅ No manual cache clearing needed
- ✅ Test-friendly (each test gets fresh instances)
- ✅ Production-ready with graceful degradation
- 📝 Use for: Production apps, complex testing, team projects
Comparison:
| Feature | getRepository |
getScopedRepository |
|---|---|---|
| Setup | Zero config | Add middleware |
| Test isolation | Manual | Automatic |
| Memory | Shared cache | Per-request cache |
| State | Global | Request-scoped |
| Best for | Prototypes | Production |
→ See full request-scoped documentation
🔄 Transactions
Automatic transaction management with async context propagation.
→ Read Transaction Documentation
Key Features:
- Auto-commit on success, auto-rollback on error
- AsyncLocalStorage-based context
- Transaction logging
💾 Cache
Redis integration with master-replica support.
⚠️ Error Handling
Custom error classes with unified HTTP responses.
🔐 Middleware
Request logging, CORS, and error handling middleware.
→ Read Middleware Documentation
🖥️ Server
Server configuration and lifecycle management.
⚙️ Code Generation
Automatic code generation with pluggable generators and centralized file watching.
Key Features:
- Orchestrator pattern for managing multiple generators
- Built-in contract generator for type-safe API clients
- Configuration-based setup (
.spfnrc.jsonorpackage.json) - Watch mode integrated into
spfn dev - Extensible with custom generators
Module Exports
Main Export
import { startServer, createServer } from '@spfn/core';Routing
import { createApp, bind, loadRoutes } from '@spfn/core/route';
import type { RouteContext, RouteContract } from '@spfn/core/route';Database
import {
getDb,
Repository,
getRepository
} from '@spfn/core/db';
import type { Pageable, Page } from '@spfn/core/db';Transactions
import {
Transactional,
getTransaction,
runWithTransaction
} from '@spfn/core/db';Cache
import { initRedis, getRedis, getRedisRead } from '@spfn/core';Client (for frontend)
import { ContractClient, createClient } from '@spfn/core/client';Environment Variables
# Database (required)
DATABASE_URL=postgresql://user:pass@localhost:5432/db
# Database Read Replica (optional)
DATABASE_READ_URL=postgresql://user:pass@replica:5432/db
# Redis (optional)
REDIS_URL=redis://localhost:6379
REDIS_WRITE_URL=redis://master:6379 # Master-replica setup
REDIS_READ_URL=redis://replica:6379
# Server
PORT=8790
HOST=localhost
NODE_ENV=developmentRequirements
- Node.js >= 18
- Next.js 15+ with App Router (when using with CLI)
- PostgreSQL
- Redis (optional)
Testing
npm test # Run all tests
npm test -- route # Run route tests only
npm test -- --coverage # With coverageTest Coverage: 120+ tests across all modules
Documentation
Guides
- File-based Routing
- Database & Repository
- Transaction Management
- Redis Cache
- Error Handling
- Middleware
- Server Configuration
- Code Generation
API Reference
- See module-specific README files linked above
License
MIT
Part of the SPFN Framework