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.