0Pricing
Flutter Mobile Development · 강의

도메인, 데이터 및 프레젠테이션 계층의 경계

엄격한 의존성 규칙에 따라 관심사를 엔터티, 저장소 및 사용 사례로 분리합니다.

도메인, 데이터 및 프레젠테이션 계층의 경계은(는) CoddyKit의 무료 Flutter Mobile Development 강의입니다. 이것은 4개 중 1번째 강의입니다. 아래에서 전체 강의를 무료로 읽을 수 있으며, 내장 코드 에디터와 24/7 AI 튜터와 함께 브라우저에서 직접 실습할 수 있습니다. 이 강의는 Flutter Mobile Development 학습 경로의 일부이며, 진행 상황이 웹과 CoddyKit 앱에 동기화됩니다. Flutter Mobile Development 강의에는 총 4개의 강의가 포함되어 있습니다.

이 강의의 일부는 아직 번역되지 않았으며 영어로 표시됩니다.

Why Layer Boundaries Matter

In a Flutter app of any real size, mixing UI, business rules, and data access into the same files turns every change into a regression risk. Clean Architecture splits the app into three layers:

  • Domain — pure business rules: entities and use cases. No Flutter, no HTTP, no Dart packages tied to I/O.
  • Data — implements repositories: API clients, databases, DTO mapping.
  • Presentation — widgets, state management (Bloc/Riverpod), and view models.

The single most important rule: dependencies point inward. Presentation and Data depend on Domain. Domain depends on nothing.

The Dependency Rule

The Domain layer is the stable core. It must never import from Data or Presentation. Think of it as a circle: arrows of dependency only ever point toward the center.

  • Presentation -> Domain (allowed)
  • Data -> Domain (allowed)
  • Domain -> Data (forbidden)
  • Domain -> Presentation (forbidden)

Because Domain owns the repository interfaces (abstractions), the Data layer that implements them depends inward. This is the Dependency Inversion Principle in action.

Domain Entities

An entity is a plain Dart object that models a core business concept. It carries no JSON parsing, no fromMap, no framework annotations. Keep it immutable and free of I/O concerns.

Notice this class imports nothing from Flutter or any network library. It is pure Dart and could compile in a console app.

class User {
  final String id;
  final String name;
  final String email;

  const User({
    required this.id,
    required this.name,
    required this.email,
  });

  User copyWith({String? name, String? email}) => User(
        id: id,
        name: name ?? this.name,
        email: email ?? this.email,
      );
}

void main() {
  const user = User(id: '1', name: 'Ada', email: 'ada@example.com');
  final renamed = user.copyWith(name: 'Ada L.');
  print('${renamed.id}: ${renamed.name} <${renamed.email}>');
}

Repository Interfaces Live in Domain

The Domain layer declares what data operations exist, not how they happen. It does this with an abstract class (interface). The Data layer provides the concrete implementation later.

This abstraction is the seam that lets Domain stay ignorant of HTTP, SQLite, or Firebase. The return types use Domain entities only.

abstract class UserRepository {
  Future<User> getUser(String id);
  Future<void> saveUser(User user);
}

class User {
  final String id;
  final String name;
  final String email;
  const User({required this.id, required this.name, required this.email});
}

Use Cases Orchestrate Business Rules

A use case (also called an interactor) represents one unit of application behavior, such as "fetch the profile of the signed-in user." It depends only on repository interfaces from Domain.

Keeping each use case as a single-responsibility class makes the business intent explicit and trivially testable with a fake repository.

class GetUser {
  final UserRepository repository;
  const GetUser(this.repository);

  Future<User> call(String id) => repository.getUser(id);
}

abstract class UserRepository {
  Future<User> getUser(String id);
  Future<void> saveUser(User user);
}

class User {
  final String id;
  final String name;
  final String email;
  const User({required this.id, required this.name, required this.email});
}

Data Layer: DTOs and Models

The Data layer introduces models (DTOs) that know how to serialize. A common pattern is to extend or map from the Domain entity, keeping JSON logic out of Domain entirely.

Here UserModel handles fromJson/toJson and exposes a toEntity() conversion so the rest of the app only ever sees the pure User.

class UserModel {
  final String id;
  final String name;
  final String email;

  const UserModel({required this.id, required this.name, required this.email});

  factory UserModel.fromJson(Map<String, dynamic> json) => UserModel(
        id: json['id'] as String,
        name: json['name'] as String,
        email: json['email'] as String,
      );

  Map<String, dynamic> toJson() => {'id': id, 'name': name, 'email': email};

  User toEntity() => User(id: id, name: name, email: email);
}

class User {
  final String id;
  final String name;
  final String email;
  const User({required this.id, required this.name, required this.email});
}

void main() {
  final model = UserModel.fromJson({'id': '7', 'name': 'Grace', 'email': 'g@x.io'});
  final entity = model.toEntity();
  print('Entity name: ${entity.name}');
}

Data Layer: Repository Implementation

The concrete repository lives in Data and implements the Domain interface. It wires together remote data sources, local caches, and DTO mapping. Note the implements UserRepository clause — the dependency points inward to Domain.

It returns User entities, never UserModel, so serialization details never leak upward.

class UserRepositoryImpl implements UserRepository {
  final UserRemoteDataSource remote;
  const UserRepositoryImpl(this.remote);

  @override
  Future<User> getUser(String id) async {
    final model = await remote.fetchUser(id);
    return model.toEntity();
  }

  @override
  Future<void> saveUser(User user) {
    final model = UserModel(id: user.id, name: user.name, email: user.email);
    return remote.putUser(model);
  }
}

abstract class UserRemoteDataSource {
  Future<UserModel> fetchUser(String id);
  Future<void> putUser(UserModel model);
}

Presentation Depends Only on Use Cases

The Presentation layer (a Bloc, Cubit, or Riverpod notifier) holds references to use cases, not to repositories or data sources. This keeps widgets decoupled from how data is fetched.

Below, a Cubit calls the GetUser use case and emits view state. It knows nothing about JSON or HTTP.

class UserCubit extends Cubit<UserState> {
  final GetUser getUser;
  UserCubit(this.getUser) : super(UserInitial());

  Future<void> load(String id) async {
    emit(UserLoading());
    try {
      final user = await getUser(id);
      emit(UserLoaded(user));
    } catch (e) {
      emit(UserError(e.toString()));
    }
  }
}

Mapping the Folder Structure

A feature-first layout makes boundaries visible on disk. Each feature owns its three layers:

  • lib/features/user/domain/ — entities, repositories (interfaces), usecases
  • lib/features/user/data/ — models, datasources, repositories (impl)
  • lib/features/user/presentation/ — cubit/bloc, pages, widgets

When a file in domain/ imports from data/, you have a boundary violation. Lint rules (e.g. import_lint or custom analysis_options.yaml bans) can enforce this automatically.

Error Handling Across Boundaries

Low-level exceptions (a SocketException, a 404) belong to the Data layer. Don't let them bubble up raw into Domain or Presentation. Convert them into domain-level Failure types so the UI reasons about meaning, not transport.

A common idiom is returning Either<Failure, T> (via the dartz package) from repositories, or throwing typed domain exceptions caught at the use-case edge.

sealed class Failure {
  const Failure(this.message);
  final String message;
}

class NetworkFailure extends Failure {
  const NetworkFailure() : super('No connection');
}

class NotFoundFailure extends Failure {
  const NotFoundFailure() : super('Resource not found');
}

String describe(Failure f) => switch (f) {
      NetworkFailure() => 'Check your internet.',
      NotFoundFailure() => 'We could not find that.',
      _ => 'Something went wrong.',
    };

void main() {
  print(describe(const NetworkFailure()));
  print(describe(const NotFoundFailure()));
}

Testing Each Layer in Isolation

Strict boundaries pay off in tests. Because a use case depends on an interface, you can inject a fake repository with zero network or Flutter test harness.

This test runs in plain Dart — no WidgetTester, no mock HTTP server — proving the Domain layer is genuinely decoupled.

abstract class UserRepository {
  Future<User> getUser(String id);
}

class User {
  final String id;
  final String name;
  const User({required this.id, required this.name});
}

class GetUser {
  final UserRepository repo;
  const GetUser(this.repo);
  Future<User> call(String id) => repo.getUser(id);
}

class FakeUserRepository implements UserRepository {
  @override
  Future<User> getUser(String id) async => User(id: id, name: 'Test User');
}

void main() async {
  final useCase = GetUser(FakeUserRepository());
  final user = await useCase('42');
  print(user.id == '42' && user.name == 'Test User'
      ? 'PASS'
      : 'FAIL');
}

Checkpoint: The Dependency Direction

Consider a Flutter feature using Clean Architecture. Where should the UserRepository abstract interface be declared, and which layer implements it?

Recap

You separated a Flutter feature into three layers with strict, inward-pointing dependencies:

  • Domain — pure entities, repository interfaces, and use cases; depends on nothing.
  • Data — DTO models with JSON logic, data sources, and repository implementations that map models to entities.
  • Presentation — Blocs/Cubits that call use cases and never touch serialization or HTTP.

Key takeaways: declare repository interfaces in Domain (Dependency Inversion), convert transport errors into domain Failures at the boundary, keep entities free of framework imports, and enforce the rules with folder structure plus lint bans. The reward is a codebase where each layer is independently testable and changes stay local.

자주 묻는 질문

“도메인, 데이터 및 프레젠테이션 계층의 경계” 강의는 무료인가요?

네 — “도메인, 데이터 및 프레젠테이션 계층의 경계” 전체 내용을 이 웹사이트에서 무료로 읽을 수 있습니다. 인터랙티브하게 실습하려면(내장 코드 에디터와 24/7 AI 튜터), CoddyKit PRO로 업그레이드하면 Flutter Mobile Development 강의 전체를 잠금 해제할 수 있습니다. Flutter Mobile Development 강의에는 총 4개의 강의가 포함되어 있습니다.

“도메인, 데이터 및 프레젠테이션 계층의 경계”에서 뭘 배우나요?

엄격한 의존성 규칙에 따라 관심사를 엔터티, 저장소 및 사용 사례로 분리합니다. 브라우저에서 직접 실행하는 실습 코드로 Flutter Mobile Development을(를) 배우며, 24/7 AI 튜터가 강의를 진행하면서 질문에 답변해줍니다.

Flutter Mobile Development을(를) 시작하는 데 경험이 필요한가요?

사전 경험은 필요하지 않습니다. CoddyKit의 Flutter Mobile Development은(는) 초급자부터 고급 학습자까지를 위해 구성되어 있으므로, 여기서 시작하거나 처음부터 시작할 수 있으며 자신의 속도대로 진행할 수 있습니다. 이것은 4개 중 1번째 강의입니다.

“도메인, 데이터 및 프레젠테이션 계층의 경계” 강의는 얼마나 걸리나요?

대부분의 CoddyKit 강의는 약 5~10분이 소요됩니다. 각 강의는 간결하고 인터랙티브하여 꾸준한 진행이 가능하며, 웹과 앱에서 중단한 부분부터 바로 시작할 수 있습니다.

이 Flutter Mobile Development 강의에서 코드를 작성하고 실행할 수 있나요?

네. 모든 Flutter Mobile Development 강의에는 내장 코드 에디터가 포함되어 있으므로, 브라우저에서 바로 실제 코드를 작성하고 실행한 후 즉시 AI 피드백을 받을 수 있습니다 — 로컬 설정이 필요 없습니다.

이 강의의 모든 강의

  1. 도메인, 데이터 및 프레젠테이션 계층의 경계
  2. get_it 및 injectable을 활용한 의존성 주입
  3. 기능 우선 폴더 구조 및 Melos 모노레포
  4. Either, 실패 유형 및 함수형 오류 처리
← Flutter Mobile Development(으)로 돌아가기