Project Name: Employee Management System (EMS)
Project Description
Build
a RESTful API for managing employees using Spring Boot, JPA, H2
(in-memory DB), validation, global exception handling, and Actuator
monitoring.
Requirements / Tasks to Complete
1. Setup & Basic Structure
- Create Spring Boot project (Maven, Java 17 or 25).
- Add dependencies: spring-boot-starter-web,
spring-boot-starter-data-jpa, h2, spring-boot-starter-validation,
spring-boot-starter-actuator, spring-boot-devtools.
- Configure `application.properties` (H2 console, actuator exposure).
2. Employee Entity
- `@Entity` class: Employee (id, name, salary, department, email).
- Use `@NotBlank`, `@Positive`, `@Email`, `@NotNull` for validation.
3. Employee DTOs
- `EmployeeRequestDto` (for POST/PUT) — with validation annotations.
- `EmployeeResponseDto` (for GET) — no sensitive fields.
4. EmployeeRepository
- Extend `JpaRepository<Employee, Long>`.
- Add custom methods: `findByNameContaining`, `findBySalaryGreaterThan`, `findByDepartment`.
5. EmployeeService
- CRUD methods with `@Transactional`.
- Throw custom exceptions (e.g., `EmployeeNotFoundException`, `DuplicateEmailException`).
6. EmployeeController (Full CRUD)
- GET /employees — list all with pagination & sorting (`Pageable`).
- GET /employees/{id} — single employee.
- POST /employees — create with `@Valid`.
- PUT /employees/{id} — update.
- DELETE /employees/{id} — delete.
7. Validation & Error Handling
- Use `@Valid` on DTOs.
- Global `@RestControllerAdvice` to handle `MethodArgumentNotValidException` and custom exceptions.
- Return consistent `ErrorResponse` DTO.
8. Actuator Monitoring
- Expose health, info, metrics.
- Add custom info (app version, build date).
- Optional: Custom health indicator (e.g., check DB connection).
9. Bonus (if time)
- Add `@Query` for advanced search.
- Test endpoints with Postman/curl.
Deliverable (End Goal)
- Full working REST API for Employee CRUD.
- GitHub repo with commits for each feature.
- Test with Postman (all endpoints, validation errors, exceptions)
_________________________________________________________________________
GitHub URL- employee-management-system🔗
Topics covered
Spring Boot JPA + CRUD 🔗
Spring Boot Actuator + Exception Handling 🔗
Spring Boot Profiles + Testing Basics 🔗
Features
=> Full CRUD operations for employees
=> Batch create multiple employees
=> Search/Filter employees (by name, department, min salary, etc)
=> Pagination & sorting support
=> DTO pattern (No entity exposure)
=> Bean validation (@Valid, @NotBlank, @Positive, @Email)
=> Global Exception handling with custom error responses
=> Actuator endpoints for monitoring (health, info, metrics)
=> Manual mapping (to avoid external model mapper library issues)
- employee-management-system/
- ├── pom.xml ← Maven build file (core of Spring Boot project)
- ├── README.md ← Main documentation
- ├── samples/ ← Sample payload json files folder for documentation purpose
- └── src/main/java/
- └── com/
- └── example/
- └── EMS/
- ├── EmsApplication.java ← Main Spring Boot entry point
- ├── config/ ← Configuration classes
- │ - └── DataBaseHealthIndicator.java
- │ - └── GlobalExceptionHandler.java
- │ - └── ModelMapperConfig.java
- ├── controller/ ← REST Controllers
- │ - └── EmployeeController.java
- ├── model/
- │ - ├── dto/ ← Data Transfer Objects
- │ - ├ - ├── EmployeeRequestDto.java
- │ - ├ - ├── EmployeeResponseDto.java
- │ - ├ - └── ErrorResponse.java
- │ - └── entity/ ← JPA Entities
- │ - └── Employee.java
- ├── exception/ ← Custom exceptions
- │ - ├── DuplicateEmailException.java
- │ - └── ResourceNotFoundException.java
- ├── repository/ ← Spring Data JPA Repositories
- │ - └── EmployeeRepository.java
- ├── service/ ← Business logic layer
- │ - ├── impl/
- │ - │ - └── EmployeeServiceImpl.java
- │ - └── EmployeeService.java
- └── util/
- └── src/test/java/
- └── com/
- └── example/
- └── EMS/
- ├── EmsApplicationTests.java ← Basic context load test
- ├── controller/ ← Controller tests
- │ - └── EmployeeControllerTest.java
- ├── repository/ ← Repository tests
- │ - └── EmployeeRepositoryTest.java
- └── service/ ← Service tests
- └── EmployeeServiceTest.java
- Other Important Folders/Files :
- employee-management-system/
- ├──src/main/resources/
- │ - └── application.properties
- │ - └── application-test.properties
- └──target/ ← Compiled classes, JAR file, etc
Main class
=> EMSApplication.java
Endpoints
- /addEmployee
- /addEmployees
- /{id}
- /getEmployeeById
- /getAllEmployees
- /searchEmployeeById
- /searchEmployees
- /updateEmployeeById/{id}
- /updateEmployeeByName/{name}
- /deleteEmployeeById/{id}
- /deleteAllEmployees
- /getAllEmployeesWithPagination
- /searchEmployeesWithPagination
- /searchEmployeesWithPagination1
@PostMapping("/addEmployee") //http://localhost:8080/api/employees/addEmployee
public ResponseEntity<EmployeeResponseDto> addEmployee(@Valid @RequestBody EmployeeRequestDto employeeRequestDto)
=> It is used to add one Employee at a time
=> ModelMapper did not work, So used manual mapping. Manual mapping is more reliable than ModelMapper
=> It uses the default method save(employee) (from JPARepository/CRUD repository)
=> Never forget to use @Transactional in service side as it needs commit on success, rollback on error
=> url : http://localhost:8080/api/employees/addEmployee
=> Since its PostMapping and adding new entry, HttpStatus should be 201 Created
=> It is recommended to show location as well in the ResponseEntity
@PostMapping("/addEmployees") //http://localhost:8080/api/employees/addEmployees
public ResponseEntity<List<EmployeeResponseDto>> addEmployees (@RequestBody List<EmployeeRequestDto> employeeRequestDtoList)
=> It is used to add multiple Employees at a time
=> ModelMapper did not work, So used manual mapping. Manual mapping is more reliable than ModelMapper
=> It uses the default method saveAll(employeeList) (from JPARepository/CRUD repository)
=> Never forget to use @Transactional in service side as it needs commit on success, rollback on error, batch insert, all or nothing
=> url : http://localhost:8080/api/employees/addEmployees
=> Since its PostMapping and adding new entry, HttpStatus should be 201 Created
=> It is recommended to show location as well in the ResponseEntity
@GetMapping("/{id}") //http://localhost:8080/api/employees/{id}
EmployeeResponseDto getEmployeeById(@PathVariable Long id)
=> It uses PathVariable
=> sample url : http://localhost:8080/api/employees/{id}
=> It uses the default method findById(id) (from JPARepository/CRUD repository)
@GetMapping
public List<EmployeeResponseDto> getAllEmployees()
=> sample url : http://localhost:8080/api/employees
=> It uses the default method findAll() (from JPARepository/CRUD repository)
@GetMapping("/searchEmployeeById") //http://localhost:8080/api/employees/searchEmployeeById?id={id}&name={name}
EmployeeResponseDto searchEmployeeById(@Valid @RequestParam("id") Long id)
=> Sample url : http://localhost:8080/api/employees/searchEmployeeById?id=1
=> It uses RequestParam
=> It uses the default method findById(id) (from JPARepository/CRUD repository)
@GetMapping("/searchEmployees")
public ResponseEntity<List<EmployeeResponseDto>> searchEmployees(@RequestParam(name="name", required = false) String employeeName,
@RequestParam(name="department", required = false) String department,
@RequestParam(name="minSalary", required = false) Integer minSalary)
=> Sample url :
//http://localhost:8080/api/employees/searchEmployees?name={name}
//http://localhost:8080/api/employees/searchEmployees?department={department}
//http://localhost:8080/api/employees/searchEmployees?minSalary={minSalary}
//http://localhost:8080/api/employees/searchEmployees?department={department}&minSalary={minSalary}
//http://localhost:8080/api/employees/searchEmployees
=> It uses RequestParam
=> It uses the default method findAll() (from JPARepository/CRUD repository), then applies required filters
@PutMapping("/updateEmployeeById/{id}")
public ResponseEntity<EmployeeResponseDto> updateEmployeeById(@Valid @RequestBody EmployeeRequestDto employeeRequestDto, @PathVariable Long id)
=> Sample url : http://localhost:8080/api/employees/updateEmployeeById/10
=> It uses PathVariable
=> It uses the default method findById(id), save(employee)
=> Since its PutMapping and updating the existing entry, HttpStatus should be 200 Ok
=> Remember we need to throw ResourceNotFoundException if the given id does not exist
=> Since it is updating based on id (ie id is primary key, unique), It will update exactly one entry at a time
@PutMapping("/updateEmployeeByName/{name}")
public ResponseEntity<List<EmployeeResponseDto>> updateEmployeeByName(@Valid @RequestBody EmployeeRequestDto employeeRequestDto, @PathVariable ("name") String name)
=>Sample url : http://localhost:8080/api/employees/updateEmployeeByName/Sachin
Since it is updating based on name (ie name is not primary key, it can be duplicate), It may update multiple entries at a time
=> Remember we need to throw ResourceNotFoundException if no record found for the given name
=> It uses the default method findByName(name)
=> For save, it uses save(employee) in loop
=> Since its PutMapping and updating the existing entry, HttpStatus should be 200 Ok
@DeleteMapping("/deleteEmployeeById/{id}")
public ResponseEntity<HttpStatus> deleteEmployeeById(@PathVariable("id") Long id)
=> Sample url : http://localhost:8080/api/employees/deleteEmployeeById/4
=> Since it is deleting based on id (ie id is primary key, unique), It will delete exactly one entry at a time
=> Remember we need to throw ResourceNotFoundException if the given id does not exist
=> Since its DeleteMapping, HttpStatus should be 204 - No content
@DeleteMapping("/deleteAllEmployees")
public ResponseEntity<HttpStatus> deleteAllEmployees()
=> url : http://localhost:8080/api/employees/deleteAllEmployees
=> It will delete all the employee records in the repository
=> Remember even if deleted all the records, on new insert the Id will be continued from where they left. Example if 10 records inserted, deleted all, then again inserting, those Id will start from 11, 12, ..
=> Since its DeleteMapping, HttpStatus should be 204 - No content
@GetMapping("/getAllEmployeesWithPagination")
public Page<EmployeeResponseDto> getAllEmployeesWithPagination(@PageableDefault(page = 0, size = 10, sort = "id", direction = Sort.Direction.ASC) Pageable pageable)
=> Instead of List<EmployeeResponseDto>, it returns Page<EmployeeResponseDto> wise
=> Sample url :
http://localhost:8080/api/employees/getAllEmployeesWithPagination?page=0&size=5
http://localhost:8080/api/employees/getAllEmployeesWithPagination?page=1&size=5
http://localhost:8080/api/employees/getAllEmployeesWithPagination?sort=salary,desc&size=10
http://localhost:8080/api/employees/getAllEmployeesWithPagination?page=0&size=5&sort=salary,desc
=> If we do not mention query params in url, it will consider @PageableDefault values
@GetMapping("/searchEmployeesWithPagination")
public Page<EmployeeResponseDto> searchEmployeesWithPagination(@RequestParam(name="name", required = false) String employeeName,
@RequestParam(name="department", required=false) String department,
@RequestParam(name="minSalary", required=false) Integer minSalary,
Pageable pageable)
=> Sample url : http://localhost:8080/api/employees/searchEmployeesWithPagination1?page=0&size=5&name=Sach
=> To enable pagination with search filters, we can use functional interface Specification
=> The below line is depricated, but still we added for core understanding
Specification<Employee> spec = Specification.where(null);
=> Actually, above line creates WHERE 1=1 SQL structure and so we can add chain conditions (AND/OR)
=> Remeber RQCB - Root, Query, CriteriaBuilder
=> We use employeeRepository.findAll(spec, pageable);
=> Note : We have to declare the method findAll(spec, pageable) in EmployeeRepository.java file
=> If we don't use @PageableDefault, page =0, size=20 by default
@GetMapping("/searchEmployeesWithPagination1")
public Page<EmployeeResponseDto> searchEmployeesWithPagination1(@RequestParam(name="name", required = false) String employeeName,
@RequestParam(name="department", required=false) String department,
@RequestParam(name="minSalary", required=false) Integer minSalary,
Pageable pageable)
=> This serves the same purpose of searchEmployeesWithPagination1
=> Since the below line is depricated, we used modern ways speciation.allOf(specs), Specification.anyOf(specs)
Specification<Employee> spec = Specification.where(null);
=> Specification.allOf(specs) - Combines the filters as AND conditions in SQL
=> Specification.anyOf(specs) - Combines the filters as OR conditions in SQL
Global Exception Handler - handled exceptions List
- MethodArgumentNotValidException
- MethodArgumentTypeMismatchException
- BindException
- HandlerMethodValidationException
- ConstraintViolationException
- ResourceNotFoundException (Custom exception)
- DuplicateEmailException (Custom exception)
- Exception
| Endpoint / Parameter Type | Annotation Used | Exception Thrown on Validation Failure | Handler to Catch It | Typical HTTP Status |
|---|---|---|---|---|
Single DTO: @RequestBody EmployeeRequestDto dto | @Valid | MethodArgumentNotValidException | @ExceptionHandler(MethodArgumentNotValidException.class) | 400 Bad Request |
List DTO: @RequestBody List<EmployeeRequestDto> dtoList | @Valid | BindException (or wrapped MethodArgumentNotValidException) | @ExceptionHandler(BindException.class) (and sometimes MethodArgumentNotValidException) | 400 Bad Request |
JPA repository methods used
- findByName
- existsByEmail
- findAll
- findEmailsIn (@Query)
DB table list (Entity)
- Employee (@Table(name="employees"))
Actuator endpoints
=> DataBaseHealthIndicator (Custom health indicator)
How to enable actuator endpoints (health, info, metrics) ?
=> Ensure actuator dependency present in the pom.xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
=> Add configuration in application.properties file
# Enable Actuator endpoints (expose them over HTTP)
management.endpoints.web.exposure.include=health,info,metrics
# Show full details for health checks (e.g., database status)
management.endpoint.health.show-details=always
# Optional: Expose more if needed later (e.g., loggers)
# management.endpoints.web.exposure.include=health,info,metrics,loggers
# Optional: Change Actuator base path (default is /actuator)
# management.endpoints.web.base-path=/manage
NOTE : management.endpoint.health.show-details=always → shows component details (DB, disk, etc.)
=> Restart the app
=> Verify the endpoints
http://localhost:8080/actuator/health
http://localhost:8080/actuator/info
http://localhost:8080/actuator/metrics
http://localhost:8080/actuator/metrics/http.server.requests
Custom Health Indicator
=> Created DataBaseHealthIndicator (this is custom health indicator)
=> Without custom health indicator, it will just show whether the database is up or not, no extra details
=> With custom health indicator, it will show extra information that we added in the overridden health() method
How to make H2 database down for testing purpose ?
=> Create src/main/resources/application-test.properties
# Invalid URL to simulate DB down
spring.datasource.url=jdbc:h2:mem:invalid-db-name;DB_CLOSE_DELAY=-1;INVALID_PARAM=true
=> IntelliJ → Edit Configurations → add to Program arguments:
--spring.profiles.active=test
=> Restart the app, the app will not be running up. It will throw some exceptions in the console
=> So, the only way to test the /health to show db down is :
Modify DatabaseHealthIndicator to force DOWN in test profile (temporary for testing)
package com.example.employeeManagementSystem.config;
import org.springframework.core.env.Environment;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.stereotype.Component;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.SQLException;
import java.util.Arrays;
@Component
public class DataBaseHealthIndicator implements HealthIndicator {
private final DataSource dataSource;
private final Environment environment;
//Constructor Injection
public DataBaseHealthIndicator(DataSource dataSource, Environment environment){
this.dataSource = dataSource;
this.environment = environment;
}
@Override //http://localhost:8080/actuator/health
public Health health(){
boolean isTestProfile = Arrays.asList(environment.getActiveProfiles()).contains("test");
if (isTestProfile) {
// Simulated DOWN for test profile
return Health.down()
.withDetail("profile", "test")
.withDetail("status", "Simulated DOWN for testing")
.build();
}
// Normal DB check for all other profiles
try(Connection connection = dataSource.getConnection()){
if(connection.isValid(1)){ // 1 second timeout
DatabaseMetaData dataBaseMetaData = connection.getMetaData();
return Health.up()
.withDetail("Database", "Connected Successfully")
.withDetail("DataBaseName", dataBaseMetaData.getDatabaseProductName())
.withDetail("DataBaseVersion", dataBaseMetaData.getDatabaseProductVersion())
.withDetail("Driver", dataBaseMetaData.getDriverName())
.withDetail("DriverVersion", dataBaseMetaData.getDriverVersion())
.build();
}else{
return Health.down()
.withDetail("Reason","Connection is not valid")
.build();
}
}catch (SQLException e){
return Health.down()
.withDetail("error", "Failed to connect "+e.getMessage())
.withException(e)
.build();
}
}
}
=> Run the test profile
=> Alternate way is to have two separate classes
1. TestHealthIndicator (only for test profile)
@Component
@Profile("test")
public class TestHealthIndicator implements HealthIndicator {
@Override
public Health health() {
return Health.down()
.withDetail("profile", "test")
.withDetail("status", "Simulated DOWN for testing")
.build();
}
}
2. DatabaseHealthIndicator (for non-test)
@Component
@Profile("!test")
public class DatabaseHealthIndicator implements HealthIndicator {
// Your normal DB check code...
}
=> Spring registers only one at runtime based on active profile, no name conflict
TEST CASES - @WebMvcTest + MockMVC
=> @ExtendWith(MockitoExtension.class) + @Mock / @InjectMocks is the pure Mockito way (no Spring at all).
=> @WebMvcTest is the official, recommended way for testing Spring MVC controllers in Spring Boot
=> So, prefer practising @WebMvcTest way of test cases
=> @WebMvcTest + MockMvc - for Controller test
=> @MockitoBean - for Service test (@MockBean is deprecated)
=> @DataJpaTest - for Repository test
=> @SpringBootTest - End to end integration test with full spring context i.e full flow test
=> Example :
@Test //Success - Valid request → 201 Created with Location header & body
void testAddEmployee_Success() throws Exception{
//Prepare request DTO
EmployeeRequestDto employeeRequestDto = new EmployeeRequestDto("Sachin", 900000, "Cricket", "sachin@gmail.com");
//Prepare expected response DTO
EmployeeResponseDto employeeResponseDto = new EmployeeResponseDto(1L, "Sachin", 900000, "Cricket", "sachin@gmail.com");
//Mock service behavior
when(employeeService.addEmployee(any(EmployeeRequestDto.class))).thenReturn(employeeResponseDto);
//Perform POST request
mockMvc.perform(post("/api/employees/addEmployee")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(employeeRequestDto)) //Converts Java DTO to JSON string for request body
)
.andExpect(status().isCreated()) //201 created
.andExpect(header().string("location", containsString("api/employees/1")))
.andExpect(jsonPath("$.id").value(1L))
.andExpect(jsonPath("$.name").value("Sachin"))
.andExpect(jsonPath("$.salary").value(900000))
.andExpect(jsonPath("$.department").value("Cricket"))
.andExpect(jsonPath("$.email").value("sachin@gmail.com"));
//Verify service was called once
verify(employeeService, times(1)).addEmployee(any(EmployeeRequestDto.class));
}
Explanation of key parts
@WebMvcTest(EmployeeController.class)
=> Loads only the web layer (controller, filters, message converters)
=> No full context, no real service/DB — very fast
@MockitoBean EmployeeService
=> Replaces the real EmployeeService with a mock in the Spring context
=> You control what it returns or throws
MockMvc
=> Simulates HTTP requests (POST with JSON body)
=> perform(post(...)) sends the request
=> andExpect(...) asserts response (status, JSON content, headers)
objectMapper.writeValueAsString()
=> Converts Java DTO to JSON string for request body
jsonPath(...)
=> Asserts specific JSON fields in response (e.g., $.name)
verify(...)
=> Ensures service method was called exactly once (or never on validation fail)
Ensure the dependencies needed (in pom.xml) :
=> spring-boot-starter-test (includes JUnit 5, Mockito, MockMvc, AssertJ, Jackson)
Key points to remember
=> One endpoint API can have the following 3 test cases (referred the example endpoint)
- Success case (valid request → 201 Created + Location header + response body)
- Validation failure (invalid DTO → 400 Bad Request + error messages)
- Service exception (e.g., any business error → 500 or custom status)
=> Test case consists of the following 5 parts (often, not always)
- Prepare request DTO
- Prepare expected response DTO
- Mock service behavior (when..thenReturn or when..thenThrow)
- MockMvc perform http request (mockMvc.perform(post())..andExpect(...);)
- Verify the service was called exactly once or never depending upon the case (Eg. verify(employeeService, times(1)).addEmployee(any(EmployeeRequestDto.class));)
--------------------------------------------------------------------------------------------------------
Interview Readiness - preparation plan
| Day | Focus Area | Duration | What We Will Do |
|---|---|---|---|
| 1 | Project Overview + Storytelling | 1 hour | Create strong 2-3 minute introduction + overall architecture explanation |
| 2 | Technical Deep Dive (Core) | 1–1.5 hrs | Database design, API design, Layered architecture, DTOs, Exception handling |
| 3 | Challenges, Problem Solving & Decisions | 1–1.5 hrs | Most important for senior roles – challenges you faced + how you solved them |
| 4 | Advanced Topics + Improvements | 1 hour | Scalability, Security, Performance, Future improvements, Monitoring |
| 5 | Full Mock Interview + Revision | 1–1.5 hrs | Complete simulated interview + feedback + weak area revision |
Project employee-management-system introduction
High level architecture explanation
All APIs are under the base path /api/employees. I implemented the following key endpoints:
- POST /api/employees/addEmployee → Create single employee
- POST /api/employees/addEmployees → Create multiple employees (Batch)
- GET /api/employees → Get all employees
- GET /api/employees/{id} → Get employee by ID (Path Variable)
- GET /api/employees/getEmployeeById → Get employee by ID (RequestParam)
- GET /api/employees/searchEmployees → Search employees by name, department, minSalary
- GET /api/employees/getAllEmployeesWithPagination → Get all employees with pagination & sorting
- GET /api/employees/searchEmployeesWithPagination → Search with pagination
- PUT /api/employees/updateEmployeeById/{id} → Update employee by ID
- PUT /api/employees/updateEmployeeByName/{name} → Update employee(s) by name
- DELETE /api/employees/deleteEmployeeById/{id} → Delete by ID
- DELETE /api/employees/deleteAllEmployees → Delete all employees
I used @RequestBody, @PathVariable, @RequestParam, and Pageable for pagination. All responses are wrapped in ResponseEntity with proper HTTP status codes (201 for create, 200 for success, 204 for delete, etc.)
Major Challenges & How You Solved Them