AdonisJS adapter for the Fanion feature flagging library
AdonisJS Fanion is the official AdonisJS adapter for the Fanion feature flagging library. It provides a seamless integration with AdonisJS applications, allowing you to control feature rollouts, conduct A/B tests, and manage application behavior dynamically.
- 🚀 AdonisJS Native - Built specifically for AdonisJS v6 with full IoC support
- 🔧 TypeScript First - Complete type safety and IntelliSense support
- 🏪 Multiple Storage Options - Memory, database, or custom storage providers
- 🎯 Context-Aware - Automatic HTTP context integration
- 🛡️ Middleware Support - Protect routes with feature flags
- 🎨 Decorators - Use decorators in controllers for feature gating
- 📊 A/B Testing - Built-in utilities for A/B testing and gradual rollouts
- 🌍 Environment Support - Environment-based feature flags
npm i @mgvdev/fanion-adonisjs
Next, configure the package using the configure command:
node ace configure @mgvdev/fanion-adonisjs
This will:
- Create a
config/fanion.ts
configuration file - Register the service provider
- Set up environment variables
- Optionally install database drivers
// Define feature flags in config/fanion.ts
export default defineConfig({
features: [
{
name: 'new-dashboard',
description: 'Enable new dashboard design',
check: () => true,
},
{
name: 'beta-features',
description: 'Enable beta features for specific users',
check: (context) => context.user?.isBetaUser === true,
},
],
})
// Use in controllers
import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'
import type { FanionService } from '@fanion/adonisjs/types'
export default class DashboardController {
@inject()
async index({ response, auth }: HttpContext, fanion: FanionService) {
const showNewDashboard = await fanion.activeForRequest('new-dashboard', { auth })
if (showNewDashboard) {
return response.ok({ message: 'New dashboard!' })
}
return response.ok({ message: 'Classic dashboard' })
}
}
// routes/web.ts
import router from '@adonisjs/core/services/router'
import { featureFlag } from '@fanion/adonisjs'
// Protect entire route
router.get('/beta', [featureFlag('beta-features')]).use(async ({ response }) => {
return response.ok({ message: 'Beta feature accessed!' })
})
// Redirect when disabled
router.get('/new-ui', [featureFlagWithRedirect('new-ui', '/old-ui')]).use(async ({ response }) => {
return response.ok({ message: 'New UI!' })
})
import { requireFeature } from '@fanion/adonisjs'
export default class AdminController {
@requireFeature('admin-panel')
async index({ response }: HttpContext) {
return response.ok({ message: 'Admin panel' })
}
@requireFeature('super-admin', {
onDisabled: 'redirect',
redirectTo: '/dashboard',
})
async users({ response }: HttpContext) {
return response.ok({ users: [] })
}
}
// config/fanion.ts
import { defineConfig } from '@fanion/adonisjs/types'
import env from '#start/env'
export default defineConfig({
// Enable debug logging
debug: env.get('NODE_ENV') === 'development',
// Auto-initialize storage on app start
autoInit: true,
// Default context provider
defaultContextProvider: () => ({
environment: env.get('NODE_ENV'),
appVersion: env.get('APP_VERSION', '1.0.0'),
}),
})
// config/fanion.ts
import { defineConfig } from '@fanion/adonisjs/types'
import db from '@adonisjs/lucid/services/db'
export default defineConfig({
storageDriver: {
type: 'knex',
config: {
connection: db.connection(),
tableName: 'feature_flags',
featureNameColumn: 'feature_name',
valueColumn: 'value',
},
},
})
// config/fanion.ts
export default defineConfig({
features: [
// Simple boolean flag (stored in database)
{
name: 'maintenance-mode',
description: 'Enable maintenance mode',
store: true,
defaultValue: false,
},
// User-based flag
{
name: 'premium-features',
description: 'Enable premium features',
check: (context) => {
return context.user?.plan === 'premium' && context.user?.verified
},
},
// Percentage-based rollout
{
name: 'new-feature',
description: 'Roll out new feature to 25% of users',
check: (context) => {
const userId = context.user?.id || 0
return userId % 100 < 25
},
},
// Environment-based flag
{
name: 'debug-mode',
description: 'Enable debug mode in development',
check: () => process.env.NODE_ENV === 'development',
},
],
})
import { ABTesting } from '@fanion/adonisjs'
// In config/fanion.ts
export default defineConfig({
features: [
{
name: 'checkout-variant-a',
description: 'A/B test for checkout flow - Variant A',
check: ABTesting.createABTest('checkout', 50, (ctx) => ctx.user?.id || 0),
},
],
})
// In your controller
export default class CheckoutController {
@inject()
async show({ response }: HttpContext, fanion: FanionService) {
const showVariantA = await fanion.active('checkout-variant-a', { user: auth.user })
return response.ok({
variant: showVariantA ? 'A' : 'B',
checkoutFlow: showVariantA ? 'simplified' : 'standard',
})
}
}
// Create custom context for feature evaluation
import { createFeatureContext } from '@fanion/adonisjs'
export default class ApiController {
@inject()
async data({ request, auth }: HttpContext, fanion: FanionService) {
const context = createFeatureContext(
{ auth },
{
apiVersion: request.header('api-version'),
clientType: request.header('client-type'),
country: request.header('cf-ipcountry'), // Cloudflare country header
}
)
const features = await fanion.activeMany(
['enhanced-api', 'geo-restrictions', 'rate-limiting'],
context
)
return response.ok({ features })
}
}
import { EnvironmentFlags } from '@fanion/adonisjs'
export default defineConfig({
features: [
{
name: 'dev-tools',
check: EnvironmentFlags.developmentOnly(),
},
{
name: 'analytics',
check: EnvironmentFlags.productionOnly(),
},
{
name: 'staging-banner',
check: EnvironmentFlags.createEnvironmentFlag(['staging', 'development']),
},
],
})
export default class DashboardController {
@inject()
async index({ auth }: HttpContext, fanion: FanionService) {
const flags = await fanion.activeMany(
['new-dashboard', 'advanced-analytics', 'export-feature', 'real-time-updates'],
{ user: auth.user }
)
return response.ok({
dashboard: {
showNewDesign: flags['new-dashboard'],
showAnalytics: flags['advanced-analytics'],
allowExport: flags['export-feature'],
realTimeUpdates: flags['real-time-updates'],
},
})
}
}
import { featureFlag, featureFlagWithRedirect, featureFlagWithHandler } from '@fanion/adonisjs'
// Simple abort on disabled
router.get('/feature', [featureFlag('my-feature')])
// Redirect when disabled
router.get('/feature', [featureFlagWithRedirect('my-feature', '/coming-soon')])
// Custom handler when disabled
router.get('/feature', [
featureFlagWithHandler('my-feature', ({ response }) => {
return response.status(503).json({ message: 'Feature temporarily unavailable' })
}),
])
import { createFanionMiddleware } from '@fanion/adonisjs'
// Custom middleware with context provider
const betaMiddleware = createFanionMiddleware({
flag: 'beta-features',
contextProvider: async ({ auth, request }) => ({
user: auth.user,
userAgent: request.header('user-agent'),
isMobile: request.header('user-agent')?.includes('Mobile'),
}),
onDisabled: 'custom',
customHandler: ({ response }) => {
return response.status(404).json({
error: 'Feature not found',
message: 'This feature is not available in your current plan',
})
},
})
router.get('/beta', [betaMiddleware])
import { isFeatureActive, getUserFeatures, ifFeatureActive } from '@fanion/adonisjs'
// Check feature globally
const isEnabled = await isFeatureActive('my-feature')
// Get all features for a user
const userFeatures = await getUserFeatures(user, ['feature1', 'feature2'])
// Conditional execution
await ifFeatureActive('email-notifications', async () => {
await sendWelcomeEmail(user)
})
// In a service provider or preloader
import { ViewHelpers } from '@fanion/adonisjs'
export default class AppProvider {
async boot() {
const fanion = await this.app.container.make('fanion')
const view = await this.app.container.make('view')
// Add global view helpers
view.global(ViewHelpers.createViewGlobals(fanion))
}
}
{{-- In your Edge templates --}}
@if(await isFeatureActive('new-ui'))
<div class="new-ui-component">
<!-- New UI content -->
</div>
@else
<div class="legacy-ui-component">
<!-- Legacy UI content -->
</div>
@end
When using database storage, the following table structure is created:
CREATE TABLE feature_flags (
feature_name VARCHAR PRIMARY KEY,
value BOOLEAN NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
You can customize the table name and column names in the configuration:
export default defineConfig({
storageDriver: {
type: 'knex',
config: {
connection: db.connection(),
tableName: 'app_feature_flags',
featureNameColumn: 'flag_name',
valueColumn: 'is_enabled',
},
},
})
// Good
'user-profile-redesign'
'checkout-express-shipping'
'admin-advanced-analytics'
// Avoid
'flag1'
'test'
'new-feature'
export default defineConfig({
features: [
{
name: 'payment-method-apple-pay',
description: 'Enable Apple Pay as payment method in checkout',
check: (context) => {
// Only enable for iOS users
return context.userAgent?.includes('iPhone') || context.userAgent?.includes('iPad')
},
},
],
})
// Store critical features in database for quick toggles
{
name: 'payment-processing',
description: 'Kill switch for payment processing',
store: true,
defaultValue: true,
}
{
name: 'new-search-algorithm',
description: 'Gradual rollout of new search algorithm',
check: (context) => {
const userId = context.user?.id || 0
const rolloutPercentage = 10 // Start with 10%
return (userId % 100) < rolloutPercentage
},
}
export default class FeatureController {
@inject()
async show({ response }: HttpContext, fanion: FanionService) {
try {
const isActive = await fanion.active('experimental-feature')
// Use feature
} catch (error) {
// Log error and fall back to safe default
console.error('Feature flag error:', error)
// Provide fallback behavior
}
}
}
// tests/functional/feature_flags.spec.ts
import { test } from '@japa/runner'
import { FanionServiceImpl } from '@fanion/adonisjs'
test.group('Feature Flags', () => {
test('should enable premium features for premium users', async ({ assert }) => {
const fanion = new FanionServiceImpl()
fanion.define('premium-features', (context) => {
return context.user?.plan === 'premium'
})
const result = await fanion.active('premium-features', {
user: { plan: 'premium' },
})
assert.isTrue(result)
})
})
test('should protect routes with feature flags', async ({ client }) => {
const response = await client.get('/beta-feature')
// Should return 404 if feature is disabled
response.assertStatus(404)
})
If you're currently using manual feature flag implementations:
// Before
if (process.env.ENABLE_NEW_FEATURE === 'true') {
// Feature logic
}
// After
if (await fanion.active('new-feature')) {
// Feature logic
}
Most feature flag libraries can be migrated by:
- Defining your existing flags in
config/fanion.ts
- Replacing flag checks with
fanion.active()
- Updating middleware to use Fanion middleware
- Migrating stored flags to your database
Feature flag not found error
FeatureNotExistsError: Feature flag 'my-feature' is not defined
Solution: Make sure the feature is defined in your configuration or via fanion.define()
.
Storage provider not initialized
Error: No store provider defined
Solution: Configure a storage driver in your configuration or set autoInit: true
.
Middleware not working
Error: Binding not found: fanion
Solution: Make sure the FanionProvider is registered in your adonisrc.ts
providers array.
Enable debug mode to see detailed logging:
export default defineConfig({
debug: true,
})
This will log all feature flag evaluations to help with debugging.
define<T>(name: string, check?: (context: T) => boolean | Promise<boolean>): void
defineAndStore(name: string, defaultValue?: boolean): Promise<void>
active<T>(name: string, context?: T): Promise<boolean>
activeForRequest(name: string, ctx: HttpContext, additionalContext?: any): Promise<boolean>
activeMany<T>(flags: string[], context?: T): Promise<Record<string, boolean>>
getDefinedFlags(): string[]
featureFlag(flagName: string, onDisabled?: 'abort' | 'next')
featureFlagWithRedirect(flagName: string, redirectTo: string)
featureFlagWithHandler(flagName: string, customHandler: Function)
createFanionMiddleware(options: FanionMiddlewareOptions)
isFeatureActive<T>(flagName: string, context?: T): Promise<boolean>
isFeatureActiveForRequest(flagName: string, ctx: HttpContext, additionalContext?: any): Promise<boolean>
getUserFeatures(user: any, flagNames?: string[]): Promise<Record<string, boolean>>
ifFeatureActive<T>(flagName: string, callback: Function, context?: T): Promise<any>
requireFeature(flagName: string, options?: object): MethodDecorator
ABTesting.createABTest(testName: string, percentage: number, identifier?: Function)
ABTesting.percentageRollout(identifier: number | string, percentage: number): boolean
EnvironmentFlags.developmentOnly(): () => boolean
EnvironmentFlags.productionOnly(): () => boolean
EnvironmentFlags.createEnvironmentFlag(environments: string[]): () => boolean
We welcome contributions! Please see our Contributing Guide for details.
MIT © Maxence Guyonvarho
- Fanion Core Library - The core feature flagging library
- AdonisJS - The Node.js framework this adapter is built for
Made with ❤️ by Maxence Guyonvarho