Firebase Functions Architecture Proposal

Overview

Clean architecture for Firebase CallableRequest integration with type-safe Either<ErrorSchema, SuccessSchema> result handling.

Core Components

1. Result Type (store package)

// store/lib/function/core/function_result.dart
typedef FunctionResult<E, R> = Either<E, R>;

// Extension for working with function results
extension FunctionResultX<E, R> on FunctionResult<E, R> {
  bool get isSuccess => isRight();
  bool get isFailure => isLeft();
  
  E? get error => fold((l) => l, (r) => null);
  R? get data => fold((l) => null, (r) => r);
}

2. Enhanced AppFunctionService (services package)

// services/lib/function/app_function_service.dart
class AppFunctionService {
  static AppFunctionService? _instance;
  final FirebaseFunctions functionInstance;
  final Duration timeout;

  factory AppFunctionService({...}) => _instance ??= AppFunctionService._(...);
  
  /// Generic callable execution with Either result parsing
  Future<Either<ErrorSchema, T>> executeCallable<T>({
    required String functionName,
    required T Function(JsonDef) fromJson,
    JsonDef? args,
  }) async {
    LOG.d('Calling function: $functionName with args: $args');
    
    try {
      final callable = functionInstance.httpsCallable(
        functionName,
        options: HttpsCallableOptions(timeout: timeout),
      );
      
      final result = await callable.call(args);
      return _parseEitherResponse<T>(result.data, fromJson);
      
    } on FirebaseFunctionsException catch (e) {
      LOG.e('FirebaseFunctionsException in $functionName: ${e.code} - ${e.message}');
      return Left(_parseFirebaseError(e));
      
    } catch (e, stack) {
      LOG.e('Unknown error in $functionName: $e', error: e, stackTrace: stack);
      return Left(ErrorSchema.unknownError(e.toString()));
    }
  }
  
  /// Parse the Either-wrapped JSON response from backend
  Either<ErrorSchema, T> _parseEitherResponse<T>(
    dynamic data,
    T Function(JsonDef) fromJson,
  ) {
    if (data is! Map) {
      return Left(ErrorSchema.parseError('Invalid response format'));
    }
    
    final json = Map<String, dynamic>.from(data);
    final status = json['status'] as String?;
    
    if (status == 'success' && json.containsKey('data')) {
      try {
        return Right(fromJson(json['data'] as JsonDef));
      } catch (e) {
        return Left(ErrorSchema.parseError('Failed to parse success data: $e'));
      }
    } else if (status == 'failure' && json.containsKey('error')) {
      try {
        return Left(ErrorSchema.fromJson(json['error'] as JsonDef));
      } catch (e) {
        return Left(ErrorSchema.parseError('Failed to parse error data: $e'));
      }
    } else {
      return Left(ErrorSchema.parseError('Unknown response format'));
    }
  }
  
  /// Parse FirebaseFunctionsException into ErrorSchema
  ErrorSchema _parseFirebaseError(FirebaseFunctionsException e) {
    // Try to extract typed error from details
    if (e.details != null && e.details is Map) {
      try {
        final details = Map<String, dynamic>.from(e.details as Map);
        // Backend may send error wrapped in details
        if (details.containsKey('error')) {
          return ErrorSchema.fromJson(details['error'] as JsonDef);
        }
        // Or the details itself is the error
        return ErrorSchema.fromJson(details);
      } catch (_) {
        // Fall through to generic error
      }
    }
    
    // Map Firebase error code to ErrorSchema
    return ErrorSchema(
      errorCode: _mapFirebaseCode(e.code),
      title: _getErrorTitle(e.code),
      message: e.message ?? 'Unknown Firebase error',
      details: e.details,
    );
  }
  
  String _mapFirebaseCode(String firebaseCode) {
    // Map firebase error codes to your app error codes
    switch (firebaseCode) {
      case 'invalid-argument': return 'INVALID_ARGUMENT';
      case 'unauthenticated': return 'UNAUTHENTICATED';
      case 'permission-denied': return 'PERMISSION_DENIED';
      case 'not-found': return 'NOT_FOUND';
      case 'already-exists': return 'ALREADY_EXISTS';
      case 'resource-exhausted': return 'RATE_LIMITED';
      case 'failed-precondition': return 'FAILED_PRECONDITION';
      case 'unavailable': return 'SERVICE_UNAVAILABLE';
      case 'deadline-exceeded': return 'TIMEOUT';
      case 'internal': return 'INTERNAL_ERROR';
      case 'unimplemented': return 'NOT_IMPLEMENTED';
      default: return 'UNKNOWN_ERROR';
    }
  }
  
  String _getErrorTitle(String firebaseCode) {
    // User-friendly error titles
    switch (firebaseCode) {
      case 'invalid-argument': return 'Invalid Request';
      case 'unauthenticated': return 'Authentication Required';
      case 'permission-denied': return 'Access Denied';
      case 'not-found': return 'Not Found';
      case 'deadline-exceeded': return 'Request Timeout';
      default: return 'Error';
    }
  }
}

3. Enhanced AppFunction Base Class

// services/lib/function/app_function.dart
abstract class AppFunction<E, R> {
  final AppFunctionService? _service;
  
  const AppFunction({AppFunctionService? service}) : _service = service;
  
  /// Function name (must match backend function name)
  @mustBeOverridden
  String get name;
  
  /// Deserializer for success result
  @mustBeOverridden
  R Function(JsonDef) get fromJson;
  
  /// Optional: Override for custom error type parsing
  E Function(JsonDef)? get errorFromJson => null;
  
  AppFunctionService get service => _service ?? AppFunctionService();
  
  /// Execute the function with Either result
  Future<Either<E, R>> execute({JsonDef? params}) async {
    if (errorFromJson != null) {
      // Custom error type
      return _executeWithCustomError(params);
    } else {
      // Default ErrorSchema error type
      return _executeWithDefaultError(params) as Future<Either<E, R>>;
    }
  }
  
  Future<Either<ErrorSchema, R>> _executeWithDefaultError(JsonDef? params) {
    return service.executeCallable<R>(
      functionName: name,
      fromJson: fromJson,
      args: params,
    );
  }
  
  Future<Either<E, R>> _executeWithCustomError(JsonDef? params) async {
    final result = await service.executeCallable<R>(
      functionName: name,
      fromJson: fromJson,
      args: params,
    );
    
    // Convert ErrorSchema to custom error type if needed
    return result.fold(
      (errorSchema) {
        if (errorFromJson != null) {
          try {
            return Left(errorFromJson!(errorSchema.toJson()));
          } catch (e) {
            // Fallback if conversion fails
            return Left(errorSchema as E);
          }
        }
        return Left(errorSchema as E);
      },
      (data) => Right(data),
    );
  }
}

4. Concrete Function Implementations

Example 1: Stripe Refresh Account

// services/lib/function/stripe/stripe_refresh_account_function.dart
class StripeRefreshAccountFunction extends AppFunction<ErrorSchema, StripeRefreshAccountResponseSchema> {
  const StripeRefreshAccountFunction({super.service});
  
  @override
  String get name => 'refresh_stripe_account';
  
  @override
  StripeRefreshAccountResponseSchema Function(JsonDef) get fromJson =>
    StripeRefreshAccountResponseSchema.fromJson;
  
  /// Convenience method with typed parameters
  Future<Either<ErrorSchema, StripeRefreshAccountResponseSchema>> refresh({
    required String accountId,
  }) {
    return execute(params: {'account_id': accountId});
  }
}

// Usage:
final function = StripeRefreshAccountFunction();
final result = await function.refresh(accountId: 'acct_123');

result.fold(
  (error) => showError(error.message),
  (data) => handleSuccess(data),
);

Example 2: Search Function (Refactored)

// services/lib/function/search/search_function.dart
class SearchFunction extends AppFunction<ErrorSchema, GeneralSearchResponseSchema> {
  final SearchFunctionType searchType;
  final SearchSortType sortType;
  final String? uid;
  
  const SearchFunction({
    required this.searchType,
    required this.sortType,
    this.uid,
    super.service,
  });
  
  @override
  String get name => 'search_${searchType.name}';
  
  @override
  GeneralSearchResponseSchema Function(JsonDef) get fromJson =>
    GeneralSearchResponseSchema.fromJson;
  
  Future<Either<ErrorSchema, GeneralSearchResponseSchema>> search({
    required String queryStr,
    int pageNum = 1,
  }) {
    final schema = SearchRequestSchema(
      q: queryStr,
      pageNum: pageNum,
      searchType: searchType.name,
      sortType: sortType.name,
      autoComplete: false,
      uid: uid,
    );
    
    return execute(params: schema.toJson());
  }
}

// Usage:
final searchFn = SearchFunction(
  searchType: SearchFunctionType.dare,
  sortType: SearchSortType.recent,
);

final result = await searchFn.search(queryStr: 'test', pageNum: 1);

result.fold(
  (error) {
    LOG.e('Search failed: ${error.message}');
    return [];
  },
  (data) => data.results,
);

5. Error Schema (store package)

// store/lib/function/schema/error_schema.dart
@freezed
class ErrorSchema with _$ErrorSchema {
  const factory ErrorSchema({
    @JsonKey(name: 'error_code') required String errorCode,
    required String title,
    required String message,
    @JsonKey(name: 'details') dynamic details,
    @JsonKey(name: 'retry_after') int? retryAfter,
  }) = _ErrorSchema;
  
  factory ErrorSchema.fromJson(JsonDef json) => _$ErrorSchemaFromJson(json);
  
  // Factory constructors for common errors
  factory ErrorSchema.unknownError([String? msg]) => ErrorSchema(
    errorCode: 'UNKNOWN_ERROR',
    title: 'Unknown Error',
    message: msg ?? 'An unknown error occurred',
  );
  
  factory ErrorSchema.parseError(String msg) => ErrorSchema(
    errorCode: 'PARSE_ERROR',
    title: 'Parse Error',
    message: msg,
  );
  
  factory ErrorSchema.networkError() => const ErrorSchema(
    errorCode: 'NETWORK_ERROR',
    title: 'Network Error',
    message: 'Failed to connect to server',
  );
}

Benefits

  1. Type Safety: Full type safety with generics AppFunction<ErrorType, SuccessType>
  2. Consistent Error Handling: All errors flow through ErrorSchema
  3. Clean API: Either pattern provides clean success/failure handling
  4. Testability: Easy to mock and test
  5. Extensibility: Easy to add new functions
  6. Server Alignment: Matches backend Either pattern exactly

Migration Path

  1. Update ErrorSchema in store package
  2. Refactor AppFunctionService with executeCallable method
  3. Update AppFunction base class with Either support
  4. Migrate existing functions (SearchFunction, etc.) one by one
  5. Update calling code to use Either pattern

Testing Example

void main() {
  test('StripeRefreshAccountFunction success', () async {
    final mockService = MockAppFunctionService();
    when(mockService.executeCallable<StripeRefreshAccountResponseSchema>(
      functionName: 'refresh_stripe_account',
      fromJson: any,
      args: any,
    )).thenAnswer((_) async => Right(mockSuccessResponse));
    
    final function = StripeRefreshAccountFunction(service: mockService);
    final result = await function.refresh(accountId: 'test');
    
    expect(result.isRight(), true);
  });
}