Skip to content

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:

val tracked = mutableListOf<Event>()
val viewModel = PaywallViewModel(analytics = EventTrackerService { tracked += it })
/// 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)
    }
}
/// A fake is a small class, because the interface has one method.
final class RecordingEventTracker implements EventTrackerService {
  final tracked = <Event>[];

  @override
  Future<void> track(Event event) async => tracked.add(event);
}
// A fake is a small class, because the interface has one method.
export class RecordingEventTracker implements EventTrackerService {
  readonly tracked: Event[] = [];

  async track(event: Event): Promise<void> {
    this.tracked.push(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.