How to Build a Simple Feature Flag System in Dart

Feature flags (also called feature toggles) let you enable or disable features in your application at runtime instead of branching your code or deploying multiple versions.
How to Build a Simple Feature Flag System in Dart
simple feature flag system in Dart

Feature flags (also called feature toggles) let you enable or disable features in your application at runtime. Instead of branching your code or deploying multiple versions, you can control feature visibility through configuration.

Feature flags are a powerful technique for controlling feature rollouts, A/B testing, and managing application behavior without deploying new code. This guide will walk you through building a simple but effective feature flag system in Dart.

Common Use Cases:

  • Gradual rollouts: Release features to a percentage of users 
  • A/B testing: Show different features to different user groups 
  • Kill switches: Quickly disable problematic features 
  • Environment-specific features: Show dev features only in development

Benefits:

  • Reduced deployment risk 
  • Faster iteration cycles 
  • Better user experience control 
  • Simplified testing and debugging

We'll build a lightweight system that stores flags in memory, evaluates them based on user context, and exposes them through a REST API.

Building the Flag Entity

This section covers the core data structure that represents a feature flag in our system, including all the properties needed to store and manage flag information.

Let's start with the core flag entity that represents a feature flag:

Filename: dart_feature_flag/lib/entities/flag.dart:

enum FlagType {
  boolean,
  string,
  number,
}

class Flag {
  final String key;
  final String name;
  final FlagType type;
  final bool enabled;
  final dynamic defaultValue;
  final String? description;
  final DateTime createdAt;
  final DateTime updatedAt;

  Flag({
    required this.key,
    required this.name,
    required this.type,
    required this.enabled,
    required this.defaultValue,
    this.description,
    DateTime? createdAt,
    DateTime? updatedAt,
  })  : createdAt = createdAt ?? DateTime.now(),
        updatedAt = updatedAt ?? DateTime.now();

  Map<String, dynamic> toJson() {
    return {
      'key': key,
      'name': name,
      'type': type.toString().split('.').last,
      'enabled': enabled,
      'defaultValue': defaultValue,
      'description': description,
      'createdAt': createdAt.toIso8601String(),
      'updatedAt': updatedAt.toIso8601String(),
    };
  }

  factory Flag.fromJson(Map<String, dynamic> json) {
    return Flag(
      key: json['key'],
      name: json['name'],
      type: FlagType.values.firstWhere(
        (e) => e.toString().split('.').last == json['type'],
      ),
      enabled: json['enabled'],
      defaultValue: json['defaultValue'],
      description: json['description'],
      createdAt: DateTime.parse(json['createdAt']),
      updatedAt: DateTime.parse(json['updatedAt']),
    );
  }

  Flag copyWith({
    String? key,
    String? name,
    FlagType? type,
    bool? enabled,
    dynamic defaultValue,
    String? description,
    DateTime? updatedAt,
  }) {
    return Flag(
      key: key ?? this.key,
      name: name ?? this.name,
      type: type ?? this.type,
      enabled: enabled ?? this.enabled,
      defaultValue: defaultValue ?? this.defaultValue,
      description: description ?? this.description,
      createdAt: createdAt,
      updatedAt: updatedAt ?? DateTime.now(),
    );
  }
}

This flag entity includes:

  • key: Unique identifier for the flag 
  • name: Human-readable name 
  • type: Boolean, string, or number values 
  • enabled: Whether the flag is active 
  • defaultValue: Fallback value when flag is disabled 
  • description: Optional documentation

Creating Basic Evaluation Logic

Here we'll implement the logic that determines what value a flag should return based on user context and flag configuration.

The evaluator determines what value a flag should return for a given context:

Filename: dart_feature_flag/lib/models/evaluation_context.dart:

class EvaluationContext {
  final String? userId;
  final String? country;
  final String? deviceType;
  final Map<String, dynamic> attributes;

  EvaluationContext({
    this.userId,
    this.country,
    this.deviceType,
    Map<String, dynamic>? attributes,
  }) : attributes = attributes ?? {};

  Map<String, dynamic> toJson() {
    return {
      'userId': userId,
      'country': country,
      'deviceType': deviceType,
      'attributes': attributes,
    };
  }
}

For FlagEvaluator:

Filename: dart_feature_flag/lib/services/flag_evaluator.dart:

class FlagEvaluator {
  /// Evaluates a flag against the given context
  dynamic evaluate(Flag flag, EvaluationContext context) {
    // If flag is disabled, return default value
    if (!flag.enabled) {
      return flag.defaultValue;
    }

    // For this simple implementation, we'll just return the default value
    // In a more advanced system, you would evaluate targeting rules here
    return flag.defaultValue;
  }

  /// Convenience method for boolean flags
  bool isEnabled(Flag flag, EvaluationContext context) {
    final result = evaluate(flag, context);
    return result is bool ? result : false;
  }

  /// Convenience method for string flags
  String getStringValue(Flag flag, EvaluationContext context, String fallback) {
    final result = evaluate(flag, context);
    return result is String ? result : fallback;
  }

  /// Convenience method for number flags
  num getNumberValue(Flag flag, EvaluationContext context, num fallback) {
    final result = evaluate(flag, context);
    return result is num ? result : fallback;
  }
}

The evaluator provides:

  • evaluate(): Main evaluation method that returns the flag value 
  • isEnabled(): Boolean convenience method 
  • getStringValue() and getNumberValue(): Type-safe value retrieval 

Setting Up HTTP API

This section creates a REST API that allows external applications to manage flags and retrieve their values over HTTP.

Now let's create a REST API to manage and evaluate flags:

Filename: dart_feature_flag/backend/api/lib/controllers/flag_controller.dart:

import 'dart:convert';
import 'dart:io';
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart';
import 'package:shelf_router/shelf_router.dart';

class FlagController {
  final FlagRepository _repository;
  final FlagEvaluator _evaluator;

  FlagController(this._repository, this._evaluator);

  Router get router {
    final router = Router();

    // Evaluate a flag
    router.get('/flags/<key>/evaluate', _evaluateFlag);
    
    // Create a flag
    router.post('/flags', _createFlag);
    
    // Update a flag
    router.put('/flags/<key>', _updateFlag);
    
    // Get all flags
    router.get('/flags', _getAllFlags);
    
    // Delete a flag
    router.delete('/flags/<key>', _deleteFlag);

    return router;
  }

  Future<Response> _evaluateFlag(Request request) async {
    try {
      final key = request.params['key']!;
      final flag = _repository.getFlag(key);

      if (flag == null) {
        return Response.notFound('Flag not found');
      }

      // Parse context from query parameters
      final context = EvaluationContext(
        userId: request.url.queryParameters['userId'],
        country: request.url.queryParameters['country'],
        deviceType: request.url.queryParameters['deviceType'],
      );

      final value = _evaluator.evaluate(flag, context);

      return Response.ok(
        jsonEncode({
          'key': key,
          'value': value,
          'enabled': flag.enabled,
        }),
        headers: {'Content-Type': 'application/json'},
      );
    } catch (e) {
      return Response.internalServerError(
        body: jsonEncode({'error': e.toString()}),
      );
    }
  }

  Future<Response> _createFlag(Request request) async {
    try {
      final body = await request.readAsString();
      final data = jsonDecode(body) as Map<String, dynamic>;

      final flag = Flag(
        key: data['key'],
        name: data['name'],
        type: FlagType.values.firstWhere(
          (e) => e.toString().split('.').last == data['type'],
        ),
        enabled: data['enabled'] ?? true,
        defaultValue: data['defaultValue'],
        description: data['description'],
      );

      _repository.saveFlag(flag);

      return Response.ok(
        jsonEncode(flag.toJson()),
        headers: {'Content-Type': 'application/json'},
      );
    } catch (e) {
      return Response.badRequest(
        body: jsonEncode({'error': e.toString()}),
      );
    }
  }

  Future<Response> _updateFlag(Request request) async {
    try {
      final key = request.params['key']!;
      final existingFlag = _repository.getFlag(key);

      if (existingFlag == null) {
        return Response.notFound('Flag not found');
      }

      final body = await request.readAsString();
      final data = jsonDecode(body) as Map<String, dynamic>;

      final updatedFlag = existingFlag.copyWith(
        enabled: data['enabled'],
        defaultValue: data['defaultValue'],
        description: data['description'],
      );

      _repository.saveFlag(updatedFlag);

      return Response.ok(
        jsonEncode(updatedFlag.toJson()),
        headers: {'Content-Type': 'application/json'},
      );
    } catch (e) {
      return Response.internalServerError(
        body: jsonEncode({'error': e.toString()}),
      );
    }
  }

  Future<Response> _getAllFlags(Request request) async {
    try {
      final flags = _repository.getAllFlags();
      return Response.ok(
        jsonEncode(flags.map((f) => f.toJson()).toList()),
        headers: {'Content-Type': 'application/json'},
      );
    } catch (e) {
      return Response.internalServerError(
        body: jsonEncode({'error': e.toString()}),
      );
    }
  }

  Future<Response> _deleteFlag(Request request) async {
    try {
      final key = request.params['key']!;
      final deleted = _repository.deleteFlag(key);

      if (!deleted) {
        return Response.notFound('Flag not found');
      }

      return Response.ok(
        jsonEncode({'message': 'Flag deleted successfully'}),
        headers: {'Content-Type': 'application/json'},
      );
    } catch (e) {
      return Response.internalServerError(
        body: jsonEncode({'error': e.toString()}),
      );
    }

The API provides endpoints for:

  • GET /flags/{key}/evaluate: Evaluate a flag with context 
  • POST /flags: Create new flags 
  • PUT /flags/{key}: Update existing flags 
  • GET /flags: List all flags 
  • DELETE /flags/{key}: Remove flags

Adding Simple Storage

We'll implement an in-memory storage system to persist flags during runtime, which can later be replaced with database storage.

For this example, we'll use in-memory storage. In production, you'd use a database:

Filename: dart_feature_flag/lib/repositories/flag_repository.dart:

class FlagRepository {
  final Map<String, Flag> _flags = {};

  /// Get a flag by key
  Flag? getFlag(String key) {
    return _flags[key];
  }

  /// Save or update a flag
  void saveFlag(Flag flag) {
    _flags[flag.key] = flag;
  }

  /// Get all flags
  List<Flag> getAllFlags() {
    return _flags.values.toList();
  }

  /// Delete a flag
  bool deleteFlag(String key) {
    return _flags.remove(key) != null;
  }

  /// Check if a flag exists
  bool exists(String key) {
    return _flags.containsKey(key);
  }

  /// Get flags count
  int get count => _flags.length;

  /// Clear all flags (useful for testing)
  void clear() {
    _flags.clear();
  }
}

Using Flags in Your App

This section demonstrates how to create a client library and integrate feature flags into your Dart applications.

Create a client to interact with your flag system:

Filename: dart_feature_flag/lib/client/flag_client.dart: (For FlagClient)

import 'dart:convert';
import 'package:http/http.dart' as http;

class FlagClient {
  final String baseUrl;
  final http.Client _client;

  FlagClient(this.baseUrl, {http.Client? client}) 
      : _client = client ?? http.Client();

  /// Check if a boolean flag is enabled
  Future<bool> isEnabled(String key, {EvaluationContext? context}) async {
    try {
      final response = await _evaluateFlag(key, context);
      return response['value'] as bool? ?? false;
    } catch (e) {
      print('Error evaluating flag $key: $e');
      return false;
    }
  }

  /// Get a string flag value
  Future<String> getStringValue(String key, String defaultValue, {EvaluationContext? context}) async {
    try {
      final response = await _evaluateFlag(key, context);
      return response['value'] as String? ?? defaultValue;
    } catch (e) {
      print('Error evaluating flag $key: $e');
      return defaultValue;
    }
  }

  /// Get a number flag value
  Future<num> getNumberValue(String key, num defaultValue, {EvaluationContext? context}) async {
    try {
      final response = await _evaluateFlag(key, context);
      return response['value'] as num? ?? defaultValue;
    } catch (e) {
      print('Error evaluating flag $key: $e');
      return defaultValue;
    }
  }

  Future<Map<String, dynamic>> _evaluateFlag(String key, EvaluationContext? context) async {
    final uri = Uri.parse('$baseUrl/flags/$key/evaluate');
    
    if (context != null) {
      final queryParams = <String, String>{};
      if (context.userId != null) queryParams['userId'] = context.userId!;
      if (context.country != null) queryParams['country'] = context.country!;
      if (context.deviceType != null) queryParams['deviceType'] = context.deviceType!;
      
      final uriWithParams = uri.replace(queryParameters: queryParams);
      final response = await _client.get(uriWithParams);
      
      if (response.statusCode == 200) {
        return jsonDecode(response.body);
      }
    }
    
    throw Exception('Failed to evaluate flag: ${uri.toString()}');
  }
}

Usage Example:

Filename: dart_feature_flag/example/main.dart (For Usage Examples:)

void main() async {
  final flagClient = FlagClient('http://localhost:8080');
  
  final context = EvaluationContext(
    userId: 'user123',
    country: 'US',
    deviceType: 'mobile',
  );

  // Check if new checkout flow is enabled
  if (await flagClient.isEnabled('new_checkout_flow', context: context)) {
    print('Showing new checkout UI');
  } else {
    print('Showing legacy checkout UI');
  }

  // Get app theme
  final theme = await flagClient.getStringValue('app_theme', 'light', context: context);
  print('Using theme: $theme');

  // Get max items limit
  final maxItems = await flagClient.getNumberValue('max_cart_items', 10, context: context);
  print('Max cart items: $maxItems');
}

Code Organization

Filename: dart_feature_flag/backend/api/bin/main.dart

// Create a main server file
Future<void> main() async {
  final repository = FlagRepository();
  final evaluator = FlagEvaluator();
  final controller = FlagController(repository, evaluator);
  
  final handler = Pipeline()
      .addMiddleware(logRequests())
      .addHandler(controller.router);

  final server = await serve(handler, 'localhost', 8080);
  print('Server running on ${server.address.host}:${server.port}');
}

Best Practices

Performance Considerations:

  • Cache flag values -to reduce API calls and improve response times. Implement a TTL-based cache that refreshes periodically. 
  • Batch evaluations -when checking multiple flags by creating an endpoint that accepts multiple flag keys in a single request. 
  • Use connection pooling -for HTTP requests to reuse connections and reduce overhead when making frequent API calls.
  • Implement timeouts -on all HTTP requests to prevent your application from hanging when the flag service is slow or unresponsive.

Error Handling:

  • Always provide defaults -when flag evaluation fails, ensuring your application continues working even when flags are unavailable.
  • Log errors -without breaking user experience - capture flag evaluation failures for monitoring while returning sensible defaults.
  • Graceful degradation -ensures your application falls back to safe defaults when the entire flag service becomes unavailable.

Testing Strategies:

  • Unit test: flag evaluation logic separately  by creating focused tests for the FlagEvaluator class that verify correct value returns for different flag states and contexts without external dependencies.
  • Mock the flag client; in application tests to simulate different flag responses and ensure your application handles both enabled and disabled feature states correctly without making real API calls.
  • Test with different contexts: to verify targeting works by creating test scenarios with various user attributes (different countries, device types, user IDs) and asserting the correct flag values are returned.
  • Integration tests: for API endpoints should verify the complete request/response cycle, including authentication, data validation, error handling, and proper HTTP status codes for all flag management operations.

Handling Flags in Larger Projects

As projects scale, so does the complexity of managing features. A simple in-code flag system, booleans or hardcoded maps, works well during early development, but tends to become brittle when you need:

  • Feature toggling without redeploying
  • Environment- or user-specific flag behavior
  • Gradual rollouts or A/B testing
  • Centralized visibility or control over live flags

At that point, many teams transition from DIY setups to more structured tools that support operational needs out of the box.

For Dart developers, Intellitoggle offers a Dart-native, production-ready alternative built specifically for real-world use. It integrates cleanly into both Flutter apps and Dart backends, making it easier to manage flags consistently across your stack. While not necessary for every project, it becomes especially useful when feature control needs to be dynamic, remote, or environment-aware.

Conclusion

You've successfully built a functional feature flag system that provides the foundation for controlling application behavior at runtime. The system includes a well-structured flag entity supporting multiple data types, a robust evaluation engine, and a complete REST API for flag management.
This modular approach allows you to start simple and progressively add more sophisticated features as your needs grow. The clean separation between storage, evaluation, and API layers makes the system easy to maintain and extend.
With this foundation in place, you can enhance the system by adding database persistence for production use, implementing advanced targeting rules for user segmentation, and creating percentage-based rollouts for gradual feature releases.

About the author

Dart Code Labs

Dart Code Labs - explore content on Dart backend development, authentication, microservices, and server frameworks with expert guides and insights!

Dart Code Labs

Great! You’ve successfully signed up.

Welcome back! You've successfully signed in.

You've successfully subscribed to Dart Code Labs.

Success! Check your email for magic link to sign-in.

Success! Your billing info has been updated.

Your billing was not updated.