An elegant thread safety pattern for Swift that makes concurrency constraints visible and enforceable through naming conventions and focused static analysis.
# Clone or add SafeJourney as a dependency
git clone https://github.com/customerio/safe-journey.git
cd safe-journey
# Check your Swift project
swift run sj Sources/
- What is SafeJourney?
- Why Use This Pattern?
- Pattern Rules
- Installation
- Usage
- Integration with CI/CD
- Examples
- FAQ
- Contributing
SafeJourney is a basic thread safety pattern for Swift that uses visual naming conventions to make mutable state visible and provides simple static checks to maintain consistency. Originally developed by Customer.io for their mobile SDKs, it focuses on a specific pattern rather than comprehensive concurrency analysis.
SafeJourney is not a sophisticated static analyzer - it's a focused pattern matcher with clear limitations. It works well for teams that adopt the underscore naming convention and want basic guard rails to prevent common mistakes.
By marking shared mutable state and its access paths explicitly, SafeJourney makes threading intent visible and verifiable.
public final class EventsProcessor: @unchecked Sendable {
private let maxEventsBatchSize: Int
private let batchSyncQueue: DispatchQueue
// ✅ Requires protection
private var _eventData: [[String: Any]] = []
private var _timerCancellable: AnyCancellable?
public func enqueue(eventPayload: [String: Any]) throws {
async { [weak self] in
self?._eventData.append(eventPayload)
self?._persistEvents()
}
}
private func _persistEvents() {
storage.save(_eventData)
}
}
Underscore-prefixed properties and methods make mutable state and its access constraints immediately visible in code reviews.
Underscore functions never re-enter queues. Public methods enforce queue protection. This eliminates many common pitfalls in concurrent code.
A simple checker catches violations of the pattern within individual files. It has limitations but provides useful guard rails for teams using this convention.
The pattern is simple to learn, fast to apply, and helps teams avoid subtle concurrency bugs without heavyweight solutions.
DispatchQueue is a performant serial queue. SafeJourney encourages batching and queue-local operations.
// ❌ Unsafe: the need for protection is not clear
private var mutableProperty: String = ""
// ✅ Safe: clearly marked for protected access
private var _mutableProperty: String = ""
// ❌ Unsafe: exposed mutable state can be misused
public var _state: String = ""
// ✅ Safe: only accessible within the class
private var _state: String = ""
func updateState() {
// ❌ Unsafe direct access
_mutableProperty = "new"
// ✅ Safe access inside queue
queue.sync {
_mutableProperty = "new"
}
}
private func _processData() {
// ❌ Unsafe: might cause re-entry or deadlocks
publicMethod()
// ❌ Unsafe: calls to non-underscore methods in same file
helperMethod()
// ✅ Safe: underscore methods can call other underscore methods
_state = "processed"
_helperMethod()
}
// Note: Safe Journey checker only analyzes functions within the same file.
// Calls to external functions/frameworks are not analyzed (tool limitation).
A convention is great till it fails due to human error. Hence a complementary static check acts as a consistent guard rail.
git clone https://github.com/customerio/safe-journey.git
cd safe-journey
swift run sj
Add SafeJourney as a dependency in your Package.swift
:
dependencies: [
.package(url: "https://github.com/customerio/safe-journey.git", from: "1.0.0")
]
Then run the checker from your project root:
swift run --package-path path/to/safe-journey sj Sources/
swift run sj # Current directory
swift run sj Sources/ # Specific directory
swift run sj MyFile.swift # Specific file
swift run sj --help # Help menu
# Custom queue wrapper methods
swift run sj --queue-methods customAsync,safeSync Sources/
# Using configuration file
swift run sj --config safejourney.json Sources/
Create a safejourney.json
file to customize queue wrapper methods:
{
"queueWrapperMethods": ["sync", "async", "customAsync", "safeExecute"],
"excludePatterns": ["Tests", "Generated"]
}
Or pass custom methods via CLI:
swift run sj --queue-methods customAsync,differentAsyncHelper Sources/
🔍 SafeJourney Pattern Checker
🎯 Checking: Sources/
❌ Sources/EventProcessor.swift:45: Function 'updateState' cannot directly access _eventData. Use queue protection
💡 Suggestion: Wrap in queue.sync { } or queue.async { }
⚠️ Sources/UserManager.swift:23: Mutable property should use underscore prefix
💡 Suggestion: Change 'var property' to 'private var _property'
📊 Summary: 1 error, 1 warning
🚨 Fix violations before committing.
## Integration with CI/CD
### GitHub Actions
```yaml
name: Thread Safety Check
on: [push, pull_request]
jobs:
safe-journey:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Swift
uses: swift-actions/setup-swift@v1
with:
swift-version: "5.9"
- name: Run Thread Safety Check
run: swift run sj Sources/
```
### Pre-commit Hook
```bash
#!/bin/sh
# Ensure SafeJourney is available
if [ ! -d ".safe-journey" ]; then
echo "📥 Cloning SafeJourney..."
git clone https://github.com/customerio/safe-journey.git .safe-journey
fi
swift run --package-path .safe-journey sj Sources/
if [ $? -ne 0 ]; then
echo "❌ Thread safety violations found. Please fix before committing."
exit 1
fi
```
### Xcode Build Phase
```bash
# Ensure SafeJourney is available
if [ ! -d "${SRCROOT}/.safe-journey" ]; then
echo "📥 Cloning SafeJourney..."
git clone https://github.com/customerio/safe-journey.git "${SRCROOT}/.safe-journey"
fi
# Run SafeJourney checker
cd "${SRCROOT}/.safe-journey"
swift run sj "${SRCROOT}/Sources"
```
## Examples
See the `examples/` directory for complete working examples of the SafeJourney pattern.
## Limitations
SafeJourney is a **basic pattern matcher**, not a comprehensive static analyzer. Here are its intentional limitations:
### ✅ **What SafeJourney Detects**
- Underscore property access without queue protection
- Non-private underscore properties and functions
- Underscore functions calling non-underscore functions **in the same file**
- Mutable properties without underscore prefix
### ❌ **What SafeJourney Does NOT Detect**
- Cross-file function calls (calls to external modules/frameworks are ignored)
- Complex data flow analysis
- Race conditions beyond the basic pattern
- Sophisticated concurrency issues
- System function safety (assumes system calls are safe)
### 🎯 **Design Philosophy**
SafeJourney prioritizes **simplicity and clarity** over comprehensive analysis. It's designed to catch common violations of a specific naming convention, not to solve all concurrency problems.
If you need comprehensive static analysis, consider tools like Swift's built-in concurrency checking (`-strict-concurrency=complete`) or more sophisticated analyzers.
## FAQ
### Q: Why not just use actors?
Actors are useful in isolation, but in many real-world systems, concurrency is cross-cutting. `await` boundaries introduce partial transaction points, making it hard to reason about state. SafeJourney gives finer control over execution and lets you isolate concerns cleanly.
### Q: Does this impact performance?
Not meaningfully. Serial queues are efficient and widely used in many performant applications.
### Q: How do I migrate existing code?
Start small. Apply the pattern to your most shared or error-prone classes first. Let the checker identify violations incrementally.
### Q: Can underscore methods invoke callbacks?
Yes, as long as they escape to another queue.
```swift
private func _process(completion: @escaping () -> Void, callbackQueue: DispatchQueue = .global()) {
// work...
callbackQueue.async {
completion()
}
}
```
## Contributing
We welcome contributions. See [CONTRIBUTING.md](CONTRIBUTING.md) for details.
```bash
git clone https://github.com/customerio/safe-journey.git
cd safe-journey
./test.sh
```
## License
MIT License — see [LICENSE](LICENSE).
## Acknowledgments
Developed by Customer.io to solve production-grade concurrency challenges in their SDKs. Special thanks to the Mobile team for pioneering this effort.
---
**Ready to bring clarity and safety to your concurrency model?**
Start with the [Quick Start](#quick-start) guide above!