nullables-refactor
Nullables Refactor
Analyze a file and produce a step-by-step refactoring plan to make OUTSIDE_WORLD code nullable.
Vocabulary
This skill uses terms from references/vocabulary.md. Key terms: PURE, IN_MEMORY, OUTSIDE_WORLD, INFRASTRUCTURE_WRAPPER, NULLABLE_CLASS, HARDWIRED_INFRA, INJECTED_INFRA, CREATE_BOUNDARY_RULE, DUAL_FACTORY, EMBEDDED_STUB, NULLABLE, FACTORY_OBJECT, DELAYED_INSTANTIATION, CONFIGURABLE_RESPONSE, OUTPUT_TRACKING, VALUE_OBJECT.
How We Break Down The World
Code is classified by where its side effects reach:
| Category | Side effects | Nullable treatment needed? | Examples |
|---|---|---|---|
| PURE | None | No | Computations, transformations, formatters |
| IN_MEMORY | Mutates things passed in or held in memory | No | DOM manipulation, mutable data structures, in-memory state |
| OUTSIDE_WORLD | Crosses the process boundary (I/O) | Yes | Network calls, disk access, database queries, environment reads |
The nullables pattern specifically targets OUTSIDE_WORLD code. PURE and IN_MEMORY code is fine as-is.
Within OUTSIDE_WORLD code, there are two kinds of entity:
| Entity | Role | Has DUAL_FACTORY? | Contains I/O directly? |
|---|---|---|---|
| INFRASTRUCTURE_WRAPPER | Wraps one external system. Leaf of the dependency graph. | Yes — owns the EMBEDDED_STUB | Yes — that's its job |
| NULLABLE_CLASS | Orchestrates injected dependencies that talk to the outside world. | Yes — injects NULLABLEs in .createNull() |
No — receives wrappers via injection |
An application-level class is effectively a high-level NULLABLE_CLASS because it holds instances that ultimately reach the outside world.
Code that doesn't talk to the outside world:
| Entity | Treatment |
|---|---|
| PURE functions/classes | No special treatment. Plain functions or classes with .create() if stateful. |
| VALUE_OBJECT | .create() + .createTestInstance(). No .createNull(). Mutable VALUE_OBJECTs are IN_MEMORY, not OUTSIDE_WORLD. |
Input
The user provides a file path (or file contents). The agent reads the file, analyzes it, and produces a plan. The agent does NOT execute the plan without confirmation.
Analysis Algorithm
For each exported class, function, or significant code unit in the file:
Step 1: Classify by side-effect boundary
For each code unit, ask: where do its side effects reach?
- PURE — no side effects. Same inputs → same outputs, touches nothing else.
- IN_MEMORY — mutates things passed to it or held in memory (DOM nodes, data structures), but never crosses the process boundary.
- OUTSIDE_WORLD — performs I/O across the process boundary (network, disk, database, environment, third-party services).
Step 2: For OUTSIDE_WORLD code — INFRASTRUCTURE_WRAPPER or NULLABLE_CLASS?
Skip to Step 3 for PURE or IN_MEMORY code.
This is the critical first question for any OUTSIDE_WORLD code unit. Before checking anything else:
If it's a standalone function with OUTSIDE_WORLD side effects
This is HARDWIRED_INFRA. A function that performs I/O should become an INFRASTRUCTURE_WRAPPER class:
- Recommend: convert to a class with DUAL_FACTORY (
.create()/.createNull()). - The class wraps the external system and provides a clean API.
.createNull()uses an EMBEDDED_STUB to replace the real I/O.- Name it descriptively:
HttpClient,FileStore,DatabaseRepo, etc. - The class should provide data in the form the application needs, not the external system's raw format.
If it's a class — determine its role:
- Is its sole purpose to wrap one external system (e.g., HTTP, database, filesystem)? → It should be an INFRASTRUCTURE_WRAPPER. It owns the EMBEDDED_STUB, provides CONFIGURABLE_RESPONSE, and is the leaf of the dependency graph.
- Does it have business logic or orchestration AND also contain I/O calls? → It is a NULLABLE_CLASS that has HARDWIRED_INFRA. The I/O should be extracted into a separate INFRASTRUCTURE_WRAPPER and injected into this class.
The difference matters: an INFRASTRUCTURE_WRAPPER contains the external calls and stubs them internally. A NULLABLE_CLASS uses INFRASTRUCTURE_WRAPPERs via injection and gets NULLABLE versions in tests.
Then check the following:
-
HARDWIRED_INFRA?
- Scan for any OUTSIDE_WORLD calls used directly inside the class (imported and called inline rather than injected). These are HARDWIRED_INFRA.
- PURE and IN_MEMORY code is fine — only flag code that crosses the process boundary.
- Recommend: extract into an INFRASTRUCTURE_WRAPPER and inject through CREATE_BOUNDARY_RULE.
- This check comes first because it often reshapes the class — the remaining checks apply to the class after extraction.
-
Has DUAL_FACTORY?
- Does the class have a static
.create()method? If not, flag it. - Does the class have a static
.createNull()method? If not, flag it. - Does
.createNull()accept CONFIGURABLE_RESPONSE parameters? If it has OUTSIDE_WORLD dependencies, it should.
- Does the class have a static
-
CREATE_BOUNDARY_RULE compliance?
- Scan
.create(): every OUTSIDE_WORLD dependency should be instantiated viaDependency.create(), notnew Dependency(). - These calls must be lexically inside the static
.create()method — this is the CREATE_BOUNDARY_RULE. Not in instance methods, not in the constructor. - Scan
.createNull(): same rule, usingDependency.createNull(). - CONFIGURABLE_RESPONSE parameters from the outer
.createNull()should flow down to inner.createNull()calls where appropriate. - If an instance method or constructor calls
SomeClass.create(), this is a CREATE_BOUNDARY_RULE violation. Two remedies:- Immediate instantiation: move the
.create()call into the static.create()and inject the instance via constructor. - DELAYED_INSTANTIATION: if the dependency is only needed conditionally or after an event, pass a FACTORY_OBJECT via the constructor instead.
- Immediate instantiation: move the
- Scan
-
Decide on DELAYED_INSTANTIATION
- For each dependency, ask: is it always needed, or only under certain conditions?
- Always needed → immediate instantiation in
.create(). - Conditionally needed (based on runtime state, events, user input) → DELAYED_INSTANTIATION via FACTORY_OBJECT.
- If multiple delayed dependencies exist, group them into a single FACTORY_OBJECT:
{ Bar: (...) => Bar.create(...), Baz: (...) => Baz.create(...) }.
-
OUTPUT_TRACKING needed?
- If the class writes to external systems, recommend adding event emission and
trackX()methods for testability.
- If the class writes to external systems, recommend adding event emission and
Step 3: For PURE, IN_MEMORY, and VALUE_OBJECT code
PURE or IN_MEMORY code
- No nullable treatment needed.
- If stateless → plain functions are fine.
- If stateful with IN_MEMORY mutation → a class with
.create()is fine. No.createNull()needed. - Flag any OUTSIDE_WORLD calls found here — they're HARDWIRED_INFRA that should be extracted.
VALUE_OBJECT
- Should have
.create()(may require all params). - Should have
.createTestInstance()with convenient defaults. - No
.createNull()needed. - Mutable VALUE_OBJECTs doing IN_MEMORY work are fine.
- Flag any OUTSIDE_WORLD operations — they don't belong here.
Step 3: Check for third-party framework interactions
- If the code uses a DI framework (e.g., effect-ts), flag it. The boundary between framework-managed injection and DUAL_FACTORY needs case-by-case discussion.
- If the code uses a third-party library for I/O (e.g., tanstack-query, axios), note whether the library provides a test client. Recommend how it fits into the DUAL_FACTORY architecture.
Step 4: Identify the dependency graph
- List all dependencies the code creates or uses.
- For each dependency, note whether it already has DUAL_FACTORY.
- If not, flag it — the refactoring may need to recurse into those dependencies.
- Order the plan so that leaf dependencies (INFRASTRUCTURE_WRAPPERs) are refactored first, then work up to Application layer.
Output Format
Present the plan as:
## Refactoring Plan: <filename>
### Classification
| Code Unit | Side Effects | Entity Type | Current State | Target State |
|-----------|-------------|-------------|--------------|--------------|
| ... | PURE / IN_MEMORY / OUTSIDE_WORLD | INFRASTRUCTURE_WRAPPER / NULLABLE_CLASS / PURE / VALUE_OBJECT | ... | ... |
### Issues Found
1. **[HARDWIRED_INFRA | CREATE_BOUNDARY_RULE_VIOLATION | MISSING_DUAL_FACTORY | ...]** `CodeUnit` — description
- **Recommendation**: what to do
- **Why**: brief rationale
### Dependency Graph
- `AppClass` → `ServiceClass` → `HttpClient` (leaf)
- Refactor order: HttpClient → ServiceClass → AppClass
### Steps
1. ...
2. ...
3. ...
### Questions for the human
- Any decisions that need human input (e.g., naming, DELAYED_INSTANTIATION vs immediate, third-party framework boundaries)
After Refactoring
Once the refactoring plan has been executed, use the nullables-test skill to write tests. That skill checks that:
- All HARDWIRED_INFRA has been replaced by INJECTED_INFRA
- Every piece of INJECTED_INFRA has
.createNull()(recursing into dependencies if needed) - The class under test is ready for narrow, sociable, state-based tests via
.createNull()
What This Skill Does NOT Do
- Does not execute the refactoring — it produces the plan for the human to review.
- Does not write tests — use
nullables-testafter refactoring is complete. - Does not make decisions about third-party framework boundaries — it flags them for discussion.
More from danielbush/skills
nullables
Guide for implementing James Shore's Nullables pattern and A-Frame architecture for testing without mocks. Use when implementing or refactoring code to follow patterns of: (1) Separating logic, infrastructure, and application layers, (2) Creating testable infrastructure with create/createNull factory methods, (3) Writing narrow, sociable, state-based tests without mocks, (4) Implementing value objects, (5) Building infrastructure wrappers that use embedded stubs, or (6) Designing dependency injection through static factory methods.
15work-tracker
Create and manage work items, tickets, and tracking artifacts in a project's work/ directory. Also handles session continuity, summarisation, and searching past work. Supports unsupervised tickets — self-contained work items queued for autonomous agent execution. Use when the human wants to: create/move/scan work items, review the backlog, summarise a session, recall past work, continue from a previous session ('where were we', 'let's continue'), or queue work for an unsupervised agent. Bootstraps work/ on first use.
15nullables-test
Write illustrative tests for code that follows the Nullables pattern. Verifies the class under test is ready (all HARDWIRED_INFRA replaced by INJECTED_INFRA, every dependency has .createNull), then writes narrow, sociable, state-based tests using .createNull(). Tests should illustrate the system's concepts and architecture, not just achieve coverage. Use after applying the nullables-refactor skill, or when writing tests for code that already uses DUAL_FACTORY.
13effect-ts
>
7nullable-architecture
Use when refactoring code or writing tests in the Nullables style: choose between `new`, `.create()`, and `.createNull()`, introduce infrastructure wrappers at the environment boundary, add behavior simulation and output tracking, and write narrow, sociable, example-driven, state-based tests without mocks or spies.
5jcodemunch
Code search and exploration using jcodemunch-mcp via `bunx mcporter`. TRIGGER when: reading code, exploring a codebase, looking up functions/classes/symbols, finding where something is defined or used, understanding how files relate, navigating unfamiliar code, checking what depends on something, investigating imports, tracing call chains, orienting on a repo, answering 'how does X work', 'where is X defined', 'what calls X', 'what would break if I change X', 'show me the code for X', searching across multiple files or repos, or any task that benefits from symbol-aware code intelligence beyond simple grep. Prefer this over raw file reads when exploring code structure, relationships, or usage patterns.
3