How to Implement LEGO Architecture in Flutter [Full Handbook] — Opportunihub
Course Remote

How to Implement LEGO Architecture in Flutter [Full Handbook]

Atuoha Anthony · Remote

At a glance

Type
Course
Organisation
Atuoha Anthony
Location
Remote
Work mode
Remote
Deadline
Rolling / not stated
Posted
11 Sep 2026

About this course

<p>Almost everyone has snapped two LEGO bricks together at some point, even without owning a single set as an adult. You press one brick down onto another, feel it click, and it holds.</p> <p>You likely never once thought about how the brick was molded, what plastic it used, or which factory it came from. You only cared about one thing in that moment: did the studs match?</p> <p>That small, ordinary moment is the entire idea behind this handbook. Now step away from LEGO for a second and picture a Flutter project instead. Somewhere in that project is a screen everyone on the team is secretly afraid to open. It fetches data, formats it, validates it, and renders it, all inside one enormous <code>build()</code> method.</p> <p>The thing is: it works. Nobody wants to touch it. A change to the checkout flow means scrolling past three unrelated concerns just to find the one line that needs editing.</p> <p>The difference between those two experiences (the satisfying click of a LEGO brick and the dread of opening that one file) comes down to a single habit. LEGO bricks are built so that nothing needs to understand anything else's insides, only its connection points. But most code isn't built that way by default.</p> <p>"LEGO Architecture" is simply the decision to build code the way LEGO builds bricks. And this handbook is going to teach you that habit slowly, starting from something almost too small to call architecture at all, and building up, piece by piece, until it can hold together an entire app.</p> <p>Along the way we'll also look at Clean Architecture, a specific, well-known way of applying this same habit, and see exactly where the two meet.</p> <h2 id="heading-table-of-contents">Table of Contents</h2> <ul> <li><p><a href="#heading-prerequisites">Prerequisites</a></p> </li> <li><p><a href="#heading-what-lego-architecture-actually-means">What "LEGO Architecture" Actually Means</a></p> </li> <li><p><a href="#heading-lego-thinking-at-the-widget-level">LEGO Thinking at the Widget Level</a></p> </li> <li><p><a href="#heading-bricks-with-studs-contracts-instead-of-concrete-dependencies">Bricks With Studs: Contracts Instead of Concrete Dependencies</a></p> </li> <li><p><a href="#heading-lego-at-the-folder-level">LEGO at the Folder Level</a></p> </li> <li><p><a href="#heading-contracts-between-modules-repositories-and-service-locators">Contracts Between Modules: Repositories and Service Locators</a></p> </li> <li><p><a href="#heading-composing-whole-features-like-a-lego-set">Composing Whole Features Like a LEGO Set</a></p> </li> <li><p><a href="#heading-clean-architecture-crash-course">Clean Architecture Crash Course</a></p> </li> <li><p><a href="#heading-lego-architecture-compared-with-clean-architecture">LEGO Architecture Compared With Clean Architecture</a></p> </li> <li><p><a href="#heading-merging-both-in-a-modular-monorepo">Merging Both in a Modular Monorepo</a></p> <ul> <li><p><a href="#heading-starting-from-an-empty-folder">Starting From an Empty Folder</a></p> </li> <li><p><a href="#heading-giving-the-project-somewhere-for-native-code-to-live">Giving the Project Somewhere for Native Code to Live</a></p> </li> <li><p><a href="#heading-creating-the-first-brick">Creating the First Brick</a></p> </li> <li><p><a href="#heading-connecting-the-brick-to-the-app-with-a-path-dependency">Connecting the Brick to the App With a Path Dependency</a></p> </li> <li><p><a href="#heading-where-melosyaml-actually-comes-from">Where <code>melos.yaml</code> Actually Comes From</a></p> </li> <li><p><a href="#heading-what-melos-bootstrap-actually-does">What <code>melos bootstrap</code> Actually Does</a></p> </li> <li><p><a href="#heading-what-actually-belongs-in-appmains-lib-folder">What Actually Belongs in <code>appmain</code>'s lib Folder</a></p> </li> </ul> </li> <li><p><a href="#heading-swappable-state-management-bricks">Swappable State Management Bricks</a></p> </li> <li><p><a href="#heading-a-full-worked-example-products-lego-style-with-clean-layers-inside">A Full Worked Example: Products, LEGO Style, With Clean Layers Inside</a></p> </li> <li><p><a href="#heading-when-to-use-which-and-common-pitfalls">When to Use Which, and Common Pitfalls</a></p> </li> <li><p><a href="#heading-wrapping-up">Wrapping Up</a></p> </li> <li><p><a href="#heading-references">References</a></p> </li> </ul> <h2 id="heading-prerequisites">Prerequisites</h2> <p>You should be comfortable writing basic Flutter widgets and running a Flutter app, since the early sections build directly on <code>StatelessWidget</code> and ordinary widget composition.</p> <p>You should also understand Dart classes, constructors, and abstract classes, since contracts, the studs this whole handbook is built around, are just abstract classes and interfaces. Some familiarity with dependency injection or service locators is helpful but not required, since that idea is introduced from scratch when it first comes up.</p> <p>Later sections use <code>flutter_bloc</code>, <code>get_it</code>, <code>dio</code>, and <code>go_router</code> as example packages. You don't need to have used them before, since every import is explained the moment it appears.</p> <p>A working knowledge of what Clean Architecture is trying to achieve (keeping business logic independent of frameworks) is useful context too, though the handbook also includes a crash course for readers meeting it for the first time.</p> <p>No prior knowledge of monorepos is required either, since the section on merging LEGO Architecture with a modular monorepo builds that idea from an empty folder. But if you want a deeper, dedicated walkthrough of monorepo structure, Melos, and Dart Workspaces before getting there, reading <a href="https://www.freecodecamp.org/news/how-to-use-monorepos-in-flutter/">How to Use Monorepos in Flutter</a> first gives you useful background on why teams reach for a monorepo in the first place.</p> <h2 id="heading-what-lego-architecture-actually-means">What "LEGO Architecture" Actually Means</h2> <p>Go back to that LEGO brick for a moment, because it has exactly two things worth noticing about it. There's what the brick is, meaning its shape, its color, and its purpose. And there are its studs, the standardized connection points on top and the tubes underneath that let it snap onto any other brick following the same standard.</p> <p>Nobody needs to know how a brick was molded to click it onto another one. They only need the studs to match.</p> <p>That's the whole idea, and software can copy it almost exactly. The brick becomes a unit of your app, which could be a widget, a class, a service, or an entire feature. The studs become the contract that brick exposes to the outside world, which in code usually means an abstract class, an interface, or a well-defined function signature.</p> <p>Snapping two bricks together, in code, means one part of your app depends on another part only through that contract, and never by reaching in and relying on how the other part happens to be built underneath.</p> <img src="https://cdn.hashnode.com/uploads/covers/63a47b24490dd1c9cd9c32ff/bc967d8d-de6e-44ed-9093-a5cab4f7952e.png" alt="Diagram showing two concrete implementations, Brick A and Brick B, connecting through dotted arrows to a shared contract, represented as an abstract class or interface." style="display: block;" width="600" height="400" loading="lazy"> <p>Notice that both bricks touch the world only through the contract sitting between them. Neither one ever needs to know which concrete brick is plugged in on the other side. That single habit of reaching for the contract instead of the concrete thing is the whole engine behind everything that follows in this handbook. You'll meet it again and again, first in a single widget, then in a class, then in a whole feature, and eventually in an entire package.</p> <p>There's one more thing worth internalizing before any code appears. A brick that's doing its job well should make sense on its own, without forcing you to open several other files first. You should be able to swap what's plugged into it without its neighbors ever noticing. And it should never show its neighbors how it does something, only what it does.</p> <p>Keep those three feelings in mind. Every example from here on is really just those three feelings, expressed as Dart.</p> <h2 id="heading-lego-thinking-at-the-widget-level">LEGO Thinking at the Widget Level</h2> <p>Here's a secret: you've already been doing a small version of this, possibly without naming it. Look at this line, which you've almost certainly written before:</p> <pre><code class="language-dart">Padding( padding: const EdgeInsets.all(8), child: const Text('Hello'), ) </code></pre> <p><code>Padding</code> does exactly one thing, and it doesn't care in the slightest what you hand it as a <code>child</code>. It could be <code>Text</code>, an <code>Image</code>, a <code>Column</code>, or anything else. That's a brick and a stud, hiding in plain sight. <code>Padding</code> is the brick. Its <code>child</code> parameter is the stud, because any widget that fits through that door is welcome. You never taught <code>Padding</code> how to render text or images. It never needed to know.</p> <p>Now watch what happens the moment that habit is dropped, using something small enough to hold in your head all at once. Say you need a little rounded, shaded box to show a price.</p> <pre><code class="language-dart">class PriceTag extends StatelessWidget { final double price; const PriceTag({super.key, required this.price}); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(6), ), child: Text('\$${price.toStringAsFixed(2)}'), ); } } </code></pre> <p>This is a perfectly acceptable, perfectly small widget, and there's nothing broken about it. But look closely at what it's actually doing. It's deciding two unrelated things at once inside the same class: what the box around the content should look like, and what the content itself is.</p> <p>The moment you need that same rounded, shaded box around something that's not a price, say a small label reading "Sale", you're stuck. You either copy the <code>Container</code> and its decoration into a new widget, or you reach for <code>extends</code> and start building a small class hierarchy just to reuse six lines of styling.</p> <p>Both of those are the tight coupling this whole handbook is trying to talk you out of.</p> <p>The fix is the same one <code>Padding</code> already showed you. Pull the box out on its own, and let it accept any child at all.</p> <pre><code class="language-dart">class SurfaceCard extends StatelessWidget { final Widget child; const SurfaceCard({super.key, required this.child}); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(6), ), child: child, ); } } </code></pre> <p><code>SurfaceCard</code> now knows only one thing: how to look like a small rounded, shaded box. It also has one stud, its <code>child</code>, exactly the same shape as <code>Padding</code>'s. <code>PriceTag</code> shrinks down to almost nothing, because it no longer needs to know how to draw a box at all.</p> <pre><code class="language-dart">class PriceTag extends StatelessWidget { final double price; const PriceTag({super.key, required this.price}); @override Widget build(BuildContext context) { return SurfaceCard(child: Text('\$${price.toStringAsFixed(2)}')); } } </code></pre> <p>That single change is the entire lesson of this section. <code>SurfaceCard</code> can now sit behind a "Sale" label, a small avatar, a rating badge, or anything else, and it will never need to be touched again. This is because it was never taught to care what its child looks like.</p> <p>The test for whether a brick like this is genuinely well-built is simple: can you reuse it somewhere brand new without copying a single line out of it? If yes, its studs are doing their job.</p> <p>Once that clicks, the same habit scales up without changing shape at all, just size. A product card in a shopping app is really the same idea, with a slightly bigger child.</p> <pre><code class="language-dart">class ProductThumbnail extends StatelessWidget { final String imageUrl; const ProductThumbnail({super.key, required this.imageUrl}); @override Widget build(BuildContext context) { return ClipRRect( borderRadius: BorderRadius.circular(6), child: Image.network(imageUrl, height: 120, fit: BoxFit.cover), ); } } class ProductCard extends StatelessWidget { final String name; final double price; final String imageUrl; const ProductCard({ super.key, required this.name, required this.price, required this.imageUrl, }); @override Widget build(BuildContext context) { return SurfaceCard( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ ProductThumbnail(imageUrl: imageUrl), Text(name, style: const TextStyle(fontWeight: FontWeight.bold)), Text('\$${price.toStringAsFixed(2)}'), ], ), ); } } </code></pre> <p>Nothing new happened here conceptually. <code>ProductThumbnail</code> is its own small brick, responsible only for loading and clipping an image. So if you later switch from <code>Image.network</code> to a caching image package, exactly one file changes, and nothing that uses it even notices.</p> <p><code>ProductCard</code> isn't really building anything itself anymore. It's arranging bricks that already exist (<code>SurfaceCard</code> for the box and <code>ProductThumbnail</code> for the picture) the same way you would snap two pieces from different bins into one small model.</p> <p>Every import across all three widgets is still the plain <code>package:flutter/material.dart</code>. No new package was needed to get here, because LEGO thinking at this level isn't a library, it's a decision about where you draw the line between a box and what goes inside it.</p> <h2 id="heading-bricks-with-studs-contracts-instead-of-concrete-dependencies">Bricks With Studs: Contracts Instead of Concrete Dependencies</h2> <p>Composition alone gets you reusable UI, but it doesn't yet get you swappable behavior. For that you need an explicit contract, usually an abstract class or a function type, that sits between a brick and whatever it depends on.</p> <p>Suppose <code>ProductCard</code> needs to react to a tap by adding a product to the cart, but you don't want the card itself to know whether that means calling a REST API, writing to local storage, or just printing to the console during a demo.</p> <pre><code class="language-dart">abstract class CartWriter { Future&lt;void&gt; add(String productId); } class ApiCartWriter implements CartWriter { final Dio client; ApiCartWriter(this.client); @override Future&lt;void&gt; add(String productId) async { await client.post('/cart/items', data: {'productId': productId}); } } class InMemoryCartWriter implements CartWriter { final List&lt;String&gt; items = []; @override Future&lt;void&gt; add(String productId) async { items.add(productId); } } </code></pre> <p>And the widget only ever talks to the contract.</p> <pre><code class="language-dart">class AddToCartButton extends StatelessWidget { final String productId; final CartWriter cartWriter; const AddToCartButton({ super.key, required this.productId, required this.cartWriter, }); @override Widget build(BuildContext context) { return ElevatedButton( onPressed: () =&gt; cartWriter.add(productId), child: const Text('Add to cart'), ); } } </code></pre> <p><code>abstract class CartWriter</code> is the stud. It declares exactly one capability, <code>add(String productId)</code>, and says nothing about how it's implemented. This is the contract not concretion rule from the previous section, made literal in code.</p> <p><code>ApiCartWriter</code> is one brick that satisfies the contract using <code>Dio</code>, a popular HTTP client package that would be brought in with <code>import 'package:dio/dio.dart';</code> at the top of this file in a real project. It owns all networking detail, so nothing outside this class needs to know the endpoint URL or the request shape. <code>InMemoryCartWriter</code> is a second brick satisfying the same contract. It's useful for tests, previews, or offline demos, and it has zero dependencies of its own: no Dio, and no network.</p> <p><code>AddToCartButton</code> takes a <code>CartWriter</code> through its constructor rather than instantiating one itself. This is called dependency injection, and it's the mechanism that makes contracts actually useful, since the widget is handed a brick from outside instead of building its own.</p> <p>This is the payoff worth pausing on: you can now write a widget test that passes <code>InMemoryCartWriter</code> and asserts that <code>cartWriter.items</code> contains the right product, with no mocking framework and no network stub required.</p> <img src="https://cdn.hashnode.com/uploads/covers/63a47b24490dd1c9cd9c32ff/d60c5ce5-212b-4465-a4f5-64be543d997d.png" alt="Architecture diagram showing AddToCartButton depending on the CartWriter abstract class, which acts as the shared contract and connects to ApiCartWriter for network operations and InMemoryCartWriter for in-memory testing." style="display: block;" width="600" height="400" loading="lazy"> <h2 id="heading-lego-at-the-folder-level">LEGO at the Folder Level</h2> <p>Once you accept that individual classes should snap together through contracts, the same logic applies to how you organize folders.</p> <p>A common early mistake is organizing by type, with a <code>screens</code> folder, a <code>widgets</code> folder, and a <code>services</code> folder sitting side by side. This looks tidy, but it's the opposite of LEGO thinking. To understand or change the cart feature, you have to jump between three unrelated folders, and nothing stops a cart service file from quietly importing something from a product screen file. Nothing is actually self-contained.</p> <p>The LEGO-friendly version organizes by feature instead. Each feature is its own brick, containing everything it needs, and only exposing what other features are allowed to touch.</p> <pre><code class="language-plaintext">lib/ features/ product/ product.dart &lt;- "barrel" file: the public stud src/ widgets/ product_card.dart product_thumbnail.dart services/ cart_writer.dart models/ product.dart cart/ cart.dart src/ widgets/ cart_item.dart services/ cart_repository.dart core/ theme/ routing/ network/ </code></pre> <p>The key file here is <code>product.dart</code>, a barrel file that exports only what other features are meant to use.</p> <pre><code class="language-dart">// lib/features/product/product.dart library product; export 'src/widgets/product_card.dart'; export 'src/models/product.dart'; // note: cart_writer.dart is intentionally NOT exported. // it's an internal implementation detail of this feature. </code></pre> <p>The <code>library product;</code> line names this file as the entry point of the product package within your app, which is a convention rather than a hard boundary by itself. The <code>export</code> statements re-export selected files, so anything not listed here, such as <code>cart_writer.dart</code>, stays private to the feature. Other features that write <code>import 'package:app/features/product/product.dart';</code> simply can't see it.</p> <p>This mirrors the real LEGO idea exactly, since <code>src/</code> is the inside of the brick, the molded plastic, and the barrel file is the studs: the only surface other bricks are allowed to touch.</p> <p>You can enforce this boundary for real using Dart's <code>analysis_options.yaml</code> alongside import linting packages, or simply through code review discipline: no file inside <code>features/cart/src/</code> should ever import a <code>src/</code> file from <code>features/product/</code>. If cart genuinely needs something from product, it imports the barrel file <code>product.dart</code>, never the internals directly.</p> <img src="https://cdn.hashnode.com/uploads/covers/63a47b24490dd1c9cd9c32ff/f8b46a18-d356-4c47-beba-f9525ade82da.png" alt="Architecture diagram showing how contains private internals accessed through the barrel file, which exposes the feature’s public API while hiding its internal implementation details." style="display: block;" width="600" height="400" loading="lazy"> <h2 id="heading-contracts-between-modules-repositories-and-service-locators">Contracts Between Modules: Repositories and Service Locators</h2> <p>Folder boundaries stop other features from importing your internals, but real apps also need to inject implementations across those boundaries. For example, the cart feature needs something that can fetch product prices, without depending on the product feature's concrete service class. This is where the repository pattern and a service locator come in.</p> <p>First, the contract lives in a shared, neutral place, not inside either feature.</p> <pre><code class="language-dart">// lib/core/contracts/product_lookup.dart abstract class ProductLookup { Future&lt;double&gt; priceOf(String productId); } </code></pre> <p>The product feature provides the real implementation.</p> <pre><code class="language-dart">// lib/features/product/src/services/product_repository.dart import 'package:app/core/contracts/product_lookup.dart'; class ProductRepository implements ProductLookup { final Map&lt;String, double&gt; _cachedPrices; ProductRepository(this._cachedPrices); @override Future&lt;double&gt; priceOf(String productId) async { return _cachedPrices[productId] ?? 0; } } </code></pre> <p>The cart feature only ever depends on <code>ProductLookup</code>, and the real brick gets wired in through a service locator, a registry that hands out configured instances by contract type. <code>get_it</code> is the standard package for this.</p> <pre><code class="language-dart">// lib/core/di/service_locator.dart import 'package:get_it/get_it.dart'; import 'package:app/core/contracts/product_lookup.dart'; import 'package:app/features/product/src/services/product_repository.dart'; final getIt = GetIt.instance; void setupServiceLocator() { getIt.registerLazySingleton&lt;ProductLookup&gt;( () =&gt; ProductRepository({'p1': 19.99, 'p2': 4.50}), ); } </code></pre> <pre><code class="language-dart">// lib/features/cart/src/services/cart_calculator.dart import 'package:app/core/contracts/product_lookup.dart'; import 'package:app/core/di/service_locator.dart'; class CartCalculator { final ProductLookup _productLookup; CartCalculator({ProductLookup? productLookup}) : _productLookup = productLookup ?? getIt&lt;ProductLookup&gt;(); Future&lt;double&gt; total(List&lt;String&gt; productIds) async { double sum = 0; for (final id in productIds) { sum += await _productLookup.priceOf(id); } return sum; } } </code></pre> <p>The line <code>import 'package:get_it/get_it.dart';</code> brings in the service locator package, and <code>GetIt.instance</code> gives you a single global registry (a singleton) that the whole app shares.</p> <p>The call <code>registerLazySingleton&lt;ProductLookup&gt;(...)</code> tells the locator that, when someone asks for a <code>ProductLookup</code>, it should hand them this one instance of <code>ProductRepository</code>. It should build it only the first time it's requested.</p> <p>The generic type parameter is what matters here, since the registry is keyed by the contract, not by <code>ProductRepository</code>. That's the enforcement mechanism behind depending on contracts.</p> <p><code>setupServiceLocator()</code> is called once, typically in <code>main()</code>, before <code>runApp()</code>, and this becomes your app's single assembly point – the one place allowed to know about every concrete brick.</p> <p><code>CartCalculator</code>'s constructor accepts an optional <code>ProductLookup</code>, defaulting to whatever the locator provides. This optional parameter trick is what makes the class trivially testable, since a test passes in a fake <code>ProductLookup</code> while production lets it resolve from <code>getIt</code>.</p> <p>Notice that <code>cart_calculator.dart</code> never imports anything from <code>features/product/src/</code>. It only imports the shared contract and the locator. The product feature could be rewritten from scratch, swapping the in-memory map for a real backend call. <code>cart_calculator.dart</code> wouldn't need a single edited line, as long as <code>ProductRepository</code> still implemented <code>ProductLookup</code>.</p> <p>This is LEGO Architecture's most important trick at scale. The contract lives in neutral territory inside <code>core/contracts/</code>, the concrete brick lives inside the feature that owns it, and a single wiring point (the service locator) is the only place that ever imports both sides.</p> <h2 id="heading-composing-whole-features-like-a-lego-set">Composing Whole Features Like a LEGO Set</h2> <p>The final level before comparing against Clean Architecture is treating entire features as pluggable modules that the app shell assembles at startup. This happens the same way a LEGO instruction booklet tells you which sub-assemblies snap onto the base plate.</p> <pre><code class="language-dart">// lib/core/feature_module.dart import 'package:go_router/go_router.dart'; abstract class FeatureModule { List&lt;RouteBase&gt; get routes; void registerDependencies(); } </code></pre> <pre><code class="language-dart">// lib/features/cart/cart_module.dart import 'package:go_router/go_router.dart'; import 'package:app/core/feature_module.dart'; import 'package:app/core/di/service_locator.dart'; import 'src/screens/cart_screen.dart'; import 'src/services/cart_calculator.dart'; class CartModule implements FeatureModule { @override void registerDependencies() { getIt.registerFactory&lt;CartCalculator&gt;(() =&gt; CartCalculator()); } @override List&lt;RouteBase&gt; get routes =&gt; [ GoRoute(path: '/cart', builder: (context, state) =&gt; const CartScreen()), ]; } </code></pre> <pre><code class="language-dart">// lib/app.dart import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'features/cart/cart_module.dart'; import 'features/product/product_module.dart'; import 'core/feature_module.dart'; final List&lt;FeatureModule&gt; modules = [ ProductModule(), CartModule(), ]; GoRouter buildRouter() { for (final module in modules) { module.registerDependencies(); } return GoRouter( routes: modules.expand((m) =&gt; m.routes).toList(), ); } class App extends StatelessWidget { const App({super.key}); @override Widget build(BuildContext context) { return MaterialApp.router(routerConfig: buildRouter()); } } </code></pre> <p><code>FeatureModule</code> is the highest level stud in the app. Any feature that wants to plug into the shell must provide <code>routes</code>, meaning the screens it exposes, and <code>registerDependencies()</code>, meaning what it needs wired into the service locator.</p> <p><code>CartModule</code> implements that contract, and inside <code>registerDependencies()</code> it registers <code>CartCalculator</code> as a factory. This is a new instance every time it's requested, unlike the singleton <code>ProductRepository</code> from the previous section. The registration style is a decision each feature makes for itself.</p> <p>The import <code>package:go_router/go_router.dart</code> brings in the <code>go_router</code> package. This turns <code>RouteBase</code> objects into a working navigation stack, and <code>GoRoute(path: ..., builder: ...)</code> maps a URL-like path to a screen. <code>app.dart</code> is the true composition root of the entire application. The <code>modules</code> list is the instruction booklet, and it's the only file in the whole app that knows every feature exists. It loops through each module, lets it register its own dependencies, and flattens all their routes into one <code>GoRouter</code>.</p> <p>To add a whole new feature to the app, you write one new <code>FeatureModule</code> implementation and add one line to the <code>modules</code> list, and no existing feature file is touched. That's the LEGO promise fully realized: adding a new brick to the set never requires re-molding the bricks already in the box.</p> <img src="https://cdn.hashnode.com/uploads/covers/63a47b24490dd1c9cd9c32ff/66074b06-4127-4ae1-bbc3-486b89923d9b.png" alt="Architecture diagram showing as the application entry point that registers dependencies and assembles the router, while collecting routes from self-contained, and future feature modules that can be plugged in independently." style="display: block;" width="600" height="400" loading="lazy"> <p>That's LEGO Architecture from the ground up. Widgets compose, classes depend on contracts, folders enforce boundaries, contracts cross module lines through a locator, and whole features snap into the app shell through a <code>FeatureModule</code> contract. Now let's look at Clean Architecture, so we can compare the two on equal footing.</p> <h2 id="heading-clean-architecture-crash-course">Clean Architecture Crash Course</h2> <p>Clean Architecture, as popularized by Robert C. Martin, is a specific layering scheme built around one rule, known as the Dependency Rule: source code dependencies can only point inward, toward higher level policy. Nothing in an inner layer can know anything about an outer layer.</p> <img src="https://cdn.hashnode.com/uploads/covers/63a47b24490dd1c9cd9c32ff/24cc55e9-0a72-4b3e-b3e0-b4fe89d5d98c.png" alt="Architecture diagram showing the Presentation and Data layers pointing inward to depend on and implement the central Domain Layer, demonstrating the core dependency rule." style="display: block;" width="600" height="400" loading="lazy"> <p>Let's build a single feature (getting a product by id) through all three layers, starting with the domain layer and its entity: a plain, framework free object.</p> <pre><code class="language-dart">// lib/features/product/domain/entities/product.dart class Product { final String id; final String name; final double price; const Product({required this.id, required this.name, required this.price}); } </code></pre> <p>Next comes the domain layer's repository port, an interface the domain defines but doesn't implement.</p> <pre><code class="language-dart">// lib/features/product/domain/repositories/product_repository.dart import '../entities/product.dart'; abstract class ProductRepository { Future&lt;Product&gt; getById(String id); } </code></pre> <p>Then the domain layer's use case: a single, named business action.</p> <pre><code class="language-dart">// lib/features/product/domain/usecases/get_product.dart import '../entities/product.dart'; import '../repositories/product_repository.dart'; class GetProduct { final ProductRepository repository; GetProduct(this.repository); Future&lt;Product&gt; call(String id) =&gt; repository.getById(id); } </code></pre> <p>Now the data layer, starting with the repository implementation that satisfies the domain's port.</p> <pre><code class="language-dart">// lib/features/product/data/repositories/product_repository_impl.dart import 'package:app/features/product/domain/entities/product.dart'; import 'package:app/features/product/domain/repositories/product_repository.dart'; import '../datasources/product_remote_data_source.dart'; class ProductRepositoryImpl implements ProductRepository { final ProductRemoteDataSource remoteDataSource; ProductRepositoryImpl(this.remoteDataSource); @override Future&lt;Product&gt; getById(String id) async { final dto = await remoteDataSource.fetchProduct(id); return Product(id: dto.id, name: dto.name, price: dto.price); } } </code></pre> <p>And the remote data source, which owns the actual HTTP call and the raw JSON shape.</p> <pre><code class="language-dart">// lib/features/product/data/datasources/product_remote_data_source.dart import 'package:dio/dio.dart'; class ProductDto { final String id; final String name; final double price; ProductDto({required this.id, required this.name, required this.price}); factory ProductDto.fromJson(Map&lt;String, dynamic&gt; json) =&gt; ProductDto( id: json['id'], name: json['name'], price: (json['price'] as num).toDouble(), ); } class ProductRemoteDataSource { final Dio client; ProductRemoteDataSource(this.client); Future&lt;ProductDto&gt; fetchProduct(String id) async { final response = await client.get('/products/$id'); return ProductDto.fromJson(response.data); } } </code></pre> <p>Finally, the presentation layer: a Cubit that calls the use case.</p> <pre><code class="language-dart">// lib/features/product/presentation/cubit/product_cubit.dart import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:app/features/product/domain/entities/product.dart'; import 'package:app/features/product/domain/usecases/get_product.dart'; sealed class ProductState {} class ProductLoading extends ProductState {} class ProductLoaded extends ProductState { final Product product; ProductLoaded(this.product); } class ProductError extends ProductState { final String message; ProductError(this.message); } class ProductCubit extends Cubit&lt;ProductState&gt; { final GetProduct getProduct; ProductCubit(this.getProduct) : super(ProductLoading()); Future&lt;void&gt; load(String id) async { emit(ProductLoading()); try { final product = await getProduct(id); emit(ProductLoaded(product)); } catch (e) { emit(ProductError(e.toString())); } } } </code></pre> <p>Let's walk through each layer in order.</p> <p>First, <code>entities/product.dart</code> has zero imports. That's intentional, since it's the single most important rule of the domain layer: it can't import Flutter, Dio, or any framework. It's pure Dart, so it could be reused in a command line tool or a backend without modification.</p> <p><code>repositories/product_repository.dart</code> is an abstract class, the port. The domain layer defines what it needs, <code>getById</code>, but never how it's fetched. This is identical in spirit to <code>CartWriter</code> and <code>ProductLookup</code> from earlier sections. After all, Clean Architecture didn't invent dependency inversion, it just applies it systematically at every seam.</p> <p><code>usecases/get_product.dart</code> wraps one business action, and <code>GetProduct</code> implements <code>call(String id)</code>, which lets you invoke an instance like a function, <code>getProduct('p1')</code>. Its constructor takes a <code>ProductRepository</code> (again the abstract port), never the concrete <code>ProductRepositoryImpl</code>.</p> <p><code>data/datasources/product_remote_data_source.dart</code> owns <code>import 'package:dio/dio.dart';</code> and all knowledge of the wire format through <code>ProductDto.fromJson</code>. This is the only file in the whole feature allowed to know what the raw JSON from the server looks like.</p> <p><code>data/repositories/product_repository_impl.dart</code> implements the domain's port and translates between shapes, taking a <code>ProductDto</code> (the data layer shape) and returning a <code>Product</code> (the domain layer shape). This translation step is what lets the domain layer stay ignorant of JSON entirely.</p> <p><code>presentation/cubit/product_cubit.dart</code> imports <code>package:flutter_bloc/flutter_bloc.dart</code> for <code>Cubit</code>, plus the domain's <code>GetProduct</code> and <code>Product</code>, but never anything from <code>data/</code>. The <code>sealed class ProductState</code> with its three subclasses (<code>ProductLoading</code>, <code>ProductLoaded</code>, and <code>ProductError</code>) models every possible UI state explicitly, so the widget layer can switch over them without guessing.</p> <p>Wiring it together is Clean Architecture's version of the composition root introduced earlier.</p> <pre><code class="language-dart">// lib/features/product/product_injection.dart import 'package:dio/dio.dart'; import 'package:get_it/get_it.dart'; import 'domain/repositories/product_repository.dart'; import 'domain/usecases/get_product.dart'; import 'data/datasources/product_remote_data_source.dart'; import 'data/repositories/product_repository_impl.dart'; void registerProductFeature(GetIt getIt) { getIt.registerLazySingleton(() =&gt; Dio()); getIt.registerLazySingleton(() =&gt; ProductRemoteDataSource(getIt&lt;Dio&gt;())); getIt.registerLazySingleton&lt;ProductRepository&gt;( () =&gt; ProductRepositoryImpl(getIt&lt;ProductRemoteDataSource&gt;()), ); getIt.registerFactory(() =&gt; GetProduct(getIt&lt;ProductRepository&gt;())); } </code></pre> <p>This file is the only place in the entire feature that sees every layer at once: domain, data, and the concrete <code>Dio</code> client. This is exactly the same responsibility that <code>service_locator.dart</code> and <code>CartModule</code> held in the earlier LEGO examples.</p> <h2 id="heading-lego-architecture-compared-with-clean-architecture">LEGO Architecture Compared With Clean Architecture</h2> <p>At this point the resemblance between these two architectures should be pretty clear: both are built on dependency inversion, depending on contracts rather than concretions, and both use a single wiring point to assemble concrete pieces.</p> <p>The difference is what each one is optimized to answer.</p> <p>LEGO Architecture is best described as a mindset or philosophy about composability and boundaries. You get to choose the unit of composition, whether that's a widget, a service, or a whole feature module.</p> <p>Boundaries live wherever you decide to put them: in folders, barrel files, or module contracts. You also choose how small or large a brick should be. The primary goal is interchangeability, so that any piece can be swapped without breaking its neighbors.</p> <p>LEGO architecture has a low learning curve to start, since the first level needs nothing new beyond Flutter itself, and it scales up gradually as you adopt more of its later levels. It carries as much or as little boilerplate as you choose to add.</p> <p>It works best for apps that need flexible feature boundaries, teams working in parallel, and incremental adoption. Its main risk is what might be called LEGO in name only, where bricks quietly reach into each other's internals despite the folder structure suggesting otherwise.</p> <p>Clean Architecture, in contrast, is a specific, named layering scheme with a fixed shape: presentation, domain, and data, with the Dependency Rule always pointing inward.</p> <p>Its units of composition are specifically entities, use cases, and repositories. Its primary goal is testability and independence from frameworks, UI, and databases, and it tends to be fairly fine-grained by default, with a prescribed structure repeated per feature.</p> <p>Its learning curve is steeper up front, since several files are needed per feature from day one, and it carries noticeably more boilerplate per feature, including an entity, a use case, two repository layers, a DTO, and a cubit or similar.</p> <p>It's best suited to apps with complex business rules that must stay independent of UI or framework churn. Its main risk is boilerplate for boilerplate's sake: building three layers for a feature that has no real business logic to protect.</p> <p>The most useful way to think about the relationship between the two is this: Clean Architecture is one very well-specified way to build LEGO bricks out of a single feature. Its entities, use cases, and repositories are themselves bricks with studs, interfaces, wired together through dependency injection. This is precisely the LEGO idea, just applied with a fixed, opinionated shape.</p> <p>You're not choosing LEGO <strong>or</strong> Clean Architecture. You're choosing how much of Clean Architecture's specific shape to apply within your LEGO bricks.</p> <h2 id="heading-merging-both-in-a-modular-monorepo">Merging Both in a Modular Monorepo</h2> <p>Everything up to this point has used one folder structure inside one Flutter project. Barrel files kept features from reaching into each other's internals, but that boundary was still just a convention. Nothing physically stopped a file inside <code>features/cart/</code> from importing a file inside <code>features/product/src/</code>, other than discipline and code review.</p> <p>At production scale, some teams remove that gap entirely by turning each feature into its own real Dart package, so the boundary is enforced by the package system itself rather than by discipline.</p> <p>This is often called a monorepo, and the tool most commonly used to manage it in Flutter is Melos. The rest of this section builds that setup from nothing, one small step at a time, so that nothing about the final folder tree feels like it appeared by magic.</p> <h3 id="heading-starting-from-an-empty-folder">Starting From an Empty Folder</h3> <p>Before any Flutter command runs, there's just a folder on your computer, with nothing Flutter-specific in it at all.</p> <pre><code class="language-bash">mkdir my_lego_project cd my_lego_project </code></pre> <p>At this point <code>my_lego_project</code> isn't a Flutter project. It has no <code>pubspec.yaml</code>, no <code>lib</code> folder, and no <code>android</code> folder. It's only a plain directory, the same as any folder you would create to hold documents. Everything that follows is built inside it, deliberately, one piece at a time.</p> <h3 id="heading-giving-the-project-somewhere-for-native-code-to-live">Giving the Project Somewhere for Native Code to Live</h3> <p>A phone still needs a real Android project and a real iOS project to run on. So the very first thing you'll create inside <code>my_lego_project</code> is one ordinary Flutter app, using the exact same command you've always used:</p> <pre><code class="language-bash">mkdir apps cd apps flutter create app_main </code></pre> <p><code>flutter create app_main</code> behaves exactly as it always has. It generates <code>android/</code>, <code>ios/</code>, <code>lib/main.dart</code>, and a <code>pubspec.yaml</code>, all inside <code>apps/app_main/</code>. Nothing about this step is LEGO-specific yet. The only decision made so far is where this ordinary app lives on disk: inside an <code>apps</code> folder rather than at the project root.</p> <pre><code class="language-plaintext">my_lego_project/ apps/ app_main/ android/ ios/ lib/ main.dart pubspec.yaml </code></pre> <p>This <code>app_main</code> folder is the only place in the whole project that will ever contain <code>android/</code> or <code>ios/</code>. Every other package created from here on will deliberately not have them.</p> <h3 id="heading-creating-the-first-brick">Creating the First Brick</h3> <p>Now step back out to the project root and create a second folder called <code>packages</code>, sitting next to <code>apps</code>.</p> <pre><code class="language-bash">cd ../.. mkdir packages cd packages </code></pre> <p>Inside <code>packages</code>, create your first feature – but this time pass a different flag to the same <code>flutter create</code> command.</p> <pre><code class="language-bash">flutter create --template=package feature_login </code></pre> <p>The only thing different from before is <code>--template=package</code>. Without it, <code>flutter create</code> assumes you want a runnable app and generates native folders. With it, Flutter generates a plain library (meaning it produces a <code>lib/</code> folder, a <code>test/</code> folder, and a <code>pubspec.yaml</code>) and it deliberately leaves out <code>android/</code>, <code>ios/</code>, and <code>web/</code>. This is because a package like this is never launched on its own. It only ever gets pulled into an app that does have those folders.</p> <pre><code class="language-plaintext">my_lego_project/ apps/ app_main/ (has native folders) packages/ feature_login/ lib/ test/ pubspec.yaml </code></pre> <p>At this exact moment, <code>feature_login</code> and <code>app_main</code> know nothing about each other. They're two unrelated folders that happen to sit near each other on disk.</p> <h3 id="heading-connecting-the-brick-to-the-app-with-a-path-dependency">Connecting the Brick to the App With a Path Dependency</h3> <p>To let <code>app_main</code> use code from <code>feature_login</code>, you add it as a dependency. You can do this the same way you would add any package from pub.dev, except you point at a local folder instead of a name and version.</p> <pre><code class="language-yaml"># apps/app_main/pubspec.yaml name: app_main description: The actual iOS and Android wrapper application. dependencies: flutter: sdk: flutter feature_login: path: ../../packages/feature_login </code></pre> <p>The line <code>path: ../../packages/feature_login</code> is a relative path from <code>app_main</code>'s own <code>pubspec.yaml</code> back up two folders and down into <code>feature_login</code>. This isn't a Melos feature and it's not a LEGO Architecture invention. It's a plain feature of Dart's package manager, the same <code>path:</code> dependency you would use to point at any local package.</p> <p>Once this is saved, running <code>flutter pub get</code> inside <code>apps/app_main</code> is enough for <code>lib/main.dart</code> in <code>app_main</code> to write <code>import 'package:feature_login/feature_login.dart';</code> and use whatever that package exposes.</p> <p>It's worth noticing that the whole setup already works at this point, with exactly two packages and zero mentions of Melos so far. We haven't introduced Melos yet because it's not what creates the boundary between packages. The boundary already exists, enforced by <code>pubspec.yaml</code> and the <code>path:</code> dependency. What Melos adds is convenience once this pattern is repeated across many packages, which is the next problem to solve.</p> <h3 id="heading-where-melosyaml-actually-comes-from">Where melos.yaml Actually Comes From</h3> <p><code>melos.yaml</code> isn't generated by any Flutter command, and no tool creates it for you automatically. You install a package, and you write this file yourself, by hand, as a plain text file at the very root of the project.</p> <p>First, install Melos itself as a global Dart tool, once, on your machine:</p> <pre><code class="language-bash">dart pub global activate melos </code></pre> <p>Then, at the root of <code>my_lego_project</code>, alongside the <code>apps</code> and <code>packages</code> folders, create a new file named <code>melos.yaml</code> and type the following into it:</p> <pre><code class="language-yaml">name: my_lego_project packages: - apps/** - packages/** </code></pre> <pre><code class="language-plaintext">my_lego_project/ melos.yaml apps/ app_main/ packages/ feature_login/ </code></pre> <p>The <code>packages:</code> list here uses glob patterns, meaning <code>apps/**</code> and <code>packages/**</code> tell Melos to look inside both folders and treat every subfolder it finds that contains a <code>pubspec.yaml</code> as one member of the monorepo. Nothing here is hidden or automatic. You're explicitly telling Melos where to search.</p> <h3 id="heading-what-melos-bootstrap-actually-does">What <code>melos bootstrap</code> Actually Does</h3> <p>With two packages, running <code>flutter pub get</code> once inside <code>app_main</code> and once inside <code>feature_login</code> isn't a burden. The value of Melos becomes clear once there are ten or twenty packages, each needing dependencies resolved and each depending on several others through local paths. Instead of visiting every folder by hand, you run one command from the project root:</p> <pre><code class="language-bash">melos bootstrap </code></pre> <p>This single command reads <code>melos.yaml</code>, finds every package under <code>apps/**</code> and <code>packages/**</code>, and runs the equivalent of <code>flutter pub get</code> across all of them at once, resolving every local <code>path:</code> dependency along the way. It's an orchestration tool sitting on top of a mechanism that already existed (the ordinary <code>pubspec.yaml</code> and <code>path:</code> dependency shown above) rather than a new mechanism of its own.</p> <p>The modularity itself comes from separate <code>pubspec.yaml</code> files and explicit path dependencies. Melos exists to make running commands across many of them fast and repeatable, and later, in a CI pipeline, to run tests only on the packages that actually changed.</p> <h3 id="heading-what-actually-belongs-in-appmains-lib-folder">What Actually Belongs in app_main's lib Folder</h3> <p>A natural question at this point is whether every feature really becomes its own package. After all, in ordinary Flutter development a package usually means something reusable like a date picker, not a whole login screen.</p> <p>In this pattern, yes, a whole feature such as login becomes its own package, including its screens, its state management, and its business logic. The reason is the same isolation goal that has driven every level of this handbook.</p> <p>If <code>feature_login</code> is its own package, a developer working inside <code>feature_home</code> can't accidentally import something from inside <code>feature_login</code>, because it was never declared as a dependency in <code>feature_home</code>'s own <code>pubspec.yaml</code>. The compiler refuses the import outright, rather than a reviewer having to catch it by eye.</p> <p>That raises a second question: if the screens, state management, and logic all live inside feature packages, what's left inside <code>app_main/lib</code>? The answer is that <code>app_main/lib</code> shrinks down to exactly three responsibilities.</p> <ol> <li><p>It holds <code>main.dart</code>, which boots the app and calls <code>runApp()</code>.</p> </li> <li><p>It holds the dependency injection setup. This means the composition root from earlier sections, where concrete implementations (such as a real network client) get created and handed to whichever feature packages need them.</p> </li> <li><p>And it holds the master router, since a feature package like <code>feature_login</code> deliberately doesn't know that <code>feature_home</code> exists. So only <code>app_main</code>, which depends on both, is in a position to navigate from one to the other.</p> </li> </ol> <p>Here is what that navigation glue looks like concretely, starting inside the feature package itself:</p> <pre><code class="language-dart">// packages/feature_login/lib/login_screen.dart abstract class LoginNavigationContract { void onLoginSuccess(); } class LoginScreen extends StatelessWidget { final LoginNavigationContract navigator; const LoginScreen({super.key, required this.navigator}); @override Widget build(BuildContext context) { return ElevatedButton( onPressed: () =&gt; navigator.onLoginSuccess(), child: const Text('Submit'), ); } } </code></pre> <p><code>feature_login</code> defines <code>LoginNavigationContract</code>, an abstract class with one method, <code>onLoginSuccess()</code>. <code>LoginScreen</code> accepts an implementation of it through its constructor rather than importing any other feature directly.</p> <p>This is the same contract pattern used throughout this handbook, applied at the package boundary instead of the class boundary. <code>feature_login</code> states what needs to happen next, without ever stating where "next" actually is.</p> <p><code>app_main</code> is the only package allowed to know that both <code>feature_login</code> and <code>feature_home</code> exist, so it's the one that answers that question.</p> <pre><code class="language-dart">// apps/app_main/lib/app_navigator.dart import 'package:feature_login/feature_login.dart'; import 'package:feature_home/feature_home.dart'; import 'package:flutter/material.dart'; class AppNavigator implements LoginNavigationContract { final BuildContext context; AppNavigator(this.context); @override void onLoginSuccess() { Navigator.push(context, MaterialPageRoute(builder: (_) =&gt; const HomeScreen())); } } </code></pre> <p><code>AppNavigator</code> implements <code>LoginNavigationContract</code> and is the only place that imports both <code>feature_login</code> and <code>feature_home</code> at once. When <code>onLoginSuccess()</code> fires, it pushes <code>HomeScreen</code>, a widget that lives inside <code>feature_home</code>. Wiring it into the running app happens back in <code>main.dart</code>.</p> <pre><code class="language-dart">// apps/app_main/lib/main.dart import 'package:flutter/material.dart'; import 'package:feature_login/feature_login.dart'; import 'app_navigator.dart'; void main() =&gt; runApp(const App()); class App extends StatelessWidget { const App({super.key}); @override Widget build(BuildContext context) { return MaterialApp( home: LoginScreen(navigator: AppNavigator(context)), ); } } </code></pre> <p>This is the complete picture. <code>feature_login</code> owns its screens, its validation, and the question of what should happen after a successful login, expressed only as a contract.</p> <p><code>app_main</code>, and only <code>app_main</code>, owns the concrete answer, along with <code>android/</code>, <code>ios/</code>, <code>main.dart</code>, dependency injection, and routing. Every other package in <code>packages/</code> follows the same shape as <code>feature_login</code>: a <code>lib/</code> folder, a <code>test/</code> folder, a <code>pubspec.yaml</code> with explicit <code>path:</code> dependencies, and no native folders at all, because those exist in exactly one place in the whole project.</p> <pre><code class="language-plaintext">my_lego_project/ melos.yaml apps/ app_main/ android/ &lt;- only here ios/ &lt;- only here lib/ main.dart owns: boot, DI, routing app_navigator.dart pubspec.yaml depends on every feature package packages/ feature_login/ lib/ owns: login screens, logic pubspec.yaml depends on nothing feature-specific feature_home/ lib/ owns: home screens, logic pubspec.yaml </code></pre> <p>The line that matters most once every package is in place is still the same one introduced earlier: a feature package's <code>pubspec.yaml</code> only lists the packages it is genuinely allowed to depend on. <code>feature_home</code> never appears in <code>feature_login</code>'s <code>pubspec.yaml</code>, so <code>feature_login</code> can't import it even by accident. That's enforced by the Dart package system itself rather than by a reviewer catching it.</p> <p>This is the strongest version of LEGO Architecture available in Flutter. Your bricks are literal, independently-versioned packages, your studs are literal package dependencies declared in <code>pubspec.yaml</code>, and the compiler, not code review, enforces the rule for you.</p> <h2 id="heading-swappable-state-management-bricks">Swappable State Management Bricks</h2> <p>One more advanced LEGO move worth knowing is making even your state management library a brick you can swap. This matters when a team is migrating from Bloc to Riverpod, or wants to support both during a transition.</p> <p>The trick is the same one used throughout this handbook: define a contract the UI depends on, and let two different state management implementations satisfy it.</p> <pre><code class="language-dart">// lib/features/product/presentation/product_presenter.dart abstract class ProductPresenter { ProductUiState get state; Stream&lt;ProductUiState&gt; get stateStream; Future&lt;void&gt; load(String id); } class ProductUiState { final bool isLoading; final String? name; final String? error; const ProductUiState({this.isLoading = false, this.name, this.error}); } </code></pre> <p>A Bloc based implementation might look like this:</p> <pre><code class="language-dart">class BlocProductPresenter implements ProductPresenter { final ProductCubit _cubit; BlocProductPresenter(this._cubit); @override ProductUiState get state =&gt; _mapState(_cubit.state); @override Stream&lt;ProductUiState&gt; get stateStream =&gt; _cubit.stream.map(_mapState); @override Future&lt;void&gt; load(String id) =&gt; _cubit.load(id); ProductUiState _mapState(ProductState s) =&gt; switch (s) { ProductLoading() =&gt; const ProductUiState(isLoading: true), ProductLoaded(product: final p) =&gt; ProductUiState(name: p.name), ProductError(message: final m) =&gt; ProductUiState(error: m), }; } </code></pre> <p>Here, the widget layer only ever imports <code>ProductPresenter</code> and <code>ProductUiState</code>, never <code>ProductCubit</code>, <code>Bloc</code>, or Riverpod directly.</p> <p><code>BlocProductPresenter</code> is the adapter brick that translates Bloc's specific <code>ProductState</code> shape into the generic <code>ProductUiState</code> the UI understands, using Dart's <code>switch</code> pattern matching over the <code>sealed class</code> hierarchy defined earlier. If the team later writes a Riverpod-based presenter, the widget code doesn't change at all, since only the wiring in the composition root changes which presenter gets handed to the widget tree.</p> <p>This is the LEGO principle applied to its most volatile dependency, since the state management library itself becomes just another interchangeable brick.</p> <h2 id="heading-a-full-worked-example-products-lego-style-with-clean-layers-inside">A Full Worked Example: Products, LEGO-Style, With Clean Layers Inside</h2> <p>Let's put everything together into one coherent feature, showing the full file tree and how every piece connects.</p> <pre><code class="language-plaintext">lib/ core/ contracts/ product_lookup.dart &lt;- shared interface di/ service_locator.dart feature_module.dart &lt;- app-shell contract features/ product/ product.dart &lt;- barrel file / public stud product_module.dart &lt;- implements FeatureModule domain/ entities/product.dart repositories/product_repository.dart usecases/get_product.dart data/ datasources/product_remote_data_source.dart repositories/product_repository_impl.dart presentation/ cubit/product_cubit.dart widgets/product_card.dart &lt;- composed UI bricks </code></pre> <p>The module file ties every level together in one place.</p> <pre><code class="language-dart">// lib/features/product/product_module.dart import 'package:dio/dio.dart'; import 'package:go_router/go_router.dart'; import 'package:app/core/feature_module.dart'; import 'package:app/core/di/service_locator.dart'; import 'package:app/core/contracts/product_lookup.dart'; import 'domain/repositories/product_repository.dart'; import 'domain/usecases/get_product.dart'; import 'data/datasources/product_remote_data_source.dart'; import 'data/repositories/product_repository_impl.dart'; import 'presentation/screens/product_screen.dart'; class ProductModule implements FeatureModule { @override void registerDependencies() { getIt.registerLazySingleton(() =&gt; Dio()); getIt.registerLazySingleton( () =&gt; ProductRemoteDataSource(getIt&lt;Dio&gt;()), ); getIt.registerLazySingleton&lt;ProductRepository&gt;( () =&gt; ProductRepositoryImpl(getIt&lt;ProductRemoteDataSource&gt;()), ); // this repository ALSO satisfies the cross-feature ProductLookup // contract, so cart (or any other feature) can use it // without ever importing anything from this feature's src/. getIt.registerLazySingleton&lt;ProductLookup&gt;( () =&gt; getIt&lt;ProductRepository&gt;() as ProductLookup, ); getIt.registerFactory(() =&gt; GetProduct(getIt&lt;ProductRepository&gt;())); } @override List&lt;RouteBase&gt; get routes =&gt; [ GoRoute( path: '/product/:id', builder: (context, state) =&gt; ProductScreen(productId: state.pathParameters['id']!), ), ]; } </code></pre> <p>This one file is doing exactly one job (assembly). Every dependency it wires up flows in a single direction: from data, up through domain, up to presentation, matching the Clean Architecture diagram from earlier.</p> <p>At the same time it satisfies the <code>FeatureModule</code> contract from the composing features section, which means <code>app.dart</code> treats <code>ProductModule</code> identically to <code>CartModule</code>. It's just another brick to add to the <code>modules</code> list.</p> <p>The design decision worth calling out is that <code>ProductRepositoryImpl</code> implements two interfaces at once: the feature local <code>ProductRepository</code>, used inside this feature's own use case, and the cross feature <code>ProductLookup</code>, used by other features such as cart that only need a narrow slice of what this feature can do.</p> <p>This is a common advanced LEGO pattern, where a single concrete brick exposes multiple, differently shaped studs. This lets different consumers see only the surface relevant to them, without those consumers needing to depend on each other or on the full feature.</p> <img src="https://cdn.hashnode.com/uploads/covers/63a47b24490dd1c9cd9c32ff/b1228a73-fe70-4f3b-8230-3943ecd07fce.png" alt="Architecture diagram showing as one concrete implementation that implements two interfaces: , used only within the product feature through the use case, and , a narrow interface exposed for use by other features such as the cart." style="display: block;" width="600" height="400" loading="lazy"> <h2 id="heading-when-to-use-which-and-common-pitfalls">When to Use Which, and Common Pitfalls</h2> <p>For a small app with a short timeline and few business rules, it's worth staying at the earlier levels of LEGO Architecture. Compose widgets, define a handful of contracts where you genuinely expect to swap implementations (such as the network client or auth), and avoid forcing entities, use cases, and DTOs onto a feature that's really just showing a list and letting the user tap an item.</p> <p>For a growing team with multiple people touching the same codebase, moving to feature folders with barrel files and a <code>FeatureModule</code> contract stops merge conflicts and accidental cross-feature coupling before they start.</p> <p>For complex domain logic that must outlive the UI framework, or that a backend team might reuse, bringing in full Clean Architecture layers inside each feature pays for itself the moment business rules stop being trivial. They cover things like discounts, tax rules, eligibility checks, and state machines.</p> <p>For multiple teams shipping independently, or a design system shared across apps, moving to the modular monorepo pattern makes sense, since features become real packages and the compiler enforces boundaries instead of relying on code review.</p> <p>There are two ways this tends to fail in practice. The first is LEGO in name only, where a folder is named <code>features/cart/</code>, but a file inside it reaches directly into <code>../../product/src/services/product_repository.dart</code>. The moment any file reaches past another feature's barrel file into its <code>src/</code>, independent bricks stop existing. What's left is a monolith wearing a feature folder costume. The fix is always the same: route the dependency through a contract in <code>core/contracts/</code>.</p> <p>The second is Clean Architecture cargo culting, where a feature that's genuinely just fetch a list and render it ends up with an entity, a repository interface, a repository implementation, a DTO, a use case, and a cubit. It has six files and three layers for a screen with no real business logic.</p> <p>This isn't wrong exactly, but it's wasted effort, since the whole point of the Dependency Rule is to protect volatile business logic from framework churn, and there's no business logic here to protect.</p> <p>When a feature has no rules beyond showing what the server sent, it's fine to let the repository return the DTO shape directly and skip the entity and use case ceremony. Those layers can always be added later, the moment real logic shows up, without having wasted time building them speculatively.</p> <h2 id="heading-wrapping-up">Wrapping Up</h2> <p>Think of LEGO Architecture as a way of organizing your code, not something you install or copy.</p> <p>Before you start building, you first define how the different parts of your application should connect, deciding what each part is allowed to depend on and what it should expose to others.</p> <p>Once those rules are clear, you build the actual classes and implementations around them, while keeping each component’s internal details private so other parts of the application only interact with it through its public interface.</p> <p>Finally, you bring the concrete pieces together at one clear assembly point instead of creating dependencies throughout the codebase.</p> <p>Clean Architecture is what you get when you apply that same discipline with a specific, well-tested shape (entities, use cases, and repositories) inside each feature.</p> <p>A good place to start today is pulling the decoration logic out of your next widget into its own <code>SurfaceCard</code>-style component. The next time you write a service class, make it implement an abstract class instead of being called directly. Everything else in this handbook, like feature modules, service locators, modular monorepos, and Clean Architecture layers, is that same one habit, repeated at a larger scale.</p> <h2 id="heading-references">References</h2> <p><strong>The Clean Architecture Blog by Robert C. Martin:</strong> <a href="https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html">https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html</a></p> <p><strong>Flutter's official app architecture guide:</strong> <a href="https://docs.flutter.dev/app-architecture">https://docs.flutter.dev/app-architecture</a></p> <p><strong>Flutter's architecture design pattern recipes:</strong> <a href="https://docs.flutter.dev/app-architecture/design-patterns">https://docs.flutter.dev/app-architecture/design-patterns</a></p> <p><strong>get_it package documentation:</strong> <a href="https://pub.dev/packages/get%5C_it">https://pub.dev/packages/get\_it</a></p> <p><strong>go_router package documentation:</strong> <a href="https://pub.dev/packages/go%5C_router">https://pub.dev/packages/go\_router</a></p> <p><strong>flutter_bloc package documentation:</strong> <a href="https://pub.dev/packages/flutter%5C_bloc">https://pub.dev/packages/flutter\_bloc</a></p> <p><strong>dio package documentation:</strong> <a href="https://pub.dev/packages/dio">https://pub.dev/packages/dio</a></p> <p><strong>Melos, a tool for managing Dart and Flutter monorepos:</strong> <a href="https://melos.invertase.dev/">https://melos.invertase.dev/</a></p> <p><strong>Effective Dart, official style and structure guidance:</strong> <a href="https://dart.dev/effective-dart">https://dart.dev/effective-dart</a></p>

How to apply

  1. 1 Read the full details above and confirm you meet the eligibility criteria.
  2. 2 Prepare your documents — an updated CV, and any cover letter, proposal or certificates required.
  3. 3 Click Apply on official site to complete your application on Atuoha Anthony’s official page.
  4. 4 Submit as early as possible — many close once filled.
Apply on official site

Sourced from freecodecamp. Always verify details on the official website. Opportunihub never charges you to apply.

Frequently asked questions

How do I apply for How to Implement LEGO Architecture in Flutter [Full Handbook]?

Review the full details and eligibility on this page, prepare your documents, then use the “Apply on official site” button to complete your application on Atuoha Anthony’s official page.

Is this opportunity remote or location-based?

This opportunity is remote-friendly and open to applicants who can work from anywhere.

Is How to Implement LEGO Architecture in Flutter [Full Handbook] free to apply for?

Opportunihub lists this Course for free. Legitimate Courses do not ask for payment to apply — never pay a fee to submit an application.