The part of the app that should not ship with the app
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 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:
- write a
MetroWaveSourcein the app; - fetch
https://catalog.example.invalid/channels.json; - decode the response into a few native structs;
- map each channel into the app’s internal station model.
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.
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.
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.
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.