| name | java-best-practices |
| description | Comprehensive Java development best practices covering SOLID principles, DRY, Clean Code, Java-specific patterns (Optional, immutability, streams, lambdas), exception handling, collections, concurrency, testing with JUnit 5 and Mockito, code organization, performance optimization, and common anti-patterns. Essential reference for uncle-duke-java agent during code reviews and architecture guidance. |
| allowed-tools | Read, Grep, Glob |
Java Best Practices
Purpose
This skill provides comprehensive best practices for Java development, serving as a reference guide during code reviews and architectural decisions. It covers SOLID principles, DRY, Clean Code, Java-specific patterns, testing strategies, and common anti-patterns.
When to use this skill:
- Conducting code reviews of Java projects
- Writing new Java code
- Refactoring existing Java code
- Evaluating architecture and design decisions
- Teaching Java best practices to team members
- Working with Spring Framework applications
Context
High-quality Java code is essential for building maintainable, scalable, and robust applications. This skill documents industry-standard practices that emphasize:
- SOLID Principles: Foundation for well-designed object-oriented code
- Clean Code: Readable, maintainable, and self-documenting code
- Java-Specific Features: Proper use of modern Java features (8+)
- Testability: Code that's easy to test and verify
- Performance: Efficient use of Java language and JVM features
- Spring Framework: Best practices for Spring-based applications
This skill is designed to be referenced by the uncle-duke-java agent during code reviews and by developers when writing Java code.
Prerequisites
Required Knowledge:
- Java fundamentals (Java 8+)
- Object-oriented programming concepts
- Basic understanding of design patterns
- Familiarity with Spring Framework (for Spring-specific sections)
Required Tools:
- JDK 8 or higher (11, 17, or 21 recommended)
- Maven or Gradle for build management
- JUnit 5 for testing
- Mockito for mocking
- IDE with Java support (IntelliJ IDEA, Eclipse, VS Code)
Expected Project Structure:
project/
├── src/
│ ├── main/
│ │ ├── java/
│ │ │ └── com/example/
│ │ │ ├── model/
│ │ │ ├── service/
│ │ │ ├── repository/
│ │ │ ├── controller/
│ │ │ └── util/
│ │ └── resources/
│ │ └── application.properties
│ └── test/
│ └── java/
│ └── com/example/
├── pom.xml (or build.gradle)
└── README.md
SOLID Principles in Java
Single Responsibility Principle (SRP)
Rule: A class should have only one reason to change. Each class should have a single, well-defined responsibility.
Why it matters: Classes with multiple responsibilities are harder to understand, test, and maintain. Changes to one responsibility can affect the others.
SRP in Practice
❌ Bad - Multiple Responsibilities:
public class User {
private String email;
private String password;
public boolean isValid() {
return email != null && email.contains("@")
&& password != null && password.length() >= 8;
}
public void save() {
Connection conn = DriverManager.getConnection("jdbc:...");
PreparedStatement ps = conn.prepareStatement("INSERT INTO users...");
ps.setString(1, email);
ps.setString(2, password);
ps.executeUpdate();
}
public void sendWelcomeEmail() {
EmailService.send(email, "Welcome!", "Welcome to our app");
}
public void encryptPassword() {
this.password = BCrypt.hashpw(password, BCrypt.gensalt());
}
}
Issues:
- User class has 4 responsibilities: data, validation, persistence, email
- Changes to validation logic affect the User class
- Changes to database schema affect the User class
- Changes to email templates affect the User class
- Difficult to test individual responsibilities
✅ Good - Single Responsibility:
public class User {
private final String email;
private final String passwordHash;
public User(String email, String passwordHash) {
this.email = email;
this.passwordHash = passwordHash;
}
public String getEmail() { return email; }
public String getPasswordHash() { return passwordHash; }
}
public class UserValidator {
public ValidationResult validate(String email, String password) {
List<String> errors = new ArrayList<>();
if (email == null || !email.contains("@")) {
errors.add("Invalid email format");
}
if (password == null || password.length() < 8) {
errors.add("Password must be at least 8 characters");
}
return new ValidationResult(errors.isEmpty(), errors);
}
}
public class UserRepository {
private final DataSource dataSource;
public UserRepository(DataSource dataSource) {
this.dataSource = dataSource;
}
public void save(User user) {
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(
"INSERT INTO users (email, password_hash) VALUES (?, ?)")) {
ps.setString(1, user.getEmail());
ps.setString(2, user.getPasswordHash());
ps.executeUpdate();
} catch (SQLException e) {
throw new DataAccessException("Failed to save user", e);
}
}
}
public class EmailService {
public void sendWelcomeEmail(User user) {
send(user.getEmail(), "Welcome!", "Welcome to our app");
}
private void send(String to, String subject, String body) {
}
}
public class PasswordEncoder {
public String encode(String rawPassword) {
return BCrypt.hashpw(rawPassword, BCrypt.gensalt());
}
public boolean matches(String rawPassword, String encodedPassword) {
return BCrypt.checkpw(rawPassword, encodedPassword);
}
}
Benefits:
- Each class has one clear responsibility
- Easy to test each responsibility in isolation
- Changes to one concern don't affect others
- Classes are small and focused
Open/Closed Principle (OCP)
Rule: Software entities (classes, modules, functions) should be open for extension but closed for modification.
Why it matters: You should be able to add new functionality without changing existing code, reducing the risk of breaking existing features.
OCP in Practice
❌ Bad - Violates OCP:
public class PaymentProcessor {
public void processPayment(String paymentType, double amount) {
if (paymentType.equals("CREDIT_CARD")) {
System.out.println("Processing credit card payment: $" + amount);
} else if (paymentType.equals("PAYPAL")) {
System.out.println("Processing PayPal payment: $" + amount);
} else if (paymentType.equals("BITCOIN")) {
System.out.println("Processing Bitcoin payment: $" + amount);
}
}
}
Issues:
- Must modify PaymentProcessor to add new payment types
- Violates OCP (not closed for modification)
- Growing if-else chain
- Hard to test individual payment types
✅ Good - Follows OCP:
public interface PaymentMethod {
void process(double amount);
}
public class CreditCardPayment implements PaymentMethod {
@Override
public void process(double amount) {
System.out.println("Processing credit card payment: $" + amount);
}
}
public class PayPalPayment implements PaymentMethod {
@Override
public void process(double amount) {
System.out.println("Processing PayPal payment: $" + amount);
}
}
public class BitcoinPayment implements PaymentMethod {
@Override
public void process(double amount) {
System.out.println("Processing Bitcoin payment: $" + amount);
}
}
public class PaymentProcessor {
public void processPayment(PaymentMethod paymentMethod, double amount) {
paymentMethod.process(amount);
}
}
PaymentProcessor processor = new PaymentProcessor();
processor.processPayment(new CreditCardPayment(), 100.0);
processor.processPayment(new PayPalPayment(), 50.0);
public class ApplePayPayment implements PaymentMethod {
@Override
public void process(double amount) {
System.out.println("Processing Apple Pay payment: $" + amount);
}
}
Benefits:
- New payment methods added without modifying existing code
- Each payment type is independently testable
- Follows OCP: open for extension, closed for modification
- Clear separation of concerns
OCP with Strategy Pattern
✅ Advanced Example - Discount Strategies:
public interface DiscountStrategy {
double applyDiscount(double price);
}
public class NoDiscount implements DiscountStrategy {
@Override
public double applyDiscount(double price) {
return price;
}
}
public class PercentageDiscount implements DiscountStrategy {
private final double percentage;
public PercentageDiscount(double percentage) {
this.percentage = percentage;
}
@Override
public double applyDiscount(double price) {
return price * (1 - percentage / 100);
}
}
public class FixedAmountDiscount implements DiscountStrategy {
private final double amount;
public FixedAmountDiscount(double amount) {
this.amount = amount;
}
@Override
public double applyDiscount(double price) {
return Math.max(0, price - amount);
}
}
public class PriceCalculator {
private final DiscountStrategy discountStrategy;
public PriceCalculator(DiscountStrategy discountStrategy) {
this.discountStrategy = discountStrategy;
}
public double calculateFinalPrice(double originalPrice) {
return discountStrategy.applyDiscount(originalPrice);
}
}
PriceCalculator calc1 = new PriceCalculator(new PercentageDiscount(10));
double price1 = calc1.calculateFinalPrice(100);
PriceCalculator calc2 = new PriceCalculator(new FixedAmountDiscount(15));
double price2 = calc2.calculateFinalPrice(100);
Liskov Substitution Principle (LSP)
Rule: Objects of a superclass should be replaceable with objects of a subclass without breaking the application. Subtypes must be substitutable for their base types.
Why it matters: Violating LSP leads to unexpected behavior and breaks polymorphism.
LSP in Practice
❌ Bad - Violates LSP:
public class Rectangle {
protected int width;
protected int height;
public void setWidth(int width) {
this.width = width;
}
public void setHeight(int height) {
this.height = height;
}
public int getArea() {
return width * height;
}
}
public class Square extends Rectangle {
@Override
public void setWidth(int width) {
this.width = width;
this.height = width;
}
@Override
public void setHeight(int height) {
this.width = height;
this.height = height;
}
}
public void testRectangle(Rectangle rect) {
rect.setWidth(5);
rect.setHeight(4);
assertEquals(20, rect.getArea());
}
Issues:
- Square changes the behavior of Rectangle methods
- Cannot substitute Square for Rectangle
- Violates LSP and breaks polymorphism
✅ Good - Follows LSP:
public interface Shape {
int getArea();
}
public class Rectangle implements Shape {
private final int width;
private final int height;
public Rectangle(int width, int height) {
this.width = width;
this.height = height;
}
@Override
public int getArea() {
return width * height;
}
public int getWidth() { return width; }
public int getHeight() { return height; }
}
public class Square implements Shape {
private final int side;
public Square(int side) {
this.side = side;
}
@Override
public int getArea() {
return side * side;
}
public int getSide() { return side; }
}
public int calculateTotalArea(List<Shape> shapes) {
return shapes.stream()
.mapToInt(Shape::getArea)
.sum();
}
Benefits:
- Square and Rectangle are independent
- Both implement Shape contract correctly
- Can substitute any Shape implementation
- No unexpected behavior
LSP - Pre and Post Conditions
✅ Good - Maintains Contracts:
public interface BankAccount {
void deposit(double amount);
void withdraw(double amount) throws InsufficientFundsException;
double getBalance();
}
public class SavingsAccount implements BankAccount {
private double balance;
@Override
public void deposit(double amount) {
if (amount <= 0) {
throw new IllegalArgumentException("Amount must be positive");
}
balance += amount;
}
@Override
public void withdraw(double amount) throws InsufficientFundsException {
if (amount <= 0) {
throw new IllegalArgumentException("Amount must be positive");
}
if (amount > balance) {
throw new InsufficientFundsException();
}
balance -= amount;
}
@Override
public double getBalance() {
return balance;
}
}
public class CheckingAccount implements BankAccount {
private double balance;
private final double overdraftLimit;
public CheckingAccount(double overdraftLimit) {
this.overdraftLimit = overdraftLimit;
}
@Override
public void deposit(double amount) {
if (amount <= 0) {
throw new IllegalArgumentException("Amount must be positive");
}
balance += amount;
}
@Override
public void withdraw(double amount) throws InsufficientFundsException {
if (amount <= 0) {
throw new IllegalArgumentException("Amount must be positive");
}
if (amount > balance + overdraftLimit) {
throw new InsufficientFundsException();
}
balance -= amount;
}
@Override
public double getBalance() {
return balance;
}
}
Interface Segregation Principle (ISP)
Rule: Clients should not be forced to depend on interfaces they don't use. Many specific interfaces are better than one general-purpose interface.
Why it matters: Large interfaces force implementations to provide methods they don't need, leading to empty implementations and tight coupling.
ISP in Practice
❌ Bad - Fat Interface:
public interface Worker {
void work();
void eat();
void sleep();
void getSalary();
void attendMeeting();
}
public class RobotWorker implements Worker {
@Override
public void work() {
System.out.println("Robot working");
}
@Override
public void eat() {
throw new UnsupportedOperationException("Robots don't eat");
}
@Override
public void sleep() {
throw new UnsupportedOperationException("Robots don't sleep");
}
@Override
public void getSalary() {
throw new UnsupportedOperationException("Robots don't get paid");
}
@Override
public void attendMeeting() {
System.out.println("Robot attending meeting");
}
}
Issues:
- Robot forced to implement biological methods
- Throwing UnsupportedOperationException is a code smell
- Violates ISP
- Tight coupling to irrelevant methods
✅ Good - Segregated Interfaces:
public interface Workable {
void work();
}
public interface Eatable {
void eat();
}
public interface Sleepable {
void sleep();
}
public interface Payable {
void getSalary();
}
public interface MeetingAttendee {
void attendMeeting();
}
public class HumanWorker implements Workable, Eatable, Sleepable, Payable, MeetingAttendee {
@Override
public void work() {
System.out.println("Human working");
}
@Override
public void eat() {
System.out.println("Human eating");
}
@Override
public void sleep() {
System.out.println("Human sleeping");
}
@Override
public void getSalary() {
System.out.println("Human receiving salary");
}
@Override
public void attendMeeting() {
System.out.println("Human attending meeting");
}
}
public class RobotWorker implements Workable, MeetingAttendee {
@Override
public void work() {
System.out.println("Robot working");
}
@Override
public void attendMeeting() {
System.out.println("Robot attending meeting");
}
}
Benefits:
- Implementations only provide methods that make sense
- No UnsupportedOperationException needed
- Clear separation of concerns
- Flexible composition
Dependency Inversion Principle (DIP)
Rule: High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions.
Why it matters: DIP decouples code, making it more flexible, testable, and maintainable.
DIP in Practice
❌ Bad - High-level depends on low-level:
public class MySQLDatabase {
public void save(String data) {
System.out.println("Saving to MySQL: " + data);
}
}
public class UserService {
private MySQLDatabase database;
public UserService() {
this.database = new MySQLDatabase();
}
public void createUser(String userData) {
database.save(userData);
}
}
Issues:
- UserService tightly coupled to MySQLDatabase
- Cannot switch to PostgreSQL without modifying UserService
- Hard to test (can't mock database)
- Violates DIP
✅ Good - Both depend on abstraction:
public interface Database {
void save(String data);
}
public class MySQLDatabase implements Database {
@Override
public void save(String data) {
System.out.println("Saving to MySQL: " + data);
}
}
public class PostgreSQLDatabase implements Database {
@Override
public void save(String data) {
System.out.println("Saving to PostgreSQL: " + data);
}
}
public class MongoDatabase implements Database {
@Override
public void save(String data) {
System.out.println("Saving to MongoDB: " + data);
}
}
public class UserService {
private final Database database;
public UserService(Database database) {
this.database = database;
}
public void createUser(String userData) {
database.save(userData);
}
}
Database db = new MySQLDatabase();
UserService service = new UserService(db);
service.createUser("John Doe");
Database postgresDb = new PostgreSQLDatabase();
UserService postgresService = new UserService(postgresDb);
Database mockDb = mock(Database.class);
UserService testService = new UserService(mockDb);
Benefits:
- UserService decoupled from database implementation
- Easy to switch database implementations
- Easy to test with mocks
- Follows DIP
SOLID Principles in Spring Framework
Spring Framework is built on SOLID principles, particularly Dependency Inversion.
Dependency Injection in Spring
✅ Spring DI Example:
public interface UserRepository {
User findById(Long id);
void save(User user);
}
@Repository
public class JpaUserRepository implements UserRepository {
@PersistenceContext
private EntityManager entityManager;
@Override
public User findById(Long id) {
return entityManager.find(User.class, id);
}
@Override
public void save(User user) {
entityManager.persist(user);
}
}
@Service
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public User getUser(Long id) {
return userRepository.findById(id);
}
}
@RestController
@RequestMapping("/users")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping("/{id}")
public ResponseEntity<User> getUser(@PathVariable Long id) {
User user = userService.getUser(id);
return ResponseEntity.ok(user);
}
}
Spring DI Best Practices:
- Use constructor injection (required dependencies, immutability)
- Prefer field injection only for optional dependencies
- Depend on interfaces, not concrete classes
- Use
@Qualifier when multiple implementations exist
DRY (Don't Repeat Yourself)
Rule: Every piece of knowledge must have a single, unambiguous, authoritative representation within a system.
Why it matters: Duplication leads to inconsistencies, harder maintenance, and more bugs.
Identifying Code Duplication
❌ Bad - Obvious Duplication:
public class OrderService {
public void processOnlineOrder(Order order) {
if (order == null) {
throw new IllegalArgumentException("Order cannot be null");
}
if (order.getItems().isEmpty()) {
throw new IllegalArgumentException("Order must have items");
}
if (order.getTotalAmount() <= 0) {
throw new IllegalArgumentException("Order total must be positive");
}
System.out.println("Processing online order: " + order.getId());
order.setStatus(OrderStatus.PROCESSING);
saveOrder(order);
}
public void processPhoneOrder(Order order) {
if (order == null) {
throw new IllegalArgumentException("Order cannot be null");
}
if (order.getItems().isEmpty()) {
throw new IllegalArgumentException("Order must have items");
}
if (order.getTotalAmount() <= 0) {
throw new IllegalArgumentException("Order total must be positive");
}
System.out.println("Processing phone order: " + order.getId());
order.setStatus(OrderStatus.PROCESSING);
saveOrder(order);
}
}
✅ Good - Extract Common Logic:
public class OrderService {
public void processOnlineOrder(Order order) {
validateOrder(order);
processOrder(order, "online");
}
public void processPhoneOrder(Order order) {
validateOrder(order);
processOrder(order, "phone");
}
private void validateOrder(Order order) {
if (order == null) {
throw new IllegalArgumentException("Order cannot be null");
}
if (order.getItems().isEmpty()) {
throw new IllegalArgumentException("Order must have items");
}
if (order.getTotalAmount() <= 0) {
throw new IllegalArgumentException("Order total must be positive");
}
}
private void processOrder(Order order, String type) {
System.out.println("Processing " + type + " order: " + order.getId());
order.setStatus(OrderStatus.PROCESSING);
saveOrder(order);
}
}
Utility Classes and Helper Methods
✅ Create Utility Classes for Reusable Logic:
public final class StringUtils {
private StringUtils() {
}
public static boolean isBlank(String str) {
return str == null || str.trim().isEmpty();
}
public static String capitalize(String str) {
if (isBlank(str)) {
return str;
}
return str.substring(0, 1).toUpperCase() + str.substring(1).toLowerCase();
}
public static String truncate(String str, int maxLength) {
if (str == null || str.length() <= maxLength) {
return str;
}
return str.substring(0, maxLength) + "...";
}
}
if (StringUtils.isBlank(username)) {
throw new ValidationException("Username is required");
}
String displayName = StringUtils.capitalize(name);
Generics for Reusability
✅ Use Generics to Avoid Duplication:
public class GenericRepository<T, ID> {
private final Class<T> entityClass;
@PersistenceContext
private EntityManager entityManager;
public GenericRepository(Class<T> entityClass) {
this.entityClass = entityClass;
}
public Optional<T> findById(ID id) {
T entity = entityManager.find(entityClass, id);
return Optional.ofNullable(entity);
}
public List<T> findAll() {
CriteriaQuery<T> query = entityManager.getCriteriaBuilder()
.createQuery(entityClass);
query.select(query.from(entityClass));
return entityManager.createQuery(query).getResultList();
}
public void save(T entity) {
entityManager.persist(entity);
}
public void delete(T entity) {
entityManager.remove(entity);
}
}
@Repository
public class UserRepository extends GenericRepository<User, Long> {
public UserRepository() {
super(User.class);
}
public Optional<User> findByEmail(String email) {
}
}
Clean Code Principles
Meaningful Names
Rule: Names should reveal intent, be pronounceable, and be searchable.
❌ Bad Names:
int d;
String yyyymmdd;
List<int[]> list1;
public void getData() {
}
✅ Good Names:
int elapsedTimeInDays;
String formattedDate;
List<Customer> activeCustomers;
public Customer getCustomerById(Long customerId) {
}
Naming Conventions
public class CustomerService { }
public class OrderRepository { }
public interface Serializable { }
public interface UserRepository { }
public void calculateTotal() { }
public Customer findCustomerById(Long id) { }
String customerName;
int orderCount;
boolean isActive;
public static final int MAX_RETRY_COUNT = 3;
public static final String DEFAULT_ENCODING = "UTF-8";
package com.example.service;
package com.example.repository;
boolean isValid();
boolean hasPermission();
boolean canExecute();
Function Size and Complexity
Rule: Functions should be small and do one thing. Aim for 5-20 lines per method.
❌ Bad - Large, Complex Method:
public void processOrder(Order order) {
if (order == null) throw new IllegalArgumentException();
if (order.getItems().isEmpty()) throw new IllegalArgumentException();
double total = 0;
for (OrderItem item : order.getItems()) {
double itemPrice = item.getPrice();
int quantity = item.getQuantity();
double discount = item.getDiscount();
total += (itemPrice * quantity) * (1 - discount);
}
order.setTotal(total);
if (order.getCoupon() != null) {
String couponCode = order.getCoupon().getCode();
if (couponCode.startsWith("SAVE")) {
total *= 0.9;
} else if (couponCode.startsWith("BIG")) {
total *= 0.8;
}
order.setTotal(total);
}
for (OrderItem item : order.getItems()) {
int available = inventoryService.getAvailableQuantity(item.getProductId());
if (available < item.getQuantity()) {
throw new InsufficientInventoryException();
}
}
orderRepository.save(order);
emailService.send(order.getCustomer().getEmail(), "Order Confirmation",
"Your order " + order.getId() + " has been confirmed");
for (OrderItem item : order.getItems()) {
inventoryService.decrementQuantity(item.getProductId(), item.getQuantity());
}
}
✅ Good - Small, Focused Methods:
public void processOrder(Order order) {
validateOrder(order);
calculateOrderTotal(order);
applyCouponDiscount(order);
checkInventoryAvailability(order);
saveOrder(order);
sendConfirmationEmail(order);
updateInventory(order);
}
private void validateOrder(Order order) {
if (order == null) {
throw new IllegalArgumentException("Order cannot be null");
}
if (order.getItems().isEmpty()) {
throw new IllegalArgumentException("Order must contain items");
}
}
private void calculateOrderTotal(Order order) {
double total = order.getItems().stream()
.mapToDouble(this::calculateItemTotal)
.sum();
order.setTotal(total);
}
private double calculateItemTotal(OrderItem item) {
return item.getPrice() * item.getQuantity() * (1 - item.getDiscount());
}
private void applyCouponDiscount(Order order) {
if (order.getCoupon() == null) {
return;
}
double discountMultiplier = getDiscountMultiplier(order.getCoupon());
order.setTotal(order.getTotal() * discountMultiplier);
}
private double getDiscountMultiplier(Coupon coupon) {
String code = coupon.getCode();
if (code.startsWith("SAVE")) return 0.9;
if (code.startsWith("BIG")) return 0.8;
return 1.0;
}
private void checkInventoryAvailability(Order order) {
for (OrderItem item : order.getItems()) {
int available = inventoryService.getAvailableQuantity(item.getProductId());
if (available < item.getQuantity()) {
throw new InsufficientInventoryException(
"Product " + item.getProductId() + " has insufficient inventory");
}
}
}
private void saveOrder(Order order) {
orderRepository.save(order);
}
private void sendConfirmationEmail(Order order) {
String email = order.getCustomer().getEmail();
String subject = "Order Confirmation";
String body = String.format("Your order %s has been confirmed", order.getId());
emailService.send(email, subject, body);
}
private void updateInventory(Order order) {
order.getItems().forEach(item ->
inventoryService.decrementQuantity(item.getProductId(), item.getQuantity())
);
}
Benefits:
- Each method has a clear, single purpose
- Easy to understand and test
- Main method reads like a table of contents
- Reusable helper methods
Comment Best Practices
Rule: Code should be self-explanatory. Comments should explain WHY, not WHAT.
❌ Bad Comments:
isActive = true;
for (User user : users) {
if (user.isActive()) {
activeUsers.add(user);
}
}
public class UserService {
}
✅ Good Comments:
isActive = true;
List<User> activeUsers = users.stream()
.filter(User::isActive)
.collect(Collectors.toList());
private int calculateRetryDelay(int attemptNumber) {
return (int) Math.pow(2, attemptNumber) * 1000;
}
public void transferFunds(Account fromAccount, Account toAccount, double amount)
throws InsufficientFundsException {
}
double taxableAmount = subtotal - discount;
When to Comment:
- Public APIs (JavaDoc)
- Complex algorithms (explain approach)
- Business rules (regulatory requirements)
- Workarounds (why the workaround is needed)
- TODO/FIXME (with ticket numbers)
When NOT to Comment:
- Obvious code
- Commented-out code (delete it, use version control)
- Change logs (use git)
Error Handling
Rule: Use exceptions for exceptional cases. Don't use exceptions for control flow.
❌ Bad Error Handling:
public User findUser(Long id) {
try {
return userRepository.findById(id);
} catch (NotFoundException e) {
return null;
}
}
public void processData(String data) {
try {
} catch (Exception e) {
}
}
try {
riskyOperation();
} catch (IOException e) {
}
✅ Good Error Handling:
public Optional<User> findUser(Long id) {
return userRepository.findById(id);
}
public void processData(String data) {
try {
parseAndValidate(data);
saveToDatabase(data);
} catch (JsonParseException e) {
log.error("Failed to parse JSON data: {}", data, e);
throw new DataProcessingException("Invalid JSON format", e);
} catch (DataAccessException e) {
log.error("Database error while saving data", e);
throw new DataProcessingException("Failed to save data", e);
}
}
try {
riskyOperation();
} catch (IOException e) {
log.error("Operation failed", e);
throw new ApplicationException("Failed to perform operation", e);
}
public String readFile(String path) throws IOException {
try (BufferedReader reader = new BufferedReader(new FileReader(path))) {
return reader.lines().collect(Collectors.joining("\n"));
}
}
Code Organization
Rule: Organize code logically within classes. Related methods should be close together.
✅ Good Class Organization:
public class UserService {
private static final int MAX_LOGIN_ATTEMPTS = 3;
private static final long LOCKOUT_DURATION_MINUTES = 30;
private static final Logger log = LoggerFactory.getLogger(UserService.class);
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final EmailService emailService;
public UserService(UserRepository userRepository,
PasswordEncoder passwordEncoder,
EmailService emailService) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.emailService = emailService;
}
public User registerUser(UserRegistrationDto dto) {
validateRegistration(dto);
User user = createUser(dto);
sendWelcomeEmail(user);
return user;
}
public AuthToken login(String email, String password) {
User user = findUserByEmail(email);
validatePassword(user, password);
return generateAuthToken(user);
}
private void validateRegistration(UserRegistrationDto dto) {
}
private User createUser(UserRegistrationDto dto) {
}
private void sendWelcomeEmail(User user) {
emailService.send(user.getEmail(), "Welcome!", getWelcomeEmailBody());
}
private User findUserByEmail(String email) {
return userRepository.findByEmail(email)
.orElseThrow(() -> new UserNotFoundException(email));
}
private void validatePassword(User user, String password) {
if (!passwordEncoder.matches(password, user.getPasswordHash())) {
throw new AuthenticationException("Invalid password");
}
}
private AuthToken generateAuthToken(User user) {
}
private String getWelcomeEmailBody() {
return "Welcome to our application!";
}
}
Java-Specific Best Practices
Using Optional Instead of Null
Rule: Use Optional<T> to represent values that may be absent. Never return null for collections.
❌ Bad - Returning Null:
public User findUser(Long id) {
User user = database.find(id);
return user;
}
User user = findUser(123L);
if (user != null) {
}
✅ Good - Using Optional:
public Optional<User> findUser(Long id) {
User user = database.find(id);
return Optional.ofNullable(user);
}
Optional<User> userOpt = findUser(123L);
userOpt.ifPresent(user -> System.out.println(user.getName()));
User user = userOpt.orElse(createDefaultUser());
User user = userOpt.orElseThrow(() ->
new UserNotFoundException("User 123 not found"));
String email = userOpt
.map(User::getEmail)
.orElse("unknown@example.com");
Optional Best Practices:
- Return
Optional<T> from methods that may not find a value
- Never use
Optional for fields
- Never pass
Optional as method parameters
- Never return null from
Optional-returning methods
- Use
Optional.empty() instead of Optional.ofNullable(null)
❌ Bad Optional Usage:
public class User {
private Optional<String> middleName;
}
public void setEmail(Optional<String> email) {
}
Optional<User> userOpt = findUser(id);
User user = userOpt.get();
✅ Good Optional Usage:
public class User {
private String middleName;
public Optional<String> getMiddleName() {
return Optional.ofNullable(middleName);
}
}
public void setEmail(@Nullable String email) {
this.email = email;
}
Optional<User> userOpt = findUser(id);
if (userOpt.isPresent()) {
User user = userOpt.get();
}
User user = userOpt.orElseThrow(() -> new NotFoundException());
Prefer Composition Over Inheritance
Rule: Favor composition (has-a) over inheritance (is-a) unless there's a true is-a relationship.
❌ Bad - Inheritance Abuse:
public class Stack extends ArrayList<Object> {
public void push(Object item) {
add(item);
}
public Object pop() {
return remove(size() - 1);
}
}
✅ Good - Composition:
public class Stack<T> {
private final List<T> elements = new ArrayList<>();
public void push(T item) {
elements.add(item);
}
public T pop() {
if (elements.isEmpty()) {
throw new EmptyStackException();
}
return elements.remove(elements.size() - 1);
}
public T peek() {
if (elements.isEmpty()) {
throw new EmptyStackException();
}
return elements.get(elements.size() - 1);
}
public boolean isEmpty() {
return elements.isEmpty();
}
public int size() {
return elements.size();
}
}
When to Use Inheritance:
- True is-a relationship exists
- Subclass is a specialized version of superclass
- Liskov Substitution Principle holds
When to Use Composition:
- Reusing functionality
- Has-a relationship
- Need flexibility to change implementation
- Multiple behaviors needed (can compose many, inherit one)
Immutability with Final
Rule: Make classes and variables immutable when possible. Use final extensively.
✅ Immutable Class:
public final class Money {
private final double amount;
private final String currency;
public Money(double amount, String currency) {
if (amount < 0) {
throw new IllegalArgumentException("Amount cannot be negative");
}
this.amount = amount;
this.currency = currency;
}
public double getAmount() {
return amount;
}
public String getCurrency() {
return currency;
}
public Money add(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException("Currency mismatch");
}
return new Money(this.amount + other.amount, this.currency);
}
public Money multiply(double multiplier) {
return new Money(this.amount * multiplier, this.currency);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Money)) return false;
Money money = (Money) o;
return Double.compare(money.amount, amount) == 0
&& currency.equals(money.currency);
}
@Override
public int hashCode() {
return Objects.hash(amount, currency);
}
}
Benefits of Immutability:
- Thread-safe by default
- Can be safely shared
- Simpler to reason about
- No defensive copying needed
- Safe to use as HashMap keys
✅ Using Final for Variables:
public void processOrder(Order order) {
final double total = order.getTotal();
final List<OrderItem> items = order.getItems();
items.add(new OrderItem());
final List<OrderItem> immutableItems =
Collections.unmodifiableList(new ArrayList<>(items));
}
Stream API Usage
Rule: Use Stream API for collection operations. It's more readable, functional, and can be parallelized.
❌ Bad - Imperative Style:
List<User> activeUsers = new ArrayList<>();
for (User user : users) {
if (user.isActive()) {
activeUsers.add(user);
}
}
List<String> emails = new ArrayList<>();
for (User user : activeUsers) {
emails.add(user.getEmail());
}
Collections.sort(emails);
✅ Good - Declarative with Streams:
List<String> emails = users.stream()
.filter(User::isActive)
.map(User::getEmail)
.sorted()
.collect(Collectors.toList());
✅ Common Stream Patterns:
List<Order> largeOrders = orders.stream()
.filter(order -> order.getTotal() > 1000)
.collect(Collectors.toList());
List<String> customerNames = orders.stream()
.map(order -> order.getCustomer().getName())
.collect(Collectors.toList());
List<OrderItem> allItems = orders.stream()
.flatMap(order -> order.getItems().stream())
.collect(Collectors.toList());
double totalRevenue = orders.stream()
.mapToDouble(Order::getTotal)
.sum();
Optional<Order> maxOrder = orders.stream()
.max(Comparator.comparing(Order::getTotal));
Map<String, List<Order>> ordersByStatus = orders.stream()
.collect(Collectors.groupingBy(Order::getStatus));
Map<Boolean, List<Order>> ordersByShipped = orders.stream()
.collect(Collectors.partitioningBy(Order::isShipped));
Optional<User> firstAdmin = users.stream()
.filter(User::isAdmin)
.findFirst();
List<String> uniqueCities = users.stream()
.map(User::getCity)
.distinct()
.collect(Collectors.toList());
List<User> firstTenUsers = users.stream()
.limit(10)
.collect(Collectors.toList());
double averageOrderValueForActiveCustomers = orders.stream()
.filter(order -> order.getCustomer().isActive())
.mapToDouble(Order::getTotal)
.average()
.orElse(0.0);
Stream Best Practices:
- Don't reuse streams (create new stream for each pipeline)
- Avoid side effects in stream operations
- Use method references when possible
- Consider parallel streams for large datasets (but measure!)
- Streams are lazy - terminal operation triggers execution
❌ Bad Stream Usage:
List<String> results = new ArrayList<>();
users.stream()
.forEach(user -> results.add(user.getName()));
List<String> results = users.stream()
.map(User::getName)
.collect(Collectors.toList());
Stream<User> userStream = users.stream();
long count = userStream.count();
List<User> list = userStream.collect(Collectors.toList());
Lambda Expressions
Rule: Use lambda expressions for functional interfaces. Prefer method references when applicable.
✅ Lambda Best Practices:
List<User> sorted = users.stream()
.sorted((u1, u2) -> u1.getName().compareTo(u2.getName()))
.collect(Collectors.toList());
List<User> sorted = users.stream()
.sorted(Comparator.comparing(User::getName))
.collect(Collectors.toList());
users.forEach(user -> {
user.setLastLoginTime(LocalDateTime.now());
user.incrementLoginCount();
userRepository.save(user);
});
@FunctionalInterface
public interface OrderProcessor {
void process(Order order);
}
OrderProcessor processor = order -> {
validateOrder(order);
calculateTotal(order);
saveOrder(order);
};
Method References
Rule: Use method references instead of lambda expressions when possible. They're more concise.
users.stream().map(User::getName);
users.forEach(System.out::println);
Supplier<List<String>> supplier = ArrayList::new;
strings.stream().map(String::length);
Types of Method References:
- Static method:
ClassName::staticMethod
- Instance method of particular object:
object::instanceMethod
- Instance method of arbitrary object:
ClassName::instanceMethod
- Constructor:
ClassName::new
Try-With-Resources
Rule: Always use try-with-resources for AutoCloseable resources.
❌ Bad - Manual Resource Management:
BufferedReader reader = null;
try {
reader = new BufferedReader(new FileReader("file.txt"));
String line = reader.readLine();
} catch (IOException e) {
log.error("Error reading file", e);
} finally {
if (reader != null) {
try {
reader.close();
} catch (IOException e) {
log.error("Error closing reader", e);
}
}
}
✅ Good - Try-With-Resources:
try (BufferedReader reader = new BufferedReader(new FileReader("file.txt"))) {
String line = reader.readLine();
} catch (IOException e) {
log.error("Error reading file", e);
}
try (FileInputStream fis = new FileInputStream("input.txt");
FileOutputStream fos = new FileOutputStream("output.txt")) {
} catch (IOException e) {
log.error("Error processing files", e);
}
StringBuilder vs String Concatenation
Rule: Use StringBuilder for multiple string concatenations in loops. Use + for simple concatenations.
❌ Bad - String Concatenation in Loop:
String result = "";
for (int i = 0; i < 1000; i++) {
result += i + ",";
}
✅ Good - StringBuilder:
StringBuilder builder = new StringBuilder();
for (int i = 0; i < 1000; i++) {
builder.append(i).append(",");
}
String result = builder.toString();
List<String> items = Arrays.asList("apple", "banana", "cherry");
String result = String.join(", ", items);
String result = IntStream.range(0, 1000)
.mapToObj(String::valueOf)
.collect(Collectors.joining(","));
✅ Simple Concatenation - Use + is Fine:
String fullName = firstName + " " + lastName;
String message = "Hello, " + name + "!";
for (String item : items) {
result = result + item;
}
Enum Usage
Rule: Use enums for fixed sets of constants. Enums can have fields, methods, and constructors.
✅ Basic Enum:
public enum OrderStatus {
PENDING,
PROCESSING,
SHIPPED,
DELIVERED,
CANCELLED
}
Order order = new Order();
order.setStatus(OrderStatus.PENDING);
if (order.getStatus() == OrderStatus.DELIVERED) {
}
✅ Enum with Fields and Methods:
public enum PaymentMethod {
CREDIT_CARD("Credit Card", 2.9),
DEBIT_CARD("Debit Card", 1.5),
PAYPAL("PayPal", 3.5),
BITCOIN("Bitcoin", 1.0);
private final String displayName;
private final double transactionFeePercent;
PaymentMethod(String displayName, double transactionFeePercent) {
this.displayName = displayName;
this.transactionFeePercent = transactionFeePercent;
}
public String getDisplayName() {
return displayName;
}
public double calculateFee(double amount) {
return amount * (transactionFeePercent / 100);
}
public double calculateTotal(double amount) {
return amount + calculateFee(amount);
}
}
PaymentMethod method = PaymentMethod.CREDIT_CARD;
double total = method.calculateTotal(100.0);
System.out.println("Paying with: " + method.getDisplayName());
✅ Enum with Abstract Methods (Strategy Pattern):
public enum Operation {
PLUS {
@Override
public double apply(double x, double y) {
return x + y;
}
},
MINUS {
@Override
public double apply(double x, double y) {
return x - y;
}
},
MULTIPLY {
@Override
public double apply(double x, double y) {
return x * y;
}
},
DIVIDE {
@Override
public double apply(double x, double y) {
if (y == 0) throw new ArithmeticException("Division by zero");
return x / y;
}
};
public abstract double apply(double x, double y);
}
double result = Operation.PLUS.apply(5, 3);
Exception Handling
Checked vs Unchecked Exceptions
Rule: Use checked exceptions for recoverable conditions, unchecked for programming errors.
Checked Exceptions:
- Extend
Exception (not RuntimeException)
- Must be declared in method signature or caught
- Use for conditions caller can reasonably handle
Unchecked Exceptions:
- Extend
RuntimeException
- Don't need to be declared or caught
- Use for programming errors
✅ When to Use Each:
public User findUserById(Long id) throws UserNotFoundException {
return userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException("User not found: " + id));
}
public void setAge(int age) {
if (age < 0) {
throw new IllegalArgumentException("Age cannot be negative");
}
this.age = age;
}
public String readFile(String path) throws IOException {
return Files.readString(Paths.get(path));
}
public void processOrder(Order order) {
Objects.requireNonNull(order, "Order cannot be null");
}
When to Catch vs Throw
Rule: Catch exceptions only if you can handle them meaningfully. Otherwise, let them propagate.
❌ Bad - Catching and Rethrowing:
public void processData(String data) throws DataProcessingException {
try {
} catch (JsonParseException e) {
throw new DataProcessingException(e);
}
}
public void processData(String data) throws JsonParseException {
}
✅ Good - Catch When You Can Handle:
public void processDataWithRetry(String data) {
int maxAttempts = 3;
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
try {
processData(data);
return;
} catch (TransientException e) {
if (attempt == maxAttempts) {
log.error("Failed after {} attempts", maxAttempts, e);
throw new DataProcessingException("Processing failed", e);
}
log.warn("Attempt {} failed, retrying...", attempt);
sleep(1000 * attempt);
}
}
}
Custom Exception Design
✅ Well-Designed Custom Exception:
public class InsufficientFundsException extends Exception {
private final double requestedAmount;
private final double availableBalance;
public InsufficientFundsException(double requestedAmount, double availableBalance) {
super(String.format("Insufficient funds: requested %.2f, available %.2f",
requestedAmount, availableBalance));
this.requestedAmount = requestedAmount;
this.availableBalance = availableBalance;
}
public double getRequestedAmount() {
return requestedAmount;
}
public double getAvailableBalance() {
return availableBalance;
}
public double getShortfall() {
return requestedAmount - availableBalance;
}
}
try {
account.withdraw(500);
} catch (InsufficientFundsException e) {
log.error("Withdrawal failed: {}", e.getMessage());
notifyUser(String.format("You need $%.2f more", e.getShortfall()));
}
Logging Exceptions
✅ Exception Logging Best Practices:
public void processOrder(Order order) {
try {
validateOrder(order);
saveOrder(order);
sendConfirmation(order);
} catch (ValidationException e) {
log.error("Order validation failed for order {}: {}",
order.getId(), e.getMessage(), e);
throw e;
} catch (DataAccessException e) {
log.error("Failed to save order {}", order.getId(), e);
throw new OrderProcessingException("Failed to save order", e);
} catch (EmailException e) {
log.warn("Failed to send confirmation email for order {}",
order.getId(), e);
}
}
Never Swallow Exceptions
❌ Bad - Swallowing Exceptions:
try {
riskyOperation();
} catch (Exception e) {
}
try {
closeResource();
} catch (Exception e) {
e.printStackTrace();
}
✅ Good - Proper Exception Handling:
try {
riskyOperation();
} catch (Exception e) {
log.error("Operation failed", e);
throw new ApplicationException("Failed to perform operation", e);
}
try {
closeResource();
} catch (IOException e) {
log.warn("Failed to close resource (non-critical)", e);
}
Collections and Generics
Choosing the Right Collection
Guide to Collection Selection:
List<String> names = new ArrayList<>();
List<Task> taskQueue = new LinkedList<>();
Set<String> uniqueEmails = new HashSet<>();
Set<Integer> sortedNumbers = new TreeSet<>();
Set<String> insertionOrderSet = new LinkedHashSet<>();
Map<Long, User> userCache = new HashMap<>();
Map<String, Integer> sortedMap = new TreeMap<>();
Map<String, String> orderedMap = new LinkedHashMap<>();
Queue<Task> taskQueue = new LinkedList<>();
Deque<String> deque = new ArrayDeque<>();
Deque<String> stack = new ArrayDeque<>();
stack.push("item");
String item = stack.pop();
Performance Characteristics:
Immutable Collections
✅ Creating Immutable Collections:
List<String> immutableList = List.of("a", "b", "c");
Set<String> immutableSet = Set.of("x", "y", "z");
Map<String, Integer> immutableMap = Map.of(
"one", 1,
"two", 2,
"three", 3
);
Map<String, Integer> largeMap = Map.ofEntries(
Map.entry("key1", 1),
Map.entry("key2", 2),
Map.entry("key3", 3)
);
List<String> list = new ArrayList<>();
list.add("a");
list.add("b");
List<String> immutableList = Collections.unmodifiableList(list);
ImmutableList<String> immutableList = ImmutableList.of("a", "b", "c");
ImmutableSet<String> immutableSet = ImmutableSet.of("x", "y", "z");
ImmutableMap<String, Integer> immutableMap = ImmutableMap.of("one", 1, "two", 2);
Generic Type Safety
✅ Proper Generic Usage:
public class Box<T> {
private T content;
public void set(T content) {
this.content = content;
}
public T get() {
return content;
}
}
public class Pair<K, V> {
private final K key;
private final V value;
public Pair(K key, V value) {
this.key = key;
this.value = value;
}
public K getKey() { return key; }
public V getValue() { return value; }
}
public class NumberBox<T extends Number> {
private T number;
public void set(T number) {
this.number = number;
}
public double getDoubleValue() {
return number.doubleValue();
}
}
public <T> List<T> createList(T... elements) {
return Arrays.asList(elements);
}
public void processList(List<? extends Number> numbers) {
for (Number num : numbers) {
System.out.println(num.doubleValue());
}
}
public void addToList(List<? super Integer> list) {
list.add(42);
}
Diamond Operator Usage
✅ Use Diamond Operator (Java 7+):
Map<String, List<String>> map = new HashMap<String, List<String>>();
Map<String, List<String>> map = new HashMap<>();
List<String> list = new ArrayList<>() {
{
add("item");
}
};
Concurrency
Thread Safety
Rule: Design for thread safety from the start. Assume multi-threaded access unless documented otherwise.
✅ Thread-Safe Patterns:
public final class ImmutableUser {
private final String name;
private final String email;
public ImmutableUser(String name, String email) {
this.name = name;
this.email = email;
}
public String getName() { return name; }
public String getEmail() { return email; }
}
public class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
private final Map<String, User> userCache = new ConcurrentHashMap<>();
private final List<String> logMessages = new CopyOnWriteArrayList<>();
private final AtomicInteger counter = new AtomicInteger(0);
public void incrementCounter() {
counter.incrementAndGet();
}
public int getCounter() {
return counter.get();
}
private static final ThreadLocal<SimpleDateFormat> dateFormat =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
public String formatDate(Date date) {
return dateFormat.get().format(date);
}
ExecutorService Usage
✅ Using ExecutorService:
ExecutorService executor = Executors.newFixedThreadPool(10);
for (int i = 0; i < 100; i++) {
final int taskId = i;
executor.submit(() -> {
processTask(taskId);
});
}
executor.shutdown();
try {
if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
executor.shutdownNow();
}
} catch (InterruptedException e) {
executor.shutdownNow();
Thread.currentThread().interrupt();
}
try (ExecutorService executor = Executors.newFixedThreadPool(10)) {
}
ExecutorService customExecutor = new ThreadPoolExecutor(
5,
10,
60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(100),
new ThreadPoolExecutor.CallerRunsPolicy()
);
CompletableFuture Patterns
✅ Asynchronous Programming with CompletableFuture:
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
return fetchDataFromApi();
});
CompletableFuture<UserDto> userFuture = CompletableFuture
.supplyAsync(() -> fetchUserFromDatabase(userId))
.thenApply(user -> mapToDto(user))
.thenApply(dto -> enrichWithAdditionalData(dto));
CompletableFuture<User> userFuture = fetchUserAsync(userId);
CompletableFuture<List<Order>> ordersFuture = fetchOrdersAsync(userId);
CompletableFuture<UserWithOrders> combined = userFuture
.thenCombine(ordersFuture, (user, orders) ->
new UserWithOrders(user, orders));
CompletableFuture<String> result = CompletableFuture
.supplyAsync(() -> riskyOperation())
.exceptionally(ex -> {
log.error("Operation failed", ex);
return "default value";
});
List<CompletableFuture<String>> futures = ids.stream()
.map(id -> CompletableFuture.supplyAsync(() -> fetchData(id)))
.collect(Collectors.toList());
CompletableFuture<Void> allOf = CompletableFuture.allOf(
futures.toArray(new CompletableFuture[0]));
allOf.thenRun(() -> {
List<String> results = futures.stream()
.map(CompletableFuture::join)
.collect(Collectors.toList());
processResults(results);
});
Avoiding Deadlocks
✅ Deadlock Prevention:
public class BankAccount {
private final Object lock = new Object();
private double balance;
public static void transfer(BankAccount from, BankAccount to, double amount) {
BankAccount first = from.hashCode() < to.hashCode() ? from : to;
BankAccount second = from.hashCode() < to.hashCode() ? to : from;
synchronized (first.lock) {
synchronized (second.lock) {
if (from == first) {
from.withdraw(amount);
to.deposit(amount);
} else {
from.deposit(amount);
to.withdraw(amount);
}
}
}
}
}
Lock lock1 = new ReentrantLock();
Lock lock2 = new ReentrantLock();
try {
if (lock1.tryLock(1, TimeUnit.SECONDS)) {
try {
if (lock2.tryLock(1, TimeUnit.SECONDS)) {
try {
} finally {
lock2.unlock();
}
}
} finally {
lock1.unlock();
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
ConcurrentHashMap<String, Account> accounts = new ConcurrentHashMap<>();
accounts.compute("account1", (key, account) -> {
account.withdraw(100);
return account;
});
Atomic Classes
✅ Using Atomic Classes:
public class Statistics {
private final AtomicLong totalRequests = new AtomicLong(0);
private final AtomicLong successfulRequests = new AtomicLong(0);
private final AtomicLong failedRequests = new AtomicLong(0);
public void recordSuccess() {
totalRequests.incrementAndGet();
successfulRequests.incrementAndGet();
}
public void recordFailure() {
totalRequests.incrementAndGet();
failedRequests.incrementAndGet();
}
public double getSuccessRate() {
long total = totalRequests.get();
if (total == 0) return 0.0;
return (double) successfulRequests.get() / total;
}
private final AtomicReference<Config> config =
new AtomicReference<>(new Config());
public void updateConfig(Config newConfig) {
Config current;
do {
current = config.get();
} while (!config.compareAndSet(current, newConfig));
}
}
Testing (TDD)
Unit Testing with JUnit 5
✅ JUnit 5 Best Practices:
@DisplayName("User Service Tests")
class UserServiceTest {
private UserService userService;
private UserRepository userRepository;
private PasswordEncoder passwordEncoder;
@BeforeEach
void setUp() {
userRepository = mock(UserRepository.class);
passwordEncoder = mock(PasswordEncoder.class);
userService = new UserService(userRepository, passwordEncoder);
}
@Test
@DisplayName("Should create user with valid data")
void shouldCreateUserWithValidData() {
String email = "test@example.com";
String password = "SecurePass123!";
String encodedPassword = "encoded_password";
when(passwordEncoder.encode(password)).thenReturn(encodedPassword);
when(userRepository.existsByEmail(email)).thenReturn(false);
User user = userService.createUser(email, password);
assertNotNull(user);
assertEquals(email, user.getEmail());
assertEquals(encodedPassword, user.getPasswordHash());
verify(userRepository).save(any(User.class));
}
@Test
@DisplayName("Should throw exception when email already exists")
void shouldThrowExceptionWhenEmailExists() {
String email = "existing@example.com";
when(userRepository.existsByEmail(email)).thenReturn(true);
assertThrows(DuplicateEmailException.class, () ->
userService.createUser(email, "password"));
verify(userRepository, never()).save(any());
}
@ParameterizedTest
@ValueSource(strings = {"", " ", "invalid-email", "@example.com"})
@DisplayName("Should throw exception for invalid email formats")
void shouldThrowExceptionForInvalidEmail(String invalidEmail) {
assertThrows(ValidationException.class, () ->
userService.createUser(invalidEmail, "password"));
}
@ParameterizedTest
@CsvSource({
"test@example.com, short, 'Password too short'",
"test@example.com, nodigits, 'Password must contain digits'",
"invalid, ValidPass123!, 'Invalid email format'"
})
@DisplayName("Should validate user input correctly")
void shouldValidateUserInput(String email, String password, String expectedError) {
ValidationException exception = assertThrows(ValidationException.class, () ->
userService.createUser(email, password));
assertTrue(exception.getMessage().contains(expectedError));
}
@Test
@DisplayName("Should handle repository exception gracefully")
void shouldHandleRepositoryException() {
when(userRepository.save(any())).thenThrow(new DataAccessException("DB error"));
assertThrows(UserCreationException.class, () ->
userService.createUser("test@example.com", "password"));
}
@Nested
@DisplayName("User Authentication Tests")
class UserAuthenticationTests {
@Test
@DisplayName("Should authenticate user with correct password")
void shouldAuthenticateWithCorrectPassword() {
User user = new User("test@example.com", "encoded_pass");
when(userRepository.findByEmail("test@example.com"))
.thenReturn(Optional.of(user));
when(passwordEncoder.matches("password", "encoded_pass"))
.thenReturn(true);
boolean authenticated = userService.authenticate("test@example.com", "password");
assertTrue(authenticated);
}
@Test
@DisplayName("Should reject authentication with wrong password")
void shouldRejectWithWrongPassword() {
User user = new User("test@example.com", "encoded_pass");
when(userRepository.findByEmail("test@example.com"))
.thenReturn(Optional.of(user));
when(passwordEncoder.matches("wrong", "encoded_pass"))
.thenReturn(false);
boolean authenticated = userService.authenticate("test@example.com", "wrong");
assertFalse(authenticated);
}
}
}
Mocking with Mockito
✅ Mockito Best Practices:
public class OrderServiceTest {
@Mock
private OrderRepository orderRepository;
@Mock
private PaymentService paymentService;
@Mock
private EmailService emailService;
@InjectMocks
private OrderService orderService;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
}
@Test
void shouldProcessOrderSuccessfully() {
Order order = new Order();
order.setId(1L);
order.setTotal(100.0);
when(paymentService.charge(any(), anyDouble())).thenReturn(true);
when(orderRepository.save(any(Order.class))).thenReturn(order);
orderService.processOrder(order);
verify(paymentService).charge(order, 100.0);
verify(orderRepository).save(order);
verify(emailService).sendConfirmation(order);
assertEquals(OrderStatus.COMPLETED, order.getStatus());
}
@Test
void shouldHandlePaymentFailure() {
Order order = new Order();
when(paymentService.charge(any(), anyDouble())).thenReturn(false);
assertThrows(PaymentFailedException.class, () ->
orderService.processOrder(order));
verify(emailService, never()).sendConfirmation(any());
assertEquals(OrderStatus.PAYMENT_FAILED, order.getStatus());
}
@Test
void shouldRetryOnTransientFailure() {
Order order = new Order();
when(paymentService.charge(any(), anyDouble()))
.thenThrow(new TransientException())
.thenThrow(new TransientException())
.thenReturn(true);
orderService.processOrderWithRetry(order);
verify(paymentService, times(3)).charge(any(), anyDouble());
}
@Test
void shouldCaptureArgumentValues() {
ArgumentCaptor<Order> orderCaptor = ArgumentCaptor.forClass(Order.class);
Order order = new Order();
order.setTotal(100.0);
orderService.processOrder(order);
verify(orderRepository).save(orderCaptor.capture());
Order savedOrder = orderCaptor.getValue();
assertEquals(OrderStatus.COMPLETED, savedOrder.getStatus());
assertNotNull(savedOrder.getProcessedAt());
}
}
Test Organization
Package Structure:
src/
├── main/
│ └── java/
│ └── com/example/
│ ├── model/
│ │ └── User.java
│ └── service/
│ └── UserService.java
└── test/
└── java/
└── com/example/
├── model/
│ └── UserTest.java
└── service/
└── UserServiceTest.java
AAA Pattern (Arrange-Act-Assert)
✅ Clear Test Structure:
@Test
void shouldCalculateDiscountCorrectly() {
Product product = new Product("Laptop", 1000.0);
Discount discount = new Discount(10);
PriceCalculator calculator = new PriceCalculator();
double finalPrice = calculator.calculateFinalPrice(product, discount);
assertEquals(900.0, finalPrice, 0.01);
}
Testing Private Methods
Rule: Don't test private methods directly. Test them through public methods.
❌ Bad:
@Test
void testPrivateMethod() {
Method method = UserService.class.getDeclaredMethod("validateEmail", String.class);
method.setAccessible(true);
boolean result = (boolean) method.invoke(userService, "test@example.com");
assertTrue(result);
}
✅ Good:
@Test
void shouldRejectInvalidEmailDuringUserCreation() {
assertThrows(ValidationException.class, () ->
userService.createUser("invalid-email", "password"));
}
Test Coverage Goals
Recommended Coverage Targets:
- Critical business logic: 100%
- Service layer: 90-100%
- Controller layer: 80-90%
- Utility classes: 90-100%
- Overall project: 80% minimum
Tools:
- JaCoCo for coverage reporting
- SonarQube for code quality analysis
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.10</version>
<executions>
<execution>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
<execution>
<id>coverage-check</id>
<goals>
<goal>check</goal>
</goals>
<configuration>
<rules>
<rule>
<element>PACKAGE</element>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
Code Organization
Package Structure
✅ Layered Architecture: