Skip to main content

Command Palette

Search for a command to run...

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

Published
8 min read
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.dart not card_01.dart

  • Follow feature naming: auth_button.dart for feature-specific components

  • Keep 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.