Day 002: Control Flow and Functions — Teaching a Program to Make Decisions
Computational Foundations ·Engineering Notes· Building a CLI Utilities Pack
Imagine a program that knows a user’s account balance is ₹100. A withdrawal request arrives for ₹40.
The program can represent both numbers. It can subtract one from the other. But before it changes the balance, it must answer a more important question: should the withdrawal be allowed?
The answer depends on a condition. If the balance is sufficient, the program should perform the withdrawal. Otherwise, it should reject the request.
Now imagine adding ten more operations. Each operation has different inputs, different rules, and different failure cases. Some must be repeated. Some must be rejected under particular conditions. Others share the same validation logic.
The challenge is no longer simply to store information or calculate a result. It is to coordinate behavior.
This is where two fundamental programming mechanisms become essential: control flow determines which operations execute, while functions organize those operations into named, reusable units.
I want to understand why these mechanisms exist, how Java implements them, and what their design reveals when a program becomes more complicated.
1. The first problem: a program needs to respond to conditions
In my previous investigation, I studied variables. A variable gives a name to a value that a program can use.
For example:
int balance = 100;
int amount = 40;These declarations establish two pieces of information. They do not, by themselves, define what the program should do with them.
To make a decision, I need a condition:
if (balance >= amount) {
balance = balance - amount;
}The expression balance >= amount evaluates to a boolean value: true or false.
If it is true, the assignment executes. If it is false, the assignment is skipped.
The important change is that the program’s behavior now depends on its data.
Without branching, I would have difficulty expressing the rule that one operation should occur under one condition and a different operation under another. The program would have no straightforward way to select between those paths.
This mechanism is called control flow: the rules governing the order in which instructions execute.
The idea extends beyond a withdrawal example. A command-line program must decide which command to execute. A web server must determine how to handle a request. A database must evaluate conditions that affect query execution. An operating system must coordinate which work receives processor time.
These systems are not equivalent, and they use different mechanisms internally. But each must determine what happens next.
Before investigating larger systems, I want to understand the basic mechanisms precisely.
2. Three ways to control execution
Control flow begins with three fundamental patterns: sequence, selection, and iteration.
Sequence: doing things in order
Consider a small calculation:
int price = 50;
int quantity = 3;
int total = price * quantity;System.out.println(total);The program assigns values, calculates the total, and prints the result.
The order matters. The calculation uses the values established by the earlier instructions.
Sequence is the simplest execution pattern, but a sequence alone cannot express every behavior a program needs.
Selection: choosing between alternatives
Suppose a command-line utility must distinguish between a valid and invalid repetition count.
if (count >= 1 && count <= 10) {
repeatText(count);
} else {
System.out.println("Count must be between 1 and 10.");
}The condition divides the possible inputs into two categories. One branch performs the operation; the other reports why it cannot proceed.
The programmer is responsible for defining the boundary correctly. Should zero be accepted? What about ten? What happens with a negative number?
A condition that looks simple can encode a meaningful requirement. Getting that condition wrong changes the program’s behavior.
This is why I want to treat branches as decisions that need testing, not merely syntax that needs to compile.
Iteration: repeating an operation
Now suppose the program must print a message three times.
One approach is to write three print statements. That works, but the number of repetitions is embedded in the structure of the code.
A loop expresses the repetition as a rule:
for (int i = 0; i < 3; i++) {
System.out.println("Hello");
}The loop initializes a counter, evaluates a condition, executes its body, updates the counter, and checks the condition again.
When i reaches three, the condition i < 3 becomes false, and the loop terminates.
The advantage is not just fewer lines. The number of repetitions is now represented as a value that can change independently of the operation.
If the repetition count comes from user input, the same loop can accommodate different requests without duplicating the print statement.
But loops introduce their own obligations. Their initialization, termination condition, and update must work together correctly. An incorrect condition can produce missing output, excessive work, or an infinite loop.
Control flow allows a program to choose and repeat behavior. Yet as the number of operations grows, another question emerges: where should each operation live?
3. The problem with putting everything in one method
Imagine that my command-line utility supports addition, text repetition, help messages, and application exit.
The simplest implementation might place all the input handling, validation, calculations, output, and error handling inside main().
At first, this seems reasonable. There are only a few commands, and all the logic is visible in one place.
Then I add more commands.
The method grows. Validation rules begin to appear in multiple places. A change to one operation becomes harder to distinguish from a change to another. Testing an individual behavior requires navigating through code that performs several unrelated responsibilities.
The problem is not the number of lines alone. It is the growing difficulty of understanding which part of the program is responsible for which behavior.
This is the problem functions help address.
In Java, functions are called methods. A method gives an operation a name and defines how it receives inputs, performs work, and optionally returns a result.
Consider addition:
private int add(int first, int second) {
return first + second;
}The method has a clear contract at the level of its signature:
addnames the operation.firstandsecondare its parameters.intspecifies the return type.- The body defines the calculation.
The caller can now use the method:
int result = add(5, 7);
System.out.println(result);The addition logic no longer needs to be rewritten wherever the result is required.
This is reuse, but reuse is only part of the benefit. A method also creates a boundary around a piece of behavior. I can inspect its implementation, test its result, and reason about its inputs without having to examine every instruction in the application.
That boundary is useful only if it makes sense. A method that combines unrelated responsibilities may simply move complexity into another location. A good method should have a coherent purpose and an understandable relationship between its inputs, behavior, and result.
The design question is therefore not, “How many methods should I create?”
It is, “Which operations deserve to be understood and changed independently?”
4. Building a command dispatcher in Java
I will investigate that question by extending my cumulative CLI utilities pack.
The program will support four commands:
add— add two integers.repeat— print text a specified number of times.help— display available commands.exit— terminate the application.
It must also handle unknown commands and invalid numeric input.
The following implementation is a small working model. It gives us something concrete to execute, trace, test, and refactor.
import java.util.Scanner;public class CLIDispatcher { private final Scanner scanner = new Scanner(System.in); public static void main(String[] args) {
new CLIDispatcher().run();
} private void run() {
boolean running = true; while (running) {
System.out.print(
"\nCommands: add, repeat, help, exit\n> "
); String command = scanner.nextLine()
.trim()
.toLowerCase(); switch (command) {
case "add" -> add();
case "repeat" -> repeat();
case "help" -> showHelp();
case "exit" -> running = false;
default -> System.out.println(
"Unknown command: " + command
);
}
} System.out.println("Goodbye.");
} private void add() {
Integer first = readInteger("First number: ");
Integer second = readInteger("Second number: "); if (first == null || second == null) {
System.out.println(
"Addition cancelled: invalid input."
);
return;
} System.out.println("Result: " + (first + second));
} private void repeat() {
System.out.print("Text: ");
String text = scanner.nextLine(); Integer count = readInteger("Repeat count (1-10): "); if (count == null || count < 1 || count > 10) {
System.out.println(
"Count must be between 1 and 10."
);
return;
} for (int i = 0; i < count; i++) {
System.out.println((i + 1) + ": " + text);
}
} private Integer readInteger(String prompt) {
System.out.print(prompt);
String input = scanner.nextLine().trim(); try {
return Integer.valueOf(input);
} catch (NumberFormatException exception) {
return null;
}
} private void showHelp() {
System.out.println("add - Add two integers");
System.out.println("repeat - Print text a chosen number of times");
System.out.println("help - Display available commands");
System.out.println("exit - Stop the program");
}
}
This example uses switch-arrow syntax, available in Java 14 and later.
Get Prac's Journal’s stories in your inbox
Join Medium for free to get updates from this writer.
The structure is more important than the syntax.
main() starts the application. run() manages the command loop. The switch statement chooses the operation. Individual methods implement the commands, while readInteger() centralizes numeric parsing.
The program combines sequence, selection, iteration, and method invocation into one small system.
The next question is whether that structure actually behaves as intended.
5. Follow the execution, one step at a time
Suppose the user enters:
repeat
hello
3I want to trace the execution rather than infer correctness from the appearance of the code.
First, run() reads the command and converts it to lowercase. The switch selects the repeat() method.
The method reads the text hello. It then calls readInteger(), which attempts to convert the repetition count from text into an integer.
The resulting value is 3.
The validation condition checks whether the count is missing, less than one, or greater than ten. Since none of those conditions holds, execution continues.
The loop starts with i = 0.
IterationValue of iCondition i < 3Action10TruePrint hello21TruePrint hello32TruePrint helloEnd3FalseExit the loop
After the loop finishes, repeat() returns to the command loop. The program is ready for another command.
The output is:
1: hello
2: hello
3: helloThis trace exposes the relationship between the source code and its execution.
The switch selects a path. The method call moves execution into a particular operation. The loop repeats its body while its condition holds. When the method completes, control returns to its caller.
A program is not simply a collection of statements. Its behavior emerges from the execution paths those statements permit.
6. Test the boundaries, not just the happy path
A program that works for one input has demonstrated very little.
I want to know what happens when its assumptions are challenged.
For the repeat command, the most important boundary cases are straightforward:
Input countExpected behavior1Print the message once10Print the message ten times0Reject the count11Reject the countInvalid textReport invalid input without crashing
The parser introduces another important boundary. When the user enters abc instead of an integer, Integer.valueOf() throws a NumberFormatException. The catch block handles that exception and returns null.
The calling method must then recognize that no valid integer was produced.
This design keeps the application running, but it is deliberately simple. Returning null communicates that parsing failed without explaining the specific reason. A larger application might use a structured result that distinguishes malformed input, out-of-range values, and other validation failures.
There is also a limitation in the addition command. Java’s int type has a finite range, so sufficiently large sums can overflow. The current implementation does not explicitly detect that condition.
These are not reasons to discard the example. They are reasons to investigate it honestly.
The implementation demonstrates a mechanism. Testing establishes which behaviors have been verified. Remaining limitations identify the next engineering questions.
Correctness is not merely producing the expected output for valid input. It also means defining what happens when the input violates the program’s assumptions.
7. Change the requirement and observe what breaks
The original requirement allows repetition counts between one and ten.
Now suppose the maximum changes from ten to one hundred.
The obvious edit is to change the validation condition. But the upper bound also appears in the error message, and the help text or documentation may need updating.
A requirement that sounds local can affect several parts of a program.
I can make the constraint explicit:
private static final int MAX_REPEAT_COUNT = 100;Then use it in validation:
if (count == null || count < 1
|| count > MAX_REPEAT_COUNT) {
System.out.println(
"Count must be between 1 and "
+ MAX_REPEAT_COUNT + "."
);
return;
}The constant gives the rule a name and makes the upper bound easier to maintain consistently.
But I do not want to assume that introducing a constant automatically makes the design better. The benefit depends on whether it clarifies a meaningful rule and whether the value needs to be shared.
A stronger experiment would introduce another command that uses the same constraint. I could then investigate whether a shared validation method makes the code easier to maintain or whether the two commands have different enough requirements to justify separate rules.
I would compare the implementations using evidence:
- How many locations must change when the requirement changes?
- Is validation duplicated?
- Do the tests catch an inconsistent limit?
- Is the resulting code easier to understand?
- Does the new abstraction simplify the design or merely add indirection?
I should not claim that the refactored version is faster unless I perform an experiment that measures performance. This investigation is primarily about correctness and maintainability.
The larger lesson is that requirements change, and a useful design makes the consequences of those changes easier to identify and control.
8. What survives when the system grows?
A small command-line utility is not a distributed service. It has no need for the concurrency controls, network protocols, or operational infrastructure found in a large production system.
Still, the basic engineering questions do not disappear as the environment becomes more demanding.
A service must choose how to handle each request, validate inputs, invoke the correct operation, and respond when a dependency fails.
New constraints emerge as well.
Concurrency: Multiple requests may execute simultaneously. Shared mutable state can introduce race conditions.
Latency: Slow operations may delay responses or consume resources needed by other requests.
Failure handling: A database, network connection, or downstream service may become unavailable.
Observability: Logs, metrics, and traces help engineers understand what happened, where a failure occurred, and how the system behaved under load.
Architectural complexity: A central dispatcher that works well for four commands may become difficult to maintain when responsibilities multiply. Separate handlers or a different dispatch mechanism may eventually be justified.
These are general engineering considerations, not claims about Google’s private implementation.
To investigate how such concerns are addressed in real systems, I can consult public material such as Google’s Site Reliability Engineering book and its published engineering practices.
The distinction between evidence and inference matters here. Public documentation can establish what a source describes. My small Java program can demonstrate a local mechanism. Neither, by itself, establishes how an undocumented internal component at Google works.
I want that discipline to remain part of my engineering practice: separate documented facts, direct observations, reasonable inferences, hypotheses, and unknowns.
9. What would count as evidence of understanding?
It would be easy to finish this exercise after writing the class and watching it run.
But a successful execution demonstrates only that one path worked under one set of conditions.
A more convincing investigation would include tests for every command, every dispatch branch, malformed numeric input, and the repetition boundaries. It would record the observed behavior before and after the requirement change.
It would also examine whether the design makes the next change easier or harder.
For this small CLI, branch coverage, test outcomes, validation consistency, and the size and clarity of the refactor are more relevant than a benchmark of raw execution speed.
The measurement should fit the question being investigated.
I would consider the laboratory complete when I can explain the mechanism without notes, trace an execution from input to output, identify meaningful failure cases, and defend a design decision using evidence rather than preference alone.
10. From instructions to behavior
The most important thing I learned from this investigation is not how to write an if statement, a for loop, or a Java method.
It is why these mechanisms are necessary.
Variables allow a program to represent information. Control flow determines which operations execute and under what conditions. Methods give operations names and boundaries, making them easier to reuse, test, and change.
Together, they let a program respond to different circumstances without requiring a separate, manually written sequence of instructions for every possible input.
But they also create responsibilities. Branches must account for the intended cases. Loops must terminate correctly. Methods must have coherent responsibilities. Invalid inputs must produce controlled outcomes. Changes must preserve behavior that should remain unchanged.
The question I want to carry forward is not simply whether I can make a program work.
It is whether I can explain why it works, identify the assumptions on which it depends, find the conditions under which it fails, and determine how its design should change when those conditions change.
That is where learning programming begins to become engineering.
And it is the foundation for the next investigation: how to organize data and behavior into larger components without losing control of their interactions.










