Introduction
Developing a robust CRUD (Create, Read, Update, Delete) API is a basic skill for any backend engineer.
It involves not only programming logic for the four fundamental data operatins, but also knowledge of the inner workings of the dadabase.
In this article, we build a user registration API using Spring Boot and MongoDB.
The Spring Boot project will be set up, MongoDB will be integrated as the NoSQL database, and the full set of API endpoints to manage users (and their addresses) will be implemented.
This article is beginner-friendly, and aims to provide expert tips to anyone with small knowledge of Java.
By the end, you’ll have a working RESTful API and a clear understanding of the key steps to integrate Spring Boot with MongoDB.
What is Covered
- Project setup with Spring Initializr and required dependencies.
- Defining the domain model (User and Address) and MongoDB data annotations.
- Creating repository interfaces for MongoDB and leveraging Spring Data query methods.
- Implementing service layer logic for user operations (create, retrieve, update, delete).
- Building REST controllers with Spring MVC to expose CRUD endpoints.
- Practical examples of running the application and testing each operation.
Setting Up the Spring Boot Project
The first step is to initialize a new Spring Boot project with the necessary tools for web development and MongoDB integration. Use the Spring Initializr web tool to generate our project scaffold:
- Open Spring Initializr site.
-
Project settings: Choose Maven (or Gradle) as the build tool, Java as the language, and set Java version to 17+ (Spring Boot 3.x requires Java 17 or higher).
- Enter a group and artifact name (for example, com.altabuild and crud-springboot-mongodb).
-
Add Dependencies: Include Spring Web (for building REST APIs) and Spring Data MongoDB (for MongoDB integration).
-
Lombok is also include to reduce boilerplate (optional).
- Refer to https://projectlombok.org/ for more information about this awesome library.
-
Lombok is also include to reduce boilerplate (optional).
- Generate the project to download a ZIP file. Unzip it and open the project in your IDE (IntelliJ, Eclipse, VS Code, etc.).
If your IDE has Spring Initializr integration, you can configure these settings and create the project directly from there.
With the project generated, let’s examine the key pieces of the scaffold. Spring Boot will have created a main application class (e.g., CrudApplication.java) with a main method to run the app. Our next task is to define the data model representing our domain.
Defining the Domain Model
We’ll manage two main entities in our system: User and Address. In MongoDB (a NoSQL document database), data is stored as flexible JSON-like documents. Using Spring Data MongoDB, we represent these as Java classes annotated with @Document. Each document will have an @id field for its unique identifier.
Note that in these entities, it has been used Lombok annotations, such as @Getter and @setter to reduce code boilerplate and avoid writing constructors, getter and setter, and builder methods.
User Entity – represents a system user with basic attributes and a reference to their address:
package com.altabuild.crud.infrastructure.entity;
import lombok.*;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import java.time.LocalDateTime;
@Document(collection = "user_entity")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class UserEntity {
@Id
private String id;
private String name;
private String email;
private String document;
private LocalDateTime createDate;
private LocalDateTime updateDate;
}
Address Entity – represents a postal address with street and city fields:
package com.altabuild.crud.infrastructure.entity;
import lombok.*;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Document(collection = "address_entity")
public class AddressEntity {
@Id
private String id;
private String userId;
private String street;
private Long number;
private String neighborhood;
private String complement;
private String city;
private String zip;
}
A few things to note from the above model definitions:
- We use Spring Data’s @Document annotation to mark these classes as MongoDB documents, specifying the collection name (e.g., "users" and "addresses").
- MongoDB will store User documents in a collection named users and Address documents in addresses.
- Each entity has an id field annotated with @id. When we save these objects, MongoDB will generate a unique ID (usually a 24-character hex string) if not provided. We use String type for IDs for simplicity.
- The AddressEntity contains a field userId which will store the ID of an associated user. In a relational database, we might use a foreign key; in MongoDB, we can simply keep the reference ID.
Alternative approach: embed the address object directly within the user document. Here we choose separate collections to illustrate referencing, and maintain an AddressEntity repository for address operations.
We’ll also define simple DTO classes for input and output, although in simple cases you could use the entity directly. In this application, UserRequestDTO might include name, email, and address details to create a new user, while UserResponseDTO would represent the data we return (including the resolved address object). This separation helps maintain a clean boundary between our API and database representations.
For example, a UserRequestDTO could look like:
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
@EqualsAndHashCode
public class UserRequestDTO {
private String name;
@JsonProperty(required = true)
private String email;
private String document;
private AddressRequestDTO address;
}
And a corresponding UserResponseDTO might combine user and address info for the response:
public record UserResponseDTO(String id,
String name,
String email,
String document,
AddressResponseDTO address) {
}
The referenced AddressResponseDTO would contain the associated Address info as explained further.
Same way as users, we will define the request and response DTOs for Address.
The AddresRequestDTO could look like:
package com.altabuild.crud.api.request;
import lombok.*;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EqualsAndHashCode
public class AddressRequestDTO {
private String street;
private Long number;
private String neighborhood;
private String complement;
private String city;
private String zip;
}
And a corresponding AddressResponseDTO might contain the address info for the response:
package com.altabuild.crud.api.response;
public record AddressResponseDTO(String street,
Long number,
String neighborhood,
String complement,
String city,
String zip) {
}
Using DTOs is considered a good practice in larger applications – it prevents exposing internal database structures and allows flexibility in shaping the JSON data returned by your API. In our case, the service layer will handle converting between UserEntity/AddressEntity and these DTOs.
In this example it has been used record type for the response DTOs just as examples, but it can be beneficial since Records are inherently immutable, which promotes data safety, and simplifies reasoning about code.
Creating Repositories for MongoDB
Spring Data MongoDB dramatically simplifies data access by providing repository interfaces that handle common operations out-of-the-box. The project will have two repository interfaces: one for users and one for addresses. These will extend MongoRepository which comes with methods like save(), findAll(), findById(), deleteById(), etc., without the need to implement them.
User Repository:
package com.altabuild.crud.infrastructure.repository;
import com.altabuild.crud.infrastructure.entity.UserEntity;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.transaction.annotation.Transactional;
public interface UserRepository extends MongoRepository<UserEntity, String> {
UserEntity findByEmail(String email);
UserEntity findByNameContaining(String namePart);
@Transactional
void deleteByEmail(String email);
}
Here we specify UserEntity as the domain type and String as the ID type.
We’ve also defined two custom finder methods:
- findByEmail(String email) – Spring Data parses this method name and automatically implements a query to find a user by the email field.
- findByNameContaining(String namePart) – This will find users where the name contains the given substring (useful for search functionality). Spring Data MongoDB supports many such keywords (Containing, StartsWith, etc.) for building queries without writing explicit query code.
Address Repository:
package com.altabuild.crud.infrastructure.repository;
import com.altabuild.crud.infrastructure.entity.AddressEntity;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.transaction.annotation.Transactional;
public interface AddressRepository extends MongoRepository<AddressEntity, String> {
AddressEntity findByUserId(String userId);
@Transactional
void deleteByUserId(String userId);
}
In many cases, you might not need a separate repository for addresses if you embed them within user documents. However, we include it here to demonstrate relationships. Having a dedicated AddressRepository allows us to perform operations on addresses independently (e.g., find all users in a given city by first querying addresses).
With our repositories in place, Spring will automatically create implementations for them at runtime. Next, we’ll write the service layer to orchestrate the business logic using these repositories.
Implementing the Service Layer
The service layer contains the core logic of our application, decoupling business rules from the web/API layer.
We will create a UserService class that uses UserRepository and AddressRepository to implement user-related operations. This service will also manage the conversion between entities and DTOs.
package com.altabuild.crud.business;
import com.altabuild.crud.api.converter.AddressConverter;
import com.altabuild.crud.api.converter.UserConverter;
import com.altabuild.crud.api.converter.UserMapper;
import com.altabuild.crud.api.request.UserRequestDTO;
import com.altabuild.crud.api.response.UserResponseDTO;
import com.altabuild.crud.infrastructure.entity.AddressEntity;
import com.altabuild.crud.infrastructure.entity.UserEntity;
import com.altabuild.crud.infrastructure.repository.UserRepository;
import com.altabuild.crud.infrastructure.exceptions.BusinessException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import static org.springframework.util.Assert.notNull;
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final UserConverter userConverter;
private final AddressConverter addressConverter;
private final UserMapper userMapper;
private final AddressService addressService;
public UserEntity saveUser(UserEntity userEntity) {
return userRepository.save(userEntity);
}
public UserResponseDTO createUser(UserRequestDTO userRequestDTO) {
try {
notNull(userRequestDTO, "The user data fields are required");
if (userRepository.findByEmail(userRequestDTO.getEmail()) != null) {
// custom exception for duplicate email
throw new BusinessException("Email already used!"); // custom
}
UserEntity userEntity = saveUser(userConverter.toUserEntity(userRequestDTO));
AddressEntity addressEntity = addressService.saveAddress(userRequestDTO.getAddress(), userEntity.getId());
return userMapper.toUserResponseDTO(userEntity, addressEntity);
} catch (Exception e) {
throw new BusinessException("Error during user creation", e);
}
}
public List<UserEntity> listAllUsers() {
return userRepository.findAll();
}
public UserEntity findById(String id) {
return userRepository.findById(id)
.orElseThrow(() -> new BusinessException("User not found"));
}
public UserEntity updateUser(String id, UserRequestDTO updatedUser) {
UserEntity existingUser = findById(id); // throws exception if not found
existingUser.setName(updatedUser.getName());
existingUser.setEmail(updatedUser.getEmail());
// If address is updated, handle that as well:
if (updatedUser.getAddress() != null) {
addressService.saveAddress(updatedUser.getAddress(), existingUser.getId());
}
return userRepository.save(existingUser);
}
public UserResponseDTO findByEmail(String email) {
try {
UserEntity entity = userRepository.findByEmail(email);
notNull(entity, "User not found");
AddressEntity addressEntity = addressService.findByUserId(entity.getId());
return userMapper.toUserResponseDTO(entity, addressEntity);
} catch (Exception e) {
throw new BusinessException("Error during user search", e);
}
}
@Transactional
public void deleteUser(String email) {
UserEntity entity = userRepository.findByEmail(email);
userRepository.deleteByEmail(email);
addressService.deleteByUserId(entity.getId());
}
}
A few highlights from the service code:
- createUser: to create a new user, we first might validate that the email isn’t already in use (to maintain uniqueness).
- We then save the address entity (using addressRepository), set the obtained address ID into the user entity, and save the user. This returns the saved UserEntity (which now has an id and an addressId). In practice, this method would likely accept a UserRequestDTO, from which we construct UserEntity and AddressEntity objects.
- findById: retrieves a user by ID or throws a custom BusinessException if not found. This exception could be annotated to return a 404 Not Found status.
- updateUser: finds the existing user, updates its fields with the provided data, and saves it. Here we assume the updatedUser already has the necessary fields (e.g., from a DTO after conversion).
- deleteUser: finds and deletes a user by email. You might also choose to delete the associated address, depending on cascade rules – for simplicity, we keep it as deleting only the user document.
Our BusinessException is a runtime exception we define (perhaps under infrastructure.exceptions) to indicate business rule violations or not-found scenarios.
In a real app, we’d add a @ControllerAdvice to translate these exceptions into HTTP responses with appropriate status codes.
Similarly to UserService, we also have the Service class for Adrress, following the same patterns. For simplicity, not all the CRUD methods were implemented.
package com.altabuild.crud.business;
import com.altabuild.crud.api.converter.AddressConverter;
import com.altabuild.crud.api.converter.AddressMapper;
import com.altabuild.crud.api.request.AddressRequestDTO;
import com.altabuild.crud.infrastructure.entity.AddressEntity;
import com.altabuild.crud.infrastructure.repository.AddressRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class AddressService {
private final AddressRepository addressRepository;
private final AddressMapper addressMapper;
private final AddressConverter addressConverter;
public AddressEntity saveAddress(AddressRequestDTO addressRequestDTO, String userId) {
AddressEntity addressEntity = addressConverter.toAddressEnitity(addressRequestDTO, userId);
return addressRepository.save(addressEntity);
}
public AddressEntity findByUserId(String userId) {
return addressRepository.findByUserId(userId);
}
public void deleteByUserId(String userId) {
addressRepository.deleteByUserId(userId);
}
}
Using DTOs in Service:
Notice that our service methods use UserEntity and AddressEntity. In practice, the controller will pass in data from UserRequestDTO (which includes address info); the service can create the AddressEntity and UserEntity from that. Similarly, after getting a UserEntity from the repository, the service (or controller) will assemble a UserResponseDTO that includes the address details.
For example, to build a response DTO, one might do:
UserEntity user = userRepository.findById(id).get();
AddressEntity addr = addressRepository.findById(user.getAddressId()).get();
UserResponseDTO response = UserMapper.toResponseDTO(user, addr);
Where UserMapper.toResponseDTO is a utility to map the fields into a UserResponseDTO object. This extra mapping layer keeps our API models clean and decoupled from database entities.
Now that the core logic is ready, let’s expose these operations through a RESTful API using Spring MVC controllers.
Building the REST Controller
The controller layer handles HTTP requests and responses.
We will create a UserController annotated with @RestController to expose endpoints for managing users. This controller will use UserService to perform the heavy lifting, and it will handle conversion to/from DTOs for the API.
package com.altabuild.crud.api;
import com.altabuild.crud.api.converter.UserConverter;
import com.altabuild.crud.api.converter.UserMapper;
import com.altabuild.crud.api.request.UserRequestDTO;
import com.altabuild.crud.api.response.UserResponseDTO;
import com.altabuild.crud.business.UserService;
import com.altabuild.crud.infrastructure.entity.UserEntity;
import com.altabuild.crud.infrastructure.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Controller
@RequestMapping("/user")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
private final UserConverter userConverter;
private final UserMapper userMapper;
@GetMapping
public ResponseEntity<List<UserResponseDTO>> listUsers() {
// Fetch all users and convert each to ResponseDTO
List<UserEntity> users = userService.listAllUsers();
return ResponseEntity.ok(userMapper.toResponseList(users));
}
@GetMapping("/{id}")
public ResponseEntity<UserResponseDTO> findById(@PathVariable String id) {
UserEntity user = userService.findById(id);
// fetch address entity to include in response
// service or converter can handle fetching address by
return ResponseEntity.ok(userMapper.toUserResponseDTO(user));
}
@PostMapping()
public ResponseEntity<UserResponseDTO> createUser(@RequestBody UserRequestDTO userRequestDTO) {
return ResponseEntity.status(201).body(userService.createUser(userRequestDTO));
}
@PutMapping("/{id}")
public ResponseEntity<UserResponseDTO> updateUser(
@PathVariable String id, @RequestBody UserRequestDTO userRequestDTO) {
UserEntity user = userService.updateUser(id, userRequestDTO);
return ResponseEntity.ok(userMapper.toUserResponseDTO(user));
}
@GetMapping()
public ResponseEntity<UserResponseDTO> searchUserByEmail(@RequestParam ("email") String email) {
return ResponseEntity.ok(userService.findByEmail(email));
}
@DeleteMapping
public ResponseEntity<Void> deleteUser(@RequestParam ("email") String email) {
userService.deleteUser(email);
return ResponseEntity.noContent().build();
// return ResponseEntity.accepted().build();
}
}
Let’s unpack what the controller is doing:
- It defines the base request mapping as /users, so all endpoints will be under that path (e.g., GET /users).
- listUsers() handles GET /users and returns a list of all users. The service returns a list of UserEntity, which we convert to a list of UserResponseDTO to send as JSON.
- findById(id) handles GET /users/{id} to fetch one user by ID. It uses the service, then converts to UserResponseDTO. The included address info means the converter likely fetches the AddressEntity by addressId and maps it into the response DTO.
- createUser() handles POST /users. The request body is a JSON that binds to UserRequestDTO (containing name, email, and address fields). We convert this into the entity objects, call service to save, then return a 201 Created response with the saved data as UserResponseDTO.
- updateUser() handles PUT /users/{id} to update an existing user. We get the DTO from the request, convert and pass it to the service’s update method. The updated entity is then converted to DTO for the response.
- deleteUser() handles DELETE /users/{id}. It calls the service to delete the user and returns HTTP 204 No Content (since there’s no response body).
- The accepted status is also suitable for this case.
Each method wraps the service calls and manages HTTP details (status codes, request parsing). We use ResponseEntity to control the HTTP status for create (201) and deletes, etc.
The use of DTO conversion in the controller keeps the controller code a bit verbose, but it isolates mapping logic (via UserConverter) from business logic.
Running and Testing the Application
With the code in place, it’s time to run the Spring Boot application and test the endpoints.
Database setup
Make sure you have MongoDB running.
By default, Spring Boot will attempt to connect to a MongoDB instance at mongodb://localhost:27017 (the default port) and use a database named test (unless you configured a different database name in application.properties or application.yml).
If you haven’t installed MongoDB locally, you can use a Docker container or MongoDB Atlas (cloud) – just update the connection string in the application configuration. For an Atlas connection example:
spring:
data:
mongodb:
uri: mongodb://<username>:<password>@cluster0.mongodb.net/<dbname>?retryWrites=true&w=majority
Running the app
Use your IDE or Maven/Gradle plugin to run the CrudApplication.
You should see Spring Boot start up. Look for log messages indicating a successful connection to Mongo (e.g., “MongoDB: connecting to mongodb://localhost:27017...”).
Once the application is running, you can test the API using a tool like Postman, cURL, or your browser (for GET requests). Here are some tests to verify each operation.
Example JSON Requests/Responses
To illustrate, these are some examples of requests, performed by Curl to perform the CRUD operations:
Create user
Send a POST request to http://localhost:8080/users with a JSON body like the one shown earlier. You should receive a 201 response with the created user data.
Each new user will get a unique id and their address will also get its own id.
-
Request (POST /users):
curl -X POST "{{baseUrl}}/users" \ -H "Content-Type: application/json" \ -d '{ "name": "Jane Doe", "email": "jane.doe@example.com", "document": "12345678900", "address": { "street": "Av. Brasil", "number": 1000, "neighborhood": "Centro", "complement": "Apto 101", "city": "São Paulo", "zip": "01000-000" } }'
-
Response (201 Created):
{ "id": "f1a2b3c4-1d2e-4f56-8a9b-7c6d5e4f3a21", "name": "Jane Doe", "email": "jane.doe@example.com", "document": "12345678900", "address": { "id": "f1a2b3c4-1d2e-4f56-8a9b-7c6d5e4654d84", "street": "Av. Brasil", "number": 1000, "neighborhood": "Centro", "complement": "Apto 101", "city": "São Paulo", "zip": "01000-000" } }
In the response, the user document and address document have their generated IDs, and the address is embedded as an object for convenience.
This output structure is defined by our UserResponseDTO and AddressResponseDTO classes.
Search by user email
Copy an email from the previous response and send a GET to http://localhost:8080/users/<that-email>. You should get the specific user or a 404 if the email is not found. Our controller would throw a BusinessException which ideally is mapped to a 404 status by an exception handler.
-
Request (GET /users?email={{email}}):
curl -X GET "{{baseUrl}}/users?email={{email}}"
-
Response (200 OK):
{ "id": "f1a2b3c4-1d2e-4f56-8a9b-7c6d5e4f3a21", "name": "Jane Doe", "email": "jane.doe@example.com", "document": "12345678900", "address": { "street": "Av. Brasil", "number": 1000, "neighborhood": "Centro", "complement": "Apto 101", "city": "São Paulo", "zip": "01000-000" } }
In case no user is found:
-
Response (404 Not Found):
{ "message": "User not found for email: jane.doe@example.com" }
List all users
Send a GET request to http://localhost:8080/users. You should receive an array of all users in the system (with their addresses embedded). This confirms that findAll() and the data retrieval from MongoDB are working.
-
Request (GET /users):
curl -X GET "{{baseUrl}}/users"
-
Response (201 Created):
[ { "id": "f1a2b3c4-1d2e-4f56-8a9b-7c6d5e4f3a21", "name": "Jane Doe", "email": "jane.doe@example.com", "document": "12345678900", "address": { "street": "Av. Brasil", "number": 1000, "neighborhood": "Centro", "complement": "Apto 101", "city": "São Paulo", "zip": "01000-000" } }, { "id": "c0ffee00-5bad-4b12-9e77-0123456789ab", "name": "John Smith", "email": "john.smith@example.com", "document": "98765432100", "address": { "street": "Rua das Flores", "number": 250, "neighborhood": "Jardins", "complement": "Casa", "city": "São Paulo", "zip": "01400-000" } } ]
Update user
Send a PUT to http://localhost:8080/users/<id> with a JSON body containing the fields to update. The response should be the updated user data. Check that changes persist by doing another GET.
-
Request (PUT /users):
curl -X PUT "{{baseUrl}}/users" \ -H "Content-Type: application/json" \ -d '{ "name": "Jane Doe", "email": "jane.doe@example.com", "document": "12345678900", "address": { "street": "Av. Brasil", "number": 1000, "neighborhood": "Centro", "complement": "Apto 101", "city": "São Paulo", "zip": "01000-000" } }'
-
Response (201 Created):
{ "id": "f1a2b3c4-1d2e-4f56-8a9b-7c6d5e4f3a21", "name": "Jane Doe", "email": "jane.doe@example.com", "document": "12345678900", "address": { "street": "Av. Brasil", "number": 1000, "neighborhood": "Centro", "complement": "Apto 101", "city": "São Paulo", "zip": "01000-000" } }
In case the user is not found:
-
Response (404 Not Found):
{ "message": "User not found for email: jane.doe@example.com" }
Delete by user email
Send a DELETE request to http://localhost:8080/users/<email> for an existing user. The response should have status 204 No Content. Confirm deletion by trying to GET that user again (should now return not found).
-
Request (GET /users?email={{email}}):
curl -X DELETE "{{baseUrl}}/users?email={{email}}"
Response (204 No Content): no body
In case user is not found:
-
Response (404 Not Found):
{ "message": "User not found for email: jane.doe@example.com" }
Throughout these operations, Spring Data MongoDB is handling the heavy lifting of database access. We didn’t write a single SQL or MongoDB query – the repository methods and naming conventions took care of it. This demonstrates the power of Spring Boot’s integration with MongoDB: we get a lot of functionality with minimal boilerplate.
Practical tip:
During development, you can watch your MongoDB collections to see the data being created. For instance, using the Mongo Compass GUI or the mongo shell, check the users and addresses collections to verify documents. You’ll notice that the user’s document contains the addressId linking to an entry in the addresses collection.
Tests using Postman
If you want to try all the requests using a proper tool to test APIs, I'd recommend Postman.
There is a collection to test all the endpoints/operations in this project repository.
Feel free to import it to Postman to test your running project.
Source code to test yourself
All the code for this project, test cURL requests, Postman collection, and other resources are available on GitHub in https://github.com/altairlage/crud-springboot-mongodb.
Feel free to clone the project, run, and experiment.
That's all folks!
Now you have the basic knowledge to create SpringBoot applications that use MongoDB as database.
We built a fully functional User Registration API with Spring Boot and MongoDB – covering everything from project setup to deployment-ready endpoints.
Despite being an introductory project, we followed best practices by using a layered architecture and DTOs, giving our code a clean and professional structure. The result is a RESTful service that can perform CRUD operations while storing data in MongoDB’s flexible document model.
If you follow up this tutorial, you will have exercised:
- Spring Boot + MongoDB Integration: Spring Boot’s auto-configuration and Spring Data MongoDB make it straightforward to connect to a Mongo database and perform CRUD operations with repository interfaces, without writing low-level queries.
- Project Structure and Layers: Organizing the code with clear layers (controller, service, repository, domain model) leads to cleaner and maintainable code. Controllers handle HTTP and DTO mapping, Services encapsulate business logic, and Repositories abstract the database.
- Use of DTOs: Introducing Request and Response DTOs decouples external API representation from internal database entities. This provides flexibility (for example, combining user and address info in responses conveniently) and security (not exposing internal fields).
- Spring Data Query Methods: We leveraged method naming conventions to create custom queries like findByEmail effortlessly. Spring Data parsed these and executed the appropriate MongoDB queries, which shows how powerful convention-over-configuration can be.
- Exception Handling & Validation: It’s important to handle edge cases – e.g., trying to create a user with an existing email, or fetching a non-existent user. We used a custom exception (BusinessException) to signal errors. In a full implementation, we’d map this to proper HTTP responses (400 Bad Request, 404 Not Found, etc.) using Spring’s @ControllerAdvice.
- Testing the API: Tools like Postman or curl are essential for manually testing each endpoint. We verified that create/read/update/delete all function as expected with our MongoDB back-end. The JSON examples confirm that our API returns data in a friendly, nested structure (embedding address details in the user response).
Challenge!
You can now extend this project by implementing the following features:
- Enhance validation – ensure email format is valid, or name is not empty (you can use Spring Boot’s Bean Validation @valid annotations on DTOs).
- Add pagination or filtering – if the list of users grows, implement paging with Spring Data’s Pageable, or add more query methods (e.g., find users by city via the address repository).
- Security and Auth – consider integrating Spring Security and JWT if you want to secure these endpoints for an actual application scenario.
- UI Integration – this API could serve as the backend for a front-end application or mobile app. Try creating a simple React or Angular front-end that communicates with these endpoints.
Top comments (0)