문서 목차
이 페이지에서목차

Data Mapper Pattern

Rules for writing Mapper classes that convert OpenAPI Response to Domain Entity.

Rules for writing Mapper classes that convert OpenAPI Response to Domain Entity.

Directory Structure#

Resolve .cob/feature-layout.json first; without it use cob's existing legacy-tree detection, otherwise bundle. Preserve markers and follow the generation placement contract. New mappers are feature-owned; kernel/core must not re-export feature implementations.

feature/{feature_name}/{app,console}/lib/src/
├── data/
│   ├── mappers/           # Mapper 클래스 위치
│   │   └── {feature}_mapper.dart
│   ├── repository/
│   │   └── mixins/
│   │       └── {feature}_openapi_mixin.dart  # Mapper 사용
│   └── data.dart          # mappers export 포함
└── domain/

조건부 legacy — 기존 Mapper 목록 (legacy 판정에서만)#

FeatureFile PathMain Conversion
attendance feature/application/attendance/.../attendance_mapper.dart QR verification, attendance records
classroom feature/application/classroom/.../classroom_mapper.dart Class, student, folder info
homework feature/application/homework/.../homework_mapper.dart Homework list, progress
league feature/application/league/.../league_mapper.dart League, ranking info
level_test feature/application/level_test/.../level_test_mapper.dart Level test, questions, results
notice_board feature/application/notice_board/.../notice_board_mapper.dart Notice board, attachments
notification feature/application/notification/.../notification_mapper.dart Push notifications
payment feature/application/payment/.../payment_mapper.dart Payment info
report feature/application/report/.../report_mapper.dart Learning reports
review feature/application/review/.../review_mapper.dart Wrong answer notes
auth feature/common/auth/.../auth_mapper.dart Authentication, tokens
mypage feature/common/mypage/.../mypage_mapper.dart User profile
settings feature/common/settings/.../settings_mapper.dart App settings

Note: core.dart re-exports Mappers from all Feature packages, so all Mappers are accessible via just import 'package:core/core.dart';

Mapper Class Structure#

import 'package:core/core.dart';
import 'package:dependencies/dependencies.dart';
import 'package:openapi/api.dart';

/// Mapper for converting {Feature} API Response to Domain Entity
abstract final class {Feature}Mapper {
  /// Converts {Response} to {Entity}
  static {Entity} from{Response}({Response} response) {
    return {Entity}(
      // 필드 매핑
    );
  }

  /// Converts API error to Failure
  static Failure mapException(Object error, StackTrace stackTrace) {
    Log.e('{Feature} API Error', error: error, stackTrace: stackTrace);
    if (error is DioException) {
      return NetworkFailure(
        error.message ?? 'Network error occurred',
        error: error,
        stackTrace: stackTrace,
      );
    }
    return UnexpectedFailure(
      error.toString(),
      error: error is Exception ? error : null,
      stackTrace: stackTrace,
    );
  }
}

Rules#

Class Declaration#

  • Use abstract final class (prevent instantiation)
  • All methods declared as static

Naming Conventions#

ItemPatternExample
File name {feature}_mapper.dart classroom_mapper.dart
Class name{Feature}MapperClassroomMapper
Conversion method from{ResponseType}() fromClassResponse()
Parsing method parse{EnumType}() parseWithdrawReason()
Error mappingmapException()Common usage

Writing Conversion Methods#

// Good: Clear null handling with defaults
static ClassroomClassInfo fromClassResponse(ClassResponse response) {
  return ClassroomClassInfo(
    classId: response.classId?.toString() ?? '',
    name: response.name ?? '',
    createdAt: response.createdAt ?? DateTime.now(),
  );
}

// Bad: Direct usage without null check
static ClassroomClassInfo fromClassResponse(ClassResponse response) {
  return ClassroomClassInfo(
    classId: response.classId.toString(), // NPE 위험
    name: response.name,
  );
}

Safe Parsing Patterns#

int.tryParse usage (required)

// ✅ Good: Safe parsing with int.tryParse
static int parseUserId(String? value) {
  return int.tryParse(value ?? '') ?? 0;
}

// ❌ Bad: Direct int.parse usage (risk of exception)
static int parseUserId(String? value) {
  return int.parse(value!); // FormatException 위험
}

Enum conversion with parseXXX methods

/// Converts String to WithdrawReason enum
static WithdrawReason parseWithdrawReason(String? value) {
  return switch (value) {
    'INCONVENIENT' => WithdrawReason.inconvenient,
    'CONTENT_UNSATISFIED' => WithdrawReason.contentUnsatisfied,
    'COST_BURDEN' => WithdrawReason.costBurden,
    'LOW_FREQUENCY' => WithdrawReason.lowFrequency,
    'OTHER_SERVICE' => WithdrawReason.otherService,
    'ETC' => WithdrawReason.etc,
    _ => WithdrawReason.etc, // Default value
  };
}

OpenAPI Enum Pattern#

Enum types generated from OpenAPI schema use @JsonEnum annotation.

Required Implementation Pattern#

When using @JsonEnum(valueField: 'json'), toJson() method must be added manually.

// package/openapi/lib/src/api/models/{enum_name}.dart
@JsonEnum(valueField: 'json')
enum StudentEnrollmentItemType {
  create('CREATE'),
  existing('EXISTING');

  const StudentEnrollmentItemType(this.json);
  final String json;

  // ✅ Required: toJson() method (called by build_runner in .g.dart)
  String toJson() => json;

  // ✅ Recommended: fromJson() factory method
  static StudentEnrollmentItemType fromJson(String value) {
    return StudentEnrollmentItemType.values.firstWhere(
      (item) => item.json == value,
      orElse: () => StudentEnrollmentItemType.existing, // Safe default
    );
  }
}

When Error Occurs#

Error: The method 'toJson' isn't defined for the type 'EnumType'.
  'type': instance.type.toJson(),

Solution: Add String toJson() => json; method to the enum file

Checklist#

  • Verify @JsonEnum(valueField: 'json') annotation
  • final String json; field exists
  • Add String toJson() => json; method
  • Add static fromJson() method (with default value)

Usage in Repository Mixin#

import 'package:{feature}/src/data/mappers/{feature}_mapper.dart';

mixin {Feature}OpenApiMixin implements I{Feature}Repository {
  @override
  Future<Either<Failure, {Entity}>> get{Entity}ById(String id) async {
    try {
      final response = await openApiService.{feature}Api.get{Entity}(id: id);
      return Right({Feature}Mapper.from{Response}(response));
    } on Exception catch (error, stackTrace) {
      return Left({Feature}Mapper.mapException(error, stackTrace));
    }
  }
}

Export Configuration#

Add mapper export to data/data.dart:

export 'mappers/{feature}_mapper.dart';
export 'repository/{feature}_repository.dart';
export 'repository/factories/factories.dart';

Security Rules (Logging)#

Sensitive Information Logging Prohibited#

// ❌ Prohibited: Including sensitive info in logs
Log.d('🔐 로그인 시도: userId=$userId');
Log.d('📱 SMS 인증: phoneNumber=$phoneNumber, code=$code');
Log.d('🔄 토큰 갱신: refreshToken=$refreshToken');

// ✅ Recommended: Log status only, exclude sensitive info
Log.d('🔐 로그인 API 호출');
Log.d('📱 SMS 인증번호 확인 API 호출');
Log.d('🔄 토큰 갱신 API 호출');

Sensitive Information List#

TypeExample
Credentialspassword, userPw, pin
TokensaccessToken, refreshToken, idToken
IdentificationuserId, loginId (exclude for logging purposes)
Personal InfophoneNumber, email
Auth CodessmsCode, verificationCode, authCode

DioException Handling#

Import Rules#

// ✅ Correct: Import through dependencies package
import 'package:dependencies/dependencies.dart';
// DioException, DioExceptionType 사용 가능

// ❌ Prohibited: Direct dio package import
import 'package:dio/dio.dart';

Error Handling Pattern#

} on DioException catch (error, stackTrace) {
  Log.e('❌ API 에러', error: error, stackTrace: stackTrace);
  // Branch by HTTP status code (recommended)
  if (error.response?.statusCode == 401) {
    return const Left(AuthFailure.tokenExpired);
  }
  return Left(_mapException(error, stackTrace));
} on Exception catch (error, stackTrace) {
  Log.e('❌ API 에러', error: error, stackTrace: stackTrace);
  return Left(_mapException(error, stackTrace));
}

HTTP Status Code to Failure Mapping#

Status CodeFailure TypeDescriptionExample
400 ValidationFailure Bad request Missing required parameter
401 AuthFailure.tokenExpired Auth expired Token expired/invalid
403 AuthFailure.forbidden Forbidden Insufficient access
404 NotFoundFailure Resource not found Non-existent data
409ConflictFailureConflictDuplicate data
500+ServerFailureServer errorInternal server error

상태 코드별 분기 예when

static Failure mapException(Object error, StackTrace stackTrace) {
  if (error is DioException) {
    final statusCode = error.response?.statusCode;
    return switch (statusCode) {
      400 => ValidationFailure(error.message ?? '잘못된 요청'),
      401 => const AuthFailure.tokenExpired(),
      403 => const AuthFailure.forbidden(),
      404 => NotFoundFailure(error.message ?? '리소스를 찾을 수 없음'),
      409 => ConflictFailure(error.message ?? '데이터 충돌'),
      >= 500 => ServerFailure(error.message ?? '서버 오류'),
      _ => NetworkFailure(error.message ?? 'Network error'),
    };
  }
  return UnexpectedFailure(error.toString(), stackTrace: stackTrace);
}

Checklist#

Default Configuration#

  • Create mapper file in data/mappers/ directory
  • Use abstract final class
  • Declare all methods as static
  • Add export to data.dart
  • Use {Feature}Mapper in Repository mixin

Naming Patterns#

  • Name conversion methods as from{Response}()
  • Name enum parsing methods as parse{EnumType}()

Safe Code#

  • Null-safe field mapping (provide defaults)
  • Use int.tryParse (int.parse prohibited)
  • Enum conversion with switch expression (include default)
  • No sensitive info in logs (userId, password, token, etc.)

Error Handling#

  • Include mapException() common error handling
  • Return appropriate Failure per HTTP status code
  • 401 -> AuthFailure.tokenExpired
  • 404 -> NotFoundFailure
  • 500+ -> ServerFailure
  • Include both error and stackTrace parameters in Log.e() calls

Private Helper Method Pattern#

Naming Rules#

PurposePrefixExample
Enum/Status conversion _map* _mapProgressStatus(), _mapTemplate()
Field/data extraction _extract* _extractChoices(), _extractStimulus()
Conditional calculation None _calculateAccuracyRate(), _validateData()

Usage Examples#

// ✅ CORRECT
static HomeworkAssignmentStatus _mapProgressStatus(ProgressStatus? status) {
  return switch (status) {
    ProgressStatus.notStarted => HomeworkAssignmentStatus.notStarted,
    ProgressStatus.inProgress => HomeworkAssignmentStatus.inProgress,
    ProgressStatus.completed => HomeworkAssignmentStatus.completed,
    _ => HomeworkAssignmentStatus.notStarted,
  };
}

static List<QuizChoice> _extractChoices(ResponseItem item) {
  return item.choices?.map((c) => QuizChoice(
    text: c.text ?? '',
    isCorrect: c.isCorrect ?? false,
  )).toList() ?? [];
}

// ❌ WRONG
static HomeworkAssignmentStatus _progressStatusMapper(...) { }  // 동사 먼저
static List<QuizChoice> _getChoices(...) { }  // _get은 헬퍼 메서드에 부적절

Log.e() Structured Pattern (Required)#

Standard Pattern#

// ✅ CORRECT: error와 stackTrace 모두 파라미터로 전달
Log.e('Homework API Error', error: error, stackTrace: stackTrace);
Log.e('❌ API 에러', error: error, stackTrace: stackTrace);

// ❌ WRONG: 문자열 보간 사용 (파싱 불가)
Log.e('API Error: $error', stackTrace: stackTrace);
Log.e('API Error: $error');

// ❌ WRONG: stackTrace 누락
Log.e('API Error', error: error);

Reason#

  1. Consistency: All error logging follows the same structure
  2. Parsable: Log aggregation tools can parse error field
  3. Information preservation: stackTrace stored as structured field

검색어를 입력하면 문서를 찾습니다.

↑ ↓ 이동 · Enter 열기Esc 닫기