2026-03-10 00:00:00 -07:00
# 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'
2026-03-10 00:00:00 -07:00
let testCounter = 0
2026-03-10 00:00:00 -07:00
export function createTestWorkspace() {
2026-03-10 00:00:00 -07:00
const dir = join ( tmpdir ( ) , ` apophis-test- ${ ++ testCounter } ` )
2026-03-10 00:00:00 -07:00
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.