Skip to content

Modular apps

In an app split into feature modules, each feature module should own its analytics: its events, and how they reach each vendor. The app module sets up the vendors without knowing which features exist. Factories are what make that possible.

A feature ships its own factories

A feature module declares its events. It writes a factory only for a vendor that needs something special. An event that just needs sending under its own name needs no factory at all, because the generic factory at the end of that vendor's chain sends it.

So you don't write one factory per feature for every vendor. A factory exists only where a vendor really needs something different:

  • tokens for Adjust;
  • conversions for AppsFlyer;
  • one of GA4's recommended events.
:feature:checkout    CheckoutFirebaseEventTrackerFactory   (GA4 purchase)
                     CheckoutAdjustEventTrackerFactory     (dashboard tokens)
                     nothing for Amplitude: the generic factory is fine
:feature:profile     nothing at all: every event goes out under its own name
:feature:search      SearchMixpanelEventTrackerFactory     (a super property)

The app collects them

The app module doesn't need to know what a feature's factories are. Each feature hands over its own, and the app joins them, through your DI library or by hand:

The feature adds its factory, named after the feature:

// :feature:checkout adds its factory, and knows nothing about the app's other vendors.
val checkoutModule = module {
    factory<FirebaseEventTrackerFactory>(named("checkout")) { CheckoutFirebaseEventTrackerFactory(get()) }
}

The analytics module builds the chain from whatever the features added:

// :analytics builds the Firebase provider from whatever the features added.
val firebaseAnalyticsModule = module {
    single<Herald.Provider>(named("firebase")) {
        val firebaseAnalytics = get<FirebaseAnalytics>()
        val tracker = FirebaseAnalyticsTrackerService(
            CompositeFirebaseEventTrackerFactory(
                getAll<FirebaseEventTrackerFactory>() +                    // features, any order
                    ScreenViewFirebaseEventTrackerFactory(firebaseAnalytics) + // then placed by hand
                    GenericFirebaseEventTrackerFactory(firebaseAnalytics),
            ),
            CompositeFirebasePropertySetterFactory(GenericFirebasePropertySetterFactory(firebaseAnalytics)),
        )
        val service = FirebaseAnalyticsService(firebaseAnalytics)
        Herald.Provider("firebase", tracker, tracker, service, service, service)
    }
}

Name every factory you add

Koin keeps only one of two unnamed definitions of the same type: the module loaded last wins, and getAll finds just one factory. Give each one a named(...) qualifier, such as the feature's name.

The feature adds its factory with @IntoSet:

@Module
@InstallIn(SingletonComponent::class)
object CheckoutAnalyticsModule {
    @Provides
    @IntoSet
    fun firebase(firebaseAnalytics: FirebaseAnalytics): FirebaseEventTrackerFactory =
        CheckoutFirebaseEventTrackerFactory(firebaseAnalytics)
}

The analytics module injects Set<@JvmSuppressWildcards FirebaseEventTrackerFactory> and builds the chain the same way.

The feature module exposes its factories:

// The checkout feature module exposes its factories, and knows nothing about the app's other
// vendors.
enum CheckoutAnalytics {
    static func firebase() -> [any FirebaseEventTrackerFactory] {
        [CheckoutFirebaseEventTrackerFactory()]
    }
}

The app builds the chain from what the features contribute:

// The app builds the Firebase provider from whatever the features contribute.
func firebaseProvider(
    featureFactories: [any FirebaseEventTrackerFactory]  // from every feature, any order
) -> HeraldProvider {
    let tracker = FirebaseAnalyticsTrackerService(
        eventTrackerFactory: CompositeFirebaseEventTrackerFactory(
            featureFactories + [
                ScreenViewFirebaseEventTrackerFactory(),  // then placed by hand
                GenericFirebaseEventTrackerFactory(),
            ]),
        propertySetterFactory: GenericFirebasePropertySetterFactory()
    )
    let service = FirebaseAnalyticsService()
    return HeraldProvider(
        name: "firebase",
        events: tracker,
        properties: tracker,
        identity: service,
        lifecycle: service,
        consent: service
    )
}

func appFirebaseProvider() -> HeraldProvider {
    firebaseProvider(
        featureFactories: CheckoutAnalytics.firebase()
            // + ProfileAnalytics.firebase(), one line per feature
    )
}

The feature package exposes its factories:

// The checkout feature package exposes its factories, and knows nothing about the app's other
// vendors.
abstract final class CheckoutAnalytics {
  static List<FirebaseEventTrackerFactory> firebase(FirebaseAnalytics firebaseAnalytics) => [
    CheckoutFirebaseEventTrackerFactory(firebaseAnalytics),
  ];
}

The app builds the chain from what the features contribute:

// The app builds the Firebase provider from whatever the features contribute.
HeraldProvider firebaseProvider(
  FirebaseAnalytics firebaseAnalytics,
  List<FirebaseEventTrackerFactory> featureFactories, // from every feature, any order
) {
  final tracker = FirebaseAnalyticsTrackerService(
    eventTrackerFactory: CompositeFirebaseEventTrackerFactory([
      ...featureFactories,
      ScreenViewFirebaseEventTrackerFactory(firebaseAnalytics), // then placed by hand
      GenericFirebaseEventTrackerFactory(firebaseAnalytics),
    ]),
    propertySetterFactory: CompositeFirebasePropertySetterFactory([
      GenericFirebasePropertySetterFactory(firebaseAnalytics),
    ]),
  );
  final service = FirebaseAnalyticsService(firebaseAnalytics);
  return HeraldProvider(
    name: 'firebase',
    events: tracker,
    properties: tracker,
    identity: service,
    lifecycle: service,
    consent: service,
  );
}

HeraldProvider appFirebaseProvider(FirebaseAnalytics firebaseAnalytics) =>
    firebaseProvider(firebaseAnalytics, [
      ...CheckoutAnalytics.firebase(firebaseAnalytics),
      // ...ProfileAnalytics.firebase(firebaseAnalytics), one line per feature
    ]);

The feature package exposes its factories:

// The checkout feature package exposes its factories, and knows nothing about the app's other
// vendors.
export const CheckoutAnalytics = {
  firebase(firebaseAnalytics: Analytics): FirebaseEventTrackerFactory[] {
    return [new CheckoutFirebaseEventTrackerFactory(firebaseAnalytics)];
  },
};

The app builds the chain from what the features contribute:

// The app builds the Firebase provider from whatever the features contribute.
export function firebaseProvider(
  firebaseAnalytics: Analytics,
  featureFactories: FirebaseEventTrackerFactory[], // from every feature, any order
): HeraldProvider {
  const tracker = new FirebaseAnalyticsTrackerService({
    eventTrackerFactory: new CompositeFirebaseEventTrackerFactory([
      ...featureFactories,
      new ScreenViewFirebaseEventTrackerFactory(firebaseAnalytics), // then placed by hand
      new GenericFirebaseEventTrackerFactory(firebaseAnalytics),
    ]),
    propertySetterFactory: new CompositeFirebasePropertySetterFactory([
      new GenericFirebasePropertySetterFactory(firebaseAnalytics),
    ]),
  });
  const service = new FirebaseAnalyticsService(firebaseAnalytics);
  return new HeraldProvider({
    name: 'firebase',
    events: tracker,
    properties: tracker,
    identity: service,
    lifecycle: service,
    consent: service,
  });
}

export function appFirebaseProvider(firebaseAnalytics: Analytics): HeraldProvider {
  return firebaseProvider(firebaseAnalytics, [
    ...CheckoutAnalytics.firebase(firebaseAnalytics),
    // ...ProfileAnalytics.firebase(firebaseAnalytics), one line per feature
  ]);
}

A DI set has no order

A chain uses the first factory that answers, but a DI set has no order, and the order features are joined in shouldn't matter either. That's fine while each feature's factories handle only that feature's events, which they should. Factories whose position matters go around the injected set, placed by hand:

  • Herald's screen-view factory, before the set;
  • the generic factory, at the end;
  • for properties, Mixpanel's UserPropertyMixpanelPropertySetterFactory, which must come before the generic one.

A vendor that may be missing

A factory that takes a vendor object needs that object from DI too. If a vendor may be missing in some builds, such as Firebase without a google-services.json, provide the vendor object as a single that the app's setup code asks for only after checking the vendor exists. The feature's factory is then never created when the vendor is missing.

Two layouts

Implementing FirebaseEventTrackerFactory puts Herald's Firebase package in a feature's dependencies, so in the layout above feature modules know which vendors exist. The upside is that an event and its vendor rules live together. Some teams would rather features not know about vendors at all.

The other layout turns this around. Per-vendor modules own the vendor rules and depend on small API modules that declare the events. Features depend only on Herald's core package:

:analytics:firebase   depends on :feature:checkout:api, :feature:search:api
                      ships CheckoutFirebaseEventTrackerFactory, SearchFirebaseEventTrackerFactory
:feature:checkout     declares CheckoutStarted, and nothing about vendors

That's still not one big class that knows everything: it's one module per vendor, split into as many small factories as it likes. The chain doesn't care where a factory was compiled. The real choice is who owns the vendor rules: the feature team, or whoever owns analytics.

Moove uses the first layout with Koin. Its :analytics module depends on no feature, and features like :tickets and :inspector add their own factories.