DEV Community

marco menegazzi
marco menegazzi

Posted on • Edited on

Mosaic: The Flutter Architecture That Makes Provider, BLoC, and GetX Look Like Amateur Hour

How I replaced an entire ecosystem of conflicting packages with one coherent system


Most Flutter developers are building apps wrong. Monolithic architectures, tight coupling, hours-long builds — it's amateur hour.

I solved this. Mosaic is what happens when you actually think about architecture instead of mindlessly following Google's documentation.

Why I Built This

I got tired of watching teams drown in dependency hell. Your typical Flutter project has 200+ packages that conflict with each other, break on updates, and force you to spend more time managing dependencies than building features. You need 15 different packages just to handle basic state management, routing, and logging — all with different APIs, philosophies, and maintainers who abandon projects without warning.

Mosaic replaces your entire chaotic toolchain with one coherent system. Everything works together because it was designed together. No more version conflicts, no more API mismatches, no more dependency roulette.

What I Built

Event system that makes Redux look like a toy:

// Pattern matching that actually works
events.on<Payment>('payment/*', callback);  // Any payment event
events.on<User>('user/#', callback);        // All user events, recursively
Enter fullscreen mode Exit fullscreen mode

True modularity (not the fake "packages" everyone pretends are modules):

// Modules are completely isolated universes
class PaymentModule extends Module {
  // Own lifecycle, DI container, navigation stack
  // Zero dependencies on other modules
}
Enter fullscreen mode Exit fullscreen mode

Dynamic UI injection (bet none of you have seen this):

// Module A injects into Module B without imports
// Try doing THAT with your precious Provider pattern
injector.inject('home/sidebar', widget);
Enter fullscreen mode Exit fullscreen mode

Reactive signals that don't suck like every other state solution:

final user = AsyncSignal(() => api.fetchUser(), autorun: true);
// Loading states, error handling, computed values - all built in
// While you're still wrestling with StreamBuilder
Enter fullscreen mode Exit fullscreen mode

Thread safety that actually works:

final cache = Mutex<Map<String, User>>({});
// No more race conditions because you don't understand concurrency
Enter fullscreen mode Exit fullscreen mode

Dependency injection that respects boundaries (unlike your get_it mess):

// Global DI for shared services
global.put<HttpClient>(CustomHttpClient());

// Per-module DI for isolation
class PaymentModule extends Module {
  @override
  Future<void> onInit() async {
    di.put<PaymentService>(StripeService());
    di.lazy<PaymentRepository>(() => PaymentRepository());
    // Module dependencies stay in the module
  }
}
Enter fullscreen mode Exit fullscreen mode

The Numbers Don't Lie

  • Builds: 3 hours → 15 minutes (while your team waits for CI)
  • Team conflicts: Daily nightmare → Zero in 6 months
  • Test coverage: "What's testing?" → 95% per module
  • Deployment: All-or-nothing disasters → Independent module releases

Mosaic vs Your Favorite "Solutions"

Building a User Profile with Counter

Provider (The Widget Coupling Hell):

// 1. Create provider with boilerplate
class UserProvider extends ChangeNotifier {
  User? _user;
  int _counter = 0;

  void increment() {
    _counter++;
    notifyListeners(); // Manual notification hell
  }
}

// 2. Wrap everything in providers
MultiProvider(
  providers: [ChangeNotifierProvider(create: (_) => UserProvider())],
  child: MyApp(),
)

// 3. Use with ugly Consumer boilerplate
Consumer<UserProvider>(
  builder: (context, provider, child) {
    return Text('${provider.user?.name}: ${provider.counter}');
  },
)
Enter fullscreen mode Exit fullscreen mode

BLoC (The Event/State Explosion):

// 1. Define endless events and states
abstract class UserEvent {}
class LoadUser extends UserEvent {}
class IncrementCounter extends UserEvent {}

abstract class UserState {}
class UserLoading extends UserState {}
class UserLoaded extends UserState {
  final User user;
  final int counter;
  UserLoaded(this.user, this.counter);
}

// 2. Create bloc with massive boilerplate
class UserBloc extends Bloc<UserEvent, UserState> {
  // 30+ lines of boilerplate for basic functionality
}

// 3. Use with even more boilerplate
BlocBuilder<UserBloc, UserState>(
  builder: (context, state) {
    if (state is UserLoaded) {
      return Text('${state.user.name}: ${state.counter}');
    }
    return CircularProgressIndicator();
  },
)
Enter fullscreen mode Exit fullscreen mode

Mosaic (Real Architecture):

// 1. Module with built-in everything
class UserModule extends Module with Loggable {
  final userSignal = AsyncSignal<User>(() => fetchUser(), autorun: true);
  final counterSignal = Signal<int>(0);

  @override
  Future<void> onInit() async {
    // Built-in DI, events, logging - all integrated
    di.put<UserService>(UserService());
    events.on<String>('user/increment', (_) => counterSignal.state++);
    info('User module ready');
  }

  @override
  Widget build(BuildContext context) {
    return userSignal.when((status) {
      if (status.loading) return CircularProgressIndicator();
      if (status.success) return UserProfile(status.data!, counterSignal);
      return ErrorWidget(status.error);
    });
  }
}

// 2. Zero boilerplate usage
class UserProfile extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return counter.watch((count) => Text('${user.name}: $count'));
  }
}

// 3. Cross-module communication without coupling
events.emit('user/increment', null); // From anywhere
Enter fullscreen mode Exit fullscreen mode

The difference:

  • Provider/BLoC: 50+ lines, tight coupling, boilerplate hell
  • Mosaic: 15 lines, zero coupling, everything integrated

Why Most Developers Will Resist This

Admitting Mosaic is right means admitting your current approach is wrong.

  • Package addicts will cry "vendor lock-in" (while locked into 15+ conflicting vendors)
  • BLoC evangelists will defend their boilerplate (because they've invested too much time learning it)
  • Provider fans will claim "simplicity" (while drowning in Consumer widgets)

Let them. The developers who get it will build next-generation applications while everyone else manages their dependency graveyard.

The Brutal Truth

90% of Flutter developers will look at this and not understand what they're seeing. They'll go back to their spaghetti code and wonder why their apps are unmaintainable garbage.

But the 10% who actually get it will never touch another monolithic Flutter app again.

Here's the kicker: This solves problems most of you don't even know you have yet. You're still thinking small-scale, single-developer, toy-app mindset. I'm thinking enterprise-scale, multi-team, real-world architecture.

Get Started

dependencies:
  mosaic: ^0.0.3
Enter fullscreen mode Exit fullscreen mode

Links:

Feel free to go back to your callback hell and setState madness. I'll be here when you finally realize you need actual architecture.

Try to prove me wrong. I dare you.


What's your biggest Flutter architecture nightmare? Drop a comment and I'll show you how Mosaic solves it with one coherent system instead of 15 conflicting packages.

Top comments (0)