1. Anti-pattern: the project starts, but is bad
An anti-pattern is not “code that is not pretty” and not “I would rename the variable differently.” It is a repeated solution that first gives you a sense of progress and then systematically makes the project less explainable, less predictable, and more fragile. Spring Boot has a strong side — sensible defaults, auto-configuration, convenient project setup. But these very conveniences are easy to ruin with bad habits.
In this course, we kept one thought in mind: catalog-service is a Boot template, that is, a small but correct service template. For a template, what matters most is not the number of features, but the fact that it can be safely extended. Anti-patterns break exactly that: extensibility and explainability. And the unpleasant part is that an anti-pattern almost always looks like a “quick fix”.
To catch this not at the level of “I do not like it,” it is convenient to look at early signals. A signal is not a compilation error. It is the feeling that you are starting to make unnecessary “detours”: you enabled a strange setting, added @Enable..., wrote allow-bean-definition-overriding, scattered @Value across the project because “it is faster.” These are almost always the first cracks.
Below we will look at several anti-patterns that are especially common in junior-level Boot projects, and see how they look in our catalog-service.
2. God-config: one @Configuration for everything
There is an almost magical power in a file called AppConfig, ApplicationConfig, MainConfiguration, and inside it — “everything that has nowhere else to go.” In the first days it even seems convenient: one class, one import, one source of truth. And after a couple of weeks it turns into a “miscellaneous drawer”, where MVC configuration sits next to a Jackson bean, Actuator settings, custom formatters, some Clock, a RestTemplate “for later”, and, of course, the incantation “do not delete it, the app will not start without it.”
The early signal of god-config is usually very simple: to understand what kind of service you are writing, you have to read one huge configuration class instead of seeing small thematic pieces. The second signal is that dependencies become implicit: you do not see what the web layer really needs, what belongs to startup diagnostics, and what belongs to observability. As a result, the DI graph gets “flattened” into one pile, and any change feels dangerous.
Let us see what “the start of a bad road” looks like (the example is intentionally short, but the idea is visible):
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class ApplicationConfiguration {
@Bean
public WebMvcConfigurer mvcConfigurer() {
// IMPORTANT: this is already the "entry point" for starting to put everything into one class.
// Today it is formatters, tomorrow it is Jackson, Actuator, Clock, and "one more small bean."
return registry -> { /* formatters and converters */ };
}
@Bean
public Object startupSummaryRunner() {
// Bad signal: a "temporary patch" that then stays forever.
return new Object(); // and so on...
}
}
The funny thing is that such a class almost always appears “temporarily.” And as we know, there is nothing more permanent than the temporary.
A healthier approach for a Boot project is small thematic configuration classes. Not because “it is trendy,” but because it keeps boundaries intact. In our catalog-service, this is literally part of the project architecture: WebConfiguration, ActuatorConfiguration, StartupConfiguration.
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfiguration implements WebMvcConfigurer {
// Only web customization (MVC) here, and nothing else: not Jackson, not Actuator, not "startup helpers."
}
The idea here is that Boot gives you most of the infrastructure out of the box, and your configuration classes should not be a “replacement for Boot,” but thin layers on top of the defaults. This also helps a lot with debugging: if the problem is in MVC, you open WebConfiguration, not 900 lines of “everything.”
Sometimes even a simple diagram helps the brain stop throwing everything into one container:
flowchart TD
A["CatalogServiceApplication"] --> C["Spring Context"]
C --> W["WebConfiguration (MVC)"]
C --> S["StartupConfiguration (startup hooks)"]
C --> AC["ActuatorConfiguration (Actuator)"]
C --> CAT["catalog/* (domain, service, repository)"]
If your project has grown a god-config, that almost always means you have started building your own little framework on top of Boot, without even noticing it. And Boot, unlike people, does not take offense — it just stops helping.
3. @Value everywhere
@Value is not evil. Evil is the massive and chaotic use of @Value, when configuration “dissolves” across the project and turns into a set of string keys that are not collected anywhere into a clear model. A beginner thinks this is the fast path: “I need a number — here, I injected it.” But after a week you have three different keys describing the same thing, two different default values, and no meaningful validation.
The early signal here is also very down-to-earth: you cannot answer the question “what settings does the application actually have?” without searching the project for @Value("${...}"). Another signal is that you start being afraid to rename a key because “it is definitely used somewhere.”
The bad style looks roughly like this:
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@Service
public class FeaturedCoursesService {
// Bad signal: the string key is scattered through the code, the IDE helps only a little, renaming is dangerous.
@Value("${app.catalog.max-featured-count}")
private int maxFeaturedCount;
// Bad signal: the default value is hidden in the annotation, and you cannot see it as part of the "configuration model."
@Value("${app.catalog.default-published-only:true}")
private boolean publishedOnly;
}
There are several problems here at once, and they accumulate in an unpleasant way. Keys are strings. Default values are scattered through the code. Validation is almost nonexistent (if max-featured-count=-100, the application may “work,” but in a strange way). And this is field injection, which we deliberately avoided in the course.
The Boot-friendly path is @ConfigurationProperties with typed binding. In catalog-service, we have CatalogProperties as the central model for user configuration app.catalog.*. Then the service depends not on string keys, but on one clear object.
import org.springframework.stereotype.Service;
@Service
public class FeaturedCoursesService {
private final CatalogProperties properties;
public FeaturedCoursesService(CatalogProperties properties) {
// Good signal: the dependency is single and typed, not a set of string keys.
this.properties = properties;
}
public int featuredLimit() {
// Good signal: a single place where the value "lives" and (usually) its validation too.
return properties.maxFeaturedCount();
}
}
And here the “engineering bonus” suddenly kicks in. First, you get a normal validation point (and we did this earlier with Bean Validation on configuration). Second, the IDE can suggest properties through metadata generation. Third, you can test binding and overrides centrally instead of chasing bugs like “why is the limit suddenly 0 in production.”
An important psychological point: @ConfigurationProperties is not “more convenient,” it is more architecturally honest. You acknowledge that configuration is a separate layer, and it has a model, just as the domain has CourseCard.
4. Accidental @EnableWebMvc: the Boot MVC switch
There are annotations that work like a “small tuning.” And there are those that work like a switch. @EnableWebMvc is a classic switch. A beginner often adds it thinking, “I just need to configure MVC a little.” But in fact it says: “Boot, thanks, I’ll take it from here,” and switches you into manual MVC configuration mode. After that things start happening: some default settings disappear, static resources unexpectedly break, message converters behave differently... and the magic begins.
The early signal usually sounds like this: “After I added @EnableWebMvc, it started working... but I do not understand why.” Spoiler: it did not “start working”; it changed mode, and you have just not met the consequences yet.
The bad example is exactly the one you want to write “on internet advice”:
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
@Configuration
@EnableWebMvc // IMPORTANT: this is not "configure it a little", it switches Boot MVC into manual mode.
public class WebConfiguration {
}
If you only need to add converters/formatters or tweak something in a targeted way, the Boot-friendly path is WebMvcConfigurer. It is designed exactly for gentle customization without breaking the defaults.
import org.springframework.context.annotation.Configuration;
import org.springframework.format.FormatterRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfiguration implements WebMvcConfigurer {
@Override
public void addFormatters(FormatterRegistry registry) {
// IMPORTANT: this is "soft" customization — you add your own without throwing away Boot defaults.
// targeted query params tuning: track, level, launchedAfter...
}
}
The difference in meaning here is like between “screw on a shelf” and “build the house again because the door creaks.” @EnableWebMvc is rebuilding the house.
5. Profile chaos: configuration as a forest
Profiles and YAML configuration are one of the main bonuses of Spring Boot. But precisely because of that, they are very easy to turn into chaos. What does chaos look like? You have application.yaml, application-dev.yaml, application-local.yaml, somewhere else there is multi-document YAML, another import, another external override... and at some point nobody can confidently say: “what is the final value of app.catalog.max-featured-count in dev?”
The early signal of profile chaos is when you start “fixing” a problem by copying the same key into different files. The second signal is that you add properties “just in case” to a profile, even though it is a shared setting. The third signal is that you mix YAML and .properties because “that article used properties here.”
Here is a mini example of how chaos starts to grow: values of the same property appear in several places, and you no longer understand which one wins.
# application.yaml
app:
catalog:
# Base: a common default for all environments.
max-featured-count: 4
# application-dev.yaml
app:
catalog:
# Profile: changed only because dev differs from the base.
max-featured-count: 10
# somewhere else in multi-document YAML...
spring:
config:
activate:
on-profile: dev
app:
catalog:
# Bad signal: a third place where the same property is overridden.
max-featured-count: 7
Yes, all of this may “work.” And that is exactly why it is dangerous: the error is not loud, it quietly creates unpredictability.
The healthy profile strategy in our course was simple: the base application.yaml contains common values, profile files contain minimal differences, and catalog data (the course list) lives separately and is connected via spring.config.import.
To make the brain’s job easier, it is useful to keep a simple responsibility table:
| Configuration layer | Example file | Idea | What to put there |
|---|---|---|---|
| Base | application.yaml | shared base | application name, default flags, common baseline |
| Profile | application-local.yaml | environment difference | log levels, actuator exposure, management port |
| Import | catalog-data.yaml | separate "data unit" | course list, if it is large |
| External override | ./config/catalog-extra.yaml | external layer | local experiments/overrides without rebuilding |
This is not a “mandatory world schema,” but it makes configuration predictable. And that word matters most of all: predictability. Configuration is not the place for a quest called “find where it was overridden.”
Another very characteristic anti-pattern inside profile chaos is when profiles start being used as a replacement for feature flags. “Let us turn on this feature in the dev2 profile and that one in dev3...” and suddenly you get nine “environments” in one project. In a small service, this is almost always a loss: complexity grows faster than the benefit.
6. Versions and exclusions by hand
The next classic anti-pattern is random Boot customization “by inspiration.” Like: “I think there is an extra auto-configuration here — I’ll turn it off,” “I think Jackson should be updated — I’ll set the version manually,” “I think there are too many dependencies — I’ll exclude half of them.” In the end, you lose Boot’s main advantage: platform consistency.
The early signal is when you start adding exclude and manual versions without a clear reason that you can explain in two sentences. The second signal is when you enable properties like spring.main.allow-bean-definition-overriding=true because “otherwise it complains.” It is like taping over the “Check Engine” light: the car will drive, but it is too early to celebrate.
Here is what “manual version where it is not needed” looks like, using Gradle as an example:
dependencies {
implementation("org.springframework.boot:spring-boot-starter-webmvc")
// Bad signal: the version is set manually, although this library is already aligned by the Boot BOM.
implementation("tools.jackson.core:jackson-databind:3.0.4") // manual pin managed dependency
}
In the Boot course, we tried to follow this rule: if a dependency is managed by the Boot BOM, do not set the version manually. In part because otherwise you may accidentally assemble an incompatible combination. The normal version:
dependencies {
implementation("org.springframework.boot:spring-boot-starter-webmvc")
// Good signal: Jackson versions come through the Boot BOM and are aligned with each other.
}
The same story with exclusions. Excluding an auto-configuration is not “tuning,” it is “I am taking responsibility.” Sometimes that is justified, but more often for a beginner it is a side effect of not understanding why Boot did it this way and not otherwise. If you catch yourself wanting to “turn off everything extra,” it is better to stop and ask: “Do I really understand what is extra? Or do I just not know where it came from?”
7. Fat controller: the power of the web layer
A controller in a Boot service is a boundary. It should accept a request, pass parameters along carefully, and return the result. When the controller becomes the place for filtering, sorting, business logic, configuration reading, and “just a little more — and the repository too,” it almost always means the layer boundaries have started to spread out.
The early signal is if you open a controller and see a “mini application” there: lots of stream(), lots of if, lots of date handling, a bunch of logic like “if limit is not set, take the default,” and all of this lives next to @GetMapping. The next signal is that the controller starts depending on too many things: properties, repository, some helper classes, “so we do not need a service.”
A bad example (shortened, but recognizable):
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class CourseCatalogController {
@GetMapping("/api/catalog/featured")
public Object featured() {
// Bad signal: inside the method lives a "mini application", not a thin web-layer boundary.
// filtering, limits, business logic — everything is here
return null;
}
}
The good option is a thin controller, logic in a service, and data coming from a repository. And yes, you can have normal layers even “in memory” — we did that throughout the course.
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class CourseCatalogController {
private final CourseCatalogService service;
public CourseCatalogController(CourseCatalogService service) {
// Good signal: the controller depends on the service, not on "everything at once."
this.service = service;
}
@GetMapping("/api/catalog/featured")
public Object featured() {
// Good signal: delegating application logic to the service.
return service.findFeatured();
}
}
What matters here is not so much the return type (our project is read-only, the DTO layer is optional), but the boundary of responsibility. When the boundary exists, the project is easier to extend. When there is no boundary, the project starts to drift, even if it still starts.
8. Logs and Actuator without harm
Logs and Actuator are a huge plus in a Boot project. But, like any plus, they can be turned into a minus. The most common scenario for beginners: either there are no logs at all (only System.out.println), or there are too many logs and everything is ERROR, or sensitive values get into the logs (and then someone is very surprised). Actuator has a similar story: in local you want to “open everything,” and sometimes that accidentally reaches prod because profiles are scattered.
The early signal of noisy logging is when you cannot find a useful signal in the logs: everything is either equally important or equally noisy. Another signal is that you log entire configuration objects “for debugging,” and suddenly something appears there that should not be in logs. The early Actuator signal is simple: you see include: "*" and say, “that is convenient.”
A bad exposure variant (especially if it is in application-prod.yaml or “accidentally made it into base”):
management:
endpoints:
web:
exposure:
# Bad signal: you are opening all endpoints and almost certainly exposing too much.
include: "*"
A healthier baseline is a minimal and safe set (and expansion only where it is truly needed and tightly controlled by profile):
management:
endpoints:
web:
exposure:
# Good baseline: minimally necessary, without accidental "everything out."
include: "health,info"
And the same with logs: instead of System.out.println and random messages, use a proper Logger, and write in a way that is useful but does not leak extra details.
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class StartupSummaryRunnerSupport {
private static final Logger log =
LoggerFactory.getLogger(StartupSummaryRunnerSupport.class);
public static void logTitle(String title) {
// IMPORTANT: log a useful fact, but do not dump the whole config/context "for debugging."
log.info("Catalog title: {}", title);
}
}
By itself this is not rocket science. The main thing is to remember that the observability layer should increase the explainability of the service, not create a new headache.
Red flag: the “works in the IDE” criterion
There is a separate type of anti-pattern that is not about annotations or YAML, but about thinking: if the project is checked only by running it from the IDE, it can very easily remain “half-working.” In the Boot world this is especially important, because running as a jar, running with external config, and running with different profiles are not exotic cases, but normal service life. And if you do not test this, errors arrive “unexpectedly,” although in reality they were just waiting for their time.
The early signals here are usually everyday ones: the project has no minimal smoke tests, or they do not run; the configuration “assumes the IDE” (for example, paths that exist only in the project but will not be in the jar artifact); DevTools masks problems with restarts; you cannot quickly and honestly explain to another person how to start the service from the terminal. Important: this is not a reason to turn the course into infrastructure hell, it is just a red flag that the template is not mature yet.
9. Typical mistakes when fighting anti-patterns
Mistake #1: treating symptoms with "magic" settings instead of the cause.
The classic move is to enable spring.main.allow-bean-definition-overriding=true, add @EnableWebMvc, disable auto-configuration with exclude, “because that makes it start.” This often makes the project launch, but it makes it less explainable. It is better to stop and understand why the conflict appeared, rather than smear it over.
Mistake #2: thinking that a "small project" does not need structure.
It is exactly in a small project that the structure should be especially explicit, otherwise it quickly turns into one package with five classes that know everything about everyone. catalog-service is small, but it is a template. And a template without structure is not a template, but a random draft.
Mistake #3: confusing “faster now” with “cheaper forever.”
@Value in one place is indeed faster than @ConfigurationProperties. But @Value in twenty places turns into configuration dust that cannot be properly validated and tested. Boot style is almost always about setting up the configuration layer correctly once.
Mistake #4: putting all infrastructure into one config class for the sake of control.
God-config creates the illusion that you “have everything under control.” In fact, you lose locality of change and mix responsibilities. Thematic configuration classes seem like “extra files” right up until the moment you need to change something without touching everything else.
Mistake #5: turning controllers into the place where business logic lives.
A fat controller almost always means you no longer see the boundary between the web layer and the application logic. This makes testing, extension, and even reading the project harder. In our course we deliberately kept the controller thin, and this habit transfers very well to “real” services.
GO TO FULL VERSION