Skip to main content

Build Instagram's Auth Backend in Spring Boot 4 — Ep 2

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.xml and 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.yml with environment overrides
  • Understand exactly what @SpringBootApplication does 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-web for the REST stack, plus validation for DTO rules.
  • Security: spring-boot-starter-security — Spring Security 7.
  • Persistence: spring-boot-starter-data-jpa, the postgresql driver at runtime, and Flyway (the Boot Flyway starter plus the Postgres-specific Flyway module) to run migrations.
  • State: spring-boot-starter-data-redis for the blacklist and OTP codes.
  • Email: spring-boot-starter-mail for OTPs.

The four libraries added by hand

These aren’t on Initializr, so they go into the pom manually:

  • jjwt — split into api, impl, and jackson. 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.
A nice Spring Boot 4 detail: Boot 4 split the old single test starter into per-module test starters (like 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 DEBUG for 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.

Build along: Star and clone 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

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