Hire Me

The part of the app that should not ship with the app

Mickey · · 9 min read

Some years ago, over the course of a weekened, a big public stations internet radio provider went offline and with it, a major part of my hand-curated directory, broke.

I used to think of the remote station catalogs in Wellenreiter as data. That was the first mistake — and yours truly has been building radio apps since 2010, so I really should have known better.

A hand-curated station list is data: names, stream URLs, artwork, maybe a few tags. A remote catalog maintained by somebody else is different. It’s somebody else’s product boundary leaking into yours.

At first, the distinction doesn’t matter. You add a native source, decode a JSON endpoint, map a few fields, and the feature works. Then the endpoint changes. Then the artwork field moves. Then the provider adds regional variants. Then your app is still correct in principle, but wrong in production.

The uncomfortable part is that none of this feels like a product problem at the time. It feels like maintenance. A little parser change here, a new field there, a release with no user-visible feature attached to it. But if the app depends on those catalogs feeling alive, then keeping them alive is product work. It just happens to be product work with an awkward release cycle.

The same provider JSON at three points in time: in month one the app decodes stations with slug, title, streamURL and image; by month four the array is called channels, the artwork moved to images.square and streamURL became an array of quality levels; by month nine the slug the app used as a stable identifier was renamed during a website redesign. Each change is highlighted; the shipped app is unchanged and therefore wrong.
Remote catalogs look like data until they start moving.

The obvious implementation

Imagine a fictional service called Metro Wave. It runs a few dozen themed streams: City Nights, Analog Gold, Basement Club, Late Drive. The streams are real internet radio streams, but the station list comes from Metro Wave’s own catalog endpoint.

The first implementation is obvious:

That implementation isn’t wrong. It’s probably the fastest way to get the feature out of the door, and if you’re building the first version of the app, that’s what you should do. Shipping beats architecture cosplay.

The problem appears later, when the external catalog starts behaving like a living system. stations becomes channels. The artwork URL moves from image to images.square. The stream URL is now an array because the provider added quality levels. The slug you used as a stable identifier gets renamed during a website redesign.

None of these changes are conceptually hard. All of them are operationally expensive. A provider changed a JSON shape on Tuesday; your fix now needs a native app release, a review window, a staged rollout, and some amount of user pain in between.

That’s the wrong coupling. The app’s release machinery should not be the emergency repair path for somebody else’s catalog format.

Two timelines for the same one-line fix: shipped with the native app it passes patch, build, App Review as an external gate and a staged rollout, taking one to two weeks while users wait; shipped as a plugin it is edited, tested against the live catalog and deployed within the hour.
A one-line catalog drift should not become a full app-release event.

The split

So what I did instead was to separate the stable part from the volatile part.

The host app owns the things that are actually product-critical and should not be reinvented per source: the player, caching, search, favourites, editorial collections, artwork loading, progress reporting, error isolation, and the internal station schema. It also owns the runtime contract: what a dynamic source is allowed to do, how HTTP requests work, what a valid station looks like, and how failures are contained.

The plugin owns the one thing that changes whenever the remote source changes: translating somebody else’s catalog into the app’s station model.

In the Metro Wave example, the whole provider-specific part can be this small:

registerPlugin({
  id: "metro-wave",

  loadCollections(api) {
    return [{
      id: "metro-wave",
      title: "Metro Wave",
      stationIDs: []
    }];
  },

  loadStations(api, collectionID) {
    if (collectionID !== "metro-wave") return [];

    const catalog = api.getJSON("https://catalog.example.invalid/channels.json");

    return catalog.channels.map(channel => ({
      id: "metro-wave:" + channel.slug,
      name: channel.title,
      streamURL: channel.streams.high,
      homepage: channel.pageURL,
      imageURL: channel.images.square,
      tags: channel.tags || []
    }));
  }
});

That is not a lot of code. More importantly, it is not a lot of responsibility. There’s no player state, UI state, image cache, persistence, navigation, now-playing integration, or search index anywhere in there. The plugin doesn’t know what a tab bar is, has never heard of CarPlay, and doesn’t even know whether the app is running on a phone, a desktop, or inside a command line tool.

It only knows how to turn Metro Wave’s idea of a station into the app’s idea of a station — and that is the entire point of the exercise.

A large host-app box holding the stable responsibilities, player, cache, search, favourites, editorial collections, artwork, progress, error isolation, offline fallback and the station schema, exposes one narrow port with three calls, getJSON, loadCollections and loadStations; outside it a deliberately small plugin box does nothing but turn one provider's catalog JSON into the app's station model, and that catalog is the only thing that keeps changing.
The host owns the product contract; the plugin owns the source translation.

Why the new code got smaller

The surprising part of this kind of refactor is that the plugin code is often shorter than the native code it replaces.

At first that feels suspicious. Surely moving something out of the app should add machinery. And it does: there is a small runtime, an editor, a test runner, a persistence layer, a deployment path. But that machinery is shared. It is paid once.

The old native source paid the integration cost every time. Each source tended to grow its own little version of the same concerns: request handling, error mapping, defensive decoding, progress updates, fallback behaviour, sometimes even ad-hoc logging. None of that code expressed the provider’s shape. It expressed the absence of a proper boundary.

Once the host owns the boundary, the plugin becomes almost embarrassingly direct. Fetch the remote document. Pick the fields. Normalize the names. Return stations.

The code didn’t get shorter because JavaScript is magic (it isn’t, believe me). It got shorter because most of the old code wasn’t about the source at all.

What makes it hold

This split only works if the plugin system is treated as a runtime boundary, not as a convenience script.

A plugin must be testable before it ships. Not “the editor accepted the text”, but the whole chain: compile it, run it, fetch the remote catalog through the same HTTP surface the app will expose, validate every returned station, and try representative streams. If the test fails, the broken change should stay in the editor, not travel to every installed app.

A plugin must also fail small. A syntax error in one dynamic source should not take down the catalog. A timeout should not block editorial stations. A remote provider returning garbage should not poison the app’s stable data. The host needs to be able to say: this source failed, skip it, keep the rest of the product working.

There is a subtle design pressure here. The plugin API should be small on purpose. A tiny HTTP helper. A way to return collections. A way to return stations. Maybe text and JSON helpers. Very little else.

Every extra capability is tempting. Every extra capability also increases the amount of host behaviour that can leak into provider-specific code. The point is not to let plugins become little apps. The point is to let volatile catalog knowledge live somewhere that can be edited, tested, and deployed at the speed of the volatility.

Parallel, but isolated

There is another trap in dynamic catalogs: sequencing. Once sources are external, it is tempting to load them one after the other because that makes progress reporting easy. First source, second source, third source. The logs look tidy; the user stares at a progress bar that stalls on whichever provider is slow today.

From the user’s side, remote catalogs are independent. If Metro Wave is slow, it should not hold up a completely unrelated collection. The host can run the dynamic sources in parallel, collect their results as they finish, and update progress in completion order. The progress bar still tells the truth; it just no longer serializes the world for the convenience of the implementation.

This matters because dynamic sources fail in the real world. They time out, throttle, redirect, return empty lists, or take a long scenic route through a CDN. The architecture should assume that and stay pleasant: a dead source is one missing collection, not a stuck app.

In short

Stable stuff — playback, persistence, favourites, the station schema — belongs in the app. The exact shape of somebody else’s JSON this week doesn’t, and the two don’t move at the same speed, so they shouldn’t have to ship together. That’s all there is to it; the rest of this post was the plumbing.

A spectrum of what a shipped app may change after review, from a data update through remote configuration and a catalog update to a data-source adapter and finally new app behaviour; a dashed line marks where review gets nervous, and the plugin adapter is placed just inside it, next to the behaviour it must not become.
The useful boundary is also the one that needs discipline on reviewed platforms.

P.S. There is one uncomfortable wrinkle here, especially in the Apple universe.

Apple has never been fond of apps that change their own functional scope after review — ask me how I know: back in 2013 the relevant rules were 2.7 and 2.8, and they cost me three apps. Today it’s guideline 2.5.2, which forbids downloading code that changes features or functionality — with one explicit exception: scripts run by WebKit or JavaScriptCore, as long as they don’t significantly change the app’s primary purpose. My plugins do run in JavaScriptCore and cannot do anything but turn a catalog into stations, which is exactly why the API is as small as it is. The technical boundary and the review boundary are the same line, on purpose.

So yes: this split is useful. It’s also one of those things you implement with your teeth slightly clenched, hoping the review gods read the same guideline as you do. *sigh*

If you’ve solved this differently in your apps, I’d love to hear about it.