All posts
8 October 2026·9 min read

LEGO Architecture in Flutter: Building Apps One Brick at a Time

How thinking in LEGO bricks (small, self-contained packages with clear connectors) made my Flutter apps easier to scale, test, and change.

#flutter#dart#architecture#modularization#clean-code

Every Flutter project starts out tidy. There's a lib/ folder, a couple of screens, and maybe a services/ directory. Six months later, lib/ holds 300 files, the login screen somehow imports the payment API client, and a change to one model breaks three features you didn't touch.

I've been there more than once. The fix that worked for me came from my childhood toy box: LEGO.

In this post I'll explain what I mean by "LEGO architecture", how I structure a Flutter app this way, and where it pays off (and where it doesn't).


Why LEGO?

A LEGO brick works well for three reasons:

  1. It's self-contained. A brick is a brick. It doesn't need to know what it's attached to.
  2. It has a standard connector. The studs on top and tubes underneath follow one spec, so any brick fits any other brick.
  3. It's replaceable. If you don't like the red 2×4, pop it off and snap in a blue one. Nothing else in the build changes.

Translate that to software and you get the qualities every architecture is chasing:

LEGO conceptFlutter equivalent
BrickA Dart package (feature, data source, or UI kit)
Studs and tubesAbstract interfaces / contracts
BaseplateThe app shell: main.dart, routing, theming
Instruction manualThe composition root: dependency injection wiring
SetsFeature bundles that ship together

The rule that makes it work: bricks only connect through studs. A feature never reaches into another feature's internals. It depends on a contract, and the baseplate decides which concrete brick fills that slot.


The Bricks: Splitting the App into Packages

Instead of one big lib/ folder, the app becomes a small monorepo of packages:

my_app/
├── lib/                      # 🟩 Baseplate (app shell only)
│   ├── main.dart
│   ├── app.dart
│   └── bootstrap.dart        # 📖 Instruction manual (DI wiring)
│
└── packages/
    ├── core/                 # Shared primitives (Result, failures, extensions)
    ├── design_system/        # 🎨 UI bricks: buttons, cards, theme tokens
    │
    ├── auth_contract/        # 🔌 Studs: abstract AuthRepository + models
    ├── auth_firebase/        # 🧱 Brick: Firebase implementation
    ├── auth_feature/         # 🧱 Brick: login/signup screens + state
    │
    ├── products_contract/
    ├── products_api/
    └── products_feature/

Each package has its own pubspec.yaml, its own tests, and a deliberately small public API. Tools like Melos or Dart's native pub workspaces keep the monorepo manageable: one pub get, shared lints, and scripts that run tests across every brick.

The three kinds of bricks

I split bricks into three types, and each type has a strict rule about what it may depend on:

  • Contract bricks (*_contract): pure Dart. Abstract classes, models, and failures. No Flutter, no third-party SDKs. These are the studs.
  • Implementation bricks (*_api, *_firebase, *_local): depend on a contract and fulfil it with a real SDK.
  • Feature bricks (*_feature): widgets and state management. They depend on contracts only, never on implementations.
        ┌────────────────┐
        │  auth_feature  │  (UI + state)
        └───────┬────────┘
                │ depends on
                ▼
        ┌────────────────┐
        │ auth_contract  │  ◄── the studs
        └───────▲────────┘
                │ implements
        ┌───────┴────────┐
        │ auth_firebase  │  (Firebase SDK lives here only)
        └────────────────┘

Notice that auth_feature has no idea Firebase exists. That's the point.


The Studs: Designing Contracts

The contract is the most important brick because everything else snaps onto it. Here's a minimal auth_contract:

// packages/auth_contract/lib/src/user.dart
class AppUser {
  const AppUser({required this.id, required this.email, this.displayName});

  final String id;
  final String email;
  final String? displayName;
}
// packages/auth_contract/lib/src/auth_failure.dart
sealed class AuthFailure implements Exception {
  const AuthFailure();
}

class InvalidCredentials extends AuthFailure {
  const InvalidCredentials();
}

class NetworkUnavailable extends AuthFailure {
  const NetworkUnavailable();
}

class UnknownAuthFailure extends AuthFailure {
  const UnknownAuthFailure(this.message);
  final String message;
}
// packages/auth_contract/lib/src/auth_repository.dart
abstract interface class AuthRepository {
  /// Emits the current user, or null when signed out.
  Stream<AppUser?> get authStateChanges;

  /// Throws an [AuthFailure] on error.
  Future<AppUser> signIn({required String email, required String password});

  Future<void> signOut();
}
// packages/auth_contract/lib/auth_contract.dart
library;

export 'src/auth_failure.dart';
export 'src/auth_repository.dart';
export 'src/user.dart';

A few things to notice:

  • Domain-shaped models. AppUser isn't Firebase's User. If it were, the SDK would leak into every brick that touches it.
  • Sealed failures. Dart 3's sealed classes let the UI switch over every failure exhaustively, and the compiler tells you when a new one appears.
  • A barrel file. Only what's exported from auth_contract.dart is public. Everything in src/ stays private by convention.

Snapping in an Implementation Brick

Now the Firebase brick fulfils the contract:

// packages/auth_firebase/lib/src/firebase_auth_repository.dart
import 'package:auth_contract/auth_contract.dart';
import 'package:firebase_auth/firebase_auth.dart' as fb;

class FirebaseAuthRepository implements AuthRepository {
  FirebaseAuthRepository({fb.FirebaseAuth? firebaseAuth})
      : _auth = firebaseAuth ?? fb.FirebaseAuth.instance;

  final fb.FirebaseAuth _auth;

  @override
  Stream<AppUser?> get authStateChanges =>
      _auth.authStateChanges().map((user) => user?.toAppUser());

  @override
  Future<AppUser> signIn({
    required String email,
    required String password,
  }) async {
    try {
      final credential = await _auth.signInWithEmailAndPassword(
        email: email,
        password: password,
      );
      return credential.user!.toAppUser();
    } on fb.FirebaseAuthException catch (e) {
      throw switch (e.code) {
        'invalid-credential' ||
        'wrong-password' ||
        'user-not-found' =>
          const InvalidCredentials(),
        'network-request-failed' => const NetworkUnavailable(),
        _ => UnknownAuthFailure(e.message ?? e.code),
      };
    }
  }

  @override
  Future<void> signOut() => _auth.signOut();
}

extension on fb.User {
  AppUser toAppUser() =>
      AppUser(id: uid, email: email ?? '', displayName: displayName);
}

All Firebase-specific knowledge (error codes, the User type, the singleton) is contained in this one brick. If the team moves to Supabase next year, we write auth_supabase, swap one line in the instruction manual, and no feature code changes.


The Feature Brick: UI That Only Knows Studs

The feature brick holds the screens and state. I'm using flutter_bloc here, but Riverpod, Provider, or plain ChangeNotifier all fit the same way. The pattern doesn't care which one you pick.

// packages/auth_feature/lib/src/login_cubit.dart
import 'package:auth_contract/auth_contract.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

sealed class LoginState {
  const LoginState();
}

class LoginIdle extends LoginState {
  const LoginIdle();
}

class LoginLoading extends LoginState {
  const LoginLoading();
}

class LoginSuccess extends LoginState {
  const LoginSuccess(this.user);
  final AppUser user;
}

class LoginError extends LoginState {
  const LoginError(this.failure);
  final AuthFailure failure;
}

class LoginCubit extends Cubit<LoginState> {
  LoginCubit(this._repository) : super(const LoginIdle());

  final AuthRepository _repository; // 👈 the contract, not Firebase

  Future<void> submit(String email, String password) async {
    emit(const LoginLoading());
    try {
      final user = await _repository.signIn(email: email, password: password);
      emit(LoginSuccess(user));
    } on AuthFailure catch (failure) {
      emit(LoginError(failure));
    }
  }
}
// packages/auth_feature/lib/src/login_page.dart
import 'package:auth_contract/auth_contract.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

import 'login_cubit.dart';

class LoginPage extends StatelessWidget {
  const LoginPage({super.key});

  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (context) => LoginCubit(context.read<AuthRepository>()),
      child: const _LoginView(),
    );
  }
}

class _LoginView extends StatelessWidget {
  const _LoginView();

  String _messageFor(AuthFailure failure) => switch (failure) {
        InvalidCredentials() => 'That email or password is incorrect.',
        NetworkUnavailable() => 'Check your connection and try again.',
        UnknownAuthFailure(:final message) => message,
      };

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: BlocBuilder<LoginCubit, LoginState>(
        builder: (context, state) => AppFormLayout(
          title: 'Welcome back',
          isLoading: state is LoginLoading,
          errorText: state is LoginError ? _messageFor(state.failure) : null,
          onSubmit: (email, password) =>
              context.read<LoginCubit>().submit(email, password),
        ),
      ),
    );
  }
}

The switch in _messageFor is exhaustive. If someone adds AccountLocked to the contract, this file stops compiling until the UI handles it. The studs keep both sides honest.

AppFormLayout comes from the design_system brick, so features never hand-roll buttons or spacing. Visual consistency comes from the bricks too.


The Instruction Manual: One Place to Wire Everything

The baseplate is the only place that knows which concrete bricks are in use:

// lib/bootstrap.dart
import 'package:auth_contract/auth_contract.dart';
import 'package:auth_firebase/auth_firebase.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:products_api/products_api.dart';
import 'package:products_contract/products_contract.dart';

import 'app.dart';

Widget bootstrap() {
  return MultiRepositoryProvider(
    providers: [
      RepositoryProvider<AuthRepository>(
        create: (_) => FirebaseAuthRepository(),
      ),
      RepositoryProvider<ProductsRepository>(
        create: (_) => HttpProductsRepository(baseUrl: Env.apiUrl),
      ),
    ],
    child: const App(),
  );
}

Need a demo build that runs with no backend? Swap the bricks:

RepositoryProvider<AuthRepository>(
  create: (_) => FakeAuthRepository(), // always signs in as "demo@example.com"
),

Same features, same screens, different bricks underneath. Flavours, offline demos, and staging environments all come down to choosing different bricks from the same box.


Enforcing the Rules (Because Humans Forget)

An architecture that depends on everyone remembering the rules will slowly decay. Here's how I keep the bricks honest:

1. Let pubspec.yaml be the gatekeeper. A feature brick literally cannot import Firebase if firebase_auth isn't in its dependencies. This is the biggest win of real packages over plain folders: the dependency graph is enforced by the toolchain, not by code review.

# packages/auth_feature/pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  auth_contract:
  design_system:
  flutter_bloc: ^9.0.0
  # ❌ no auth_firebase, no firebase_auth — by design

2. Keep contracts pure Dart. If a *_contract package's pubspec has flutter: in it, something has gone wrong. A quick CI check makes this a hard rule.

3. Check the dependency graph in CI. A small script (or Melos's list --graph) can fail the build if a *_feature package ever depends on a *_api / *_firebase package.


Testing: Every Brick on Its Own

This is where the approach really earns its keep. Because each feature depends only on interfaces, tests need no Firebase emulators and no network mocking libraries. A plain fake is enough:

// packages/auth_feature/test/login_cubit_test.dart
import 'package:auth_contract/auth_contract.dart';
import 'package:auth_feature/src/login_cubit.dart';
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';

class _FakeAuthRepository implements AuthRepository {
  _FakeAuthRepository({this.failure});
  final AuthFailure? failure;

  @override
  Future<AppUser> signIn({
    required String email,
    required String password,
  }) async {
    if (failure != null) throw failure!;
    return AppUser(id: '1', email: email);
  }

  @override
  Stream<AppUser?> get authStateChanges => const Stream.empty();

  @override
  Future<void> signOut() async {}
}

void main() {
  blocTest<LoginCubit, LoginState>(
    'emits [Loading, Success] when credentials are valid',
    build: () => LoginCubit(_FakeAuthRepository()),
    act: (cubit) => cubit.submit('me@example.com', 'secret'),
    expect: () => [isA<LoginLoading>(), isA<LoginSuccess>()],
  );

  blocTest<LoginCubit, LoginState>(
    'emits [Loading, Error] on invalid credentials',
    build: () => LoginCubit(
      _FakeAuthRepository(failure: const InvalidCredentials()),
    ),
    act: (cubit) => cubit.submit('me@example.com', 'wrong'),
    expect: () => [isA<LoginLoading>(), isA<LoginError>()],
  );
}

Each brick has its own test/ folder and can run in isolation, so CI can test only the packages that changed. On a large app that turns a 12-minute pipeline into a 2-minute one.


What I Gained

After moving a mid-sized production app to this structure, the improvements I noticed most were:

  • Faster onboarding. New developers can own a single brick without understanding the whole app.
  • Fearless refactors. Replacing a REST client with GraphQL touched one package and zero screens.
  • Parallel work. Two developers on two features almost never hit merge conflicts, because they're in different packages.
  • Reuse across apps. The design_system and auth_* bricks ended up in a second app, unchanged.
  • Real boundaries. "Please don't import that" stopped being a code-review comment because the compiler says it for me.

The Honest Trade-offs

LEGO architecture isn't free, and I'd be doing you a disservice if I pretended otherwise.

  • More boilerplate. Contracts, implementations, barrel files, and pubspecs add up. For a feature with one screen, it can feel like ceremony.
  • Tooling overhead. You'll want Melos or pub workspaces, plus shared lint and CI configuration.
  • Over-bricking is real. Not every widget needs its own package. If two bricks always change together, they probably belong together.
  • Navigation across bricks needs a plan. Features shouldn't import each other's pages, so you'll need a routing contract (for example, typed route definitions in a shared package, with go_router wiring at the baseplate).

My rule of thumb: for a solo side project or a prototype, start with a well-organised feature-first folder structure inside lib/ using the same contract/implementation split. When the team grows or a second app appears, promote those folders to packages. Because the boundaries already exist, that move is mostly mechanical.


Wrapping Up

LEGO bricks have been snapping together since 1958 because the connector never changed. That's the real lesson for software: the stability of your interfaces matters more than the cleverness of your implementations.

Design good studs, keep each brick focused on one job, let the baseplate decide what goes where, and your Flutter app can keep growing without falling apart.

If you're trying this in your own project, start small: pull one feature's repository behind an interface, move it into a package, and see how it feels. You can add more bricks from there. 🧱


Thanks for reading! If you have questions or want to share how you structure your Flutter apps, feel free to reach out.

Written by

Niluka Bandara

Lead Mobile Developer in Stockholm, building Flutter apps for fintech and payments.