▞ jazzdocsblogpersonas
github

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:

  1. Define a small contract in core that core business logic depends on.
  2. Implement the contract in services as a Layer that can depend on configuration/logger/etc.
  3. Provide the Layer in app composition so core code can call the contract via its Tag.
  4. 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/interfaces so 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 (never means 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 AgentConfigService so it can read configuration (see the type parameter AgentConfigService in the return type).
  • Effect.tryPromise wraps 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)

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:
    1. Layer.provide: Explicitly provide a dependency to a single layer (as shown above)
    2. 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 using Layer.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:

  1. Importing the Tag from core/interfaces
  2. Accessing it via yield* inside an Effect.gen block
  3. 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.succeed or 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.ts if 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.

machine-readable: /docs/internals/service-template.md · /llms.txt · /llms-full.txt