1. Unified behavior: meaning and benefits
The commands already know how to run catalog search and catalog details, and diagnostics already know how to show what happened in the real call. Only one source of chaos remains: what exactly changes when we switch between real and mock. If the contract, result format, or handling of empty and error cases starts drifting here, all the discipline from before quickly falls apart.
When people say, “We have real and mock modes,” beginners often hear, “We have two different applications, just in one project.” And then the fun begins: in real mode we print nicely, in mock mode we print however it turns out; in real mode we return DTOs, in mock mode we return strings; in real mode we format errors one way, in mock mode another. In the end, any code “above” the client is forced to know about the mode, branch, and become more complicated.
“Unified behavior” is a strict and very practical idea: the mode changes the data source, but not the meaning or shape of the result. If the rest of the application can work with CatalogBookSearchItem, then it must be able to work with it in both real mode and mock mode. If catalog search in real mode can return an empty list, mock mode must also be able to return an empty list, not fail or print “no file, goodbye.”
You can think of it as a rule: the application has one “internal contract,” not two. And the mode is just “which pipe we connected to that contract.”
2. The CatalogClient contract: interface and DTOs
To keep real and mock from turning into two different applications, the rest of the code must see the same internal contract. CatalogClient still returns normalized CatalogBookSearchItem and CatalogBookDetails, not provider DTOs, not JsonNode, and not raw JSON. The important thing here is not to invent a “simplified client” for mock, but to preserve the language of the application we have already chosen.
The practical sign is very simple:
- search(query) in both modes returns a list of CatalogBookSearchItem;
- details(externalId) in both modes returns CatalogBookDetails;
- provider-specific words such as docs, key, author_name, and numFound stay inside the client layer.
As soon as this agreement holds, the entry point, result printing, and the rest of the code stop branching by mode. They work with one API and do not ask whether the data came over the network or from sample JSON.
3. Mock as a data source
Most often, mock breaks for one reason: people think mock means “plug in a ready-made result.” Then mock returns “already printed strings,” or “already normalized DTOs assembled by hand,” or even just System.out.println("mock!"). Such a mock is, of course, fast... but useless. It does not verify the most important thing: that our JSON reading and mapping logic actually works.
The correct model for mock mode here is very grounded: mock replaces the data source, but not the rest of the path. That means in mock mode we take sample JSON, read it with the same ObjectMapper, get provider DTOs, and then normalize them into the same DTOs we use in real mode.
It is convenient to keep this scheme in mind:
flowchart TD
A["Command: catalog search/details"] --> B["CatalogClient"]
B -->|real| C[HTTP request]
B -->|mock| D[Classpath resource JSON]
C --> E[Provider JSON]
D --> E[Provider JSON]
E --> F[Provider DTO]
F --> G[Mapping]
G --> H[Normalized DTO]
H --> I[Unified output]
The key point here is that the path “after JSON is obtained” is the same.
The same rule also tells us where to choose the mode. One build point is enough: there we create either RealCatalogClient or MockCatalogClient, and then only a reference of type CatalogClient lives throughout the application. If if(useMock) starts appearing in commands, printing, or mapping, the mode has already leaked too far.
It is useful to compare real and mock not as “two worlds,” but as “two implementations of one promise”:
| What we compare | real mode | mock mode | What must match |
|---|---|---|---|
| Data source | HTTP response | JSON from classpath | Normalized result |
| JSON parsing | ObjectMapper.readValue(...) | ObjectMapper.readValue(...) | Yes, by the same method |
| Mapping | provider DTO → normalized DTO | provider DTO → normalized DTO | The same mapping |
| Returned outward | normalized DTO | normalized DTO | Required |
| User output | prints normalized DTOs | prints normalized DTOs | One format |
4. Mock JSON in resources
There is one nasty trap with mock mode that almost everyone falls into once, sometimes twice, just to make sure the lesson sticks. The trap is called: “It works in the IDE, but after building the JAR, it does not.” The usual reason is that the mock file is read like a regular file from the filesystem, but inside a JAR it is no longer “just a file.” That is why we read mock JSON as a classpath resource, that is, through getResourceAsStream.
Suppose we have a file src/main/resources/mock/search-clean-code.json. Then reading it looks like this:
import java.io.IOException;
import java.io.InputStream;
// Read it specifically as a classpath resource (this works both in the IDE and from the JAR)
try (InputStream in = getClass().getResourceAsStream("/mock/search-clean-code.json")) {
if (in == null) {
// Clearly state the reason: the resource was not found (otherwise there will be an unclear NPE somewhere later)
throw new IllegalStateException("Mock resource not found: /mock/search-clean-code.json");
}
// Important: use the same objectMapper as in real mode
ProviderSearchResponse response = objectMapper.readValue(in, ProviderSearchResponse.class);
} catch (IOException e) {
throw new IllegalStateException("Cannot read mock resource", e);
}
Two things are important here. First, the path starts with / — this is an absolute path in the classpath. Second, we absolutely check in == null. Otherwise, you will get a NullPointerException in a place that does not explain at all that the file simply was not found. And IllegalStateException("Mock resource not found: ...") at least tells the truth, even if not very gently.
An important detail: we do not create a new ObjectMapper “on the spot.” If real and mock start reading JSON with different mappers, you will very quickly get two slightly different behaviors and start chasing ghosts.
Next, you do the same thing you would do in real mode: from provider DTOs you build normalized DTOs. For example, if the provider sends docs (the list of books), and a book has key, title, and author_name, the mapping might look like this:
import java.util.List;
CatalogBookSearchItem toSearchItem(ProviderBookDoc doc) {
// In a teaching mapping we often take the "first author" to avoid making the output format more complicated
List<String> authors = doc.authorNames();
String author = (authors == null || authors.isEmpty()) ? "Unknown" : authors.get(0);
// externalId is a stable identifier from the provider contract (key/ID), but in our DTO
return new CatalogBookSearchItem(doc.key(), doc.title(), author);
}
Notice that we are not trying to make the mapping “perfect,” and we are not starting a war over what a “correct author” is. Our goal right now is for the behavior to be identical in both modes and for the result format to be stable.
There is one small but practical question left: “How do we choose which file to read in mock mode?” The simplest way is to make a small map of “query → resource.” Yes, it looks almost like a correspondence table. And that is fine: mock mode is a teaching stabilization, not a full fake-data system.
import java.util.Map;
// Correspondence table: user query -> specific JSON resource in classpath
private static final Map<String, String> SEARCH_MOCKS = Map.of(
"clean code", "/mock/search-clean-code.json",
"unknown-book", "/mock/search-empty.json"
);
Then you take the path from the map and read the resource. If the query is unknown, you can return an empty result so that mock mode remains predictable instead of failing because the user entered “clean coder” instead of “clean code.” In a teaching project, “predictable” is almost always more important than “super realistic.”
5. Unified output: result printer
A very common mistake in client projects is: “Since I already called the external API, I might as well print the result right here.” It looks convenient, but it ends with the client knowing about the output format, the message language, and what is happening in main. And then you want to change the format — and suddenly you are digging into transport code. It is like changing the kitchen design by taking apart the engine of a car. Technically possible, but emotionally not recommended.
The idea is simple: the client returns data, and printing is a separate responsibility. But turning this into a new mandatory layer is also unnecessary. While there are only a few commands, output can safely remain directly in ReadLaterApplication. If formatting starts to grow and gets in the way of reading command routing, it is convenient to move it into a separate CatalogConsolePrinter as a small refactor.
A small example of such extraction:
import java.util.List;
class CatalogConsolePrinter {
void printSearch(List<CatalogBookSearchItem> items) {
// An empty result is a normal search result, not an error
if (items.isEmpty()) {
System.out.println("Nothing found");
return;
}
// Always print normalized DTOs: the format is the same in real/mock
for (CatalogBookSearchItem item : items) {
// The simplest format: id | title | author (can be made more complex later)
System.out.println(item.externalId() + " | " + item.title() + " | " + item.author());
}
}
void printDetails(CatalogBookDetails book) {
// Details are printed from the normalized model, not from provider DTO/JSON
System.out.println(book.externalId()); // OL12345M
System.out.println(book.title()); // Clean Code
System.out.println(book.author()); // Robert C. Martin
}
}
Then both real and mock return the same DTOs, and the printer prints them in the same way. The mode still changes only the data source.
6. Client readiness: criteria
The phrase “the client is ready” sounds nice, but by itself it guarantees nothing. In one project, “ready” means “I have one request working, if the internet is good.” In another, it means “I have two modes, negative scenarios, clear diagnostics, and a unified result format.” We need the second option, because otherwise the next project stage will be built on sand.
Completion criteria are not bureaucracy. They are a way not to fool yourself. If the criteria exist, you understand faster where the gap is and avoid surprises at the first code change. For our client phase, the criteria are quite grounded: commands work, modes are the same, provider details are isolated, and output is predictable.
Below is a table showing “what this looks like in practice.” This is not homework, but a cheat sheet you can use to mentally check the project yourself.
| Criterion | What it looks like in real mode | What it looks like in mock mode | What this means for the project |
|---|---|---|---|
| Command catalog search ... works | A list of results arrives | A list of results arrives | The command does not depend on the data source |
| Command catalog details <id> works | A book card arrives | A book card arrives | Details are separated from the transport layer |
| The result format is the same | Printed through CatalogConsolePrinter | Printed through CatalogConsolePrinter | Output does not branch by mode |
| No provider DTO leakage | No provider types are returned outward | No provider types are returned outward | The external contract is “sealed” inside the client |
| Mock uses JSON + mapping | JSON is read by the mapper | JSON is read by the mapper | Mock checks the real data path |
| Empty result is handled calmly | Print “Nothing found” | Print “Nothing found” | No “crashing because of emptiness” |
| Diagnostics are clear | There is operation/mode/target/result/time | There is operation/mode/source/result/time | When there is a problem, you know where it broke |
When search/details, diagnostics, and modes are held together by one internal contract, the client layer stops being a fragile pile of demo classes. Such a layer can already be safely split into packages, moved out of the entry point, and assembled by dependencies manually — without fear that real and mock will suddenly diverge in behavior.
Notice one subtle point. When we say “identical behavior,” we do not promise that real data and mock data will match “letter for letter.” We promise that the shape and rules of behavior match. And that is much more important.
For example, in real mode, catalog search clean code may return 20 results. In mock mode, it may return 1 or 3. That is fine. But if real returns List<CatalogBookSearchItem>, while mock suddenly returns “a string with one result,” that is no longer fine, because the code above the client stops being unified.
Another point: mock data must reproduce exactly the part of the contract that we actually read. If in mapping we use doc.key() and doc.title(), but the sample JSON does not have key for the book, then mock mode will either fail or produce strange null values. And that is actually a good check: mock mode reminds you that a contract is an agreement, not “some fields in there.”
7. Typical mistakes when working with the client
Mistake #1: mock mode starts returning “ready-made strings” instead of data.
This looks tempting: it is faster to write return List.of(...) or even System.out.println("MOCK RESULT") than to deal with JSON. But then mock does not verify your JSON mapping and turns into a toy stub. The result is usually: “mock is all green, real is all broken,” and you do not understand why.
Mistake #2: different output formats for real and mock.
Often this does not happen on purpose: in real mode you output “nicely,” and in mock mode you either print the object as-is or print different fields. It seems minor, but in practice it breaks the main idea: “the mode changes the source, not the behavior.” If the output format differs, you start adding if(useMock) conditions all over the code, and the project turns into two projects again.
Mistake #3: provider DTOs leak outward because it is easier.
The sneakiest version is when you first return provider DTOs “for a minute” to make output faster, and then forget to fix it. A couple of days later provider DTOs end up in ReadLaterApplication, in the printer, and in the command handlers. After that, every sneeze from the external API becomes your sneeze. Fixing it later is unpleasant: you have to clean types out of many places.
Mistake #4: reading mock files through File, which makes everything work only in the IDE.
In the IDE, the path src/main/resources/... is “visible,” and it seems like everything is fine. But after packaging, it is no longer a file on disk, but a resource inside the JAR. As a result, mock mode breaks at the worst possible time. If you read sample JSON through getResourceAsStream, you avoid this problem and get the same behavior when running from the IDE and when running the built artifact.
Mistake #5: empty results and “book not found” are not handled.
A beginner often tests only the happy path: “Well, I found clean code.” Then they enter something nonexistent, and the client either crashes, prints “null null null,” or stays silent. An empty list is a normal search result, not an error. And if you handle it the same way in real and mock, you get the sign of maturity: the application remains predictable even when nothing is found.
GO TO FULL VERSION