DEV Community

Cover image for Bootstrapping the System, Part 1: Nothing Is a Bean Yet (Chapter 13)
Kamen
Kamen

Posted on Originally published at kamenivanov.substack.com

Bootstrapping the System, Part 1: Nothing Is a Bean Yet (Chapter 13)

Twelve chapters in, this project has a domain model, a persistence layer, a service layer, an exception handler and something close to a hundred tests, and yet there is no entry point to fire up everything.

There is nothing to start. No main method, no application context, no datasource. ProductsServiceImpl is a class with a constructor that takes two arguments, and the only place in the whole repository where those two arguments are ever supplied is a test. Spring has never seen any of it.

That was intentionally made that way, and this chapter is where it gets paid for. Every class written so far is a plain Java class: no @Service, no @Repository, no @Component on a single class in domain, dao-api, dao-impl or business-logic. The modules compile without Spring on the classpath, apart from the @Transactional annotations the service layer carries as declarative metadata. Assembling them into a running application is a job for exactly one module, and the first decision here is that it is the only module allowed to do it.

This first part is about the wiring: which module depends on what, where the beans are declared, and why the component scan is switched off in the one place it would have been most convenient. The settings those beans read, and where they come from, are the subject of Part 2.


The module that knows everything

Every other module in this project is ignorant, domain knows nothing, dao-api knows the domain, dao-impl knows dao-api and Hibernate, business-logic knows dao-api and nothing about how the data gets stored. None of them knows what the others are, and none of them can reach across to find out.

The application module is the exception, and it has to be, because something has to hold the wiring:

<dependencies>
    <dependency>
        <groupId>com.pe.advanced</groupId>
        <artifactId>business-logic</artifactId>
    </dependency>

    <dependency>
        <groupId>com.pe.advanced</groupId>
        <artifactId>dao-impl</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>

    <dependency>
        <groupId>org.flywaydb</groupId>
        <artifactId>flyway-mysql</artifactId>
    </dependency>

    <dependency>
        <groupId>com.mysql</groupId>
        <artifactId>mysql-connector-j</artifactId>
    </dependency>
</dependencies>
Enter fullscreen mode Exit fullscreen mode

Notice what this list says about the architecture. business-logic does not depend on dao-impl, so a service has no way to reach a JPA entity or a repository even by accident. The MySQL driver appears here and only here, which means the rest of the codebase could not name MySQL if it wanted to. spring-boot-starter-web is the entry that will matter most later, and it is here because the controllers will be here. That sounds like the ordinary arrangement and it is not quite, because the controllers are only half of the REST layer. The other half is a module of its own, holding the interfaces that declare the endpoints together with the OpenAPI annotations that document them, and its entire dependency list is the Swagger annotations and Jackson. No Spring, no servlet API, nothing that implies a server. The controllers in this module implement those interfaces, and Spring reads the mapping annotations through the implementation.

The reason for the split is who gets to depend on the contract. A module that compiles against two annotation libraries is something a client can take as a dependency and generate against, or implement against, without inheriting a web framework it has no use for. Collapse the two and the contract stops being publishable: anyone who wants to know what this service accepts has to depend on the thing that serves it. That module does not exist yet and neither do the controllers, which is why neither appears in this list.

The Spring Boot plugin produces the executable jar:

<plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    <configuration>
        <mainClass>com.pe.advanced.spring.AdvancedApplication</mainClass>
    </configuration>
    <executions>
        <execution>
            <goals>
                <goal>repackage</goal>
            </goals>
            <configuration>
                <classifier>exec</classifier>
            </configuration>
        </execution>
    </executions>
</plugin>
Enter fullscreen mode Exit fullscreen mode

The exec classifier matters more than it looks. Without it, the repackaged fat jar replaces the plain one, and anything that wanted the plain jar on a classpath no longer has it. With it, both exist: application-1.0.0-SNAPSHOT.jar for the build, application-1.0.0-SNAPSHOT-exec.jar for running. The Dockerfile that deploys this application copies the second one.

What the plugin deliberately does not carry is a profile. It is tempting to put <profiles><profile>dev</profile></profiles> there so mvn spring-boot:run works without arguments, and I have done it before. It bakes an environment into the build, which means the same command produces a dev application on a laptop and a dev application on CI, where nothing about dev is true. The profile belongs at startup, passed in by whoever starts it.


The entry point, and the component scan that scans nothing

Here is the whole application class:

@SpringBootApplication
@ComponentScan(excludeFilters = @ComponentScan.Filter(
        type = FilterType.REGEX,
        pattern = "com\\.pe\\.advanced\\.spring\\..*"
))
@Import({
        FlywayConfiguration.class,
        PersistenceConfiguration.class,
        ServiceWorkersConfiguration.class
})
public class AdvancedApplication {

    static void main(String[] args) {
        SpringApplication.run(AdvancedApplication.class, args);
    }
}
Enter fullscreen mode Exit fullscreen mode

The excludeFilters line is the one that needs explaining, because on first reading it looks like it cancels the thing directly above it.

@SpringBootApplication turns on component scanning from the package of the annotated class downwards. That would find PersistenceConfiguration, FlywayConfiguration and the rest, since they live in com.pe.advanced.spring.config, and register them automatically. The regex filter excludes exactly that subtree, so the scan finds nothing, and every configuration class comes in through the explicit @Import list instead.

The result is a list of three class names that is the complete, ordered inventory of what this application is made of. Add a configuration and it does not take effect until the name appears here. Remove one and the context loses it. Nothing joins the application because it happened to be annotated and happened to sit in a scanned package. The list is also the measure of how much of this application exists: three entries, and all three of them are in this chapter.

That is a trade. Component scanning exists to save exactly this typing, and in a single-module application it saves a great deal of it. Here the typing is the point, and a reader who opens this file sees the current shape of the system rather than having to infer it. It also means a configuration class can be written and committed before it is wired in, which is occasionally useful and never confusing.

But the stronger reason is one I learned the hard way. A component scan covers a package, and a package is not a module. The scanner walks the classpath, which is flat, so a scan rooted at com.pe.advanced finds every class in that package in every jar on the classpath, not just the ones in this repository. Share a base package across independently released artifacts, which every organisation with an internal shared library eventually does, and a dependency can contribute beans to this application without anybody choosing to let it.

The failure that produces is unusually unpleasant, because nothing breaks. The context starts, the tests pass, and the application is subtly wrong: a bean I did not write satisfying an injection point I did write, or a BeanPostProcessor from a library quietly wrapping things, or my own bean losing a conditional contest to one I had never heard of. Nothing in the repository mentions the class, so reading the source cannot find it, and the search eventually ends at a list of bean definition names and a long moment of not believing what is in it. Worse, it can arrive on a version bump: the dependency gains a @Configuration in a patch release, and the breaking change is in a jar nobody edited.

Boot's own autoconfiguration is a different thing and worth not confusing with this. It arrives through a declared file of imports, it backs off when a bean already exists, and it is documented and overridable. The accidental pickup has none of that discipline, because nobody intended it. With the scan excluded and an explicit @Import list, a dependency's configuration can only enter this context if someone writes its name on that list, which is a sentence a reviewer can disagree with.


Beans, declared by hand

With the scan disabled, nothing registers itself. Every bean is a method.

public class ServiceWorkersConfiguration {

    @Bean
    public ProductsService productsService(ProductDao productDao, CategoryAssignmentDao assignmentDao) {
        return new ProductsServiceImpl(productDao, assignmentDao);
    }

    @Bean
    public CategoriesService categoriesService(CategoryDao categoryDao, CategoryAssignmentDao assignmentDao) {
        return new CategoriesServiceImpl(categoryDao, assignmentDao);
    }

    @Bean
    public CategoryAssignmentsService categoryAssignmentsService(
            CategoryAssignmentDao assignmentDao,
            CategoryDao categoryDao,
            ProductDao productDao
    ) {
        return new CategoryAssignmentsServiceImpl(assignmentDao, categoryDao, productDao);
    }
}
Enter fullscreen mode Exit fullscreen mode

The class itself carries no @Configuration. It does not need it: @Import registers it as a configuration class, and the @Bean methods are proxied the same way. Dropping the annotation is a small thing, but it keeps the file from implying it could be picked up on its own.

What this costs is three lines per service instead of one annotation. What it buys is that ProductsServiceImpl stays a class anyone can instantiate. ProductsServiceDeleteTestCase does exactly that: new ProductsServiceImpl(dao, assignmentDao), no context, no annotations, no test slice. The moment the class carries @Service, the question of how to get one in a test stops having an obvious answer, and the twelve chapters spent keeping the business logic free of the container would have bought nothing.

The same applies to the DAO layer, with one addition. PersistenceConfiguration declares the DAO beans, and also tells Spring where the entities and repositories live:

@EnableJpaRepositories(
        basePackages = "com.pe.advanced.dao.impl",
        entityManagerFactoryRef = "entityManagerFactory"
)
@EntityScan(basePackages = "com.pe.advanced.dao.impl")
public class PersistenceConfiguration {

    @Bean
    @DependsOn("flyway")
    public AbstractEntityManagerFactoryBean entityManagerFactory(HikariDataSource dataSource) {
        final var properties = new Properties();
        properties.setProperty(JdbcSettings.DIALECT, MySQLDialect.class.getName());
        properties.setProperty(JdbcSettings.SHOW_SQL, "false");
        properties.setProperty(BatchSettings.STATEMENT_BATCH_SIZE, "50");

        final var em = new LocalContainerEntityManagerFactoryBean();
        em.setDataSource(dataSource);
        em.setJpaVendorAdapter(new HibernateJpaVendorAdapter());
        em.setPackagesToScan("com.pe.advanced.dao.impl");
        em.setJpaProperties(properties);
        return em;
    }

    @Bean
    public ProductDao productDao(ProductRepository productRepository, Validator validator) {
        return new ProductDaoImpl(productRepository, validator);
    }

    @Bean
    public CategoryDao categoryDao(CategoryRepository categoryRepository, Validator validator) {
        return new CategoryDaoImpl(categoryRepository, validator);
    }

    @Bean
    public CategoryAssignmentDao categoryAssignmentDao(CategoryAssignmentRepository repository) {
        return new CategoryAssignmentDaoImpl(repository);
    }
}
Enter fullscreen mode Exit fullscreen mode

@EntityScan and @EnableJpaRepositories both point at a package in another module. That is the whole trick, and it is worth saying plainly: the scanning configuration lives in the module doing the scanning, not in the module being scanned. dao-impl contains entities and repository interfaces and has no idea that anything is looking for them. Move the application elsewhere and dao-impl does not change.

The batch size is the loose end from Chapter 12. That chapter deferred hibernate.jdbc.batch_size with the words "belongs in the application configuration once that module gets one", and this is the module. Fifty is a starting number, not a measured one: enough that a two-hundred-product assignment becomes four round trips instead of two hundred, small enough that a single statement does not grow unreasonable.

The constants are worth a sentence of their own, because Hibernate moved them. AvailableSettings used to be one interface holding every property name in the framework, and since 6.2 it has been split into grouped interfaces that it now extends, which means AvailableSettings still resolves everything while the groups say where a setting belongs. DIALECT and SHOW_SQL live on JdbcSettings, and the batch size does not: it is on BatchSettings, alongside ORDER_INSERTS and ORDER_UPDATES. Reaching for JdbcSettings.STATEMENT_BATCH_SIZE because the property string begins with hibernate.jdbc is the obvious mistake, and it is a compile error rather than a silent one, which is the argument for using the constants instead of string literals in the first place.

Building the EntityManagerFactory by hand rather than letting Boot autoconfigure it deserves a note, because Boot would do this correctly without being asked. The reason is @DependsOn("flyway"). Hibernate needs the schema to exist before it validates against it, and bean creation order is the only thing standing between a clean start and a validation error on a fresh database. Declaring the factory explicitly is the simplest place to express that dependency.


Migrations that cannot be rewritten

Flyway gets its own configuration class, and almost every line in it is a decision:

public class FlywayConfiguration {

    @Bean
    public ClassicConfiguration flywayConfig(HikariDataSource dataSource) {
        final var config = new ClassicConfiguration();
        config.setDataSource(dataSource);
        config.setLocationsAsStrings("classpath:db/migrations");
        config.setConnectRetries(Integer.MAX_VALUE);
        config.setConnectRetriesInterval(10);
        config.setCleanDisabled(true);
        config.setOutOfOrder(false);
        config.setValidateOnMigrate(true);
        return config;
    }

    @Bean(initMethod = "migrate")
    public Flyway flyway(ClassicConfiguration flywayConfig) {
        return new Flyway(flywayConfig);
    }
}
Enter fullscreen mode Exit fullscreen mode

setCleanDisabled(true) is the line that matters most. Flyway's clean operation drops every object in the schema. It is a wonderful tool on a local database and a catastrophe anywhere else, and the distance between those two situations is one wrong value in one environment variable. It is off permanently, and the only way to clean a database in this project is to do it yourself, on purpose, outside the application.

There used to be a second line here, and the story of why it is gone is worth a short detour. Flyway once had a clean-on-validation-error option, which did exactly what it says: on finding that the applied migrations no longer matched the files, it responded by dropping the schema. The reasoning was that a developer with a broken local database wants it rebuilt, and the reasoning is fine right up to the first time it runs somewhere that is not a developer's laptop. I had written setCleanOnValidationError(false) in this class, and on moving to a current Spring Boot the method stopped existing, because Flyway deprecated it in 10.17 and removed it in 11. That is the rare and pleasant case of a library agreeing with you by deletion: the safe position stopped being something you have to remember to configure and became the only behaviour available. The version of this class that compiles today is shorter than the one I started with and says exactly the same thing.

setValidateOnMigrate(true) compares the checksum of every applied migration against the file on the classpath. Edit a migration that has already run and the application refuses to start. This is the behaviour people complain about until the first time it catches a migration that was changed after it had been applied to staging.

setOutOfOrder(false) refuses a migration with a version lower than the highest applied. Two developers writing V4__ on separate branches find out at merge time rather than when one of them silently never runs.

The two retry settings are a different kind of decision, and the reason they exist is not that the application and the database start together. They do not, they are separate containers in a compose file and, in production, the database is a separate deployment or a managed instance that has been running for months. The accurate statement is narrower and applies more often: nothing guarantees the database is reachable at the moment this process starts. Locally that is a compose run where MySQL is initialising its data directory while the application is already up. In production it is a rolling restart that lands during a failover, a network policy that has not finished applying, or a pod scheduled a moment before the route to the database exists.

Without retries, any of those produces the same outcome. The context fails, the process exits, the orchestrator restarts it, and because restart backoff compounds, a database that was forty seconds late turns into several minutes of a pod in CrashLoopBackOff. Retrying means the process stays up and waits, which is both faster and far easier to read in a log.

That has a real cost and it’s worth mentioning it. Retrying forever means the application never fails with an exit code anyone can see. It sits there, starting, indefinitely. What makes that acceptable rather than a hang is the readiness probe configured in Part 3: the process reports itself not ready, no traffic is routed to it, and the health endpoint says which component is failing. Without a probe, infinite retries is a hang nobody is measuring, and a startup probe with a deadline is the thing that puts a ceiling on it. The decision to retry forever is really a decision to move the timeout out of Flyway and into the orchestrator, where it belongs, and it is only correct if the orchestrator is actually holding it.

setConnectRetriesInterval(10) is the companion that is easy to miss. The interval is a cap on a backoff rather than a fixed wait, and its default cap is a hundred and twenty seconds, so a database that takes a minute to come up can cost considerably more than a minute of waiting while the interval grows past it. Ten seconds keeps the retries frequent enough that the application starts roughly when the database does, which is the behaviour the local compose run needs and the one a rolling restart benefits from too.

What this class configures and what it actually runs are two different subjects, and only the first one belongs here. The migration itself turned out to carry enough decisions of its own to need a chapter, so the SQL, the column types and the constraints three earlier chapters have been relying on are the one after this.


What Part 1 bought

The application module now has a dependency list that reads as an architecture diagram, an entry point whose @Import block is the complete inventory of what the context contains, three services and three DAOs declared as methods rather than discovered by annotation, and a Flyway bean that will refuse to start the application rather than guess.

What it does not have is a single setting. The datasource that PersistenceConfiguration and FlywayConfiguration both take as a parameter does not exist yet, because a datasource needs a URL, a username and a password, and none of those three belong in a file that is in git. That is the subject of Part 2: four places a setting can come from, each answering a different question, and the shell script that turns a container's environment into something a JVM will accept.


What's Next?

The application has every bean it needs and still cannot start. PersistenceConfiguration and FlywayConfiguration both take a HikariDataSource as a parameter, nothing in the repository declares one, and a datasource needs a URL, a username and a password, none of which can be committed.

Part 2 gives each of those a home, chosen by what the setting is true of rather than by what is convenient: identical everywhere, true of an environment, true of one machine, true of a container. It also reads the datasource block line by line, which turns out to be the densest file in the project and to contain two settings that were doing nothing at all.

Codebase & Architecture Blueprint

The entire evolutionary architecture of this project is tracked using strict Git tags. To clone the repository and switch exactly to the state established in Chapter 13, Part 1, use the following link:

Note: All core modules are configured with strict compilation-level boundaries. Compile and run mvn clean install to see the structure in action. Maven version 3.9.* and Java 25 are required.

◀️ Read Chapter 12: The Domain Type That Doesn't Fit: Many-to-Many Links and Bulk Operations
▶️ Read Chapter 13 Part 2: Bootstrapping the System, Where the Settings Come From (Coming soon)


Join the Masterclass Journey

This article is part of an ongoing weekly series. If you want to receive every upcoming chapter directly in your inbox, alongside deep-dives, infrastructure architecture diagrams, and premium insights before anyone else, subscribe to my Substack Newsletter here!


Recommended Resources for Java & Spring Boot Engineers

Disclosure: I earn a commission if you purchase through the links below, at no additional cost to you.

If you are preparing for Senior/Lead Java interviews or looking to solidify your Spring Boot & Architecture skills, check out these highly-rated resources from the Javarevisited publication (Use promo code friends20 for an exclusive 20% discount automatically applied at checkout):

Top comments (0)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.