Make the Domain Explicit: From Procedural Mess to Local Reasoning
The more code hides, the more humans and agents have to reconstruct before making a safe change. In this article, we break apart a complex procedural method to optimize for reasoning.
AI models do not “understand” code the way humans do. They infer meaning, and depend heavily on what the code communicates through names, boundaries, and structure.
This is also why long procedural methods make coding life harder for agents. It’s not necessarily length per se, but rather that the longer the method, the more likely that it mixes multiple actions and responsibilities into one weakly described unit. That will confuse any agent.
However, detecting and recognizing a problem is only the start. The harder part is to act on it, and reshape the design in an agent-friendly way. As is often the case with software design, there’s an infinite number of potential paths. The proper choice depends on the problem at hand.
So far, we have looked at simplifying selection logic and transforming long IF-chains into rule pipelines. Now we’re going to expand our refactoring arsenal by taking on the problem of code that squeezes multiple side effects and business rules into the same long function.
Here’s our starting point: (Take a deep breath — it’s a long one)
public void handleCase(
BackofficeCase backofficeCase,
SideEffectPort sideEffectPort) {
String normalizedTaskType = backofficeCase.caseType().trim().toLowerCase();
if (normalizedTaskType.equals("refund")) {
sideEffectPort.appendAudit("case:refund:" + backofficeCase.accountId());
if (backofficeCase.amountCents() <= 0) {
sideEffectPort.appendAudit("refund:ignored_non_positive_amount");
return;
}
if (backofficeCase.vip() && backofficeCase.amountCents() <= 20_000) {
sideEffectPort.issueRefund(
backofficeCase.accountId(),
backofficeCase.amountCents());
sideEffectPort.sendEmail(
backofficeCase.email(),
"refund-approved-fast-track");
sideEffectPort.appendAudit("refund:vip_fast_track");
} else if (backofficeCase.hasOpenDispute()) {
sideEffectPort.sendEmail(
backofficeCase.email(),
"refund-needs-manual-review");
sideEffectPort.appendAudit(
"refund:manual_review_dispute");
} else {
sideEffectPort.issueRefund(
backofficeCase.accountId(),
backofficeCase.amountCents());
sideEffectPort.sendEmail(
backofficeCase.email(),
"refund-approved-standard");
sideEffectPort.appendAudit("refund:standard");
}
return;
}
if (normalizedTaskType.equals("welcome")) {
// ...lots of code for the welcome flow...
return;
}
if (normalizedTaskType.equals("ban")) {
// ...lots of code for the ban flow...
return;
}
if (normalizedTaskType.equals("export")) {
// ...lots of code for the export flow...
return;
}
sideEffectPort.appendAudit(
"case:unknown:" + backofficeCase.caseType());
}
That’s a lot. The preceding code seems to handle different kinds of backoffice work. We see that a refund case issues money back and sends an approval email, whereas the welcome case would send onboarding material, and so on. The method is non-trivial.
Part of the challenge is that the outcomes of the distinct steps aren’t uniform values. Rather, they represent workflows and tasks that need to be performed.
This is where the design pattern Command becomes useful. Instead of letting the method contain every possible choice, we encapsulate each business rule as a distinct executable unit.
The first step is to move the logic for each task into domain-named command objects:
private static final BackOfficeTask PROCESS_REFUND = new ProcessRefund();
private static final BackOfficeTask SEND_WELCOME_PACKAGE = new SendWelcomePackage();
private static final BackOfficeTask EVALUATE_ACCOUNT_BAN = new EvaluateAccountBan();
private static final BackOfficeTask EXPORT_ACCOUNT_DATA = new ExportAccountData();
private static final BackOfficeTask HANDLE_UNKNOWN = new HandleUnknown();
private static final List<BackOfficeTask> DOMAIN_COMMANDS = List.of(
PROCESS_REFUND,
SEND_WELCOME_PACKAGE,
EVALUATE_ACCOUNT_BAN,
EXPORT_ACCOUNT_DATA
);
public void handleCase(BackofficeCase backofficeCase,
SideEffectPort sideEffectPort) {
String normalizedTaskType = backofficeCase.caseType().trim().toLowerCase();
commandFor(normalizedTaskType).execute(backofficeCase, sideEffectPort);
}
We’ll go over the details and mechanism soon, but note how the offending handleCase method went from potentially hundreds of lines to just two lines of code. What happened?
Well, we introduced a small command model around the existing logic:
BackOfficeTaskis a shared abstraction for executable pieces of backoffice work. A pure interface.ProcessRefund,SendWelcomePackage,EvaluateAccountBan, andExportAccountDataare concrete tasks, each owning one business rule family.
Their implementation is straightforward:
private interface BackOfficeTask {
// purpose: selection -- is this the task to execute for the given input?
boolean supports(String normalizedTaskType);
// purpose: behavior -- encapsulates the logic and actions for a specific task.
void execute(BackofficeCase backofficeCase, SideEffectPort sideEffectPort);
}
private static final class ProcessRefund implements BackOfficeTask {
@Override
public boolean supports(String normalizedTaskType) {
return normalizedTaskType.equals("refund");
}
@Override
public void execute(BackofficeCase backofficeCase,
SideEffectPort sideEffectPort) {
sideEffectPort.appendAudit(
"case:refund:" + backofficeCase.accountId());
// existing refund logic preserved here
}
}
Like any design pattern, there aren’t any fixed rules or structure for what the implementation shall look like. Rather, we need to adapt the pattern to our context.
In this case, we do a simple linear search of the supporting command to match a given input task:
private static final List<BackOfficeTask> DOMAIN_COMMANDS = List.of(
PROCESS_REFUND,
SEND_WELCOME_PACKAGE,
EVALUATE_ACCOUNT_BAN,
EXPORT_ACCOUNT_DATA
);
private static BackOfficeTask commandFor(String normalizedTaskType) {
for (BackOfficeTask command : DOMAIN_COMMANDS) {
if (command.supports(normalizedTaskType)) {
return command;
}
}
return HANDLE_UNKNOWN;
}
DOMAIN_COMMANDS is a list of known tasks, and commandFor(...) is the selector that finds the matching task for the current case type. This structure is a form of responsibility chain where each command decides if it applies. (It’s also an example on combining multiple patterns in one solution).
The HANDLE_UNKNOWN command acts as a safe default. It represents a variation of the Null Object pattern, ensuring that the system always has a valid command to execute, even when no specific case matches.
he original handleCase(...) method is now an orchestrator, delegating the actual work to the selected task instead of containing every branch itself:
public void handleCase(BackofficeCase backofficeCase,
SideEffectPort sideEffectPort) {
String normalizedTaskType = backofficeCase.caseType()
.trim()
.toLowerCase();
commandFor(normalizedTaskType).execute(
backofficeCase, sideEffectPort);
}
That is the first benefit. Whereas the original method was organized around branching, our refactored version is organized around domain tasks.
Note A natural next refactoring would be to remove the string-based selection entirely. We’ll do just that at the end of the article. For now, let’s bear this pain together.
Why this helps human review
The long-method version forces the reader to keep several different concerns active at once.
While reading handleCase, you are not just tracking which branch applies. You are also tracking what kind of business action each branch performs and which side effects belong together. That is a bad fit for human working memory. Further, code that lacks cohesion also increases the risk for unexpected feature interactions: one branch changes a shared state, triggering downstream failures.
Distinct commands reduce that cognitive load by turning the large procedural mess into named chunks. The resulting command objects become cognitive units for reasoning. The reviewer can understand the dispatcher as one concern and each business rule as another. Future extensions are now likely to be additions rather than complex edits of a large block of code.
Why this matters in AI-first development
In the original method, the model has to infer that a cluster of statements represents a refund decision, a welcome flow, etc. The refactored code stops making the model reverse-engineer intent from branch shape by giving the code an explicit semantic structure. Those building blocks are now explicit domain actions.
The structure reduces ambiguity and guides both planning and modification. If an agent needs to change export behavior, ExportAccountData is the obvious unit to inspect. If it needs to reason about refund policy, ProcessRefund is the unit. The edit surface becomes narrower, and the risk of collateral changes drops.
Bringing the solution structure closer to the problem domain serves the translation from prompt to desired outcome.
This is the core idea behind refactoring towards AI-friendly code: make meaning explicit before asking the model to work with it.
Design guardrails
Use this refactoring pattern when a method coordinates several distinct actions with different side effects and/or workflows:
Preserve business logic while moving code into commands.
Keep the public API unchanged during the initial transformation.
Name concrete commands by domain purpose, not architectural suffixes.
Let each task encapsulate its side effects.
From domain actions to stronger API: evolving the design
Often, introducing explicit commands reveals further possibilities to simplify. As an example, take another look at our refactored code:
public void handleCase(BackofficeCase backofficeCase,
SideEffectPort sideEffectPort) {
String normalizedTaskType = backofficeCase.caseType().trim().toLowerCase();
commandFor(normalizedTaskType).execute(backofficeCase, sideEffectPort);
}
Right now, the commands are an implementation detail inside that class. That’s usually a good start, allowing us to optimize for local reasoning.
However, I often find the commands themselves might be part of a stronger API. So what if we start to expose these objects directly to the calling client?
// refactoring note: we now accept a Task rather than a stringly typed 'case'
public void handleCase(BackOfficeTask taskToPerform,
SideEffectPort sideEffectPort) {
taskToPerform.execute(sideEffectPort);
}
In the preceding code we did just that: we shifted the API to accept a generic BackOfficeTask rather than having to create it ourselves. A nice side effect is that we get rid of the nasty task normalization with its complex and accidental string manipulations. (That is, getting rid of backofficeCase.caseType().trim().toLowerCase() — What’s not to like about that?)
The reason this usually works well as the next refactoring step, is because at the call site, context tends to be obvious; we know if we want a refund, send a welcome package, or export data. So why not take advantage of that contextual knowledge in the API responsbile for the corresponding actions?
As an added benefit, that improved API would also align with the Open-Closed Principle, meaning new clients can extend the program with new types of tasks without modifying the code processing them. Coding agents generally perform well in code with clear intent and a consistent structure that naturally communicates its extension points. The more explicit the structure, the less reconstruction work to perform.


