10 Spring Boot REST API Mistakes You're Probably Making (And How to Fix Them)
backend
14 min read
Most Spring Boot APIs break at least 3 of these rules. Fix yours in 30 minutes with copy-paste code examples - from DTOs and error handling to security and versioning.

Published By: Nelson Djalo | Date: May 5, 2025 | Updated: April 5, 2026
Most Spring Boot APIs I review have the same problems: entities leaking into responses, inconsistent error handling, no pagination, and zero input validation. This guide covers 10 specific fixes -- from proper REST naming and DTOs to security, versioning, and API docs -- with copy-paste code examples you can drop into your project today.
Always use plural nouns and avoid action words in your URLs. This is one of the fundamental principles of REST API design and helps create intuitive, predictable endpoints.
❌ Bad
@GetMapping("/getAllUsers")
@PostMapping("/createUser")
@PutMapping("/updateUserById")
✅ Good
@GetMapping("api/v1/users")
@PostMapping("api/v1/users")
@PutMapping("api/v1/users/{id}")
Keep endpoints clean and meaningful:
api/v1/users - get all usersapi/v1/users/{id} - get user by IDapi/v1/users (POST) - create new userapi/v1/users/{id} (PUT/PATCH) - update userapi/v1/users/{id} (DELETE) - delete userAdvanced Naming Conventions:
For nested resources, use a hierarchical structure:
// Get all posts by a specific user
@GetMapping("api/v1/users/{userId}/posts")
// Get a specific post by a specific user
@GetMapping("api/v1/users/{userId}/posts/{postId}")
// Create a new post for a specific user
@PostMapping("api/v1/users/{userId}/posts")
Benefits of RESTful Naming:
Always return status codes that reflect what happened on the server. This provides clear feedback to API consumers about the success or failure of their requests.
Creating Resources:
@PostMapping("/users")
public ResponseEntity<UserDto> createUser(@RequestBody @Valid UserRequest request) {
UserDto created = userService.createUser(request);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
Updating Resources:
@PutMapping("/users/{id}")
public ResponseEntity<UserDto> updateUser(@PathVariable Long id, @RequestBody @Valid UserRequest request) {
UserDto updated = userService.updateUser(id, request);
return ResponseEntity.ok(updated);
}
Deleting Resources:
@DeleteMapping("/users/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
userService.deleteUser(id);
return ResponseEntity.noContent().build();
}
Common HTTP Status Codes:
200 OK - Success (GET, PUT, PATCH)201 Created - New resource created (POST)204 No Content - Successfully deleted (DELETE)400 Bad Request - Validation failed or malformed request401 Unauthorized - Authentication required403 Forbidden - Authenticated but not authorized404 Not Found - Resource not found409 Conflict - Resource conflict (e.g., duplicate email)422 Unprocessable Entity - Valid request but cannot be processed500 Internal Server Error - Something went wrong on the serverCustom Status Codes for Business Logic:
@PostMapping("/users")
public ResponseEntity<UserDto> createUser(@RequestBody @Valid UserRequest request) {
try {
UserDto created = userService.createUser(request);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
} catch (DuplicateEmailException e) {
return ResponseEntity.status(HttpStatus.CONFLICT).build();
}
}
Never expose your database entities directly. Use Data Transfer Objects (DTOs) to control what data is exposed to clients and protect your internal data structure.
❌ Bad:
@GetMapping("/users/{id}")
public ResponseEntity<User> getUser(@PathVariable Long id) {
User user = userRepository.findById(id).orElseThrow(() -> new UserNotFoundException("User not found"));
return ResponseEntity.ok(user); // Exposes all entity fields including sensitive data
}
✅ Good:
@GetMapping("/users/{id}")
public ResponseEntity<UserResponse> getUser(@PathVariable Long id) {
User user = userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException("User not found"));
return ResponseEntity.ok(UserResponse.from(user));
}
DTO Implementation:
public record UserResponse(
Long id,
String name,
String email,
LocalDateTime createdAt
) {
public static UserResponse from(User user) {
return new UserResponse(
user.getId(),
user.getName(),
user.getEmail(),
user.getCreatedAt()
);
}
}
Benefits of Using DTOs:
Advanced DTO Patterns:
// Request DTO for creating users
public record CreateUserRequest(
@NotBlank String name,
@Email String email,
@NotBlank @Size(min = 8) String password
) {}
// Response DTO for user details
public record UserDetailResponse(
Long id,
String name,
String email,
List<PostSummary> posts,
LocalDateTime createdAt
) {}
// Summary DTO for list views
public record UserSummaryResponse(
Long id,
String name,
String email
) {}
Avoid manual if-checks and use Jakarta Bean Validation annotations for comprehensive input validation.
❌ Bad:
@PostMapping("/users")
public ResponseEntity<?> createUser(@RequestBody UserRequest request) {
if (request.getName() == null || request.getName().isBlank()) {
throw new IllegalArgumentException("Name is required");
}
if (request.getEmail() == null || !request.getEmail().contains("@")) {
throw new IllegalArgumentException("Valid email is required");
}
// More manual validation...
}
✅ Good:
public record UserRequest(
@NotBlank(message = "Name is required")
@Size(min = 2, max = 100, message = "Name must be between 2 and 100 characters")
String name,
@NotBlank(message = "Email is required")
@Email(message = "Email must be valid")
String email,
@NotBlank(message = "Password is required")
@Size(min = 8, message = "Password must be at least 8 characters")
@Pattern(regexp = "^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[@#$%^&+=]).*$",
message = "Password must contain at least one digit, lowercase, uppercase, and special character")
String password
) {}
@PostMapping("/users")
public ResponseEntity<?> createUser(@RequestBody @Valid UserRequest request) {
// Validation is automatically handled by Spring
UserDto created = userService.createUser(request);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
Custom Validation Annotations:
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = UniqueEmailValidator.class)
public @interface UniqueEmail {
String message() default "Email already exists";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
@Component
public class UniqueEmailValidator implements ConstraintValidator<UniqueEmail, String> {
private final UserRepository userRepository;
@Override
public boolean isValid(String email, ConstraintValidatorContext context) {
return email == null || !userRepository.existsByEmail(email);
}
}
Validation Error Handling:
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ValidationErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
List<String> errors = ex.getBindingResult()
.getFieldErrors()
.stream()
.map(error -> error.getField() + ": " + error.getDefaultMessage())
.collect(Collectors.toList());
ValidationErrorResponse response = new ValidationErrorResponse(
"VALIDATION_ERROR",
"Validation failed",
errors
);
return ResponseEntity.badRequest().body(response);
}
}
Structure your code using the Controller-Service-Repository pattern to maintain clean separation of concerns and improve testability.
Controller Layer (Handles HTTP requests):
@RestController
@RequestMapping("/api/v1/users")
@Validated
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping
public ResponseEntity<Page<UserSummaryResponse>> getUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "name") String sort) {
Pageable pageable = PageRequest.of(page, size, Sort.by(sort));
Page<UserSummaryResponse> users = userService.getUsers(pageable);
return ResponseEntity.ok(users);
}
@GetMapping("/{id}")
public ResponseEntity<UserDetailResponse> getUser(@PathVariable Long id) {
UserDetailResponse user = userService.getUserById(id);
return ResponseEntity.ok(user);
}
@PostMapping
public ResponseEntity<UserDetailResponse> createUser(@RequestBody @Valid CreateUserRequest request) {
UserDetailResponse created = userService.createUser(request);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
@PutMapping("/{id}")
public ResponseEntity<UserDetailResponse> updateUser(
@PathVariable Long id,
@RequestBody @Valid UpdateUserRequest request) {
UserDetailResponse updated = userService.updateUser(id, request);
return ResponseEntity.ok(updated);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
userService.deleteUser(id);
return ResponseEntity.noContent().build();
}
}
Service Layer (Business Logic):
@Service
@Transactional
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
public Page<UserSummaryResponse> getUsers(Pageable pageable) {
return userRepository.findAll(pageable)
.map(UserSummaryResponse::from);
}
public UserDetailResponse getUserById(Long id) {
User user = userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException("User not found with id: " + id));
return UserDetailResponse.from(user);
}
public UserDetailResponse createUser(CreateUserRequest request) {
if (userRepository.existsByEmail(request.email())) {
throw new DuplicateEmailException("Email already exists: " + request.email());
}
User user = new User();
user.setName(request.name());
user.setEmail(request.email());
user.setPassword(passwordEncoder.encode(request.password()));
user.setCreatedAt(LocalDateTime.now());
User savedUser = userRepository.save(user);
return UserDetailResponse.from(savedUser);
}
public UserDetailResponse updateUser(Long id, UpdateUserRequest request) {
User user = userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException("User not found with id: " + id));
user.setName(request.name());
user.setEmail(request.email());
user.setUpdatedAt(LocalDateTime.now());
User updatedUser = userRepository.save(user);
return UserDetailResponse.from(updatedUser);
}
public void deleteUser(Long id) {
if (!userRepository.existsById(id)) {
throw new UserNotFoundException("User not found with id: " + id);
}
userRepository.deleteById(id);
}
}
Repository Layer (Data Access):
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
boolean existsByEmail(String email);
@Query("SELECT u FROM User u WHERE u.name LIKE %:name%")
List<User> findByNameContaining(@Param("name") String name);
}
Never return thousands of records at once. Use pagination to improve performance and user experience.
Controller Implementation:
@GetMapping
public ResponseEntity<Page<UserSummaryResponse>> getUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "name") String sort,
@RequestParam(defaultValue = "asc") String direction) {
Sort.Direction sortDirection = Sort.Direction.fromString(direction.toUpperCase());
Pageable pageable = PageRequest.of(page, size, Sort.by(sortDirection, sort));
Page<UserSummaryResponse> users = userService.getUsers(pageable);
return ResponseEntity.ok(users);
}
Service Implementation:
public Page<UserSummaryResponse> getUsers(Pageable pageable) {
// Validate page size to prevent abuse
if (pageable.getPageSize() > 100) {
pageable = PageRequest.of(pageable.getPageNumber(), 100, pageable.getSort());
}
return userRepository.findAll(pageable)
.map(UserSummaryResponse::from);
}
Custom Pagination Response:
public record PaginatedResponse<T>(
List<T> data,
int page,
int size,
long totalElements,
int totalPages,
boolean hasNext,
boolean hasPrevious
) {
public static <T> PaginatedResponse<T> from(Page<T> page) {
return new PaginatedResponse<>(
page.getContent(),
page.getNumber(),
page.getSize(),
page.getTotalElements(),
page.getTotalPages(),
page.hasNext(),
page.hasPrevious()
);
}
}
Advanced Pagination with Filtering:
@GetMapping("/search")
public ResponseEntity<PaginatedResponse<UserSummaryResponse>> searchUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String name,
@RequestParam(required = false) String email,
@RequestParam(defaultValue = "name") String sort) {
Pageable pageable = PageRequest.of(page, size, Sort.by(sort));
Page<UserSummaryResponse> users = userService.searchUsers(name, email, pageable);
return ResponseEntity.ok(PaginatedResponse.from(users));
}
Handle errors in one place using @ControllerAdvice to provide consistent error responses across your API.
Global Exception Handler:
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
List<String> errors = ex.getBindingResult()
.getFieldErrors()
.stream()
.map(error -> error.getField() + ": " + error.getDefaultMessage())
.collect(Collectors.toList());
ErrorResponse response = new ErrorResponse(
"VALIDATION_ERROR",
"Validation failed",
errors
);
return ResponseEntity.badRequest().body(response);
}
@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity<ErrorResponse> handleUserNotFound(UserNotFoundException ex) {
ErrorResponse response = new ErrorResponse(
"USER_NOT_FOUND",
ex.getMessage()
);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
}
@ExceptionHandler(DuplicateEmailException.class)
public ResponseEntity<ErrorResponse> handleDuplicateEmail(DuplicateEmailException ex) {
ErrorResponse response = new ErrorResponse(
"DUPLICATE_EMAIL",
ex.getMessage()
);
return ResponseEntity.status(HttpStatus.CONFLICT).body(response);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGenericException(Exception ex) {
ErrorResponse response = new ErrorResponse(
"INTERNAL_ERROR",
"An unexpected error occurred"
);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}
}
Error Response DTO:
public record ErrorResponse(
String code,
String message,
List<String> details
) {
public ErrorResponse(String code, String message) {
this(code, message, null);
}
}
Custom Exceptions:
public class UserNotFoundException extends RuntimeException {
public UserNotFoundException(String message) {
super(message);
}
}
public class DuplicateEmailException extends RuntimeException {
public DuplicateEmailException(String message) {
super(message);
}
}
Secure your endpoints using Spring Security with proper authentication and authorization.
Security Configuration:
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/auth/**").permitAll()
.requestMatchers("/api/v1/public/**").permitAll()
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(ex -> ex
.authenticationEntryPoint(new JwtAuthenticationEntryPoint())
.accessDeniedHandler(new JwtAccessDeniedHandler())
);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
JWT Authentication Filter:
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider tokenProvider;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = getJwtFromRequest(request);
if (StringUtils.hasText(token) && tokenProvider.validateToken(token)) {
String username = tokenProvider.getUsernameFromToken(token);
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
private String getJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
Method-Level Security:
@RestController
@RequestMapping("/api/v1/users")
public class UserController {
@GetMapping("/{id}")
@PreAuthorize("hasRole('USER') or #id == authentication.principal.id")
public ResponseEntity<UserDetailResponse> getUser(@PathVariable Long id) {
// Only users can access their own data, or admins can access any user
UserDetailResponse user = userService.getUserById(id);
return ResponseEntity.ok(user);
}
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
// Only admins can delete users
userService.deleteUser(id);
return ResponseEntity.noContent().build();
}
}
Keep old versions alive by versioning your endpoints to avoid breaking changes for existing consumers.
URL Versioning:
@RestController
@RequestMapping("/api/v1/users")
public class UserControllerV1 {
@GetMapping("/{id}")
public ResponseEntity<UserResponseV1> getUser(@PathVariable Long id) {
// V1 implementation
}
}
@RestController
@RequestMapping("/api/v2/users")
public class UserControllerV2 {
@GetMapping("/{id}")
public ResponseEntity<UserResponseV2> getUser(@PathVariable Long id) {
// V2 implementation with additional fields
}
}
Header Versioning:
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping("/{id}")
public ResponseEntity<?> getUser(
@PathVariable Long id,
@RequestHeader(value = "API-Version", defaultValue = "1") String version) {
if ("2".equals(version)) {
return ResponseEntity.ok(userService.getUserV2(id));
} else {
return ResponseEntity.ok(userService.getUserV1(id));
}
}
}
Content Negotiation:
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping(value = "/{id}", produces = {
"application/vnd.company.app-v1+json",
"application/vnd.company.app-v2+json"
})
public ResponseEntity<?> getUser(
@PathVariable Long id,
HttpServletRequest request) {
String acceptHeader = request.getHeader("Accept");
if (acceptHeader.contains("v2")) {
return ResponseEntity.ok(userService.getUserV2(id));
} else {
return ResponseEntity.ok(userService.getUserV1(id));
}
}
}
Versioning Strategy Benefits:
Use SpringDoc OpenAPI or Swagger UI for interactive documentation that helps developers understand and test your API.
Dependencies:
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.2.0</version>
</dependency>
OpenAPI Configuration:
@Configuration
public class OpenApiConfig {
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("User Management API")
.version("1.0")
.description("API for managing users in the system")
.contact(new Contact()
.name("API Support")
.email("support@company.com"))
.license(new License()
.name("MIT")
.url("https://opensource.org/licenses/MIT")))
.servers(List.of(
new Server().url("http://localhost:8080").description("Development server"),
new Server().url("https://api.company.com").description("Production server")
));
}
}
Controller Documentation:
@RestController
@RequestMapping("/api/v1/users")
@Tag(name = "User Management", description = "APIs for managing users")
public class UserController {
@Operation(
summary = "Get user by ID",
description = "Retrieves a user by their unique identifier"
)
@ApiResponses(value = {
@ApiResponse(
responseCode = "200",
description = "User found",
content = @Content(schema = @Schema(implementation = UserDetailResponse.class))
),
@ApiResponse(
responseCode = "404",
description = "User not found",
content = @Content(schema = @Schema(implementation = ErrorResponse.class))
)
})
@GetMapping("/{id}")
public ResponseEntity<UserDetailResponse> getUser(
@Parameter(description = "User ID", example = "1")
@PathVariable Long id) {
UserDetailResponse user = userService.getUserById(id);
return ResponseEntity.ok(user);
}
@Operation(
summary = "Create new user",
description = "Creates a new user with the provided information"
)
@PostMapping
public ResponseEntity<UserDetailResponse> createUser(
@RequestBody @Valid CreateUserRequest request) {
UserDetailResponse created = userService.createUser(request);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
}
DTO Documentation:
@Schema(description = "Request to create a new user")
public record CreateUserRequest(
@Schema(description = "User's full name", example = "John Doe")
@NotBlank(message = "Name is required")
String name,
@Schema(description = "User's email address", example = "john.doe@example.com")
@NotBlank(message = "Email is required")
@Email(message = "Email must be valid")
String email,
@Schema(description = "User's password (min 8 characters)", example = "SecurePass123!")
@NotBlank(message = "Password is required")
@Size(min = 8, message = "Password must be at least 8 characters")
String password
) {}
Access Documentation:
http://localhost:8080/swagger-ui.htmlhttp://localhost:8080/v3/api-docshttp://localhost:8080/v3/api-docs.yamlBeyond the top 10 practices, consider these advanced techniques for production-ready APIs:
Implement rate limiting to prevent API abuse and ensure fair usage:
@Component
public class RateLimitInterceptor implements HandlerInterceptor {
private final RateLimiter rateLimiter;
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
String clientId = getClientId(request);
if (!rateLimiter.tryAcquire(clientId)) {
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
response.getWriter().write("Rate limit exceeded");
return false;
}
return true;
}
}
Implement caching to improve performance:
@RestController
@RequestMapping("/api/v1/users")
public class UserController {
@Cacheable(value = "users", key = "#id")
@GetMapping("/{id}")
public ResponseEntity<UserDetailResponse> getUser(@PathVariable Long id) {
UserDetailResponse user = userService.getUserById(id);
return ResponseEntity.ok(user);
}
@CacheEvict(value = "users", key = "#id")
@PutMapping("/{id}")
public ResponseEntity<UserDetailResponse> updateUser(
@PathVariable Long id,
@RequestBody @Valid UpdateUserRequest request) {
UserDetailResponse updated = userService.updateUser(id, request);
return ResponseEntity.ok(updated);
}
}
Implement comprehensive logging for debugging and monitoring:
@Component
public class RequestResponseLoggingFilter extends OncePerRequestFilter {
private static final Logger logger = LoggerFactory.getLogger(RequestResponseLoggingFilter.class);
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
long startTime = System.currentTimeMillis();
// Log request
logger.info("Request: {} {}", request.getMethod(), request.getRequestURI());
filterChain.doFilter(request, response);
// Log response
long duration = System.currentTimeMillis() - startTime;
logger.info("Response: {} {} - {}ms",
request.getMethod(),
request.getRequestURI(),
duration);
}
}
You don't need to apply all 10 at once. Pick the ones your codebase is missing -- DTOs and global exception handling usually give the biggest immediate payoff -- and layer in the rest over time. A well-structured API pays for itself every time someone new joins the team and can read your endpoints without asking questions.
Ready to put these practices into action? Check out our comprehensive Building APIs with Spring Boot course for hands-on experience with all these techniques and more advanced API development patterns.

Skip the generic recommendations. These 9 books changed how I write code, lead teams, and think about systems - from Clean Code to books most devs haven't heard of.

The exact skills, tools, and learning order to go from zero to hired as a Java full stack developer. Covers Spring Boot, React, databases, Docker, and what employers actually look for.

Abstract class or interface? Most Java devs get this wrong. Here's a clear breakdown with a side-by-side comparison table, code examples, and a simple decision rule.
Join thousands of developers mastering in-demand skills with Amigoscode. Try it free today.