Feature Flag Service — Example Template
This example is a self-contained template showing how to design, implement, wire, and test a simple FeatureFlagService following the project architecture:
- Contract (core) —
packages/core/src/interfaces(interface + Tag) - Adapter (services) —
packages/adapters(implementation + Layer) - App wiring — layer composition in
createAppLayer(main) - Tests — how to mock the service with
Layer.succeed
IMPORTANT: This is a reference/template for contributors. Do NOT copy this file into production code paths without review.
Table of contents
- Overview
- Contract: core interface
- Adapter: HTTP-backed example implementation
- Wiring: add the layer to
createAppLayer - Usage: how core and CLI access the service
- Testing: unit test example using
Layer.succeed - Notes, safety, and checklist
Overview
Feature flags are a common cross-cutting concern. The pattern below shows how to:
- Define a small contract in
corethat core business logic depends on. - Implement the contract in
servicesas a Layer that can depend on configuration/logger/etc. - Provide the Layer in app composition so core code can call the contract via its Tag.
- Mock the contract in tests with
Layer.succeed.
1. Contract — core interface
Place this under packages/core/src/interfaces/feature-flag.ts in your real codebase. In this example it’s shown inline.
// packages/core/src/interfaces/feature-flag.ts — example
import { Context, Effect } from "effect";
export interface FeatureFlagService {
// return true/false for a named flag
// Effect.Effect<boolean, never> means: returns boolean, never fails (always succeeds)
readonly isEnabled: (flagName: string) => Effect.Effect<boolean, never>;
// optional: get rollout percentage (0..100)
readonly rolloutPercentage: (flagName: string) => Effect.Effect<number, never>;
}
// Context.GenericTag creates a dependency injection token
// This tag is used to access the service via Effect's dependency system
export const FeatureFlagServiceTag = Context.GenericTag<FeatureFlagService>("FeatureFlagService");
Design guidance
- Keep the contract small and focused.
- Prefer safe return types (boolean/number) for non-critical features — allow graceful degradation.
- Put contracts in
packages/core/src/interfacesso the core layer depends only on the contract. - Use
Effect.Effect<ReturnType, ErrorType, Dependencies>:ReturnType: what the function returns (boolean, number, etc.)ErrorType: error types it can fail with (nevermeans it never fails)Dependencies: services it needs (omitted here, added via Tag in usage)
2. Adapter — HTTP-backed example (template)
This is an example service implementation showing:
- How to read configuration via AgentConfigService
- How to build a Layer that depends on the config tag
- How to implement safe fallbacks
Place something like this under packages/adapters/src/feature-flag/http.ts in your real project (here we show the template).
// packages/adapters/src/feature-flag/http.ts
import { Effect, Layer } from "effect";
import { FeatureFlagServiceTag, type FeatureFlagService } from "../../core/interfaces/feature-flag";
import { AgentConfigServiceTag, type AgentConfigService } from "../../core/interfaces/agent-config";
class HTTPFeatureFlagService implements FeatureFlagService {
constructor(
private readonly baseUrl: string,
private readonly apiKey?: string,
) {}
isEnabled(flagName: string) {
return Effect.tryPromise({
try: async () => {
const url = `${this.baseUrl.replace(/\/$/, "")}/flags/${encodeURIComponent(flagName)}/enabled`;
const resp = await fetch(url, {
headers: this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : undefined,
});
if (!resp.ok) {
// degrade safely
return false;
}
const body = await resp.json();
return Boolean(body?.enabled);
},
catch: () => false,
});
}
rolloutPercentage(flagName: string) {
return Effect.tryPromise({
try: async () => {
const url = `${this.baseUrl.replace(/\/$/, "")}/flags/${encodeURIComponent(flagName)}`;
const resp = await fetch(url, {
headers: this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : undefined,
});
if (!resp.ok) return 0;
const body = await resp.json();
const value = typeof body?.rollout === "number" ? body.rollout : 0;
return Math.max(0, Math.min(100, value));
},
catch: () => 0,
});
}
}
export function createHTTPFeatureFlagLayer(): Layer.Layer<
FeatureFlagService,
never,
AgentConfigService
> {
return Layer.effect(
FeatureFlagServiceTag,
Effect.gen(function* () {
const configService = yield* AgentConfigServiceTag;
const appConfig = yield* configService.appConfig; // AppConfig should include a featureFlags section
const baseUrl = appConfig.featureFlags?.baseUrl ?? "https://flags.example.com";
const apiKey = appConfig.featureFlags?.apiKey;
return new HTTPFeatureFlagService(baseUrl, apiKey);
}),
);
}
Notes:
- The Layer declares it requires
AgentConfigServiceso it can read configuration (see the type parameterAgentConfigServicein the return type). Effect.tryPromisewraps async operations and converts promise rejections to Effect errors. Here we catch all errors and return safe defaults (false/0) to avoid breaking the app if the flag service is unavailable.- The Layer type
Layer.Layer<FeatureFlagService, never, AgentConfigService>means:- Provides:
FeatureFlagService(what this layer gives you) - Errors:
never(this layer creation never fails) - Requires:
AgentConfigService(what this layer needs to be created)
- Provides:
3. Wiring — provide the layer in createAppLayer (example)
In your app bootstrap (e.g., src/main.ts) you compose layers. Example snippet:
import { Layer } from "effect";
import { createConfigLayer } from "./services/config"; // provides AgentConfigServiceTag
import { createLoggerLayer } from "./services/logger";
import { createHTTPFeatureFlagLayer } from "./services/feature-flag/http";
function createAppLayer() {
const configLayer = createConfigLayer(); // provides AgentConfigServiceTag
const loggerLayer = createLoggerLayer();
// feature flag layer depends on AgentConfigService; provide configLayer first
const featureFlagLayer = createHTTPFeatureFlagLayer().pipe(Layer.provide(configLayer));
return Layer.mergeAll(
configLayer,
loggerLayer,
featureFlagLayer,
// ...other layers
);
}
Important:
- Layer dependencies: The FeatureFlag layer requires
AgentConfigService, so you must provide it before using the layer. - Two ways to provide dependencies:
Layer.provide: Explicitly provide a dependency to a single layer (as shown above)Layer.mergeAll: Merge multiple layers together; dependencies are resolved automatically if all required layers are included
- Ordering matters: When using
Layer.provide, provide dependencies before the layer that needs them. When usingLayer.mergeAll, include all required layers in the merge.
4. Usage — how core and CLI access the service
Architecture overview:
- Services implement core interfaces (services depend on core) — services are the concrete implementations
- Core and CLI access services through dependency injection via tags — they use the service without knowing the implementation
Both core and CLI layers can use the service by:
- Importing the Tag from
core/interfaces - Accessing it via
yield*inside anEffect.genblock - Calling methods on the service
Creating utility functions:
Utility functions that wrap service calls should live in packages/core/src/utils/ (or packages/core/src/agent/ if agent-specific). These are convenience wrappers that use the service tag — they’re not part of the service implementation itself.
// packages/core/src/utils/feature-flag.ts
import { Effect } from "effect";
import { FeatureFlagServiceTag, type FeatureFlagService } from "../interfaces/feature-flag";
/**
* Check if a feature flag is enabled.
*
* @param flagName - The name of the feature flag to check
* @returns An Effect that resolves to true if enabled, false otherwise
*
* @example
* ```ts
* const enabled = yield* isFeatureEnabled("new-ui");
* if (enabled) {
* // use new UI
* }
* ```
*/
export function isFeatureEnabled(
flagName: string,
): Effect.Effect<boolean, never, FeatureFlagService> {
return Effect.gen(function* () {
// yield* extracts the service from the Effect context
const flags = yield* FeatureFlagServiceTag;
// Call the service method
return yield* flags.isEnabled(flagName);
});
}
// Alternative: if you want a function that conditionally runs code
export function whenFeatureEnabled<T>(
flagName: string,
whenEnabled: () => Effect.Effect<T, never, FeatureFlagService>,
whenDisabled?: () => Effect.Effect<T, never, FeatureFlagService>,
): Effect.Effect<T, never, FeatureFlagService> {
return Effect.gen(function* () {
const enabled = yield* isFeatureEnabled(flagName);
if (enabled) {
return yield* whenEnabled();
} else if (whenDisabled) {
return yield* whenDisabled();
}
return undefined as T;
});
}
Usage examples:
In core code (packages/core/src/agent/agent-runner.ts):
import { Effect } from "effect";
import { isFeatureEnabled } from "../utils/feature-flag";
// Use the utility in core business logic
export function executeAgent(agentId: string) {
return Effect.gen(function* () {
// Effect.gen is Effect's equivalent of async/await
// yield* extracts values from Effects and services from the context
const enabled = yield* isFeatureEnabled("new-agent-strategy");
if (enabled) {
yield* runNewStrategy(agentId); // new feature path
} else {
yield* runLegacyStrategy(agentId); // fallback path
}
});
}
In CLI code (packages/cli/src/commands/chat-agent.ts):
import { Effect } from "effect";
import { isFeatureEnabled } from "../../core/utils/feature-flag";
export function chatWithAIAgentCommand(agentId: string) {
return Effect.gen(function* () {
// Check feature flag in CLI command
const useNewUI = yield* isFeatureEnabled("new-chat-ui");
if (useNewUI) {
yield* renderNewChatInterface(agentId);
} else {
yield* renderLegacyChatInterface(agentId);
}
});
}
Using the conditional runner (alternative pattern):
import { whenFeatureEnabled } from "../utils/feature-flag";
export function executeAgent(agentId: string) {
return whenFeatureEnabled(
"new-agent-strategy",
() => runNewStrategy(agentId), // when enabled
() => runLegacyStrategy(agentId), // when disabled
);
}
Direct usage (without the utility):
You can also use the service directly without a utility function:
// In core/ or cli/ code
import { Effect } from "effect";
import { FeatureFlagServiceTag } from "../interfaces/feature-flag";
const program = Effect.gen(function* () {
// Access the service directly via its tag
const flags = yield* FeatureFlagServiceTag;
// Call the service method
const enabled = yield* flags.isEnabled("new-feature");
if (enabled) {
// feature is enabled
}
});
// Run the program with the app layer that provides FeatureFlagService
const result = yield * program.pipe(Effect.provide(appLayer));
5. Testing — mock the service with Layer.succeed
Unit tests should not call the remote flag service. Provide a mock implementation with Layer.succeed.
// packages/adapters/src/feature-flag-service.test.ts
import { describe, it, expect } from "bun:test";
import { Effect, Layer } from "effect";
import { FeatureFlagServiceTag } from "@/core/interfaces/feature-flag";
const mockService = {
isEnabled: (name: string) => Effect.succeed(name === "beta-dashboard"),
rolloutPercentage: (name: string) => Effect.succeed(50),
};
it("uses mocked feature flag", async () => {
const program = Effect.gen(function* () {
const flags = yield* FeatureFlagServiceTag;
return yield* flags.isEnabled("beta-dashboard");
});
const result = await Effect.runPromise(
program.pipe(Effect.provide(Layer.succeed(FeatureFlagServiceTag, mockService))),
);
expect(result).toBe(true);
});
Notes:
Layer.succeed: Provides a mock service implementation directly. This is the simplest way to mock services in tests.- Test isolation: Unit tests should never call external services. Always mock with
Layer.succeedor similar. - Integration tests: For integration tests, provide the real layer (from
createAppLayer) but run against a test flag service or use a local stub server. - Effect.provide: This pipes the mock layer into your program, making the service available in the Effect context.
6. Notes, safety, and checklist
Checklist before copying into production src/:
- Add configuration types to
packages/core/src/types/config.ts(featureFlags config block) - Add typed errors in
packages/core/src/types/errors.tsif the service needs to surface structured failures - Use LoggerServiceTag for structured logging inside the service (instead of console)
- Add unit tests and, optionally, integration tests that run against a test flag server
- Ensure no secrets (API keys) are committed to the repo — use env variables or secure stores
- Ensure feature-flag calls are safe and fail closed or open according to product policy (this example fails safe to
false)
FAQ
Q: What if feature flags are not critical and their failure shouldn’t break the app?
A: Use conservative defaults (false/0) to avoid unexpected behavior. Consider adding metrics to detect when flags are unavailable.
For the directory structure, Effect patterns, and testing conventions this template follows, see the Code map.