1. The role of commands in ReadLaterApplication
As long as the application does not know how to start properly “from the outside” through args, it remains something like a lab exercise: the code exists, but it is hard to reproduce, hard to show to someone else, and hard to test. The catalog search and catalog details commands are our minimal “control panel” that turns the project into a real artifact rather than a set of classes “just in case”.
Let us agree that today ReadLaterApplication understands exactly two startup forms:
| Command | Launch example | Meaning |
|---|---|---|
| catalog search <query...> | |
Search for books in the external catalog by query |
| catalog details <externalId> | |
Get details by external identifier |
Pay attention to an important detail: for search, the query can consist of several words, so it is a “tail” of arguments — everything after search. For details, there is exactly one parameter — externalId. This greatly simplifies parsing and makes the behavior predictable.
To keep this CLI from turning into a network Swiss army knife, the entry point must rely on the already existing CatalogClient. HTTP, provider DTO, and mapping stay inside the client layer, while ReadLaterApplication deals only with commands and user output.
And one more principle that we keep like a mantra today: the entry point must not build URIs, hit the network, or read JSON. Its job is to recognize the command and delegate execution to the “normal code” — our internal client layer, which already knows how to work with DTOs.
2. String[] args and tokens
When you launch a Java application, main(String[] args) receives not “one command string,” but already split tokens. This is convenient, but sometimes surprising: it looks like you passed clean code, but Java sees two arguments: "clean" and "code". And that is normal — that is how command launches work in most systems.
To avoid guessing, it is useful to print args at least once and see them with your own eyes:
import java.util.Arrays;
public class ArgsDemo {
public static void main(String[] args) {
// It is useful to print the arguments exactly as the JVM received them.
// This helps quickly understand where the "command" ends and the "parameters" begin.
System.out.println(Arrays.toString(args)); // [catalog, search, clean, code]
}
}
If you launch it through Gradle like this:
./gradlew run --args="catalog search clean code"
then, in a typical environment, Java will most likely see exactly this array:
[catalog, search, clean, code]
And that leads to a simple engineering decision: for catalog search, we do not try to guess where the query starts and ends; we simply take all elements of the array starting from index 2 and join them back with a space.
And here is the great news: we do not need to build a complex command-line parser, we do not need to invent quoting, escaping, and the other joys of life. Our requirements are very simple, so the code will be simple too. And simple code is a rarity that should be treasured like the last slice of office pizza.
3. Command grammar
The most typical beginner mistake is to put command parsing directly into main() and then add everything else “along the way.” First one if, then another, then “I will also print something here,” then “I will also try to build a URI here,” and suddenly our main contains a router, a transport layer, a JSON mapper, and the god of local output all at once. That is roughly how the legend that Java is “too verbose” is born.
Let us do it cleanly: we will create a small command model and a separate parser method. The model will be so simple that it can even live as a nested type inside ReadLaterApplication.
public class ReadLaterApplication {
// Two supported catalog commands: search and details.
private enum CatalogCommandType { SEARCH, DETAILS }
// Unified parsing result model:
// - type: which command
// - value: either query (for SEARCH) or externalId (for DETAILS)
private record CatalogCommand(CatalogCommandType type, String value) {}
}
And now the method that tries to parse args. It either returns a command or null (or Optional, but let us not make life harder at the start).
import java.util.Arrays;
private CatalogCommand parseCatalogCommand(String[] args) {
// First check the minimum length so we do not get an ArrayIndexOutOfBoundsException.
if (args.length < 2) return null;
// The first "part" of the command must be exactly catalog.
if (!args[0].equals("catalog")) return null;
// catalog search <query...>
if (args[1].equals("search") && args.length >= 3) {
// IMPORTANT: query can consist of several words, so we join the "tail" of arguments.
String query = String.join(" ", Arrays.copyOfRange(args, 2, args.length));
return new CatalogCommand(CatalogCommandType.SEARCH, query);
}
// catalog details <externalId>
if (args[1].equals("details") && args.length == 3) {
// For details, there is one parameter, so we can be strict about the length.
return new CatalogCommand(CatalogCommandType.DETAILS, args[2]);
}
// If the form does not match any supported command, return null.
return null;
}
What matters here is that we check the array length before we access args[1] or args[2]. It is trivial, but it saves us from the classic ArrayIndexOutOfBoundsException, which usually appears at the most “showcase” moment — when you are demonstrating the code to someone else.
4. Routing in ReadLaterApplication
At this point we get a pleasant structure: run(args) does the parsing, checks the result, and launches the correct scenario. In other words, it does exactly what a “grown-up entry point” does in any application: it manages execution flow instead of doing all the work by hand. Even if it is only two commands for now.
An example “core” of run() may look like this:
public void run(String[] args) {
// 1) Parse args into a clear command model.
CatalogCommand command = parseCatalogCommand(args);
// 2) If the command is not recognized, print an error and a launch hint.
if (command == null) {
System.err.println("Unknown command or invalid arguments.");
printUsage();
return;
}
try {
// 3) Route execution by command type.
switch (command.type()) {
case SEARCH -> runCatalogSearch(command.value()); // value = query
case DETAILS -> runCatalogDetails(command.value()); // value = externalId
}
} catch (RuntimeException e) {
// The upper CLI layer ends the scenario with a short message instead of a stacktrace on screen.
System.err.println("The command was not executed.");
}
}
Here it is important to separate two different kinds of failure. An invalid command shape is a CLI problem, so printUsage() lives right here. A failure during command execution is already the client layer’s story; the upper entry point ends the scenario with a short message and does not dump a stacktrace to the user. Technical details should live next to the HTTP call itself, not in every command separately.
To make the picture completely transparent, we can think of it as a small pipeline:
flowchart TD
A["String[] args"] --> B["parseCatalogCommand(args)"]
B -->|null| C["printUsage()"]
B -->|CatalogCommand| D["switch(type)"]
D --> E["runCatalogSearch(query)"]
D --> F["runCatalogDetails(externalId)"]
E --> G["CatalogClient.search(...)"]
F --> H["CatalogClient.details(...)"]
Now ReadLaterApplication becomes understandable even to someone who does not know our project: they can see which commands exist, where they are parsed, and where execution is delegated.
5. catalog search: query
The search command is the first scenario people usually run, because it is the most “alive”: we enter a phrase and get back a list of books. That is why it is especially important that a multiword query does not break the whole application. Our policy is simple: everything after catalog search is joined back into a query string.
Let us assume we already have an internal client API CatalogClient that returns normalized DTOs:
import java.util.List;
public interface CatalogClient {
// Performs a book search by string query and returns normalized result items.
List<CatalogBookSearchItem> search(String query);
}
Then runCatalogSearch(query) looks almost like pseudocode, and that is great:
import java.util.List;
private void runCatalogSearch(String query) {
// Perform the search through the client layer (not manually through HTTP/JSON in the entry point).
List<CatalogBookSearchItem> items = catalogClient.search(query);
// Friendly output: the user immediately sees whether there are any results.
System.out.println("Found: " + items.size()); // For example: Found: 2
// It is better to keep list printing separate so runCatalogSearch does not grow too large.
printSearchItems(items);
}
It is better to keep the print method separate so that runCatalogSearch() does not turn into a giant wall of code:
import java.util.List;
private void printSearchItems(List<CatalogBookSearchItem> items) {
// Print normalized application fields, not provider-specific DTOs.
for (CatalogBookSearchItem item : items) {
System.out.println(item.externalId() + " | " + item.title() + " | " + item.author());
// Example line:
// OL12345M | Clean Code | Robert C. Martin
}
}
This piece works only if the client layer returned a ready-made list. If an error happened during the HTTP call or mapping, normal output does not continue: the command ends with a top-level error message instead of printing half-baked data.
Notice that we print normalized fields externalId, title, and author, not provider-specific things like key or the authors array. This is an important consequence of our architecture: the entry point and output work in the application’s language, not in the external service’s language.
If the search found nothing, items.size() will be 0 — and that is also a normal scenario. For the user, it is useful to see Found: 0 and maybe a separate phrase like Nothing found, but the zero itself is already predictable behavior.
6. catalog details: card
If search is a “list,” then details is a “card.” The catalog details command should have exactly one parameter. And this is a very convenient case, because we can be strict: if the argument count is not 3, then the command was entered incorrectly, and it is better to show usage right away than to try to “guess for the user.”
Again, we rely on the internal client API:
public interface CatalogClient {
// Returns a detailed book card by external identifier.
CatalogBookDetails details(String externalId);
}
Then executing the command is as direct as it gets:
private void runCatalogDetails(String externalId) {
// Get details through the client layer.
CatalogBookDetails details = catalogClient.details(externalId);
// Print the main "card" fields.
System.out.println("externalId: " + details.externalId());
System.out.println("title: " + details.title());
System.out.println("author: " + details.author());
}
Yes, for now this is just “simple console output.” And that is fine for our current stage: we are not building a pretty UI right now, we are building a reproducible backend-like scenario. Our goal is for the command to work the same in real and mock modes, not for the card to look prettier than a LinkedIn post.
One more important thing: the details command is very sensitive to what you pass in externalId. If you accidentally pass an empty string or make a typo, the external service may return 404, or 200 with empty data, or a strange error. At the CLI level we at least guarantee that the parameter exists at all, and then the client layer can decide what to do if the book is not found. The same rule applies to details: either we got a full card and print it, or the command ends with a short error message, without half-parsed output.
7. Usage and errors
A bad CLI differs from a good one in one simple way: when the user makes a mistake, a bad CLI punishes, while a good one helps. Frankly, the second option is more useful to us, because the user is us again in 15 minutes, when we forget exactly how to launch the project. The brain likes to erase commands from memory to free up space for important things, like programmer memes.
A minimal printUsage() can look like this:
private void printUsage() {
// A text block is easier to read here than many println calls in a row.
System.out.print("""
Usage:
./gradlew run --args="catalog search <query>"
./gradlew run --args="catalog details <externalId>"
""");
}
And in run(args) we print this hint for any command-shape error. It is also useful to distinguish between “normal output” and “error.” In Java, this is what System.err is for, and it does not exist just for decoration.
if (command == null) {
// Send the error message to stderr so it can be separated from "normal" output.
System.err.println("Error: invalid command. Expected: catalog search|details ...");
// Print usage to stdout as a hint for the user.
printUsage();
return;
}
Notice that we do not throw an exception upward and do not show a stacktrace to the user. The stacktrace is useful to us as developers, but as a help message it works about as well as a microwave manual written in ancient Elvish.
If you want to be even more human-friendly, you can add a couple of examples:
System.out.println("Examples:");
System.out.println(" ./gradlew run --args=\"catalog search clean code\"");
System.out.println(" ./gradlew run --args=\"catalog details OL12345M\"");
But it is important not to turn usage into a wall of text. The shorter and more specific the hint is, the higher the chance that someone will actually use it.
8. Typical mistakes when working with CLI
Mistake #1: accessing args[1] and args[2] without checking the array length.
This is the classic path to ArrayIndexOutOfBoundsException. It appears when you launch the application without arguments or with an incomplete command (catalog but without search/details). The right habit is to check args.length first, and only then index into the array.
Mistake #2: for catalog search, only args[2] is taken, and the rest of the query words are lost.
If you wrote String query = args[2];, then the query clean code becomes clean, and you will spend a long time wondering why the search “seems to work worse.” For search, we must join the tail of arguments using String.join(" ", ...).
Mistake #3: trying to build a “smart” command-line parser too early.
Sometimes you want to support quotes, flags, --limit=10, aliases, and half of Bash. In the end you write half a CLI framework and forget why you even started. Today our grammar is small — and that is a plus. It is better to keep the parser short and predictable.
Mistake #4: building URIs, making HTTP calls, and reading JSON directly in ReadLaterApplication.
That turns ReadLaterApplication into a “soup of everything.” Any change in the external contract breaks the entry point, and testing it becomes inconvenient. It is much calmer when ReadLaterApplication calls catalogClient.search(...), receives normalized DTOs, and simply prints them.
Mistake #5: printing provider DTOs or working with provider fields in command code.
If in runCatalogSearch() you suddenly start printing doc.key() or reaching into authors().get(0), then the boundary is broken: the external contract has leaked into a place where it does not belong. This is not just “cosmetic.” It is a risk that when the external JSON changes, you will have to fix half the project instead of one layer.
GO TO FULL VERSION