- Remove unused exports: renderProgress, formatTripleBoundaryCounterexample, clearCapturedRoutes - Remove dead BUILTIN_PLUGIN_CONTRACTS constant (auto-registration removed earlier) - Fix app-loader error messages to mention multiple export patterns - Move to attic: protocol-extensions-spec, OUTBOUND_CONTRACT_MOCKING_SPEC, PLUGIN_CONTRACTS_SPEC, fastify-structure - Build: clean | Tests: 849 pass, 0 fail
12 KiB
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:
// 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:
- Routes are registered before APOPHIS — APOPHIS must hook into the registration process, so it must be registered first.
- 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.
- No cleanup hook — File system state (WAL logs, uploaded files) accumulates between runs.
- 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.
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.
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.
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.
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.
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)
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:
// db.ts
export const db = await createConnection(process.env.DATABASE_URL) // Side effect!
Right:
// db.ts
export async function createDb(url: string) {
return createConnection(url)
}
2. Separate App Creation from Server Start
Wrong:
// server.ts
const app = Fastify()
// ... setup ...
await app.listen({ port: 3000 }) // Cannot test without starting server
export default app
Right:
// 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
// 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:
// 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
// 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):
// 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:
- Extract route definitions into
src/routes/*.tsfiles - Extract database/auth setup into
src/plugins/*.tsfiles - Create
src/app.tswith abuildApp()factory function - Move
fastify.listen()fromapp.tstosrc/server.ts - Create
src/test/setup.tsthat callsbuildApp()+apophis.register() - Ensure no side effects at import time in any
src/file - Run
npx tsc --noEmitto verify no circular dependencies - 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.