Strategic Programming
This lesson is a capstone. Everything you have learned in this course converges here: deep modules, information hiding, data-first design, composition, purity, locality. The question is not whether you know these principles but whether you default to them under pressure.
The difference between a tactical programmer and a strategic programmer is not knowledge. It is discipline. Both know what good design looks like. Only one insists on it when deadlines loom.
Tactical vs Strategic: The Real Difference
The tactical programmer thinks: "What is the fastest way to make this feature work?"
The strategic programmer thinks: "What is the simplest design that will make this feature work and remain understandable as the system evolves?"
The tactical programmer ships faster on day one. The strategic programmer ships faster on day ninety. By day three hundred, the tactical programmer is drowning in the complexity they created, and the strategic programmer is still shipping steadily.
// Tactical: get it working, worry about design later
function handlePayment(data: any) {
if (data.type == "credit") {
// 50 lines of credit card logic
} else if (data.type == "debit") {
// 45 lines of debit card logic
} else if (data.type == "paypal") {
// 40 lines of PayPal logic
} else if (data.type == "crypto") {
// added last week, 60 lines
}
// "later" never comes
}
// Strategic: invest in design now, reap benefits forever
type PaymentMethod =
| { readonly kind: "credit_card"; readonly cardToken: string; readonly amount: number }
| { readonly kind: "debit_card"; readonly cardToken: string; readonly amount: number }
| { readonly kind: "paypal"; readonly paypalOrderId: string; readonly amount: number }
| { readonly kind: "crypto"; readonly walletAddress: string; readonly amount: number; readonly chain: string };
type PaymentResult = Result<Receipt, PaymentError>;
// Each handler is a deep module
const handlers: Record<PaymentMethod["kind"], (method: PaymentMethod) => Promise<PaymentResult>> = {
credit_card: processCreditCard,
debit_card: processDebitCard,
paypal: processPayPal,
crypto: processCrypto,
};
function handlePayment(method: PaymentMethod): Promise<PaymentResult> {
return handlers[method.kind](method);
}
// Adding a new payment method:
// 1. Add variant to PaymentMethod union → compile errors show every switch to update
// 2. Write handler function
// 3. Add to handlers map
// Three steps, all local, all type-checked.Investing in Design: The 15% Rule
Ousterhout suggests investing about 15% of your development time in design improvement. Not building new features, not fixing bugs. Improving the design of existing code.
This is not refactoring for refactoring's sake. It is targeted improvement:
- When you touch a module, leave it cleaner. Fix one name, flatten one conditional, consolidate one scattered concern.
- When you add a feature, design the interface first. Before writing implementation, write the function signature. Does it feel simple? Does it hide the right things?
- When you find a leaky abstraction, fix it. Do not work around it. The workaround will become the next person's unknown unknown.
The compound effect is enormous. 15% per day, compounded over months, is the difference between a codebase that improves over time and one that decays.
Defining Errors Out of Existence
This is one of Ousterhout's most powerful ideas. Instead of handling errors, eliminate the conditions that cause them.
// Error-prone: caller must handle the error
function getElement<T>(array: readonly T[], index: number): T {
if (index < 0 || index >= array.length) {
throw new RangeError(`Index ${index} out of bounds [0, ${array.length})`);
}
return array[index];
}
// Every caller must think: "what if the index is wrong?"
// Error defined out of existence: clamp to valid range
function getElementSafe<T>(array: readonly T[], index: number, fallback: T): T {
if (array.length === 0) return fallback;
const clamped = Math.max(0, Math.min(index, array.length - 1));
return array[clamped];
}
// No error possible. The function always returns a valid element.Another example: instead of throwing when a map key does not exist, use Map.prototype.get which returns undefined. The "key not found" error is defined out of existence by making "not found" a normal return value.
// Error-prone: delete throws if item does not exist
function removeFromCart(cart: Cart, itemId: string): Cart {
const index = cart.items.findIndex(i => i.id === itemId);
if (index === -1) throw new Error(`Item ${itemId} not in cart`);
// ...
}
// Why throw? If the item is not there, the cart is already in the desired state.
// Error defined out of existence: remove is idempotent
function removeFromCart(cart: Cart, itemId: string): Cart {
return {
...cart,
items: cart.items.filter(i => i.id !== itemId),
};
}
// Removing a nonexistent item returns the cart unchanged. No error. No special case.The principle: if an error case can be handled by doing nothing (or by returning a sensible default), do that instead of throwing. Reserve exceptions for genuinely exceptional situations.
Pulling Complexity Downward (Reprise)
This principle from lesson 3 is worth repeating because it is the strategic programmer's most important habit.
Every design decision is a choice about where complexity lives. Push it into the interface and every caller pays. Pull it into the implementation and it is paid once.
// Complexity in the interface: every caller deals with pagination
async function fetchUsers(page: number, pageSize: number): Promise<{ users: User[]; total: number }> {
// ...
}
// Caller must manage page state, calculate total pages, handle empty pages.
// Complexity pulled down: module handles pagination internally
async function fetchAllUsers(): Promise<readonly User[]> {
const users: User[] = [];
let page = 0;
let hasMore = true;
while (hasMore) {
const result = await fetchPage(page, 100);
users.push(...result.users);
hasMore = result.users.length === 100;
page++;
}
return users;
}
// Caller: const users = await fetchAllUsers();
// Pagination is invisible. The caller gets all users.This does not mean always hiding pagination. If the caller genuinely needs to control pagination (UI paging, for instance), expose it. But if most callers want "all the data," provide that as the default and let them opt into pagination when they need it.
How to Evaluate Your Own Designs
Before committing code, run it through Ousterhout's red flags:
Red Flags Checklist
| Red Flag | What It Means |
|---|---|
| Shallow module | Interface is almost as complex as implementation |
| Information leakage | Implementation details escape through the interface |
| Temporal decomposition | Split by time instead of by information |
| Overexposure | More configuration than callers need |
| Pass-through method | Method that just calls another with same args |
| Repetition | Same code pattern appears in multiple places |
| Special-general mixture | One module handles both general case and special cases |
| Conjoined methods | Two methods that cannot be understood independently |
| Unknown unknowns | Changing one thing breaks something unexpected |
Self-Review Questions
Ask yourself before every commit:
- Can a new developer understand this code without asking me? If not, add information (better names, colocated logic, fewer abstractions).
- If I change one thing, how many files change? If more than two, your abstractions leak.
- How many concepts must the caller learn? Fewer is better. Can any be eliminated?
- Am I hiding information or just indirection? Indirection without information hiding is pure overhead.
- Is this the simplest design that works? Not the most flexible. Not the most "clean." The simplest.
The Course in One Page
Everything you have learned, distilled:
The Problem: Complexity is anything that makes code hard to understand or modify. It grows incrementally. The defense is discipline.
Deep Modules: Narrow interfaces, deep implementations. Pull complexity downward. The caller should learn little and get a lot.
Information Hiding: Hide implementation details, data representations, and design decisions. Avoid temporal decomposition.
Data-First Design: Use discriminated unions. Make illegal states unrepresentable. Use Option and Result instead of null and exceptions.
Composition: Build from independent pieces. Prefer interfaces over inheritance. Inject dependencies. Use pure functions.
Locality: Code that changes together belongs together. Organize by feature, not by layer. Minimize hops.
AHA: Do not abstract until you see the pattern three times. Hasty abstractions create coupling. Duplication is cheaper than the wrong abstraction.
Strategic Mindset: Invest 15% in design. Define errors out of existence. Evaluate your designs against the red flags checklist.
Check Your Understanding
What is the strategic programmer's key question?
What does 'defining errors out of existence' mean?
Name three red flags from Ousterhout's checklist and explain why each indicates a design problem.
Practice
Apply every principle from this course to rewrite a messy config parser:
Summary
- Tactical vs strategic: speed today vs sustainability forever. Invest in design.
- 15% rule: spend 15% of development time improving design.
- Define errors out of existence: if the error can be handled by doing nothing, do nothing.
- Pull complexity downward: modules absorb decisions so callers do not have to.
- Red flags checklist: shallow modules, information leakage, temporal decomposition, pass-through methods, unknown unknowns.
- The bar: will this code be obvious to the next person?
This is the end of the teaching content. The principles in this course are not trends. They are not opinions. They are the distilled experience of fifty years of software engineering, from Parnas and Dijkstra through Brooks and Ousterhout. They apply to every language, every paradigm, every scale. Master them.