Hidden Design Decisions: Refactoring Control Coupling
Boolean flags often compress important design decisions into hidden behavior, leading to the classic problem of control coupling.
One of the worst things you can do to your code is to hide a design decision. In this article we’ll look at what happens when we compress design decisions into a boolean.
A boolean parameter looks harmless. It is just one extra argument, and inside the method it’s just one more branch. Yet that small boolean often hides a larger design problem: it forces the caller to select behavior.
That is the smell known as control coupling, first identified by Larry Constantine in the classic Structured Design from 1979.
Control coupling means one module controls the internal execution flow of another. Consider this example:
public String incidentUpdate(Incident incident,
boolean executiveAudience) {
String severity = severityLabel(incident.severityLevel());
String incidentReference = incident.incidentId() +
"@" + incident.service();
String impactBand = impactBand(incident.impactedUsers());
if (executiveAudience) {
return "EXEC SUMMARY | incident=" + incidentReference
+ " | severity=" + severity
+ " | impact_band=" + impactBand
+ " | impacted_users=" + incident.impactedUsers()
+ " | owner=" +
mitigationOwner(incident.mitigationOwner());
}
return "Engineering update for incident " + incidentReference
+ " in " + incident.region()
+ " is currently " + severity + ", impacting about " + incident.impactedUsers() + " users. "
+ "Impact band is " + impactBand + ". "
+ "Mitigation owner is "
+ mitigationOwner(incident.mitigationOwner()) + ".";
}When a method takes a flag like executiveAudience, it couples the caller to the callee’s control flow in a way that is both brittle and easy to underestimate. This causes problems for both callers and the implementing class:
Implicit knowledge: The client must know that
truemeans “use the executive version” andfalsemeans “use the engineering version”. That’s a weak API with hard-to-decode knowledge that does not belong at the call site.Low cohesion: Mostly, control coupling is just the messenger telling us that we are packing multiple concerns into a single method. This in turn leads to code that’s harder to reason about and fragile to change.
The cohesion problem is obvious in the preceding incidentUpdate method. Its name suggests composing an incident update, but that’s not what it does. It composes one of two different kinds of updates, each with its own policy and audience. Once those two responsibilities are forced into the same function, the caller has to participate in choosing between them.
The solution is to make that choice explicit. We do that by turning to another classic design pattern -- Strategy.
The core intent of Strategy is to encapsulate interchangeable behavior behind a common contract so the choice can vary independently from the code that uses it. You see, a boolean flag is often a compressed strategy. Instead of compressing behavior into a boolean, we now name it and make the variation point explicit.
We transform the original code by first encapsulating each choice in distinct classes:
// This is the API for our specific Strategy classes:
public interface IncidentAudience {
String composeUpdateFor(Incident incident,
IncidentNarrative narrative);
}
// All knowledge on how to compose for Executives goes here:
private static final class ExecutiveAudienceUpdate implements IncidentAudience {
@Override
public String composeUpdateFor(Incident incident,
IncidentNarrative narrative) {
return "EXEC SUMMARY | incident=" + narrative.incidentReference()
+ " | severity=" + narrative.severity()
+ " | impact_band=" + narrative.impactBand()
+ " | impacted_users=" + incident.impactedUsers()
+ " | owner=" + narrative.mitigationOwner();
}
}
// ...and this class encapsulates knowledge of how to
// communicate with Engineers:
private static final class EngineeringAudienceUpdate implements IncidentAudience {
@Override
public String composeUpdateFor(Incident incident,
IncidentNarrative narrative) {
return "Engineering update for incident "
+ narrative.incidentReference()
+ " in " + incident.region()
+ " is currently " + narrative.severity()
+ ", impacting about "
+ incident.impactedUsers() + " users. "
+ "Impact band is " + narrative.impactBand() + ". "
+ "Mitigation owner is "
+ narrative.mitigationOwner() + ".";
}
}
// Now we create the strategy objects.
// We expose the existing audience objects as a
// convenience for callers.
// That gives us a simple first refactoring step.
// This is a safe step as long as the strategy objects are stateless:
public static final IncidentAudience EXECUTIVE_AUDIENCE = new ExecutiveAudienceUpdate();
public static final IncidentAudience ENGINEERING_AUDIENCE = new EngineeringAudienceUpdate();
Yes, there is a bit more code than before. So what do we gain?
The real payoff appears in the original function, which can now simply delegate to the selected audience:
// The original method is now significantly simpler
// as it only delegates to the strategies that
// encapsulate the concept that varies.
public String incidentUpdate(Incident incident,
IncidentAudience receiver) {
IncidentNarrative narrative = narrativeFor(incident);
return receiver.composeUpdateFor(incident, narrative);
}
In the original version, the method itself selected behavior internally based on a flag. The refactoring shifts that responsibility to the caller, who now selects an IncidentAudience. The branch disappears because the design now models the distinction directly.
The improvement is immediate at the call site:
Before:
incidentUpdate(incident, true).After:
incidentUpdate(incident, EXECUTIVE_AUDIENCE).
The first forces the reader to remember what true means. The second communicates policy and explicit intent.
Bonus: Simplifying the Contract
There is a broader lesson hidden in this refactoring.
As part of the refactoring, we did the following move to introduce a simple domain type:
private static IncidentNarrative narrativeFor(Incident incident) {
return new IncidentNarrative(
severityLabel(incident.severityLevel()),
incident.incidentId() + "@" + incident.service(),
impactBand(incident.impactedUsers()),
mitigationOwner(incident.mitigationOwner())
);
}
private record IncidentNarrative(
String severity,
String incidentReference,
String impactBand,
String mitigationOwner
) {}
Let’s compare how this type impacts the code:
// We went from this original code:
public String incidentUpdate(Incident incident,
boolean executiveAudience) {
String severity = severityLabel(incident.severityLevel());
String incidentReference = incident.incidentId() +
"@" + incident.service();
String impactBand = impactBand(incident.impactedUsers());
// -- implementation --
}
// ...to this more expressive version that introduces
// a basic domain type:
public String incidentUpdate(Incident incident,
IncidentAudience receiver) {
IncidentNarrative narrative = narrativeFor(incident);
return receiver.composeUpdateFor(incident, narrative);
}
Introducing the domain type IncidentNarrative is a move in the right direction because it makes the strategy contract clearer. Each audience now receives the source incident plus a small domain object containing the shared interpreted facts. That keeps the strategies focused on message shape rather than repeated preparation work. It sharpens the boundary of the new design.
Of course, our string-heavy data record may not win any beauty contests. But it is still a clear improvement over the original.
We could obviously have introduced domain primitives for the other concepts like severity and owner, too. But I’d rarely do that in the initial refactoring iteration, and I want these articles to highlight the often imperfect intermediate steps of reshaping existing code.
Perfection is the enemy of getting things done. Especially in software design. The important part is to move the code in a direction where the next change becomes easier to reason about than the previous one. And this refactoring did just that.
Why this helps human review
Flag arguments force the reader to mentally split a method into hidden modes, causing friction that accumulates across a codebase.
Strategies replace that hidden mode switch with named behavior that tells us directly what the code does.
The additional introduction of a domain type helps for the same reason. It turns a cluster of derived values into one named domain concept, which reduces the amount of detail a reviewer must juggle while reading the implementation.
Why this matters in AI-first development
Boolean flags are cheap for humans to write and expensive for models to interpret.
A boolean flag carries little semantic information on its own. The model has to scan additional code and context to infer what behavior the argument selects. That is a weak interface.
By contrast, named strategy objects like EXECUTIVE_AUDIENCE and ENGINEERING_AUDIENCE expose meaning directly at the call site. The model can immediately see which policy is being used. That improves local reasoning, narrows edit scope, and makes automated refactoring safer.
The same applies to the introduction of the domain type. It’s a refactoring that not only encapsulates data, but also raises the semantic abstraction level by aligning behavior with the domain concept it belongs to. That delivers explicit intent by explaining the code’s purpose structurally.
As is often the case with strong refactorings, our code ended up supporting the Open-Closed Principle. If we need a new audience later, we add a new IncidentAudience implementation rather than reopening a long method and editing its internals. That matters to an AI for the same reason it matters to humans: extension becomes more local and less risky.
The power of programming languages: implementation choices
The refactoring in this chapter uses small classes to model the strategies. But that is not the only way to implement the idea.
In functional programming languages, where functions are first-class citizens, strategies are created with even less ceremony. This means a call site can stay explicit while remaining very small. Here’s how it would look in Clojure:
;; Use partial function application to pre-bind the incident to the update:
(def update-on-incident-for (partial incident-update incident))
;; Now we can produce updates with a minimum of syntactic ceremony:
(update-on-incident-for executive-audience)
(update-on-incident-for engineering-audience)
In Java, C#, or C++, we can use a similar lightweight variant by representing each strategy as a method reference instead of a dedicated class. That saves some implementation classes. The tradeoff is mostly one of readability and expressiveness: method references are lighter, but named classes can carry domain meaning more clearly when the policies deserve first-class names.
Recommendation: use lightweight method references for internal strategy objects that aren’t exposed via the class’s public API. Prefer a proper class hierarchy for public APIs, avoiding any syntactic noise.
This implementation variant leads us to an important point: a design pattern doesn’t prescribe a specific class hierarchy or implementation technique. The preceding strategy implementations all stick to the original design idea. Rather, the important part of a pattern is its intent and trade-offs. Use the implementation form that is best suited to what the problem calls for.
One more step toward AI-readable code
This refactoring demonstrates several of the CLEAR principles in practice.
Replacing the boolean flag with explicit strategies improves Local Reasoning by making behavior visible at the call site.
Discovering and naming the concepts that vary strengthens Conceptual Alignment.
Exposing behavior structurally improves Explicit Intent.
Decoupling the specific from the general helps Reduce the Edit Surface for future extensions.
Like the other refactorings in this series, the goal is to make the code more explicit about the problem it solves. That benefits both humans and machines.


