Plan More, Implement Less: A Claude Code Workflow for Large Java Codebases

Some time ago, I asked Claude to fix a defect in our system. It fixed the defect. The change was small, the tests were green, and the PR was approved. Two days later, three other components broke. The AI model had done exactly what it was asked. It found the shortest path to a passing test, edited a file, and stopped. What it never did was ask who else depended on that file. Nobody told it to look, so it didn't look — and the file turned out to be shared by four consumers with different expectations about its behavior.

On a larger feature, the same blindness showed up one level of indirection further out. Claude produced a working implementation — the component did what it was asked to do, and its own tests said so. What no one had analyzed was what happened downstream. The changed component still published to Kafka, but what it published had shifted, and several consuming services had opinions about that. We caught this one earlier. That was luck, not process. If you have used AI coding agents on anything larger than a side project, you have a version of this story.

Why we jump straight to implementation

Here is the uncomfortable part: this is not really a model problem or an AI problem. Developers like writing code. We do not like reading requirements, mapping dependencies, or sitting with an ambiguous problem long enough to understand it. Analysis feels like overhead; implementation feels like progress. Given the choice, most of us reach for the code editor.

Then we handed that instinct to a machine that executes it faster than we ever could. An agent asked to "fix the null pointer in the order service" will fix the null pointer in the order service. It will not spontaneously wonder whether the null was a symptom, whether four other services depend on the field being nullable, or whether the ticket's author actually wanted something else. It optimizes for the request you made, not the outcome you wanted — and it does so with total confidence and no visible hesitation.

What 16,000 projects tell us about planning

Bent Flyvbjerg spent his career building a database of more than 16,000 major projects across 20-plus sectors and 136 countries — bridges, tunnels, Olympic Games, IT programs. His finding, published in How Big Things Get Done, is brutal: 91.5% of projects overrun on time or budget. Only 0.5% hit cost, schedule, and benefits together. IT is among the worst performers in the set.

Flyvbjerg's prescription is in these words: Think slow, act fast. Projects succeed when teams spend disproportionate time in planning, where changes are cheap and reversible, so that execution can be swift and uneventful. Projects fail when they rush to break ground and discover their problems in later stages.

For AI-assisted development, the translation is almost too neat: Spend most time & tokens planning. Execute the plan fast and cheap. It works on two levels at once. Reasoning should be slow, thorough, and human-supervised. Execution should be fast and mechanical. And because model tiers exist, this is also literally true of your bill — frontier models for analysis, cheaper models for implementation.

Prompts don’t scale, Claude workflows do

You cannot version a prompt someone typed at 4 pm on Tuesday. You cannot review it, improve it, or hand it to a new developer. When it produces something good, you cannot tell why. When it produces something broken, you cannot tell why either. A workflow is different because it is written down and understood by the whole team.

How you implement a workflow — skills, subagents, MCP servers, slash commands, plain prompts in a text file — matters far less than people think. Those are implementation details, and they change every few months. The traits are what actually determine whether the output is any good.

  • Most of the time and money goes into analysis. If your workflow spends more effort generating code than analyzing the problem, it is optimizing the cheap half.

  • Planning and implementation are separate phases. Not separate sections of one conversation — genuinely separate, with a written artifact between them.

  • A human is in the loop twice. Once before planning is finished, once before implementation begins. Not to approve, but to challenge the design and spot edge cases that the AI model might have missed. -

  • Context is treated as a budget. Reasoning quality degrades as the context window fills, and it degrades hardest on exactly the analytical work you care about most. Keep planning sessions under roughly 100k tokens and delegate exploration to subagents that report back summaries instead of dumping files into your main session.

The six-step Claude Code workflow for enterprise Java systems

Claude Code Plan-First Software Development Workflow

Step 1 — Read the requirements

This is where we tell the AI agent about the feature description, goals, defect report, and acceptance criteria. In an enterprise setting, this usually comes from Jira or similar tools via MCP, and this step is a perfect fit for a cheap, fast model — it is retrieval, not reasoning. Unless you paste in the requirements manually, this data retrieval step can be done using a subagent to preserve the main context window.

Step 2 — Analyze the codebase with subagents and high-end AI models

Find every piece of code related to the change. This is where the broken-shared-file class of defect gets caught, and it is worth real money. Use a frontier model here, because this is genuine reasoning. Delegate the exploration to subagents so the raw file contents never enter your main context. Each returns a summary. Launch as many as the problem shape demands. There is no recipe. You can launch one subagent per microservice. Or one for backend, one for frontend, one specifically for cross-service dependencies. That last one earns its keep more often than you would expect.

Step 3 — Let the AI interview you relentlessly

This is the step most people skip, and in my experience it improves output quality more than any other single change. Flip the direction. The model has just analyzed the codebase; now it asks you questions, one at a time, based on what it found. Matt Pocock's `/grill-me` skill is an excellent place to start — it runs exactly this kind of relentless one-question-at-a-time interview, and it offers its own recommended answer with each question, so you get informed pushback instead of a blank interrogation.

I have since evolved my custom skills to push harder on implicit assumptions specifically, because those are what silently produce partial implementations. The goal is a shared vocabulary — you understand how the model is framing the problem, and the model learns the things about your system that were never written anywhere. Hidden assumptions become explicit. Edge cases surface while they are still free.

Step 4 — Write the plan to a file

We are finally in a position to write the actual implementation plan. It must be a sequenced list of concrete steps, detailed enough that a developer or a cheaper model could execute it without guessing. You can save it as markdown. Some fellow developers go one step further and add it in source control for later reference. Use Claude plan mode to create a thorough plan. A real plan file carries more scaffolding than a five-line sketch suggests.

There is no recipe for the plan structure. You are free to experiment until you find one that is best suited for your projects. Here is how I prefer to structure my plan files:

# {TICKET-ID} — Implementation Plan

## Jira Ticket (raw)
{paste verbatim — resist the urge to summarize; the model needs the
constraints the author actually wrote, not your interpretation of them}

## Code Analysis

### Affected Modules and Components
### Dependencies and Risk Areas

## Human Interview Session
{The answers the human offered in the deep dive interview phase}

## Step-by-Step Implementation
**Current step:** Step N
- [ ] Step 1 — ...
- [ ] Step 2 — ...
- [ ] Step 3 — ...

And here is a trimmed excerpt from an actual plan— SAFM-35, a ticket from an air-traffic safety monitoring system, implementing a pure-domain calculator for aircraft separation distance:

# SAFM-35 — Implementation Analysis

## Jira Ticket (raw)
**Summary:** Story 02-A -- Compute Aircraft Separation
**Description:** As the Separation Infringement Detection Service, I want
to compute horizontal and vertical separation for every aircraft pair in
a radar cycle so that the detection predicate has accurate, unit-correct
inputs.
[... full acceptance criteria and NOTES FOR AI AGENTS, pasted verbatim ...]

## Code Analysis

### Affected Modules and Components
| Module | File | Change |
|---|---|---|
| `separation-infringement-detection` | `domain/TrajectoryPosition.java` | NEW |
| `separation-infringement-detection` | `domain/SeparationCalculator.java` | NEW |
| `separation-infringement-detection` | `test/.../SeparationCalculatorTest.java` | NEW |

### Dependencies and Risk Areas
- Depends on "02-SCAFFOLD", but `RadarPositionMessage` doesn't exist in
  this service yet. Checked: the factory takes primitive floats, not the
  DTO type, so this ticket doesn't have to wait for it.
- Domain purity: zero Spring/Kafka/MongoDB imports in either new class.
- Regression risk: low — no existing classes are modified.

### Human Interview Session
- Euclidean distance on Cartesian coordinates is fine for a single-radar
  POC (confirmed by Sebastian).
- 1852 m/NM is the exact ICAO/ISO value — no rounding needed.
- Non-negativity is a defensive contract against upstream NaN/Infinity,
  not a geometry requirement.

## Step-by-Step Implementation

**Current step:** Step 1

- [ ] Step 1 — Write failing test: `TrajectoryPositionTest.java`
- [ ] Step 2 — Write failing test: `SeparationCalculatorTest.java`
- [ ] Step 3 — Implement `TrajectoryPosition.java`
- [ ] Step 4 — Implement `SeparationCalculator.java`
- [ ] Step 5 — Confirm GREEN
- [ ] Step 6 — Full module verification gate

### Step 1 — Write failing test: `TrajectoryPositionTest.java` (covers AC8)

- [ ] Create `backend/separation-infringement-detection/src/test/java/com/atcsafety/detection/domain/TrajectoryPositionTest.java` with exactly this content:

```java
package com.atcsafety.detection.domain;

import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class TrajectoryPositionTest {

    @Nested
    class From {

        /**
         * AC8: float x/y/altFeet from RadarPositionMessage must be widened to double
         * via an explicit named conversion (the static factory), not an implicit cast.
         */
        @Test
        void should_widen_float_x_to_double_when_created_via_factory() {
            var position = TrajectoryPosition.from("BA123", 9260.0f, 0.0f, 10000.0f);

            assertThat(position.x()).isEqualTo((double) 9260.0f);
        }

        @Test
        void should_widen_float_y_to_double_when_created_via_factory() {
            var position = TrajectoryPosition.from("BA123", 3000.0f, 4000.0f, 10000.0f);

            assertThat(position.y()).isEqualTo((double) 4000.0f);
        }

        @Test
        void should_widen_float_alt_feet_to_double_when_created_via_factory() {
            var position = TrajectoryPosition.from("AF456", 0.0f, 0.0f, 20000.0f);

            assertThat(position.altFeet()).isEqualTo((double) 20000.0f);
        }

        @Test
        void should_store_callsign_when_created_via_factory() {
            var position = TrajectoryPosition.from("KL887", 0.0f, 0.0f, 10000.0f);

            assertThat(position.callsign()).isEqualTo("KL887");
        }
    }

    @Nested
    class Equality {

        @Test
        void should_be_equal_when_all_components_are_identical() {
            var posA = TrajectoryPosition.from("BA123", 1000.0f, 2000.0f, 15000.0f);
            var posB = TrajectoryPosition.from("BA123", 1000.0f, 2000.0f, 15000.0f);

            assertThat(posA).isEqualTo(posB);
        }

        @Test
        void should_not_be_equal_when_callsigns_differ() {
            var posA = TrajectoryPosition.from("BA123", 1000.0f, 2000.0f, 15000.0f);
            var posB = TrajectoryPosition.from("AF456", 1000.0f, 2000.0f, 15000.0f);

            assertThat(posA).isNotEqualTo(posB);
        }
    }
}
```

- [ ] Confirm RED — run and expect a **compile error** (`TrajectoryPosition` does not exist yet):
```bash
mvn -pl backend/separation-infringement-detection test -Dtest=TrajectoryPositionTest
```

### Step 2 — Write failing test: `SeparationCalculatorTest.java` (covers AC1–AC7)

- [ ] Create `backend/separation-infringement-detection/src/test/java/com/atcsafety/detection/domain/SeparationCalculatorTest.java` with exactly this content:

```java
package com.atcsafety.detection.domain;

import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.within;

/**
 * Unit tests for {@link SeparationCalculator}.
 *
 * <p>All tests are pure Java — no Spring context, no Kafka, no MongoDB.
 */
class SeparationCalculatorTest {

    private final SeparationCalculator calculator = new SeparationCalculator();

    private TrajectoryPosition pos(String callsign, float x, float y, float altFeet) {
        return TrajectoryPosition.from(callsign, x, y, altFeet);
    }

    @Nested
    class CalculateHorizontalSeparation {

        /** AC1: identical positions — separation must be exactly 0.0 NM. */
        @Test
        void should_return_zero_when_positions_are_identical() {
            var posA = pos("BA123", 0.0f, 0.0f, 10000.0f);
            var posB = pos("AF456", 0.0f, 0.0f, 10000.0f);

            double result = calculator.horizontalSeparationNm(posA, posB);

            assertThat(result).isEqualTo(0.0);
        }

        /** AC2: aircraft B at (9260, 0) from origin — exactly 5.0 NM (9260 / 1852 = 5.0). */
        @Test
        void should_return_five_nm_when_aircraft_is_9260_metres_apart_on_x_axis() {
            var posA = pos("BA123", 0.0f, 0.0f, 10000.0f);
            var posB = pos("AF456", 9260.0f, 0.0f, 10000.0f);

            double result = calculator.horizontalSeparationNm(posA, posB);

            assertThat(result).isEqualTo(5.0);
        }

        /** AC3: 3-4-5 triangle in metres: sqrt(3000^2 + 4000^2) = 5000 m ≈ 2.700 NM. */
        @Test
        void should_return_approximately_2700_nm_when_positions_form_3_4_5_triangle_in_metres() {
            var posA = pos("BA123", 0.0f, 0.0f, 10000.0f);
            var posB = pos("AF456", 3000.0f, 4000.0f, 10000.0f);

            double result = calculator.horizontalSeparationNm(posA, posB);

            assertThat(result).isCloseTo(2.700, within(0.001));
        }

        /** AC4: horizontal separation must never be negative. */
        @Test
        void should_return_non_negative_separation_for_any_two_positions() {
            var posA = pos("BA123", 1000.0f, 2000.0f, 15000.0f);
            var posB = pos("AF456", 3000.0f, 1000.0f, 18000.0f);

            double result = calculator.horizontalSeparationNm(posA, posB);

            assertThat(result).isGreaterThanOrEqualTo(0.0);
        }
    }

    @Nested
    class CalculateVerticalSeparation {

        /** AC5: 20000 ft vs 21500 ft — vertical separation = 1500 ft. */
        @Test
        void should_return_1500_ft_when_aircraft_differ_by_1500_ft_ascending() {
            var posA = pos("BA123", 0.0f, 0.0f, 20000.0f);
            var posB = pos("AF456", 0.0f, 0.0f, 21500.0f);

            double result = calculator.verticalSeparationFt(posA, posB);

            assertThat(result).isEqualTo(1500.0);
        }

        /** AC6: 35000 ft vs 33000 ft (inverted order) — confirms absolute, not signed, difference. */
        @Test
        void should_return_2000_ft_when_aircraft_differ_by_2000_ft_descending_order() {
            var posA = pos("BA123", 0.0f, 0.0f, 35000.0f);
            var posB = pos("AF456", 0.0f, 0.0f, 33000.0f);

            double result = calculator.verticalSeparationFt(posA, posB);

            assertThat(result).isEqualTo(2000.0);
        }

        /** AC7: same altitude — vertical separation = 0 ft. */
        @Test
        void should_return_zero_when_aircraft_are_at_same_altitude() {
            var posA = pos("BA123", 0.0f, 0.0f, 25000.0f);
            var posB = pos("AF456", 0.0f, 0.0f, 25000.0f);

            double result = calculator.verticalSeparationFt(posA, posB);

            assertThat(result).isEqualTo(0.0);
        }
    }
}
```

- [ ] Confirm RED — run and expect a **compile error** (`SeparationCalculator` does not exist yet):
```bash
mvn -pl backend/separation-infringement-detection test -Dtest=SeparationCalculatorTest
```

None of the interesting work is in the step list. The ticket is pasted in full, not paraphrased. Affected Modules and Components forces an explicit, file-level answer to "what does this touch" before anything is written — the same question a rushed implementation skips. Dependencies and Risk Areas is where a shared-dependency trap gets caught. Here it also does double duty, reasoning about a blocking dependency ("02-SCAFFOLD") rather than assuming it. Human Interview Session is step 3 of this workflow, written down as a permanent record instead of evaporating at the end of a chat.

The "Current step" tracker paired with checkboxes deserves its own mention: it turns the plan into a resumable, checkpointed artifact. A session can stop after Step 2, hand off to a cheaper model or a different developer, and the next session knows exactly where execution left off — which is what makes the `/clear`-before-implementation move in Step 6 safe.

Step 5 — Review the plan‍ ‍

A human reads the plan and changes it. Think of it as code review that happens before the code exists — the cheapest review you will ever do, because the cost of deleting a wrong paragraph is zero and the cost of deleting a wrong implementation is not.

Step 6 — Implement‍ ‍

This part should be boring. The hard thinking is already encoded in the plan, so execution is largely mechanical — which means a cheaper model handles it perfectly well, and the review burden on the resulting code drops sharply because the decisions were reviewed already. I usually use Sonnet or Haiku for this phase, depending on how complex the change is. If you implement in the same session, run `/clear` first. Nothing from the planning conversation helps here, and all of it costs attention. The plan file is the handoff. That is the entire point of writing it down: a fresh session, a cheap model, and a document that contains everything needed.

You can also implement some of the coding yourself, in the parts you care about, and let Claude pick up the rest. The plan does not care who executes it.

Next
Next

Polymorphic Claude Code Skills: Separate the Stable Process from Concrete Implementations in Your SKILL.md