nullables
npx skills add https://github.com/danielbush/skills --skill nullables
Agent 安装分布
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
Foois application layer or infrastructure layerFooshould be a classFoo.createand injects dependencies into the constructor ofFoo;Foo.createNullinjects 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
fooby callingFoo.createin production code - when testing
Foo, instantiate it directlyfoo = new Foo(...)and pass in nulled versions of dependencies: egfoo = new Foo(Bar.createNull(...)). - when
Foois a dependency in a unit test, useFoo.createNull. Foo.createshould set valid production defaults as much as possible reducing the need to pass parameters toFoo.create- an instance
foocreated byFoo.createNullshould execute in exactly the same way, line for line, as one created usingFoo.create.
- If
Foois logic layer code:- if it’s stateless, pure functions should be fine; in this case
Foobecomesfoo(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.createNullsince there should be no I/O or network operations
- use
- if it’s stateless, pure functions should be fine; in this case
- If
Valis a value object (immutable or mutable)Valshould be a class- we should have a static
Val.createbut we don’t need aVal.createNullso 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.createmay end up requiring the consumer to specify many or all parameters and should be typed accordingly - use a static
Val.createTestInstanceto create test values; this is to indicate that the value create is a test value; it’s fine for.createTestInstanceto 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
Foousing narrow, sociable unit tests without mocks… - suppose
Barrepresents any another class that an instance ofFooneeds to perform its actions - we have 2 scenarios
- (1) instantiate
Barstraight away: ensureFoo.createcallsBar.createand passes the instance toFoos constructor - (2) delayed instantiation of
Bar: we pass in a factorycreateBarthat returns an instance ofBarand let the instance ofFoocreateBarat a later time; we might do this ifBaris 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: ... };foocan then callcreate.Bar(...)to get aBarinstance at a later time Foo.createNullcan then pass in a “create” object with nulled factories so that whenfoocallscreate.Bar(...)it gets aBarinstance created byBar.createNull
- if we are passing multiple infrastructure dependencies we can use a “create” object that we pass in to the constructor:
- If you see
Bar.createwithin an instance ofFoorefactor to inject it using either (1) or (2) - if you had to create
Bar.create, repeat this process but withBarin place ofFoo - keep recursing as required
- we want to be able to test
- Now repeat the above but for
Foo.createNull(if applicable)- this means
Foo.createNullwill either (1) or (2) likeFoo.create.
- this means
- 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.createand.createNullmaking it into a client we can instantiate;- call this
Client(here) but give it an appropriately descriptive class name egHttpClient,DiskClientetc Clientsole purpose is to interface with the outside world but have minimal business logic- we also call
Clientan “infrastructure wrapper” Clientshould make an effort to provide data to the rest of the application in the form the application needs and no moreClient.createNullshould instantiate a nulled versionClient.createNullmimics 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
.createNullworry 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
Clientis not using a framework like effect-ts to manage errors, then useneverthrowand use an object with a.typefield to discriminate the error at compile time; if there is an underlying error from a throw or a rejection, assign it to.cause
- call this
- value objects (instances of
Val) are often returned byClient‘s - If
Foois infrastructure or application layer code and its methods contain I/O or network operations, replace these operations with a client and inject an instance ofClientviaFoo.createas per the above algorithm.- Where possible make
Foo.creategenerate a default production-ready instance ofClientwithout the need to specifying anything toFoo.create.
- Where possible make
- 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/.createNullarchitecture and let me know
- Now go back to
Foo- write narrow sociable unit tests against instances of
FoousingFoo.createNull
- write narrow sociable unit tests against instances of
- 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
- we should test
- If there are standalone functions that perform I/O or network operations (
foo), favour re-writingfooas a classClientand treating it as a client / infrastructure wrapper and implement.createNullaccordingly. - 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 Clientshould implement anEventEmitteror a similar mechanism using.emitand.on/.off; when some interaction with the outside world (X) occurs, it should emit an event forXClientshould additionally implement atrackXfunction whereXrepresents the outcome we’re tracking.trackXshould return a tracking object (let’s call ittrackerwhich is an instance ofTracker)Trackershould have access to the event emitter in the instance ofClientTrackercan add itself as a listener forXusing the event listener and log the events usually in sequential ordertracker.stop()should getTrackerto remove itselftracker.clear()should clear the previously logged datatracker.datashould 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
Trackerusing the above so tests can track the outcomes
End of algorithm.
Implementing a New Feature
-
Identify the layer:
- Pure computation? â Logic layer
- External system interaction? â Infrastructure layer
- Coordinating workflow? â Application layer
-
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
- Add
-
Implement dependencies through factories:
.create()calls.create()on dependencies.createNull()calls.createNull()on dependencies- Pass instantiated dependencies to constructor
-
Write tests:
- Use
.createNull()for unit tests - Configure responses via factory:
Service.createNull({ response: data }) - Track outputs:
service.trackOutput() - Verify state and return values
- Use
-
Add integration tests (sparingly):
- Use
.create()for a few narrow integration tests - Verify infrastructure wrappers correctly mimic third-party behavior
- Use
Refactoring Existing Code
-
Separate concerns:
- Extract logic into pure functions/classes
- Wrap external dependencies in infrastructure classes
- Create application layer to orchestrate
-
Add factory methods:
- Replace direct instantiation with
.create() - Add
.createNull()to infrastructure and application classes - Update constructors to receive dependencies
- Replace direct instantiation with
-
Replace mocks with nullables:
- Remove mock setup
- Use
.createNull()instances - Configure responses on nullable infrastructure
-
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