BLoC Architecture in Retail Apps: Why Your 7-BLoC Checkout Flow is Killing Your App

Alejandro Arciniegas

aug 20, 2025

Alright, let's talk about something that's been driving me absolutely crazy lately. I was reviewing a checkout architecture proposal for a major retail app, and what I saw made me want to throw my laptop out the window. Seven BLoCs. SEVEN. For a single checkout flow. Each one talking to the others through events like some kind of distributed messaging nightmare.

Look, I get it. We've all been there. You read about BLoC pattern, Clean Architecture, and suddenly everything needs its own BLoC. But after building checkout flows for Walmart Glass and working on My Firestone's payment systems, I can tell you with absolute certainty: you're doing it wrong.

The Architecture That Made Me Lose Sleep

Here's what I was looking at:

// This is what NOT to do
class BlocCheckoutCart extends Bloc<CartEvent, CartState> {
  final BlocCheckoutDelivery deliveryBloc;  // Red flag #1
  final BlocCheckoutPago paymentBloc;        // Red flag #2
  
  BlocCheckoutCart() {
    // BLoCs listening to other BLoCs? Welcome to hell
    deliveryBloc.stream.listen((state) {
      add(UpdateCartBasedOnDelivery(state));
    });
  }
}

Seven different BLoCs, all communicating through events: BlocCheckoutCart, BlocCheckoutDelivery, BlocCheckoutPago, BlocCheckoutCupones, BlocCheckoutValidation, and a couple more.

I recognized this pattern immediately because I've built systems like this before. While the intention is good - following Clean Architecture principles - this approach often leads to architectural complexity that's hard to maintain and debug.

Why Direct BLoC-to-BLoC Communication Creates Challenges

From my experience, having BLoCs communicate directly through events creates several architectural challenges:

1. Circular Dependencies Are Your Enemy

When BLoC A listens to BLoC B, and BLoC B listens to BLoC A, you've created a dependency cycle that becomes increasingly difficult to debug and maintain. I've spent many late nights debugging these scenarios, and I've learned to avoid them.

// DON'T DO THIS - This creates impossible-to-debug circular dependencies
class PaymentBloc extends Bloc {
  PaymentBloc(this.cartBloc) {
    cartBloc.stream.listen((state) {
      // Now payment depends on cart...
    });
  }
}

class CartBloc extends Bloc {
  CartBloc(this.paymentBloc) {
    paymentBloc.stream.listen((state) {
      // ...and cart depends on payment. This creates a debugging challenge!
    });
  }
}

2. State Becomes Unpredictable Chaos

With multiple BLoCs firing events at each other, your app's state becomes the combination of all individual states. Try explaining to your PM why the checkout button is disabled when the cart has items, delivery is selected, and payment is valid. Spoiler: it's because BLoC #5 is still processing an event from BLoC #3.

3. Testing? Forget About It

Writing unit tests for a single BLoC is straightforward. However, testing seven interdependent BLoCs that need to be mocked, stubbed, and orchestrated together becomes exponentially more complex, especially for simple operations like adding an item to the cart.

The Right Way: Event-Driven Architecture with an Orchestrator

After years of building retail apps that actually scale, here's the pattern that works. And yes, it still uses BLoC and follows Clean Architecture principles, but without the insanity.

Single Source of Truth: The Orchestrator Pattern

// ONE BLoC to rule them all
class CheckoutOrchestrator extends Bloc<CheckoutEvent, CheckoutState> {
  final CheckoutRepository _repository;
  final EventBus _eventBus;
  
  CheckoutOrchestrator(this._repository, this._eventBus) 
    : super(CheckoutState.initial()) {
    
    // Handle all checkout events in one place
    on<AddItemToCart>(_handleAddItem);
    on<SelectDeliveryMethod>(_handleDeliverySelection);
    on<ProcessPayment>(_handlePayment);
    
    // Services communicate through event bus, not direct references
    _eventBus.on<ServiceEvent>().listen(_handleServiceEvent);
  }
  
  // Single, immutable state
  @override
  CheckoutState get state => CheckoutState(
    cart: _cartData,
    delivery: _deliveryData,
    payment: _paymentData,
    currentStep: _currentStep,
    validation: _validationResult,
  );
}

This is similar to what we implemented at Walmart for their Scan & Go feature. One orchestrator, clear flow, no circular dependencies.

Decoupled Services That Mind Their Own Business

Instead of BLoCs talking to each other, use specialized services that handle specific business logic:

// Services are pure business logic, no UI concerns
abstract class CheckoutService {
  Stream<ServiceEvent> process(CheckoutData data);
}

class DeliveryService implements CheckoutService {
  @override
  Stream<ServiceEvent> process(CheckoutData data) async* {
    // Calculate delivery options based on cart
    final options = await _calculateDeliveryOptions(data.cart);
    yield DeliveryOptionsCalculated(options);
    
    // Validate address if needed
    if (data.delivery?.address != null) {
      final validation = await _validateAddress(data.delivery.address);
      yield AddressValidated(validation);
    }
  }
}

class PaymentService implements CheckoutService {
  @override
  Stream<ServiceEvent> process(CheckoutData data) async* {
    // Process payment independently
    // No knowledge of delivery or cart BLoCs
    final result = await _processPayment(data.payment);
    yield PaymentProcessed(result);
  }
}

Repository Pattern with Strategy for Payment Methods

Here's where it gets beautiful. Instead of having different BLoCs for different payment methods, use the Strategy pattern:

class CheckoutRepository {
  final Map<PaymentMethod, PaymentStrategy> _strategies = {
    PaymentMethod.creditCard: CreditCardStrategy(),
    PaymentMethod.paypal: PayPalStrategy(),
    PaymentMethod.applePay: ApplePayStrategy(),
    PaymentMethod.giftCard: GiftCardStrategy(),
  };
  
  Future<CheckoutResult> processCheckout(CheckoutData data) async {
    // Validate everything in a pipeline
    final validation = await _validationPipeline.validate(data);
    if (!validation.isValid) {
      return CheckoutResult.validationFailed(validation);
    }
    
    // Process payment with appropriate strategy
    final strategy = _strategies[data.payment.method];
    return strategy.execute(data);
  }
}

Real Architecture That Actually Works

Here's the architecture diagram that won't make you want to quit software development:

┌─────────────────────┐
   CheckoutPage      
└──────────┬──────────┘
           
    ┌──────▼──────┐
     Orchestrator│  Single BLoC managing the flow
    └──────┬──────┘
           
    ┌──────▼──────────┐
       Event Bus       Decoupled communication
    └────┬─────┬──────┘
              
    ┌────▼─┐ ┌─▼──────┐
    │Service││Repository│  Independent, testable units
    └──────┘ └─────────┘

Notice what's missing? BLoCs talking to BLoCs. Services depending on each other. Circular dependencies. That's not an accident.

The Validation Pipeline That Actually Scales

One of the biggest mistakes I see is spreading validation logic across multiple BLoCs. Here's how to do it right:

class ValidationPipeline {
  final List<Validator> validators = [
    InventoryValidator(),     // Check stock availability
    FraudValidator(),         // Fraud detection
    PaymentValidator(),       // Payment method validation
    DeliveryValidator(),      // Delivery constraints
    BusinessRulesValidator(), // Custom business rules
  ];
  
  Future<ValidationResult> validate(CheckoutData data) async {
    final results = <ValidationResult>[];
    
    // Run validators in parallel when possible
    final futures = validators.map((v) => v.validate(data));
    results.addAll(await Future.wait(futures));
    
    // Combine results
    final errors = results
        .where((r) => !r.isValid)
        .expand((r) => r.errors)
        .toList();
        
    return errors.isEmpty 
        ? ValidationResult.success()
        : ValidationResult.failure(errors);
  }
}

This approach lets you add new validators without touching existing code. That's SOLID principles in action, not seven BLoCs playing telephone.

State Management That Doesn't Suck

Use freezed or built_value for immutable state. Trust me on this:

@freezed
class CheckoutState with _$CheckoutState {
  const factory CheckoutState({
    required CheckoutStep currentStep,
    required CartSummary cart,
    DeliveryInfo? delivery,
    PaymentInfo? payment,
    ValidationResult? validation,
    @Default(false) bool isProcessing,
    @Default(false) bool isComplete,
    CheckoutError? error,
  }) = _CheckoutState;
  
  // Helper methods for state transitions
  const CheckoutState._();
  
  bool get canProceedToDelivery => 
      cart.items.isNotEmpty && !cart.hasUnavailableItems;
      
  bool get canProceedToPayment => 
      canProceedToDelivery && delivery != null;
      
  bool get canCompleteCheckout => 
      canProceedToPayment && 
      payment != null && 
      validation?.isValid == true;
}

Performance Optimizations That Matter

After implementing this pattern at scale, here are the performance wins:

  1. Single BLoC = Fewer Rebuilds: Instead of seven BLoCs causing rebuilds, you have one controlled source
  2. Event Bus = Async Processing: Services can process in parallel without blocking the UI
  3. Immutable State = Predictable Updates: No mysterious state mutations from random BLoCs

The Lessons That Cost Me Sleep

After building checkout flows that process millions of transactions, here's what I know for sure:

  1. Start Simple: Begin with one BLoC. Add complexity only when you have metrics proving you need it
  2. Services Over BLoCs: Business logic belongs in services, not in UI state management
  3. Events Are Not Your Message Bus: BLoC events are for user interactions, not inter-component communication
  4. Test the Flow, Not the Implementation: Your tests should validate business outcomes, not BLoC interactions

When You Actually Need Multiple BLoCs

Let's be real - sometimes you do need multiple BLoCs. But here's when:

  • Completely Independent Features: User profile and checkout can have separate BLoCs
  • Different Lifecycles: A chat feature that persists while navigating can have its own BLoC
  • Module Boundaries: If you're using a modular architecture, each module can have its BLoC

But even then, they should NEVER directly communicate through events. Use a shared repository or service layer instead.

The Bottom Line

Look, I've been where you are. You want to do things "right." You want Clean Architecture. You want your code to be "enterprise-ready." But here's the truth: the best architecture is the simplest one that solves your problem.

At Walmart, we handled Black Friday traffic with this pattern. At Firestone, we processed thousands of service appointments. Not with seven BLoCs playing hot potato with events, but with a single, well-designed orchestrator and decoupled services.

So before you create that BlocCheckoutStepValidatorListenerObserver, ask yourself: "Am I solving a real problem, or am I adding unnecessary complexity?"

Your future self (and your team) will thank you.

Want to Learn More?

If you're dealing with complex state management in Flutter or need help architecting your retail app, feel free to reach out. I've been through the trenches and I'm happy to help you avoid the same pitfalls I've encountered.

Remember: Good architecture is invisible. If you're constantly thinking about your architecture while adding features, you're doing it wrong.