Study Notes
Spring Boot by Buildingapplied-skills

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.

25 min read·verified Spring Boot 4.x·reviewed 2026-08-28

Assumes you know

After this you should be able to

  • Add structured logs
  • Choose an appropriate log level
SLF4JLogbackConfig

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.

Chapter 6 · We do — fully guided · Cross-cutting concern · Hands-on ~25 min


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.

Learning goal
  • 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.

Learn next: Use add structured logs as the base, then choose a focused test strategy in Applied skills retrieval review. Interview next: Use add structured logs as the base, then configure a security filter chain in Security: Spring Security & Basic Auth.

Continue to Applied skills retrieval review

// the big idea

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.

SLF4J — the facade (API)

The interface your code calls: log.info(...). You always code against SLF4J and never name the engine in your classes.

Logback / Log4j2 — the engine

The thing that actually formats lines and writes them to console/file. Spring Boot ships Logback by default; Log4j2 is a drop-in alternative.

Your class→ SLF4J (facade)→ Logback or Log4j2 (engine)→ console / file
// why the facade matters

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
}
notes
  • Pass TaskService.class so 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 called log.

Now narrate the service's work. Add lines to TaskService from Chapter 3/4:

service/TaskService.java
@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);
        });
    }
}
// pitfall — never string-concatenate log messages

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:

LevelUse forExample
ERRORSomething failed and needs attention."Payment gateway unreachable"
WARNOdd but survivable; might bite later."Task not found: id=99"
INFONormal high-level milestones. Default threshold."Creating task 'Buy milk'"
DEBUGDetail for diagnosing; off in production."Saved task id=1"
TRACEVery fine-grained; rarely on."Entering loop iteration 3"
// the mental model

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:

src/main/resources/application.properties
# 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%n
the key line
  • logging.level.com.example.taskflow=DEBUG — turns only your code chatty. This per-package dial is the feature: your log.debug lines 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:

# with com.example.taskflow at DEBUG: 14:22:01 INFO c.e.t.service.TaskService - Creating task with title 'Buy milk' 14:22:01 DEBUG c.e.t.service.TaskService - Saved task id=1 # set it back to INFO and the DEBUG line vanishes — no code changed: 14:25:10 INFO c.e.t.service.TaskService - Creating task with title 'Buy milk'

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.

  1. Exclude the default Logback engine

    Spring's starters pull spring-boot-starter-logging (Logback). Exclude it so two engines don't clash.

    pom.xml — on the web starter
    <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>
  2. Add the Log4j2 starter
    pom.xml
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-log4j2</artifactId>
    </dependency>
  3. Add a log4j2.xml

    Put it in src/main/resources. This is Log4j2's own config format (replacing the logging.* properties for advanced control).

    src/main/resources/log4j2.xml
    <?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>
map the XML to what you already know
  • 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.taskflow at 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.
// which should you use?

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#

Exercise 6.1 we do — full solution shown

Log every delete at INFO, and see it only when you want#

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.

TaskService.java
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 +.

Exercise 6.2 you do

Add request-level logging to the controller and route it to a file#

Goal. Each incoming POST /tasks logs an INFO line with the title, and all logs also land in logs/taskflow.log.

  • @Slf4j on the controller; an info line in create.
  • A logs/taskflow.log file appears and contains the line.
Hint
Controller side is identical to the service: annotate with @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) or LoggerFactory.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 dial
  • logging.file.name=logs/app.log → also to file
  • Log4j2: exclude starter-logging, add starter-log4j2, add log4j2.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.println in real code.
  • 💡The level dial lets you go verbose for one package while chasing a bug, then quiet again — no redeploy.
// next — a change of gear

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.taskflow changes the detail.
  • logs/taskflow.log contains the evidence when file logging is enabled.

Likely files: service/TaskService.java, application.properties. Suggested commit: feat: add taskflow diagnostic logging.

Hint 1
Log the decision and identifier, not every line of code.
Hint 2
Use DEBUG for diagnosis and INFO for meaningful lifecycle events.
Hint 3
Keep secrets and raw credentials out of logs.

Before opening the solution: run one request, find its log, and justify its level.

Reference patch — diagnostic logging
Use the facade in the service and let configuration decide its output destination and level.

Recall before continuing#

  1. What does a log level communicate?
  2. Why log an identifier?
  3. Why use a facade?

The next workshop turns the proven, observable TaskFlow shape into a fresh design decision.

// applied skills checkpoint

Before Integration, retrieve the evidence you used to test, persist, observe, and bound TaskFlow. Answer these as one connected system, not four isolated tools.

  1. Which regression belongs in a focused service test, and which belongs in a controller test?
  2. Why should an API request DTO not become the persisted entity unchanged?
  3. Which log detail proves a service decision without exposing a credential or secret?