Spring Boot Done Right: Lessons from a 400-Module Codebase
medium.com
medium.com
When looking at problems, your mind becomes consumed with how to force everything into design patterns—like architectural separation, DI, or interface / implementation split. This causes developers to lose sight of the actual essence of the problem because they are obsessed with conforming to the framework.
Because the ecosystem and toolchain surrounding Spring Boot and Java are so mature and well-supported, it is very easy to find community tools that make you feel like you are doing things the "right way."
I only realized these issues after I left Spring Boot and Java development behind. Now, I much prefer using TypeScript or Python to write code (for example, web servers).
I also prefer using various SaaS solutions to handle authentication and user registration rather than rebuilding it all myself with Spring Boot Security. I honestly never want to go back to the days of writing Java again.
It also doesn't help that it's really easy to end up bringing in the entire framework for what you assumed were single function portions of the framework.
That said, it's the framework you probably want to use if you are doing Java. Just not if you are doing fast java.
I wouldn’t trade Java / Spring for the world. It forces a coherence, perhaps not perfectly, but it provides guidelines to follow.
I’ve been stuck in Node / TypeScript / Microservice hell for most of the last 15 years and I couldn’t hate it more. The open-source ecosystem is extremely lacking, even now, on something like a good XML library. I think we’ve got four different ones installed in our core platform each handling different needs (traversal, generation, manipulation, validation).
On top of it all, since there isn’t “one way” we’ve got several dozen micro services. There isn’t a single bit of consistency other than “Express.” Logging, authentication, routing, validation - it’s all completely up in the air. It robs us all of efficiency from the lack of common language and pattern. We spend more time refreshing ourselves on any given project than doing feature work or bug fixes.
Madness!
This has made my life much easier.
Not to defend Nodejs ecosystem - your points are 100% valid.
With that in mind, I think Go is much better option.
Worse still, is the variance and onboarding for each customer was taking roughly twice as long as the previous one... I left during the dev cycle to bring in the 4th client. These were state/county agencies, each with slightly differing requirements. I was in charge of the UI and the API for the UI... putting all the logic inside the DB itself was emphatically NOT my decision.
Looking at and having looked at some of their cloud spend etc, nah.
To be more specific there's a lot of fat. Whether that's HFT style high performance is a different story.
I've trended towards feature oriented hierarchies over type of thing hierarchies as I find they tend to be simpler, easier to replace over time and often much easier to maintain than separating DAL/BLL/SVC/VM/M/V layers etc. I really don't mind if my endpoint file sits next to my database access file, next to my validation file along with the test files for all of them in a single directory for a given feature. The feature is the concern, not the fact that you have a web endpoint, or need to talk to a database.
They're also easier to manage in a monolith and break apart as needed without having to replicate the jungle for want of a banana.
I've only ever had to debug spring DI once, I'll admit it usually works. But when it doesn't..... All those proxy objects will drive you nuts!
It's a very clever solution really.. And that's the problem! You don't want to build your servers on a clever foundation, you want a simple one!
Of course there are "batteries included" frameworks for NodeJS too, but they all seem to be unstable compared to Spring Boot.
In terms of ORMs, I actually avoid them... I like data adapters that make writing general SQL queries easier... for C# I tend to use Dapper... for JS/TS, I'll use a template string interpreter shim over the database adapter that returns Enumerable<T>?... very similar to my usage of Dapper.
Just about the simplest things that I can do to get things going, and generally in the simplest path forward. Today, generally speaking, hono, zod, openapi with a bit of hand-wiring as described above. At least for the backend, services, etc. With open-api configured, I can generate client adapters and relatively easily integrate with an OAuth provider of my choice (often AD/Entra in practice).
I will also usually create a self-signing JWT auth for dev/testing to make it easier to be "anyone" in any role for testing... where the release application is more restricted.
On the other hand, I hate Java Spring because I feel like I don't trust it - it doesn't let me look into and understand the internals easily, making me feel like I'm afloat on a pile of abstracts I'm not allowed to look down into.
Looking at some other projects enterprise js/ts codebases though, I see a lot of "I don't understand how this works so I'll try random things until it works". In that kind of environment, I can understand the attraction of Spring - it's not great, but it also won't be a flaming pile of unbaked abstractions.
OTOH, Spring Boot is a huge pile of various loosely coupled framework connectors, web, queues, security, databases etc. Some of them are of good quality, some are not so good. It is that uneven mix giving the perception that Spring is a mess.
what does this mean? you can single step into framework code
As far as digging into internal, how often do you look into Express or Rails internals or any other? For me it’s a rare day to need to do so.
Only time I recall having to read Spring source code was when I used their Social Media Auth library sometime in the early 2010s when it was quite a primitive experience.
i haven't used Spring in a decade but even back then Spring Security had support for all kinds of federated authentication/authorization providers and standards.
Like sibling comment mentioned you can integrate anything with Spring Boot. Its a high productivity framework to accomplish alot. Your frustrations speak more to the job you took rather than the framework they forced you to use.
I'm confused. Spring Security does provide you ways to create your own auth layer. However, you can use your existing identity provider or any external provider (there are starters for many providers).
The reality is that as a scripted environment, there are provisions to override dependencies for testability.... so unless you literally need multiple implementations of a given adapter, you don't need a DI/IoC framework and adding one only detracts from your overall solution. I'm a strong believer in that abstractions should mostly serve to hide relative complexity to make the rest of the application easier to reason with.
I'm also a big fan of the first version of anything being done in a scripted language with an emphasis on correct behavior. JS/TS and Python are more adaptable earlier on without committing to Java/C# or even Rust or Go. I understand a desire for homogeny, but that often can hold you back from creating something functional and easy to replace first.
Mediocrity will never not exist, and you unlock a lot of value by optimising for it.
Java is default since it's what taught to many college graduates with added bonus that's taught to most college grads in Indian subcontinent so outsourcing is much easier.
I would suggest that the rise of Python is very similar, as it's common in education circles. It's not even that Java as a language is particularly bad, it's how it is used in practice. Though I really do favor C# over Java, there's a similar stigma that comes from the community itself, not the language or it's baseline abilities. And this is a valid criticism.
Dependency injection is good. It makes it possible to test stuff.
Automagic wiring of dependencies based on annotations is bad and horrible.
If you want to do dependency injection, you should do it the way Go programs do it. Create the types you need in your main method and pass them into the constructors that need them.
When you write tests and you want to inject something else, then create something else and pass that in.
But the idea that you create magic containers and then decorate packages or classes or methods or fields somewhere and then stuff suddenly gets wired into something else via reflection magic is a maintenance nightmare. This is particularly true when some bean is missing, and the one guy who knows which random package out of hundreds has that bean in it is on vacation and the poor schmucks on his team have no clue why their stuff doesn't work.
"I added Spring Boot to our messy Java project."
"Now you have 3 problems."
The other issue is dynamic configuration. How do you handle replacing certain dependencies, e.g. for testing, or different runtime profiles? You could try to implement your own solution, but the more features you add, the closer you'd get to a custom DI framework. And then you'd have an actual mess, a naive non-standard solution for a solved problem, because you didn't want to read the manual for the standard implementation.
By the way, Spring dependency injection is mainly based on types. Annotations are not strictly necessary, you can interact with the Spring context in a procedural/functional manner, if you think that makes it better. You can also configure MVC (synchronous Servlet-based web) or Webflux (async web) routes functionally.
When a bean is missing, the app will fail to start, and you will get an error message explaning what's missing and which class depends on it. The easiest way to ensure this doesn't happen is to keep the empty @SpringBootTest test case that comes with the template. It doesn't have any assertions, but it will spin up a full Spring context, and fail if there is a configuration problem.
The only complicated part about Spring Boot is how the framework itself can be reconfigured through dependency injection. When you provide a certain "bean", this can affect the auto-configuration, so that other beans, which you might expect, are no longer automatically created. To debug this behavior, check out the relevant AutoConfiguration class (in your IDE, use the "go to class" shortcut and type something like FooAutoConfi..., e.g. JdbcAutoConfiguration).
In a good codebase, the configuration itself would be tested. For instance, if you did something a bit more complicated like connecting two JDBC databases at the same time, you would test that it read the configuration from the right sources and provides the expected beans.
The JVM doesn’t need this kind of thing either, and it gets a bad wrap from the J(2)EE days, and the “simple” replacement that Spring was supposed to be.
No doubt there’s some benefits to be had, but I don’t think the trade-offs are worth it, especially at larger scales.
And 5 minutes is a bit long, maybe someone is doing some database operations during start?
That said, I did catch up on my RSS feeds during that job.
Oh man, just wait till you start sprinkling some AOP in there. My debugger is damn near useless sometimes as I try to follow bizarre paths between parts of my code.
Now read up on all the dozen of annotations. But yeah, we did not want to "re-invent the wheel".
await using cn = await pool.connect();
const records = await cn.query<MyType>`
SELECT ...
FROM ...
WHERE ...
`;
for await (const record of records) {
...
}
Oh, spring is so much better... var records = jdbcClient
.sql("select * from posts")
.query(Post.class)
.toList();
records.forEach(p -> ...);Thankfully the company that wrote the article has a linter/warning product to help avoid those pitfalls.
Usage being high doesn't say anything about quality or suitability of a product specially in enterprise settings.
lots of people take meth - so are you gonna take meth too.
```@EnableConfigurationProperties(CasConfigurationProperties.class) @EnableScheduling @ConditionalOnFeatureEnabled(feature = CasFeatureModule.FeatureCatalog.SimpleMFA) @AutoConfiguration @Import({ CasSimpleMultifactorAuthenticationComponentSerializationConfiguration.class, CasSimpleMultifactorAuthenticationConfiguration.class, CasSimpleMultifactorAuthenticationEventExecutionPlanConfiguration.class, CasSimpleMultifactorAuthenticationMultifactorProviderBypassConfiguration.class, CasSimpleMultifactorAuthenticationRestConfiguration.class, CasSimpleMultifactorAuthenticationTicketCatalogConfiguration.class, CasSimpleMultifactorAuthenticationWebflowConfiguration.class }) public class CasSimpleMultifactorAuthenticationAutoConfiguration { }```
this is what we mean by "JavaCulture" - java | JVM really wonderful things but the culture is what sets these things back
someone can't just go in and understand things - they have to understand a bunch of notations too.
then if you work at a place and try do things differently the same people will tell you to revise your PR cz it ain't software engineering (or it won't scale)
this is what we mean by "JavaCulture" - java | JVM really wonderful things but the culture is what sets these things back
Maybe there are still some Java shops out there living like this. But to me, this is a 20 year old outdated notion. shrug
That said, I have in fact also had some outstanding and talented colleagues over the years as well. A point that's probably worth reflecting on, so thanks for the reminder.
I am however struggling to understand an over engineered Rust "micro" service at work. There's no language or framework that is free from this.
Maybe it's because I have not worked in typical enterprise companies?
Spring Boot can be a really quick way to get things done. My current job is not in enterprise, but we use Spring Boot for most of our services, and rarely have issues related to Spring itself. We have micro services handling many transactions per minute and starts up within 10 seconds. The CPU usage is low and decent memory usage, even on the small pod sizes that we use. There are ways to reduce the startup, but we do not have the need. The code is easy to reason about and new hires are productive within a short time.
it is completely declarative and lives in the best place, near your code, not on some other inaccessible repository.
what have you preferred? a json file? a yaml one?
I had them, and I find the annotations are more readable than other solutions.
I expected to see some new Spring 'tricks' in this post, but it's pretty much regular things that people might do in larger codebases.
These kinds of infrastructure systems its very common to replace a bunch of the built in functionality with your own classes, and so there's alot of effort put into supporting that use case.
I helped build a large open source enterprise financial systems, and most of the deploying orgs used CAS for authN. Both CAS and the financial system was built with similar approach to extensibility.
- Go for declarative Kotlin DSLs over annotation magic for most things. Much easier to debug. It's just function calls. And DSLs are nice in IDEs with autocomplete.
- Keep it simple. It's a huge framework. But you probably don't need most of it.
- Don't go Spring everything, a lot of stuff in Spring is a bit experimental (Spring AI/MCP stuff) or a bit bare bones (Spring Data, it's a limited and extra level of indirection you mostly shouldn't need) or flat out misguided/over-engineered (Spring Batch, good alternatives are available for that).
- Decide on synchronous or asynchronous IO. The former is a lot more scalable now that Java has green threads. The latter is relatively painless from Kotlin but an absolute PITA from Java. They are very different internally and both have their pros/cons. If you need async, Kotlin co-routines is the easier path to do that. Either way, it's one of the bigger decisions to take.
- Don't copy their way of deeply nested inheritance hierarchies. Very much in fashion 20 years ago; a bit of an anti pattern now. Internal code complexity is the part I like least about Spring. And it has some really byzantine stuff in there with 5-6 levels of inheritance or worse.
I think this is the main point. You can write good code in any language following this concept.
Just rewrote it in Go. Now we are using a server which consumes 30% of ram what the existing one used to and the latency and throughput have all improved.
Don't use these stupid java backend like sprinboot.
Just rewrite it in X doesn't "just work" for complex systems. It ignores risk, and the fact that design usually matters more than language.
it's useless abstractions for the sake of useless abstractions.
java was designed so that american architecs could write a few interfaces and cheap workforce overseas could implement the actual code.
and the EE stuff evolved in a way that features could be shipped just by adding a new component that would inject itself in the right places. java "engineers" have no idea how http or cookies work, but they know where to load the spring-auth bean in a 2mb maven config.
so, any rewrite from java is an exception to the rewrites are only good because you cleaned up old features rule.