A production-ready, modular Swift feature toggle/flag management library for iOS applications, powered by Firebase Remote Config. FeatureToggleKit provides a clean, protocol-oriented architecture with separate modules for interfaces, implementation, and testingβdemonstrating SOLID principles and Clean Architecture best practices.
π Zero Vendor Lock-in Architecture
Swap Firebase for LaunchDarkly, Split.io, or your custom backend with ONE line of code change. Your entire app depends on interfaces, not implementations. No breaking changes. No feature module rewrites. Just true architectural flexibility.
// Change this ONE line to switch providers - that's it!
let featureToggle: FeatureToggleKit = FeatureToggleKitImp() // Firebase
let featureToggle: FeatureToggleKit = LaunchDarklyToggleKit() // LaunchDarkly
let featureToggle: FeatureToggleKit = YourCustomProvider() // Your backend
// β
All feature modules keep working - ZERO changes needed!Perfect for: Enterprise apps that need provider flexibility, teams concerned about vendor lock-in, or projects that may need to migrate providers in the future.
π See full provider flexibility examples below β
- β Type-safe feature toggles with support for multiple value types (Bool, String, Int64, Double, JSON)
- β Firebase Remote Config integration for dynamic feature management without app releases
- β Real-time updates via listener pattern for instant feature flag changes
- β Local override (Tweaks) for QA testing and development
- β Thread-safe operations with Swift 6 strict concurrency support
- β Zero-dependency interface for feature modules
- ποΈ Clean Architecture with clear separation of concerns
- π Dependency Injection ready - perfect for modular apps
- π§ͺ 100% testable with comprehensive mock implementations
- π¦ Modular design - use only what you need (Interface, Implementation, Mock)
- π― Protocol-oriented following Swift best practices
- π Type-safe API prevents runtime errors
FeatureToggleKit follows the Interface Segregation Principle and is organized into three distinct modules for maximum flexibility and minimal coupling:
The interface module contains all protocols and data models. This module should be injected into your feature modules to maintain loose coupling.
Key Benefits:
- β Zero dependencies (no Firebase, no external frameworks)
- β Lightweight - perfect for feature modules
- β Enables true dependency injection
- β Makes your feature modules independently testable
Contains:
FeatureToggleKit- Main protocol for accessing feature togglesFeatureToggleDefinition- Defines a feature toggle with key and default valueFeatureToggleValue- Type-safe enum representing different value typesFeatureToggleKitListener- Protocol for receiving toggle update notifications
The implementation module provides production-ready concrete implementations using Firebase Remote Config. Initialize this in your main app and inject it to feature modules.
Key Benefits:
- β Firebase Remote Config integration for remote feature management
- β Thread-safe local caching with UserDefaults persistence
- β Observable pattern with listener management
- β Tweak/override support for QA and debugging
- β Async/await Swift 6 concurrency support
Mock implementations for unit testing. Use this module in your test targets to write isolated, fast tests without Firebase dependencies.
Key Benefits:
- β No network calls - fast test execution
- β Predictable behavior - no flaky tests
- β Full control over test scenarios
- β Works with any testing framework (XCTest, Quick/Nimble, etc.)
Contains:
FeatureToggleKitMock- Mock implementation with configurable return valuesFeatureToggleKitListenerMock- Mock listener for testing notifications- Full protocol conformance for drop-in replacement
- iOS 15.0+
- Swift 6.0+
- Xcode 16.0+
- Firebase Remote Config 12.3.0+
Add FeatureToggleKit to your Package.swift:
dependencies: [
.package(url: "https://github.com/harryngict/FeatureToggleKit.git", from: "0.0.0")
]Then add the appropriate modules to your targets:
.target(
name: "YourApp",
dependencies: [
.product(name: "FeatureToggleKitImp", package: "FeatureToggleKit")
]
),
.target(
name: "YourFeatureModule",
dependencies: [
.product(name: "FeatureToggleKit", package: "FeatureToggleKit")
]
),
.testTarget(
name: "YourTests",
dependencies: [
.product(name: "FeatureToggleKitMock", package: "FeatureToggleKit")
]
)Add to your Podfile:
# Main app target
target 'YourApp' do
pod 'FeatureToggleKitImp'
end
# Feature module target
target 'YourFeatureModule' do
pod 'FeatureToggleKit'
end
# Test target
target 'YourAppTests' do
pod 'FeatureToggleKitMock'
endInitialize FeatureToggleKit in your app's entry point:
import FeatureToggleKitImp
import FirebaseCore
// Configure Firebase
FirebaseApp.configure()
// Create feature toggle definitions
let definitionProvider = YourDefinitionProvider()
// Initialize the implementation
let featureToggleKit = FeatureToggleKitImp(tweakCacheService: TweakCacheServiceImp(cacheType: .both))
featureToggleKit.setup(definitionProviders: [definitionProvider])
// Inject to your feature modules
let featureModule = FeatureModule(featureToggleKit: featureToggleKit)Create a definition provider:
enum YourFeatureToggleDefinition: String, CaseIterable {
case testFeatureToggleBool = "test_bool_enabled"
case testFeatureToggleInt = "test_int_value"
case testFeatureToggleDouble = "test_double_value"
case testFeatureToggleString = "test_string_value"
case testFeatureToggleJSON = "test_json_value"
var defaultValue: FeatureToggleValue {
switch self {
case .testFeatureToggleBool: return false
case .testFeatureToggleInt: return 0
case .testFeatureToggleDouble: return 0.0
case .testFeatureToggleString: return ""
case .testFeatureToggleJSON: return .json(value: [:])
}
}
}
struct YourDefinitionProvider: FeatureToggleDefinitionProvider {
init() {}
let name = "Harry iOS Team"
var definitions: [FeatureToggleDefinition] {
YourFeatureToggleDefinition.allCases.map { definition in
FeatureToggleDefinition(
key: definition.rawValue,
defaultValue: definition.defaultValue)
}
}
}Feature modules only depend on the interface:
import FeatureToggleKit
class FeatureModule {
private let featureToggleKit: FeatureToggleKit
init(featureToggleKit: FeatureToggleKit) {
self.featureToggleKit = featureToggleKit
}
func checkFeature() {
// Get boolean value
let isEnabled = featureToggleKit.getBoolValue(key: "new_feature_enabled")
// Get string value with fallback
let message = featureToggleKit.getStringValue(
key: "welcome_message",
fallbackValue: "Default Message"
)
// Get numeric values
let timeout = featureToggleKit.getLongValue(key: "api_timeout")
let multiplier = featureToggleKit.getDoubleValue(key: "price_multiplier")
// Get JSON configuration
let config = featureToggleKit.getJSONValue(key: "feature_config")
}
}Implement the listener protocol to receive real-time updates:
import FeatureToggleKit
class FeatureObserver: FeatureToggleKitListener {
func didReceiveFeatureToggleUpdates(values: [(String, FeatureToggleValue)]) async {
for (key, value) in values {
print("Feature \(key) updated to \(value)")
}
}
func didReceiveError(error: Error?) async {
print("Error receiving updates: \(String(describing: error))")
}
}
// Add listener
let observer = FeatureObserver()
featureToggleKit.addListener(observer)
// Remove when done
featureToggleKit.removeListener(observer)The FeatureToggleKitMock module provides comprehensive mock implementations for testing. All mocks are thread-safe and Sendable, supporting Swift 6 strict concurrency.
import XCTest
import FeatureToggleKit
import FeatureToggleKitMock
class FeatureModuleTests: XCTestCase {
var mockToggleKit: FeatureToggleKitMock!
override func setUp() {
super.setUp()
mockToggleKit = FeatureToggleKitMock()
}
func testFeatureWithToggleEnabled() {
// Configure mock behavior using handler
mockToggleKit.getBoolValueHandler = { key, fallback in
return key == "new_feature_enabled" ? true : fallback
}
// Test your feature
let feature = FeatureModule(featureToggleKit: mockToggleKit)
let isEnabled = feature.checkNewFeature()
// Assertions
XCTAssertTrue(isEnabled)
XCTAssertEqual(mockToggleKit.getBoolValueCallCount, 1)
}
}Each mock method has a corresponding handler property for custom behavior:
// Boolean values
mockToggleKit.getBoolValueHandler = { key, fallback in
switch key {
case "premium_enabled": return true
case "beta_features": return false
default: return fallback
}
}
// String values
mockToggleKit.getStringValueHandler = { key, fallback in
return key == "api_url" ? "https://test.api.com" : fallback
}
// Numeric values
mockToggleKit.getLongValueHandler = { key, fallback in
return key == "timeout" ? 30 : fallback
}
mockToggleKit.getDoubleValueHandler = { key, fallback in
return key == "price_multiplier" ? 1.5 : fallback
}
// JSON configuration
mockToggleKit.getJSONValueHandler = { key, fallback in
if key == "app_config" {
return ["theme": "dark", "version": "2.0"]
}
return fallback
}Track how many times each method was called:
func testFeatureCallsToggleCorrectly() {
mockToggleKit.getBoolValueHandler = { _, _ in true }
let feature = FeatureModule(featureToggleKit: mockToggleKit)
feature.performAction()
// Verify the feature toggle was checked
XCTAssertEqual(mockToggleKit.getBoolValueCallCount, 1)
feature.performAnotherAction()
// Verify multiple calls
XCTAssertEqual(mockToggleKit.getBoolValueCallCount, 2)
}Test real-time feature toggle updates:
func testFeatureTogglesListener() async {
let expectation = XCTestExpectation(description: "Listener called")
// Configure listener handler
mockToggleKit.addListenerHandler = { listener in
// Simulate receiving updates
Task {
await listener.didReceiveFeatureToggleUpdates(values: [
("new_feature", .bool(value: true))
])
expectation.fulfill()
}
}
let observer = FeatureObserver()
mockToggleKit.addListener(observer)
await fulfillment(of: [expectation], timeout: 1.0)
XCTAssertEqual(mockToggleKit.addListenerCallCount, 1)
}Test async methods like updateLocal:
func testLocalOverride() async {
var capturedKey: String?
var capturedValue: FeatureToggleValue?
mockToggleKit.updateLocalHandler = { key, value in
capturedKey = key
capturedValue = value
}
await mockToggleKit.updateLocal(
key: "test_feature",
newValue: .bool(value: true)
)
XCTAssertEqual(capturedKey, "test_feature")
XCTAssertEqual(capturedValue, .bool(value: true))
XCTAssertEqual(mockToggleKit.updateLocalCallCount, 1)
}func testFeatureToggleSetup() {
let definitionProvider = TestDefinitionProvider()
mockToggleKit.setupHandler = { providers in
XCTAssertEqual(providers.count, 1)
XCTAssertEqual(providers.first?.name, "TestProvider")
}
mockToggleKit.setup(definitionProviders: [definitionProvider])
XCTAssertEqual(mockToggleKit.setupCallCount, 1)
}
func testClearCache() {
var cacheClearedCalled = false
mockToggleKit.clearCacheHandler = {
cacheClearedCalled = true
}
mockToggleKit.clearCache()
XCTAssertTrue(cacheClearedCalled)
XCTAssertEqual(mockToggleKit.clearCacheCallCount, 1)
}func testGetFeatureToggleTweak() {
let definition = FeatureToggleDefinition(
key: "test_feature",
defaultValue: .bool(value: false)
)
mockToggleKit.getFeatureToggleTweakHandler = { def in
if def.key == "test_feature" {
return FeatureToggleTweak(
definition: def,
isEnabled: true,
tweakedValue: .bool(value: true)
)
}
return nil
}
let tweak = mockToggleKit.getFeatureToggleTweak(definition)
XCTAssertNotNil(tweak)
XCTAssertTrue(tweak?.isEnabled ?? false)
XCTAssertEqual(mockToggleKit.getFeatureToggleTweakCallCount, 1)
}For more granular testing, use protocol-specific mocks:
// Testing value retrieval only
let valueProviderMock = FeatureToggleValueProvidingMock()
valueProviderMock.getBoolValueHandler = { _, _ in true }
// Testing listener management only
let listenerMock = FeatureToggleListenerManagingMock()
listenerMock.addListenerHandler = { listener in
// Custom logic
}
// Testing cache management only
let cacheMock = FeatureToggleTweakCacheManagingMock()
cacheMock.clearCacheHandler = {
// Custom logic
}- Always set handlers before testing: Without handlers, mocks return default values (false, "", 0, etc.)
- Use call counts for verification: Ensure your feature interacts with toggles as expected
- Test both enabled and disabled states: Cover all code paths
- Leverage protocol mocks: Use specific protocol mocks for focused unit tests
- Thread-safety: All mocks are
@unchecked Sendableand thread-safe with internal locking - No network calls: Mocks run entirely in-memory for fast, reliable tests
import XCTest
import FeatureToggleKit
import FeatureToggleKitMock
class CheckoutModuleTests: XCTestCase {
var mockToggleKit: FeatureToggleKitMock!
var checkoutModule: CheckoutModule!
override func setUp() {
super.setUp()
mockToggleKit = FeatureToggleKitMock()
checkoutModule = CheckoutModule(featureToggleKit: mockToggleKit)
}
func testCheckoutWithNewFlowEnabled() {
// Given: New checkout flow is enabled
mockToggleKit.getBoolValueHandler = { key, _ in
return key == "new_checkout_flow"
}
// When: User proceeds to checkout
let result = checkoutModule.proceedToCheckout()
// Then: New flow is used
XCTAssertTrue(result.usedNewFlow)
XCTAssertEqual(mockToggleKit.getBoolValueCallCount, 1)
}
func testCheckoutWithNewFlowDisabled() {
// Given: New checkout flow is disabled
mockToggleKit.getBoolValueHandler = { _, fallback in fallback }
// When: User proceeds to checkout
let result = checkoutModule.proceedToCheckout()
// Then: Old flow is used
XCTAssertFalse(result.usedNewFlow)
XCTAssertEqual(mockToggleKit.getBoolValueCallCount, 1)
}
func testCheckoutConfiguration() {
// Given: Custom checkout config
mockToggleKit.getJSONValueHandler = { key, _ in
if key == "checkout_config" {
return ["payment_methods": ["card", "paypal"], "timeout": 30]
}
return [:]
}
// When: Loading checkout config
let config = checkoutModule.loadConfiguration()
// Then: Config is properly loaded
XCTAssertEqual(config.paymentMethods.count, 2)
XCTAssertEqual(mockToggleKit.getJSONValueCallCount, 1)
}
}Override feature toggle values locally for testing:
// Update local value
await featureToggleKit.updateLocal(
key: "new_feature_enabled",
newValue: .bool(value: true)
)
// Get tweak for a definition
let definition = FeatureToggleDefinition(key: "new_feature_enabled", defaultValue: false)
if let tweak = featureToggleKit.getFeatureToggleTweak(definition) {
tweak.isEnabled = true
tweak.tweakedValue = .bool(value: false)
}
// Clear all local overrides
featureToggleKit.clearCache()The modular architecture provides clear separation of concerns:
βββββββββββββββββββββββββββββββββββββββββββ
β Main App β
β (FeatureToggleKitImp) β
β - Initialize implementation β
β - Configure Firebase β
β - Inject to feature modules β
βββββββββββββββ¬ββββββββββββββββββββββββββββ
β inject
βΌ
βββββββββββββββββββββββββββββββββββββββββββ
β Feature Modules β
β (FeatureToggleKit - Interface) β
β - Depend only on protocols β
β - Loosely coupled β
β - Easy to test β
βββββββββββββββ¬ββββββββββββββββββββββββββββ
β mock in tests
βΌ
βββββββββββββββββββββββββββββββββββββββββββ
β Unit Tests β
β (FeatureToggleKitMock) β
β - Mock implementations β
β - Fast, isolated tests β
β - No Firebase dependency β
βββββββββββββββββββββββββββββββββββββββββββ
Benefits of this Architecture:
- Interface Module: Enables dependency injection and loose coupling (Dependency Inversion Principle)
- Implementation Module: Provides real Firebase-backed implementation (Single Responsibility)
- Mock Module: Facilitates fast, reliable unit testing without external dependencies
- Provider Flexibility: Swap Firebase for LaunchDarkly/Split.io/custom backend with ZERO changes to feature modules
- Vendor Independence: No lock-in to any specific provider - migrate freely
- Team Velocity: Feature teams can work independently without main app dependencies
- Compile-time Safety: Interface changes are caught at compile time across all modules
One of the most powerful aspects of FeatureToggleKit's architecture is provider flexibility. Your entire app depends on the interface, not the concrete Firebase implementation. This means:
// Today: Using Firebase Remote Config
let featureToggle: FeatureToggleKit = FeatureToggleKitImp() // Firebase implementation
// Tomorrow: Switch to LaunchDarkly - ZERO changes to feature modules!
let featureToggle: FeatureToggleKit = LaunchDarklyFeatureToggleKit()
// Or use your custom backend
let featureToggle: FeatureToggleKit = CustomBackendFeatureToggle()
// Your feature modules don't know or care!
class CheckoutModule {
init(featureToggle: FeatureToggleKit) { // Still just the interface!
// Works with ANY implementation
}
}Scenario 1: Vendor Migration
// Need to migrate from Firebase to LaunchDarkly?
// Just implement the FeatureToggleKit protocol!
class LaunchDarklyFeatureToggleKit: FeatureToggleKit {
// Implement the protocol methods
func getBoolValue(key: String, fallbackValue: Bool) -> Bool {
return ldClient.boolVariation(key: key, defaultValue: fallbackValue)
}
// ... other methods
}
// In your app initialization - ONE LINE CHANGE
// Before:
let featureToggle = FeatureToggleKitImp() // Firebase
// After:
let featureToggle = LaunchDarklyFeatureToggleKit() // LaunchDarkly
// β
All feature modules continue working without ANY changes!
// β
No recompilation of feature modules needed!
// β
Zero breaking changes!Scenario 2: Multi-Tenant or Hybrid Solutions
// Use different providers for different environments or clients
let featureToggle: FeatureToggleKit = {
switch environment {
case .production:
return FeatureToggleKitImp() // Firebase for most users
case .enterprise:
return CustomEnterpriseToggle() // Custom solution for enterprise
case .testing:
return FeatureToggleKitMock() // Mocks for automated tests
}
}()Scenario 3: Cost Optimization
// Start with expensive provider, migrate to cheaper one later
class CombinedFeatureToggleKit: FeatureToggleKit {
// Use free UserDefaults for low-priority toggles
// Use paid service only for critical feature flags
func getBoolValue(key: String, fallbackValue: Bool) -> Bool {
if criticalFeatures.contains(key) {
return premiumProvider.getValue(key)
}
return userDefaultsProvider.getValue(key)
}
}| Benefit | Impact |
|---|---|
| No Vendor Lock-in | Switch providers without rewriting your app |
| Risk Mitigation | Test new providers in parallel before full migration |
| Cost Control | Move to cheaper alternatives as needed |
| Flexibility | Use different providers per market/region/client |
| Future-Proof | New providers can be added without breaking changes |
π― ARCHITECTURAL HIGHLIGHT
Single Point of Integration: To add a new provider (LaunchDarkly, Split.io, custom backend), you only need to:
- Implement the
FeatureToggleServiceprotocol (5 simple methods)- Pass it to
FeatureToggleProviderImpconstructorThat's it! No changes to interfaces, no changes to feature modules, no changes to the rest of the implementation.
Firebase Remote Config currently implements this protocol. To use a different provider, just create your own implementation:
import FeatureToggleKit
// Current implementation uses Firebase
// File: FeatureToggleProviderImp.swift (line 10)
public init(featureToggleService: FeatureToggleService = RemoteConfig.remoteConfig()) {
self.featureToggleService = featureToggleService
}
// The FeatureToggleService protocol (only 5 methods!)
protocol FeatureToggleService {
func getFeatureToggleValue(_ definition: FeatureToggleDefinition) -> FeatureToggleValue?
func addOnConfigUpdateListener(completion: @escaping (Set<String>?, Error?) -> Void)
func activateWithCompletion(completion: @escaping (Bool, Error?) -> Void)
func setDefaults(_ definition: FeatureToggleDefinition)
func fetchAndActivate(completion: @escaping () -> Void)
}// Example: LaunchDarkly implementation
import LaunchDarkly
import FeatureToggleKit
class LaunchDarklyService: FeatureToggleService {
private let ldClient: LDClient
init(client: LDClient) {
self.ldClient = client
}
func getFeatureToggleValue(_ definition: FeatureToggleDefinition) -> FeatureToggleValue? {
// Map to LaunchDarkly API
switch definition.defaultValue {
case .bool:
let value = ldClient.variation(forKey: definition.key, defaultValue: false)
return .bool(value: value)
case .string:
let value = ldClient.variation(forKey: definition.key, defaultValue: "")
return .string(value: value)
// ... other types
}
}
func addOnConfigUpdateListener(completion: @escaping (Set<String>?, Error?) -> Void) {
// LaunchDarkly listener logic
ldClient.observe { keys in
completion(keys, nil)
}
}
// Implement remaining 3 methods...
}// Before: Using Firebase
let provider = FeatureToggleProviderImp(
featureToggleService: RemoteConfig.remoteConfig() // Firebase
)
// After: Using LaunchDarkly
let provider = FeatureToggleProviderImp(
featureToggleService: LaunchDarklyService(client: ldClient) // LaunchDarkly
)
// After: Using your custom backend
let provider = FeatureToggleProviderImp(
featureToggleService: CustomBackendService(apiClient: myAPI) // Your backend
)
// β
That's it! Everything else remains unchanged!π‘ Single Point of Change: Only FeatureToggleProviderImp needs to know about the underlying provider
π‘ Protocol Abstraction: FeatureToggleService protocol hides all provider-specific details
π‘ Dependency Injection: Provider is injected via constructor - easy to swap
π‘ Zero Breaking Changes: Rest of your app continues working without recompilation
Total Implementation Time: ~2-4 hours for most providers (just 5 methods to implement!)
Supported Providers (community & potential):
- β Firebase Remote Config (included)
- β Mock Provider (included for testing)
- π LaunchDarkly (easy to add)
- π Split.io (easy to add)
- π Optimizely (easy to add)
- π Custom REST API (easy to add)
- π Local UserDefaults (easy to add)
This library demonstrates several software engineering principles:
- Dependency Inversion Principle (DIP): Feature modules depend on abstractions, not concrete implementations - enabling provider swapping
- Interface Segregation Principle (ISP): Separate interfaces for different responsibilities (value providing, listener management, etc.)
- Single Responsibility Principle (SRP): Each module has one clear purpose
- Open/Closed Principle: Open for extension (new providers) but closed for modification (existing code)
- Separation of Concerns: Clear boundaries between interface, implementation, and testing
Perfect for:
- π± Large-scale iOS apps with multiple teams and modules
- π§ͺ Test-driven development (TDD) workflows
- π Continuous delivery with dynamic feature rollouts
- π₯ Team collaboration with clear module boundaries
- π― A/B testing and gradual feature rollouts
// Gradual feature rollout
if featureToggleKit.getBoolValue(key: "new_checkout_flow") {
// Show new checkout UI
} else {
// Keep old checkout UI
}
// Kill switch for problematic features
if !featureToggleKit.getBoolValue(key: "enable_analytics") {
return // Disable without app update
}
// Dynamic configuration
let apiTimeout = featureToggleKit.getLongValue(key: "api_timeout_seconds")
let apiConfig = featureToggleKit.getJSONValue(key: "api_config")
// A/B testing variants
let variant = featureToggleKit.getStringValue(key: "experiment_variant") // "A" or "B"Contributions are welcome! Please feel free to submit a Pull Request.
If you find this library useful, please consider giving it a star! It helps others discover the project.
This project is licensed under the MIT License - see the LICENSE file for details.
Harry Nguyen Chi Hoang
- Email: harryngict@gmail.com
- GitHub: @harryngict
- Built with Firebase Remote Config
- Designed with Swift 6 concurrency in mind