Hazem Azzam

All posts
Writing

Analytics in Clean Architecture: events as domain, sinks as infrastructure

Most analytics code lives wherever the click happens. This post shows how to put it inside a layered architecture: events as domain, sinks as infrastructure, one-way imports, and zero changes to existing hooks when you add a new destination like Pixel.

5 min read
analyticsclean-architecturetypescriptnextjsarchitecture

The shape most apps end up with

Analytics usually starts as a single function. Someone adds track('add_to_cart', { ... }) next to the click handler, and a year later there are 80 call sites, no two events have the same prop shape, the same event name is misspelled three different ways, and onboarding a new destination (Pixel, GA, TikTok) means a global grep and a long PR that touches every feature.

The cost isn't the original code. It's that "where do I track this?" has no canonical answer, so every developer answers it differently, and every renaming or sink swap becomes archaeology.

A small amount of structure fixes it. Not a heavy framework — just two questions answered consistently:

  1. What is an event? A typed business fact with a stable name and a known prop shape.
  2. Where does an event go? Through a tracker that fans out to one or more sinks, with cross-cutting context like user identity attached automatically.

In clean / layered architecture terms: events are domain, sinks are infrastructure, the tracker is a port, and call sites are presentation. Once you draw those lines, the rest of the design writes itself.

The core ports and adapters

Three concepts. That's the whole API surface.

// domain/models/AnalyticsEvent.ts
export type AnalyticsEventProp = string | number | boolean | null;

export interface AnalyticsEvent {
  name: string;
  props?: Record<string, AnalyticsEventProp>;
}

// domain/services/AnalyticsSink.ts
export interface AnalyticsSink {
  name: string;
  track(event: AnalyticsEvent): void;
}

AnalyticsEvent is the contract every event must satisfy. AnalyticsSink is the port every destination must implement. Both live in domain/ because they describe the business — they have no transport, no DOM, no fetch, no SDK imports.

The infrastructure side is the registry plus the adapters:

// infrastructure/tracker.ts
const sinks: AnalyticsSink[] = [];

export function registerSink(sink: AnalyticsSink) {
  if (sinks.some((s) => s.name === sink.name)) return; // dedupe
  sinks.push(sink);
}

export function track(event: AnalyticsEvent) {
  for (const sink of sinks) {
    try {
      sink.track(event);
    } catch {
      // analytics must never break UX
    }
  }
}

A Vercel adapter:

// infrastructure/sinks/vercel-sink.ts
import { track as vercelTrack } from "@vercel/analytics";

export class VercelAnalyticsSink implements AnalyticsSink {
  readonly name = "vercel";
  track(event: AnalyticsEvent): void {
    vercelTrack(event.name, event.props ?? undefined);
  }
}

That's it on the analytics side. No event names, no business concepts, no knowledge of carts or checkouts. This module is generic and stays generic.

The question that defines the design: where do event creators live?

This is where most teams get it wrong, and where clean architecture actually pays off.

Tempting answer: put all event creators inside analytics/events/cart.ts, analytics/events/checkout.ts, etc. One place to audit the full catalog. Easy to find.

Why it's wrong: analytics/events/cart.ts defines cartAddItem({ productId, variationId, addonCount }). To know what productId and variationId are, analytics has to know about cart. Now analytics imports from cart's domain. And next sprint analytics/events/checkout.ts imports from checkout. And analytics/events/auth.ts imports User. Analytics has become a giant index of every domain in the app — the exact opposite of "infrastructure that knows nothing about features."

The right answer: events are domain facts of the feature that emits them. cart_add_item is a cart concept, so its creator lives in core/cart/domain/events/. checkout_started belongs to checkout. Analytics only defines the generic AnalyticsEvent shape and the sink port — it never learns what a product is.

src/core/analytics/                          # generic transport
  domain/models/AnalyticsEvent.ts
  domain/services/AnalyticsSink.ts
  infrastructure/tracker.ts
  infrastructure/sinks/vercel-sink.ts

src/core/cart/domain/events/                 # cart-shaped events
  cart-add-item-event.ts
  cart-update-quantity-event.ts
  cart-remove-item-event.ts

The import direction is one-way: cart → analytics, never the reverse. Same shape as cart → its repository. Same shape as any feature → any port. Onboarding a new feature doesn't require editing analytics. Removing a feature doesn't leave orphaned event files in someone else's folder.

Group by domain action, not by variant

Inside a feature's domain/events/ folder, the question becomes: one file per event, or one file per action with all variants colocated?

A single cart_add_item action usually emits three events: success, blocked (precondition failed — no delivery type selected, invalid variation), and failed (backend rejected). They share the same business intent and almost always change together. Splitting them into three files means three imports, three places to update when the action's prop shape changes, and three places to remember to keep in sync.

Group them:

// core/cart/domain/events/cart-add-item-event.ts
export type CartAddItemBlockedReason =
  | "no_delivery_type" | "invalid_variation" | "invalid_addons";

export const cartAddItem = (p: { ... }): AnalyticsEvent => ({ name: "cart_add_item", props: p });
export const cartAddItemBlocked = (p: { ... }): AnalyticsEvent => ({ name: "cart_add_item_blocked", props: p });
export const cartAddItemFailed = (p: { ... }): AnalyticsEvent => ({ name: "cart_add_item_failed", props: p });

Independent actions (cart_update_quantity, cart_apply_coupon and its failure twin, cart_remove_coupon) live in their own files. The rule is "one file per domain action, variants colocated" — not "one file per event."

The call site: hooks, not actions

This is the second design question teams flip a coin on. Where in the codebase does track(...) get called?

Wrong answer: wrap every server action in a "client action" that tracks before calling the server. Now every mutation has two versions, the file count doubles, and the wrapper adds zero logic beyond track(...) followed by the server call.

Right answer: hooks already own the success/error branches that drive toasts and update local state. That branch is exactly where track(...) belongs.

A typical hook today:

const result = await addToCartAction(...);
if (result.success) {
  queryClient.setQueryData(CART_QUERY_KEY, result.data);
  toast.success("Added to cart");
} else {
  toast.error(result.message);
}

Add tracking with no new layer:

const result = await addToCartAction(...);
if (result.success) {
  queryClient.setQueryData(CART_QUERY_KEY, result.data);
  toast.success("Added to cart");
  track(cartAddItem({ productId, variationId, quantity, unitPrice, addonCount }));
} else {
  toast.error(result.message);
  track(cartAddItemFailed({ productId, message: result.message }));
}

The hook is still the only thing that knows what success and failure mean for this action. The event creator stays inside core/cart/domain/events/. The tracker stays inside core/analytics/infrastructure/. Three layers, one import direction, zero duplication.

Crucially, this also lets you track blocked intent — the case where the user tries to act but a precondition isn't met. "User clicked add-to-cart without selecting delivery type" is one of the most valuable signals an e-commerce funnel has, and it only exists at the hook layer where guard checks live. A "client action wrapper" pattern hides it; a hook-level tracker exposes it for free.

Cross-cutting context: enrich in the tracker, not at the call site

Every event needs user identity, branch, locale, maybe AB test bucket. If each call site is responsible for adding { userId, branchId, ... } to its event, half of them will forget, and the prop names will drift.

Put the enrichment in the tracker:

let identity: Record<string, AnalyticsEventProp> = {};

export function setAnalyticsIdentity(next: Record<string, AnalyticsEventProp>) {
  identity = next;
}

export function track(event: AnalyticsEvent) {
  const enriched: AnalyticsEvent = {
    name: event.name,
    props: { ...identity, ...event.props }, // identity first; explicit props win
  };
  for (const sink of sinks) {
    try { sink.track(enriched); } catch { /* swallow */ }
  }
}

A single AnalyticsProvider (the presentation layer of the analytics feature) reads from the app's auth/branch/locale providers and keeps the tracker's identity in sync via useEffect:

useEffect(() => {
  setAnalyticsIdentity({
    userId: user?.id ?? null,
    isAuthenticated,
    branchId: branchId ?? null,
    deliveryType: deliveryType ?? null,
  });
}, [user?.id, isAuthenticated, branchId, deliveryType]);

Event creators no longer mention identity at all. Hooks no longer mention identity. Every event in the system gets userId, isAuthenticated, branchId, deliveryType automatically. When you add a new field to identity, you change exactly one file.

The "identity first, event props last" merge order matters: if an event ever needs to override an identity field (a future user_switched event with a different userId), it can — but the default for every other event is to inherit identity unchanged.

What adding a new destination looks like

Six months in, the marketing team wants Facebook Pixel. With this layout the answer is: one new file, one new line in the provider.

// core/analytics/infrastructure/sinks/pixel-sink.ts
export class PixelSink implements AnalyticsSink {
  readonly name = "pixel";
  track(event: AnalyticsEvent): void {
    if (typeof fbq === "undefined") return;
    fbq("trackCustom", event.name, event.props ?? {});
  }
}
// AnalyticsProvider
registerSink(new PixelSink());

Zero hooks change. Zero event creators change. The new sink receives every event already firing, identity-enriched, with no risk that one of 80 call sites was missed. That's the payoff the architecture was for.

The discipline that holds it together

A few rules keep this from drifting:

  • Events live in the owning feature's domain. Not in shared, not in analytics, not in the hook file.
  • One file per domain action, variants colocated. Success/blocked/failed of the same action stay together.
  • Hooks are the only call sites. Components don't import track. Server actions don't import track.
  • Don't manually attach identity in event creators. The tracker injects it. Listing userId in your event's prop shape is redundant and confusing.
  • Sinks swallow their own errors. Analytics never breaks the user-visible flow.
  • One-way imports. Features import analytics. Analytics imports nothing from features, ever.

None of this requires a framework or a code generator. It's two interfaces (AnalyticsEvent, AnalyticsSink), a tracker module with ~30 lines, and the discipline to put new event creators in the feature that emits them. Six months later, when someone asks "where does the cart_add_item event come from?", the answer is core/cart/domain/events/cart-add-item-event.ts — and nobody has to grep.


Rate this post

All fields are optional. Just stars is fine.

No ratings yet