# 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 { 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 { // 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.