Lesson 14 of 17 · Living With Code You Didn't Write
Strategic vs Tactical Programming
One ticket, two developers
The ticket says: charge a late check-out fee when a guest leaves after noon.
Developer one opens CheckOutReservationController, adds an if on the check-out time and a line that adds the fee to the invoice total. The tests pass. The ticket closes before lunch.
The action already prices the stay. It computes the nightly rate inline and takes a loyalty discount from a static helper written for an earlier ticket, App\Support\PricingHelper:
<?php
declare(strict_types=1);
namespace App\Support;
final class PricingHelper
{
public static function applyLoyaltyDiscount(int $totalCents, bool $isLoyaltyMember): int
{
if (! $isLoyaltyMember) {
return $totalCents;
}
return (int) round($totalCents * 0.9);
}
}Here is the check-out action after developer one's change:
<?php
declare(strict_types=1);
namespace App\Reservations;
use App\Support\PricingHelper;
use DateTimeImmutable;
use Illuminate\Database\Connection;
use Illuminate\Http\JsonResponse;
final readonly class CheckOutReservationController
{
public function __construct(
private Connection $db,
) {}
public function __invoke(int $reservationId): JsonResponse
{
$reservation = $this->db->table('reservations')->find($reservationId);
$room = $this->db->table('rooms')->find($reservation->room_id);
$guest = $this->db->table('guests')->find($reservation->guest_id);
$nights = (new DateTimeImmutable($reservation->check_in))
->diff(new DateTimeImmutable($reservation->check_out))
->days;
$totalCents = $room->nightly_rate_cents * $nights;
$totalCents = PricingHelper::applyLoyaltyDiscount($totalCents, (bool) $guest->is_loyalty_member);
$checkedOutAt = new DateTimeImmutable();
// Late check-out fee, added for this ticket.
if ($checkedOutAt > $checkedOutAt->setTime(12, 0)) {
$totalCents += 2_500;
}
$this->db->table('invoices')->insert([
'reservation_id' => $reservationId,
'total_cents' => $totalCents,
'issued_at' => $checkedOutAt->format('Y-m-d H:i:s'),
]);
return new JsonResponse(['total_cents' => $totalCents]);
}
}Developer two opens the same action and notices that the fee would be the third place in the codebase that knows how a stay is priced. Before adding anything, they move the rate and the discount into one StayPrice class with a test around it, then add the late check-out rule there. The ticket closes after lunch.
StayPrice holds every rule that decides what a stay costs:
<?php
declare(strict_types=1);
namespace App\Reservations\Pricing;
use DateTimeImmutable;
final readonly class StayPrice
{
// Taken off the room total for loyalty members. The late check-out fee is not discounted.
private const float LOYALTY_DISCOUNT = 0.10;
// Charged when the guest checks out after noon.
private const int LATE_CHECK_OUT_FEE_CENTS = 2_500;
public function __construct(
private int $nightlyRateCents,
private DateTimeImmutable $checkIn,
private DateTimeImmutable $checkOut,
private bool $isLoyaltyMember,
) {}
public function totalCents(DateTimeImmutable $checkedOutAt): int
{
$totalCents = $this->nightlyRateCents * $this->checkIn->diff($this->checkOut)->days;
if ($this->isLoyaltyMember) {
$totalCents = (int) round($totalCents * (1 - self::LOYALTY_DISCOUNT));
}
if ($checkedOutAt > $checkedOutAt->setTime(12, 0)) {
$totalCents += self::LATE_CHECK_OUT_FEE_CENTS;
}
return $totalCents;
}
}PricingHelper has no callers left, so it goes. The check-out action now gathers the inputs and asks StayPrice for the total:
<?php
declare(strict_types=1);
namespace App\Reservations;
use App\Reservations\Pricing\StayPrice;
use DateTimeImmutable;
use Illuminate\Database\Connection;
use Illuminate\Http\JsonResponse;
final readonly class CheckOutReservationController
{
public function __construct(
private Connection $db,
) {}
public function __invoke(int $reservationId): JsonResponse
{
$reservation = $this->db->table('reservations')->find($reservationId);
$room = $this->db->table('rooms')->find($reservation->room_id);
$guest = $this->db->table('guests')->find($reservation->guest_id);
$price = new StayPrice(
nightlyRateCents: $room->nightly_rate_cents,
checkIn: new DateTimeImmutable($reservation->check_in),
checkOut: new DateTimeImmutable($reservation->check_out),
isLoyaltyMember: (bool) $guest->is_loyalty_member,
);
$checkedOutAt = new DateTimeImmutable();
$totalCents = $price->totalCents($checkedOutAt);
$this->db->table('invoices')->insert([
'reservation_id' => $reservationId,
'total_cents' => $totalCents,
'issued_at' => $checkedOutAt->format('Y-m-d H:i:s'),
]);
return new JsonResponse(['total_cents' => $totalCents]);
}
}The test pins down three cases, including one the old code only implied: the loyalty discount applies to the room and not to the late fee. Developer one's controller behaves the same way, because the fee is added after the discount, but nothing in it says so.
<?php
declare(strict_types=1);
namespace Tests\Unit\Reservations\Pricing;
use App\Reservations\Pricing\StayPrice;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class StayPriceTest extends TestCase
{
#[Test]
public function charges_the_nightly_rate_for_each_night(): void
{
$price = $this->threeNightStay(isLoyaltyMember: false);
self::assertSame(36_000, $price->totalCents(new DateTimeImmutable('2026-10-04 10:45')));
}
#[Test]
public function adds_the_late_check_out_fee_after_noon(): void
{
$price = $this->threeNightStay(isLoyaltyMember: false);
self::assertSame(38_500, $price->totalCents(new DateTimeImmutable('2026-10-04 12:30')));
}
#[Test]
public function discounts_the_room_but_not_the_late_check_out_fee(): void
{
$price = $this->threeNightStay(isLoyaltyMember: true);
self::assertSame(34_900, $price->totalCents(new DateTimeImmutable('2026-10-04 12:30')));
}
private function threeNightStay(bool $isLoyaltyMember): StayPrice
{
return new StayPrice(
nightlyRateCents: 12_000,
checkIn: new DateTimeImmutable('2026-10-01'),
checkOut: new DateTimeImmutable('2026-10-04'),
isLoyaltyMember: $isLoyaltyMember,
);
}
}$ vendor/bin/phpunit --testdox --filter StayPriceTest
Stay Price (Tests\Unit\Reservations\Pricing\StayPrice)
✔ Charges the nightly rate for each night
✔ Adds the late check out fee after noon
✔ Discounts the room but not the late check out fee
OK (3 tests, 3 assertions)Both developers shipped the feature. Both did it in one day. Only one of them left the next pricing change cheaper than they found it.
Two postures
John Ousterhout names the two postures behind those developers in chapter 3 of A Philosophy of Software Design, titled "Working Code Isn't Enough".
The tactical programmer's goal is to get something working: a feature, a bug fix, a green build. Every decision along the way is judged by that goal, so whatever gets to working sooner wins. A bit of duplication, a special case in the controller, a flag on a model. Each one is small and each one is defensible on its own.
The strategic programmer's goal is a design that keeps the next change cheap. Every task is also a chance to leave the codebase in slightly better shape than it was, and the extra time that takes is part of the task rather than a favour to the team.
Both of them care about shipping. The difference is in what "done" means. Tactical is done when it works. Strategic is done when it works and the next person can work with it.
Why tactical wins every ticket and loses the product
The if in the controller shipped the feature before lunch. The cost lands later, on someone else's ticket.
In developer one's codebase, the next ticket asks for a quote: the front desk wants to show a guest their total before they check out. The fastest path copies the arithmetic from the check-out action into a new controller:
<?php
declare(strict_types=1);
namespace App\Reservations;
use App\Support\PricingHelper;
use DateTimeImmutable;
use Illuminate\Database\Connection;
use Illuminate\Http\JsonResponse;
final readonly class ReservationQuoteController
{
public function __construct(
private Connection $db,
) {}
public function __invoke(int $reservationId): JsonResponse
{
$reservation = $this->db->table('reservations')->find($reservationId);
$room = $this->db->table('rooms')->find($reservation->room_id);
$guest = $this->db->table('guests')->find($reservation->guest_id);
$nights = (new DateTimeImmutable($reservation->check_in))
->diff(new DateTimeImmutable($reservation->check_out))
->days;
$totalCents = $room->nightly_rate_cents * $nights;
$totalCents = PricingHelper::applyLoyaltyDiscount($totalCents, (bool) $guest->is_loyalty_member);
return new JsonResponse(['total_cents' => $totalCents]);
}
}Take the loyalty member from the test, three nights at 12,000 cents, checking out at 12:30. The quote says 32,400 cents and the invoice says 34,900. The late fee lives in an if inside a different controller, and nothing pointed the second developer at it. In developer two's codebase the quote builds the same StayPrice and calls totalCents() with the current time, so both screens show the same number.
Lesson 3 showed the cost-of-change curve: a change that takes two days in week two rarely takes two days in year three. Tactical shortcuts are what bend that curve upwards. Each one adds a little accidental complexity, in Lesson 6's sense: complexity the problem never required, added on the way to a green build. Together they are the codebase from Lesson 1, where nobody touches a file unless they have to.
This is why "we'll clean it up later" almost never happens. Later, there is no single thing to clean up. There are hundreds of small, reasonable-looking decisions, and no ticket for undoing any of them.
The tactical tornado
Ousterhout has a name for the extreme case: the tactical tornado. A prolific developer who closes tickets faster than anyone else on the team, gets praised for it, and leaves behind code that everyone else has to work around. In his telling, the engineers who clean up after the tornado are the ones who end up looking slow.
I've worked with a few. None of them were careless people. They were responding to what the environment rewarded: tickets closed, features demoed, "how fast can you get this out?". A team that measures output by tickets closed tends to produce tornadoes. The posture follows the incentives, which is why lecturing individuals about code quality changes so little.
What the investment looks like
Ousterhout's figure is that a strategic programmer spends roughly 10 to 20 percent of their development time on investment. His claim is that this is small enough not to hurt schedules, that you start seeing the benefits within a few months, and that from then on past investments pay for future ones. His lecture notes guess the investment pays for itself in six to twelve months, question mark included. None of it is measured, and he says so in the caption of the chapter's chart comparing the two approaches over time: "I am not aware of any empirical measurements of the precise shapes of the curves."
The investment is spread across every task, never saved up for a cleanup sprint. Lesson 9 made the same point about the Boy Scout Rule: the daily habit holds the line, and a planned project moves it. Strategic programming is the daily habit, applied to design rather than to readability. The ten-minute cap from that lesson still applies to detours away from the work you came to do. Design work the ticket itself needs is part of the ticket, and it can still ship first as its own refactoring PR, like the cleanup PR there.
In practice it looks like this:
Before writing, spend a few minutes on the shape. Where does this rule belong? Is there an existing concept it fits, or is it a new one that needs a name (Lesson 12)?
While writing, take the slightly slower path when the fast one would add a special case to something that already has too many.
After it works, look at what you touched and ask what the next person needs. A better name, a test that describes the rule, a comment that records why (Lesson 17).
When you find a problem you didn't cause, fix it if it's inside the file you're already in (Lesson 4). Otherwise write it down and move on. The investment stays small per task on purpose.
Developer two's ticket closed after lunch instead of before. In exchange, the next pricing rule, and every rule after it, will land in the same place.
Strategic is not over-engineering
The easiest way to misread this lesson is to hear "invest in design" as "build for the future". Lesson 7 covered why that goes wrong. Over-engineering designs for the change you imagine. Strategic programming designs for the change you can see.
The move in the opening was about cohesion (Lesson 5): pricing logic spread across a controller and a helper moved into one class, next to the rule the ticket added. One new class with a test, and no interface, pattern or layer. It was a small change that makes the next one cheaper. The strategic programmer still checks Lesson 8's triggers before extracting anything, still lets pattern follow pain (Lesson 7), and still asks what the context can afford (Lesson 3).
For contrast, here is the version that goes too far. Each rule becomes its own class behind an interface, and the inputs travel together in a PricingContext:
<?php
declare(strict_types=1);
namespace App\Reservations\Pricing;
use DateTimeImmutable;
final readonly class PricingContext
{
public function __construct(
public int $nightlyRateCents,
public DateTimeImmutable $checkIn,
public DateTimeImmutable $checkOut,
public bool $isLoyaltyMember,
public DateTimeImmutable $checkedOutAt,
) {}
}PricingRule is the interface every rule implements:
<?php
declare(strict_types=1);
namespace App\Reservations\Pricing;
interface PricingRule
{
public function apply(int $totalCents, PricingContext $context): int;
}The late check-out fee becomes one of those rules:
<?php
declare(strict_types=1);
namespace App\Reservations\Pricing;
final readonly class LateCheckOutFeeRule implements PricingRule
{
// Charged when the guest checks out after noon.
private const int FEE_CENTS = 2_500;
public function apply(int $totalCents, PricingContext $context): int
{
if ($context->checkedOutAt > $context->checkedOutAt->setTime(12, 0)) {
return $totalCents + self::FEE_CENTS;
}
return $totalCents;
}
}To finish the job you also need a NightlyRateRule, a LoyaltyDiscountRule, a pipeline class that runs the rules in turn, and a service provider that registers them in order. That's seven files for three rules that StayPrice holds in one method. The order is a rule of its own: the discount has to run before the fee, or the fee gets discounted too. In StayPrice that order is two if statements, one above the other, backed by a comment and a test. In the pipeline it is the order of an array in a service provider, away from the rules it controls. This design earns its keep once pricing rules vary per hotel or get switched on and off by the business. Nothing in the ticket asked for that.
The line between strategic and over-engineered is whether the work was driven by a change that is happening now. If you're moving code because the ticket in front of you touches it, you're investing. If you're moving code because a hypothetical future ticket might touch it, you're speculating.
None of this is a licence to refactor everything you open. A developer who turns every ticket into a redesign is a different kind of tornado.
The startup argument
The usual objection is that early-stage products can't afford this. Ship now, fix it when there's traction. Ousterhout addresses the objection in the same chapter, using Facebook's early "move fast and break things" as the example of a tactical culture, and notes that the company later changed it to "move fast with solid infrastructure". His counter is that the payoff for design comes quickly, so "there's a good chance that the tactical approach won't even speed up your first product release." He also concedes that "a company can succeed with either approach."
Lesson 2's question, which context are you in, gives the honest answer. Some contexts really are throwaway: a prototype to test whether anyone wants the product at all. Tactical is correct there, as long as everyone agrees the code is disposable and it actually gets disposed of. The trouble starts when the prototype becomes the product, which is how most of the long-lived codebases I've worked on began.
How to tell which one you're doing
Three questions, asked at the end of a task:
Did I leave the code I touched easier or harder to change than I found it?
Is there a shortcut in this change that I know about and the next reader won't?
If there is, did I record it, and what undoing it would take, somewhere they will find it?
An honest "harder" on the first question is fine occasionally. Deadlines are real. A shortcut you know about is Lesson 9's prudent, deliberate debt only if it comes with a plan, and the third question is where that plan gets written down. A shortcut that nobody records turns into the kind of debt nobody can see.
The one thing to remember
Every task makes the next one either cheaper or more expensive, and the tactical choice never looks wrong on the day. Strategic programming is deciding in advance that working code is not enough, and paying a small, bounded price on each task so that the price of the next one stays flat.
Exercise
Take your last three merged pull requests.
For each one, answer the three questions from this lesson honestly.
Find one place where you took the tactical path. Estimate what the strategic version would have cost on the day, in minutes.
Find out whether anyone has touched that code since. If they have, look at what they had to work around.
Related Links
- A Philosophy of Software DesignJohn Ousterhout's book. Chapter 3, "Working Code Isn't Enough", is the source of the tactical and strategic distinction.
- Working Isn't Good EnoughThe lecture notes from Ousterhout's Stanford software design course covering the same chapter.
- Is High Quality Software Worth the Cost?Martin Fowler on why poor internal quality starts slowing a team down within weeks.
- Technical Debt QuadrantThe prudent versus reckless split that decides whether a tactical shortcut is defensible.