Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/deep-modules-topics-split.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@fveracoechea/operator": patch
---

Split the `deep-modules` skill into smaller topics, and point `tanstack-tools` at it.

`deep-modules` now states the find-the-feature steps in its router, and moves sub-modules, the entry-file lint rule, and environment and per-request state into topics of their own.
An entry file may export one interface object, and a sub-module follows the same rule one level down.
In `tanstack-tools`, a feature's own components import its query slice through a relative path, and the `$` aggregator is for route loaders and other modules.
2 changes: 1 addition & 1 deletion skills/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ A `SKILL.md` is a router and stays under 150 lines, and every markdown file besi

- **[adr](./adr/SKILL.md)** - Use when recording an architectural decision, when an existing ADR in docs/adr looks stale against the code, or when a change contradicts an ADR.
- **[bun](./bun/SKILL.md)** - Use when writing or reviewing Bun code including runtime, bun test, Bun.serve, bundler, bun install, or scripts.
- **[deep-modules](./deep-modules/SKILL.md)** - Use when adding or changing a feature or capability in a TypeScript app: code for a domain concept, a screen and its data, where new or shared code goes, a module interface or sub-module, or a module test.
- **[deep-modules](./deep-modules/SKILL.md)** - Use when adding or changing a feature in a TypeScript app: a screen and its data, where new or shared code goes, a sub-module, or a module test.
- **[no-slop](./no-slop/SKILL.md)** - Use when writing or editing prose, documentation, code comments, commit messages, or issue and pull request text.
- **[operative](./operative/SKILL.md)** - Use when dispatched for production or rework with a fixed Operator brief.
- **[operator](./operator/SKILL.md)** - Use when preparing a project for Operator, running approved work through a crew, or resuming an Operator session that ended.
Expand Down
48 changes: 30 additions & 18 deletions skills/deep-modules/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,41 +1,51 @@
---
name: deep-modules
description: 'Use when adding or changing a feature or capability in a TypeScript app: code for a domain concept, a screen and its data, where new or shared code goes, a module interface or sub-module, or a module test.'
description: 'Use when adding or changing a feature in a TypeScript app: a screen and its data, where new or shared code goes, a sub-module, or a module test.'
---

# Deep modules

Sort code by feature and capability, never by technical layer or by page.
Each feature and each capability is one folder under `src/modules/<name>/`, and the rest of the app reaches it through one small interface.
The folder holds every file the feature needs, whatever layer or environment the file belongs to.
Each feature and each capability is one folder under `src/modules/<name>/` that holds every file it needs, whatever layer or environment the file belongs to.

- A **feature** is a domain concept the product names, such as invoices, payments or customers. Its module owns the calls to the backend, the reads, the transforms, the cache rules and the UI.
- A **capability** is what features use and users never see, such as auth, the HTTP client for a backend, or a cache.
- A **page** is a route. It owns the URL, the loader and the layout, and places the `UI` members of the features it shows.
- A **layer** is a technical role, such as query options, selectors, hooks or an API client. A layer is never a module name.

Each module is a deep module in John Ousterhout's sense: a small interface in front of a large implementation.
The module pulls complexity down into itself, so each caller knows less.
Each module is a deep module: it pulls complexity down into itself, so each caller knows less.
The `codebase-design` skill holds the words for this: depth, seam, leverage, locality and the deletion test.
Use its words.

**Name the feature first.**
Before you write code, name the domain noun the change is about, and find the module that owns it.
When code for that noun is spread across routes and layers with no module, the feature is missing its module.
[finding-features.md](finding-features.md) is the procedure, and every task starts with it.
## Name the feature first

**The caller learns one object.**
A route, a handler or another module reaches the module through its interface object, such as `Invoices`, `Auth` or `Cache`.
A module that only the browser reaches through server functions has `$functions.ts` as its interface, and no object.
A second exported name is a second interface, and the module gets shallower with each one.
Do this before you write code, on every task.

1. Name the domain noun the change is about. Take it from the ticket, the glossary (`CONTEXT.md`), the wire schemas or the words on the screen. A page or a backend service is not a feature.
2. Search `src/` for the noun in file names, types, functions and strings, and list each place it appears.
3. The step is done when you can name the module that owns the change, or when you have a proposal for a new module with its interface object.

When the noun is spread across routes and layers with no module, the feature is missing its module.
[finding-features.md](finding-features.md) has the signs of a missing module, a worked example, and what to do with the old code.

## The caller learns one object per entry file

A route, a handler or another module reaches a module through its entry files, and each entry file exports one PascalCase interface object, such as `Invoices`, `Auth` or `Cache`.
Beside the object, an entry file may export the types a caller needs to name its inputs and results, such as `Session` beside `Auth`.
Two other entry files carry no object: `schemas.ts` holds the wire schemas, and `$functions.ts` holds a TanStack Start module's server functions.
Any other exported name is a second interface, and the module gets shallower with each one.

A sub-module follows the same rule one level down.
In `chat/turn/`, `main.ts` exports one object, `Turn`, and the rest of `chat/` reaches the turn only through it.
Routes see only `Chat`.
Each level hides its parts from the level above, so a sub-module makes the module deeper ([sub-modules.md](sub-modules.md)).

## Build from the call site down

**Build from the call site down.**
Write the line the caller will contain first, such as `<Invoices.UI.OverdueList />` or `Auth.requireSession()`, then write the implementation behind it.
Code written from the bottom up produces helpers that no module owns.

## Know the environment

The rules hold in every environment.
The entry files differ, and [module-shape.md](module-shape.md) lists the set for each environment.

- **Full-stack**, such as TanStack Start: one module owns both sides of the server boundary and its UI. The framework draws that boundary per file.
Expand All @@ -44,8 +54,10 @@ The entry files differ, and [module-shape.md](module-shape.md) lists the set for

## Pick the task

- **Deciding which module owns a change, or finding a feature that has no module**: [finding-features.md](finding-features.md).
- **Creating a module, adding a file to one, placing a helper, or enforcing the entry points**: [module-shape.md](module-shape.md).
- **Creating a module, adding a file to one, placing a helper, or checking import direction**: [module-shape.md](module-shape.md).
- **Splitting a module into sub-modules, or adding a folder inside one**: [sub-modules.md](sub-modules.md).
- **Setting up the lint rule that guards the entry files**: [entry-file-lint.md](entry-file-lint.md).
- **Reading environment variables or keeping per-request state in a module**: [server-state.md](server-state.md).
- **Designing or reviewing an interface, or deciding whether a module earns its place**: [interface-design.md](interface-design.md).
- **Writing a test for a module, or for code that uses one**: [module-tests.md](module-tests.md).

Expand Down
32 changes: 32 additions & 0 deletions skills/deep-modules/entry-file-lint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Guard the entry files with lint

## Reject an import that skips an entry file

A `no-restricted-imports` pattern, in oxlint or ESLint, rejects every import from outside a module that skips its entry files:

```jsonc
"no-restricted-imports": ["error", {"patterns": [{
"group": [
"@/modules/*/*", "@/modules/*/**", "**/modules/*/*", "**/modules/*/**",
"!@/modules/*/main", "!@/modules/*/main.server", "!@/modules/*/main.client",
"!@/modules/*/$functions", "!@/modules/*/schemas", "!@/modules/*/schemas.generated",
"!**/modules/*/main", "!**/modules/*/main.server", "!**/modules/*/main.client",
"!**/modules/*/$functions", "!**/modules/*/schemas", "!**/modules/*/schemas.generated"
],
"message": "Import a module through its entry files, or put the helper on its interface object. Inside a module, use relative imports."
}]}]
```

Keep only the entry names the repo uses.
The pattern also rejects `@/modules/chat/turn/main`, so a sub-module stays private to its parent.
The pattern is done when a deliberate deep import fails lint, and lint passes again after you revert it.

## Reject a route import from a module

A second `no-restricted-imports` pattern on `src/modules/**` can reject `@/routes/**`, so a module never imports its callers.
Verify it the same way: a deliberate route import from a module fails lint.

## Know what the patterns miss

A relative import escapes both patterns.
Inside a module, a reviewer looks for an import that reaches past a sub-module's `main`, such as `../turn/turn-stream`.
30 changes: 12 additions & 18 deletions skills/deep-modules/finding-features.md
Original file line number Diff line number Diff line change
@@ -1,24 +1,20 @@
# Find the feature that owns a change

## Name the domain noun, not the page or the backend
The step itself, name the noun and search `src/` for it, is in [SKILL.md](SKILL.md).
This file is what you consult while you do it.

A feature is named after something the product and its users talk about: `invoices`, `payments`, `customers`.
Take the noun from the ticket, the glossary (`CONTEXT.md`), the names in the wire schemas, or the words on the screen.
## Tell a feature from a backend service

Two other names look like features and are not:

- A page, such as a dashboard, is a place where several features appear. The route owns the URL, the loader and the layout, and it places each feature's `UI` members.
- A backend service, such as `billing-api`, serves several features. Its transport, session and error mapping are a capability. Each feature's operations belong to that feature.
A backend service, such as `billing-api`, serves several features.
Its transport, session and error mapping are a capability.
Each feature's operations belong to that feature.

A change can touch two features.
Name both, and give each part of the change to its owner.

## Search the tree for the noun before you write code

Search `src/` for the noun in file names, types, functions and strings, and list each place it appears.
The step is done when you can name the module that owns the change, or when you have a proposal for a new module with its interface object.
## A feature can live in five places with no module

For example, in a billing app the noun `invoice` can appear in five places, none of them a module named for it:
In a billing app the noun `invoice` can appear in five places, none of them a module named for it:

| Place | What it knows about invoices |
| ------------------------------------------------- | -------------------------------------------------- |
Expand All @@ -43,9 +39,7 @@ That is an `invoices` module the code base never named.

## Give a feature its module even when one route uses it

A feature module earns its place through what it hides, not through its number of callers.
Remove the module in your head, and see where its code would go.
When its reads, rules and UI would move into a route folder and two layer modules, the module passes the deletion test with one caller.
A feature module with one caller passes the deletion test in [interface-design.md](interface-design.md) when its reads, rules and UI would otherwise move into a route folder and two layer modules.

A page's `-` folder keeps the pieces of that page's own layout, such as the grid that arranges the sections.
Code that knows a domain noun goes in the feature's module from its first line, not after a second route asks for it.
Expand All @@ -55,7 +49,7 @@ Code that knows a domain noun goes in the feature's module from its first line,
- The transport, the session and the error mapping stay as a capability module that every feature uses.
- Each feature module owns its operations, its reads, its transforms, its cache rules and its UI.
- A repo that keeps one aggregator, such as a `$` query-options builder, lets the aggregator import each feature's part from the feature's entry file. The logic stays in the feature.
- The feature's UI reads its own data through a relative path, never back through the aggregator. The aggregator then depends on the feature in one direction only, and no cycle forms between the two modules.
- The aggregator is for route loaders and other modules. The feature's own UI reads its data through a relative path, so the aggregator depends on the feature in one direction only, and no cycle forms between the two modules.

## Follow the repo's ADR where it keeps a layer module

Expand All @@ -66,6 +60,6 @@ Name the conflict in your proposal, so the team can amend the ADR (the `adr` ski
## Put new code in the feature's module, and propose the move for old code

When the repo already spreads a feature across routes and layers, write the new code in the feature's module and create the module when it does not exist yet.
Keep the change you were asked for small.
When the new code needs an operation that still lives in the old place, call it there, and add it to the move.
List the other files that belong to the feature, and give the list to the user as a proposed move, a follow-up ticket or a note in the pull request.
Every place from the search is either in this change or on the move list.
Give the move list to the user as a proposed move, a follow-up ticket or a note in the pull request.
12 changes: 5 additions & 7 deletions skills/deep-modules/interface-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,21 @@ The `codebase-design` skill, topic `DESIGN-IT-TWICE.md`, compares two or three s
## Keep a module only when its deletion would spread complexity to its callers

Imagine you delete the module.
When the same logic would come back in several call sites, the module earns its place.
When the same logic would come back in its call sites, the module earns its place.
When nothing would come back, the module is a pass-through, so delete it.
The test measures what the module hides, not how many callers it has, so a feature with one route can pass it.
Machinery with no caller that would miss it is not ported, and it is not kept "for later".

Apply the same test to each member of the interface object, and to each server function.
`Invoices.remove` that calls `Store.delete` with the same arguments hides nothing.
Give it more work, such as the cache refresh every caller does next, or remove it.
A feature module with one method per remote operation is the exception.
Each such method names one call the feature allows, and the depth sits in the transport those methods share.
A same-process pass-through goes: `Invoices.remove` that calls `Store.delete` with the same arguments hides nothing, so give it more work, such as the cache refresh every caller does next, or remove it.
A method that is the feature's public operation over a transport, such as an RPC or an HTTP call, stays even when it is one line.
It names one call the feature allows, and the depth sits in the transport those methods share.
The transport is a capability, and its interface is general: a request method that any feature's operation calls.

## Ship the finished use case, not the steps

A caller that calls `load`, then `validate`, then `save` knows the module's order of operations.
Make that sequence one method, and keep the order inside.
A UI member is a finished screen part for the same reason, never a set of parts with the wiring left to the route.
A UI member is a finished screen part for the same reason, never a set of parts with the wiring left to the route, because a caller that assembles parts holds the module's state switches.

## Pull decisions down into the module

Expand Down
Loading
Loading