Files
apophis-fastify/docs/fastify-structure.md
T
John Dvorak 8d7382417d docs: add paper citations, fix pedagogical issues, improve SKILL.md
- Cite arxiv 2602.23922 (Invariant-Driven Automated Testing) in all major docs
- Add Progressive Complexity section to SKILL.md for LLM guidance
- Fix SKILL.md Fast Start example to use deterministic ID generation
- Fix getting-started.md failure output inconsistency
- Fix auth-patterns.md TypeScript syntax in JS doc
- Fix fastify-structure.md Date.now() in test helper
- Fix observe.md misleading workspace heading
- Build: clean | Tests: 849 pass, 0 fail
2026-04-30 11:34:00 -07:00

460 lines
12 KiB
Markdown

# Structuring Your Fastify App for APOPHIS
APOPHIS requires that you register its plugin **before** defining routes, and it needs to access your route schemas at test time. If your application is a single file that creates the server, connects to databases, registers routes, and starts listening, you cannot test it with APOPHIS.
This guide shows how to restructure a monolithic Fastify application into a testable plugin architecture.
---
## The Problem
Here is what Arbiter's setup looked like — a single file doing everything:
```typescript
// server.ts — THE WRONG WAY
import Fastify from 'fastify'
import database from './database'
import routes from './routes'
const fastify = Fastify()
// Database setup
await database.connect(process.env.DATABASE_URL)
// Register plugins
await fastify.register(require('@fastify/swagger'))
await fastify.register(require('@fastify/cors'))
// Register routes
fastify.register(routes)
// Add decorators
fastify.decorate('db', database)
// Start server
await fastify.listen({ port: 3000 })
```
**Why this breaks APOPHIS:**
1. **Routes are registered before APOPHIS** — APOPHIS must hook into the registration process, so it must be registered first.
2. **No way to create a test instance** — The database connection and server start are unconditional. You cannot create a second Fastify instance for testing without starting another server.
3. **No cleanup hook** — File system state (WAL logs, uploaded files) accumulates between runs.
4. **Side effects at import time** — Importing the file has side effects. You cannot import routes without importing the database connection.
---
## The Solution: App Factory Pattern
Separate **application creation** from **server startup**. Export a function that creates a configured Fastify instance without starting it.
### Recommended Directory Structure
```
src/
app.ts # App factory: creates Fastify instance, registers plugins
server.ts # Entry point: creates app, connects DB, starts server
plugins/
database.ts # Database connection plugin
auth.ts # Auth decorator plugin
routes/
users.ts # Route definitions with schema annotations
health.ts # Health check route
test/
setup.ts # Test bootstrap: creates app, registers APOPHIS
contracts.test.ts # Contract test entry point
```
### 1. App Factory (`src/app.ts`)
This file exports a function that creates a Fastify instance and registers all plugins **except** APOPHIS and the database connection. It should have no side effects.
```typescript
import Fastify from 'fastify'
import type { FastifyInstance } from 'fastify'
// Your application plugins
import databasePlugin from './plugins/database'
import authPlugin from './plugins/auth'
import userRoutes from './routes/users'
import healthRoutes from './routes/health'
export interface AppOptions {
// Pass configuration explicitly instead of reading env vars
databaseUrl?: string
jwtSecret?: string
enableLogging?: boolean
}
export async function buildApp(opts: AppOptions = {}): Promise<FastifyInstance> {
const fastify = Fastify({
logger: opts.enableLogging ?? true,
// Disable request logging in test mode to reduce noise
disableRequestLogging: process.env.NODE_ENV === 'test',
})
// Register infrastructure plugins
await fastify.register(databasePlugin, { url: opts.databaseUrl })
await fastify.register(authPlugin, { secret: opts.jwtSecret })
// Register route plugins
await fastify.register(userRoutes, { prefix: '/api/users' })
await fastify.register(healthRoutes, { prefix: '/health' })
return fastify
}
```
### 2. Database Plugin (`src/plugins/database.ts`)
Encapsulate database setup in a Fastify plugin. This makes it composable and testable.
```typescript
import fp from 'fastify-plugin'
import type { FastifyInstance } from 'fastify'
import { createConnection } from './db-client'
export interface DatabasePluginOptions {
url?: string
}
export default fp(async (fastify: FastifyInstance, opts: DatabasePluginOptions) => {
const db = await createConnection(opts.url ?? process.env.DATABASE_URL)
// Decorate fastify with db access
fastify.decorate('db', db)
// Clean up on close
fastify.addHook('onClose', async () => {
await db.disconnect()
})
})
```
### 3. Route Files with Contracts (`src/routes/users.ts`)
Define routes in separate files. Each route file is a Fastify plugin that receives the parent instance.
```typescript
import type { FastifyInstance } from 'fastify'
export default async function userRoutes(fastify: FastifyInstance) {
fastify.post('/', {
schema: {
'x-category': 'constructor',
'x-ensures': [
'status:201',
'response_body(this).id != null',
'response_body(this).email matches "^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$"',
],
body: {
type: 'object',
properties: {
name: { type: 'string', minLength: 1 },
email: { type: 'string', format: 'email' },
},
required: ['name', 'email'],
},
response: {
201: {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
email: { type: 'string' },
},
},
},
},
}, async (req, reply) => {
const user = await fastify.db.users.create(req.body)
reply.status(201)
return user
})
fastify.get('/:id', {
schema: {
'x-category': 'observer',
'x-ensures': [
'if status:200 then response_body(this).id != null',
],
params: {
type: 'object',
properties: {
id: { type: 'string' },
},
},
},
}, async (req, reply) => {
const user = await fastify.db.users.findById(req.params.id)
if (!user) {
reply.status(404)
return { error: 'Not found' }
}
return user
})
}
```
### 4. Production Entry Point (`src/server.ts`)
The production entry point imports the app factory, adds APOPHIS, connects to services, and starts the server.
```typescript
import { buildApp } from './app'
import apophis from 'apophis-fastify'
async function start() {
const fastify = await buildApp({
databaseUrl: process.env.DATABASE_URL,
jwtSecret: process.env.JWT_SECRET,
})
// Register APOPHIS before ready() but after all routes
await fastify.register(apophis, {
runtime: process.env.NODE_ENV === 'production' ? 'error' : 'warn',
timeout: 5000,
})
await fastify.ready()
// Start server
await fastify.listen({ port: Number(process.env.PORT) || 3000 })
console.log(`Server listening on ${fastify.server.address()}`)
}
start().catch((err) => {
console.error(err)
process.exit(1)
})
```
### 5. Test Bootstrap (`src/test/setup.ts`)
The test file creates a fresh app instance, registers APOPHIS, and runs contract tests against it.
```typescript
import { buildApp } from '../app'
import apophis from 'apophis-fastify'
import type { FastifyInstance } from 'fastify'
export async function createTestApp(): Promise<FastifyInstance> {
// Use test database or in-memory store
const fastify = await buildApp({
databaseUrl: process.env.TEST_DATABASE_URL ?? ':memory:',
jwtSecret: 'test-secret',
enableLogging: false,
})
// Register APOPHIS for testing
await fastify.register(apophis, {
timeout: 2000, // Faster timeouts in tests
cleanup: true, // Auto-cleanup resources
})
await fastify.ready()
return fastify
}
```
### 6. Contract Test Entry Point (`src/test/contracts.test.ts`)
```typescript
import { test } from 'node:test'
import assert from 'node:assert'
import { createTestApp } from './setup'
test('contract tests', async () => {
const fastify = await createTestApp()
try {
const result = await fastify.apophis.contract({
depth: 'standard',
seed: 42, // Deterministic
})
console.log(result.summary)
// Fail the test suite if any contract fails
assert.strictEqual(
result.summary.failed,
0,
`Contract failures: ${result.tests
.filter((t) => !t.ok)
.map((t) => t.name)
.join(', ')}`
)
} finally {
// Always clean up
await fastify.apophis.cleanup()
await fastify.close()
}
})
```
---
## Key Principles
### 1. No Side Effects at Import Time
**Wrong:**
```typescript
// db.ts
export const db = await createConnection(process.env.DATABASE_URL) // Side effect!
```
**Right:**
```typescript
// db.ts
export async function createDb(url: string) {
return createConnection(url)
}
```
### 2. Separate App Creation from Server Start
**Wrong:**
```typescript
// server.ts
const app = Fastify()
// ... setup ...
await app.listen({ port: 3000 }) // Cannot test without starting server
export default app
```
**Right:**
```typescript
// app.ts
export async function buildApp() {
const app = Fastify()
// ... setup without listen() ...
return app
}
// server.ts
const app = await buildApp()
await app.listen({ port: 3000 })
```
### 3. Use Fastify Plugins for Everything
Routes, database connections, auth, decorators — everything should be a Fastify plugin. This makes composition explicit and testable.
### 4. APOPHIS Registration Order
```typescript
// 1. Create app (registers routes)
const app = await buildApp()
// 2. Register APOPHIS (hooks into existing routes)
await app.register(apophis, opts)
// 3. Ready (compiles schemas)
await app.ready()
// 4. Test or serve
await app.apophis.contract({...})
// OR
await app.listen({...})
```
---
## Handling Arbiter-Specific Issues
### File System State (WAL Logs)
If your server writes to files or WAL logs:
```typescript
// test/setup.ts
import { mkdirSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
let testCounter = 0
export function createTestWorkspace() {
const dir = join(tmpdir(), `apophis-test-${++testCounter}`)
mkdirSync(dir, { recursive: true })
return {
path: dir,
cleanup() {
rmSync(dir, { recursive: true, force: true })
},
}
}
// In your test:
const workspace = createTestWorkspace()
const app = await buildApp({
dataDir: workspace.path, // Server writes here instead of production path
})
```
### Database Seeding
```typescript
// test/setup.ts
export async function seedTestDatabase(db: Database) {
await db.migrate.latest()
await db.seed.run()
}
// In your contract test:
const app = await createTestApp()
await seedTestDatabase(app.db)
```
### Complex Dependency Injection
If routes depend on external services (ledger, graph store):
```typescript
// Use test doubles via plugin options
export async function buildApp(opts: AppOptions) {
const app = Fastify()
// Production: real ledger
// Test: mock ledger
await app.register(ledgerPlugin, {
client: opts.ledgerClient ?? new RealLedgerClient(),
})
return app
}
```
---
## Migration Checklist
If you have a monolithic `server.ts` like Arbiter:
- [x] Extract route definitions into `src/routes/*.ts` files
- [x] Extract database/auth setup into `src/plugins/*.ts` files
- [x] Create `src/app.ts` with a `buildApp()` factory function
- [x] Move `fastify.listen()` from `app.ts` to `src/server.ts`
- [x] Create `src/test/setup.ts` that calls `buildApp()` + `apophis.register()`
- [x] Ensure no side effects at import time in any `src/` file
- [x] Run `npx tsc --noEmit` to verify no circular dependencies
- [x] Run contract tests: `npm run test:contracts`
---
## Summary
| Monolithic | Plugin Architecture |
|-----------|-------------------|
| Single file with everything | Factory function + plugin files |
| Side effects at import | Pure functions, explicit initialization |
| Cannot create test instance | Create unlimited instances |
| APOPHIS must be first (impossible) | APOPHIS registered after routes, before ready() |
| Manual cleanup | Hooks for automatic cleanup |
| Database URL hardcoded | Injected via options |
The plugin architecture takes 30 minutes to set up and saves hours of debugging when APOPHIS cannot access your routes.