nullables

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Nullables and A-Frame Architecture

Nullables模式与A-Frame架构

Overview

概述

This skill guides implementation of James Shore's Nullables pattern and A-Frame architecture. This approach enables fast, reliable testing without mocks through:
  • A-Frame Architecture: Separation of Logic, Infrastructure, and Application layers
  • Nullables Pattern: Production code with an "off switch" for testing
  • Static Factory Methods:
    .create()
    for production,
    .createNull()
    for tests
  • State-Based Testing: Narrow, sociable tests that verify outputs instead of interactions
本技能指导如何实现James Shore的Nullables模式与A-Frame架构。该方法通过以下方式实现快速、可靠的无Mock测试:
  • A-Frame Architecture:分离逻辑层、基础设施层与应用层
  • Nullables Pattern:带有测试用“关闭开关”的生产代码
  • Static Factory Methods
    .create()
    用于生产环境,
    .createNull()
    用于测试环境
  • State-Based Testing:窄范围、社交型测试,验证输出而非交互过程

When to Use This Pattern

适用场景

Apply this pattern when:
  • If the code uses a dependency injection framework (eg effect-ts) then use it to perform injections; also inform me where this happens as we might need to think about where the boundary lies between a 3rd party injection approach and the use of Nullables (
    .create
    /
    .createNull
    .
  • Writing new code
  • Refactoring existing code to eliminate mocks and improve test reliability
  • Writing tests for code that uses nullable patterns such as classes that use static factory method
    .create
    .
在以下场景中应用此模式:
  • 如果代码使用依赖注入框架(如effect-ts),则使用该框架进行注入;同时请告知注入发生的位置,因为我们可能需要考虑第三方注入方式与Nullables(
    .create
    /
    .createNull
    )的边界。
  • 编写新代码时
  • 重构现有代码以消除Mock并提升测试可靠性时
  • 为使用可空模式的代码(如使用静态工厂方法
    .create
    的类)编写测试时

Core Principles

核心原则

1. Three-Layer Architecture

1. 三层架构

Organize code into distinct layers:
  • Logic: Pure business logic and value objects (no external dependencies)
  • Infrastructure: Code interfacing with external systems (network, filesystem, databases)
  • Application: High-level orchestration coordinating logic and infrastructure
    • application code is effectively infrastructure code because it contains instances of instrastructure that talk to the outside world
将代码组织为不同的层级:
  • Logic:纯业务逻辑与值对象(无外部依赖)
  • Infrastructure:与外部系统交互的代码(网络、文件系统、数据库)
  • Application:协调逻辑层与基础设施层的高层编排代码
    • 应用层代码本质上属于基础设施层,因为它包含与外部世界交互的基础设施实例

2. Dual Factory Methods

2. 双工厂方法

Every application and infrastructure class implements:
javascript
class ServiceClass {
  static create(config) {
    // Production instance - calls .create() on dependencies
    const dependency = Dependency.create();
    return new ServiceClass(dependency);
  }

  static createNull(config = {}) {
    // Test instance - calls .createNull() on dependencies
    const dependency = Dependency.createNull();
    return new ServiceClass(dependency);
  }

  constructor(dependency) {
    // Constructor receives instantiated dependencies
  }
}
每个应用层与基础设施层类都需实现:
javascript
class ServiceClass {
  static create(config) {
    // 生产环境实例 - 调用依赖的.create()
    const dependency = Dependency.create();
    return new ServiceClass(dependency);
  }

  static createNull(config = {}) {
    // 测试环境实例 - 调用依赖的.createNull()
    const dependency = Dependency.createNull();
    return new ServiceClass(dependency);
  }

  constructor(dependency) {
    // 构造函数接收实例化后的依赖
  }
}

3. Testing Without Mocks

3. 无Mock测试

  • Unit tests: Use
    .createNull()
    - fast, sociable, no external calls to the outside world
  • Integration tests: Use
    .create()
    sparingly - verify live infrastructure
  • Configure responses via factory:
    Service.createNull({ response: data })
  • Track outputs:
    service.trackSentEmails()
  • Verify state and outputs, not interaction patterns
  • 单元测试:使用
    .createNull()
    - 快速、社交型,无外部系统调用
  • 集成测试:谨慎使用
    .create()
    - 验证真实基础设施
  • 通过工厂配置响应:
    Service.createNull({ response: data })
  • 跟踪输出:
    service.trackSentEmails()
  • 验证状态与输出,而非交互模式

4. Value Objects

4. 值对象

  • Immutable by default - represent data without behavior
  • Mutable value objects allowed - use methods for in-memory transformations only
  • Infrastructure operations (network, I/O) should take/return value objects, not be methods on them
  • 默认不可变 - 仅表示数据,无业务行为
  • 允许可变值对象 - 仅用于内存内转换的方法
  • 基础设施操作(网络、I/O)应接收/返回值对象,而非将其作为值对象的方法

Nullable algorithm for writing and testing code

编写与测试代码的Nullable算法

Suppose
Foo
is the class under test. We will use names like
Foo
,
foo
,
Bar
,
Client
etc for the purposes of describing this algorithm, but more descriptive names should be used in the code.
The use of static methods
Foo.create
and
Foo.createNull
for classes in the code is similar to a dependency injection approach with the addition of creating nulled instances via
Foo.createNull
for unit tests that pretend to talk to the outside world.
  • If
    Foo
    is application layer or infrastructure layer
    • Foo
      should be a class (the only exception might be logic code where a function might suffice, in which case call it
      foo
      )
    • most code should be classes except potentially for logic code
    • always use a static .create in order to instantiate:
      Foo.create,
      Bar.create
    • Foo.create
      should set valid production defaults as much as possible reducing the need to pass parameters to
      Foo.create
  • If
    Foo
    is is logic layer code:
    • if it's stateless, pure functions should be fine so it would be
      foo
      not
      Foo
    • if it involves non-I/O non-network in-memory manipulation, a class may be suitable
    • use
      Foo.create
    • no need to create
      Foo.createNull
      since there should be no I/O or network operations
  • If
    Val
    is a a value object (immutable or mutable)
    • Val
      should be a class
    • we should have a static
      Val.create
      but we don't need a
      Val.createNull
      so some of the procedures below may not apply; this is because value objects shouldn't be doing I/O operations so we don't need nulled versions
    • it may not make sense to set defaults for all parameters when creating value objects; so
      Val.create
      may end up requiring the consumer to specify many or all parameters and should be typed accordingly
    • use a static
      Val.createTestInstance
      to create test values; this is to indicate that the value create is a test value; it's fine for
      .createTestInstance
      to set defaults to make tests less verbose
The following is the algorithm for refactoring and creating new code. It doesn't need to be followed precisely, it is meant to convery the end result.
  • Find the code (eg a class
    Foo
    ) that you think is the most important to write or test
    • we want to be able to test
      Foo
      using narrow, sociable unit tests without mocks...
    • suppose
      Bar
      represents any another class that an instance of
      Foo
      needs to perform its actions
    • take class
      Foo
    • (1) ensure
      Foo.create
      calls
      Bar.create
      and passes the instance to
      Foo
      s constructor
    • (2) or: we pass in a factory
      createBar
      that returns an instance of
      Bar
      and let the instance of
      Foo
      create
      Bar
      at a later time
    • take note of calls to instances of
      Bar
      within
      Foo's
      instance code and refactor to inject them using either (1) or (2)
    • if you had to create
      Bar.create
      , repeat this process but with
      Bar
      in place of
      Foo
    • keep recursing as required
  • Now repeat the above but for
    Foo.createNull
    (if applicable)
    • this means
      Foo.createNull
      will either (1) call
      Bar.createNull
      and pass to
      Foo
      s constructor (2) or it will pass
      createBar
      and this version of
      createBar
      will call
      Bar.createNull
    • the point of
      .createNull
      is to ensure any infrastructure code is "nulled" and returns a configured response rather than perform an I/O operation (network, disk etc)
  • Code that directly makes I/O calls to access an external service or resource such as the network (eg
    fetch
    ) or disk etc is infrastrucure layer code and should be wrapped up in a class with
    .create
    and
    .createNull
    making it into a client we can instantiate;
    • call this
      Client
      (here) but give it an appropriately descriptive class name eg
      HttpClient
      ,
      DiskClient
      etc
    • Client
      sole purpose is to interface with the outside world but have minimal business logic
    • we also call
      Client
      an "infrastructure wrapper"
    • Client
      should make an effort to provide data to the rest of the application in the form the application needs and no more
    • Client.createNull
      should instantiate a nulled version
    • Client.createNull
      mimics I/O calls (eg mimics calls to fetch)
      • by default it should return a default "happy-path" response
      • it should take parameters (use a param object) that can configure the responses ("configurable response parameters")
      • "configurable response parameters" should configure for possible responses or outcomes we might have to test for; as a human reading the tests I should be able to easily see at a glance what outcome is being configured for in the nulled object; let the implementation within
        .createNull
        worry about the details
    • in some situations an "embedded stub" might be appropriate that stubs out a built-in I/O or network operation; the embedded stub should be configued within
      Client.createNull
      .
    • if
      Client
      is not using a framework like effect-ts to manage errors, then use
      neverthrow
      and use an object with a
      .type
      field to discriminate the error at compile time; if there is an underlying error from a throw or a rejection, assign it to
      .cause
  • value objects (instances of
    Val
    ) are often returned by
    Client
    's
  • If
    Foo
    is infrastructure or application layer code and its methods contain I/O or network operations, replace these operations with a client and inject an instance of
    Client
    via
    Foo.create
    as per the above algorithm.
    • Where possible make
      Foo.create
      generate a default production-ready instance of
      Client
      without the need to specifying anything to
      Foo.create
      .
  • If the code makes use of a 3rd party library to perform I/O eg tanstack-query / react-query / effect.runPromise etc, then...
    • determine if this library provides a test client and use it for the tests
    • think about how it should be incorporated into the
      .create
      /
      .createNull
      architecture and let me know
  • Now go back to
    Foo
    • write narrow sociable unit tests against instances of
      Foo
      using
      Foo.createNull
  • For insfrastructure wrapper code (
    Client
    ),
    • we should test
      Client.createNull
    • in a separate folder designated for live integration tests we should also test
      Client.create
    • these shouldn't be too numerous, we're testing against a live service to test the actual non-nulled code and also that we get back the responses we expect
    • these live integration tests should be run in a separate command to the unit tests above
  • If there are standalone functions that perform I/O or network operations (
    foo
    ), favour re-writing
    foo
    as a class
    Client
    and treating it as a client / infrastructure wrapper and implement
    .createNull
    accordingly.
  • Sometimes we want to know that changes to the outside world occurred in tests, maybe also in a certain order.
    • this allows us to keep tests state-based and outcome-focused rather than relying on mocks and testing internal interactions
    • Usually this happens in a
      Client
    • Client
      should implement an
      EventEmitter
      or a similar mechanism using
      .emit
      and
      .on
      /
      .off
      ; when some interaction with the outside world (
      X
      ) occurs, it should emit an event for
      X
    • Client
      should additionally implement a
      trackX
      function where
      X
      represents the outcome we're tracking
    • .trackX
      should return a tracking object (let's call it
      tracker
      which is an instance of
      Tracker
      )
    • Tracker
      should have access to the event emitter in the instance of
      Client
    • Tracker
      can add itself as a listener for
      X
      using the event listener and log the events usually in sequential order
    • tracker.stop()
      should get
      Tracker
      to remove itself
    • tracker.clear()
      should clear the previously logged data
    • tracker.data
      should be a getter to access the data
    • in some cases, tracking may be useful to the application, in which this event emitter should be exposed publicly; we can still implement a
      Tracker
      using the above so tests can track the outcomes
End of algorithm.
假设
Foo
是被测类。为描述该算法,我们将使用
Foo
foo
Bar
Client
等名称,但在实际代码中应使用更具描述性的名称。
代码中类使用静态方法
Foo.create
Foo.createNull
的方式类似于依赖注入,区别在于通过
Foo.createNull
创建空实例,用于模拟与外部世界交互的单元测试。
  • 如果
    Foo
    属于应用层或基础设施层
    • Foo
      应为类(唯一例外是逻辑层代码,函数可能足够,此时命名为
      foo
    • 除逻辑层代码外,大部分代码应为类
    • 始终使用静态
      .create
      进行实例化:
      Foo.create,
      Bar.create
    • Foo.create
      应设置有效的生产环境默认值,尽可能减少调用
      Foo.create
      时所需传递的参数
  • 如果
    Foo
    属于逻辑层代码:
    • 如果是无状态的纯函数,则使用
      foo
      而非
      Foo
    • 如果涉及非I/O、非网络的内存内操作,类可能更合适
    • 使用
      Foo.create
    • 无需创建
      Foo.createNull
      ,因为逻辑层不应包含I/O或网络操作
  • 如果
    Val
    是值对象(不可变或可变)
    • Val
      应为类
    • 应实现静态
      Val.create
      ,但无需
      Val.createNull
      ,因此以下部分流程可能不适用;这是因为值对象不应执行I/O操作,因此无需空版本
    • 创建值对象时,可能无需为所有参数设置默认值;因此
      Val.create
      可能需要消费者指定多个或全部参数,并进行相应的类型定义
    • 使用静态
      Val.createTestInstance
      创建测试用值;这用于表明该值是测试用例,
      .createTestInstance
      可设置默认值以简化测试代码
以下是重构与创建新代码的算法。无需严格遵循,仅用于传达最终目标。
  • 找到你认为最重要的需编写或测试的代码(如类
    Foo
    • 我们希望能够使用窄范围、社交型的单元测试,无需Mock即可测试
      Foo
      ...
    • 假设
      Bar
      代表
      Foo
      实例执行操作所需的任何其他类
    • 针对类
      Foo
    • (1) 确保
      Foo.create
      调用
      Bar.create
      并将实例传递给
      Foo
      的构造函数
    • (2) 或者:传入工厂函数
      createBar
      ,该函数返回
      Bar
      实例,让
      Foo
      实例在后续创建
      Bar
    • 记录
      Foo
      实例代码中对
      Bar
      实例的调用,并使用(1)或(2)的方式重构为依赖注入
    • 如果必须创建
      Bar.create
      ,则重复此流程,将
      Foo
      替换为
      Bar
    • 根据需要递归执行
  • 现在针对
    Foo.createNull
    (如适用)重复上述流程
    • 这意味着
      Foo.createNull
      将(1)调用
      Bar.createNull
      并传递给
      Foo
      的构造函数,或(2)传递
      createBar
      ,而此版本的
      createBar
      将调用
      Bar.createNull
    • .createNull
      的目的是确保所有基础设施代码被“空化”,返回配置好的响应而非执行I/O操作(网络、磁盘等)
  • 直接调用I/O以访问外部服务或资源(如
    fetch
    网络调用或磁盘操作)的代码属于基础设施层,应包装在包含
    .create
    .createNull
    的类中,使其成为可实例化的客户端;
    • 此处命名为
      Client
      ,但实际应使用更具描述性的类名,如
      HttpClient
      DiskClient
    • Client
      的唯一目的是与外部世界交互,仅包含最少的业务逻辑
    • 我们也将
      Client
      称为“基础设施包装器”
    • Client
      应尽可能以应用所需的形式向应用提供数据,不提供额外内容
    • Client.createNull
      应实例化空版本
    • Client.createNull
      模拟I/O调用(如模拟fetch调用)
      • 默认应返回默认的“正常路径”响应
      • 应接收参数(使用参数对象)以配置响应(“可配置响应参数”)
      • “可配置响应参数”应针对我们可能需要测试的响应或结果进行配置;作为阅读测试的人员,我应能够一眼看出空对象配置的是哪种结果;
        .createNull
        内部的实现细节无需关注
    • 在某些情况下,嵌入式存根可能适合替代内置的I/O或网络操作;嵌入式存根应在
      Client.createNull
      中配置
    • 如果
      Client
      未使用effect-ts等框架管理错误,则使用
      neverthrow
      ,并使用包含
      .type
      字段的对象在编译时区分错误;如果存在来自throw或rejection的底层错误,将其分配给
      .cause
  • 值对象(
    Val
    的实例)通常由
    Client
    返回
  • 如果
    Foo
    属于基础设施层或应用层,且其方法包含I/O或网络操作,则将这些操作替换为客户端,并按照上述算法通过
    Foo.create
    注入
    Client
    实例
    • 尽可能让
      Foo.create
      生成默认的生产环境就绪
      Client
      实例,无需向
      Foo.create
      传递任何参数
  • 如果代码使用第三方库执行I/O,如tanstack-query / react-query / effect.runPromise等,则...
    • 确定该库是否提供测试客户端,并在测试中使用
    • 思考如何将其整合到
      .create
      /
      .createNull
      架构中,并告知我
  • 现在回到
    Foo
    • 使用
      Foo.createNull
      编写针对
      Foo
      实例的窄范围社交型单元测试
  • 对于基础设施包装器代码(
    Client
    • 我们应测试
      Client.createNull
    • 在专门用于实时集成测试的单独文件夹中,还应测试
      Client.create
    • 这类测试不应过多,我们针对真实服务测试实际的非空代码,并验证返回的响应符合预期
    • 这些实时集成测试应与上述单元测试分开运行
  • 如果存在执行I/O或网络操作的独立函数(
    foo
    ),建议将
    foo
    重写为类
    Client
    ,将其视为客户端/基础设施包装器,并相应实现
    .createNull
  • 有时我们希望在测试中了解外部世界的变化,甚至是变化的顺序
    • 这使我们能够保持测试基于状态和结果,而非依赖Mock和测试内部交互
    • 通常这发生在
      Client
    • Client
      应实现
      EventEmitter
      或类似机制,使用
      .emit
      .on
      /
      .off
      ;当与外部世界发生某些交互(
      X
      )时,应触发
      X
      事件
    • Client
      还应实现
      trackX
      函数,其中
      X
      代表我们要跟踪的结果
    • .trackX
      应返回一个跟踪对象(我们称之为
      tracker
      ,即
      Tracker
      的实例)
    • Tracker
      应能访问
      Client
      实例中的事件发射器
    • Tracker
      可使用事件监听器将自身添加为
      X
      的监听器,并通常按顺序记录事件
    • tracker.stop()
      应让
      Tracker
      移除自身的监听
    • tracker.clear()
      应清除之前记录的数据
    • tracker.data
      应为访问数据的getter
    • 在某些情况下,跟踪功能可能对应用有用,此时事件发射器应公开;我们仍可使用上述方式实现
      Tracker
      ,以便测试跟踪结果
算法结束。

Implementing a New Feature

实现新功能

  1. Identify the layer:
    • Pure computation? → Logic layer
    • External system interaction? → Infrastructure layer
    • Coordinating workflow? → Application layer
  2. Create the class with factory methods:
    • Add
      static create()
      with reasonable defaults
    • Add
      static createNull()
      for infrastructure and application classes
    • Use constructor for dependency injection
  3. Implement dependencies through factories:
    • .create()
      calls
      .create()
      on dependencies
    • .createNull()
      calls
      .createNull()
      on dependencies
    • Pass instantiated dependencies to constructor
  4. Write tests:
    • Use
      .createNull()
      for unit tests
    • Configure responses via factory:
      Service.createNull({ response: data })
    • Track outputs:
      service.trackOutput()
    • Verify state and return values
  5. Add integration tests (sparingly):
    • Use
      .create()
      for a few narrow integration tests
    • Verify infrastructure wrappers correctly mimic third-party behavior
  1. 确定层级:
    • 纯计算?→ 逻辑层
    • 与外部系统交互?→ 基础设施层
    • 协调工作流?→ 应用层
  2. 创建带工厂方法的类:
    • 添加带有合理默认值的
      static create()
    • 为基础设施层与应用层类添加
      static createNull()
    • 使用构造函数进行依赖注入
  3. 通过工厂实现依赖:
    • .create()
      调用依赖的
      .create()
    • .createNull()
      调用依赖的
      .createNull()
    • 将实例化后的依赖传递给构造函数
  4. 编写测试:
    • 使用
      .createNull()
      进行单元测试
    • 通过工厂配置响应:
      Service.createNull({ response: data })
    • 跟踪输出:
      service.trackOutput()
    • 验证状态与返回值
  5. 添加集成测试(谨慎使用):
    • 使用
      .create()
      编写少量窄范围集成测试
    • 验证基础设施包装器是否正确模拟第三方行为

Refactoring Existing Code

重构现有代码

  1. Separate concerns:
    • Extract logic into pure functions/classes
    • Wrap external dependencies in infrastructure classes
    • Create application layer to orchestrate
  2. Add factory methods:
    • Replace direct instantiation with
      .create()
    • Add
      .createNull()
      to infrastructure and application classes
    • Update constructors to receive dependencies
  3. Replace mocks with nullables:
    • Remove mock setup
    • Use
      .createNull()
      instances
    • Configure responses on nullable infrastructure
  4. Refactor tests to be state-based:
    • Remove interaction assertions (verify/toHaveBeenCalled)
    • Add state and output assertions
    • Use event emitters for observable state changes
  1. 分离关注点:
    • 将逻辑提取为纯函数/类
    • 将外部依赖包装为基础设施类
    • 创建应用层进行协调
  2. 添加工厂方法:
    • .create()
      替换直接实例化
    • 为基础设施层与应用层类添加
      .createNull()
    • 更新构造函数以接收依赖
  3. 用可空对象替换Mock:
    • 移除Mock设置
    • 使用
      .createNull()
      实例
    • 在可空基础设施上配置响应
  4. 将测试重构为基于状态的测试:
    • 移除交互断言(verify/toHaveBeenCalled)
    • 添加状态与输出断言
    • 使用事件发射器处理可观察的状态变化

Implementation Patterns

实现模式

Pattern: Infrastructure Wrapper

模式:基础设施包装器

Wrap third-party code. Use an embedded stub that mimics the third-party library:
javascript
class HttpClient {
  static create() {
    return new HttpClient(realHttp); // Real HTTP library
  }

  static createNull() {
    return new HttpClient(new StubbedHttp()); // Embedded stub
  }

  constructor(http) {
    this._http = http;
  }

  async request(options) {
    return await this._http.request(options);
  }
}

// Embedded stub - mimics third-party library interface
class StubbedHttp {
  async request(options) {
    return { status: 200, body: {} };
  }
}
包装第三方代码。使用嵌入式存根模拟第三方库:
javascript
class HttpClient {
  static create() {
    return new HttpClient(realHttp); // 真实HTTP库
  }

  static createNull() {
    return new HttpClient(new StubbedHttp()); // 嵌入式存根
  }

  constructor(http) {
    this._http = http;
  }

  async request(options) {
    return await this._http.request(options);
  }
}

// 嵌入式存根 - 模拟第三方库接口
class StubbedHttp {
  async request(options) {
    return { status: 200, body: {} };
  }
}

Pattern: Logic Sandwich

模式:逻辑三明治

Application code: Read → Process → Write
javascript
async transfer(fromId, toId, amount) {
  // Read from infrastructure
  const accounts = await this._repo.getAccounts(fromId, toId);

  // Process with logic
  const result = this._logic.transfer(accounts, amount);

  // Write through infrastructure
  await this._repo.saveAccounts(result);
}
应用层代码:读取 → 处理 → 写入
javascript
async transfer(fromId, toId, amount) {
  // 从基础设施读取数据
  const accounts = await this._repo.getAccounts(fromId, toId);

  // 使用逻辑层处理
  const result = this._logic.transfer(accounts, amount);

  // 通过基础设施写入数据
  await this._repo.saveAccounts(result);
}

Pattern: Event Emitters for State

模式:用于状态的事件发射器

Expose state changes for testing:
javascript
class Account extends EventEmitter {
  deposit(amount) {
    this._balance += amount;
    this.emit('balance-changed', { balance: this._balance });
  }
}

// Test by observing events
account.on('balance-changed', (event) => events.push(event));
公开状态变化以用于测试:
javascript
class Account extends EventEmitter {
  deposit(amount) {
    this._balance += amount;
    this.emit('balance-changed', { balance: this._balance });
  }
}

// 通过观察事件进行测试
account.on('balance-changed', (event) => events.push(event));

Detailed Reference

详细参考

For comprehensive implementation details, see nullables-guide.md:
  • Complete A-Frame architecture details
  • Nullables pattern implementation
  • Value objects (immutable and mutable)
  • Testing strategies and examples
  • Infrastructure wrappers and embedded stubs
  • Configurable responses and output tracking
  • Event emitters for state-based tests
  • Implementation checklist
如需完整的实现细节,请查看 nullables-guide.md
  • 完整的A-Frame架构细节
  • Nullables模式实现
  • 值对象(不可变与可变)
  • 测试策略与示例
  • 基础设施包装器与嵌入式存根
  • 可配置响应与输出跟踪
  • 用于基于状态测试的事件发射器
  • 实现检查清单