Files
apophis-fastify/docs/attic/fastify-structure.md

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:

  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.

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/*.ts files
  • Extract database/auth setup into src/plugins/*.ts files
  • Create src/app.ts with a buildApp() factory function
  • Move fastify.listen() from app.ts to src/server.ts
  • Create src/test/setup.ts that calls buildApp() + apophis.register()
  • Ensure no side effects at import time in any src/ file
  • Run npx tsc --noEmit to 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.