
Refactoring Guide
- 55 installs
- 62 repo stars
- Updated August 3, 2026
- terrylica/cc-skills
Helps with code review & quality tasks.
About
refactoring-guide is a Claude Code skill for code review & quality. It helps solo builders move faster with AI-assisted development.
- refactoring-guide
- Code Review & Quality
- AI-coding skill
Refactoring Guide by the numbers
- 55 all-time installs (skills.sh)
- +1 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #557 of 1,352 Code Review & Quality skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/terrylica/cc-skills --skill refactoring-guideAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 55 |
|---|---|
| repo stars | ★ 62 |
| Last updated | August 3, 2026 |
| Repository | terrylica/cc-skills ↗ |
What it does
Helps with code review & quality tasks.
Files
Refactoring Guide
Principles that LLMs consistently get wrong during refactoring. This skill corrects systematic blind spots around coupling analysis, type-level design, module boundaries, and safe migration strategies.
The core problem: LLMs optimize for what code _looks like_ (structural similarity), but good modularization optimizes for how code _changes together_ (temporal cohesion). Every principle here addresses that gap.
Self-Evolving Skill: This skill improves through use. If instructions are wrong, parameters drifted, or a workaround was needed — fix this file immediately, don't defer. Only update for real, reproducible issues.
When to Use
- Refactoring tasks: Extract module, split file, reduce coupling, reorganize
- Code review: Spot smells and suggest the right fix (not the superficial one)
- Architecture decisions: Module boundaries, dependency direction, integration points
- Proactively: When you detect any signal from the Detection Heuristics table below
Workflow
When refactoring, follow this sequence:
1. Detect — Scan the code against the Detection Heuristics table. Identify which smells are present. 2. Diagnose — For each smell, read the corresponding reference file to understand the correct principle. 3. Plan — Design the refactoring using the right technique. For multi-file changes (>3 files), use the Mikado Method (see references/architecture.md). 4. Execute — Apply changes. For shared interfaces, use expand-contract (see references/tactical-moves.md). 5. Verify — Confirm the refactoring reduced the specific coupling type identified in step 1.
Detection Heuristics
Scan for these signals to identify which principle to apply:
| Signal | Likely Smell | Principle | Reference |
|---|---|---|---|
| Same group of parameters passed to 3+ functions | Data clump | Parse, don't validate — extract parameter object | type-design.md §1 |
| Method uses more of another class's fields than its own | Feature envy | Move method to where data lives | module-boundaries.md |
if isinstance / type switch with >2 branches | Missing polymorphism | Replace conditional with polymorphism | type-design.md §2 |
| Import cycle between modules | Acyclic violation | Extract shared or invert dependency | module-boundaries.md §2 |
| Boolean parameter on public API | Flag argument | Split into separate methods or use enum | tactical-moves.md §5 |
# TODO: remove after migration older than 3 months | Dead code | Delete it now | tactical-moves.md §1 |
Function has both return and side effects (db/file/network) | Mixed concerns | Functional core, imperative shell | architecture.md §1 |
| Test requires mocking >3 dependencies | Over-coupling | Missing a seam — identify and create one | structural-coupling.md §1 |
| Changing one feature touches >3 directories | Wrong slicing | Package by feature, not layer | module-boundaries.md §1 |
| Two modules that always change in the same PR | Under-modularized | Common closure — merge them | module-boundaries.md §3 |
| One module changes for unrelated reasons | Divergent change | Split by reason-for-change | structural-coupling.md §4 |
| One logical change touches 5+ files | Shotgun surgery | Merge the scattered concern | structural-coupling.md §4 |
init() must be called before process() | Temporal coupling | Type-state pattern | type-design.md §4 |
| External API types used deep in business logic | Leaked integration | Anti-corruption layer at boundary | architecture.md §2 |
| Same struct mutated in 3+ different modules | Unclear data ownership | Designate owning module for each data type | structural-coupling.md §5 |
| Vendor SDK types used in core logic | Volatility leak | Wrap behind narrow stable interface | structural-coupling.md §6 |
| Module exposes setters instead of operations | Undefended invariants | Expose intention-revealing operations | module-boundaries.md §5 |
| Infrastructure exceptions surface in business logic | Error leakage | Translate errors at module boundary | module-boundaries.md §6 |
| Pass-through layer with no logic (just forwards calls) | Fake modularity | Remove unnecessary indirection | tactical-moves.md §9 |
Module named utils, common, helpers, shared | Dumping ground | Split by actual consumer clusters | module-boundaries.md §4 |
| Domain logic inside controllers, handlers, or jobs | Misplaced business logic | Extract to domain module | architecture.md §1 |
| Services scattered across modules constructing own deps | Missing composition root | Centralize wiring at app entry point | architecture.md §5 |
| God service that coordinates AND decides everything | Mixed orchestration | Separate orchestration from computation | architecture.md §1 |
Principle Summary
Each principle is covered in detail in references/. Read the relevant file when you encounter its smell.
Structural Coupling (references/structural-coupling.md)
1. Seam identification — Find natural seams before extracting; don't cut across them 2. Connascence spectrum — Coupling has 9 strength levels; refactor toward weaker forms 3. Stability metrics — Depend in the direction of stability (lower instability) 4. Divergent change vs. shotgun surgery — Opposites requiring opposite fixes; don't confuse them 5. Data ownership — Every data structure has one owning module; others read via contracts, never mutate 6. Volatility isolation — Wrap high-churn dependencies behind narrow stable interfaces
Type-Level Design (references/type-design.md)
1. Parse, don't validate — Parse at boundaries into typed results; never pass raw input downstream 2. Make illegal states unrepresentable — Discriminated unions over boolean/optional fields 3. Newtype / branded types — Wrap primitives with distinct types to prevent semantic confusion 4. Temporal coupling → type-state — Return new types that expose only currently-valid methods
Architecture (references/architecture.md)
1. Functional core, imperative shell — Pure functions for decisions, thin IO shell for effects 2. Anti-corruption layer — Translate external models at integration boundaries 3. Strangler fig — Incremental migration, never big-bang rewrites 4. Mikado method — For large refactors: try, record failures, revert, work bottom-up 5. Composition root — All wiring at one entry point, not scattered through modules
Module Boundaries (references/module-boundaries.md)
1. Package by feature, not layer — Vertical slicing keeps feature changes local 2. Acyclic dependencies — Module graph must be a DAG 3. Common closure — Group by reason-for-change, not technical similarity 4. Interface segregation — Don't force consumers to depend on unused exports 5. Invariant enforcement — Modules defend their own invariants; expose operations, not setters 6. Error boundary translation — Each module translates errors to its own domain vocabulary
Tactical Moves (references/tactical-moves.md)
1. Deletion as refactoring — Best refactoring often has negative line count 2. Rule of three — Wait for three instances before abstracting 3. Inline then re-extract — Flatten confused code first, then re-decompose cleanly 4. Expand-contract — For shared interfaces: add new alongside old, migrate, remove old 5. Boolean parameter prohibition — Split or use enum instead 6. Configuration as explicit dependency — Pass config, don't import globally 7. Characterization tests first — Pin behavior before refactoring 8. Conway's law alignment — Module boundaries should match team boundaries 9. Over-modularization check — Boundary must improve change isolation, not just organize files 10. Module documentation template — For each module: responsibility, ownership, dependencies, invariants, error model
Rust-Specific (references/rust-specific.md)
Read when refactoring Rust codebases. Covers visibility as architecture, public API surface control, crate vs. module boundaries, Cargo features, workspace feature unification, and dependency policy tooling.
Swift/macOS-Specific (references/swift-macos-specific.md)
Read when refactoring Swift codebases on macOS. Covers access control as architecture (package modifier), explicit import visibility (SE-0409), target/framework boundary selection, macro isolation, and API governance tooling.
Post-Execution Reflection
After this skill completes, check before closing:
1. Did the command succeed? — If not, fix the instruction or error table that caused the failure. 2. Did parameters or output change? — If the underlying tool's interface drifted, update Usage examples and Parameters table to match. 3. Was a workaround needed? — If you had to improvise (different flags, extra steps), update this SKILL.md so the next invocation doesn't need the same workaround.
Only update if the issue is real and reproducible — not speculative.
Architectural Refactoring Patterns
Patterns for refactoring at the architectural level — separating concerns across system layers and managing large-scale migrations safely.
---
§1. Functional Core, Imperative Shell (Gary Bernhardt)
Every function should be _either_ pure (computes and returns, no side effects) _or_ a shell (orchestrates IO, minimal logic). Functions that do both are the primary source of untestable, hard-to-refactor code.
# WRONG: IO and logic interleaved — can't test discount logic without a DB
def process_order(order_id: str):
order = db.fetch(order_id) # IO
if order.total > 100: # logic
discount = order.total * 0.1 # logic
db.apply_discount(order, discount) # IO
send_email(order.customer) # IO
# RIGHT: pure core computes, shell orchestrates
def calculate_discount(order: Order) -> Optional[Discount]: # pure — testable
if order.total > 100:
return Discount(order.total * 0.1)
return None
def process_order(order_id: str): # shell — only IO orchestration
order = db.fetch(order_id)
if discount := calculate_discount(order):
db.apply_discount(order, discount)
send_email(order.customer)Why LLMs get this wrong: LLMs write code top-to-bottom as a narrative — "first fetch, then check, then save." This naturally interleaves IO with logic. The refactoring move is to _pull the logic out_ into pure functions that receive data and return decisions, leaving the shell as a thin orchestrator.
How to apply: For any function that both reads/writes external state AND contains conditional logic, extract the conditional logic into a pure function. The pure function takes data in, returns a decision. The shell feeds data to the pure function and acts on the result.
---
§2. Anti-Corruption Layer (Eric Evans)
At every external integration boundary, create a translation layer that converts external models into internal domain types. Internal business logic should never directly handle external schemas — they change without notice, they have different naming conventions, and they carry concerns your domain doesn't care about.
# WRONG: Stripe's schema leaks throughout internal code
def process(stripe_event: dict):
if stripe_event["type"] == "payment_intent.succeeded":
amount = stripe_event["data"]["object"]["amount"]
customer = stripe_event["data"]["object"]["customer"]
# 20 more lines of Stripe-specific field access...
# RIGHT: translate at boundary, use internal domain types everywhere else
@dataclass
class PaymentReceived: # internal domain event
amount_cents: int
customer_id: str
def from_stripe(event: dict) -> PaymentReceived: # ACL — only place that knows Stripe's schema
return PaymentReceived(
amount_cents=event["data"]["object"]["amount"],
customer_id=event["data"]["object"]["customer"],
)
def process(payment: PaymentReceived): # business logic — clean, testable
...How to apply: At every integration boundary (third-party API, database ORM, message queue, file format), write a translation function or module that converts external representations into internal domain types. The rest of the codebase uses only the internal types. When the external API changes, only the ACL changes.
---
§3. Strangler Fig (Martin Fowler)
For replacing existing systems or modules, never do a big-bang rewrite. Instead, wrap the old code in a facade, route traffic through it, replace one path at a time, and remove the facade when migration is complete.
The process:
1. Create a facade/adapter in front of the old code 2. Route all callers through the facade 3. Implement new logic behind the facade for one use case 4. Verify the new path works (tests, monitoring) 5. Repeat for the next use case 6. When all paths are migrated, remove the facade and the old code
Why LLMs get this wrong: LLMs prefer clean rewrites — they generate a fresh implementation and replace the old one in one shot. This works for small functions but fails catastrophically for modules with many callers, complex state, or subtle edge cases that the rewrite misses. The strangler fig approach ensures old and new coexist and both work at every step.
How to apply: For every refactoring PR involving module replacement, both old and new code must coexist and both must pass tests. If you can't keep both working simultaneously, the migration scope is too large — use smaller increments.
---
§4. Mikado Method (Ola Ellnestam & Daniel Brolund)
For large refactors that touch many files, the Mikado method prevents the tangled intermediate states that LLMs create when they push forward through compilation errors.
The process:
1. Attempt the goal change directly 2. If it breaks, record what broke as a prerequisite on a dependency graph (the "Mikado graph") 3. Revert the goal change completely (git checkout/stash) 4. Recursively apply the method to each prerequisite, working bottom-up from leaves 5. When all prerequisites are green, apply the goal change — it now succeeds cleanly
Why LLMs get this wrong: When a refactoring causes a compilation error or test failure, LLMs try to fix it immediately, creating cascading changes that tangle multiple concerns. The Mikado insight is that _reverting and working bottom-up_ is faster and safer than pushing forward through breakage.
How to apply: For any refactoring that touches >3 files, use Mikado. The key discipline is: when something breaks, don't fix forward — revert and add the broken thing as a prerequisite. This ensures every intermediate commit is green and the refactoring can be abandoned at any point without leaving the codebase in a broken state.
Example Mikado graph:
Goal: Replace OldAuth with NewAuth
├── Prerequisite: Extract AuthInterface from OldAuth
│ ├── Prerequisite: Move shared types to auth-types module
│ └── Prerequisite: Remove direct OldAuth imports in billing/
└── Prerequisite: Add NewAuth adapter implementing AuthInterface
└── Prerequisite: Create NewAuth credential storeWork leaves first (bottom-up): shared types → remove direct imports → extract interface → create credential store → add adapter → replace.
---
§5. Composition Root Pattern
All wiring (dependency injection, configuration loading, service construction) should happen at one place near the boundary of the application — the composition root — not spread through reusable modules.
Why LLMs miss this: LLMs scatter construction and wiring throughout the codebase. A service creates its own database connection, a handler constructs its own logger, a utility reads environment variables directly. This makes modules hard to test, hard to reconfigure, and invisibly coupled to their environment.
How to apply:
- The composition root is typically
main(), the app entry point, or a dedicated wiring module - It creates all services, injects dependencies, loads configuration, and starts the application
- Reusable modules accept their dependencies as parameters — they never construct or discover them
- This is the one place where it's acceptable to import config globally, instantiate database connections, and wire things together
# composition root (main.py) — the only place that knows about all concrete implementations
def main():
db = create_database(os.environ["DB_URL"])
mailer = SmtpMailer(os.environ["SMTP_HOST"])
billing = BillingService(db, mailer) # inject dependencies
app = create_app(billing)
app.run()Module Boundary Design
Principles for deciding where to draw module boundaries. The core insight: boundaries should optimize for _change locality_ (most changes stay within one module), not for _technical similarity_ (all controllers together, all models together).
---
§1. Package by Feature, Not Layer
Vertical (feature) slicing keeps feature changes local. Horizontal (layer) slicing forces every feature change to touch every directory.
# WRONG: horizontal slicing — adding "notifications" touches every directory
src/controllers/user.ts
src/controllers/billing.ts
src/controllers/notification.ts ← new
src/services/user.ts
src/services/billing.ts
src/services/notification.ts ← new
src/models/user.ts
src/models/billing.ts
src/models/notification.ts ← new
# RIGHT: vertical slicing — adding "notifications" is one self-contained directory
src/user/controller.ts
src/user/service.ts
src/user/model.ts
src/billing/controller.ts
src/billing/service.ts
src/billing/model.ts
src/notification/ ← entire feature in one place
controller.ts
service.ts
model.tsDiagnostic: If a feature change requires touching >2 top-level directories, the code is sliced wrong. Each feature should be a self-contained vertical slice.
How to apply: When creating new modules or restructuring existing ones, group by domain concept (user, billing, notification) not by technical role (controllers, services, models). Shared infrastructure (database clients, logging, config) lives in a separate shared/ or infrastructure/ module that features depend on.
---
§2. Acyclic Dependencies Principle
The module dependency graph must be a Directed Acyclic Graph (DAG). Import cycles between modules make the entire cycle effectively one giant module — you can't understand, test, or deploy any part independently.
Detection: If module A imports from B, and B imports from A (directly or transitively), there's a cycle.
Three fixes:
1. Extract shared concept into module C — If A and B both need the same type or function, extract it into a new module C that both depend on. The cycle becomes A→C←B.
2. Dependency inversion — If A depends on B's concrete implementation, define an interface in A and have B implement it. A depends only on its own interface; B depends on A's interface. The dependency direction flips.
3. Merge A and B — If the cycle can't be broken cleanly, the two modules are actually one module. Merge them and accept that reality.
How to apply: Before creating a new import, check: does this create a cycle? If yes, apply one of the three fixes. Tools like import-graph (JS), pydeps (Python), or cargo-depgraph (Rust) visualize the dependency graph.
---
§3. Common Closure Principle
Classes that change together belong together. This is the module-level equivalent of the Single Responsibility Principle, but at a coarser granularity.
Why LLMs get this wrong: LLMs group code by _technical similarity_ — "these are all validators, put them in validators/; these are all formatters, put them in formatters/." But a billing validator and a billing formatter change together when billing requirements change. They belong in billing/, not in separate technical-role directories.
Diagnostic: Look at your git history. If two files consistently change in the same commits or PRs, they should be in the same module. If they're in different modules, that's shotgun surgery (see structural-coupling.md §4).
How to apply: When deciding where a new piece of code belongs, ask: "when this changes, what else will change?" Put it next to those things. If a code review reveals that a PR touches files in 4 different modules to accomplish one logical change, those files belong together.
---
§4. Interface Segregation at Module Level
Don't force consumers to depend on exports they don't use. A module with a broad public API that serves multiple different consumers is actually multiple modules duct-taped together.
Detection: If consumer A uses functions 1-3 of a module and consumer B uses functions 4-6, the module should be split so that A and B depend on smaller, focused modules.
Why this matters: When functions 4-6 change, consumer A gets rebuilt/retested even though nothing it uses changed. At scale, this creates slow builds, unnecessary test runs, and false coupling signals in change analysis.
How to apply:
1. List all consumers of the module 2. For each consumer, note which exports they actually use 3. If there are distinct clusters (consumer group A uses one subset, group B uses another), split the module along those cluster lines 4. Shared utilities used by both clusters stay in the module or move to a shared dependency
This is especially important for "utility" modules that grow over time — they tend to become dumping grounds that couple unrelated consumers.
---
§5. Invariant Enforcement at Boundaries
A module boundary without invariant enforcement is just file separation. Each module should defend its own invariants — the rules that must always be true about its data and state.
Why LLMs miss this: LLMs create "clean" module boundaries by moving files into directories, but leave the data structures as raw shared structs that anyone can put into an invalid state. The boundary looks good on a diagram but provides no protection.
How to apply:
- Replace raw shared structs with operations that preserve rules (constructors that validate, methods that maintain consistency)
- Expose fewer setters, more intention-revealing operations:
account.deposit(amount)instead ofaccount.balance = account.balance + amount - The module's public API should make it impossible (or at least difficult) to violate its invariants from outside
- If an invariant can only be stated as a comment ("balance must never be negative"), the API isn't defending it — add runtime checks at the boundary
---
§6. Error Boundary Translation
Each module should translate low-level errors into its own error vocabulary. Infrastructure exceptions (socket timeouts, SQL constraint violations, file permission errors) should not leak through module boundaries into business logic.
Why LLMs miss this: LLMs let exceptions propagate unchanged. A ConnectionRefusedError from a database driver surfaces in a billing function, coupling the billing logic to the database implementation. If you swap databases, error handling throughout the business layer breaks.
How to apply:
- At each module boundary, catch infrastructure errors and translate them into domain-meaningful errors
DatabaseConnectionError→PaymentProcessingUnavailableFileNotFoundError→ConfigurationMissing- The consuming module should only need to understand the domain error, not the infrastructure cause
- This pairs with Anti-Corruption Layer (see
architecture.md§2) — translate data at the boundary, translate errors at the boundary
Rust-Specific Modularization
Rust modularization principles that LLMs consistently miss. In Rust, modularity quality is measured less by directory neatness and more by how well you control visibility, public API evolution, feature unification, and dependency exposure.
---
§1. Visibility as Architecture
In Rust, pub(crate), pub(super), and pub(in path) are first-class boundary controls, not minor cleanup tools. LLMs default to pub on everything.
How to apply:
- Aggressively prefer the narrowest visibility that works
- Build a clean public façade with
pub usere-exports inlib.rsor a top-level module - Internal paths stay private; the public surface is curated, not a mirror of the filesystem
- Think of visibility annotations as load-bearing architecture, not syntax decoration
---
§2. Public API Surface as the Real Boundary
Modularity in Rust is not just "who can call what today" but "what changes stay non-breaking later." Exposing concrete fields, unsealed extension points, or raw representation types weakens boundaries even when the code looks cleaner.
Protective patterns:
- Sealed traits: Prevent external implementations via a private supertrait
- Private struct fields: Force construction through
::new()— preserves invariants - Newtypes: Wrap primitives to prevent representation leakage
- Non-exhaustive enums:
#[non_exhaustive]preserves ability to add variants
How to apply: Before declaring anything pub, ask: "will I regret this being part of my semver contract?" Use cargo-public-api to list and diff your public surface.
---
§3. Crate Boundaries vs. Module Boundaries
LLMs often split code into more crates without asking whether the boundary is meant to be semver-stable. Crate boundaries in Rust carry compatibility consequences that module boundaries don't.
When to create a new crate (any of these justify it):
- Different semver release cadence
- Proc-macro requirement (forced separate crate)
- Compile-time isolation needed
- Different dependency policy or license requirements
- Independently testable/deployable unit
When NOT to create a new crate:
- Just to organize files (use modules within the same crate)
- To "look more modular" without actual boundary justification
---
§4. Façade Modules and Re-Exports
A Rust library benefits from private internal structure plus a curated public façade. pub use declarations redirect public names away from private canonical paths.
// lib.rs — curated public façade
pub use self::parser::Parser;
pub use self::executor::ExecutionResult;
// internal topology is hidden — users see a flat, domain-shaped API
// Internal modules stay private
mod parser;
mod executor;
mod optimizer; // not re-exported — implementation detailHow to apply: The filesystem/module tree is an implementation detail. The pub use surface in lib.rs is the real API. Don't mirror internal structure in the public surface.
---
§5. Proc-Macro Boundary Isolation
Procedural macros must live in a crate of type proc-macro, and they cannot be used from the crate where they are defined. This is a forced architectural boundary.
How to apply:
- Keep proc-macro crates tiny — only the macro definitions
- Push shared semantics (types, validation logic, builder patterns) into a normal library crate
- The proc-macro crate depends on the shared library crate, not vice versa
- Don't let proc-macro crates become dumping grounds for business logic
---
§6. Cargo Features as Module Design
Features are not just build toggles — they are part of how modularity behaves under compilation. Cargo features are additive, which means enabling a feature can never disable functionality.
LLM blind spot: Models split crates cleanly, then ignore feature interactions that recombine them into surprising build surfaces.
How to apply:
- Treat feature flags as part of the modular architecture, not just CI configuration
- Audit feature additivity: enabling feature A + feature B must not create conflicts
- Don't use features for mutually exclusive configurations (use separate crates instead)
- Document which features expose which public API surface
---
§7. Workspace Feature Unification
Cargo's feature unification means that if any crate in a workspace enables a feature on a shared dependency, ALL crates in that workspace get that feature. This is a major Rust-specific blind spot.
Tools:
cargo-hakari— manages workspace-hack crates to control unificationcargo-hack— tests all feature combinations to catch unification surprisescargo tree -e features— visualize which features are actually enabled and why
How to apply: A modular design that looks correct at the package level can be operationally unstable because feature unification changes what dependencies get built. Always check: "what features does my crate get when built as part of the workspace vs. standalone?"
---
§8. Workspaces for Governance, Not Domain Boundaries
Cargo workspaces share a lockfile and target directory, and support workspace.dependencies, workspace.package, and workspace.lints. This is excellent for version/lint/metadata policy.
LLM blind spot: Models overuse workspaces as if centralization were the same as modularity. A workspace is a governance tool, not a domain boundary.
How to apply:
- Use
workspace.dependenciesto centralize version pins - Use
workspace.lintsto enforce consistent lint policy - Don't conflate "same workspace" with "same domain" — crates in a workspace can serve completely unrelated purposes
- The workspace boundary doesn't define module cohesion
---
§9. Dependency Exposure Control
On nightly, Cargo's public-dependency feature marks dependencies as public or private, and rustc's exported_private_dependencies lint warns if a private dependency leaks into your public interface.
How to apply:
- Identify which dependencies' types appear in your public API
- Those are your true public dependencies — everything else should be implementation-private
- When a dependency's types leak into your public surface, you're coupling your semver stability to that dependency's release cadence
- Use
cargo-public-apito detect leakage
---
§10. Dependency Policy Tooling
In Rust, modularization is not complete until you have dependency-graph policy:
| Tool | Purpose |
|---|---|
cargo-deny | Lint dependency graph: bans, licenses, advisories, sources |
cargo-vet | Audit third-party dependencies against trusted reviews |
cargo-semver-checks | Catch semver regressions in public API changes |
cargo-public-api | List and diff public API surface (nightly) |
cargo-modules | Visualize module structure and internal dependency graph |
cargo-udeps | Detect unused dependencies (nightly) |
cargo-machete | Faster but imprecise unused dependency detection (stable) |
cargo-hack | Test all feature combinations |
cargo-hakari | Manage workspace feature unification |
How to apply: Include these in CI and run them before proposing modularization changes. The model should reason about what these tools would report, not just what the code "looks like."
Structural Coupling Analysis
Principles for analyzing and reducing coupling before extracting code. These address the LLM tendency to jump straight to "extract method/class" without first understanding the coupling topology.
---
§1. Seam Identification (Michael Feathers)
A seam is a place where behavior can be altered without editing the code at that point. Identifying seams before refactoring prevents cutting across natural boundaries.
Types of seams:
- Object seam: Replace a dependency via polymorphism (pass an interface, swap the implementation)
- Preprocessing seam: Alter behavior via build config, feature flags, or environment variables
- Link seam: Swap behavior at the module/import level (dependency injection, module mocking)
Why this matters for LLMs: The instinct is to extract code based on visual grouping — "these lines look related, extract them." But if the extraction cuts across a seam, you create a function that straddles two concerns and is harder to change than the original. Extracting _at_ a seam creates clean boundaries because the seam is already a point of behavioral variation.
How to apply:
1. Before extracting, ask: "where can behavior be altered without editing this code?" 2. Those alteration points are seams. Extract _at_ the seam, not across it. 3. If no seam exists for the change you need, create one first — introduce a parameter, extract an interface, or add an indirection layer. Then extract.
---
§2. Connascence Spectrum
Connascence measures the strength of coupling between components. It has a hierarchy — not all coupling is equally harmful. The goal is to refactor toward weaker forms, especially across module boundaries.
From weakest (acceptable) to strongest (refactor away):
| Strength | Type | Example | Across boundaries? |
|---|---|---|---|
| 1 | Name (CoN) | Using the same function name | Yes — this is fine |
| 2 | Type (CoT) | Agreeing on a type for a parameter | Yes — this is fine |
| 3 | Meaning (CoM) | status: 1 means "active" | No — use an enum |
| 4 | Position (CoP) | Argument order matters | No — use named params/kwargs |
| 5 | Algorithm (CoA) | Must use same hash algorithm | No — extract into shared module |
| 6 | Execution (CoE) | init() must be called before run() | No — enforce via type-state |
| 7 | Timing (CoT) | Race conditions between components | No — eliminate or synchronize |
| 8 | Value (CoV) | Values in two places must be consistent | No — single source of truth |
| 9 | Identity (CoI) | Must be the exact same instance | No — make explicit |
How to apply:
- Across module boundaries, only Name (CoN) and Type (CoT) connascence should exist.
- If you find Position (CoP) or stronger crossing a boundary, _that's_ the refactoring target — not the code that merely "looks messy."
- When reviewing a refactoring plan, check: does this change introduce stronger connascence than what existed before? If so, reconsider.
---
§3. Stability Metrics (Robert C. Martin)
These metrics determine the _direction_ dependencies should flow. LLMs don't naturally reason about coupling directionality.
Definitions:
- Afferent coupling (Ca) = number of external modules that depend on this module (incoming arrows)
- Efferent coupling (Ce) = number of external modules this module depends on (outgoing arrows)
- Instability I = Ce / (Ca + Ce). Range: 0 (maximally stable, hard to change) to 1 (maximally unstable, easy to change)
Two key principles:
1. Stable Dependencies Principle: Depend in the direction of stability. A module with I=0.8 (unstable) can depend on a module with I=0.2 (stable), but not the reverse. A volatile module must NOT be depended upon by a stable one — it drags the stable module into instability.
2. Stable Abstractions Principle: Stable modules (low I, many dependents) should be abstract. If a module has high Ca (many things depend on it) but is concrete, it becomes a painful bottleneck — it resists change but change is needed.
How to apply:
Before moving code between modules, estimate I for both. Move code so dependencies flow from higher I to lower I. If you're about to make a stable, concrete module more complex, consider extracting an interface first.
---
§4. Divergent Change vs. Shotgun Surgery
These are opposite code smells that require opposite fixes. LLMs routinely confuse them.
| Smell | Symptom | Correct Fix |
|---|---|---|
| Divergent change | One module changes for multiple unrelated reasons (billing logic + auth logic + UI formatting all in one file) | Split the module by reason-for-change |
| Shotgun surgery | One logical change (e.g., adding a new payment method) requires touching 5+ scattered files | Merge the scattered concern into one module |
The diagnostic questions:
- "How many _reasons_ does this module change?" → If >1, it's divergent change → split
- "How many _modules_ does this single reason touch?" → If >3, it's shotgun surgery → merge
Why LLMs get this wrong: Both smells involve "too much changing." LLMs pattern-match on "things are changing a lot, so I should break them apart." But shotgun surgery requires the _opposite_ — merging scattered pieces together. The distinguishing factor is whether the unit of analysis is the module (divergent) or the reason-for-change (shotgun).
---
§5. Data Ownership
Every important data structure should have a clear owning module — the single module responsible for creating, mutating, and enforcing invariants on that data. Other modules may read the data via contracts (function calls, events, read-only views) but should never mutate it directly.
Why LLMs miss this: LLMs often create "shared schema" modules where multiple modules import and freely mutate the same data structures. This creates invisible coupling — any module can put the data into an invalid state, and debugging requires understanding every mutation site.
How to apply:
- For each core data structure, ask: "which module is responsible for this data being correct?"
- That module owns the type definition and all write operations
- Other modules get read-only access or request changes through the owner's API
- Avoid "shared schema everywhere" unless the schema is intentionally canonical (e.g., a protobuf contract)
Red flag: If you see the same struct/class being mutated in 3+ different modules, ownership is unclear and invariants are undefended.
---
§6. Volatility Isolation
High-volatility code (vendor SDKs, file format parsers, framework adapters, DB drivers) should sit behind narrow, stable interfaces. This prevents churn in volatile dependencies from rippling into stable business logic.
Why LLMs miss this: LLMs don't assess how often a dependency changes. They wrap things for "clean architecture" reasons but miss the primary motivation: isolating the blast radius of change. A stable API wrapped around unstable internals is good. Mixing stable and unstable concerns is not.
How to apply:
- Identify volatile dependencies: anything with frequent version bumps, breaking API changes, or vendor lock-in risk
- Wrap each behind a narrow interface that exposes only what your code needs
- The interface should use your domain's types, not the vendor's types (this overlaps with Anti-Corruption Layer — see
architecture.md§2) - When the vendor SDK changes, only the adapter module changes; business logic is untouched
Volatility spectrum (from most volatile to most stable):
1. External vendor SDKs and third-party APIs 2. Framework-specific code (web framework, ORM, UI toolkit) 3. Infrastructure (database driver, message queue, cache) 4. Business rules and domain logic (should be the most stable layer)
Swift/macOS-Specific Modularization
Modularization principles for Swift on macOS that LLMs consistently miss. In Swift, the best modularization work is about controlling access, import, and binary/distribution boundaries — not creating more folders or frameworks.
---
§1. Default to Package/Target Boundaries, Not Frameworks
In modern Xcode/SwiftPM, a target is already a module boundary. Use local Swift packages and SwiftPM targets as the default modularization unit.
Boundary type selection (choose consciously):
| Boundary Type | When to Use |
|---|---|
| Package target/module | Default — source-level modularity within the app |
| Macro target | Swift macros (forced separate target, sandboxed execution) |
| Framework/XCFramework | Separately distributed binaries, independently versioned SDK components |
| Mergeable library | Keep library-shaped dev boundaries without separate runtime binaries |
LLM blind spot: Models reach for frameworks by default. Reserve frameworks/XCFrameworks for code built and updated separately from its clients. Library evolution (BUILD_LIBRARY_FOR_DISTRIBUTION) is off by default — don't enable it for modules that are always built and shipped together.
---
§2. Access Control as Architecture
Swift has fine-grained access control that LLMs under-use. The package access modifier (SE-0386) exists specifically so symbols can be shared across modules within the same package without making them public.
Access level hierarchy (narrowest first):
| Level | Scope | When to use |
|---|---|---|
private | Current declaration | Default for implementation details |
fileprivate | Current file | When multiple types in one file need shared access |
internal | Current module/target | Default for intra-module use |
package | Current package | Share across sibling modules without going public |
public | Any importing module | Part of the stable API surface |
open | Any module + subclassing/override | Only when external subclassing is intentional |
How to apply: Prefer package over public when sharing across sibling modules within the same Swift package. Use public instead of open unless you intentionally want external subclassing.
---
§3. Explicit Import Visibility (SE-0409)
SE-0409 added access-level modifiers on import. In current language modes (including Swift 6), imports default to public for source compatibility — meaning import Foo accidentally keeps dependency exposure broader than intended.
// WRONG: implicit public import — leaks dependency to consumers
import Foundation
// RIGHT: explicit import visibility
internal import CryptoKit // implementation-only dependency
package import SharedUtils // shared within this package only
public import DomainTypes // intentionally part of this module's public surfaceHow to apply: Write internal import, package import, or public import deliberately. Prefer internal import for implementation-only dependencies. This replaces the deprecated @_implementationOnly import.
---
§4. @_spi as Exception, Not Default
Swift's SPI (@_spi(name)) lets you expose "friend APIs" across module boundaries. But it's still an underscored mechanism.
Correct priority order for inter-module sharing:
1. internal/package access — covers most cases 2. Scoped imports (SE-0409) — controls what consumers see 3. @_spi — only when you genuinely need friend APIs that normal access control can't express
LLM blind spot: Models reach for @_spi as a first-class modularity tool. It should be a deliberate exception for rare cases.
---
§5. Module Aliasing for Collisions (SE-0339)
SwiftPM's module aliasing resolves naming collisions without source edits. But it has important limits:
- Works for pure Swift modules only
- Works for source builds, not distributed binaries
- Has caveats with ObjC/C/C++ interop and runtime reflection
How to apply: Use module aliasing when you have genuine naming collisions. Don't use it as a general modularity strategy — it's a collision resolver, not a boundary designer.
---
§6. Macro Target Isolation
Swift macros are not "just another helper module." A macro target:
- Is its own target type (built as a host executable)
- Executes in a sandbox (no filesystem or network access)
- Is automatically available to dependent targets
- Is coupled to
swift-syntaxreleases (toolchain-coupled)
How to apply:
- Keep macro targets thin — only macro definitions and syntax transformations
- Push shared domain logic into normal library targets
- Treat macro code as more toolchain-coupled than ordinary domain code
- Same principle as Rust proc-macros: thin boundary, shared semantics elsewhere
---
§7. Resources as Target-Owned Bundles
SwiftPM scopes resources to targets and treats them as module-local bundles accessed via Bundle.module. Resources are target-owned, not globally shared.
How to apply:
- UI assets, templates, and data files belong to the target that uses them
- Only extract a resource module when you want a real bundle-ownership boundary, not just to tidy folders
- Access resources via the package-provided
Bundle.modulemechanism
---
§8. Public API Governance
Once a package/module becomes a real boundary, you need tooling to protect it.
| Tool | Purpose |
|---|---|
swift package diagnose-api-breaking-changes | Detect semver regressions in public API |
Tuist graph | Visualize module dependency graph |
Tuist inspect implicit-imports | Find hidden dependency edges |
| Periphery | Dead code/declaration detection (supports macOS + Xcode + SwiftPM) |
cargo doc --document-private-items equivalent: Xcode DocC | Inspect internal vs public API surface |
How to apply: Run diagnose-api-breaking-changes before publishing any package update. Use Tuist or manual graph inspection to detect implicit imports and hidden dependency edges.
Library evolution caveat: Enabling BUILD_LIBRARY_FOR_DISTRIBUTION changes performance characteristics and affects exhaustive switch on enums (@frozen vs non-frozen). Only enable for separately distributed binary frameworks.
Tactical Refactoring Moves
Concrete techniques for executing refactorings safely. These address the gap between knowing _what_ to refactor and knowing _how_ to do it without breaking things.
---
§1. Deletion as Refactoring
The best refactoring often has a negative line count. Before adding abstraction, ask: "can I just delete the code that makes this complex?"
Deletion candidates:
- Dead code paths that no conditional ever reaches
- Unused abstractions (interfaces with one implementor that will never have a second)
- Unnecessary indirection layers (a wrapper that just calls through)
- Backwards-compatibility shims for migrations that finished months ago
# TODO: remove after Xwhere X has long passed- Feature flags for features that are permanently on or permanently off
Why LLMs miss this: LLMs are trained on code-generation tasks. Their instinct is to add, extract, wrap — creative construction. Deletion feels destructive and risky. But dead code has a maintenance cost: it confuses readers, appears in search results, and must be kept compiling when dependencies change.
How to apply: Before any additive refactoring (extract, wrap, abstract), first check: is there dead code that could simply be removed to solve the problem? Use git log and grep to verify something is truly unused before deleting.
---
§2. Rule of Three
Tolerate duplication until the third instance. Two copies might diverge; three copies that remain identical are a genuine pattern worth abstracting.
Why LLMs get this wrong: LLMs abstract on first duplication — they see two similar blocks and immediately extract a helper. But premature abstraction creates the _wrong_ abstraction, and wrong abstractions are harder to fix than duplication. You have to undo the abstraction before you can create the right one.
The reasoning: With two instances, you don't yet know which parts are the stable pattern and which parts are accidental similarity. The third instance reveals the pattern — the parts that remain identical across all three are the real abstraction; the parts that differ are the parameters.
How to apply: When you see two similar code blocks, resist the urge to extract. Wait. If a third instance appears with the same structure, _now_ you have enough evidence to extract the right abstraction with the right parameters.
---
§3. Inline Then Re-Extract
When a function has grown confused through years of patches, the decomposition reflects the historical accident of how it was modified, not the actual logic flow. LLMs try to split these functions further, creating more confusion.
The technique:
1. Inline everything back into the caller — expand all helper calls, unwind abstractions 2. Read the flattened code as one linear sequence 3. Re-extract with boundaries based on the actual logic flow you now see
Why this works: The flattened version reveals the true data flow and branching structure, stripped of the misleading names and boundaries that accumulated over time. Fresh extraction from this flat version creates a decomposition that matches reality.
How to apply: When a function and its helpers are hard to follow despite having "clean" names, the decomposition is probably wrong. Don't add another layer — inline everything, read it fresh, and re-decompose from scratch.
---
§4. Parallel Change (Expand-Contract)
For modifying interfaces that have multiple callers, never change the interface in-place. Instead:
1. Expand: Add the new interface (new method signature, new API endpoint, new type) _alongside_ the old one 2. Migrate: Move callers to the new interface one at a time, each as its own commit 3. Contract: Remove the old interface once all callers have migrated
Why LLMs get this wrong: LLMs modify interfaces in-place, breaking all callers simultaneously. This forces a single atomic commit that changes the interface AND all callers — a merge conflict magnet and a rollback nightmare.
How to apply: For any interface with >1 caller, use expand-contract. Each step is a small, reviewable, independently deployable commit:
- Commit 1: Add new method alongside old
- Commits 2-N: Migrate callers one at a time
- Final commit: Remove old method
This is especially important for public APIs, shared libraries, and database schemas.
---
§5. Boolean Parameter Prohibition
A boolean parameter means the function does two different things depending on the flag. At the call site, render(template, True) is unreadable — what does True mean?
# WRONG: what does True mean at call site?
render(template, True)
process(data, False, True)
# RIGHT: separate methods or enum
render_with_cache(template)
render_without_cache(template)
# or: enum makes the choice explicit
render(template, cache=CachePolicy.ENABLED)
process(data, validate=ValidationMode.SKIP, compress=CompressionMode.GZIP)How to apply: When you see a boolean parameter:
- If it changes the core behavior: split into two functions with descriptive names
- If it's a configuration toggle: use an enum with named values
- Exception: private/internal functions where the call site is 1-2 lines away and the meaning is obvious can use booleans
---
§6. Configuration as Explicit Dependency
Global config imports create hidden coupling — every function silently depends on a global singleton, making testing hard and reasoning non-local.
# WRONG: hidden dependency — can't test without manipulating global state
from config import settings
def send(msg):
if settings.DRY_RUN: return
actually_send(msg)
# RIGHT: explicit dependency — testable, dependencies visible in signature
def send(msg, *, dry_run: bool = False):
if dry_run: return
actually_send(msg)How to apply: Pass configuration as parameters. If many functions need the same config, group related settings into a dataclass and pass that:
@dataclass(frozen=True)
class EmailConfig:
dry_run: bool = False
smtp_host: str = "localhost"
timeout_seconds: int = 30
def send(msg, config: EmailConfig):
if config.dry_run: return
...Global config imports are only acceptable at the composition root — the main() function or application entry point that wires everything together.
---
§7. Characterization Tests Before Refactoring
Before ANY refactoring, write tests that pin the current behavior — including known bugs. These are called "characterization tests" (Michael Feathers) because they characterize what the code _actually does_, not what it _should_ do.
The process:
1. Write tests that cover the code's current behavior (inputs → actual outputs, even if buggy) 2. Refactor the code 3. Run the characterization tests — they must all pass 4. Only _then_ fix bugs in separate commits
Why LLMs get this wrong: LLMs refactor first, then discover tests break. At that point, it's unclear whether the test was wrong or the refactoring introduced a regression. By pinning behavior first, any test failure during refactoring is definitively a regression.
How to apply: For any refactoring that changes control flow, data flow, or module structure, write characterization tests _before_ making changes. Golden file tests (snapshot the output) are the fastest way to pin behavior for complex functions.
---
§8. Conway's Law Alignment
Module boundaries should align with team/ownership boundaries. If two teams own different features, those features should be in different modules — even if the code is technically similar.
Why LLMs get this wrong: LLMs optimize for technical elegance, grouping similar code together regardless of who maintains it. But in practice, a module owned by two teams creates coordination overhead: conflicting priorities, merge conflicts, unclear responsibility for bugs, and slow review cycles.
How to apply: When deciding module boundaries, factor in team structure:
- One team, one module (ideal)
- Shared modules should have a single designated owner team
- If two teams keep conflicting on a shared module, split it along team lines
This applies less to solo projects and small teams, but becomes critical at scale. Even in solo projects, thinking about "future team boundaries" can inform good modularization — features that might eventually be owned by different people should be in separate modules now.
---
§9. Over-Modularization Check
A module boundary is justified only if it improves change isolation, ownership clarity, or dependency direction. Not because names look neat, folders are balanced, or the architecture resembles a pattern.
Why LLMs miss this: LLMs reflexively split code into more modules because "modularity = good." But every boundary has a cost: indirection, more files to navigate, more imports to manage, more interfaces to maintain. A module that only reorganizes files without reducing coupling is fake modularity.
Signs of over-modularization:
- Pass-through layers with no real boundary value (a "service" that just calls the "repository" with identical arguments)
- Modules with only one caller and no independent reason to exist
- Abstractions created "for testing" that add complexity but the tests could work with simpler stubs
- Boundaries drawn for aesthetic reasons ("every feature should have its own module") rather than change-isolation reasons
How to apply: For every proposed module, be able to answer: "what change does this boundary protect me from?" If the answer is vague or hypothetical, the boundary probably shouldn't exist yet. It's easier to split a too-large module later than to merge a prematurely-split one.
---
§10. Module Documentation Template
When proposing a modularization refactoring, explicitly document each proposed module. This forces clear thinking about boundaries and prevents vague "it just feels cleaner" justifications.
For each module, state:
1. Responsibility: One sentence — what this module does 2. Reason(s) to change: What real-world events cause modifications here 3. Data ownership: What data structures this module owns and maintains invariants on 4. Depends on: What this module may import/call 5. Depended on by: What may import/call this module 6. Public contract: The interface exposed to consumers 7. Invariants: Rules that must always hold for this module's data 8. Side effects: IO, network, file, database operations performed 9. Error model: What errors this module surfaces to consumers 10. Why this boundary: Why this is better than keeping the code together
The last item is the most important — it forces justification for the boundary's existence and catches over-modularization (§9).
Type-Level Design for Refactoring
Principles for using the type system to eliminate categories of bugs at compile time. LLMs consistently under-use types as a design tool, defaulting to runtime validation and primitive types where compile-time guarantees are possible.
---
§1. Parse, Don't Validate (Alexis King)
Validation checks a condition and throws; parsing checks a condition and returns a typed result that encodes the proof. The difference is that parsing _preserves the evidence_ of validity in the type system.
# WRONG: validate then pass raw — nothing prevents passing unvalidated str later
def process_email(raw: str):
if not is_valid_email(raw):
raise ValueError("invalid email")
send_to(raw) # raw is still str
# RIGHT: parse into a type that proves validity
class Email:
def __init__(self, raw: str):
if not is_valid_email(raw):
raise ValueError("invalid email")
self.address = raw # construction IS the proof
def process_email(email: Email): # type signature requires proof
send_to(email)Why this matters: With validation, every function downstream must trust that someone upstream validated correctly. With parsing, the type signature _is_ the proof — if you have an Email, it was validated at construction. This eliminates an entire category of "forgot to validate" bugs.
How to apply: At every system boundary (user input, API response, file read, environment variable), parse into a domain type. Downstream functions accept the parsed type, never the raw input. If a function accepts str where it should accept Email, that's a refactoring target.
---
§2. Make Illegal States Unrepresentable
When a type has boolean or optional fields that can't actually vary independently, the type allows states that should be impossible. Discriminated unions eliminate these ghost states.
// WRONG: 4 boolean combos, only 2 are valid
// (connected=false, authenticated=true is impossible)
type Connection = { connected: boolean; authenticated: boolean };
// RIGHT: only valid states exist
type Connection =
| { state: "disconnected" }
| { state: "connected"; socket: WebSocket }
| { state: "authenticated"; socket: WebSocket; token: string };How to apply: When you see a boolean field or optional field, ask: "can the other fields be set independently of this one?" If the answer is no — if some combinations are nonsensical — replace with a discriminated union. Each variant carries exactly the fields that are valid in that state.
Language-specific patterns:
- TypeScript: Discriminated unions with a literal
typeorstatefield - Python:
@dataclasssubclasses orLiteralunion types (3.8+), orenum.Enum - Rust:
enumwith associated data (the gold standard) - Go: Interface with unexported methods + concrete structs per state
---
§3. Newtype / Branded Types
Wrapping primitive types in distinct domain types prevents accidentally swapping arguments that happen to share a primitive type.
// Branded type pattern (TypeScript)
type UserId = string & { readonly __brand: unique symbol };
type Email = string & { readonly __brand: unique symbol };
function sendEmail(to: Email, from: UserId) {} // compiler catches swapped args# Python: NewType for lightweight distinction
from typing import NewType
UserId = NewType("UserId", str)
Email = NewType("Email", str)
def send_email(to: Email, sender: UserId) -> None: ...How to apply: If two parameters have the same primitive type but different semantic meaning — especially across module boundaries — wrap them. Common candidates: IDs of different entity types, file paths vs URLs, amounts in different currencies, timestamps in different timezones.
---
§4. Temporal Coupling → Type-State Pattern
Temporal coupling exists when methods must be called in a specific order but nothing in the API enforces it. The type-state pattern makes the ordering un-break-able by returning a new type after each step.
# WRONG: temporal coupling — caller must know the magic order
client = Client()
client.connect() # must call first
client.authenticate() # must call second
client.send(data) # only valid after both — but compiles regardless
# RIGHT: type-state makes wrong order impossible
class Disconnected:
def connect(self, host: str) -> Connected: ...
class Connected:
def authenticate(self, creds: Credentials) -> Authenticated: ...
class Authenticated:
def send(self, data: bytes) -> None: ...
# Usage: the only path is Disconnected → Connected → Authenticated
session = Disconnected().connect("host").authenticate(creds)
session.send(data)
# session.connect(...) # AttributeError — can't go backwardsHow to apply: When you see a sequence where method B is only valid after method A, or documentation says "you must call X before Y," that's temporal coupling. The fix: each step returns a _different type_ that only exposes the methods valid in the new state. This converts a runtime "you forgot to initialize" crash into a compile-time (or at least type-checker) error.