Kidjo is a kids subscription platform made of three apps: Kidjo TV for video, Kidjo Games, and Kidjo Stories for audiobooks. One brand, one account, one subscription. The apps ship to Android, iOS and Web, serve children aged 2 to 10 with a COPPA-safe experience, and have passed 10M+ downloads. All three come out of a single repository: a Melos monorepo on Dart pub workspaces.
This post walks through how that repository is structured, and more importantly why: which rules the build enforces, which choices were deliberately boring, and what the structure bought a team shipping three apps at once. Everything below is shown as patterns with generic names and invented example snippets, not client code.
Why a monorepo
Three apps sharing one account system, one subscription and one brand cannot live in three repositories without paying for it. Every shared behaviour either gets copied three times or gets published as internal packages with their own release ceremony. Version skew follows: one app on the current core, another two versions behind, a bug fixed in one place and still live in another.
A monorepo collapses that coordination cost. One repository, one dependency resolution, and a single commit can change a shared layer together with all three apps that consume it, reviewed as one change. The question stops being "how do we synchronise five repos" and becomes "which boundaries do we enforce inside one repo", which is a much better question to spend energy on.
Why Melos on Dart pub workspaces
Two tools, two different jobs.
Dart pub workspaces give the repository a single dependency resolution. Every package resolves against one lockfile, so all three apps agree on the version of every dependency. There is no "works in the video app, breaks in the games app" caused by skew, and an upgrade happens once, in one place, for everyone.
Melos sits on top for orchestration: running analysis, tests or builds across every package, or only across the packages a change affects, from one command at the root. Workspaces solve resolution; Melos solves running things. Before pub workspaces existed, Melos also handled package linking itself; on a current Dart SDK you let pub do that part and keep Melos for the task running.
The root pubspec.yaml declares the members:
# pubspec.yaml at the workspace root
name: workspace
publish_to: none
environment:
sdk: ^3.6.0
workspace:
- apps/video
- apps/games
- apps/audiobooks
- packages/core
- packages/components
dev_dependencies:
melos: ^7.0.0
Each member package opts in with a single line in its own pubspec: resolution: workspace.
The workspace layout
The layout is small enough to hold in your head. apps/ contains the three application shells: video, games, audiobooks. Each shell stays thin: an entry point, a route table, the screens specific to that app, and wiring. The weight lives in two shared packages.
packages/core is the offline-first data layer. It hides every source of data behind two small interfaces, LocalCache and ApiClient, and exposes repositories built on top of them. packages/components is the design system: theme, typography and the shared widgets that make three differently shaped apps read as one brand.
The public surface of core looks like this:
// packages/core/lib/core.dart (public surface, trimmed)
abstract interface class ApiClient {
Future<Map<String, Object?>> getJson(String path);
}
abstract interface class LocalCache {
Future<Map<String, Object?>?> readJson(String key);
Future<void> writeJson(String key, Map<String, Object?> value);
}
class CatalogRepository {
CatalogRepository(this._api, this._cache);
final ApiClient _api;
final LocalCache _cache;
// Cache first, network second: every screen has something to
// render, and a plane ride with no signal still has content.
Stream<Catalog> watchCatalog() async* {
final cached = await _cache.readJson('catalog');
if (cached != null) yield Catalog.fromJson(cached);
final fresh = await _api.getJson('/v1/catalog');
await _cache.writeJson('catalog', fresh);
yield Catalog.fromJson(fresh);
}
}
Two things about this shape. First, the interfaces keep core honest: apps and tests inject whatever ApiClient and LocalCache they want, so the data layer's logic runs under test with fakes instead of platform plumbing. Second, offline-first is the default rather than a feature. Kids use these apps in cars and on planes; the repository yields the cache first and refreshes behind it, so every screen inherits that behaviour without asking for it.
Dependency rules the build enforces
A monorepo makes it easy to share code. That is also its failure mode: it makes it easy to share code. Nothing physically separates the design system from the data layer, or one package's internals from another's imports, except rules. Rules that live in people's heads lose to deadlines.
So the rules here are not convention. They are pubspec entries plus analyzer configuration, and breaking them fails the build:
- Apps depend on
coreandcomponents. coreandcomponentsnever depend on each other, and never on an app.- Nobody imports another package's
src/.

The first two rules cost nothing to enforce. A package's pubspec simply does not declare the dependency:
# packages/components/pubspec.yaml
name: components
publish_to: none
resolution: workspace
environment:
sdk: ^3.6.0
dependencies:
flutter:
sdk: flutter
# No core here. The design system renders what it is given;
# it never reaches into the data layer.
On its own that is still only a convention, because in a workspace every package resolves together and an undeclared import will happily compile. The analyzer closes the gap when the relevant lints are raised to errors:
# analysis_options.yaml at the workspace root, included by every package
analyzer:
errors:
# An import with no matching pubspec entry fails the build,
# instead of scrolling past as a style hint.
depend_on_referenced_packages: error
# Reaching into another package's src/ fails the build too:
# packages talk through their public surface only.
implementation_imports: error
linter:
rules:
- depend_on_referenced_packages
- implementation_imports
The payoff shows up at review time. A pull request that adds a forbidden import does not open a debate about layering; it arrives red. Review goes back to being about the change itself, and new joiners learn the boundaries from their first build failure instead of from a wiki page nobody reads.
State and navigation choices
State is Riverpod, written by hand, with no code generation. That trade is deliberate. Codegen buys some ergonomics and charges for them in build_runner time, generated files in every diff, and one more step between a clean checkout and a running app, multiplied by every package in the workspace. Hand-written providers stay plain objects you can read, grep and step through:
// apps/video/lib/state/catalog_state.dart
// getIt is the app's composition root, wired once at startup.
final catalogRepositoryProvider = Provider<CatalogRepository>(
(ref) => CatalogRepository(getIt<ApiClient>(), getIt<LocalCache>()),
);
final catalogProvider = StreamProvider<Catalog>(
(ref) => ref.watch(catalogRepositoryProvider).watchCatalog(),
);
// In a widget, reading it stays one line:
// final catalog = ref.watch(catalogProvider);
get_it covers dependency injection at the composition root: each app registers its concrete ApiClient and LocalCache implementations at startup, and core never learns which platform it is running on. Riverpod then owns reactive state on top. Two tools, one seam: get_it wires infrastructure once, Riverpod makes state observable to widgets.
Navigation is go_router, but shared code never touches it directly. Shared flows navigate through an AppNavigator interface, and each app implements it against its own route table:
// packages/core/lib/src/navigation/app_navigator.dart
abstract interface class AppNavigator {
void openPlayer(String contentId);
void openGrownUpsArea();
void goBack();
}
// apps/video/lib/navigation/go_router_navigator.dart
class GoRouterNavigator implements AppNavigator {
GoRouterNavigator(this._router);
final GoRouter _router;
@override
void openPlayer(String contentId) => _router.push('/player/$contentId');
@override
void openGrownUpsArea() => _router.push('/grown-ups');
@override
void goBack() => _router.pop();
}
The reasoning: three apps have three route tables, and shared code that pushes literal route strings couples every package to every app's URL scheme. Behind the interface, core can say "open the player" without knowing what that means in the games app versus the video app. Web is one of the ship targets, so URL-based routing was never optional; go_router provides it to all three apps, and the interface keeps it at the edge where it belongs.
Players that take new content without a release
The three apps are data-driven players. The video app plays a catalog of 3000+ videos with live playback and offline downloads. The games app runs 40+ WebView games. The audiobooks app carries 1000+ audiobooks with a dyslexia-friendly reader. None of that content lives in the binaries. The apps know how to render catalog entries; the catalog itself is data, loaded at runtime. New videos, games and audiobooks reach kids without an app release.

That sentence is the whole business case for the architecture, so it is worth spelling out what it demands:
- The catalog is a contract. What a player can render is defined by data it interprets, not by code baked into a release. Product changes to content are backend changes.
- Old binaries meet new content. Not every family updates the app, so a player must skip what it cannot interpret instead of failing the whole catalog:
// packages/core/lib/src/catalog/entry_parser.dart
CatalogEntry? tryParseEntry(Map<String, Object?> json) {
return switch (json['type']) {
'video' => VideoEntry.fromJson(json),
'game' => GameEntry.fromJson(json),
'book' => BookEntry.fromJson(json),
// Unknown type: this binary predates the content.
// Skip the entry, keep the catalog.
_ => null,
};
}
- Offline is part of the contract too. A download started yesterday has to survive today's catalog refresh, which is exactly why downloads and caching live in
corebehindLocalCacheinstead of inside any single app.
What the monorepo bought the team
The structure is why three apps are a sane workload instead of three times the work. The plumbing that makes a subscription platform hard, meaning accounts, entitlements, offline data and a shared design language, exists once and ships three times. A fix lands in one commit and reaches every app in its next release. The app shells stay thin enough that most product work happens in the shared packages, where it has immediate leverage.
There is also migration context: Kidjo did not start life in Flutter. The platform was migrated from a native Swift codebase, and the rebuild is what made this structure possible, because the package boundaries were drawn before the code that filled them.
Honesty requires the other column. A monorepo concentrates risk as well as leverage: a careless change in components can break three apps before lunch. CI has to become workspace-aware and run what a change affects, or pipeline time grows with every package added. And single resolution means lockstep: when a dependency upgrade lands, every app takes it together, ready or not. These costs are real. They are also cheaper than version skew and triple maintenance, which is the comparison that matters.
When a Melos monorepo is worth it
Reach for this setup when the signals line up:
- More than one app ships from the same team, sharing brand, account or data.
- A design system has more than one real consumer.
- You are already copying code between repositories, or publishing internal packages whose only consumers are your own apps.
Skip it when they do not:
- One app does not need Melos to be modular. Local packages inside a single app repo give you boundaries without the orchestration layer.
- Packages with a single consumer, created for a second app that does not exist yet, are speculation. Wait for the second app; restructuring into a workspace later is cheaper than carrying ceremony for years.
The monorepo itself is not the interesting decision. The enforced boundaries inside it are. Three apps, one subscription, a shared core, and a build that says no: that combination is what keeps three ship targets from quietly becoming three codebases.