1. The role of logs in a backend service
A fast feedback loop is useful while you are next to the application and can restart it right away from the IDE. But a service has another life too: it runs on its own, and you have to deal with it after the fact. At that moment, you do not need yet another println; you need a permanent diagnostic channel — logs.
When you write a console application, it seems that the most natural way to find out “what is happening” is to set a breakpoint and inspect variables. In a service, everything quickly gets more complicated: requests arrive in parallel, the service lives for a long time, and the problem often shows up at the moment when you have already gone to drink tea. At that point the debugger does not help, because it is good when you are nearby and in control, while a service is an independent thing.
Imagine a typical situation: catalog-service is launched not from the IDE, but as a regular process (even if locally). It started, ran for a couple of hours, someone visited /api/catalog/featured, and then suddenly “something became strange”: the page is empty, although the courses are definitely there. You will not be able to “go back in time” and look at the state at the moment when it became strange. But you will almost always be able to open the logs and see a chain of events: the service started, how many courses it loaded, in which mode it was running, and which important branches it executed.
That is the main role of logs: they record the life story of the service. You can think of a log as an airplane’s “black box,” only without dramatic music in the background. A debugger is a microscope in a doctor’s hands. Logs are the patient’s medical record, read when the patient is already home and you cannot “turn on” the same moment again.
2. System.out.println and its traps
System.out.println really does seem convenient. It is always at hand, requires no imports, and you can throw in a couple of lines and immediately see what is happening inside the method. For educational tasks, that is fine: you are learning Java, and you need to quickly see the result. But as soon as you are writing a backend service, println starts working against you, because it is not about diagnostics, but about “quickly outputting something.”
The main problem is not even that println is “bad” (it is not bad, it is just not for this). The problem is that println does not give you proper management tools: you cannot easily distinguish important messages from secondary ones, filter output for a specific package, enable and disable verbosity without changing the code, or add system context (time, thread, source) in a consistent way. In the end, the service turns into a “chatty neighbor”: it says a lot, but it is unclear whether it is saying anything useful.
Let us compare this more concretely:
| Characteristic | System.out.println | Logging |
|---|---|---|
| Message “importance” | No, it is just a string | There are importance levels (at least the idea of “important/not important”) |
| Message source | You have to write it in manually | You can usually see which class wrote it |
| Format | However you assembled the string, that is how it will be | There is a unified log line format (time, level, thread, etc.) |
| Noise control | Almost absent | You can “soften” or “amplify” details without changing business code |
| Multithreading behavior | Messages are easily mixed together | At least the thread is usually visible, and you can group by source |
| Lifecycle | Often stays in the code “forever” | Logs are part of the discipline; they are written deliberately |
And one more subtle point: println very easily turns into a “homegrown protocol” that breaks with any refactoring. You write System.out.println("here 1"), then a week later you forget what “here 1” means, and you end up doing archaeology across the repository. Yes, archaeology is a useful skill, but it is better if that remains optional, not the daily routine.
A mini example of “how people usually start”:
public void start() {
// Bad habit: println does not provide a level of importance and does not add context automatically
System.out.println("started"); // started
}
The output will be there, but it does not answer the questions “who started? what exactly started? is this normal or an error? when exactly?”. For a live service, that is too little information.
3. Event, context, and importance in logs
For a log to be useful, it should not be a “stream of thoughts from the developer,” but a description of service events. A good log message usually does three things: names the event, adds context, and shows importance. That is why logging is a discipline: you do not just print text, you leave a trace that someone later (including you in a month) will read and try to understand.
An event answers the question “what happened?”. Context is “with what values/in which mode?”. Importance is “how serious is it?”. Even if you are not yet very confident with importance levels (we will develop them further later), the very idea of importance immediately makes the log more readable: different events should sound different.
For now, think of log simply as a working logging object, and {} as a way to substitute values without string concatenation. What matters here is the shape of the message itself: event and fact.
Look at the difference between messages:
// Bad: says nothing about the event
log.info("done");
// Better: says what was done and shows the result
// {} are placeholders; values are passed separately, without string concatenation
log.info("Featured courses prepared, count={}", featured.size());
Even if you do not know anything about the internals of logging, you can already feel it: the second message can be read as part of a story, while the first is just a random remark.
Another important point: a log should not retell every line of implementation. If a method performs 12 steps and you log all 12 at the same level, you get a “12-volume novel” that nobody will finish. In logs, you want to see anchor points: the beginning of an important process, the result of an important process, a suspicious state, an error.
That is why in catalog-service messages like “catalog loaded, courses count is such-and-such” and “featured selection prepared” make sense, while “now I am doing stream().filter()” does not. Stream is an implementation detail, not a domain event.
4. What to log in catalog-service
When you start logging, the most common mistake is either to log everything “just in case” or to log almost nothing (“we will figure it out later”). In an educational project, you want to find the golden middle: leave enough signals so that the service can be explained and diagnosed, but do not turn logs into white noise.
In catalog-service, a very natural first logging point is startup and loading catalog data from configuration. Our data comes from CatalogProperties, so at startup it is useful to confirm that the config was actually read and wired the way we expected. That is not “useless chatter,” but an early health check for the application: if there are suddenly 0 courses, that is an important signal.
Another good point is forming the “featured selection.” This is an application-specific, domain thing: it depends on data and settings, and it makes suspicious states easy to spot. For example, if maxFeaturedCount=4, but the result is 0, that is not necessarily an error, but it is definitely a reason to be alert.
Below is a small fragment from the service where the log “compresses” an important event into one line instead of retelling the implementation:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
@Service
public class CourseCatalogService {
// The logger is bound to the class: in logs, the source of the message is visible
private static final Logger log = LoggerFactory.getLogger(CourseCatalogService.class);
public void logFeaturedPrepared(int limit, int actualCount) {
// We record the domain event + important context (limit and fact)
log.info("Featured courses prepared, limit={}, count={}", limit, actualCount);
}
}
Here the log does not tell you how exactly you selected the courses, but it records the fact: “the selection is ready,” and shows two numbers that really help with diagnostics.
Another example is “maintenance mode.” In our case, this is a configuration flag (maintenanceMode). Even if it only minimally affects behavior right now, the mode itself is important context. The idea is simple: if the service is in maintenance, then strange behavior (for example, empty responses) may be expected. And then one log at startup saves you half an hour of investigation.
5. Where to place logs in the service layers
A service has layers: web layer (controller), application layer (service), data access layer (repository), plus bootstrap startup code. If you log chaotically, it is easy to get three identical messages for one event: the controller says “starting,” the service says “doing,” the repository says “doing,” and then all of that repeats again when an exception is handled. In the end, you did not help diagnostics; you just multiplied the noise.
A useful mental model is this: logging is like road signs. If you place a “Caution, turn” sign every two meters, drivers stop noticing it. If you place it where there is real danger, it works. So you should log at places that are truly important for understanding the system: scenario boundaries, final results, abnormal states.
Below is a simple diagram of where useful logs usually “live” in our project:
flowchart TD
A["Startup / bootstrap"] --> B[Service layer]
B --> C[Repository layer]
D[Web layer] --> B
A:::good -->|"Startup summary, counts, modes"| L1["Logs"]
B:::good -->|"Domain event: featured prepared"| L1
D:::meh -->|"Log only if there is a clear reason"| L1
C:::meh -->|"Careful: it is easy to create noise"| L1
classDef good fill:#e7ffe7,stroke:#2f7d32,stroke-width:1px;
classDef meh fill:#fff7e6,stroke:#b26a00,stroke-width:1px;
Bootstrap and the service layer are good candidates for the first meaningful logs in catalog-service. The controller and repository layers require caution: it is easy there to start “logging every little thing,” especially if you want to see every request. But at this level we care more about the state of the service than about turning the log into a tracker of every movement.
Another criterion for “where to stop” is this: the log should be understandable without reading the entire class. If a message sounds like “step=3 reached,” then only the author understands it (and even then, only for the first two days). If a message sounds like “Catalog loaded, courses=12,” anyone who knows the domain will understand it.
6. Refactoring: println → log
The transition from println to logging is better done not as a “revolution across the whole project,” but as small, clear steps. The best “first step” is to take a place where you were already printing something to the console (for example, a startup summary) and move it to a log. That way you do not change business behavior, you do not add a new feature, you just make the output of diagnostic information more mature.
First let us see what the naive version looks like (and yes, we have all done it — even the people who now act as if they never have):
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
@Component
public class StartupSummaryRunner implements ApplicationRunner {
@Override
public void run(ApplicationArguments args) {
// Naive version: the message goes directly to stdout and falls out of the general logging system
System.out.println("Catalog startup summary prepared"); // Catalog startup summary prepared
}
}
The message will appear, but it is not connected to the logging system in any way. Next, we replace it with a log message that will already be part of the overall startup picture:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
@Component
public class StartupSummaryRunner implements ApplicationRunner {
// SLF4J logger: formatting/levels/output are controlled by logging configuration
private static final Logger log = LoggerFactory.getLogger(StartupSummaryRunner.class);
@Override
public void run(ApplicationArguments args) {
// Now the message goes into the application's shared log stream
log.info("Catalog startup summary prepared");
}
}
Notice that we did not “make the world more complicated”; we simply changed the output channel. But this small step gives a big benefit: now the message goes through the logging system, which means it will be formatted the same way as the rest of the Spring Boot output, it can be filtered, and it will not look like it was “taped onto” the console.
At this step, it is enough to see the transition from stdout to logging. A full startup summary is a separate task: there, profiles, ports, and domain metrics matter, not just the fact that a message exists.
7. Typical mistakes when moving to logs
When you start logging, you tend either to “over-log” everything (and get noise), or to log just as meaninglessly as you used to print to the console. That is a normal stage of growth: first we write a lot, then we learn to write with purpose. Below are a few mistakes that almost every beginner makes, and because of them logs quickly stop helping.
Mistake #1: leaving System.out.println in services and runner classes “because it is faster.”
println really is faster to write, but it almost always stays in the code longer than you planned. As a result, part of the information lives in logs, part in random prints, and you read two different “worlds.” If a message is important, let it be a log message. If it is not important, it probably should not exist at all.
Mistake #2: writing messages like here, ok, done, step1.
Such messages create an illusion of control (“I can see that the code got here”), but they do not help you understand the state of the service. A week later you will see done and not remember what exactly was “done.” A good log names the event in human words and adds a measurable result: courses=12, maintenanceMode=true, limit=4.
Mistake #3: logging implementation instead of the event.
If you write a log before every line, you turn the log into “comments that are printed.” That is not diagnostics, that is a series called “How I did stream().filter().” A log should describe the beginning and the end of an important step, not copy the method’s internal kitchen.
Mistake #4: logging everything at the same level just “so it is visible.”
Beginners often make everything info because “it is visible in the console.” But then you will not have a difference between an important event and a background detail. Even if you are not very confident with levels yet, it is at least useful to separate intuitively between “important summary” and “detail for diagnostics.”
Mistake #5: printing the full object “just in case.”
Phrases like log.info("properties={}", properties) look tempting, but they usually create huge strings that are hard to read. It is much more useful to choose 1–3 fields that really explain the state and print them. “Compressing context” is one of the main goals of logging.
GO TO FULL VERSION