Part 1 left the application with a complete set of beans and no way to start. Both PersistenceConfiguration and FlywayConfiguration take a HikariDataSource as a parameter, nothing declares one, and a datasource needs three things that cannot be committed to a repository: a URL, a username and a password.
The lazy fix is a local.properties with all three in it, gitignored, and a note in the README. That works on a laptop and nowhere else. The application also has to start in a container, where there is no file to read, and in whatever CI eventually runs it, where neither the file nor the container's environment looks like either of the first two. One of those three contexts will be wrong whichever one gets to be the default.
So the question is not where the settings live. It is how many different questions a setting can be answering, and giving each of those questions its own place.
Properties, and the four places they come from
This project uses .properties rather than .yml, and the reason is unglamorous: one setting per line, greppable, and the key is visible in full at every line. In yml, finding out whether spring.datasource.hikari.maximum-pool-size is set means reading an indented tree and assembling the path in your head. With properties, searching for the string finds it. Blocks of related settings sit next to each other and read like a table. Yml wins on nesting and on lists, and if this project grows a configuration section that is genuinely a list of objects, that section will be worth moving. Until then the flat file is easier to scan. Configuration arrives at the running application from four places, and each one answers a different question.
application.properties holds what is true everywhere. The context path, the server port, the connection pool sizing, the JPA settings, the actuator exposure. If a setting would be identical on a laptop, in staging and in production, it lives here and nowhere else.
application-dev.properties holds what is true of an environment. Log levels verbose enough to be useful while developing, and whatever else differs between dev, staging and production as a matter of what the environment is for. This is what a profile is: a description of an environment, shared by everyone who deploys to it.
local.properties holds what is true of one machine. The database URL, the username, the password. It is in .gitignore, it is passed as an additional config location when running the application, and it never appears in the repository. A profile would be the wrong tool here, because a profile named after a developer is in git by definition, and the moment two people have one, application-dev.properties describes nobody.
What the repository does carry is local.properties.example, with the three keys and values that match the compose file from Part 3, and a first line saying to copy it. That distinction is worth making explicitly, because the tempting shortcut is to commit the real file on the grounds that root against localhost is not a secret. It is not a secret, and committing it is still wrong, for a duller reason than security: the moment the file is in git, every developer's machine-specific values are in everyone's working copy, and the first person who changes a port has changed it for everybody. The example file is the shape of the thing, the thing itself belongs to whoever is running it.
Environment variables hold what is true of a container, and this is the place that gets conflated with the other three most often. A container has no file to read. Whatever is true of a particular deployment arrives through its environment, which makes the name of the variable part of the contract between this application and whatever starts it: a compose file, a deployment manifest, a CI job. Part 3 is where that contract gets written, in a script that translates those names into something a JVM will accept and refuses to start when one of them is missing.
The datasource block, line by line
The largest block in application.properties is the datasource, and it is worth reading one line at a time, because very little of it is boilerplate:
spring.jpa.open-in-view=false
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.hikari.pool-name=advanced-connection-pool
spring.datasource.hikari.initialization-fail-timeout=-1
spring.datasource.hikari.data-source-properties.useServerPrepStmts=true
spring.datasource.hikari.data-source-properties.cachePrepStmts=true
spring.datasource.hikari.data-source-properties.prepStmtCacheSize=250
spring.datasource.hikari.data-source-properties.prepStmtCacheSqlLimit=2048
spring.datasource.hikari.maximum-pool-size=${DB_POOL_SIZE:25}
spring.datasource.hikari.minimum-idle=${DB_POOL_SIZE:25}
spring.datasource.hikari.max-lifetime=1800000
spring.datasource.hikari.connection-timeout=5000
open-in-view=false disables the filter that holds a Hibernate session open for the duration of the whole HTTP request. Boot enables it by default and logs a warning about it on every single start, which is an unusually direct admission that the default is wrong. With the filter on, a lazy association can be loaded during JSON serialization, long after the service method has returned, which means a query firing from the web layer that nothing in the service layer ever asked for. This project is partly protected from that by its own structure, since the domain module contains no entities and the transformers in dao-impl copy what they need while the session is still open. Turning the filter off converts the remaining risk from a silent extra query into a visible failure, which is the version I would rather debug.
The driver class is redundant, strictly speaking, because Boot infers it from the JDBC URL. I keep it because the URL is not in this file at all: it arrives from local.properties on a laptop and from an environment variable in a container, which leaves the driver line as the only place in the committed configuration that states which database this application talks to. pool-name earns its line the same way, by being visible later: it appears in HikariCP's log output and in the names of its threads, so a thread dump taken during an incident says which pool a stuck thread was waiting on.
initialization-fail-timeout=-1 tells Hikari not to fail pool creation when the database is unreachable at startup. It belongs to the same decision as Flyway's infinite connect retries from earlier in this chapter: in a container the application and the database start together, the database is slower, and waiting is better than crash-looping while the orchestrator restarts a process that was never broken. It is worth being precise about what this does not do. It does not hide a failure, the application starts, the readiness probe reports it as not ready because the database health indicator is failing, and the orchestrator keeps traffic away from it. The failure is still visible, just in the place designed to report it.
The four prepared statement properties are a single setting wearing four names, and the ordering matters. MySQL's client-side statement cache does nothing on its own - cachePrepStmts without useServerPrepStmts caches objects that still send the full SQL text on every execution. The pair together moves parsing to the server once per statement shape and then reuses it, which is worth having in a system where the same handful of queries run constantly. The two numbers come from HikariCP's own MySQL guidance rather than from a measurement of this project, and I am stating that rather than implying otherwise.
The pool is deliberately fixed, with maximum and minimum set to the same value from the same variable. Pool resizing is cheap until the moment it is not. A spike arrives, the pool grows, and every new connection costs a TCP connect, a MySQL handshake and an authentication round trip, all of it paid inside the request unlucky enough to have triggered the growth. Fixing the size pays that cost once at startup instead. The trade-off is real and in the other direction: twenty-five connections are held open by an application doing nothing, and on a database shared by several services, connections are the scarce resource rather than memory. Pulling both numbers from one variable means an environment can scale the pool down without accidentally making it dynamic again.
Strictly speaking the second line restates a default. Leaving minimumIdle unset is what HikariCP's own documentation recommends, and an unset minimum already equals the maximum. I set it anyway, for two reasons that are about reading rather than behaviour: one variable visibly driving both numbers is what makes the fixed pool obvious to someone scanning the file, and an environment that wants a smaller pool should not have to know which of the two settings is the one that matters. That is a judgement call rather than a rule, and the next paragraph is about a line where the same judgement goes the other way.
What is not in that block is idle-timeout, and the reason is not that obvious - Hikari only retires idle connections down to minimumIdle, so when minimum and maximum are equal there is nothing for an idle timeout to act on and the setting is ignored entirely. It had been sitting in this file looking exactly like tuning, left over from a time when the pool was allowed to shrink. A configuration file nobody reads end to end accumulates these: lines that were correct under an earlier decision and became decoration under a later one, while still reading as deliberate to the next person who opens the file.
max-lifetime is the one that does the work the idle timeout appeared to be doing. Thirty minutes retires every connection regardless of whether it was busy, which keeps the pool ahead of MySQL's own wait_timeout and ahead of the load balancers and firewalls that silently drop long-lived TCP connections. A connection the pool believes is alive and the network has already discarded is the source of the stale-connection errors that only ever show up after a quiet period.
connection-timeout=5000 is a decision about failure shape. The default is thirty seconds, which means that under contention a thread waits half a minute for a connection before giving up, and the symptom a user sees is a request that eventually succeeds very slowly. Five seconds turns the same condition into errors, which are visible in a way that latency is not. This is also the setting that would have made the REQUIRES_NEW approach from Chapter 12 fail loudly rather than quietly: a per-item transaction across a two-hundred-item batch exhausts a twenty-five connection pool immediately, and with this timeout it says so in five seconds instead of appearing as a request that takes ten minutes.
Two lines came out of this block that are worth mentioning for their absence. spring.jpa.database and spring.jpa.database-platform both declare the Hibernate dialect, Hibernate resolves it from the connection metadata without either of them, and keeping both means two places to edit when the database changes and two chances to edit only one.
Within a single file, defaults go inline, as the pool size above does. The value after the colon is what applies when the variable is absent, which makes the file self-documenting about what can be overridden and what it falls back to. It also marks the setting as optional, and that is the distinction the entrypoint script in Part 3 makes from the other direction: a property with no inline default is one the container must supply, and that is precisely the list the script refuses to start without.
Typed configuration
Flat properties are untyped strings and are only checked when something reads them. @ConfigurationProperties turns a group of them into an object that is validated at startup, and wiring one in is the first change this part makes to the entry point from Part 1: the class list grows by an @EnableConfigurationProperties block alongside the @Import.
@ConfigurationProperties(prefix = "app.assignments")
@Validated
public record AssignmentProperties(
@NotNull @Min(1) @Max(1000) Integer maxBatchSize
) {
}
Bound from:
app.assignments.max-batch-size=200
This is the size cap Chapter 12 said the bulk assignment endpoint would need. The chapter argued that a single transaction and an IN clause are fine at a few hundred ids and a problem at a hundred thousand, and left the bound unspecified. Here it is a number with a name, validated into a range.
Where it gets read is a decision in its own right, and the obvious answer is wrong. CategoryAssignmentsServiceImpl is the class that loops over the ids, so injecting the properties record there looks natural. It would also be the first time in thirteen chapters that a class in business-logic took a Spring configuration object as a constructor argument, which is precisely the boundary the previous twelve were spent defending. A maximum batch size is a statement about what the API accepts, not about what the business rule allows: a request carrying ten thousand ids should be refused before anything reads a database, and refusing it is the job of the layer that parses the request. So the record is read by the controller, in the module that is allowed to know about Spring, and the service keeps a constructor that a test can satisfy with three mocks.
That leaves this chapter introducing a configuration value before the thing that consumes it exists, which is a slightly odd order to publish in and an honest reflection of how it happened. The alternative was to hold the property back and reopen the entry point two chapters later, which buys tidiness in exchange for pretending the decision was made at a different time than it was.
The record is registered through @EnableConfigurationProperties on the application class rather than by annotating it with @Component, which keeps it consistent with everything else: the application decides what exists.
@Validated is what makes the constraints run. Without it the annotations are documentation. With it, max-batch-size=0 fails the context startup with a message naming the property and the constraint, which is a far better outcome than a bulk endpoint that silently accepts batches of zero.
What this part settled
A setting now has exactly one correct home, chosen by what it is true of rather than by what is convenient. Identical everywhere goes in application.properties. True of an environment goes in a profile. True of one machine goes in a gitignored file nobody else sees. True of a container arrives as an environment variable, and what happens to it on the way into the JVM is the subject of Part 3.
The datasource block turned out to be the densest file, and two of its lines were doing nothing: an idle timeout that cannot fire against a fixed pool, and a pair of dialect declarations saying the same thing twice. Both had been correct under an earlier decision and had quietly become decoration under a later one, which is what a configuration file does when nobody reads it end to end.
Reading a file like that end to end is most of the work, and it is work that only pays once. Every setting here is now either a decision somebody can disagree with or a default nobody has to think about, and the difference between the two is written down rather than inferred.
What's Next?
Every setting now has a correct home, and the process that reads them is still unconfigured in every respect that is not a property. Nothing has decided how many requests it may hold at once. Nothing has decided what a log line looks like or where it is written. Nothing turns a container's environment into something a JVM will accept, given that nothing hands a properties file to a container. And the application cannot yet answer the first question anyone asks of a running service, which is which version of itself it happens to be.
Part 3 is those four, and it ends with an image and a compose file, so that from here on the whole thing starts with one command.
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 2, use the following link:
-
GitHub Repository (Tag:
chapter-13-spring-p2): advanced-spring-multimodule
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 13 Part 1: Bootstrapping the System, Nothing Is a Bean Yet
▶️ Read Chapter 13 Part 3: Bootstrapping the System, Making It Run (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):
- Grokking the Java Interview Prepare for core Java, concurrency, JVM internals, and design pattern questions. Get Full Book (Paid) | Download Free Sample Copy
- Grokking the Spring Boot Interview Master Spring Core, Auto-configuration, Spring Data JPA, Security, and Microservices. Get Full Book (Paid) | Download Free Sample Copy
- Grokking the SQL Interview Deep-dive into query optimization, indexing, joins, and complex SQL window functions. Get Full Book (Paid) | Download Free Sample Copy
- The Complete Java + Spring + SQL Interview Bundle Get all three interview guides in a single heavily discounted package. Get the Ultimate Interview Bundle
- Spring Professional Certification Practice Questions (250+ Questions) Validating your skills? Practice with real exam-style questions before taking the Spring Professional certification. Get the Spring Professional Questions | Download Free Sample Copy
Top comments (0)