1. Anti-patterns in Spring Core
An anti-pattern is not a “compilation error” and not “something that is forbidden by the specification.” It is like driving a car with the handbrake on: formally it moves, sometimes even fast, but the smell of burning and strange noises are guaranteed to appear. In Spring, anti-patterns are especially insidious because the framework is very forgiving: it allows a lot, and you will pay for that later — in maintenance and tests.
It is important to understand why we are having this conversation at all. By the end of the course, you already have a working mental model of the container and a working ContextFlow that demonstrates good practices. If we do not explicitly discuss “how it breaks” now, you will very quickly run into a real-world situation: the project works, releases go out, but any small refactoring causes a domino effect — and at some point the team starts being afraid to touch the code. This is not dramatization: it is literally the standard fate of a project where Spring has turned into magic.
To make it easier to navigate, let us fix a small “map of the terrain” of anti-patterns in a table. This is not a list of “ban everything,” but a set of signals: if you see a symptom, you know where to look.
| Anti-pattern | What it looks like in code | Why it is bad | Healthy alternative |
|---|---|---|---|
| Annotation soup | a class is overloaded with annotations, but its role is unclear | annotations start hiding the design instead of expressing it | separate roles: service separately, config separately, listeners separately |
| Hidden dependencies | dependencies are hidden in fields/static/getBean() | the class contract is impossible to understand and it is hard to test | constructor injection + explicit wiring |
| Service locator / static context | business code looks up a bean on its own | this is DI in reverse: IoC breaks | the container chooses dependencies at startup, not during the scenario |
| Mutable singleton | a singleton bean stores mutable state | “ghosts from the past,” flaky tests, non-obvious behavior | stateless services + short-lived objects/sessions |
| Overused SpEL | configuration turns into a string-based puzzle | hard to read and debug, late-runtime errors | properties + ordinary Java configuration code |
| Event soup | the main scenario is hidden in a chain of listeners | the execution flow becomes implicit | events only for side effects, orchestration — in direct code |
| Framework leakage | the domain and application layer “speak Spring” | business logic becomes dependent on the container | keep Spring-specific code in config/support |
2. Annotation soup
Annotation soup usually starts innocently: “well, I will add one more annotation so it works.” Then one more. Then another. And one day you open a class — and it looks as if the whole team dressed it up for a company party: everyone hung one ornament on it. The problem is not the number of annotations as such, but that the role of the class stops being clear.
The most unpleasant effect here is that annotations begin to replace architecture. Instead of honestly separating responsibilities (use-case service, configuration, infrastructure, listener, aspect), we create one “universal soldier” and try to close every hole with it. In ContextFlow, we built the structure domain / application / infrastructure / support / config precisely so that this would not happen.
Here is an example of a “Christmas tree,” where from the code it is no longer clear: is this a service? configuration? infrastructure? part of profiles? (the example is intentionally caricatured — like a meme, but such memes are usually based on real events):
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
@Service // the class is simultaneously declared as a service (use-case layer?)
@Configuration // ...and as configuration (wiring layer?) — roles are mixed
@Profile("demo") // ...and also tied to a profile, which makes the behavior non-obvious
public class OrderPlacementService {
// The class is intentionally empty here: the problem is exactly the mixing of roles by annotations.
}
The problem is not even that Spring “dislikes” such a combination. The problem is you, two weeks later: you will open the class and wonder why a use-case service suddenly became configuration and why it only lives in the demo profile. In the end, instead of an understandable scheme, you get “well, it just turned out that way historically.”
A healthy version looks more boring, but boredom here is a compliment. The service remains a service, and profiles and wiring live in configuration:
import org.springframework.stereotype.Service;
@Service // One role: use-case / application service
public class OrderPlacementService {
// use-case orchestration, no tricks
}
And profile-specific decisions belong where they fit: in config.profiles (or in @Bean methods with @Profile). This approach makes the project readable: you understand what changes when a profile switches, and where exactly it is described. And if you really want to “add one more annotation,” that is a good internal signal to stop and ask: “have I mixed roles?”
3. Hidden dependencies
Hidden dependencies are like hidden wiring in a wall: as long as you are not trying to drill a hole, everything looks fine. But as soon as refactoring, testing, or just moving logic into another module begins, you suddenly discover that the class cannot live without the container, and its real “contract shape” is hidden somewhere inside. Spring can inject dependencies in almost any way, but your job is not to hide them from the human.
The most classic form of hidden dependencies is field injection. We already discussed it earlier, and now we just record it as an anti-pattern that is very easy to pick up because it is short. Short — yes. Good — no.
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class NotificationDispatchService {
@Autowired
private NotificationSender sender; // The dependency is hidden in a field: the class contract is not visible from the outside.
// In tests, such an object is hard to create "like a normal person" without Spring/reflection.
}
Why does this feel bad in practice? Because from the class you cannot see what it needs to work. You cannot create the object “like a regular Java object” in a test without reflection, and the IDE does not help you understand which dependencies are mandatory. On top of that, there is a temptation to add new dependencies “quietly” — just one more field, and done.
The normal option for ContextFlow is constructor injection. It makes dependencies part of the public contract of the class:
import org.springframework.stereotype.Service;
@Service
public class NotificationDispatchService {
private final NotificationSender sender; // We explicitly fix the mandatory dependency.
public NotificationDispatchService(NotificationSender sender) {
// The constructor shows the contract: without sender, this service does not work.
this.sender = sender;
}
}
Yes, there are more lines here. But these are the lines that save you hours later. And what is nice: Spring works as predictably as possible in this style. If a dependency is missing, you fail at startup (fail-fast), not somewhere in the middle of a scenario after the user has already received half a result.
4. Service locator and static context
If constructor injection is “the class receives ready-made dependencies,” then service locator is “the class itself runs around and looks for what it needs.” In other words, DI flips back, and IoC turns into I-am-in-control (and yes, that sounds like the title of a toxic track). In Spring, this usually appears in two ways: a direct applicationContext.getBean(...) in business code or a static context holder so that the container can be accessed from anywhere.
Here is a typical “do not do this” example from a business class:
public class OrderPlacementService {
public void place(String message) {
// Business code itself reaches into the container: the dependency is not visible in the constructor.
NotificationSender sender =
StaticContextHolder.getBean(NotificationSender.class);
// Testing and reading such code hurts: you need a Spring context even for a simple scenario.
sender.send(message);
}
}
At first glance, this is convenient. At second glance, half the course breaks. You just made the dependency implicit, hid it from the constructor, and also tied the business logic to the container. Such code is hard to test, hard to read, and almost impossible to guarantee will behave the same across different profiles.
To make it completely clear how “static access” appears, here is a minimal holder example (it is easy to write in a minute, which is why it appears so often in real projects — and just as often becomes a problem):
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component;
@Component
public class StaticContextHolder implements ApplicationContextAware {
// Global state: write it once — and it lives "everywhere" and "forever".
private static ApplicationContext context;
public void setApplicationContext(ApplicationContext c) {
// The context is set during Spring initialization.
// In tests / multiple contexts this easily leads to overwriting and surprises.
context = c;
}
public static <T> T getBean(Class<T> type) {
// Effectively this is a Service Locator: any code can request a dependency "on the spot".
return context.getBean(type);
}
}
The problem here is not even “static” as such. The problem is that you get global state with an implicit lifetime. In tests, one context can overwrite another, different profiles start to conflict, and initialization order begins to matter. And the most unpleasant part: the code looks like “ordinary Java code,” but in fact it depends on whether Spring is even running.
The correct remedy is usually simple and boring: return to DI and make the dependency explicit. If you need to choose an implementation dynamically, that is also solved by DI tools (for example, @Qualifier, @Primary, Map<String, T> injection), not by searching the container at runtime.
5. Mutable singleton
A mutable singleton bean is when a service quietly remembers something between calls. Sometimes this is done “for convenience,” sometimes “for cache,” and sometimes simply because the class got a new ArrayList<>(), and nobody asked: “how long does this object live?” In Spring, by default, most beans are singleton scope, which means they live as long as the ApplicationContext lives. And that is usually the entire application run.
Here is a teaching example of a buffer that looks innocent but can easily become a trap:
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
@Service // Important to remember: by default this is a singleton for the entire ApplicationContext.
public class ReportBuffer {
// This is mutable state that will live between calls and tests.
private final List<String> lines = new ArrayList<>();
public void add(String line) {
// Every addition changes the singleton bean state.
lines.add(line);
}
public List<String> snapshot() {
// We return a copy safely, but the source of the problem remains: state accumulates.
return List.copyOf(lines);
}
}
If you inject such a buffer into ReportingService and call report generation several times in one run, you may get “ghosts from the past”: the second report contains lines from the first. In tests, this appears even more amusingly: test A adds something, test B suddenly sees that data and fails, and then you spend an hour looking for “why tests are flaky.” That is the feeling when code “sometimes works,” and you start suspecting the phase of the moon.
The healthy approach for ContextFlow is usually one of two. Either the state lives in method-local structures, meaning it is created on every call:
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
@Service
public class ReportingService {
public List<String> buildReportLines() {
// State is created for each call: nothing "sticks" between calls.
List<String> lines = new ArrayList<>();
lines.add("HEADER");
return List.copyOf(lines);
}
}
Or, if you need a session object (we already covered this in the scopes topic), you create a short-lived session and obtain it through a provider, instead of storing state in a singleton service:
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service;
@Service
public class ReportingService {
// Provider allows you to obtain a new instance of the generation "session" each time.
private final ObjectProvider<ReportGenerationSession> sessions;
public ReportingService(ObjectProvider<ReportGenerationSession> sessions) {
this.sessions = sessions;
}
public void generateDailyReport() {
// Important idea: state lives inside ReportGenerationSession, not inside the singleton service.
sessions.getObject().run();
}
}
The point is the same: singleton services in application.service should be stateless by default, otherwise the project becomes unpredictable. And yes, even in a non-web application. “But we have only one thread!” sounds convincing only until you start running several scenarios in sequence or running tests in parallel.
6. Overused SpEL
SpEL (Spring Expression Language) is a useful thing in small doses. Like chili pepper: sometimes it makes a dish better, but if you pour in half the jar, you are no longer eating soup, you are undergoing a challenge. The anti-pattern begins where SpEL is used as a “programming language inside strings,” and important decisions become unreadable and poorly testable.
Here is an example that looks “smart,” but in practice quickly turns into a puzzle:
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Component
public class ReportOutputSettings {
// The path calculation logic is hidden in a string:
// system properties, placeholders and concatenation are mixed together.
@Value("#{systemProperties['user.home'] + '/' + '${contextflow.app-name}' + '/reports'}")
private String outputDir;
}
Why is this bad? First, you mixed several sources at once: system properties and placeholders. Second, you hid the path calculation logic in a string that IDEs barely refactor and validate. Third, errors appear late: the string may be invalid, the key may be missing, user.home may be unexpected — and you only learn that at runtime.
For ContextFlow, a healthy alternative usually looks like this: let properties store the value, and do transformation and normalization in ordinary Java configuration code. For example, you store contextflow.report.output-dir as a string, and in config convert it to a Path:
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.nio.file.Path;
@Configuration
public class ReportingPathsConfig {
@Bean
Path reportOutputDir(@Value("${contextflow.report.output-dir}") String dir) {
// All the "logic" is now in ordinary code:
// easy to debug, test and refactor.
return Path.of(dir);
}
}
This is readable, debuggable, testable and refactors nicely. And most importantly: you are not creating a “magic string” whose meaning is clear only to the author (and only for the first two days). SpEL remains a tool to “help locally,” not a way to hide half of the configuration logic inside an annotation.
7. Event soup
Application events are a great thing, but they have one superpower: they are very easy to abuse. Event soup happens when you start turning everything into events, and at some point the application’s main scenario becomes unreadable without walking through ten listeners. It looks like a “flexible architecture,” and feels like “I am afraid to press Run without a debugger.”
To feel the difference, it is useful to see two diagrams. First — a healthy model for ContextFlow, where the use-case service does the main work, and events are only for side effects:
flowchart TD
A[OrderPlacementService.placeOrder] --> B[OrderStore.save]
A --> C[OrderPricingService.calculate]
A --> D[publish OrderCreatedEvent]
D --> E[Audit listener]
D --> F[Notification listener]
D --> G[Statistics listener]
And now — event soup, where the use-case service turns into a “mailbox,” and logic spreads across listeners:
flowchart TD
A[OrderPlacementService.placeOrder] --> D[publish CreateOrderCommand event]
D --> B["Listener #1: save order"]
D --> C["Listener #2: calculate price"]
D --> E["Listener #3: audit"]
D --> F["Listener #4: notify"]
On paper, “everything is modular.” In real life, you have lost the point where the scenario is actually controlled. It becomes especially unpleasant when someone adds another listener, changes @Order, adds condition, and everything starts depending on order and conditions.
Here is a caricatured but very recognizable code path that leads to event soup:
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
@Service
public class OrderPlacementService {
private final ApplicationEventPublisher publisher;
public OrderPlacementService(ApplicationEventPublisher publisher) {
// The dependency on publisher itself is fine.
this.publisher = publisher;
}
public void place(CreateOrderCommand cmd) {
// Bad signal: we publish a "command" as an event and expect listeners to do the main work.
publisher.publishEvent(cmd); // "let someone make the order"
}
}
The service no longer “places an order.” It “asks the universe to make an order.” And who exactly will do it, how exactly, and what happens if one listener fails — all of that stops being obvious.
The healthy version stays simple: the service performs the required steps (id, store, pricing, status), then publishes an event as a signal that “the order has been created” for side effects:
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
@Service
public class OrderPlacementService {
private final ApplicationEventPublisher publisher;
public OrderPlacementService(ApplicationEventPublisher publisher) {
this.publisher = publisher;
}
public void place(String orderId) {
// Mandatory orchestration goes here (save, price, status...)
// The event is a notification of fact: order created.
publisher.publishEvent(new OrderCreatedEvent(orderId));
}
}
A rule that works well in your head: an event is a “notification of fact,” not a “request to perform the main work”. This keeps the architecture from turning into mush.
8. Framework leakage
Framework leakage is when business code and the domain part start depending on Spring-specific things more strongly than on their own contracts. Usually this does not show up immediately, but as a series of “small conveniences”: here I will read Environment, here I will keep ApplicationContext, here I will implement Aware “for diagnostics,” and then suddenly it turns out that without the container you cannot even understand how the use-case works.
A classic leakage marker is Aware in the application/service layer. For example, a service suddenly starts implementing EnvironmentAware:
import org.springframework.context.EnvironmentAware;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Service;
@Service
public class NotificationDispatchService implements EnvironmentAware {
// Spring infrastructure "leaks" into the business service: a dependency on Environment appears.
private Environment env;
public void setEnvironment(Environment environment) {
// Now the service can potentially make runtime decisions based on the environment.
// This makes behavior less predictable and complicates testing.
this.env = environment;
}
}
Spring itself will swallow this just fine. But architecturally, you have made it so the service needs an “environment context,” which means it starts making runtime decisions inside business methods: which notification channel to choose, where to write, how to format... In the end, you return to the same problem you tried to avoid with profiles and configuration: variability “inside the code” instead of variability “in container assembly.”
In ContextFlow, we deliberately enforced discipline: business services do not implement Aware, and if a diagnostic bean is needed, it lives separately in support.diagnostics. And if a service needs configuration, it comes as an ordinary dependency or typed value, not as a “container control panel.”
A very similar leakage is trying to fix the self-invocation AOP problem with “tricks” instead of refactoring, for example by accessing the proxy from inside. We already discussed this in the AOP part: technically possible, but as a habit it turns into a “magic Spring style” that is hard to maintain. The course’s strong baseline principle remains: first fix the design, then apply Spring mechanics, not the other way around.
9. Diagnosing anti-patterns
When you see a project for the first time (or your own code a month later), you need to quickly understand whether Spring is being used as an engineering tool or as a set of spells. The good news: anti-patterns usually leave visible traces. The bad news: the brain quickly adapts to “well, it works.” So it is useful to have a few simple questions to ask the code.
The first question sounds almost childish: “If I open the class, will I understand what it needs to work?” If dependencies are not visible in the constructor, if there is getBean() inside, if there is some static holder somewhere — you are in hidden dependencies territory. The second question: “Can I read the main order creation scenario in one place?” If not, and you have to jump through listeners, aspects and post-processors to understand what really happens, it smells like event soup or excessive “magic.”
The third question: “Is this class about business or about Spring?” If it is business, but it implements Aware, pokes at Environment, reads resources directly, stores references to the context — that is leakage. The fourth question: “Where does variability live?” If it is described in configuration (profiles, different beans) — good. If variability is in if (profile == ...) inside the service — you have again done manual wiring, only in a more expensive wrapper.
To reinforce this, here is a short diagnostic table:
| Signal in code | Likely anti-pattern | What to usually do |
|---|---|---|
| @Autowired on a field | hidden dependencies | switch to constructor injection |
| ApplicationContext.getBean() in a service | service locator | pass the dependency through DI, use @Qualifier/Map |
| static context | static context access | remove the holder, rework wiring |
| List/Map in a singleton service as state | mutable singleton | move state to method-local or session/prototype |
| complex @Value("#{...}") | overused SpEL | move it to properties + Java config |
| events “do everything” | event soup | bring orchestration back into the service, keep events for side effects |
| Aware in application/domain | framework leakage | isolate in support.*, give services ordinary dependencies |
If you keep this table in mind, code review becomes easier: you do not argue “I like it / I do not like it,” you discuss observable signs and consequences. This is especially useful for junior level: instead of taste-based arguments, there is clear engineering logic.
10. Typical mistakes when working with Spring Core
At the end of the course, it is easy to fall into a trap: see an anti-pattern, nod “aha, got it,” and a week later repeat it in your own project because “it is faster.” So it is useful to record several typical mistakes specifically as behavior, not terms. This is not about “you must not do this,” but about the fact that doing this almost always ends the same way: the code becomes opaque, and fixes become expensive.
Mistake #1: treating an anti-pattern as a “rare case” that only bad developers make.
In practice, anti-patterns appear among normal people under time pressure: “we need a demo urgently,” “we are running late,” “we will fix it later.” The problem is that “later” often never comes, and the code has already become part of the system. This is cured by the habit of asking yourself two questions: are dependencies visible, and is the main scenario readable without jumping around?
Mistake #2: treating an architectural smell with one more annotation.
If a class is unclear, adding @Lazy, @DependsOn, @Order, conditions and other decorations rarely makes it clearer. Usually it is a signal that the class role is blurred or the responsibility boundaries have drifted. The fix is often boring: split the class in two, move wiring into configuration, remove the “universal soldier.”
Mistake #3: “allowing” service locator because “sometimes it is okay.”
Yes, there are cases where ObjectProvider is appropriate in infrastructure. But once you allow getBean() in business code, you get a style leak across the whole project. In a month, ApplicationContext will start appearing everywhere “for convenience,” and DI will turn into decoration. It is better to keep the boundary strict: business code does not look for dependencies, it receives them.
Mistake #4: storing state in a singleton bean because “we will just clean the list carefully.”
This is one of the most common causes of strange bugs and flaky tests. “We will clean it carefully” usually turns into “we forgot to clean it in one of the early returns.” In ContextFlow, we deliberately showed that state lives either in short-lived objects (session/prototype) or in local method variables. Singleton services are stateless, like Swiss banks (at least in theory).
Mistake #5: turning events into the main way to implement business logic.
Events are needed to move side effects away and reduce coupling. But if you made “order creation” an event-driven process rather than a fact, you lost orchestration and made the system implicit. Inside one application this is especially annoying: you could literally have written direct code, but instead you decided to make a quest for yourself.
GO TO FULL VERSION