A play counter is the easiest feature to demo and one of the easiest to get quietly wrong. plays = plays + 1 passes every manual test you'll ever click through. It also counts a retried request four times, loses plays when a worker crashes at the wrong moment, and happily lets one script "listen" to a track 2,000 times an hour.
So when I built a Spotify-style play count in .NET 10 recently, I didn't start with the counter. I started with the tests that would tell me the counter was lying. Here are seven of them, roughly in the order they bit me. The test bodies are trimmed to the part that matters; where something is cut, the comment says so.
Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10. The complete, working project these tests come from (
spotify-play-counts-dotnet, Clean Architecture, 152 passing tests, plus a 95-page PDF) is inside. Join Tech Skill Builder
First, what "a play" means
Spotify for Artists says a stream counts when someone plays a song for at least 30 seconds. Offline plays count when the listener comes back online, which has to happen at least once every 30 days. Confirmed artificial streams get removed from public numbers, so the total can go down. Those three sentences are the whole spec, and every test below is just one of them, pushed until it breaks.
The rough shape I ended up with:
client batch -> ingest (30 s rule, stateless) -> queue partitioned by track
-> worker: dedupe + guard + bucket + combine -> one conditional commit
-> hourly rows + totals -> cached public read
Test 1: the 30-second boundary, both sides of it
[Theory]
[InlineData(29_999, Verdict.TooShort)]
[InlineData(30_000, Verdict.Qualified)]
public void Thirty_second_rule_and_duration_bounds(int msPlayed, Verdict expected)
{
var play = Plays.For(track: SongOf(minutes: 3), msPlayed: msPlayed);
Assert.Equal(expected, _qualifier.Evaluate(play, _track, _clock.GetUtcNow()));
// plus: negative ms, longer-than-the-track, future timestamps, 31-day-old plays
// ... full theory data in the complete project
}
Boring, yes. But an off-by-one at 30,000 ms means real money in royalties, and the "older than 30 days" case is the one that tells a client to stop retrying. Order your checks so the most useful rejection reason wins.
Test 2: a retried event is counted once
[Fact]
public async Task Redelivered_event_is_not_counted_twice()
{
await Aggregate(Stream("e-1001"));
await Aggregate(Stream("e-1001")); // at-least-once delivery, same id
Assert.Equal(1, await Total("track-1"));
}
Your queue will redeliver. Spotify's own event pipeline write-ups describe at-least-once transport with deduplication done downstream. Ingest can't dedupe across requests, because it's stateless and runs on every API instance, so this test belongs to the worker. Ingest gets its own test that says the opposite: the same id in two requests is published twice. Write that one down so nobody "fixes" it later.
Test 3: a racing writer doesn't cause a double count
This is the one I'd make mandatory. The worker reads "which ids have I seen?", adds counts, then marks the ids seen. Crash between those steps and you either double count or drop plays.
[Fact]
public async Task Racing_writer_causes_a_replan_and_no_double_count()
{
var store = new RacingCounterStore(markFirst: "e-2001"); // marks an id after our read
var handler = NewAggregator(store);
await handler.Handle(BatchOf("e-2001", "e-2002"), default);
Assert.Equal(2, store.TotalFor("track-1"));
Assert.True(store.CommitAttempts > 1); // it re-planned instead of writing blind
}
What makes it pass is a single commit that marks ids seen and applies the deltas, and is refused if any id was marked since the read:
var outcome = await store.TryCommitAsync(
new CounterCommit(partition, counted, held, deltas, now), ct);
if (!outcome.Committed) continue; // re-read, re-plan
// retry limit, metrics and give-up path omitted
// ... full implementation in the complete project
Test 4: a hot track is one write, not a thousand
[Fact]
public async Task Batch_of_many_plays_is_one_write_per_track_hour()
{
var batch = Enumerable.Range(0, 1_000).Select(i => Stream($"e-{i}", track: "hit")).ToList();
await Aggregate(batch);
Assert.Equal(1, _store.RowWritesFor("hit"));
Assert.Equal(1_000, await Total("hit"));
}
People jump to sharded counters for viral tracks. Batching by (track, hour) first gets you most of the way for free, because nobody writes the same row a thousand times. The cost is a little lag, and that's fine for a number that sits behind a cache anyway.
One related trap: route partitions with a stable hash. string.GetHashCode() is randomized per process in .NET, so two API instances would send the same track to different partitions. A tiny test pinning your hash to known reference values catches that forever.
Test 5: a late offline play goes to the open hour, flagged
[Fact]
public async Task Late_stream_is_filed_in_the_open_hour_and_flagged()
{
_clock.SetUtcNow(Noon);
var play = Stream("e-3001", startedAt: Noon.AddDays(-3)); // phone was in airplane mode
await Aggregate(play);
var row = await HourRow("track-1", HourOf(Noon));
Assert.Equal(1, row.LateCount);
// and the sealed hour from three days ago is untouched
}
Dashboards and charts read a closed hour once. Spotify's ETL posts describe writing late data into the currently open hour instead of reopening old ones, and that's what this test enforces. There's a sibling test, Event_in_an_unsealed_past_hour_keeps_its_own_hour, so you don't over-correct and shove every past play forward.
Test 6: one listener can't out-listen the clock
[Fact]
public async Task Repeat_plays_beyond_what_an_hour_can_hold_are_held()
{
var plays = RepeatPlays(listener: "l-9", track: "track-1", count: 200, withinOneHour: true);
await Aggregate(plays);
Assert.True(await Total("track-1") <= 120); // 3600 s / 30 s per play
// held plays are marked seen, so a retry can't sneak them back in
}
This is a placeholder, not bot detection. Spotify keeps its real thresholds private on purpose. But even the dumb rule needs two more tests: the cap has to span batches (Repeat_cap_spans_batches), and different listeners mustn't share it.
Test 7: the public number is allowed to be stale, and says so
[Fact]
public async Task Second_read_within_ttl_is_served_from_cache_and_is_stale()
{
var first = await ReadPublic("track-1");
await Aggregate(Stream("e-4001"), Stream("e-4002"));
var second = await ReadPublic("track-1");
Assert.Equal(first.Total, second.Total);
Assert.Equal("cache", second.ServedFrom);
// TTL expiry, admin bypass and "below 1,000 shows nothing" tests omitted
}
Nobody refreshes an album page waiting for one play to tick. Making staleness explicit (where the number came from, and how old it is) turns "is this a bug?" into a non-question.
The test I haven't shown you
There's an eighth group, and it's the hard one: Correction_shows_up_as_a_negative_row_not_a_rewrite, Running_the_same_correction_twice_subtracts_once, Sealed_hours_are_not_rewritten_the_correction_lands_in_the_open_hour. They're what happens when cleaning confirms that last week's 40,000 streams were a bot farm, after the chart job and royalty export already read those hours. Deciding how that number goes down without looking broken is a design question more than a coding one, and it deserves more room than a blog post.
Get the complete project
The full .NET 10 solution behind these tests is inside Tech Skill Builder: Domain, Application, Infrastructure and Api as separate projects, the 30-second qualifier, stable partition routing, BackgroundService aggregators with conditional commits, hourly buckets with a watermark, the repeat-play guard, the cached read path, the cleaning and correction flow, and all 152 tests (131 unit, 21 integration). It comes with a 95-page PDF that walks through Spotify's published pipeline, the architecture and request-flow diagrams, the trade-offs and the correction design.
Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10: https://elitesolutions1.gumroad.com/l/TechSkillBuilder
Which of these would have caught a bug in your own counters? I'd like to hear what you test for in the comments.
Top comments (0)