Capabilities¶
Herald splits analytics into five small interfaces, called capabilities. Each covers one job a class might have, and a class asks only for the ones it uses.
| Interface | Methods | Typical caller |
|---|---|---|
EventTrackerService |
track(event) |
ViewModels, use cases, composables |
PropertyTrackerService |
set(property) |
whatever learns the attribute: settings, purchase flow |
IdentifiableUserService |
identify(identity), reset() |
sign-in and sign-out |
AnalyticsLifecycleService |
start(), flush() |
app start-up, going to background |
ConsentService |
setEnabled(enabled) |
the privacy or consent screen |
In Kotlin they're suspend functions, and Herald runs the vendor calls off the main thread. In
Dart they return a Future. See Threads.
Ask only for what you use¶
class PaywallViewModel(private val analytics: EventTrackerService) {
suspend fun onPlanSelected(plan: String, seats: Int) =
analytics.track(PlanSelected(plan, seats, price = 9.99, trial = false))
}
class PrivacySettingsViewModel(private val consent: ConsentService) {
suspend fun onAnalyticsToggled(allowed: Boolean) = consent.setEnabled(allowed)
}
final class PaywallViewModel {
private let analytics: any EventTrackerService
init(analytics: any EventTrackerService) {
self.analytics = analytics
}
func onPlanSelected(plan: String, seats: Int) {
analytics.track(PlanSelected(plan: plan, seats: seats, price: 9.99, trial: false))
}
}
final class PrivacySettingsViewModel {
private let consent: any ConsentService
init(consent: any ConsentService) {
self.consent = consent
}
func onAnalyticsToggled(allowed: Bool) {
consent.setEnabled(allowed)
}
}
class PaywallViewModel(final EventTrackerService analytics) {
Future<void> onPlanSelected(String plan, int seats) =>
analytics.track(PlanSelected(plan, seats: seats, price: 9.99, trial: false));
}
class PrivacySettingsViewModel(final ConsentService consent) {
Future<void> onAnalyticsToggled(bool allowed) => consent.setEnabled(allowed);
}
export class PaywallViewModel {
constructor(private readonly analytics: EventTrackerService) {}
onPlanSelected(plan: string, seats: number): Promise<void> {
return this.analytics.track(new PlanSelected(plan, seats, 9.99, false));
}
}
export class PrivacySettingsViewModel {
constructor(private readonly consent: ConsentService) {}
onAnalyticsToggled(allowed: boolean): Promise<void> {
return this.consent.setEnabled(allowed);
}
}
A paywall that tracks events can't sign the user out or turn analytics off, and the consent screen can't track anything. Each class's constructor shows exactly what it does with analytics, so a reviewer sees it at a glance.
The split follows who calls what, and when:
- start-up runs once, when the app starts;
- consent can change at any moment, from a settings screen;
- identity changes at sign-in and sign-out.
With one big interface, every screen could restart every vendor.
One object, five names¶
Herald implements all five. Your DI setup binds that one instance to each interface, so every
class gets the same Herald, seen through the interface it asked for. See
Set up your app.
A fake is a few lines¶
EventTrackerService and PropertyTrackerService have a single method, so a fake in a unit test
is tiny:
/// A fake is a small class, because the protocol has one method. A test calls it from one thread,
/// so it skips the locking a shared tracker would need: hence `@unchecked Sendable`.
final class RecordingEventTracker: EventTrackerService, @unchecked Sendable {
var tracked: [any Event] = []
func track(_ event: any Event) {
tracked.append(event)
}
}
For tests that use several interfaces, or check the order of calls, use FakeAnalyticsProvider. See Testing.
Next¶
How Herald passes calls on: what happens after a class calls track.