Side effects shouldn't dictate your architecture.
StepByStepTracking
is a lightweight tracking utility designed with Clean Architecture in mind. It treats tracking as a side effect β not something that pollutes your domain models or flows. Instead of forcing tracking data through every layer, it uses a step-based context model to automatically associate values with events.
- β No more passing tracking values through multiple layers.
- β No need to modify your domain models just for analytics.
- β Works seamlessly with SwiftUI and Composable UIs.
You can attach shared properties (e.g., userID
) to all subsequent events, making them automatically included without being explicitly passed around.
tracker.setProperties(.properties(.userID), value: "USER_ID")
Each screen or logical state in your app can be mapped to a tracking step. This step holds contextual properties automatically inherited by all events triggered within that scope.
For example, when navigating to the home
screen:
tracker.onStep(.steps(.home), properties: [
.property(.properties(.screenName), "home")
])
Steps are identified by a unique name/id
. Re-calling onStep
with a previously used ID resets the step stack, simulating natural view hierarchies. Typically, you call onStep
in onAppear
:
struct HomeView: View {
@Environment(\.tracker) var tracker: TrackingService
var body: some View {
VStack {
...
}
.onAppear {
tracker.onStep(.steps(.home), properties: [
.property(.properties(.screenName), "home")
])
}
}
}
Letβs say a user is logged in and on the home screen, and they tap a Refresh
button:
Button("Refresh") {
tracker.sendEvent(.events(.refresh))
}
The resulting event payload will automatically include:
event name
:refresh
userID
:USER_ID
screen
:home
UIKit or Custom Step compatible. Perfect for Composable UI architectures (e.g. SwiftUI), where passing props deeply can be cumbersome.
Sometimes, data retrieved from a network or database isn't relevant to business logic but is still useful for tracking.
For example, fetched posts:
[
{ "id": 1, "name": "Post 1", "content": "Post 1 content", "campaignID": "c1" },
{ "id": 2, "name": "Post 2", "content": "Post 2 content", "campaignID": "c2" }
]
Converted to a domain model:
struct Post {
let id: Int
let name: String
}
You donβt need campaignID
for business logic, but you want to track it.
Use setDeferredProperties
right after data loading:
let values = items.compactMap { item -> TrackingElement? in
TrackingElement(.init(name: String(item.id)), properties: [
.property(.properties(.id), item.id),
.property(.properties(.name), item.name),
.property(.properties(.campaignID), item.campaignID)
])
}
tracker.setDeferredProperties(values)
When a user taps on a post, just trigger the event using its ID:
tracker.sendDeferredEvent(.events(.postSelected), byPropertiesID: id)
The full event payload will include:
event name
:postSelected
id
:<selectedItem.id>
name
:<selectedItem.name>
campaignID
:<selectedItem.campaignID>
StepByStepTracking
helps you:
- Keep tracking as a side effect
- Avoid modifying domain models
- Enable powerful contextual tracking without boilerplate
- Simplify SwiftUI/UIKit analytics
Architecture wins when side effects stay decoupled.