← 返回蜂巢洞察

Flutter中的模块化设计:如何将简洁的架构设计与领域驱动设计相结合,从而打造出独立性强、可扩展性高的功能模块

作为在小型团队中工作的工程师,目前的开发结构或许还可以应付得来。但倘若你的团队规模扩大到20人或更多,且所有人都在同一个代码库上进行开发,那么你就需要运用精心的设计思路、顺畅的协作流程,以及一些能够保持代码库简洁性的方法。 在选择文件夹结构、应用程序架构以及项目中使用的各种模式时,你也必须十分谨慎、有目的性地进行决策。 大多数Flutter应用程序的开发过程都是类似的。起初,你只需要一个“lib”文件夹、几个界面模板、一个“models”目录,以及一个负责处理所有逻辑的“services”文件即可。这样的结构能够正常运行,应用程序也能顺利发布,大家也会对此感到满意。 但随着应用程序的发展,新的

作为在小型团队中工作的工程师,目前的开发结构或许还可以应付得来。但倘若你的团队规模扩大到20人或更多,且所有人都在同一个代码库上进行开发,那么你就需要运用精心的设计思路、顺畅的协作流程,以及一些能够保持代码库简洁性的方法。

在选择文件夹结构、应用程序架构以及项目中使用的各种模式时,你也必须十分谨慎、有目的性地进行决策。

大多数Flutter应用程序的开发过程都是类似的。起初,你只需要一个“lib”文件夹、几个界面模板、一个“models”目录,以及一个负责处理所有逻辑的“services”文件即可。这样的结构能够正常运行,应用程序也能顺利发布,大家也会对此感到满意。

但随着应用程序的发展,新的功能会不断加入,团队规模也会扩大。原本还算易于管理的代码库很快就会变得杂乱无章——修改其中一个部分往往会引发其他问题,没人能清楚地知道业务逻辑到底存储在什么地方。“models”目录里可能会有三百个文件,“services”文件的代码行数也可能达到六千行,新加入的工程师往往需要花费数周时间才能弄清楚该把新的代码添加到哪里。

这并不是一个纪律问题,而是一个结构设计问题——最初的设计根本无法支撑应用程序的进一步发展而不导致系统崩溃。

“功能模块化”正是能够避免这种问题的组织策略。它并不是一种全新的框架,也不是对“清晰架构”或“领域驱动设计”的替代方案。它是将这两种设计理念的原则结合起来,针对每个具体功能进行应用,从而使应用程序的每个部分都具备独立性、可测试性,并且能够在不互相干扰的情况下实现扩展。

在本文中,我们将探讨使用“分层优先”设计模式时可能会遇到的问题,了解“领域驱动设计”的实际运作原理,学习如何利用值对象来表达业务规则,理解“清晰架构”的含义,以及这些概念是如何共同构成“功能模块化”体系的。

目录

先决条件

在阅读本文之前,您应该已经掌握了以下内容:

  • 使用Dart构建Flutter应用程序

  • 面向对象编程的基本概念:类、继承、接口

  • 软件架构中的层次结构:将用户界面、业务逻辑和数据访问分开

  • Dart中的异步编程:Future与async/await

您不需要具备关于“清洁架构”或“领域驱动设计”的先验经验。本文会在相关内容出现时对其进行介绍。

功能模块化的真正含义

功能模块化是指根据应用程序的功能来组织代码结构,而不是按照技术层次来进行划分。

应用程序中的每一个功能都包含了其运行所需的所有要素:领域模型、业务规则、数据访问逻辑以及用户界面。每个功能都是整个应用程序架构中的一个独立组成部分,它不依赖于其他功能来运行,也不会向其他功能暴露自身的内部实现细节。

这与大多数应用程序最初的组织方式截然不同——在那种方式中,所有与领域相关的代码被放在一个文件夹中,所有数据访问逻辑被放在另一个文件夹里,而用户界面代码则被放在第三个文件夹中,无论这些代码实际上属于哪个功能模块。

功能模块化并不等同于“清洁架构”。清洁架构明确界定了各个技术层次的定义以及它们之间交互的规则,它规定领域层不能依赖于基础设施层,业务规则不能了解用户界面的实现细节,数据流必须通过明确的边界进行传递。

功能模块化也与“领域驱动设计”不同。领域驱动设计为描述复杂的业务逻辑提供了相应的术语体系,它引入了实体、值对象、聚合体、领域服务以及有界上下文等概念,以便将这些复杂的业务逻辑清晰地体现在代码中。

功能模块化结合了清洁架构的层次结构规则与领域驱动设计的建模方法,它在功能模块层面应用这些原则。每个功能模块都会拥有自己独立的领域层、应用层、基础设施层和用户界面层。清洁架构的规则决定了这些层次在功能模块内部是如何相互协作的,而领域驱动设计的概念则规定了领域模型在领域层内部的组织结构。

这样一来,代码库就可以无限扩展,而其中的任何一部分都不会变得过于庞大,从而难以理解或安全地进行修改。

以层次结构为优先的组织方式存在的问题

要理解为什么功能模块化如此重要,就需要了解另一种组织方式在大规模应用中的实际表现。

在以层次结构为优先的组织方式中,代码文件是根据其技术功能来分类存放的:

lib/  
  domain/  
    employee.dart  
    payment.dart  
    product.dart  
    notification.dart  
    user.dart  
    ……还有47个文件  

  application/  
    get_employee_usecase.dart  
    process_payment_usecase.dart  
    get_products/usecase.dart  
    send_notification_usecase.dart  
    ……还有83个用例文件  

  infrastructure/  
    employee_repository_impl.dart  
    payment_repository_impl.dart  
    product_datasource.dart  
    notification_service_impl.dart  
    ……还有91个实现类文件  

  presentation/  
    employee_page.dart  
    payment_page.dart  
    product_list_page.dart  
    ……还有200个界面元素和视图组件

这种架构在小规模应用中运行得很好,但在中等规模的应用中就会开始出现问题,而在大规模应用中则会带来严重的麻烦。

当开发人员需要修改“员工”功能时,他们需要访问四个不同的顶层文件夹中的文件。要修改“领域”文件夹中的“员工”实体相关代码,首先需要进入应用程序文件夹,然后找到存储相关实现代码的基础设施文件夹,最后才能进入负责用户界面的展示文件夹。这些文件夹彼此并不相邻,而是被其他共享这些文件夹的功能所分隔开来。

要想理解任何一个具体的功能,就必须将分散在整个代码库中的相关代码片段重新组合起来。对于新加入团队的工程师来说,要了解“员工”功能的实现机制,就需要先去查看这四个不同的文件夹才能掌握整个系统的结构。

由于各个功能模块之间会共享文件和依赖关系,因此单独测试“员工”功能是很困难的——很难划清它们之间的清晰界限。

功能模块化正是为了解决这个问题而存在的,它能够将属于同一个功能的所有代码和资源集中在一起。

基础架构:简洁架构与DDD的设计理念

在了解文件夹结构和代码实现之前,首先需要明白简洁架构与DDD设计理念各自为功能模块化做出了哪些贡献,以及为什么这两者都是必不可少的。

简洁架构的贡献

简洁架构将代码组织成多个层次,并遵循严格的依赖关系规则:内部层次永远不能依赖于外部层次。“领域”层是最内层的,应用程序层包裹着“领域”层,而基础设施层则是最外层的。

在Flutter应用中,这一原则具体表现为以下几点:

“领域”层包含了实体对象、值对象以及仓库接口。它不依赖于Flutter框架、HTTP库、本地数据库或任何外部包,完全由Dart语言编写,负责实现该功能的具体业务逻辑,且与应用程序的构建或部署方式完全分离。 “应用程序层”包含了各种用例。用例负责协调“领域”层中的对象来完成特定的业务任务,它仅依赖于“领域”层,不了解HTTP、SQLite或Riverpod等外部技术。 “基础设施层”包含了在“领域”层中定义的仓库接口的具体实现代码。这一层涉及HTTP客户端、本地数据库以及外部服务,它依赖于“领域”层的接口,但“领域”层本身永远不会依赖于它。 “展示层”负责处理用户界面,包括各种组件、通知机制以及状态管理。它通过用例与“应用程序层”进行交互,能够根据状态的变化做出相应的响应,但不包含任何业务逻辑代码。

这种明确的依赖关系确保了业务规则不会受到基础设施细节的影响。例如,如果将HTTP客户端从Dio更换为http,完全不需要修改任何与“领域”层相关的代码;同样,如果将状态管理机制从Riverpod更换为BLoC,也无需更改任何用例代码。

领域驱动设计的作用

DDD为正确地对领域层进行建模提供了相应的术语体系。

实体是具有唯一标识的领域对象。它拥有一个能够将其与同一类型的其他实体区分开来的ID。例如,员工实体是通过其员工ID来识别的;即使两个员工的名字相同,但由于他们的ID不同,它们仍然属于不同的实体。实体可以拥有会随时间变化的状态,并且这些实体会遵循某些规则来规定这种状态如何发生变化。

值对象用于封装单一的数据片段,并确保该数据的有效性。EmployeeId就是一个值对象,电子邮件地址或货币金额也同样属于值对象。值对象是不可变的;如果传入的数据无效,那么在创建值对象时就会抛出异常,从而防止无效数据进入领域层。

领域服务用于处理那些不属于任何单一实体的业务逻辑。当某条规则涉及多个实体,或者需要这些实体之间进行协调,而这种协调无法通过单个实体来实现时,那么这类逻辑就应该被放入领域服务中。

仓库是定义在领域层中的一个接口,它规定了如何持久化存储以及检索领域对象。领域层知道自己需要哪些操作,而基础设施层则负责提供具体的实现方式;领域层永远不需要知道仓库背后使用的是哪种数据库或API。

模块化功能中的实体、值对象和数据传输对象

理解这三种类型之间的区别对于实现功能的模块化至关重要;如果搞错了这些区别,就会导致业务规则错误地被嵌入到不合适的层次中。

值对象

值对象用于封装单一的数据片段,并确保该数据的有效性;一旦提供了无效数据,值对象会立即抛出异常。这意味着,在你的领域模型中,永远不可能存在无效数据。

class EmployeeId {
  final String value;

  EmployeeId(this.value) {
    if (value.isEmpty) {
      throw DomainException('员工ID不能为空');
    }
    if (value.length < 4) {
      throw DomainException('员工ID至少需要包含4个字符');
    }
  }

  @override
  bool operator==(Object other) => other is EmployeeId && other.value == value;

  @override
  int get hashCode => value.hashCode;

  @override
  String toString() => value;
}
class Money {
  final double amount;
  final String currency;

  Money({required this.amount, required this.currency}) {
    if (amount < 0) {
      throw DomainException('金额不能为负数');
    }
    if (currency.isEmpty) {
      throw DomainException('货币名称不能为空');
    }
  }

  Money add(Money other) {
    if (currency != other(currency) {
      throw DomainException('不同货币无法相加');
    }
    return Money(amount: amount + other.amount, currency: currency);
  }

  @override
  String toString() => '$currency ${amount.toStringAsFixed(2)}';
}

值对象是不可变的。你永远不能修改一个值对象,而应该创建一个新的值对象。上面提到的`EmployeeId`和`Money`在创建时就会进行验证。在你的应用程序领域中,根本不存在无效的`EmployeeId`,也不可能存在负数的`Money`值。这种约束是内置于这些数据类型的本身之中的。

实体

实体是一种具有唯一标识符,并且其状态变化方式受到特定规则约束的领域对象。

class Employee {
  final EmployeeId id;
  final String name;
  DateTime? clockInTime;
  DateTime? clockOutTime;

  Employee({
    required this.id,
    required this.name,
  });

  void clockIn(DateTime time) {
    if (clockInTime != null && clockOutTime == null) {
      throw DomainException('无法签到:已经签到了');
    }
    clockInTime = time;
    clockOutTime = null;
  }

  void clockOut(DateTime time) {
    if (clockInTime == null) {
      throw DomainException('无法签出:尚未签到');
    }
    if (time.isBefore(clockInTime!)) {
      throw DomainException('签出时间不能早于签到时间');
    }
    clockOutTime = time;
  }

  bool get isClockedIn => clockInTime != null && clockOutTime == null;

  Duration? get hoursWorked {
    if (clockInTime == null || clockOutTime == null) return null;
    return clockOutTime!.difference(clockInTime!);
  }
}

实体自身负责管理其状态的变化过程。`clockIn`和`clockOut`方法并不仅仅是简单的赋值操作,它们实际上是在执行业务规则。员工不可能在没有签出的情况下再次签到,同样也不可能在没有签到的情况下就进行签出操作。这些规则是存在于实体本身之中的,而不是存在于使用场景、用户界面组件或数据存储结构中。

数据传输对象

数据传输对象的作用是从外部来源获取原始数据——比如API返回的结果、数据库中的记录内容或是JSON格式的数据。数据传输对象本身并不包含任何业务规则或行为逻辑,它们的存在仅仅是为了实现数据在不同系统组件之间的传递。

class EmployeeDTO {
  final String id;
  final String name;
  final String? clockInTime;
  final String? clockOutTime;

  const EmployeeDTO({
    required this.id,
    required this.name,
    this.clockInTime,
    thisclockOutTime,
  });

  factory EmployeeDTO.fromJson(Map json) {
    return EmployeeDTO(
      id: json['id'] as String,
      name: json['name'] as String,
      clockInTime: json['clock_in_time'] as String?,
      clockOutTime: json['clock_out_time'] as String?,
    );
  }

  Employee toDomain() {
    final employee = Employee(
      id: EmployeeId(id),
      name: name,
    );

    if (clockInTime != null) {
      employee.clockIn(DateTime.parse(clockInTime!));
    }
    if (clockOutTime != null) {
      employee_clockOut(DateTime.parse(clockOutTime!));
    }

    return employee;
  }
}

数据传输对象属于基础设施层。它了解JSON格式的数据,而领域实体则完全不了解JSON。`toDomain()`方法负责将原始数据转换成结构正确的领域对象,在这个过程中也会同时执行相应的业务规则。

业务规则及其所在的位置

在模块化架构中,每一条业务规则都位于领域层。这并非某种主观选择,而是这种架构设计所必须遵循的原则,只有这样才能确保整个系统的正常运行。

值对象用于维护那些与数据本身相关的、具有原子性且本质性的规则。例如,电子邮件地址必须包含“@”符号;金额不能为负数;员工ID不能为空等等。这些规则直接与数据相关,因此应该被存储在值对象中。

实体则用于维护那些会随时间变化而改变状态的规则。比如,一名员工不能多次打卡上班;已经退款的款项不能再被再次处理;如果某个任务的依赖项尚未完成,那么该任务也无法被视为已完成。这类规则涉及到实体的状态变化,因此应该被存储在实体中。

领域服务用于维护那些涉及多个实体或需要跨多个实体进行协调的规则。例如,员工不能自己批准自己的休假申请;一笔付款在完成退款操作后就不能再次被处理;如果某个任务的依赖项尚未完成,那么该任务也无法被视为已完成。这类规则涉及到多个实体,因此不适合被存储在任何单个实体中,而应该通过领域服务来管理。

这条规则非常简单且绝对:凡是属于业务规则的,就必须被存储在领域层中。应用层会调用领域层中的功能;基础设施层负责实现领域层提供的接口;表示层则会根据处理结果做出相应的响应。不过,这些层次中的任何一个都不具备定义或修改业务规则的功能。

正是这种明确的边界划分,才使得系统具有可靠性。用户无法通过直接访问数据库来绕过业务规则的约束;也无法通过从某个小部件中直接调用存储库的方法来跳过这些规则。因为改变领域状态的唯一途径,就是通过领域对象本身来实现。

异常处理与安全状态传播

当领域层中的某条业务规则被违反时,该领域层会抛出一个DomainException异常。这个异常会依次传递到应用层,最终在表示层被转换成用户界面可以显示的状态。

关键在于,这种异常处理机制能够确保应用程序不会因此崩溃。异常会在预定的边界处被捕获,并被转换为UI能够呈现的安全状态。

以下是整个处理流程的详细说明:

// 领域层 — 当规则被违反时抛出异常
class Employee {
  void clockIn(DateTime time) {
    if (clockInTime != null && clockOutTime == null) {
      throw DomainException('无法打卡上班:已经打卡过了');
    }
    clockInTime = time;
  }
}
// 应用层 — 捕获领域层抛出的异常,并返回处理结果
class ClockInUseCase {
  final EmployeeRepository _repository;

  ClockInUseCase(this._repository);

  Future:> execute(
    String employeeId,
    DateTime time,
  ) async {
    try {
      final employee = await _repository.findById(EmployeeId(employeeId));

      if (employee == null) {
        return Result.failure(AppException.notFound('未找到该员工'));
      }

      employee.clockIn(time);
      await _repository.save(employee);

      return Result.success(employee);
    } on DomainException catch (e) {
      return Result FAILURE(AppException.businessRule(e.message));
    } catch (e) {
      return ResultFAILURE(AppException.unknown(e.toString()));
    }
  }
}
// 表现层——将处理结果转换为UI状态
@riverpod
class EmployeeNotifier extends _$EmployeeNotifier {
  @override
  AsyncValue〈Employee?>> build() => const AsyncData(null);

  Future〈void〉 clockIn(String employeeId) async {
    state = const AsyncLoading();

    final result = await ref
        .read(clockInUseCaseProvider)
        .execute(employeeId, DateTime.now());

    result.fold(
      onSuccess: (employee) => state = AsyncData(employee),
      onFailure: (error) => state = AsyncError(error, StackTrace.current),
    );
  }
}
// 小部件——用于渲染状态,不涉及任何业务逻辑
class EmployeeClockInWidget extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final state = ref.watch(employeeNotifierProvider);

    return state_when(
      data: (employee) => employee != null
          ? ClockInSuccess(employee: employee)
          : const ClockInForm(),
      loading: () => const LoadingIndicator(),
      error: (error, _) =>>ErrorMessage(message: error.toString()),
    );
  }
}

整个处理流程是线性且可预测的:

  1. 用户的操作会触发相应的业务逻辑流程。

  2. 该业务逻辑流程会调用相关的领域模型。

  3. 领域模型会执行相应的规则进行处理。

  4. 如果违反了某条规则,就会抛出DomainException异常。

  5. 业务逻辑流程会捕获这个异常,并返回一个表示操作失败的Result对象。

  6. 通知器会将这个Result对象转换为AsyncError状态。

  7. 小部件会根据这个错误状态来显示相应的错误信息。

在整个处理过程中,异常永远不会被忽略而不得到处理;同时,小部件也从未涉及任何业务逻辑代码。

文件夹结构

有了这些设计概念,文件夹结构也就自然而然地形成了:

lib/
  shared/
    core/
      errors/
        domain_exception.dart
        app_exception.dart
      result/
        result.dart
      di/
        injection.dart
      utils/
        date_utils.dart

  features/
    employee/
      domain/
        entities/
          employee.dart
        value_objects/
          employee_id.dart
        repositories/
          employee_repository.dart
        services/
          attendance_domain_service.dart
        exceptions/
          employee_exceptions.dart

      application/
        use_cases/
          clock_in_usecase.dart
          clock_out/usecase.dart
          get_employee-usecase.dart

      infrastructure/
        datasources/
          employee_remotedatasource.dart
          employee_local_datasource.dart
        repositories/
          employee_repository_impl.dart
        dtos/
          employee_dto.dart

      presentation/
        notifier/
          employeeNotifier.dart
          employee_notifier.g.dart
        state/
          employee_state.dart
        pages/
          employee_dashboard_page.dart
          clock_in_page.dart
        widgets/
          employee_card.dart
          clock_in_button.dart

    payment/
      domain/
        entities/
          payment.dart
        value_objects/
          money.dart
          payment_reference.dart
        repositories/
          payment_repository.dart
        services/
          paymentverification_service.dart

      application/
        use_cases/
          initiate_payment_usecase.dart
          verify_payment/usecase.dart
          refund_payment_usecase.dart

      infrastructure/
        datasources/
          payment_remotedatasource.dart
        repositories/
          payment_repository_impl.dart
        dtos/
          payment_dto.dart

      presentation/
        notifier/
          paymentNotifier.dart
          payment_notifier.g.dart
        pages/
          payment_page.dart
          paymentconfirmation_page.dart
        widgets/
          payment_summary_card.dart

  main.dart

shared/core文件夹中仅包含那些真正需要在所有功能模块之间共享的内容:基础错误类型、Result类型、依赖注入配置,以及那些不包含任何业务逻辑的工具类。其余的所有内容都应该属于某个特定的功能模块。

每个功能模块都构成了一个完整的独立系统结构。该功能模块所需的所有组件都应保存在其对应的文件夹中。例如,负责开发“Employee”功能模块的开发者完全不需要离开features/employee目录,就能理解或修改这个功能模块的相关内容。

实际应用案例一:Employee功能模块

在劳动力管理应用程序中,“Employee”功能模块负责处理员工考勤的整个生命周期,包括签到、签出、班次安排以及考勤记录的存储与管理。

领域模型

// 值对象类
class EmployeeId {
  final String value;

  EmployeeId(this.value) {
    if (value.isEmpty) throw DomainException('员工ID不能为空');
  }
}

class ShiftDuration {
  final Duration duration;

  ShiftDuration(this.duration) {
    if (duration.isNegative) {
      throw DomainException('班次时长不能为负数');
    }
    if (duration.inHours > 16) {
      throw DomainException('班次时长不能超过16小时');
    }
  }
}
// 实体类
class Employee {
  final EmployeeId id;
  final String name;
  final String teamId;
  DateTime? clockInTime;
  DateTime? clockOutTime;

  Employee({
    required this.id,
    required this.name,
    required this.teamId,
  });

  void clockIn(DateTime time) {
    if (isClockedIn) {
      throw DomainException('该员工已经签到');
    }
    clockInTime = time;
    clockOutTime = null;
  }

  void clockOut(DateTime time) {
    if (!isClockedIn) {
      throw DomainException('该员工尚未签到');
    }
    if (time.isBefore(clockInTime!)) {
      throw DomainException('签出时间不能早于签到时间');
    }

    final shift = ShiftDuration(time.difference CLOCKInTime!));
    clockOutTime = time;
  }

  bool get isClockedIn => clockInTime != null && clockOutTime == null;

  Duration? get currentShiftDuration {
    if (!isClockedIn) return null;
    return DateTime.now().difference(clockInTime!);
  }
}
// 领域层中的仓库接口
abstract class EmployeeRepository {
  Future findById(EmployeeId id);
  Future> findByTeam(String teamId);
  Future save(Employee employee);
}

应用层

class ClockInUseCase {
  final EmployeeRepository _repository;

  ClockInUseCase(this._repository);

  Future> execute(String employeeId) async {
    try {
      final id = EmployeeId(employeeId);
      final employee = await _repository.findById(id);

      if (employee == null) {
        return Result.failure(
          AppException.notFound('未找到员工 $employeeId'),
        );
      }

      employee.clockIn(DateTime.now());
      await _repository.save(employee);

      return Result.success(employee);
    } on DomainException catch (e) {
      return Result FAILURE(AppException.businessRule(e.message));
    } catch (e) {
      return ResultFAILURE(AppException.unknown(e.toString()));
    }
  }
}

class ClockOutUseCase {
  final EmployeeRepository _repository;

  ClockOutUseCase(this._repository);

  Future> execute(String employeeId) async {
    try {
      final id = EmployeeId(employeeId);
      final employee = await _repository.findById(id);

      if (employee == null) {
        return ResultFAILURE(
          AppException.notFound('未找到员工 $employeeId'),
        );
      }

      employee.clockOut(DateTime.now());
      await _repository.save(employee);

      return Result.success(employee);
    } on DomainException catch (e) {
      return Result FAILURE(AppException.businessRule(e.message));
    } catch (e) {
      return ResultFAILURE(AppException.unknown(e.toString()));
    }
  }
}

基础设施层

class EmployeeDTO {
  final String id;
  final String name;
  final String teamId;
  final String? clockInTime;
  final String? clockOutTime;

  const EmployeeDTO({
    required this.id,
    required this.name,
    required this.teamId,
    this.clockInTime,
    thisclockOutTime,
  });

  factory EmployeeDTO.fromJson(Map json) {
    return EmployeeDTO(
      id: json['id'] as String,
      name: json['name'] as String,
      teamId: json['team_id'] as String,
      clockInTime: json['clock_in_time'] as String?,
      clockOutTime: json['clock_out_time'] as String?,
    );
  }

  Employee toDomain() {
    final employee = Employee(
      id: EmployeeId(id),
      name: name,
      teamId: teamId,
    );

    if (clockInTime != null) {
      employee.clockIn(DateTime.parse(clockInTime!));
    }
    if (clockOutTime != null) {
      employee_clockOut(DateTime.parse(clockOutTime!));
    }

    return employee;
  }

  Map toJson() {
    return {
      'id': id,
      'name': name,
      'team_id': teamId,
      'clock_in_time': clockInTime,
      'clock_out_time': clockOutTime,
    };
  }
}

class EmployeeRepositoryImpl implements EmployeeRepository {
  final EmployeeRemoteDataSource _remote;

  EmployeeRepositoryImpl(this._remote);

  @override
  Future findById(EmployeeId id) async {
    final dto = await _remote.fetchEmployee(id.value);
    return dto?.toDomain();
  }

  @override
  Future> findByTeam(String teamId) async {
    final dtos = await _remote.fetchTeamEmployees(teamId);
    return dtos.map((dto) => dto.toDomain()).ToList();
  }

  @override
  Future save(Employee employee) async {
    final dto = EmployeeDTO(
      id: employee.id.value,
      name: employee.name,
      teamId: employee.teamId,
      clockInTime: employee.clockInTime?.toIso8601String(),
      clockOutTime: employeeclockOutTime?.toIso8601String(),
    );
    await _remote.updateEmployee dto);
  }
}

表现层

@riverpod
class EmployeeNotifier extends _$EmployeeNotifier {
  @override
  AsyncValue build() => const AsyncData(null);

  Future clockIn(String employeeId) async {
    state = const AsyncLoading();

    final result = await ref
        .read(clockInUseCaseProvider)
        .execute(employeeId);

    result.fold(
      onSuccess: (employee) => state = AsyncData(employee),
      onFailure: (error) => state = AsyncError(error, StackTrace.current),
    );
  }

  Future clockOut(String employeeId) async {
    state = const AsyncLoading();

    final result = await ref
        .read(clockOutUseCaseProvider)
        .execute(employeeId);

    result.fold(
      onSuccess: (employee) => state = AsyncData(employee),
      failures: (error) => state = AsyncError(error, StackTrace.current),
    );
  }
}
class EmployeeDashboardPage extends ConsumerWidget {
  final String employeeId;

  const EmployeeDashboardPage({required this.employeeId, super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final state = ref.watch(employeeNotifierProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('员工仪表板')),
      body: state.when(
        data: (employee) => employee != null
            ? EmployeeDashboardContent(
                employee: employee,
                onClockIn: () => ref
                    .read(employeeNotifierProvider.notifier)
                    .clockIn(employeeId),
                onClockOut: () => ref
                    .read(employeeNotifierProvider.notifier)
                    .clockOut(employeeId),
              )
            : const EmptyDashboard(),
        loading: () => const Center(child: CharSequence()),
        error: (error, _) => ErrorView(message: error.toString()),
      ),
    );
  }
}

这个部件对考勤操作、EmployeeId值对象以及DomainException异常一无所知。它只是显示当前的状态,并将相关的操作委托给通知器来处理。其他所有的逻辑都是在更低层的模块中实现的。

实际应用案例二:支付功能

支付功能负责处理整个支付流程,包括启动、处理、验证以及异常处理。这是一个业务规则非常复杂的领域,明确地对这些规则进行建模能够带来巨大的好处。

业务领域

enum PaymentStatus {
  pending,
  processing,
  completed,
  failed,
  refunded,
}

class PaymentReference {
  final String value;

  PaymentReference(this.value) {
    if (value.isEmpty) {
      throw DomainException('支付参考编号不能为空');
    }
    if (!RegExp(r'^[A-Z0-9]{8,16}$').hasMatch(value)) {
      throw DomainException('支付参考编号格式无效');
    }
  }
}

class Payment {
  final PaymentReference reference;
  final Money amount;
  final String payerId;
  final String recipientId;
  PaymentStatus status;
  String? failureReason;
  DateTime? processedAt;

  Payment({
    required this/reference,
    required this.amount,
    required this.payerId,
    required this.recipientId,
    this.status = PaymentStatuspending,
  });

  void startProcessing() {
    if (status != PaymentStatus.pending) {
      throw DomainException(
        '当前状态无法开始处理支付操作: 当前状态为 ${status.name}', 
      );
    }
    status = PaymentStatus.processing;
  }

  void complete() {
    if (status != PaymentStatus.processing) {
      throw DomainException(
        '当前状态无法完成支付操作: 当前状态为 ${status.name}', 
      );
    }
    status = PaymentStatuscompleted;
    processedAt = DateTime.now();
  }

  void fail(String reason) {
    if (status != PaymentStatus.processing) {
      throw DomainException(
        '当前状态无法记录失败原因: 当前状态为 ${status.name}', 
      );
    }
    status = PaymentStatus.failed;
    failureReason = reason;
  }

  void refund() {
    if (status != PaymentStatuscompleted) {
      throw DomainException(
        '只有已完成支付的交易才能进行退款操作, 
      );
    }
    status = PaymentStatus/refunded;
  }

  bool get canBeRefunded => status == PaymentStatus_completed;
  bool get isTerminal =>
      status == PaymentStatus.completed ||
      status == PaymentStatus_FAILED ||
      status == PaymentStatus.refunded;
}

支付实体会确保所有有效的状态转换都能得到正确执行。已经处于处理中的支付请求无法再次被启动;尚未完成的支付请求无法被退款;而已经失败的支付请求也无法被完成。

这些规则被编码到该实体中,并会在状态发生任何变化时被严格执行。

应用层

class InitiatePaymentUseCase {
  final PaymentRepository _repository;

  InitiatePaymentUseCase(this._repository);

  Future〈Result〈Payment, AppException〉>> execute({
    required String reference,
    required double amount,
    required String currency,
    required String payerId,
    required String recipientId,
  }) async {
    try {
      final payment = Payment(
        reference: PaymentReference(reference),
        amount: Money(amount: amount, currency: currency),
        payerId: payerId,
        recipientId: recipientId,
      );

      payment.startProcessing();
      await _repository.save(payment);

      return Result.success(payment);
    } on DomainException catch (e) {
      return Result FAILURE(AppException.businessRule(e.message));
    } catch (e) {
      return ResultFAILURE(AppException.unknown(e.toString()));
    }
  }
}

class VerifyPaymentUseCase {
  final PaymentRepository _repository;
  final PaymentVerificationService _verificationService;

  VerifyPaymentUseCase(this._repository, this._verificationService);

  Future〈Result〈Payment, AppException〉>> execute(String reference) async {
    try {
      final ref = PaymentReference(reference);
      final payment = await _repository.findByReference(ref);

      if (payment == null) {
        return ResultFAILURE(
          AppException.notFound('未找到编号为 $reference 的支付记录'),
        );
      }

      final isVerified = await _verificationService.verify(payment);

      if (isVerified) {
        payment.complete();
      } else {
        payment.fail('验证失败');
      }

      await _repository.save(payment);
      return Result.success(payment);
    } on DomainException catch (e) {
      return ResultFAILURE(AppException.businessRule(e.message));
    } catch (e) {
      return Result_FAILURE(AppException.unknown(e.toString()));
    }
  }
}

表示层

@riverpod
class PaymentNotifier extends _$PaymentNotifier {
  @override
  AsyncValue〈Payment?>> build() => const AsyncData(null);

  Future〈void〉 initiatePayment({
    required String reference,
    required double amount,
    required String currency,
    required String payerId,
    required String recipientId,
  }) async {
    state = const AsyncLoading();

    final result = await ref.read(initiatePaymentUseCaseProvider).execute(
          reference: reference,
          amount: amount,
          currency: currency,
          payerId: payerId,
          recipientId: recipientId,
        );

    result.fold(
      onSuccess: (payment) => state = AsyncData(payment),
      failures: (error) => state = AsyncError(error, StackTrace.current),
    );
  }

  Future〈void〉 verifyPayment(String reference) async {
    state = const AsyncLoading();

    final result = await ref
        .read(verifyPaymentUseCaseProvider)
        .execute(reference);

    result.fold(
      onSuccess: (payment) => state = AsyncData(payment),
      failures: (error) => state = AsyncError(error, StackTrace.current),
    );
  }
}
class PaymentPage extends ConsumerWidget {
  const PaymentPage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final state = ref.watch(paymentNotifierProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('支付')),
      body: state.when(
        data: (payment) {
          if (payment == null) return const PaymentForm();
          return PaymentStatusView/payment: payment);
        },
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (error, _) => PaymentErrorView(message: error.toString()),
      ),
    );
  }
}

跨功能通信

独立运行的各个功能模块之间必然需要进行通信。例如,“员工”功能可能需要检查用户是否已经完成身份验证;而“支付”功能则可能需要通知“员工”功能关于薪资发放的情况。

跨功能通信时,必须遵循以下规则,以确保各功能模块的独立性:

1. 各功能模块绝不能直接导入彼此的内部代码层。

“员工”功能绝对不能从features/payment/domainentities/payment.dart文件中直接导入代码。功能模块之间的直接导入会导致紧密耦合,这违背了模块化设计的初衷。

2>所有共享的领域概念都应存储在shared/core目录中。

如果“员工”功能和“支付”功能都需要使用诸如UserIdMoney这样的数据结构,那么这些概念就应该放在shared/core/domain目录中,然后由这两个功能模块从中导入相应的代码。

3>跨功能通信是通过定义好的接口来实现的。

当“支付”功能需要处理员工的薪资发放事宜时,它会依赖shared/core中定义的EmployeeService接口;而“员工”功能则负责实现该接口的具体逻辑。 “支付”功能根本不知道这个接口是由哪个功能模块提供的。

4>事件通知机制是通过共享的事件总线来实现的。

当一笔付款完成时,“支付”功能会发布一个PaymentCompleted事件;任何需要响应这笔付款完成的操作都会订阅这个事件。 “支付”功能并不知道有哪些功能模块在监听这个事件,而这些监听模块也并不会直接从“支付”功能中导入代码。

提升模块化程度的设计模式

某些设计模式在模块化的功能结构中能够发挥特别重要的作用。

原型模式在“员工”功能的实现过程中非常有用。例如,一个企业可能会为不同的职位制定标准的员工模板;通过复制这些模板,就可以创建出配置相同的新员工实例,这样就可以确保业务规则能够在新实例的状态转换过程中被正确执行,而不会被忽略。

class Employee {
  Employee clone() {
    return Employee(
      id: EmployeeId(`${id.value}_copy_${DateTime.now().millisecondsSinceEpoch}'),
      name: name,
      teamId: teamId,
    );
  }
}

单例模式适用于那些真正具有唯一性的领域对象。当前经过身份验证的用户就是一个单例实例,当前的会话也是一个单例实例。这些单例对象被放在shared/core目录中,因为它们涉及跨功能的通用逻辑。

仓库模式是每个功能模块中基础设施抽象层的核心。领域层定义接口,基础设施层提供实现细节,而应用层则只需使用这些接口,而无需了解其背后的具体实现方式。

观察者模式通过领域事件实现了跨功能之间的通信,而且这种通信方式并不会导致紧密耦合。当某个领域的状态发生重要变化时,相关功能会发布事件,其他功能则会通过共享的事件总线来订阅这些事件。

面向大型团队的扩展

随着团队规模的扩大,功能模块化的优势会更加明显。当有十名工程师同时开发不同的功能时,每个功能模块的独立性能够有效避免频繁出现的合并冲突。负责开发“员工”功能的工程师与负责开发“支付”功能的工程师几乎永远不会修改相同的文件。

当团队规模达到三十人时,各个功能模块甚至可以被拆分为独立的Dart包。例如,“员工”功能会被归入packages/employee目录,“支付”功能则会被归入packages/payment目录。这些模块都会依赖于共同的packages/core包。每个模块都有自己的pubspec.yaml文件、自己的测试用例,也可以独立进行版本控制。这种模块化方式是功能优先设计理念的自然延伸。

当团队规模进一步扩大到五十人时,各个团队甚至可以完全负责开发某个特定的功能模块。例如,“员工”功能模块由“员工团队”负责开发,“支付”功能模块则由“支付团队”负责开发。通过packages/core目录中定义的接口,这些功能模块之间的边界就被明确地划分出来了。一个团队可以独立发布自己负责的功能模块的新版本,其他团队也可以在准备就绪后及时更新到这个新版本。

今天你们所采用的文件夹结构,同样适用于拥有五十名工程师的大型开发团队。这些设计概念并不会发生变化,只是这些结构的界限会变得更加清晰明了而已。

结论

功能模块化并不是一种简单的文件夹组织方式,而是一项关于如何合理组织软件代码的战略决策。它的目的在于确保软件能够在不断扩展的过程中保持良好的性能和质量。

功能模块化借鉴了“清晰架构”中的分层设计原则以及“领域驱动开发”中的领域建模方法,并将这些理念应用到具体的功能模块层面。每个功能模块都负责管理自己所涉及的领域逻辑、业务规则、数据访问方式以及用户界面。这样的设计能够确保没有任何信息在各个模块之间泄露或交叉影响,因此每个模块都可以被独立地理解、修改和测试,而无需了解整个应用程序的运作机制。

“员工”功能会在其对应的实体中执行考勤规则;“支付”功能则会确保其对应的实体中的交易状态能够按照预定的规则进行转换。这两种功能都使用了值对象来防止无效数据进入系统。同时,它们也都采用了相同的错误处理机制,以便将错误信息安全地从数据层传递到应用层,最终显示在用户界面中。

当“员工”功能出现新的需求时,你只需打开对应的文件夹并进行相应的修改即可;而“支付”功能则无需受到影响。同样地,当需要为“支付”功能添加新的支付方式时,你也只需要修改相关的文件,而“员工”功能依然保持不变。

这就是“模块化设计”的真正含义——它不仅仅是一个带有名称的文件夹,而是你的应用程序中一个独立、完整的组成部分,它拥有自己所需的一切资源,同时也不会与其它部分共享不应该共享的数据。

这就是所谓的“功能模块化”。也正是这种设计方式,使得Flutter应用程序能够由一名工程师单独开发,也可以由五十名工程师共同协作进行开发,而代码库却不会因此变得难以维护或管理。

祝编码愉快!!

相关文章

技术实践

如何在重构旧代码之前设计相应的特性测试用例

许多工程师在继承遗留代码后,首先想要做的就是改进这些代码。 你会遇到一些难以理解的函数,或者看到重复的逻辑、深度嵌套的条件语句、混合了业务规则的数据库调用,以及那些使得测试几乎无法进行的依赖关系。 你知道这些代码本可以做得更好,于是开始对其进行优化。 然而,随后就会出现问题。并不是因为新的实现方式存在明显的错误,而是因为旧的实现方式在背后做了某些人们之前根本不知道它在做的事情。 这就是在遗留代码现代化过程中最常出现的风险之一。 在修改代码之前,你需要有一种方法来回答一个简单的问题: 我是否保留了那些原本就重要的功能行为? 这时,特性测试就派上了用场。 特性测试并不是从“软件应该做什么”这个角度

阅读全文
技术实践

学习Excel公式与函数

Excel仍然是科技和商业领域中最常用、最强大的工具之一,但真正掌握它的公式和功能有时会让人感到不知所措。为了帮助您成为真正的电子表格高手,我们刚刚在freeCodeCamp.org的YouTube频道上发布了一门内容全面的新课程。 这门课程由Sergio讲授,他曾经是亚马逊的软件工程师,也是“Formula Wars”这款工具的创造者。Sergio表示,自己早期掌握的Excel技能帮助他获得了投资银行的实习机会,进而开启了他的软件工程职业生涯。现在,他采用了一种非常实用、以代码为教学方式的互动式学习方法。通过测验和实际操作练习,您将很快巩固对电子表格相关概念的理解。 你将学到的内容 核心功能

阅读全文
技术实践

如何在 Django 中构建能够识别推荐行为的拆分支付流程

当一个产品只有一种结算方式时,支付逻辑通常很简单:向用户收费,将订单标记为已支付,然后完成后续流程。 但一旦商业模式涉及到预付款、后期余额结算,以及中间环节的推荐奖励或优惠券应用,情况就会发生彻底变化。 在这种情况下,你不仅仅是在收取款项,而是在管理整个支付流程。 在本教程中,我将向您展示如何在Django中构建一个能够处理推荐机制的分期支付系统,该系统能够: 分别记录预付款和剩余余额的信息 支持优惠券和应用合作伙伴提供的优惠机制 防止重复支付的发生 安全地使用数据库事务 确保推荐奖励的发放始终一致 只有当整个支付流程完成时,才会解锁相应的功能或结果 核心思想很简单:把支付过程视作一种状态转换

阅读全文