CodeGym /Courses /Spring Boot /Fail-fast and StartupSummaryRunner

Fail-fast and StartupSummaryRunner

Spring Boot
Level 6, Lesson 4
Available

1. Fail-fast: fail immediately and clearly

If you are just getting started, there is a very human desire: “As long as it starts, we can figure out the rest later.” The problem is that a server-side application that sort of started but actually works incorrectly is the worst-case scenario. It is already accepting requests, it is already writing logs, it is already consuming resources... and at the same time it may return garbage or fail on the first real user action. Fail-fast is the habit of saying: “If something critical is broken, it is better to fail immediately, loudly, and clearly.”

In the context of startup, fail-fast means a very simple idea: you have a minimal set of conditions without which the application must not be considered functional. As soon as you detect a violation, you do not shrug and print a “warning”; you stop the startup by throwing an exception. Boot will honestly record that the start failed, and you will get a clear signal: “Cannot work. Fix it.”

To feel the difference, it is useful to compare fail-fast with its toxic twin, fail-late (or “we’ll deal with it later”):

Approach What happens when a startup problem occurs What it feels like in real life
Fail-fast The application does not become “ready”; startup ends with an error You see the problem quickly, fix it quickly, and start again quickly
Fail-late The application starts “halfway” and breaks later The error appears in an unexpected place, the user is already suffering, and you are debugging “from traces”

In the educational project we are, of course, not building a production banking service (and thank the JVM gods for that), but it is better to develop the right habit on a simple example. Later, when the project gets bigger, this habit will literally save you hours of your life.

2. A runner at the end of startup: summary and checks

For fail-fast to be not “magic exceptions” but a deliberate tool, it is important to understand the place of the runner in the whole startup sequence. Runners are invoked when Spring has already assembled the container, created beans, and injected dependencies. That means you can safely inject ApplicationContext, Environment, ApplicationArguments, and any of your components — they already exist. But at the same time you are still in the startup phase: if you throw an exception here, the application will not become ready, and the ApplicationReadyEvent event will not happen.

A simplified timeline looks like this:

sequenceDiagram
    %% Runner runs at the very end of startup: the context is already up, but "ready" has not happened yet
    participant JVM as "JVM/main()"
    participant Boot as "SpringApplication.run()"
    participant Ctx as "ApplicationContext"
    participant R as "Runners"
    participant App as "Application ready (ready)"

    JVM->>Boot: start
    Boot->>Ctx: create/refresh context
    Ctx-->>Boot: context is up
    Boot->>R: call ApplicationRunner/CommandLineRunner
    alt check failed
        R-->>Boot: "throw an exception (fail-fast)"
        Boot-->>JVM: ApplicationFailedEvent + termination
    else everything is OK
        R-->>Boot: finished work
        Boot-->>App: "ApplicationReadyEvent (ready)"
    end

Pay attention to a very practical idea: a runner is the “last line before readiness.”

Here it is useful to keep a simple rule for choosing the right place.

  • runner — a one-time application-level action: a short summary, a quick check, fail-fast.
  • lifecycle listener — a marker of phases started / ready / failed and light diagnostics, but not the main business logic.
  • @PostConstruct / @PreDestroy — local preparation and cleanup of a specific bean, not the whole application.
  • Anything heavy, long-running, and periodic is better not to hang on the critical startup path at all.

And here is the practical effect: if an exception is thrown from the runner, Boot records failed, and readiness is never reached.

That is why a runner is ideal for two things we need right now.

First — to print a short startup summary: what application this is, how many beans it has, what arguments were passed, and generally “we made it to the end of startup.” It is like a “Welcome to the hotel” sign, only without the carpets and with beans.

Second — to perform quick critical checks. Quick is the key word. If a check takes a minute, a dozen network requests, and a shaman dance around an external system, then this is no longer fail-fast but “fail-slow-with-drama.” We need minimal startup quality control, without turning the runner into a hidden scenario engine.

3. StartupSummaryRunner: a minimal summary

When a developer first sees the startup log of a Boot application, there are many lines, half of which look like “incantations of the ancients.” That is why the idea of a startup summary in the project is very simple: add a few human-readable signals that are easy to read with your eyes and that will be the same on every run. This is not a replacement for framework logs and not an attempt to “re-explain Spring.” It is a short check: “the application reached the runner phase, sees arguments, and the context really came up.”

At this stage of the course (while the project is still a “skeleton”), we need only a few things. It is convenient to keep them in mind as a small “signal map”:

Signal How to get it Why you should care
Number of bean definitions context.getBeanDefinitionCount() Quickly understand that the context really came up and that this is not an “empty startup”
Active profiles (in the general sense) environment.getActiveProfiles() See that the startup environment is not “unknown” (even if there are no profiles)
Raw startup arguments args.getSourceArgs() Make sure we are reading arguments correctly and see what we actually started with
Process PID (optional) ProcessHandle.current().pid() Useful when you launch several times and confuse windows/processes

And now — what you should not do in StartupSummaryRunner, otherwise you will turn a simple idea into a monster. Do not print “everything”: the list of all beans, the entire Environment, all system properties, the entire JVM configuration, all environment variables. First, it is noise. Second, it can be unsafe even in educational code (sometimes env contains tokens, keys, and other “not for printing” things). Third, it hurts comprehension: you want to give 3–5 signals, not hold a “feast for the debugger.”

And it is better to keep this in one short runner rather than spreading it across several competing components: then startup reads like one clear policy, not a pile of surprises.

4. Implementing StartupSummaryRunner in catalog-service

The nice part: now we are going to take everything above and turn it into a live component in our project. It is important to maintain educational discipline: one class — one clear responsibility, constructor — only dependencies, runner — short logic. We are not going to build a “universal god-runner” that initializes the world, explains Spring, and makes coffee. We will build a small but useful thing that can be read even after a sleepless night.

Where to place the class

When a project is small, it is very easy to start dumping everything into one package “because it is faster.” Then you open the project a week later — and it looks like a box with wires: everything seems to work, but touching it is scary. That is why, already at the skeleton stage, it is important to train yourself to put classes where they logically belong. In our project there is a catalog.bootstrap package — that is a good place for startup components that help bring up the application and print diagnostics.

For StartupSummaryRunner it looks like this (roughly):

com.example.catalogservice
└── catalog
    └── bootstrap
        └── StartupSummaryRunner.java

Yes, right now this may feel like “extra neatness.” But this neatness is what later saves the project from turning into a mess.

First, let us assemble the roughest draft, and then immediately replace it with a more readable one. We do not need both of these classes in the project at the same time: the second block is the proper final summary.

Draft version: profiles, args, number of beans

Let us make a simple implementation based on ApplicationRunner, so we can immediately access ApplicationArguments. We will need ApplicationContext (to find the number of beans) and Environment (to find the active profiles). Everything via constructor injection — as we agreed in the course. This class is needed only as a draft probe to feel the set of signals; a little below we will replace it with the final StartupSummaryRunner.

import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.ApplicationContext;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;

@Component // Draft probe-runner: below we will replace it with a more human-readable final version
public class StartupSummaryProbeRunner implements ApplicationRunner {

    private final ApplicationContext context; // Needed for a diagnostic signal: how many beans were brought up
    private final Environment environment;    // Needed to see active profiles (if any)

    public StartupSummaryProbeRunner(ApplicationContext context, Environment environment) {
        // Constructor is only for dependencies, without "startup logic"
        this.context = context;
        this.environment = environment;
    }

    @Override
    public void run(ApplicationArguments args) {
        // This method is called when the context is already up, but the application is still not considered "ready"
        System.out.println("Bean definitions: " + context.getBeanDefinitionCount()); // Example: Bean definitions: 128
        System.out.println("Raw args count: " + args.getSourceArgs().length);        // Example: Raw args count: 0
        System.out.println("Active profiles: " + environment.getActiveProfiles().length); // Example: Active profiles: 0
    }
}

Final version: title, PID, and format

Plain lines like Bean definitions: 128 are already not bad, but in logs you often want a “frame”: so that it is clear this is our summary and not a random println() from somewhere else. Let us add a simple title and the process PID. PID is optional, but it helps a lot if you often restart the application and get confused about which window belongs to which run.

Now we replace the previous probe with a proper StartupSummaryRunner. This is the kind of version that makes sense to keep in the project as the basic startup summary.

import java.util.Arrays;

import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.ApplicationContext;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;

@Component // This runner is the normal permanent startup summary for the project
public class StartupSummaryRunner implements ApplicationRunner {

    private final ApplicationContext context; // Quick signal: the context is up and there really are beans in it
    private final Environment environment;    // Print active profiles in a human-readable form

    public StartupSummaryRunner(ApplicationContext context, Environment environment) {
        this.context = context;
        this.environment = environment;
    }

    @Override
    public void run(ApplicationArguments args) {
        long pid = ProcessHandle.current().pid(); // PID helps distinguish different runs in different windows/terminals

        System.out.println("=== catalog-service startup summary ==="); // Title so the summary stands out in logs
        System.out.println("pid = " + pid); // Example: pid = 12345
        System.out.println("bean definitions = " + context.getBeanDefinitionCount()); // Example: bean definitions = 128
        System.out.println("args = " + Arrays.toString(args.getSourceArgs())); // Example: args = []
        System.out.println("profiles = " + Arrays.toString(environment.getActiveProfiles())); // Example: profiles = []
    }
}

From here on, it is more convenient to evolve this class: if you want to add another diagnostic signal, it is better to extend it rather than create a second summary runner next to it.

5. Educational fail-fast variant: mandatory --mode

Now let us add the actual “fail nicely.” We will choose a simple educational scenario: the application expects the option argument --mode. For example, you can start it like this: --mode=demo. If --mode is not passed or is passed without a value, we consider it a critical startup error and throw an exception.

Why is this a good educational example? Because it requires neither configuration files, nor network calls, nor external resources. It fits entirely into today’s material: ApplicationArguments, option arguments, and runner.

It is important not to confuse this with a new permanent behavior of catalog-service. The regular summary from the previous section remains the normal project variant. Below is a separate educational runner variant, which is more convenient to enable instead of the usual summary runner during the experiment, so that you can feel how Boot stops startup on a critical error.

Let us do this carefully, with a clear message:

import java.util.List;

import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;

@Component // Educational fail-fast probe: it does not have to remain in the project as a permanent startup requirement
public class RequiredModeCheckRunner implements ApplicationRunner {

    @Override
    public void run(ApplicationArguments args) {
        // Critical check: without mode the application is not considered to be started correctly
        String mode = readRequiredMode(args);

        System.out.println("mode = " + mode);      // Example: mode = demo
        System.out.println("Startup checks: OK");  // Example: Startup checks: OK
    }

    private String readRequiredMode(ApplicationArguments args) {
        // 1) Check that the key itself was passed: --mode
        if (!args.containsOption("mode")) {
            // The error text should be "usable": it is immediately clear how to start correctly
            throw new IllegalStateException("Missing required startup option: --mode=demo");
        }

        // 2) Check that the key has a value: --mode=...
        List<String> values = args.getOptionValues("mode");
        if (values == null || values.isEmpty() || values.get(0).isBlank()) {
            throw new IllegalStateException("Startup option --mode must have a value. Example: --mode=demo");
        }

        // 3) In the educational example we take the first value (if multiple were passed, that is an intentional simplification)
        return values.get(0).trim();
    }
}

There are several important details here that beginners often miss.

First, containsOption("mode") checks for the presence of the --mode key itself. But the presence of the key does not yet guarantee that it has a proper value. That is why we additionally read getOptionValues("mode") and check null, an empty list, and isBlank().

Second, we throw IllegalStateException with a clear message. Not “mode error,” not “something wrong,” but a phrase that directly says what needs to be done. This is called a “message that can be used.” You may have the most beautiful stack trace in the world, but if the error text does not suggest how to fix the startup, then it is not fail-fast, but fail-annoying.

Third, we do not catch the exception. And this is crucial. Fail-fast works only when the error really stops the startup. If you wrap it in try/catch and pretend that everything is fine, you are back in the world of fail-late — only with an extra layer of self-deception.

In an educational project, this is just a convenient way to see fail-fast in action; after such an experiment, catalog-service does not have to require --mode for every run forever.

An anti-example (do not do this) looks roughly like this:

try {
    readRequiredMode(args); // We "checked" it, but now we will ruin everything
} catch (Exception e) {
    System.out.println("Oops: " + e.getMessage()); // Reported the error...
    // ...and keep starting as if nothing happened (bad idea): the application will be "half-working"
}

This is “a successful start of a broken application.” Yes, it will start. Yes, you will even see a warning. But if mode is really critical to behavior, you are only postponing the problem, and it will surface exactly where you least want to see it.

6. Runner boundaries: no “second phase”

A runner is a powerful extension point, and that is exactly why it very easily turns into the place where people “stuff everything.” A little logic, a little check, a little initialization, then “let’s add one more small call”... and now you have written half the application in one run() method. To prevent that, it is useful to keep a simple boundary: the runner should be short, predictable, and fast.

If we speak purely practically, a runner usually does one of two things. Either it prints a diagnostic summary so that you can see exactly what was launched. Or it performs short checks and fails startup when there are critical problems. Sometimes both things are combined, but then they must remain short and obvious, as in the examples above.

Another nuance: a runner is not the place for “heavy initialization.” If inside run() you do long work, you stretch out application startup. In an educational project this may just be annoying, but in a real service it becomes an operations problem: deployment is slow, restart is slow, and diagnostics are hard. Anything heavy either should not live at startup at all, or should require a different mechanism, not on the critical startup path.

And one last thing: if you have several runners, their invocation order may be non-obvious. In the early stage of the course, it is better to keep one explicit runner with summary and checks than to hang five different “small” runners across the project and then try to remember why they print lines in an unexpected order.

7. Typical application startup mistakes

Below are several mistakes that most often show up when people first begin writing startup logic. They are easy to make “by habit,” and then it is very difficult to explain why the application behaves strangely.

Mistake #1: “fail-fast” only in words — the exception is caught and ignored.
The most frustrating situation: you wrote a check, even threw an exception... and then wrapped everything in try/catch and kept going because “well, let it at least come up.” That gives you an application that can report an error but cannot react to it. If the condition is truly critical, the exception must stop the startup.

Mistake #2: StartupSummaryRunner turns into a “dump of the whole world.”
There is a temptation to print everything “for diagnostics”: all arguments, all env vars, all beans, the whole context. In the end, instead of diagnostics you get noise. There is nothing for the eye to catch on, important signals get lost, and sometimes you also accidentally print sensitive values. A summary should be short and stable, not an encyclopedia.

Mistake #3: critical startup logic is hidden in the constructor or @PostConstruct.
A bean constructor is for dependencies, not for actions. @PostConstruct is the local lifecycle of a specific object, not the “official application start.” When you hide critical startup logic there, you make understanding and diagnostics harder: who called it, when, and why. A runner makes startup logic explicit and readable.

Mistake #4: the runner does heavy work and “holds startup” for too long.
If a runner takes seconds or minutes, you lose startup predictability. Even in an educational project this is annoying, and in a real service it breaks deployment and restarts. A good startup check either passes quickly or fails quickly — without “long reflection.”

Mistake #5: there is a check, but the error message is useless.
throw new IllegalStateException("bad") technically works, but it has almost no practical value. The message should suggest what exactly is wrong and what to do: for example, “Missing required startup option: --mode=demo”. It is a small thing that saves a lot of time in total, especially when you return to the project after a break.

1
Task
Spring Boot, level 6, lesson 4
Locked
Short startup summary via `StartupSummaryRunner`
Short startup summary via `StartupSummaryRunner`
1
Task
Spring Boot, level 6, lesson 4
Locked
Required parameter `--mode` at startup
Required parameter `--mode` at startup
1
Survey/quiz
Spring Boot, level 6, lesson 4
Unavailable
Spring Boot
Application startup and events
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION