Career OS

What Spring Actually Solves

First week on a Java backend team. You open the codebase: @Service, @Repository, @Autowired on everything, and not a single new for any of those classes — yet at runtime the objects all exist and know about each other. Most beginners call this magic and start cargo-culting annotations. This module shows you the trick, and the trick is ten lines of plain Java you could write yourself.

The Goal

By the end of this module you can:

  • Explain what a bean is in one sentence that contains zero Spring vocabulary
  • Wire an object graph by hand — manual dependency injection — and point at exactly what Spring automates
  • Defend constructor injection over field @Autowired with three concrete reasons
  • Predict how many instances of any bean exist at runtime, and why a mutable field in one is a production bug
  • Read Spring’s circular-dependency startup error and find the cycle in seconds

The Lesson

The pain — SplitEase wants to grow up

Your capstone Ledger is self-contained: friends and expenses live in collections, math happens in methods, the CLI prints. Now three real requirements arrive — the kind product managers actually write:

  1. Friends and expenses must survive a restart — the Ledger needs some kind of friend store.
  2. When an expense is added, the sharers should be told — the Ledger needs a notifier.
  3. Expenses need timestamps you can control in tests — the Ledger needs a clock.

The first instinct is the one Java hands you — new the things you need, where you need them:

public class Ledger {
    private final FriendStore store = new FileFriendStore("friends.txt");
    private final Notifier notifier = new EmailNotifier("smtp.example.com", 587);
    private final Clock clock = Clock.systemUTC();

    // ...the balance and settle-up logic from the capstone
}

It compiles. It runs. And it has quietly poisoned the codebase:

The problemWhere it bites
Tight coupling — Ledger names its exact collaboratorsWant SMS instead of email? Edit Ledger. Want PostgreSQL instead of a file? Edit Ledger. The brain of the app changes every time a peripheral does.
Untestable — testing the settle-up math now writes real files and sends real emailYour math test should not need an SMTP server. Today it does.
Change ripplesEmailNotifier gains a constructor parameterEvery class that says new EmailNotifier(...) breaks. In a real codebase that is a grep and 14 file edits for one changed line.
The constructor liesnew Ledger() looks freeIt actually drags in the filesystem and a mail server. Nothing in its signature warns you.

The fix is a design move, not a framework

Flip the direction. The Ledger stops creating its dependencies and starts declaring them:

public class Ledger {
    private final FriendStore store;
    private final Notifier notifier;
    private final Clock clock;

    public Ledger(FriendStore store, Notifier notifier, Clock clock) {
        this.store = store;
        this.notifier = notifier;
        this.clock = clock;
    }

    // ...logic unchanged
}

And one place — main — builds the whole graph:

public static void main(String[] args) {
    FriendStore store = new FileFriendStore("friends.txt");
    Notifier notifier = new EmailNotifier("smtp.example.com", 587);
    Clock clock = Clock.systemUTC();

    Ledger ledger = new Ledger(store, notifier, clock);
    SplitEase app = new SplitEase(ledger);
    app.run();
}

That’s it. That is dependency injection — dependencies are handed in from outside instead of constructed inside. Ten lines, zero framework, and you already understand every one of them. This main even has a name in the trade: the composition root — the single place that knows how the whole object graph fits together.

flowchart TD
    SE[SplitEase CLI] --> L[Ledger]
    L --> FS[FriendStore interface]
    L --> N[Notifier interface]
    L --> CK[Clock]
    FS -.-> IM[FileFriendStore today]
    FS -.-> PG[Postgres store later]
    N -.-> CN[ConsoleNotifier in tests]
    N -.-> EM[EmailNotifier in prod]

Look what fell out for free: a test can now do new Ledger(new InMemoryFriendStore(), new RecordingNotifier(), fixedClock) — no files, no email, instant, deterministic. Swapping PostgreSQL in later means changing one line in main, not surgery on Ledger.

Spring’s container is that main method, industrialized

Now scale the picture. A real backend has 80 classes and a few hundred dependency edges. Your hand-written composition root becomes 200 lines of constructor calls that must run in exactly the right order, re-sorted by hand every time anyone adds a dependency. Nobody wants to maintain that main.

So Spring maintains it for you. At startup, the container does precisely the three things your main did:

  1. Find the classes you’ve marked as participating (component scan).
  2. Sort them so dependencies are built before the things that need them.
  3. Call the constructors, passing in the already-built objects.

That’s the de-magic headline of this whole track: @Autowired is not magic — it’s your ten-line main, generated and executed for you.

What a bean actually is

One sentence, no Spring vocabulary: a bean is an object that the container created and manages. Nothing more.

Not a special class. No interface to implement. No bytecode wizardry on the class itself. The Ledger above becomes a bean the moment Spring constructs it instead of your main doing it. The same class, new-ed by you in a test, is not a bean — and works identically. “Managed” just means the container built it, holds a reference to it, injects it wherever it’s declared, and will tidy it up at shutdown.

Four annotations, one meaning

AnnotationMechanicallyThe intent it signals
@Componentregister this class as a beangeneric — no better label fits
@Serviceregister this class as a beanbusiness logic lives here (your Ledger)
@Repositoryregister this class as a beandata access — also translates low-level DB exceptions into Spring’s hierarchy
@Controller / @RestControllerregister this class as a beanhandles HTTP — also tells the web layer to route requests here

The honest version: @Service is @Component wearing a costume. They all do the same core thing — put this class on the scan’s list. The last two add small real extras (noted in the table), but the bean-making machinery is identical. Pick the one that tells a human reader what the class is for.

Component scan — how Spring finds them

Your module 02 app will have one class annotated @SpringBootApplication, sitting in the root package — say com.splitease. At startup, the scan walks com.splitease and every package below it, collecting annotated classes.

The classic trap hides in that sentence. Put a class in com.utils — beside the root, not below it — and the scan never visits it. No warning at compile time. At startup you get:

Parameter 0 of constructor in com.splitease.ledger.LedgerService required
a bean of type 'com.utils.FriendStore' that could not be found.

“Could not be found” almost never means Spring is broken. It means the scan never walked past that class. First check, always: is the class under the root package, and is the annotation actually on it? Rule for this whole track: everything lives under com.splitease.

Constructor injection — the only style this track allows

Spring will inject dependencies three ways. You will see this in old codebases and tutorials:

@Service
public class LedgerService {
    @Autowired
    private FriendStore store;   // field injection — banned in this track
}

You will write this:

@Service
public class LedgerService {
    private final FriendStore store;

    public LedgerService(FriendStore store) {   // Spring calls this for you
        this.store = store;
    }
}

(With a single constructor, you don’t even write @Autowired — Spring uses that constructor automatically.)

Why field injection is banned here — three reasons, all concrete:

  1. final becomes possible. A constructor-set field can be final: never null after construction, never reassigned, safely visible to every thread. Field injection forbids final because Spring has to mutate the field after the object exists.
  2. Tests don’t need Spring. new LedgerService(new InMemoryFriendStore()) — done. A field-injected class can only be filled in by the container or by reflection hacks; your fast unit tests now drag a framework along.
  3. Cycles fail fast. If two beans need each other through constructors, the app refuses to start and prints the cycle. With field injection, Spring can paper over the cycle using half-initialized objects — the design smell survives and bites later.

There’s a fourth, quieter reason: a constructor with nine parameters screams “this class does too much.” Nine @Autowired fields whisper it. Pain you can see is pain you fix.

One instance, shared by everyone — singleton scope

By default, the container creates one instance of each bean, total. Inject FriendStore into five different classes and all five hold the same object. That’s singleton scope, and it’s the right default — most beans are stateless machinery, so why copy them?

But it carries a rule you must never break: a bean’s fields are shared by every thread serving every request. So no mutable per-request state in beans:

@Service
public class LedgerService {
    private Friend currentFriend;   // BUG: one field, all requests

    public void addExpense(...) {
        this.currentFriend = ...;   // request A writes
        // ...                      // request B overwrites mid-flight
    }
}

Two users hit the API at the same moment, the field interleaves, and user A’s expense lands on user B’s friend. It works flawlessly in dev — you’re the only user — and corrupts data in production under load. This is the shared-mutable-state problem from the concurrency module wearing a Spring costume.

The rule: beans hold dependencies (final fields) and no per-request data. Per-request data lives in method parameters and local variables — every thread has its own stack, so locals are private by construction.

Startup, watched in slow motion

The container has a proper name: the ApplicationContext. Here’s what happens between SpringApplication.run(...) and the “started” log line:

flowchart TD
    A[main calls SpringApplication run] --> B[Component scan walks the root package and below]
    B --> C["Every @Component @Service @Repository @Controller class becomes a bean definition"]
    C --> D[Container sorts definitions so dependencies come before dependents]
    D --> E[Constructors run in that order each receiving already built beans]
    E --> F[ApplicationContext holds every singleton and the app reports ready]

Bean definitions first (the recipe), then bean instances (the cooked dish), in dependency order — leaves first, exactly like your hand-written main had to do.

When the wiring fails — read the cycle

Say LedgerService injects NotificationService (notify sharers on a new expense). Months later someone makes NotificationService inject LedgerService (to include the current balance in the message). Startup now prints:

***************************
APPLICATION FAILED TO START
***************************

Description:

The dependencies of some of the beans in the application context form a cycle:

┌─────┐
|  ledgerService defined in file [.../LedgerService.class]
↑     ↓
|  notificationService defined in file [.../NotificationService.class]
└─────┘

Action:

Relying upon circular references is discouraged and they are prohibited
by default. Update your application to remove the dependency cycle.

Read it like a stack trace: the box is the diagram. Each line is a bean; the arrows say who needs whom; the loop closing back on itself is the problem. Translate it to manual DI and the impossibility is obvious — try writing that main: which constructor do you call first? Neither can run without the other’s result.

The internet’s fix is @Lazy or spring.main.allow-circular-references=true. Don’t. The cycle is a design smell, and the error is a gift. Real fixes: extract the shared piece into a third class both depend on, or pass the balance in as a method parameter instead of injecting the whole service. Constructor injection found this at boot, on your machine — that’s reason 3 from the ban list earning its keep.

See It Move

Step through one container boot: the scan finds annotated classes, beans get built in dependency order, and each constructor receives beans that already exist.

Step through it and notice:

  • The build order is forced by the arrows — the store exists before the Ledger because the Ledger’s constructor demands it
  • Nothing happens to a class until the scan finds its annotation — unannotated or out-of-package classes simply never enter the container
  • Each bean is constructed exactly once; every later injection hands out the same instance
  • This is your ten-line main animated — nothing on screen is something you haven’t done by hand today

Check The Concept

How This Shows Up At Work

  • The classic production incident. A service caches the “current user” in a field. Low traffic, no symptoms for months. A marketing push triples concurrency and users start seeing each other’s data. The postmortem line is always the same: singleton bean, mutable state. Engineers who know the default scope catch this in code review instead of in the incident channel.
  • The code review comment you will receive. Push field @Autowired to most product-company repos and the review says “constructor injection, please.” Now you can answer whyfinal, framework-free tests, fail-fast cycles — instead of just complying.
  • The interview opener. “What is a bean, and how does Spring create it?” is a top-three Spring question. Most candidates recite “an object managed by the IoC container” and crumble at the follow-up “and what does managed mean?” You can describe the main method Spring replaced. That’s a hire signal.
  • The silent missing bean. During a refactor a teammate drags a class into a sibling package. CI fails with “required a bean of type … could not be found.” The person who knows component scan checks the package path first and fixes it in one minute; the person who doesn’t burns an afternoon re-reading annotations.
  • The cycle after a feature. A new requirement makes service A need service B and B need A. The junior adds @Lazy because a forum said so; the design smell ships. The senior move is reading the cycle box and extracting a third class — and now that move is yours.

Build This

Say it plainly first: the Spring project starts in module 02, where you generate splitease-api on start.spring.io. Today’s build is deliberately Spring-free — you do Spring’s job by hand exactly once, so that next module the container is automation you recognize, not magic you trust.

What exists: your SplitEase CLI from the capstone — leave it untouched. What you add: a small standalone wiring exercise beside it.

  1. Make a folder splitease-wiring. Each class below is its own file.

  2. Define the two seams as interfaces — Ledger will depend on these, never on implementations:

import java.util.List;

public interface FriendStore {
    void save(String name);
    List<String> findAll();
}
public interface Notifier {
    void send(String message);
}
  1. Write the real implementations:
import java.util.ArrayList;
import java.util.List;

public class InMemoryFriendStore implements FriendStore {
    private final List<String> names = new ArrayList<>();

    @Override
    public void save(String name) {
        names.add(name);
    }

    @Override
    public List<String> findAll() {
        return List.copyOf(names);
    }
}
public class ConsoleNotifier implements Notifier {
    @Override
    public void send(String message) {
        System.out.println("[notify] " + message);
    }
}
  1. Write a Ledger that declares and never creates:
import java.util.Objects;

public class Ledger {
    private final FriendStore store;
    private final Notifier notifier;

    public Ledger(FriendStore store, Notifier notifier) {
        this.store = Objects.requireNonNull(store, "store must not be null");
        this.notifier = Objects.requireNonNull(notifier, "notifier must not be null");
    }

    public void addFriend(String name) {
        if (store.findAll().contains(name)) {
            throw new IllegalArgumentException("Friend already exists: " + name);
        }
        store.save(name);
        notifier.send("Added friend " + name);
    }

    public int friendCount() {
        return store.findAll().size();
    }
}
  1. Write the composition root — this main is the thing Spring automates:
public class Main {
    public static void main(String[] args) {
        FriendStore store = new InMemoryFriendStore();
        Notifier notifier = new ConsoleNotifier();
        Ledger ledger = new Ledger(store, notifier);

        ledger.addFriend("Asha");
        ledger.addFriend("Rohit");
        System.out.println("Friends: " + ledger.friendCount());
    }
}
  1. Compile and run:
javac *.java
java Main

Expected output:

[notify] Added friend Asha
[notify] Added friend Rohit
Friends: 2
  1. Now the payoff — prove the fake makes it testable. Add a recording fake and a framework-free test:
import java.util.ArrayList;
import java.util.List;

public class RecordingNotifier implements Notifier {
    final List<String> sent = new ArrayList<>();

    @Override
    public void send(String message) {
        sent.add(message);
    }
}
import java.util.List;

public class WiringTest {
    public static void main(String[] args) {
        RecordingNotifier notifier = new RecordingNotifier();
        Ledger ledger = new Ledger(new InMemoryFriendStore(), notifier);

        ledger.addFriend("Asha");

        boolean stored = ledger.friendCount() == 1;
        boolean notified = notifier.sent.equals(List.of("Added friend Asha"));
        boolean rejectsDuplicate;
        try {
            ledger.addFriend("Asha");
            rejectsDuplicate = false;
        } catch (IllegalArgumentException e) {
            rejectsDuplicate = true;
        }

        System.out.println(stored && notified && rejectsDuplicate
                ? "ALL CHECKS PASS" : "SOMETHING FAILED");
    }
}

Compile and java WiringTest — expect ALL CHECKS PASS. Sit with what just happened: Ledger was tested with zero changes to Ledger — real notifier out, recording fake in, all through the constructor. That swap is the entire reason dependency injection exists. In module 08 JUnit replaces these booleans; the technique is already yours.

Now break it on purpose:

  1. Feel the coupling wall. In Ledger, delete the constructor parameters and hard-wire this.store = new InMemoryFriendStore(); this.notifier = new ConsoleNotifier();. Try to make WiringTest use the RecordingNotifier. You can’t — there is no way in, short of editing Ledger itself. That dead end is what every new-inside-the-class costs you. Revert.
  2. See fail-fast earn its keep. In Main, pass null for the notifier. Run it — requireNonNull explodes immediately at wiring time with “notifier must not be null”. Now delete the two requireNonNull calls and pass null again: it boots fine and only explodes when addFriend runs. In a real system that’s the difference between a refused startup on your machine and a 2 a.m. NullPointerException in production. Spring’s startup gives you the first behavior for free. Restore the checks.
  3. Build an impossible cycle by hand. Change ConsoleNotifier to demand a Ledger in its constructor (say, to include friendCount() in messages) while Ledger still demands a Notifier. Now try to write Main. Whichever you construct first, the other doesn’t exist yet — it is not a compile error, it is logically impossible to order. That impossibility is exactly what Spring’s cycle box reports. Revert, and remember the feeling next time you see the error.

Where to Practice

ResourceWhat to do thereHow long
docs.spring.ioSpring Framework reference, Core section — read the introduction to the IoC container and recognize everything you built today25 min
BaeldungRead the free article “Intro to Inversion of Control and Dependency Injection with Spring” — skim confidently, you have done the hard part by hand20 min
BaeldungRead “Constructor Dependency Injection in Spring” and compare its arguments to this module’s three reasons15 min
start.spring.ioJust look around the generator — change options, generate nothing. Module 02 starts here5 min

Check Yourself

  1. Define a bean without using the words Spring, container, or IoC. (Yes, the no-container version — what kind of thing is it?)
  2. What plain Java code is @Autowired replacing? Be specific about where that code would live.
  3. Field @Autowired is banned in this track — give all three reasons.
  4. The same @Service class is injected into five controllers. How many instances exist, and what follows about its fields?
  5. Why is private Friend currentFriend; in a singleton service a concurrency bug, and where should that data live instead?
  6. Mechanically, what’s the difference between @Component and @Service? Why do both exist?
  7. Your class has @Component but startup says a bean of its type “could not be found.” What do you check first?
  8. With constructor injection, when does a circular dependency surface — and why is that timing a feature, not an annoyance?
Answers
  1. An object that something else constructed and holds onto, handing it to whoever declares they need it. In Spring’s case that something is the ApplicationContext — but the object itself is ordinary.
  2. Constructor calls in a composition root — a main that builds dependencies first and passes them into the classes that need them. Spring generates and runs that wiring for you at startup.
  3. Constructor injection allows final fields (never null, never reassigned, thread-safe to publish); it lets unit tests build the class with plain new and fakes, no framework; and it makes circular dependencies fail loudly at startup instead of being papered over with half-initialized objects.
  4. One instance, total — singleton scope is the default. Therefore its fields are shared by every thread and every request, so they must hold only dependencies, never per-request data.
  5. One instance serves all concurrent requests, so two requests write the same field and interleave — user A’s data bleeds into user B’s. Per-request data belongs in method parameters and locals, which are per-thread because each thread has its own stack.
  6. Nothing mechanical — both register the class as a bean via component scan. @Service exists purely to tell human readers “business logic here.” (@Repository and @Controller add small real extras: exception translation and HTTP routing.)
  7. The package. Component scan starts at the @SpringBootApplication class’s package and walks only downward — a class beside or above the root is invisible. Then check the annotation is actually on the class.
  8. At startup — the context cannot construct either bean first, so it refuses to boot and prints the cycle. That’s a feature: you find the design flaw on your machine in seconds instead of through weird behavior in production.

Explain it out loud: Explain to an empty chair what happens between SpringApplication.run() and the “ready” log line — scan, bean definitions, dependency ordering, constructors — and then point at which part of that your ten-line Main was doing by hand. If you can’t connect the two, redo Build This step 5.

Still Unclear?

Copy-paste any of these into Claude — they deepen understanding, they don’t do the work:

I just wired Ledger, InMemoryFriendStore and ConsoleNotifier by hand in a
plain Java main. Interview me about WHY this beats new-ing dependencies
inside Ledger: testability, coupling, change ripple. Challenge weak answers.
Then ask me what exactly Spring automates here. Do not write code.
Explain constructor injection versus field @Autowired injection from three
angles: (1) a unit test trying to build the class, (2) the final keyword,
(3) a circular dependency. Use a tiny two-class example described in words,
not code, then quiz me on all three angles.
My Spring Boot app failed to start with a circular dependency error between
two services. Walk me through three redesign options - extract a third
class, pass data as a method parameter, publish an event - with trade-offs.
Do not mention @Lazy unless I ask, and then explain why it is a last resort.

Why AI Can’t Do This For You

AI writes flawless @Service classes on demand. But when your app prints “required a bean of type FriendStore that could not be found” at 9 p.m. before a demo, the cause lives in your package layout, your annotations, your refactor from an hour ago — context no prompt contains. The person who knows how component scan walks packages checks one path and fixes it in a minute. The person who doesn’t pastes errors into a chat window and hopes.

And the singleton-state bug is worse: generated code reviews clean, runs clean in dev, and corrupts real users’ data only under concurrent production load. AI cannot reproduce your traffic. The engineer who carries “one instance, all threads” in their head reads a service’s fields and spots the time bomb before it ships. That mental model is what this module installed — and it cannot be outsourced.

Module done? Add it to today’s tracker

Saves your progress on this device.