Defining Features
Write feature classes, choose a scope, and short-circuit resolution.
A feature is a class that decides what value a flag has for a given scope. Features live in app/features/, are indexed at build time, and resolved on demand by the Flick service.
Anatomy of a feature
Every feature extends BaseFeature<Scope> and implements a single required method, resolve:
import { BaseFeature } from "@foadonis/flick";
import User from "#models/user";
export default class NewCheckoutFeature extends BaseFeature<User> {
async resolve(user: User) {
if (user.isAdmin) return true;
return Math.random() > 0.5;
}
}resolve is allowed to be non-deterministic. The first call for a given scope
rolls the dice, and the configured driver caches the
result so the same user keeps seeing the same variant on every subsequent
call.
A few rules apply:
- The file must end with
_feature.tsso the indexer picks it up. - The feature's name is the filename with
_featurestripped, sonew_checkout_feature.tsbecomes thenew_checkoutflag. - The file must default-export the feature class.
- The generic parameter on
BaseFeature<Scope>declares what scope the feature accepts. Flick uses it to type theresolveargument and theflick.for(scope)call site.
The indexer runs as part of the Adonis assembler hooks. Any feature file you
add or remove is picked up on the next build or node ace serve restart.
Variant returns
resolve can return any value, not just a boolean. This makes a single feature class power both classic on/off flags and multivariate experiments.
import { BaseFeature } from "@foadonis/flick";
import User from "#models/user";
export default class CheckoutButtonColorFeature extends BaseFeature<User> {
async resolve(user: User): Promise<"blue" | "green" | "red"> {
const bucket = user.id % 3;
if (bucket === 0) return "blue";
if (bucket === 1) return "green";
return "red";
}
}You can return primitive values, plain objects, or full configuration payloads. Whatever shape you return is what consumers see when they call flick.for(scope).value('...').
isActive returns true for any truthy resolved value, so a variant string
like 'blue' counts as active. To read which variant a scope actually got,
use value instead. See Resolving Features.
Async work and dependencies
resolve is asynchronous, so it can hit the database, call an external API, or read from cache. Features are also resolved through the Adonis container, which means you can inject services straight into the constructor:
import { inject } from "@adonisjs/core";
import { BaseFeature } from "@foadonis/flick";
import GeoService from "#services/geo_service";
import User from "#models/user";
@inject()
export default class RegionalPricingFeature extends BaseFeature<User> {
constructor(private geo: GeoService) {
super();
}
async resolve(user: User) {
const country = await this.geo.countryFor(user);
return ["US", "CA", "MX"].includes(country);
}
}BaseFeature has a no-argument constructor, so super() with no arguments is all that's required. Because the container instantiates the feature, every binding registered with Adonis (services, models, config providers) is available to inject.
Expensive work in resolve is fine: the configured
driver caches the result per scope identifier, so the
cost is only paid the first time. Subsequent calls return immediately without
touching the database or external service.
Short-circuiting with before()
A feature can implement an optional before(scope) hook to short-circuit resolution. Any value it returns other than undefined becomes the feature's result, and resolve is skipped entirely:
- Return
trueto mark the feature active without runningresolve. - Return
false(or any falsy value such as0or'') to skipresolveand resolve to that value. - Return a variant value to short-circuit straight to that variant.
- Return nothing (
voidorundefined) to fall through toresolve.
Because only undefined falls through, before is the natural place for global kill switches and overrides for privileged users.
Kill switch
Flip a config flag to disable a feature for everyone without redeploying logic:
import { BaseFeature } from "@foadonis/flick";
import env from "#start/env";
import User from "#models/user";
export default class NewCheckoutFeature extends BaseFeature<User> {
async before() {
if (env.get("KILL_NEW_CHECKOUT")) return false;
}
async resolve(user: User) {
if (user.isAdmin) return true;
return Math.random() > 0.5;
}
}Force-on for staff
Make sure internal users always see a feature, regardless of rollout state:
export default class NewCheckoutFeature extends BaseFeature<User> {
async before(user: User) {
if (user.isStaff) return true;
}
async resolve(user: User) {
return Math.random() > 0.5;
}
}before runs on every resolution and is not cached. resolve is cached. If
you need an always-on override, before is the right place. If you need a
cached value, return it from resolve.
Scopes
A scope is whatever object you pass to flick.for(...). It identifies who (or what) the flag is being resolved for, so the resolved value can be cached and consistent across requests.
Scopes must implement FeatureScopeable:
export interface FeatureScopeable {
toFeatureIdentifier(): string | number;
}The returned identifier must be stable for the same logical scope: the same user should always return the same identifier, otherwise Flick will treat them as different scopes and re-resolve the feature.
The HasFeatures mixin
For Lucid models, the HasFeatures mixin implements FeatureScopeable for you using the model's primary key:
import { compose } from "@adonisjs/core/helpers";
import { BaseModel } from "@adonisjs/lucid/orm";
import { HasFeatures } from "@foadonis/flick";
export default class User extends compose(BaseModel, HasFeatures) {}This is the recommended approach for any Lucid model you want to use as a scope.
Implementing FeatureScopeable manually
For non-Lucid scopes (e.g., a request, an organization handle, a session) implement the interface yourself:
import { FeatureScopeable } from "@foadonis/flick/types";
export class TenantScope implements FeatureScopeable {
constructor(private slug: string) {}
toFeatureIdentifier() {
return this.slug;
}
}Use it the same way you'd use a model:
await flick.for(new TenantScope("acme")).value("new_checkout");Returning a random or per-request identifier (a timestamp, a UUID generated per call) will defeat the cache and resolve the feature on every call. Use a value that's stable across the lifetime of the scope.
Next steps
- Resolving Features: Read flag values with
isActive,value, andmatch - Drivers: Pick the right cache backend for your environment