> ## Content Index
> Fetch the complete content index at: https://williamboles.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Our Ever Expanding Appetite for Analytics
- URL: https://williamboles.com/our-ever-expanding-appetite-for-analytics/
- Published: 2016-10-08T16:04:51.000Z
- Updated: 2026-09-26T21:38:18.000Z
- Description: Gaining insights into how our users use our apps is essential to fully serve their needs. However, gaining those insights can be messy. Here, we look at integrating analytics into our apps without it getting in the way.
- Author: William Boles
- Tags: Analytics, Separation of Concerns

You've just shipped a new feature. A week later, Jeff asks whether anyone's actually using it. You check the analytics dashboard and... there's nothing. No events, no data, no insights. The feature is flying blind. You can't say whether that increase in daily active users is connected to it, whether it's worth iterating on, or whether you should quietly kill it. All you've got is a gut feeling.

Every feature brings with it an appetite for data. Analytics isn't an add-on - it's a vital part of the feature. But it's easy to miss that and instead see it as something to bolt on after you've *finished* the feature. The result is analytics scattered throughout the feature with little structure or thought about how analytics shapes the design. This approach can easily lead to analytic bugs that, in turn, will lead to decision-making based on faulty data.

Let's change that.

![A photo of bread being made](https://storage.ghost.io/c/b9/b4/b9b42214-5933-4584-8e56-9ee8ca21d23b/content/images/size/w1000/2026/03/photo-of-bread-dough-min.jpg)

This post will explore how to build an analytics layer that is easy to write, extend and test by placing it separate but parallel to the functionality that it tracks. Each area of the app will get its own analytics registry responsible for constructing events.

> This post will gradually build up to a working example using the [Mixpanel SDK](https://docs.mixpanel.com/docs/tracking-methods/sdks/swift?ref=williamboles.com). But I get you're busy. Head over to the [completed example](https://github.com/wibosco/AnalyticalAppetite-Example?ref=williamboles.com) and take a look at `AnalyticsService`, `AnalyticsEvent` and `FeedAnalyticalRegistry` to see how things end up.

## Looking at the Menu

Before jumping into building each component, let's take a moment to look at the overall architecture we're going to put in place and how each component fits into it.

![A class diagram showing the flow from a trigger into an AnalyticalRegistry, which then branches into two paths: one that creates an AnalyticsEvent and another that sends that event to an AnalyticsService](https://storage.ghost.io/c/b9/b4/b9b42214-5933-4584-8e56-9ee8ca21d23b/content/images/2026/03/analytics_class_diagram.jpg)

- `AnalyticsEvent` \- represents a user event worth tracking.
- `AnalyticsService` \- is the bridge between the wider app and the analytics provider(s). It accepts `AnalyticsEvent` instances and delivers them to `Mixpanel`, `Firebase`, etc. By fully wrapping the analytics provider(s), it insulates the rest of the app from any provider changes.
- `AnalyticalRegistry` \- groups related events together and exposes each event as a method. It constructs and configures `AnalyticsEvent` instances before passing them to `AnalyticsService`.

> I've used generic naming in the class diagram, but in the example, we will be working in concrete types, so `AnalyticalRegistry` will become `FeedAnalyticalRegistry` and `ProfileAnalyticalRegistry`.

> Don't worry if that doesn't all make sense yet; we will look into each component in greater depth below.

## Starting with the Event

Before we can track anything, we need to be able to build the concept of an event:

```
// 1
struct AnalyticsEvent {
    // 2
    let name: String

    // 3
    let properties: [String: Any]?

    init(name: String,
         properties: [String: Any]? = nil) {
        self.name = name
        self.properties = properties
    }
}
```

Here's what we did above:

1. `AnalyticsEvent` is a struct used for tracking the details about a particular action that has just occurred.
2. `name` is the identifier for this event, e.g. "Post Liked".
3. `properties` is a dictionary of additional context about the event, e.g. `["Liked": true]`. The values are `Any` because analytics properties can be a mix of types - `Bool`, `String`, `Int`, etc - and we need a single dictionary to hold them all.

> The above `AnalyticsEvent` is deliberately simple for this post, but this type can be extended/altered to fit your exact needs.

With `AnalyticsEvent` in place, the next piece is the service that delivers it to our analytics provider(s).

## Serving up the Service

As mentioned, we don't want our wider app to care about what analytic provider(s) we are using. `AnalyticsService` hides those details:

```
// 1
class AnalyticsService {
    private let mixpanel: Mixpanel

    // 2
    init(mixpanel: Mixpanel) {
        self.mixpanel = mixpanel
    }

    // 3
    func send(_ event: AnalyticsEvent) {
        mixpanel.track(event.name,
                       properties: event.properties)
    }
}
```

1. `AnalyticsService` is a class because it's a long-lived shared resource passed to multiple registries rather than a value you'd copy around.
2. `Mixpanel` is [injected](https://williamboles.com/let-dependency-injection-lead-you-to-a-better-design/) through the initialiser rather than created internally, keeping the dependency explicit.
3. `send(_:)` takes an `AnalyticsEvent` and passes its name and properties straight through to the `track(_:properties:)` method on `mixpanel`. This is the only place in the app that talks to the `Mixpanel` SDK.

That last point is worth underlining. By funnelling every event through this single method, `Mixpanel` never leaks into the rest of the codebase. If `Mixpanel` changes its API, or we switch to `Firebase` tomorrow, this one method is the only thing that needs updating. Every registry, every call site, every test - all untouched.

Events exist and the service can deliver them, but nothing connects the two yet:

```
// 1
class FeedAnalyticalRegistry {
    // 2
    private let service: AnalyticsService

    init(service: AnalyticsService) {
        self.service = service
    }

    // 3

    func sendPostOpenedEvent() {
        let event = AnalyticsEvent(name: "Post Opened")

        service.send(event)
    }

    func sendLikeEvent(liked: Bool) {
        let event = AnalyticsEvent(name: "Post Liked",
                                   properties: ["Liked": liked])

        service.send(event)
    }

    func sendSharedEvent(shared: Bool) {
        let event = AnalyticsEvent(name: "Post Shared",
                                   properties: ["Shared": shared])

        service.send(event)
    }
}
```

1. `FeedAnalyticalRegistry` groups all feed-related analytics events into one type. You'd create similar registries for other areas of your app - `ProfileAnalyticalRegistry`, `SettingsAnalyticalRegistry`, etc.
2. The registry depends on an `AnalyticsService` instance.
3. Each method represents a single event. The caller passes typed parameters - `liked: Bool`, `shared: Bool` \- and the registry handles constructing the `AnalyticsEvent` with the correct name and property keys. This keeps event names and property structures defined in one place rather than scattered across call sites.

`FeedAnalyticalRegistry` doesn't care how the value of `liked` or `shared` was gathered; it just takes that information and knows how to construct an `AnalyticsEvent` instance from it. This [separation of concerns](https://en.wikipedia.org/wiki/Separation%5Fof%5Fconcerns?ref=williamboles.com) means that a registry can be used in multiple domains, such as paired with a view-model, controller, service, etc., without the internal implementation of that registry having to change. The environment each registry operates in never leaks into its implementation.

The registry is where the real value of this approach lives. Instead of analytics being scattered across the feature, every event definition lives here in one place.

`FeedAnalyticalRegistry` is really quite simple, but there's no reason that more complex logic can't be inside a registry:

```
class ProfileAnalyticalRegistry {
    private let service: AnalyticsService

    init(service: AnalyticsService) {
        self.service = service
    }

    func sendFieldsChangedEvent(firstNameChanged: Bool,
                                lastNameChanged: Bool,
                                emailAddressChanged: Bool,
                                bioChanged: Bool) {
        let totalFieldsChanged = [firstNameChanged, lastNameChanged, emailAddressChanged, bioChanged]
            .filter { $0 }
            .count

        let properties: [String: Any] = ["First Name Changed": firstNameChanged,
                                         "Last Name Changed": lastNameChanged,
                                         "Email Address Changed": emailAddressChanged,
                                         "Bio Changed": bioChanged,
                                         "Total Fields Changed": totalFieldsChanged]

        let event = AnalyticsEvent(name: "Profile Fields",
                                   properties: properties)

        service.send(event)
    }
}
```

`ProfileAnalyticalRegistry` follows the same pattern as `FeedAnalyticalRegistry` \- we inject in an `AnalyticsService` instance and expose very focused methods that hide the `AnalyticsEvent` building process.

The production code is in place - but analytics events that aren't tested are analytics events we can't trust yet.

## Testing to Taste

To test `AnalyticsService`, we need a way to inject a [test-double](https://en.wikipedia.org/wiki/Test%5Fdouble?ref=williamboles.com) in the place of `Mixpanel`. That test-double needs to match the interface of `Mixpanel` to be acceptable to the compiler - we could subclass `Mixpanel` and override its method, but a better approach is to create a protocol that we can wrap both `Mixpanel` and the test-double in.

We don't own `Mixpanel`, so we can't directly change its implementation, but we can use an extension to conform `Mixpanel` to our new protocol:

```
// 1
protocol MixpanelTracking {
    func track(_ event: String, properties: [AnyHashable : Any]?)
}

// 2
extension Mixpanel: MixpanelTracking { }
```

1. `MixpanelTracking` is a protocol that mirrors the `track(_:properties:)` method on `Mixpanel`.
2. `Mixpanel` already has a `track(_:properties:)` method with this exact signature, so conforming it to `MixpanelTracking` requires no additional code - the extension is empty.

> This pattern - defining a protocol that matches an existing method and conforming the type via an empty extension - works for any third-party dependency where you want to introduce a testability seam without modifying the original type. It's not specific to analytics or `Mixpanel`; anywhere you depend on a concrete type you don't control, the same approach applies.

With `Mixpanel` now conforming to `MixpanelTracking`, we can update `AnalyticsService` to accept a type of `MixpanelTracking` rather than `Mixpanel`:

```
class AnalyticsService {
    private let mixpanel: MixpanelTracking

    init(mixpanel: MixpanelTracking) {
        self.mixpanel = mixpanel
    }

    // Omitted unchanged functionality
}
```

This allows us to create a test-double conforming to `MixpanelTracking` to take the place of `Mixpanel` in our unit tests:

```
class StubMixpanelTracking: MixpanelTracking {
    // 1
    enum Event {
        case track(String, [AnyHashable : Any]?)
    }

    // 2
    private(set) var events = [Event]()

    // 3
    func track(_ event: String,
               properties: [AnyHashable : Any]?) {
        events.append(.track(event, properties))
    }
}
```

1. `Event` is an enum that records which method was called on the stub. With only `track(_:properties:)` on the protocol, it has a single case - `track`. As `track(_:properties:)` has two parameters, the `track` case has two associated values to allow any test to inspect the values passed into the method. Recording method calls as enum cases avoids maintaining individual properties for each method's state, and scales naturally as the API grows.
2. `events` is an array that captures every call in order. Using an array rather than a single property means we can verify that the right number of events were sent, not just the last one.
3. As `StubMixpanelTracking` conforms to `MixpanelTracking`, it must implement `track(_:properties:)`. Here, we append a new `Event` case to the array to track that the method was called.

> This isn't a post about how to write test-doubles, so there is no need to get in touch about how you consider `StubAnalyticsEventSending` to be more of a `Spy` than a `Stub`.

We can now write our `AnalyticsService` unit tests:

```
final class AnalyticsServiceTests: XCTestCase {
    // 1
    var mixpanel: StubMixpanelTracking!

    // 2
    var sut: AnalyticsService!

    // 3
    override func setUp() {
        super.setUp()

        mixpanel = StubMixpanelTracking()
        sut = AnalyticsService(mixpanel: mixpanel)
    }

    // 4
    
    func test_givenAnEventWithNameOnly_whenSendIsCalled_thenEventDetailsPassedToMixpanel() {
        let event = AnalyticsEvent(name: "test_name")

        sut.send(event)

        XCTAssertEqual(mixpanel.events.count, 1)

        guard case let .track(name, properties) = mixpanel.events.first else {
            XCTFail("Expected track event")
            return
        }

        XCTAssertEqual(name, "test_name")
        XCTAssertNil(properties)
    }

    func test_givenAnEventWithNameAndProperties_whenSendIsCalled_thenEventDetailsPassedToMixpanel() {
        let event = AnalyticsEvent(name: "test_name", properties: ["test_key": "test_value"])

        sut.send(event)

        XCTAssertEqual(mixpanel.events.count, 1)

        guard case let .track(name, properties) = mixpanel.events.first else {
            XCTFail("Expected track event")
            return
        }

        XCTAssertEqual(name, "test_name")
        XCTAssertEqual(properties as? [String : String], ["test_key": "test_value"])
    }
}
```

1. `mixpanel` is our test-double that captures events. The property is force unwrapped, as each time a test runs `setUp()`, this property will be set, so we can't set it here, but we know it will always be there by the time the test is running.
2. `sut` \- [system under test](https://en.wikipedia.org/wiki/System%5Funder%5Ftest?ref=williamboles.com) \- is the service we're testing.
3. `setUp()` creates fresh instances of both properties before every test, ensuring each test is independent and unaffected by any previous test's state.
4. Each test follows the same pattern: trigger an event on the service, use `guard case` to extract the captured values from the stub, then assert that the event's name and properties match what we expect. This consistent pattern makes it straightforward to add tests for new events.

With `AnalyticsService` fully tested, we can move on to the registries.

Much like `AnalyticsService`, we need to write a test-double to allow the registry's dependencies to be swapped out during testing:

```
protocol AnalyticsEventSending {
    func send(_ event: AnalyticsEvent)
}
```

`AnalyticsEventSending` is a protocol that has just one method `send(_:)` that matches the method in `AnalyticsService`. To conform `AnalyticsService` to `AnalyticsEventSending`, we need to make one change:

```
class AnalyticsService: AnalyticsEventSending {
    // Omitted unchanged functionality
}
```

Both registries now need to accept `AnalyticsEventSending` rather than `AnalyticsService`:

```
class FeedAnalyticalRegistry {
    private let service: AnalyticsEventSending

    init(service: AnalyticsEventSending) {
        self.service = service
    }

    // Omitted unchanged functionality
}

class ProfileAnalyticalRegistry {
    private let service: AnalyticsEventSending

    init(service: AnalyticsEventSending) {
        self.service = service
    }

    // Omitted unchanged functionality
}
```

With that change, we can now write a test-double conforming to `AnalyticsEventSending`:

```
class StubAnalyticsEventSending: AnalyticsEventSending {
    enum Event {
        case send(AnalyticsEvent)
    }

    private(set) var events = [Event]()

    func send(_ event: AnalyticsEvent) {
        events.append(.send(event))
    }
}
```

`StubAnalyticsEventSending` follows the same pattern as `StubMixpanelTracking`.

We can now write registry unit tests, starting with `FeedAnalyticalRegistry`:

```
class FeedAnalyticalRegistryTests: XCTestCase {
    var analyticsService: StubAnalyticsEventSending!
    var sut: FeedAnalyticalRegistry!

    override func setUp() {
        super.setUp()

        analyticsService = StubAnalyticsEventSending()
        sut = FeedAnalyticalRegistry(service: analyticsService)
    }

    func test_whenSendPostOpenedEventIsCalled_thenEventDetailsAreCorrect() {
        sut.sendPostOpenedEvent()

        XCTAssertEqual(analyticsService.events.count, 1)

        guard case let .send(event) = analyticsService.events.first else {
            XCTFail("Expected send event")
            return
        }

        XCTAssertEqual(event.name, "Post Opened")
        XCTAssertNil(event.properties)
    }

    func test_givenLikedIsTrue_whenSendLikeEventIsCalled_thenEventDetailsAreCorrect() {
        let liked = true

        sut.sendLikeEvent(liked: liked)

        XCTAssertEqual(analyticsService.events.count, 1)

        guard case let .send(event) = analyticsService.events.first else {
            XCTFail("Expected send event")
            return
        }

        XCTAssertEqual(event.name, "Post Liked")
        XCTAssertEqual(event.properties?["Liked"] as? Bool, liked)
    }

    func test_givenLikedIsFalse_whenSendLikeEventIsCalled_thenEventDetailsAreCorrect() {
        let liked = false

        sut.sendLikeEvent(liked: liked)

        XCTAssertEqual(analyticsService.events.count, 1)

        guard case let .send(event) = analyticsService.events.first else {
            XCTFail("Expected send event")
            return
        }

        XCTAssertEqual(event.name, "Post Liked")
        XCTAssertEqual(event.properties?["Liked"] as? Bool, liked)
    }

    func test_givenSharedIsTrue_whenSendSharedEventIsCalled_thenEventDetailsAreCorrect() {
        let shared = true

        sut.sendSharedEvent(shared: shared)

        XCTAssertEqual(analyticsService.events.count, 1)

        guard case let .send(event) = analyticsService.events.first else {
            XCTFail("Expected send event")
            return
        }

        XCTAssertEqual(event.name, "Post Shared")
        XCTAssertEqual(event.properties?["Shared"] as? Bool, shared)
    }

    func test_givenSharedIsFalse_whenSendSharedEventIsCalled_thenEventDetailsAreCorrect() {
        let shared = false

        sut.sendSharedEvent(shared: shared)

        XCTAssertEqual(analyticsService.events.count, 1)

        guard case let .send(event) = analyticsService.events.first else {
            XCTFail("Expected send event")
            return
        }

        XCTAssertEqual(event.name, "Post Shared")
        XCTAssertEqual(event.properties?["Shared"] as? Bool, shared)
    }
}
```

`ProfileAnalyticalRegistryTests` will follow the same pattern.

> See the accompanying [example](https://github.com/wibosco/AnalyticalAppetite-Example?ref=williamboles.com) project for the full listing.

As `AnalyticsEvent` doesn't contain any functionality, we will not write unit tests for it.

And that is everything we need for our analytic event tracking. Well done on making it here 💃.

## ☕️ Coffees?

The appetite for analytics only ever grows. Features get tracked, questions get asked, and new events get requested. With this approach, satisfying that appetite is just a method on a registry, a configured event, and a test that proves it all works.

Nothing scattered, nothing flying blind. Just answers there whenever Jeff needs them.

To see the above code snippets together in a working example, head over to the [repository](https://github.com/wibosco/AnalyticalAppetite-Example?ref=williamboles.com) and clone the project.