Order Management Microservices

Build a secured microservices system with Spring Boot, Eureka, and JWT auth.

Introduction

30 Second Summary

When you order food through a delivery app, your request doesn't hit one giant program. It flows through dozens of small, independent services that each handle one job: checking the menu, processing payment, dispatching a driver. This is microservices architecture in action.

In this project, you will build a complete Order Management System composed of 5 Spring Boot microservices that register themselves with a discovery server, route requests through a gateway, and secure everything with JWT authentication.

What You'll Build

A multi-module Maven project with 5 independently runnable Spring Boot applications: a Discovery Server, API Gateway, Auth Service, Product Service, and Order Service.

By the end of this project, you'll have:

  • A running microservices system with 5 Spring Boot applications communicating through Eureka service discovery.
  • A production-grade JWT authentication service with user registration, login, token validation, and role-based access control.
  • A fully functional order creation flow where the Order Service calls the Product Service through the gateway, secured end-to-end.
  • Secret Mission: Add a circuit breaker with Resilience4j to gracefully handle service failures during order creation.

Are there any prerequisites?

This project assumes you are familiar with building monolithic Spring Boot applications, Java 17 or later, and REST API concepts. You do not need prior experience with microservices, Spring Cloud, or JWT.

Set Up the Maven Multi-Module Project

A microservices system is made up of multiple independent applications that work together. To keep all these applications organized under one roof, you need a project structure that can manage shared configuration and dependencies in a single place.

In this step, you will create a Maven multi-module project that acts as the parent container for all five services. You will also build and run the first module, the Eureka Discovery Server, to confirm everything is wired up correctly.

In this step, get ready to:
  • Create the parent POM with Spring Boot 3.5.14, Java 21, and Spring Cloud 2025.0.2 BOM.
  • Generate the Discovery Server module and verify the Eureka dashboard loads.
  • Import the multi-module project into your IDE and confirm all dependencies resolve.
Create the parent POM with Spring Boot and Spring Cloud

The parent POM defines shared properties like the Java version, Spring Boot version, and Spring Cloud version in one place. Every child module inherits these settings automatically.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the project folder and move into it by running these commands:
mkdir order-management-system
cd order-management-system
  • Create the directory structure for all five modules by running:
mkdir discovery-server
mkdir api-gateway
mkdir auth-service
mkdir product-service
mkdir order-service
  • Confirm the directories exist by running:
ls

You should see discovery-server, api-gateway, auth-service, product-service, and order-service listed in the output.

  • Create a new file called pom.xml in the order-management-system folder and paste the following content:
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <!-- Inherit Spring Boot defaults -->
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.5.14</version>
        <relativePath/>
    </parent>

    <!-- Project coordinates -->
    <groupId>com.orderms</groupId>
    <artifactId>order-management-system</artifactId>
    <version>1.0.0</version>
    <packaging>pom</packaging>

    <!-- Shared properties for all child modules -->
    <properties>
        <java.version>21</java.version>
        <spring-cloud.version>2025.0.2</spring-cloud.version>
        <jjwt.version>0.13.0</jjwt.version>
    </properties>

    <!-- Declare all child modules -->
    <modules>
        <module>discovery-server</module>
        <module>api-gateway</module>
        <module>auth-service</module>
        <module>product-service</module>
        <module>order-service</module>
    </modules>

    <!-- Centralized dependency version management -->
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring-cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>
</project>

What does this POM do?

The <packaging>pom</packaging> tag marks this as a parent-only project. It does not produce a JAR itself.

The <dependencyManagement> section imports the Spring Cloud BOM (Bill of Materials). This means child modules can declare Spring Cloud dependencies without specifying version numbers. The BOM handles version compatibility automatically.

  • Save pom.xml.

Not sure how to create the file?

You can use any text editor to create pom.xml. If you prefer the terminal, run nano pom.xml (macOS/Linux) to open a simple editor, paste the content, then press Ctrl+O to save and Ctrl+X to exit.

help me create a pom.xml file from the terminal

✔️ Awesome, I've got everything!

You are on track. Make sure you saved the file before moving on.

ⓧ I'd like to double check the full code

<?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 http://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>3.5.14</version>
        <relativePath/>
    </parent>

    <groupId>com.orderms</groupId>
    <artifactId>order-management-system</artifactId>
    <version>1.0.0</version>
    <packaging>pom</packaging>

    <properties>
        <java.version>21</java.version>
        <spring-cloud.version>2025.0.2</spring-cloud.version>
        <jjwt.version>0.13.0</jjwt.version>
    </properties>

    <modules>
        <module>discovery-server</module>
        <module>api-gateway</module>
        <module>auth-service</module>
        <module>product-service</module>
        <module>order-service</module>
    </modules>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring-cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>
</project>
Generate the Discovery Server module

The Discovery Server is a service discovery registry. Every other microservice will register itself here so the system knows where each service is running.

  • Create the source directory structure for the Discovery Server by running:
mkdir -p discovery-server/src/main/java/com/orderms/discovery
mkdir -p discovery-server/src/main/resources
  • Create a new file called pom.xml inside the discovery-server folder and paste this content:
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <!-- Inherit from the parent multi-module POM -->
    <parent>
        <groupId>com.orderms</groupId>
        <artifactId>order-management-system</artifactId>
        <version>1.0.0</version>
    </parent>

    <artifactId>discovery-server</artifactId>

    <!-- Eureka Server dependency for service registry -->
    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

Why is there no version on the dependency?

The parent POM imports the Spring Cloud BOM, which already defines the correct version for spring-cloud-starter-netflix-eureka-server. Child modules inherit that version automatically.

  • Create a new file called application.yml in discovery-server/src/main/resources and paste this configuration:
server:
  port: 8761

spring:
  application:
    name: discovery-server

eureka:
  instance:
    hostname: localhost
  client:
    register-with-eureka: false
    fetch-registry: false
    service-url:
      defaultZone: http://${eureka.instance.hostname}:${server.port}/eureka/

What does this configuration do?

  • port: 8761 sets the standard Eureka port that other services will connect to.
  • register-with-eureka: false tells this server not to register itself as a client. It is the registry, not a service that needs to be discovered.
  • fetch-registry: false prevents it from trying to fetch registry data from another Eureka instance. This is a standalone, single-node setup.
  • Create a new file called DiscoveryServerApplication.java in discovery-server/src/main/java/com/orderms/discovery and paste this code:
package com.orderms.discovery;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.eureka.server.EnableEurekaServer;

// Marks this application as a Eureka service registry
@SpringBootApplication
@EnableEurekaServer
public class DiscoveryServerApplication {
    public static void main(String[] args) {
        SpringApplication.run(DiscoveryServerApplication.class, args);
    }
}

What does @EnableEurekaServer do?

This annotation transforms a standard Spring Boot application into a Eureka service registry. Other services will register themselves here on startup, and the registry provides a dashboard where you can see all connected services.

  • Save all three files (pom.xml, application.yml, DiscoveryServerApplication.java).
  • Start the Discovery Server by running these commands:
cd discovery-server
mvn spring-boot:run

What should I see?

Maven will download dependencies the first time, which can take a minute or two. Once the application starts, you will see log output ending with a line like Started DiscoveryServerApplication in the terminal.

  • Open your browser and navigate to http://localhost:8761.

You should see the Eureka dashboard with the heading Instances currently registered with Eureka and an empty table (no services registered yet).

Dashboard not loading?

  • If Maven reports a build failure, check that your parent pom.xml is saved in the order-management-system root folder (one level above discovery-server).
  • If you see a port already in use error, another process is using port 8761. Stop that process or change the port in application.yml.
  • Make sure you ran the command from inside the discovery-server directory, not from the parent order-management-system folder.

help me troubleshoot my Eureka Discovery Server not starting

  • Go back to your terminal and press Ctrl+C to stop the Discovery Server for now.
  • Navigate back to the project root by running:
cd ..
Set up your IDE and import the project

Now that the project structure is verified, import it into your IDE for a better editing experience going forward.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type IntelliJ and press Enter to open it.
  • Click Open (or File then Open if a project is already open).
  • Navigate to your Desktop and select the order-management-system folder.
  • Click Open.

What if I use VS Code?

If you prefer VS Code, open it and click File then Open Folder. Select the order-management-system folder. Install the Extension Pack for Java extension if you have not already. VS Code will detect the Maven project and resolve dependencies automatically.

  • IntelliJ will detect the pom.xml and ask if you want to import it as a Maven project. Click Trust Project if prompted.
  • Wait for the indexing and dependency resolution to finish (you will see a progress bar at the bottom).
  • Verify Java 21 is selected: click File, then Project Structure (or press Cmd+; on macOS / Ctrl+Alt+Shift+S on Windows).
  • Under Project SDK, confirm it shows Java 21. If not, click the dropdown and select your JDK 21 installation.
  • Click OK to close the dialog.

In the Project panel on the left, you should see the parent module and the discovery-server module with all its files. The other four module folders will appear empty for now.

Maven dependencies not resolving?

  • Right-click the parent pom.xml in the Project panel and select Maven then Reload project.
  • If IntelliJ cannot find Maven, go to File then Settings then Build, Execution, Deployment then Build Tools then Maven. Confirm the Maven home path points to your Maven installation.

help me fix Maven dependency resolution in IntelliJ

Your project skeleton is ready and the Discovery Server has proven it can start. Next up, you will build the Auth Service with full JWT authentication so your system can securely identify users and control access.

Build the Auth Service with JWT

Your Discovery Server is up and running, so other services now have a place to register. The next piece of your microservices system is authentication.

Every request flowing through your system needs to prove who is making it. You will build an Auth Service that handles user registration, login, and JWT token generation. This service becomes the gatekeeper for your entire architecture.

In this step, get ready to:
  • Create the Auth Service module with its dependencies and data model.
  • Implement JWT generation, validation, and a Spring Security filter chain.
  • Build registration, login, and token validation endpoints with global exception handling.
Create the Auth Service module and define the User entity

The Auth Service needs its own Maven module with dependencies for web, JPA, security, validation, and JWT. You will also define the User entity that stores credentials and roles.

  • Create a new directory called auth-service inside your order-management-system project folder.
  • Inside auth-service, create a pom.xml file with the following content:
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>com.orderms</groupId>
        <artifactId>order-management-system</artifactId>
        <version>1.0.0</version>
    </parent>

    <artifactId>auth-service</artifactId>

    <dependencies>
        <!-- Web MVC for REST endpoints -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- JPA for database access -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>

        <!-- Spring Security for authentication -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

        <!-- Bean Validation for request DTOs -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>

        <!-- H2 in-memory database -->
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>runtime</scope>
        </dependency>

        <!-- JJWT for token generation and validation -->
        <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>

        <!-- Eureka client for service discovery -->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>

        <!-- Lombok to reduce boilerplate -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

What does this POM do?

This child POM inherits from your parent order-management-system project, which means it automatically gets the Spring Boot and Spring Cloud version management.

The ${jjwt.version} property resolves to 0.13.0 defined in the parent POM. Three JJWT artifacts work together: the API, the implementation, and Jackson serialization support.

  • Reload your Maven project in your IDE so it resolves the new dependencies.
  • Verify that Maven downloads the JJWT, Spring Security, and Eureka client libraries without errors.

Maven failing to resolve dependencies?

Make sure the <parent> block references com.orderms as the groupId and order-management-system as the artifactId. The parent POM must already list auth-service in its <modules> section.

help me troubleshoot Maven dependency resolution errors in my auth-service module

  • Create the directory structure auth-service/src/main/resources/ and add the application.yml file:
server:
  port: 9000

spring:
  application:
    name: auth-service
  datasource:
    url: jdbc:h2:mem:authdb
  jpa:
    hibernate:
      ddl-auto: create-drop

app:
  security:
    jwt:
      secret-key: bXlzdXBlcnNlY3JldGtleXRoYXRpc2F0bGVhc3QyNTZiaXRzbG9uZ2Zvckhtcy0yNTY=
      expiration-ms: 86400000

eureka:
  client:
    service-url:
      defaultZone: http://localhost:8761/eureka/

What do these properties configure?

  • Port 9000 keeps the Auth Service separate from the Discovery Server on 8761.
  • The H2 in-memory database jdbc:h2:mem:authdb stores users without any external database install.
  • The secret-key is a base64-encoded string used for HMAC-SHA256 token signing. The expiration-ms sets tokens to expire after 24 hours (86400000 ms).
  • The Eureka client URL tells this service where to register itself.

Now create the User entity and supporting types. The User entity implements Spring Security's UserDetails interface so it integrates directly with the authentication system.

  • Create the package com.orderms.auth.entity under auth-service/src/main/java/ and add Role.java:
package com.orderms.auth.entity;

public enum Role {
    USER, ADMIN
}
  • In the same package, create User.java:
package com.orderms.auth.entity;

import jakarta.persistence.*;
import lombok.*;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;
import java.util.Set;
import java.util.stream.Collectors;

@Entity
@Table(name = "users")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class User implements UserDetails {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true)
    private String email;

    private String password;

    private String name;

    // Store roles as a separate collection table
    @ElementCollection(fetch = FetchType.EAGER)
    @Enumerated(EnumType.STRING)
    private Set<Role> roles;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return roles.stream()
                .map(role -> new SimpleGrantedAuthority("ROLE_" + role.name()))
                .collect(Collectors.toList());
    }

    @Override
    public String getUsername() {
        return email;
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

Why does User implement UserDetails?

Spring Security needs a UserDetails object to authenticate users. By implementing this interface directly on your entity, the authentication system can load users from the database without an adapter class.

The getUsername() method returns the email because that is what users will log in with. The getAuthorities() method converts roles into Spring Security's ROLE_ prefixed format.

  • Create the package com.orderms.auth.repository and add UserRepository.java:
package com.orderms.auth.repository;

import com.orderms.auth.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String email);
}
  • Finally, create the main application class at com.orderms.auth.AuthServiceApplication.java:
package com.orderms.auth;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class AuthServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(AuthServiceApplication.class, args);
    }
}

You will not be able to start the service yet because Spring Security requires additional configuration. You will add that in the next substep.

Implement JWT generation and security configuration

With the data model in place, you need the security layer. This includes a service to create and validate JWTs, a filter that intercepts every request to check for a valid token, and a Spring Security configuration that ties everything together.

  • Create the package com.orderms.auth.security and add JwtService.java:
package com.orderms.auth.security;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;

import javax.crypto.SecretKey;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;

@Service
public class JwtService {

    @Value("${app.security.jwt.secret-key}")
    private String secretKey;

    @Value("${app.security.jwt.expiration-ms}")
    private long jwtExpiration;

    // Extract the username (email) from a token
    public String extractUsername(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    // Generic claim extractor
    public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = extractAllClaims(token);
        return claimsResolver.apply(claims);
    }

    // Generate a token with user roles as extra claims
    public String generateToken(UserDetails userDetails) {
        Map<String, Object> extraClaims = new HashMap<>();
        extraClaims.put("roles", userDetails.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.toList()));
        return generateToken(extraClaims, userDetails);
    }

    // Build the JWT with claims, subject, timestamps, and signature
    public String generateToken(Map<String, Object> extraClaims, UserDetails userDetails) {
        return Jwts.builder()
                .claims(extraClaims)
                .subject(userDetails.getUsername())
                .issuedAt(new Date(System.currentTimeMillis()))
                .expiration(new Date(System.currentTimeMillis() + jwtExpiration))
                .signWith(getSigningKey())
                .compact();
    }

    // Validate token against a specific user
    public boolean isTokenValid(String token, UserDetails userDetails) {
        final String username = extractUsername(token);
        return (username.equals(userDetails.getUsername())) && !isTokenExpired(token);
    }

    // Validate token structure and expiration only
    public boolean isTokenValid(String token) {
        try {
            extractAllClaims(token);
            return !isTokenExpired(token);
        } catch (Exception e) {
            return false;
        }
    }

    private boolean isTokenExpired(String token) {
        return extractExpiration(token).before(new Date());
    }

    private Date extractExpiration(String token) {
        return extractClaim(token, Claims::getExpiration);
    }

    // Parse and verify the token signature
    private Claims extractAllClaims(String token) {
        return Jwts.parser()
                .verifyWith(getSigningKey())
                .build()
                .parseSignedClaims(token)
                .getPayload();
    }

    // Decode the base64 secret and create an HMAC-SHA key
    private SecretKey getSigningKey() {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        return Keys.hmacShaKeyFor(keyBytes);
    }
}

What does JwtService do?

  • generateToken() creates a signed JWT containing the user's email as the subject and their roles as custom claims.
  • isTokenValid() checks whether the token signature is correct and the expiration date has not passed.
  • getSigningKey() decodes the base64 secret from application.yml into an HMAC-SHA256 key using JJWT's Keys.hmacShaKeyFor() method.
  • In the same security package, create JwtAuthenticationFilter.java:
package com.orderms.auth.security;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.lang.NonNull;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtService jwtService;
    private final UserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(
            @NonNull HttpServletRequest request,
            @NonNull HttpServletResponse response,
            @NonNull FilterChain filterChain
    ) throws ServletException, IOException {
        // Look for the Authorization header
        final String authHeader = request.getHeader("Authorization");

        // Skip if no Bearer token present
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }

        // Extract the token (everything after "Bearer ")
        final String jwt = authHeader.substring(7);
        final String userEmail = jwtService.extractUsername(jwt);

        // If we have an email and no existing authentication, validate
        if (userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails userDetails = this.userDetailsService.loadUserByUsername(userEmail);

            if (jwtService.isTokenValid(jwt, userDetails)) {
                UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
                        userDetails, null, userDetails.getAuthorities()
                );
                authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(authToken);
            }
        }

        filterChain.doFilter(request, response);
    }
}

How does the filter work?

This filter runs once per request. It checks the Authorization header for a Bearer token. If found, it extracts the user's email from the token, loads their details from the database, and sets them in the SecurityContext.

If no token is present, the filter passes the request through unchanged. The security configuration will then decide whether to reject or allow it based on the URL pattern.

  • You also need a UserDetailsService implementation. Create the package com.orderms.auth.config and add SecurityConfig.java:
package com.orderms.auth.config;

import com.orderms.auth.security.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthFilter;
    private final UserDetailsService userDetailsService;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .anyRequest().authenticated()
            )
            .authenticationProvider(authenticationProvider())
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    @Bean
    public AuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
        authProvider.setUserDetailsService(userDetailsService);
        authProvider.setPasswordEncoder(passwordEncoder());
        return authProvider;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
        return config.getAuthenticationManager();
    }
}

What does this security configuration do?

  • Disables CSRF because this is a stateless REST API that uses tokens instead of cookies.
  • Sets session management to STATELESS so Spring never creates HTTP sessions.
  • Permits all requests to /api/auth/** (register, login, validate) without authentication.
  • Requires authentication for every other endpoint.
  • Inserts the JWT filter before Spring's default UsernamePasswordAuthenticationFilter so tokens are checked first.

Why disable CSRF for a REST API?

CSRF protection prevents browser-based attacks where a malicious site tricks the browser into sending cookies to your API. Since your microservices use Bearer tokens in the Authorization header (not cookies), CSRF attacks are not possible. Disabling it avoids unnecessary 403 errors on POST requests.

  • The UserDetailsService bean needs to load users from your database. Create a com.orderms.auth.service package and add UserDetailsServiceImpl.java:
package com.orderms.auth.service;

import com.orderms.auth.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return userRepository.findByEmail(username)
                .orElseThrow(() -> new UsernameNotFoundException("User not found with email: " + username));
    }
}

You will not see a change yet. The next substep adds the controller and service that complete the authentication flow.

Create the authentication endpoints and exception handling

The security infrastructure is ready. Now you need DTOs to define request and response shapes, a service to handle registration and login logic, custom exceptions for proper error responses, and a controller to expose the endpoints.

  • Create the package com.orderms.auth.dto and add the three DTO records. First, RegisterRequest.java:
package com.orderms.auth.dto;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;

public record RegisterRequest(
        @NotBlank String name,
        @Email @NotBlank String email,
        @NotBlank String password
) {}
  • Add LoginRequest.java:
package com.orderms.auth.dto;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;

public record LoginRequest(
        @Email @NotBlank String email,
        @NotBlank String password
) {}
  • Add AuthResponse.java:
package com.orderms.auth.dto;

public record AuthResponse(String token) {}

Why use Java records for DTOs?

Records are immutable data carriers that automatically generate constructors, getters, equals(), and hashCode(). They are perfect for DTOs because they enforce immutability and reduce boilerplate. The validation annotations (@NotBlank, @Email) trigger automatically when paired with @Valid on the controller method parameter.

  • Create the package com.orderms.auth.exception and add ResourceNotFoundException.java:
package com.orderms.auth.exception;

public class ResourceNotFoundException extends RuntimeException {
    public ResourceNotFoundException(String message) {
        super(message);
    }
}
  • Add UserAlreadyExistsException.java:
package com.orderms.auth.exception;

public class UserAlreadyExistsException extends RuntimeException {
    public UserAlreadyExistsException(String message) {
        super(message);
    }
}
  • Add ErrorResponse.java:
package com.orderms.auth.exception;

import java.time.LocalDateTime;

public record ErrorResponse(int status, String message, LocalDateTime timestamp) {}
  • Add the GlobalExceptionHandler.java that converts exceptions into structured JSON responses:
package com.orderms.auth.exception;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleResourceNotFound(ResourceNotFoundException ex) {
        ErrorResponse error = new ErrorResponse(
                HttpStatus.NOT_FOUND.value(),
                ex.getMessage(),
                LocalDateTime.now()
        );
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
    }

    @ExceptionHandler(BadCredentialsException.class)
    public ResponseEntity<ErrorResponse> handleBadCredentials(BadCredentialsException ex) {
        ErrorResponse error = new ErrorResponse(
                HttpStatus.UNAUTHORIZED.value(),
                "Invalid email or password",
                LocalDateTime.now()
        );
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error);
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, Object>> handleValidationErrors(MethodArgumentNotValidException ex) {
        Map<String, Object> response = new HashMap<>();
        Map<String, String> errors = new HashMap<>();
        ex.getBindingResult().getAllErrors().forEach(error -> {
            String fieldName = ((FieldError) error).getField();
            String errorMessage = error.getDefaultMessage();
            errors.put(fieldName, errorMessage);
        });
        response.put("status", HttpStatus.BAD_REQUEST.value());
        response.put("errors", errors);
        response.put("timestamp", LocalDateTime.now().toString());
        return ResponseEntity.badRequest().body(response);
    }

    @ExceptionHandler(UserAlreadyExistsException.class)
    public ResponseEntity<ErrorResponse> handleUserAlreadyExists(UserAlreadyExistsException ex) {
        ErrorResponse error = new ErrorResponse(
                HttpStatus.CONFLICT.value(),
                ex.getMessage(),
                LocalDateTime.now()
        );
        return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
    }
}

What does the GlobalExceptionHandler do?

The @RestControllerAdvice annotation makes this class intercept exceptions thrown by any controller. Instead of returning a generic 500 error, each handler method returns a structured JSON response with the appropriate HTTP status code.

  • ResourceNotFoundException returns 404.
  • BadCredentialsException returns 401.
  • MethodArgumentNotValidException returns 400 with per-field error messages.
  • UserAlreadyExistsException returns 409.
  • Now build the AuthService.java in the com.orderms.auth.service package:
package com.orderms.auth.service;

import com.orderms.auth.dto.AuthResponse;
import com.orderms.auth.dto.LoginRequest;
import com.orderms.auth.dto.RegisterRequest;
import com.orderms.auth.entity.Role;
import com.orderms.auth.entity.User;
import com.orderms.auth.exception.UserAlreadyExistsException;
import com.orderms.auth.repository.UserRepository;
import com.orderms.auth.security.JwtService;
import io.jsonwebtoken.Claims;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

import java.util.HashMap;
import java.util.Map;
import java.util.Set;

@Service
@RequiredArgsConstructor
public class AuthService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final JwtService jwtService;
    private final AuthenticationManager authenticationManager;

    // Register a new user and return a JWT
    public AuthResponse register(RegisterRequest request) {
        if (userRepository.findByEmail(request.email()).isPresent()) {
            throw new UserAlreadyExistsException("User already exists with email: " + request.email());
        }

        User user = User.builder()
                .name(request.name())
                .email(request.email())
                .password(passwordEncoder.encode(request.password()))
                .roles(Set.of(Role.USER))
                .build();

        userRepository.save(user);
        String token = jwtService.generateToken(user);
        return new AuthResponse(token);
    }

    // Authenticate and return a JWT
    public AuthResponse login(LoginRequest request) {
        authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(request.email(), request.password())
        );

        User user = userRepository.findByEmail(request.email())
                .orElseThrow();
        String token = jwtService.generateToken(user);
        return new AuthResponse(token);
    }

    // Validate a token and return its claims
    public Map<String, Object> validateToken(String token) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("email", jwtService.extractUsername(token));
        claims.put("valid", jwtService.isTokenValid(token));
        Claims allClaims = jwtService.extractClaim(token, c -> c);
        claims.put("roles", allClaims.get("roles"));
        return claims;
    }
}

What does AuthService do?

  • register() checks for duplicate emails, hashes the password with BCrypt, saves the user, and returns a JWT.
  • login() delegates to Spring Security's AuthenticationManager to verify credentials. If they are invalid, Spring throws a BadCredentialsException that your GlobalExceptionHandler catches.
  • validateToken() extracts claims from the token and returns them. The API Gateway will call this endpoint to verify tokens.
  • Finally, create the package com.orderms.auth.controller and add AuthController.java:
package com.orderms.auth.controller;

import com.orderms.auth.dto.AuthResponse;
import com.orderms.auth.dto.LoginRequest;
import com.orderms.auth.dto.RegisterRequest;
import com.orderms.auth.service.AuthService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;

@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {

    private final AuthService authService;

    @PostMapping("/register")
    public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
        return ResponseEntity.status(HttpStatus.CREATED).body(authService.register(request));
    }

    @PostMapping("/login")
    public ResponseEntity<AuthResponse> login(@Valid @RequestBody LoginRequest request) {
        return ResponseEntity.ok(authService.login(request));
    }

    @PostMapping("/validate")
    public ResponseEntity<Map<String, Object>> validateToken(@RequestBody Map<String, String> request) {
        String token = request.get("token");
        Map<String, Object> claims = authService.validateToken(token);
        return ResponseEntity.ok(claims);
    }
}

What are the three endpoints?

  • POST /api/auth/register accepts a name, email, and password. Returns a JWT on successful registration.
  • POST /api/auth/login accepts email and password. Returns a JWT on successful authentication.
  • POST /api/auth/validate accepts a token string and returns its decoded claims. The gateway will use this to verify incoming tokens.
  • Save all files.
  • Make sure your Discovery Server is still running on port 8761.
  • Start the Auth Service by running the AuthServiceApplication main class from your IDE.
  • Open http://localhost:8761 in your browser. You should see AUTH-SERVICE listed in the Eureka dashboard under Instances currently registered with Eureka.

Auth Service not starting?

  • If you see a circular dependency error, make sure UserDetailsServiceImpl is annotated with @Service and is in a subpackage of com.orderms.auth.
  • If you see a bean creation error for JwtAuthenticationFilter, confirm that both JwtService and UserDetailsServiceImpl are in subpackages of com.orderms.auth so component scanning finds them.
  • If the service starts but does not appear in Eureka, check that eureka.client.service-url.defaultZone in application.yml matches the Discovery Server's address.

help me debug why my Auth Service won't start or register with Eureka

  • Test the register endpoint by sending a POST request. In your terminal, run:
curl -X POST http://localhost:9000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"name": "Test User", "email": "test@example.com", "password": "password123"}'

You should see a JSON response containing a token field with a long JWT string.

  • Test the login endpoint by running:
curl -X POST http://localhost:9000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email": "test@example.com", "password": "password123"}'

You should see another JSON response with a token field.

Getting a 403 or 401 on register?

If you get a 403 on the register endpoint, your security configuration is not permitting /api/auth/** correctly. Double check that SecurityConfig.java has .requestMatchers("/api/auth/**").permitAll() before .anyRequest().authenticated().

help me fix 403 errors on my Auth Service register endpoint

✔️ Awesome, I've got everything!

Great. Make sure you have saved all files and that the Auth Service is running on port 9000.

ⓧ I'd like to double check the full code

Here are the key files for reference. Compare against your own code.

package com.orderms.auth.security;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;

import javax.crypto.SecretKey;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;

@Service
public class JwtService {

    @Value("${app.security.jwt.secret-key}")
    private String secretKey;

    @Value("${app.security.jwt.expiration-ms}")
    private long jwtExpiration;

    public String extractUsername(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = extractAllClaims(token);
        return claimsResolver.apply(claims);
    }

    public String generateToken(UserDetails userDetails) {
        Map<String, Object> extraClaims = new HashMap<>();
        extraClaims.put("roles", userDetails.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.toList()));
        return generateToken(extraClaims, userDetails);
    }

    public String generateToken(Map<String, Object> extraClaims, UserDetails userDetails) {
        return Jwts.builder()
                .claims(extraClaims)
                .subject(userDetails.getUsername())
                .issuedAt(new Date(System.currentTimeMillis()))
                .expiration(new Date(System.currentTimeMillis() + jwtExpiration))
                .signWith(getSigningKey())
                .compact();
    }

    public boolean isTokenValid(String token, UserDetails userDetails) {
        final String username = extractUsername(token);
        return (username.equals(userDetails.getUsername())) && !isTokenExpired(token);
    }

    public boolean isTokenValid(String token) {
        try {
            extractAllClaims(token);
            return !isTokenExpired(token);
        } catch (Exception e) {
            return false;
        }
    }

    private boolean isTokenExpired(String token) {
        return extractExpiration(token).before(new Date());
    }

    private Date extractExpiration(String token) {
        return extractClaim(token, Claims::getExpiration);
    }

    private Claims extractAllClaims(String token) {
        return Jwts.parser()
                .verifyWith(getSigningKey())
                .build()
                .parseSignedClaims(token)
                .getPayload();
    }

    private SecretKey getSigningKey() {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        return Keys.hmacShaKeyFor(keyBytes);
    }
}
package com.orderms.auth.security;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.lang.NonNull;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtService jwtService;
    private final UserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(
            @NonNull HttpServletRequest request,
            @NonNull HttpServletResponse response,
            @NonNull FilterChain filterChain
    ) throws ServletException, IOException {
        final String authHeader = request.getHeader("Authorization");

        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }

        final String jwt = authHeader.substring(7);
        final String userEmail = jwtService.extractUsername(jwt);

        if (userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails userDetails = this.userDetailsService.loadUserByUsername(userEmail);

            if (jwtService.isTokenValid(jwt, userDetails)) {
                UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
                        userDetails, null, userDetails.getAuthorities()
                );
                authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(authToken);
            }
        }

        filterChain.doFilter(request, response);
    }
}
package com.orderms.auth.config;

import com.orderms.auth.security.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthFilter;
    private final UserDetailsService userDetailsService;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .anyRequest().authenticated()
            )
            .authenticationProvider(authenticationProvider())
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    @Bean
    public AuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
        authProvider.setUserDetailsService(userDetailsService);
        authProvider.setPasswordEncoder(passwordEncoder());
        return authProvider;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
        return config.getAuthenticationManager();
    }
}
package com.orderms.auth.controller;

import com.orderms.auth.dto.AuthResponse;
import com.orderms.auth.dto.LoginRequest;
import com.orderms.auth.dto.RegisterRequest;
import com.orderms.auth.service.AuthService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;

@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {

    private final AuthService authService;

    @PostMapping("/register")
    public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
        return ResponseEntity.status(HttpStatus.CREATED).body(authService.register(request));
    }

    @PostMapping("/login")
    public ResponseEntity<AuthResponse> login(@Valid @RequestBody LoginRequest request) {
        return ResponseEntity.ok(authService.login(request));
    }

    @PostMapping("/validate")
    public ResponseEntity<Map<String, Object>> validateToken(@RequestBody Map<String, String> request) {
        String token = request.get("token");
        Map<String, Object> claims = authService.validateToken(token);
        return ResponseEntity.ok(claims);
    }
}
package com.orderms.auth.exception;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleResourceNotFound(ResourceNotFoundException ex) {
        ErrorResponse error = new ErrorResponse(
                HttpStatus.NOT_FOUND.value(),
                ex.getMessage(),
                LocalDateTime.now()
        );
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
    }

    @ExceptionHandler(BadCredentialsException.class)
    public ResponseEntity<ErrorResponse> handleBadCredentials(BadCredentialsException ex) {
        ErrorResponse error = new ErrorResponse(
                HttpStatus.UNAUTHORIZED.value(),
                "Invalid email or password",
                LocalDateTime.now()
        );
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error);
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, Object>> handleValidationErrors(MethodArgumentNotValidException ex) {
        Map<String, Object> response = new HashMap<>();
        Map<String, String> errors = new HashMap<>();
        ex.getBindingResult().getAllErrors().forEach(error -> {
            String fieldName = ((FieldError) error).getField();
            String errorMessage = error.getDefaultMessage();
            errors.put(fieldName, errorMessage);
        });
        response.put("status", HttpStatus.BAD_REQUEST.value());
        response.put("errors", errors);
        response.put("timestamp", LocalDateTime.now().toString());
        return ResponseEntity.badRequest().body(response);
    }

    @ExceptionHandler(UserAlreadyExistsException.class)
    public ResponseEntity<ErrorResponse> handleUserAlreadyExists(UserAlreadyExistsException ex) {
        ErrorResponse error = new ErrorResponse(
                HttpStatus.CONFLICT.value(),
                ex.getMessage(),
                LocalDateTime.now()
        );
        return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
    }
}

Your Auth Service is live and issuing JWTs. Next up, you will build the Product Service that uses these tokens to control who can create, update, and delete products.

Build the Product Service

Your Auth Service handles registration and login. Now you need something for users to actually interact with once they're authenticated.

In this step, you'll build the Product Service. This is a standalone Spring Boot application with its own database, its own API, and its own security layer. It manages the product catalog that the Order Service will query later.

In this step, get ready to:
  • Create the Product Service module with its entity, repository, and seed data.
  • Build CRUD endpoints with DTOs, validation, and exception handling.
  • Secure endpoints with a lightweight header-based authentication filter.
Create the module, entity, and seed data

The Product Service is a self-contained microservice with its own H2 database. It follows the same structure as the Auth Service but focuses exclusively on managing products.

  • Create a new directory called product-service inside your order-management-system project root.
  • Create a pom.xml file inside product-service/ with the following content:
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>com.orderms</groupId>
        <artifactId>order-management-system</artifactId>
        <version>1.0.0</version>
    </parent>

    <artifactId>product-service</artifactId>

    <dependencies>
        <!-- Web framework for REST endpoints -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!-- JPA for database operations -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <!-- Security for role-based access control -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <!-- Bean validation for request DTOs -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <!-- In-memory database -->
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>runtime</scope>
        </dependency>
        <!-- Service discovery client -->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>
        <!-- Reduces boilerplate -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

How does this differ from the Auth Service?

The Product Service does not include JJWT dependencies. It relies on headers injected by the API Gateway instead of validating JWTs itself. This keeps downstream services lightweight.

  • Save product-service/pom.xml.
  • In your IDE, refresh or reimport the Maven project so it recognizes the new module.

Module not recognized by your IDE?

Make sure the product-service module is already listed in the parent pom.xml under <modules>. It was added in Step 1. If Maven still fails, try closing and reopening the project.

help me troubleshoot why my IDE doesn't recognize the product-service module in my Maven multi-module project

Now create the Product entity. This represents a single item in your product catalog.

  • Create the directory structure product-service/src/main/java/com/orderms/product/entity/.
  • Create Product.java in the entity package with the following code:
package com.orderms.product.entity;

import jakarta.persistence.*;
import lombok.*;

import java.math.BigDecimal;

@Entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // Display name for the product
    private String name;

    // Optional longer description
    private String description;

    // Unit price stored as BigDecimal for precision
    private BigDecimal price;

    // How many units are in stock
    private Integer stockQuantity;
}

Why BigDecimal for price?

Floating-point types like double introduce rounding errors with currency. BigDecimal gives exact decimal arithmetic, which is essential for anything involving money.

  • Create the repository package and add ProductRepository.java:
package com.orderms.product.repository;

import com.orderms.product.entity.Product;
import org.springframework.data.jpa.repository.JpaRepository;

// Spring Data JPA generates CRUD implementations at runtime
public interface ProductRepository extends JpaRepository<Product, Long> {
}
  • Save both files.

Next, configure the application and seed some sample products so you have data to work with immediately.

  • Create product-service/src/main/resources/application.yml with the following configuration:
server:
  port: 9001

spring:
  application:
    name: product-service
  datasource:
    url: jdbc:h2:mem:productdb
  jpa:
    hibernate:
      ddl-auto: create-drop

eureka:
  client:
    service-url:
      defaultZone: http://localhost:8761/eureka/

What does this configuration do?

  • Port 9001 keeps it separate from the Auth Service (9000) and Discovery Server (8761).
  • The productdb H2 database is completely isolated from the Auth Service's authdb. Each microservice owns its data.
  • The Eureka client registers this service so the API Gateway can discover it by name.
  • Create the main application class at product-service/src/main/java/com/orderms/product/ProductServiceApplication.java with seed data:
package com.orderms.product;

import com.orderms.product.entity.Product;
import com.orderms.product.repository.ProductRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;

import java.math.BigDecimal;

@SpringBootApplication
public class ProductServiceApplication {

    public static void main(String[] args) {
        SpringApplication.run(ProductServiceApplication.class, args);
    }

    // Seed sample products on startup so you have data to test with
    @Bean
    CommandLineRunner initData(ProductRepository productRepository) {
        return args -> {
            productRepository.save(Product.builder()
                    .name("Wireless Keyboard")
                    .description("Ergonomic wireless keyboard with backlight")
                    .price(new BigDecimal("49.99"))
                    .stockQuantity(100)
                    .build());

            productRepository.save(Product.builder()
                    .name("USB-C Hub")
                    .description("7-in-1 USB-C hub with HDMI and ethernet")
                    .price(new BigDecimal("35.99"))
                    .stockQuantity(50)
                    .build());

            productRepository.save(Product.builder()
                    .name("Mechanical Mouse")
                    .description("High-precision mechanical mouse for developers")
                    .price(new BigDecimal("29.99"))
                    .stockQuantity(75)
                    .build());
        };
    }
}

What is a CommandLineRunner?

A CommandLineRunner bean runs automatically after the application context loads. Here it inserts 3 sample products into the H2 database every time the service starts. This gives you test data without needing Postman or SQL scripts.

  • Save application.yml and ProductServiceApplication.java.
  • Make sure your Discovery Server is running, then start the Product Service. You should see it register as PRODUCT-SERVICE in the Eureka dashboard at http://localhost:8761.

Product Service won't start?

Check that the package structure matches com.orderms.product exactly. Spring Boot scans from the main class's package downward. If your entity is in a different root package, it won't be found.

help me debug why my Spring Boot Product Service fails to start in my multi-module project

Build the DTOs, service, and controller

With the entity and database ready, you need the layers that expose products as a REST API. This means request/response DTOs for clean separation, a service layer for business logic, and a controller for HTTP endpoints.

  • Create the dto package at product-service/src/main/java/com/orderms/product/dto/.
  • Add ProductRequest.java for incoming create/update requests:
package com.orderms.product.dto;

import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;

import java.math.BigDecimal;

// Incoming request shape for creating or updating a product
public record ProductRequest(
        @NotBlank String name,
        String description,
        @Positive BigDecimal price,
        @Min(0) Integer stockQuantity
) {}
  • Add ProductResponse.java for outgoing responses:
package com.orderms.product.dto;

import java.math.BigDecimal;

// Outgoing response shape sent back to clients
public record ProductResponse(
        Long id,
        String name,
        String description,
        BigDecimal price,
        Integer stockQuantity
) {}

What do these DTOs do?

DTOs (Data Transfer Objects) separate your API contract from your database entity. ProductRequest carries validation annotations like @NotBlank and @Positive that enforce input rules before your service logic runs.

ProductResponse controls exactly which fields clients see. You could add or remove entity fields without breaking the API.

  • Save both DTO files.

Before building the service layer, you need the exception classes it depends on.

  • Create the exception package and add ResourceNotFoundException.java:
package com.orderms.product.exception;

// Thrown when a product lookup fails
public class ResourceNotFoundException extends RuntimeException {
    public ResourceNotFoundException(String message) {
        super(message);
    }
}
  • Add ErrorResponse.java in the same package:
package com.orderms.product.exception;

import java.time.LocalDateTime;

// Standard error shape returned to clients
public record ErrorResponse(int status, String message, LocalDateTime timestamp) {}
  • Add GlobalExceptionHandler.java to translate exceptions into JSON responses:
package com.orderms.product.exception;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;

@RestControllerAdvice
public class GlobalExceptionHandler {

    // Returns 404 with a message when a product is not found
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleResourceNotFound(ResourceNotFoundException ex) {
        ErrorResponse error = new ErrorResponse(
                HttpStatus.NOT_FOUND.value(),
                ex.getMessage(),
                LocalDateTime.now()
        );
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
    }

    // Returns 400 with field-level validation errors
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, Object>> handleValidationErrors(MethodArgumentNotValidException ex) {
        Map<String, Object> response = new HashMap<>();
        Map<String, String> errors = new HashMap<>();
        ex.getBindingResult().getAllErrors().forEach(error -> {
            String fieldName = ((FieldError) error).getField();
            String errorMessage = error.getDefaultMessage();
            errors.put(fieldName, errorMessage);
        });
        response.put("status", HttpStatus.BAD_REQUEST.value());
        response.put("errors", errors);
        response.put("timestamp", LocalDateTime.now().toString());
        return ResponseEntity.badRequest().body(response);
    }
}

What does @RestControllerAdvice do?

This annotation makes the class a global exception handler for all controllers. Instead of writing try-catch blocks in every endpoint, Spring intercepts exceptions and routes them here. Each @ExceptionHandler method handles a specific exception type and returns a structured JSON error response.

  • Save all three exception files.

Now build the service layer that contains the CRUD business logic.

  • Create the service package and add ProductService.java:
package com.orderms.product.service;

import com.orderms.product.dto.ProductRequest;
import com.orderms.product.dto.ProductResponse;
import com.orderms.product.entity.Product;
import com.orderms.product.exception.ResourceNotFoundException;
import com.orderms.product.repository.ProductRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.stream.Collectors;

@Service
@RequiredArgsConstructor
public class ProductService {

    private final ProductRepository productRepository;

    // Fetches all products and converts to response DTOs
    public List<ProductResponse> getAllProducts() {
        return productRepository.findAll().stream()
                .map(this::mapToResponse)
                .collect(Collectors.toList());
    }

    // Finds a single product or throws 404
    public ProductResponse getProductById(Long id) {
        Product product = productRepository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Product not found with id: " + id));
        return mapToResponse(product);
    }

    // Creates a new product from the validated request
    public ProductResponse createProduct(ProductRequest request) {
        Product product = Product.builder()
                .name(request.name())
                .description(request.description())
                .price(request.price())
                .stockQuantity(request.stockQuantity())
                .build();
        Product saved = productRepository.save(product);
        return mapToResponse(saved);
    }

    // Updates an existing product or throws 404
    public ProductResponse updateProduct(Long id, ProductRequest request) {
        Product product = productRepository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Product not found with id: " + id));
        product.setName(request.name());
        product.setDescription(request.description());
        product.setPrice(request.price());
        product.setStockQuantity(request.stockQuantity());
        Product updated = productRepository.save(product);
        return mapToResponse(updated);
    }

    // Deletes a product or throws 404 if it doesn't exist
    public void deleteProduct(Long id) {
        if (!productRepository.existsById(id)) {
            throw new ResourceNotFoundException("Product not found with id: " + id);
        }
        productRepository.deleteById(id);
    }

    // Converts entity to response DTO
    private ProductResponse mapToResponse(Product product) {
        return new ProductResponse(
                product.getId(),
                product.getName(),
                product.getDescription(),
                product.getPrice(),
                product.getStockQuantity()
        );
    }
}

What does this service do?

  • getAllProducts() retrieves every product and maps each entity to a response DTO.
  • getProductById() throws ResourceNotFoundException if the ID doesn't exist. The GlobalExceptionHandler converts this into a 404 JSON response automatically.
  • createProduct() and updateProduct() use the Builder pattern to construct entities from validated request data.
  • Save ProductService.java.

Finally, wire everything together with the REST controller.

  • Create the controller package and add ProductController.java:
package com.orderms.product.controller;

import com.orderms.product.dto.ProductRequest;
import com.orderms.product.dto.ProductResponse;
import com.orderms.product.service.ProductService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/products")
@RequiredArgsConstructor
public class ProductController {

    private final ProductService productService;

    // Public: any authenticated user can list products
    @GetMapping
    public ResponseEntity<List<ProductResponse>> getAllProducts() {
        return ResponseEntity.ok(productService.getAllProducts());
    }

    // Public: any authenticated user can view a single product
    @GetMapping("/{id}")
    public ResponseEntity<ProductResponse> getProductById(@PathVariable Long id) {
        return ResponseEntity.ok(productService.getProductById(id));
    }

    // Admin only: create a new product
    @PostMapping
    @PreAuthorize("hasRole('ADMIN')")
    public ResponseEntity<ProductResponse> createProduct(@Valid @RequestBody ProductRequest request) {
        return ResponseEntity.status(HttpStatus.CREATED).body(productService.createProduct(request));
    }

    // Admin only: update an existing product
    @PutMapping("/{id}")
    @PreAuthorize("hasRole('ADMIN')")
    public ResponseEntity<ProductResponse> updateProduct(@PathVariable Long id, @Valid @RequestBody ProductRequest request) {
        return ResponseEntity.ok(productService.updateProduct(id, request));
    }

    // Admin only: delete a product
    @DeleteMapping("/{id}")
    @PreAuthorize("hasRole('ADMIN')")
    public ResponseEntity<Void> deleteProduct(@PathVariable Long id) {
        productService.deleteProduct(id);
        return ResponseEntity.noContent().build();
    }
}

What does @PreAuthorize do?

The @PreAuthorize("hasRole('ADMIN')") annotation blocks any request where the authenticated user's roles don't include ADMIN. GET endpoints remain open to all authenticated users. This is method-level security that works alongside the filter you'll build next.

  • Save ProductController.java.

Seeing compile errors?

If your IDE shows errors on @PreAuthorize, don't worry. The @EnableMethodSecurity annotation that activates it goes in the SecurityConfig you'll create in the next substep. The code will compile once both are in place.

help me resolve compile errors with @PreAuthorize in my Spring Boot Product Service

Secure with a header-based authentication filter

In a microservices architecture, the API Gateway validates JWTs and forwards trusted user information as HTTP headers. Downstream services like the Product Service only need to read those headers to know who is making the request.

Why not validate JWTs directly in the Product Service?

The gateway already validates the token and rejects invalid requests before they reach downstream services. Duplicating JWT validation in every service creates maintenance burden. If you change the JWT secret, you'd have to update every single service.

Instead, the gateway injects X-User-Email and X-User-Roles headers that downstream services trust. This is a common pattern in production microservices.

  • Create the security package at product-service/src/main/java/com/orderms/product/security/.
  • Add HeaderAuthenticationFilter.java:
package com.orderms.product.security;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.lang.NonNull;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

@Component
public class HeaderAuthenticationFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(
            @NonNull HttpServletRequest request,
            @NonNull HttpServletResponse response,
            @NonNull FilterChain filterChain
    ) throws ServletException, IOException {

        // Read headers injected by the API Gateway
        String userEmail = request.getHeader("X-User-Email");
        String userRoles = request.getHeader("X-User-Roles");

        if (userEmail != null && !userEmail.isEmpty()) {
            // Parse comma-separated roles into Spring Security authorities
            List<SimpleGrantedAuthority> authorities = List.of();
            if (userRoles != null && !userRoles.isEmpty()) {
                authorities = Arrays.stream(userRoles.split(","))
                        .map(role -> new SimpleGrantedAuthority(role.trim()))
                        .collect(Collectors.toList());
            }

            // Set the authenticated user in Spring's SecurityContext
            UsernamePasswordAuthenticationToken authToken =
                    new UsernamePasswordAuthenticationToken(userEmail, null, authorities);
            SecurityContextHolder.getContext().setAuthentication(authToken);
        }

        filterChain.doFilter(request, response);
    }
}

What does this filter do?

  • It reads X-User-Email and X-User-Roles from the request headers.
  • It converts the comma-separated roles into SimpleGrantedAuthority objects that Spring Security understands.
  • It sets the authentication in SecurityContextHolder so @PreAuthorize annotations can check roles downstream.
  • Save HeaderAuthenticationFilter.java.
  • Now create the config package and add SecurityConfig.java to register the filter:
package com.orderms.product.config;

import com.orderms.product.security.HeaderAuthenticationFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final HeaderAuthenticationFilter headerAuthFilter;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            // Disable CSRF since this is a stateless API
            .csrf(AbstractHttpConfigurer::disable)
            // No sessions needed
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            // Permit all at the filter chain level; @PreAuthorize handles role checks
            .authorizeHttpRequests(auth -> auth
                .anyRequest().permitAll()
            )
            // Run our header filter before Spring's default auth filter
            .addFilterBefore(headerAuthFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

Why permit all requests at the filter chain level?

The gateway already blocks unauthenticated requests. The Product Service permits everything through its filter chain and delegates authorization to @PreAuthorize annotations on individual controller methods. This keeps the security configuration simple while still enforcing role-based access.

The @EnableMethodSecurity annotation activates method-level security annotations like @PreAuthorize across all controllers.

  • Save SecurityConfig.java.
  • Restart the Product Service. It should start without errors and remain visible in the Eureka dashboard.

Seeing a Spring Security error on startup?

If you see an error about missing UserDetailsService, check that your SecurityConfig does NOT reference one. Unlike the Auth Service, the Product Service doesn't need a UserDetailsService because it trusts gateway headers instead of validating credentials.

help me fix Spring Security startup errors in my Product Service that uses header-based authentication

✔️ Awesome, I've got everything!

Great. Double check you've saved all files in the product-service module and that it starts cleanly.

ⓧ I'd like to double check the full code

Here are the key files for your Product Service module:

<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>com.orderms</groupId>
        <artifactId>order-management-system</artifactId>
        <version>1.0.0</version>
    </parent>

    <artifactId>product-service</artifactId>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>
package com.orderms.product.entity;

import jakarta.persistence.*;
import lombok.*;

import java.math.BigDecimal;

@Entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private String description;
    private BigDecimal price;
    private Integer stockQuantity;
}
package com.orderms.product.repository;

import com.orderms.product.entity.Product;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ProductRepository extends JpaRepository<Product, Long> {
}
server:
  port: 9001

spring:
  application:
    name: product-service
  datasource:
    url: jdbc:h2:mem:productdb
  jpa:
    hibernate:
      ddl-auto: create-drop

eureka:
  client:
    service-url:
      defaultZone: http://localhost:8761/eureka/
package com.orderms.product;

import com.orderms.product.entity.Product;
import com.orderms.product.repository.ProductRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;

import java.math.BigDecimal;

@SpringBootApplication
public class ProductServiceApplication {

    public static void main(String[] args) {
        SpringApplication.run(ProductServiceApplication.class, args);
    }

    @Bean
    CommandLineRunner initData(ProductRepository productRepository) {
        return args -> {
            productRepository.save(Product.builder()
                    .name("Wireless Keyboard")
                    .description("Ergonomic wireless keyboard with backlight")
                    .price(new BigDecimal("49.99"))
                    .stockQuantity(100)
                    .build());

            productRepository.save(Product.builder()
                    .name("USB-C Hub")
                    .description("7-in-1 USB-C hub with HDMI and ethernet")
                    .price(new BigDecimal("35.99"))
                    .stockQuantity(50)
                    .build());

            productRepository.save(Product.builder()
                    .name("Mechanical Mouse")
                    .description("High-precision mechanical mouse for developers")
                    .price(new BigDecimal("29.99"))
                    .stockQuantity(75)
                    .build());
        };
    }
}
package com.orderms.product.dto;

import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;

import java.math.BigDecimal;

public record ProductRequest(
        @NotBlank String name,
        String description,
        @Positive BigDecimal price,
        @Min(0) Integer stockQuantity
) {}
package com.orderms.product.dto;

import java.math.BigDecimal;

public record ProductResponse(
        Long id,
        String name,
        String description,
        BigDecimal price,
        Integer stockQuantity
) {}
package com.orderms.product.exception;

public class ResourceNotFoundException extends RuntimeException {
    public ResourceNotFoundException(String message) {
        super(message);
    }
}
package com.orderms.product.exception;

import java.time.LocalDateTime;

public record ErrorResponse(int status, String message, LocalDateTime timestamp) {}
package com.orderms.product.exception;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleResourceNotFound(ResourceNotFoundException ex) {
        ErrorResponse error = new ErrorResponse(
                HttpStatus.NOT_FOUND.value(),
                ex.getMessage(),
                LocalDateTime.now()
        );
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, Object>> handleValidationErrors(MethodArgumentNotValidException ex) {
        Map<String, Object> response = new HashMap<>();
        Map<String, String> errors = new HashMap<>();
        ex.getBindingResult().getAllErrors().forEach(error -> {
            String fieldName = ((FieldError) error).getField();
            String errorMessage = error.getDefaultMessage();
            errors.put(fieldName, errorMessage);
        });
        response.put("status", HttpStatus.BAD_REQUEST.value());
        response.put("errors", errors);
        response.put("timestamp", LocalDateTime.now().toString());
        return ResponseEntity.badRequest().body(response);
    }
}
package com.orderms.product.service;

import com.orderms.product.dto.ProductRequest;
import com.orderms.product.dto.ProductResponse;
import com.orderms.product.entity.Product;
import com.orderms.product.exception.ResourceNotFoundException;
import com.orderms.product.repository.ProductRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.stream.Collectors;

@Service
@RequiredArgsConstructor
public class ProductService {

    private final ProductRepository productRepository;

    public List<ProductResponse> getAllProducts() {
        return productRepository.findAll().stream()
                .map(this::mapToResponse)
                .collect(Collectors.toList());
    }

    public ProductResponse getProductById(Long id) {
        Product product = productRepository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Product not found with id: " + id));
        return mapToResponse(product);
    }

    public ProductResponse createProduct(ProductRequest request) {
        Product product = Product.builder()
                .name(request.name())
                .description(request.description())
                .price(request.price())
                .stockQuantity(request.stockQuantity())
                .build();
        Product saved = productRepository.save(product);
        return mapToResponse(saved);
    }

    public ProductResponse updateProduct(Long id, ProductRequest request) {
        Product product = productRepository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Product not found with id: " + id));
        product.setName(request.name());
        product.setDescription(request.description());
        product.setPrice(request.price());
        product.setStockQuantity(request.stockQuantity());
        Product updated = productRepository.save(product);
        return mapToResponse(updated);
    }

    public void deleteProduct(Long id) {
        if (!productRepository.existsById(id)) {
            throw new ResourceNotFoundException("Product not found with id: " + id);
        }
        productRepository.deleteById(id);
    }

    private ProductResponse mapToResponse(Product product) {
        return new ProductResponse(
                product.getId(),
                product.getName(),
                product.getDescription(),
                product.getPrice(),
                product.getStockQuantity()
        );
    }
}
package com.orderms.product.controller;

import com.orderms.product.dto.ProductRequest;
import com.orderms.product.dto.ProductResponse;
import com.orderms.product.service.ProductService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/products")
@RequiredArgsConstructor
public class ProductController {

    private final ProductService productService;

    @GetMapping
    public ResponseEntity<List<ProductResponse>> getAllProducts() {
        return ResponseEntity.ok(productService.getAllProducts());
    }

    @GetMapping("/{id}")
    public ResponseEntity<ProductResponse> getProductById(@PathVariable Long id) {
        return ResponseEntity.ok(productService.getProductById(id));
    }

    @PostMapping
    @PreAuthorize("hasRole('ADMIN')")
    public ResponseEntity<ProductResponse> createProduct(@Valid @RequestBody ProductRequest request) {
        return ResponseEntity.status(HttpStatus.CREATED).body(productService.createProduct(request));
    }

    @PutMapping("/{id}")
    @PreAuthorize("hasRole('ADMIN')")
    public ResponseEntity<ProductResponse> updateProduct(@PathVariable Long id, @Valid @RequestBody ProductRequest request) {
        return ResponseEntity.ok(productService.updateProduct(id, request));
    }

    @DeleteMapping("/{id}")
    @PreAuthorize("hasRole('ADMIN')")
    public ResponseEntity<Void> deleteProduct(@PathVariable Long id) {
        productService.deleteProduct(id);
        return ResponseEntity.noContent().build();
    }
}
package com.orderms.product.security;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.lang.NonNull;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

@Component
public class HeaderAuthenticationFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(
            @NonNull HttpServletRequest request,
            @NonNull HttpServletResponse response,
            @NonNull FilterChain filterChain
    ) throws ServletException, IOException {

        String userEmail = request.getHeader("X-User-Email");
        String userRoles = request.getHeader("X-User-Roles");

        if (userEmail != null && !userEmail.isEmpty()) {
            List<SimpleGrantedAuthority> authorities = List.of();
            if (userRoles != null && !userRoles.isEmpty()) {
                authorities = Arrays.stream(userRoles.split(","))
                        .map(role -> new SimpleGrantedAuthority(role.trim()))
                        .collect(Collectors.toList());
            }

            UsernamePasswordAuthenticationToken authToken =
                    new UsernamePasswordAuthenticationToken(userEmail, null, authorities);
            SecurityContextHolder.getContext().setAuthentication(authToken);
        }

        filterChain.doFilter(request, response);
    }
}
package com.orderms.product.config;

import com.orderms.product.security.HeaderAuthenticationFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final HeaderAuthenticationFilter headerAuthFilter;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .anyRequest().permitAll()
            )
            .addFilterBefore(headerAuthFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

Your Product Service is live with CRUD endpoints, validation, and role-based security. Next up, you'll build the API Gateway that sits in front of all services, validates JWTs, and routes requests to the right destination.

Build the API Gateway

Your Auth Service and Product Service are registered in Eureka, but clients still need to know the exact port for each service. In production, you never expose individual microservice ports to the outside world.

An API Gateway solves this by acting as the single entry point on one known port. It routes requests to the correct downstream service, validates JWT tokens centrally, and injects user identity headers so downstream services don't duplicate auth logic.

In this step, get ready to:
  • Create the API Gateway module with Spring Cloud Gateway WebMVC and JJWT dependencies.
  • Implement a JWT authentication filter that validates tokens and injects user headers.
  • Test the full request flow through the gateway on port 8080.
Create the gateway module and configure routes

The gateway needs four key dependencies: the Spring Cloud Gateway WebMVC starter for routing, the Eureka client for service discovery, Spring Security for the filter chain, and JJWT for token validation.

  • Create a new directory at api-gateway/ inside your order-management-system project folder.
  • Create the file api-gateway/pom.xml with the following content:
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>com.orderms</groupId>
        <artifactId>order-management-system</artifactId>
        <version>1.0.0</version>
    </parent>

    <artifactId>api-gateway</artifactId>

    <dependencies>
        <!-- Spring Cloud Gateway WebMVC for servlet-based routing -->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-gateway-server-webmvc</artifactId>
        </dependency>

        <!-- Eureka client for service discovery -->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>

        <!-- Spring Security for filter chain -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

        <!-- JJWT for token validation -->
        <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>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

What does this POM do?

The spring-cloud-starter-gateway-server-webmvc artifact is the MVC (Servlet-based) variant of Spring Cloud Gateway. It runs on Tomcat and aligns with the rest of your services which all use Spring MVC.

The ${jjwt.version} placeholder resolves to 0.13.0 from the parent POM's properties section. This keeps version numbers consistent across all modules.

  • Create the main application class at api-gateway/src/main/java/com/orderms/gateway/ApiGatewayApplication.java:
package com.orderms.gateway;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class ApiGatewayApplication {
    public static void main(String[] args) {
        SpringApplication.run(ApiGatewayApplication.class, args);
    }
}

Now configure the gateway's routing rules and Eureka registration.

  • Create api-gateway/src/main/resources/application.yml with the following configuration:
server:
  port: 8080

spring:
  application:
    name: api-gateway
  cloud:
    gateway:
      server:
        webmvc:
          routes:
            - id: auth-service
              uri: lb://AUTH-SERVICE
              predicates:
                - Path=/api/auth/**
            - id: product-service
              uri: lb://PRODUCT-SERVICE
              predicates:
                - Path=/api/products/**
            - id: order-service
              uri: lb://ORDER-SERVICE
              predicates:
                - Path=/api/orders/**

app:
  security:
    jwt:
      secret-key: ${JWT_SECRET:bXlzdXBlcnNlY3JldGtleXRoYXRpc2F0bGVhc3QyNTZiaXRzbG9uZ2Zvckhtcy0yNTY=}

eureka:
  client:
    service-url:
      defaultZone: http://localhost:8761/eureka/

What do these routes do?

Each route maps a URL path prefix to a downstream service using Eureka's logical name. The lb:// prefix tells the gateway to use client-side load balancing via service discovery.

The app.security.jwt.secret-key uses the same Base64-encoded key as the Auth Service. Both services must share the same secret so the gateway can validate tokens the Auth Service generated.

  • Save both files.
  • In your IDE, refresh the Maven project so it recognises the new api-gateway module.

Maven not recognising the module?

Make sure the parent POM already lists <module>api-gateway</module> in its <modules> section. It was added in Step 1. Right-click the parent pom.xml and reload the Maven project.

help me fix Maven module resolution for the api-gateway

Implement the JWT authentication filter

The gateway needs its own JWT validation logic. It reads the token from incoming requests, verifies the signature, and extracts the user's email and roles to pass downstream as headers.

  • Create the gateway's JWT service at api-gateway/src/main/java/com/orderms/gateway/security/JwtService.java:
package com.orderms.gateway.security;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import javax.crypto.SecretKey;
import java.util.Date;
import java.util.List;
import java.util.function.Function;

@Service
public class JwtService {

    @Value("${app.security.jwt.secret-key}")
    private String secretKey;

    // Extract the subject (email) from the token
    public String extractUsername(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    // Extract roles as a comma-separated string for the downstream header
    @SuppressWarnings("unchecked")
    public String extractRoles(String token) {
        Claims claims = extractAllClaims(token);
        List<String> roles = claims.get("roles", List.class);
        return roles != null ? String.join(",", roles) : "";
    }

    // Generic claim extractor
    public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = extractAllClaims(token);
        return claimsResolver.apply(claims);
    }

    // Validate token structure and expiration
    public boolean isTokenValid(String token) {
        try {
            extractAllClaims(token);
            return !isTokenExpired(token);
        } catch (Exception e) {
            return false;
        }
    }

    private boolean isTokenExpired(String token) {
        return extractClaim(token, Claims::getExpiration).before(new Date());
    }

    private Claims extractAllClaims(String token) {
        return Jwts.parser()
                .verifyWith(getSigningKey())
                .build()
                .parseSignedClaims(token)
                .getPayload();
    }

    private SecretKey getSigningKey() {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        return Keys.hmacShaKeyFor(keyBytes);
    }
}

Why duplicate the JwtService?

Each microservice is an independent deployment unit with its own classpath. The gateway cannot import classes from the Auth Service directly. This gateway version is stripped down to validation and extraction only. It does not generate tokens.

Next, build the filter that uses this service to intercept every request.

  • Create api-gateway/src/main/java/com/orderms/gateway/security/JwtAuthenticationFilter.java:
package com.orderms.gateway.security;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.lang.NonNull;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.*;
import java.util.stream.Collectors;

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtService jwtService;

    @Override
    protected void doFilterInternal(
            @NonNull HttpServletRequest request,
            @NonNull HttpServletResponse response,
            @NonNull FilterChain filterChain
    ) throws ServletException, IOException {
        // Skip authentication for public auth endpoints
        String path = request.getRequestURI();
        if (path.equals("/api/auth/register") || path.equals("/api/auth/login")) {
            filterChain.doFilter(request, response);
            return;
        }

        // Extract the Authorization header
        final String authHeader = request.getHeader("Authorization");
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            sendUnauthorizedResponse(response, "Missing or invalid Authorization header");
            return;
        }

        final String token = authHeader.substring(7);

        // Validate the token
        if (!jwtService.isTokenValid(token)) {
            sendUnauthorizedResponse(response, "Invalid or expired token");
            return;
        }

        // Extract user info from the token
        String userEmail = jwtService.extractUsername(token);
        String roles = jwtService.extractRoles(token);

        // Set SecurityContext so Spring Security allows the request through
        List<SimpleGrantedAuthority> authorities = Arrays.stream(roles.split(","))
                .filter(role -> !role.isBlank())
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());

        UsernamePasswordAuthenticationToken authentication =
                new UsernamePasswordAuthenticationToken(userEmail, null, authorities);
        SecurityContextHolder.getContext().setAuthentication(authentication);

        // Wrap request to inject user headers for downstream services
        HttpServletRequestWrapper wrappedRequest = new HttpServletRequestWrapper(request) {
            @Override
            public String getHeader(String name) {
                if ("X-User-Email".equalsIgnoreCase(name)) return userEmail;
                if ("X-User-Roles".equalsIgnoreCase(name)) return roles;
                return super.getHeader(name);
            }

            @Override
            public Enumeration<String> getHeaders(String name) {
                if ("X-User-Email".equalsIgnoreCase(name))
                    return Collections.enumeration(List.of(userEmail));
                if ("X-User-Roles".equalsIgnoreCase(name))
                    return Collections.enumeration(List.of(roles));
                return super.getHeaders(name);
            }

            @Override
            public Enumeration<String> getHeaderNames() {
                List<String> names = new ArrayList<>(Collections.list(super.getHeaderNames()));
                names.add("X-User-Email");
                names.add("X-User-Roles");
                return Collections.enumeration(names);
            }
        };

        filterChain.doFilter(wrappedRequest, response);
    }

    // Return a structured 401 JSON response
    private void sendUnauthorizedResponse(HttpServletResponse response, String message) throws IOException {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.getWriter().write(
                "{\"status\":401,\"error\":\"Unauthorized\",\"message\":\"" + message + "\"}"
        );
    }
}

What does this filter do?

  • Skips /api/auth/register and /api/auth/login so unauthenticated users can create accounts and log in.
  • Extracts the Bearer token from the Authorization header and validates it with the shared secret.
  • Sets the SecurityContext so Spring Security's .anyRequest().authenticated() check passes.
  • Wraps the request with X-User-Email and X-User-Roles headers so downstream services (like the Product Service) know who is making the request.
  • Save both files.

Import errors on JJWT classes?

If your IDE shows red underlines on io.jsonwebtoken imports, reload the Maven project. The JJWT jars are pulled in through the api-gateway/pom.xml you just created.

help me resolve JJWT import errors in the API Gateway module

Configure gateway security and test the full flow

The last piece is the SecurityConfig that wires the filter into Spring Security's chain and defines which paths are public.

  • Create api-gateway/src/main/java/com/orderms/gateway/config/SecurityConfig.java:
package com.orderms.gateway.config;

import com.orderms.gateway.security.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthFilter;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            // Disable CSRF since we use stateless JWT auth
            .csrf(AbstractHttpConfigurer::disable)
            // No HTTP sessions needed
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            // Public endpoints that skip JWT validation
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/register", "/api/auth/login").permitAll()
                .anyRequest().authenticated()
            )
            // Run our JWT filter before Spring's default auth filter
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

What does this config do?

This mirrors the Auth Service's security config pattern: CSRF off, stateless sessions, and a custom filter inserted before UsernamePasswordAuthenticationFilter.

The requestMatchers for register and login are redundant with the filter's skip logic, but they serve as a safety net. Spring Security won't block those paths even if the filter misbehaves.

  • Save the file.

Now test the entire request flow through the gateway.

  • Start all services in this order: Discovery Server, Auth Service, Product Service, API Gateway.
  • Open your browser and navigate to http://localhost:8761.
  • Verify that AUTH-SERVICE, PRODUCT-SERVICE, and API-GATEWAY all appear in the Eureka dashboard.

Test the authentication flow using Postman or curl.

  • Send a POST request to register a new user by running:
curl -X POST http://localhost:8080/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"test@example.com","password":"password123","name":"Test User"}'
  • You should receive a JSON response containing a token field with your JWT.
  • Send a POST request to log in by running:
curl -X POST http://localhost:8080/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"test@example.com","password":"password123"}'
  • Copy the token value from the response.
  • Fetch the product list through the gateway by running (replace your-jwt-token-here with the token you copied):
curl http://localhost:8080/api/products \
  -H "Authorization: Bearer your-jwt-token-here"
  • You should see a JSON array of the sample products seeded by the Product Service.
  • Test that unauthenticated requests are blocked by running:
curl http://localhost:8080/api/products
  • You should receive a 401 response with the message Missing or invalid Authorization header.

Getting 401 even with a valid token?

Make sure the app.security.jwt.secret-key value in api-gateway/src/main/resources/application.yml exactly matches the key in auth-service/src/main/resources/application.yml. A single character difference means the gateway cannot verify tokens the Auth Service signed.

Also check that the token hasn't expired. The Auth Service issues tokens with a 24-hour expiration, so a freshly generated token should work.

help me debug 401 errors when accessing the gateway with my JWT

✔️ Awesome, I've got everything!

Great. Double check you've saved all files in the api-gateway module.

ⓧ I'd like to double check the full code

Here is the complete application.yml for the API Gateway:

server:
  port: 8080

spring:
  application:
    name: api-gateway
  cloud:
    gateway:
      server:
        webmvc:
          routes:
            - id: auth-service
              uri: lb://AUTH-SERVICE
              predicates:
                - Path=/api/auth/**
            - id: product-service
              uri: lb://PRODUCT-SERVICE
              predicates:
                - Path=/api/products/**
            - id: order-service
              uri: lb://ORDER-SERVICE
              predicates:
                - Path=/api/orders/**

app:
  security:
    jwt:
      secret-key: ${JWT_SECRET:bXlzdXBlcnNlY3JldGtleXRoYXRpc2F0bGVhc3QyNTZiaXRzbG9uZ2Zvckhtcy0yNTY=}

eureka:
  client:
    service-url:
      defaultZone: http://localhost:8761/eureka/

Here is the complete JwtService.java:

package com.orderms.gateway.security;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import javax.crypto.SecretKey;
import java.util.Date;
import java.util.List;
import java.util.function.Function;

@Service
public class JwtService {

    @Value("${app.security.jwt.secret-key}")
    private String secretKey;

    public String extractUsername(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    @SuppressWarnings("unchecked")
    public String extractRoles(String token) {
        Claims claims = extractAllClaims(token);
        List<String> roles = claims.get("roles", List.class);
        return roles != null ? String.join(",", roles) : "";
    }

    public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = extractAllClaims(token);
        return claimsResolver.apply(claims);
    }

    public boolean isTokenValid(String token) {
        try {
            extractAllClaims(token);
            return !isTokenExpired(token);
        } catch (Exception e) {
            return false;
        }
    }

    private boolean isTokenExpired(String token) {
        return extractClaim(token, Claims::getExpiration).before(new Date());
    }

    private Claims extractAllClaims(String token) {
        return Jwts.parser()
                .verifyWith(getSigningKey())
                .build()
                .parseSignedClaims(token)
                .getPayload();
    }

    private SecretKey getSigningKey() {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        return Keys.hmacShaKeyFor(keyBytes);
    }
}

Here is the complete JwtAuthenticationFilter.java:

package com.orderms.gateway.security;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.lang.NonNull;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.*;
import java.util.stream.Collectors;

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtService jwtService;

    @Override
    protected void doFilterInternal(
            @NonNull HttpServletRequest request,
            @NonNull HttpServletResponse response,
            @NonNull FilterChain filterChain
    ) throws ServletException, IOException {
        String path = request.getRequestURI();
        if (path.equals("/api/auth/register") || path.equals("/api/auth/login")) {
            filterChain.doFilter(request, response);
            return;
        }

        final String authHeader = request.getHeader("Authorization");
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            sendUnauthorizedResponse(response, "Missing or invalid Authorization header");
            return;
        }

        final String token = authHeader.substring(7);

        if (!jwtService.isTokenValid(token)) {
            sendUnauthorizedResponse(response, "Invalid or expired token");
            return;
        }

        String userEmail = jwtService.extractUsername(token);
        String roles = jwtService.extractRoles(token);

        List<SimpleGrantedAuthority> authorities = Arrays.stream(roles.split(","))
                .filter(role -> !role.isBlank())
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());

        UsernamePasswordAuthenticationToken authentication =
                new UsernamePasswordAuthenticationToken(userEmail, null, authorities);
        SecurityContextHolder.getContext().setAuthentication(authentication);

        HttpServletRequestWrapper wrappedRequest = new HttpServletRequestWrapper(request) {
            @Override
            public String getHeader(String name) {
                if ("X-User-Email".equalsIgnoreCase(name)) return userEmail;
                if ("X-User-Roles".equalsIgnoreCase(name)) return roles;
                return super.getHeader(name);
            }

            @Override
            public Enumeration<String> getHeaders(String name) {
                if ("X-User-Email".equalsIgnoreCase(name))
                    return Collections.enumeration(List.of(userEmail));
                if ("X-User-Roles".equalsIgnoreCase(name))
                    return Collections.enumeration(List.of(roles));
                return super.getHeaders(name);
            }

            @Override
            public Enumeration<String> getHeaderNames() {
                List<String> names = new ArrayList<>(Collections.list(super.getHeaderNames()));
                names.add("X-User-Email");
                names.add("X-User-Roles");
                return Collections.enumeration(names);
            }
        };

        filterChain.doFilter(wrappedRequest, response);
    }

    private void sendUnauthorizedResponse(HttpServletResponse response, String message) throws IOException {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.getWriter().write(
                "{\"status\":401,\"error\":\"Unauthorized\",\"message\":\"" + message + "\"}"
        );
    }
}

Here is the complete SecurityConfig.java:

package com.orderms.gateway.config;

import com.orderms.gateway.security.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthFilter;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/register", "/api/auth/login").permitAll()
                .anyRequest().authenticated()
            )
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

Your gateway is live and securing all traffic. Next up, you'll build the Order Service that calls the Product Service through this gateway to create orders end-to-end.

Build the Order Service with Inter-Service Communication

Your gateway routes requests, your auth service issues tokens, and your product service manages inventory. The missing piece is the Order Service that ties everything together.

In this step, you will build the Order Service that creates orders by calling the Product Service through the gateway. This is where inter-service communication comes to life. The Order Service makes an HTTP call to verify product availability before confirming an order.

In this step, get ready to:
  • Create the Order Service module with its entity, repository, and configuration.
  • Build DTOs and a ProductClient that calls the Product Service through the API Gateway.
  • Implement the OrderService and OrderController with full exception handling for inter-service failures.
Create the Order Service module

The Order Service needs its own module directory, POM file, entity, repository, and configuration. It runs on port 9002 and registers with Eureka like the other services.

  • Create a new directory called order-service inside your order-management-system project root.
  • Inside order-service, create the pom.xml file with the following content:
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>com.orderms</groupId>
        <artifactId>order-management-system</artifactId>
        <version>1.0.0</version>
    </parent>

    <artifactId>order-service</artifactId>

    <dependencies>
        <!-- Web framework for REST controllers -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- JPA for database access -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>

        <!-- Security for method-level annotations -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

        <!-- Validation for request DTOs -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>

        <!-- In-memory database -->
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>runtime</scope>
        </dependency>

        <!-- Eureka client for service discovery -->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>

        <!-- Lombok for reducing boilerplate -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

What does this POM do?

This child POM inherits from the parent order-management-system project. It pulls in Spring Web, JPA, Security, Validation, the H2 database, Eureka client, and Lombok. The parent's dependencyManagement block ensures Spring Cloud versions are consistent across all modules.

  • Create the directory structure order-service/src/main/java/com/orderms/order/ and order-service/src/main/resources/.
  • Create the application.yml inside order-service/src/main/resources/:
server:
  port: 9002

spring:
  application:
    name: order-service
  datasource:
    url: jdbc:h2:mem:orderdb
  jpa:
    hibernate:
      ddl-auto: create-drop

eureka:
  client:
    service-url:
      defaultZone: http://localhost:8761/eureka/

What does this configuration do?

The Order Service runs on port 9002 with an in-memory H2 database called orderdb. The ddl-auto: create-drop setting tells Hibernate to create tables on startup and drop them on shutdown. The Eureka client configuration registers this service with the Discovery Server.

  • Create the OrderStatus enum at order-service/src/main/java/com/orderms/order/entity/OrderStatus.java:
package com.orderms.order.entity;

public enum OrderStatus {
    PENDING, CONFIRMED, FAILED
}
  • Create the Order entity at order-service/src/main/java/com/orderms/order/entity/Order.java:
package com.orderms.order.entity;

import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.math.BigDecimal;
import java.time.LocalDateTime;

// JPA entity representing a customer order
@Entity
@Table(name = "orders")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // Email of the user who placed the order
    private String userEmail;

    // ID of the product being ordered
    private Long productId;

    // Number of items ordered
    private Integer quantity;

    // Total cost (price * quantity)
    private BigDecimal totalPrice;

    // Current status of the order
    @Enumerated(EnumType.STRING)
    private OrderStatus status;

    // When the order was placed
    private LocalDateTime orderDate;
}

What does this entity do?

The Order entity maps to an orders table in the H2 database. It stores who placed the order, which product, how many, the calculated total price, and the current status. The @Enumerated(EnumType.STRING) annotation stores the status as a readable string rather than an integer.

  • Create the OrderRepository interface at order-service/src/main/java/com/orderms/order/repository/OrderRepository.java:
package com.orderms.order.repository;

import com.orderms.order.entity.Order;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

// Spring Data JPA repository for Order entities
public interface OrderRepository extends JpaRepository<Order, Long> {
    // Custom query method to find all orders by a specific user
    List<Order> findByUserEmail(String userEmail);
}
  • Create the main application class at order-service/src/main/java/com/orderms/order/OrderServiceApplication.java:
package com.orderms.order;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class OrderServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderServiceApplication.class, args);
    }
}
  • Since spring-boot-starter-security is on the classpath, you need a SecurityConfig that permits all requests. The gateway already handles authentication. Create order-service/src/main/java/com/orderms/order/config/SecurityConfig.java:
package com.orderms.order.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.web.SecurityFilterChain;

// Permits all requests since the API Gateway handles JWT validation
@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(AbstractHttpConfigurer::disable)
            .authorizeHttpRequests(auth -> auth.anyRequest().permitAll());
        return http.build();
    }
}

Why permit all requests here?

The API Gateway already validates JWT tokens and only forwards authenticated requests to downstream services. It injects X-User-Email and X-User-Roles headers into forwarded requests. The Order Service trusts these headers, so it does not need its own token validation.

  • Save all files in the order-service module.
  • Reload your Maven project in your IDE to pick up the new module. In IntelliJ, right-click the parent pom.xml and select Maven then Reload Project.

Module not recognized by your IDE?

Make sure the order-service module is already listed in the parent pom.xml under <modules>. The parent POM from Step 1 already declares it. If your IDE still does not recognize the module, close and reopen the project.

Need help with the module setup?

Build DTOs and the ProductClient

The Order Service needs to communicate with the Product Service to verify that a product exists and has enough stock. It does this by making an HTTP call through the API Gateway using Spring's RestClient.

  • Create the ProductResponse DTO at order-service/src/main/java/com/orderms/order/dto/ProductResponse.java. This mirrors the Product Service's response shape:
package com.orderms.order.dto;

import java.math.BigDecimal;

// Mirrors the Product Service response so we can deserialize it
public record ProductResponse(
    Long id,
    String name,
    String description,
    BigDecimal price,
    Integer stockQuantity
) {}
  • Create the OrderRequest DTO at order-service/src/main/java/com/orderms/order/dto/OrderRequest.java:
package com.orderms.order.dto;

import jakarta.validation.constraints.Min;

// Incoming request payload for creating an order
public record OrderRequest(
    Long productId,
    @Min(1) Integer quantity
) {}
  • Create the OrderResponse DTO at order-service/src/main/java/com/orderms/order/dto/OrderResponse.java:
package com.orderms.order.dto;

import lombok.Builder;

import java.math.BigDecimal;
import java.time.LocalDateTime;

// Outgoing response payload representing a created order
@Builder
public record OrderResponse(
    Long id,
    String userEmail,
    Long productId,
    Integer quantity,
    BigDecimal totalPrice,
    String status,
    LocalDateTime orderDate
) {}

Why use Java records for DTOs?

Records are immutable data carriers introduced in Java 16. They automatically generate constructors, getters, equals(), hashCode(), and toString(). This makes them ideal for DTOs where you just need to carry data between layers without extra boilerplate.

  • Create the custom exception classes. First, ResourceNotFoundException at order-service/src/main/java/com/orderms/order/exception/ResourceNotFoundException.java:
package com.orderms.order.exception;

// Thrown when a requested resource (e.g., product) does not exist
public class ResourceNotFoundException extends RuntimeException {
    public ResourceNotFoundException(String message) {
        super(message);
    }
}
  • Create ProductServiceUnavailableException at order-service/src/main/java/com/orderms/order/exception/ProductServiceUnavailableException.java:
package com.orderms.order.exception;

// Thrown when the Product Service is unreachable or returns a 5xx error
public class ProductServiceUnavailableException extends RuntimeException {
    public ProductServiceUnavailableException(String message) {
        super(message);
    }
}

Now you can build the ProductClient that makes HTTP calls to the Product Service through the API Gateway.

  • Create ProductClient at order-service/src/main/java/com/orderms/order/client/ProductClient.java:
package com.orderms.order.client;

import com.orderms.order.dto.ProductResponse;
import com.orderms.order.exception.ProductServiceUnavailableException;
import com.orderms.order.exception.ResourceNotFoundException;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatusCode;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;

@Component
public class ProductClient {

    private final RestClient restClient;

    public ProductClient(RestClient.Builder restClientBuilder) {
        // Point at the API Gateway so JWT validation happens on the call
        this.restClient = restClientBuilder
                .baseUrl("http://localhost:8080")
                .build();
    }

    public ProductResponse getProductById(Long productId, String authorizationHeader) {
        return restClient.get()
                .uri("/api/products/{id}", productId)
                // Forward the caller's JWT so the gateway can authenticate this request
                .header("Authorization", authorizationHeader)
                .retrieve()
                .onStatus(HttpStatusCode::is4xxClientError, (request, response) -> {
                    if (response.getStatusCode().value() == 404) {
                        throw new ResourceNotFoundException("Product not found with id: " + productId);
                    }
                })
                .onStatus(HttpStatusCode::is5xxServerError, (request, response) -> {
                    throw new ProductServiceUnavailableException("Product service is temporarily unavailable");
                })
                .body(ProductResponse.class);
    }
}

What does this code do?

The ProductClient uses Spring Boot's auto-configured RestClient.Builder to create an HTTP client pointed at the API Gateway on http://localhost:8080. It calls GET /api/products/{id} and passes the original JWT from the Authorization header so the gateway can authenticate the downstream request.

The onStatus handlers translate HTTP errors into domain exceptions. A 404 becomes a ResourceNotFoundException and any 5xx becomes a ProductServiceUnavailableException.

Why route through the gateway instead of calling the Product Service directly?

Routing through the gateway at http://localhost:8080 means the call follows the same authentication path as external requests. The gateway validates the JWT and adds identity headers before forwarding to the Product Service. This ensures consistent security enforcement regardless of whether the call originates from a user or another service.

  • Save all DTO and client files.

RestClient import not resolving?

Make sure your order-service/pom.xml includes spring-boot-starter-web. The RestClient class is part of spring-web which is pulled in by the web starter. Reload Maven if needed.

Help me debug my RestClient import issue.

Implement the OrderService and OrderController

The OrderService orchestrates the order creation flow. It calls the ProductClient to check availability, calculates the total price, and saves the confirmed order.

  • Create the InsufficientStockException at order-service/src/main/java/com/orderms/order/exception/InsufficientStockException.java:
package com.orderms.order.exception;

// Thrown when a product does not have enough stock for the requested quantity
public class InsufficientStockException extends RuntimeException {
    public InsufficientStockException(String message) {
        super(message);
    }
}
  • Create the OrderService at order-service/src/main/java/com/orderms/order/service/OrderService.java:
package com.orderms.order.service;

import com.orderms.order.client.ProductClient;
import com.orderms.order.dto.OrderRequest;
import com.orderms.order.dto.OrderResponse;
import com.orderms.order.dto.ProductResponse;
import com.orderms.order.entity.Order;
import com.orderms.order.entity.OrderStatus;
import com.orderms.order.exception.InsufficientStockException;
import com.orderms.order.repository.OrderRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;

@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;
    private final ProductClient productClient;

    public OrderResponse createOrder(OrderRequest request, String userEmail, String authorizationHeader) {
        // Call the Product Service through the gateway to get product details
        ProductResponse product = productClient.getProductById(request.getProductId(), authorizationHeader);

        // Verify the product has enough stock for this order
        if (product.getStockQuantity() < request.getQuantity()) {
            throw new InsufficientStockException(
                    "Insufficient stock for product: " + product.getName() +
                    ". Available: " + product.getStockQuantity() +
                    ", Requested: " + request.getQuantity()
            );
        }

        // Calculate the total price based on product price and quantity
        BigDecimal totalPrice = product.getPrice().multiply(BigDecimal.valueOf(request.getQuantity()));

        // Build and save the confirmed order
        Order order = Order.builder()
                .userEmail(userEmail)
                .productId(request.getProductId())
                .quantity(request.getQuantity())
                .totalPrice(totalPrice)
                .status(OrderStatus.CONFIRMED)
                .orderDate(LocalDateTime.now())
                .build();

        Order savedOrder = orderRepository.save(order);
        return mapToResponse(savedOrder);
    }

    public List<OrderResponse> getOrdersByUser(String userEmail) {
        return orderRepository.findByUserEmail(userEmail).stream()
                .map(this::mapToResponse)
                .collect(Collectors.toList());
    }

    private OrderResponse mapToResponse(Order order) {
        return OrderResponse.builder()
                .id(order.getId())
                .userEmail(order.getUserEmail())
                .productId(order.getProductId())
                .quantity(order.getQuantity())
                .totalPrice(order.getTotalPrice())
                .status(order.getStatus().name())
                .orderDate(order.getOrderDate())
                .build();
    }
}

What does this code do?

The createOrder method orchestrates the full order flow. It fetches the product to confirm it exists, checks that stock is sufficient, calculates the total price, and saves the order with a CONFIRMED status.

If the product does not exist, the ProductClient throws a ResourceNotFoundException. If stock is too low, it throws InsufficientStockException. Both exceptions bubble up to the global exception handler.

  • Create the OrderController at order-service/src/main/java/com/orderms/order/controller/OrderController.java:
package com.orderms.order.controller;

import com.orderms.order.dto.OrderRequest;
import com.orderms.order.dto.OrderResponse;
import com.orderms.order.service.OrderService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderController {

    private final OrderService orderService;

    @PostMapping
    public ResponseEntity<OrderResponse> createOrder(
            @Valid @RequestBody OrderRequest request,
            // The gateway injects the authenticated user's email
            @RequestHeader("X-User-Email") String userEmail,
            // We forward the JWT so the ProductClient can authenticate downstream
            @RequestHeader("Authorization") String authorizationHeader) {
        return ResponseEntity.status(HttpStatus.CREATED)
                .body(orderService.createOrder(request, userEmail, authorizationHeader));
    }

    @GetMapping
    public ResponseEntity<List<OrderResponse>> getMyOrders(
            @RequestHeader("X-User-Email") String userEmail) {
        return ResponseEntity.ok(orderService.getOrdersByUser(userEmail));
    }
}

What does this code do?

The controller exposes two endpoints. POST /api/orders creates a new order. GET /api/orders returns all orders for the current user.

Both endpoints read the X-User-Email header that the API Gateway injected after JWT validation. The POST endpoint also reads the Authorization header so it can pass the JWT along to the ProductClient for the downstream call.

  • Create the GlobalExceptionHandler at order-service/src/main/java/com/orderms/order/exception/GlobalExceptionHandler.java:
package com.orderms.order.exception;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;

@RestControllerAdvice
public class GlobalExceptionHandler {

    // Handle product not found or missing resource
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleResourceNotFound(ResourceNotFoundException ex) {
        ErrorResponse error = new ErrorResponse(
                HttpStatus.NOT_FOUND.value(),
                ex.getMessage(),
                LocalDateTime.now()
        );
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
    }

    // Handle insufficient stock for an order
    @ExceptionHandler(InsufficientStockException.class)
    public ResponseEntity<ErrorResponse> handleInsufficientStock(InsufficientStockException ex) {
        ErrorResponse error = new ErrorResponse(
                HttpStatus.UNPROCESSABLE_ENTITY.value(),
                ex.getMessage(),
                LocalDateTime.now()
        );
        return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).body(error);
    }

    // Handle Product Service being down or unreachable
    @ExceptionHandler(ProductServiceUnavailableException.class)
    public ResponseEntity<ErrorResponse> handleProductServiceUnavailable(ProductServiceUnavailableException ex) {
        ErrorResponse error = new ErrorResponse(
                HttpStatus.SERVICE_UNAVAILABLE.value(),
                ex.getMessage(),
                LocalDateTime.now()
        );
        return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(error);
    }

    // Handle validation errors from @Valid DTOs
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, Object>> handleValidationErrors(MethodArgumentNotValidException ex) {
        Map<String, Object> response = new HashMap<>();
        Map<String, String> errors = new HashMap<>();
        ex.getBindingResult().getAllErrors().forEach(error -> {
            String fieldName = ((FieldError) error).getField();
            String errorMessage = error.getDefaultMessage();
            errors.put(fieldName, errorMessage);
        });
        response.put("status", HttpStatus.BAD_REQUEST.value());
        response.put("errors", errors);
        response.put("timestamp", LocalDateTime.now().toString());
        return ResponseEntity.badRequest().body(response);
    }
}
  • Create the ErrorResponse record at order-service/src/main/java/com/orderms/order/exception/ErrorResponse.java:
package com.orderms.order.exception;

import java.time.LocalDateTime;

// Standard error response shape for all exception handlers
public record ErrorResponse(
    int status,
    String message,
    LocalDateTime timestamp
) {}

What does this code do?

The @RestControllerAdvice class catches exceptions thrown anywhere in the Order Service and translates them into proper JSON error responses. A missing product returns a 404, insufficient stock returns a 422, and Product Service failures return a 503. Validation errors on the request DTO return a 400 with field-level error details.

  • Save all files in the order-service module.
  • Start the Order Service from your IDE by running OrderServiceApplication.java. Make sure the Discovery Server is already running on port 8761.
  • Open the Eureka dashboard at http://localhost:8761 in your browser. You should see ORDER-SERVICE registered alongside the other services.

Order Service not appearing in Eureka?

Check that your application.yml has the correct eureka.client.service-url.defaultZone pointing to http://localhost:8761/eureka/. Also verify that the Discovery Server is running before you start the Order Service.

Help me debug why my Order Service isn't registering with Eureka.

✔️ Awesome, I've got everything!

Great. Double check you have saved all files in the order-service module and that the service is running without errors.

ⓧ I'd like to double check the full code

Here is the full ProductClient.java for comparison:

package com.orderms.order.client;

import com.orderms.order.dto.ProductResponse;
import com.orderms.order.exception.ProductServiceUnavailableException;
import com.orderms.order.exception.ResourceNotFoundException;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatusCode;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;

@Component
public class ProductClient {

    private final RestClient restClient;

    public ProductClient(RestClient.Builder restClientBuilder) {
        this.restClient = restClientBuilder
                .baseUrl("http://localhost:8080")
                .build();
    }

    public ProductResponse getProductById(Long productId, String authorizationHeader) {
        return restClient.get()
                .uri("/api/products/{id}", productId)
                .header("Authorization", authorizationHeader)
                .retrieve()
                .onStatus(HttpStatusCode::is4xxClientError, (request, response) -> {
                    if (response.getStatusCode().value() == 404) {
                        throw new ResourceNotFoundException("Product not found with id: " + productId);
                    }
                })
                .onStatus(HttpStatusCode::is5xxServerError, (request, response) -> {
                    throw new ProductServiceUnavailableException("Product service is temporarily unavailable");
                })
                .body(ProductResponse.class);
    }
}

Here is the full OrderService.java for comparison:

package com.orderms.order.service;

import com.orderms.order.client.ProductClient;
import com.orderms.order.dto.OrderRequest;
import com.orderms.order.dto.OrderResponse;
import com.orderms.order.dto.ProductResponse;
import com.orderms.order.entity.Order;
import com.orderms.order.entity.OrderStatus;
import com.orderms.order.exception.InsufficientStockException;
import com.orderms.order.repository.OrderRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;

@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;
    private final ProductClient productClient;

    public OrderResponse createOrder(OrderRequest request, String userEmail, String authorizationHeader) {
        ProductResponse product = productClient.getProductById(request.getProductId(), authorizationHeader);

        if (product.getStockQuantity() < request.getQuantity()) {
            throw new InsufficientStockException(
                    "Insufficient stock for product: " + product.getName() +
                    ". Available: " + product.getStockQuantity() +
                    ", Requested: " + request.getQuantity()
            );
        }

        BigDecimal totalPrice = product.getPrice().multiply(BigDecimal.valueOf(request.getQuantity()));

        Order order = Order.builder()
                .userEmail(userEmail)
                .productId(request.getProductId())
                .quantity(request.getQuantity())
                .totalPrice(totalPrice)
                .status(OrderStatus.CONFIRMED)
                .orderDate(LocalDateTime.now())
                .build();

        Order savedOrder = orderRepository.save(order);
        return mapToResponse(savedOrder);
    }

    public List<OrderResponse> getOrdersByUser(String userEmail) {
        return orderRepository.findByUserEmail(userEmail).stream()
                .map(this::mapToResponse)
                .collect(Collectors.toList());
    }

    private OrderResponse mapToResponse(Order order) {
        return OrderResponse.builder()
                .id(order.getId())
                .userEmail(order.getUserEmail())
                .productId(order.getProductId())
                .quantity(order.getQuantity())
                .totalPrice(order.getTotalPrice())
                .status(order.getStatus().name())
                .orderDate(order.getOrderDate())
                .build();
    }
}

Your Order Service is live and registered with Eureka. In the next step, you will test the entire system end-to-end by creating a user, logging in, and placing an order through the gateway.

Test the Full System End-to-End

You've built all 5 microservices. Now it's time to prove they actually work together as a system.

In this step, you'll start every service, then exercise the full request flow from registration through order creation. You'll also verify that your custom exception handling returns clean JSON error responses for every failure scenario.

In this step, get ready to:
  • Start all 5 services in the correct order and verify them in the Eureka dashboard.
  • Test the complete happy-path flow: register, login, list products, create an order.
  • Verify edge cases return proper JSON error responses with correct HTTP status codes.
Start all 5 services and verify the Eureka dashboard

Startup order matters in a microservices system. The Discovery Server must be running first so that each subsequent service can register itself on startup.

You'll need 5 separate terminal windows or tabs for this. Each service runs as its own process.

  • Open 5 terminal windows. In each, navigate to your project's root folder (order-management-system).
  • In Terminal 1, start the Discovery Server by running:
cd discovery-server
mvn spring-boot:run
  • Wait until you see a log line indicating the server started on port 8761.
  • In Terminal 2, start the Auth Service by running:
cd auth-service
mvn spring-boot:run
  • In Terminal 3, start the Product Service by running:
cd product-service
mvn spring-boot:run
  • In Terminal 4, start the Order Service by running:
cd order-service
mvn spring-boot:run
  • In Terminal 5, start the API Gateway by running:
cd api-gateway
mvn spring-boot:run

Why start the gateway last?

The API Gateway uses Eureka to resolve service names (like lb://AUTH-SERVICE) into actual URLs. If it starts before the downstream services register, it won't know where to route requests.

  • Open your browser and navigate to http://localhost:8761.
  • Verify that you see 4 services listed under Instances currently registered with Eureka: AUTH-SERVICE, PRODUCT-SERVICE, ORDER-SERVICE, and API-GATEWAY.

Not seeing all 4 services?

Services take 30 seconds to register after startup. Refresh the Eureka dashboard after waiting. If a service is still missing, check its terminal window for startup errors.

Confirm each service's application.yml has the correct eureka.client.service-url.defaultZone pointing to http://localhost:8761/eureka/.

Help me troubleshoot why my services aren't appearing in the Eureka dashboard

Test the complete authentication and ordering flow

With all services running, you can now test the full request lifecycle. Every request goes through the API Gateway on port 8080. The gateway routes it to the correct service and validates the JWT token.

You can use Postman or curl for these tests. The examples below use curl so you can run them directly in a 6th terminal window.

  • Register a new user by running this command:
curl -X POST http://localhost:8080/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"name": "Test User", "email": "test@example.com", "password": "password123"}'

What should I see?

You should receive a 201 response with a JSON body containing a token field. This is the JWT you'll use for all subsequent requests. Copy this token value.

  • Log in with the same credentials to verify login works:
curl -X POST http://localhost:8080/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email": "test@example.com", "password": "password123"}'

You should receive a 200 response with a fresh JWT token.

  • List the available products using your JWT token. Replace YOUR_TOKEN with the token you copied from the register or login response:
curl -X GET http://localhost:8080/api/products \
  -H "Authorization: Bearer YOUR_TOKEN"

What should I see?

A 200 response with a JSON array of products. These were seeded by the CommandLineRunner in your Product Service. Note the product IDs and stock quantities. You'll need a valid productId for the next request.

  • Create an order using a valid product ID from the list above. Replace YOUR_TOKEN with your JWT:
curl -X POST http://localhost:8080/api/orders \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{"productId": 1, "quantity": 2}'

What should I see?

A 201 response with a JSON body showing the order details: id, userEmail, productId, quantity, totalPrice, and a status of CONFIRMED. This proves the Order Service successfully called the Product Service through the gateway, validated stock, and saved the order.

  • Verify you can also retrieve your orders by running:
curl -X GET http://localhost:8080/api/orders \
  -H "Authorization: Bearer YOUR_TOKEN"

You should see a 200 response containing an array with the order you just created.

Getting a 401 or 403 error on the order request?

Make sure you're using a fresh token. If too much time has passed since registration, the token may have expired. Re-run the login command to get a new one.

Verify the Authorization header starts with Bearer (with a space) followed by the full token string.

Help me debug 401/403 errors when calling my microservices through the API Gateway

Test edge cases and error handling

A production system needs to fail gracefully. Your custom exception handlers should return structured JSON errors instead of raw stack traces. Let's verify each failure scenario.

  • Test that a request without a token returns 401 by running:
curl -X GET http://localhost:8080/api/products

You should receive a 401 Unauthorized response. The gateway blocked this request because no Authorization header was provided.

  • Test that an invalid token returns 401 by running:
curl -X GET http://localhost:8080/api/products \
  -H "Authorization: Bearer invalid.token.here"

You should also receive a 401 response. The gateway rejected the malformed JWT.

  • Test ordering a non-existent product by running this with your valid token:
curl -X POST http://localhost:8080/api/orders \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{"productId": 9999, "quantity": 1}'

What should I see?

A 404 response with a JSON body containing status, message, and timestamp fields. The ResourceNotFoundException thrown by your ProductClient was caught by the GlobalExceptionHandler and converted into a clean API response.

  • Test ordering more than available stock. Use a valid product ID but set the quantity higher than its stockQuantity:
curl -X POST http://localhost:8080/api/orders \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{"productId": 1, "quantity": 99999}'

You should receive a 422 response with a message from your InsufficientStockException showing the available and requested quantities.

Why structured error responses matter

Every error response from your system returns a consistent JSON structure with status, message, and timestamp. Frontend clients can parse these reliably. Without @ControllerAdvice, Spring Boot would return HTML error pages or generic JSON with no useful context.

Getting a 500 error instead of a structured JSON response?

A 500 means an unhandled exception escaped your GlobalExceptionHandler. Check the Order Service terminal for the full stack trace. The exception type shown there needs a matching @ExceptionHandler method in your handler class.

Help me debug why my GlobalExceptionHandler isn't catching the exception properly

Your microservices system is fully operational. Every service discovers its neighbors through Eureka, the gateway secures all traffic with JWT validation, and your custom exception handlers return clean error responses for every failure mode. Next up, you'll make this system production-resilient with a circuit breaker.

Secret mission

Add Circuit Breaker with Resilience4j

Your Order Service works great when the Product Service is running. But what happens when it goes down? Right now, you get ugly 500 errors. In the real world, services fail all the time. Your mission: add a circuit breaker to gracefully handle Product Service failures and return meaningful responses instead of crashing.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally with in-memory H2 databases, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • 5 running Spring Boot applications (Discovery Server, Auth Service, Product Service, Order Service, API Gateway).
  • The order-management-system project folder on your Desktop.
  • Postman collections or saved requests (if any).

Keep everything running

No action needed. Choose this if you're still actively building or want to continue testing your microservices system.

  • Your 5 Spring Boot services will keep running until you stop them or restart your machine.
  • H2 in-memory databases reset each time you restart a service, so any test data will be lost on restart.
  • Your project folder remains on your Desktop ready for further development.

Pause - I'll come back to this later

Shut down running processes to free up memory. Your project files stay on disk so you can pick up where you left off.

  • Switch to each terminal window running a Spring Boot service and press Ctrl+C to stop it.
  • Repeat for all 5 services (Discovery Server, Auth Service, Product Service, Order Service, API Gateway).
  • Your order-management-system folder stays on your Desktop. When you return, start the services again in order: Discovery Server first, then the others.

Delete - I don't want to use this again

Remove all project resources and start fresh. This permanently deletes your code and any local data.

  • Stop all 5 running Spring Boot services by pressing Ctrl+C in each terminal window.
  • Delete the project folder by running the command for your operating system below.

macOS

  • Delete the project folder by running:
rm -rf ~/Desktop/order-management-system
  • Verify the folder is gone by running:
ls ~/Desktop | grep order

Windows

  • Delete the project folder by running:
rmdir /s /q %USERPROFILE%\Desktop\order-management-system
  • Verify the folder is gone by running:
dir %USERPROFILE%\Desktop\order*
  • If you saved any Postman collections for this project, delete them from Postman by right-clicking the collection and selecting Delete.

Nice Work!

Nice Work!

You've just built a professional-grade microservices system from scratch using Spring Boot and Spring Cloud. Five independently runnable applications, all communicating through service discovery and secured end-to-end with JWT.

You've learned how to:

  • Build a microservices architecture with 5 Spring Boot applications communicating through Eureka service discovery and Spring Cloud Gateway as a single entry point.
  • Implement stateless JWT authentication with role-based access control, securing inter-service requests through token validation at the gateway.
  • Create inter-service communication using Spring 6 RestClient, with DTOs, Jakarta Bean Validation, and global exception handling via @ControllerAdvice.
  • Added circuit breaker fault tolerance with Resilience4j so the Order Service gracefully handles Product Service failures instead of crashing.

Ready to quiz yourself?