1. Args: command on top of an already assembled config
By this point, the effective config is already assembled: application.properties is read from the classpath, env overrides individual values when needed, and in the end we get a typed AppConfig. args solve a different problem — which scenario we are launching right now.
For ReadLater Starter, this distinction is very concrete: server.port, catalog.api.mode, and catalog.api.base-url live in the config. And catalog search clean code or catalog details OL12345M are data for a specific launch. If you mix these two axes, main() will very quickly turn into a CLI beast, where it is unclear what reconfigures the environment and what selects the command itself.
2. Canonical launch modes
When an application supports several modes, the most valuable thing is to agree on a canonical launch form. Not "roughly like this", not "I run it from the IDE with a button", but in a way that lets you write one line in the README — and have it work for everyone. If you have ever launched someone else's project where "how to run it" was described as "well, figure it out yourselves", you understand why this matters.
We have three launch modes fixed, and we are not inventing a fourth one "just in case", because that is a favorite way for beginners to make life harder for themselves.
| Scenario | What it means | Example launch via Gradle |
|---|---|---|
| server | Start the local HTTP server | ./gradlew run --args="server" |
| catalog search <query...> | Search for books by query | ./gradlew run --args="catalog search clean code" |
| catalog details <externalId> | Get details by external ID | ./gradlew run --args="catalog details OL12345M" |
Note a subtle point: a search query can logically consist of several words. And in the example catalog search clean code, Gradle will pass four elements to main(String[] args): ["catalog", "search", "clean", "code"]. If you write a parser that is "strictly for 3 arguments", then you will accidentally limit search to one word. And then the student, or you yourselves, will spend 20 minutes wondering why "clean code" is searched as "clean", and why "code" disappeared at all. Spoiler: it did not disappear, it just ended up in args[3].
So we will consider the following rule correct: catalog search accepts at least one piece of the query, and if there are more pieces, we join them back together with spaces. This is not a "complex CLI parser", it is just a sign of respect for reality.
Another important point: args are not the place to choose the server port and real/mock mode in this version of the application. The port and mode are configuration, and today we have already agreed where they live: in application.properties with the ability to override via env vars. If you start specifying the port sometimes through args, sometimes through env, sometimes through a file, you will create three ways to do the same thing, and all three will conflict. It is like having three alarms on your phone and wondering why you still did not sleep well.
3. Args parser: String[] → LaunchCommand
Students often do this: somewhere deep in the code they write if (args[0].equals("server")), then somewhere else — if (args[1].equals("search")), and then they are surprised that the application crashes with ArrayIndexOutOfBoundsException when launched without arguments. That is normal: at some point we all believed in a kind world where the user always enters everything correctly. But backend thinking is precisely about the fact that the world is not kind, just complicated.
Our goal is to turn the raw string array String[] args into a typed command, which is then convenient to process without constant args.length checks and magic indices. For this, we introduce a simple type, LaunchCommand. It makes sense to keep it package-wise in com.example.readlater.app, because this is about application startup, not about the catalog and not about the reading list.
The minimal variant — plain Java, no extra complexity — can look like this:
// Single type for all application launch variants.
// This allows us to work with commands as objects later, not as "magic" strings.
public sealed interface LaunchCommand
permits ServerCommand, CatalogSearchCommand, CatalogDetailsCommand {}
// Command: start the local server.
public record ServerCommand() implements LaunchCommand {}
// Command: search in the catalog (the query is already joined into one string).
public record CatalogSearchCommand(String query) implements LaunchCommand {}
// Command: book details by external identifier.
public record CatalogDetailsCommand(String externalId) implements LaunchCommand {}
The point is not "wow, how trendy, a sealed interface". The point is different: later in the code we will work not with server/catalog strings, but with understandable objects. And that dramatically reduces the number of errors.
Now we need a parser. Not a separate framework, not a library, but an honest function that checks the command shape and either returns LaunchCommand or fails with a clear "how to use it" message.
import java.util.Arrays;
public final class LaunchCommandParser {
public static LaunchCommand parse(String[] args) {
// 1) server
if (args.length == 1 && "server".equals(args[0])) {
return new ServerCommand();
}
// 2) catalog search <query...>
// Important: the query may contain several words, so we take the "tail" of the array and join it with spaces.
if (args.length >= 3 && "catalog".equals(args[0]) && "search".equals(args[1])) {
String query = String.join(" ", Arrays.copyOfRange(args, 2, args.length));
return new CatalogSearchCommand(query);
}
// 3) catalog details <externalId>
if (args.length == 3 && "catalog".equals(args[0]) && "details".equals(args[1])) {
return new CatalogDetailsCommand(args[2]);
}
// If the command is not recognized, stop the launch and show the person how to do it correctly.
throw new IllegalArgumentException("""
Usage:
server
catalog search <query...>
catalog details <externalId>
""".trim());
}
}
Pay attention to args.length >= 3 in search. That is our support for multi-word queries. Yes, we just take the rest of the array and join it with spaces. No magic. Just convenience for the user.
Now the question is: what should we do with the exception? In a learning project, it is fine if the application fails at startup with a clear message, because launching without a command is not a recoverable situation, it is invalid input. But it is important that the message is human-readable, not "Index 0 out of bounds".
Here is an example of how to show usage neatly. Yes, via System.err.println — today we are not doing logging:
try {
// At the application boundary, we turn "raw" String[] into a typed command.
LaunchCommand cmd = LaunchCommandParser.parse(args);
} catch (IllegalArgumentException e) {
// Print a clear hint and stop the launch.
System.err.println(e.getMessage());
return;
}
And here the backend habit is already visible: input data is validated at the boundary, and a normal structure goes into the application itself.
4. main() as the assembly point
At some point, a developer learns the word "composition" and starts trying to write a "universal application launch framework". That is fun, but we keep ourselves in check. Our task is more modest and more useful: to make sure main() does not turn into a dump of business logic, while still remaining an honest starting point (composition root) where everything is assembled together.
main() now assembles two independent things: the ready AppConfig and the LaunchCommand. And only then does it hand them over to the runner.
If you built the project following the previous lectures, your main() created ApplicationBootstrap, which in turn created CommandRouter, and that one parsed args by itself. Now the responsibilities are split differently: parsing args has moved to LaunchCommandParser, while assembling dependencies and choosing the scenario are combined in a single class — ReadLaterRunner. It receives a ready AppConfig, assembles the services (as ApplicationBootstrap used to) and decides what to run based on the command type (as CommandRouter used to). A skeleton might look like this:
package com.example.readlater.app;
import com.example.readlater.config.AppConfig;
public class ReadLaterRunner {
private final AppConfig config;
public ReadLaterRunner(AppConfig config) {
this.config = config;
// here we assemble services and controllers — what ApplicationBootstrap used to do
}
public void run(LaunchCommand cmd) {
// scenario selection — what CommandRouter used to do
switch (cmd) {
case ServerCommand s -> startServer();
case CatalogSearchCommand c -> searchCatalog(c.query());
case CatalogDetailsCommand c -> showDetails(c.externalId());
}
}
private void startServer() { /* … */ }
private void searchCatalog(String query) { /* … */ }
private void showDetails(String externalId) { /* … */ }
}
In the next lectures, we will show main() in a shortened form and focus on other things (logging, errors), so the runner won't be mentioned separately there — its role doesn't change because of that.
An example of "gluing" may look like this:
package com.example.readlater.app;
import com.example.readlater.config.AppConfig;
import com.example.readlater.config.AppConfigFactory;
import com.example.readlater.config.PropertiesLoader;
import java.util.Properties;
public class ReadLaterApplication {
public static void main(String[] args) {
Properties props = new PropertiesLoader().load();
AppConfig config = AppConfigFactory.from(props, System.getenv());
LaunchCommand cmd;
try {
cmd = LaunchCommandParser.parse(args);
} catch (IllegalArgumentException e) {
System.err.println(e.getMessage());
return;
}
ReadLaterRunner runner = new ReadLaterRunner(config);
runner.run(cmd);
}
}
Notice that there is not a single line here like "pick server.port from env" or "parse catalog.api.mode". The config layer already did all of that. main() only connects the ready AppConfig with the ready command.
To let the brain fully accept the picture, it is useful to draw a launch diagram. I like mermaid because it is cheaper than "drawing it in your head and suffering".
flowchart TD
A["Application startup (main)"] --> B["PropertiesLoader"]
B --> C["Properties"]
C --> D["AppConfigFactory.from(props, System.getenv())"]
D --> E["AppConfig"]
A --> F["LaunchCommandParser.parse(args)"]
F --> G["LaunchCommand: server / catalog search / catalog details"]
E --> H["ReadLaterRunner"]
G --> H
H --> I{"Scenario selection"}
I -->|"server"| J["Server mode"]
I -->|"catalog search"| K["Catalog search"]
I -->|"catalog details"| L["Catalog details"]
That way main() remains the application's front door, not the place where all the logic in the world suddenly lives.
5. Order matters: first AppConfig, then the command
The priority rule for values is already fixed in the config layer: env → application.properties → default. Here only one thing matters: LaunchCommandParser.parse(args) is not part of this chain. By the time args are parsed, server.port, catalog.api.mode, and other settings have already turned into the effective AppConfig.
So in main(), it is convenient to keep this order: first PropertiesLoader, then AppConfigFactory.from(props, System.getenv()), and only after that LaunchCommandParser.parse(args). That way the code immediately shows two separate axes: the environment was assembled once, then the command was chosen.
6. Typical mistakes with args and config
At this point, you usually want to make a list of 12 items, but we agreed to keep the narrative smooth, like a well-polished build.gradle.kts — well, almost. The mistakes below are not "you are bad", they are "almost everyone does this before they learn the hard way". Our job is to learn them virtually and in advance.
Mistake #1: mixing launch mode and configuration, turning args into a dumping ground.
When you start passing both server and port, baseUrl, and timeouts through args, the application becomes unpredictable: some settings change via env, some via a file, some via args, and nowhere is there a single "source of truth". In the course, it is better to keep a hard rule: args describe the command and launch data, configuration lives in properties/env.
Mistake #2: parsing args "where they are used" and hitting ArrayIndexOutOfBoundsException.
If args[1] is read in one method, args[2] in another, and the args.length check is forgotten in a third, then you are guaranteed to get a crash on some non-standard launch. The fix is simple: one parser at the entry point, which either returns LaunchCommand or stops the launch with a clear usage message.
Mistake #3: making catalog search strictly three arguments and accidentally breaking multi-word queries.
This is a subtle bug, because it does not look like an error: the application "works", but it searches for the wrong thing. If you have catalog search clean code, and the parser only takes args[2], then you are effectively searching for "clean". The solution is to accept args.length >= 3 and join the rest of the query back together with spaces.
Mistake #4: choosing the priority of sources differently in different places.
Today you wrote "env is more important than file", tomorrow in another class you did the opposite, and the day after you added a default "just in case". The result: you set an environment variable, but it does not work, because this particular key is read differently. The fix is one rule in ConfigReader or AppConfigFactory, not local System.getenv() and props.getProperty(...) all over the code.
Mistake #5: replacing an unknown command with a default value.
Sometimes people do this: "if the command is unclear, launch server." That seems convenient until one day you run ./gradlew run --args="sevrer" and spend 10 minutes staring at an empty terminal, wondering why "nothing is happening". An unknown launch mode is an error, and it should be explicit.
GO TO FULL VERSION