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
- 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!