Skip to content

How Top Southeast Asian Super-Apps Structure Their Flutter Monorepos

There's a version of this post that's just vibes — vague advice about "separation of concerns" and "keep things modular." This isn't that post.

I've spent years building Flutter apps at GoJek, Stockbit, and Reku. These aren't side projects. They're apps with tens of millions of users, engineering teams in the dozens, and features shipping every week. The architecture decisions made at that scale have real consequences.

Here's what actually works.


The Problem with a Single Package

Most Flutter apps start as a single package. That's fine. At some point, though, "fine" becomes a liability.

Symptoms you'll recognize: build times balloon as the app grows. Two teams modify the same file in the same sprint and spend an afternoon resolving conflicts. A bug in the analytics layer causes a failed build in the checkout feature. Nobody's quite sure where to put the new thing.

The instinct is to "just be more disciplined." That never works. The solution is structure that makes discipline the path of least resistance.


Feature Modules as First-Class Citizens

The pattern used by mature Flutter teams in Southeast Asia is straightforward to describe and genuinely transformative in practice: every feature is its own Dart package.

Not a folder. A package. With its own pubspec.yaml, its own public API surface, and explicit dependencies declared. The main app shell depends on feature packages. Feature packages do not depend on each other.

This sounds like overhead. It is, briefly, at the start. What you get back: isolated build caches (a clean rebuild of checkout doesn't touch auth), clear ownership boundaries, and the ability to pull a feature out of the app entirely without archaeology.

The feature's public interface is what it exports. Everything else is private. If another feature needs something from yours, that need surfaces as a conversation — not a late-night grep.


The API/Impl Split (When It Matters)

Not every package needs this. But for packages that are consumed across many features — networking, analytics, monitoring — it's worth the investment.

The pattern: create a feature_api package that contains only abstract interfaces and shared data types. Create a feature_impl package that depends on feature_api and contains the actual implementation. Feature modules depend on feature_api, never on feature_impl.

Why? Two reasons.

First, vendor swapping. When your analytics provider changes — and it will — you write a new implementation that satisfies the same interface. No feature code changes. Second, testing. Every feature that depends only on the abstract interface gets easy, fast mocks with zero effort.

The anti-pattern is applying this everywhere. A debug overlay, an in-app logger, a splash screen — none of these need an _api split. Apply it where isolation buys you something real.


Dual Dependency Injection

Single-container DI gets messy at scale. The pattern that works: two GetIt instances.

A global container holds app-wide singletons — SDKs, HTTP clients, crash reporters, anything that lives for the lifetime of the process. This is registered at startup, before any feature code runs.

A local container per feature holds repositories, data sources, use cases, and viewmodels scoped to that feature. These register themselves when the feature initializes and clean up when the user leaves.

The benefit is containment. A memory leak in a feature's DI scope doesn't pollute the global container. Rotating between features cleans up after itself. Debugging a registration problem in checkout doesn't require reading registrations from auth, analytics, and networking first.


Feature SDK Self-Registration

Large teams can't afford a central DI configuration file that every engineer edits. The conflicts are relentless.

The pattern: each feature exposes a FeatureSDK class responsible for its own registration. The app shell calls FeatureSDK.register() for each feature at startup. That's the only place feature names appear in the app shell — in a list, in order.

The FeatureSDK registers dependencies, declares routes, and sets up any pre-launch hooks. Adding a new feature means adding one line to the registration list. Removing a feature means deleting one line. Nothing else in the app shell changes.


Two-Phase Initialization

App startup has two kinds of work: things that must complete before the first frame (blocking), and things that can happen in the background (non-blocking).

Phase one — pre-launch — covers DI registration, route setup, and environment configuration. The splash screen holds until this completes. These operations are fast by design. Nothing in phase one makes a network request.

Phase two — post-launch — covers analytics initialization, feature flag fetching, remote config, and non-critical SDK warmup. These run asynchronously after the first frame renders. The user sees the app immediately. The infrastructure catches up.

Teams that don't make this distinction end up with 3-4 second startup times caused by analytics SDKs and remote config calls blocking the main isolate. Users leave. The fix is architectural, not a quick patch.


CI/CD is Architecture Too

The patterns above live in code. But production-readiness includes the pipeline.

Mature teams ship with: per-package test runs in CI (so a failing test in analytics doesn't block a networking fix from merging), automated build numbering, Fastlane lanes for both platforms, and artifact upload to TestFlight/Firebase App Distribution on every merge to main.

Setting this up from scratch takes days. It's also identical on every project. This is exactly the kind of work that should be automated.


Closing

These patterns aren't exotic. They're not research. They're what teams at GoJek, Stockbit, Reku, and their peers settled on after shipping to millions of users and learning from the pain of not having them.

The frustrating thing is that they're not written down anywhere accessible. They live in private repos, internal wikis, and the heads of senior engineers.

I've spent the past year codifying them into Archipelago — a Flutter CLI toolkit that generates a monorepo with all of this wired up from the start.

If you want to stop rebuilding the foundation and start shipping features, the waitlist is open at archipelago.banuacoder.com.

The CLI is already on pub.dev:

dart pub global activate archipelago_cli

The first run might surprise you with how much it sets up.

Built by Banua Coder