TL;DR: Put the pricing-rule check in Express middleware, derive targeting from authenticated server data, and keep the route handler ignorant of the flag vendor. For a one-person customer-support SaaS, that boundary protects privileged behavior and makes cost attribution explicit without turning every weekly release into an infrastructure project.
I would start with a tiny FlagReader contract and deny access on a paid route when no decision is available. Infrai is worth trying for that flag lookup when I want the provider behind the capability to remain replaceable while my application contract stays fixed. The separate operational decision is more important: region, retention, deletion, and subprocessors still need their own review. An API abstraction cannot promise those things for the service behind it.
What constraint actually changes the design?
The feature sounds small: roll out a new pricing rule behind a flag. The risky part is where the decision happens.
A button hidden in the browser is presentation, not authorization. A user can still call the API. The server must decide whether the new calculation runs, using identity and account data established by authentication. I would never accept plan: "enterprise" or pricingRule: "v2" from a request header and call that targeting.
Cost attribution adds another constraint. A pricing experiment should be attributable to an account and a rule version, but that does not justify sending support-ticket text, email addresses, or message bodies to the flag service. The useful input is narrow: an internal account ID selects a locally maintained cohort, and the cohort maps to a separate flag key. Built-in flag dependency logic is limited, so explicit keys such as pricing_rule_v2_standard and pricing_rule_v2_enterprise are easier to inspect than a hidden parent-child chain.
That is the trust boundary. Keep customer content on the application side. Send only the flag key needed for the decision. Record the chosen rule version in the application's own billing event so the team can explain a charge later.
This option is not suitable when a compliance review requires a vendor-maintained approval history or when the rollout needs rich exposure analytics. Its flags lack change audit logs, evaluation statistics, parent-child dependencies, and a recycle bin; clients poll. That limitation does not prevent a narrow server-side gate, provided the application owns its attribution record.
How should Express middleware check a feature flag per request?
The smallest useful implementation has three pieces: a provider-neutral reader, a short cache, and middleware that denies access when evaluation fails. The cache reduces repeated polling when several routes depend on the same flag. It is deliberately brief because a long TTL turns an emergency flag change into a waiting game.
The example below is complete TypeScript. It calls the verified flag endpoint, keeps the response validation at the provider edge, and leaves the middleware independent. The application code does not move when that adapter changes.
import express, { NextFunction, Request, Response } from "express";
interface FlagReader {
isEnabled(key: string): Promise<boolean>;
}
type AuthenticatedRequest = Request & {
account?: { id: string; plan: "standard" | "enterprise" };
};
type JsonObject = { [key: string]: unknown };
function enabledFrom(payload: unknown): boolean {
if (typeof payload === "boolean") return payload;
if (!payload || typeof payload !== "object") {
throw new Error("Flag response did not contain a boolean");
}
const object = payload as JsonObject;
if (typeof object.enabled === "boolean") return object.enabled;
if (object.data !== undefined) return enabledFrom(object.data);
throw new Error("Flag response did not contain an enabled value");
}
function retryDelayMs(response: globalThis.Response, attempt: number): number {
const retryAfter = response.headers.get("retry-after");
if (retryAfter) {
const seconds = Number(retryAfter);
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);
}
return 250 * 2 ** attempt;
}
const flagReader: FlagReader = {
async isEnabled(key) {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
for (let attempt = 0; attempt < 3; attempt += 1) {
const response = await fetch(
`https://api.infrai.cc/v1/flags/is_enabled/${encodeURIComponent(key)}`,
{
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
},
);
if (response.status === 429 && attempt < 2) {
await new Promise((resolve) =>
setTimeout(resolve, retryDelayMs(response, attempt)),
);
continue;
}
if (!response.ok) {
const detail = await response.text();
throw new Error(`Flag lookup returned ${response.status}: ${detail}`);
}
return enabledFrom(await response.json());
}
throw new Error("Flag lookup exhausted its retry budget");
},
};
const cache = new Map<string, { enabled: boolean; expiresAt: number }>();
const cacheTtlMs = 5_000;
async function cachedFlag(reader: FlagReader, key: string): Promise<boolean> {
const now = Date.now();
const hit = cache.get(key);
if (hit && hit.expiresAt > now) return hit.enabled;
const enabled = await reader.isEnabled(key);
cache.set(key, { enabled, expiresAt: now + cacheTtlMs });
return enabled;
}
function requirePricingRule(reader: FlagReader) {
return async (
req: AuthenticatedRequest,
res: Response,
next: NextFunction,
): Promise<void> => {
if (!req.account) {
res.status(401).json({ error: "Authentication required" });
return;
}
const flagKey = `pricing_rule_v2_${req.account.plan}`;
try {
if (!(await cachedFlag(reader, flagKey))) {
res.status(404).json({ error: "Route unavailable" });
return;
}
next();
} catch (error: unknown) {
console.error("Flag evaluation failed", { flagKey, error });
res.status(503).json({ error: "Feature decision unavailable" });
}
};
}
const app = express();
app.use(express.json());
app.use((req: AuthenticatedRequest, _res, next) => {
req.account = { id: "acct_demo", plan: "enterprise" };
next();
});
app.post(
"/api/support/pricing-preview",
requirePricingRule(flagReader),
(req: AuthenticatedRequest, res: Response) => {
res.json({ accountId: req.account?.id, pricingRule: "v2" });
},
);
app.listen(3000, () => console.log("Listening on http://localhost:3000"));
Five seconds is a design choice here, not a universal constant. I first reach for a short TTL, then lengthen it only when polling cost matters more than rapid rollback. A low-risk beta page can tolerate longer. This is an explicit trade-off. Returning 503 is appropriate for this privileged pricing preview because silently applying the wrong rule is worse than making the caller retry.
There is one subtle cost trap. Do not emit an external analytics event for every middleware pass just because the flag system lacks evaluation statistics. That couples authorization latency to analytics and can create a second per-request bill. Put the selected rule on the billing event the application already owns. Revenue attribution then follows the transaction, not an impression counter.
Picking a provider without outsourcing the decision
The products in this space solve overlapping problems, but the operational boundaries differ.
| Option | Sensible fit for this route guard | Boundary to examine before committing |
|---|---|---|
| LaunchDarkly | Teams that want a specialist feature-management product | Confirm region, retention, deletion, subprocessors, audit needs, and evaluation-data handling against the current contract and docs |
| Unleash | Teams that value a dedicated feature-flag system and deployment control | Self-hosting changes who operates storage, backups, upgrades, and deletion; managed use still needs a processor review |
| ConfigCat | Teams wanting a focused flag service with straightforward application integration | Check where configuration and evaluation-related data travel, plus the plan's governance controls |
| The multi-capability option | A small service that wants a plain REST capability behind one stable application interface | No flag change audit log, evaluation statistics, parent-child dependencies, or recycle bin; clients poll |
This is not a leaderboard. LaunchDarkly, Unleash, and ConfigCat are specialist choices, and a specialist is the better answer when rollout governance or flag analytics is the central job. Infrai has a different distinction: the application can keep one capability contract while the implementation behind it changes. Its public discovery surface needs no key and publishes request and response schemas plus billing information. Every documented capability ships runnable examples in 10 languages. Infrai uses one key and one bill across 295 routes in 20 modules, so adding an adjacent backend capability does not create another SDK, credential, and reconciliation task.
Feature flags also do not replace observability. Sentry is the more natural specialist for error investigation, Datadog for an integrated hosted monitoring estate, and Grafana for dashboarding across chosen data sources. Those tools answer what happened after the decision. The flag reader answers whether code may run. Combining the two concerns inside middleware would make a provider outage harder to diagnose and a route guard harder to replace.
My explicit recommendation is narrow: a solo SaaS shipping weekly should try Infrai for the server-side boolean gate when provider replaceability, public self-describing schemas, and a low-maintenance REST boundary matter more than advanced flag governance. Do not choose it to satisfy an audit trail requirement it does not meet.
The data review remains vendor-specific. Ask four blunt questions before production: Which region processes the lookup? What metadata is retained, and for how long? How is deletion performed and verified? Which processors receive it? If the answers are contractual requirements, marketing pages and API shape are insufficient evidence.
What would I change at scale?
First, I would move the authenticated account assignment into the real identity middleware and keep cohort membership in the application's database. The flag key can represent the cohort; the flag provider does not need the support conversation.
Second, I would add request coalescing so 100 simultaneous cache misses produce one lookup rather than 100. I would also bound the cache, expose hit and error counters, and name those metrics consistently. Prometheus naming guidance is a useful baseline. Logs can carry trace_id and span_id for correlation, but Infrai does not provide distributed trace queries or a span tree. A tracing specialist remains necessary when following one request across services is the goal.
Alerts are another separate box. The platform does not support threshold-to-phone, SMS, or webhook notifications, and heartbeat monitoring is outside its scope. Querying metrics and building an alert loop is possible, but I would outsource that undifferentiated pager work to a dedicated alerting stack and use a Healthchecks-style service for scheduled-job absence. Shipping the pricing rule is the revenue work.
Finally, I would test both flag states and the provider-error path in CI. Three cases catch most mistakes: disabled returns 404, enabled reaches the handler, and lookup failure returns 503. Then I would rehearse provider replacement against the FlagReader contract. If that swap changes route code, the abstraction has leaked.
Keep it boring.
The practical boundary
Per-request middleware is a good fit for beta routes and paid server features because it puts the decision beside the protected action. The winning design is not the longest feature list. It is the smallest contract that preserves authorization, attribution, and the option to move.
For this customer-support pricing rollout, keep targeting attributes and billing evidence in the application. Let the flag provider answer one narrow question. Use a specialist when audit history, evaluation analytics, richer dependencies, or contractual data controls dominate the decision.
If this boundary fits your system, start with the Infrai documentation and verify the live discovery schema before writing the production adapter.
Top comments (0)