Building a Simple User API with Spring Boot and JPA (Java)

Yeah, you’re not alone - Spring Boot seems intimidating at first. All those annotations and configuration files make you wonder if you’re building a simple API or launching a rocket ship. But here’s the thing: once you get past the initial setup, Spring Boot is incredibly powerful and actually makes your life easier.

We’re going to build the same user management API you might have seen in the FastAPI example (the python doobies can’t have all the fun!), but this time with Java and Spring Boot. By the end, you’ll understand why so many enterprise applications are built with Spring.

What We’re Building (Again)

Same deal as before - a REST API that manages users with:

  • First name
  • Last name
  • Email
  • GitHub handle

But this time we’re doing it the Java way, with all the type safety and enterprise-grade features that come with it.

Maven Setup

First things first - we need a pom.xml file. This is Maven’s way of managing dependencies (think of it like Python’s requirements.txt, but way more verbose):

 1<?xml version="1.0" encoding="UTF-8"?>
 2<project xmlns="http://maven.apache.org/POM/4.0.0"
 3         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
 4         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
 5         http://maven.apache.org/xsd/maven-4.0.0.xsd">
 6    <modelVersion>4.0.0</modelVersion>
 7    
 8    <groupId>com.zipcode</groupId>
 9    <artifactId>user-api</artifactId>
10    <version>1.0.0</version>
11    <packaging>jar</packaging>
12    
13    <name>Simple User API</name>
14    <description>A basic user management REST API</description>
15    
16    <parent>
17        <groupId>org.springframework.boot</groupId>
18        <artifactId>spring-boot-starter-parent</artifactId>
19        <version>3.2.0</version>
20        <relativePath/>
21    </parent>
22    
23    <properties>
24        <java.version>17</java.version>
25    </properties>
26    
27    <dependencies>
28        <!-- Spring Boot Web Starter -->
29        <dependency>
30            <groupId>org.springframework.boot</groupId>
31            <artifactId>spring-boot-starter-web</artifactId>
32        </dependency>
33        
34        <!-- Spring Boot Data JPA -->
35        <dependency>
36            <groupId>org.springframework.boot</groupId>
37            <artifactId>spring-boot-starter-data-jpa</artifactId>
38        </dependency>
39        
40        <!-- H2 Database for development -->
41        <dependency>
42            <groupId>com.h2database</groupId>
43            <artifactId>h2</artifactId>
44            <scope>runtime</scope>
45        </dependency>
46        
47        <!-- Spring Boot Test Starter -->
48        <dependency>
49            <groupId>org.springframework.boot</groupId>
50            <artifactId>spring-boot-starter-test</artifactId>
51            <scope>test</scope>
52        </dependency>
53    </dependencies>
54    
55    <build>
56        <plugins>
57            <plugin>
58                <groupId>org.springframework.boot</groupId>
59                <artifactId>spring-boot-maven-plugin</artifactId>
60            </plugin>
61        </plugins>
62    </build>
63</project>

Yeah, it’s verbose. But look what we get: web framework, database integration, testing framework, and an embedded server - all configured and ready to go. That’s the Spring Boot magic.

Application Configuration

Create src/main/resources/application.properties:

 1# Server configuration
 2server.port=8080
 3
 4# H2 Database configuration
 5spring.datasource.url=jdbc:h2:mem:userdb
 6spring.datasource.driverClassName=org.h2.Driver
 7spring.datasource.username=sa
 8spring.datasource.password=password
 9
10# JPA/Hibernate properties
11spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
12spring.jpa.hibernate.ddl-auto=create-drop
13spring.jpa.show-sql=true
14
15# H2 Console (for development)
16spring.h2.console.enabled=true
17spring.h2.console.path=/h2-console

This configures an in-memory H2 database - perfect for development and testing. It’s like SQLite but runs entirely in memory, so it’s super fast and resets every time you restart the app. (We could use Sqlite3, but hey, H2 is written in Java, my friend)

The User Entity

Now let’s create our User model. In Spring, we call these “entities”. Create src/main/java/com/zipcode/userapi/model/User.java:

 1package com.zipcode.userapi.model;
 2
 3import jakarta.persistence.*;
 4import jakarta.validation.constraints.Email;
 5import jakarta.validation.constraints.NotBlank;
 6
 7@Entity
 8@Table(name = "users")
 9public class User {
10    
11    @Id
12    @GeneratedValue(strategy = GenerationType.IDENTITY)
13    private Long id;
14    
15    @NotBlank(message = "First name is required")
16    @Column(name = "first_name")
17    private String firstName;
18    
19    @NotBlank(message = "Last name is required")
20    @Column(name = "last_name")
21    private String lastName;
22    
23    @Email(message = "Email should be valid")
24    @NotBlank(message = "Email is required")
25    @Column(unique = true)
26    private String email;
27    
28    @NotBlank(message = "GitHub handle is required")
29    @Column(name = "github_handle", unique = true)
30    private String githubHandle;
31    
32    // Default constructor (required by JPA)
33    public User() {}
34    
35    // Constructor for creating users
36    public User(String firstName, String lastName, String email, String githubHandle) {
37        this.firstName = firstName;
38        this.lastName = lastName;
39        this.email = email;
40        this.githubHandle = githubHandle;
41    }
42    
43    // Getters and setters
44    public Long getId() {
45        return id;
46    }
47    
48    public void setId(Long id) {
49        this.id = id;
50    }
51    
52    public String getFirstName() {
53        return firstName;
54    }
55    
56    public void setFirstName(String firstName) {
57        this.firstName = firstName;
58    }
59    
60    public String getLastName() {
61        return lastName;
62    }
63    
64    public void setLastName(String lastName) {
65        this.lastName = lastName;
66    }
67    
68    public String getEmail() {
69        return email;
70    }
71    
72    public void setEmail(String email) {
73        this.email = email;
74    }
75    
76    public String getGithubHandle() {
77        return githubHandle;
78    }
79    
80    public void setGithubHandle(String githubHandle) {
81        this.githubHandle = githubHandle;
82    }
83    
84    @Override
85    public String toString() {
86        return "User{" +
87                "id=" + id +
88                ", firstName='" + firstName + '\'' +
89                ", lastName='" + lastName + '\'' +
90                ", email='" + email + '\'' +
91                ", githubHandle='" + githubHandle + '\'' +
92                '}';
93    }
94}

See all those annotations? They’re doing the heavy lifting:

  • @Entity tells JPA this is a database table
  • @Id and @GeneratedValue handle auto-incrementing primary keys
  • @Column configures database columns (including unique constraints)
  • @NotBlank and @Email provide validation

Yeah, it’s more verbose than Python, but you get compile-time safety and amazing tooling support.

The Repository Layer

Here’s where Spring really shines. Create src/main/java/com/zipcode/userapi/repository/UserRepository.java:

 1package com.zipcode.userapi.repository;
 2
 3import com.zipcode.userapi.model.User;
 4import org.springframework.data.jpa.repository.JpaRepository;
 5import org.springframework.stereotype.Repository;
 6
 7import java.util.Optional;
 8
 9@Repository
10public interface UserRepository extends JpaRepository<User, Long> {
11    
12    // Spring Data JPA automatically implements these methods!
13    Optional<User> findByEmail(String email);
14    Optional<User> findByGithubHandle(String githubHandle);
15    boolean existsByEmail(String email);
16    boolean existsByGithubHandle(String githubHandle);
17}

Wait, what? That’s it? No SQL queries? No database connection code?

Exactly! Spring Data JPA looks at your method names and automatically generates the SQL for you. findByEmail becomes SELECT * FROM users WHERE email = ?. It’s like magic, but better - it’s convention over configuration.

The REST Controller

Now for the main event - our REST API endpoints. Create src/main/java/com/zipcode/userapi/controller/UserController.java:

  1package com.zipcode.userapi.controller;
  2
  3import com.zipcode.userapi.model.User;
  4import com.zipcode.userapi.repository.UserRepository;
  5import jakarta.validation.Valid;
  6import org.springframework.beans.factory.annotation.Autowired;
  7import org.springframework.http.HttpStatus;
  8import org.springframework.http.ResponseEntity;
  9import org.springframework.web.bind.annotation.*;
 10
 11import java.util.List;
 12import java.util.Optional;
 13
 14@RestController
 15@RequestMapping("/api/users")
 16@CrossOrigin(origins = "*")
 17public class UserController {
 18    
 19    @Autowired
 20    private UserRepository userRepository;
 21    
 22    @GetMapping
 23    public List<User> getAllUsers() {
 24        return userRepository.findAll();
 25    }
 26    
 27    @GetMapping("/{id}")
 28    public ResponseEntity<User> getUserById(@PathVariable Long id) {
 29        Optional<User> user = userRepository.findById(id);
 30        
 31        if (user.isPresent()) {
 32            return ResponseEntity.ok(user.get());
 33        } else {
 34            return ResponseEntity.notFound().build();
 35        }
 36    }
 37    
 38    @PostMapping
 39    public ResponseEntity<?> createUser(@Valid @RequestBody User user) {
 40        try {
 41            // Check for duplicates
 42            if (userRepository.existsByEmail(user.getEmail())) {
 43                return ResponseEntity.badRequest()
 44                    .body("Error: Email is already in use!");
 45            }
 46            
 47            if (userRepository.existsByGithubHandle(user.getGithubHandle())) {
 48                return ResponseEntity.badRequest()
 49                    .body("Error: GitHub handle is already in use!");
 50            }
 51            
 52            User savedUser = userRepository.save(user);
 53            return ResponseEntity.status(HttpStatus.CREATED).body(savedUser);
 54            
 55        } catch (Exception e) {
 56            return ResponseEntity.badRequest()
 57                .body("Error: Could not create user - " + e.getMessage());
 58        }
 59    }
 60    
 61    @PutMapping("/{id}")
 62    public ResponseEntity<?> updateUser(@PathVariable Long id, 
 63                                       @Valid @RequestBody User userDetails) {
 64        try {
 65            Optional<User> optionalUser = userRepository.findById(id);
 66            
 67            if (!optionalUser.isPresent()) {
 68                return ResponseEntity.notFound().build();
 69            }
 70            
 71            User user = optionalUser.get();
 72            
 73            // Check if email is being changed to one that already exists
 74            if (!user.getEmail().equals(userDetails.getEmail()) && 
 75                userRepository.existsByEmail(userDetails.getEmail())) {
 76                return ResponseEntity.badRequest()
 77                    .body("Error: Email is already in use!");
 78            }
 79            
 80            // Check if GitHub handle is being changed to one that already exists
 81            if (!user.getGithubHandle().equals(userDetails.getGithubHandle()) && 
 82                userRepository.existsByGithubHandle(userDetails.getGithubHandle())) {
 83                return ResponseEntity.badRequest()
 84                    .body("Error: GitHub handle is already in use!");
 85            }
 86            
 87            // Update fields
 88            user.setFirstName(userDetails.getFirstName());
 89            user.setLastName(userDetails.getLastName());
 90            user.setEmail(userDetails.getEmail());
 91            user.setGithubHandle(userDetails.getGithubHandle());
 92            
 93            User updatedUser = userRepository.save(user);
 94            return ResponseEntity.ok(updatedUser);
 95            
 96        } catch (Exception e) {
 97            return ResponseEntity.badRequest()
 98                .body("Error: Could not update user - " + e.getMessage());
 99        }
100    }
101    
102    @DeleteMapping("/{id}")
103    public ResponseEntity<?> deleteUser(@PathVariable Long id) {
104        try {
105            Optional<User> user = userRepository.findById(id);
106            
107            if (!user.isPresent()) {
108                return ResponseEntity.notFound().build();
109            }
110            
111            userRepository.deleteById(id);
112            return ResponseEntity.ok().body("User deleted successfully");
113            
114        } catch (Exception e) {
115            return ResponseEntity.badRequest()
116                .body("Error: Could not delete user - " + e.getMessage());
117        }
118    }
119}

Those annotations are doing all the routing work:

  • @RestController combines @Controller and @ResponseBody
  • @RequestMapping sets the base URL path
  • @GetMapping, @PostMapping, etc. map HTTP methods to methods
  • @PathVariable extracts URL parameters
  • @RequestBody converts JSON to Java objects
  • @Valid triggers validation

The Main Application Class

Finally, create src/main/java/com/zipcode/userapi/UserApiApplication.java:

 1package com.zipcode.userapi;
 2
 3import org.springframework.boot.SpringApplication;
 4import org.springframework.boot.autoconfigure.SpringBootApplication;
 5
 6@SpringBootApplication
 7public class UserApiApplication {
 8    public static void main(String[] args) {
 9        SpringApplication.run(UserApiApplication.class, args);
10    }
11}

That’s it! @SpringBootApplication is a magic annotation that enables auto-configuration, component scanning, and Spring Boot features.

Running Your Spring Boot Server

Build and run with Maven:

1# Clean and compile
2mvn clean compile
3
4# Run the application
5mvn spring-boot:run

Or if you prefer the JAR approach:

1# Package into a JAR
2mvn clean package
3
4# Run the JAR
5java -jar target/user-api-1.0.0.jar

Your server will start on http://localhost:8080. Want to see your database? Visit http://localhost:8080/h2-console and use the connection details from your application.properties.

JUnit Testing

Spring Boot makes testing incredibly easy. Create src/test/java/com/zipcode/userapi/UserControllerTest.java:

  1package com.zipcode.userapi;
  2
  3import com.fasterxml.jackson.databind.ObjectMapper;
  4import com.zipcode.userapi.model.User;
  5import com.zipcode.userapi.repository.UserRepository;
  6import org.junit.jupiter.api.BeforeEach;
  7import org.junit.jupiter.api.Test;
  8import org.springframework.beans.factory.annotation.Autowired;
  9import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureWebMvc;
 10import org.springframework.boot.test.context.SpringBootTest;
 11import org.springframework.http.MediaType;
 12import org.springframework.test.context.TestPropertySource;
 13import org.springframework.test.web.servlet.MockMvc;
 14import org.springframework.test.web.servlet.setup.MockMvcBuilders;
 15import org.springframework.web.context.WebApplicationContext;
 16
 17import static org.hamcrest.Matchers.*;
 18import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
 19import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
 20
 21@SpringBootTest
 22@AutoConfigureWebMvc
 23@TestPropertySource(properties = {
 24    "spring.datasource.url=jdbc:h2:mem:testdb",
 25    "spring.jpa.hibernate.ddl-auto=create-drop"
 26})
 27class UserControllerTest {
 28
 29    @Autowired
 30    private WebApplicationContext webApplicationContext;
 31    
 32    @Autowired
 33    private UserRepository userRepository;
 34    
 35    private MockMvc mockMvc;
 36    private ObjectMapper objectMapper = new ObjectMapper();
 37
 38    @BeforeEach
 39    void setUp() {
 40        mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build();
 41        userRepository.deleteAll(); // Clean slate for each test
 42    }
 43
 44    @Test
 45    void shouldCreateUser() throws Exception {
 46        User user = new User("John", "Doe", "john.doe@example.com", "johndoe");
 47        
 48        mockMvc.perform(post("/api/users")
 49                .contentType(MediaType.APPLICATION_JSON)
 50                .content(objectMapper.writeValueAsString(user)))
 51                .andExpect(status().isCreated())
 52                .andExpect(jsonPath("$.firstName", is("John")))
 53                .andExpect(jsonPath("$.lastName", is("Doe")))
 54                .andExpect(jsonPath("$.email", is("john.doe@example.com")))
 55                .andExpect(jsonPath("$.githubHandle", is("johndoe")))
 56                .andExpect(jsonPath("$.id", notNullValue()));
 57    }
 58
 59    @Test
 60    void shouldGetAllUsers() throws Exception {
 61        // Create test users
 62        userRepository.save(new User("Alice", "Johnson", "alice@example.com", "alicejohnson"));
 63        userRepository.save(new User("Bob", "Wilson", "bob@example.com", "bobwilson"));
 64
 65        mockMvc.perform(get("/api/users"))
 66                .andExpect(status().isOk())
 67                .andExpect(jsonPath("$", hasSize(2)))
 68                .andExpect(jsonPath("$[0].firstName", is("Alice")))
 69                .andExpect(jsonPath("$[1].firstName", is("Bob")));
 70    }
 71
 72    @Test
 73    void shouldGetUserById() throws Exception {
 74        User savedUser = userRepository.save(
 75            new User("Jane", "Smith", "jane.smith@example.com", "janesmith"));
 76
 77        mockMvc.perform(get("/api/users/" + savedUser.getId()))
 78                .andExpect(status().isOk())
 79                .andExpect(jsonPath("$.firstName", is("Jane")))
 80                .andExpect(jsonPath("$.lastName", is("Smith")))
 81                .andExpect(jsonPath("$.id", is(savedUser.getId().intValue())));
 82    }
 83
 84    @Test
 85    void shouldReturnNotFoundForNonExistentUser() throws Exception {
 86        mockMvc.perform(get("/api/users/99999"))
 87                .andExpect(status().isNotFound());
 88    }
 89
 90    @Test
 91    void shouldUpdateUser() throws Exception {
 92        User originalUser = userRepository.save(
 93            new User("Original", "Name", "original@example.com", "originalname"));
 94
 95        User updatedUser = new User("Updated", "Name", "updated@example.com", "updatedname");
 96
 97        mockMvc.perform(put("/api/users/" + originalUser.getId())
 98                .contentType(MediaType.APPLICATION_JSON)
 99                .content(objectMapper.writeValueAsString(updatedUser)))
100                .andExpect(status().isOk())
101                .andExpect(jsonPath("$.firstName", is("Updated")))
102                .andExpect(jsonPath("$.email", is("updated@example.com")))
103                .andExpected(jsonPath("$.githubHandle", is("updatedname")));
104    }
105
106    @Test
107    void shouldDeleteUser() throws Exception {
108        User savedUser = userRepository.save(
109            new User("ToDelete", "User", "delete@example.com", "deleteuser"));
110
111        mockMvc.perform(delete("/api/users/" + savedUser.getId()))
112                .andExpect(status().isOk())
113                .andExpect(content().string(containsString("User deleted successfully")));
114
115        // Verify it's gone
116        mockMvc.perform(get("/api/users/" + savedUser.getId()))
117                .andExpect(status().isNotFound());
118    }
119
120    @Test
121    void shouldRejectDuplicateEmail() throws Exception {
122        // Create first user
123        userRepository.save(new User("First", "User", "duplicate@example.com", "firstuser"));
124
125        // Try to create second user with same email
126        User duplicateUser = new User("Second", "User", "duplicate@example.com", "seconduser");
127
128        mockMvc.perform(post("/api/users")
129                .contentType(MediaType.APPLICATION_JSON)
130                .content(objectMapper.writeValueAsString(duplicateUser)))
131                .andExpected(status().isBadRequest())
132                .andExpect(content().string(containsString("Email is already in use")));
133    }
134
135    @Test
136    void shouldValidateRequiredFields() throws Exception {
137        User invalidUser = new User("", "", "", ""); // All blank fields
138
139        mockMvc.perform(post("/api/users")
140                .contentType(MediaType.APPLICATION_JSON)
141                .content(objectMapper.writeValueAsString(invalidUser)))
142                .andExpect(status().isBadRequest());
143    }
144}

Run the tests with:

1mvn test

These tests use Spring’s MockMvc to test your endpoints without starting a full server. Notice how we use @TestPropertySource to use a separate test database? That’s a best practice - never test against your real data.

curl Commands for Spring Boot API

Same curl concepts, slightly different URLs. Here are the commands for testing your Java API:

Basic Operations

 1# Get all users
 2curl http://localhost:8080/api/users
 3
 4# Create a new user
 5curl -X POST "http://localhost:8080/api/users" \
 6  -H "Content-Type: application/json" \
 7  -d '{
 8    "firstName": "John",
 9    "lastName": "Doe",
10    "email": "john.doe@example.com",
11    "githubHandle": "johndoe"
12  }'
13
14# Get a specific user (replace 1 with actual ID)
15curl http://localhost:8080/api/users/1
16
17# Update a user
18curl -X PUT "http://localhost:8080/api/users/1" \
19  -H "Content-Type: application/json" \
20  -d '{
21    "firstName": "John",
22    "lastName": "Smith",
23    "email": "john.smith@example.com",
24    "githubHandle": "johnsmith"
25  }'
26
27# Delete a user
28curl -X DELETE http://localhost:8080/api/users/1

Testing Validation and Errors

 1# Test validation - missing required fields
 2curl -X POST "http://localhost:8080/api/users" \
 3  -H "Content-Type: application/json" \
 4  -d '{
 5    "firstName": "John"
 6  }'
 7
 8# Test duplicate email
 9curl -X POST "http://localhost:8080/api/users" \
10  -H "Content-Type: application/json" \
11  -d '{
12    "firstName": "Jane",
13    "lastName": "Doe",
14    "email": "john.doe@example.com",
15    "githubHandle": "janedoe"
16  }'
17
18# Test invalid email format
19curl -X POST "http://localhost:8080/api/users" \
20  -H "Content-Type: application/json" \
21  -d '{
22    "firstName": "Test",
23    "lastName": "User", 
24    "email": "not-an-email",
25    "githubHandle": "testuser"
26  }'

Debug with Verbose Output

1# See full HTTP conversation
2curl -v http://localhost:8080/api/users
3
4# Pretty-print JSON response
5curl http://localhost:8080/api/users | python -m json.tool

Why curl Still Matters for Java APIs

Everything I said about curl for Python APIs applies here too, but there are a few Java-specific reasons why it’s even more important:

Spring Boot’s Error Responses: Spring returns detailed error information in JSON format. curl lets you see these error details immediately:

1# This will show you exactly what validation failed
2curl -X POST "http://localhost:8080/api/users" \
3  -H "Content-Type: application/json" \
4  -d '{"firstName": ""}'

Testing Different Content Types: Java APIs are pickier about content types. curl helps you test these scenarios:

1# This will fail because we're not setting Content-Type
2curl -X POST "http://localhost:8080/api/users" \
3  -d '{"firstName": "John", "lastName": "Doe", "email": "john@example.com", "githubHandle": "john"}'
4
5# This works because we specify the content type
6curl -X POST "http://localhost:8080/api/users" \
7  -H "Content-Type: application/json" \
8  -d '{"firstName": "John", "lastName": "Doe", "email": "john@example.com", "githubHandle": "john"}'

Production Debugging: Java applications often run behind load balancers or proxies. curl helps you test the actual endpoint your users hit, not just your local development server.

Spring Boot vs FastAPI - What You Just Learned

You’ve now seen both approaches to building REST APIs. Here’s what’s different:

Spring Boot Pros:

  • Type safety everywhere (compile-time error checking)
  • Massive ecosystem and community
  • Enterprise-grade features built-in
  • Excellent tooling and IDE support
  • Battle-tested in production environments
  • Automatic SQL generation with JPA

Spring Boot Cons:

  • More verbose (more code to write)
  • Steeper learning curve
  • Longer startup times
  • More configuration options (can be overwhelming)

The Bottom Line: FastAPI is great for rapid prototyping and simpler applications. Spring Boot is better when you need robustness, type safety, and are building something that needs to scale and be maintained by a team.

Both are excellent choices - it really depends on your project requirements and team preferences.

What’s Next?

You’ve got the foundations of Spring Boot now! Here are some natural next steps:

  • Add Spring Security for authentication
  • Implement database migrations with Flyway
  • Add API documentation with SpringDoc/OpenAPI
  • Set up profiles for different environments
  • Learn about Spring Boot Actuator for monitoring
  • Explore Spring Data REST for even less boilerplate

The great thing about Spring Boot is that it grows with you. Start simple, add features as you need them. And remember - every major enterprise Java application you’ll work with probably uses Spring in some form, so this knowledge is incredibly valuable for your career.

Now go build something awesome!