Skip to content

The factory chain

For each vendor, an ordered list of small classes called factories decides what that vendor receives for each event. Herald calls that list a chain. It's where a vendor's rules meet your app's events.

A factory answers only for its own events

A factory looks at one event and gives one of three answers, as a Resolution:

Answer Meaning The chain then
Claimed "mine: send it with these trackers" stops, and runs the trackers
Dropped "mine: send nothing" stops, and sends nothing
Declined "not mine" asks the next factory

A factory answers only for the events it knows, and declines everything else. That's the whole idea: no factory has to know every event in the app, so each feature can ship the factory for its own events.

class CheckoutFirebaseEventTrackerFactory(
    private val firebaseAnalytics: FirebaseAnalytics,
) : FirebaseEventTrackerFactory {

    override fun create(event: Event): Resolution<FirebaseEventTracker> = when (event) {
        is CheckoutCompleted -> Resolution.Claimed(PurchaseFirebaseEventTracker(event, firebaseAnalytics))
        is PayTapped -> Resolution.Claimed(GenericEventTracker(event, firebaseAnalytics)) // Herald's own
        is CardNumberSeen -> Resolution.Dropped   // mine, and it goes nowhere
        else -> Resolution.Declined               // not mine: ask the next factory
    }
}
struct CheckoutFirebaseEventTrackerFactory: FirebaseEventTrackerFactory {
    func create(_ event: any Event) -> Resolution<any FirebaseEventTracker> {
        if let checkout = event as? CheckoutCompleted {
            return .claimed([PurchaseFirebaseEventTracker(event: checkout)])
        }
        if event is PayTapped {
            return .claimed([GenericFirebaseEventTracker(event: event)])  // Herald's own
        }
        if event is CardNumberSeen {
            return .dropped  // mine, and it goes nowhere
        }
        return .declined  // not mine: ask the next factory
    }
}
final class const CheckoutFirebaseEventTrackerFactory(final FirebaseAnalytics firebaseAnalytics)
    implements FirebaseEventTrackerFactory {
  @override
  Resolution<FirebaseEventTracker> create(Event event) => switch (event) {
    CheckoutCompleted() => .claimed([PurchaseFirebaseEventTracker(event, firebaseAnalytics)]),
    PayTapped() => .claimed([
      GenericFirebaseEventTracker(event, firebaseAnalytics),
    ]), // Herald's own
    CardNumberSeen() => .dropped(), // mine, and it goes nowhere
    _ => .declined(), // not mine: ask the next factory
  };
}
export class CheckoutFirebaseEventTrackerFactory implements FirebaseEventTrackerFactory {
  constructor(private readonly firebaseAnalytics: Analytics) {}

  create(event: Event): Resolution<FirebaseEventTracker> {
    if (event instanceof CheckoutCompleted) {
      return Resolution.claimed([new PurchaseFirebaseEventTracker(event, this.firebaseAnalytics)]);
    }
    if (event instanceof PayTapped) {
      // Herald's own tracker
      return Resolution.claimed([new GenericFirebaseEventTracker(event, this.firebaseAnalytics)]);
    }
    if (event instanceof CardNumberSeen) {
      return Resolution.dropped(); // mine, and it goes nowhere
    }
    return Resolution.declined(); // not mine: ask the next factory
  }
}
  • Claimed takes one or more trackers, the vendor calls to make. It can't be empty; use Dropped to take an event and send nothing.
  • A factory recognises an event with a plain when (Kotlin) or switch (Dart) on its type, so no DI container is involved.
  • Pass several trackers to send one event to one vendor in more than one way. See One event, two vendor calls.

Trackers make the vendor call

A tracker is a small class that makes one vendor call for one event. Herald ships generic ones, such as Firebase's generic tracker, which logs an event under its own name. When a vendor needs something different, write your own:

class PurchaseFirebaseEventTracker(
    private val event: CheckoutCompleted,
    private val firebaseAnalytics: FirebaseAnalytics,
) : FirebaseEventTracker {

    override suspend fun track() {
        // GA4's recommended purchase event, with the typed parameters as its Bundle.
        firebaseAnalytics.logEvent(FirebaseAnalytics.Event.PURCHASE, event.parameters.toBundle())
    }
}
struct PurchaseFirebaseEventTracker: FirebaseEventTracker {
    let event: CheckoutCompleted

    func track() {
        // GA4's recommended purchase event, with the typed parameters.
        Analytics.logEvent("purchase", parameters: event.parameters.toFirebaseParameters())
    }
}
final class const PurchaseFirebaseEventTracker(
  final CheckoutCompleted event,
  final FirebaseAnalytics firebaseAnalytics,
) implements FirebaseEventTracker {
  @override
  Future<void> track() =>
      // GA4's recommended purchase event, with the typed parameters.
      firebaseAnalytics.logEvent(
        name: 'purchase',
        parameters: event.parameters.toFirebaseParameters(),
      );
}
export class PurchaseFirebaseEventTracker implements FirebaseEventTracker {
  constructor(
    private readonly event: CheckoutCompleted,
    private readonly firebaseAnalytics: Analytics,
  ) {}

  async track(): Promise<void> {
    // GA4's recommended purchase event, with the typed parameters.
    logEvent(this.firebaseAnalytics, 'purchase', toFirebaseParameters(parametersOf(this.event)));
  }
}

A tracker has a single method. A named class is easier to test and to find than a lambda.

One event, two vendor calls

Sometimes a vendor needs two calls for one thing that happened. Mixpanel is a good example: an event shows up in its event reports, but its revenue reports read charges saved on the buyer's profile. A purchase needs both.

Your event stays a normal event:

data class OrderPaid(val orderId: String, val total: Double) : Event {
    override val name = "order_paid"
    override val parameters = parameters {
        put("order_id", orderId)
        put("total", total)
    }
}
struct OrderPaid: Event {
    let orderId: String
    let total: Double

    var name: String { "order_paid" }
    var parameters: [String: AnalyticsValue] {
        ["order_id": .string(orderId), "total": .double(total)]
    }
}
final class const OrderPaid(final String orderId, final double total) extends Event {
  @override
  String get name => 'order_paid';

  @override
  Map<String, AnalyticsValue> get parameters => {
    'order_id': .string(orderId),
    'total': .double(total),
  };
}
export class OrderPaid implements Event {
  readonly name = 'order_paid';

  constructor(
    readonly orderId: string,
    readonly total: number,
  ) {}

  get parameters(): EventParameters {
    return { order_id: this.orderId, total: double(this.total) };
  }
}

Herald's generic tracker already sends the event. For the charge, write a small tracker that calls Mixpanel's profile API:

// Adds the amount to the buyer's profile, which Mixpanel's revenue reports read.
class ChargeMixpanelEventTracker(
    private val event: OrderPaid,
    private val mixpanel: MixpanelAPI,
) : MixpanelEventTracker {
    override suspend fun track() {
        mixpanel.people.trackCharge(event.total, null)
    }
}
// Adds the amount to the buyer's profile, which Mixpanel's revenue reports read.
struct ChargeMixpanelEventTracker: MixpanelEventTracker {
    let event: OrderPaid
    let mixpanel: MixpanelInstance

    func track() {
        mixpanel.people.trackCharge(amount: event.total)
    }
}
// Adds the amount to the buyer's profile, which Mixpanel's revenue reports read.
final class const ChargeMixpanelEventTracker(final OrderPaid event, final Mixpanel mixpanel)
    implements MixpanelEventTracker {
  @override
  Future<void> track() async => mixpanel.getPeople().trackCharge(event.total);
}
// Adds the amount to the buyer's profile, which Mixpanel's revenue reports read.
export class ChargeMixpanelEventTracker implements MixpanelEventTracker {
  constructor(
    private readonly event: OrderPaid,
    private readonly mixpanel: Mixpanel,
  ) {}

  async track(): Promise<void> {
    this.mixpanel.getPeople().trackCharge(this.event.total, {});
  }
}

Then the Mixpanel factory takes the event and answers with both trackers:

class CheckoutMixpanelEventTrackerFactory(
    private val mixpanel: MixpanelAPI,
) : MixpanelEventTrackerFactory {
    override fun create(event: Event): Resolution<MixpanelEventTracker> = when (event) {
        is OrderPaid -> Resolution.Claimed(
            GenericEventTracker(event, mixpanel),        // 1. the order_paid event, as usual
            ChargeMixpanelEventTracker(event, mixpanel), // 2. a charge on the buyer's profile
        )
        else -> Resolution.Declined
    }
}
// Mixpanel isn't marked Sendable, so a factory that stores it needs `@preconcurrency import Mixpanel`.
struct CheckoutMixpanelEventTrackerFactory: MixpanelEventTrackerFactory {
    let mixpanel: MixpanelInstance

    func create(_ event: any Event) -> Resolution<any MixpanelEventTracker> {
        guard let order = event as? OrderPaid else {
            return .declined
        }
        return .claimed([
            // 1. the order_paid event, as usual; 2. a charge on the buyer's profile
            GenericMixpanelEventTracker(event: order, mixpanel: mixpanel),
            ChargeMixpanelEventTracker(event: order, mixpanel: mixpanel),
        ])
    }
}
final class const CheckoutMixpanelEventTrackerFactory(final Mixpanel mixpanel)
    implements MixpanelEventTrackerFactory {
  @override
  Resolution<MixpanelEventTracker> create(Event event) => switch (event) {
    OrderPaid() => .claimed([
      GenericMixpanelEventTracker(event, mixpanel), // 1. the order_paid event, as usual
      ChargeMixpanelEventTracker(event, mixpanel), // 2. a charge on the buyer's profile
    ]),
    _ => .declined(),
  };
}
export class CheckoutMixpanelEventTrackerFactory implements MixpanelEventTrackerFactory {
  constructor(private readonly mixpanel: Mixpanel) {}

  create(event: Event): Resolution<MixpanelEventTracker> {
    if (event instanceof OrderPaid) {
      return Resolution.claimed([
        new GenericMixpanelEventTracker(event, this.mixpanel), // 1. the order_paid event, as usual
        new ChargeMixpanelEventTracker(event, this.mixpanel), // 2. a charge on the buyer's profile
      ]);
    }
    return Resolution.declined();
  }
}

Herald runs both trackers one after the other, in that order, for this one vendor. If the first one throws, the second doesn't run, and the failure goes to your error reporter. The other vendors are unaffected: Firebase and Amplitude still get order_paid from their own chains, as usual. The Moove sample app does the same for its ticket purchases.

The chain

A chain is a composite factory: it asks its factories in order and returns the first answer that isn't Declined:

val chain = CompositeFirebaseEventTrackerFactory(
    CheckoutFirebaseEventTrackerFactory(firebaseAnalytics),  // feature factories first
    ScreenViewFirebaseEventTrackerFactory(firebaseAnalytics), // then Herald's screen views
    GenericFirebaseEventTrackerFactory(firebaseAnalytics),    // then everything else
)
let chain = CompositeFirebaseEventTrackerFactory([
    CheckoutFirebaseEventTrackerFactory(),  // feature factories first
    ScreenViewFirebaseEventTrackerFactory(),  // then Herald's screen views
    GenericFirebaseEventTrackerFactory(),  // then everything else
])
final chain = CompositeFirebaseEventTrackerFactory([
  CheckoutFirebaseEventTrackerFactory(firebaseAnalytics), // feature factories first
  ScreenViewFirebaseEventTrackerFactory(firebaseAnalytics), // then Herald's screen views
  GenericFirebaseEventTrackerFactory(firebaseAnalytics), // then everything else
]);
const chain = new CompositeFirebaseEventTrackerFactory([
  new CheckoutFirebaseEventTrackerFactory(firebaseAnalytics), // feature factories first
  new ScreenViewFirebaseEventTrackerFactory(firebaseAnalytics), // then Herald's screen views
  new GenericFirebaseEventTrackerFactory(firebaseAnalytics), // then everything else
]);

When every factory declines, the chain declines too, and the event isn't sent to that vendor.

What happens to events without a factory

You don't write a factory for every event, so what happens to the others? You choose, per vendor, with the last factory in its list:

  • Send it anyway. End the list with the vendor's generic factory, and every event without a factory of its own goes out under its own name. That suits Firebase, Mixpanel and Amplitude, where you want everything.
  • Skip it. End the list with nothing, and only the events a factory handled are sent. That suits Adjust and AppsFlyer, where each event needs setting up in the vendor's dashboard first.
  • Report it. End the list with the vendor's required-mapping factory, such as RequireMappedFirebaseEventTrackerFactory, and an event nobody handled goes to your error reporter. Use it when every event must be sent on purpose, so a forgotten one shows up instead of disappearing.
// Firebase: events without a factory of their own are still sent, under their own name.
val firebase = CompositeFirebaseEventTrackerFactory(
    firebaseFactories + GenericFirebaseEventTrackerFactory(firebaseAnalytics),
)

// Adjust: nothing at the end, so only events a factory handled are sent.
val adjustRules = CompositeAdjustEventTrackerFactory(adjustFactories)
// Firebase: events without a factory of their own are still sent, under their own name.
let firebase = CompositeFirebaseEventTrackerFactory(
    firebaseFactories + [GenericFirebaseEventTrackerFactory()])

// Adjust: nothing at the end, so only events a factory handled are sent.
let adjust = CompositeAdjustEventTrackerFactory(adjustFactories)
// Firebase: events without a factory of their own are still sent, under their own name.
final firebase = CompositeFirebaseEventTrackerFactory([
  ...firebaseFactories,
  GenericFirebaseEventTrackerFactory(firebaseAnalytics),
]);

// Adjust: nothing at the end, so only events a factory handled are sent.
final adjust = CompositeAdjustEventTrackerFactory(adjustFactories);
// Firebase: events without a factory of their own are still sent, under their own name.
const firebase = new CompositeFirebaseEventTrackerFactory([
  ...firebaseFactories,
  new GenericFirebaseEventTrackerFactory(firebaseAnalytics),
]);

// Adjust: nothing at the end, so only events a factory handled are sent.
const adjust = new CompositeAdjustEventTrackerFactory(adjustFactories);

The required-mapping version looks like this:

val strict = CompositeFirebaseEventTrackerFactory(
    CheckoutFirebaseEventTrackerFactory(firebaseAnalytics),
    ScreenViewFirebaseEventTrackerFactory(firebaseAnalytics),
    RequireMappedFirebaseEventTrackerFactory, // an unclaimed event throws UnhandledEventException
)
let strict = CompositeFirebaseEventTrackerFactory([
    CheckoutFirebaseEventTrackerFactory(),
    ScreenViewFirebaseEventTrackerFactory(),
    RequireMappedFirebaseEventTrackerFactory(),  // an unclaimed event is reported
])
final strict = CompositeFirebaseEventTrackerFactory([
  CheckoutFirebaseEventTrackerFactory(firebaseAnalytics),
  ScreenViewFirebaseEventTrackerFactory(firebaseAnalytics),
  const RequireMappedFirebaseEventTrackerFactory(), // an unclaimed event is reported
]);
const strict = new CompositeFirebaseEventTrackerFactory([
  new CheckoutFirebaseEventTrackerFactory(firebaseAnalytics),
  new ScreenViewFirebaseEventTrackerFactory(firebaseAnalytics),
  new RequireMappedFirebaseEventTrackerFactory(), // an unclaimed event is reported
]);

A catch-all goes last

Generic and required-mapping factories answer for everything, so nothing after them is ever asked. They're marked FallbackFactory, and every composite checks when it's built. A fallback anywhere but last, or two of them, fails at start-up with the factory and its position named.

Order matters

Apart from the last factory, the order is up to you:

  • Specific before general. A feature's factory placed before Herald's screen-view factory can change how that feature's screen views are sent.
  • Mixpanel's properties need UserPropertyMixpanelPropertySetterFactory before GenericMixpanelPropertySetterFactory. A UserProperty is also a Property, so the generic factory would otherwise claim it, and the people profile would never be written.
  • DI sets have no order. When features add factories through a DI set, place the factories that must come first or last, like the screen-view and generic ones, around the set by hand. See Modular apps.

Properties have chains too

Properties work the same way: property setter factories answer with a Resolution, a composite asks them in order, and a generic or required-mapping factory can close the list. Dropped keeps one property away from one vendor.

Next

  • Routing: chains in practice, per vendor.
  • Custom markers: your own marker types, with their own vendor calls.