Help us improve
Share bugs, ideas, or general feedback.
From ufil
GM Clean Architecture patterns — modular (Failure + FailureHandlerMixin) vs non-modular (Result<T> + ErrorMapper) for Flutter
npx claudepluginhub ghozimahdi/gm-claude-plugins --plugin ufilHow this skill is triggered — by the user, by Claude, or both
Slash command
/ufil:clean-architectureThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
- **Presentation -> Domain -> Data** flow only. Never access data layer from presentation
Creates p5.js generative art with seeded randomness, noise fields, and interactive parameter exploration. Use for algorithmic art, flow fields, or particle systems.
Share bugs, ideas, or general feedback.
@injectable Bloc + @lazySingleton UseCase). Pages dispatch events with context.read<TBloc>().add(...) and read state with BlocBuilder/BlocConsumer/BlocSelector/BlocListener. A page that imports a UseCase or calls getIt<XUseCase>() is ALWAYS wrong, even for one-shot ops like logout/refresh/delete — there is NO "too simple to need a bloc" exception.@freezed + @Default, NO nullable@freezed
sealed class TenantModel with _$TenantModel {
const factory TenantModel({
@Default('') String id,
@Default('') String name,
@Default('') String phone,
}) = _TenantModel;
}
@freezed + nullable + @JsonKey, NO .toModel()@freezed
abstract class TenantDto with _$TenantDto {
const factory TenantDto({
@JsonKey(name: 'id') String? id,
@JsonKey(name: 'name') String? name,
@JsonKey(name: 'phone_number') String? phone,
}) = _TenantDto;
factory TenantDto.fromJson(Map<String, dynamic> json) {
return _$TenantDtoFromJson(json);
}
}
@lazySingleton class (BOTH project types)@lazySingleton
class TenantModelMapper {
TenantModel mapFromData(TenantDto? data) {
return TenantModel(
id: data?.id ?? '',
name: data?.name ?? '',
phone: data?.phone ?? '',
);
}
}
@lazySingleton
class TenantRemoteDatasource {
const TenantRemoteDatasource(this._dio);
final Dio _dio;
Future<List<TenantDto>> getTenants({required String propertyId}) async {
final response = await _dio.get(
'/tenants',
queryParameters: {'property_id': propertyId},
);
final list = response.data as List<dynamic>;
return list.map((e) => TenantDto.fromJson(e as Map<String, dynamic>)).toList();
}
}
Future<Result<T>> where T is a mapped Result/Modelabstract class TenantRepository {
Future<Result<GetTenantsResult>> getTenants({required String propertyId});
}
with ErrorMapper, injects mapper, generic catch (e)@LazySingleton(as: TenantRepository)
class TenantRepositoryImpl with ErrorMapper implements TenantRepository {
TenantRepositoryImpl(this._datasource, this._resultMapper);
final TenantRemoteDataSource _datasource;
final GetTenantsResultMapper _resultMapper;
@override
Future<Result<GetTenantsResult>> getTenants({required String propertyId}) async {
try {
final response = await _datasource.getTenants(propertyId: propertyId);
return Result.ok(_resultMapper.mapFromData(response));
} catch (e) {
return Result.error(mapToFailure(e));
}
}
}
KEY: The mapper runs inside the repo before Result.ok(...). Result<T> always wraps a mapped domain type (Model or Result), never a DTO/Response.
@lazySingleton, returns Future<Result<T>>@lazySingleton
class GetTenantsUseCase {
GetTenantsUseCase(this._repository);
final TenantRepository _repository;
Future<Result<GetTenantsResult>> call({required String propertyId}) {
return _repository.getTenants(propertyId: propertyId);
}
}
switch, error variant carries Failure failurefinal result = await _getTenantsUseCase(propertyId: id);
switch (result) {
case Ok(:final value):
emit(state.copyWith(tenantsState: GetTenantsState.done(result: value)));
case Error(:final error):
emit(
state.copyWith(
tenantsState: GetTenantsState.error(failure: error),
),
);
}
lib/features/<name>/
├── data/
│ ├── datasources/
│ │ └── <name>_remote_datasource.dart
│ ├── models/
│ │ └── <name>_dto.dart
│ └── repositories/
│ └── <name>_repository_impl.dart
├── domain/
│ ├── models/
│ │ └── <name>_model.dart
│ ├── repositories/
│ │ └── <name>_repository.dart
│ └── usecases/
│ └── get_<name>_usecase.dart
└── presentation/
├── blocs/
│ └── <name>/
│ ├── <name>_bloc.dart
│ ├── <name>_event.dart
│ └── <name>_state.dart
├── pages/
│ └── <name>_page.dart
└── widgets/
└── <name>_widget.dart
Modular projects use a 3-layer error handling chain in data_common:
DioException → AppExceptionDioExceptionAppException/DioException → Failure// packages/data/data_common/lib/src/network/app_exception.dart
class AppException extends DioException {
final int? statusCode;
AppException({required String message, this.statusCode})
: super(requestOptions: RequestOptions(), message: message);
}
class UnauthorizedException extends AppException {
UnauthorizedException() : super(message: 'Unauthorized access', statusCode: 401);
}
class NoConnectionException extends AppException {
NoConnectionException() : super(message: 'No internet connection');
}
class TimeoutException extends AppException {
TimeoutException() : super(message: 'Request timed out');
}
class ForbiddenException extends AppException {
ForbiddenException() : super(message: 'Access forbidden', statusCode: 403);
}
class ServerException extends AppException {
ServerException() : super(message: 'Internal server error', statusCode: 500);
}
class ClientException extends AppException {
ClientException({required super.message, super.statusCode}) : super();
}
// packages/data/data_common/lib/src/network/dio_error_interceptor.dart
class DioErrorInterceptor extends Interceptor {
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
switch (err.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
throw TimeoutException();
case DioExceptionType.connectionError:
throw NoConnectionException();
case DioExceptionType.badResponse:
final statusCode = err.response?.statusCode ?? 0;
if (statusCode == 401) throw UnauthorizedException();
if (statusCode == 403) throw ForbiddenException();
if (statusCode >= 400 && statusCode < 500) {
throw ClientException(message: 'Request failed', statusCode: statusCode);
}
if (statusCode >= 500) throw ServerException();
throw AppException(message: 'Unknown error', statusCode: statusCode);
default:
throw AppException(message: err.message ?? 'Unknown error');
}
}
}
@freezed, contains Failure + data// packages/domain/domain_tenant/lib/src/models/get_tenants_result.dart
import 'package:domain_common/domain_common.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'tenant_model.dart';
part 'get_tenants_result.freezed.dart';
@freezed
abstract class GetTenantsResult with _$GetTenantsResult {
const factory GetTenantsResult({
@Default(Failure.noFailure()) Failure failure,
@Default([]) List<TenantModel> items,
}) = _GetTenantsResult;
}
// packages/domain/domain_tenant/lib/src/repositories/tenant_repository.dart
import 'package:domain_common/domain_common.dart';
import 'package:domain_tenant/src/models/get_tenants_result.dart';
abstract class TenantRepository {
// Query (returns data) → Future<Result>
Future<GetTenantsResult> getTenants({required String propertyId});
// Action (no data needed) → Future<Failure>
Future<Failure> deleteTenant(String id);
}
Future<Result> — Result with data on success, Result with failure on errorFuture<Failure> — Failure.noFailure() on success, specific Failure on errorResult<T> wrapper in modular projectsmapToFailure() returns Failure// packages/data/data_common/lib/src/mixins/failure_handler_mixin.dart
import 'package:data_common/src/network/app_exception.dart';
import 'package:dio/dio.dart';
import 'package:domain_common/domain_common.dart';
import 'package:flutter/foundation.dart';
mixin FailureHandlerMixin {
Failure mapToFailure(Object error) {
if (kDebugMode) {
debugPrint(error.toString());
}
if (error is AppException) {
return _mapAppExceptionToFailure(error);
} else if (error is DioException) {
return _mapDioExceptionToFailure(error);
} else {
return Failure.unexpectedError(message: error.toString());
}
}
Failure _mapAppExceptionToFailure(AppException exception) {
switch (exception.runtimeType) {
case const (UnauthorizedException):
return const Failure.unauthorized();
case const (ServerException):
return const Failure.serverFailure();
case const (ClientException):
return const Failure.requestFailure();
case const (TimeoutException):
return const Failure.timeout();
case const (NoConnectionException):
return const Failure.noConnection();
case const (ForbiddenException):
return const Failure.forbidden();
default:
return Failure.unexpectedError(
message: exception.message ?? exception.toString(),
);
}
}
Failure _mapDioExceptionToFailure(DioException exception) {
switch (exception.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
return const Failure.timeout();
case DioExceptionType.connectionError:
return const Failure.noConnection();
default:
return Failure.unexpectedError(
message: exception.message ?? 'Unknown Dio error',
);
}
}
}
// packages/data/data_tenant/lib/src/repositories/tenant_repository_impl.dart
@LazySingleton(as: TenantRepository)
class TenantRepositoryImpl with FailureHandlerMixin implements TenantRepository {
const TenantRepositoryImpl(this._datasource, this._resultMapper);
final TenantRemoteDatasource _datasource;
final GetTenantsResultMapper _resultMapper;
@override
Future<GetTenantsResult> getTenants({required String propertyId}) async {
try {
final response = await _datasource.getTenants(propertyId: propertyId);
return _resultMapper.mapFromData(response);
} catch (e) {
return GetTenantsResult(failure: mapToFailure(e));
}
}
}
KEY: Repository does NOT manually map DTOs. It passes the full response to resultMapper.mapFromData(response). The ResultMapper handles all DTO → model conversion.
@lazySingleton, returns Future<Result>// packages/domain/domain_tenant/lib/src/usecases/get_tenants_usecase.dart
@lazySingleton
class GetTenantsUsecase {
const GetTenantsUsecase(this._repository);
final TenantRepository _repository;
Future<GetTenantsResult> call({required String propertyId}) {
return _repository.getTenants(propertyId: propertyId);
}
}
switch on result.failurefinal result = await _getTenantsUsecase(propertyId: id);
switch (result.failure) {
case NoFailure():
emit(state.copyWith(tenantsState: GetTenantsState.done(items: result.items)));
default:
emit(
state.copyWith(
tenantsState: GetTenantsState.error(failure: result.failure),
),
);
}
packages/
├── library/
│ └── library_common/ # AppConfig, shared utilities
├── domain/
│ ├── domain_common/ # Failure model, shared contracts
│ │ └── lib/src/
│ │ ├── models/
│ │ │ └── failure.dart
│ │ └── mixins/
│ └── domain_<feature>/
│ └── lib/src/
│ ├── models/
│ │ └── <feature>_model.dart
│ ├── repositories/
│ │ └── <feature>_repository.dart
│ ├── usecases/
│ │ └── get_<feature>_usecase.dart
│ ├── di/
│ │ └── di.dart
│ └── config/
│ └── domain_<feature>_config.dart
├── data/
│ ├── data_common/ # FailureHandlerMixin, NetworkModule
│ │ └── lib/src/
│ │ └── mixins/
│ │ └── failure_handler_mixin.dart
│ └── data_<feature>/
│ └── lib/src/
│ ├── datasources/
│ │ └── <feature>_remote_datasource.dart
│ ├── models/
│ │ └── <feature>_dto.dart
│ ├── repositories/
│ │ └── <feature>_repository_impl.dart
│ ├── di/
│ │ └── di.dart
│ └── config/
│ └── data_<feature>_config.dart
└── presentation/
├── feature_common/ # Shared widgets, RouteProviders
└── feature_<feature>/
└── lib/src/
├── blocs/
│ └── <feature>_list/
│ ├── <feature>_list_bloc.dart
│ ├── <feature>_list_event.dart
│ └── <feature>_list_state.dart
├── pages/
│ └── <feature>_list_page.dart
├── widgets/
│ └── <feature>_card_widget.dart
├── di/
│ └── di.dart
└── config/
└── feature_<feature>_config.dart
@lazySingleton mapper classes ({Name}ModelMapper, {Action}ResultMapper, {Action}RequestMapper). NEVER .toModel() on DTO.Result<T> + ErrorMapper — repos wrap mapped Result/Model in Result, bloc uses switch on ResultFailureHandlerMixin — query repos return Future<Result> (Result contains Failure + data), action repos return Future<Failure> directly. Bloc: query → switch on result.failure, action → switch on failureResult.error@lazySingleton, plain call() method@Default(Failure.noFailure()) Failure failure in BOTH project typesPlugin docs live at $CLAUDE_PLUGIN_ROOT (run echo $CLAUDE_PLUGIN_ROOT to resolve).
${CLAUDE_PLUGIN_ROOT}/docs/ARCHITECTURE.md — Clean Architecture overview, modular vs single-module${CLAUDE_PLUGIN_ROOT}/docs/DOMAIN_LAYER.md — Domain layer patterns and conventions${CLAUDE_PLUGIN_ROOT}/docs/DATA_LAYER.md — Data layer patterns, DTOs, datasources${CLAUDE_PLUGIN_ROOT}/docs/PRESENTATION_LAYER.md — Presentation layer, pages, widgets${CLAUDE_PLUGIN_ROOT}/docs/NAMING_CONVENTIONS.md — File and class naming standards${CLAUDE_PLUGIN_ROOT}/docs/MAPPERS.md — Mapper creation rules (apply to BOTH project types)