Sparround

Use cases: when you need one and when it hurts

A use case (also called an interactor) is a class that represents one business operation: "calculate the cart total", "place the order", "load the profile together with its subscription status".

The official Flutter guide treats a use case as an optional layer and recommends it only when one of three conditions holds:

1. The operation needs data from several repositories. 2. The logic is exceedingly complex. 3. The same logic is used by several view models.

The guide states both the benefit and the price openly.

Benefits: it avoids duplication in view models, improves testability by separating complex business logic from UI logic, and keeps view models readable.

Costs: it increases architectural complexity (more classes, higher cognitive load), tests need additional mocks, and boilerplate grows.

The guide's advice is direct: add use cases only when needed. If you later find that most view models get their data through use cases, you can refactor to use them exclusively.

dart
// fayl: lib/domain/usecases/calculate_cart_total.dart
import 'package:my_app/domain/models/cart.dart';
import 'package:my_app/domain/models/cart_total.dart';
import 'package:my_app/domain/repositories/cart_repository.dart';
import 'package:my_app/domain/repositories/coupon_repository.dart';
import 'package:my_app/domain/repositories/delivery_repository.dart';

class CalculateCartTotal {
  const CalculateCartTotal({
    required CartRepository cartRepository,
    required CouponRepository couponRepository,
    required DeliveryRepository deliveryRepository,
  })  : _cart = cartRepository,
        _coupons = couponRepository,
        _delivery = deliveryRepository;

  final CartRepository _cart;
  final CouponRepository _coupons;
  final DeliveryRepository _delivery;

  /// Üç mənbəni birləşdirir — buna görə use-case-dir.
  Future<CartTotal> call({String? couponCode, required String cityId}) async {
    final cart = await _cart.fetchCurrent();

    final subtotal = cart.items.fold<double>(
      0,
      (sum, item) => sum + item.product.finalPrice * item.quantity,
    );

    // Kupon: yoxdursa, ya da etibarsızdırsa endirim sıfırdır.
    final coupon = couponCode == null
        ? null
        : await _coupons.findValid(couponCode, subtotal: subtotal);
    final discount = coupon?.discountFor(subtotal) ?? 0;

    // Çatdırılma: pulsuz həddi keçildikdə sıfırlanır.
    final tariff = await _delivery.tariffFor(cityId);
    final shipping =
        subtotal - discount >= tariff.freeFrom ? 0.0 : tariff.price;

    return CartTotal(
      subtotal: subtotal,
      discount: discount,
      shipping: shipping,
      total: subtotal - discount + shipping,
      appliedCoupon: coupon,
    );
  }
}

// ── Riverpod-da bağlanması ──
@riverpod
CalculateCartTotal calculateCartTotal(Ref ref) => CalculateCartTotal(
      cartRepository: ref.watch(cartRepositoryProvider),
      couponRepository: ref.watch(couponRepositoryProvider),
      deliveryRepository: ref.watch(deliveryRepositoryProvider),
    );

// ── Notifier sadə qalır: bir çağırış ──
@riverpod
class CartSummary extends _$CartSummary {
  @override
  Future<CartTotal> build({String? couponCode, required String cityId}) =>
      ref.watch(calculateCartTotalProvider)(
        couponCode: couponCode,
        cityId: cityId,
      );
}

A real use case satisfying condition #1: the total depends on three separate sources. Keeping this logic in the notifier would make it untestable.

OperationWhich condition holdsDecision
"Fetch my orders"None — one repository, one callDo not write one; the notifier calls the repository directly
"Cancel the order"NoneDo not write one
"Calculate the cart total"#1 (three repositories) plus #2 (complex rules)Write one
"Show the profile plus subscription status"#1 (two repositories)Write one (or combine in the notifier if it is for a single screen)
"Format the price by currency"None — this is display logicDo not write one; this belongs to the presentation layer
"Compute the discount percentage"None — it depends on one model's fieldsDo not write one; make it a getter on the domain model
"Payment flow: validate, reserve, confirm"#2 (multi-step) plus #3 (two screens)Write one

The shape of a use case. Three variants are common:

  • A class with a `call()` method — invoked as calculateTotal(couponCode: ...). Compact, but the method name is invisible at the call site, which raises "what is this function?" while navigating.
  • A class with a named methodCalculateCartTotal().execute(...) or PlaceOrder().placeOrder(...). More explicit, especially when searching in an IDE.
  • A plain functionFuture<CartTotal> calculateCartTotal({required CartRepository cart, …}). Simplest to test since dependencies are parameters, but verbose at the call site.

The choice is a team convention; what matters is using one shape consistently across the project.

Naming. A use case is named verb + noun: PlaceOrder, CalculateCartTotal, RefreshSession. Names like OrderUseCase, CartManager or OrderService hide the operation.

The anti-pattern. Wrapping a single repository call: class GetProductsUseCase { Future<List<Product>> call() => _repo.fetchActive(); }. That class adds nothing — only one more file, one more provider, one more mock and one more test file. The guide's "only when needed" advice exists precisely to prevent it.

The use case layer is not an all-or-nothing decision. The official stance says as much: use cases are added gradually, and if most view models end up using them, you can move the rest across too.

The practical consequence: having three files in lib/domain/usecases/ is perfectly normal — even in a 30-screen app. That is not "half-done architecture", it is a measured decision.

Practice. Find a place in your project that combines data from two repositories (or performs a complex calculation) and extract it into a use case. Then write two tests for it: the happy path and one edge case (an invalid coupon, for instance).

Done means: the notifier is down to a single call, and the tests run against fake repositories with no network.

📚 Sources and documentation