DEV Community

Bhanu Nagpure
Bhanu Nagpure

Posted on • Originally published at Medium

Why I Stopped Building Apps and Started Building an Engine in Flutter

Architecture diagram

As an indie developer, your biggest bottleneck isn’t skill — it’s time. And maintaining multiple apps will absolutely destroy it.

A few months ago, I started building PyMaster, a gamified app to help people learn Python on their phones. But the moment I shipped the first version, I didn’t feel done. I wanted to teach SQL next. Then JavaScript. Maybe even Rust.

That’s when I realized I had a problem.

The Copy-Paste Trap

The obvious move was to duplicate the PyMaster codebase, swap the logos, change the content, and publish a new app.

Simple, right?

I actually tried it. I cloned the repo, renamed a few things, and within ten minutes I already hated what I was looking at.

  • Three separate codebases
  • Three separate sets of bugs
  • Three separate times I’d have to push a fix every time I touched the streak logic

As a solo founder, that’s not a roadmap — that’s a slow death.

So I scrapped it and spent a weekend thinking differently.

Instead of building another app, I’d build an engine — one codebase that could compile into an infinite number of apps.

A white-label educational platform, powered by Flutter + Riverpod.

Here’s exactly how I architected it.

The Core Concept: Inversion of Control

In a normal app, your UI knows too much.

It’s got hardcoded strings like:

Text("Welcome to Python")
Enter fullscreen mode Exit fullscreen mode

It also has tightly coupled parsers that only understand Python syntax.

The UI is opinionated, and that opinion is baked in deep.

To white-label this, I needed the UI to become completely dumb.

It shouldn’t know what it’s teaching — only how to render what it’s given.

I achieved this by creating a master interface called CourseBlueprint.

// The master contract every app flavor must fulfill
abstract class CourseBlueprint {
  String get appTitle;
  String get virtualCurrencyName;

  // Theme Engine
  Color get brandPrimaryColor;
  Color get brandSecondaryColor;

  // Logic Injection
  CodeParserStrategy get codeParser;

  // Per-app 3rd Party Config
  String get analyticsId;
  String get aiSystemPrompt;
}
Enter fullscreen mode Exit fullscreen mode

This interface is the backbone of the entire system.

Every flavor of the app — Python, SQL, JavaScript — must implement it.

The UI never imports a PythonCodeParser directly.

It just asks for a parser, and the configuration provides one.

Creating the “Flavors”

With the blueprint in place, spinning up a new app takes hours, not weeks.

Here’s what the Python flavor looks like:

// Python-specific implementation
class PythonCourseConfig implements CourseBlueprint {
  @override
  String get appTitle => "PyMaster";

  @override
  String get virtualCurrencyName => "Tokens";

  @override
  Color get brandPrimaryColor => const Color(0xFFFFD43B); // Python Yellow

  @override
  CodeParserStrategy get codeParser => PythonCodeParser(); // Handles indentation

  @override
  String get aiSystemPrompt =>
      "You are an expert Python tutor. Explain the error in simple terms.";
}
Enter fullscreen mode Exit fullscreen mode

If I want to launch a SQL app tomorrow, I simply:

  1. Create SqlCourseConfig
  2. Change the theme color
  3. Inject a SqlKeywordParser
  4. Update the AI prompt

That’s genuinely it.

The real win here isn’t technical elegance.

It’s where my time goes.

Instead of wrestling infrastructure, I spend almost all my effort on the content inside the app.

Tying It Together with a Factory

How does the app know which configuration to load?

I use Flutter’s --dart-define flag during compilation.

A simple factory reads the flavor and returns the right config.

class BlueprintFactory {
  static CourseBlueprint getForFlavor(String flavor) {
    switch (flavor.toLowerCase()) {
      case 'python':
        return PythonCourseConfig();

      case 'sql':
        return SqlCourseConfig();

      default:
        return PythonCourseConfig(); // Safe fallback
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

I’ll be honest.

The first time I wired this up, I spent nearly two hours debugging why my SQL flavor kept rendering in Python Yellow.

Turns out I forgot to pass the build flag.

--dart-define=APP_FLAVOR=sql
Enter fullscreen mode Exit fullscreen mode

So it silently fell back to the default.

Infuriating in the moment.

Obvious in hindsight. Classic.

Injecting Config into the UI with Riverpod

The last piece was making the configuration globally accessible.

Without prop-drilling it through every widget.

This is where Riverpod shines.

// Global provider — overridden at startup
final blueprintProvider = Provider<CourseBlueprint>((ref) {
  throw UnimplementedError("Must be overridden in main.dart");
});

void main() {
  const flavor =
      String.fromEnvironment('APP_FLAVOR', defaultValue: 'python');

  final selectedConfig = BlueprintFactory.getForFlavor(flavor);

  runApp(
    ProviderScope(
      overrides: [
        blueprintProvider.overrideWithValue(selectedConfig)
      ],
      child: const MyEduApp(),
    ),
  );
}
Enter fullscreen mode Exit fullscreen mode

Now any widget can read the configuration.

Widget build(BuildContext context, WidgetRef ref) {
  final config = ref.watch(blueprintProvider);

  return Text(
    "Welcome to ${config.appTitle}!",
    style: TextStyle(color: config.brandPrimaryColor),
  );
}
Enter fullscreen mode Exit fullscreen mode

The UI is completely blind to the domain.

It simply renders what it’s given.

That’s the whole point.

Why This Changes Everything for a Solo Founder

Every bug I fix in the core gamification engine — streaks, XP, offline progression — gets fixed for every app compiled from this codebase.

  • Write the fix once
  • Ship the improvement once
  • Every flavor inherits it

That leverage is everything for a solo developer.


I’m betting this architecture will outlive any single app I build with it.

Whether that’s a smart long-term bet or just what you tell yourself after spending a weekend over-engineering…

I guess we’ll find out.


If you want to see how this engine feels in production, I just shipped the first flavor:

PyMaster is now live on the Google Play Store, featuring:

  • gamified offline progression
  • a dynamic theme engine
  • a built-in AI tutor

Discussion

Have you architected something similar?

Did you go with:

  • app flavors
  • monorepos
  • plugin architectures

I’m curious what trade-offs you ran into.

Drop your thoughts in the comments 👇

Top comments (0)