In Episode 1 we drew the whole blueprint — nine endpoints, the security filter chain, JWT with refresh-token rotation, Redis, and PostgreSQL. Today we pour the foundation.
Episode 2 is where the project comes into existence. We generate it, wire up every single dependency, set up all the configuration in one place, and understand the entry point — so that when the real code starts in Episode 3, we’re building on something solid instead of a mystery pom.xml we copy-pasted off the internet. No business logic yet. Just a clean, deliberate skeleton for a production-grade Spring Boot 4 authentication service.
What you’ll learn in Episode 2
This is a hands-on, IDE walkthrough episode. The philosophy: a foundation you don’t understand is a foundation you can’t debug. So instead of pasting a pom and moving on, we go dependency by dependency and explain why each one earns its place. By the end you’ll be able to:
- Generate a Spring Boot 4 project correctly from Spring Initializr
- Read a real
pom.xmland group dependencies by the job they do - Know which libraries Spring Boot manages for you and which you pin yourself
- Configure the whole app in a single
application.ymlwith environment overrides - Understand exactly what
@SpringBootApplicationdoes and why package placement matters - Understand why we don’t run the app yet — and why that’s the right call
Chapters
(Timings track the video — nudge them if your final edit shifted.)
- 0:00 — Recap of Episode 1, and today’s goal
- 0:42 — Generate the project on start.spring.io
- 2:29 — The
pom.xml, dependency by dependency - 9:03 — Configuration in one place:
application.yml - 18:30 — Recap and what’s next (Episode 3: Exceptions)
Step 1: Generate the project the right way
Every Spring project should begin at start.spring.io. The settings for this build:
- Project: Maven
- Language: Java
- Spring Boot: 4.0.x
- Group:
com.example - Artifact:
insta-auth-service - Packaging: Jar · Java: 21
Then the dependencies picked from Initializr, each with a clear job:
- Spring Web — the REST stack
- Spring Security — the framework we spent half of Episode 1 on (this is Spring Security 7, which ships with Boot 4)
- Spring Data JPA + PostgreSQL Driver — talking to Postgres
- Spring Data Redis — the token blacklist and OTP storage
- Validation — so request DTOs can enforce rules like “this must be a valid email”
- Java Mail Sender — sending OTP emails
- Flyway Migration — versioned database schema
- Lombok — killing boilerplate
Generate, unzip, open in IntelliJ. A few things this series needs aren’t on Initializr, so we add them to the pom by hand next.
Step 2: The pom.xml, grouped by job
Reading a pom top-to-bottom teaches you nothing. Reading it by responsibility teaches you the architecture. Here’s how the dependencies group.
The parent and properties
The parent is spring-boot-starter-parent, version 4.0.5 — that single line pins compatible versions for almost everything in the Spring ecosystem. Java is set to 21. In <properties> we pin the versions of the libraries Spring doesn’t manage for us: jjwt, MapStruct, and Bucket4j.
The dependencies, by responsibility
- Web layer:
spring-boot-starter-webfor the REST stack, plus validation for DTO rules. - Security:
spring-boot-starter-security— Spring Security 7. - Persistence:
spring-boot-starter-data-jpa, thepostgresqldriver at runtime, and Flyway (the Boot Flyway starter plus the Postgres-specific Flyway module) to run migrations. - State:
spring-boot-starter-data-redisfor the blacklist and OTP codes. - Email:
spring-boot-starter-mailfor OTPs.
The four libraries added by hand
These aren’t on Initializr, so they go into the pom manually:
- jjwt — split into
api,impl, andjackson. You compile against the api; the other two are runtime. That’s just how the JWT library is packaged. - Bucket4j (core) — the token-bucket rate limiter.
- MapStruct — maps between entities and DTOs without hand-writing the mapping. It pays off later in the series.
- springdoc-openapi — gives us Swagger UI for free.
spring-boot-starter-data-jpa-test). If you learned Spring Boot on version 2 or 3, that’s a change worth noticing.
The full pom.xml
Here’s the complete build file for reference — the Initializr dependencies, the four hand-added libraries, and the annotation-processor block that keeps Lombok and MapStruct happy together:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.0.5</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>insta-auth-service</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>insta-auth-service</name>
<description>Instagram-style authentication backend</description>
<properties>
<java.version>21</java.version>
<jjwt.version>0.12.5</jjwt.version>
<bucket4j.version>8.10.1</bucket4j.version>
<mapstruct.version>1.6.3</mapstruct.version>
</properties>
<dependencies>
<!-- Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Persistence: JPA + Postgres + Flyway -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<!-- State: Redis (JWT blacklist + OTP) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Email: OTP delivery -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<!-- Added by hand #1: JWT (jjwt) -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<!-- Added by hand #2: rate limiting (Bucket4j) -->
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j-core</artifactId>
<version>${bucket4j.version}</version>
</dependency>
<!-- Added by hand #3: entity <-> DTO mapping (MapStruct) -->
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${mapstruct.version}</version>
</dependency>
<!-- Added by hand #4: Swagger UI (springdoc) -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.8.6</version>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Tests (Boot 4 splits the old single test starter into per-module starters) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<!-- Annotation processors: Lombok + MapStruct must run in the right order.
If you ever see "cannot find symbol getX()", this block is why. -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</path>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${mapstruct.version}</version>
</path>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok-mapstruct-binding</artifactId>
<version>0.2.0</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
Step 3: The structure we’re going to build
The finished service uses eight packages: config, controller, dto, entity, exception, repository, security, and service. No util dumping ground — utility sprawl is where architectures go to rot.
But here’s the discipline that matters: we don’t create those packages now. Each one appears the moment we write its first class, so nothing ever sits empty. If you clone the repo today, you won’t see eight empty folders — you’ll see the structure grow episode by episode as real code fills it.
Step 4: All configuration in one place
Configuration lives in a single application.yml — not scattered across application-dev.yml and application-prod.yml. Instead, every environment-specific value uses the ${VAR:default} pattern: a sensible default for local development, overridable by an environment variable in production. One file, no profile juggling.
A few decisions baked into that config:
ddl-auto: validate— Flyway owns the schema. Hibernate only checks that the entities match the tables; it never modifies them. This is the production-safe setting.- A custom
app:block — token lifetimes, the lockout policy, and other choices we made ourselves live here, in plain sight. Want to change the lockout window? It’s one line. This is the “we designed this, we didn’t copy it” part of the config. - The JWT secret — the value in the file is for local development only. In production you generate a strong secret and inject it as
JWT_SECRET. Never ship the committed value. - springdoc + logging — springdoc sets our Swagger UI path, and logging is turned up to
DEBUGfor our own package so we can watch what’s happening as we build.
The full application.yml
All of the configuration, in one file. Notice the ${VAR:default} pattern throughout, and the custom app: block where every security decision lives:
server:
port: 8080
spring:
application:
name: insta-auth-service
# --- Postgres. Every value is env-overridable via ${VAR:default} ---
datasource:
url: ${DB_URL:jdbc:postgresql://localhost:5432/insta_auth}
username: ${DB_USERNAME:postgres}
password: ${DB_PASSWORD:postgres}
jpa:
hibernate:
ddl-auto: validate # Flyway owns the schema; Hibernate only checks it
show-sql: false
properties:
hibernate:
format_sql: true
flyway:
enabled: true
locations: classpath:db/migration
# --- Redis: JWT blacklist + OTP storage ---
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
timeout: 2000ms
# --- Mail: OTP delivery. localhost:1025 = MailHog (dev fake SMTP) ---
mail:
host: ${MAIL_HOST:localhost}
port: ${MAIL_PORT:1025}
properties:
mail:
smtp:
auth: false
starttls:
enable: false
# ============================================================
# app: OUR config, not Spring's. Every knob that defines how
# this auth system behaves lives here, in one readable place.
# ============================================================
app:
jwt:
secret: ${JWT_SECRET:local-dev-only-change-in-production} # DEV ONLY
access-token-expiration: 15m
refresh-token-expiration: 7d
otp:
length: 6
expiration: 10m
security:
max-failed-attempts: 5
lockout-duration: 30m
rate-limit:
capacity: 10
refill-period: 1m
cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000}
springdoc:
swagger-ui:
path: /swagger-ui.html
logging:
level:
com.example.insta_auth_service: DEBUG
Step 5: The entry point
The class Initializr generated — InstaAuthServiceApplication — is tiny, and worth understanding fully. @SpringBootApplication is really three annotations in one: it enables auto-configuration, marks the class as a configuration class, and tells Spring to component-scan this package and everything beneath it.
That last part is why every package we create from here on lives under this class — component scanning finds all of it automatically. The main method simply hands control to Spring. This one small class boots the entire application — once there’s an application to boot.
Why we don’t run it yet
Here’s what we have at the end of Episode 2, and it’s all we have: a generated project, every dependency wired and understood, all configuration in one place, a db/migration folder ready for Flyway, and the entry point. No controllers, no services, no entities — none of that exists yet, on purpose.
And no, we haven’t run it. There’s no database behind it and no code to exercise. The app comes to life later, once we’ve built the pieces and wired up Docker to run everything together. For now, this clean, understood foundation is exactly what we want — and it’s the difference between a project you can grow and one you’ll be fighting by Episode 5.
insta-auth-service on GitHub. From here on, every episode adds real code on top of this foundation.
What’s next: Episode 3 — Exceptions
Before we build a single feature, we build the error-handling backbone: a global exception handler and a consistent API response wrapper, so every error this app ever throws comes back clean and predictable. That’s the first real code we write — and it’s what makes the rest of the series smooth. New episodes drop every Monday and Thursday.
Frequently asked questions
Why generate the project on start.spring.io instead of by hand?
Spring Initializr produces a correct, version-aligned starting point in seconds — the right parent POM, a matching Java version, and dependencies that are guaranteed to play nicely together. Hand-rolling a pom is a great way to spend an afternoon debugging version conflicts you didn’t need to create.
Why are some dependencies added to the pom by hand?
Not every library is on Spring Initializr. The JWT library (jjwt), the rate limiter (Bucket4j), the DTO mapper (MapStruct), and the API docs tool (springdoc-openapi) are added manually, with their versions pinned in <properties> because Spring Boot’s parent POM doesn’t manage them.
What does ddl-auto: validate actually do?
It tells Hibernate to check that your entity classes match the existing database tables at startup, but never to create or alter them. That job belongs to Flyway, which runs versioned migrations. Letting Hibernate auto-generate schema is convenient in a toy app and dangerous in production — validate is the grown-up setting.
Why isn’t there an application-dev.yml and application-prod.yml?
Because a single application.yml with ${VAR:default} overrides does the same job with less to maintain. Local development uses the defaults; production supplies environment variables. One file, one source of truth.
Why doesn’t the app run at the end of this episode?
There’s deliberately nothing to run yet — no entities, no controllers, and no database wired up. Running comes later in the series once the pieces exist and Docker Compose brings up Postgres and Redis. Booting an empty skeleton would prove nothing.
Watch Episode 2 and build along
- ▶️ Watch the full walkthrough above and subscribe for the Monday/Thursday episodes.
- ⭐ Star the GitHub repo — clone it and follow along.
- 🎓 Want the deeper version with OAuth2, MFA, and AWS deployment? Check out the full Udemy course.
- 💬 Want a 1:1 code review, architecture help, or career guidance? Book a session on Topmate.
New to the series? Start with Episode 1: The Architecture. And drop a comment on the video telling me where you’re watching from — I reply to every one in the first 48 hours. See you in Episode 3.
Comments
Post a Comment