Refactoring Checklist
8 phases, each with a clear checklist. Commit after every phase. Zero regressions.
Phase 0: Pre-Refactoring Analysis
# Measure current state
wc -l src/**/*.py # Line counts
grep -r "Dict\[str" src/ # Raw dict usage
grep -r "Any" src/ # Any type usage
grep -r "except:" src/ # Bare excepts
pytest --cov=src --cov-report=term # Current coverageCreate refactoring plan:
- List all files to modify
- Identify breaking changes
- Define success criteria
- Create feature branch
git checkout -b refactor/module-namePhase 1: Extract Models (Pydantic v2)
Find all dict usages and replace with Pydantic models:
# BEFORE
def process(data: dict) -> dict:
return {"status": "ok", "result": data["value"]}
# AFTER
class ProcessInput(BaseModel):
value: str
count: int = Field(ge=0)
class ProcessOutput(BaseModel):
status: str
result: str
def process(data: ProcessInput) -> ProcessOutput:
return ProcessOutput(status="ok", result=data.value)Checklist:
- No
dictin function signatures - No
Dict[str, Any]anywhere - No
Anytype annotations -
model_validate()notparse_obj() -
model_dump()notdict() -
@field_validatornot@validator - Field constraints defined
Phase 2: Extract Exceptions
# BEFORE
try:
result = api_call()
except Exception as e:
return {"error": str(e)}
# AFTER
try:
result = api_call()
except httpx.HTTPStatusError as e:
raise NetworkError(f"API call failed: {e}") from eChecklist:
- No bare
except: - No generic
except Exception:without re-raise - All exceptions inherit from base
- Exception chaining with
from e - Error codes defined
Phase 3: Decompose Large Modules
large_module.py (800 lines)
├── Validation (150 lines) → validators/user.py
├── Repository (200 lines) → repositories/user.py
├── Service (250 lines) → services/user.py
├── Helpers (100 lines) → utils/helpers.py
└── Constants (100 lines) → _constants.pyExtract one group at a time → run tests → commit → repeat.
Checklist:
- All files < 500 lines
- All classes < 200 lines
- All functions < 20 lines
- No circular imports
- Public API in
__init__.py
Phase 4: Dependency Injection
# BEFORE — hidden dependencies
class UserService:
def __init__(self):
self.db = get_database()
self.cache = get_cache()
# AFTER — explicit injection
class UserService:
def __init__(self, db: DatabaseProtocol, cache: CacheProtocol) -> None:
self._db = db
self._cache = cacheChecklist:
- No global state in classes
- Dependencies via constructor
- Protocols defined for abstractions
- Testable with mocks
Phase 5: Async Conversion
# BEFORE — blocking
async def process():
response = requests.get(url) # BLOCKING!
data = json.load(open(path)) # BLOCKING!
# AFTER — async
async def process():
async with httpx.AsyncClient() as client:
response = await client.get(url)
async with aiofiles.open(path) as f:
data = json.loads(await f.read())Checklist:
- All I/O operations async
- No blocking calls in async
- Sync wrappers provided
- Connection pooling
- Proper timeout handling
Phase 6: Testing
Write missing tests for every public function:
@pytest.mark.asyncio
async def test_get_user_success(mock_repo, service):
mock_repo.find_by_id.return_value = sample_user
result = await service.get_user(1)
assert result == sample_user
@pytest.mark.asyncio
async def test_get_user_not_found(mock_repo, service):
mock_repo.find_by_id.return_value = None
with pytest.raises(NotFoundError):
await service.get_user(999)Checklist:
- Coverage >= 95%
- Error cases tested
- Async tests with pytest-asyncio
- No flaky tests
Phase 7: Documentation
def create_user(self, data: UserCreate) -> User:
"""Create a new user.
Args:
data: User creation data.
Returns:
Created user with assigned ID.
Raises:
ValidationError: If data fails validation.
ConflictError: If email exists.
"""Checklist:
- All public functions have docstrings
- Args, Returns, Raises documented
- Module docstrings explain purpose
- README updated if API changed
Phase 8: Final Validation
# Format
black src/ tests/
isort src/ tests/
# Lint
flake8 src/ tests/
mypy src/ --strict
# Test
pytest --cov=src --cov-fail-under=95
# Security
bandit -r src/Final Checklist:
- No
dict,Any, bareexcept: - Functions < 20, Classes < 200, Files < 500
- mypy —strict passes
- Coverage >= 95%
- All I/O async with pooling
Commit Strategy
Atomic commits per phase:
git commit -m "refactor(models): extract Pydantic v2 models"
git commit -m "refactor(exceptions): implement exception hierarchy"
git commit -m "refactor(services): decompose with DI"
git commit -m "refactor(async): convert I/O to async"
git commit -m "test: add tests for refactored module"
git commit -m "docs: update docstrings and README"Rollback Plan
# Feature flag for gradual migration
if config.use_new_models:
return new_process(NewInput.model_validate(data))
else:
return legacy_process(data)Rules
- Branch per refactor — never refactor on main
- One phase at a time — commit after each phase
- Tests pass after every commit — no regressions
- Measure before and after — line counts, coverage, performance
- Feature flags for risky changes — gradual rollout
Last updated on