Logging: SLF4J, Logback & Log4j
Add useful logs to TaskFlow, understand facade vs engine, tune levels from config, and connect logging back to interface-driven design.
Assumes you know
After this you should be able to
- Add structured logs
- Choose an appropriate log level
Spring Boot by Building · Chapter 6
Every real app needs a running commentary of what it's doing — and System.out.println
is not it. We add proper logging to TaskFlow: the facade you code against, the engine underneath, log levels you
control from config, and how to swap in Log4j2 when a project asks for it.
Choose your route#
Study the same Logging: SLF4J, Logback & Log4j explanation with a different purpose: build and verify it in Learn deeply, or retrieve and defend it in Prepare for interviews.
- Add structured logs
- Choose an appropriate log level
Shared core#
The chapter below remains the canonical explanation for both goals. Keep its model, examples, and trade-offs shared; only the deliberate practice changes with the route.
Learn deeply: build evidence#
Use this chapter’s TaskFlow work to make one small, observable change. Run the stated acceptance checks before recording practice.
Prepare for interviews: explain the trade-off#
Without notes, explain the boundary this chapter protects, one concrete implementation decision, and the cost of a tempting shortcut. Then answer one follow-up using the chapter’s own example.
Logging is a cross-cutting concern — it isn't a layer, it sits beside all of them.
Every class can ask for a logger and narrate its work at a chosen level (error, warn, info,
debug). You then turn those levels up or down from configuration, without recompiling — quiet in
production, chatty while debugging. That dial is the whole point, and it's why println can never
compete.
Section 1Facade vs engine — the same split you already met#
Logging has the exact two-part shape as persistence (spec vs implementation). Learn it once, recognise it everywhere.
The interface your code calls: log.info(...). You always code against SLF4J and never
name the engine in your classes.
The thing that actually formats lines and writes them to console/file. Spring Boot ships Logback by default; Log4j2 is a drop-in alternative.
Because your code only talks to SLF4J, swapping Logback for Log4j2 changes zero lines of your Java — same as swapping H2 for MySQL changed no service code. Same lesson, different corner of the app: program to the interface, configure the implementation. Nothing to install for the default engine — the web starter already pulled Logback in.
Section 2Get a logger and use it#
Two ways to obtain a logger. The manual way shows what's happening; the Lombok way is what you'll actually write.
Manual (SLF4J directly)#
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class TaskService {
private static final Logger log = LoggerFactory.getLogger(TaskService.class);
// ...
}Lombok (one annotation)#
import lombok.extern.slf4j.Slf4j;
@Slf4j // generates the `log` field for you
public class TaskService {
// `log` just exists now
}- Pass
TaskService.classso each log line is tagged with the class it came from — that tag is how you filter later. @Slf4j(Lombok) generates the identical field; we use it from here since TaskFlow already has Lombok. Both give you a variable calledlog.
Now narrate the service's work. Add lines to TaskService from Chapter 3/4:
@Slf4j
@Service
public class TaskService {
private final TaskRepository repository;
public TaskService(TaskRepository repository) { this.repository = repository; }
public Task create(String title) {
log.info("Creating task with title '{}'", title); // parameterized — see below
Task saved = repository.save(new Task(title, false));
log.debug("Saved task id={}", saved.getId()); // detail, off by default
return saved;
}
public Task getById(Long id) {
return repository.findById(id).orElseThrow(() -> {
log.warn("Task not found: id={}", id); // a notable, non-fatal event
return new TaskNotFoundException(id);
});
}
}Write log.debug("Saved task id={}", id), not
log.debug("Saved task id=" + id). The {} placeholder form only builds the string
if that level is actually enabled — so a disabled DEBUG line costs nothing. The concatenation builds
the string every time, even when nobody logs it. This is the single most common logging mistake.
Section 3The levels, and when to use each#
A level is the severity/importance of a line. You set a threshold; anything at or above it prints, anything below is silent. From most to least severe:
| Level | Use for | Example |
|---|---|---|
| ERROR | Something failed and needs attention. | "Payment gateway unreachable" |
| WARN | Odd but survivable; might bite later. | "Task not found: id=99" |
| INFO | Normal high-level milestones. Default threshold. | "Creating task 'Buy milk'" |
| DEBUG | Detail for diagnosing; off in production. | "Saved task id=1" |
| TRACE | Very fine-grained; rarely on. | "Entering loop iteration 3" |
Set the threshold to INFO and you see milestones only — clean production output. Drop it to
DEBUG for one package while chasing a bug and you get the play-by-play — without editing or
redeploying code. That live dial is exactly why levels exist.
Section 4Control it all from application.properties#
No code changes — logging is configured, like the database was. Add these and restart:
# turn your own package up to DEBUG, keep the noisy framework at INFO/WARN
logging.level.root=INFO
logging.level.com.example.taskflow=DEBUG
logging.level.org.springframework.web=INFO
logging.level.org.hibernate.SQL=DEBUG # log the SQL (like show-sql, via logging)
# also write logs to a file, not just the console
logging.file.name=logs/taskflow.log
# customise the console line format (optional)
logging.pattern.console=%d{HH:mm:ss} %-5level %logger{20} - %msg%nlogging.level.com.example.taskflow=DEBUG— turns only your code chatty. This per-package dial is the feature: yourlog.debuglines now appear, while Spring's internals stay quiet.logging.file.name— also append to a file, so logs survive after the console scrolls away.
Create a task and watch the levels sort themselves:
Section 5Swapping in Log4j2#
Many teams standardise on Log4j2 (for its performance and config style). Because your code uses the SLF4J facade, the switch is two build changes + one config file — no Java touched.
- Exclude the default Logback engine
Spring's starters pull
spring-boot-starter-logging(Logback). Exclude it so two engines don't clash.<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <exclusions> <exclusion> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-logging</artifactId> </exclusion> </exclusions> </dependency> - Add the Log4j2 starter
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-log4j2</artifactId> </dependency> - Add a
log4j2.xmlPut it in
src/main/resources. This is Log4j2's own config format (replacing thelogging.*properties for advanced control).<?xml version="1.0" encoding="UTF-8"?> <Configuration status="WARN"> <Appenders> <Console name="Console" target="SYSTEM_OUT"> <PatternLayout pattern="%d{HH:mm:ss} %-5level %logger{20} - %msg%n"/> </Console> <File name="File" fileName="logs/taskflow.log"> <PatternLayout pattern="%d %-5level %logger - %msg%n"/> </File> </Appenders> <Loggers> <!-- your package: verbose --> <Logger name="com.example.taskflow" level="debug"/> <!-- everything else: info --> <Root level="info"> <AppenderRef ref="Console"/> <AppenderRef ref="File"/> </Root> </Loggers> </Configuration>
- Appender = where logs go (Console, File). PatternLayout = the
line format — same idea as
logging.pattern.console. - Logger
name=...level=...= the per-package dial, now in XML:com.example.taskflowat debug, Root (everything else) at info. - Your
log.info(...)/log.debug(...)calls are unchanged — they went through SLF4J, so they don't care which engine reads them.
For learning and most apps, stick with the default (Logback) and configure it in
application.properties — it's zero setup. Reach for Log4j2 when a team already uses it or
you need its specific features. Either way your code is identical; that's the entire benefit of coding to the
facade.
Section 6Exercises#
Goal. Deleting a task logs an INFO line; a missing delete logs a WARN. Then verify you can silence your app's INFO by raising the level to WARN in config.
public void delete(Long id) {
getById(id); // logs WARN + throws 404 if missing
repository.deleteById(id);
log.info("Deleted task id={}", id);
}Then set logging.level.com.example.taskflow=WARN and restart — the
"Deleted" INFO line disappears while the "not found" WARN still shows. You just changed what your app says
without changing what it does.
Self-check: the level dial worked, and you used {} not
+.
Goal. Each incoming POST /tasks logs an INFO line with the title,
and all logs also land in logs/taskflow.log.
@Slf4jon the controller; aninfoline increate.- A
logs/taskflow.logfile appears and contains the line.
Hint
@Slf4j, call
log.info("POST /tasks title={}", req.title()). The file is one property:
logging.file.name=logs/taskflow.log.Notebook Summary#
Logging on one page
Facade vs engine#
- SLF4J = the API you call (
log.info) · Logback = default engine · Log4j2 = alternative engine - Code against the facade → swap the engine with zero Java changes
Using it#
- Get a logger:
@Slf4j(Lombok) orLoggerFactory.getLogger(X.class) - Levels: ERROR > WARN > INFO > DEBUG > TRACE; INFO is the default threshold
- Always
log.debug("id={}", id)with{}, never string+
Config (no code)#
logging.level.com.example.taskflow=DEBUG→ per-package diallogging.file.name=logs/app.log→ also to file- Log4j2: exclude
starter-logging, addstarter-log4j2, addlog4j2.xml(Appenders + Loggers)
Box these#
- ⭐Logging is cross-cutting — beside the layers, not one of them.
- ⚠Never log secrets/passwords/tokens. Never use
System.out.printlnin real code. - 💡The level dial lets you go verbose for one package while chasing a bug, then quiet again — no redeploy.
Chapter 7 — From requirements to a blueprint. You can now build and observe every layer. The remaining question is the one you asked: given a fresh requirements sheet, what do I make, and how do I decide? We turn a spec into a plan — entities, interfaces, classes — before writing code.
TaskFlow checkpoint#
Current state: tests expose regressions, but production failures are opaque. Outcome: useful TaskFlow logs. Prerequisite: service methods. Java bridge: SLF4J is an interface, Logback is one implementation. Estimate: 25 minutes.
Mission: leave a diagnostic trail#
Add one contextual log at a service decision, configure a package level, and prove the result in the console or file.
- A TaskFlow operation writes an id and outcome at an appropriate level.
- Changing
logging.level.com.example.taskflowchanges the detail. logs/taskflow.logcontains the evidence when file logging is enabled.
Likely files: service/TaskService.java, application.properties. Suggested commit: feat: add taskflow diagnostic logging.
Hint 1
Hint 2
Hint 3
Before opening the solution: run one request, find its log, and justify its level.
Reference patch — diagnostic logging
Recall before continuing#
- What does a log level communicate?
- Why log an identifier?
- Why use a facade?
The next workshop turns the proven, observable TaskFlow shape into a fresh design decision.
Before Integration, retrieve the evidence you used to test, persist, observe, and bound TaskFlow. Answer these as one connected system, not four isolated tools.
- Which regression belongs in a focused service test, and which belongs in a controller test?
- Why should an API request DTO not become the persisted entity unchanged?
- Which log detail proves a service decision without exposing a credential or secret?