Every engineering leader I talk to eventually says some version of this:
"We tried Cursor, Copilot, Claude, or another coding agent on our legacy module. It broke things. The agent does not understand the codebase."
They are usually right about the failure and wrong about the cause.
The model is rarely incapable of making the change. The trouble is that legacy code gives the agent no safe way to check whether a change is correct. The code being old is not the issue. The code being unprotected is.
Why agents fail on legacy code
Coding agents are very good at targeted changes when they have a feedback loop.
Give an agent a failing test and it can often implement the change quickly. Give it a healthy test suite and it can refactor while staying inside the guardrails.
Now remove the tests.
The agent reads a large service class with hidden side effects. It changes a conditional. It extracts a method. It moves dependencies around. But it has no reliable way to know whether the behavior is still correct, so it guesses.
Michael Feathers put it simply in Working Effectively with Legacy Code: legacy code is code without tests. His approach still applies:
- before you change behavior, expose seams
- before you refactor, pin current behavior
- before you trust the agent, give it a safety net
Without that safety net, the agent is gambling, not refactoring.
What legacy code actually looks like
When people say "legacy module," they rarely mean a clean old codebase. They mean something like this:
@Service
public class OrderProcessingService {
@PersistenceContext
private EntityManager entityManager;
@Autowired
private RestTemplate restTemplate;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Value("${pricing.api.url}")
private String pricingApiUrl;
@Value("${reports.output.dir}")
private String reportsDir;
@Autowired
private NotificationClient notificationClient;
@Transactional
public OrderResult processOrder(OrderRequest request) {
Customer customer = entityManager.createQuery(
"SELECT c FROM Customer c WHERE c.id = :id", Customer.class)
.setParameter("id", request.getCustomerId())
.getSingleResult();
PricingRules rules = (PricingRules) redisTemplate
.opsForHash().get("pricing:" + customer.getTier(), "rules");
if (rules == null) {
rules = restTemplate.getForObject(
pricingApiUrl + "/rules?tier=" + customer.getTier(),
PricingRules.class);
redisTemplate.opsForHash().put("pricing:" + customer.getTier(),
"rules", rules);
}
BigDecimal discount = BigDecimal.ZERO;
if (customer.getTier().equals("PLATINUM")) {
discount = request.getTotal().multiply(new BigDecimal("0.15"));
} else if (customer.getTier().equals("GOLD")) {
discount = request.getTotal().multiply(new BigDecimal("0.10"));
} else if (customer.getTier().equals("SILVER")) {
discount = request.getTotal().multiply(new BigDecimal("0.05"));
}
if (request.getCouponCode() != null) {
discount = discount.add(new BigDecimal("20.00"));
}
BigDecimal adjusted = rules.getMarkup().add(request.getTotal());
BigDecimal finalAmount = adjusted.subtract(discount);
Order order = new Order();
order.setCustomerId(customer.getId());
order.setOriginalTotal(request.getTotal());
order.setDiscount(discount);
order.setFinalAmount(finalAmount);
order.setStatus("PROCESSED");
entityManager.persist(order);
try {
String reportPath = reportsDir + "/order_" + order.getId() + ".csv";
Files.write(Paths.get(reportPath),
("total,discount,final\n" +
request.getTotal() + "," + discount + "," + finalAmount)
.getBytes());
} catch (IOException e) {
// swallowed
}
if (finalAmount.compareTo(new BigDecimal("1000")) > 0) {
notificationClient.sendWebhook(
"https://hooks.slack.com/services/T0EXAMPLE/B0EXAMPLE/000000000000",
"Large order: " + order.getId());
}
return new OrderResult(order.getId(), finalAmount, discount);
}
}
This one method touches:
- the database
- the cache
- an external API
- the file system
- a downstream notification client
- business logic
- configuration
That is several problems packed into one method.
If an agent changes this file without tests, it is working against side effects it cannot see. That is where trust collapses.
A method that works
Here is the sequence that works better.
1. Map the dependencies first
Before the agent writes code, have it map the surface area:
Analyze OrderProcessingService.java. For each method, list:
- external dependencies
- internal calls
- configuration usage
- likely seam points for extraction
Output as a markdown table.
The goal is to find where the code touches the outside world and where pure logic can be pulled out safely. A rough map is enough.
A person still leads this step. The agent can trace the mechanics. The engineer decides which seams matter.
2. Write characterization tests before refactoring
Most teams try to jump straight to unit tests. That usually fails because the code was never designed with clean unit boundaries.
Characterization testing works better: capture current behavior first, including the ugly parts.
@SpringBootTest
@Transactional
class OrderProcessingServiceCharacterizationTest {
// Pricing API, cache, file system, and notifications are stubbed
// in the test configuration, so only processOrder's logic runs.
@Autowired OrderProcessingService service;
@Autowired EntityManager entityManager;
@Test void goldCustomer_noCoupon_getsTenPercent() {
Customer customer = new Customer();
customer.setId(1L);
customer.setTier("GOLD");
entityManager.persist(customer);
OrderRequest request = new OrderRequest(1L, new BigDecimal("100.00"), null);
OrderResult result = service.processOrder(request);
assertThat(result.getDiscount()).isEqualByComparingTo("10.00");
}
@Test void couponOnSmallOrder_discountExceedsTotal() {
Customer customer = new Customer();
customer.setId(5L);
customer.setTier("SILVER");
entityManager.persist(customer);
OrderRequest request = new OrderRequest(5L, new BigDecimal("10.00"), "SAVE20");
OrderResult result = service.processOrder(request);
// Current buggy behavior, pinned intentionally
assertThat(result.getDiscount()).isEqualByComparingTo("20.50");
}
}
Pinning a bug here only records it; you fix it later, on purpose. The tests make current behavior visible, so the agent stops changing it by accident.
This is where coding agents help a lot. Once the scope is clear, they write repetitive characterization tests faster than most people want to by hand.
3. Refactor one seam at a time
Once behavior is pinned, limit the agent to one change at a time.
Extract the discount calculation logic from processOrder() into a package-private
method called calculateDiscount(String tier, BigDecimal total, String couponCode).
Do not change behavior. Run the characterization tests after the change.
If any test fails, stop and explain why.
That produces a smaller step you can verify:
BigDecimal calculateDiscount(String tier, BigDecimal total, String couponCode) {
BigDecimal discount = BigDecimal.ZERO;
if (tier.equals("PLATINUM")) {
discount = total.multiply(new BigDecimal("0.15"));
} else if (tier.equals("GOLD")) {
discount = total.multiply(new BigDecimal("0.10"));
} else if (tier.equals("SILVER")) {
discount = total.multiply(new BigDecimal("0.05"));
}
if (couponCode != null) {
discount = discount.add(new BigDecimal("20.00"));
}
return discount;
}
Then move to the next seam. One seam, one verification cycle; never ten refactorings in one prompt.
4. Put interfaces around the infrastructure
Once the logic starts to separate, pull out the external concerns:
- pricing lookup
- report writing
- notification sending
- repository access
That turns one method full of mixed concerns into components you can test:
public interface PricingService {
PricingRules getRules(String tier);
}
public interface ReportWriter {
void writeOrderReport(Order order);
}
The main service gets smaller, clearer, and easier to mock. More importantly, the agent now has clean boundaries for future changes.
5. Only then let the agent modernize
Once the safety net exists and the seams are exposed, the agent becomes useful for the cleanup that pays off:
- replace brittle conditionals
- fix bugs you have deliberately identified
- add proper error handling
- isolate side effects
- improve naming and structure
At that point the agent is working inside a checked workflow instead of guessing inside a black box.
What this looks like in practice
There is no one-prompt shortcut. It is a disciplined sequence:
Phase 1: Map the surface area
- identify dependencies
- find the seams
- separate pure logic from methods heavy on infrastructure
Phase 2: Pin current behavior
- write characterization tests
- include current bugs if they are part of real behavior
- make unintended changes visible
Phase 3: Extract and simplify
- pull pure logic into testable units
- put external concerns behind interfaces
- shrink the blast radius of each change
Phase 4: Modernize safely
- replace brittle structures
- fix known bugs on purpose
- improve error handling and boundaries
The timeline varies by codebase. What matters is that once the code is under test, the agent stops guessing and works inside a system that rejects bad changes quickly.
What this says about coding agents
Coding agents do not refactor by magic. They are very fast implementation engines that still need:
- constraints
- verification
- small change boundaries
- human review
The teams getting real value from coding agents on legacy code are rarely the ones with the cleverest prompts. They apply old, boring, correct engineering discipline (seams, tests, small verified changes) and then let the agent speed up the mechanical work.
Michael Feathers' playbook still holds. What changed is that agents make it faster to carry out once the safety net exists.
If your coding agent cannot refactor your legacy code, check the safety net before you blame the model.
At CoEdify, we use coding agents the way we use any serious engineering tool: inside a checked workflow. On legacy systems, the safety net around the agent is almost always what separates a useful agent from a dangerous one.