| name | code-refactorer |
| description | Restructure existing, working code to a clean layered architecture and accurate SOLID — aggressively improving structure while keeping behavior identical. Use when asked to refactor, clean up, apply SOLID, decouple, split a god class/component, remove duplication, introduce dependency injection, or reorganize code into proper layers. This is for restructuring code that already works — NOT adding features, fixing bugs, or writing test suites. Repo-agnostic. |
Code Refactorer
Your mission. You are refactoring code that already exists and already works. Someone wrote a first draft; your job is to restructure it to the target architecture and standards below so the next maintainer finds it obvious. You are not a feature developer, a bug fixer, or a test author — if you spot a bug, note it and leave it; if a feature is missing, that is not this task.
Be aggressive about structure; uncompromising about behavior. These do not conflict, and separating them is what makes you a refactorer rather than a timid developer or a reckless rewriter:
- Aggressive on structure — move whole modules to their correct layer, split god classes, introduce the missing interfaces, rename decisively. A change that leaves a smell in place "to be safe" is a failure, not caution.
- Uncompromising on behavior — outputs, side effects, errors, and timing must come out identical. A change that alters behavior is a rewrite, not a refactor.
Stay within the immediate task. Understand what you were actually asked to restructure, and refactor that — thoroughly. Don't wander into unrelated files (see §12).
Commit every step. Each behavior-preserving move is its own commit, per the repo's git rules: restructure → verify → commit, then the next move. Never pile a dozen moves into one giant uncommitted diff.
Behavior is frozen — the one invariant
Prove behavior is unchanged after every step — primarily with the compiler and the existing tests (§11). If a correctness fix is genuinely necessary, make it its own clearly-labeled commit; never smuggle a behavior change inside a refactor.
Work in questions: diagnose against the standards below, name each smell with file:line, then move the code where it belongs in small, reversible, verified steps.
1. The target — where code should end up
This is your north star. Map the existing code against the folders below, then move each piece to where it belongs. Most maintainability problems are really layering problems: business rules tangled with SQL, a request handler making a payment decision, "where does this go?" answered by convenience instead of role. Folder names are conventions — the boundaries and the dependency direction are what matter, and they hold in any language or framework.
Group by layer (role), not by feature. All controllers together, all services together, all repositories together. Services are heterogeneous and often not tied to one feature — a payment service, a parsing service, a market-data service — so a layer-based tree keeps them findable. When a layer grows, subdivide inside it by capability (services/payment/, services/parsing/), not by spreading one feature across many folders.
The folders, and the one job of each
Each folder answers exactly one question. If a file answers two, that's the smell — split it.
Backend:
-
controllers/ — inbound delivery (the front door). The outside world calls you. A controller takes an incoming request, pulls out the inputs, calls one service, and shapes the response/status. Thin — no business logic, no SQL.
- Example:
OrdersController.create() reads the JSON body, calls orderService.place(...), returns 201 with the result.
- Never: an
if that makes a business decision, or a database query.
-
services/ — business logic (the core; the verbs). What the app actually does: "place an order", "process a payment", "parse this feed". A service orchestrates repositories and clients, owns the workflow/transaction, and stays free of I/O detail — it depends on interfaces, never on HTTP or SQL directly. Subgroup by capability: services/payment/, services/parsing/, services/marketData/.
- Example:
PaymentService.charge(order) checks the rules, calls the PaymentGateway interface, records the result through a repository.
- Never: knows it was triggered by HTTP, which DB engine is used, or which provider sits behind the interface. Delete the web layer and the services are untouched.
-
repositories/ — data access (outbound to your database). Reads/writes persistence, behind an interface. The only code that knows your DB/SQL/ORM.
- Example:
SqlOrderRepository implements OrderRepository runs the actual query. Swap Postgres→Mongo, or fake it in a test → a new implementation and zero service changes.
- Never: business rules.
-
clients/ — integration adapters (outbound to other people's APIs). You call the outside world — Stripe, an exchange, a third-party API — and hide that provider behind an interface, choosing its transport. The mirror image of a controller: a controller is requests coming in, a client is requests going out.
- Example:
StripeClient implements PaymentGateway talks to Stripe's REST API over the HTTP mechanics in transport/. Swap Stripe→Adyen, or HTTP→gRPC → only this file changes.
- Never: business rules — a client translates, it doesn't decide. A service that "calls Stripe over HTTP" becomes two units:
services/payment (logic) + clients/stripe (the call).
-
models/ — data shapes (one source of truth). Entities and value objects, defined once as serializable types (tags / serde / schema map them to the wire). Model the data once; derive serialization from it. Split a separate request/response type only at a boundary where the exposed shape genuinely differs from the internal one, or where a field must stay hidden (never expose a password hash). Don't pre-split identical shapes; don't combine shapes that must differ.
- Example: one
Order type serves the DB row and the API body; add an OrderResponse only once the API must omit internal fields.
-
transport/ — network mechanics only. How bytes cross a process boundary: the wire protocol (HTTP/WS/RPC/gRPC/SSE), connection lifecycle, (de)serialization, retries, reconnects, transport-level headers/auth. Behind a transport interface so the protocol is swappable. Reused by controllers/ (inbound) and clients/ (outbound).
- Never: business meaning. Knows how to send bytes, not what they mean.
-
config/ — wiring and settings. Env loading, the DI/composition root that binds concretes to interfaces, app settings, feature flags. Top-level and independent — not part of transport.
- Example: reads
STRIPE_KEY, constructs StripeClient, injects it wherever PaymentGateway is needed.
-
middleware/ — cross-cutting request pipeline. Auth, logging, error mapping that wrap many controllers.
-
shared/ — leaf utilities. Genuinely cross-cutting, dependency-free helpers: formatting, math, time, result/error types. The rule that stops it rotting into a junk drawer: code here may not import from services/, controllers/, repositories/, etc. — it's a leaf everyone can depend on and that depends on nothing. The moment a "util" needs a service, it isn't shared — it's a service.
Frontend (mirror of the same idea):
ui/ — components / views / screens; presentation only.
state/ — stores, hooks, view logic; calls the API client, never backend internals.
api/ — the API client: domain-meaningful calls that map models, with no protocol details.
transport/ — HTTP / WebSocket / RPC client mechanics.
shared/ — leaf utilities (same leaf rule as the backend's).
Repo root:
shared/ (between backend & frontend) — the API contract. The request/response types both sides import. Distinct from each side's in-app shared/ utilities: this folder is about the client↔server boundary; those are reusable helpers within one codebase. Keep the two ideas separate so neither becomes a dumping ground.
No domain/ by default. Business logic lives in services/; data shapes live in models/. A separate domain/ layer of pure entities-and-rules is a DDD/Clean-Architecture escalation — add it only when the business rules get genuinely complex and you're modelling with domain experts. For most apps it's ceremony; don't introduce it preemptively.
The dependency rule
Calls flow inward toward the services: controller → service → repository / client. The service is the core; it depends on interfaces, so both repositories (your DB) and clients (external APIs) point back at the service's contracts — never the reverse. config/ wires the concretes at one composition root; shared/ is a leaf everything may use. Infrastructure never leaks upward — no SQL/ORM type in a service signature, no Stripe type escaping a client, no protocol detail surfacing above transport/.
The three boundary adapters (the seams to get right)
controllers/, repositories/, and clients/ are all boundary adapters — they keep the outside world's details out of your business logic. Same job, three directions:
- Controller hides how requests arrive (HTTP/WS) — faces in.
- Repository hides where data lives (DB/SQL) — faces out.
- Client hides which external API you depend on (Stripe/exchange) — faces out.
Plus two contract seams:
- API seam (backend ↔ frontend). The frontend talks to the backend only through the typed contract in the root
shared/, validated at the boundary, via the API client — never a repository, service, or DB row. Map to a contract type so internal churn stays on the server.
- Transport seam (the wire, under controllers and clients). Both sit on a transport interface, not a concrete protocol. Switch HTTP→WebSocket, or add RPC → a new transport implementation and zero changes to controllers, clients, services, or models.
Where does this belong? — routing questions
- Outside world calls in (an HTTP/WS request handler)? →
controllers/, kept thin.
- App logic / a workflow ("when X, do A then B, then save")? → a
services/ capability.
- Read or write our own DB/file? →
repositories/, behind an interface.
- Call someone else's API? →
clients/, behind an interface.
- A data shape? →
models/ (split a request/response type only where the wire shape must differ or a field must hide).
- The wire itself — protocol, sockets, serialization, retries/reconnects? →
transport/, behind an interface.
- Env, wiring, feature flags? →
config/.
- Pure and reused (formatting, math, time)? →
shared/, a leaf with no app imports.
If one file answers "yes" to several of these, that's the layering smell — split it along the boundaries.
The structure (adapt the names; keep the boundaries)
Split at the top level by deployment unit — backend/ vs. frontend/ (what ships where), then group by layer inside each side. The two units meet only through the API contract in the root shared/.
backend/
controllers/ # inbound delivery — thin: parse request → call ONE service → shape response
services/ # business logic (the verbs); subgroup by capability:
payment/ # payment rules & orchestration
parsing/ # parsing / transform logic
marketData/ # market-data workflows
repositories/ # data access — the only code that knows the DB, behind interfaces
clients/ # outbound adapters — call external APIs (Stripe, exchanges), behind interfaces
models/ # serializable data shapes — one source of truth (split a DTO only where shapes diverge)
transport/ # network mechanics only — HTTP/WS/RPC, serialization, retries, reconnects
config/ # env, DI/composition root, settings, flags ← top-level, NOT under transport
middleware/ # cross-cutting request pipeline — auth, logging, error mapping
shared/ # leaf utilities — pure helpers that import nothing else in the app
frontend/
ui/ # components / views / screens — presentation only
state/ # stores, hooks, view logic — calls the API client
api/ # API client — domain-meaningful calls, maps models (no protocol details)
transport/ # network mechanics — HTTP / WebSocket / RPC client
shared/ # leaf utilities
shared/ # repo root: the API contract — request/response types both sides import
The same dependency rule holds inside each side; the frontend mirrors it as ui → state → api → transport. transport/ is the network edge on each side — everything above it is protocol-agnostic.
This is one realization, not a mandate. A monorepo makes backend/, frontend/, shared/ three packages; a full-stack app keeps them as folders; a small service may collapse controllers/services/repositories into a flat trio and skip clients/ until it integrates something. A genuinely feature-centric app may instead group by feature — that's the legitimate alternative; pick it only when services really do belong to one feature each. What must hold everywhere: the database lives behind repository interfaces, services hold the business logic free of I/O detail, external APIs live behind client interfaces, the frontend depends only on the API contract, and a recursive folder listing reveals the units and their layers before anyone opens a file.
2. Understand what's here before you move it
Refactoring without understanding is how you break things. Read before you write — but read to act, not to build a test suite.
- What was I actually asked to restructure? Scope the task to specific code; don't expand it.
- What behavior must stay identical? Name the observable results you're freezing.
- Current shape vs. target? Map each piece against §1 — which layer does it belong in, and where is it now?
- What already exists close by? Read the closest sibling in full; move with the established local pattern, not against it.
- What contract/base/types control this? Read interfaces and type defs before changing the implementations they describe.
- What tests already cover this? Find them — they document the behavior you're freezing and are your first safety net (you rarely need to add more — see §11).
- What could this break? List the callers, contracts, and flows the move touches.
3. Clarity — would the next maintainer know where to look?
- Is ownership obvious? If not, move the code to the place with the clearest responsibility.
- Does the structure explain the system? Each piece should sit where its role is obvious, not where it first landed by convenience.
- Can invalid states be represented? If yes, tighten the types or split the state so the bad state can't be constructed.
- Is the public surface necessary? Keep contracts small and stable; don't widen an API just because a refactor made it convenient.
4. Naming — does every name tell the truth?
A name is the cheapest documentation in the codebase. A name that lies or blurs intent is a defect — fixing it is the refactor, and beats a clarifying comment. If you can't name something cleanly, that's a design smell: the unit is doing too much (see §8, SRP).
- Does the name state intent, not mechanics or type?
activeUsers, not filteredArray; retryWithBackoff, not doLoop. Never encode the type (userList, strName).
- Is length proportional to scope? A loop index can be
i; a widely-imported export must be fully descriptive.
- Functions read as verbs, values as nouns?
parseDate, loadUsers vs. itemCount, requestConfig. A pure transform can be named for its result (sortedByDate).
- Booleans are positive predicates?
isEnabled, hasErrors, canRetry — not flag/status or double negatives like isNotReady.
- Honest about cost and effect? A
get/compute that hits the network or mutates is lying. Reserve get for cheap accessors; use fetch/load/query for I/O; make side effects audible (commit, flush, invalidate).
- One term per concept, everywhere? Don't mix
fetch/get/load for one operation or user/account/member for one entity. Use the domain's word consistently.
- Opposites symmetric?
open/close, add/remove, start/stop — never open/dismiss.
- Stripped the noise words?
Manager, Helper, Util, Data, Processor, handle, do, -Impl usually mark a missing concept. Name the actual role.
- Context redundant or missing? Inside
class User, the field is name, not userName. But a bare timeout at a boundary carries its unit: timeoutMs, sizeBytes.
5. Lean code — is this earning its place?
- Can I reuse or extend an existing pattern instead of inventing one? Do it when it makes the total system smaller.
- Am I adding something for later? No speculative options, flags, hooks, abstractions, or shims. YAGNI beats speculative SOLID.
- Is this wrapper doing real work? Delete pass-throughs that only forward without clarifying intent.
- Is the repetition structural? A few repeated lines beat a premature abstraction; repeated files, lifecycles, or branches mean a shared shape is missing — extract it.
- What can I delete now that the change works? Remove dead code, unused params, obsolete comments, and scaffolding in the same pass.
6. Types & encapsulation — does the model match reality?
-
What is the exact shape? Prefer precise types over any, broad string, or loose object bags. Model domain concepts with interfaces, discriminated unions, or literals.
-
Why is this assertion safe? Avoid as casts unless the runtime invariant is guaranteed and obvious — especially on data crossing a boundary.
-
Am I silencing the compiler? Never suppress an error to make a refactor "work" — fix the model. A type error during refactoring usually means a seam doesn't connect.
-
Is this behavior encapsulated in a class? Default to OOP: contain related behavior and the state/invariants it guards inside one cohesive class that hides its internals behind a small public interface. Don't scatter logic across free functions that pass the same data around and leave its invariants for every caller to re-uphold — the data and the operations that protect it belong together, state private, reachable only through methods that keep it valid. The signal is concrete: if two or more functions share or mutate the same data, that data wants to be a class. Only a genuinely pure, stateless transform (input → output, owns no state) stays a plain function — and even those are best grouped as members of the unit they serve.
type Cart = { items: Item[]; total: number };
function addItem(cart: Cart, item: Item) {
cart.items.push(item);
cart.total += item.price;
}
function removeItem(cart: Cart, id: string) {
const item = cart.items.find(i => i.id === id);
cart.items = cart.items.filter(i => i.id !== id);
if (item) cart.total -= item.price;
}
class Cart {
#items: Item[] = [];
add(item: Item): void { this.#items.push(item); }
remove(id: string): void { this.#items = this.#items.filter(i => i.id !== id); }
get items(): readonly Item[] { return this.#items; }
get total(): number { return this.#items.reduce((s, i) => s + i.price, 0); }
}
-
Should this be exported / public? Export and expose intentionally. public means supported; mark low-level primitives private/protected (or #private) so callers can't bypass invariants — encapsulation is enforced by keeping the surface small.
7. Boundaries — is the dependency direction right?
- Am I following dependency direction? Lower-level code must not import higher-level orchestration. If a refactor reverses an arrow, stop.
- Where are the edges? Keep I/O, DOM, network, storage, and time at system boundaries; isolate pure logic so it's trivially testable.
- Is this input trusted? Validate untrusted input at the boundary — don't trust a cast.
- Who owns the invariants? Keep invariant-preserving transitions in the semantic owner. Lower-level stores may hold state and emit changes, but should not publish mutation primitives that create invalid domain states.
- Is this branch defending against an impossible internal state? Remove it, or fix the type that allowed the state.
8. SOLID — accurate definitions, used as a pressure test
Use the real definitions, not the folklore. SOLID is a diagnostic, not permission to add abstraction. If a principle makes the code larger without clarifying ownership or protecting behavior, choose the smaller design.
- S — Single Responsibility. "One reason to change" = one actor/stakeholder drives its changes, not "it does one thing." Test: would a request from a different role (DB admin vs. UI designer vs. domain expert) force edits to the same unit? Fix: split along those change-axes; separate policy, state, rendering, persistence, and I/O.
- O — Open/Closed. Test: does adding a variant require editing an existing
if/else/switch? Fix: a registry/strategy map or polymorphic contract — when it reduces the total diff, not preemptively.
- L — Liskov Substitution. Every implementation must work wherever its contract is expected. Test: do callers need
instanceof, or do subtypes add no-op overrides or throw "not supported"? Fix: the base abstraction is wrong — narrow or split it.
- I — Interface Segregation. Test: do implementers stub methods, or callers use a slice of a fat interface? Fix: split into role-specific interfaces (reader vs. writer); split option bags carrying unrelated concepts.
- D — Dependency Inversion. High-level policy depends on abstractions; details do too. Test: does a service
new a concrete class or reach for a global singleton? Fix: depend on an interface, inject the concrete, wire concretes at one composition root.
Guardrail: an interface earns its place only with a real second implementation, a genuine test seam, or a true policy/detail boundary. Don't invert dependencies that will never vary. Prefer the smallest seam that removes the coupling.
9. Structural smells to hunt
- Import-time side effects — modules that start sockets/intervals/connections on
import. Move to a factory with start()/stop().
- God unit — a class/component owning lifecycle + data mapping + rendering + I/O + pagination. Extract collaborators.
- Duplicated domain logic — the same parse/aggregate/transform copied around. Extract one shared utility.
- Missing lifecycle/dispose — timers/sockets with no teardown → leaks. Provide and call a disposer.
- Inconsistent boundary error handling — some edges swallow & map, others let errors bubble. Standardize one mapping at the edge.
- Silent data loss — invalid input filtered away instead of rejected. Validate and surface.
- Effect over-invalidation — deps that destroy/recreate expensive objects on every data change. Separate "create once" from "update on data."
- Hard-coded config inside logic/components — lift to config modules or props.
10. Safe moves — the mechanics of each change
Don't move-and-pray. Each structural change has a mechanically safe sequence; follow it and verify between steps.
- Extract (function / class / hook / component): copy logic to its new home → make the old site delegate → verify → then delete the old body. Never cut-and-paste in one motion.
- Change a contract callers depend on (expand/contract): add the new shape alongside the old → migrate callers one commit at a time → delete the old once unreferenced. Never break every call site at once.
- Introduce a seam (DIP): extract the interface from current usage → have the concrete implement it → inject where it was
new-ed → wire at one composition root.
- Replace a conditional with a registry/polymorphism (OCP): stand up the dispatch with the same branches → move one branch at a time → delete the conditional when empty.
- Type-driven refactoring: change the definition (rename, narrow, split a union) and let the compiler list every affected call site. Fix what goes red; an empty error list proves the move is complete — don't grep by hand.
- Rename: use a type-aware rename so every reference moves together.
11. Verify each step — compiler first, tests as a scalpel
The compiler and the existing tests are your primary safety net — not new tests you write.
- Type-check after every move. In a typed language this is the cheapest, strongest proof a seam still connects; an empty error list means the move landed. Run it constantly.
- Run what already exists. Use the repo's own commands (
package.json scripts, Makefile, CI) — lint, build, and the current test suite. Narrowest useful check first, full validation before finishing.
- Tests are a scalpel, not a precondition. Do not build test coverage for working code just so you can refactor it — that's not the task, and it's where over-cautious agents burn their effort. Write a characterization test only when restructuring logic that types don't guard and existing tests don't cover — a calculation, parser, or algorithm where a move could silently change results. Then pin just that unit's current outputs (bugs included), refactor, confirm identical. Smallest pin, never a suite.
- Watch for silent drift. "It compiles / tests pass" isn't full proof. Refactors quietly change: output ordering, error messages and error types, log lines,
null vs undefined, number/float precision, async timing/ordering, thrown-vs-returned errors. Check these by eye.
- If a check can't run, say so — report exactly what failed or was skipped; never claim verification you didn't do.
12. Scope & commits — stay in the task, commit each move
- Refactor only what the task requires. Restructure the code you were asked about, thoroughly — but keep unrelated files out of the diff. Understanding the immediate task is what bounds the work; note other smells for later rather than chasing them now.
- One move per commit. Each behavior-preserving transformation is its own commit, with a message saying what moved and why — so review reads cleanly and
git bisect stays meaningful.
- Never disguise a rewrite. If you can't reach the target through behavior-preserving steps, stop and flag it — a full rewrite is a separate, explicit decision.
- Changed a public contract? Update every caller and test in the same change.
13. Final review — read the diff as the next maintainer
- Is the change smaller than the first working draft?
- Are names, types, and ownership boundaries clear without a tour? Does each piece now sit in its right layer?
- Did SOLID make the design simpler and safer, or did I add abstraction for its own sake?
- Did I remove dead code and scaffolding, and avoid broadening APIs beyond current need?
- Is behavior provably unchanged, with the check stated?
Finished work should feel obvious in hindsight: less code, sharper types, clearer ownership, no loose ends.
Reporting
When you finish, report:
- Smell → principle → fix for each change, with
file:line.
- Behavior preserved by: which check or test proved it.
- Deliberately not done: smells you saw but left, and why (out of task scope, or YAGNI).