I Built a Production-Ready Spring Boot 4.1.0 SaaS Boilerplate — Here Is What I Learned

java dev.to

Why I Built This

Every Spring Boot project I started, I spent the first 2–3 weeks building the exact same things:

  • JWT authentication
  • Email verification
  • Forgot password flow
  • Google OAuth2 integration
  • Docker setup
  • CI/CD pipeline

That is weeks of work before writing a single line of my actual product.

So I packaged all of it into SpringLaunch API — a production-ready Spring Boot 4.1.0 boilerplate.

Here are the biggest lessons I learned while building it.


Spring Boot 4 Changed A Lot

Spring Boot 4.1.0 (released June 2026) is a major release.

Several APIs changed compared to Boot 3.

Test packages moved

// Spring Boot 3
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;

// Spring Boot 4
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
Enter fullscreen mode Exit fullscreen mode

@MockBean became @MockitoBean

// Spring Boot 3
@MockBean
private JwtService jwtService;

// Spring Boot 4
@MockitoBean
private JwtService jwtService;
Enter fullscreen mode Exit fullscreen mode

DaoAuthenticationProvider is now auto-configured

If your application exposes both:

  • UserDetailsService
  • PasswordEncoder

Spring Security automatically creates the DaoAuthenticationProvider.

No manual bean configuration is required anymore.


JWT Strategy — Two Tokens, Two Places

I use a hybrid authentication strategy.

Token Lifetime Storage
Access Token 15 minutes JSON response body
Refresh Token 7 days HTTP-only Cookie

Access token:

public record AuthResponse(
    String accessToken,
    UserResponse user
) {}
Enter fullscreen mode Exit fullscreen mode

Refresh token:

ResponseCookie cookie = ResponseCookie.from("refreshToken", token)
    .httpOnly(true)
    .secure(true)
    .sameSite("Lax")
    .path("/")
    .maxAge(maxAgeSeconds)
    .build();
Enter fullscreen mode Exit fullscreen mode

Why HTTP-only cookies?

JavaScript cannot read them.

Even if an XSS attack executes on the page, it cannot steal the refresh token.


The @Async Self-Invocation Trap

This bug cost me over an hour.

Calling an @Async method from the same class bypasses Spring's proxy, so the method executes synchronously.

❌ Wrong

@Service
public class AuthServiceImpl {

    @Async
    private void sendEmail() { }

    public void register() {
        sendEmail();
    }
}
Enter fullscreen mode Exit fullscreen mode

✅ Correct

@Service
public class EmailService {

    @Async
    public void sendEmail() { }
}

@Service
public class AuthServiceImpl {

    private final EmailService emailService;

    public void register() {
        emailService.sendEmail();
    }
}
Enter fullscreen mode Exit fullscreen mode

Because the call goes through Spring's proxy, it becomes truly asynchronous.


Argon2 — Use Password4j

Spring Security's Argon2PasswordEncoder is soft-deprecated.

Instead, Boot 4 recommends Password4j integration.

❌ Old

new Argon2PasswordEncoder(
    16,
    32,
    1,
    65536,
    3
);
Enter fullscreen mode Exit fullscreen mode

✅ Recommended

import org.springframework.security.crypto.password4j.Argon2Password4jPasswordEncoder;

new Argon2Password4jPasswordEncoder();
Enter fullscreen mode Exit fullscreen mode

Cleaner code with recommended defaults.


Factory Methods On JPA Entities

JPA entities cannot be records because they require:

  • mutable fields
  • a no-argument constructor

Instead, I use factory methods.

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User extends BaseEntity implements UserDetails {

    public static User ofLocal(
        String name,
        String username,
        String email,
        String encodedPassword
    ) {

        User user = new User();

        user.name = name.trim();
        user.username = username.toLowerCase();
        user.email = email.toLowerCase();
        user.password = encodedPassword;

        user.role = UserRole.USER;
        user.provider = AuthProvider.LOCAL;
        user.emailVerified = false;

        return user;
    }

    public static User ofGoogle(
        String name,
        String username,
        String email,
        String providerId
    ) {

        User user = new User();

        // ...

        user.emailVerified = true;

        return user;
    }
}
Enter fullscreen mode Exit fullscreen mode

No one can accidentally create an invalid user.


API Versioning From Day One

Every endpoint is versioned.

public final class ApiVersion {

    public static final String V1 = "/v1";

    private ApiVersion() {}
}
Enter fullscreen mode Exit fullscreen mode

Controllers simply use:

@RequestMapping(ApiVersion.V1 + "/auth")
public class AuthController {
}
Enter fullscreen mode Exit fullscreen mode

When breaking changes arrive:

  • Add /v2
  • Keep /v1
  • Existing clients never break

What Is Included

SpringLaunch API currently includes:

  • Spring Boot 4.1.0
  • Java 21
  • JWT Authentication
  • Google OAuth2 Login
  • Email Verification
  • Password Reset
  • 17 REST Endpoints
  • 42 Automated Tests
  • Docker Compose
  • GitHub Actions CI
  • Render Deployment Configuration
  • 7 Documentation Guides

Final Thoughts

Building this taught me far more than just authentication.

I learned how Spring Boot 4 changed testing, security, password encoding, asynchronous execution, and application architecture.

Most importantly, I now have a production-ready starting point that saves weeks every time I build a new SaaS.

If you're interested, you can check out SpringLaunch API here:

Landing Page

https://sujankim.github.io/springlaunch

It's also available on Gumroad.

I'd love to hear your thoughts or answer any questions about the implementation decisions.


Source: dev.to

arrow_back Back to Tutorials