nullables

📁 danielbush/skills 📅 10 days ago
9
总安装量
8
周安装量
#32960
全站排名
安装命令
npx skills add https://github.com/danielbush/skills --skill nullables

Agent 安装分布

gemini-cli 8
claude-code 8
codex 8
opencode 3
github-copilot 3
kimi-cli 3

Skill 文档

Nullables and A-Frame Architecture

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

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.

Core Principles

1. Three-Layer Architecture

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

2. Dual Factory Methods

Every application and infrastructure class implements:

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
  }
}

3. Testing Without Mocks

  • 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

4. Value Objects

  • 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

Nullable algorithm for writing and testing code

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. Most code should be classes except potentially for logic layer code. Foo, Bar, Client are classes; foo is an instance of Foo etc.

Suppose Foo is the class under test or being created.

  • If Foo is application layer or infrastructure layer
    • Foo should be a class
    • Foo.create and injects dependencies into the constructor of Foo;
    • Foo.createNull injects nulled versions of the any infrastructure dependencies for unit tests; “nulled” ensures any infrastructure code is “nulled” and returns a configured response rather than perform an I/O operation (network, disk etc); “nulled” instance never interacts with the outside world.
    • always get instances of foo by calling Foo.create in production code
    • when testing Foo, instantiate it directly foo = new Foo(...) and pass in nulled versions of dependencies: eg foo = new Foo(Bar.createNull(...)).
    • when Foo is a dependency in a unit test, use Foo.createNull.
    • Foo.create should set valid production defaults as much as possible reducing the need to pass parameters to Foo.create
    • an instance foo created by Foo.createNull should execute in exactly the same way, line for line, as one created using Foo.create.
  • If Foo is logic layer code:
    • if it’s stateless, pure functions should be fine; in this case Foo becomes foo (upper case is for classes)
    • 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 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
    • we have 2 scenarios
    • (1) instantiate Bar straight away: ensure Foo.create calls Bar.create and passes the instance to Foos constructor
    • (2) delayed instantiation of Bar: we pass in a factory createBar that returns an instance of Bar and let the instance of Foo create Bar at a later time; we might do this if Bar is only created based on some condition/event at a later.
      • if we are passing multiple infrastructure dependencies we can use a “create” object that we pass in to the constructor: { Bar: Bar.create, Baz: ... } or { Bar: (...) => Bar.create(...), Baz: ... }; foo can then call create.Bar(...) to get a Bar instance at a later time
      • Foo.createNull can then pass in a “create” object with nulled factories so that when foo calls create.Bar(...) it gets a Bar instance created by Bar.createNull
    • If you see Bar.create within an instance of Foo refactor to inject it 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) or (2) like Foo.create.
  • 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.

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

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

Implementation Patterns

Pattern: Infrastructure Wrapper

Wrap third-party code. Use an embedded stub that mimics the third-party library:

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: {} };
  }
}

Pattern: Logic Sandwich

Application code: Read → Process → Write

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);
}

Pattern: Event Emitters for State

Expose state changes for testing:

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));

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