Refactoring Catalog: Framework-Specific Recipes
Every ecosystem accumulates debt in its own dialect. React grows god components, Django grows fat models, Spring grows anemic services wired to everything, and .NET grows controllers that are secretly the whole application.
These are copy-ready before and after examples for the refactorings that come up most often in each stack, with the reasoning behind each change so you can adapt it rather than paste it.
This is one of three deep dives under Proven Techniques to Tackle Tech Debt. Start there if you are still deciding which technique fits your situation. The companion pages are characterization testing and dependency untangling.
How to Use This Catalog
A refactoring recipe is not a to-do list. Each guide below shows a real shape of bad code, the shape you want instead, and the specific transformation that gets you from one to the other. Read the "before" first and decide whether it actually describes your code - if it does not, skip the recipe. The most expensive refactoring is the one applied to a problem you did not have.
Pin the behavior
Before you move a line, get the current behavior under test. Refactoring without a safety net is just editing.
Change one shape
Apply exactly one recipe per pull request. Mixed refactorings are unreviewable and unrevertable.
Leave the door open
Stop at a state that is better than where you started, even if it is not the destination. Partial is fine; broken is not.
Framework-Specific Refactoring Guides
Detailed before/after examples for popular frameworks and languages
Different frameworks have different patterns for accumulating tech debt - and different strategies for cleaning it up. These guides provide concrete, copy-paste-ready examples for the most common refactoring scenarios in each ecosystem.
React Refactoring Guide React 19 TypeScript
React applications accumulate debt in predictable ways: god components, prop drilling, mixed concerns, and class component remnants. Here is how to systematically address each pattern.
Pattern 1: Extract Custom Hooks from God Components
God components mix data fetching, state management, business logic, and UI rendering. Extract reusable hooks to separate concerns.
// BEFORE: God component with mixed concerns (400+ lines)
// UserDashboard.tsx - BEFORE
import { useState, useEffect } from 'react';
export function UserDashboard({ userId }: { userId: string }) {
// State sprawl - all mixed together
const [user, setUser] = useState<User | null>(null);
const [orders, setOrders] = useState<Order[]>([]);
const [notifications, setNotifications] = useState<Notification[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState('overview');
const [searchQuery, setSearchQuery] = useState('');
const [filteredOrders, setFilteredOrders] = useState<Order[]>([]);
// Data fetching mixed with component
useEffect(() => {
setIsLoading(true);
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
setUser(data);
setIsLoading(false);
})
.catch(err => {
setError(err.message);
setIsLoading(false);
});
}, [userId]);
useEffect(() => {
fetch(`/api/users/${userId}/orders`)
.then(res => res.json())
.then(setOrders);
}, [userId]);
useEffect(() => {
fetch(`/api/users/${userId}/notifications`)
.then(res => res.json())
.then(setNotifications);
}, [userId]);
// Business logic mixed with component
useEffect(() => {
if (searchQuery) {
setFilteredOrders(
orders.filter(o =>
o.id.includes(searchQuery) ||
o.product.toLowerCase().includes(searchQuery.toLowerCase())
)
);
} else {
setFilteredOrders(orders);
}
}, [orders, searchQuery]);
const markNotificationRead = async (id: string) => {
await fetch(`/api/notifications/${id}/read`, { method: 'POST' });
setNotifications(prev =>
prev.map(n => n.id === id ? { ...n, read: true } : n)
);
};
// ... 300+ more lines of mixed concerns
// UI rendering, event handlers, formatting, etc.
return (
<div>
{/* 200+ lines of JSX */}
</div>
);
}// AFTER: Separated concerns with custom hooks
// hooks/useUser.ts - Extracted data fetching hook
import { useState, useEffect } from 'react';
import type { User } from '../types';
interface UseUserResult {
user: User | null;
isLoading: boolean;
error: string | null;
refetch: () => void;
}
export function useUser(userId: string): UseUserResult {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchUser = async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error(`Failed to fetch user: ${response.status}`);
}
const data = await response.json();
setUser(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchUser();
}, [userId]);
return { user, isLoading, error, refetch: fetchUser };
}
// hooks/useOrders.ts - Extracted orders hook with filtering
import { useState, useEffect, useMemo } from 'react';
import type { Order } from '../types';
interface UseOrdersResult {
orders: Order[];
filteredOrders: Order[];
isLoading: boolean;
searchQuery: string;
setSearchQuery: (query: string) => void;
}
export function useOrders(userId: string): UseOrdersResult {
const [orders, setOrders] = useState<Order[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
useEffect(() => {
setIsLoading(true);
fetch(`/api/users/${userId}/orders`)
.then(res => res.json())
.then(data => {
setOrders(data);
setIsLoading(false);
});
}, [userId]);
// Memoized filtering - no unnecessary re-renders
const filteredOrders = useMemo(() => {
if (!searchQuery) return orders;
const query = searchQuery.toLowerCase();
return orders.filter(order =>
order.id.includes(query) ||
order.product.toLowerCase().includes(query)
);
}, [orders, searchQuery]);
return { orders, filteredOrders, isLoading, searchQuery, setSearchQuery };
}
// hooks/useNotifications.ts - Extracted notifications with actions
import { useState, useEffect, useCallback } from 'react';
import type { Notification } from '../types';
interface UseNotificationsResult {
notifications: Notification[];
unreadCount: number;
markAsRead: (id: string) => Promise<void>;
markAllAsRead: () => Promise<void>;
}
export function useNotifications(userId: string): UseNotificationsResult {
const [notifications, setNotifications] = useState<Notification[]>([]);
useEffect(() => {
fetch(`/api/users/${userId}/notifications`)
.then(res => res.json())
.then(setNotifications);
}, [userId]);
const unreadCount = notifications.filter(n => !n.read).length;
const markAsRead = useCallback(async (id: string) => {
await fetch(`/api/notifications/${id}/read`, { method: 'POST' });
setNotifications(prev =>
prev.map(n => n.id === id ? { ...n, read: true } : n)
);
}, []);
const markAllAsRead = useCallback(async () => {
await fetch(`/api/users/${userId}/notifications/read-all`, { method: 'POST' });
setNotifications(prev => prev.map(n => ({ ...n, read: true })));
}, [userId]);
return { notifications, unreadCount, markAsRead, markAllAsRead };
}
// UserDashboard.tsx - AFTER: Clean, focused component
import { useState } from 'react';
import { useUser } from '../hooks/useUser';
import { useOrders } from '../hooks/useOrders';
import { useNotifications } from '../hooks/useNotifications';
import { DashboardHeader } from './DashboardHeader';
import { OrdersTable } from './OrdersTable';
import { NotificationPanel } from './NotificationPanel';
export function UserDashboard({ userId }: { userId: string }) {
const [activeTab, setActiveTab] = useState('overview');
// Clean hook usage - concerns separated
const { user, isLoading: userLoading, error } = useUser(userId);
const { filteredOrders, searchQuery, setSearchQuery } = useOrders(userId);
const { notifications, unreadCount, markAsRead } = useNotifications(userId);
if (userLoading) return <LoadingSpinner />;
if (error) return <ErrorDisplay message={error} />;
if (!user) return null;
return (
<div className="max-w-7xl mx-auto p-6">
<DashboardHeader
user={user}
unreadCount={unreadCount}
activeTab={activeTab}
onTabChange={setActiveTab}
/>
{activeTab === 'orders' && (
<OrdersTable
orders={filteredOrders}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
/>
)}
{activeTab === 'notifications' && (
<NotificationPanel
notifications={notifications}
onMarkRead={markAsRead}
/>
)}
</div>
);
}
// Benefits achieved:
// 1. Component reduced from 400+ lines to ~40 lines
// 2. Hooks are reusable across other components
// 3. Each hook is independently testable
// 4. Business logic separated from presentation
// 5. Memoization prevents unnecessary re-renders
// 6. TypeScript interfaces provide clear contractsPattern 2: Replace Prop Drilling with Context
When props pass through 4+ component levels, it is time for Context or state management.
// BEFORE: Prop drilling nightmare
// App.tsx - Props passed through 5 levels
function App() {
const [user, setUser] = useState<User | null>(null);
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const [cart, setCart] = useState<CartItem[]>([]);
return (
<Layout
user={user}
theme={theme}
setTheme={setTheme}
cart={cart}
setCart={setCart}
>
<Main
user={user}
theme={theme}
cart={cart}
setCart={setCart}
/>
</Layout>
);
}
// Layout.tsx - Just passes props through
function Layout({ user, theme, setTheme, cart, setCart, children }) {
return (
<div className={theme}>
<Header user={user} theme={theme} setTheme={setTheme} cart={cart} />
{children}
<Footer theme={theme} />
</div>
);
}
// Header.tsx - More prop passing
function Header({ user, theme, setTheme, cart }) {
return (
<header>
<Logo theme={theme} />
<Nav user={user} />
<ThemeToggle theme={theme} setTheme={setTheme} />
<CartIcon cart={cart} /> {/* Finally uses the prop */}
</header>
);
}
// Problem: Every component in the chain must know about all props
// Adding a new feature means updating 5+ components// AFTER: Context-based state management
// contexts/AppContext.tsx - Centralized state
import { createContext, useContext, useReducer, ReactNode } from 'react';
interface AppState {
user: User | null;
theme: 'light' | 'dark';
cart: CartItem[];
}
type AppAction =
| { type: 'SET_USER'; payload: User | null }
| { type: 'TOGGLE_THEME' }
| { type: 'ADD_TO_CART'; payload: CartItem }
| { type: 'REMOVE_FROM_CART'; payload: string }
| { type: 'CLEAR_CART' };
const initialState: AppState = {
user: null,
theme: 'light',
cart: [],
};
function appReducer(state: AppState, action: AppAction): AppState {
switch (action.type) {
case 'SET_USER':
return { ...state, user: action.payload };
case 'TOGGLE_THEME':
return { ...state, theme: state.theme === 'light' ? 'dark' : 'light' };
case 'ADD_TO_CART':
return { ...state, cart: [...state.cart, action.payload] };
case 'REMOVE_FROM_CART':
return { ...state, cart: state.cart.filter(item => item.id !== action.payload) };
case 'CLEAR_CART':
return { ...state, cart: [] };
default:
return state;
}
}
const AppContext = createContext<{
state: AppState;
dispatch: React.Dispatch<AppAction>;
} | null>(null);
export function AppProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(appReducer, initialState);
return (
<AppContext.Provider value={{ state, dispatch }}>
{children}
</AppContext.Provider>
);
}
// Custom hooks for specific state slices
export function useUser() {
const context = useContext(AppContext);
if (!context) throw new Error('useUser must be within AppProvider');
return {
user: context.state.user,
setUser: (user: User | null) =>
context.dispatch({ type: 'SET_USER', payload: user }),
};
}
export function useTheme() {
const context = useContext(AppContext);
if (!context) throw new Error('useTheme must be within AppProvider');
return {
theme: context.state.theme,
toggleTheme: () => context.dispatch({ type: 'TOGGLE_THEME' }),
};
}
export function useCart() {
const context = useContext(AppContext);
if (!context) throw new Error('useCart must be within AppProvider');
return {
cart: context.state.cart,
cartCount: context.state.cart.length,
addToCart: (item: CartItem) =>
context.dispatch({ type: 'ADD_TO_CART', payload: item }),
removeFromCart: (id: string) =>
context.dispatch({ type: 'REMOVE_FROM_CART', payload: id }),
clearCart: () => context.dispatch({ type: 'CLEAR_CART' }),
};
}
// App.tsx - Clean, no prop drilling
function App() {
return (
<AppProvider>
<Layout>
<Main />
</Layout>
</AppProvider>
);
}
// Header.tsx - Direct access to needed state
function Header() {
return (
<header>
<Logo />
<Nav />
<ThemeToggle />
<CartIcon />
</header>
);
}
// CartIcon.tsx - Uses only what it needs
function CartIcon() {
const { cartCount } = useCart();
return (
<button className="relative">
<ShoppingCartIcon />
{cartCount > 0 && (
<span className="badge">{cartCount}</span>
)}
</button>
);
}
// Benefits:
// 1. No prop drilling - components access state directly
// 2. Adding features doesn't require updating intermediate components
// 3. State updates are centralized and predictable
// 4. Custom hooks provide type-safe, focused interfaces
// 5. Easy to test - mock the context providerPython/Django Refactoring Guide Python 3.12+ Django
Django models often become god objects with business logic, validation, and database concerns all mixed together. Extract services to keep models focused on data representation.
Pattern: Extract Service Layer from Fat Models
# BEFORE: Fat model with too many responsibilities
# models.py - BEFORE: God model with everything
from django.db import models
from django.core.mail import send_mail
import stripe
class Order(models.Model):
user = models.ForeignKey('User', on_delete=models.CASCADE)
status = models.CharField(max_length=20, default='pending')
total = models.DecimalField(max_digits=10, decimal_places=2)
created_at = models.DateTimeField(auto_now_add=True)
# Business logic in model - BAD
def calculate_total(self):
"""Calculate order total with discounts and tax."""
subtotal = sum(
item.product.price * item.quantity
for item in self.items.all()
)
# Discount logic
if self.user.is_premium:
subtotal *= 0.9
# Tax logic
tax_rate = self._get_tax_rate(self.user.state)
return subtotal * (1 + tax_rate)
def _get_tax_rate(self, state):
rates = {'CA': 0.0725, 'NY': 0.08, 'TX': 0.0625}
return rates.get(state, 0)
# Payment processing in model - VERY BAD
def process_payment(self, payment_method_id):
"""Process payment via Stripe."""
try:
stripe.api_key = settings.STRIPE_SECRET_KEY
intent = stripe.PaymentIntent.create(
amount=int(self.total * 100),
currency='usd',
payment_method=payment_method_id,
confirm=True,
)
if intent.status == 'succeeded':
self.status = 'paid'
self.payment_id = intent.id
self.save()
# Notification in model - ALSO BAD
self._send_confirmation_email()
return True
return False
except stripe.error.CardError as e:
self.status = 'payment_failed'
self.save()
return False
def _send_confirmation_email(self):
"""Send order confirmation."""
send_mail(
subject=f'Order #{self.id} Confirmed',
message=f'Your order total: ${self.total}',
from_email='[email protected]',
recipient_list=[self.user.email],
)
# Inventory management in model - STILL BAD
def reserve_inventory(self):
for item in self.items.all():
product = item.product
if product.stock < item.quantity:
raise ValueError(f'Insufficient stock for {product.name}')
product.stock -= item.quantity
product.save()
# Problems:
# 1. Model knows about Stripe, email, inventory
# 2. Cannot test payment logic without Stripe
# 3. Cannot test email without email server
# 4. Single model has 6+ responsibilities# AFTER: Clean model with extracted services
# models.py - AFTER: Focused data model
from django.db import models
from decimal import Decimal
class Order(models.Model):
"""Order model - focused on data representation only."""
user = models.ForeignKey('User', on_delete=models.CASCADE)
status = models.CharField(max_length=20, default='pending')
subtotal = models.DecimalField(max_digits=10, decimal_places=2, default=0)
discount = models.DecimalField(max_digits=10, decimal_places=2, default=0)
tax = models.DecimalField(max_digits=10, decimal_places=2, default=0)
total = models.DecimalField(max_digits=10, decimal_places=2, default=0)
payment_id = models.CharField(max_length=100, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-created_at']
def __str__(self):
return f'Order #{self.id} - {self.user.email}'
# services/pricing.py - Pure business logic
from dataclasses import dataclass
from decimal import Decimal
from typing import List
@dataclass
class PricingResult:
subtotal: Decimal
discount: Decimal
tax: Decimal
total: Decimal
class PricingService:
"""Handles all pricing calculations - easily unit testable."""
TAX_RATES = {
'CA': Decimal('0.0725'),
'NY': Decimal('0.08'),
'TX': Decimal('0.0625'),
}
MEMBERSHIP_DISCOUNTS = {
'premium': Decimal('0.10'),
'gold': Decimal('0.05'),
'standard': Decimal('0'),
}
def calculate(
self,
items: List[dict],
membership_level: str,
state: str
) -> PricingResult:
"""Calculate complete order pricing."""
subtotal = self._calculate_subtotal(items)
discount = self._calculate_discount(subtotal, membership_level)
discounted_amount = subtotal - discount
tax = self._calculate_tax(discounted_amount, state)
total = discounted_amount + tax
return PricingResult(
subtotal=subtotal,
discount=discount,
tax=tax,
total=total,
)
def _calculate_subtotal(self, items: List[dict]) -> Decimal:
return sum(
Decimal(str(item['price'])) * item['quantity']
for item in items
)
def _calculate_discount(
self,
subtotal: Decimal,
membership_level: str
) -> Decimal:
rate = self.MEMBERSHIP_DISCOUNTS.get(membership_level, Decimal('0'))
return subtotal * rate
def _calculate_tax(self, amount: Decimal, state: str) -> Decimal:
rate = self.TAX_RATES.get(state, Decimal('0'))
return amount * rate
# services/payment.py - Payment gateway abstraction
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Optional
@dataclass
class PaymentResult:
success: bool
payment_id: Optional[str] = None
error: Optional[str] = None
class PaymentGateway(ABC):
"""Abstract payment gateway - allows multiple implementations."""
@abstractmethod
def charge(self, amount: Decimal, payment_method_id: str) -> PaymentResult:
pass
class StripePaymentGateway(PaymentGateway):
"""Stripe implementation of payment gateway."""
def __init__(self, api_key: str):
self.api_key = api_key
def charge(self, amount: Decimal, payment_method_id: str) -> PaymentResult:
import stripe
stripe.api_key = self.api_key
try:
intent = stripe.PaymentIntent.create(
amount=int(amount * 100),
currency='usd',
payment_method=payment_method_id,
confirm=True,
)
if intent.status == 'succeeded':
return PaymentResult(success=True, payment_id=intent.id)
return PaymentResult(success=False, error='Payment not confirmed')
except stripe.error.CardError as e:
return PaymentResult(success=False, error=str(e))
# services/order_service.py - Orchestration layer
from django.db import transaction
from typing import List
class OrderService:
"""Orchestrates order creation workflow."""
def __init__(
self,
pricing_service: PricingService,
payment_gateway: PaymentGateway,
inventory_service: 'InventoryService',
notification_service: 'NotificationService',
):
self.pricing = pricing_service
self.payment = payment_gateway
self.inventory = inventory_service
self.notifications = notification_service
@transaction.atomic
def create_order(
self,
user,
items: List[dict],
payment_method_id: str
) -> Order:
"""Create a complete order with payment and inventory updates."""
# Validate inventory
self.inventory.validate_stock(items)
# Calculate pricing
pricing = self.pricing.calculate(
items=items,
membership_level=user.membership_level,
state=user.state,
)
# Process payment
payment_result = self.payment.charge(
amount=pricing.total,
payment_method_id=payment_method_id,
)
if not payment_result.success:
raise PaymentError(payment_result.error)
# Create order
order = Order.objects.create(
user=user,
subtotal=pricing.subtotal,
discount=pricing.discount,
tax=pricing.tax,
total=pricing.total,
payment_id=payment_result.payment_id,
status='paid',
)
# Reserve inventory
self.inventory.reserve(items)
# Send notification (async)
self.notifications.send_order_confirmation(user.email, order)
return order
# tests/test_pricing.py - Easy to test!
import pytest
from decimal import Decimal
from services.pricing import PricingService
class TestPricingService:
def setup_method(self):
self.service = PricingService()
def test_calculates_subtotal_correctly(self):
items = [
{'price': '10.00', 'quantity': 2},
{'price': '25.00', 'quantity': 1},
]
result = self.service.calculate(items, 'standard', 'TX')
assert result.subtotal == Decimal('45.00')
def test_applies_premium_discount(self):
items = [{'price': '100.00', 'quantity': 1}]
result = self.service.calculate(items, 'premium', 'TX')
assert result.discount == Decimal('10.00')
def test_calculates_california_tax(self):
items = [{'price': '100.00', 'quantity': 1}]
result = self.service.calculate(items, 'standard', 'CA')
assert result.tax == Decimal('7.25')
# Benefits:
# 1. PricingService is 100% unit testable - no mocks needed
# 2. PaymentGateway can be mocked for testing
# 3. Easy to swap Stripe for another provider
# 4. Model is clean, focused on data
# 5. Clear separation of concernsJava/Spring Boot Refactoring Guide Java 25 LTS Spring Boot 4
Spring Boot applications often suffer from anemic domain models, service classes that are just transaction scripts, and tight coupling to frameworks. Adopt Clean Architecture patterns to make code more testable.
// BEFORE: Anemic model with logic in service
// OrderService.java - BEFORE: Transaction script style
@Service
public class OrderService {
@Autowired
private OrderRepository orderRepository;
@Autowired
private ProductRepository productRepository;
@Autowired
private PaymentClient paymentClient;
@Transactional
public Order createOrder(CreateOrderRequest request) {
// All logic in service - model is just data holder
Order order = new Order();
order.setUserId(request.getUserId());
order.setStatus("PENDING");
order.setCreatedAt(LocalDateTime.now());
BigDecimal total = BigDecimal.ZERO;
for (OrderItemRequest itemReq : request.getItems()) {
Product product = productRepository.findById(itemReq.getProductId())
.orElseThrow(() -> new RuntimeException("Product not found"));
if (product.getStock() < itemReq.getQuantity()) {
throw new RuntimeException("Insufficient stock");
}
OrderItem item = new OrderItem();
item.setProductId(product.getId());
item.setQuantity(itemReq.getQuantity());
item.setPrice(product.getPrice());
order.getItems().add(item);
total = total.add(
product.getPrice().multiply(BigDecimal.valueOf(itemReq.getQuantity()))
);
// Direct mutation
product.setStock(product.getStock() - itemReq.getQuantity());
productRepository.save(product);
}
order.setTotal(total);
// Direct external call
PaymentResponse payment = paymentClient.charge(total, request.getPaymentMethod());
if (!payment.isSuccess()) {
throw new RuntimeException("Payment failed");
}
order.setPaymentId(payment.getId());
order.setStatus("PAID");
return orderRepository.save(order);
}
}
// Problems:
// 1. Order entity is anemic - just getters/setters
// 2. All business rules in service
// 3. Tight coupling to repositories and external clients
// 4. Hard to test - needs full Spring context// AFTER: Rich domain model with ports and adapters
// domain/Order.java - Rich domain model
public class Order {
private final OrderId id;
private final UserId userId;
private final List<OrderItem> items;
private OrderStatus status;
private Money total;
private PaymentId paymentId;
private final Instant createdAt;
// Private constructor - use factory method
private Order(OrderId id, UserId userId, List<OrderItem> items) {
this.id = id;
this.userId = userId;
this.items = new ArrayList<>(items);
this.status = OrderStatus.PENDING;
this.total = calculateTotal();
this.createdAt = Instant.now();
}
// Factory method with validation
public static Order create(UserId userId, List<OrderItem> items) {
if (items == null || items.isEmpty()) {
throw new InvalidOrderException("Order must have at least one item");
}
return new Order(OrderId.generate(), userId, items);
}
// Business logic lives in the entity
private Money calculateTotal() {
return items.stream()
.map(OrderItem::getLineTotal)
.reduce(Money.ZERO, Money::add);
}
public void applyDiscount(Discount discount) {
if (this.status != OrderStatus.PENDING) {
throw new IllegalStateException("Cannot apply discount to non-pending order");
}
this.total = discount.apply(this.total);
}
public void markAsPaid(PaymentId paymentId) {
if (this.status != OrderStatus.PENDING) {
throw new IllegalStateException("Can only pay pending orders");
}
this.paymentId = paymentId;
this.status = OrderStatus.PAID;
}
public void cancel() {
if (this.status == OrderStatus.SHIPPED) {
throw new IllegalStateException("Cannot cancel shipped order");
}
this.status = OrderStatus.CANCELLED;
}
// Domain event
public OrderCreatedEvent toCreatedEvent() {
return new OrderCreatedEvent(this.id, this.userId, this.total);
}
// Getters only - no setters
public OrderId getId() { return id; }
public OrderStatus getStatus() { return status; }
public Money getTotal() { return total; }
}
// domain/ports/OrderRepository.java - Port (interface)
public interface OrderRepository {
Optional<Order> findById(OrderId id);
Order save(Order order);
List<Order> findByUserId(UserId userId);
}
// domain/ports/PaymentGateway.java - Port for external service
public interface PaymentGateway {
PaymentResult charge(Money amount, PaymentMethod method);
}
// application/CreateOrderUseCase.java - Application service
@UseCase // Custom stereotype annotation
public class CreateOrderUseCase {
private final OrderRepository orderRepository;
private final InventoryService inventoryService;
private final PaymentGateway paymentGateway;
private final EventPublisher eventPublisher;
// Constructor injection - no @Autowired needed
public CreateOrderUseCase(
OrderRepository orderRepository,
InventoryService inventoryService,
PaymentGateway paymentGateway,
EventPublisher eventPublisher) {
this.orderRepository = orderRepository;
this.inventoryService = inventoryService;
this.paymentGateway = paymentGateway;
this.eventPublisher = eventPublisher;
}
@Transactional
public Order execute(CreateOrderCommand command) {
// Validate inventory
inventoryService.validateStock(command.items());
// Create order - domain logic in entity
Order order = Order.create(command.userId(), command.items());
// Apply discount if eligible
command.discountCode()
.map(discountService::lookup)
.ifPresent(order::applyDiscount);
// Process payment
PaymentResult payment = paymentGateway.charge(
order.getTotal(),
command.paymentMethod()
);
if (!payment.isSuccess()) {
throw new PaymentFailedException(payment.getError());
}
order.markAsPaid(payment.getPaymentId());
// Reserve inventory
inventoryService.reserve(command.items());
// Persist
Order saved = orderRepository.save(order);
// Publish event
eventPublisher.publish(saved.toCreatedEvent());
return saved;
}
}
// infrastructure/persistence/JpaOrderRepository.java - Adapter
@Repository
class JpaOrderRepository implements OrderRepository {
private final OrderJpaRepository jpaRepository;
private final OrderMapper mapper;
@Override
public Optional<Order> findById(OrderId id) {
return jpaRepository.findById(id.value())
.map(mapper::toDomain);
}
@Override
public Order save(Order order) {
OrderEntity entity = mapper.toEntity(order);
OrderEntity saved = jpaRepository.save(entity);
return mapper.toDomain(saved);
}
}
// infrastructure/payment/StripePaymentGateway.java - Adapter
@Component
class StripePaymentGateway implements PaymentGateway {
private final StripeClient stripeClient;
@Override
public PaymentResult charge(Money amount, PaymentMethod method) {
try {
var intent = stripeClient.paymentIntents().create(
PaymentIntentCreateParams.builder()
.setAmount(amount.toCents())
.setCurrency("usd")
.setPaymentMethod(method.getId())
.setConfirm(true)
.build()
);
return PaymentResult.success(PaymentId.of(intent.getId()));
} catch (StripeException e) {
return PaymentResult.failed(e.getMessage());
}
}
}
// Benefits:
// 1. Domain model contains business logic
// 2. Use cases are testable without Spring context
// 3. Infrastructure concerns isolated in adapters
// 4. Easy to swap implementations (different payment provider)
// 5. Domain events enable eventual consistencyC#/.NET Refactoring Guide .NET 10 LTS C# 14
Legacy .NET applications often mix ASP.NET MVC controllers with Entity Framework and business logic. Modern .NET supports clean patterns with minimal boilerplate.
// BEFORE: Fat controller with mixed concerns
// OrdersController.cs - BEFORE
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
private readonly ApplicationDbContext _context;
private readonly IStripeClient _stripe;
[HttpPost]
public async Task<IActionResult> CreateOrder([FromBody] CreateOrderDto dto)
{
// Validation in controller
if (dto.Items == null || !dto.Items.Any())
return BadRequest("Order must have items");
// Business logic in controller
var total = 0m;
foreach (var item in dto.Items)
{
var product = await _context.Products.FindAsync(item.ProductId);
if (product == null)
return NotFound($"Product {item.ProductId} not found");
if (product.Stock < item.Quantity)
return BadRequest($"Insufficient stock for {product.Name}");
total += product.Price * item.Quantity;
}
// Payment in controller
var paymentIntent = await _stripe.PaymentIntents.CreateAsync(new()
{
Amount = (long)(total * 100),
Currency = "usd",
PaymentMethod = dto.PaymentMethodId,
Confirm = true
});
// Create order
var order = new Order
{
UserId = dto.UserId,
Total = total,
PaymentId = paymentIntent.Id,
Status = "Paid"
};
_context.Orders.Add(order);
await _context.SaveChangesAsync();
return Ok(order);
}
}// AFTER: Clean architecture with modern C# patterns
// Domain/Order.cs - Rich domain model with records
public sealed class Order
{
public OrderId Id { get; }
public UserId UserId { get; }
public IReadOnlyList<OrderItem> Items { get; }
public Money Total { get; private set; }
public OrderStatus Status { get; private set; }
public PaymentId? PaymentId { get; private set; }
private Order(UserId userId, IEnumerable<OrderItem> items)
{
Id = OrderId.New();
UserId = userId;
Items = items.ToList().AsReadOnly();
Status = OrderStatus.Pending;
Total = CalculateTotal();
}
public static Result<Order> Create(UserId userId, IEnumerable<OrderItem> items)
{
var itemList = items.ToList();
if (!itemList.Any())
return Result.Failure<Order>("Order must have at least one item");
return Result.Success(new Order(userId, itemList));
}
private Money CalculateTotal() =>
Items.Aggregate(Money.Zero, (sum, item) => sum + item.LineTotal);
public Result MarkAsPaid(PaymentId paymentId)
{
if (Status != OrderStatus.Pending)
return Result.Failure("Only pending orders can be paid");
PaymentId = paymentId;
Status = OrderStatus.Paid;
return Result.Success();
}
}
// Application/Commands/CreateOrderCommand.cs - CQRS pattern
public sealed record CreateOrderCommand(
Guid UserId,
IReadOnlyList<OrderItemDto> Items,
string PaymentMethodId
) : IRequest<Result<OrderDto>>;
// Application/Handlers/CreateOrderHandler.cs - MediatR handler
public sealed class CreateOrderHandler
: IRequestHandler<CreateOrderCommand, Result<OrderDto>>
{
private readonly IOrderRepository _orders;
private readonly IInventoryService _inventory;
private readonly IPaymentGateway _payments;
private readonly IEventPublisher _events;
public CreateOrderHandler(
IOrderRepository orders,
IInventoryService inventory,
IPaymentGateway payments,
IEventPublisher events)
{
_orders = orders;
_inventory = inventory;
_payments = payments;
_events = events;
}
public async Task<Result<OrderDto>> Handle(
CreateOrderCommand request,
CancellationToken ct)
{
// Validate inventory
var stockResult = await _inventory.ValidateStockAsync(request.Items, ct);
if (stockResult.IsFailure)
return stockResult.Error;
// Create order - business logic in domain
var items = request.Items.Select(i => new OrderItem(i.ProductId, i.Quantity, i.Price));
var orderResult = Order.Create(new UserId(request.UserId), items);
if (orderResult.IsFailure)
return orderResult.Error;
var order = orderResult.Value;
// Process payment
var paymentResult = await _payments.ChargeAsync(
order.Total,
request.PaymentMethodId,
ct);
if (paymentResult.IsFailure)
return paymentResult.Error;
// Mark as paid
order.MarkAsPaid(paymentResult.Value);
// Reserve inventory
await _inventory.ReserveAsync(request.Items, ct);
// Persist
await _orders.AddAsync(order, ct);
// Publish event
await _events.PublishAsync(new OrderCreatedEvent(order.Id), ct);
return order.ToDto();
}
}
// API/Endpoints/OrderEndpoints.cs - Minimal API
public static class OrderEndpoints
{
public static void MapOrderEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/orders")
.WithTags("Orders")
.RequireAuthorization();
group.MapPost("/", CreateOrder)
.WithName("CreateOrder")
.Produces<OrderDto>(StatusCodes.Status201Created)
.ProducesValidationProblem()
// .WithOpenApi() was removed here: it is deprecated in .NET 10
// (compiler diagnostic ASPDEPR002). The built-in OpenAPI document
// generation reads metadata methods like WithSummary instead.
.WithSummary("Create an order");
}
private static async Task<IResult> CreateOrder(
[FromBody] CreateOrderRequest request,
[FromServices] ISender mediator,
CancellationToken ct)
{
var command = new CreateOrderCommand(
request.UserId,
request.Items,
request.PaymentMethodId);
var result = await mediator.Send(command, ct);
return result.IsSuccess
? Results.Created($"/api/orders/{result.Value.Id}", result.Value)
: Results.Problem(result.Error);
}
}
// Benefits:
// 1. Domain model enforces business rules
// 2. Result pattern for explicit error handling
// 3. CQRS separates read/write concerns
// 4. Minimal API endpoints are thin
// 5. Easy to test handlers in isolationRelated Resources
Techniques Hub
The decision guide: which technique fits which situation, and how much permission each one needs.
Characterization Testing
Pin the current behavior before you apply any recipe here. Seams, sprout and wrap, and golden masters.
Dependency Untangling
When the problem is not the file you are editing but everything that reaches into it.
Frequently Asked Questions
Small enough that a reviewer can state the transformation in one sentence: "extracted the fetch logic into a hook", "moved order validation out of the model into a service". If you cannot write that sentence, the pull request is doing more than one thing and should be split. A refactoring pull request should also contain no behavior changes at all - if a bug fix sneaks in, reviewers stop trusting the diff and start reading every line, which is exactly what you were trying to avoid.
Yes, and they do not have to be good tests. Characterization tests that simply record what the code currently returns are enough to catch an accidental behavior change during a structural move. The point is not correctness, it is invariance: the output before the refactoring must match the output after. Once the structure is better, replacing those recorded expectations with real assertions is much easier. The companion page on characterization testing walks through building them for code that has no seams at all.
The underlying moves are framework-independent: separate data access from business rules, give each unit one reason to change, push side effects to the edges, and depend on abstractions rather than concrete infrastructure. The React hook extraction and the Django service layer are the same refactoring wearing different clothes. Read the closest example, identify which of those four moves it is performing, and apply that move using your own framework's idioms.
No. Refactoring is an investment in future changes to that code, so it only pays off where future changes will happen. Check the change history first: a file with no commits in two years is not costing you anything, no matter how ugly it is. Spend the effort on the files that appear in every second pull request. If the module is scheduled for replacement, the useful work is characterizing its behavior so the replacement has a specification, not tidying code that is about to be thrown away.
Encode the new shape as a rule the build enforces. A lint rule that forbids importing the data layer from a component, a maximum file length, a dependency-direction check, or an architecture test that fails when a layer reaches sideways. Documentation and code review will not hold the line, because both depend on someone remembering. If the structure you just created is worth the effort you spent, it is worth a check that fails the build when someone reverses it.
They are good at the mechanical part - extracting a function, renaming across a file, converting a class component - and poor at the judgment part, which is deciding where the boundary belongs. An assistant will happily split a component into six files that are all still coupled to the same global state, producing more files and no less debt. Use them to execute a boundary you have already chosen, keep the diff small enough to review line by line, and require that the characterization tests still pass before you accept it.
Not Sure Which Refactoring You Need?
Go back to the decision guide and match your situation to a technique before you touch the code.
Back to Proven Techniques