Sometimes we are given a task where we need to model a hierarchy of different complexity levels and not really sure how to do that properly in the most efficient, reliable, and flexible way. Let’s review one of the data modeling patterns that give us some answers for that.
Consider we have a hierarchy with a ragged variable depth, like on a picture below.

The requirements are that we need to:
This is where a closure table comes into the picture.
A closure table gives you the ability to work with tree structure hierarchies. It involves storing all paths through a tree, not just those with a direct parent-child relationship. For example: things like family tree(Parent – Kids – etc), company hierarchy(Owner – CEO – Manager – Employee), or file sytem(/users/folauk/Documents/test.mp4). There are other ways such as Path Enumeration and Nested Sets to keep track of this kind of information but using a closure is the simplest and most efficient way of doing it.
The main building block of the pattern is an additional structure(table) or a mapping table that keeps the tree relationships — a set of node pairs for each path from the ancestor to a descendant or parent to child, including a path to itself.

Our example here is a social media site where a user can put up a post. Other users can comment on that post and can comment on comments from the same post. The tree here is comments under comments. We have to keep track of which comments go under which comments and their parents.

CREATE TABLE `user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `date_of_birth` date DEFAULT NULL, `email` varchar(255) DEFAULT NULL, `first_name` varchar(255) DEFAULT NULL, `gender` varchar(255) DEFAULT NULL, `last_name` varchar(255) DEFAULT NULL, `phone_number` varchar(255) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=21 DEFAULT CHARSET=latin1; CREATE TABLE `post` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `content` longtext, `created_at` datetime(6) DEFAULT NULL, `last_updated_at` datetime(6) DEFAULT NULL, `uuid` varchar(255) NOT NULL, `author_id` bigint(20) DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `UK_b1a5vs6t5vi32stck5kjw0tgf` (`uuid`), KEY `FK12njtf8e0jmyb45lqfpt6ad89` (`author_id`), CONSTRAINT `FK12njtf8e0jmyb45lqfpt6ad89` FOREIGN KEY (`author_id`) REFERENCES `user` (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=41 DEFAULT CHARSET=latin1; CREATE TABLE `comment` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `content` longtext, `created_at` datetime(6) DEFAULT NULL, `last_updated_at` datetime(6) DEFAULT NULL, `author_id` bigint(20) DEFAULT NULL, `post_id` bigint(20) DEFAULT NULL, PRIMARY KEY (`id`), KEY `FKh1gtv412u19wcbx22177xbkjp` (`author_id`), KEY `FKs1slvnkuemjsq2kj4h3vhx7i1` (`post_id`), CONSTRAINT `FKh1gtv412u19wcbx22177xbkjp` FOREIGN KEY (`author_id`) REFERENCES `user` (`id`), CONSTRAINT `FKs1slvnkuemjsq2kj4h3vhx7i1` FOREIGN KEY (`post_id`) REFERENCES `post` (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=201 DEFAULT CHARSET=latin1; CREATE TABLE `comment_tree` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `depth` int(11) DEFAULT NULL, `child_id` bigint(20) DEFAULT NULL, `parent_id` bigint(20) DEFAULT NULL, PRIMARY KEY (`id`), KEY `FKhosfyxi6cpykkhyrccfxq2k2x` (`child_id`), KEY `FKq2283664y5ywd961iojgjk98b` (`parent_id`), CONSTRAINT `FKhosfyxi6cpykkhyrccfxq2k2x` FOREIGN KEY (`child_id`) REFERENCES `comment` (`id`), CONSTRAINT `FKq2283664y5ywd961iojgjk98b` FOREIGN KEY (`parent_id`) REFERENCES `comment` (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=1201 DEFAULT CHARSET=latin1;
Here we have a comment table that represents our comment, author of the comment, and the post it belongs to.
Insert comments(rows) into the comment_tree(closure) table
INSERT INTO comment_tree(parent_id, child_id, depth) SELECT p.parent_id, c.child_id, p.depth+c.depth+1 FROM comment_tree as p, comment_tree as c WHERE p.child_id = :parentId and c.parent_id = :childId;
Delete comments(rows) in the comment_tree(closure) table
DELETE link
FROM comment_tree as parent, comment_tree as link, comment_tree as child
WHERE parent.parent_id = link.parent_id AND child.child_id = link.child_id
-- AND parent.child_id = {parentId} and child.parent_id = {childId}
AND parent.child_id = ? AND child.parent_id = ?
Retrieve comments under a comment.
Get all comments under comment with id 4.
SELECT com.* FROM comment AS com JOIN comment_tree AS comTree ON com.id = comTree.child_id WHERE comTree.parent_id = 4;
Retrieve parent comments of a comment
Get all parent comments of comment with id 4.
-- retrieve parent comments of comment with id 4 SELECT com.*, comTree.* FROM comment AS com JOIN comment_tree AS comTree ON com.id = comTree.parent_id WHERE comTree.child_id = 4;
Writing Java code that compiles and runs is easy. Writing Java code that is readable, maintainable, secure, and performant — code that your teammates (and your future self) will thank you for — is a different challenge entirely. This tutorial distills the practices that separate production-quality Java from weekend-project Java.
We will cover 20 best practices organized into seven categories: code style, object-oriented design, error handling, performance, modern Java features, testing and quality, code organization, and security. Each practice includes concrete bad and good examples so you can immediately apply what you learn.
You read code far more often than you write it. Studies consistently show that developers spend 70-80% of their time reading existing code. The practices in this section make that reading time dramatically more productive.
Names are the most important form of documentation in your code. A well-chosen name eliminates the need for comments, makes code self-documenting, and reduces the cognitive load on every developer who reads it. Java has established conventions that every professional project follows:
| Element | Convention | Example |
|---|---|---|
| Classes and Interfaces | PascalCase (nouns) | UserAccount, PaymentProcessor |
| Methods | camelCase (verbs) | calculateTotal(), sendNotification() |
| Variables | camelCase (nouns) | firstName, orderCount |
| Constants | UPPER_SNAKE_CASE | MAX_RETRY_COUNT, DEFAULT_TIMEOUT |
| Packages | all lowercase, dot-separated | com.example.payment |
| Type Parameters | Single uppercase letter | T, E, K, V |
| Enums | PascalCase (type), UPPER_SNAKE_CASE (values) | OrderStatus.PENDING |
Boolean names deserve special attention. A boolean variable or method should read like a true/false question:
// BAD - unclear what these represent
boolean flag = true;
boolean status = false;
boolean check = user.verify();
// GOOD - reads like a question: "Is it active?" -> true/false
boolean isActive = true;
boolean hasPermission = false;
boolean canExecute = user.hasRole("ADMIN");
boolean shouldRetry = attempts < MAX_RETRIES;
boolean wasProcessed = order.getStatus() == Status.COMPLETE;
Avoid abbreviations. Abbreviations save a few keystrokes but cost minutes of confusion every time someone reads the code. Modern IDEs have autocomplete -- there is no reason to abbreviate.
// BAD - abbreviations force the reader to decode
int qty;
String addr;
UserAcctMgr uam;
public void calcTtlAmt() { ... }
// GOOD - instantly clear
int quantity;
String address;
UserAccountManager userAccountManager;
public void calculateTotalAmount() { ... }
Use meaningful, intention-revealing names. The name should tell you why it exists, what it does, and how it is used. If a name requires a comment, the name is wrong.
// BAD - what does d represent? What are the 86400000?
long d = System.currentTimeMillis() - start;
if (d > 86400000) { ... }
// GOOD - the name tells the whole story
long elapsedTimeMillis = System.currentTimeMillis() - startTimeMillis;
long ONE_DAY_IN_MILLIS = 24 * 60 * 60 * 1000L;
if (elapsedTimeMillis > ONE_DAY_IN_MILLIS) { ... }
Avoid single-letter variables except in short loop counters (i, j, k) or lambda parameters where the type makes the meaning obvious (e.g., users.forEach(u -> u.activate())).
Consistent formatting is not about personal preference -- it is about team velocity. When everyone follows the same formatting rules, code reviews focus on logic instead of style debates, and every file is instantly familiar.
Key formatting rules:
if, for, while, even for single statements. This prevents bugs when someone adds a second line.// BAD - missing braces, inconsistent spacing, dense
if(active) doSomething();
for(int i=0;i<10;i++) process(i);
if (x > 0)
doThis();
doThat(); // BUG: doThat() runs ALWAYS, not inside the if
// GOOD - consistent braces, spacing, and structure
if (active) {
doSomething();
}
for (int i = 0; i < 10; i++) {
process(i);
}
if (x > 0) {
doThis();
doThat(); // clearly inside the if block
}
Use your IDE's auto-format. In IntelliJ IDEA, press Ctrl+Alt+L (Windows/Linux) or Cmd+Option+L (Mac). In Eclipse, press Ctrl+Shift+F. Configure your team's formatting rules once and share the settings file across the project. Consider adding a .editorconfig file to your repository.
The best code needs no comments because the names and structure explain everything. When comments are necessary, they should explain why, not what. If you find yourself writing a comment that explains what the code does, consider renaming variables or extracting a method instead.
// BAD - comments that repeat the code (add nothing)
// Increment counter by 1
counter++;
// Check if user is null
if (user == null) {
return;
}
// Get the list of users from the database
List users = userRepository.findAll();
// GOOD - comments that explain WHY
// Circuit breaker: stop retrying after 3 failures to avoid
// cascading timeouts to downstream services
if (failureCount >= MAX_FAILURES) {
return fallbackResponse();
}
// Using insertion order map because the API contract guarantees
// fields are returned in the order they were added
Map fields = new LinkedHashMap<>();
Javadoc for public APIs. Every public class and public method should have Javadoc. This is non-negotiable for library code and strongly recommended for application code.
/** * Transfers funds between two accounts within a single transaction. * *Both accounts must be active and belong to the same currency. * The transfer is atomic -- if any step fails, both accounts * remain unchanged.
* * @param fromAccountId the source account ID * @param toAccountId the destination account ID * @param amount the amount to transfer (must be positive) * @return the transaction confirmation with a unique reference ID * @throws InsufficientFundsException if the source account balance is too low * @throws AccountNotFoundException if either account does not exist * @throws IllegalArgumentException if amount is zero or negative */ public TransactionConfirmation transfer( String fromAccountId, String toAccountId, BigDecimal amount) { // implementation }
TODO and FIXME conventions:
// TODO: -- something that needs to be done but is not urgent// FIXME: -- a known bug or problematic code that needs fixing// HACK: -- a workaround that should be replaced with a proper solutionAlways include your name or ticket number so someone can follow up: // TODO(JIRA-1234): Replace with batch API when available
Java is fundamentally an object-oriented language. These design practices help you write code that is flexible, testable, and resilient to change.
SOLID is a set of five design principles that guide you toward maintainable object-oriented code. Here is a quick reminder of each with a practical Java example:
| Principle | Meaning | One-Line Summary |
|---|---|---|
| Single Responsibility | A class should have only one reason to change | Separate UserValidator from UserRepository |
| Open/Closed | Open for extension, closed for modification | Add new behavior via new classes, not by editing existing ones |
| Liskov Substitution | Subtypes must be substitutable for their base types | If Square extends Rectangle, setting width must not break area calculation |
| Interface Segregation | No client should depend on methods it does not use | Split Worker into Workable and Feedable |
| Dependency Inversion | Depend on abstractions, not concretions | Constructor takes NotificationService interface, not EmailService class |
// BAD - Single Responsibility violation: one class does everything
public class UserService {
public void registerUser(User user) { ... }
public void sendWelcomeEmail(User user) { ... } // email logic
public void generatePdfReport(User user) { ... } // PDF logic
public boolean validateUser(User user) { ... } // validation logic
}
// GOOD - each class has one responsibility
public class UserService {
private final UserValidator validator;
private final UserRepository repository;
private final NotificationService notificationService;
public UserService(UserValidator validator,
UserRepository repository,
NotificationService notificationService) {
this.validator = validator;
this.repository = repository;
this.notificationService = notificationService;
}
public void registerUser(User user) {
validator.validate(user);
repository.save(user);
notificationService.sendWelcomeNotification(user);
}
}
Inheritance creates a tight coupling between parent and child classes. A change in the parent can break every child. Composition -- where a class contains another class rather than extending it -- gives you the same code reuse with much more flexibility.
The rule of thumb: use inheritance only when there is a genuine is-a relationship (Dog is an Animal). If the relationship is has-a or uses-a, use composition.
// BAD - inheritance for code reuse (fragile base class problem)
public class Logger {
public void log(String message) {
System.out.println("[LOG] " + message);
}
}
public class UserService extends Logger {
// UserService IS-A Logger? That makes no sense.
public void createUser(String name) {
log("Creating user: " + name); // inherited method
// ...
}
}
// GOOD - composition: UserService HAS-A Logger
public class UserService {
private final Logger logger;
public UserService(Logger logger) {
this.logger = logger;
}
public void createUser(String name) {
logger.log("Creating user: " + name);
// ...
}
}
// Now you can swap FileLogger, ConsoleLogger, or NoOpLogger
// without touching UserService at all.
When you declare variables, parameters, and return types, use the most general type that still provides the methods you need. This decouples your code from specific implementations and makes it easy to swap one implementation for another.
// BAD - tied to a specific implementation ArrayListnames = new ArrayList<>(); HashMap userMap = new HashMap<>(); public ArrayList getNames() { return names; } // GOOD - programmed to the interface List names = new ArrayList<>(); Map userMap = new HashMap<>(); public List getNames() { return names; } // Tomorrow you can switch to LinkedList or CopyOnWriteArrayList // without changing any calling code.
This principle extends to dependency injection. Your classes should depend on interfaces, not concrete classes:
// BAD - hard dependency on a concrete class
public class OrderService {
private MySQLOrderRepository repository = new MySQLOrderRepository();
}
// GOOD - depends on an abstraction, injected via constructor
public class OrderService {
private final OrderRepository repository;
public OrderService(OrderRepository repository) {
this.repository = repository;
}
}
// In production: new OrderService(new MySQLOrderRepository())
// In tests: new OrderService(new FakeOrderRepository())
An immutable object cannot be modified after creation. Immutable objects are inherently thread-safe, simpler to reason about, and safer to share across your codebase. Make your classes immutable unless there is a compelling reason not to.
To make a class immutable:
final (prevent subclassing)private final// BAD - mutable class with exposed internals
public class Order {
public List items; // anyone can modify this list
public double total; // anyone can change the total
public Order(List items) {
this.items = items; // stores a reference -- caller can mutate it
}
}
// GOOD - immutable class with defensive copying
public final class Order {
private final List items;
private final double total;
public Order(List items, double total) {
// Defensive copy: changes to the original list won't affect us
this.items = List.copyOf(items);
this.total = total;
}
public List getItems() {
// Returns an unmodifiable view -- caller cannot add/remove items
return items; // already unmodifiable from List.copyOf()
}
public double getTotal() {
return total;
}
}
Use unmodifiable collections when returning collections from methods. This prevents callers from accidentally modifying your internal state.
// Java 9+ factory methods create unmodifiable collections Listcolors = List.of("Red", "Green", "Blue"); Set primes = Set.of(2, 3, 5, 7, 11); Map scores = Map.of("Alice", 95, "Bob", 87); // For mutable lists you need to return unmodifiable views public List getUsers() { return Collections.unmodifiableList(users); } // Java 16+ records are inherently immutable (no setter generation) public record Money(BigDecimal amount, Currency currency) { }
How you handle errors defines the reliability of your application. Poor error handling leads to silent failures, lost data, and hours of debugging. These practices ensure your application fails predictably and provides useful information when things go wrong.
Catch specific exceptions. Catching Exception or Throwable hides bugs, catches things you never intended to handle (like OutOfMemoryError), and makes debugging a nightmare.
// BAD - catches everything, including programming errors
try {
processOrder(order);
} catch (Exception e) {
log.error("Something went wrong"); // what went wrong? No clue.
}
// GOOD - catch specific exceptions, handle each appropriately
try {
processOrder(order);
} catch (InsufficientFundsException e) {
log.warn("Insufficient funds for order {}: {}", order.getId(), e.getMessage());
notifyCustomer(order, "Payment failed: insufficient funds");
} catch (InventoryException e) {
log.error("Inventory check failed for order {}", order.getId(), e);
queueForRetry(order);
}
Never leave a catch block empty. An empty catch block silently swallows the error and guarantees that someone will spend hours debugging a problem that the application already detected and threw away.
// BAD - the worst thing you can do with an exception
try {
Files.delete(tempFile);
} catch (IOException e) {
// silently ignored -- if the delete fails, you'll never know
}
// GOOD - at minimum, log it. Better: handle it.
try {
Files.delete(tempFile);
} catch (IOException e) {
log.warn("Failed to delete temp file {}: {}", tempFile, e.getMessage());
}
Use try-with-resources for any object that implements AutoCloseable. This guarantees cleanup even if an exception occurs, and it is more concise than try-finally.
// BAD - manual resource management (verbose and error-prone)
Connection conn = null;
PreparedStatement stmt = null;
try {
conn = dataSource.getConnection();
stmt = conn.prepareStatement("SELECT * FROM users WHERE id = ?");
stmt.setLong(1, userId);
ResultSet rs = stmt.executeQuery();
// process results
} catch (SQLException e) {
log.error("Query failed", e);
} finally {
if (stmt != null) { try { stmt.close(); } catch (SQLException e) { } }
if (conn != null) { try { conn.close(); } catch (SQLException e) { } }
}
// GOOD - try-with-resources handles all cleanup automatically
try (Connection conn = dataSource.getConnection();
PreparedStatement stmt = conn.prepareStatement(
"SELECT * FROM users WHERE id = ?")) {
stmt.setLong(1, userId);
try (ResultSet rs = stmt.executeQuery()) {
// process results
}
} catch (SQLException e) {
log.error("Query failed for user {}", userId, e);
}
Create custom exceptions for business logic. Custom exceptions make your error handling expressive and allow callers to react differently to different business failures.
public class InsufficientFundsException extends RuntimeException {
private final BigDecimal currentBalance;
private final BigDecimal requestedAmount;
public InsufficientFundsException(BigDecimal currentBalance,
BigDecimal requestedAmount) {
super(String.format("Cannot withdraw %s: current balance is %s",
requestedAmount, currentBalance));
this.currentBalance = currentBalance;
this.requestedAmount = requestedAmount;
}
public BigDecimal getCurrentBalance() { return currentBalance; }
public BigDecimal getRequestedAmount() { return requestedAmount; }
}
// Usage: fail fast with a meaningful exception
public void withdraw(BigDecimal amount) {
if (amount.compareTo(balance) > 0) {
throw new InsufficientFundsException(balance, amount);
}
balance = balance.subtract(amount);
}
Fail fast. Validate inputs at the beginning of a method. Do not let invalid data travel deep into your code where it causes confusing errors far from the source of the problem.
Tony Hoare, who invented null references, called them his "billion-dollar mistake." In Java, NullPointerException is the most common runtime exception. These practices help you avoid it.
Return empty collections instead of null.
// BAD - forces every caller to check for null public ListgetOrdersByCustomer(String customerId) { List orders = repository.findByCustomerId(customerId); if (orders.isEmpty()) { return null; // caller MUST check: if (orders != null) } return orders; } // GOOD - return empty collection, callers can iterate safely public List getOrdersByCustomer(String customerId) { List orders = repository.findByCustomerId(customerId); return orders != null ? orders : Collections.emptyList(); } // Even better with modern Java: public List getOrdersByCustomer(String customerId) { return Objects.requireNonNullElse( repository.findByCustomerId(customerId), List.of() ); }
Use Optional for values that might not exist. Optional is for return types that legitimately may have no value. It forces the caller to handle the absence explicitly.
// BAD - returning null for "not found"
public User findByEmail(String email) {
// Returns null if not found -- caller might forget to check
return userMap.get(email);
}
// GOOD - Optional makes the absence explicit
public Optional findByEmail(String email) {
return Optional.ofNullable(userMap.get(email));
}
// Callers are forced to handle both cases:
userService.findByEmail("alice@example.com")
.ifPresentOrElse(
user -> System.out.println("Found: " + user.getName()),
() -> System.out.println("User not found")
);
// Or with a default:
User user = userService.findByEmail("alice@example.com")
.orElseThrow(() -> new UserNotFoundException("alice@example.com"));
Use Objects.requireNonNull() at the start of constructors and methods to catch null early with a clear message.
public class EmailService {
private final SmtpClient client;
private final TemplateEngine templateEngine;
public EmailService(SmtpClient client, TemplateEngine templateEngine) {
// Fail immediately with a clear message instead of getting a
// NullPointerException 5 method calls later
this.client = Objects.requireNonNull(client, "SmtpClient must not be null");
this.templateEngine = Objects.requireNonNull(templateEngine,
"TemplateEngine must not be null");
}
}
Premature optimization is the root of all evil (Knuth), but knowing the common performance pitfalls saves you from writing obviously slow code. These practices are about choosing the right tool for the job.
Strings are immutable in Java. Every concatenation creates a new String object. For single-line concatenation the compiler optimizes it, but inside loops you must use StringBuilder.
// BAD - creates a new String object on every iteration // For 10,000 iterations, this creates ~10,000 intermediate String objects public String buildReport(Listlines) { String result = ""; for (String line : lines) { result += line + "\n"; // new String every time } return result; } // GOOD - StringBuilder reuses a single internal buffer public String buildReport(List lines) { StringBuilder sb = new StringBuilder(lines.size() * 80); // pre-size estimate for (String line : lines) { sb.append(line).append("\n"); } return sb.toString(); } // ALSO GOOD - String.join() for simple cases public String buildReport(List lines) { return String.join("\n", lines); }
Text blocks (Java 13+) make multi-line strings readable and maintainable:
// BAD - unreadable concatenation of a multi-line string
String json = "{\n" +
" \"name\": \"" + name + "\",\n" +
" \"email\": \"" + email + "\"\n" +
"}";
// GOOD - text block (Java 13+) with formatted() (Java 15+)
String json = """
{
"name": "%s",
"email": "%s"
}
""".formatted(name, email);
Choosing the right collection is one of the most impactful performance decisions you can make. Here is a quick guide:
| Need | Use | Why |
|---|---|---|
| Ordered list with random access | ArrayList |
O(1) get by index, backed by array |
| Frequent insertions/removals at both ends | ArrayDeque |
O(1) add/remove at head and tail |
| Unique elements, fast lookup | HashSet |
O(1) contains, add, remove |
| Sorted unique elements | TreeSet |
O(log n) operations, natural ordering |
| Key-value lookup | HashMap |
O(1) get/put on average |
| Sorted key-value pairs | TreeMap |
O(log n) operations, sorted keys |
| Insertion-ordered key-value pairs | LinkedHashMap |
O(1) operations + maintains order |
| Thread-safe key-value | ConcurrentHashMap |
Lock striping for concurrent access |
// BAD - wrong collection for the job LinkedListusers = new LinkedList<>(); // random access is O(n) users.get(500); // traverses 500 nodes to get element // GOOD - ArrayList for indexed access ArrayList users = new ArrayList<>(); users.get(500); // O(1) array index lookup // Pre-size when you know the approximate size (avoids resizing) List names = new ArrayList<>(1000); Map cache = new HashMap<>(256); // Use Collections utility methods for common operations List numbers = Arrays.asList(5, 3, 8, 1, 9); Collections.sort(numbers); // sort in place int max = Collections.max(numbers); // find max Collections.shuffle(numbers); // random order List synced = Collections.synchronizedList(numbers);
Use streams appropriately. Streams are excellent for transformations and filtering but add overhead. Do not use them for simple iterations where a for-each loop is clearer.
// OVERKILL - stream adds nothing for a simple loop
users.stream().forEach(user -> user.sendEmail());
// BETTER - plain for-each is simpler
for (User user : users) {
user.sendEmail();
}
// GOOD USE OF STREAM - transformation, filtering, collecting
List activeEmails = users.stream()
.filter(User::isActive)
.map(User::getEmail)
.sorted()
.collect(Collectors.toList());
Every resource you open -- database connections, file handles, network sockets, thread pools -- must be closed. Failing to close resources causes memory leaks, connection pool exhaustion, and file handle starvation.
Always use try-with-resources (see Exception Handling section above). Here is a pattern for managing multiple resources:
// Read a file, process each line, write results to another file
public void processFile(Path input, Path output) throws IOException {
try (BufferedReader reader = Files.newBufferedReader(input);
BufferedWriter writer = Files.newBufferedWriter(output)) {
String line;
while ((line = reader.readLine()) != null) {
String processed = transform(line);
writer.write(processed);
writer.newLine();
}
}
// Both reader and writer are automatically closed here,
// even if an exception occurred
}
// For custom resources, implement AutoCloseable
public class ConnectionPool implements AutoCloseable {
private final List connections = new ArrayList<>();
public Connection acquire() { /* ... */ }
@Override
public void close() {
connections.forEach(conn -> {
try { conn.close(); } catch (SQLException e) {
log.warn("Failed to close connection", e);
}
});
connections.clear();
}
}
Java has evolved rapidly since Java 8. Using modern language features makes your code more concise, readable, and less error-prone. If your project supports Java 17 or later, take advantage of these features.
Local variable type inference -- var (Java 10+): Use var when the type is obvious from the right side. Do not use it when it obscures the type.
// GOOD uses of var - type is obvious from context var names = new ArrayList(); // clearly ArrayList var reader = new BufferedReader(new FileReader("data.txt")); var count = 0; // clearly int var entry = map.entrySet().iterator().next(); // avoids verbose generic type // BAD uses of var - type is unclear var result = process(); // what type is result? var data = getData(); // what type is data? var x = calculate(a, b); // impossible to understand without IDE
Records (Java 16+): Use records for simple data carriers. A record automatically generates the constructor, getters, equals(), hashCode(), and toString().
// BAD - 50 lines of boilerplate for a simple data class
public class Point {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() { return x; }
public int getY() { return y; }
@Override
public boolean equals(Object o) { /* 10 lines */ }
@Override
public int hashCode() { return Objects.hash(x, y); }
@Override
public String toString() { return "Point[x=" + x + ", y=" + y + "]"; }
}
// GOOD - one line with Java 16+ records
public record Point(int x, int y) { }
// Records can have validation and custom methods
public record EmailAddress(String value) {
public EmailAddress {
// Compact constructor for validation
if (value == null || !value.contains("@")) {
throw new IllegalArgumentException("Invalid email: " + value);
}
}
}
Sealed classes (Java 17+): Restrict which classes can extend a type. This enables exhaustive pattern matching in switch expressions.
// Sealed class - only these three can extend Shape
public sealed interface Shape
permits Circle, Rectangle, Triangle {
}
public record Circle(double radius) implements Shape { }
public record Rectangle(double width, double height) implements Shape { }
public record Triangle(double base, double height) implements Shape { }
// The compiler KNOWS all subtypes, so switch can be exhaustive (Java 21+)
public double area(Shape shape) {
return switch (shape) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.width() * r.height();
case Triangle t -> 0.5 * t.base() * t.height();
// No default needed -- compiler verifies all cases are covered
};
}
Switch expressions (Java 14+): Switch can now return values and use arrow syntax to eliminate fall-through bugs.
// BAD - traditional switch with fall-through risk
String dayType;
switch (day) {
case MONDAY:
case TUESDAY:
case WEDNESDAY:
case THURSDAY:
case FRIDAY:
dayType = "Weekday";
break; // forget this break and you get a bug
case SATURDAY:
case SUNDAY:
dayType = "Weekend";
break;
default:
dayType = "Unknown";
}
// GOOD - switch expression (Java 14+), no fall-through possible
String dayType = switch (day) {
case MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY -> "Weekday";
case SATURDAY, SUNDAY -> "Weekend";
};
Pattern matching for instanceof (Java 16+): Eliminates the redundant cast after an instanceof check.
// BAD - old way: check instanceof, then cast
if (obj instanceof String) {
String s = (String) obj; // redundant cast
System.out.println(s.length());
}
// GOOD - pattern matching (Java 16+): check and cast in one step
if (obj instanceof String s) {
System.out.println(s.length());
}
// Works great with negation too
if (!(obj instanceof String s)) {
return; // early exit
}
// s is in scope here
System.out.println(s.toUpperCase());
Streams and lambdas are powerful tools for functional-style programming in Java. Used well, they make code concise and declarative. Used poorly, they create unreadable chains that are harder to debug than simple loops.
When to use streams:
When NOT to use streams:
// BAD - overly complex stream, hard to read and debug Map> result = orders.stream() .filter(o -> o.getStatus() != null && o.getStatus().equals("ACTIVE") && o.getTotal() != null && o.getTotal().compareTo(BigDecimal.ZERO) > 0) .sorted(Comparator.comparing(Order::getCreatedAt).reversed()) .map(o -> new OrderDTO(o.getId(), o.getCustomer().getName(), o.getItems().stream().map(Item::getName).collect(Collectors.joining(", ")), o.getTotal())) .collect(Collectors.groupingBy(OrderDTO::customerName)); // GOOD - break it into readable steps List activeOrders = orders.stream() .filter(this::isActiveWithPositiveTotal) .sorted(Comparator.comparing(Order::getCreatedAt).reversed()) .toList(); Map > result = activeOrders.stream() .map(this::toOrderDTO) .collect(Collectors.groupingBy(OrderDTO::customerName)); // Helper methods make it self-documenting private boolean isActiveWithPositiveTotal(Order order) { return "ACTIVE".equals(order.getStatus()) && order.getTotal() != null && order.getTotal().compareTo(BigDecimal.ZERO) > 0; } private OrderDTO toOrderDTO(Order order) { String itemNames = order.getItems().stream() .map(Item::getName) .collect(Collectors.joining(", ")); return new OrderDTO(order.getId(), order.getCustomer().getName(), itemNames, order.getTotal()); }
Prefer method references over lambdas when the lambda simply delegates to an existing method:
// OK - lambda names.stream().map(name -> name.toUpperCase()).collect(Collectors.toList()); names.stream().filter(name -> name.isEmpty()).count(); // BETTER - method reference (cleaner, less noise) names.stream().map(String::toUpperCase).collect(Collectors.toList()); names.stream().filter(String::isEmpty).count(); // More method reference examples users.stream().map(User::getEmail)... // instance method numbers.stream().forEach(System.out::println); // static method names.stream().map(String::new)... // constructor
Avoid side effects in streams. Streams are designed for functional-style operations. Modifying external state inside a stream is confusing and can cause bugs with parallel streams.
// BAD - side effects: modifying external list inside a stream Listresults = new ArrayList<>(); names.stream() .filter(n -> n.length() > 3) .forEach(n -> results.add(n)); // mutating external state // GOOD - collect into a new list (no side effects) List results = names.stream() .filter(n -> n.length() > 3) .collect(Collectors.toList()); // Or with Java 16+ List results = names.stream() .filter(n -> n.length() > 3) .toList();
Code without tests is legacy code the moment it is written. These practices ensure your code is testable and that your tests are useful.
Testability is a design quality. If your code is hard to test, that is a sign of poor design. The following principles make code easy to test:
Use dependency injection. Classes that create their own dependencies are impossible to test in isolation.
// BAD - untestable: creates its own dependencies
public class OrderProcessor {
private final PaymentGateway gateway = new StripePaymentGateway();
private final EmailService email = new SmtpEmailService();
public void process(Order order) {
gateway.charge(order.getTotal()); // hits real Stripe API in tests!
email.send(order.getEmail(), "Order confirmed"); // sends real emails!
}
}
// GOOD - testable: dependencies are injected
public class OrderProcessor {
private final PaymentGateway gateway;
private final EmailService email;
public OrderProcessor(PaymentGateway gateway, EmailService email) {
this.gateway = gateway;
this.email = email;
}
public void process(Order order) {
gateway.charge(order.getTotal());
email.send(order.getEmail(), "Order confirmed");
}
}
// In tests, inject fakes or mocks:
@Test
void shouldChargeAndSendEmail() {
var fakeGateway = new FakePaymentGateway();
var fakeEmail = new FakeEmailService();
var processor = new OrderProcessor(fakeGateway, fakeEmail);
processor.process(testOrder);
assertTrue(fakeGateway.wasCharged());
assertEquals("Order confirmed", fakeEmail.getLastMessage());
}
Keep methods small and focused. A method that does one thing is easy to test. A method that does five things requires a combinatorial explosion of test cases.
Use meaningful test names that describe the scenario and expected behavior:
// BAD - test names that tell you nothing when they fail
@Test void test1() { ... }
@Test void testProcess() { ... }
@Test void testUser() { ... }
// GOOD - test names describe scenario and expected behavior
@Test void withdraw_withSufficientFunds_shouldReduceBalance() { ... }
@Test void withdraw_withInsufficientFunds_shouldThrowException() { ... }
@Test void withdraw_withNegativeAmount_shouldThrowIllegalArgument() { ... }
@Test void findByEmail_withNonexistentEmail_shouldReturnEmpty() { ... }
// ALSO GOOD - Given/When/Then style
@Test void givenInactiveUser_whenLogin_thenAccountLockedExceptionThrown() { ... }
Avoid static methods for business logic. Static methods cannot be overridden, mocked, or injected. They couple your code to a specific implementation.
// BAD - static utility for business logic is untestable in callers
public class PricingUtils {
public static BigDecimal calculateDiscount(Order order) {
// Complex business logic tied to a static method
// Callers cannot replace this in tests
}
}
// GOOD - instance method on an injectable service
public class PricingService {
public BigDecimal calculateDiscount(Order order) {
// Same logic, but now callers can inject a fake
}
}
// Static methods are fine for pure utility functions with no side effects:
public final class StringUtils {
public static boolean isBlank(String s) {
return s == null || s.trim().isEmpty();
}
}
Logging is your primary tool for understanding what your application does in production. Poor logging means you are flying blind when things go wrong.
Use SLF4J as the logging facade. SLF4J lets you swap the underlying implementation (Logback, Log4j2) without changing application code.
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class PaymentService {
// One logger per class, declared as a static final field
private static final Logger log = LoggerFactory.getLogger(PaymentService.class);
public PaymentResult processPayment(PaymentRequest request) {
log.info("Processing payment for order {}", request.getOrderId());
try {
PaymentResult result = gateway.charge(request);
log.info("Payment successful: orderId={}, transactionId={}",
request.getOrderId(), result.getTransactionId());
return result;
} catch (PaymentDeclinedException e) {
log.warn("Payment declined for order {}: {}",
request.getOrderId(), e.getReason());
throw e;
} catch (Exception e) {
log.error("Payment processing failed for order {}",
request.getOrderId(), e); // exception as last arg logs stack trace
throw new PaymentProcessingException("Failed to process payment", e);
}
}
}
Use the correct log levels:
| Level | When to Use | Example |
|---|---|---|
ERROR |
Something failed that requires attention | Database connection lost, payment failed |
WARN |
Something unexpected but the system recovered | Retry succeeded, fallback used, deprecated API called |
INFO |
Significant business events | User registered, order placed, payment processed |
DEBUG |
Detailed flow information for troubleshooting | Method entry/exit, intermediate values |
TRACE |
Very detailed debugging (rarely used) | Loop iterations, full request/response bodies |
Use parameterized logging. Never concatenate strings in log statements. With parameterized logging, the string is only built if the log level is enabled.
// BAD - string concatenation happens even if DEBUG is disabled
log.debug("Processing user " + user.getName() + " with id " + user.getId());
// GOOD - parameterized: {} placeholders are only resolved if level is enabled
log.debug("Processing user {} with id {}", user.getName(), user.getId());
// BAD - checking log level AND using concatenation (defeats the purpose)
if (log.isDebugEnabled()) {
log.debug("Processing user " + user.getName());
}
// GOOD - parameterized logging handles the check internally
log.debug("Processing user {}", user.getName());
What to log:
What NOT to log:
How you organize your code determines how quickly developers can find what they need, understand the system architecture, and make changes without breaking unrelated features.
Package by feature, not by layer. The traditional approach of packaging by layer (controllers, services, repositories) scatters related code across the project. Packaging by feature keeps everything related together.
// BAD - package by layer (a change to "orders" touches 4 packages)
com.example.app
├── controller/
│ ├── OrderController.java
│ ├── UserController.java
│ └── ProductController.java
├── service/
│ ├── OrderService.java
│ ├── UserService.java
│ └── ProductService.java
├── repository/
│ ├── OrderRepository.java
│ ├── UserRepository.java
│ └── ProductRepository.java
└── model/
├── Order.java
├── User.java
└── Product.java
// GOOD - package by feature (everything about "orders" is together)
com.example.app
├── order/
│ ├── OrderController.java
│ ├── OrderService.java
│ ├── OrderRepository.java
│ ├── Order.java
│ └── OrderDTO.java
├── user/
│ ├── UserController.java
│ ├── UserService.java
│ ├── UserRepository.java
│ └── User.java
└── product/
├── ProductController.java
├── ProductService.java
├── ProductRepository.java
└── Product.java
Benefits of packaging by feature:
Keep classes focused (Single Responsibility Principle). A class should have one, and only one, reason to change. If you find yourself describing a class with "and" ("this class handles orders and sends emails and generates reports"), it has too many responsibilities.
Keep classes small. A class with 2000 lines is a "God class" that tries to do everything. Aim for classes under 200-300 lines. If a class is growing beyond that, look for responsibilities to extract.
Use the Builder pattern when a constructor has many parameters (more than 3-4). Builders are self-documenting and prevent parameter-ordering bugs.
// BAD - constructor with many parameters (which is email? which is phone?)
User user = new User("Alice", "Smith", "alice@example.com",
"555-1234", "123 Main St", "Springfield", "IL", "62701", true, false);
// GOOD - Builder pattern: every parameter is labeled
User user = User.builder()
.firstName("Alice")
.lastName("Smith")
.email("alice@example.com")
.phone("555-1234")
.address(Address.builder()
.street("123 Main St")
.city("Springfield")
.state("IL")
.zipCode("62701")
.build())
.isActive(true)
.isAdmin(false)
.build();
Here is a clean Builder implementation:
public class User {
private final String firstName;
private final String lastName;
private final String email;
private User(Builder builder) {
this.firstName = Objects.requireNonNull(builder.firstName, "firstName required");
this.lastName = Objects.requireNonNull(builder.lastName, "lastName required");
this.email = Objects.requireNonNull(builder.email, "email required");
}
public static Builder builder() {
return new Builder();
}
// Getters...
public String getFirstName() { return firstName; }
public String getLastName() { return lastName; }
public String getEmail() { return email; }
public static class Builder {
private String firstName;
private String lastName;
private String email;
public Builder firstName(String firstName) {
this.firstName = firstName;
return this;
}
public Builder lastName(String lastName) {
this.lastName = lastName;
return this;
}
public Builder email(String email) {
this.email = email;
return this;
}
public User build() {
return new User(this);
}
}
}
Security is not something you bolt on at the end. It must be part of how you write every line of code. These practices protect your application from the most common vulnerabilities.
Always validate input. Never trust data from external sources -- user input, API responses, file uploads, URL parameters. Validate, sanitize, and constrain before processing.
// BAD - no input validation
public void createUser(String name, String email, int age) {
// Directly uses untrusted input
User user = new User(name, email, age);
repository.save(user);
}
// GOOD - validate everything
public void createUser(String name, String email, int age) {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("Name must not be blank");
}
if (name.length() > 100) {
throw new IllegalArgumentException("Name must not exceed 100 characters");
}
if (email == null || !EMAIL_PATTERN.matcher(email).matches()) {
throw new IllegalArgumentException("Invalid email format");
}
if (age < 0 || age > 150) {
throw new IllegalArgumentException("Age must be between 0 and 150");
}
User user = new User(name.trim(), email.toLowerCase().trim(), age);
repository.save(user);
}
private static final Pattern EMAIL_PATTERN =
Pattern.compile("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$");
Use parameterized queries. SQL injection is one of the most dangerous and preventable vulnerabilities. Never build SQL strings by concatenating user input.
// BAD - SQL injection vulnerability!
// If username is: ' OR '1'='1' -- , this returns ALL users
public User findUser(String username) {
String sql = "SELECT * FROM users WHERE username = '" + username + "'";
return jdbcTemplate.queryForObject(sql, userRowMapper);
}
// GOOD - parameterized query: input is treated as DATA, never as SQL
public User findUser(String username) {
String sql = "SELECT * FROM users WHERE username = ?";
return jdbcTemplate.queryForObject(sql, userRowMapper, username);
}
// GOOD - using JPA named parameters
@Query("SELECT u FROM User u WHERE u.username = :username")
Optional findByUsername(@Param("username") String username);
Never hardcode secrets. Passwords, API keys, database credentials, and tokens must never appear in source code. Use environment variables, secret managers, or configuration files that are excluded from version control.
// BAD - secrets in source code (visible in git history forever)
public class DatabaseConfig {
private static final String DB_PASSWORD = "MyS3cretP@ssw0rd";
private static final String API_KEY = "sk-1234567890abcdef";
}
// GOOD - read from environment variables
public class DatabaseConfig {
private final String dbPassword = System.getenv("DB_PASSWORD");
private final String apiKey = System.getenv("API_KEY");
public DatabaseConfig() {
if (dbPassword == null || dbPassword.isBlank()) {
throw new IllegalStateException(
"DB_PASSWORD environment variable is not set");
}
}
}
// GOOD - Spring Boot: use application.yml with environment variable references
// spring:
// datasource:
// password: ${DB_PASSWORD}
// API keys read from a vault or secrets manager in production
Apply the principle of least privilege. Give classes, methods, and fields the minimum visibility they need. Default to private and only widen access when there is a clear reason.
// BAD - everything is public (exposes internal implementation)
public class UserService {
public List userCache = new ArrayList<>(); // internal state exposed
public Connection dbConnection; // anyone can access this
public String hashPassword(String password) { ... } // internal method exposed
public void updateCache(User user) { ... } // internal method exposed
}
// GOOD - minimal visibility
public class UserService {
private final List userCache = new ArrayList<>();
private final DataSource dataSource;
public UserService(DataSource dataSource) {
this.dataSource = dataSource;
}
// Only public methods that form the API contract
public User findById(long id) { ... }
public void register(User user) { ... }
// Internal helpers are private
private String hashPassword(String password) { ... }
private void updateCache(User user) { ... }
}
Here is every best practice from this tutorial in one scannable table. Bookmark this page and use it as a checklist during code reviews.
| # | Best Practice | Do NOT | DO |
|---|---|---|---|
| 1 | Naming Conventions | int d;, String addr;, void calc(); |
int daysSinceCreation;, String address;, void calculateTotal(); |
| 2 | Code Formatting | No braces on if/for, inconsistent indentation | Always use braces, 4-space indent, IDE auto-format |
| 3 | Comments | // increment i by 1, comments that repeat code |
Comment the why, Javadoc for public APIs |
| 4 | SOLID Principles | God classes with 10 responsibilities | One class, one responsibility, depend on abstractions |
| 5 | Composition over Inheritance | class UserService extends Logger |
class UserService { private Logger logger; } |
| 6 | Program to Interfaces | ArrayList<String> names = new ArrayList<>(); |
List<String> names = new ArrayList<>(); |
| 7 | Immutability | Public mutable fields, expose internal lists | private final fields, defensive copies, List.copyOf() |
| 8 | Exception Handling | catch (Exception e) { } |
Catch specific exceptions, try-with-resources, fail fast |
| 9 | Null Safety | Return null for "not found" |
Return Optional, empty collections, Objects.requireNonNull() |
| 10 | String Handling | result += line in a loop |
StringBuilder, String.join(), text blocks |
| 11 | Collections | LinkedList for random access, no pre-sizing |
Right collection for the job, pre-size when known |
| 12 | Resource Management | Manual close in finally blocks | try-with-resources for all AutoCloseable objects |
| 13 | Modern Java Features | 50-line boilerplate POJOs | Records, sealed classes, pattern matching, switch expressions |
| 14 | Streams and Lambdas | 10-operation chain, side effects in streams | Break chains into methods, use method references, no side effects |
| 15 | Testable Code | new StripeGateway() inside the class |
Constructor injection, small methods, meaningful test names |
| 16 | Logging | log.debug("user " + name), inconsistent levels |
SLF4J with {} placeholders, correct log levels |
| 17 | Project Structure | Package by layer (controller/service/repo) |
Package by feature (order/user/product) |
| 18 | Class Design | 2000-line God class, 10-parameter constructors | Small focused classes, Builder pattern |
| 19 | Security | String concatenation in SQL, hardcoded passwords | Parameterized queries, environment variables, input validation |
Best practices are not rules carved in stone -- they are guidelines refined through decades of collective experience. There will be times when breaking a guideline makes sense, but you should always be able to articulate why you are breaking it.
Start by adopting these practices one at a time. Pick the ones that address your biggest pain points first. As they become habits, your code will become easier to read, safer to change, and more enjoyable to work with -- for you and for everyone on your team.
The best code is code that future developers (including future you) can understand, modify, and extend without fear.
Reformat Code
For Mac
⌥ ⌘ L
command + option + l
View Structure/Outline of Class
command + 7
The difference between a junior developer and a senior developer is often not just knowledge of design patterns or architecture — it is how fast they can translate ideas into code. IDE shortcuts are one of the biggest productivity multipliers available to you.
Consider this: every time you reach for the mouse to navigate a menu, find a file, or refactor a variable name, you lose 2-5 seconds. That does not sound like much, but a developer performs these actions hundreds of times per day. Over a year, those seconds add up to weeks of lost productivity.
Here is what changes when you master IDE shortcuts:
This tutorial covers shortcuts for the two most popular Java IDEs: Eclipse and IntelliJ IDEA. The shortcuts shown use Windows/Linux keybindings. On macOS, replace Ctrl with Cmd and Alt with Option unless noted otherwise.
Tip: Do not try to memorize all shortcuts at once. Pick 3-5 from this list, use them exclusively for a week until they become muscle memory, then add 3-5 more. Within a few months, you will be dramatically faster.
Navigation shortcuts let you jump to any part of the codebase instantly. These are the shortcuts you will use most frequently — master them first.
| Action | Eclipse | IntelliJ IDEA | Notes |
|---|---|---|---|
| Go to Class | Ctrl + Shift + T |
Ctrl + N |
Type any part of the class name. Supports CamelCase (e.g., “USC” finds UserServiceController) |
| Go to File / Resource | Ctrl + Shift + R |
Ctrl + Shift + N |
Opens any file — .xml, .properties, .html, .java, etc. |
| Go to Line Number | Ctrl + L |
Ctrl + G |
Jump to a specific line number in the current file |
| Go to Symbol / Method | Ctrl + O |
Ctrl + F12 |
Shows all methods and fields in the current class. Start typing to filter. |
| Go to Declaration | F3 or Ctrl + Click |
Ctrl + B or Ctrl + Click |
Jump to where a variable, method, or class is defined |
| Go to Implementation | Ctrl + T |
Ctrl + Alt + B |
From an interface method, jump to its implementations |
| Navigate Back | Alt + Left |
Ctrl + Alt + Left |
Go back to where you were before (like browser back button) |
| Navigate Forward | Alt + Right |
Ctrl + Alt + Right |
Undo a “navigate back” |
| Recent Files | Ctrl + E |
Ctrl + E |
Shows list of recently opened files (same in both IDEs) |
| Switch Between Tabs | Ctrl + Page Up / Down |
Alt + Left / Right |
Cycle through open editor tabs |
| Show Type Hierarchy | F4 |
Ctrl + H |
See the inheritance tree of a class |
| Search Everywhere | N/A | Double Shift |
IntelliJ-only: search classes, files, actions, settings — everything |
| Quick Open Type | Ctrl + Shift + T |
Ctrl + N |
Open any class by name, supports wildcard (*) patterns |
Pro tip: IntelliJ’s Double Shift (Search Everywhere) is arguably the single most powerful shortcut. It searches classes, files, methods, settings, actions, and even Git branches. If you only learn one IntelliJ shortcut, make it this one.
Pro tip: Eclipse’s Ctrl + Shift + T supports CamelCase shortcuts. Instead of typing “ApplicationContextConfiguration”, type “ACC” and it will find the class. This works in IntelliJ too with Ctrl + N.
These shortcuts make writing and rearranging code faster. Once you learn them, you will never manually copy/paste to move a line or type a comment prefix again.
| Action | Eclipse | IntelliJ IDEA | Notes |
|---|---|---|---|
| Duplicate Line | Ctrl + Alt + Down |
Ctrl + D |
Duplicates the current line or selected block below |
| Delete Line | Ctrl + D |
Ctrl + Y |
Deletes the entire current line (careful: same key, different action!) |
| Move Line Up | Alt + Up |
Alt + Shift + Up |
Moves the current line or selected block up |
| Move Line Down | Alt + Down |
Alt + Shift + Down |
Moves the current line or selected block down |
| Comment Line | Ctrl + / |
Ctrl + / |
Toggle // comment on current line or selection (same in both) |
| Block Comment | Ctrl + Shift + / |
Ctrl + Shift + / |
Wrap selection in /* */ block comment |
| Auto-Format Code | Ctrl + Shift + F |
Ctrl + Alt + L |
Reformats the entire file or selected code according to style rules |
| Auto-Import | Ctrl + Shift + O |
Ctrl + Alt + O |
Eclipse: organizes imports (adds missing, removes unused). IntelliJ: removes unused (auto-import adds on the fly) |
| Quick Fix / Suggestions | Ctrl + 1 |
Alt + Enter |
Shows context actions: create method, add import, fix error, etc. |
| Complete Code | Ctrl + Space |
Ctrl + Space |
Autocomplete methods, variables, types (same in both) |
| Smart Complete | N/A | Ctrl + Shift + Space |
IntelliJ-only: context-aware completion (filters by expected type) |
| Surround With | Alt + Shift + Z |
Ctrl + Alt + T |
Wrap selected code in try/catch, if/else, for loop, etc. |
| Select Word | Double-click |
Ctrl + W |
IntelliJ: expands selection progressively (word > statement > block > method > class) |
| Shrink Selection | N/A | Ctrl + Shift + W |
IntelliJ-only: reverse of Ctrl + W |
| Undo | Ctrl + Z |
Ctrl + Z |
Standard undo (same in both) |
| Redo | Ctrl + Y |
Ctrl + Shift + Z |
Note: Ctrl + Y in IntelliJ deletes a line, NOT redo! |
Warning: Notice that Ctrl + D does completely different things in Eclipse (delete line) versus IntelliJ (duplicate line). Similarly, Ctrl + Y is redo in Eclipse but delete line in IntelliJ. If you switch between IDEs, this will trip you up. IntelliJ offers an “Eclipse keymap” option (Settings > Keymap) that remaps IntelliJ shortcuts to match Eclipse, which can ease the transition.
Code generation shortcuts automatically create boilerplate code that would take minutes to write by hand. These are especially useful for Java, which requires a lot of boilerplate (constructors, getters/setters, equals, hashCode, toString).
| Action | Eclipse | IntelliJ IDEA | Notes |
|---|---|---|---|
| Generate Menu | Alt + Shift + S |
Alt + Insert |
Opens the generation menu with all options below |
| Generate Constructor | Alt + Shift + S then O |
Alt + Insert > Constructor |
Select which fields to include in the constructor |
| Generate Getters/Setters | Alt + Shift + S then R |
Alt + Insert > Getter and Setter |
Select fields to generate getters/setters for |
| Generate toString() | Alt + Shift + S then S |
Alt + Insert > toString() |
Creates a toString() method including selected fields |
| Generate equals() / hashCode() | Alt + Shift + S then H |
Alt + Insert > equals() and hashCode() |
Creates both methods based on selected fields |
| Override Methods | Alt + Shift + S then V |
Ctrl + O |
Override a method from a parent class |
| Implement Interface Methods | Alt + Shift + S then V |
Ctrl + I |
Implement abstract methods from an interface |
| Generate Delegate Methods | Alt + Shift + S then E |
Alt + Insert > Delegate Methods |
Delegate to a field (composition pattern) |
Here is an example of how quickly you can build a complete Java class using generation shortcuts:
// Step 1: Type the class with fields (the only part you type manually)
public class Employee {
private Long id;
private String firstName;
private String lastName;
private String email;
private double salary;
}
// Step 2: Place cursor inside the class body
// Press Alt + Insert (IntelliJ) or Alt + Shift + S (Eclipse)
// Select "Constructor" > choose all fields > OK
// Result: Constructor generated automatically
// Step 3: Press Alt + Insert again > select "Getter and Setter" > select all fields
// Result: 10 methods generated (5 getters + 5 setters)
// Step 4: Press Alt + Insert > "toString()" > select all fields
// Result: toString() generated
// Step 5: Press Alt + Insert > "equals() and hashCode()" > select id and email
// Result: Both methods generated
// Total time: ~15 seconds for a complete domain class
// Without shortcuts: 5-10 minutes of typing
Modern alternative: If you are on Java 16+, consider using records instead. A record auto-generates the constructor, getters, equals, hashCode, and toString in a single line:
// Java 16+ record -- replaces all the generated boilerplate above
public record Employee(Long id, String firstName, String lastName, String email, double salary) {}
// Constructor, getters (id(), firstName(), etc.), equals(), hashCode(), toString()
// are ALL auto-generated by the compiler
Refactoring shortcuts are what separate IDE users from text editor users. These shortcuts let you safely restructure code without manually finding and replacing text across files — the IDE understands Java syntax and only changes the correct references.
| Action | Eclipse | IntelliJ IDEA | Notes |
|---|---|---|---|
| Rename | Alt + Shift + R |
Shift + F6 |
Renames a variable, method, class, or package across ALL references in the project. The most important refactoring shortcut. |
| Extract Variable | Alt + Shift + L |
Ctrl + Alt + V |
Extracts a selected expression into a new local variable |
| Extract Constant | N/A (use menu) |
Ctrl + Alt + C |
Extracts a value into a static final constant |
| Extract Field | Alt + Shift + F |
Ctrl + Alt + F |
Extracts a value into an instance field |
| Extract Method | Alt + Shift + M |
Ctrl + Alt + M |
Extracts selected code into a new method. The IDE figures out the parameters and return type. |
| Extract Parameter | N/A (use menu) | Ctrl + Alt + P |
Converts a local variable or expression into a method parameter |
| Inline | Alt + Shift + I |
Ctrl + Alt + N |
Opposite of extract: replaces a variable/method with its value/body |
| Move Class | Alt + Shift + V |
F6 |
Move a class to a different package (updates all imports) |
| Change Method Signature | Alt + Shift + C |
Ctrl + F6 |
Add/remove/reorder parameters, change return type |
| Refactoring Menu | Alt + Shift + T |
Ctrl + Alt + Shift + T |
Shows all available refactoring options for the selected code |
Rename is the refactoring you will use most often. It is drastically safer than find-and-replace because the IDE understands Java:
// Before renaming: variable "name" is used in multiple places
public class UserService {
public User findUser(String name) { // parameter "name"
log.info("Searching for: " + name); // reference to "name"
User user = repository.findByName(name); // reference to "name"
if (user == null) {
throw new UserNotFoundException("User not found: " + name);
}
return user;
}
}
// Place cursor on "name" and press Shift + F6 (IntelliJ) or Alt + Shift + R (Eclipse)
// Type "username" and press Enter
// ALL references update simultaneously -- but "findByName" (different variable) is NOT changed
// After renaming:
public class UserService {
public User findUser(String username) { // renamed
log.info("Searching for: " + username); // renamed
User user = repository.findByName(username); // renamed (the argument)
if (user == null) {
throw new UserNotFoundException("User not found: " + username);
}
return user;
}
}
// The method name "findByName" was correctly left unchanged because
// it is a different identifier. Find-and-replace would have broken it.
Extract Method is the second most important refactoring. It takes a block of code, moves it into a new method, and replaces the original code with a call to the new method:
// Before: a long method doing too many things
public void processOrder(Order order) {
// Select these lines, then press Ctrl + Alt + M (IntelliJ) or Alt + Shift + M (Eclipse)
double subtotal = 0;
for (OrderItem item : order.getItems()) {
subtotal += item.getPrice() * item.getQuantity();
}
double tax = subtotal * 0.08;
double total = subtotal + tax;
order.setTotal(total);
// End selection
sendConfirmationEmail(order);
updateInventory(order);
}
// After: the IDE extracts it into a new method automatically
public void processOrder(Order order) {
calculateTotal(order); // extracted method call
sendConfirmationEmail(order);
updateInventory(order);
}
// The IDE generated this method with the correct parameter and logic
private void calculateTotal(Order order) {
double subtotal = 0;
for (OrderItem item : order.getItems()) {
subtotal += item.getPrice() * item.getQuantity();
}
double tax = subtotal * 0.08;
double total = subtotal + tax;
order.setTotal(total);
}
Debugging shortcuts let you step through code, inspect variables, and find bugs without constantly clicking buttons in the debug toolbar. These are essential for efficient debugging sessions.
| Action | Eclipse | IntelliJ IDEA | Notes |
|---|---|---|---|
| Toggle Breakpoint | Ctrl + Shift + B |
Ctrl + F8 |
Set or remove a breakpoint on the current line |
| Run in Debug Mode | F11 |
Shift + F9 |
Start the application in debug mode |
| Run (No Debug) | Ctrl + F11 |
Shift + F10 |
Run without debugging |
| Step Over | F6 |
F8 |
Execute the current line and move to the next (skip method internals) |
| Step Into | F5 |
F7 |
Enter into the method call on the current line |
| Step Out | F7 |
Shift + F8 |
Finish the current method and return to the caller |
| Resume | F8 |
F9 |
Continue execution until the next breakpoint |
| Run to Cursor | Ctrl + R |
Alt + F9 |
Run until execution reaches the line where your cursor is (temporary breakpoint) |
| Evaluate Expression | Ctrl + Shift + I |
Alt + F8 |
Evaluate any Java expression during debugging (inspect variables, call methods) |
| View Breakpoints | Breakpoints View | Ctrl + Shift + F8 |
See all breakpoints, add conditions, enable/disable |
| Stop | Ctrl + F2 |
Ctrl + F2 |
Terminate the running application (same in both) |
| Smart Step Into | N/A | Shift + F7 |
IntelliJ-only: when a line has multiple method calls, choose which one to step into |
Pro tip: In IntelliJ, you can set conditional breakpoints. Right-click on a breakpoint and enter a condition like user.getId() == 42. The breakpoint will only trigger when the condition is true. This is incredibly useful when debugging loops or processing lists of data.
Pro tip: In both IDEs, you can change variable values during debugging. In the Variables panel, right-click a variable and select “Set Value.” This lets you test different scenarios without restarting the application.
Search shortcuts help you find anything in your codebase. Whether you are looking for a method call, a string literal, or all usages of a class, these shortcuts get you there instantly.
| Action | Eclipse | IntelliJ IDEA | Notes |
|---|---|---|---|
| Find in Current File | Ctrl + F |
Ctrl + F |
Basic find in the current editor (same in both) |
| Find and Replace | Ctrl + H |
Ctrl + R |
Find and replace in current file. Supports regex. |
| Find in Path / Project | Ctrl + H (Search tab) |
Ctrl + Shift + F |
Search across entire project, with file filters and regex |
| Replace in Path / Project | Ctrl + H (Search tab) |
Ctrl + Shift + R |
Find and replace across entire project |
| Find Usages | Ctrl + Shift + G |
Alt + F7 |
Find all places where a class, method, or variable is used |
| Highlight Usages | Ctrl + Shift + G |
Ctrl + Shift + F7 |
IntelliJ highlights all usages in the current file |
| Find Next | Ctrl + K |
F3 or Enter |
Jump to next search result |
| Find Previous | Ctrl + Shift + K |
Shift + F3 |
Jump to previous search result |
| Search Everywhere | N/A | Double Shift |
IntelliJ-only: searches everything — classes, files, actions, settings, git branches |
| Find Action / Command | Ctrl + 3 |
Ctrl + Shift + A |
Search for any IDE action by name (useful when you forget a shortcut) |
Find Usages (Alt + F7 in IntelliJ / Ctrl + Shift + G in Eclipse) is one of the most valuable search tools. Before changing or deleting a method, use Find Usages to see every place it is called. This prevents you from breaking code you did not know about.
Pro tip: When you forget a shortcut, use Find Action (Ctrl + Shift + A in IntelliJ / Ctrl + 3 in Eclipse). Type what you want to do (e.g., “extract method”) and the IDE will show you the action along with its keyboard shortcut.
Both Eclipse (with EGit) and IntelliJ IDEA have built-in Git support. These shortcuts let you commit, push, pull, and review changes without leaving the IDE or opening a terminal.
| Action | Eclipse (EGit) | IntelliJ IDEA | Notes |
|---|---|---|---|
| Commit | Ctrl + # (Team menu) |
Ctrl + K |
Open the commit dialog with staged changes |
| Push | Team > Push | Ctrl + Shift + K |
Push committed changes to remote |
| Pull / Update | Team > Pull | Ctrl + T |
Pull latest changes from remote |
| Show Diff | Compare With > HEAD | Ctrl + D (in commit dialog) |
See what changed in a file compared to the last commit |
| Show History / Log | Team > Show in History | Alt + 9 |
View the Git log for a file or the entire project |
| Annotate / Blame | Team > Show Annotations | Right-click gutter > Annotate | See who wrote each line and when (git blame) |
| VCS Operations Menu | N/A | Alt + ` (backtick) |
IntelliJ-only: quick access to all VCS operations |
| Rollback / Revert | Replace With > HEAD | Ctrl + Alt + Z |
Revert selected file changes to the last committed version |
| Create Branch | Team > Switch To > New Branch | Git widget (bottom-right) or VCS menu | Both IDEs let you create and switch branches from the IDE |
Pro tip: IntelliJ’s commit dialog (Ctrl + K) lets you review every changed file, see the diff, write a commit message, and amend the previous commit — all in one place. You can also run code analysis before committing to catch issues.
Live templates (IntelliJ) and code templates (Eclipse) let you type a short abbreviation and expand it into a complete code snippet. Both IDEs come with built-in templates, and you can create your own.
| Abbreviation | Expansion | Eclipse | IntelliJ |
|---|---|---|---|
sout |
System.out.println(); |
sysout + Ctrl + Space |
sout + Tab |
souf |
System.out.printf(); |
N/A (manual) | souf + Tab |
soutv |
System.out.println("var = " + var); |
N/A | soutv + Tab |
main |
public static void main(String[] args) { } |
main + Ctrl + Space |
psvm + Tab |
fori |
for (int i = 0; i < ; i++) { } |
for + Ctrl + Space |
fori + Tab |
foreach |
for (Type item : collection) { } |
foreach + Ctrl + Space |
iter + Tab |
ifn |
if (var == null) { } |
N/A | ifn + Tab |
inn |
if (var != null) { } |
N/A | inn + Tab |
thr |
throw new |
N/A | thr + Tab |
St |
String |
N/A | St + Tab |
psf |
public static final |
N/A | psf + Tab |
psfs |
public static final String |
N/A | psfs + Tab |
IntelliJ also supports postfix completion, which is uniquely powerful. You type an expression first, then add a template suffix:
// Postfix completion (IntelliJ only) -- type the expression FIRST, then the template // .var -- creates a variable from an expression new ArrayList().var // press Tab --> List strings = new ArrayList<>(); // .if -- wraps in an if statement user != null.if // press Tab --> if (user != null) { } // .not -- negates a boolean isValid.not // press Tab --> !isValid // .for -- creates a for-each loop users.for // press Tab --> for (User user : users) { } // .fori -- creates an indexed for loop users.fori // press Tab --> for (int i = 0; i < users.size(); i++) { } // .null -- null check result.null // press Tab --> if (result == null) { } // .nn -- not-null check result.nn // press Tab --> if (result != null) { } // .try -- wraps in try-catch inputStream.read().try // press Tab --> try { inputStream.read(); } catch (Exception e) { } // .return -- adds return statement calculateTotal().return // press Tab --> return calculateTotal(); // .sout -- wraps in System.out.println result.sout // press Tab --> System.out.println(result);
IntelliJ: Go to Settings > Editor > Live Templates. Click the + button, define an abbreviation, write the template text, and set the context (Java, etc.).
Eclipse: Go to Window > Preferences > Java > Editor > Templates. Click "New", define a name, and write the template pattern.
Here are some useful custom templates you should create:
// Custom template: "log" -- creates a logger declaration
// Abbreviation: log
// Template:
private static final Logger log = LoggerFactory.getLogger($CLASS_NAME$.class);
// Set $CLASS_NAME$ to className() expression in IntelliJ
// Custom template: "todo" -- creates a TODO comment with date
// Abbreviation: todo
// Template:
// TODO ($USER$ - $DATE$): $END$
// Set $USER$ to user(), $DATE$ to date(), $END$ is where cursor lands
// Custom template: "test" -- creates a JUnit test method
// Abbreviation: test
// Template:
@Test
void should$NAME$() {
// Given
$END$
// When
// Then
}
// Custom template: "builder" -- creates a builder pattern skeleton
// Abbreviation: builder
// Template:
public static class Builder {
public Builder $FIELD_NAME$($TYPE$ $PARAM$) {
this.$FIELD_NAME$ = $PARAM$;
return this;
}
public $CLASS_NAME$ build() {
return new $CLASS_NAME$(this);
}
}
Eclipse template variables: Eclipse uses ${cursor} for cursor position, ${enclosing_type} for the class name, ${word_selection} for selected text, and ${date} for the current date.
IntelliJ template variables: IntelliJ uses $END$ for cursor position and supports expressions like className(), date(), user(), suggestVariableName(), and completeSmart() for intelligent auto-fill.
Both IDEs allow you to customize any keyboard shortcut. This is useful when you want to match your muscle memory from another editor, fix conflicts with your OS, or assign shortcuts to frequently-used actions that have no default binding.
Window > Preferences > General > KeysSettings > KeymapIntelliJ provides pre-built keymaps that match other editors:
| Keymap | Matches | When to Use |
|---|---|---|
| IntelliJ IDEA Classic | IntelliJ defaults | New IntelliJ users or those who learned IntelliJ first |
| Eclipse | Eclipse keybindings | Developers transitioning from Eclipse to IntelliJ |
| VS Code | Visual Studio Code bindings | Developers coming from VS Code |
| NetBeans | NetBeans keybindings | Developers transitioning from NetBeans |
| macOS | macOS conventions (Cmd-based) | Mac users (default on macOS IntelliJ) |
Recommendation: If you are switching from Eclipse to IntelliJ, start with the Eclipse keymap to maintain productivity. Over time, gradually learn the IntelliJ-specific shortcuts (like Double Shift, postfix completion, and Ctrl + W for expanding selection) since they have no Eclipse equivalent and are genuinely more powerful.
If you take nothing else from this tutorial, learn these 20 shortcuts. They cover the actions you perform most frequently and will give you the biggest productivity boost. The shortcuts are ordered by how often a typical Java developer uses them.
| # | Action | Eclipse | IntelliJ IDEA | Why It Matters |
|---|---|---|---|---|
| 1 | Autocomplete | Ctrl + Space |
Ctrl + Space |
Fastest way to write code -- type 2-3 chars and let the IDE finish |
| 2 | Quick Fix | Ctrl + 1 |
Alt + Enter |
Fixes errors, adds imports, creates methods, suggests improvements |
| 3 | Go to Class | Ctrl + Shift + T |
Ctrl + N |
Jump to any class instantly instead of browsing the project tree |
| 4 | Go to Declaration | F3 |
Ctrl + B |
Jump to where something is defined -- essential for code exploration |
| 5 | Rename | Alt + Shift + R |
Shift + F6 |
Safely rename across the entire project in one action |
| 6 | Find in Project | Ctrl + H |
Ctrl + Shift + F |
Search for any text, regex, or pattern across all files |
| 7 | Format Code | Ctrl + Shift + F |
Ctrl + Alt + L |
Auto-format to maintain consistent code style |
| 8 | Organize Imports | Ctrl + Shift + O |
Ctrl + Alt + O |
Add missing imports and remove unused ones in one keystroke |
| 9 | Comment Line | Ctrl + / |
Ctrl + / |
Instantly comment/uncomment code for debugging or testing |
| 10 | Duplicate Line | Ctrl + Alt + Down |
Ctrl + D |
Copy a line without cut/paste -- great for repetitive code |
| 11 | Delete Line | Ctrl + D |
Ctrl + Y |
Remove an entire line without selecting it first |
| 12 | Move Line | Alt + Up/Down |
Alt + Shift + Up/Down |
Rearrange code without cut/paste |
| 13 | Find Usages | Ctrl + Shift + G |
Alt + F7 |
See everywhere a method/variable/class is used before changing it |
| 14 | Generate Code | Alt + Shift + S |
Alt + Insert |
Generate constructors, getters, setters, toString, equals, hashCode |
| 15 | Extract Method | Alt + Shift + M |
Ctrl + Alt + M |
Break long methods into smaller, readable pieces |
| 16 | Surround With | Alt + Shift + Z |
Ctrl + Alt + T |
Wrap code in try/catch, if/else, loop without manual typing |
| 17 | Navigate Back | Alt + Left |
Ctrl + Alt + Left |
Go back to where you were before (like browser back button) |
| 18 | Toggle Breakpoint | Ctrl + Shift + B |
Ctrl + F8 |
Set/remove breakpoints without clicking in the margin |
| 19 | Step Over (Debug) | F6 |
F8 |
Execute one line at a time during debugging |
| 20 | Search Everywhere | Ctrl + 3 |
Double Shift |
Find anything -- the ultimate "I do not know where this is" shortcut |
Here is a practical plan for learning these shortcuts:
Print this table and keep it next to your monitor. Every time you reach for the mouse to do one of these actions, stop, look at the table, and use the shortcut instead. Within a month, you will not need the table anymore.
IntelliJ IDEA has a built-in feature that tracks your shortcut usage. Go to Help > My Productivity (or Help > Productivity Guide in older versions). It shows you which shortcuts you use, which features save you the most time, and which actions you still perform the slow way. Use this to identify your next shortcuts to learn.
In Eclipse, press and hold Ctrl + Shift + L to see a popup of all available shortcuts. Press it again to open the Keys preferences where you can customize any shortcut. This is useful when you know what you want to do but cannot remember the key combination.
What are the basics of a system?
The picture below illustrates more less of what a production system made of.

Web browser and mobile app are clients of our system.
Load Balancer
A load balancer evenly distributes incoming traffic among web servers that are defined in a load-balanced set. Users connect to the public IP of the load balancer directly. With this setup, web servers are unreachable directly by clients anymore. For better security, private IPs are used for communication between servers. A private IP is an IP address reachable only between servers in the same network; however, it is unreachable over the internet. The load balancer communicates with web servers through private IPs.

By having more than one server, we successfully solved no failover issue and improved the availability of the web tier. Details are explained below:
Database
Database job is to store and retreive data. You can choose between a traditional relational database and a non-relational database. Sometimes this is not an easy decision as it depends on the requirements on your system. For most developers, relational databases are the best option because they have been around for over 40 years and historically, they have worked well. However, if relational databases are not suitable for your specific use cases, it is critical to explore beyond relational databases. Non-relational databases might be the right choice if:
Database vertical scaling vs horizontal scaling
Vertical scaling, referred to as “scale up”, means the process of adding more power (CPU, RAM, etc.) to your servers. Horizontal scaling, referred to as “scale-out”, allows you to scale by adding more servers into your pool of resources.
When traffic is low, vertical scaling is a great option, and the simplicity of vertical scaling is its main advantage. Unfortunately, it comes with serious limitations.
Vertical scaling, also known as scaling up, is the scaling by adding more power (CPU, RAM, DISK, etc.) to an existing machine. There are some powerful database servers. According to Amazon Relational Database Service (RDS) [12], you can get a database server with 24 TB of RAM. This kind of powerful database server could store and handle lots of data. For example, stackoverflow.com in 2013 had over 10 million monthly unique visitors, but it only had 1 master database [13]. However, vertical scaling comes with some serious drawbacks:
• You can add more CPU, RAM, etc. to your database server, but there are hardware limits. If you have a large user base, a single server is not enough.
• Greater risk of single point of failures.
• The overall cost of vertical scaling is high. Powerful servers are much more expensive.
Horizontal scaling is more desirable for large scale applications due to the limitations of vertical scaling.

Database replication
A master database generally only supports write operations. A slave database gets copies of the data from the master database and only supports read operations. All the data-modifying commands like insert, delete, or update must be sent to the master database. Most applications require a much higher ratio of reads to writes; thus, the number of slave databases in a system is usually larger than the number of master databases.

Advantages of database replication:
Cache
A cache is a temporary storage area that stores the result of expensive responses or frequently accessed data in memory so that subsequent requests are served more quickly. Every time a new web page loads, one or more database calls are executed to fetch data. The application performance is greatly affected by calling the database repeatedly. The cache can mitigate this problem.
The cache tier is a temporary data store layer, much faster than the database. The benefits of having a separate cache tier include better system performance, ability to reduce database workloads, and the ability to scale the cache tier independently.

After receiving a request, a web server first checks if the cache has the available response. If it has, it sends data back to the client. If not, it queries the database, stores the response in cache, and sends it back to the client. This caching strategy is called a read-through cache. Other caching strategies are available depending on the data type, size, and access patterns.
Cache considerations
Here are a few considerations for using a cache system:
CDN(Content Delivery Network)
A CDN is a network of geographically dispersed servers used to deliver static content. CDN servers cache static content like images, videos, CSS, JavaScript files, etc.
Dynamic content caching is a relatively new concept and beyond the scope of this book. It enables the caching of HTML pages that are based on request path, query strings, cookies, and request headers. Refer to the article mentioned in reference material [9] for more about this. This book focuses on how to use CDN to cache static content.
Here is how CDN works at the high-level: when a user visits a website, a CDN server closest to the user will deliver static content. Intuitively, the further users are from CDN servers, the slower the website loads. For example, if CDN servers are in San Francisco, users in Los Angeles will get content faster than users in Europe. Figure 1-9 is a great example that shows how CDN improves load time.


CDN considerations
Stateless web tier
Now it is time to consider scaling the web tier horizontally. For this, we need to move state (for instance user session data) out of the web tier. A good practice is to store session data in the persistent storage such as relational database or NoSQL. Each web server in the cluster can access state data from databases. This is called stateless web tier.

In this stateless architecture, HTTP requests from users can be sent to any web servers, which fetch state data from a shared data store. State data is stored in a shared data store and kept out of web servers. A stateless system is simpler, more robust, and scalable.
Here is the design with a stateless web tier.

Here we move the session data out of the web tier and store them in the persistent data store. The shared data store could be a relational database, Memcached/Redis, NoSQL, etc. The NoSQL data store is chosen as it is easy to scale. Autoscaling means adding or removing web servers automatically based on the traffic load. After the state data is removed out of web servers, auto-scaling of the web tier is easily achieved by adding or removing servers based on traffic load.
Your website grows rapidly and attracts a significant number of users internationally. To improve availability and provide a better user experience across wider geographical areas, supporting multiple data centers is crucial.
Stateful architecture
A stateful server and stateless server has some key differences. A stateful server remembers client data (state) from one request to the next. A stateless server keeps no state information.

user A’s session data and profile image are stored in Server 1. To authenticate User A, HTTP requests must be routed to Server 1. If a request is sent to other servers like Server 2, authentication would fail because Server 2 does not contain User A’s session data. Similarly, all HTTP requests from User B must be routed to Server 2; all requests from User C must be sent to Server 3.
The issue is that every request from the same client must be routed to the same server. This can be done with sticky sessions in most load balancers [10]; however, this adds the overhead. Adding or removing servers is much more difficult with this approach. It is also challenging to handle server failures.
Data Center

In normal operation, users are geoDNS-routed, also known as geo-routed, to the closest data center, with a split traffic of x% in US-East and (100 – x)% in US-West. geoDNS is a DNS service that allows domain names to be resolved to IP addresses based on the location of a user.
In the event of any significant data center outage, we direct all traffic to a healthy data center.
Several technical challenges must be resolved to achieve multi-data center setup:
Message Queue
A message queue is a durable component, stored in memory, that supports asynchronous communication. It serves as a buffer and distributes asynchronous requests. The basic architecture of a message queue is simple. Input services, called producers/publishers, create messages, and publish them to a message queue. Other services or servers, called consumers/subscribers, connect to the queue, and perform actions defined by the messages.

Decoupling makes the message queue a preferred architecture for building a scalable and reliable application. With the message queue, the producer can post a message to the queue when the consumer is unavailable to process it. The consumer can read messages from the queue even when the producer is unavailable.
The producer and the consumer can be scaled independently. When the size of the queue becomes large, more workers are added to reduce the processing time. However, if the queue is empty most of the time, the number of workers can be reduced.
Logging, metrics, automation
Logging: Monitoring error logs is important because it helps to identify errors and problems in the system. You can monitor error logs at per server level or use tools to aggregate them to a centralized service for easy search and viewing.
Metrics: Collecting different types of metrics help us to gain business insights and understand the health status of the system. Some of the following metrics are useful:
Automation: When a system gets big and complex, we need to build or leverage automation tools to improve productivity. Continuous integration is a good practice, in which each code check-in is verified through automation, allowing teams to detect problems early. Besides, automating your build, test, deploy process, etc. could improve developer productivity significantly.

Millions of users and beyond
Scaling a system is an iterative process. Iterating on what we have learned in this chapter could get us far. More fine-tuning and new strategies are needed to scale beyond millions of users. For example, you might need to optimize your system and decouple the system to even smaller services. All the techniques learned in this chapter should provide a good foundation to tackle new challenges. To conclude this chapter, we provide a summary of how we scale our system to support millions of users: