As environments change, decisions that were reasonable in the past often cease to be valid. This article explains the challenges that the iOS client for Service Configuration, LINE's dynamic configuration delivery system, faced as the LINE app grew and how we overcame them.
What is Service Configuration?
A new version of the LINE app is released every two weeks. Because the app contains many services built by different teams, it is difficult to change the release schedule to meet the requirements of an individual service. This constraint creates the need for Service Configuration.
Suppose, for example, that a service wants to launch a new feature during a holiday period in a particular country. The team can release the app with the feature set up so that it can be enabled and disabled dynamically, leaving it disabled by default. The team can then enable the feature at the desired time and launch it without an app update.
Service Configuration is the system that enables this kind of dynamic service release. When a service operator changes a configuration value on the admin page, the Service Configuration server sends a notification to the LINE app. The app requests the new configuration values from the server, which determines and returns the applicable values based on the user's region, device, OS version, and other factors.
In addition to feature toggles, Service Configuration supports rollback plans, A/B tests, sampling rates for error collection, behavior policies for specific UIs, and many other use cases. Because many configurations operate at the same time, each service defines a string "key" for every feature that needs dynamic control and assigns a "value" to each key. The Service Configuration server returns these values as a dictionary that maps strings to strings.
{
"function.media.image_medium": "1280,70",
"function.media.image_high": "2048,80",
"function.media.message.flow.v2.image": "Y",
...
}
The iOS version of the LINE app currently uses about 700 configuration keys across more than 60 modules.
Problems caused by increasing scale
The original system was developed as a monolith. Because the monolithic structure required every key to be declared in a single file, the file listing the keys grew to 7,000 lines. Most keys were used by only one module, but they still had to be declared public.
Early in the project, managing every key in one file was probably a simple and reasonable approach. As the project and team grew, however, the problems caused by this structure became increasingly severe.
The circular dependency dilemma
The most fundamental problem with the monolithic structure was that intermodule dependency directions prevented configuration values from being represented by meaningful types.
Consider an example. In the LINE app, users can choose between standard and high quality for photos sent to a chat. Service Configuration determines the actual resolution and JPEG compression ratio for each option. A value such as "1280,70" means "send the image at a maximum resolution of 1280 x 1280 pixels with a compression ratio of 0.7". It is better to represent this value with a dedicated type such as ImageTransferQuality than to pass it around as a raw string.
The problem is deciding where to declare this dedicated type. According to the separation of concerns principle, it belongs in the photo module. If it is declared there, however, the Service Configuration system cannot return the dedicated type directly. The photo module already depends on the configuration module, so doing so would create a reverse dependency. As shown below, the configuration module therefore exposed the raw, unparsed string, and the photo module parsed it each time.
// LineConfigurationSubsystem module - can return only a string
public final class LineConfigurationManager {
public var transferQualityImageMedium: String? { /* ... */ }
}
// LinePhotos module - parse directly at the call site
let string = configurationManager.shared.transferQualityImageMedium
let mediumQuality = ImageTransferQuality.parse(string)
Declaring the type in the configuration module would eliminate the parsing step, but it would also expose the type to unrelated modules with no connection to photo quality. In practice, we conventionally had to add the LineConfiguration- prefix to such types to avoid polluting the namespace. This was a structural dilemma in which every option had a drawback.
An incomplete abstraction
Another characteristic of the original implementation was that using it repeatedly forced developers to learn implementation details.
First, developers had to write the logic for decoding values received from the server. The original implementation grouped keys used together into structures called property groups and decoded each group all at once. The following implementation decodes the Boolean configuration value albumLikeEnabled from a property group named Album.
extension LineConfigurationManagerProperties.Album: Decodable {
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
albumLikeEnabled = try container
.decodeBoolIfPresent(forKey: .albumLikeEnabled) ?? false
}
}
Long-time Swift users may have noticed that decodeBoolIfPresent(forKey:), which performs the decoding, is not a method defined by the standard library. Understanding why this method is necessary requires examining the communication protocol used by the Service Configuration server.
The server sends Boolean values as the strings "Y" and "N". The iOS client encodes them as a property list and calls the init(from:) initializer above. The standard decodeIfPresent(_:forKey:) method has no reason to interpret strings such as "Y" and "N" as Boolean values, so it identifies a type mismatch, throws an error, and the call fails.
The decodeBoolIfPresent(forKey:) method was created to work around this problem, but its resemblance to the standard method made the two easy to confuse. Worse, when the standard method caused initialization to fail, the default configuration value was silently used, making the cause difficult to identify. As a result, incorrect implementations using the standard method sometimes passed code review and were discovered only later.
Another problem was that developers had to write defaults with subtly different purposes three times, as shown below.
// 1: PropertyGroup's static default (fallback when another key fails to decode)
extension LineConfigurationManagerProperties {
struct Album: PropertyGroup {
let albumLikeEnabled: Bool
static var `default`: Album {
Album(albumLikeEnabled: false) // ← first default
// 2: init(from:) fallback (when the key is missing or the value is invalid)
extension LineConfigurationManagerProperties.Album: Decodable {
init(from decoder: any Decoder) throws {
albumLikeEnabled = try container
.decodeBoolIfPresent(forKey: .albumLikeEnabled) ?? false // ← second default
// 3: defaultConfiguration dictionary (before a server value arrives)
extension LineConfigurationManager {
static var defaultConfiguration: [String: Any] {
["function.album.like.enabled": false] // ← third default
Although the three defaults had subtly different purposes, there was never a practical need to distinguish them. In particular, the first was used when another configuration key in the same property group failed to decode. There was no reason for a decoding failure in one key to affect the value of another, so this requirement was unnecessary and arose from a design flaw.
These design flaws may have seemed natural to developers who already understood the server communication protocol and implementation details. For new team members, however, they became arbitrary rules to memorize, adding yet another piece of knowledge to teach during onboarding.
Lack of thread safety
The original implementation was written without any consideration for concurrency. Yet its structure contained many sources of concurrency problems. Decoding was lazy for each property group, previously decoded instances were discarded whenever new values arrived from the server, and other mutations occurred frequently. At the same time, multiple services read configuration values from different threads.
The problem was also visible in our metrics. Use-after-free errors caused hundreds of crashes every day, steadily generating bug tickets and prompting hotfix releases. Because this was undefined behavior, it probably affected even more devices in a range of ways.
In a smaller project and in a codebase written before the broad adoption of Swift Concurrency, these issues would probably have been rare bugs whose fundamental repair was difficult to justify. As the number of services and concurrent tasks increased, however, they occurred more often and became problems that had to be solved.
No debug overrides
During QA, testers often need to change a particular configuration value temporarily. The Service Configuration system itself, however, had no override feature. Providing overrides to QA engineers therefore required separate persistent storage, a debug menu UI, and text showing the current value. Each time, developers had to change between three and twenty lines across six or seven files in different modules.
Every module built its own test doubles
Because LineConfigurationManager, which provided the configuration values, was a singleton, each module defined a protocol containing only the properties it used. The implementation depended on this protocol instead of the singleton directly, and each module wrote its own test double to make the code testable.
This approach may have been a reasonable compromise for individual service developers, but it was extremely wasteful across the project. The project contained dozens of protocols and test doubles, all of which had to change throughout the lifecycle of their configuration keys. Because test doubles belonged to separate compilation targets, a missed update could remain unnoticed until the continuous integration (CI) build ran after a pull request was submitted. The quality of these handcrafted doubles also varied. A double might announce a new configuration value at a different time from the production implementation, leaving room for a bug to appear in production but not in tests, or the other way around.
Each inconvenience may have seemed minor to an individual team, but together they steadily eroded productivity across the project.
The starting point for a solution: Learning from a proven design
After defining the problems, we looked for an existing design that had already solved similar ones. At their core, our requirements were as follows.
- Provide type-safe access to a large collection of key-value pairs.
- Allow each module to define its own keys independently.
- Operate safely in a concurrent environment.
Foundation's AttributedString was an existing design that met these requirements. AttributedString represents text whose ranges can have attributes. It manages many text attributes, including fonts, colors, and links, in a type-safe way while allowing frameworks such as UIKit, AppKit, and SwiftUI to define attributes independently. The AttributedString design is available as open-source code in the Swift Foundation project, so we could examine its internal implementation.
Type structure
For this project, we focused on four types in the AttributedString API.
AttributedStringKey
This protocol defines a single attribute. It declares the key name and value type.
public protocol AttributedStringKey {
associatedtype Value: Hashable
static var name: String { get }
}
AttributeScope
This protocol groups related keys. Each framework defines a scope, such as UIKit attributes or SwiftUI attributes.
extension AttributeScopes {
struct UIKitAttributes: AttributeScope {
let font: UIKitAttributes.FontAttribute
let foregroundColor: UIKitAttributes.ForegroundColorAttribute
}
}
AttributeDynamicLookup
This is an enum with no enumerated members, or case declarations, so it cannot be instantiated. Its only role is to act as a key path routing target for dynamic member lookup.
@dynamicMemberLookup
public enum AttributeDynamicLookup {
public subscript<T: AttributedStringKey>(
dynamicMember keyPath: KeyPath<AttributeScopes, T>
) -> T {
fatalError("Unreachable")
}
}
The key to this design is that the subscript containing fatalError is never called at runtime. Once the compiler extracts the type information for T referenced by the key path, AttributeDynamicLookup has completed its role. This technique implements type-level routing without runtime cost. We examine this routing in more detail in the next section.
AttributeContainer
This value type stores the actual values. It uses the type information provided by the previous three types to support safe reads and writes.
@dynamicMemberLookup
public struct AttributeContainer {
public subscript<K: AttributedStringKey>(
dynamicMember keyPath: KeyPath<AttributeDynamicLookup, K>
) -> K.Value? { get set }
}
Type resolution flow
To understand how these four types work together, follow how the line container.font in the following code is resolved.
var container = AttributeContainer()
container.font = .systemFont(ofSize: 14)
// ^^^^
// How is this property access actually resolved?
AttributeContainer has no property named font. Because it has the @dynamicMemberLookup attribute, the compiler instead looks for subscript(dynamicMember:). This subscript takes a KeyPath<AttributeDynamicLookup, K> argument.
The compiler therefore looks for font in AttributeDynamicLookup. That type also has no font property and also has the @dynamicMemberLookup attribute, so the compiler checks its subscript. UIKit declares an extension subscript on AttributeDynamicLookup that accepts KeyPath<AttributeScopes.UIKitAttributes, K>, so the compiler searches UIKitAttributes for font. The UIKit scope has a font property, completing the resolution.
As a result, the compiler infers that K is UIKitAttributes.FontAttribute. The AttributeContainer subscript retrieves the necessary information from this type and stores the value under the correct key name. This entire process takes place at compile time. At runtime, it performs only a simple dictionary read or write.
container.font
│
▼ @dynamicMemberLookup
AttributeContainer.subscript(dynamicMember: KeyPath<AttributeDynamicLookup, K>)
│
▼ @dynamicMemberLookup
AttributeDynamicLookup.subscript(dynamicMember: KeyPath<AttributeScopes.UIKitAttributes, K>)
│
▼ property lookup
AttributeScopes.UIKitAttributes.font → UIKitAttributes.FontAttribute
│
▼ type inference complete
K = UIKitAttributes.FontAttribute, K.Value = UIFont
Adding a new attribute requires only implementing AttributedStringKey and adding a property to the scope. The new attribute joins this chain naturally, creating an extensible structure that requires no changes to existing code.
Reapplying and adapting the design
Our research showed that many aspects of the AttributedString API design could be applied directly to the new Service Configuration system. Some aspects did not meet our requirements, so we retained almost the same type structure but adapted the remaining parts to our needs.
We adopted the same structure for the core technique of routing KeyPath with a case-free enum, grouping keys by scope, and providing property access syntax with @dynamicMemberLookup.
| AttributedString | Configuration |
|---|---|
AttributedStringKey | ConfigurationKey |
AttributeScope | ConfigurationScope |
AttributeScopes | ConfigurationScopes |
AttributeDynamicLookup | ConfigurationDynamicLookup |
AttributeContainer | ConfigurationValues |
ScopedAttributeContainer | ScopedConfigurationValues |
The following sections cover the adaptations required by differences between the domains. We also explain how each change resolves the problems described earlier.
Eliminating circular dependencies: Supporting per-module declarations
If keys can be defined in individual modules, dedicated value types can reside in those modules as well. The Service Configuration system contains only the key protocol and does not declare individual keys, so no reverse dependency occurs.
// 1. Define the key in a scope - declare it in the photo module
extension ConfigurationScopes {
struct LinePhotos: ConfigurationScope {
let transferQualityImageMedium: TransferQualityImageMediumKey
enum TransferQualityImageMediumKey: ConfigurationKey {
static let name = "function.media.image_medium"
static let defaultValue: ImageTransferQuality = .standard
}
}
}
// 2. Add a subscript to DynamicLookup (only once per scope)
extension ConfigurationDynamicLookup {
subscript<K: ConfigurationKey>(
dynamicMember keyPath: KeyPath<ConfigurationScopes.LinePhotos, K>
) -> K {
self[K.self]
}
}
Accessing service.transferQualityImageMedium triggers the same type resolution chain analyzed in AttributedString.
service.transferQualityImageMedium
│
▼ ConfigurationService.subscript(dynamicMember: KeyPath<ConfigurationDynamicLookup, K>)
▼ ConfigurationDynamicLookup.subscript(dynamicMember: KeyPath<ConfigurationScopes.LinePhotos, K>)
▼ LinePhotos.transferQualityImageMedium → TransferQualityImageMediumKey
│
▼ K = TransferQualityImageMediumKey, K.Value = ImageTransferQuality
look up "function.media.image_medium" value → decode → ImageTransferQuality
There is no longer a need to declare value types such as ImageTransferQuality in the configuration module. The key and value type can be defined together in the photo module's scope. The circular dependency dilemma is resolved.
Fixing the abstraction: Encapsulating decoding in each key
AttributedStringKey has no defaultValue, decode, or encode. Because AttributeContainer stores values in [ObjectIdentifier: Any], it can downcast them to the Value type without separate decoding and return nil when an attribute is absent.
The Service Configuration system is different. Values arrive from the server as strings, each key can have a different serialization method, and returning a default is more convenient than returning nil when no server value exists. We therefore added decode(from:) and encode(_:) to the key protocol and separated the serialization logic formerly written in property group initializers into individual keys. The defaultValue serves as the fallback when no server value exists, consolidating the previous three defaults into one.
public protocol ConfigurationKey: SendableMetatype {
associatedtype Value: Sendable
static var name: String { get }
static var defaultValue: Value { get }
static func decode(from string: String) throws -> Value
static func encode(_ value: Value) throws -> String?
}
We solved the need to repeatedly implement identical decoding logic by providing default implementations of decode(from:) and encode(_:). To provide these defaults, we divided value types into the following two protocols.
// Values that use Service Configuration-specific encoding rules, such as "Y"/"N" Booleans and numbers
public protocol ConfigurationValueStringRepresentable {
init?(configurationValueString string: String)
var configurationValueString: String { get }
}
// Compound values serialized as JavaScript object notation (JSON)
public protocol ConfigurationValueJSONRepresentable {
init(fromConfigurationValueJSON decoder: any Decoder) throws
func encode(toConfigurationValueJSON encoder: any Encoder) throws
}
Because Bool conforms to ConfigurationValueStringRepresentable, keys whose associated Value type is Bool automatically receive an implementation that correctly decodes "Y" and "N" as true and false. Developers no longer need to write decoding logic or distinguish decodeBoolIfPresent(forKey:) from the standard method. Declaring a key as shown below now selects the correct decoding logic.
enum AlbumLikeEnabledKey: ConfigurationKey {
static let name = "function.album.like.enabled"
static let defaultValue = false
}
Achieving thread safety: Letting the compiler verify it
AttributeContainer is a value type. The Service Configuration system, in contrast, updates its values during server synchronization, and most users always want to read the latest values. We therefore designed the configuration service around the reference-type ConfigurationService protocol. This is the most fundamental structural difference from AttributeContainer.
Modeling the service as a reference type created the same conditions for concurrency problems as before. We addressed them statically with Swift 6's powerful Sendable checks. Specifically, we put all mutable state inside OSAllocatedUnfairLock and allowed access only through the lock's withLock API. This enabled the compiler to verify that every access to mutable state was synchronized. As a result, we made the ConfigurationService class Sendable without the @unchecked attribute, allowing clients to read configuration values freely across concurrency domains without compiler warnings or errors.
Using a lock raised another interesting question: Should decoding take place inside the critical section? A custom decode(from:) implementation could access another lock and cause a deadlock, or it could try to read another configuration value, recursively access the same lock, and crash. To prevent these problems, the implementation retrieves only the string value while holding the lock and performs decoding after releasing it.
Solving QA problems: Building overrides into Service Configuration
AttributeContainer uses a binary model in which a value is either present or absent. The Service Configuration system has three possible value sources. A value manually overridden by a developer in a debug build has the highest priority, followed by the server value and then the default. Overrides are stored separately from server values, so they persist when the server synchronizes.
enum ValueSource: CaseIterable {
#if DEBUG_MENU_ENABLED
case overridden // value set manually in the debug menu
#endif
case server // value received from the server
case `default` // default defined by the key
}
We also designed the ConfigurationKey protocol to define metadata for building the debug UI.
protocol ConfigurationKey {
#if DEBUG_MENU_ENABLED
static var debugTitle: String { get }
static var debugControlType: ConfigurationDebugControlType<Value> { get }
#endif
}
These properties have default implementations. The debugTitle, which exposes the configuration key under a human-readable name, is generated heuristically from the key's name. The debugControlType, which determines the input control used to change a configuration value, is selected according to the associated Value type, such as providing a toggle for a Bool value.
The debug override feature that previously required changes across several files is now located in one place, the key enum. Useful default implementations mean that developers rarely need to think about the debug menu.
Solving testing problems: Providing a high-quality test double
Previously, each module wrote its own protocol for injecting the singleton and its own test double. The new system provides MockConfigurationService with the ConfigurationService protocol.
@Test
func `check calculation`() {
let service = MockConfigurationService()
let logic = MyBusinessLogic(configurationService: service)
// Inject a value through the writable subscript
service.myValue = 7
#expect(logic.calculate() == 42)
}
The test double provides a setter that is not part of the protocol, allowing tests to set configuration values freely. It also uses the same value resolution logic as the production implementation, including ValueSource priority and decode/encode. There is no longer a need to define a protocol manually or worry that the double behaves differently from production.
Conclusion
Simply borrowing Foundation's approach could not fully address the requirements of a different domain. Using a reference type so clients could read the latest values introduced new concurrency considerations, and domain-specific requirements such as serialization and debug menus required adaptations to the protocol.
The new Service Configuration system is being rolled out gradually across multiple releases. It currently covers only dozens of keys, but we hope it will replace the original system, resolve the existing problems, and serve us well until greater scale or an entirely different kind of challenge eventually requires its replacement. If other teams face similar problems, we hope the approach described in this article proves useful.


