Clean Code & APIs
Write maintainable code and design robust APIs
Clean Code & APIs in Enterprise Java
Master clean code principles and API design patterns with free flashcards and spaced repetition practice. This lesson covers SOLID principles, RESTful API best practices, documentation standards, and error handling strategiesβessential concepts for building maintainable enterprise Java applications.
Welcome to Professional Java Development π»
In enterprise environments, code isn't just something that worksβit's an asset that teams maintain for years, sometimes decades. Clean code principles and well-designed APIs are the foundation of sustainable software development. This lesson will transform how you write Java code, moving from "it works" to "it works beautifully."
What makes code "clean"? Clean code is readable, maintainable, testable, and expresses intent clearly. It's code that makes your colleagues smile rather than cringe during code reviews. What makes an API "good"? A good API is intuitive, consistent, hard to misuse, and evolves gracefully over time.
Let's dive into the principles and patterns that separate junior code from professional enterprise-grade Java.
Core Concepts: The Pillars of Clean Code ποΈ
1. SOLID Principles: The Foundation
SOLID is an acronym for five fundamental object-oriented design principles that guide clean code architecture:
| Principle | Definition | Key Benefit |
|---|---|---|
| Single Responsibility | A class should have one, and only one, reason to change | Easier to understand and modify |
| Open/Closed | Open for extension, closed for modification | Add features without breaking existing code |
| Liskov Substitution | Subtypes must be substitutable for their base types | Polymorphism works correctly |
| Interface Segregation | Many specific interfaces are better than one general interface | Clients aren't forced to depend on unused methods |
| Dependency Inversion | Depend on abstractions, not concretions | Loose coupling, easier testing |
π‘ Memory Device: "Sarah Once Loved Ice cream Daily" helps you remember SOLID in order!
Single Responsibility Principle (SRP)
Each class should do one thing well. When a class has multiple responsibilities, changes to one responsibility can break the others.
β Violates SRP:
public class UserService {
public void createUser(User user) {
// Responsibility 1: Business logic
validateUser(user);
// Responsibility 2: Database access
Connection conn = DriverManager.getConnection(url);
PreparedStatement stmt = conn.prepareStatement("INSERT...");
stmt.executeUpdate();
// Responsibility 3: Email notification
EmailService.send(user.getEmail(), "Welcome!");
// Responsibility 4: Logging
System.out.println("User created: " + user.getName());
}
}
β Follows SRP:
public class UserService {
private final UserRepository repository;
private final EmailNotifier emailNotifier;
private final UserValidator validator;
private final Logger logger;
public void createUser(User user) {
validator.validate(user);
repository.save(user);
emailNotifier.sendWelcomeEmail(user);
logger.info("User created: {}", user.getId());
}
}
Now each class has a single, well-defined responsibility. Changes to email formatting don't require touching the UserService.
Open/Closed Principle (OCP)
You should be able to add new functionality without modifying existing code. Use abstraction and polymorphism.
β Violates OCP:
public class PaymentProcessor {
public void processPayment(Payment payment) {
if (payment.getType().equals("CREDIT_CARD")) {
// Credit card logic
} else if (payment.getType().equals("PAYPAL")) {
// PayPal logic
} else if (payment.getType().equals("BITCOIN")) {
// Bitcoin logic - had to modify this class!
}
}
}
Every new payment method requires modifying the PaymentProcessor class.
β Follows OCP:
public interface PaymentMethod {
void process(Payment payment);
}
public class CreditCardPayment implements PaymentMethod {
@Override
public void process(Payment payment) {
// Credit card specific logic
}
}
public class PayPalPayment implements PaymentMethod {
@Override
public void process(Payment payment) {
// PayPal specific logic
}
}
public class PaymentProcessor {
public void processPayment(Payment payment, PaymentMethod method) {
method.process(payment);
}
}
Now you can add BitcoinPayment implements PaymentMethod without touching existing code!
Dependency Inversion Principle (DIP)
High-level modules should not depend on low-level modules. Both should depend on abstractions.
β Violates DIP:
public class OrderService {
private MySQLDatabase database = new MySQLDatabase();
public void saveOrder(Order order) {
database.insert(order);
}
}
OrderService is tightly coupled to MySQL. Switching to PostgreSQL requires changing OrderService.
β Follows DIP:
public interface OrderRepository {
void save(Order order);
}
public class MySQLOrderRepository implements OrderRepository {
@Override
public void save(Order order) {
// MySQL specific implementation
}
}
public class OrderService {
private final OrderRepository repository;
public OrderService(OrderRepository repository) {
this.repository = repository;
}
public void saveOrder(Order order) {
repository.save(order);
}
}
Now OrderService depends on the abstraction OrderRepository. You can inject any implementation at runtime!
2. Meaningful Names: Speak in Code π
Code is read far more often than it's written. Names should reveal intent without requiring comments.
Naming Principles:
| Principle | Bad Example | Good Example |
|---|---|---|
| Intention-Revealing | int d; |
int elapsedTimeInDays; |
| Avoid Disinformation | List<User> userMap; |
List<User> userList; |
| Use Pronounceable Names | genymdhms |
generationTimestamp |
| Use Searchable Names | if (status == 7) |
if (status == STATUS_APPROVED) |
| Class Names = Nouns | ProcessData |
DataProcessor |
| Method Names = Verbs | userData() |
getUserData() |
π§ Naming Convention Memory Aid:
- Classes: Think of them as things β
Customer,OrderProcessor,EmailValidator - Methods: Think of them as actions β
calculate(),sendEmail(),validateInput() - Booleans: Should ask questions β
isValid,hasPermission,canDelete
// β What does this do?
public List<User> get(int x) {
List<User> temp = new ArrayList<>();
for (User u : db) {
if (u.s == x) {
temp.add(u);
}
}
return temp;
}
// β
Crystal clear intent
public List<User> getActiveUsersByStatus(int statusCode) {
List<User> activeUsers = new ArrayList<>();
for (User user : database) {
if (user.getStatusCode() == statusCode) {
activeUsers.add(user);
}
}
return activeUsers;
}
3. Functions: Do One Thing Well βοΈ
The First Rule of Functions: They should be small. The Second Rule of Functions: They should be smaller than that.
Function Design Guidelines:
- Do one thing (Single Responsibility at function level)
- One level of abstraction (don't mix high-level and low-level operations)
- Few parameters (ideally 0-2, maximum 3)
- No side effects (function name should describe everything it does)
- Command-Query Separation (either change state OR return information, not both)
β Function doing too much:
public boolean saveAndValidateUser(User user, Database db) {
// Validation
if (user.getName() == null || user.getName().isEmpty()) {
return false;
}
if (!user.getEmail().contains("@")) {
return false;
}
// Transformation
user.setName(user.getName().trim().toLowerCase());
// Side effect: logging
System.out.println("Saving user: " + user.getName());
// Database operation
db.save(user);
// Another side effect: email
emailService.sendWelcome(user);
return true;
}
β Clean, focused functions:
public void createUser(User user) {
validateUser(user);
User normalizedUser = normalizeUser(user);
saveUser(normalizedUser);
notifyUserCreation(normalizedUser);
}
private void validateUser(User user) {
if (!isValidName(user.getName())) {
throw new InvalidUserException("Invalid name");
}
if (!isValidEmail(user.getEmail())) {
throw new InvalidUserException("Invalid email");
}
}
private boolean isValidName(String name) {
return name != null && !name.trim().isEmpty();
}
private boolean isValidEmail(String email) {
return email != null && email.contains("@");
}
private User normalizeUser(User user) {
return user.toBuilder()
.name(user.getName().trim().toLowerCase())
.build();
}
private void saveUser(User user) {
repository.save(user);
logger.info("User saved: {}", user.getId());
}
private void notifyUserCreation(User user) {
emailService.sendWelcomeEmail(user);
}
Each function now has one clear responsibility and reveals its intent through its name.
4. RESTful API Design Principles π
REST (Representational State Transfer) is an architectural style for designing networked applications. Great REST APIs follow consistent patterns.
Core REST Principles:
| Principle | Description | Example |
|---|---|---|
| Resource-Based | URLs represent resources (nouns), not actions | GET /users/123 not /getUser?id=123 |
| HTTP Methods | Use verbs for operations: GET, POST, PUT, DELETE, PATCH | DELETE /users/123 |
| Stateless | Each request contains all info needed | Include auth token in every request |
| Hierarchical | Express relationships in URL structure | /users/123/orders/456 |
| Standard Status Codes | Use HTTP status codes correctly | 201 Created, 404 Not Found, 422 Validation Error |
HTTP Method Usage
HTTP Method Semantics ββββββββββββ¬ββββββββββββββ¬βββββββββββββββ¬ββββββββββββββ β Method β Purpose β Idempotent β Safe β ββββββββββββΌββββββββββββββΌβββββββββββββββΌββββββββββββββ€ β GET β Retrieve β β Yes β β Yes β β POST β Create β β No β β No β β PUT β Replace β β Yes β β No β β PATCH β Update β β No β β No β β DELETE β Remove β β Yes β β No β ββββββββββββ΄ββββββββββββββ΄βββββββββββββββ΄ββββββββββββββ **Idempotent**: Same request multiple times = same result **Safe**: Doesn't modify server state
π‘ REST API Naming Best Practices:
// β
GOOD: Resource-based URLs
GET /api/users // List all users
GET /api/users/123 // Get specific user
POST /api/users // Create new user
PUT /api/users/123 // Replace user 123
PATCH /api/users/123 // Update user 123 partially
DELETE /api/users/123 // Delete user 123
// Nested resources
GET /api/users/123/orders // Get orders for user 123
POST /api/users/123/orders // Create order for user 123
// Filtering and pagination
GET /api/users?role=admin&page=2&size=20
// β BAD: Action-based URLs (RPC style)
GET /api/getUser?id=123
POST /api/createUser
POST /api/deleteUser?id=123
GET /api/user/123/getOrders
Spring Boot REST Controller Example
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
@Autowired
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping
public ResponseEntity<Page<UserDTO>> getAllUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
Page<UserDTO> users = userService.findAll(PageRequest.of(page, size));
return ResponseEntity.ok(users);
}
@GetMapping("/{id}")
public ResponseEntity<UserDTO> getUserById(@PathVariable Long id) {
return userService.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PostMapping
public ResponseEntity<UserDTO> createUser(
@Valid @RequestBody CreateUserRequest request) {
UserDTO created = userService.create(request);
URI location = ServletUriComponentsBuilder
.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(created.getId())
.toUri();
return ResponseEntity.created(location).body(created);
}
@PutMapping("/{id}")
public ResponseEntity<UserDTO> updateUser(
@PathVariable Long id,
@Valid @RequestBody UpdateUserRequest request) {
return userService.update(id, request)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
if (userService.delete(id)) {
return ResponseEntity.noContent().build();
}
return ResponseEntity.notFound().build();
}
}
Key Clean Code Practices in This Controller:
- β Dependency injection via constructor
- β Proper HTTP status codes (200 OK, 201 Created, 204 No Content, 404 Not Found)
- β Location header in POST response
- β
Validation with
@Valid - β DTOs instead of exposing entities
- β Consistent naming and URL structure
5. Error Handling & Exception Design π¨
Clean error handling makes your API predictable and debuggable.
Exception Hierarchy Best Practices:
// Base custom exception
public abstract class BusinessException extends RuntimeException {
private final String errorCode;
public BusinessException(String errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
public String getErrorCode() {
return errorCode;
}
}
// Specific exceptions
public class ResourceNotFoundException extends BusinessException {
public ResourceNotFoundException(String resource, Long id) {
super("RESOURCE_NOT_FOUND",
String.format("%s with id %d not found", resource, id));
}
}
public class ValidationException extends BusinessException {
private final Map<String, String> fieldErrors;
public ValidationException(Map<String, String> fieldErrors) {
super("VALIDATION_ERROR", "Validation failed");
this.fieldErrors = fieldErrors;
}
public Map<String, String> getFieldErrors() {
return fieldErrors;
}
}
public class UnauthorizedException extends BusinessException {
public UnauthorizedException(String message) {
super("UNAUTHORIZED", message);
}
}
Global Exception Handler:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(
ResourceNotFoundException ex) {
ErrorResponse error = ErrorResponse.builder()
.timestamp(Instant.now())
.status(HttpStatus.NOT_FOUND.value())
.error("Not Found")
.message(ex.getMessage())
.code(ex.getErrorCode())
.build();
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
@ExceptionHandler(ValidationException.class)
public ResponseEntity<ErrorResponse> handleValidation(
ValidationException ex) {
ErrorResponse error = ErrorResponse.builder()
.timestamp(Instant.now())
.status(HttpStatus.UNPROCESSABLE_ENTITY.value())
.error("Validation Error")
.message(ex.getMessage())
.code(ex.getErrorCode())
.fieldErrors(ex.getFieldErrors())
.build();
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY)
.body(error);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGeneric(Exception ex) {
ErrorResponse error = ErrorResponse.builder()
.timestamp(Instant.now())
.status(HttpStatus.INTERNAL_SERVER_ERROR.value())
.error("Internal Server Error")
.message("An unexpected error occurred")
.code("INTERNAL_ERROR")
.build();
// Log the full exception internally
log.error("Unexpected error", ex);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(error);
}
}
Consistent Error Response Format:
@Data
@Builder
public class ErrorResponse {
private Instant timestamp;
private int status;
private String error;
private String message;
private String code;
private Map<String, String> fieldErrors;
private String path;
}
Example Error Response JSON:
{
"timestamp": "2024-01-15T10:30:00Z",
"status": 422,
"error": "Validation Error",
"message": "Validation failed",
"code": "VALIDATION_ERROR",
"fieldErrors": {
"email": "Must be a valid email address",
"age": "Must be at least 18"
},
"path": "/api/users"
}
π‘ Exception Handling Golden Rules:
- Never swallow exceptions (empty catch blocks)
- Use specific exception types (not just
Exception) - Don't expose internal details to API consumers
- Log exceptions at the right level (ERROR for server issues, WARN for client issues)
- Include error codes for easy lookup and internationalization
6. API Versioning Strategies π’
APIs evolve. Versioning allows changes without breaking existing clients.
Common Versioning Approaches:
| Strategy | Example | Pros | Cons |
|---|---|---|---|
| URI Versioning | /api/v1/users |
Simple, explicit, cacheable | URL pollution, multiple endpoints |
| Header Versioning | Accept: application/vnd.api.v1+json |
Clean URLs, flexible | Less visible, harder to test |
| Query Parameter | /api/users?version=1 |
Easy to add | Optional nature, caching issues |
| Content Negotiation | Accept: application/json; version=1 |
RESTful, powerful | Complex to implement |
π Recommended: URI Versioning
Most widely adopted for its simplicity and explicitness:
@RestController
@RequestMapping("/api/v1/users")
public class UserControllerV1 {
@GetMapping("/{id}")
public ResponseEntity<UserDTOV1> getUser(@PathVariable Long id) {
// Version 1 implementation
return ResponseEntity.ok(userService.findByIdV1(id));
}
}
@RestController
@RequestMapping("/api/v2/users")
public class UserControllerV2 {
@GetMapping("/{id}")
public ResponseEntity<UserDTOV2> getUser(@PathVariable Long id) {
// Version 2 with breaking changes
return ResponseEntity.ok(userService.findByIdV2(id));
}
}
API Evolution Best Practices:
- β Deprecation warnings before removal
- β Sunset headers indicating end-of-life dates
- β Clear migration guides in documentation
- β Support at least 2 versions simultaneously
- β οΈ Breaking changes require version bump
7. API Documentation with OpenAPI/Swagger π
Documentation is part of clean code. Undocumented APIs are unusable APIs.
SpringDoc OpenAPI Example:
@RestController
@RequestMapping("/api/users")
@Tag(name = "User Management", description = "Operations for managing users")
public class UserController {
@Operation(
summary = "Get user by ID",
description = "Retrieves a user's details by their unique identifier"
)
@ApiResponses(value = {
@ApiResponse(
responseCode = "200",
description = "User found",
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = UserDTO.class)
)
),
@ApiResponse(
responseCode = "404",
description = "User not found",
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = ErrorResponse.class)
)
),
@ApiResponse(
responseCode = "401",
description = "Unauthorized - invalid or missing token"
)
})
@GetMapping("/{id}")
public ResponseEntity<UserDTO> getUserById(
@Parameter(description = "User ID", example = "123")
@PathVariable Long id) {
return userService.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@Operation(
summary = "Create new user",
description = "Creates a new user account with the provided information"
)
@ApiResponses(value = {
@ApiResponse(
responseCode = "201",
description = "User created successfully"
),
@ApiResponse(
responseCode = "422",
description = "Validation error"
)
})
@PostMapping
public ResponseEntity<UserDTO> createUser(
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "User creation details",
required = true
)
@Valid @RequestBody CreateUserRequest request) {
UserDTO created = userService.create(request);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
}
DTO with Schema Documentation:
@Data
@Schema(description = "User data transfer object")
public class UserDTO {
@Schema(
description = "Unique user identifier",
example = "123",
accessMode = Schema.AccessMode.READ_ONLY
)
private Long id;
@Schema(
description = "User's full name",
example = "John Doe",
required = true,
minLength = 2,
maxLength = 100
)
@NotBlank
@Size(min = 2, max = 100)
private String name;
@Schema(
description = "User's email address",
example = "john.doe@example.com",
format = "email",
required = true
)
@NotBlank
@Email
private String email;
@Schema(
description = "User's role in the system",
example = "ADMIN",
allowableValues = {"USER", "ADMIN", "MODERATOR"}
)
private String role;
@Schema(
description = "Timestamp when user was created",
example = "2024-01-15T10:30:00Z",
accessMode = Schema.AccessMode.READ_ONLY
)
private Instant createdAt;
}
This generates interactive Swagger UI documentation automatically at /swagger-ui.html.
Common Mistakes to Avoid β οΈ
1. God Classes
Problem: One class that does everything.
// β 3000-line UserManager that handles validation, database,
// email, logging, caching, authentication...
public class UserManager {
// 50+ methods, 1000+ lines
}
Solution: Break into focused, single-responsibility classes.
2. Primitive Obsession
Problem: Using primitives instead of objects for domain concepts.
// β What does String represent? Email? Username?
public void sendNotification(String recipient, String message) {
// ...
}
Solution: Create value objects.
// β
Type safety and clarity
public void sendNotification(Email recipient, Message message) {
// ...
}
3. Returning Null
Problem: Forces null checks everywhere, causes NullPointerExceptions.
// β Caller must remember to check null
public User findUser(Long id) {
// might return null
}
Solution: Use Optional.
// β
Makes absence explicit
public Optional<User> findUser(Long id) {
return Optional.ofNullable(userRepository.findById(id));
}
4. Magic Numbers and Strings
Problem: Unexplained literal values scattered through code.
// β What does 86400 mean?
if (elapsed > 86400) {
// ...
}
Solution: Named constants.
// β
Self-documenting
private static final int SECONDS_PER_DAY = 86400;
if (elapsed > SECONDS_PER_DAY) {
// ...
}
5. Leaky Abstractions
Problem: Implementation details leak through API.
// β Exposes JPA entity annotations to API layer
@RestController
public class UserController {
@GetMapping("/users/{id}")
public User getUser(@PathVariable Long id) {
return userRepository.findById(id); // JPA entity!
}
}
Solution: Use DTOs to decouple layers.
// β
API independent of persistence layer
@GetMapping("/users/{id}")
public UserDTO getUser(@PathVariable Long id) {
return userService.findById(id)
.map(userMapper::toDTO)
.orElseThrow(() -> new NotFoundException());
}
6. Ignoring HTTP Semantics
Problem: Using POST for everything.
// β POST used for retrieval
@PostMapping("/getUser")
public User getUser(@RequestBody Long id) {
return userService.find(id);
}
Solution: Use correct HTTP methods.
// β
GET for retrieval
@GetMapping("/users/{id}")
public UserDTO getUser(@PathVariable Long id) {
return userService.findById(id);
}
7. Over-Engineering
Problem: Adding unnecessary abstraction layers.
// β Factory for factory for builder for simple object
UserFactoryFactory.getInstance()
.createFactory()
.createBuilder()
.build();
Solution: Keep it simple unless complexity is justified.
// β
Direct instantiation when appropriate
new User(name, email);
8. Inconsistent Error Responses
Problem: Different endpoints return errors in different formats.
// β Endpoint A returns {"error": "..."}
// β Endpoint B returns {"message": "..."}
// β Endpoint C returns {"errorMsg": "..."}
Solution: Standardized error format via global exception handler.
Key Takeaways π―
π Quick Reference Card: Clean Code & API Design
| Category | Best Practice |
|---|---|
| SOLID Principles | Single responsibility, Open/Closed, Liskov substitution, Interface segregation, Dependency inversion |
| Naming | Classes = nouns, Methods = verbs, Booleans = questions (isValid), Reveal intent |
| Functions | Do one thing, Small (ideally < 20 lines), 0-2 parameters, No side effects |
| REST URLs | Resource-based (nouns), Hierarchical, Plural nouns, lowercase, hyphens not underscores |
| HTTP Methods | GET (read), POST (create), PUT (replace), PATCH (update), DELETE (remove) |
| Status Codes | 200 OK, 201 Created, 204 No Content, 400 Bad Request, 401 Unauthorized, 404 Not Found, 422 Validation |
| Error Handling | Custom exception hierarchy, Global handler, Consistent format, Error codes, Don't expose internals |
| DTOs | Separate API contracts from domain models, Validation annotations, Schema documentation |
| Versioning | URI versioning preferred (/api/v1/), Support 2+ versions, Deprecation notices |
| Documentation | OpenAPI/Swagger annotations, Examples in schemas, Response code documentation |
π§ The Three Questions of Clean Code:
- Can someone else understand this in 30 seconds? (Readability)
- Can I change this without breaking other things? (Maintainability)
- Can I test this in isolation? (Testability)
If you answer "no" to any of these, refactor!
π Progressive Mastery Path:
- Beginner: Write code that works
- Intermediate: Write code that others can read
- Advanced: Write code that others enjoy reading
- Expert: Write code that teaches others
π Further Study
- Clean Code: A Handbook of Agile Software Craftsmanship by Robert C. Martin - https://www.amazon.com/Clean-Code-Handbook-Software-Craftsmanship/dp/0132350882
- RESTful Web APIs by Leonard Richardson & Mike Amundsen - https://www.oreilly.com/library/view/restful-web-apis/9781449359713/
- Spring Boot REST API Best Practices - https://spring.io/guides/tutorials/rest/
π‘ Final Thought: Clean code isn't about perfectionβit's about continuous improvement. Every commit is an opportunity to leave the codebase slightly better than you found it. This is the "Boy Scout Rule" of software development: Always leave the code cleaner than you found it.
Now it's your turn to practice these principles. Start with small refactorings in your current projects, and watch your code quality transform! π