Layered Component Architecture (LCA): A Pragmatic Approach to Flutter UI Organization

Bismillahir Rahmanir Raheem
If you're using Clean Architecture with Riverpod in Flutter (like the excellent approach detailed by Code with Andrea), you've probably faced this question: How do I organize my UI components without losing my sanity?
You might have tried Atomic Design, only to find yourself drowning in atoms, molecules, and organisms that require more mental overhead than actual development. Or perhaps you've ended up with a messy widgets/ folder that grows into an unmaintainable jungle.
Today, I want to share a pragmatic solution I call Layered Component Architecture (LCA) - a simple yet powerful approach that balances maintainability with developer sanity.
Bonus Productivity Tip for Developers
Just a quick heads-up, if you're a developer looking to work faster and to boost your productivity, check out our tool VoiceHype—a powerful SaaS product built specifically for developers who want to speak instead of type. With VoiceHype, you can not only generate accurate transcriptions by voice, but also optimize with advanced LLMs like Claude. It supports multiple smart modes tailored to different tasks and use-cases. Alhamdulillah, it's available as a VS Code extension—just search for "VoiceHype" on the marketplace and give it a try. It's made with developers in mind, and we hope you'll find it truly useful, InshaAllah.
Checkout https://voicehype.ai.
The Problem with Current Approaches
Atomic Design: Too Complex
Atomic Design works beautifully for design systems, but in practice:
Five levels of abstraction (atoms → molecules → organisms → templates → pages)
Constant decision fatigue: "Is this a molecule or an organism?"
Over-engineering simple UI components
Maintenance nightmare as your app grows
Feature-Only Organization: Too Rigid
Putting all widgets inside features leads to:
Massive code duplication
No clear reusability patterns
Blurred boundaries between pure UI and business logic
Flat Widget Structure: Too Chaotic
A single widgets/ folder becomes:
Impossible to navigate
No clear ownership or responsibility
Everything mixed together
Introducing Layered Component Architecture (LCA)
LCA solves these problems with a simple four-layer approach that respects both reusability and business logic boundaries.
The Four Layers
1. Shared Components (shared/widgets/)
Purpose: Pure UI components used across multiple features Characteristics:
Zero business logic
Highly reusable
Theme-aware
Generic functionality
// shared/widgets/buttons/primary_button.dart
class PrimaryButton extends ConsumerWidget {
final String text;
final VoidCallback? onPressed;
final bool isLoading;
const PrimaryButton({
required this.text,
this.onPressed,
this.isLoading = false,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
return ElevatedButton(
onPressed: isLoading ? null : onPressed,
child: isLoading
? const CircularProgressIndicator()
: Text(text),
);
}
}
2. Feature Components (features/*/presentation/widgets/components/)
Purpose: Pure UI components specific to a single feature Characteristics:
Zero business logic
Feature-specific styling or behavior
Not reusable across features
Domain-specific but UI-only
// features/auth/presentation/widgets/components/otp_input_field.dart
class OTPInputField extends StatelessWidget {
final String value;
final ValueChanged<String> onChanged;
final bool hasError;
const OTPInputField({
required this.value,
required this.onChanged,
this.hasError = false,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
width: 50,
height: 50,
decoration: BoxDecoration(
border: Border.all(
color: hasError ? Colors.red : Colors.grey,
width: 2,
),
borderRadius: BorderRadius.circular(8),
),
child: TextField(
textAlign: TextAlign.center,
maxLength: 1,
onChanged: onChanged,
// OTP-specific styling and behavior
),
);
}
}
3. Feature Widgets (features/*/presentation/widgets/)
Purpose: Components that contain business logic and state management Characteristics:
Contains Riverpod controllers
Feature-specific functionality
Orchestrates business logic
May compose other components
// features/auth/presentation/widgets/login_form.dart
class LoginForm extends ConsumerWidget {
const LoginForm({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = ref.watch(authControllerProvider);
final formState = ref.watch(loginFormProvider);
return Form(
key: formState.formKey,
child: Column(
children: [
// Uses shared component
AppTextField(
label: 'Email',
value: formState.email,
onChanged: (value) => ref
.read(loginFormProvider.notifier)
.updateEmail(value),
),
// Uses shared component
PrimaryButton(
text: 'Login',
isLoading: controller.isLoading,
onPressed: () => ref
.read(authControllerProvider.notifier)
.login(formState.email, formState.password),
),
],
),
);
}
}
4. Screens (features/*/presentation/screens/)
Purpose: Top-level pages that orchestrate the entire user interface Characteristics:
Contains page-level business logic
Composes widgets and components
Handles navigation and page state
Entry points for features
// features/auth/presentation/screens/login_screen.dart
class LoginScreen extends ConsumerWidget {
const LoginScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.listen(authControllerProvider, (previous, next) {
if (next.hasError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(next.error!)),
);
}
});
return Scaffold(
appBar: AppBar(title: const Text('Login')),
body: const Padding(
padding: EdgeInsets.all(16.0),
child: LoginForm(), // Feature widget
),
);
}
}
The Complete Folder Structure
lib/
├── src/
│ ├── features/
│ │ ├── auth/
│ │ │ ├── domain/
│ │ │ ├── data/
│ │ │ └── presentation/
│ │ │ ├── controllers/
│ │ │ ├── screens/
│ │ │ │ └── login_screen.dart
│ │ │ └── widgets/
│ │ │ ├── login_form.dart # Feature Widget
│ │ │ └── components/
│ │ │ └── otp_input_field.dart # Feature Component
│ │ ├── dashboard/
│ │ │ └── presentation/
│ │ │ ├── controllers/
│ │ │ ├── screens/
│ │ │ └── widgets/
│ │ │ └── components/
│ └── shared/
│ ├── widgets/ # Shared Components
│ │ ├── buttons/
│ │ │ ├── buttons.dart # Barrel export
│ │ │ ├── primary_button.dart
│ │ │ └── secondary_button.dart
│ │ ├── forms/
│ │ │ ├── forms.dart
│ │ │ └── app_text_field.dart
│ │ └── layout/
│ │ ├── layout.dart
│ │ └── app_scaffold.dart
│ ├── theme/
│ └── constants/
The Decision Framework
When creating a new UI component, ask yourself:
1. "Does this component need business logic?"
Yes → Feature Widget or Screen
No → Component (either Shared or Feature)
2. "Will this be used in multiple features?"
Yes → Shared Component
No → Feature Component
3. "Is this a full page?"
Yes → Screen
No → Widget or Component
Benefits of LCA
1. Mental Clarity
Simple decision tree
Clear responsibility boundaries
No arbitrary categorization
2. Maintainability
Easy to locate components
Clear ownership patterns
Logical organization
3. Reusability
Shared components promote consistency
Feature components avoid over-abstraction
Business logic stays contained
4. Scalability
Structure grows naturally with your app
No architectural debt accumulation
Easy onboarding for new team members
5. Riverpod Integration
Clean separation of UI and state management
Controllers stay in appropriate widgets
No prop drilling through pure components
Best Practices
Barrel Exports
Create index files for easy imports:
// shared/widgets/widgets.dart
export 'buttons/buttons.dart';
export 'forms/forms.dart';
export 'layout/layout.dart';
// features/auth/presentation/widgets/widgets.dart
export 'login_form.dart';
export 'components/components.dart';
Consistent Naming
Use descriptive names:
user_profile_card.dartnotcard_01.dartFollow feature naming:
auth_button.dartfor feature-specific componentsKeep it simple: avoid overly technical terms
Theme Integration
Ensure all components respect your design system:
class AppCard extends StatelessWidget {
final Widget child;
const AppCard({required this.child, Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(AppSpacing.md),
boxShadow: [
BoxShadow(
color: theme.shadowColor.withOpacity(0.1),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: child,
);
}
}
Common Pitfalls to Avoid
1. Over-Componentization
Don't create a component for every single UI element. If it's only used once and is simple, keep it inline.
2. Business Logic in Components
Components should be pure UI. The moment you add a Riverpod provider or business logic, it becomes a widget.
3. Premature Abstraction
Start with feature-specific components. Only move to shared when you have at least 2-3 use cases.
4. Ignoring the Decision Framework
Stick to the decision tree. Don't create exceptions "just this once" - they accumulate technical debt.
Migration Strategy
If you're coming from an existing structure:
Phase 1: Create the Structure
Set up the folder structure without moving existing code.
Phase 2: Move Shared Components
Identify truly reusable widgets and move them to shared/widgets/.
Phase 3: Organize Feature Components
Move feature-specific UI-only widgets to appropriate components/ folders.
Phase 4: Refactor Feature Widgets
Ensure widgets with business logic are properly categorized and using Riverpod correctly.
Phase 5: Clean Up
Remove old folders and update imports throughout your app.
Real-World Example
Let's see how this works in practice with an e-commerce app:
lib/src/
├── features/
│ ├── products/
│ │ └── presentation/
│ │ ├── screens/
│ │ │ ├── product_list_screen.dart
│ │ │ └── product_detail_screen.dart
│ │ └── widgets/
│ │ ├── product_list.dart # Business logic
│ │ ├── product_search_bar.dart # Business logic
│ │ └── components/
│ │ ├── product_rating_stars.dart # UI only, product-specific
│ │ └── price_tag.dart # UI only, product-specific
│ ├── cart/
│ │ └── presentation/
│ │ ├── screens/
│ │ └── widgets/
│ │ ├── cart_list.dart # Business logic
│ │ └── components/
│ │ └── quantity_selector.dart # UI only, cart-specific
└── shared/
└── widgets/
├── buttons/
│ ├── primary_button.dart # Used everywhere
│ └── icon_button.dart # Used everywhere
├── cards/
│ └── app_card.dart # Used everywhere
└── loading/
└── loading_indicator.dart # Used everywhere
The product_rating_stars.dart is pure UI but only makes sense in the products context, so it's a feature component. The app_card.dart is used across products, cart, and user profile, so it's shared.
Conclusion
Layered Component Architecture (LCA) provides a pragmatic middle ground between the over-engineering of Atomic Design and the chaos of unstructured widget organization. By respecting both reusability boundaries and business logic separation, LCA creates a maintainable, scalable architecture that grows with your Flutter application.
The key is keeping it simple: four clear layers, a simple decision framework, and respect for the boundaries between pure UI and business logic. This approach works particularly well with Riverpod's architecture patterns and Clean Architecture principles.
Remember, architecture should serve your development process, not hinder it. LCA aims to reduce cognitive load while maintaining clean separation of concerns - exactly what you need for building robust Flutter applications.

