Java Advanced Features




Subscribe To Our Newsletter
You will receive our latest post and tutorial.
Thank you for subscribing!

required
required


Encryption and Decryption




1. What is Encryption?

Encryption is the process of converting readable data (plaintext) into an unreadable format (ciphertext) using a mathematical algorithm and a secret key. Only someone who has the correct key can reverse the process (decryption) and recover the original data.

Real-world analogy: Think of encryption like a lockbox. You put a letter (plaintext) inside a lockbox, close it with a key (encryption), and hand it to someone. Only the person with the matching key can open the box and read the letter (decryption). Anyone who intercepts the locked box sees nothing useful.

Encryption is critical in software development for several reasons:

  • Protecting sensitive data — passwords, credit card numbers, personal information, medical records
  • Securing communication — HTTPS, email encryption, messaging apps
  • Compliance — GDPR, HIPAA, PCI DSS all require data encryption
  • Data integrity — ensuring data has not been tampered with during transmission
  • Authentication — proving identity through digital signatures and certificates

There are two fundamental types of encryption:

Type Keys Used Speed Use Case Example Algorithms
Symmetric Encryption Same key for encrypt and decrypt Fast Encrypting large amounts of data AES, DES, ChaCha20
Asymmetric Encryption Public key encrypts, private key decrypts Slow Key exchange, digital signatures RSA, ECDSA, Ed25519

In practice, most systems use both together. For example, HTTPS uses asymmetric encryption to exchange a symmetric key, then uses that symmetric key to encrypt the actual data. This is called hybrid encryption.

Here is a simple visualization of the encryption process:

  • Encryption: Plaintext + Key + Algorithm = Ciphertext
  • Decryption: Ciphertext + Key + Algorithm = Plaintext

Throughout this tutorial, we will explore how Java provides all the tools you need to implement encryption, hashing, and message authentication in your applications.



2. Java Cryptography Architecture (JCA)

Java provides a comprehensive set of cryptographic APIs through the Java Cryptography Architecture (JCA) and the Java Cryptography Extension (JCE). These are part of the standard JDK, so you do not need any external libraries for core cryptographic operations.

Here are the key classes and packages you will use:

Class / Package Purpose Example Use
javax.crypto.Cipher Core encryption/decryption engine AES encryption, RSA encryption
javax.crypto.KeyGenerator Generate symmetric keys Generate AES-256 key
javax.crypto.SecretKey Represents a symmetric key AES key storage
javax.crypto.Mac Message Authentication Code HMAC-SHA256
java.security.KeyPairGenerator Generate public/private key pairs RSA key pair
java.security.MessageDigest Cryptographic hashing SHA-256 hash
java.security.SecureRandom Cryptographically secure random numbers Generating IVs, salts, tokens
java.security.Signature Digital signatures Sign and verify data
java.util.Base64 Encode/decode binary data as text Transmitting encrypted data as strings

The JCA uses a provider model. A provider is a package that implements cryptographic algorithms. The default provider is SunJCE, but you can plug in third-party providers like Bouncy Castle for additional algorithms. In most cases, the built-in providers are sufficient.

The general pattern for using JCA classes follows a consistent factory pattern:

import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import java.security.MessageDigest;
import java.security.SecureRandom;

public class JCAOverview {
    public static void main(String[] args) throws Exception {

        // 1. Cipher -- the core encryption engine
        // Pattern: Cipher.getInstance("Algorithm/Mode/Padding")
        Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
        System.out.println("Cipher algorithm: " + cipher.getAlgorithm());
        // Output: Cipher algorithm: AES/GCM/NoPadding

        // 2. KeyGenerator -- generates symmetric keys
        KeyGenerator keyGen = KeyGenerator.getInstance("AES");
        keyGen.init(256); // 256-bit AES key
        System.out.println("KeyGenerator algorithm: " + keyGen.getAlgorithm());
        // Output: KeyGenerator algorithm: AES

        // 3. MessageDigest -- hashing
        MessageDigest digest = MessageDigest.getInstance("SHA-256");
        System.out.println("MessageDigest algorithm: " + digest.getAlgorithm());
        // Output: MessageDigest algorithm: SHA-256

        // 4. SecureRandom -- cryptographically strong random numbers
        SecureRandom random = new SecureRandom();
        byte[] randomBytes = new byte[16];
        random.nextBytes(randomBytes);
        System.out.println("Generated " + randomBytes.length + " random bytes");
        // Output: Generated 16 random bytes

        // 5. List available providers
        for (java.security.Provider provider : java.security.Security.getProviders()) {
            System.out.println("Provider: " + provider.getName() + " v" + provider.getVersion());
        }
        // Output: Provider: SUN v22.0, Provider: SunJCE v22.0, ...
    }
}



3. Hashing

Hashing is a one-way process that converts data of any size into a fixed-length string of characters. Unlike encryption, hashing is not reversible — you cannot recover the original data from a hash. The same input always produces the same hash (deterministic), but even a tiny change in input produces a completely different hash (avalanche effect).

Important: Hashing is NOT encryption. Encryption is reversible (with the key); hashing is not. They serve different purposes.

Algorithm Output Size Secure? Use Case
MD5 128 bits (32 hex chars) No — broken, collisions found Checksums only (never for security)
SHA-1 160 bits (40 hex chars) No — deprecated Legacy systems only
SHA-256 256 bits (64 hex chars) Yes Data integrity, file verification
SHA-512 512 bits (128 hex chars) Yes High security requirements

Java provides hashing through the MessageDigest class:

import java.security.MessageDigest;
import java.nio.charset.StandardCharsets;

public class HashingExamples {
    public static void main(String[] args) throws Exception {

        String data = "Hello, World!";

        // SHA-256 hash
        String sha256Hash = hash(data, "SHA-256");
        System.out.println("SHA-256: " + sha256Hash);
        // Output: SHA-256: dffd6021bb2bd5b0af676290809ec3a53191dd81c7f70a4b28688a362182986f

        // SHA-512 hash
        String sha512Hash = hash(data, "SHA-512");
        System.out.println("SHA-512: " + sha512Hash);
        // Output: SHA-512: 374d794a95cdcfd8b35993185fef9ba368f160d8daf432d08ba9f1ed1e5abe6cc69291e0fa2fe0006a52570ef18c19def4e617c33ce52ef0a6e5fbe318cb0387

        // MD5 (DO NOT use for security purposes)
        String md5Hash = hash(data, "MD5");
        System.out.println("MD5:    " + md5Hash);
        // Output: MD5:    65a8e27d8879283831b664bd8b7f0ad4

        // Demonstrate avalanche effect -- tiny change, completely different hash
        String data2 = "Hello, World?"; // changed ! to ?
        String hash2 = hash(data2, "SHA-256");
        System.out.println("\nOriginal:  " + sha256Hash);
        System.out.println("Modified:  " + hash2);
        // Completely different hashes despite one character difference
    }

    public static String hash(String data, String algorithm) throws Exception {
        MessageDigest digest = MessageDigest.getInstance(algorithm);
        byte[] hashBytes = digest.digest(data.getBytes(StandardCharsets.UTF_8));
        return bytesToHex(hashBytes);
    }

    private static String bytesToHex(byte[] bytes) {
        StringBuilder hexString = new StringBuilder();
        for (byte b : bytes) {
            String hex = Integer.toHexString(0xff & b);
            if (hex.length() == 1) {
                hexString.append('0');
            }
            hexString.append(hex);
        }
        return hexString.toString();
    }
}

Hashing with Salt: When hashing passwords or sensitive data, you should always add a salt — a random value prepended to the input before hashing. This prevents attackers from using precomputed hash tables (rainbow tables) to crack your hashes.

import java.security.MessageDigest;
import java.security.SecureRandom;
import java.nio.charset.StandardCharsets;
import java.util.Base64;

public class SaltedHashing {
    public static void main(String[] args) throws Exception {

        String password = "mySecretPassword";

        // Generate a random salt (16 bytes = 128 bits)
        byte[] salt = generateSalt();
        System.out.println("Salt: " + Base64.getEncoder().encodeToString(salt));

        // Hash with salt
        String hashedPassword = hashWithSalt(password, salt);
        System.out.println("Hashed: " + hashedPassword);

        // Verify -- same password + same salt = same hash
        String verifyHash = hashWithSalt(password, salt);
        System.out.println("Verified: " + hashedPassword.equals(verifyHash));
        // Output: Verified: true

        // Different password = different hash
        String wrongHash = hashWithSalt("wrongPassword", salt);
        System.out.println("Wrong password match: " + hashedPassword.equals(wrongHash));
        // Output: Wrong password match: false
    }

    public static byte[] generateSalt() {
        byte[] salt = new byte[16];
        new SecureRandom().nextBytes(salt);
        return salt;
    }

    public static String hashWithSalt(String password, byte[] salt) throws Exception {
        MessageDigest digest = MessageDigest.getInstance("SHA-256");
        digest.update(salt); // add salt first
        byte[] hashBytes = digest.digest(password.getBytes(StandardCharsets.UTF_8));
        return Base64.getEncoder().encodeToString(hashBytes);
    }
}

Warning: Even salted SHA-256 is NOT sufficient for password hashing in production. SHA-256 is too fast — attackers can try billions of guesses per second. For passwords, use a dedicated password hashing algorithm like bcrypt, scrypt, or Argon2 (covered in Section 8).



4. Symmetric Encryption (AES)

Symmetric encryption uses the same key for both encryption and decryption. It is fast and efficient, making it the standard choice for encrypting data at rest and in transit.

AES (Advanced Encryption Standard) is the most widely used symmetric encryption algorithm. It was adopted by the U.S. government in 2001 and is considered secure with proper key sizes (128, 192, or 256 bits).

When using AES, you must choose a cipher mode and padding scheme. The mode determines how blocks of data are processed:

Mode Full Name IV Required? Authenticated? Recommendation
ECB Electronic Codebook No No NEVER use — identical blocks produce identical ciphertext, leaks patterns
CBC Cipher Block Chaining Yes No Acceptable but requires separate HMAC for authentication
CTR Counter Yes No Good for streaming, but no built-in authentication
GCM Galois/Counter Mode Yes Yes Recommended — provides both encryption AND authentication

Key Concepts:

  • IV (Initialization Vector) — a random value used to ensure the same plaintext does not produce the same ciphertext each time. The IV does not need to be secret, but it must be unique for each encryption with the same key.
  • Padding — block ciphers require input to be a multiple of the block size (128 bits for AES). Padding fills the gap. GCM mode does not need padding (use NoPadding).
  • Authentication Tag — GCM mode produces a tag that verifies the ciphertext has not been tampered with.

Let us start with AES key generation:

import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.util.Base64;

public class AESKeyGeneration {
    public static void main(String[] args) throws Exception {

        // Generate a new AES-256 key
        KeyGenerator keyGen = KeyGenerator.getInstance("AES");
        keyGen.init(256); // 128, 192, or 256 bits
        SecretKey secretKey = keyGen.generateKey();

        // Convert key to Base64 string (for storage)
        String encodedKey = Base64.getEncoder().encodeToString(secretKey.getEncoded());
        System.out.println("AES Key (Base64): " + encodedKey);
        System.out.println("Key length: " + secretKey.getEncoded().length + " bytes");
        // Output: Key length: 32 bytes (256 bits)

        // Reconstruct key from Base64 string
        byte[] decodedKey = Base64.getDecoder().decode(encodedKey);
        SecretKey reconstructedKey = new SecretKeySpec(decodedKey, "AES");
        System.out.println("Keys match: " + secretKey.equals(reconstructedKey));
        // Output: Keys match: true
    }
}

Now let us implement the recommended approach: AES-256-GCM. This mode provides both encryption and authentication (it detects if the ciphertext has been tampered with).

import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import java.security.SecureRandom;
import java.util.Base64;

public class AESGCMEncryption {

    private static final int GCM_IV_LENGTH = 12;     // 96 bits (recommended for GCM)
    private static final int GCM_TAG_LENGTH = 128;    // 128 bits authentication tag

    public static void main(String[] args) throws Exception {

        // Generate AES-256 key
        KeyGenerator keyGen = KeyGenerator.getInstance("AES");
        keyGen.init(256);
        SecretKey secretKey = keyGen.generateKey();

        String plaintext = "This is a secret message that needs to be encrypted.";
        System.out.println("Original:  " + plaintext);

        // Encrypt
        String encrypted = encrypt(plaintext, secretKey);
        System.out.println("Encrypted: " + encrypted);

        // Decrypt
        String decrypted = decrypt(encrypted, secretKey);
        System.out.println("Decrypted: " + decrypted);

        // Verify
        System.out.println("Match: " + plaintext.equals(decrypted));
        // Output: Match: true

        // Encrypting the same text again produces DIFFERENT ciphertext (because of random IV)
        String encrypted2 = encrypt(plaintext, secretKey);
        System.out.println("\nSame plaintext, different ciphertext:");
        System.out.println("Encryption 1: " + encrypted);
        System.out.println("Encryption 2: " + encrypted2);
        System.out.println("Same ciphertext? " + encrypted.equals(encrypted2));
        // Output: Same ciphertext? false (because IV is different each time)
    }

    public static String encrypt(String plaintext, SecretKey key) throws Exception {
        // Generate a random IV
        byte[] iv = new byte[GCM_IV_LENGTH];
        new SecureRandom().nextBytes(iv);

        // Initialize cipher for encryption
        Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
        GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
        cipher.init(Cipher.ENCRYPT_MODE, key, gcmSpec);

        // Encrypt
        byte[] ciphertext = cipher.doFinal(plaintext.getBytes("UTF-8"));

        // Prepend IV to ciphertext (IV is needed for decryption but is not secret)
        byte[] ivAndCiphertext = new byte[iv.length + ciphertext.length];
        System.arraycopy(iv, 0, ivAndCiphertext, 0, iv.length);
        System.arraycopy(ciphertext, 0, ivAndCiphertext, iv.length, ciphertext.length);

        return Base64.getEncoder().encodeToString(ivAndCiphertext);
    }

    public static String decrypt(String encryptedBase64, SecretKey key) throws Exception {
        byte[] ivAndCiphertext = Base64.getDecoder().decode(encryptedBase64);

        // Extract IV from the beginning
        byte[] iv = new byte[GCM_IV_LENGTH];
        System.arraycopy(ivAndCiphertext, 0, iv, 0, iv.length);

        // Extract ciphertext (rest of the bytes)
        byte[] ciphertext = new byte[ivAndCiphertext.length - iv.length];
        System.arraycopy(ivAndCiphertext, iv.length, ciphertext, 0, ciphertext.length);

        // Initialize cipher for decryption
        Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
        GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
        cipher.init(Cipher.DECRYPT_MODE, key, gcmSpec);

        // Decrypt
        byte[] plaintext = cipher.doFinal(ciphertext);
        return new String(plaintext, "UTF-8");
    }
}

For reference, here is AES-CBC encryption (still commonly used in legacy systems). Note that CBC mode requires a separate HMAC step for authentication, which is why GCM is preferred:

import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.IvParameterSpec;
import java.security.SecureRandom;
import java.util.Base64;

public class AESCBCEncryption {

    private static final int IV_SIZE = 16; // 128 bits for AES block size

    public static String encrypt(String plaintext, SecretKey key) throws Exception {
        // Generate random IV
        byte[] iv = new byte[IV_SIZE];
        new SecureRandom().nextBytes(iv);

        // Initialize cipher
        Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
        cipher.init(Cipher.ENCRYPT_MODE, key, new IvParameterSpec(iv));

        // Encrypt
        byte[] ciphertext = cipher.doFinal(plaintext.getBytes("UTF-8"));

        // Prepend IV to ciphertext
        byte[] result = new byte[iv.length + ciphertext.length];
        System.arraycopy(iv, 0, result, 0, iv.length);
        System.arraycopy(ciphertext, 0, result, iv.length, ciphertext.length);

        return Base64.getEncoder().encodeToString(result);
    }

    public static String decrypt(String encryptedBase64, SecretKey key) throws Exception {
        byte[] data = Base64.getDecoder().decode(encryptedBase64);

        // Extract IV
        byte[] iv = new byte[IV_SIZE];
        System.arraycopy(data, 0, iv, 0, iv.length);

        // Extract ciphertext
        byte[] ciphertext = new byte[data.length - iv.length];
        System.arraycopy(data, iv.length, ciphertext, 0, ciphertext.length);

        // Decrypt
        Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
        cipher.init(Cipher.DECRYPT_MODE, key, new IvParameterSpec(iv));

        return new String(cipher.doFinal(ciphertext), "UTF-8");
    }

    public static void main(String[] args) throws Exception {
        KeyGenerator keyGen = KeyGenerator.getInstance("AES");
        keyGen.init(256);
        SecretKey key = keyGen.generateKey();

        String message = "AES-CBC encryption example";
        String encrypted = encrypt(message, key);
        String decrypted = decrypt(encrypted, key);

        System.out.println("Original:  " + message);
        System.out.println("Encrypted: " + encrypted);
        System.out.println("Decrypted: " + decrypted);
        // Output:
        // Original:  AES-CBC encryption example
        // Encrypted: (Base64 string - different each time due to random IV)
        // Decrypted: AES-CBC encryption example
    }
}



5. Asymmetric Encryption (RSA)

Asymmetric encryption uses a pair of keys: a public key and a private key. Data encrypted with the public key can only be decrypted with the corresponding private key, and vice versa.

Real-world analogy: Think of a mailbox. Anyone can drop a letter into the mailbox (encrypt with the public key), but only the person with the mailbox key can open it and read the letters (decrypt with the private key).

RSA (Rivest-Shamir-Adleman) is the most widely used asymmetric algorithm. It is significantly slower than AES, so it is typically used for:

  • Key exchange — securely share a symmetric key over an insecure channel
  • Digital signatures — prove that data came from the expected sender and was not modified
  • Small data encryption — encrypt small pieces of data (RSA can only encrypt data smaller than the key size minus padding)
Feature Symmetric (AES) Asymmetric (RSA)
Keys 1 shared key 2 keys (public + private)
Speed Very fast ~100x slower
Key distribution Hard (must share secretly) Easy (public key is public)
Data size limit No limit Limited by key size
Use case Bulk data encryption Key exchange, signatures
import javax.crypto.Cipher;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.util.Base64;

public class RSAEncryption {
    public static void main(String[] args) throws Exception {

        // Generate RSA key pair (2048 bits minimum, 4096 for high security)
        KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
        keyGen.initialize(2048);
        KeyPair keyPair = keyGen.generateKeyPair();

        PublicKey publicKey = keyPair.getPublic();
        PrivateKey privateKey = keyPair.getPrivate();

        System.out.println("Public key format: " + publicKey.getFormat());
        // Output: Public key format: X.509
        System.out.println("Private key format: " + privateKey.getFormat());
        // Output: Private key format: PKCS#8

        // Encrypt with public key
        String plaintext = "Secret message for RSA";
        String encrypted = encrypt(plaintext, publicKey);
        System.out.println("Encrypted: " + encrypted);

        // Decrypt with private key
        String decrypted = decrypt(encrypted, privateKey);
        System.out.println("Decrypted: " + decrypted);
        // Output: Decrypted: Secret message for RSA
    }

    public static String encrypt(String plaintext, PublicKey publicKey) throws Exception {
        // Use RSA with OAEP padding (recommended over PKCS1)
        Cipher cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding");
        cipher.init(Cipher.ENCRYPT_MODE, publicKey);
        byte[] ciphertext = cipher.doFinal(plaintext.getBytes("UTF-8"));
        return Base64.getEncoder().encodeToString(ciphertext);
    }

    public static String decrypt(String encryptedBase64, PrivateKey privateKey) throws Exception {
        Cipher cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding");
        cipher.init(Cipher.DECRYPT_MODE, privateKey);
        byte[] plaintext = cipher.doFinal(Base64.getDecoder().decode(encryptedBase64));
        return new String(plaintext, "UTF-8");
    }
}

Digital Signatures

Digital signatures work in the opposite direction from encryption. You sign with your private key (proving you are the author) and anyone can verify with your public key (confirming the data was not modified).

import java.security.*;
import java.util.Base64;

public class DigitalSignatureExample {
    public static void main(String[] args) throws Exception {

        // Generate key pair
        KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
        keyGen.initialize(2048);
        KeyPair keyPair = keyGen.generateKeyPair();

        String message = "This document was signed by me.";

        // Sign with private key
        String signature = sign(message, keyPair.getPrivate());
        System.out.println("Signature: " + signature);

        // Verify with public key
        boolean isValid = verify(message, signature, keyPair.getPublic());
        System.out.println("Signature valid: " + isValid);
        // Output: Signature valid: true

        // Tamper with the message
        boolean isTamperedValid = verify("This document was NOT signed by me.", signature, keyPair.getPublic());
        System.out.println("Tampered message valid: " + isTamperedValid);
        // Output: Tampered message valid: false
    }

    public static String sign(String data, PrivateKey privateKey) throws Exception {
        Signature signer = Signature.getInstance("SHA256withRSA");
        signer.initSign(privateKey);
        signer.update(data.getBytes("UTF-8"));
        byte[] signatureBytes = signer.sign();
        return Base64.getEncoder().encodeToString(signatureBytes);
    }

    public static boolean verify(String data, String signatureBase64, PublicKey publicKey) throws Exception {
        Signature verifier = Signature.getInstance("SHA256withRSA");
        verifier.initVerify(publicKey);
        verifier.update(data.getBytes("UTF-8"));
        byte[] signatureBytes = Base64.getDecoder().decode(signatureBase64);
        return verifier.verify(signatureBytes);
    }
}



6. Base64 Encoding

Important: Base64 is NOT encryption. It is an encoding scheme that converts binary data into a text representation using 64 printable ASCII characters. Anyone can decode Base64 — there is no key, no secret, and no security. It is completely reversible by anyone.

Base64 is used when you need to transmit binary data (like encrypted bytes, images, or files) over channels that only support text (like JSON, XML, email, or URLs).

Java 8+ provides the java.util.Base64 class with three variants:

Encoder/Decoder Character Set Use Case
Base64.getEncoder() A-Z, a-z, 0-9, +, / General purpose (may include line breaks with MIME)
Base64.getUrlEncoder() A-Z, a-z, 0-9, -, _ URL-safe (no +, /, or =)
Base64.getMimeEncoder() Same as basic + line breaks Email attachments (MIME format)
import java.util.Base64;
import java.nio.charset.StandardCharsets;

public class Base64Examples {
    public static void main(String[] args) {

        String original = "Hello, Java Encryption!";

        // Basic encoding/decoding
        String encoded = Base64.getEncoder().encodeToString(original.getBytes(StandardCharsets.UTF_8));
        System.out.println("Encoded: " + encoded);
        // Output: Encoded: SGVsbG8sIEphdmEgRW5jcnlwdGlvbiE=

        String decoded = new String(Base64.getDecoder().decode(encoded), StandardCharsets.UTF_8);
        System.out.println("Decoded: " + decoded);
        // Output: Decoded: Hello, Java Encryption!

        // URL-safe encoding (replaces + with - and / with _)
        byte[] binaryData = {(byte) 0xFF, (byte) 0xFE, (byte) 0xFD, (byte) 0xFC};
        String urlSafe = Base64.getUrlEncoder().encodeToString(binaryData);
        System.out.println("URL-safe: " + urlSafe);
        // Output: URL-safe: __79_A==

        // URL-safe without padding (no trailing = signs)
        String urlSafeNoPadding = Base64.getUrlEncoder().withoutPadding().encodeToString(binaryData);
        System.out.println("URL-safe (no padding): " + urlSafeNoPadding);
        // Output: URL-safe (no padding): __79_A

        // Encoding binary data (like encrypted bytes) for storage as text
        byte[] encryptedBytes = new byte[]{0x12, 0x34, 0x56, 0x78, (byte) 0x9A, (byte) 0xBC};
        String storable = Base64.getEncoder().encodeToString(encryptedBytes);
        System.out.println("Binary as text: " + storable);
        // Output: Binary as text: EjRWeJq8

        // Decode back to original bytes
        byte[] restored = Base64.getDecoder().decode(storable);
        System.out.println("Bytes match: " + java.util.Arrays.equals(encryptedBytes, restored));
        // Output: Bytes match: true
    }
}



7. HMAC (Hash-based Message Authentication Code)

An HMAC is a specific type of message authentication code that combines a cryptographic hash function with a secret key. It provides two guarantees:

  • Data integrity — the data has not been modified in transit
  • Authenticity — the data came from someone who knows the secret key

Unlike a plain hash, an HMAC requires a secret key. This means an attacker cannot forge a valid HMAC without knowing the key. This is why HMACs are used in API authentication (like AWS request signing), JWT tokens, webhook verification, and session tokens.

Common HMAC algorithms:

Algorithm Output Size Use Case
HmacSHA256 256 bits Most common — API signing, JWTs, general purpose
HmacSHA384 384 bits Higher security requirement
HmacSHA512 512 bits Maximum security, slightly slower
HmacMD5 128 bits Do NOT use — MD5 is broken
import javax.crypto.Mac;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Base64;

public class HMACExample {
    public static void main(String[] args) throws Exception {

        // Generate a key for HMAC
        KeyGenerator keyGen = KeyGenerator.getInstance("HmacSHA256");
        SecretKey hmacKey = keyGen.generateKey();

        String message = "Order #12345: $99.99 charged to card ending in 4242";

        // Generate HMAC
        String hmac = generateHMAC(message, hmacKey);
        System.out.println("Message: " + message);
        System.out.println("HMAC:    " + hmac);

        // Verify HMAC -- same message + same key = same HMAC
        String verifyHmac = generateHMAC(message, hmacKey);
        System.out.println("Valid: " + hmac.equals(verifyHmac));
        // Output: Valid: true

        // Tampered message produces different HMAC
        String tamperedMessage = "Order #12345: $9999.99 charged to card ending in 4242";
        String tamperedHmac = generateHMAC(tamperedMessage, hmacKey);
        System.out.println("Tampered valid: " + hmac.equals(tamperedHmac));
        // Output: Tampered valid: false

        // Using a string-based key (common for API integrations)
        String apiSecret = "my-api-secret-key-do-not-share";
        SecretKeySpec stringKey = new SecretKeySpec(
            apiSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"
        );
        String apiHmac = generateHMAC("webhook-payload-data", stringKey);
        System.out.println("API HMAC: " + apiHmac);
    }

    public static String generateHMAC(String data, SecretKey key) throws Exception {
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(key);
        byte[] hmacBytes = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
        return Base64.getEncoder().encodeToString(hmacBytes);
    }
}

Practical use case: Webhook verification. When services like Stripe or GitHub send webhooks, they include an HMAC signature in the header. You recompute the HMAC using your secret key and compare it to the received signature. If they match, the webhook is legitimate.

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;

public class WebhookVerification {

    /**
     * Verify a webhook signature.
     * The sender computes HMAC-SHA256 of the body using a shared secret
     * and sends it in the X-Signature header.
     */
    public static boolean verifyWebhook(String payload, String receivedSignature, String secret)
            throws Exception {

        SecretKeySpec key = new SecretKeySpec(
            secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"
        );

        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(key);
        byte[] computedBytes = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));

        // Convert to hex string for comparison
        StringBuilder hex = new StringBuilder();
        for (byte b : computedBytes) {
            hex.append(String.format("%02x", b));
        }
        String computedSignature = hex.toString();

        // Use constant-time comparison to prevent timing attacks
        return java.security.MessageDigest.isEqual(
            computedSignature.getBytes(),
            receivedSignature.getBytes()
        );
    }

    public static void main(String[] args) throws Exception {
        String webhookBody = "{\"event\":\"payment.completed\",\"amount\":9999}";
        String sharedSecret = "whsec_test_secret_key";

        // Simulate: sender computes signature
        SecretKeySpec key = new SecretKeySpec(
            sharedSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"
        );
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(key);
        byte[] sigBytes = mac.doFinal(webhookBody.getBytes(StandardCharsets.UTF_8));
        StringBuilder sigHex = new StringBuilder();
        for (byte b : sigBytes) {
            sigHex.append(String.format("%02x", b));
        }

        // Receiver verifies
        boolean valid = verifyWebhook(webhookBody, sigHex.toString(), sharedSecret);
        System.out.println("Webhook signature valid: " + valid);
        // Output: Webhook signature valid: true

        // Tampered payload fails
        boolean tampered = verifyWebhook(
            "{\"event\":\"payment.completed\",\"amount\":0}",
            sigHex.toString(), sharedSecret
        );
        System.out.println("Tampered webhook valid: " + tampered);
        // Output: Tampered webhook valid: false
    }
}



8. Password Hashing

Password hashing is a special case that requires algorithms specifically designed to be slow. This might sound counterintuitive, but here is why:

  • SHA-256 can hash billions of values per second on modern GPUs
  • An attacker with a database of SHA-256 password hashes can try every common password in seconds
  • Dedicated password hashing algorithms (bcrypt, scrypt, Argon2) are intentionally slow and memory-intensive
  • They include a work factor (cost parameter) that can be increased as hardware gets faster
Approach Hashes/Second (GPU) Suitable for Passwords?
MD5 ~100 billion Absolutely not
SHA-256 ~10 billion No — too fast
SHA-256 + salt ~10 billion No — still too fast
bcrypt ~10,000 (tunable) Yes — recommended
scrypt ~1,000 (tunable) Yes — memory-hard
Argon2 ~100 (tunable) Yes — winner of PHC, best choice

bcrypt is the most widely used in Java applications. It automatically handles salt generation and embeds the salt in the output, so you do not need to manage salts separately.

Here is a bcrypt implementation using the jBCrypt library (or Spring Security’s BCryptPasswordEncoder):

// Using Spring Security's BCryptPasswordEncoder (most common in Java projects)
// Add dependency: org.springframework.security:spring-security-crypto

import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

public class BCryptPasswordExample {
    public static void main(String[] args) {

        BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(12); // work factor = 12

        String rawPassword = "MySecureP@ssw0rd!";

        // Hash the password (salt is generated automatically)
        String hashedPassword = encoder.encode(rawPassword);
        System.out.println("Hashed: " + hashedPassword);
        // Output: Hashed: $2a$12$LJ/3Xq... (60 characters, different each time)

        // Hashing the same password again gives a DIFFERENT hash (different salt)
        String hashedPassword2 = encoder.encode(rawPassword);
        System.out.println("Hashed again: " + hashedPassword2);
        System.out.println("Same hash? " + hashedPassword.equals(hashedPassword2));
        // Output: Same hash? false (because salt is different)

        // Verify password -- this is how you check login credentials
        boolean matches = encoder.matches(rawPassword, hashedPassword);
        System.out.println("Correct password: " + matches);
        // Output: Correct password: true

        boolean wrongMatch = encoder.matches("WrongPassword", hashedPassword);
        System.out.println("Wrong password: " + wrongMatch);
        // Output: Wrong password: false
    }
}

If you are not using Spring Security, here is a pure Java approach using PBKDF2 (Password-Based Key Derivation Function 2), which is built into the JDK:

import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import java.security.SecureRandom;
import java.util.Base64;

public class PBKDF2PasswordHashing {

    private static final int ITERATIONS = 210_000;  // OWASP recommendation (2023+)
    private static final int KEY_LENGTH = 256;       // bits
    private static final int SALT_LENGTH = 16;       // bytes

    public static void main(String[] args) throws Exception {

        String password = "MySecureP@ssw0rd!";

        // Hash the password
        String stored = hashPassword(password);
        System.out.println("Stored: " + stored);
        // Output: Stored: 210000:BASE64_SALT:BASE64_HASH

        // Verify
        boolean valid = verifyPassword(password, stored);
        System.out.println("Valid: " + valid);
        // Output: Valid: true

        boolean invalid = verifyPassword("WrongPassword", stored);
        System.out.println("Wrong password: " + invalid);
        // Output: Wrong password: false
    }

    public static String hashPassword(String password) throws Exception {
        // Generate random salt
        byte[] salt = new byte[SALT_LENGTH];
        new SecureRandom().nextBytes(salt);

        // Hash with PBKDF2
        PBEKeySpec spec = new PBEKeySpec(
            password.toCharArray(), salt, ITERATIONS, KEY_LENGTH
        );
        SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
        byte[] hash = factory.generateSecret(spec).getEncoded();

        // Store iterations:salt:hash (all needed to verify later)
        return ITERATIONS + ":"
            + Base64.getEncoder().encodeToString(salt) + ":"
            + Base64.getEncoder().encodeToString(hash);
    }

    public static boolean verifyPassword(String password, String stored) throws Exception {
        String[] parts = stored.split(":");
        int iterations = Integer.parseInt(parts[0]);
        byte[] salt = Base64.getDecoder().decode(parts[1]);
        byte[] expectedHash = Base64.getDecoder().decode(parts[2]);

        // Hash the input password with the same salt and iterations
        PBEKeySpec spec = new PBEKeySpec(
            password.toCharArray(), salt, iterations, expectedHash.length * 8
        );
        SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
        byte[] actualHash = factory.generateSecret(spec).getEncoded();

        // Constant-time comparison to prevent timing attacks
        return java.security.MessageDigest.isEqual(expectedHash, actualHash);
    }
}



9. Practical Examples

9.1 File Encryption and Decryption

Encrypting files follows the same AES pattern but processes data in streams to handle large files without loading everything into memory:

import javax.crypto.Cipher;
import javax.crypto.CipherInputStream;
import javax.crypto.CipherOutputStream;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import java.io.*;
import java.security.SecureRandom;

public class FileEncryption {

    private static final int GCM_IV_LENGTH = 12;
    private static final int GCM_TAG_LENGTH = 128;

    public static void encryptFile(File inputFile, File outputFile, SecretKey key)
            throws Exception {

        byte[] iv = new byte[GCM_IV_LENGTH];
        new SecureRandom().nextBytes(iv);

        Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
        cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_LENGTH, iv));

        try (FileOutputStream fos = new FileOutputStream(outputFile)) {
            // Write IV first (needed for decryption)
            fos.write(iv);

            try (FileInputStream fis = new FileInputStream(inputFile);
                 CipherOutputStream cos = new CipherOutputStream(fos, cipher)) {

                byte[] buffer = new byte[8192];
                int bytesRead;
                while ((bytesRead = fis.read(buffer)) != -1) {
                    cos.write(buffer, 0, bytesRead);
                }
            }
        }
    }

    public static void decryptFile(File inputFile, File outputFile, SecretKey key)
            throws Exception {

        try (FileInputStream fis = new FileInputStream(inputFile)) {
            // Read IV
            byte[] iv = new byte[GCM_IV_LENGTH];
            fis.read(iv);

            Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
            cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_LENGTH, iv));

            try (CipherInputStream cis = new CipherInputStream(fis, cipher);
                 FileOutputStream fos = new FileOutputStream(outputFile)) {

                byte[] buffer = new byte[8192];
                int bytesRead;
                while ((bytesRead = cis.read(buffer)) != -1) {
                    fos.write(buffer, 0, bytesRead);
                }
            }
        }
    }

    public static void main(String[] args) throws Exception {
        // Generate key
        KeyGenerator keyGen = KeyGenerator.getInstance("AES");
        keyGen.init(256);
        SecretKey key = keyGen.generateKey();

        // Create a test file
        File original = new File("secret_document.txt");
        try (FileWriter fw = new FileWriter(original)) {
            fw.write("This is my secret document with sensitive information.");
        }

        // Encrypt
        File encrypted = new File("secret_document.enc");
        encryptFile(original, encrypted, key);
        System.out.println("File encrypted: " + encrypted.getName());
        System.out.println("Original size: " + original.length() + " bytes");
        System.out.println("Encrypted size: " + encrypted.length() + " bytes");

        // Decrypt
        File decrypted = new File("secret_document_decrypted.txt");
        decryptFile(encrypted, decrypted, key);
        System.out.println("File decrypted: " + decrypted.getName());

        // Verify
        try (BufferedReader br = new BufferedReader(new FileReader(decrypted))) {
            System.out.println("Content: " + br.readLine());
        }
        // Output: Content: This is my secret document with sensitive information.

        // Cleanup
        original.delete();
        encrypted.delete();
        decrypted.delete();
    }
}

9.2 Secure Token Generation

Generating cryptographically secure tokens for session IDs, API keys, password reset links, and verification codes:

import java.security.SecureRandom;
import java.util.Base64;

public class SecureTokenGenerator {

    private static final SecureRandom secureRandom = new SecureRandom();

    /**
     * Generate a URL-safe token (for password reset links, API keys, etc.)
     */
    public static String generateToken(int byteLength) {
        byte[] tokenBytes = new byte[byteLength];
        secureRandom.nextBytes(tokenBytes);
        return Base64.getUrlEncoder().withoutPadding().encodeToString(tokenBytes);
    }

    /**
     * Generate a hex token (for session IDs, CSRF tokens)
     */
    public static String generateHexToken(int byteLength) {
        byte[] tokenBytes = new byte[byteLength];
        secureRandom.nextBytes(tokenBytes);
        StringBuilder hex = new StringBuilder();
        for (byte b : tokenBytes) {
            hex.append(String.format("%02x", b));
        }
        return hex.toString();
    }

    /**
     * Generate a numeric OTP (for SMS verification, 2FA)
     */
    public static String generateOTP(int digits) {
        int max = (int) Math.pow(10, digits);
        int otp = secureRandom.nextInt(max);
        return String.format("%0" + digits + "d", otp);
    }

    public static void main(String[] args) {
        // API key (32 bytes = 256 bits of entropy)
        System.out.println("API Key:     " + generateToken(32));
        // Output: API Key:     xK9mPq... (43 characters, URL-safe)

        // Session ID (16 bytes = 128 bits)
        System.out.println("Session ID:  " + generateHexToken(16));
        // Output: Session ID:  a1b2c3d4e5f6... (32 hex characters)

        // Password reset token (24 bytes = 192 bits)
        System.out.println("Reset Token: " + generateToken(24));
        // Output: Reset Token:  Mj7kL2... (32 characters, URL-safe)

        // 6-digit OTP
        System.out.println("OTP:         " + generateOTP(6));
        // Output: OTP:         384729

        // NEVER use java.util.Random for security tokens!
        // Random is predictable. SecureRandom uses OS entropy sources.
    }
}

9.3 Encrypting Sensitive Configuration

A common need is encrypting sensitive values in configuration files (database passwords, API keys) so they are not stored in plaintext:

import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;

public class ConfigEncryptor {

    private static final int GCM_IV_LENGTH = 12;
    private static final int GCM_TAG_LENGTH = 128;
    private static final String PREFIX = "ENC(";
    private static final String SUFFIX = ")";

    private final SecretKey key;

    public ConfigEncryptor(String base64Key) {
        byte[] keyBytes = Base64.getDecoder().decode(base64Key);
        this.key = new SecretKeySpec(keyBytes, "AES");
    }

    public String encryptValue(String plaintext) throws Exception {
        byte[] iv = new byte[GCM_IV_LENGTH];
        new SecureRandom().nextBytes(iv);

        Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
        cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_LENGTH, iv));
        byte[] ciphertext = cipher.doFinal(plaintext.getBytes("UTF-8"));

        byte[] combined = new byte[iv.length + ciphertext.length];
        System.arraycopy(iv, 0, combined, 0, iv.length);
        System.arraycopy(ciphertext, 0, combined, iv.length, ciphertext.length);

        return PREFIX + Base64.getEncoder().encodeToString(combined) + SUFFIX;
    }

    public String decryptValue(String encryptedValue) throws Exception {
        // Strip ENC(...) wrapper
        String base64 = encryptedValue.substring(PREFIX.length(), encryptedValue.length() - SUFFIX.length());
        byte[] combined = Base64.getDecoder().decode(base64);

        byte[] iv = new byte[GCM_IV_LENGTH];
        System.arraycopy(combined, 0, iv, 0, iv.length);
        byte[] ciphertext = new byte[combined.length - iv.length];
        System.arraycopy(combined, iv.length, ciphertext, 0, ciphertext.length);

        Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
        cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_LENGTH, iv));
        return new String(cipher.doFinal(ciphertext), "UTF-8");
    }

    public boolean isEncrypted(String value) {
        return value.startsWith(PREFIX) && value.endsWith(SUFFIX);
    }

    public static void main(String[] args) throws Exception {
        // In production, this key comes from environment variable or key vault
        // Generate once: Base64.getEncoder().encodeToString(KeyGenerator...generateKey().getEncoded())
        String masterKey = "k7Hn3pRqSvWxYz1A2bCdEfGhIjKlMnOp4qRsTuVw5X8=";

        ConfigEncryptor encryptor = new ConfigEncryptor(masterKey);

        // Encrypt sensitive config values
        Map config = new HashMap<>();
        config.put("db.url", "jdbc:mysql://localhost:3306/mydb");
        config.put("db.password", encryptor.encryptValue("SuperSecret123!"));
        config.put("api.key", encryptor.encryptValue("sk-abc123def456"));

        // Print config (safe to commit to version control)
        System.out.println("=== Encrypted Config ===");
        for (Map.Entry entry : config.entrySet()) {
            System.out.println(entry.getKey() + " = " + entry.getValue());
        }
        // Output:
        // db.url = jdbc:mysql://localhost:3306/mydb
        // db.password = ENC(base64encodeddata...)
        // api.key = ENC(base64encodeddata...)

        // At runtime, decrypt the values
        System.out.println("\n=== Decrypted at Runtime ===");
        for (Map.Entry entry : config.entrySet()) {
            String value = entry.getValue();
            if (encryptor.isEncrypted(value)) {
                value = encryptor.decryptValue(value);
            }
            System.out.println(entry.getKey() + " = " + value);
        }
        // Output:
        // db.url = jdbc:mysql://localhost:3306/mydb
        // db.password = SuperSecret123!
        // api.key = sk-abc123def456
    }
}



10. Common Mistakes

Cryptography is one of the easiest areas to get wrong in software development. A small mistake can make your encryption completely useless. Here are the most common mistakes Java developers make:

Mistake 1: Using ECB Mode

ECB (Electronic Codebook) mode encrypts each block independently. This means identical plaintext blocks produce identical ciphertext blocks, which leaks patterns in your data.

// WRONG -- ECB mode leaks patterns
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding"); // NEVER DO THIS
cipher.init(Cipher.ENCRYPT_MODE, key);

// Encrypting "AAAAAAAAAAAAAAAA" twice produces the SAME ciphertext
// An attacker can see which blocks are identical

// RIGHT -- Use GCM mode (provides encryption + authentication)
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
byte[] iv = new byte[12];
new SecureRandom().nextBytes(iv);
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(128, iv));

Mistake 2: Hardcoding Encryption Keys

// WRONG -- key is visible in source code and version control
private static final String SECRET_KEY = "MyHardcodedKey123";

// WRONG -- key is in a config file committed to Git
// application.properties: encryption.key=MyHardcodedKey123

// RIGHT -- key comes from environment variable
String key = System.getenv("ENCRYPTION_KEY");
if (key == null) {
    throw new IllegalStateException("ENCRYPTION_KEY environment variable not set");
}

// RIGHT -- key comes from a secrets manager (AWS Secrets Manager, HashiCorp Vault)
// String key = secretsManager.getSecret("encryption-key");

Mistake 3: Using Random Instead of SecureRandom

import java.util.Random;
import java.security.SecureRandom;

// WRONG -- java.util.Random is predictable (uses a linear congruential generator)
// If an attacker knows the seed, they can predict ALL future values
Random insecureRandom = new Random();
byte[] iv = new byte[16];
insecureRandom.nextBytes(iv); // PREDICTABLE IV -- defeats the purpose of encryption

// RIGHT -- SecureRandom uses OS entropy (e.g., /dev/urandom on Linux)
SecureRandom secureRandom = new SecureRandom();
byte[] safeIv = new byte[16];
secureRandom.nextBytes(safeIv); // Cryptographically unpredictable

Mistake 4: Using MD5 or SHA-1 for Security

// WRONG -- MD5 has known collision attacks since 2004
MessageDigest md5 = MessageDigest.getInstance("MD5");
byte[] hash = md5.digest(data.getBytes()); // Two different inputs can produce the same hash

// WRONG -- SHA-1 has been broken since 2017 (Google's SHAttered attack)
MessageDigest sha1 = MessageDigest.getInstance("SHA-1");

// RIGHT -- Use SHA-256 or SHA-512
MessageDigest sha256 = MessageDigest.getInstance("SHA-256");

// For passwords, do NOT use any SHA variant -- use bcrypt/scrypt/Argon2

Mistake 5: Not Using Authenticated Encryption

// WRONG -- AES-CBC without HMAC. An attacker can modify the ciphertext
// and you will decrypt garbage without knowing it was tampered with.
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
// ... encrypt ...
// No way to detect if ciphertext was modified!

// RIGHT -- AES-GCM provides authenticated encryption.
// If the ciphertext is modified, decryption throws AEADBadTagException.
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
// ... encrypt ...
// Decryption automatically verifies integrity via the authentication tag

Mistake 6: Reusing IVs with the Same Key

// WRONG -- Using a fixed IV
private static final byte[] FIXED_IV = new byte[12]; // all zeros
// Same key + same IV + different plaintexts = complete security failure in GCM mode

// WRONG -- Using a counter without proper management
int counter = 0;
byte[] iv = ByteBuffer.allocate(12).putInt(counter++).array();
// If the counter resets (app restart), you reuse IVs!

// RIGHT -- Always generate a random IV for each encryption
byte[] iv = new byte[12];
new SecureRandom().nextBytes(iv); // Fresh random IV every time

Mistake 7: Rolling Your Own Crypto

// WRONG -- Inventing your own "encryption" algorithm
public static String myEncrypt(String text) {
    StringBuilder result = new StringBuilder();
    for (char c : text.toCharArray()) {
        result.append((char) (c + 3)); // Caesar cipher -- trivially broken
    }
    return result.toString();
}

// WRONG -- XOR "encryption" with a repeated key
public static byte[] xorEncrypt(byte[] data, byte[] key) {
    byte[] result = new byte[data.length];
    for (int i = 0; i < data.length; i++) {
        result[i] = (byte) (data[i] ^ key[i % key.length]); // trivially broken
    }
    return result;
}

// RIGHT -- Use standard algorithms that have been analyzed by cryptographers
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
// AES has been analyzed by thousands of cryptographers since 1998
// and is approved for TOP SECRET government data



11. Best Practices

Here is a summary of what you should and should not do when implementing cryptography in Java:

Category Do Do Not
Symmetric Encryption Use AES-256-GCM Use ECB mode, DES, 3DES, or Blowfish
Asymmetric Encryption Use RSA-OAEP (2048+ bits) or ECDSA Use RSA-PKCS1v1.5 or RSA less than 2048 bits
Password Hashing Use bcrypt, scrypt, or Argon2 Use SHA-256, MD5, or any fast hash for passwords
General Hashing Use SHA-256 or SHA-512 Use MD5 or SHA-1 for security purposes
Random Numbers Use SecureRandom Use java.util.Random for crypto
Key Storage Use env vars, secrets manager, or key vault Hardcode keys in source code or config files
IV / Nonce Generate a fresh random IV for each encryption Reuse IVs or use fixed/predictable IVs
Authentication Use authenticated encryption (GCM) or HMAC Encrypt without verifying integrity
Algorithm Design Use standard, well-tested algorithms Invent your own encryption scheme
Comparison Use constant-time comparison for MACs/hashes Use String.equals() for crypto comparisons
Key Size AES-256, RSA-2048+, HMAC-SHA256 AES-128 (acceptable), RSA-1024 (broken)
Dependencies Keep crypto libraries updated Use deprecated or unmaintained crypto libraries

Additional guidance:

  • Never log plaintext secrets. Log the fact that encryption/decryption happened, but never log the actual passwords, keys, or sensitive data.
  • Wipe sensitive data from memory when done. Use char[] instead of String for passwords (strings are immutable and linger in memory). Call Arrays.fill(charArray, '\0') when done.
  • Use try-with-resources or finally blocks to ensure keys and sensitive byte arrays are zeroed out.
  • Rotate keys periodically. Have a plan for key rotation without downtime -- encrypt new data with new keys, re-encrypt old data during migration.
  • Test your crypto. Write unit tests that verify encryption round-trips correctly, that different plaintexts produce different ciphertexts, and that tampered ciphertext is rejected.



12. Complete Practical Example -- SecureVault

Let us bring everything together in a practical SecureVault class that provides the four most common cryptographic operations: encrypt/decrypt strings, hash passwords, generate secure tokens, and verify data integrity with HMAC.

This class is designed to be production-quality. You can drop it into your project and use it immediately.

import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.Mac;
import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.PBEKeySpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Base64;

/**
 * SecureVault -- a utility class for common cryptographic operations.
 *
 * Features:
 * - AES-256-GCM encryption/decryption (authenticated encryption)
 * - PBKDF2 password hashing with salt
 * - HMAC-SHA256 message authentication
 * - Cryptographically secure token generation
 * - SHA-256 hashing for data integrity
 */
public class SecureVault {

    private static final int GCM_IV_LENGTH = 12;       // 96 bits
    private static final int GCM_TAG_LENGTH = 128;      // 128 bits
    private static final int PBKDF2_ITERATIONS = 210_000;
    private static final int PBKDF2_KEY_LENGTH = 256;   // bits
    private static final int SALT_LENGTH = 16;           // bytes

    private final SecureRandom secureRandom = new SecureRandom();

    // ===== ENCRYPTION / DECRYPTION (AES-256-GCM) =====

    /**
     * Generate a new AES-256 encryption key.
     */
    public SecretKey generateEncryptionKey() throws Exception {
        KeyGenerator keyGen = KeyGenerator.getInstance("AES");
        keyGen.init(256);
        return keyGen.generateKey();
    }

    /**
     * Reconstruct a key from its Base64-encoded form.
     */
    public SecretKey keyFromBase64(String base64Key) {
        byte[] keyBytes = Base64.getDecoder().decode(base64Key);
        return new SecretKeySpec(keyBytes, "AES");
    }

    /**
     * Export a key as a Base64 string (for secure storage).
     */
    public String keyToBase64(SecretKey key) {
        return Base64.getEncoder().encodeToString(key.getEncoded());
    }

    /**
     * Encrypt a string using AES-256-GCM.
     * The IV is prepended to the ciphertext in the output.
     */
    public String encrypt(String plaintext, SecretKey key) throws Exception {
        byte[] iv = new byte[GCM_IV_LENGTH];
        secureRandom.nextBytes(iv);

        Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
        cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_LENGTH, iv));
        byte[] ciphertext = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));

        byte[] combined = new byte[iv.length + ciphertext.length];
        System.arraycopy(iv, 0, combined, 0, iv.length);
        System.arraycopy(ciphertext, 0, combined, iv.length, ciphertext.length);

        return Base64.getEncoder().encodeToString(combined);
    }

    /**
     * Decrypt a string that was encrypted with encrypt().
     * Throws AEADBadTagException if the ciphertext was tampered with.
     */
    public String decrypt(String encryptedBase64, SecretKey key) throws Exception {
        byte[] combined = Base64.getDecoder().decode(encryptedBase64);

        byte[] iv = Arrays.copyOfRange(combined, 0, GCM_IV_LENGTH);
        byte[] ciphertext = Arrays.copyOfRange(combined, GCM_IV_LENGTH, combined.length);

        Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
        cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_LENGTH, iv));
        byte[] plaintext = cipher.doFinal(ciphertext);

        return new String(plaintext, StandardCharsets.UTF_8);
    }

    // ===== PASSWORD HASHING (PBKDF2) =====

    /**
     * Hash a password using PBKDF2-HMAC-SHA256.
     * Returns: "iterations:base64Salt:base64Hash"
     */
    public String hashPassword(String password) throws Exception {
        byte[] salt = new byte[SALT_LENGTH];
        secureRandom.nextBytes(salt);

        byte[] hash = pbkdf2(password.toCharArray(), salt, PBKDF2_ITERATIONS, PBKDF2_KEY_LENGTH);

        return PBKDF2_ITERATIONS + ":"
            + Base64.getEncoder().encodeToString(salt) + ":"
            + Base64.getEncoder().encodeToString(hash);
    }

    /**
     * Verify a password against a stored hash.
     */
    public boolean verifyPassword(String password, String storedHash) throws Exception {
        String[] parts = storedHash.split(":");
        int iterations = Integer.parseInt(parts[0]);
        byte[] salt = Base64.getDecoder().decode(parts[1]);
        byte[] expectedHash = Base64.getDecoder().decode(parts[2]);

        byte[] actualHash = pbkdf2(password.toCharArray(), salt, iterations, expectedHash.length * 8);

        return MessageDigest.isEqual(expectedHash, actualHash);
    }

    private byte[] pbkdf2(char[] password, byte[] salt, int iterations, int keyLength)
            throws Exception {
        PBEKeySpec spec = new PBEKeySpec(password, salt, iterations, keyLength);
        SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
        return factory.generateSecret(spec).getEncoded();
    }

    // ===== HMAC (Message Authentication) =====

    /**
     * Generate an HMAC-SHA256 for data integrity verification.
     */
    public String hmac(String data, SecretKey key) throws Exception {
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(key);
        byte[] hmacBytes = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
        return Base64.getEncoder().encodeToString(hmacBytes);
    }

    /**
     * Verify an HMAC using constant-time comparison.
     */
    public boolean verifyHmac(String data, String expectedHmac, SecretKey key) throws Exception {
        String actualHmac = hmac(data, key);
        return MessageDigest.isEqual(
            actualHmac.getBytes(StandardCharsets.UTF_8),
            expectedHmac.getBytes(StandardCharsets.UTF_8)
        );
    }

    // ===== HASHING (SHA-256) =====

    /**
     * Hash data using SHA-256 (for data integrity, NOT passwords).
     */
    public String sha256(String data) throws Exception {
        MessageDigest digest = MessageDigest.getInstance("SHA-256");
        byte[] hashBytes = digest.digest(data.getBytes(StandardCharsets.UTF_8));
        return Base64.getEncoder().encodeToString(hashBytes);
    }

    // ===== TOKEN GENERATION =====

    /**
     * Generate a URL-safe random token.
     */
    public String generateToken(int byteLength) {
        byte[] tokenBytes = new byte[byteLength];
        secureRandom.nextBytes(tokenBytes);
        return Base64.getUrlEncoder().withoutPadding().encodeToString(tokenBytes);
    }

    /**
     * Generate a numeric OTP.
     */
    public String generateOTP(int digits) {
        int max = (int) Math.pow(10, digits);
        int otp = secureRandom.nextInt(max);
        return String.format("%0" + digits + "d", otp);
    }
}

Now let us use SecureVault in a complete application that demonstrates every feature:

import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;

public class SecureVaultDemo {
    public static void main(String[] args) throws Exception {

        SecureVault vault = new SecureVault();

        System.out.println("===== SecureVault Demo =====\n");

        // ---- 1. Encryption / Decryption ----
        System.out.println("--- 1. AES-256-GCM Encryption ---");
        SecretKey encKey = vault.generateEncryptionKey();
        String keyBase64 = vault.keyToBase64(encKey);
        System.out.println("Key (Base64): " + keyBase64);

        String secret = "My credit card number is 4111-1111-1111-1111";
        String encrypted = vault.encrypt(secret, encKey);
        System.out.println("Plaintext:  " + secret);
        System.out.println("Encrypted:  " + encrypted);

        String decrypted = vault.decrypt(encrypted, encKey);
        System.out.println("Decrypted:  " + decrypted);
        System.out.println("Match: " + secret.equals(decrypted));

        // Same plaintext, different ciphertext (random IV)
        String encrypted2 = vault.encrypt(secret, encKey);
        System.out.println("Same ciphertext? " + encrypted.equals(encrypted2));
        // Output: Same ciphertext? false

        // Reconstruct key from Base64
        SecretKey restoredKey = vault.keyFromBase64(keyBase64);
        String decrypted2 = vault.decrypt(encrypted, restoredKey);
        System.out.println("Decrypted with restored key: " + decrypted2);

        // ---- 2. Password Hashing ----
        System.out.println("\n--- 2. Password Hashing (PBKDF2) ---");
        String password = "S3cur3P@ssw0rd!";
        String hashedPassword = vault.hashPassword(password);
        System.out.println("Password: " + password);
        System.out.println("Hashed:   " + hashedPassword);

        boolean correctPassword = vault.verifyPassword(password, hashedPassword);
        System.out.println("Correct password: " + correctPassword);
        // Output: Correct password: true

        boolean wrongPassword = vault.verifyPassword("wrongpassword", hashedPassword);
        System.out.println("Wrong password: " + wrongPassword);
        // Output: Wrong password: false

        // ---- 3. HMAC ----
        System.out.println("\n--- 3. HMAC-SHA256 ---");
        SecretKey hmacKey = vault.generateEncryptionKey(); // can reuse AES key for HMAC
        String apiPayload = "{\"orderId\":\"12345\",\"amount\":99.99}";
        String signature = vault.hmac(apiPayload, hmacKey);
        System.out.println("Payload:   " + apiPayload);
        System.out.println("HMAC:      " + signature);

        boolean validHmac = vault.verifyHmac(apiPayload, signature, hmacKey);
        System.out.println("Valid HMAC: " + validHmac);
        // Output: Valid HMAC: true

        String tamperedPayload = "{\"orderId\":\"12345\",\"amount\":0.01}";
        boolean tamperedHmac = vault.verifyHmac(tamperedPayload, signature, hmacKey);
        System.out.println("Tampered HMAC valid: " + tamperedHmac);
        // Output: Tampered HMAC valid: false

        // ---- 4. SHA-256 Hashing ----
        System.out.println("\n--- 4. SHA-256 Hashing ---");
        String fileContent = "Important document content";
        String hash = vault.sha256(fileContent);
        System.out.println("Content: " + fileContent);
        System.out.println("SHA-256: " + hash);

        // Verify integrity
        String hash2 = vault.sha256(fileContent);
        System.out.println("Integrity check: " + hash.equals(hash2));
        // Output: Integrity check: true

        // ---- 5. Token Generation ----
        System.out.println("\n--- 5. Secure Token Generation ---");
        System.out.println("API Key (32 bytes):     " + vault.generateToken(32));
        System.out.println("Session Token (16 bytes): " + vault.generateToken(16));
        System.out.println("Reset Token (24 bytes):  " + vault.generateToken(24));
        System.out.println("6-digit OTP:            " + vault.generateOTP(6));
        System.out.println("4-digit OTP:            " + vault.generateOTP(4));

        // ---- 6. Tamper Detection ----
        System.out.println("\n--- 6. Tamper Detection (GCM) ---");
        String sensitiveData = "Transfer $10,000 to account 98765";
        String encryptedData = vault.encrypt(sensitiveData, encKey);
        System.out.println("Encrypted: " + encryptedData);

        try {
            // Simulate tampering: modify one character in the Base64 string
            char[] chars = encryptedData.toCharArray();
            chars[20] = (chars[20] == 'A') ? 'B' : 'A'; // flip a character
            String tampered = new String(chars);
            vault.decrypt(tampered, encKey); // This should throw an exception
            System.out.println("ERROR: Tampered data was accepted!");
        } catch (Exception e) {
            System.out.println("Tamper detected: " + e.getClass().getSimpleName());
            // Output: Tamper detected: AEADBadTagException
        }

        System.out.println("\n===== Demo Complete =====");
    }
}

The output of the complete demo:

===== SecureVault Demo =====

--- 1. AES-256-GCM Encryption ---
Key (Base64): kR7xN9pQ2sVwYz1A3bCdEfGhIjKlMnOp4qRsTuVw5X8=
Plaintext:  My credit card number is 4111-1111-1111-1111
Encrypted:  (Base64 encoded ciphertext -- different each time)
Decrypted:  My credit card number is 4111-1111-1111-1111
Match: true
Same ciphertext? false
Decrypted with restored key: My credit card number is 4111-1111-1111-1111

--- 2. Password Hashing (PBKDF2) ---
Password: S3cur3P@ssw0rd!
Hashed:   210000:BASE64_SALT:BASE64_HASH
Correct password: true
Wrong password: false

--- 3. HMAC-SHA256 ---
Payload:   {"orderId":"12345","amount":99.99}
HMAC:      (Base64 encoded HMAC)
Valid HMAC: true
Tampered HMAC valid: false

--- 4. SHA-256 Hashing ---
Content: Important document content
SHA-256: (Base64 encoded hash)
Integrity check: true

--- 5. Secure Token Generation ---
API Key (32 bytes):      xK9mPqR7sVwYz1A3bCdEfGhIjKlMnOp4qRsTuVw5X8
Session Token (16 bytes): a1B2c3D4e5F6g7H8i9J0kL
Reset Token (24 bytes):  Mj7kL2nP9qR3sT5uV8wX0yZ1a2B3c4D
6-digit OTP:             384729
4-digit OTP:             9152

--- 6. Tamper Detection (GCM) ---
Encrypted: (Base64 encoded ciphertext)
Tamper detected: AEADBadTagException

===== Demo Complete =====

Here is a summary of the concepts covered in this tutorial and where each appears in the SecureVault example:

# Concept Algorithm / Class Where Used
1 Symmetric Encryption AES-256-GCM encrypt(), decrypt()
2 Key Generation KeyGenerator generateEncryptionKey()
3 Key Serialization Base64, SecretKeySpec keyToBase64(), keyFromBase64()
4 Initialization Vector SecureRandom, GCMParameterSpec Random IV in encrypt()
5 Authenticated Encryption GCM authentication tag Tamper detection in demo section 6
6 Password Hashing PBKDF2-HMAC-SHA256 hashPassword(), verifyPassword()
7 Salt SecureRandom Random salt in hashPassword()
8 HMAC HmacSHA256, Mac hmac(), verifyHmac()
9 Constant-Time Comparison MessageDigest.isEqual() verifyPassword(), verifyHmac()
10 SHA-256 Hashing MessageDigest sha256()
11 Secure Token Generation SecureRandom generateToken(), generateOTP()
12 Base64 Encoding Base64 URL-safe encoder Throughout (key storage, ciphertext, tokens)

Quick Reference

Task Recommended Approach Java Classes
Encrypt data AES-256-GCM Cipher, KeyGenerator, GCMParameterSpec, SecureRandom
Hash passwords bcrypt or PBKDF2 BCryptPasswordEncoder or SecretKeyFactory + PBEKeySpec
Verify data integrity HMAC-SHA256 Mac, SecretKeySpec
Hash data (non-password) SHA-256 MessageDigest
Generate random tokens SecureRandom + Base64 SecureRandom, Base64.getUrlEncoder()
Exchange keys securely RSA-OAEP or ECDH KeyPairGenerator, Cipher
Sign data SHA256withRSA Signature, KeyPairGenerator
Encode binary as text Base64 Base64.getEncoder() / getUrlEncoder()
March 18, 2020

log4j

1. Why Logging?

Imagine you are a detective investigating a crime scene. Without evidence — fingerprints, security camera footage, witness statements — you would have no way to reconstruct what happened. Logging is the evidence trail for your application. It records what your program did, when it did it, and what went wrong.

Logging is the practice of recording messages from your application during runtime. These messages capture events, errors, state changes, and diagnostic information that help you understand your application’s behavior — especially when things go wrong in production at 3 AM and you cannot attach a debugger.

Why Not System.out.println?

Every Java developer starts with System.out.println() for debugging. It works, but it is the equivalent of using a flashlight when you need a full surveillance system. Here is why it falls short in real applications:

Feature System.out.println Logging Framework
Severity levels None — everything looks the same TRACE, DEBUG, INFO, WARN, ERROR
On/off control Must delete or comment out lines Change config file, no code changes
Output destination Console only Console, files, databases, remote servers
Timestamps Must add manually Automatic
Thread info Must add manually Automatic
Class/method info Must add manually Automatic
File rotation Not possible Automatic (e.g., daily, by size)
Performance Always executes string building Lazy evaluation, skip if level disabled
Production ready No Yes

What to Log

  • Application startup and shutdown — configuration loaded, services initialized, graceful shutdown
  • Business events — order placed, payment processed, user registered
  • Errors and exceptions — failed database connections, invalid input, timeout errors
  • Warnings — deprecated API usage, retry attempts, approaching resource limits
  • Performance data — request duration, query execution time, cache hit/miss ratios
  • External system interactions — API calls sent/received, database queries, message queue operations

What NOT to Log

  • Passwords — never log user passwords, even encrypted ones
  • Credit card numbers — PCI-DSS compliance requires masking (show only last 4 digits)
  • Social Security Numbers — personally identifiable information (PII)
  • API keys and secrets — attackers read log files too
  • Session tokens — could enable session hijacking
  • Medical or health data — HIPAA compliance

A simple rule: if you would not want it on a billboard, do not log it.

// BAD: System.out.println for debugging
public class BadDebugging {
    public void processOrder(Order order) {
        System.out.println("Processing order: " + order.getId());      // No timestamp
        System.out.println("Order total: " + order.getTotal());        // No severity
        System.out.println("Sending to payment...");                   // Cannot turn off
        // These println calls will clutter production logs forever
    }
}

// GOOD: Proper logging
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class GoodLogging {
    private static final Logger log = LoggerFactory.getLogger(GoodLogging.class);

    public void processOrder(Order order) {
        log.info("Processing order id={}, total={}", order.getId(), order.getTotal());
        log.debug("Sending order to payment gateway");
        // Output: 2026-02-28 10:15:32.451 [main] INFO  GoodLogging - Processing order id=12345, total=99.99
        // In production, DEBUG messages are automatically suppressed
    }
}

2. Java Logging Landscape

Java has multiple logging frameworks, which can be confusing for newcomers. Here is the landscape and how the pieces fit together:

Framework Type Description Status
java.util.logging (JUL) Implementation Built into the JDK since Java 1.4. No external dependencies needed. Active but rarely used in modern projects
Log4j 1.x Implementation Was the de facto standard for years. Uses log4j.properties or log4j.xml. END OF LIFE — Critical security vulnerability CVE-2021-44228. DO NOT USE.
Log4j 2 Implementation Complete rewrite of Log4j. Async logging, plugin architecture, modern design. Active, maintained by Apache
Logback Implementation Created by the founder of Log4j as its successor. Native SLF4J implementation. Active, default in Spring Boot
SLF4J Facade (API) Simple Logging Facade for Java. An abstraction layer — you code against SLF4J and swap implementations without changing code. Active, industry standard

The Facade Pattern: Why SLF4J Matters

Think of SLF4J like a universal remote control. You press the same buttons regardless of whether your TV is Samsung, LG, or Sony. Similarly, you write logging code using SLF4J’s API, and the actual logging is handled by whichever implementation (Logback, Log4j2) is on the classpath.

This means:

  • Your application code uses org.slf4j.Logger — never a specific implementation class
  • You can switch from Logback to Log4j2 by changing a Maven dependency — zero code changes
  • Libraries you depend on can use SLF4J too, and their logs funnel through your chosen implementation

Recommended Stack

For most Java applications in 2026, use: SLF4J (facade) + Logback (implementation). This is the default in Spring Boot and the most widely adopted combination. This tutorial will focus primarily on this stack, but we will also cover JUL and Log4j2.

3. Log Levels

Log levels let you categorize messages by severity. You can then configure your application to show only messages at or above a certain level — for example, showing everything in development but only WARN and ERROR in production.

SLF4J Log Levels (from least to most severe)

Level Purpose When to Use Example
TRACE Extremely detailed diagnostic information Step-by-step algorithm execution, variable values in loops, entering/exiting methods log.trace("Entering calculateTax with amount={}", amount)
DEBUG Detailed information useful during development SQL queries executed, cache hit/miss, intermediate calculation results, request/response payloads log.debug("Query returned {} rows in {}ms", count, elapsed)
INFO Important business or application events Application started, user logged in, order processed, scheduled job completed log.info("Order {} placed successfully by user {}", orderId, userId)
WARN Potentially harmful situations that are recoverable Retry attempts, deprecated API usage, approaching disk/memory limits, fallback to default log.warn("Payment gateway timeout, retrying (attempt {}/3)", attempt)
ERROR Serious failures that need attention Unhandled exceptions, failed database connections, data corruption, business rule violations that halt processing log.error("Failed to process payment for order {}", orderId, exception)

Level Hierarchy

Levels form a hierarchy. When you set the log level to a certain value, all messages at that level and above are logged. Messages below that level are suppressed.

Configured Level TRACE DEBUG INFO WARN ERROR
TRACE Yes Yes Yes Yes Yes
DEBUG No Yes Yes Yes Yes
INFO No No Yes Yes Yes
WARN No No No Yes Yes
ERROR No No No No Yes

Rule of thumb: Development uses DEBUG or TRACE. Production uses INFO (or WARN for very high-throughput systems). You should be able to understand what your application is doing from INFO logs alone.

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class LogLevelDemo {
    private static final Logger log = LoggerFactory.getLogger(LogLevelDemo.class);

    public void processPayment(String orderId, double amount) {
        log.trace("Entering processPayment(orderId={}, amount={})", orderId, amount);

        log.debug("Validating payment amount: {}", amount);

        if (amount <= 0) {
            log.warn("Invalid payment amount {} for order {}, using minimum $0.01", amount, orderId);
            amount = 0.01;
        }

        try {
            log.info("Processing payment of ${} for order {}", amount, orderId);
            // ... payment logic ...
            log.info("Payment successful for order {}", orderId);
        } catch (Exception e) {
            log.error("Payment failed for order {} with amount ${}", orderId, amount, e);
            // The exception 'e' is passed as the LAST argument -- SLF4J will print the full stack trace
        }

        log.trace("Exiting processPayment for order {}", orderId);
    }
}
// If level is set to INFO, output would be:
// 2026-02-28 10:30:00.123 [main] INFO  LogLevelDemo - Processing payment of $49.99 for order ORD-001
// 2026-02-28 10:30:00.456 [main] INFO  LogLevelDemo - Payment successful for order ORD-001
// (TRACE and DEBUG messages are suppressed)

4. java.util.logging (JUL)

Java includes a built-in logging framework in the java.util.logging package. It requires no external dependencies, which makes it a good starting point for learning and for simple applications where you want zero third-party libraries.

JUL Log Levels

JUL uses its own level names, which differ from SLF4J:

JUL Level SLF4J Equivalent Description
FINEST TRACE Highly detailed tracing
FINER TRACE Fairly detailed tracing
FINE DEBUG General debugging
CONFIG - Configuration info
INFO INFO Informational messages
WARNING WARN Potential problems
SEVERE ERROR Serious failures
import java.util.logging.Level;
import java.util.logging.Logger;

public class JulExample {
    // Create a logger named after the class
    private static final Logger logger = Logger.getLogger(JulExample.class.getName());

    public static void main(String[] args) {
        // Basic logging at different levels
        logger.info("Application starting");
        logger.warning("Configuration file not found, using defaults");
        logger.severe("Database connection failed!");

        // Parameterized logging (JUL uses {0}, {1} style -- not {} like SLF4J)
        String user = "alice";
        int loginAttempts = 3;
        logger.log(Level.INFO, "User {0} logged in after {1} attempts", new Object[]{user, loginAttempts});

        // Logging an exception
        try {
            int result = 10 / 0;
        } catch (ArithmeticException e) {
            logger.log(Level.SEVERE, "Division error occurred", e);
        }

        // Check if level is enabled before expensive operations
        if (logger.isLoggable(Level.FINE)) {
            logger.fine("Debug data: " + expensiveToString());
        }
    }

    private static String expensiveToString() {
        // Imagine this method is costly to call
        return "detailed debug information";
    }
}
// Output:
// Feb 28, 2026 10:45:00 AM JulExample main
// INFO: Application starting
// Feb 28, 2026 10:45:00 AM JulExample main
// WARNING: Configuration file not found, using defaults
// Feb 28, 2026 10:45:00 AM JulExample main
// SEVERE: Database connection failed!

JUL Limitations

While JUL works for simple cases, it has significant drawbacks compared to modern frameworks:

  • Verbose API -- logger.log(Level.INFO, "msg {0}", new Object[]{val}) vs. SLF4J's log.info("msg {}", val)
  • Limited formatting -- the default output format is ugly and multi-line (class name and method on separate lines)
  • Poor configuration -- uses a global logging.properties file that is awkward to customize per-package
  • No native support for modern features -- no built-in JSON output, no MDC (Mapped Diagnostic Context), no async logging
  • Performance -- slower than Logback and Log4j2 for high-throughput scenarios

Verdict: Use JUL for quick scripts or when you truly cannot add dependencies. For any real application, use SLF4J + Logback.

5. SLF4J + Logback (Recommended)

SLF4J (Simple Logging Facade for Java) + Logback is the most popular logging stack in the Java ecosystem. Spring Boot uses it by default. SLF4J provides the API you code against; Logback provides the engine that does the actual logging.

5.1 Maven Dependencies



    
    
        org.slf4j
        slf4j-api
        2.0.16
    

    
    
        ch.qos.logback
        logback-classic
        1.5.15
    
// Gradle: Add to build.gradle
dependencies {
    implementation 'org.slf4j:slf4j-api:2.0.16'
    implementation 'ch.qos.logback:logback-classic:1.5.15'
}

5.2 Basic Setup

The setup follows a consistent two-step pattern in every class:

  1. Import org.slf4j.Logger and org.slf4j.LoggerFactory
  2. Create a private static final Logger field using LoggerFactory.getLogger(YourClass.class)

Passing the class to getLogger() means the logger is named after your class, so log output shows exactly which class produced each message.

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class UserService {
    // Step 1: Declare the logger -- always private static final
    private static final Logger log = LoggerFactory.getLogger(UserService.class);

    public User findUserById(long id) {
        log.info("Looking up user with id={}", id);

        User user = userRepository.findById(id);

        if (user == null) {
            log.warn("User not found for id={}", id);
            return null;
        }

        log.debug("Found user: name={}, email={}", user.getName(), user.getEmail());
        return user;
    }
}
// Output with INFO level:
// 2026-02-28 10:30:00.123 [main] INFO  c.e.service.UserService - Looking up user with id=42
// 2026-02-28 10:30:00.125 [main] WARN  c.e.service.UserService - User not found for id=42

5.3 Parameterized Logging with {} Placeholders

This is one of SLF4J's most important features. Never use string concatenation in log statements. Use {} placeholders instead.

Why? With string concatenation, Java builds the string every time, even if the log level is disabled. With placeholders, SLF4J only builds the string if the message will actually be logged.

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class ParameterizedLogging {
    private static final Logger log = LoggerFactory.getLogger(ParameterizedLogging.class);

    public void demonstrate(Order order) {
        // BAD: String concatenation -- always builds the string, even if DEBUG is off
        log.debug("Processing order " + order.getId() + " for user " + order.getUserId()
                + " with " + order.getItems().size() + " items");

        // GOOD: Parameterized logging -- string built ONLY if DEBUG is enabled
        log.debug("Processing order {} for user {} with {} items",
                order.getId(), order.getUserId(), order.getItems().size());

        // Multiple placeholders -- they are filled in order
        log.info("User {} placed order {} with total ${}", "alice", "ORD-123", 99.99);
        // Output: User alice placed order ORD-123 with total $99.99

        // Logging exceptions -- exception is ALWAYS the last argument
        try {
            processPayment(order);
        } catch (Exception e) {
            // The exception goes last -- SLF4J recognizes it and prints the full stack trace
            log.error("Payment failed for order {}", order.getId(), e);
            // Output:
            // 2026-02-28 10:30:00.123 [main] ERROR ParameterizedLogging - Payment failed for order ORD-123
            // java.lang.RuntimeException: Insufficient funds
            //     at ParameterizedLogging.processPayment(ParameterizedLogging.java:35)
            //     at ParameterizedLogging.demonstrate(ParameterizedLogging.java:22)
            //     ...
        }
    }
}

5.4 Logging Exceptions Correctly

When logging exceptions, always pass the exception object as the last argument. SLF4J will automatically print the full stack trace. This is the single most important logging pattern to get right.

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class ExceptionLogging {
    private static final Logger log = LoggerFactory.getLogger(ExceptionLogging.class);

    public void demonstrateExceptionLogging() {
        try {
            riskyOperation();
        } catch (Exception e) {
            // BAD: Loses the stack trace entirely
            log.error("Something failed");

            // BAD: Only logs the exception message, no stack trace
            log.error("Something failed: " + e.getMessage());

            // BAD: Converts stack trace to string manually -- ugly and loses structure
            log.error("Something failed: " + e.toString());

            // GOOD: Pass exception as the last argument -- full stack trace is printed
            log.error("Something failed", e);

            // GOOD: With context AND exception -- placeholders first, exception last
            log.error("Failed to process order {} for user {}", orderId, userId, e);
            // SLF4J knows the last argument is an exception because {} count (2) < argument count (3)
        }
    }
}

6. Log4j2

Log4j2 is the modern successor to Log4j 1.x, built from the ground up by Apache. It is a completely different codebase from Log4j 1.x.

Critical Warning: Log4j 1.x (versions 1.2.x) reached end of life in 2015 and has the critical Log4Shell vulnerability (CVE-2021-44228), one of the most severe security vulnerabilities in Java history. If you are using Log4j 1.x, you must migrate immediately. Log4j2 (versions 2.x) is the safe, modern version.

6.1 Maven Dependencies



    
    
        org.slf4j
        slf4j-api
        2.0.16
    

    
    
        org.apache.logging.log4j
        log4j-slf4j2-impl
        2.24.3
    

    
    
        org.apache.logging.log4j
        log4j-core
        2.24.3
    

6.2 Log4j2 Configuration (log4j2.xml)

Place this file in src/main/resources/log4j2.xml:



    
        
        
            
        

        
        
            
            
                
                
            
            
        
    

    
        
        
            
            
        

        
        
            
        
    

6.3 Log4j2 Async Logging

Log4j2's standout feature is its async logging capability using the LMAX Disruptor library. This can dramatically improve performance in high-throughput applications by logging on a separate thread.



    com.lmax
    disruptor
    4.0.0



    
        
            
        
    

    
        
        
            
        

        
            
        
    

6.4 When to Choose Log4j2 Over Logback

Feature Logback Log4j2
Spring Boot default Yes No (requires exclusion + config)
Async performance Good (AsyncAppender) Excellent (LMAX Disruptor)
Garbage-free logging No Yes (reduces GC pauses)
Lambda support No Yes (lazy message construction)
Plugin architecture Limited Extensive
Community adoption Higher (Spring ecosystem) Strong (Apache ecosystem)
Configuration reload Yes Yes (automatic)

Bottom line: Use Logback for most applications, especially with Spring Boot. Choose Log4j2 if you need maximum throughput with async logging (e.g., high-frequency trading, real-time data pipelines).

7. Logback Configuration

Logback is configured via an XML file named logback.xml (or logback-spring.xml in Spring Boot) placed in src/main/resources/. The configuration has three main components:

  • Appenders -- Where log output goes (console, files, remote servers)
  • Encoders/Patterns -- How log messages are formatted
  • Loggers -- Which packages/classes log at which level

7.1 Basic logback.xml




    
    
    
    
        
            %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n
        
    

    
    
    
    
        logs/application.log
        
            %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n
        
    

    
    
    
    
        logs/application.log

        
        
            logs/application.%d{yyyy-MM-dd}.%i.log.gz
            10MB
            30          
            1GB     
        

        
            %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n
        
    

    
    
    

    
    
        
        
    

    
    

    
    
    

    
    
        
    

7.2 Encoder Pattern Reference

The pattern string controls how each log message is formatted. Here are the most common conversion specifiers:

Specifier Output Example
%d{pattern} Date/time %d{yyyy-MM-dd HH:mm:ss.SSS} = 2026-02-28 10:30:00.123
%level or %-5level Log level (padded to 5 chars) INFO, DEBUG, ERROR
%logger{n} Logger name (abbreviated to n chars) %logger{36} = c.e.service.UserService
%msg The log message User logged in successfully
%n Newline (platform-specific) \n or \r\n
%thread Thread name main, http-nio-8080-exec-1
%class Full class name (slow) com.example.service.UserService
%method Method name (slow) findUserById
%line Line number (slow) 42
%X{key} MDC value %X{requestId} = abc-123
%highlight() ANSI color by level (console only) ERROR in red, WARN in yellow

Performance note: %class, %method, and %line are computed by generating a stack trace, which is expensive. Avoid them in production patterns.

7.3 Common Patterns

// Development pattern (human-readable with colors)
%d{HH:mm:ss.SSS} %highlight(%-5level) %cyan(%logger{36}) - %msg%n
// Output: 10:30:00.123 INFO  c.e.service.UserService - Order placed

// Production pattern (full detail, no color)
%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n
// Output: 2026-02-28 10:30:00.123 [http-nio-8080-exec-1] INFO  c.e.service.UserService - Order placed

// Production with MDC (request tracking)
%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} [requestId=%X{requestId}] - %msg%n
// Output: 2026-02-28 10:30:00.123 [http-nio-8080-exec-1] INFO  c.e.service.UserService [requestId=abc-123-def] - Order placed

// JSON pattern for ELK/Splunk (see Section 12)
{"timestamp":"%d{yyyy-MM-dd'T'HH:mm:ss.SSSZ}","level":"%level","logger":"%logger","thread":"%thread","message":"%msg","requestId":"%X{requestId}"}%n

7.4 Filtering by Package

One of the most powerful configuration features is setting different log levels for different packages. This lets you see detailed logs from your code while keeping framework noise quiet.


    
        
            %d{HH:mm:ss.SSS} %-5level %logger{36} - %msg%n
        
    

    
    

    
    

    
    

    
    

    
    

    
    

    
    
        
    

8. Logging Patterns and Formats

The format of your log messages matters more than you might think. In development, you want human-readable output. In production, you often want structured (JSON) output that can be parsed by log aggregation tools like the ELK stack (Elasticsearch, Logstash, Kibana) or Splunk.

8.1 Pattern Format Quick Reference

Environment Pattern Why
Development %d{HH:mm:ss} %-5level %logger{20} - %msg%n Short, readable, fast to scan
Staging %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n Full detail for debugging issues that match production
Production (text) %d{ISO8601} [%thread] %-5level %logger{36} [%X{requestId}] - %msg%n ISO timestamps, MDC context, full logger names
Production (JSON) Use Logstash encoder (see below) Machine-parseable for log aggregation

8.2 JSON Logging for Production

For production environments using ELK stack, Splunk, or Datadog, structured JSON logs are essential. Each log line is a valid JSON object that these tools can parse, index, and search.



    net.logstash.logback
    logstash-logback-encoder
    8.0


    
    
        
            
            requestId
            userId
        
    

    
        
    

With JSON logging, each log line looks like this:

{"@timestamp":"2026-02-28T10:30:00.123Z","@version":"1","message":"Order ORD-123 placed successfully","logger_name":"com.myapp.service.OrderService","thread_name":"http-nio-8080-exec-1","level":"INFO","requestId":"abc-123-def","userId":"user-42"}

This structured output means you can search for all logs where userId="user-42" or find all ERROR-level messages for a specific requestId -- something that is extremely difficult with plain text logs.

9. MDC (Mapped Diagnostic Context)

Imagine you are a doctor in a busy emergency room, treating 20 patients simultaneously. Without patient wristbands (IDs), you would have no way to tell which vitals belong to which patient. MDC is the wristband for your application's requests.

MDC (Mapped Diagnostic Context) lets you attach key-value pairs to the current thread. These values are then automatically included in every log message produced by that thread. This is invaluable in multi-threaded web applications where dozens of requests are processed concurrently.

Common MDC Fields

  • requestId -- A unique ID for each HTTP request, used to trace all log lines for one request
  • userId -- The authenticated user making the request
  • sessionId -- The user's session
  • transactionId -- For tracing business transactions across services
  • correlationId -- For tracing requests across microservices
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;

public class MdcExample {
    private static final Logger log = LoggerFactory.getLogger(MdcExample.class);

    public void handleRequest(String requestId, String userId) {
        // Put values into MDC at the start of the request
        MDC.put("requestId", requestId);
        MDC.put("userId", userId);

        try {
            log.info("Request received");
            processOrder();
            sendConfirmation();
            log.info("Request completed successfully");
        } finally {
            // CRITICAL: Always clear MDC when the request is done
            // Threads are reused in thread pools -- leftover MDC values leak into other requests!
            MDC.clear();
        }
    }

    private void processOrder() {
        // This log line automatically includes requestId and userId from MDC
        log.info("Processing order");
        // Output: 2026-02-28 10:30:00.123 [http-exec-1] INFO  MdcExample [requestId=abc-123, userId=user-42] - Processing order
    }

    private void sendConfirmation() {
        log.info("Sending confirmation email");
        // Output: 2026-02-28 10:30:00.456 [http-exec-1] INFO  MdcExample [requestId=abc-123, userId=user-42] - Sending confirmation email
    }
}

9.1 MDC with Web Applications (Servlet Filter)

In real applications, you set up MDC in a servlet filter or Spring interceptor so that every request automatically gets a unique ID. You never have to manually add MDC in individual controllers or services.

import org.slf4j.MDC;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.util.UUID;

public class LoggingFilter implements Filter {
    private static final String REQUEST_ID = "requestId";
    private static final String USER_ID = "userId";

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {

        HttpServletRequest httpRequest = (HttpServletRequest) request;

        try {
            // Generate or extract request ID
            String requestId = httpRequest.getHeader("X-Request-ID");
            if (requestId == null || requestId.isBlank()) {
                requestId = UUID.randomUUID().toString().substring(0, 8);
            }

            // Set MDC values
            MDC.put(REQUEST_ID, requestId);

            // Extract user from security context (if authenticated)
            String userId = extractUserId(httpRequest);
            if (userId != null) {
                MDC.put(USER_ID, userId);
            }

            // Continue processing the request
            chain.doFilter(request, response);

        } finally {
            // Always clean up to prevent thread pool contamination
            MDC.clear();
        }
    }

    private String extractUserId(HttpServletRequest request) {
        // In a real app, extract from security context or JWT token
        return request.getRemoteUser();
    }
}

9.2 MDC in logback.xml

To display MDC values in your log output, use the %X{key} specifier in your pattern:


    
        
            
            %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} [req=%X{requestId} user=%X{userId}] - %msg%n
        
    

    
        
    


10. Best Practices

These are the logging practices that separate junior developers from senior developers. Follow these in every Java project.

10.1 Use SLF4J as Your Logging Facade

Always code against the SLF4J API, never a specific implementation. This gives you the freedom to switch between Logback, Log4j2, or any future implementation without touching your code.

// BAD: Coupling to a specific implementation
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
private static final Logger log = LogManager.getLogger(MyClass.class);

// BAD: Using java.util.logging directly
import java.util.logging.Logger;
private static final Logger log = Logger.getLogger(MyClass.class.getName());

// GOOD: SLF4J facade -- works with ANY implementation
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
private static final Logger log = LoggerFactory.getLogger(MyClass.class);

10.2 Use Parameterized Logging

This is the single most common logging mistake in Java code reviews. Never concatenate strings in log statements.

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class ParameterizedBestPractice {
    private static final Logger log = LoggerFactory.getLogger(ParameterizedBestPractice.class);

    public void process(Order order) {
        // BAD: String concatenation -- always builds the string even if DEBUG is off
        log.debug("Order " + order.getId() + " has " + order.getItems().size() + " items totaling $" + order.getTotal());
        // This calls order.getId(), order.getItems().size(), and order.getTotal()
        // PLUS concatenates 5 strings -- all wasted work if DEBUG is disabled

        // GOOD: Parameterized -- only builds string if DEBUG is enabled
        log.debug("Order {} has {} items totaling ${}", order.getId(), order.getItems().size(), order.getTotal());
    }
}

10.3 Log at the Appropriate Level

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class AppropriateLevel {
    private static final Logger log = LoggerFactory.getLogger(AppropriateLevel.class);

    public void processOrder(Order order) {
        // TRACE: Very fine-grained, method entry/exit
        log.trace("Entering processOrder with order={}", order);

        // DEBUG: Technical detail helpful during development
        log.debug("Validating order items against inventory");

        // INFO: Business event -- this is what operations teams monitor
        log.info("Order {} placed by user {} for ${}", order.getId(), order.getUserId(), order.getTotal());

        // WARN: Something unusual but recoverable
        if (order.getTotal() > 10000) {
            log.warn("High-value order {} for ${} -- flagged for review", order.getId(), order.getTotal());
        }

        // ERROR: Something failed -- needs human attention
        try {
            chargePayment(order);
        } catch (PaymentException e) {
            log.error("Payment failed for order {} with amount ${}", order.getId(), order.getTotal(), e);
        }
    }
}

10.4 Include Context in Messages

A log message without context is like a clue without a case number. Always include the relevant IDs and values that will help you investigate.

// BAD: No context -- useless for debugging
log.error("Payment failed");
log.info("User logged in");
log.warn("Retry attempt");

// GOOD: Context-rich -- you can trace exactly what happened
log.error("Payment failed for order={} user={} amount=${} gateway={}", orderId, userId, amount, gateway);
log.info("User {} logged in from IP {} using {}", userId, ipAddress, userAgent);
log.warn("Retry attempt {}/{} for order={} after {}ms delay", attempt, maxRetries, orderId, delay);

10.5 Use isDebugEnabled() for Expensive Operations

While parameterized logging avoids string concatenation overhead, it does not avoid the cost of computing the arguments. If computing an argument is expensive, guard the log statement.

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class ExpensiveLogging {
    private static final Logger log = LoggerFactory.getLogger(ExpensiveLogging.class);

    public void processLargeDataSet(List records) {
        // BAD: computeStats() is called EVERY TIME, even when DEBUG is off
        log.debug("Dataset statistics: {}", computeStats(records));
        // computeStats() might iterate over millions of records

        // GOOD: Guard expensive computation
        if (log.isDebugEnabled()) {
            log.debug("Dataset statistics: {}", computeStats(records));
        }

        // ALSO GOOD for simple arguments -- no guard needed
        log.debug("Processing {} records", records.size());
        // records.size() is O(1) and trivially cheap
    }

    private String computeStats(List records) {
        // Imagine this iterates the entire list, computes averages, etc.
        return "min=1, max=100, avg=42.5, stddev=12.3";
    }
}

10.6 Do Not Log Sensitive Data

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class SensitiveDataLogging {
    private static final Logger log = LoggerFactory.getLogger(SensitiveDataLogging.class);

    public void authenticateUser(String username, String password) {
        // BAD: NEVER log passwords
        log.info("Login attempt: user={}, password={}", username, password);

        // GOOD: Log the event without sensitive data
        log.info("Login attempt for user={}", username);
    }

    public void processPayment(String creditCardNumber, double amount) {
        // BAD: NEVER log full credit card numbers
        log.info("Charging card {} for ${}", creditCardNumber, amount);

        // GOOD: Mask the sensitive data
        String masked = maskCreditCard(creditCardNumber);
        log.info("Charging card {} for ${}", masked, amount);
        // Output: Charging card ****-****-****-4242 for $99.99
    }

    private String maskCreditCard(String number) {
        if (number == null || number.length() < 4) return "****";
        return "****-****-****-" + number.substring(number.length() - 4);
    }
}

10.7 Do Not Log Inside Tight Loops

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class LoopLogging {
    private static final Logger log = LoggerFactory.getLogger(LoopLogging.class);

    public void processRecords(List records) {
        // BAD: Logging inside a loop with 1 million records = 1 million log lines
        for (Record record : records) {
            log.debug("Processing record: {}", record.getId());
            process(record);
        }

        // GOOD: Log summary information
        log.info("Starting to process {} records", records.size());
        int successCount = 0;
        int failCount = 0;

        for (Record record : records) {
            try {
                process(record);
                successCount++;
            } catch (Exception e) {
                failCount++;
                // Only log individual failures -- these are exceptional
                log.warn("Failed to process record {}: {}", record.getId(), e.getMessage());
            }
        }

        log.info("Completed processing: {} succeeded, {} failed out of {} total",
                successCount, failCount, records.size());
    }
}

Best Practices Summary

Practice Do Do Not
API Use SLF4J facade Use implementation-specific API (JUL, Log4j directly)
Parameters log.info("User {}", userId) log.info("User " + userId)
Exceptions log.error("Msg", exception) log.error("Msg: " + e.getMessage())
Levels INFO for business events, DEBUG for technical details Everything at INFO or everything at DEBUG
Context Include IDs, amounts, counts Vague messages like "Error occurred"
MDC Set requestId/userId in filter Manually add IDs to every message
Sensitive data Mask or omit Log passwords, credit cards, tokens
Loops Log summary before/after Log every iteration
Guards if (log.isDebugEnabled()) for expensive computation Call expensive methods as log arguments
Logger declaration private static final Logger Creating new Logger per method call

11. Common Mistakes

Every experienced Java developer has made these mistakes. Recognizing them in code reviews will make you a better developer.

Mistake 1: Using System.out.println Instead of a Logger

// MISTAKE: System.out.println scattered through production code
public class OrderService {
    public void placeOrder(Order order) {
        System.out.println("Placing order: " + order);        // No level, no timestamp, no thread
        System.out.println("Validating...");                   // Cannot turn off without deleting
        System.out.println("Done!");                           // Goes to stdout only
    }
}

// FIX: Use a proper logger
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class OrderService {
    private static final Logger log = LoggerFactory.getLogger(OrderService.class);

    public void placeOrder(Order order) {
        log.info("Placing order {}", order.getId());
        log.debug("Validating order items");
        log.info("Order {} placed successfully", order.getId());
    }
}

Mistake 2: String Concatenation in Log Statements

// MISTAKE: String concatenation is evaluated even when the level is disabled
log.debug("User " + user.getName() + " has " + user.getOrders().size() + " orders"
        + " totaling $" + calculateTotal(user.getOrders()));
// If DEBUG is off, Java still:
//   1. Calls user.getName()
//   2. Calls user.getOrders().size()
//   3. Calls calculateTotal() -- potentially expensive!
//   4. Concatenates 5 strings
//   5. Throws the result away

// FIX: Use parameterized logging
log.debug("User {} has {} orders totaling ${}",
        user.getName(), user.getOrders().size(), calculateTotal(user.getOrders()));
// With parameterized logging, if DEBUG is off, SLF4J skips building the string.
// NOTE: The arguments are still evaluated. For expensive arguments, use isDebugEnabled() guard.

Mistake 3: Not Logging Exceptions Properly

try {
    connectToDatabase();
} catch (SQLException e) {

    // MISTAKE 1: Swallowing the exception entirely
    // (empty catch block -- the worst possible thing)

    // MISTAKE 2: Only logging the message, losing the stack trace
    log.error("Database error: " + e.getMessage());
    // Output: Database error: Connection refused
    // WHERE did it fail? Which line? What was the root cause? All lost.

    // MISTAKE 3: Using printStackTrace() instead of logging
    e.printStackTrace();
    // This goes to System.err, bypassing the logging framework entirely.
    // No timestamp, no level, no file output, no MDC.

    // CORRECT: Pass the exception as the last argument
    log.error("Failed to connect to database", e);
    // Output includes the full stack trace:
    // 2026-02-28 10:30:00.123 [main] ERROR DatabaseService - Failed to connect to database
    // java.sql.SQLException: Connection refused
    //     at com.mysql.cj.jdbc.ConnectionImpl.createNewIO(ConnectionImpl.java:839)
    //     at com.mysql.cj.jdbc.ConnectionImpl.(ConnectionImpl.java:453)
    //     at DatabaseService.connectToDatabase(DatabaseService.java:42)
    //     ...
    // Caused by: java.net.ConnectException: Connection refused (Connection refused)
    //     at java.base/java.net.PlainSocketImpl.socketConnect(Native Method)
    //     ...
}

Mistake 4: Logging Too Much or Too Little

// MISTAKE: Logging too much -- "log diarrhea"
public double calculateTax(double amount, String state) {
    log.info("calculateTax called");                    // Noise
    log.info("amount = " + amount);                     // Noise + concatenation
    log.info("state = " + state);                       // Noise + concatenation
    double rate = getTaxRate(state);
    log.info("tax rate = " + rate);                     // Noise
    double tax = amount * rate;
    log.info("tax = " + tax);                           // Noise
    log.info("returning tax");                          // Noise
    return tax;
}
// This method generates 6 log lines for a simple calculation.
// Multiply by 1000 requests/second and you have 6000 lines/second of noise.

// MISTAKE: Logging too little
public double calculateTax(double amount, String state) {
    return amount * getTaxRate(state);
    // No logging at all. If tax calculations are wrong, where do you start?
}

// CORRECT: Log meaningful events at the right level
public double calculateTax(double amount, String state) {
    log.debug("Calculating tax for amount={} state={}", amount, state);
    double rate = getTaxRate(state);
    double tax = amount * rate;
    log.debug("Tax calculated: amount={} state={} rate={} tax={}", amount, state, rate, tax);
    return tax;
}
// Two DEBUG lines that can be turned off in production but enabled when needed.

Mistake 5: Logging Sensitive Data

// MISTAKE: Logging user data verbatim
public void registerUser(UserRegistration reg) {
    log.info("Registering user: {}", reg);
    // If UserRegistration.toString() includes password, SSN, or credit card... game over.
    // Log files are often stored in plain text, backed up to multiple servers,
    // and accessed by many team members.
}

// CORRECT: Log only safe, relevant fields
public void registerUser(UserRegistration reg) {
    log.info("Registering user: email={}", reg.getEmail());
    // Or override toString() to exclude sensitive fields:
    // @Override public String toString() {
    //     return "UserRegistration{email='" + email + "', name='" + name + "'}";
    //     // password, ssn, creditCard intentionally excluded
    // }
}

Mistake 6: Using Log4j 1.x

// MISTAKE: Still using Log4j 1.x (versions 1.2.x)
import org.apache.log4j.Logger;   // <-- This is Log4j 1.x -- SECURITY VULNERABILITY!

// Log4j 1.x reached End of Life in August 2015.
// CVE-2021-44228 (Log4Shell) allows Remote Code Execution -- attackers can take over your server.
// This is a CRITICAL vulnerability rated 10.0 out of 10.0 on the CVSS scale.

// FIX: Migrate to SLF4J + Logback (or Log4j2)
// Step 1: Remove log4j 1.x dependency
// Step 2: Add SLF4J + Logback dependencies (see Section 5)
// Step 3: Replace imports:
import org.slf4j.Logger;          // <-- SLF4J facade
import org.slf4j.LoggerFactory;

// Step 4: Replace logger creation:
// OLD: private static final Logger log = Logger.getLogger(MyClass.class);
// NEW:
private static final Logger log = LoggerFactory.getLogger(MyClass.class);

// Step 5: Replace log4j.properties with logback.xml (see Section 7)
// Step 6: The logging method calls (log.info, log.error, etc.) are almost identical

12. Logging in Production

Production logging has different requirements than development logging. In production, your logs are the primary tool for understanding what is happening across hundreds of servers processing thousands of requests per second.

12.1 Structured Logging (JSON)

In production, logs should be machine-parseable. Plain text logs like 2026-02-28 10:30 INFO OrderService - Order placed are hard for log aggregation tools to parse reliably. JSON format solves this.

With JSON logging, tools like Elasticsearch, Splunk, Datadog, and Grafana Loki can index every field and let you write queries like:

  • Show all ERROR logs from the last hour
  • Show all logs where userId = "alice" and orderId = "ORD-123"
  • Count the number of payment failures per minute
  • Alert when error rate exceeds 5% of total requests

12.2 The ELK Stack

The ELK stack (Elasticsearch, Logstash, Kibana) is the most popular open-source log aggregation platform:

Component Role Description
Elasticsearch Store and search Distributed search engine that indexes log data for fast queries
Logstash Collect and transform Ingests logs from multiple sources, parses them, and sends to Elasticsearch
Kibana Visualize Web UI for searching logs, building dashboards, and setting up alerts

12.3 Log Rotation

Without log rotation, log files grow until they fill the disk and your application crashes. Always configure rolling policies:



    logs/application.log

    
        
        logs/application.%d{yyyy-MM-dd}.%i.log.gz

        
        50MB

        
        90

        
        5GB
    

    
        %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} [%X{requestId}] - %msg%n
    

12.4 Performance Considerations

Concern Solution
High-throughput logging blocks threads Use async appenders (Logback's AsyncAppender or Log4j2's AsyncLogger)
Disk I/O bottleneck Write to a local buffer, ship to remote collector (Logstash, Fluentd)
Large stack traces Logback automatically shortens repeated stack frames with ... 42 common frames omitted
GC pressure from log string building Use parameterized logging ({}), consider Log4j2's garbage-free mode
Log file size Use GZIP compression on rolled files (.log.gz)
Too many DEBUG/TRACE in production Set root level to INFO, use DEBUG only for your packages when investigating

12.5 Async Appender Example



    
    
        logs/application.log
        
            logs/application.%d{yyyy-MM-dd}.log.gz
            30
        
        
            %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n
        
    

    
    
        1024           
        0 
        false 
        
    

    
        
    

13. Complete Practical Example: OrderService

Let us tie everything together with a realistic, production-quality example. This OrderService demonstrates all the logging concepts we have covered: appropriate log levels, parameterized messages, exception handling, MDC for request tracking, and best practices throughout.

13.1 Dependencies (pom.xml)


    
        org.slf4j
        slf4j-api
        2.0.16
    
    
        ch.qos.logback
        logback-classic
        1.5.15
    

13.2 Configuration (logback.xml)



    
        
            %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{30} [orderId=%X{orderId} user=%X{userId}] - %msg%n
        
    

    
        logs/orders.log
        
            logs/orders.%d{yyyy-MM-dd}.%i.log.gz
            10MB
            30
            1GB
        
        
            %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} [orderId=%X{orderId} user=%X{userId}] - %msg%n
        
    

    
        
        
    

    
        
    

13.3 Order Model

package com.example.orders;

import java.util.List;

public class Order {
    private final String id;
    private final String userId;
    private final List items;
    private double total;
    private OrderStatus status;

    public Order(String id, String userId, List items) {
        this.id = id;
        this.userId = userId;
        this.items = items;
        this.total = items.stream().mapToDouble(OrderItem::getSubtotal).sum();
        this.status = OrderStatus.PENDING;
    }

    public String getId() { return id; }
    public String getUserId() { return userId; }
    public List getItems() { return items; }
    public double getTotal() { return total; }
    public OrderStatus getStatus() { return status; }
    public void setStatus(OrderStatus status) { this.status = status; }
    public void setTotal(double total) { this.total = total; }

    // toString excludes any sensitive user data
    @Override
    public String toString() {
        return "Order{id='" + id + "', items=" + items.size() + ", total=" + total + ", status=" + status + "}";
    }
}

enum OrderStatus { PENDING, VALIDATED, PAID, SHIPPED, CANCELLED }

class OrderItem {
    private final String productName;
    private final int quantity;
    private final double price;

    public OrderItem(String productName, int quantity, double price) {
        this.productName = productName;
        this.quantity = quantity;
        this.price = price;
    }

    public String getProductName() { return productName; }
    public int getQuantity() { return quantity; }
    public double getPrice() { return price; }
    public double getSubtotal() { return quantity * price; }
}

13.4 OrderService with Production-Quality Logging

package com.example.orders;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;

import java.util.List;

public class OrderService {
    private static final Logger log = LoggerFactory.getLogger(OrderService.class);

    private static final double HIGH_VALUE_THRESHOLD = 1000.0;
    private static final double TAX_RATE = 0.08;
    private static final double DISCOUNT_THRESHOLD = 500.0;
    private static final double DISCOUNT_RATE = 0.10;

    /**
     * Process an order end-to-end with proper logging at every stage.
     */
    public void processOrder(Order order) {
        // Set MDC context for this order -- all subsequent log lines include these values
        MDC.put("orderId", order.getId());
        MDC.put("userId", order.getUserId());

        long startTime = System.currentTimeMillis();

        try {
            // INFO: Business event -- order processing started
            log.info("Order processing started: {} items, total=${}",
                    order.getItems().size(), order.getTotal());

            // Step 1: Validate
            validateOrder(order);

            // Step 2: Apply discounts
            applyDiscounts(order);

            // Step 3: Calculate tax
            calculateTax(order);

            // Step 4: Process payment
            processPayment(order);

            // Step 5: Ship
            shipOrder(order);

            long elapsed = System.currentTimeMillis() - startTime;
            // INFO: Business event -- order completed with timing
            log.info("Order processing completed successfully in {}ms, finalTotal=${}",
                    elapsed, order.getTotal());

        } catch (Exception e) {
            long elapsed = System.currentTimeMillis() - startTime;
            order.setStatus(OrderStatus.CANCELLED);
            // ERROR: Something went wrong -- include the exception for stack trace
            log.error("Order processing failed after {}ms", elapsed, e);
        } finally {
            // CRITICAL: Always clear MDC to prevent thread contamination
            MDC.clear();
        }
    }

    private void validateOrder(Order order) {
        log.debug("Validating order");

        if (order.getItems() == null || order.getItems().isEmpty()) {
            // ERROR: Invalid input -- this should not happen if upstream validation works
            log.error("Order has no items");
            throw new IllegalArgumentException("Order must have at least one item");
        }

        for (OrderItem item : order.getItems()) {
            if (item.getQuantity() <= 0) {
                log.error("Invalid quantity {} for product '{}'",
                        item.getQuantity(), item.getProductName());
                throw new IllegalArgumentException("Quantity must be positive for: " + item.getProductName());
            }
            if (item.getPrice() < 0) {
                log.error("Negative price ${} for product '{}'",
                        item.getPrice(), item.getProductName());
                throw new IllegalArgumentException("Price cannot be negative for: " + item.getProductName());
            }
        }

        order.setStatus(OrderStatus.VALIDATED);
        // DEBUG: Technical detail about validation result
        log.debug("Order validated: {} items passed all checks", order.getItems().size());
    }

    private void applyDiscounts(Order order) {
        double originalTotal = order.getTotal();
        log.debug("Checking discounts for total=${}", originalTotal);

        if (originalTotal >= DISCOUNT_THRESHOLD) {
            double discount = originalTotal * DISCOUNT_RATE;
            order.setTotal(originalTotal - discount);
            // INFO: Business event -- discount applied (operations wants to track this)
            log.info("Discount applied: {}% off ${} = -${}, newTotal=${}",
                    (int)(DISCOUNT_RATE * 100), originalTotal, discount, order.getTotal());
        } else {
            log.debug("No discount applied: total ${} below threshold ${}",
                    originalTotal, DISCOUNT_THRESHOLD);
        }
    }

    private void calculateTax(Order order) {
        double beforeTax = order.getTotal();
        double tax = beforeTax * TAX_RATE;
        order.setTotal(beforeTax + tax);
        // DEBUG: Technical calculation detail
        log.debug("Tax calculated: ${} * {} = ${}, newTotal=${}",
                beforeTax, TAX_RATE, tax, order.getTotal());
    }

    private void processPayment(Order order) {
        // INFO: Business event -- payment attempt
        log.info("Processing payment of ${}", order.getTotal());

        // WARN: Flag high-value orders
        if (order.getTotal() > HIGH_VALUE_THRESHOLD) {
            log.warn("High-value order detected: ${} exceeds threshold ${}",
                    order.getTotal(), HIGH_VALUE_THRESHOLD);
        }

        // Simulate payment processing
        try {
            simulatePaymentGateway(order);
            order.setStatus(OrderStatus.PAID);
            log.info("Payment processed successfully for ${}", order.getTotal());
        } catch (RuntimeException e) {
            // ERROR: Payment failed -- include the exception
            log.error("Payment gateway rejected transaction for ${}", order.getTotal(), e);
            throw e;
        }
    }

    private void simulatePaymentGateway(Order order) {
        // Simulate: orders with total over $5000 fail (for demo purposes)
        if (order.getTotal() > 5000) {
            throw new RuntimeException("Payment declined: exceeds single transaction limit");
        }
        log.debug("Payment gateway returned: APPROVED");
    }

    private void shipOrder(Order order) {
        log.info("Initiating shipment");
        order.setStatus(OrderStatus.SHIPPED);
        log.info("Order shipped to user {}", order.getUserId());
    }
}

13.5 Running the Example

package com.example.orders;

import java.util.List;

public class OrderApp {
    public static void main(String[] args) {
        OrderService service = new OrderService();

        // Scenario 1: Normal order
        System.out.println("=== Scenario 1: Normal Order ===");
        Order normalOrder = new Order("ORD-001", "alice",
                List.of(new OrderItem("Laptop Stand", 1, 45.99),
                        new OrderItem("USB-C Cable", 2, 12.99)));
        service.processOrder(normalOrder);

        System.out.println();

        // Scenario 2: High-value order with discount
        System.out.println("=== Scenario 2: High-Value Order ===");
        Order highValue = new Order("ORD-002", "bob",
                List.of(new OrderItem("MacBook Pro", 1, 2499.00),
                        new OrderItem("AppleCare+", 1, 399.00)));
        service.processOrder(highValue);

        System.out.println();

        // Scenario 3: Order that exceeds payment limit (will fail)
        System.out.println("=== Scenario 3: Failed Payment ===");
        Order tooExpensive = new Order("ORD-003", "charlie",
                List.of(new OrderItem("Server Rack", 3, 2500.00)));
        service.processOrder(tooExpensive);

        System.out.println();

        // Scenario 4: Invalid order (empty items)
        System.out.println("=== Scenario 4: Invalid Order ===");
        Order emptyOrder = new Order("ORD-004", "dave", List.of());
        service.processOrder(emptyOrder);
    }
}

13.6 Expected Output

=== Scenario 1: Normal Order ===
2026-02-28 10:30:00.001 [main] INFO  c.e.orders.OrderService [orderId=ORD-001 user=alice] - Order processing started: 2 items, total=$71.97
2026-02-28 10:30:00.002 [main] DEBUG c.e.orders.OrderService [orderId=ORD-001 user=alice] - Validating order
2026-02-28 10:30:00.002 [main] DEBUG c.e.orders.OrderService [orderId=ORD-001 user=alice] - Order validated: 2 items passed all checks
2026-02-28 10:30:00.002 [main] DEBUG c.e.orders.OrderService [orderId=ORD-001 user=alice] - Checking discounts for total=$71.97
2026-02-28 10:30:00.002 [main] DEBUG c.e.orders.OrderService [orderId=ORD-001 user=alice] - No discount applied: total $71.97 below threshold $500.0
2026-02-28 10:30:00.003 [main] DEBUG c.e.orders.OrderService [orderId=ORD-001 user=alice] - Tax calculated: $71.97 * 0.08 = $5.7576, newTotal=$77.7276
2026-02-28 10:30:00.003 [main] INFO  c.e.orders.OrderService [orderId=ORD-001 user=alice] - Processing payment of $77.7276
2026-02-28 10:30:00.003 [main] DEBUG c.e.orders.OrderService [orderId=ORD-001 user=alice] - Payment gateway returned: APPROVED
2026-02-28 10:30:00.003 [main] INFO  c.e.orders.OrderService [orderId=ORD-001 user=alice] - Payment processed successfully for $77.7276
2026-02-28 10:30:00.003 [main] INFO  c.e.orders.OrderService [orderId=ORD-001 user=alice] - Initiating shipment
2026-02-28 10:30:00.003 [main] INFO  c.e.orders.OrderService [orderId=ORD-001 user=alice] - Order shipped to user alice
2026-02-28 10:30:00.004 [main] INFO  c.e.orders.OrderService [orderId=ORD-001 user=alice] - Order processing completed successfully in 3ms, finalTotal=$77.7276

=== Scenario 2: High-Value Order ===
2026-02-28 10:30:00.005 [main] INFO  c.e.orders.OrderService [orderId=ORD-002 user=bob] - Order processing started: 2 items, total=$2898.0
2026-02-28 10:30:00.005 [main] DEBUG c.e.orders.OrderService [orderId=ORD-002 user=bob] - Validating order
2026-02-28 10:30:00.005 [main] DEBUG c.e.orders.OrderService [orderId=ORD-002 user=bob] - Order validated: 2 items passed all checks
2026-02-28 10:30:00.005 [main] DEBUG c.e.orders.OrderService [orderId=ORD-002 user=bob] - Checking discounts for total=$2898.0
2026-02-28 10:30:00.005 [main] INFO  c.e.orders.OrderService [orderId=ORD-002 user=bob] - Discount applied: 10% off $2898.0 = -$289.8, newTotal=$2608.2
2026-02-28 10:30:00.006 [main] DEBUG c.e.orders.OrderService [orderId=ORD-002 user=bob] - Tax calculated: $2608.2 * 0.08 = $208.656, newTotal=$2816.856
2026-02-28 10:30:00.006 [main] INFO  c.e.orders.OrderService [orderId=ORD-002 user=bob] - Processing payment of $2816.856
2026-02-28 10:30:00.006 [main] WARN  c.e.orders.OrderService [orderId=ORD-002 user=bob] - High-value order detected: $2816.856 exceeds threshold $1000.0
2026-02-28 10:30:00.006 [main] DEBUG c.e.orders.OrderService [orderId=ORD-002 user=bob] - Payment gateway returned: APPROVED
2026-02-28 10:30:00.006 [main] INFO  c.e.orders.OrderService [orderId=ORD-002 user=bob] - Payment processed successfully for $2816.856
2026-02-28 10:30:00.006 [main] INFO  c.e.orders.OrderService [orderId=ORD-002 user=bob] - Initiating shipment
2026-02-28 10:30:00.006 [main] INFO  c.e.orders.OrderService [orderId=ORD-002 user=bob] - Order shipped to user bob
2026-02-28 10:30:00.007 [main] INFO  c.e.orders.OrderService [orderId=ORD-002 user=bob] - Order processing completed successfully in 2ms, finalTotal=$2816.856

=== Scenario 3: Failed Payment ===
2026-02-28 10:30:00.008 [main] INFO  c.e.orders.OrderService [orderId=ORD-003 user=charlie] - Order processing started: 3 items, total=$7500.0
2026-02-28 10:30:00.008 [main] DEBUG c.e.orders.OrderService [orderId=ORD-003 user=charlie] - Validating order
2026-02-28 10:30:00.008 [main] DEBUG c.e.orders.OrderService [orderId=ORD-003 user=charlie] - Order validated: 3 items passed all checks
2026-02-28 10:30:00.008 [main] DEBUG c.e.orders.OrderService [orderId=ORD-003 user=charlie] - Checking discounts for total=$7500.0
2026-02-28 10:30:00.008 [main] INFO  c.e.orders.OrderService [orderId=ORD-003 user=charlie] - Discount applied: 10% off $7500.0 = -$750.0, newTotal=$6750.0
2026-02-28 10:30:00.009 [main] DEBUG c.e.orders.OrderService [orderId=ORD-003 user=charlie] - Tax calculated: $6750.0 * 0.08 = $540.0, newTotal=$7290.0
2026-02-28 10:30:00.009 [main] INFO  c.e.orders.OrderService [orderId=ORD-003 user=charlie] - Processing payment of $7290.0
2026-02-28 10:30:00.009 [main] WARN  c.e.orders.OrderService [orderId=ORD-003 user=charlie] - High-value order detected: $7290.0 exceeds threshold $1000.0
2026-02-28 10:30:00.009 [main] ERROR c.e.orders.OrderService [orderId=ORD-003 user=charlie] - Payment gateway rejected transaction for $7290.0
java.lang.RuntimeException: Payment declined: exceeds single transaction limit
    at com.example.orders.OrderService.simulatePaymentGateway(OrderService.java:112)
    ...
2026-02-28 10:30:00.010 [main] ERROR c.e.orders.OrderService [orderId=ORD-003 user=charlie] - Order processing failed after 2ms

=== Scenario 4: Invalid Order ===
2026-02-28 10:30:00.011 [main] INFO  c.e.orders.OrderService [orderId=ORD-004 user=dave] - Order processing started: 0 items, total=$0.0
2026-02-28 10:30:00.011 [main] DEBUG c.e.orders.OrderService [orderId=ORD-004 user=dave] - Validating order
2026-02-28 10:30:00.011 [main] ERROR c.e.orders.OrderService [orderId=ORD-004 user=dave] - Order has no items
2026-02-28 10:30:00.011 [main] ERROR c.e.orders.OrderService [orderId=ORD-004 user=dave] - Order processing failed after 0ms
java.lang.IllegalArgumentException: Order must have at least one item
    at com.example.orders.OrderService.validateOrder(OrderService.java:70)
    ...

13.7 What This Example Demonstrates

# Concept Where in Code
1 Logger declaration (private static final) OrderService class field
2 MDC for request tracking processOrder() -- MDC.put/MDC.clear
3 MDC cleanup in finally block processOrder() -- prevents thread contamination
4 INFO for business events "Order processing started", "Payment processed", "Order shipped"
5 DEBUG for technical details "Validating order", "Tax calculated", "Payment gateway returned"
6 WARN for recoverable issues "High-value order detected"
7 ERROR with exception "Payment gateway rejected" -- exception passed as last argument
8 Parameterized logging ({}) Every log statement uses {} instead of string concatenation
9 Context in messages Order ID, user ID, amounts, item counts included
10 Performance tracking Elapsed time measured and logged on completion/failure
11 No sensitive data logged toString() excludes user details; no passwords/tokens
12 Separate logback.xml configuration Console + rolling file, package-level filtering, MDC in pattern

14. Quick Reference

Topic Key Point
Recommended stack SLF4J (facade) + Logback (implementation)
Logger declaration private static final Logger log = LoggerFactory.getLogger(MyClass.class)
Parameterized logging log.info("User {} placed order {}", userId, orderId)
Exception logging log.error("Something failed for order {}", orderId, exception) -- exception is always the last argument
Log levels TRACE < DEBUG < INFO < WARN < ERROR. Use INFO for business events, DEBUG for technical details.
MDC MDC.put("requestId", id) in filter/interceptor, %X{requestId} in pattern, MDC.clear() in finally
Configuration file logback.xml in src/main/resources
Production format JSON via logstash-logback-encoder for ELK/Splunk/Datadog
Log rotation SizeAndTimeBasedRollingPolicy with maxFileSize, maxHistory, totalSizeCap
Async logging Logback AsyncAppender or Log4j2 AsyncLogger for high throughput
Never log Passwords, credit cards, SSNs, API keys, session tokens
Never use Log4j 1.x (CVE-2021-44228), System.out.println, string concatenation in log calls
March 18, 2020

Database

1. What is JDBC?

JDBC (Java Database Connectivity) is Java’s standard API for connecting to and interacting with relational databases. Think of it as the bridge between your Java application and a database — it provides a uniform way to send SQL statements, retrieve results, and manage database connections regardless of which database vendor you use (MySQL, PostgreSQL, Oracle, SQLite, H2, etc.).

Before JDBC, every database vendor had its own proprietary API. If you wrote code for Oracle, you could not reuse it for MySQL without a complete rewrite. JDBC solved this by defining a standard set of interfaces in the java.sql package that every database vendor implements through a driver.

JDBC Architecture

The architecture has four layers:

    +---------------------+
    |  Java Application   |   <-- Your code
    +---------------------+
             |
    +---------------------+
    |     JDBC API         |   <-- java.sql.* interfaces
    |  (DriverManager,     |       (Connection, Statement,
    |   DataSource)        |        ResultSet, etc.)
    +---------------------+
             |
    +---------------------+
    |    JDBC Driver       |   <-- Vendor-specific implementation
    |  (MySQL Connector/J, |       (mysql-connector-j, postgresql, etc.)
    |   PostgreSQL Driver) |
    +---------------------+
             |
    +---------------------+
    |     Database         |   <-- MySQL, PostgreSQL, Oracle, H2, etc.
    +---------------------+

Your application only talks to the JDBC API. The driver translates your calls into database-specific protocol. This means you can switch databases by changing the driver and connection URL -- your SQL code stays the same (assuming standard SQL).

Key JDBC Interfaces

Interface / Class Package Purpose
DriverManager java.sql Manages JDBC drivers, creates connections
Connection java.sql Represents a session with the database
Statement java.sql Executes static SQL statements
PreparedStatement java.sql Executes parameterized (precompiled) SQL
CallableStatement java.sql Calls stored procedures
ResultSet java.sql Holds query results, row-by-row iteration
DataSource javax.sql Factory for connections (preferred over DriverManager in production)

JDBC Driver Types

There are four types of JDBC drivers, but in modern development you will almost exclusively use Type 4 (Thin Driver):

Type Name Description Used Today?
Type 1 JDBC-ODBC Bridge Translates JDBC calls to ODBC calls Removed in Java 8
Type 2 Native-API Uses native database client libraries Rare
Type 3 Network Protocol Middleware translates to database protocol Rare
Type 4 Thin Driver (Pure Java) Directly converts JDBC calls to database protocol Yes -- standard

Type 4 drivers are pure Java, require no native libraries, and are the easiest to deploy. MySQL Connector/J, PostgreSQL JDBC, and the H2 driver are all Type 4.

2. Setting Up

To use JDBC, you need two things: the java.sql package (included in the JDK) and a JDBC driver for your specific database.

Adding the JDBC Driver Dependency

The driver is a JAR file that you add to your project. Here are the most common ones:

Maven (pom.xml):



    com.mysql
    mysql-connector-j
    8.3.0




    org.postgresql
    postgresql
    42.7.3




    com.h2database
    h2
    2.2.224

Gradle (build.gradle):

// MySQL
implementation 'com.mysql:mysql-connector-j:8.3.0'

// PostgreSQL
implementation 'org.postgresql:postgresql:42.7.3'

// H2
implementation 'com.h2database:h2:2.2.224'

Loading the Driver

In older Java code (pre-JDBC 4.0 / pre-Java 6), you had to explicitly load the driver class:

// Legacy approach -- no longer needed since Java 6 / JDBC 4.0
Class.forName("com.mysql.cj.jdbc.Driver");
Class.forName("org.postgresql.Driver");

Since JDBC 4.0, drivers use the Service Provider mechanism (META-INF/services/java.sql.Driver) and are auto-loaded when present on the classpath. You do not need Class.forName() anymore -- just add the dependency and call DriverManager.getConnection().

If you see Class.forName() in production code today, it is legacy code that can be safely removed.

3. Establishing a Connection

A Connection object represents an active session with the database. You create one using DriverManager.getConnection() with a JDBC URL, username, and password.

JDBC URL Format

The URL follows this pattern: jdbc:<subprotocol>://<host>:<port>/<database>?<parameters>

Database JDBC URL Example Default Port
MySQL jdbc:mysql://localhost:3306/mydb 3306
PostgreSQL jdbc:postgresql://localhost:5432/mydb 5432
H2 (in-memory) jdbc:h2:mem:testdb N/A
H2 (file-based) jdbc:h2:file:./data/mydb N/A
Oracle jdbc:oracle:thin:@localhost:1521:orcl 1521
SQL Server jdbc:sqlserver://localhost:1433;databaseName=mydb 1433

Basic Connection (Don't Do This in Production)

The simplest way to connect -- useful for learning, but it has problems we will fix shortly:

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;

public class BasicConnection {
    public static void main(String[] args) {
        String url = "jdbc:mysql://localhost:3306/mydb";
        String user = "root";
        String password = "secret";

        Connection connection = null;
        try {
            connection = DriverManager.getConnection(url, user, password);
            System.out.println("Connected to database!");
            System.out.println("Database: " + connection.getCatalog());
            System.out.println("Auto-commit: " + connection.getAutoCommit());

            // ... use connection ...

        } catch (SQLException e) {
            System.err.println("Connection failed: " + e.getMessage());
            System.err.println("SQL State: " + e.getSQLState());
            System.err.println("Error Code: " + e.getErrorCode());
        } finally {
            // Must close manually -- easy to forget!
            if (connection != null) {
                try {
                    connection.close();
                } catch (SQLException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}
// Output:
// Connected to database!
// Database: mydb
// Auto-commit: true

Proper Connection with try-with-resources (Always Use This)

Connection, Statement, PreparedStatement, and ResultSet all implement AutoCloseable. This means you should always use try-with-resources to ensure they are closed properly, even when exceptions occur.

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;

public class ProperConnection {
    // Store these in environment variables or a config file, never hardcoded
    private static final String URL = "jdbc:mysql://localhost:3306/mydb";
    private static final String USER = "root";
    private static final String PASSWORD = "secret";

    public static void main(String[] args) {
        // try-with-resources -- connection auto-closes when block ends
        try (Connection conn = DriverManager.getConnection(URL, USER, PASSWORD)) {
            System.out.println("Connected!");
            System.out.println("Valid: " + conn.isValid(5)); // 5-second timeout
            // ... do work ...
        } catch (SQLException e) {
            // Connection automatically closed, even on exception
            System.err.println("Error: " + e.getMessage());
        }
        // No finally block needed -- connection is guaranteed closed
    }
}

Connection with Properties

For more configuration options, pass connection parameters as a Properties object:

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.util.Properties;

public class ConnectionWithProperties {
    public static void main(String[] args) {
        String url = "jdbc:mysql://localhost:3306/mydb";

        Properties props = new Properties();
        props.setProperty("user", "root");
        props.setProperty("password", "secret");
        props.setProperty("useSSL", "true");
        props.setProperty("serverTimezone", "UTC");
        props.setProperty("characterEncoding", "UTF-8");
        props.setProperty("connectTimeout", "5000");  // 5 seconds

        try (Connection conn = DriverManager.getConnection(url, props)) {
            System.out.println("Connected with properties!");
        } catch (SQLException e) {
            System.err.println("Connection failed: " + e.getMessage());
        }
    }
}

4. Statement vs PreparedStatement

JDBC provides two main ways to execute SQL: Statement and PreparedStatement. Understanding the difference is critical -- using the wrong one is the #1 security mistake in database programming.

Statement -- Simple, Static SQL

Statement executes a raw SQL string. It is fine for static queries with no user input:

import java.sql.*;

public class StatementExample {
    public static void main(String[] args) throws SQLException {
        try (Connection conn = DriverManager.getConnection(
                "jdbc:h2:mem:testdb", "sa", "")) {

            Statement stmt = conn.createStatement();

            // DDL -- creating a table (no user input, Statement is fine)
            stmt.executeUpdate("CREATE TABLE countries (id INT PRIMARY KEY, name VARCHAR(50))");

            // Static insert (no user input)
            stmt.executeUpdate("INSERT INTO countries VALUES (1, 'United States')");
            stmt.executeUpdate("INSERT INTO countries VALUES (2, 'Japan')");

            // Static query
            ResultSet rs = stmt.executeQuery("SELECT * FROM countries");
            while (rs.next()) {
                System.out.println(rs.getInt("id") + ": " + rs.getString("name"));
            }
        }
    }
}
// Output:
// 1: United States
// 2: Japan

The SQL Injection Problem

If you ever build SQL by concatenating user input into a Statement, you create a SQL injection vulnerability -- one of the most dangerous and common security flaws in software:

// DANGEROUS -- NEVER DO THIS
public User findUser(String username) throws SQLException {
    Statement stmt = conn.createStatement();

    // User input is concatenated directly into SQL
    String sql = "SELECT * FROM users WHERE username = '" + username + "'";
    ResultSet rs = stmt.executeQuery(sql);
    // ...
}

// Normal input: username = "john"
// SQL becomes: SELECT * FROM users WHERE username = 'john'   <-- works fine

// Malicious input: username = "' OR '1'='1"
// SQL becomes: SELECT * FROM users WHERE username = '' OR '1'='1'
//              This returns ALL users!

// Even worse: username = "'; DROP TABLE users; --"
// SQL becomes: SELECT * FROM users WHERE username = ''; DROP TABLE users; --'
//              This DELETES your entire users table!

PreparedStatement -- Parameterized, Safe SQL

PreparedStatement solves SQL injection by separating the SQL structure from the data. You write the SQL with ? placeholders, and the driver safely binds the values:

// SAFE -- Always use PreparedStatement for any query involving user input
public User findUser(String username) throws SQLException {
    String sql = "SELECT * FROM users WHERE username = ?";
    try (PreparedStatement pstmt = conn.prepareStatement(sql)) {
        pstmt.setString(1, username);  // Parameter index starts at 1
        ResultSet rs = pstmt.executeQuery();
        // ...
    }
}

// Input: username = "' OR '1'='1"
// The driver treats the ENTIRE string as a literal value, not SQL.
// It effectively becomes: WHERE username = '\' OR \'1\'=\'1'
// No injection possible!

Statement vs PreparedStatement Comparison

Feature Statement PreparedStatement
SQL Injection Vulnerable when concatenating user input Immune -- parameters are escaped automatically
Performance Parsed and compiled every execution Precompiled -- the database caches the execution plan
Readability Messy string concatenation Clean ? placeholders
Type Safety Everything is string concatenation Typed setters: setInt(), setDate(), setString()
Batch Operations Supported but slower Significantly faster with reuse
Use Case DDL (CREATE TABLE, ALTER TABLE), static admin queries All DML (INSERT, UPDATE, DELETE, SELECT with parameters)

Rule of thumb: Always use PreparedStatement. The only exception is DDL statements (CREATE TABLE, DROP TABLE) where there are no parameters to bind.

PreparedStatement Setter Methods

Method Java Type SQL Type
setInt(index, value) int INTEGER
setLong(index, value) long BIGINT
setDouble(index, value) double DOUBLE
setString(index, value) String VARCHAR / TEXT
setBoolean(index, value) boolean BOOLEAN / BIT
setDate(index, value) java.sql.Date DATE
setTimestamp(index, value) java.sql.Timestamp TIMESTAMP / DATETIME
setBigDecimal(index, value) BigDecimal DECIMAL / NUMERIC
setNull(index, sqlType) null Any (specify with Types.VARCHAR, etc.)
setObject(index, value) Object Auto-detected

5. CRUD Operations

CRUD stands for Create, Read, Update, Delete -- the four fundamental database operations. Let us walk through each one using a users table.

5.1 CREATE Table

Creating a table is a DDL (Data Definition Language) operation. Use executeUpdate() since it does not return a result set:

import java.sql.*;

public class CreateTableExample {
    public static void main(String[] args) {
        String url = "jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1";

        try (Connection conn = DriverManager.getConnection(url, "sa", "")) {

            String sql = """
                CREATE TABLE IF NOT EXISTS users (
                    id          INT AUTO_INCREMENT PRIMARY KEY,
                    username    VARCHAR(50)  NOT NULL UNIQUE,
                    email       VARCHAR(100) NOT NULL,
                    age         INT,
                    salary      DECIMAL(10,2),
                    active      BOOLEAN DEFAULT TRUE,
                    created_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP
                )
                """;

            try (Statement stmt = conn.createStatement()) {
                stmt.executeUpdate(sql);
                System.out.println("Table 'users' created successfully.");
            }

        } catch (SQLException e) {
            System.err.println("Error: " + e.getMessage());
        }
    }
}
// Output:
// Table 'users' created successfully.

5.2 INSERT -- Adding Records

Use PreparedStatement with executeUpdate(). To retrieve the auto-generated ID, pass Statement.RETURN_GENERATED_KEYS:

public class InsertExample {

    // Insert a single user and return the generated ID
    public static long insertUser(Connection conn, String username, String email,
                                   int age, double salary) throws SQLException {
        String sql = "INSERT INTO users (username, email, age, salary) VALUES (?, ?, ?, ?)";

        try (PreparedStatement pstmt = conn.prepareStatement(sql,
                Statement.RETURN_GENERATED_KEYS)) {

            pstmt.setString(1, username);
            pstmt.setString(2, email);
            pstmt.setInt(3, age);
            pstmt.setDouble(4, salary);

            int rowsAffected = pstmt.executeUpdate();
            System.out.println(rowsAffected + " row(s) inserted.");

            // Retrieve the auto-generated key
            try (ResultSet generatedKeys = pstmt.getGeneratedKeys()) {
                if (generatedKeys.next()) {
                    long id = generatedKeys.getLong(1);
                    System.out.println("Generated ID: " + id);
                    return id;
                }
            }
        }
        return -1;
    }

    public static void main(String[] args) throws SQLException {
        try (Connection conn = DriverManager.getConnection(
                "jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1", "sa", "")) {

            // Create table first (from previous example)
            // ...

            insertUser(conn, "john_doe", "john@example.com", 28, 75000.00);
            insertUser(conn, "jane_smith", "jane@example.com", 32, 92000.00);
            insertUser(conn, "bob_wilson", "bob@example.com", 45, 68000.00);
        }
    }
}
// Output:
// 1 row(s) inserted.
// Generated ID: 1
// 1 row(s) inserted.
// Generated ID: 2
// 1 row(s) inserted.
// Generated ID: 3

5.3 SELECT -- Reading Records

Use executeQuery() which returns a ResultSet. Iterate over it with next():

public class SelectExample {

    // Select all users
    public static void selectAllUsers(Connection conn) throws SQLException {
        String sql = "SELECT id, username, email, age, salary, active, created_at FROM users";

        try (PreparedStatement pstmt = conn.prepareStatement(sql);
             ResultSet rs = pstmt.executeQuery()) {

            System.out.println("--- All Users ---");
            while (rs.next()) {
                int id = rs.getInt("id");
                String username = rs.getString("username");
                String email = rs.getString("email");
                int age = rs.getInt("age");
                double salary = rs.getDouble("salary");
                boolean active = rs.getBoolean("active");
                Timestamp createdAt = rs.getTimestamp("created_at");

                System.out.printf("ID: %d | %s | %s | Age: %d | $%.2f | Active: %b | %s%n",
                        id, username, email, age, salary, active, createdAt);
            }
        }
    }

    // Select with a WHERE clause
    public static void selectUserByUsername(Connection conn, String username) throws SQLException {
        String sql = "SELECT * FROM users WHERE username = ?";

        try (PreparedStatement pstmt = conn.prepareStatement(sql)) {
            pstmt.setString(1, username);

            try (ResultSet rs = pstmt.executeQuery()) {
                if (rs.next()) {
                    System.out.println("Found: " + rs.getString("username")
                            + " (" + rs.getString("email") + ")");
                } else {
                    System.out.println("User not found: " + username);
                }
            }
        }
    }

    // Select with multiple conditions
    public static void selectUsersAboveSalary(Connection conn, double minSalary)
            throws SQLException {
        String sql = "SELECT username, salary FROM users WHERE salary > ? AND active = ? ORDER BY salary DESC";

        try (PreparedStatement pstmt = conn.prepareStatement(sql)) {
            pstmt.setDouble(1, minSalary);
            pstmt.setBoolean(2, true);

            try (ResultSet rs = pstmt.executeQuery()) {
                System.out.println("Users earning above $" + minSalary + ":");
                while (rs.next()) {
                    System.out.printf("  %s: $%.2f%n",
                            rs.getString("username"), rs.getDouble("salary"));
                }
            }
        }
    }
}
// Output:
// --- All Users ---
// ID: 1 | john_doe | john@example.com | Age: 28 | $75000.00 | Active: true | 2024-01-15 10:30:00.0
// ID: 2 | jane_smith | jane@example.com | Age: 32 | $92000.00 | Active: true | 2024-01-15 10:30:00.0
// ID: 3 | bob_wilson | bob@example.com | Age: 45 | $68000.00 | Active: true | 2024-01-15 10:30:00.0
//
// Found: john_doe (john@example.com)
//
// Users earning above $70000.0:
//   jane_smith: $92000.00
//   john_doe: $75000.00

5.4 UPDATE -- Modifying Records

Use executeUpdate() which returns the number of affected rows. Always check this value to confirm the operation succeeded:

public class UpdateExample {

    public static int updateUserEmail(Connection conn, int userId, String newEmail)
            throws SQLException {
        String sql = "UPDATE users SET email = ? WHERE id = ?";

        try (PreparedStatement pstmt = conn.prepareStatement(sql)) {
            pstmt.setString(1, newEmail);
            pstmt.setInt(2, userId);

            int rowsAffected = pstmt.executeUpdate();
            if (rowsAffected > 0) {
                System.out.println("Updated email for user ID " + userId);
            } else {
                System.out.println("No user found with ID " + userId);
            }
            return rowsAffected;
        }
    }

    public static int giveRaise(Connection conn, double percentage, double minCurrentSalary)
            throws SQLException {
        String sql = "UPDATE users SET salary = salary * (1 + ? / 100) WHERE salary >= ?";

        try (PreparedStatement pstmt = conn.prepareStatement(sql)) {
            pstmt.setDouble(1, percentage);
            pstmt.setDouble(2, minCurrentSalary);

            int rowsAffected = pstmt.executeUpdate();
            System.out.println(rowsAffected + " user(s) received a " + percentage + "% raise.");
            return rowsAffected;
        }
    }
}
// Output:
// Updated email for user ID 1
// 2 user(s) received a 10.0% raise.

5.5 DELETE -- Removing Records

Delete operations also use executeUpdate(). Be very careful -- always use a WHERE clause unless you intentionally want to delete all rows:

public class DeleteExample {

    public static int deleteUser(Connection conn, int userId) throws SQLException {
        String sql = "DELETE FROM users WHERE id = ?";

        try (PreparedStatement pstmt = conn.prepareStatement(sql)) {
            pstmt.setInt(1, userId);

            int rowsAffected = pstmt.executeUpdate();
            if (rowsAffected > 0) {
                System.out.println("Deleted user with ID " + userId);
            } else {
                System.out.println("No user found with ID " + userId);
            }
            return rowsAffected;
        }
    }

    // Soft delete -- better practice than hard delete
    public static int deactivateUser(Connection conn, int userId) throws SQLException {
        String sql = "UPDATE users SET active = false WHERE id = ? AND active = true";

        try (PreparedStatement pstmt = conn.prepareStatement(sql)) {
            pstmt.setInt(1, userId);

            int rowsAffected = pstmt.executeUpdate();
            System.out.println(rowsAffected > 0 ? "User deactivated." : "User not found or already inactive.");
            return rowsAffected;
        }
    }
}
// Output:
// Deleted user with ID 3
// User deactivated.

Execute Method Summary

Method Returns Use For
executeQuery(sql) ResultSet SELECT statements
executeUpdate(sql) int (rows affected) INSERT, UPDATE, DELETE, DDL
execute(sql) boolean (true if ResultSet) When you do not know the SQL type at compile time

6. ResultSet in Detail

A ResultSet is a table of data returned by a query. It maintains an internal cursor pointing to the current row. The cursor starts before the first row, so you must call next() to move to the first row.

Navigating a ResultSet

public class ResultSetNavigation {
    public static void main(String[] args) throws SQLException {
        try (Connection conn = DriverManager.getConnection("jdbc:h2:mem:testdb", "sa", "")) {
            // Setup
            Statement setup = conn.createStatement();
            setup.executeUpdate("CREATE TABLE products (id INT, name VARCHAR(50), price DECIMAL(10,2))");
            setup.executeUpdate("INSERT INTO products VALUES (1, 'Laptop', 999.99)");
            setup.executeUpdate("INSERT INTO products VALUES (2, 'Mouse', 29.99)");
            setup.executeUpdate("INSERT INTO products VALUES (3, 'Keyboard', 79.99)");

            // Basic forward-only iteration
            try (PreparedStatement pstmt = conn.prepareStatement("SELECT * FROM products");
                 ResultSet rs = pstmt.executeQuery()) {

                // Access by column name (preferred -- more readable)
                while (rs.next()) {
                    String name = rs.getString("name");
                    double price = rs.getDouble("price");
                    System.out.println(name + ": $" + price);
                }
            }

            // Access by column index (1-based, faster but fragile)
            try (PreparedStatement pstmt = conn.prepareStatement("SELECT * FROM products");
                 ResultSet rs = pstmt.executeQuery()) {

                while (rs.next()) {
                    int id = rs.getInt(1);        // column 1
                    String name = rs.getString(2); // column 2
                    double price = rs.getDouble(3); // column 3
                    System.out.println(id + ". " + name + ": $" + price);
                }
            }
        }
    }
}
// Output:
// Laptop: $999.99
// Mouse: $29.99
// Keyboard: $79.99
// 1. Laptop: $999.99
// 2. Mouse: $29.99
// 3. Keyboard: $79.99

Handling NULL Values

When a database column is NULL, JDBC getter methods return a default value for primitives (0 for numbers, false for boolean). Use wasNull() to check:

// If age column is NULL in the database:
int age = rs.getInt("age");      // Returns 0 (not null, because int is a primitive)
boolean wasNull = rs.wasNull();  // Returns true -- the actual value was NULL

// Better approach: use wrapper types with getObject()
Integer age = rs.getObject("age", Integer.class);  // Returns null if column is NULL
if (age == null) {
    System.out.println("Age not provided");
} else {
    System.out.println("Age: " + age);
}

// For String, getString() already returns null for NULL columns
String email = rs.getString("email");  // Returns null if column is NULL

ResultSet Types

By default, a ResultSet is forward-only. You can request a scrollable ResultSet when creating the statement:

ResultSet Type Scroll? See Updates? Description
TYPE_FORWARD_ONLY No No Default. Cursor moves forward only. Most efficient.
TYPE_SCROLL_INSENSITIVE Yes No Can scroll in any direction. Snapshot at query time.
TYPE_SCROLL_SENSITIVE Yes Yes Reflects changes made by others. Rarely supported well.
// Create a scrollable ResultSet
try (PreparedStatement pstmt = conn.prepareStatement(
        "SELECT * FROM products",
        ResultSet.TYPE_SCROLL_INSENSITIVE,
        ResultSet.CONCUR_READ_ONLY)) {

    ResultSet rs = pstmt.executeQuery();

    // Move to last row
    rs.last();
    System.out.println("Last product: " + rs.getString("name"));
    System.out.println("Total rows: " + rs.getRow());

    // Move to first row
    rs.first();
    System.out.println("First product: " + rs.getString("name"));

    // Move to a specific row
    rs.absolute(2);
    System.out.println("Second product: " + rs.getString("name"));

    // Move relative to current position
    rs.relative(-1);  // one row back
    System.out.println("Back to first: " + rs.getString("name"));
}
// Output:
// Last product: Keyboard
// Total rows: 3
// First product: Laptop
// Second product: Mouse
// Back to first: Laptop

ResultSetMetaData

When you need to inspect the structure of a result set dynamically (column names, types, count):

try (PreparedStatement pstmt = conn.prepareStatement("SELECT * FROM users");
     ResultSet rs = pstmt.executeQuery()) {

    ResultSetMetaData meta = rs.getMetaData();
    int columnCount = meta.getColumnCount();

    // Print column info
    System.out.println("Columns in result set:");
    for (int i = 1; i <= columnCount; i++) {
        System.out.printf("  %s (%s) - Nullable: %s%n",
                meta.getColumnName(i),
                meta.getColumnTypeName(i),
                meta.isNullable(i) == ResultSetMetaData.columnNullable ? "Yes" : "No");
    }

    // Print all data dynamically
    while (rs.next()) {
        for (int i = 1; i <= columnCount; i++) {
            System.out.print(meta.getColumnName(i) + "=" + rs.getObject(i) + " | ");
        }
        System.out.println();
    }
}
// Output:
// Columns in result set:
//   ID (INTEGER) - Nullable: No
//   USERNAME (VARCHAR) - Nullable: No
//   EMAIL (VARCHAR) - Nullable: No
//   AGE (INTEGER) - Nullable: Yes
//   SALARY (DECIMAL) - Nullable: Yes
//   ACTIVE (BOOLEAN) - Nullable: Yes
//   CREATED_AT (TIMESTAMP) - Nullable: Yes

7. Transactions

A transaction groups multiple SQL operations into a single unit of work that either all succeed or all fail. Think of transferring money between bank accounts -- you cannot debit one account without crediting the other.

ACID Properties

Property Meaning Example
Atomicity All operations succeed or all are rolled back Transfer: debit AND credit both happen, or neither does
Consistency Database moves from one valid state to another Total money across accounts stays the same
Isolation Concurrent transactions do not interfere Two transfers at the same time produce correct results
Durability Committed data survives system crashes Once "transfer complete" is shown, it is saved permanently

Auto-Commit Mode

By default, JDBC runs in auto-commit mode -- every SQL statement is automatically committed as soon as it executes. This means each statement is its own transaction. For multi-statement transactions, you must disable auto-commit:

import java.sql.*;

public class TransactionExample {

    public static void transferMoney(Connection conn, int fromAccountId,
                                      int toAccountId, double amount) throws SQLException {

        // Save auto-commit state to restore later
        boolean originalAutoCommit = conn.getAutoCommit();

        try {
            // Step 1: Disable auto-commit -- starts a transaction
            conn.setAutoCommit(false);

            // Step 2: Debit from source account
            String debitSql = "UPDATE accounts SET balance = balance - ? WHERE id = ? AND balance >= ?";
            try (PreparedStatement debit = conn.prepareStatement(debitSql)) {
                debit.setDouble(1, amount);
                debit.setInt(2, fromAccountId);
                debit.setDouble(3, amount);

                int rows = debit.executeUpdate();
                if (rows == 0) {
                    throw new SQLException("Insufficient funds or account not found: " + fromAccountId);
                }
            }

            // Step 3: Credit to destination account
            String creditSql = "UPDATE accounts SET balance = balance + ? WHERE id = ?";
            try (PreparedStatement credit = conn.prepareStatement(creditSql)) {
                credit.setDouble(1, amount);
                credit.setInt(2, toAccountId);

                int rows = credit.executeUpdate();
                if (rows == 0) {
                    throw new SQLException("Destination account not found: " + toAccountId);
                }
            }

            // Step 4: Both operations succeeded -- commit
            conn.commit();
            System.out.printf("Transferred $%.2f from account %d to account %d%n",
                    amount, fromAccountId, toAccountId);

        } catch (SQLException e) {
            // Step 5: Something went wrong -- rollback ALL changes
            conn.rollback();
            System.err.println("Transfer failed, rolled back: " + e.getMessage());
            throw e;

        } finally {
            // Restore original auto-commit state
            conn.setAutoCommit(originalAutoCommit);
        }
    }
}
// Output (success):
// Transferred $500.00 from account 1 to account 2
//
// Output (failure):
// Transfer failed, rolled back: Insufficient funds or account not found: 1

Savepoints

Savepoints let you roll back part of a transaction without undoing everything. They act as checkpoints within a transaction:

public static void processOrderWithSavepoint(Connection conn) throws SQLException {
    conn.setAutoCommit(false);
    Savepoint orderSavepoint = null;

    try {
        // Insert the order
        try (PreparedStatement pstmt = conn.prepareStatement(
                "INSERT INTO orders (customer_id, total) VALUES (?, ?)")) {
            pstmt.setInt(1, 42);
            pstmt.setDouble(2, 299.99);
            pstmt.executeUpdate();
        }

        // Mark a savepoint after the order is created
        orderSavepoint = conn.setSavepoint("afterOrder");

        // Try to insert shipping info (might fail)
        try {
            try (PreparedStatement pstmt = conn.prepareStatement(
                    "INSERT INTO shipping (order_id, address) VALUES (?, ?)")) {
                pstmt.setInt(1, 1);
                pstmt.setString(2, "123 Main St");
                pstmt.executeUpdate();
            }
        } catch (SQLException e) {
            // Shipping failed -- roll back to savepoint (order is still intact)
            conn.rollback(orderSavepoint);
            System.out.println("Shipping failed, but order preserved. Will ship later.");
        }

        conn.commit();
        System.out.println("Order processed successfully.");

    } catch (SQLException e) {
        conn.rollback();  // Roll back everything
        throw e;
    } finally {
        conn.setAutoCommit(true);
    }
}
// Output:
// Order processed successfully.

8. Batch Operations

When you need to execute the same SQL statement many times with different parameters (e.g., inserting 1,000 rows), sending each statement individually is extremely slow due to network round-trips. Batch operations bundle multiple statements into a single round-trip.

Performance Comparison

Approach 10,000 Inserts Network Round-Trips
Individual inserts ~30 seconds 10,000
Batch inserts ~0.5 seconds 1
Batch + transaction ~0.3 seconds 1
import java.sql.*;

public class BatchExample {

    public static void batchInsertUsers(Connection conn, List users) throws SQLException {
        String sql = "INSERT INTO users (username, email, age) VALUES (?, ?, ?)";

        conn.setAutoCommit(false);  // Wrap batch in a transaction for performance

        try (PreparedStatement pstmt = conn.prepareStatement(sql)) {

            int batchSize = 500;  // Flush every 500 rows to avoid memory issues
            int count = 0;

            for (String[] user : users) {
                pstmt.setString(1, user[0]);  // username
                pstmt.setString(2, user[1]);  // email
                pstmt.setInt(3, Integer.parseInt(user[2]));  // age

                pstmt.addBatch();  // Add to batch
                count++;

                if (count % batchSize == 0) {
                    pstmt.executeBatch();  // Send batch to database
                    pstmt.clearBatch();    // Clear for next batch
                    System.out.println("Flushed " + count + " rows...");
                }
            }

            // Execute remaining records
            pstmt.executeBatch();

            conn.commit();
            System.out.println("Batch insert complete: " + count + " total rows.");

        } catch (SQLException e) {
            conn.rollback();
            System.err.println("Batch failed, rolled back: " + e.getMessage());
            throw e;
        } finally {
            conn.setAutoCommit(true);
        }
    }

    public static void main(String[] args) throws SQLException {
        try (Connection conn = DriverManager.getConnection("jdbc:h2:mem:testdb", "sa", "")) {
            // Create table...

            List users = List.of(
                new String[]{"user1", "user1@mail.com", "25"},
                new String[]{"user2", "user2@mail.com", "30"},
                new String[]{"user3", "user3@mail.com", "35"},
                new String[]{"user4", "user4@mail.com", "28"},
                new String[]{"user5", "user5@mail.com", "42"}
            );

            batchInsertUsers(conn, users);
        }
    }
}
// Output:
// Batch insert complete: 5 total rows.

Batch with Statement (Multiple Different SQL)

You can also batch different SQL statements together using Statement:

try (Statement stmt = conn.createStatement()) {
    conn.setAutoCommit(false);

    stmt.addBatch("INSERT INTO audit_log (action) VALUES ('User created')");
    stmt.addBatch("UPDATE users SET active = true WHERE id = 1");
    stmt.addBatch("DELETE FROM temp_tokens WHERE expired = true");

    int[] results = stmt.executeBatch();
    // results[0] = rows affected by first statement
    // results[1] = rows affected by second statement
    // results[2] = rows affected by third statement

    for (int i = 0; i < results.length; i++) {
        System.out.println("Statement " + (i + 1) + ": " + results[i] + " row(s) affected");
    }

    conn.commit();
}
// Output:
// Statement 1: 1 row(s) affected
// Statement 2: 1 row(s) affected
// Statement 3: 3 row(s) affected

9. Connection Pooling

Creating a database connection is expensive. It involves TCP handshake, authentication, SSL negotiation, and resource allocation. Opening a new connection for every database call and closing it afterward is extremely wasteful in a real application.

Connection pooling solves this by maintaining a pool of pre-created, reusable connections. When your code needs a connection, it borrows one from the pool. When done, it returns it to the pool instead of closing it.

Without Pooling vs With Pooling

Without Pooling:
  Request 1: Open connection -> Execute query -> Close connection  (200ms overhead)
  Request 2: Open connection -> Execute query -> Close connection  (200ms overhead)
  Request 3: Open connection -> Execute query -> Close connection  (200ms overhead)

With Pooling:
  Startup:   Create 10 connections in pool
  Request 1: Borrow connection -> Execute query -> Return to pool  (0ms overhead)
  Request 2: Borrow connection -> Execute query -> Return to pool  (0ms overhead)
  Request 3: Borrow connection -> Execute query -> Return to pool  (0ms overhead)

HikariCP -- The Industry Standard

HikariCP is the fastest and most widely used connection pool for Java. Spring Boot uses it as the default. Add it as a dependency:



    com.zaxxer
    HikariCP
    5.1.0

Setting up HikariCP:

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;

public class ConnectionPoolExample {

    private static HikariDataSource dataSource;

    // Initialize the pool once at application startup
    public static void initPool() {
        HikariConfig config = new HikariConfig();

        // Required settings
        config.setJdbcUrl("jdbc:mysql://localhost:3306/mydb");
        config.setUsername("root");
        config.setPassword("secret");

        // Pool sizing
        config.setMaximumPoolSize(10);    // Max connections in pool
        config.setMinimumIdle(5);         // Min idle connections maintained
        config.setIdleTimeout(300000);    // 5 min -- close idle connections after this
        config.setMaxLifetime(1800000);   // 30 min -- max lifetime of a connection

        // Connection validation
        config.setConnectionTimeout(30000);  // 30 sec -- wait for connection from pool
        config.setConnectionTestQuery("SELECT 1");  // Validate connection health

        // Performance settings
        config.addDataSourceProperty("cachePrepStmts", "true");
        config.addDataSourceProperty("prepStmtCacheSize", "250");
        config.addDataSourceProperty("prepStmtCacheSqlLimit", "2048");

        dataSource = new HikariDataSource(config);
        System.out.println("Connection pool initialized.");
    }

    // Get a connection from the pool
    public static Connection getConnection() throws SQLException {
        return dataSource.getConnection();
    }

    // Shutdown the pool at application exit
    public static void closePool() {
        if (dataSource != null && !dataSource.isClosed()) {
            dataSource.close();
            System.out.println("Connection pool closed.");
        }
    }

    public static void main(String[] args) {
        initPool();

        // Use connections from the pool
        try (Connection conn = getConnection()) {
            // Connection is borrowed from the pool
            System.out.println("Got connection from pool: " + conn.isValid(2));
            // ... do work ...
        } catch (SQLException e) {
            e.printStackTrace();
        }
        // Connection is returned to the pool here, NOT closed

        closePool();
    }
}
// Output:
// Connection pool initialized.
// Got connection from pool: true
// Connection pool closed.

DataSource vs DriverManager

Feature DriverManager DataSource (with pool)
Connection creation New connection every time Reuses pooled connections
Performance Slow (TCP+auth per call) Fast (borrow/return)
Resource management Manual Automatic (pool manages lifecycle)
Configuration URL + user + password Pool size, timeouts, validation, metrics
Use case Learning, small scripts Production applications

Rule: Use DriverManager for learning and scripts. Use DataSource with HikariCP for any real application.

10. Stored Procedures

A stored procedure is a precompiled SQL program stored in the database. JDBC calls stored procedures using CallableStatement, which extends PreparedStatement.

First, here is a simple stored procedure in MySQL:

-- MySQL stored procedure: Get user by ID
DELIMITER //
CREATE PROCEDURE get_user_by_id(IN user_id INT)
BEGIN
    SELECT id, username, email, salary FROM users WHERE id = user_id;
END //
DELIMITER ;

-- MySQL stored procedure with OUT parameter
DELIMITER //
CREATE PROCEDURE count_active_users(OUT user_count INT)
BEGIN
    SELECT COUNT(*) INTO user_count FROM users WHERE active = true;
END //
DELIMITER ;

-- MySQL stored procedure with IN and OUT
DELIMITER //
CREATE PROCEDURE calculate_bonus(
    IN employee_id INT,
    IN bonus_percentage DECIMAL(5,2),
    OUT bonus_amount DECIMAL(10,2)
)
BEGIN
    SELECT salary * (bonus_percentage / 100) INTO bonus_amount
    FROM users WHERE id = employee_id;
END //
DELIMITER ;

Calling these procedures from Java:

import java.sql.*;

public class StoredProcedureExample {

    // Call procedure with IN parameter that returns a ResultSet
    public static void getUserById(Connection conn, int userId) throws SQLException {
        String sql = "{CALL get_user_by_id(?)}";

        try (CallableStatement cstmt = conn.prepareCall(sql)) {
            cstmt.setInt(1, userId);

            try (ResultSet rs = cstmt.executeQuery()) {
                if (rs.next()) {
                    System.out.printf("User: %s (%s) - $%.2f%n",
                            rs.getString("username"),
                            rs.getString("email"),
                            rs.getDouble("salary"));
                }
            }
        }
    }

    // Call procedure with OUT parameter
    public static int countActiveUsers(Connection conn) throws SQLException {
        String sql = "{CALL count_active_users(?)}";

        try (CallableStatement cstmt = conn.prepareCall(sql)) {
            cstmt.registerOutParameter(1, Types.INTEGER);  // Register OUT param
            cstmt.execute();

            int count = cstmt.getInt(1);  // Get the OUT value
            System.out.println("Active users: " + count);
            return count;
        }
    }

    // Call procedure with IN and OUT parameters
    public static double calculateBonus(Connection conn, int employeeId,
                                         double percentage) throws SQLException {
        String sql = "{CALL calculate_bonus(?, ?, ?)}";

        try (CallableStatement cstmt = conn.prepareCall(sql)) {
            cstmt.setInt(1, employeeId);                      // IN
            cstmt.setDouble(2, percentage);                    // IN
            cstmt.registerOutParameter(3, Types.DECIMAL);      // OUT
            cstmt.execute();

            double bonus = cstmt.getDouble(3);
            System.out.printf("Bonus for employee %d at %.1f%%: $%.2f%n",
                    employeeId, percentage, bonus);
            return bonus;
        }
    }
}
// Output:
// User: john_doe (john@example.com) - $75000.00
// Active users: 3
// Bonus for employee 1 at 10.0%: $7500.00

11. Common Mistakes

These are the mistakes that cause the most bugs, security vulnerabilities, and performance issues in JDBC code. Avoid every one of them.

Mistake 1: SQL Injection via String Concatenation

The single most dangerous mistake. Never concatenate user input into SQL strings:

// WRONG -- SQL injection vulnerability
String sql = "SELECT * FROM users WHERE username = '" + userInput + "'";
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(sql);

// RIGHT -- parameterized query
String sql = "SELECT * FROM users WHERE username = ?";
PreparedStatement pstmt = conn.prepareStatement(sql);
pstmt.setString(1, userInput);
ResultSet rs = pstmt.executeQuery();

Mistake 2: Not Closing Resources

Every Connection, Statement, and ResultSet holds database and JVM resources. Forgetting to close them causes connection leaks, which eventually crash your application:

// WRONG -- resources never closed if exception occurs
Connection conn = DriverManager.getConnection(url, user, password);
PreparedStatement pstmt = conn.prepareStatement(sql);
ResultSet rs = pstmt.executeQuery();
// if exception here, conn, pstmt, and rs are leaked
rs.close();
pstmt.close();
conn.close();

// RIGHT -- try-with-resources guarantees cleanup
try (Connection conn = DriverManager.getConnection(url, user, password);
     PreparedStatement pstmt = conn.prepareStatement(sql);
     ResultSet rs = pstmt.executeQuery()) {
    // Work with rs...
} // All three auto-closed, even on exception

Mistake 3: Ignoring Transactions

With auto-commit on (the default), each statement is its own transaction. If a multi-step operation fails halfway, you end up with inconsistent data:

// WRONG -- auto-commit means each statement is independent
pstmt1.executeUpdate();  // Debits account A -- committed immediately
pstmt2.executeUpdate();  // Credits account B -- FAILS!
// Account A was debited but account B was never credited. Money vanished.

// RIGHT -- wrap related operations in a transaction
conn.setAutoCommit(false);
try {
    pstmt1.executeUpdate();  // Debits account A -- NOT committed yet
    pstmt2.executeUpdate();  // Credits account B -- NOT committed yet
    conn.commit();           // Both committed together
} catch (SQLException e) {
    conn.rollback();         // Both rolled back together
}

Mistake 4: The N+1 Query Problem

Executing a query inside a loop, generating N additional queries for N results:

// WRONG -- N+1 problem: 1 query to get orders + N queries to get users
ResultSet orders = stmt.executeQuery("SELECT * FROM orders");  // 1 query
while (orders.next()) {
    int userId = orders.getInt("user_id");
    // This runs once per order -- if 1000 orders, that's 1000 extra queries!
    PreparedStatement userStmt = conn.prepareStatement("SELECT * FROM users WHERE id = ?");
    userStmt.setInt(1, userId);
    ResultSet user = userStmt.executeQuery();
    // ...
}

// RIGHT -- use a JOIN to get all data in one query
String sql = """
    SELECT o.id AS order_id, o.total, u.username, u.email
    FROM orders o
    JOIN users u ON o.user_id = u.id
    """;
ResultSet rs = stmt.executeQuery(sql);  // 1 query for everything

Mistake 5: Hardcoding Database Credentials

// WRONG -- credentials in source code (committed to git!)
Connection conn = DriverManager.getConnection(
    "jdbc:mysql://prod-server:3306/mydb", "admin", "P@ssw0rd!");

// RIGHT -- use environment variables or config files
String url = System.getenv("DB_URL");
String user = System.getenv("DB_USER");
String password = System.getenv("DB_PASSWORD");
Connection conn = DriverManager.getConnection(url, user, password);

Mistake 6: Using getObject() Without Type Safety

// WRONG -- loses type safety, requires casting
Object value = rs.getObject("salary");
double salary = (Double) value;  // ClassCastException if null or wrong type

// RIGHT -- use typed getters
double salary = rs.getDouble("salary");
if (rs.wasNull()) {
    // Handle null case
}

// ALSO RIGHT -- use getObject with type parameter (Java 7+)
Double salary = rs.getObject("salary", Double.class);  // Returns null if SQL NULL

12. Best Practices

These are the practices that separate production-quality JDBC code from tutorial-level code. Follow every one of them.

12.1 Always Use PreparedStatement

Even when the query has no parameters, PreparedStatement gives you precompilation benefits and establishes a consistent pattern. The only exception is DDL (CREATE TABLE, ALTER TABLE).

12.2 Always Use try-with-resources

Close resources in the reverse order of creation. With try-with-resources, this is automatic:

// Gold standard pattern for JDBC
try (Connection conn = dataSource.getConnection();
     PreparedStatement pstmt = conn.prepareStatement(sql)) {

    pstmt.setString(1, value);

    try (ResultSet rs = pstmt.executeQuery()) {
        while (rs.next()) {
            // Process row
        }
    }
}

12.3 Use Connection Pooling in Production

Never use DriverManager.getConnection() in production. Always use a connection pool (HikariCP). If using Spring Boot, HikariCP is the default -- just configure it in application.properties.

12.4 Use Transactions for Multi-Statement Operations

Any operation that modifies multiple rows or tables should be wrapped in a transaction. Follow this pattern:

public void doMultiStepWork(Connection conn) throws SQLException {
    conn.setAutoCommit(false);
    try {
        // Step 1...
        // Step 2...
        // Step 3...
        conn.commit();
    } catch (SQLException e) {
        conn.rollback();
        throw e;  // Re-throw so the caller knows it failed
    } finally {
        conn.setAutoCommit(true);
    }
}

12.5 Keep SQL Out of Business Logic

Use the DAO pattern (covered in the next section) to separate data access from business logic. Your service classes should not contain SQL strings.

12.6 Log SQL Errors Properly

try {
    // database operation
} catch (SQLException e) {
    // Log ALL the information -- you will need it for debugging
    logger.error("Database error - Message: {} | SQLState: {} | ErrorCode: {} | SQL: {}",
            e.getMessage(), e.getSQLState(), e.getErrorCode(), sql, e);
    throw e;  // Re-throw or wrap in a custom exception
}

12.7 Use Meaningful Column Aliases

// WRONG -- what does rs.getString(1) mean? Fragile if column order changes.
ResultSet rs = stmt.executeQuery("SELECT a, b, c FROM table1");
String x = rs.getString(1);

// RIGHT -- use aliases and access by name
String sql = """
    SELECT u.username AS user_name,
           o.total    AS order_total,
           o.created  AS order_date
    FROM users u
    JOIN orders o ON u.id = o.user_id
    """;
ResultSet rs = stmt.executeQuery(sql);
String name = rs.getString("user_name");
double total = rs.getDouble("order_total");

Best Practices Summary

Practice Do Don't
SQL parameters Use PreparedStatement with ? Concatenate strings into SQL
Resource cleanup Use try-with-resources Manual close in finally block
Connections Use connection pool (HikariCP) Create new connections per query
Transactions Explicit setAutoCommit(false) for multi-step ops Rely on auto-commit for related changes
Architecture DAO pattern, SQL in data layer only SQL strings scattered in business logic
Column access Access by column name Access by column index
Credentials Environment variables or config files Hardcode in source code
Error handling Log SQL state, error code, and message Catch and swallow exceptions
Queries Use JOINs for related data Query in a loop (N+1)
Null handling Check wasNull() or use getObject() Assume primitives from DB are never null

13. DAO Pattern

The Data Access Object (DAO) pattern separates the data access logic from the business logic. Your service layer should not know or care whether data comes from MySQL, PostgreSQL, a REST API, or a flat file. It just calls methods on a DAO interface.

Why Use the DAO Pattern?

  • Separation of concerns -- SQL stays in one place, business logic in another
  • Testability -- you can mock the DAO interface in unit tests
  • Swappability -- switch databases or use in-memory implementations without changing business logic
  • Single responsibility -- each DAO handles one entity (UserDAO, OrderDAO, ProductDAO)

Structure

    +------------------+          +------------------+
    |   UserService    | -------> |    UserDAO       |  <-- Interface
    | (business logic) |          |  (data contract) |
    +------------------+          +------------------+
                                         ^
                                         |  implements
                                  +------------------+
                                  |  UserDAOImpl     |  <-- JDBC implementation
                                  | (SQL + JDBC)     |
                                  +------------------+

Step 1: Define the Model

import java.time.LocalDateTime;

public class User {
    private int id;
    private String username;
    private String email;
    private int age;
    private double salary;
    private boolean active;
    private LocalDateTime createdAt;

    // No-arg constructor (needed for DAO mapping)
    public User() {}

    // Constructor for creating new users (no ID yet)
    public User(String username, String email, int age, double salary) {
        this.username = username;
        this.email = email;
        this.age = age;
        this.salary = salary;
        this.active = true;
    }

    // Getters and setters
    public int getId() { return id; }
    public void setId(int id) { this.id = id; }
    public String getUsername() { return username; }
    public void setUsername(String username) { this.username = username; }
    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }
    public int getAge() { return age; }
    public void setAge(int age) { this.age = age; }
    public double getSalary() { return salary; }
    public void setSalary(double salary) { this.salary = salary; }
    public boolean isActive() { return active; }
    public void setActive(boolean active) { this.active = active; }
    public LocalDateTime getCreatedAt() { return createdAt; }
    public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }

    @Override
    public String toString() {
        return String.format("User{id=%d, username='%s', email='%s', age=%d, salary=%.2f, active=%b}",
                id, username, email, age, salary, active);
    }
}

Step 2: Define the DAO Interface

import java.util.List;
import java.util.Optional;

public interface UserDAO {
    // Create
    User save(User user);

    // Read
    Optional findById(int id);
    Optional findByUsername(String username);
    List findAll();
    List findByMinSalary(double minSalary);

    // Update
    boolean update(User user);

    // Delete
    boolean deleteById(int id);

    // Count
    long count();
}

Step 3: Implement the DAO with JDBC

import javax.sql.DataSource;
import java.sql.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

public class UserDAOImpl implements UserDAO {

    private final DataSource dataSource;

    public UserDAOImpl(DataSource dataSource) {
        this.dataSource = dataSource;  // Injected -- no hardcoded connection info
    }

    @Override
    public User save(User user) {
        String sql = "INSERT INTO users (username, email, age, salary, active) VALUES (?, ?, ?, ?, ?)";

        try (Connection conn = dataSource.getConnection();
             PreparedStatement pstmt = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {

            pstmt.setString(1, user.getUsername());
            pstmt.setString(2, user.getEmail());
            pstmt.setInt(3, user.getAge());
            pstmt.setDouble(4, user.getSalary());
            pstmt.setBoolean(5, user.isActive());

            pstmt.executeUpdate();

            try (ResultSet keys = pstmt.getGeneratedKeys()) {
                if (keys.next()) {
                    user.setId(keys.getInt(1));
                }
            }
            return user;

        } catch (SQLException e) {
            throw new RuntimeException("Failed to save user: " + user.getUsername(), e);
        }
    }

    @Override
    public Optional findById(int id) {
        String sql = "SELECT * FROM users WHERE id = ?";

        try (Connection conn = dataSource.getConnection();
             PreparedStatement pstmt = conn.prepareStatement(sql)) {

            pstmt.setInt(1, id);

            try (ResultSet rs = pstmt.executeQuery()) {
                if (rs.next()) {
                    return Optional.of(mapRow(rs));
                }
            }
            return Optional.empty();

        } catch (SQLException e) {
            throw new RuntimeException("Failed to find user by ID: " + id, e);
        }
    }

    @Override
    public Optional findByUsername(String username) {
        String sql = "SELECT * FROM users WHERE username = ?";

        try (Connection conn = dataSource.getConnection();
             PreparedStatement pstmt = conn.prepareStatement(sql)) {

            pstmt.setString(1, username);

            try (ResultSet rs = pstmt.executeQuery()) {
                if (rs.next()) {
                    return Optional.of(mapRow(rs));
                }
            }
            return Optional.empty();

        } catch (SQLException e) {
            throw new RuntimeException("Failed to find user by username: " + username, e);
        }
    }

    @Override
    public List findAll() {
        String sql = "SELECT * FROM users ORDER BY id";
        List users = new ArrayList<>();

        try (Connection conn = dataSource.getConnection();
             PreparedStatement pstmt = conn.prepareStatement(sql);
             ResultSet rs = pstmt.executeQuery()) {

            while (rs.next()) {
                users.add(mapRow(rs));
            }
            return users;

        } catch (SQLException e) {
            throw new RuntimeException("Failed to find all users", e);
        }
    }

    @Override
    public List findByMinSalary(double minSalary) {
        String sql = "SELECT * FROM users WHERE salary >= ? ORDER BY salary DESC";
        List users = new ArrayList<>();

        try (Connection conn = dataSource.getConnection();
             PreparedStatement pstmt = conn.prepareStatement(sql)) {

            pstmt.setDouble(1, minSalary);

            try (ResultSet rs = pstmt.executeQuery()) {
                while (rs.next()) {
                    users.add(mapRow(rs));
                }
            }
            return users;

        } catch (SQLException e) {
            throw new RuntimeException("Failed to find users by salary", e);
        }
    }

    @Override
    public boolean update(User user) {
        String sql = "UPDATE users SET username = ?, email = ?, age = ?, salary = ?, active = ? WHERE id = ?";

        try (Connection conn = dataSource.getConnection();
             PreparedStatement pstmt = conn.prepareStatement(sql)) {

            pstmt.setString(1, user.getUsername());
            pstmt.setString(2, user.getEmail());
            pstmt.setInt(3, user.getAge());
            pstmt.setDouble(4, user.getSalary());
            pstmt.setBoolean(5, user.isActive());
            pstmt.setInt(6, user.getId());

            return pstmt.executeUpdate() > 0;

        } catch (SQLException e) {
            throw new RuntimeException("Failed to update user: " + user.getId(), e);
        }
    }

    @Override
    public boolean deleteById(int id) {
        String sql = "DELETE FROM users WHERE id = ?";

        try (Connection conn = dataSource.getConnection();
             PreparedStatement pstmt = conn.prepareStatement(sql)) {

            pstmt.setInt(1, id);
            return pstmt.executeUpdate() > 0;

        } catch (SQLException e) {
            throw new RuntimeException("Failed to delete user: " + id, e);
        }
    }

    @Override
    public long count() {
        String sql = "SELECT COUNT(*) FROM users";

        try (Connection conn = dataSource.getConnection();
             PreparedStatement pstmt = conn.prepareStatement(sql);
             ResultSet rs = pstmt.executeQuery()) {

            rs.next();
            return rs.getLong(1);

        } catch (SQLException e) {
            throw new RuntimeException("Failed to count users", e);
        }
    }

    // Private helper -- maps a ResultSet row to a User object
    private User mapRow(ResultSet rs) throws SQLException {
        User user = new User();
        user.setId(rs.getInt("id"));
        user.setUsername(rs.getString("username"));
        user.setEmail(rs.getString("email"));
        user.setAge(rs.getInt("age"));
        user.setSalary(rs.getDouble("salary"));
        user.setActive(rs.getBoolean("active"));

        Timestamp ts = rs.getTimestamp("created_at");
        if (ts != null) {
            user.setCreatedAt(ts.toLocalDateTime());
        }
        return user;
    }
}

Step 4: Use the DAO in Business Logic

public class UserService {

    private final UserDAO userDAO;

    public UserService(UserDAO userDAO) {
        this.userDAO = userDAO;  // Dependency injection
    }

    public User registerUser(String username, String email, int age, double salary) {
        // Business logic validation -- no SQL here
        if (username == null || username.isBlank()) {
            throw new IllegalArgumentException("Username cannot be empty");
        }
        if (userDAO.findByUsername(username).isPresent()) {
            throw new IllegalArgumentException("Username already taken: " + username);
        }

        User user = new User(username, email, age, salary);
        return userDAO.save(user);
    }

    public User getUserOrThrow(int id) {
        return userDAO.findById(id)
                .orElseThrow(() -> new RuntimeException("User not found: " + id));
    }

    public List getHighEarners(double threshold) {
        return userDAO.findByMinSalary(threshold);
    }
}

// Usage:
// UserDAO dao = new UserDAOImpl(dataSource);
// UserService service = new UserService(dao);
// User user = service.registerUser("john_doe", "john@example.com", 28, 75000);

14. Complete Practical Example

Let us put everything together in a complete, runnable application. This example demonstrates a CRUD application for managing employees with proper resource management, transactions, batch operations, the DAO pattern, and all the best practices covered in this tutorial.

We will use the H2 in-memory database so you can run this without installing anything.

Project Structure

src/
  Employee.java              -- Model class
  EmployeeDAO.java            -- DAO interface
  EmployeeDAOImpl.java        -- JDBC implementation with all best practices
  EmployeeApp.java            -- Main application demonstrating all operations

Employee Model

import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;

public class Employee {
    private int id;
    private String firstName;
    private String lastName;
    private String email;
    private String department;
    private BigDecimal salary;
    private LocalDate hireDate;
    private boolean active;
    private LocalDateTime createdAt;

    public Employee() {}

    public Employee(String firstName, String lastName, String email,
                    String department, BigDecimal salary, LocalDate hireDate) {
        this.firstName = firstName;
        this.lastName = lastName;
        this.email = email;
        this.department = department;
        this.salary = salary;
        this.hireDate = hireDate;
        this.active = true;
    }

    // Getters and setters
    public int getId() { return id; }
    public void setId(int id) { this.id = id; }
    public String getFirstName() { return firstName; }
    public void setFirstName(String firstName) { this.firstName = firstName; }
    public String getLastName() { return lastName; }
    public void setLastName(String lastName) { this.lastName = lastName; }
    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }
    public String getDepartment() { return department; }
    public void setDepartment(String department) { this.department = department; }
    public BigDecimal getSalary() { return salary; }
    public void setSalary(BigDecimal salary) { this.salary = salary; }
    public LocalDate getHireDate() { return hireDate; }
    public void setHireDate(LocalDate hireDate) { this.hireDate = hireDate; }
    public boolean isActive() { return active; }
    public void setActive(boolean active) { this.active = active; }
    public LocalDateTime getCreatedAt() { return createdAt; }
    public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }

    public String getFullName() {
        return firstName + " " + lastName;
    }

    @Override
    public String toString() {
        return String.format("Employee{id=%d, name='%s %s', dept='%s', salary=%s, active=%b}",
                id, firstName, lastName, department, salary, active);
    }
}

EmployeeDAO Interface

import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;

public interface EmployeeDAO {

    // Schema management
    void createTable();

    // CRUD
    Employee save(Employee employee);
    Optional findById(int id);
    List findAll();
    List findByDepartment(String department);
    boolean update(Employee employee);
    boolean deleteById(int id);

    // Batch
    int[] saveBatch(List employees);

    // Transaction example
    boolean transferDepartment(int employeeId, String newDepartment, BigDecimal salaryAdjustment);

    // Aggregation
    long count();
    BigDecimal getAverageSalaryByDepartment(String department);
}

EmployeeDAOImpl -- Full JDBC Implementation

import javax.sql.DataSource;
import java.math.BigDecimal;
import java.sql.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

public class EmployeeDAOImpl implements EmployeeDAO {

    private final DataSource dataSource;

    public EmployeeDAOImpl(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    @Override
    public void createTable() {
        String sql = """
            CREATE TABLE IF NOT EXISTS employees (
                id          INT AUTO_INCREMENT PRIMARY KEY,
                first_name  VARCHAR(50) NOT NULL,
                last_name   VARCHAR(50) NOT NULL,
                email       VARCHAR(100) NOT NULL UNIQUE,
                department  VARCHAR(50) NOT NULL,
                salary      DECIMAL(10,2) NOT NULL,
                hire_date   DATE NOT NULL,
                active      BOOLEAN DEFAULT TRUE,
                created_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP
            )
            """;

        try (Connection conn = dataSource.getConnection();
             Statement stmt = conn.createStatement()) {
            stmt.executeUpdate(sql);
        } catch (SQLException e) {
            throw new RuntimeException("Failed to create employees table", e);
        }
    }

    @Override
    public Employee save(Employee emp) {
        String sql = """
            INSERT INTO employees (first_name, last_name, email, department, salary, hire_date, active)
            VALUES (?, ?, ?, ?, ?, ?, ?)
            """;

        try (Connection conn = dataSource.getConnection();
             PreparedStatement pstmt = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {

            setEmployeeParams(pstmt, emp);
            pstmt.executeUpdate();

            try (ResultSet keys = pstmt.getGeneratedKeys()) {
                if (keys.next()) {
                    emp.setId(keys.getInt(1));
                }
            }
            return emp;

        } catch (SQLException e) {
            throw new RuntimeException("Failed to save employee: " + emp.getEmail(), e);
        }
    }

    @Override
    public Optional findById(int id) {
        String sql = "SELECT * FROM employees WHERE id = ?";

        try (Connection conn = dataSource.getConnection();
             PreparedStatement pstmt = conn.prepareStatement(sql)) {

            pstmt.setInt(1, id);
            try (ResultSet rs = pstmt.executeQuery()) {
                return rs.next() ? Optional.of(mapRow(rs)) : Optional.empty();
            }

        } catch (SQLException e) {
            throw new RuntimeException("Failed to find employee: " + id, e);
        }
    }

    @Override
    public List findAll() {
        String sql = "SELECT * FROM employees ORDER BY last_name, first_name";
        return executeQuery(sql);
    }

    @Override
    public List findByDepartment(String department) {
        String sql = "SELECT * FROM employees WHERE department = ? AND active = true ORDER BY last_name";
        List employees = new ArrayList<>();

        try (Connection conn = dataSource.getConnection();
             PreparedStatement pstmt = conn.prepareStatement(sql)) {

            pstmt.setString(1, department);
            try (ResultSet rs = pstmt.executeQuery()) {
                while (rs.next()) {
                    employees.add(mapRow(rs));
                }
            }
            return employees;

        } catch (SQLException e) {
            throw new RuntimeException("Failed to find employees in department: " + department, e);
        }
    }

    @Override
    public boolean update(Employee emp) {
        String sql = """
            UPDATE employees
            SET first_name = ?, last_name = ?, email = ?, department = ?,
                salary = ?, hire_date = ?, active = ?
            WHERE id = ?
            """;

        try (Connection conn = dataSource.getConnection();
             PreparedStatement pstmt = conn.prepareStatement(sql)) {

            setEmployeeParams(pstmt, emp);
            pstmt.setInt(8, emp.getId());
            return pstmt.executeUpdate() > 0;

        } catch (SQLException e) {
            throw new RuntimeException("Failed to update employee: " + emp.getId(), e);
        }
    }

    @Override
    public boolean deleteById(int id) {
        String sql = "DELETE FROM employees WHERE id = ?";

        try (Connection conn = dataSource.getConnection();
             PreparedStatement pstmt = conn.prepareStatement(sql)) {

            pstmt.setInt(1, id);
            return pstmt.executeUpdate() > 0;

        } catch (SQLException e) {
            throw new RuntimeException("Failed to delete employee: " + id, e);
        }
    }

    @Override
    public int[] saveBatch(List employees) {
        String sql = """
            INSERT INTO employees (first_name, last_name, email, department, salary, hire_date, active)
            VALUES (?, ?, ?, ?, ?, ?, ?)
            """;

        try (Connection conn = dataSource.getConnection()) {
            conn.setAutoCommit(false);

            try (PreparedStatement pstmt = conn.prepareStatement(sql)) {
                for (Employee emp : employees) {
                    setEmployeeParams(pstmt, emp);
                    pstmt.addBatch();
                }

                int[] results = pstmt.executeBatch();
                conn.commit();
                return results;

            } catch (SQLException e) {
                conn.rollback();
                throw e;
            } finally {
                conn.setAutoCommit(true);
            }

        } catch (SQLException e) {
            throw new RuntimeException("Batch insert failed", e);
        }
    }

    @Override
    public boolean transferDepartment(int employeeId, String newDepartment,
                                       BigDecimal salaryAdjustment) {
        String updateEmpSql = "UPDATE employees SET department = ?, salary = salary + ? WHERE id = ?";
        String auditSql = """
            INSERT INTO department_transfers (employee_id, new_department, salary_change, transfer_date)
            VALUES (?, ?, ?, CURRENT_DATE)
            """;

        try (Connection conn = dataSource.getConnection()) {
            conn.setAutoCommit(false);

            try {
                // Step 1: Update employee's department and salary
                try (PreparedStatement pstmt = conn.prepareStatement(updateEmpSql)) {
                    pstmt.setString(1, newDepartment);
                    pstmt.setBigDecimal(2, salaryAdjustment);
                    pstmt.setInt(3, employeeId);

                    int rows = pstmt.executeUpdate();
                    if (rows == 0) {
                        throw new SQLException("Employee not found: " + employeeId);
                    }
                }

                // Step 2: Record the transfer in audit table
                // (Create audit table if it does not exist)
                try (Statement stmt = conn.createStatement()) {
                    stmt.executeUpdate("""
                        CREATE TABLE IF NOT EXISTS department_transfers (
                            id INT AUTO_INCREMENT PRIMARY KEY,
                            employee_id INT NOT NULL,
                            new_department VARCHAR(50) NOT NULL,
                            salary_change DECIMAL(10,2),
                            transfer_date DATE NOT NULL
                        )
                        """);
                }

                try (PreparedStatement pstmt = conn.prepareStatement(auditSql)) {
                    pstmt.setInt(1, employeeId);
                    pstmt.setString(2, newDepartment);
                    pstmt.setBigDecimal(3, salaryAdjustment);
                    pstmt.executeUpdate();
                }

                conn.commit();
                return true;

            } catch (SQLException e) {
                conn.rollback();
                throw e;
            } finally {
                conn.setAutoCommit(true);
            }

        } catch (SQLException e) {
            throw new RuntimeException("Department transfer failed for employee: " + employeeId, e);
        }
    }

    @Override
    public long count() {
        String sql = "SELECT COUNT(*) FROM employees";

        try (Connection conn = dataSource.getConnection();
             PreparedStatement pstmt = conn.prepareStatement(sql);
             ResultSet rs = pstmt.executeQuery()) {

            rs.next();
            return rs.getLong(1);

        } catch (SQLException e) {
            throw new RuntimeException("Failed to count employees", e);
        }
    }

    @Override
    public BigDecimal getAverageSalaryByDepartment(String department) {
        String sql = "SELECT AVG(salary) FROM employees WHERE department = ? AND active = true";

        try (Connection conn = dataSource.getConnection();
             PreparedStatement pstmt = conn.prepareStatement(sql)) {

            pstmt.setString(1, department);
            try (ResultSet rs = pstmt.executeQuery()) {
                rs.next();
                BigDecimal avg = rs.getBigDecimal(1);
                return avg != null ? avg : BigDecimal.ZERO;
            }

        } catch (SQLException e) {
            throw new RuntimeException("Failed to get average salary for: " + department, e);
        }
    }

    // ---------- Private Helpers ----------

    private void setEmployeeParams(PreparedStatement pstmt, Employee emp) throws SQLException {
        pstmt.setString(1, emp.getFirstName());
        pstmt.setString(2, emp.getLastName());
        pstmt.setString(3, emp.getEmail());
        pstmt.setString(4, emp.getDepartment());
        pstmt.setBigDecimal(5, emp.getSalary());
        pstmt.setDate(6, Date.valueOf(emp.getHireDate()));
        pstmt.setBoolean(7, emp.isActive());
    }

    private Employee mapRow(ResultSet rs) throws SQLException {
        Employee emp = new Employee();
        emp.setId(rs.getInt("id"));
        emp.setFirstName(rs.getString("first_name"));
        emp.setLastName(rs.getString("last_name"));
        emp.setEmail(rs.getString("email"));
        emp.setDepartment(rs.getString("department"));
        emp.setSalary(rs.getBigDecimal("salary"));
        emp.setHireDate(rs.getDate("hire_date").toLocalDate());
        emp.setActive(rs.getBoolean("active"));
        Timestamp ts = rs.getTimestamp("created_at");
        if (ts != null) {
            emp.setCreatedAt(ts.toLocalDateTime());
        }
        return emp;
    }

    private List executeQuery(String sql) {
        List employees = new ArrayList<>();
        try (Connection conn = dataSource.getConnection();
             PreparedStatement pstmt = conn.prepareStatement(sql);
             ResultSet rs = pstmt.executeQuery()) {

            while (rs.next()) {
                employees.add(mapRow(rs));
            }
            return employees;

        } catch (SQLException e) {
            throw new RuntimeException("Query failed: " + sql, e);
        }
    }
}

EmployeeApp -- Main Application

import org.h2.jdbcx.JdbcDataSource;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;

public class EmployeeApp {

    public static void main(String[] args) {
        // 1. Set up DataSource (H2 in-memory database)
        JdbcDataSource dataSource = new JdbcDataSource();
        dataSource.setURL("jdbc:h2:mem:company;DB_CLOSE_DELAY=-1");
        dataSource.setUser("sa");
        dataSource.setPassword("");

        // 2. Create DAO
        EmployeeDAO dao = new EmployeeDAOImpl(dataSource);

        // 3. Create the table
        dao.createTable();
        System.out.println("=== Table created ===\n");

        // 4. INSERT -- Save individual employees
        Employee alice = dao.save(new Employee(
                "Alice", "Johnson", "alice@company.com",
                "Engineering", new BigDecimal("95000.00"), LocalDate.of(2020, 3, 15)));
        Employee bob = dao.save(new Employee(
                "Bob", "Smith", "bob@company.com",
                "Engineering", new BigDecimal("88000.00"), LocalDate.of(2021, 7, 1)));
        System.out.println("Saved: " + alice);
        System.out.println("Saved: " + bob);

        // 5. BATCH INSERT -- Save multiple employees at once
        List batch = List.of(
                new Employee("Carol", "Williams", "carol@company.com",
                        "Marketing", new BigDecimal("72000.00"), LocalDate.of(2022, 1, 10)),
                new Employee("David", "Brown", "david@company.com",
                        "Marketing", new BigDecimal("68000.00"), LocalDate.of(2022, 6, 20)),
                new Employee("Eve", "Davis", "eve@company.com",
                        "Engineering", new BigDecimal("105000.00"), LocalDate.of(2019, 11, 5))
        );
        int[] batchResults = dao.saveBatch(batch);
        System.out.println("\nBatch inserted " + batchResults.length + " employees.");

        // 6. SELECT -- Find all employees
        System.out.println("\n=== All Employees ===");
        List allEmployees = dao.findAll();
        allEmployees.forEach(System.out::println);

        // 7. SELECT -- Find by ID
        System.out.println("\n=== Find by ID ===");
        dao.findById(1).ifPresentOrElse(
                e -> System.out.println("Found: " + e),
                () -> System.out.println("Not found")
        );

        // 8. SELECT -- Find by department
        System.out.println("\n=== Engineering Department ===");
        dao.findByDepartment("Engineering").forEach(System.out::println);

        // 9. UPDATE -- Give Alice a raise
        System.out.println("\n=== Update: Alice gets a raise ===");
        alice.setSalary(new BigDecimal("105000.00"));
        alice.setEmail("alice.johnson@company.com");
        boolean updated = dao.update(alice);
        System.out.println("Updated: " + updated);
        dao.findById(alice.getId()).ifPresent(System.out::println);

        // 10. TRANSACTION -- Transfer Bob to Marketing with a salary adjustment
        System.out.println("\n=== Transaction: Transfer Bob to Marketing ===");
        boolean transferred = dao.transferDepartment(bob.getId(), "Marketing", new BigDecimal("5000.00"));
        System.out.println("Transfer successful: " + transferred);
        dao.findById(bob.getId()).ifPresent(System.out::println);

        // 11. AGGREGATION -- Average salary by department
        System.out.println("\n=== Average Salaries ===");
        System.out.println("Engineering avg: $" + dao.getAverageSalaryByDepartment("Engineering"));
        System.out.println("Marketing avg: $" + dao.getAverageSalaryByDepartment("Marketing"));

        // 12. DELETE -- Remove David
        System.out.println("\n=== Delete David ===");
        boolean deleted = dao.deleteById(4);
        System.out.println("Deleted: " + deleted);

        // 13. Final state
        System.out.println("\n=== Final State ===");
        System.out.println("Total employees: " + dao.count());
        dao.findAll().forEach(System.out::println);
    }
}

Expected Output

=== Table created ===

Saved: Employee{id=1, name='Alice Johnson', dept='Engineering', salary=95000.00, active=true}
Saved: Employee{id=2, name='Bob Smith', dept='Engineering', salary=88000.00, active=true}

Batch inserted 3 employees.

=== All Employees ===
Employee{id=4, name='David Brown', dept='Marketing', salary=68000.00, active=true}
Employee{id=5, name='Eve Davis', dept='Engineering', salary=105000.00, active=true}
Employee{id=1, name='Alice Johnson', dept='Engineering', salary=95000.00, active=true}
Employee{id=2, name='Bob Smith', dept='Engineering', salary=88000.00, active=true}
Employee{id=3, name='Carol Williams', dept='Marketing', salary=72000.00, active=true}

=== Find by ID ===
Found: Employee{id=1, name='Alice Johnson', dept='Engineering', salary=95000.00, active=true}

=== Engineering Department ===
Employee{id=5, name='Eve Davis', dept='Engineering', salary=105000.00, active=true}
Employee{id=1, name='Alice Johnson', dept='Engineering', salary=95000.00, active=true}
Employee{id=2, name='Bob Smith', dept='Engineering', salary=88000.00, active=true}

=== Update: Alice gets a raise ===
Updated: true
Employee{id=1, name='Alice Johnson', dept='Engineering', salary=105000.00, active=true}

=== Transaction: Transfer Bob to Marketing ===
Transfer successful: true
Employee{id=2, name='Bob Smith', dept='Marketing', salary=93000.00, active=true}

=== Average Salaries ===
Engineering avg: $105000.00
Marketing avg: $77666.67

=== Delete David ===
Deleted: true

=== Final State ===
Total employees: 4
Employee{id=5, name='Eve Davis', dept='Engineering', salary=105000.00, active=true}
Employee{id=1, name='Alice Johnson', dept='Engineering', salary=105000.00, active=true}
Employee{id=2, name='Bob Smith', dept='Marketing', salary=93000.00, active=true}
Employee{id=3, name='Carol Williams', dept='Marketing', salary=72000.00, active=true}

Concepts Demonstrated

# Concept Where in Code
1 DataSource (not DriverManager) JdbcDataSource setup in EmployeeApp
2 DAO pattern EmployeeDAO interface + EmployeeDAOImpl
3 PreparedStatement everywhere Every CRUD method uses ? placeholders
4 try-with-resources Every method auto-closes Connection, PreparedStatement, ResultSet
5 Generated keys save() retrieves auto-increment ID
6 Optional for nullable results findById() returns Optional<Employee>
7 ResultSet mapping Private mapRow() helper converts rows to objects
8 Batch operations saveBatch() uses addBatch() + executeBatch()
9 Transactions transferDepartment() wraps two tables in a transaction
10 Rollback on failure saveBatch() and transferDepartment() both rollback on exception
11 BigDecimal for money Salary uses BigDecimal, not double
12 java.time API Uses LocalDate and LocalDateTime, not legacy java.util.Date
13 Dependency injection DAO receives DataSource via constructor
14 Aggregation queries count() and getAverageSalaryByDepartment()

15. Quick Reference

Topic Key Point
JDBC Java's standard API for database connectivity. Uses interfaces in java.sql with vendor-specific drivers.
Driver Use Type 4 (pure Java). Auto-loaded since JDBC 4.0 -- no Class.forName() needed.
Connection Use DataSource + HikariCP in production. Always close with try-with-resources.
Statement For static SQL only (DDL). Never concatenate user input.
PreparedStatement For all parameterized SQL. Prevents SQL injection. Precompiled for performance.
CallableStatement For calling stored procedures. Register OUT parameters with registerOutParameter().
ResultSet Forward-only cursor by default. Use column names over indices. Check wasNull() for primitives.
Transactions setAutoCommit(false), then commit() or rollback(). Always restore auto-commit in finally.
Batch addBatch() + executeBatch(). Combine with transactions for maximum performance.
Connection Pool HikariCP is the standard. Set max pool size, idle timeout, and enable statement caching.
DAO Pattern Interface + implementation. Separates SQL from business logic. Enables testability.
Resource Cleanup Always try-with-resources. Close in reverse order: ResultSet, Statement, Connection.
Security Always PreparedStatement. Never concatenate. Credentials in env vars, not code.
October 17, 2019

Regex

What is Regex?

Regex (short for Regular Expression) is a sequence of characters that defines a search pattern. Think of it as a mini-language specifically designed for matching, searching, extracting, and replacing text.

Here is a real-world analogy: imagine you are in a library looking for books. Instead of searching for one specific title, you tell the librarian: “Find me all books whose title starts with ‘Java’, has a number in the middle, and ends with ‘Guide’.” That description is essentially a regex — a template that matches multiple possibilities based on a pattern, not a fixed string.

In Java, regex is used everywhere:

  • Validation — Check if user input matches an expected format (email, phone number, password)
  • Search — Find all occurrences of a pattern in a large body of text
  • Extraction — Pull specific pieces of data out of strings (dates from logs, numbers from reports)
  • Replacement — Transform text by replacing matched patterns with new content
  • Splitting — Break strings apart at complex delimiters

Without regex, tasks like “find all email addresses in a 10,000-line log file” would require dozens of lines of manual string parsing. With regex, it takes one line.

Java Regex Classes

Java provides regex support through the java.util.regex package, which contains three core classes:

Class Purpose Key Methods
Pattern A compiled representation of a regex pattern. Compiling is expensive, so you compile once and reuse. compile(), matcher(), matches(), split()
Matcher The engine that performs matching operations against a string using a Pattern. matches(), find(), group(), replaceAll()
PatternSyntaxException An unchecked exception thrown when a regex pattern has invalid syntax. getMessage(), getPattern(), getIndex()

The basic workflow for using regex in Java follows three steps:

  1. Compile the regex string into a Pattern object
  2. Create a Matcher by calling pattern.matcher(inputString)
  3. Execute a matching operation: matches(), find(), lookingAt(), etc.
import java.util.regex.Pattern;
import java.util.regex.Matcher;

public class RegexBasics {
    public static void main(String[] args) {
        // Step 1: Compile the pattern
        Pattern pattern = Pattern.compile("Java");

        // Step 2: Create a matcher for the input string
        Matcher matcher = pattern.matcher("I love Java programming");

        // Step 3: Execute matching operations
        boolean found = matcher.find();
        System.out.println("Found 'Java': " + found); // Found 'Java': true

        // matches() checks if the ENTIRE string matches the pattern
        boolean fullMatch = matcher.matches();
        System.out.println("Entire string is 'Java': " + fullMatch); // Entire string is 'Java': false

        // Reset and find the match position
        matcher.reset();
        if (matcher.find()) {
            System.out.println("Match starts at index: " + matcher.start()); // Match starts at index: 7
            System.out.println("Match ends at index: " + matcher.end());     // Match ends at index: 11
            System.out.println("Matched text: " + matcher.group());          // Matched text: Java
        }
    }
}

There is an important distinction between three Matcher methods:

Method What it Checks Example Pattern: "Java"
matches() Does the entire string match the pattern? "Java" returns true, "Java rocks" returns false
find() Is the pattern found anywhere in the string? "I love Java" returns true
lookingAt() Does the beginning of the string match the pattern? "Java rocks" returns true, "I love Java" returns false

For quick one-off checks, you can skip the compile step and use the static Pattern.matches() method. However, this recompiles the pattern every time, so avoid it in loops or frequently called methods.

// Quick one-off match (compiles a new Pattern every call -- avoid in loops)
boolean isMatch = Pattern.matches("\\d+", "12345");
System.out.println("All digits: " + isMatch); // All digits: true

// Even quicker: String.matches() delegates to Pattern.matches()
boolean isDigits = "12345".matches("\\d+");
System.out.println("All digits: " + isDigits); // All digits: true

Basic Pattern Syntax

A regex pattern is built from two types of characters:

  • Literal characters — Match themselves exactly. The pattern cat matches the text “cat”.
  • Metacharacters — Special characters with special meaning. They are the building blocks of pattern logic.

Java has 14 metacharacters that have special meaning in regex. If you want to match these characters literally, you must escape them with a backslash (\).

Metacharacter Meaning To Match Literally
. Any single character (except newline by default) \\.
^ Start of string (or line in MULTILINE mode) \\^
$ End of string (or line in MULTILINE mode) \\$
* Zero or more of preceding element \\*
+ One or more of preceding element \\+
? Zero or one of preceding element \\?
{ } Quantifier range (e.g., {2,5}) \\{ \\}
[ ] Character class definition \\[ \\]
( ) Grouping and capturing \\( \\)
\ Escape character \\\\
| Alternation (OR) \\|

Critical Java note: In Java strings, the backslash (\) is itself an escape character. So to write the regex \d (which means “a digit”), you must write "\\d" in Java code — the first backslash escapes the second one for Java, and the resulting \d is what the regex engine sees.

import java.util.regex.*;

public class MetacharacterEscaping {
    public static void main(String[] args) {
        // Without escaping: . matches ANY character
        System.out.println("file.txt".matches("file.txt"));  // true
        System.out.println("fileXtxt".matches("file.txt"));  // true -- oops, . matched 'X'

        // With escaping: \\. matches only a literal dot
        System.out.println("file.txt".matches("file\\.txt")); // true
        System.out.println("fileXtxt".matches("file\\.txt")); // false -- correct!

        // Matching a literal dollar sign in a price
        Pattern price = Pattern.compile("\\$\\d+\\.\\d{2}");
        System.out.println(price.matcher("$19.99").matches()); // true
        System.out.println(price.matcher("$5.00").matches());  // true
        System.out.println(price.matcher("19.99").matches());  // false -- missing $

        // Use Pattern.quote() to treat an entire string as a literal
        String userInput = "price is $10.00 (USD)";
        String searchTerm = "$10.00";
        Pattern literal = Pattern.compile(Pattern.quote(searchTerm));
        Matcher m = literal.matcher(userInput);
        System.out.println(m.find()); // true -- matched "$10.00" literally
    }
}

Character Classes

A character class (also called a character set) matches a single character from a defined set. You define a character class by placing characters inside square brackets [].

Custom Character Classes

Syntax Meaning Example Matches
[abc] Any one of a, b, or c [aeiou] Any vowel
[a-z] Any character in range a through z [a-zA-Z] Any letter
[0-9] Any digit 0 through 9 [0-9a-f] Any hex digit
[^abc] Any character except a, b, or c [^0-9] Any non-digit
[a-z&&[^aeiou]] Intersection: a-z but not vowels [a-z&&[^aeiou]] Any consonant

Predefined Character Classes

Java provides shorthand notation for commonly used character classes. These save typing and improve readability.

Shorthand Equivalent Meaning
\d [0-9] Any digit
\D [^0-9] Any non-digit
\w [a-zA-Z0-9_] Any word character (letter, digit, or underscore)
\W [^a-zA-Z0-9_] Any non-word character
\s [ \t\n\r\f] Any whitespace character
\S [^ \t\n\r\f] Any non-whitespace character
. (almost anything) Any character except newline (unless DOTALL flag is set)

Remember: in Java strings, you write \\d to produce the regex \d.

import java.util.regex.*;

public class CharacterClasses {
    public static void main(String[] args) {
        // Custom character class: match a vowel followed by a consonant
        Pattern vc = Pattern.compile("[aeiou][^aeiou\\s\\d]");
        Matcher m = vc.matcher("hello world");
        while (m.find()) {
            System.out.println("Found: " + m.group() + " at index " + m.start());
        }
        // Found: el at index 1
        // Found: or at index 7

        // \\d matches any digit
        System.out.println("abc".matches("\\d+")); // false
        System.out.println("123".matches("\\d+")); // true

        // \\w matches word characters (letters, digits, underscore)
        System.out.println("hello_world".matches("\\w+")); // true
        System.out.println("hello world".matches("\\w+")); // false -- space is not a word char

        // \\s matches whitespace
        System.out.println("has spaces".matches(".*\\s.*")); // true
        System.out.println("nospaces".matches(".*\\s.*"));   // false

        // . matches any character except newline
        System.out.println("a".matches("."));  // true
        System.out.println("1".matches("."));  // true
        System.out.println("".matches("."));   // false -- needs exactly one char

        // Ranges: hex digit check
        Pattern hex = Pattern.compile("[0-9a-fA-F]+");
        System.out.println(hex.matcher("1a2bFF").matches()); // true
        System.out.println(hex.matcher("GHIJ").matches());   // false

        // Negation: match non-digits
        Matcher nonDigits = Pattern.compile("[^0-9]+").matcher("abc123def");
        while (nonDigits.find()) {
            System.out.println("Non-digit segment: " + nonDigits.group());
        }
        // Non-digit segment: abc
        // Non-digit segment: def
    }
}

Quantifiers

Quantifiers control how many times a preceding element must occur for a match. Without quantifiers, each element in a pattern matches exactly once.

Quantifier Reference

Quantifier Meaning Example Pattern Matches Does Not Match
* Zero or more ab*c “ac”, “abc”, “abbc” “adc”
+ One or more ab+c “abc”, “abbc” “ac”
? Zero or one (optional) colou?r “color”, “colour” “colouur”
{n} Exactly n times \\d{3} “123” “12”, “1234”
{n,} At least n times \\d{2,} “12”, “123”, “1234” “1”
{n,m} Between n and m times \\d{2,4} “12”, “123”, “1234” “1”, “12345”

Greedy vs Lazy Quantifiers

By default, all quantifiers are greedy — they match as much text as possible. Adding a ? after a quantifier makes it lazy (also called reluctant) — it matches as little text as possible.

Greedy Lazy Behavior
* *? Match as few as possible (zero or more)
+ +? Match as few as possible (one or more)
? ?? Match zero if possible
{n,m} {n,m}? Match n times if possible

The difference matters most when your pattern has flexible parts and you need to control where the match stops.

import java.util.regex.*;

public class Quantifiers {
    public static void main(String[] args) {
        // Greedy vs Lazy demonstration
        String html = "bold and more bold";

        // Greedy: .* grabs as much as possible
        Matcher greedy = Pattern.compile(".*").matcher(html);
        if (greedy.find()) {
            System.out.println("Greedy: " + greedy.group());
            // Greedy: bold and more bold
            // -- matched from first  to LAST 
        }

        // Lazy: .*? grabs as little as possible
        Matcher lazy = Pattern.compile(".*?").matcher(html);
        while (lazy.find()) {
            System.out.println("Lazy: " + lazy.group());
        }
        // Lazy: bold
        // Lazy: more bold
        // -- matched each ... pair individually

        // Exact count: match a US zip code (5 digits, optional -4 digits)
        Pattern zip = Pattern.compile("\\d{5}(-\\d{4})?");
        System.out.println(zip.matcher("90210").matches());      // true
        System.out.println(zip.matcher("90210-1234").matches()); // true
        System.out.println(zip.matcher("9021").matches());       // false
        System.out.println(zip.matcher("902101234").matches());  // false

        // Range: password length check (8 to 20 characters)
        Pattern length = Pattern.compile(".{8,20}");
        System.out.println(length.matcher("short").matches());             // false (5 chars)
        System.out.println(length.matcher("justright").matches());         // true (9 chars)
        System.out.println(length.matcher("a]".repeat(11)).matches());     // false (22 chars)

        // Optional element: match "http" or "https"
        Pattern protocol = Pattern.compile("https?://.*");
        System.out.println(protocol.matcher("http://example.com").matches());  // true
        System.out.println(protocol.matcher("https://example.com").matches()); // true
        System.out.println(protocol.matcher("ftp://example.com").matches());   // false
    }
}

Anchors and Boundaries

Anchors do not match characters — they match positions in the string. They assert that the current position in the string meets a certain condition.

Anchor Meaning Example
^ Start of string (or start of each line with MULTILINE flag) ^Hello matches “Hello world” but not “Say Hello”
$ End of string (or end of each line with MULTILINE flag) world$ matches “Hello world” but not “world peace”
\b Word boundary (between a word char and a non-word char) \bcat\b matches “the cat sat” but not “concatenate”
\B Non-word boundary (between two word chars or two non-word chars) \Bcat\B matches “concatenate” but not “the cat sat”

Word boundaries (\b) are one of the most useful anchors. A word boundary exists between a word character (\w) and a non-word character (\W), or at the start/end of the string if it begins/ends with a word character.

import java.util.regex.*;

public class AnchorsAndBoundaries {
    public static void main(String[] args) {
        // ^ and $ -- start and end anchors
        System.out.println("Hello World".matches("^Hello.*"));  // true
        System.out.println("Say Hello".matches("^Hello.*"));    // false

        // Without anchors, find() looks anywhere in the string
        Matcher m1 = Pattern.compile("error").matcher("An error occurred");
        System.out.println(m1.find()); // true

        // With anchors, matches() checks the entire string
        System.out.println("An error occurred".matches("error")); // false -- not the whole string
        System.out.println("error".matches("error"));             // true

        // \\b word boundary -- match whole words only
        String text = "The cat scattered the catalog across the category";
        Matcher wordCat = Pattern.compile("\\bcat\\b").matcher(text);
        int count = 0;
        while (wordCat.find()) {
            System.out.println("Found whole word 'cat' at index " + wordCat.start());
            count++;
        }
        System.out.println("Total matches: " + count);
        // Found whole word 'cat' at index 4
        // Total matches: 1
        // -- "scattered", "catalog", and "category" were correctly excluded

        // Without word boundary -- matches "cat" inside other words too
        Matcher anyCat = Pattern.compile("cat").matcher(text);
        count = 0;
        while (anyCat.find()) {
            count++;
        }
        System.out.println("Without boundary: " + count + " matches");
        // Without boundary: 4 matches

        // ^ and $ with MULTILINE flag -- match each line
        String multiline = "First line\nSecond line\nThird line";
        Matcher lineStarts = Pattern.compile("^\\w+", Pattern.MULTILINE).matcher(multiline);
        while (lineStarts.find()) {
            System.out.println("Line starts with: " + lineStarts.group());
        }
        // Line starts with: First
        // Line starts with: Second
        // Line starts with: Third
    }
}

Groups and Capturing

Parentheses () in a regex serve two purposes: they group parts of the pattern together (so quantifiers or alternation can apply to the whole group), and they capture the matched text (so you can retrieve it later).

Capturing Groups

Each pair of parentheses creates a capturing group, numbered left-to-right starting at 1. Group 0 always refers to the entire match.

For the pattern (\\d{3})-(\\d{3})-(\\d{4}) matching “555-123-4567”:

  • group(0) = “555-123-4567” (entire match)
  • group(1) = “555” (area code)
  • group(2) = “123” (prefix)
  • group(3) = “4567” (line number)

Named Groups

Numbered groups can be hard to read in complex patterns. Java supports named capturing groups using the syntax (?<name>...). You retrieve the value with matcher.group("name").

Non-Capturing Groups

Sometimes you need parentheses for grouping (e.g., to apply a quantifier to a group) but do not need to capture the matched text. Use (?:...) for a non-capturing group. This is slightly more efficient since the regex engine does not need to store the match.

Backreferences

A backreference refers back to a previously captured group within the same pattern. \\1 refers to the text matched by group 1, \\2 refers to group 2, and so on. This is useful for finding repeated patterns like duplicate words.

import java.util.regex.*;

public class GroupsAndCapturing {
    public static void main(String[] args) {
        // --- Numbered Capturing Groups ---
        String phone = "Call me at 555-123-4567 or 800-555-0199";
        Pattern phonePattern = Pattern.compile("(\\d{3})-(\\d{3})-(\\d{4})");
        Matcher m = phonePattern.matcher(phone);

        while (m.find()) {
            System.out.println("Full match:  " + m.group(0));
            System.out.println("Area code:   " + m.group(1));
            System.out.println("Prefix:      " + m.group(2));
            System.out.println("Line number: " + m.group(3));
            System.out.println();
        }
        // Full match:  555-123-4567
        // Area code:   555
        // Prefix:      123
        // Line number: 4567
        //
        // Full match:  800-555-0199
        // Area code:   800
        // Prefix:      555
        // Line number: 0199

        // --- Named Capturing Groups ---
        String dateStr = "2026-02-28";
        Pattern datePattern = Pattern.compile(
            "(?\\d{4})-(?\\d{2})-(?\\d{2})"
        );
        Matcher dm = datePattern.matcher(dateStr);

        if (dm.matches()) {
            System.out.println("Year:  " + dm.group("year"));   // Year:  2026
            System.out.println("Month: " + dm.group("month"));  // Month: 02
            System.out.println("Day:   " + dm.group("day"));    // Day:   28
        }

        // --- Non-Capturing Groups ---
        // Match "http" or "https" without capturing the "s"
        Pattern url = Pattern.compile("(?:https?)://([\\w.]+)");
        Matcher um = url.matcher("Visit https://example.com today");

        if (um.find()) {
            System.out.println("Full match: " + um.group(0));  // Full match: https://example.com
            System.out.println("Domain:     " + um.group(1));  // Domain:     example.com
            // group(1) is the domain, not "https" -- because (?:...) did not capture
        }

        // --- Backreferences: find duplicate words ---
        String text = "This is is a test test of of duplicate words";
        Pattern dupes = Pattern.compile("\\b(\\w+)\\s+\\1\\b", Pattern.CASE_INSENSITIVE);
        Matcher dupeMatcher = dupes.matcher(text);

        while (dupeMatcher.find()) {
            System.out.println("Duplicate found: \"" + dupeMatcher.group() + "\"");
        }
        // Duplicate found: "is is"
        // Duplicate found: "test test"
        // Duplicate found: "of of"
    }
}

Alternation and Lookaround

Alternation (OR)

The pipe character | acts as an OR operator. The pattern cat|dog matches either “cat” or “dog”. Alternation has the lowest precedence of any regex operator, so gray|grey matches “gray” or “grey”, not “gra” followed by “y|grey”.

To limit the scope of alternation, use parentheses: gr(a|e)y matches “gray” or “grey”.

Lookahead and Lookbehind

Lookaround assertions check if a pattern exists before or after the current position, but they do not consume characters (the match position does not advance). They are “zero-width assertions” — they assert a condition without including the matched text in the result.

Syntax Name Meaning Example
(?=...) Positive lookahead What follows must match \\d+(?= dollars) matches “100” in “100 dollars”
(?!...) Negative lookahead What follows must NOT match \\d+(?! dollars) matches “100” in “100 euros”
(?<=...) Positive lookbehind What precedes must match (?<=\\$)\\d+ matches "50" in "$50"
(? Negative lookbehind What precedes must NOT match (? matches "50" in "50" but not in "$50"

Lookarounds are especially useful in password validation, where you need to check multiple conditions at the same position (e.g., must contain a digit AND a special character AND an uppercase letter).

import java.util.regex.*;

public class AlternationAndLookaround {
    public static void main(String[] args) {
        // --- Alternation ---
        Pattern pet = Pattern.compile("cat|dog|bird");
        String text = "I have a cat and a dog but no bird";
        Matcher m = pet.matcher(text);
        while (m.find()) {
            System.out.println("Found pet: " + m.group());
        }
        // Found pet: cat
        // Found pet: dog
        // Found pet: bird

        // Alternation with grouping
        Pattern color = Pattern.compile("gr(a|e)y");
        System.out.println(color.matcher("gray").matches());  // true
        System.out.println(color.matcher("grey").matches());  // true
        System.out.println(color.matcher("griy").matches());  // false

        // --- Positive Lookahead: find numbers followed by "px" ---
        Matcher lookahead = Pattern.compile("\\d+(?=px)").matcher("width: 100px; height: 50px; margin: 10em");
        while (lookahead.find()) {
            System.out.println("Pixel value: " + lookahead.group());
        }
        // Pixel value: 100
        // Pixel value: 50
        // -- "10" was excluded because it is followed by "em", not "px"

        // --- Negative Lookahead: find numbers NOT followed by "px" ---
        Matcher negLookahead = Pattern.compile("\\d+(?!px)").matcher("width: 100px; margin: 10em");
        while (negLookahead.find()) {
            System.out.println("Non-pixel: " + negLookahead.group());
        }
        // Non-pixel: 10
        // Non-pixel: 10

        // --- Positive Lookbehind: extract amounts after $ ---
        Matcher lookbehind = Pattern.compile("(?<=\\$)\\d+\\.?\\d*").matcher("Price: $19.99 and $5.00");
        while (lookbehind.find()) {
            System.out.println("Amount: " + lookbehind.group());
        }
        // Amount: 19.99
        // Amount: 5.00

        // --- Password validation using multiple lookaheads ---
        // At least 8 chars, one uppercase, one lowercase, one digit, one special char
        Pattern strongPassword = Pattern.compile(
            "^(?=.*[A-Z])"  +   // at least one uppercase
            "(?=.*[a-z])"   +   // at least one lowercase
            "(?=.*\\d)"     +   // at least one digit
            "(?=.*[@#$%^&+=!])" + // at least one special character
            ".{8,}$"             // at least 8 characters total
        );

        String[] passwords = {"Passw0rd!", "password", "SHORT1!", "MyP@ss12"};
        for (String pw : passwords) {
            boolean strong = strongPassword.matcher(pw).matches();
            System.out.println(pw + " -> " + (strong ? "STRONG" : "WEAK"));
        }
        // Passw0rd! -> STRONG
        // password -> WEAK
        // SHORT1! -> WEAK
        // MyP@ss12 -> STRONG
    }
}

Common String Methods with Regex

Java's String class has several built-in methods that accept regex patterns. These are convenient for simple use cases where you do not need the full power of Pattern and Matcher.

Method What it Does Returns
String.matches(regex) Tests if the entire string matches the regex boolean
String.split(regex) Splits the string at each match of the regex String[]
String.split(regex, limit) Splits with a limit on the number of parts String[]
String.replaceAll(regex, replacement) Replaces all matches with the replacement String
String.replaceFirst(regex, replacement) Replaces only the first match String

Performance warning: Every call to these methods compiles a new Pattern internally. If you call them in a loop or frequently, compile the Pattern once yourself and use Matcher instead.

import java.util.Arrays;

public class StringRegexMethods {
    public static void main(String[] args) {
        // --- matches() -- checks the ENTIRE string ---
        System.out.println("12345".matches("\\d+"));       // true
        System.out.println("123abc".matches("\\d+"));      // false -- not all digits
        System.out.println("hello".matches("[a-z]+"));     // true

        // --- split() -- break a string into parts ---
        // Split on one or more whitespace characters
        String sentence = "Split   this   string   up";
        String[] words = sentence.split("\\s+");
        System.out.println(Arrays.toString(words));
        // [Split, this, string, up]

        // Split a CSV line (handles optional spaces after commas)
        String csv = "Java, Python,  C++, JavaScript";
        String[] languages = csv.split(",\\s*");
        System.out.println(Arrays.toString(languages));
        // [Java, Python, C++, JavaScript]

        // Split with a limit
        String path = "com.example.project.Main";
        String[] parts = path.split("\\.", 3); // at most 3 parts
        System.out.println(Arrays.toString(parts));
        // [com, example, project.Main]

        // --- replaceAll() -- replace all matches ---
        // Remove all non-alphanumeric characters
        String dirty = "Hello, World! @2026";
        String clean = dirty.replaceAll("[^a-zA-Z0-9]", "");
        System.out.println(clean); // HelloWorld2026

        // Normalize whitespace: replace multiple spaces/tabs with a single space
        String messy = "too   many     spaces    here";
        String normalized = messy.replaceAll("\\s+", " ");
        System.out.println(normalized); // too many spaces here

        // --- replaceFirst() -- replace only the first match ---
        String text = "error: file not found. error: permission denied.";
        String result = text.replaceFirst("error", "WARNING");
        System.out.println(result);
        // WARNING: file not found. error: permission denied.

        // Use captured groups in replacement with $1, $2, etc.
        // Reformat dates from MM/DD/YYYY to YYYY-MM-DD
        String date = "02/28/2026";
        String reformatted = date.replaceAll("(\\d{2})/(\\d{2})/(\\d{4})", "$3-$1-$2");
        System.out.println(reformatted); // 2026-02-28
    }
}

Pattern Flags

Pattern flags modify how the regex engine interprets the pattern. You pass them as the second argument to Pattern.compile(), or embed them directly in the pattern using inline flag syntax.

Flag Constant Inline Effect
Pattern.CASE_INSENSITIVE (?i) Matches letters regardless of case. abc matches "ABC".
Pattern.MULTILINE (?m) ^ and $ match start/end of each line, not just the entire string.
Pattern.DOTALL (?s) . matches any character including newline.
Pattern.COMMENTS (?x) Whitespace and comments (# to end of line) in the pattern are ignored. Great for readability.
Pattern.UNICODE_CASE (?u) Case-insensitive matching follows Unicode rules, not just ASCII.
Pattern.LITERAL -- The pattern is treated as a literal string (metacharacters have no special meaning).

You can combine multiple flags using the bitwise OR operator (|).

import java.util.regex.*;

public class PatternFlags {
    public static void main(String[] args) {
        // --- CASE_INSENSITIVE ---
        Pattern ci = Pattern.compile("java", Pattern.CASE_INSENSITIVE);
        System.out.println(ci.matcher("JAVA").matches());   // true
        System.out.println(ci.matcher("Java").matches());   // true
        System.out.println(ci.matcher("jAvA").matches());   // true

        // Same thing using inline flag (?i)
        System.out.println("JAVA".matches("(?i)java"));     // true

        // --- MULTILINE ---
        String log = "ERROR: disk full\nWARN: low memory\nERROR: timeout";
        Pattern errorLines = Pattern.compile("^ERROR.*$", Pattern.MULTILINE);
        Matcher m = errorLines.matcher(log);
        while (m.find()) {
            System.out.println(m.group());
        }
        // ERROR: disk full
        // ERROR: timeout

        // --- DOTALL ---
        String html = "
\nHello\nWorld\n
"; // Without DOTALL, . does not match newlines System.out.println(html.matches("
.*
")); // false // With DOTALL, . matches everything including newlines Pattern dotall = Pattern.compile("
.*
", Pattern.DOTALL); System.out.println(dotall.matcher(html).matches()); // true // --- COMMENTS -- write readable patterns --- Pattern readable = Pattern.compile( "\\d{3}" + // area code "-" + // separator "\\d{3}" + // prefix "-" + // separator "\\d{4}" // line number ); System.out.println(readable.matcher("555-123-4567").matches()); // true // Using COMMENTS flag with whitespace and # comments in the pattern itself Pattern commented = Pattern.compile( "(?x) " + // enable comments mode "\\d{3} " + // area code "- " + // dash separator "\\d{3} " + // prefix "- " + // dash separator "\\d{4} " // line number ); System.out.println(commented.matcher("555-123-4567").matches()); // true // --- Combining multiple flags --- Pattern combined = Pattern.compile( "^error.*$", Pattern.CASE_INSENSITIVE | Pattern.MULTILINE ); Matcher cm = combined.matcher("Error: something\nERROR: another\ninfo: ok"); while (cm.find()) { System.out.println("Found: " + cm.group()); } // Found: Error: something // Found: ERROR: another } }

Practical Validation Examples

One of the most common uses of regex is input validation. Below are battle-tested patterns for common formats, each broken down so you understand every part.

1. Email Validation

A simplified but practical email regex. Note that the full RFC 5322 email spec is extremely complex -- this pattern covers the vast majority of real-world addresses.

// Email: local-part@domain.tld
// ^                    -- start of string
// [a-zA-Z0-9._%+-]+   -- local part: letters, digits, dots, underscores, %, +, -
// @                    -- literal @ symbol
// [a-zA-Z0-9.-]+      -- domain: letters, digits, dots, hyphens
// \.                   -- literal dot before TLD
// [a-zA-Z]{2,}        -- TLD: at least 2 letters (com, org, io, etc.)
// $                    -- end of string

String emailRegex = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$";

String[] emails = {"user@example.com", "first.last@company.co.uk", "invalid@", "@nodomain.com", "test@site.io"};
for (String email : emails) {
    System.out.println(email + " -> " + (email.matches(emailRegex) ? "VALID" : "INVALID"));
}
// user@example.com -> VALID
// first.last@company.co.uk -> VALID
// invalid@ -> INVALID
// @nodomain.com -> INVALID
// test@site.io -> VALID

2. Phone Number (US)

Matches multiple common US phone formats: (555) 123-4567, 555-123-4567, 5551234567, +1-555-123-4567.

// US phone: optional country code, various separator formats
// ^                    -- start
// (\\+1[- ]?)?        -- optional +1 country code with optional separator
// \\(?                -- optional opening parenthesis
// \\d{3}              -- area code (3 digits)
// \\)?                -- optional closing parenthesis
// [- ]?               -- optional separator (dash or space)
// \\d{3}              -- prefix (3 digits)
// [- ]?               -- optional separator
// \\d{4}              -- line number (4 digits)
// $                    -- end

String phoneRegex = "^(\\+1[- ]?)?(\\(?\\d{3}\\)?[- ]?)?\\d{3}[- ]?\\d{4}$";

String[] phones = {"(555) 123-4567", "555-123-4567", "5551234567", "+1-555-123-4567", "123"};
for (String phone : phones) {
    System.out.println(phone + " -> " + (phone.matches(phoneRegex) ? "VALID" : "INVALID"));
}
// (555) 123-4567 -> VALID
// 555-123-4567 -> VALID
// 5551234567 -> VALID
// +1-555-123-4567 -> VALID
// 123 -> INVALID

3. Password Strength

Uses lookaheads to enforce multiple rules simultaneously: minimum length, uppercase, lowercase, digit, and special character.

// Password must have:
// (?=.*[A-Z])          -- at least one uppercase letter
// (?=.*[a-z])          -- at least one lowercase letter
// (?=.*\\d)            -- at least one digit
// (?=.*[@#$%^&+=!])    -- at least one special character
// .{8,20}              -- between 8 and 20 characters total

String passwordRegex = "^(?=.*[A-Z])(?=.*[a-z])(?=.*\\d)(?=.*[@#$%^&+=!]).{8,20}$";

String[] passwords = {"Str0ng!Pass", "weakpassword", "SHORT1!", "NoSpecial1", "G00d@Pwd"};
for (String pw : passwords) {
    System.out.println(pw + " -> " + (pw.matches(passwordRegex) ? "STRONG" : "WEAK"));
}
// Str0ng!Pass -> STRONG
// weakpassword -> WEAK (no uppercase, no digit, no special)
// SHORT1! -> WEAK (less than 8 chars)
// NoSpecial1 -> WEAK (no special character)
// G00d@Pwd -> STRONG

4. URL Validation

// URL: protocol://domain:port/path?query#fragment
// ^https?://           -- http or https
// [\\w.-]+             -- domain name
// (:\\d{1,5})?         -- optional port (1-5 digits)
// (/[\\w./-]*)*        -- optional path segments
// (\\?[\\w=&%-]*)?     -- optional query string
// (#[\\w-]*)?          -- optional fragment
// $

String urlRegex = "^https?://[\\w.-]+(:\\d{1,5})?(/[\\w./-]*)*(\\?[\\w=&%-]*)?(#[\\w-]*)?$";

String[] urls = {
    "https://example.com",
    "http://localhost:8080/api/users",
    "https://site.com/page?name=test&id=5",
    "ftp://invalid.com",
    "https://example.com/path#section"
};
for (String url : urls) {
    System.out.println(url + " -> " + (url.matches(urlRegex) ? "VALID" : "INVALID"));
}
// https://example.com -> VALID
// http://localhost:8080/api/users -> VALID
// https://site.com/page?name=test&id=5 -> VALID
// ftp://invalid.com -> INVALID
// https://example.com/path#section -> VALID

5. IP Address (IPv4)

// IPv4: four octets (0-255) separated by dots
// Each octet: 25[0-5] | 2[0-4]\\d | [01]?\\d{1,2}
// This handles: 0-9, 10-99, 100-199, 200-249, 250-255

String ipRegex = "^((25[0-5]|2[0-4]\\d|[01]?\\d{1,2})\\.){3}(25[0-5]|2[0-4]\\d|[01]?\\d{1,2})$";

String[] ips = {"192.168.1.1", "255.255.255.255", "0.0.0.0", "256.1.1.1", "192.168.1"};
for (String ip : ips) {
    System.out.println(ip + " -> " + (ip.matches(ipRegex) ? "VALID" : "INVALID"));
}
// 192.168.1.1 -> VALID
// 255.255.255.255 -> VALID
// 0.0.0.0 -> VALID
// 256.1.1.1 -> INVALID (256 is out of range)
// 192.168.1 -> INVALID (only 3 octets)

6. Date Format (YYYY-MM-DD)

// Date: YYYY-MM-DD (basic format validation, not full calendar validation)
// \\d{4}              -- 4-digit year
// -                   -- separator
// (0[1-9]|1[0-2])    -- month: 01-12
// -                   -- separator
// (0[1-9]|[12]\\d|3[01]) -- day: 01-31

String dateRegex = "^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$";

String[] dates = {"2026-02-28", "2026-13-01", "2026-00-15", "2026-12-31", "26-01-01"};
for (String date : dates) {
    System.out.println(date + " -> " + (date.matches(dateRegex) ? "VALID" : "INVALID"));
}
// 2026-02-28 -> VALID
// 2026-13-01 -> INVALID (month 13)
// 2026-00-15 -> INVALID (month 00)
// 2026-12-31 -> VALID
// 26-01-01 -> INVALID (2-digit year)

7. Credit Card Number

// Credit card: 13-19 digits, optionally separated by spaces or dashes every 4 digits
// Common formats: Visa (4xxx), Mastercard (5xxx), Amex (34xx/37xx)

String ccRegex = "^\\d{4}[- ]?\\d{4}[- ]?\\d{4}[- ]?\\d{1,7}$";

String[] cards = {"4111111111111111", "4111-1111-1111-1111", "4111 1111 1111 1111", "411", "1234567890123456789012"};
for (String card : cards) {
    System.out.println(card + " -> " + (card.matches(ccRegex) ? "VALID FORMAT" : "INVALID FORMAT"));
}
// 4111111111111111 -> VALID FORMAT
// 4111-1111-1111-1111 -> VALID FORMAT
// 4111 1111 1111 1111 -> VALID FORMAT
// 411 -> INVALID FORMAT
// 1234567890123456789012 -> INVALID FORMAT

// Note: this only validates the FORMAT, not the actual card number.
// Use the Luhn algorithm for checksum validation.

8. Social Security Number (SSN)

// SSN format: XXX-XX-XXXX
// (?!000|666)         -- area number cannot be 000 or 666
// (?!9)               -- area number cannot start with 9
// \\d{3}              -- 3-digit area number
// -                   -- separator
// (?!00)\\d{2}        -- 2-digit group number (not 00)
// -                   -- separator
// (?!0000)\\d{4}      -- 4-digit serial number (not 0000)

String ssnRegex = "^(?!000|666)(?!9\\d{2})\\d{3}-(?!00)\\d{2}-(?!0000)\\d{4}$";

String[] ssns = {"123-45-6789", "000-12-3456", "666-12-3456", "900-12-3456", "123-00-6789", "123-45-0000"};
for (String ssn : ssns) {
    System.out.println(ssn + " -> " + (ssn.matches(ssnRegex) ? "VALID" : "INVALID"));
}
// 123-45-6789 -> VALID
// 000-12-3456 -> INVALID (area 000)
// 666-12-3456 -> INVALID (area 666)
// 900-12-3456 -> INVALID (area starts with 9)
// 123-00-6789 -> INVALID (group 00)
// 123-45-0000 -> INVALID (serial 0000)

Search and Replace

Beyond validation, regex is heavily used for searching text and performing replacements. The Matcher class gives you fine-grained control over the search and replace process.

Finding All Matches with find()

The find() method scans the input for the next match. Call it in a while loop to iterate through all matches.

import java.util.regex.*;
import java.util.ArrayList;
import java.util.List;

public class SearchAndReplace {
    public static void main(String[] args) {
        // --- Finding all matches ---
        String text = "Contact us at support@company.com or sales@company.com. " +
                       "Personal: john.doe@gmail.com";
        Pattern emailPattern = Pattern.compile("[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}");
        Matcher finder = emailPattern.matcher(text);

        List emails = new ArrayList<>();
        while (finder.find()) {
            emails.add(finder.group());
            System.out.println("Found email at [" + finder.start() + "-" + finder.end() + "]: " + finder.group());
        }
        // Found email at [17-36]: support@company.com
        // Found email at [40-57]: sales@company.com
        // Found email at [69-88]: john.doe@gmail.com
        System.out.println("Total emails found: " + emails.size()); // Total emails found: 3

        // --- Simple replaceAll ---
        String censored = emailPattern.matcher(text).replaceAll("[REDACTED]");
        System.out.println(censored);
        // Contact us at [REDACTED] or [REDACTED]. Personal: [REDACTED]

        // --- replaceFirst ---
        String firstOnly = emailPattern.matcher(text).replaceFirst("[REDACTED]");
        System.out.println(firstOnly);
        // Contact us at [REDACTED] or sales@company.com. Personal: john.doe@gmail.com
    }
}

Custom Replacement with appendReplacement / appendTail

When you need dynamic replacements (e.g., the replacement depends on the matched value), use appendReplacement() and appendTail(). This pair lets you build a result string incrementally, applying custom logic to each match.

import java.util.regex.*;

public class CustomReplacement {
    public static void main(String[] args) {
        // Convert all words to title case using appendReplacement
        String input = "the quick brown fox jumps over the lazy dog";
        Pattern wordPattern = Pattern.compile("\\b([a-z])(\\w*)");
        Matcher m = wordPattern.matcher(input);
        StringBuilder result = new StringBuilder();

        while (m.find()) {
            String titleCase = m.group(1).toUpperCase() + m.group(2);
            m.appendReplacement(result, titleCase);
        }
        m.appendTail(result);
        System.out.println(result);
        // The Quick Brown Fox Jumps Over The Lazy Dog

        // Mask credit card numbers: show only last 4 digits
        String data = "Card: 4111-1111-1111-1111, Another: 5500-0000-0000-0004";
        Pattern ccPattern = Pattern.compile("(\\d{4})[- ]?(\\d{4})[- ]?(\\d{4})[- ]?(\\d{4})");
        Matcher ccMatcher = ccPattern.matcher(data);
        StringBuilder masked = new StringBuilder();

        while (ccMatcher.find()) {
            String replacement = "****-****-****-" + ccMatcher.group(4);
            ccMatcher.appendReplacement(masked, replacement);
        }
        ccMatcher.appendTail(masked);
        System.out.println(masked);
        // Card: ****-****-****-1111, Another: ****-****-****-0004

        // Java 9+: Matcher.replaceAll with a Function
        String prices = "Items cost $5 and $23 and $100";
        Pattern pricePattern = Pattern.compile("\\$(\\d+)");
        String doubled = pricePattern.matcher(prices).replaceAll(mr -> {
            int amount = Integer.parseInt(mr.group(1));
            return "\\$" + (amount * 2);
        });
        System.out.println(doubled);
        // Items cost $10 and $46 and $200
    }
}

Common Mistakes

Even experienced developers make regex mistakes. Here are the most frequent pitfalls and how to avoid them.

1. Forgetting to Double-Escape Backslashes in Java

This is the number one mistake for Java developers. In regex, \d means "digit." In a Java string, \d is not a valid escape sequence. You must write \\d so Java's string parser produces the single backslash that the regex engine expects.

// WRONG -- Java does not recognize \d as a string escape
// String pattern = "\d+";  // Compilation error!

// CORRECT -- double backslash to produce \d for the regex engine
String pattern = "\\d+";

// To match a literal backslash in text, you need FOUR backslashes:
// Java string: "\\\\"  -> produces: \\  -> regex sees: \ (literal backslash)
String backslashPattern = "\\\\";
System.out.println("C:\\Users".matches(".*\\\\.*")); // true

2. Catastrophic Backtracking

Certain regex patterns can cause the engine to take an exponential amount of time on certain inputs. This happens when a pattern has nested quantifiers that can match the same characters in multiple ways.

// DANGEROUS -- nested quantifiers can cause catastrophic backtracking
// String bad = "(a+)+b";
// On input "aaaaaaaaaaaaaaaaaac", the engine tries every possible way
// to split the 'a's between the inner and outer groups before failing.
// This can freeze your application.

// SAFE -- flatten the nesting
String safe = "a+b";
// This matches the same thing but without the exponential backtracking risk.

// Another common danger: matching quoted strings with nested quantifiers
// DANGEROUS: "(.*)*"
// SAFE:      "[^"]*"   -- use negated character class instead

3. Overly Complex Patterns

If your regex is more than about 80 characters long, consider breaking the validation into multiple simpler steps. A 200-character regex that validates everything at once is nearly impossible to maintain.

// BAD -- one massive unreadable regex
// String nightmare = "^(?=.*[A-Z])(?=.*[a-z])(?=.*\\d)(?=.*[@#$%^&+=!])[a-zA-Z0-9@#$%^&+=!]{8,20}$";

// BETTER -- break into understandable steps
public static boolean isStrongPassword(String password) {
    if (password == null) return false;
    if (password.length() < 8 || password.length() > 20) return false;
    if (!password.matches(".*[A-Z].*")) return false;  // needs uppercase
    if (!password.matches(".*[a-z].*")) return false;  // needs lowercase
    if (!password.matches(".*\\d.*")) return false;     // needs digit
    if (!password.matches(".*[@#$%^&+=!].*")) return false; // needs special char
    return true;
}
// Easier to read, debug, and extend. Each rule is independently testable.

4. Testing Only the Happy Path

Always test your regex with edge cases: empty strings, very long strings, strings with special characters, and strings that are close to matching but should not.

// Testing an email regex -- you need ALL of these test cases
String emailRegex = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$";

// Happy path
assert "user@example.com".matches(emailRegex);         // standard email
assert "first.last@company.co.uk".matches(emailRegex); // dots and subdomains

// Edge cases that should FAIL
assert !"".matches(emailRegex);                        // empty string
assert !"@example.com".matches(emailRegex);            // missing local part
assert !"user@".matches(emailRegex);                   // missing domain
assert !"user@.com".matches(emailRegex);               // domain starts with dot
assert !"user@com".matches(emailRegex);                // no TLD separator
assert !"user@@example.com".matches(emailRegex);       // double @

// Edge cases that should PASS
assert "a@b.co".matches(emailRegex);                   // minimal valid email
assert "user+tag@gmail.com".matches(emailRegex);       // plus addressing

5. Using matches() When You Meant find()

String.matches() and Matcher.matches() check if the entire string matches the pattern. If you want to check if the pattern appears anywhere in the string, use Matcher.find().

String text = "Error code: 404";

// WRONG -- matches() checks the ENTIRE string
System.out.println(text.matches("\\d+"));  // false -- the entire string is not digits

// CORRECT -- find() searches for the pattern anywhere
Matcher m = Pattern.compile("\\d+").matcher(text);
System.out.println(m.find());     // true
System.out.println(m.group());    // 404

// If you must use matches(), wrap the pattern with .*
System.out.println(text.matches(".*\\d+.*")); // true -- but find() is cleaner

Best Practices

Follow these guidelines to write regex that is correct, readable, and performant.

1. Compile Patterns Once

The Pattern.compile() method is expensive. If you use the same regex multiple times (in a loop, in a method called frequently, etc.), compile it once and store it as a static final field.

public class UserValidator {

    // GOOD -- compiled once, reused many times
    private static final Pattern EMAIL_PATTERN =
        Pattern.compile("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$");

    private static final Pattern PHONE_PATTERN =
        Pattern.compile("^(\\+1[- ]?)?(\\(?\\d{3}\\)?[- ]?)?\\d{3}[- ]?\\d{4}$");

    public static boolean isValidEmail(String email) {
        return email != null && EMAIL_PATTERN.matcher(email).matches();
    }

    public static boolean isValidPhone(String phone) {
        return phone != null && PHONE_PATTERN.matcher(phone).matches();
    }

    // BAD -- compiles a new Pattern on every call
    // public static boolean isValidEmailBad(String email) {
    //     return email.matches("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$");
    // }
}

2. Use Named Groups for Readability

Named groups make your code self-documenting. Instead of remembering that group(3) is the year, use group("year").

3. Use Pattern.quote() for Literal Matching

When you are searching for user-supplied text that might contain regex metacharacters, use Pattern.quote() to escape everything automatically.

4. Keep Patterns Simple

If a regex grows beyond a readable length, consider breaking the validation into multiple steps or using a combination of regex and plain Java logic.

5. Comment Complex Patterns

Use Java string concatenation with comments, or the COMMENTS flag, to make complex patterns understandable.

6. Test with Edge Cases

Always test with: empty strings, null input, maximum-length input, strings with only special characters, strings that are "almost" valid, and internationalized input (if applicable).

7. Prefer Specific Patterns Over Greedy Wildcards

Instead of .* (which matches anything), use character classes that describe what you actually expect: [^"]* instead of .* inside quotes, \\d+ instead of .+ for numbers.

Best Practices Summary

Practice Do Do Not
Compile patterns static final Pattern P = Pattern.compile(...) str.matches("...") in a loop
Escape user input Pattern.quote(userInput) Concatenate user input directly into regex
Name groups (?<year>\\d{4}) (\\d{4}) then group(1)
Be specific [^"]* between quotes .* between quotes
Handle null Check null before matching Call .matches() on nullable values
Break complex logic Multiple simple checks One enormous regex
Test edge cases Empty, long, special chars, near-misses Test only the happy path

Quick Reference Table

A comprehensive reference of all regex syntax elements covered in this tutorial.

Category Syntax Meaning Java String
Character Classes [abc] Any of a, b, or c "[abc]"
[^abc] Not a, b, or c "[^abc]"
[a-z] Range a through z "[a-z]"
\d / \D Digit / Non-digit "\\d" / "\\D"
\w / \W Word char / Non-word char "\\w" / "\\W"
\s / \S Whitespace / Non-whitespace "\\s" / "\\S"
. Any character (except newline) "."
Quantifiers * Zero or more "a*"
+ One or more "a+"
? Zero or one "a?"
{n} Exactly n "a{3}"
{n,m} Between n and m "a{2,5}"
*? / +? Lazy (minimal) match "a*?" / "a+?"
Anchors ^ Start of string/line "^"
$ End of string/line "$"
\b Word boundary "\\b"
\B Non-word boundary "\\B"
Groups (...) Capturing group "(abc)"
(?:...) Non-capturing group "(?:abc)"
(?<name>...) Named group "(?<name>abc)"
\1 Backreference to group 1 "\\1"
| Alternation (OR) "cat|dog"
Lookaround (?=...) Positive lookahead "(?=abc)"
(?!...) Negative lookahead "(?!abc)"
(?<=...) Positive lookbehind "(?<=abc)"
(? Negative lookbehind "(?
Flags (?i) Case insensitive Pattern.CASE_INSENSITIVE
(?m) Multiline (^ $ match lines) Pattern.MULTILINE
(?s) Dotall (. matches newline) Pattern.DOTALL
(?x) Comments mode Pattern.COMMENTS
(?u) Unicode case Pattern.UNICODE_CASE
-- Literal (no metacharacters) Pattern.LITERAL

Complete Practical Example: Log Parser and Input Validator

This final example brings together everything we have learned. It is a complete, runnable program that demonstrates regex in two real-world scenarios: parsing structured log files and validating user input for a registration form.

import java.util.regex.*;
import java.util.*;
import java.util.stream.Collectors;

/**
 * Complete Regex Example: LogParser and InputValidator
 *
 * Demonstrates:
 * - Pattern compilation and reuse (static final)
 * - Named capturing groups
 * - Multiple validation patterns
 * - find() with while loop for extraction
 * - replaceAll for data masking
 * - appendReplacement for custom replacement
 * - Lookaheads for password validation
 * - Word boundaries
 * - Greedy vs lazy matching
 * - Pattern flags
 */
public class RegexDemo {

    // =========================================================================
    // Part 1: Log Parser -- Extract structured data from log entries
    // =========================================================================

    // Pre-compiled patterns (compiled once, reused across all calls)
    private static final Pattern LOG_PATTERN = Pattern.compile(
        "(?\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})" + // 2026-02-28 14:30:00
        "\\s+\\[(?\\w+)]"  +                                  // [ERROR]
        "\\s+(?[\\w.]+)"   +                                  // com.app.Service
        "\\s+-\\s+(?.*)"                                     // - The log message
    );

    private static final Pattern IP_PATTERN = Pattern.compile(
        "\\b(?:(?:25[0-5]|2[0-4]\\d|[01]?\\d{1,2})\\.){3}(?:25[0-5]|2[0-4]\\d|[01]?\\d{1,2})\\b"
    );

    private static final Pattern ERROR_CODE_PATTERN = Pattern.compile(
        "\\b[A-Z]{2,4}-\\d{3,5}\\b"  // e.g., ERR-5001, HTTP-404
    );

    public static void parseLogEntries(String[] logLines) {
        System.out.println("=== LOG PARSER RESULTS ===");
        System.out.println();

        Map levelCounts = new LinkedHashMap<>();
        List errorMessages = new ArrayList<>();

        for (String line : logLines) {
            Matcher m = LOG_PATTERN.matcher(line);
            if (m.matches()) {
                String timestamp = m.group("timestamp");
                String level = m.group("level");
                String className = m.group("class");
                String message = m.group("message");

                // Count log levels
                levelCounts.merge(level, 1, Integer::sum);

                // Collect error messages
                if ("ERROR".equals(level)) {
                    errorMessages.add(timestamp + " | " + className + " | " + message);
                }

                // Extract IP addresses from the message
                Matcher ipMatcher = IP_PATTERN.matcher(message);
                while (ipMatcher.find()) {
                    System.out.println("  IP found in log: " + ipMatcher.group()
                        + " (from " + className + ")");
                }

                // Extract error codes from the message
                Matcher codeMatcher = ERROR_CODE_PATTERN.matcher(message);
                while (codeMatcher.find()) {
                    System.out.println("  Error code found: " + codeMatcher.group()
                        + " (at " + timestamp + ")");
                }
            }
        }

        System.out.println();
        System.out.println("Log Level Summary:");
        levelCounts.forEach((level, count) ->
            System.out.println("  " + level + ": " + count));

        System.out.println();
        System.out.println("Error Messages:");
        errorMessages.forEach(msg -> System.out.println("  " + msg));
    }

    // =========================================================================
    // Part 2: Input Validator -- Validate form fields for user registration
    // =========================================================================

    private static final Pattern EMAIL_PATTERN = Pattern.compile(
        "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"
    );

    private static final Pattern PHONE_PATTERN = Pattern.compile(
        "^(\\+1[- ]?)?(\\(?\\d{3}\\)?[- ]?)?\\d{3}[- ]?\\d{4}$"
    );

    private static final Pattern PASSWORD_PATTERN = Pattern.compile(
        "^(?=.*[A-Z])(?=.*[a-z])(?=.*\\d)(?=.*[@#$%^&+=!]).{8,20}$"
    );

    private static final Pattern USERNAME_PATTERN = Pattern.compile(
        "^[a-zA-Z][a-zA-Z0-9_]{2,19}$"  // starts with letter, 3-20 chars, only alphanumeric and _
    );

    private static final Pattern DATE_PATTERN = Pattern.compile(
        "^(?\\d{4})-(?0[1-9]|1[0-2])-(?0[1-9]|[12]\\d|3[01])$"
    );

    public static Map validateRegistration(
            String username, String email, String password, String phone, String birthDate) {

        Map errors = new LinkedHashMap<>();

        // Username validation
        if (username == null || username.isEmpty()) {
            errors.put("username", "Username is required");
        } else if (!USERNAME_PATTERN.matcher(username).matches()) {
            errors.put("username", "Must start with a letter, 3-20 chars, only letters/digits/underscore");
        }

        // Email validation
        if (email == null || email.isEmpty()) {
            errors.put("email", "Email is required");
        } else if (!EMAIL_PATTERN.matcher(email).matches()) {
            errors.put("email", "Invalid email format");
        }

        // Password validation with specific feedback
        if (password == null || password.isEmpty()) {
            errors.put("password", "Password is required");
        } else {
            List passwordIssues = new ArrayList<>();
            if (password.length() < 8) passwordIssues.add("at least 8 characters");
            if (password.length() > 20) passwordIssues.add("at most 20 characters");
            if (!password.matches(".*[A-Z].*")) passwordIssues.add("an uppercase letter");
            if (!password.matches(".*[a-z].*")) passwordIssues.add("a lowercase letter");
            if (!password.matches(".*\\d.*")) passwordIssues.add("a digit");
            if (!password.matches(".*[@#$%^&+=!].*")) passwordIssues.add("a special character (@#$%^&+=!)");
            if (!passwordIssues.isEmpty()) {
                errors.put("password", "Password needs: " + String.join(", ", passwordIssues));
            }
        }

        // Phone validation
        if (phone != null && !phone.isEmpty() && !PHONE_PATTERN.matcher(phone).matches()) {
            errors.put("phone", "Invalid US phone format");
        }

        // Birth date validation
        if (birthDate != null && !birthDate.isEmpty()) {
            Matcher dm = DATE_PATTERN.matcher(birthDate);
            if (!dm.matches()) {
                errors.put("birthDate", "Invalid date format (use YYYY-MM-DD)");
            } else {
                int year = Integer.parseInt(dm.group("year"));
                if (year > 2026 || year < 1900) {
                    errors.put("birthDate", "Year must be between 1900 and 2026");
                }
            }
        }

        return errors;
    }

    // =========================================================================
    // Part 3: Data Masking -- Redact sensitive information from text
    // =========================================================================

    private static final Pattern SSN_IN_TEXT = Pattern.compile(
        "\\b\\d{3}-\\d{2}-\\d{4}\\b"
    );

    private static final Pattern CC_IN_TEXT = Pattern.compile(
        "\\b(\\d{4})[- ]?(\\d{4})[- ]?(\\d{4})[- ]?(\\d{4})\\b"
    );

    private static final Pattern EMAIL_IN_TEXT = Pattern.compile(
        "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}"
    );

    public static String maskSensitiveData(String text) {
        // Mask SSNs: 123-45-6789 -> ***-**-6789
        String result = SSN_IN_TEXT.matcher(text).replaceAll(mr -> {
            String ssn = mr.group();
            return "***-**-" + ssn.substring(ssn.length() - 4);
        });

        // Mask credit cards: show only last 4 digits
        Matcher ccMatcher = CC_IN_TEXT.matcher(result);
        StringBuilder sb = new StringBuilder();
        while (ccMatcher.find()) {
            ccMatcher.appendReplacement(sb, "****-****-****-" + ccMatcher.group(4));
        }
        ccMatcher.appendTail(sb);
        result = sb.toString();

        // Mask emails: user@domain.com -> u***@domain.com
        Matcher emailMatcher = EMAIL_IN_TEXT.matcher(result);
        sb = new StringBuilder();
        while (emailMatcher.find()) {
            String email = emailMatcher.group();
            int atIndex = email.indexOf('@');
            String masked = email.charAt(0) + "***" + email.substring(atIndex);
            emailMatcher.appendReplacement(sb, Matcher.quoteReplacement(masked));
        }
        emailMatcher.appendTail(sb);

        return sb.toString();
    }

    // =========================================================================
    // Main -- Run all demonstrations
    // =========================================================================

    public static void main(String[] args) {

        // --- Part 1: Parse log entries ---
        String[] logLines = {
            "2026-02-28 14:30:00 [INFO] com.app.UserService - User login from 192.168.1.100",
            "2026-02-28 14:30:05 [ERROR] com.app.PaymentService - Payment failed: ERR-5001 for IP 10.0.0.1",
            "2026-02-28 14:30:10 [WARN] com.app.AuthService - Failed login attempt from 172.16.0.50",
            "2026-02-28 14:30:15 [ERROR] com.app.OrderService - Order processing failed: HTTP-500 timeout",
            "2026-02-28 14:30:20 [INFO] com.app.CacheService - Cache refreshed successfully",
            "2026-02-28 14:30:25 [ERROR] com.app.DatabaseService - Connection lost: DB-1001 to 192.168.1.200"
        };
        parseLogEntries(logLines);

        System.out.println();
        System.out.println("========================================");
        System.out.println();

        // --- Part 2: Validate registration forms ---
        System.out.println("=== REGISTRATION VALIDATION ===");
        System.out.println();

        // Test case 1: Valid registration
        Map errors1 = validateRegistration(
            "john_doe", "john@example.com", "MyP@ss123", "(555) 123-4567", "1990-06-15"
        );
        System.out.println("Test 1 (valid): " + (errors1.isEmpty() ? "PASSED" : "FAILED: " + errors1));

        // Test case 2: Multiple validation failures
        Map errors2 = validateRegistration(
            "2bad", "not-an-email", "weak", "12345", "2026-13-45"
        );
        System.out.println("Test 2 (invalid):");
        errors2.forEach((field, error) -> System.out.println("  " + field + ": " + error));

        // Test case 3: Specific password feedback
        Map errors3 = validateRegistration(
            "alice", "alice@test.com", "onlylowercase", null, null
        );
        System.out.println("Test 3 (weak password):");
        errors3.forEach((field, error) -> System.out.println("  " + field + ": " + error));

        System.out.println();
        System.out.println("========================================");
        System.out.println();

        // --- Part 3: Mask sensitive data ---
        System.out.println("=== DATA MASKING ===");
        System.out.println();
        String sensitiveText = "Customer SSN: 123-45-6789, CC: 4111-1111-1111-1111, " +
                               "Email: john.doe@gmail.com, Alt SSN: 987-65-4321";
        System.out.println("Original: " + sensitiveText);
        System.out.println("Masked:   " + maskSensitiveData(sensitiveText));
    }
}

Output

=== LOG PARSER RESULTS ===

  IP found in log: 192.168.1.100 (from com.app.UserService)
  IP found in log: 10.0.0.1 (from com.app.PaymentService)
  Error code found: ERR-5001 (at 2026-02-28 14:30:05)
  IP found in log: 172.16.0.50 (from com.app.AuthService)
  Error code found: HTTP-500 (at 2026-02-28 14:30:15)
  Error code found: DB-1001 (at 2026-02-28 14:30:25)
  IP found in log: 192.168.1.200 (from com.app.DatabaseService)

Log Level Summary:
  INFO: 2
  ERROR: 3
  WARN: 1

Error Messages:
  2026-02-28 14:30:05 | com.app.PaymentService | Payment failed: ERR-5001 for IP 10.0.0.1
  2026-02-28 14:30:15 | com.app.OrderService | Order processing failed: HTTP-500 timeout
  2026-02-28 14:30:25 | com.app.DatabaseService | Connection lost: DB-1001 to 192.168.1.200

========================================

=== REGISTRATION VALIDATION ===

Test 1 (valid): PASSED
Test 2 (invalid):
  username: Must start with a letter, 3-20 chars, only letters/digits/underscore
  email: Invalid email format
  password: Password needs: at least 8 characters, an uppercase letter, a digit, a special character (@#$%^&+=!)
  phone: Invalid US phone format
  birthDate: Invalid date format (use YYYY-MM-DD)
Test 3 (weak password):
  password: Password needs: an uppercase letter, a digit, a special character (@#$%^&+=!)

========================================

=== DATA MASKING ===

Original: Customer SSN: 123-45-6789, CC: 4111-1111-1111-1111, Email: john.doe@gmail.com, Alt SSN: 987-65-4321
Masked:   Customer SSN: ***-**-6789, CC: ****-****-****-1111, Email: j***@gmail.com, Alt SSN: ***-**-4321

Concepts Demonstrated

# Concept Where Used
1 Pattern compilation and reuse static final Pattern fields throughout
2 Named capturing groups LOG_PATTERN: (?<timestamp>...), (?<level>...), (?<class>...), (?<message>...)
3 find() with while loop IP address and error code extraction from log messages
4 matches() for full-string validation All validators: email, phone, username, password, date
5 Lookaheads for password rules PASSWORD_PATTERN uses (?=.*[A-Z]), (?=.*\\d), etc.
6 Word boundaries SSN_IN_TEXT, CC_IN_TEXT, ERROR_CODE_PATTERN use \\b
7 appendReplacement / appendTail Credit card and email masking with custom replacement logic
8 replaceAll with Function (Java 9+) SSN masking: replaceAll(mr -> ...)
9 Matcher.quoteReplacement() Email masking: prevents $ and \ in replacement from being interpreted
10 Numbered capturing groups CC_IN_TEXT: group(4) to get last 4 digits
11 Group extraction for further processing Date validation: extracting year for range check
12 Multiple regex patterns working together Log parser uses 3 patterns; validator uses 5 patterns; masker uses 3 patterns
13 Breaking complex validation into steps Password validation gives specific feedback per rule instead of one giant regex
14 Null-safe validation All validators check for null before applying regex
March 11, 2019

Generics

1. What are Generics?

Generics allow you to write classes, interfaces, and methods that operate on types specified by the caller, rather than hardcoded into the implementation. Introduced in Java 5 (JDK 1.5), generics bring type safety at compile time — the compiler catches type mismatches before your code ever runs, eliminating an entire category of runtime errors.

Before generics, collections stored everything as Object. You could put a String into a List, then accidentally retrieve it as an Integer, and the compiler would not complain. The error would only surface at runtime as a ClassCastException — often in production, often at the worst possible time.

The Problem Generics Solve

Consider this pre-generics code (Java 1.4 and earlier):

import java.util.ArrayList;
import java.util.List;

public class WithoutGenerics {
    public static void main(String[] args) {
        // Raw List -- no type information. Anything goes.
        List names = new ArrayList();
        names.add("Alice");
        names.add("Bob");
        names.add(42);  // No compiler error! An Integer slipped into a "names" list.

        // Later, when we retrieve elements...
        for (int i = 0; i < names.size(); i++) {
            String name = (String) names.get(i); // Manual cast required every time
            System.out.println(name.toUpperCase());
        }
        // Output:
        // ALICE
        // BOB
        // Exception in thread "main" java.lang.ClassCastException:
        //   java.lang.Integer cannot be cast to java.lang.String
    }
}

There are three problems with the raw type approach:

  1. No compile-time type checking -- the Integer 42 was silently added to a list that should only contain strings
  2. Manual casting -- every retrieval requires (String) cast, which is tedious and error-prone
  3. Runtime failure -- the ClassCastException only appears when the code runs, not when it compiles

Now here is the same code with generics:

import java.util.ArrayList;
import java.util.List;

public class WithGenerics {
    public static void main(String[] args) {
        // Parameterized List -- only String elements allowed
        List names = new ArrayList<>();
        names.add("Alice");
        names.add("Bob");
        // names.add(42);  // COMPILE ERROR: incompatible types: int cannot be converted to String

        // No casting needed -- the compiler knows every element is a String
        for (String name : names) {
            System.out.println(name.toUpperCase());
        }
        // Output:
        // ALICE
        // BOB
    }
}

Before vs After Generics

Aspect Without Generics (Raw Types) With Generics
Declaration List names = new ArrayList(); List<String> names = new ArrayList<>();
Type safety None -- any Object can be added Enforced at compile time
Retrieval Manual cast: (String) list.get(0) No cast needed: list.get(0)
Error detection Runtime (ClassCastException) Compile time (compiler error)
Readability Type intent is hidden Type intent is self-documenting

The golden rule: Generics move type errors from runtime to compile time. A bug caught by the compiler costs you 30 seconds. A bug caught in production costs you hours -- or worse.

2. Generic Classes

A generic class is a class that declares one or more type parameters in angle brackets after its name. These type parameters act as placeholders -- they are replaced with actual types when the class is instantiated. This allows you to write a single class that works with any type while maintaining full type safety.

Basic Syntax

The general form of a generic class declaration is:

// T is a type parameter -- a placeholder for a real type
public class Box {
    private T content;

    public Box(T content) {
        this.content = content;
    }

    public T getContent() {
        return content;
    }

    public void setContent(T content) {
        this.content = content;
    }

    @Override
    public String toString() {
        return "Box[" + content + "]";
    }
}

// Usage -- T is replaced with actual types at instantiation
public class BoxDemo {
    public static void main(String[] args) {
        // Box of String
        Box nameBox = new Box<>("Alice");
        String name = nameBox.getContent(); // No cast needed
        System.out.println(name); // Output: Alice

        // Box of Integer
        Box ageBox = new Box<>(30);
        int age = ageBox.getContent(); // Autoboxing/unboxing works seamlessly
        System.out.println(age); // Output: 30

        // Box of a custom type
        Box> listBox = new Box<>(List.of("A", "B", "C"));
        List items = listBox.getContent();
        System.out.println(items); // Output: [A, B, C]
    }
}

Type Parameter Naming Conventions

By convention, type parameters use single uppercase letters. This distinguishes them from class names at a glance. Here are the standard conventions used throughout the Java ecosystem:

Letter Convention Example Usage
T Type (general purpose) Box<T>, Optional<T>
E Element (used in collections) List<E>, Set<E>, Queue<E>
K Key (used in maps) Map<K, V>
V Value (used in maps) Map<K, V>
N Number Calculator<N extends Number>
S, U, V Second, third, fourth types Function<T, R>, BiFunction<T, U, R>
R Return type Function<T, R>, Callable<R>

Multiple Type Parameters

A generic class can declare multiple type parameters, separated by commas. A classic example is a Pair class that holds two values of potentially different types:

public class Pair {
    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;
    }

    @Override
    public String toString() {
        return "(" + key + ", " + value + ")";
    }

    // Static factory method -- type inference handles the type parameters
    public static  Pair of(K key, V value) {
        return new Pair<>(key, value);
    }
}

public class PairDemo {
    public static void main(String[] args) {
        // Explicit type arguments
        Pair entry = new Pair<>("age", 30);
        System.out.println(entry.getKey());   // Output: age
        System.out.println(entry.getValue()); // Output: 30

        // Using the factory method -- types are inferred
        Pair> config = Pair.of("roles", List.of("ADMIN", "USER"));
        System.out.println(config); // Output: (roles, [ADMIN, USER])

        // Pair of Pairs -- generics compose naturally
        Pair> location = Pair.of("Office", Pair.of(37.7749, -122.4194));
        System.out.println(location); // Output: (Office, (37.7749, -122.4194))
    }
}

Generic Class with Bounded Type

You can constrain a type parameter so that it only accepts types that extend a particular class or implement a particular interface. This lets you call methods of the bound type inside your generic class:

// Only accepts types that are Numbers (Integer, Double, Long, etc.)
public class NumberBox {
    private T number;

    public NumberBox(T number) {
        this.number = number;
    }

    public double doubleValue() {
        // Because T extends Number, we can call Number's methods
        return number.doubleValue();
    }

    public boolean isPositive() {
        return number.doubleValue() > 0;
    }

    public T getNumber() {
        return number;
    }
}

public class NumberBoxDemo {
    public static void main(String[] args) {
        NumberBox intBox = new NumberBox<>(42);
        System.out.println(intBox.doubleValue()); // Output: 42.0
        System.out.println(intBox.isPositive());  // Output: true

        NumberBox doubleBox = new NumberBox<>(-3.14);
        System.out.println(doubleBox.isPositive()); // Output: false

        // NumberBox stringBox = new NumberBox<>("hello");
        // COMPILE ERROR: String does not extend Number
    }
}

3. Generic Methods

A generic method is a method that declares its own type parameters, independent of any type parameters on the enclosing class. The type parameter list appears in angle brackets before the return type. Generic methods can appear in generic classes, non-generic classes, or even as static methods.

The compiler infers the type argument from the arguments you pass, so you rarely need to specify it explicitly.

public class GenericMethodDemo {

    // Generic method --  is declared before the return type
    public static  void printArray(T[] array) {
        for (T element : array) {
            System.out.print(element + " ");
        }
        System.out.println();
    }

    // Generic method that returns a value
    public static  T getFirst(List list) {
        if (list == null || list.isEmpty()) {
            return null;
        }
        return list.get(0);
    }

    // Generic method with two type parameters
    public static  Map mapOf(K key, V value) {
        Map map = new HashMap<>();
        map.put(key, value);
        return map;
    }

    // Generic swap method
    public static  void swap(T[] array, int i, int j) {
        T temp = array[i];
        array[i] = array[j];
        array[j] = temp;
    }

    // Generic max method -- requires Comparable bound
    public static > T max(T a, T b) {
        return a.compareTo(b) >= 0 ? a : b;
    }

    public static void main(String[] args) {
        // printArray -- type inferred from arguments
        Integer[] intArr = {1, 2, 3, 4, 5};
        String[] strArr = {"Hello", "World"};
        printArray(intArr);  // Output: 1 2 3 4 5
        printArray(strArr);  // Output: Hello World

        // getFirst -- type inferred from list
        List names = List.of("Alice", "Bob", "Charlie");
        String first = getFirst(names); // No cast needed
        System.out.println("First: " + first); // Output: First: Alice

        // swap
        String[] colors = {"Red", "Blue", "Green"};
        swap(colors, 0, 2);
        printArray(colors); // Output: Green Blue Red

        // max -- works with any Comparable type
        System.out.println(max(10, 20));          // Output: 20
        System.out.println(max("apple", "banana")); // Output: banana
        System.out.println(max(3.14, 2.71));      // Output: 3.14
    }
}

Generic Methods in Non-Generic Classes

A key insight is that a method can be generic even if its enclosing class is not. This is common for utility classes:

// This is NOT a generic class -- no type parameter on the class itself
public class ArrayUtils {

    // But these are generic methods
    public static  boolean contains(T[] array, T target) {
        for (T element : array) {
            if (element.equals(target)) {
                return true;
            }
        }
        return false;
    }

    public static  int indexOf(T[] array, T target) {
        for (int i = 0; i < array.length; i++) {
            if (array[i].equals(target)) {
                return i;
            }
        }
        return -1;
    }

    public static  T[] reverse(T[] array) {
        @SuppressWarnings("unchecked")
        T[] reversed = (T[]) java.lang.reflect.Array.newInstance(
            array.getClass().getComponentType(), array.length);
        for (int i = 0; i < array.length; i++) {
            reversed[i] = array[array.length - 1 - i];
        }
        return reversed;
    }

    public static void main(String[] args) {
        String[] fruits = {"Apple", "Banana", "Cherry"};

        System.out.println(contains(fruits, "Banana")); // Output: true
        System.out.println(contains(fruits, "Grape"));  // Output: false
        System.out.println(indexOf(fruits, "Cherry"));  // Output: 2

        String[] reversed = reverse(fruits);
        System.out.println(Arrays.toString(reversed)); // Output: [Cherry, Banana, Apple]
    }
}

4. Bounded Type Parameters

Sometimes you need a type parameter that is not completely open-ended. You want to restrict it to a family of types so that you can call specific methods on it. This is where bounded type parameters come in.

Upper Bounds with extends

The extends keyword in a type parameter bound means "is a subtype of" -- this works for both classes and interfaces. When you write <T extends Number>, T can be Integer, Double, Long, or any other subclass of Number.

Why is this useful? Because inside the method or class, you can call any method defined on the bound type. Without the bound, T is effectively Object, and you can only call toString(), equals(), and hashCode().

public class BoundedTypeDemo {

    // Without bound: T is Object -- we can't call .doubleValue()
    // public static  double sum(List numbers) {
    //     double total = 0;
    //     for (T n : numbers) {
    //         total += n.doubleValue(); // COMPILE ERROR: Object has no doubleValue()
    //     }
    //     return total;
    // }

    // With upper bound: T must be a Number -- now we can call doubleValue()
    public static  double sum(List numbers) {
        double total = 0;
        for (T n : numbers) {
            total += n.doubleValue(); // OK: Number has doubleValue()
        }
        return total;
    }

    // Finding the maximum in a list -- T must be Comparable
    public static > T findMax(List list) {
        if (list == null || list.isEmpty()) {
            throw new IllegalArgumentException("List must not be null or empty");
        }
        T max = list.get(0);
        for (int i = 1; i < list.size(); i++) {
            if (list.get(i).compareTo(max) > 0) {
                max = list.get(i);
            }
        }
        return max;
    }

    public static void main(String[] args) {
        List integers = List.of(10, 20, 30);
        List doubles = List.of(1.5, 2.5, 3.5);

        System.out.println("Sum of integers: " + sum(integers)); // Output: Sum of integers: 60.0
        System.out.println("Sum of doubles: " + sum(doubles));   // Output: Sum of doubles: 7.5

        // sum(List.of("a", "b")); // COMPILE ERROR: String does not extend Number

        List words = List.of("banana", "apple", "cherry");
        System.out.println("Max word: " + findMax(words));      // Output: Max word: cherry
        System.out.println("Max integer: " + findMax(integers)); // Output: Max integer: 30
    }
}

Multiple Bounds

A type parameter can have multiple bounds, separated by &. The syntax is <T extends ClassA & InterfaceB & InterfaceC>. If one of the bounds is a class (not an interface), it must be listed first.

import java.io.Serializable;

public class MultipleBoundsDemo {

    // T must be Comparable AND Serializable
    public static  & Serializable> T clampedMax(T a, T b, T ceiling) {
        T larger = a.compareTo(b) >= 0 ? a : b;
        return larger.compareTo(ceiling) > 0 ? ceiling : larger;
    }

    // A practical example: a method that requires both Number and Comparable
    public static > T clamp(T value, T min, T max) {
        if (value.compareTo(min) < 0) return min;
        if (value.compareTo(max) > 0) return max;
        return value;
    }

    public static void main(String[] args) {
        // Integer implements both Comparable and Serializable
        System.out.println(clampedMax(10, 20, 15)); // Output: 15

        // clamp example
        System.out.println(clamp(5, 1, 10));   // Output: 5 (within range)
        System.out.println(clamp(-3, 1, 10));  // Output: 1 (below min, clamped up)
        System.out.println(clamp(15, 1, 10));  // Output: 10 (above max, clamped down)
        System.out.println(clamp(3.7, 1.0, 5.0)); // Output: 3.7
    }
}

5. Wildcards

Wildcards (?) represent an unknown type. They are used in type arguments (not type parameters) -- that is, you use them when using a generic type, not when declaring one. Wildcards are essential for writing flexible APIs that can accept a range of parameterized types.

There are three kinds of wildcards:

Wildcard Syntax Meaning Use Case
Upper bounded ? extends Type Any subtype of Type Reading from a structure (producer)
Lower bounded ? super Type Any supertype of Type Writing to a structure (consumer)
Unbounded ? Any type at all When the type does not matter

Upper Bounded Wildcards (? extends Type)

Use upper bounded wildcards when you want to read from a generic structure. You know the elements are at least the bound type, so you can safely read them as that type. However, you cannot write to such a structure (except null), because the compiler does not know the exact type.

public class UpperBoundedWildcardDemo {

    // This method accepts List, List, List, etc.
    public static double sumOfList(List list) {
        double total = 0;
        for (Number n : list) { // Safe to read as Number
            total += n.doubleValue();
        }
        return total;
    }

    // Without the wildcard, this would ONLY accept List -- not List!
    // Remember: List is NOT a subtype of List, even though Integer IS a subtype of Number.
    // This is called "invariance" and it's a critical concept to understand.

    public static void main(String[] args) {
        List integers = List.of(1, 2, 3);
        List doubles = List.of(1.5, 2.5, 3.5);
        List numbers = List.of(1, 2.5, 3L);

        System.out.println(sumOfList(integers)); // Output: 6.0
        System.out.println(sumOfList(doubles));  // Output: 7.5
        System.out.println(sumOfList(numbers));  // Output: 6.5

        // Why can't we add to a List?
        List unknown = integers;
        // unknown.add(42);    // COMPILE ERROR!
        // unknown.add(3.14);  // COMPILE ERROR!
        // The compiler doesn't know if the list is List, List, etc.
        // Adding a Double to a List would break type safety.
        Number first = unknown.get(0); // But READING is fine
        System.out.println("First: " + first); // Output: First: 1
    }
}

Lower Bounded Wildcards (? super Type)

Use lower bounded wildcards when you want to write to a generic structure. You know the structure accepts at least the bound type and all its subtypes, so you can safely add elements of that type. However, when you read from it, you can only guarantee the element is an Object.

public class LowerBoundedWildcardDemo {

    // This method accepts List, List, List
    public static void addIntegers(List list) {
        list.add(1);   // Safe: Integer is always accepted
        list.add(2);
        list.add(3);
        // list.add(3.14); // COMPILE ERROR: Double is not Integer
    }

    // Copy elements from a producer to a consumer
    public static  void copy(List source, List dest) {
        for (T item : source) {
            dest.add(item);
        }
    }

    public static void main(String[] args) {
        // List -- can hold Integer
        List intList = new ArrayList<>();
        addIntegers(intList);
        System.out.println(intList); // Output: [1, 2, 3]

        // List -- can hold Integer (since Integer extends Number)
        List numList = new ArrayList<>();
        addIntegers(numList);
        System.out.println(numList); // Output: [1, 2, 3]

        // List -- can hold Integer (since Integer extends Object)
        List objList = new ArrayList<>();
        addIntegers(objList);
        System.out.println(objList); // Output: [1, 2, 3]

        // copy example
        List source = List.of(10, 20, 30);
        List destination = new ArrayList<>();
        copy(source, destination);
        System.out.println(destination); // Output: [10, 20, 30]
    }
}

The PECS Principle (Producer Extends, Consumer Super)

Joshua Bloch coined this mnemonic in Effective Java, and it is the single most important rule for using wildcards correctly:

  • Producer Extends: If a parameterized type produces (provides) elements of type T, use ? extends T. You are reading from it.
  • Consumer Super: If a parameterized type consumes (accepts) elements of type T, use ? super T. You are writing to it.
  • If a parameter both produces and consumes, do not use wildcards -- use an exact type.
import java.util.*;
import java.util.function.Predicate;

public class PECSDemo {

    // 'src' is a PRODUCER (we read from it) -- use extends
    // 'dest' is a CONSUMER (we write to it) -- use super
    public static  void transferFiltered(
            List src,       // Producer: extends
            List dest,        // Consumer: super
            Predicate filter) // Consumer: super (it "consumes" T to test it)
    {
        for (T item : src) {
            if (filter.test(item)) {
                dest.add(item);
            }
        }
    }

    public static void main(String[] args) {
        List source = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
        List evens = new ArrayList<>();

        // Transfer even numbers from List to List
        transferFiltered(source, evens, n -> n % 2 == 0);
        System.out.println("Evens: " + evens); // Output: Evens: [2, 4, 6, 8, 10]
    }
}

Unbounded Wildcards (?)

Use an unbounded wildcard when the actual type does not matter. This is appropriate when you are working with methods from Object (like toString() or size()) or when the method's logic is type-independent.

public class UnboundedWildcardDemo {

    // We don't care what type the list contains -- we just want its size
    public static int countElements(List list) {
        return list.size();
    }

    // Print any list -- we only call toString() on elements
    public static void printList(List list) {
        for (Object element : list) {
            System.out.print(element + " ");
        }
        System.out.println();
    }

    // Check if two lists have the same size
    public static boolean sameSize(List a, List b) {
        return a.size() == b.size();
    }

    public static void main(String[] args) {
        List names = List.of("Alice", "Bob");
        List ages = List.of(30, 25);
        List scores = List.of(95.5, 87.3, 91.0);

        printList(names);  // Output: Alice Bob
        printList(ages);   // Output: 30 25
        printList(scores); // Output: 95.5 87.3 91.0

        System.out.println(countElements(scores)); // Output: 3
        System.out.println(sameSize(names, ages)); // Output: true
    }
}

Wildcard Summary

Scenario Wildcard to Use Can Read As Can Write
Read elements as T ? extends T T Only null
Write elements of type T ? super T Object T and subtypes
Read and write No wildcard (use T) T T
Type does not matter ? Object Only null

6. Type Erasure

Here is the part that trips up many Java developers: generics exist only at compile time. At runtime, the JVM has no concept of generic types. The compiler removes (erases) all type parameter information and replaces it with their bounds (or Object if unbounded). This process is called type erasure.

Type erasure was a deliberate design choice to maintain backward compatibility with pre-generics Java code. It means that List<String> and List<Integer> are the same class at runtime -- they are both just List.

What Happens During Erasure

Generic Code (Compile Time) After Erasure (Runtime)
List<String> List
Box<Integer> Box
<T> T getFirst(List<T>) Object getFirst(List)
<T extends Number> T sum(T a, T b) Number sum(Number a, Number b)
Pair<String, Integer> Pair (fields become Object)

Consequences of Type Erasure

Type erasure imposes several limitations that every Java developer must understand. These are not bugs -- they are inherent trade-offs of the erasure-based generics design:

public class TypeErasureDemo {

    // 1. You CANNOT create instances of type parameters
    public static  T createInstance() {
        // return new T(); // COMPILE ERROR: Type parameter T cannot be instantiated directly
        // Workaround: pass a Supplier or Class
        return null;
    }

    // Workaround using Class
    public static  T createInstance(Class clazz) throws Exception {
        return clazz.getDeclaredConstructor().newInstance();
    }

    // 2. You CANNOT use instanceof with generic types
    public static  void checkType(List list) {
        // if (list instanceof List) {} // COMPILE ERROR
        // At runtime, list is just a List -- the  is gone
        if (list instanceof List) { } // This is OK -- unbounded wildcard is fine
    }

    // 3. You CANNOT create generic arrays
    public static  void cannotCreateGenericArray() {
        // T[] array = new T[10]; // COMPILE ERROR
        // List[] arrayOfLists = new List[10]; // COMPILE ERROR

        // Workaround: use Array.newInstance or collections
        @SuppressWarnings("unchecked")
        T[] workaround = (T[]) new Object[10]; // Works but produces unchecked warning
    }

    // 4. You CANNOT overload methods that differ only by type parameter
    // public static void process(List list) { }
    // public static void process(List list) { } // COMPILE ERROR
    // After erasure, both signatures become process(List list) -- they clash

    // 5. You CANNOT catch or throw generic types
    // class MyException extends Exception { } // COMPILE ERROR
    // The JVM needs to know the exact exception type at runtime

    public static void main(String[] args) throws Exception {
        // Demonstrating that generic types are the same at runtime
        List strings = new ArrayList<>();
        List integers = new ArrayList<>();

        System.out.println(strings.getClass() == integers.getClass()); // Output: true
        System.out.println(strings.getClass().getName()); // Output: java.util.ArrayList
        // Both are just ArrayList at runtime -- the  and  are gone

        // Creating instances via Class
        String s = createInstance(String.class);
        System.out.println("Created: " + s); // Output: Created:
    }
}

Bridge Methods

When a generic class is extended with a concrete type, the compiler sometimes generates bridge methods to maintain polymorphism after type erasure. You will rarely interact with bridge methods directly, but understanding them explains some unexpected behaviors:

// Generic interface
public interface Transformer {
    T transform(T input);
}

// Concrete implementation
public class UpperCaseTransformer implements Transformer {
    @Override
    public String transform(String input) {
        return input.toUpperCase();
    }
}

// After type erasure, the interface becomes:
//   Object transform(Object input)
//
// But UpperCaseTransformer has:
//   String transform(String input)
//
// These are different signatures! The compiler generates a "bridge method":
//   public Object transform(Object input) {
//       return transform((String) input);  // delegates to the real method
//   }
//
// This bridge method ensures that calling transformer.transform(obj)
// via the interface still dispatches to the correct implementation.

public class BridgeMethodDemo {
    public static void main(String[] args) {
        Transformer transformer = new UpperCaseTransformer();
        System.out.println(transformer.transform("hello")); // Output: HELLO

        // The bridge method is visible via reflection
        for (java.lang.reflect.Method m : UpperCaseTransformer.class.getDeclaredMethods()) {
            System.out.println(m.getName() + " - bridge: " + m.isBridge()
                + " - params: " + java.util.Arrays.toString(m.getParameterTypes()));
        }
        // Output:
        // transform - bridge: false - params: [class java.lang.String]
        // transform - bridge: true - params: [class java.lang.Object]
    }
}

7. Generic Interfaces

Just as classes can be generic, interfaces can declare type parameters. In fact, some of the most important interfaces in the Java standard library are generic: Comparable<T>, Iterable<T>, Comparator<T>, Function<T, R>, and more.

When a class implements a generic interface, it has three choices:

Approach Example When to Use
Concrete type class Name implements Comparable<Name> The type is fixed and known
Keep generic class Box<T> implements Container<T> The implementing class is also generic
Raw type class Legacy implements Comparable Never do this -- legacy only
// Defining a generic interface
public interface Repository {
    T findById(ID id);
    List findAll();
    T save(T entity);
    void deleteById(ID id);
    boolean existsById(ID id);
}

// Approach 1: Implement with concrete types
public class UserRepository implements Repository {
    private final Map store = new HashMap<>();

    @Override
    public User findById(Long id) {
        return store.get(id);
    }

    @Override
    public List findAll() {
        return new ArrayList<>(store.values());
    }

    @Override
    public User save(User user) {
        store.put(user.getId(), user);
        return user;
    }

    @Override
    public void deleteById(Long id) {
        store.remove(id);
    }

    @Override
    public boolean existsById(Long id) {
        return store.containsKey(id);
    }
}

// Approach 2: Keep the type parameters -- create a reusable base class
public class InMemoryRepository implements Repository {
    private final Map store = new HashMap<>();
    private final Function idExtractor;

    public InMemoryRepository(Function idExtractor) {
        this.idExtractor = idExtractor;
    }

    @Override
    public T findById(ID id) {
        return store.get(id);
    }

    @Override
    public List findAll() {
        return new ArrayList<>(store.values());
    }

    @Override
    public T save(T entity) {
        store.put(idExtractor.apply(entity), entity);
        return entity;
    }

    @Override
    public void deleteById(ID id) {
        store.remove(id);
    }

    @Override
    public boolean existsById(ID id) {
        return store.containsKey(id);
    }
}

Implementing Comparable<T>

Comparable<T> is arguably the most frequently implemented generic interface. It defines a natural ordering for a class. Here is a proper implementation:

public class Employee implements Comparable {
    private final String name;
    private final double salary;

    public Employee(String name, double salary) {
        this.name = name;
        this.salary = salary;
    }

    public String getName() { return name; }
    public double getSalary() { return salary; }

    // Natural ordering: by salary (ascending)
    @Override
    public int compareTo(Employee other) {
        return Double.compare(this.salary, other.salary);
    }

    @Override
    public String toString() {
        return name + "($" + salary + ")";
    }
}

public class ComparableDemo {
    public static void main(String[] args) {
        List team = new ArrayList<>(List.of(
            new Employee("Alice", 95000),
            new Employee("Bob", 72000),
            new Employee("Charlie", 110000),
            new Employee("Diana", 88000)
        ));

        // Sort uses Comparable.compareTo()
        Collections.sort(team);
        System.out.println("By salary: " + team);
        // Output: By salary: [Bob($72000.0), Diana($88000.0), Alice($95000.0), Charlie($110000.0)]

        // Collections.min() and max() also use Comparable
        System.out.println("Lowest paid: " + Collections.min(team));  // Output: Lowest paid: Bob($72000.0)
        System.out.println("Highest paid: " + Collections.max(team)); // Output: Highest paid: Charlie($110000.0)
    }
}

8. Generic Collections

Generics and the Collections Framework are inseparable. When generics were introduced in Java 5, the entire Collections API was retrofitted to use them. This is why you write List<String> instead of the raw List. Understanding how generics power collections is essential for everyday Java development.

The Diamond Operator (Java 7+)

Before Java 7, you had to repeat the type arguments on both sides of the assignment:

import java.util.*;

public class GenericCollectionsDemo {

    public static void main(String[] args) {

        // Java 5/6 -- type arguments repeated on both sides (verbose)
        Map> scores1 = new HashMap>();

        // Java 7+ -- diamond operator infers the type arguments
        Map> scores2 = new HashMap<>(); // Much cleaner

        // Java 9+ -- factory methods with implicit typing
        List names = List.of("Alice", "Bob", "Charlie");
        Set primes = Set.of(2, 3, 5, 7, 11);
        Map ages = Map.of("Alice", 30, "Bob", 25);

        // -------------------------------------------------------
        // List -- ordered, allows duplicates
        // -------------------------------------------------------
        List languages = new ArrayList<>();
        languages.add("Java");
        languages.add("Python");
        languages.add("Java"); // Duplicates OK
        String first = languages.get(0); // No cast needed -- type safe
        System.out.println("Languages: " + languages); // Output: Languages: [Java, Python, Java]

        // -------------------------------------------------------
        // Set -- no duplicates
        // -------------------------------------------------------
        Set uniqueLanguages = new LinkedHashSet<>(languages);
        System.out.println("Unique: " + uniqueLanguages); // Output: Unique: [Java, Python]

        // -------------------------------------------------------
        // Map -- key-value pairs
        // -------------------------------------------------------
        Map> courseStudents = new HashMap<>();
        courseStudents.put("CS101", List.of("Alice", "Bob"));
        courseStudents.put("CS201", List.of("Charlie", "Diana"));

        // Type safety carries through nested generics
        List cs101Students = courseStudents.get("CS101"); // returns List, not Object
        System.out.println("CS101: " + cs101Students); // Output: CS101: [Alice, Bob]

        // -------------------------------------------------------
        // Queue -- FIFO ordering
        // -------------------------------------------------------
        Queue taskQueue = new LinkedList<>();
        taskQueue.offer("Build feature");
        taskQueue.offer("Write tests");
        taskQueue.offer("Deploy");
        System.out.println("Next task: " + taskQueue.poll()); // Output: Next task: Build feature

        // -------------------------------------------------------
        // Deque -- double-ended queue (also used as a stack)
        // -------------------------------------------------------
        Deque stack = new ArrayDeque<>();
        stack.push("First");
        stack.push("Second");
        stack.push("Third");
        System.out.println("Stack pop: " + stack.pop()); // Output: Stack pop: Third

        // -------------------------------------------------------
        // PriorityQueue -- elements ordered by natural ordering or Comparator
        // -------------------------------------------------------
        PriorityQueue minHeap = new PriorityQueue<>();
        minHeap.addAll(List.of(30, 10, 50, 20, 40));
        System.out.print("Priority order: ");
        while (!minHeap.isEmpty()) {
            System.out.print(minHeap.poll() + " ");
        }
        // Output: Priority order: 10 20 30 40 50
        System.out.println();
    }
}

Type Safety Benefits in Practice

The real power of generic collections becomes apparent when you chain operations. Every step preserves type information, and the compiler verifies correctness throughout:

import java.util.*;
import java.util.stream.*;

public class TypeSafetyBenefitDemo {

    record Student(String name, int grade, String major) {}

    public static void main(String[] args) {
        List students = List.of(
            new Student("Alice", 92, "CS"),
            new Student("Bob", 85, "Math"),
            new Student("Charlie", 97, "CS"),
            new Student("Diana", 88, "CS"),
            new Student("Eve", 91, "Math")
        );

        // Every step is type-safe -- the compiler tracks types through the entire pipeline
        Map> byMajor = students.stream()
            .collect(Collectors.groupingBy(Student::major)); // Map>

        Map avgGradeByMajor = students.stream()
            .collect(Collectors.groupingBy(
                Student::major,                                   // K = String
                Collectors.averagingInt(Student::grade)            // V = Double
            ));

        System.out.println("CS students: " + byMajor.get("CS"));
        // Output: CS students: [Student[name=Alice, grade=92, major=CS], ...]

        System.out.println("Average grades: " + avgGradeByMajor);
        // Output: Average grades: {CS=92.33333333333333, Math=88.0}

        // Optional -- not Optional
        Optional topStudent = students.stream()
            .max(Comparator.comparingInt(Student::grade));

        topStudent.ifPresent(s ->
            System.out.println("Top student: " + s.name() + " (" + s.grade() + ")")
        );
        // Output: Top student: Charlie (97)
    }
}

9. Recursive Type Bounds

A recursive type bound is a type parameter that is bounded by an expression involving the type parameter itself. The most common form is <T extends Comparable<T>>, which reads: "T is a type that can compare itself to other instances of T."

This pattern appears throughout the Java standard library and is essential for writing type-safe APIs that deal with ordering, self-referential structures, or fluent builders.

The Classic: <T extends Comparable<T>>

public class RecursiveTypeBoundDemo {

    // Without recursive bound -- less type safe
    // public static  T findMax(List list) {
    //     // Can't call compareTo() because T is just Object
    // }

    // With recursive bound -- T must be comparable to itself
    public static > T findMax(List list) {
        if (list.isEmpty()) throw new IllegalArgumentException("Empty list");
        T max = list.get(0);
        for (T item : list) {
            if (item.compareTo(max) > 0) {
                max = item;
            }
        }
        return max;
    }

    // Sorting with recursive bounds
    public static > List sorted(List list) {
        List copy = new ArrayList<>(list);
        Collections.sort(copy); // Works because T is Comparable
        return copy;
    }

    // Range check with recursive bounds
    public static > boolean isBetween(T value, T low, T high) {
        return value.compareTo(low) >= 0 && value.compareTo(high) <= 0;
    }

    public static void main(String[] args) {
        System.out.println(findMax(List.of(3, 1, 4, 1, 5, 9))); // Output: 9
        System.out.println(findMax(List.of("banana", "apple", "cherry"))); // Output: cherry

        System.out.println(sorted(List.of(5, 2, 8, 1, 9))); // Output: [1, 2, 5, 8, 9]

        System.out.println(isBetween(5, 1, 10));  // Output: true
        System.out.println(isBetween(15, 1, 10)); // Output: false
        System.out.println(isBetween("dog", "cat", "fox")); // Output: true
    }
}

Fluent Builder with Recursive Generics

Recursive type bounds enable the curiously recurring template pattern (CRTP), which is particularly useful for builder hierarchies. Without it, a subclass builder's methods would return the parent builder type, breaking method chaining.

// Base class with a self-referential builder
public abstract class Pizza {
    private final String size;
    private final boolean cheese;
    private final boolean pepperoni;
    private final boolean mushrooms;

    // T extends Builder -- the recursive bound
    protected abstract static class Builder> {
        private String size;
        private boolean cheese;
        private boolean pepperoni;
        private boolean mushrooms;

        // Each method returns T (the concrete builder type), not Builder
        @SuppressWarnings("unchecked")
        protected T self() {
            return (T) this;
        }

        public T size(String size) {
            this.size = size;
            return self();
        }

        public T cheese(boolean cheese) {
            this.cheese = cheese;
            return self();
        }

        public T pepperoni(boolean pepperoni) {
            this.pepperoni = pepperoni;
            return self();
        }

        public T mushrooms(boolean mushrooms) {
            this.mushrooms = mushrooms;
            return self();
        }

        public abstract Pizza build();
    }

    protected Pizza(Builder builder) {
        this.size = builder.size;
        this.cheese = builder.cheese;
        this.pepperoni = builder.pepperoni;
        this.mushrooms = builder.mushrooms;
    }

    @Override
    public String toString() {
        return size + " pizza [cheese=" + cheese + ", pepperoni=" + pepperoni
            + ", mushrooms=" + mushrooms + "]";
    }
}

// Subclass with its own builder that adds extra options
public class CalzonePizza extends Pizza {
    private final boolean sauceInside;

    public static class Builder extends Pizza.Builder {
        private boolean sauceInside;

        public Builder sauceInside(boolean sauceInside) {
            this.sauceInside = sauceInside;
            return self();
        }

        @Override
        public CalzonePizza build() {
            return new CalzonePizza(this);
        }

        @Override
        protected Builder self() {
            return this;
        }
    }

    private CalzonePizza(Builder builder) {
        super(builder);
        this.sauceInside = builder.sauceInside;
    }
}

// Usage -- method chaining works correctly across the class hierarchy
public class BuilderDemo {
    public static void main(String[] args) {
        CalzonePizza pizza = new CalzonePizza.Builder()
            .size("Large")           // returns CalzonePizza.Builder, not Pizza.Builder
            .cheese(true)            // still CalzonePizza.Builder
            .pepperoni(true)         // still CalzonePizza.Builder
            .sauceInside(true)       // CalzonePizza.Builder-specific method
            .build();

        System.out.println(pizza);
        // Output: Large pizza [cheese=true, pepperoni=true, mushrooms=false]
    }
}

Understanding Enum<E extends Enum<E>>

The Java Enum class uses the most famous recursive type bound in the language: Enum<E extends Enum<E>>. This ensures that enum methods like compareTo() and valueOf() work with the correct enum type, not just Enum in general.

When you write enum Color { RED, GREEN, BLUE }, the compiler generates class Color extends Enum<Color>. This means:

  • Color.RED.compareTo(Color.BLUE) works because Comparable<Color> is inherited
  • You cannot accidentally compare Color.RED to Size.LARGE -- the type system prevents it
  • Enum.valueOf(Color.class, "RED") returns Color, not Enum

10. Generic Utility Methods

One of the greatest strengths of generics is the ability to write reusable utility methods that work across all types while maintaining compile-time type safety. The Java standard library is full of these (see Collections, Arrays, Objects), and writing your own is a hallmark of senior-level Java development.

import java.util.*;
import java.util.function.*;
import java.util.stream.*;

public class GenericUtilities {

    // -------------------------------------------------------
    // 1. Generic filter -- return elements matching a predicate
    // -------------------------------------------------------
    public static  List filter(List list, Predicate predicate) {
        List result = new ArrayList<>();
        for (T item : list) {
            if (predicate.test(item)) {
                result.add(item);
            }
        }
        return result;
    }

    // -------------------------------------------------------
    // 2. Generic transform (map) -- convert each element
    // -------------------------------------------------------
    public static  List transform(List list, Function mapper) {
        List result = new ArrayList<>(list.size());
        for (T item : list) {
            result.add(mapper.apply(item));
        }
        return result;
    }

    // -------------------------------------------------------
    // 3. Generic reduce -- combine all elements into one value
    // -------------------------------------------------------
    public static  T reduce(List list, T identity, BinaryOperator combiner) {
        T result = identity;
        for (T item : list) {
            result = combiner.apply(result, item);
        }
        return result;
    }

    // -------------------------------------------------------
    // 4. Generic groupBy -- group elements by a classifier
    // -------------------------------------------------------
    public static  Map> groupBy(List list, Function classifier) {
        Map> groups = new LinkedHashMap<>();
        for (T item : list) {
            K key = classifier.apply(item);
            groups.computeIfAbsent(key, k -> new ArrayList<>()).add(item);
        }
        return groups;
    }

    // -------------------------------------------------------
    // 5. Generic partition -- split into two groups by predicate
    // -------------------------------------------------------
    public static  Map> partition(List list, Predicate predicate) {
        Map> result = new LinkedHashMap<>();
        result.put(true, new ArrayList<>());
        result.put(false, new ArrayList<>());
        for (T item : list) {
            result.get(predicate.test(item)).add(item);
        }
        return result;
    }

    // -------------------------------------------------------
    // 6. Generic zip -- combine two lists into a list of pairs
    // -------------------------------------------------------
    public static  List> zip(List first, List second) {
        int size = Math.min(first.size(), second.size());
        List> result = new ArrayList<>(size);
        for (int i = 0; i < size; i++) {
            result.add(Map.entry(first.get(i), second.get(i)));
        }
        return result;
    }

    // -------------------------------------------------------
    // 7. Generic frequency count
    // -------------------------------------------------------
    public static  Map frequencyCount(List list) {
        Map counts = new LinkedHashMap<>();
        for (T item : list) {
            counts.merge(item, 1L, Long::sum);
        }
        return counts;
    }

    public static void main(String[] args) {
        List numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

        // filter
        List evens = filter(numbers, n -> n % 2 == 0);
        System.out.println("Evens: " + evens); // Output: Evens: [2, 4, 6, 8, 10]

        // transform
        List labels = transform(numbers, n -> "Item-" + n);
        System.out.println("Labels: " + labels);
        // Output: Labels: [Item-1, Item-2, Item-3, ..., Item-10]

        // reduce
        int sum = reduce(numbers, 0, Integer::sum);
        System.out.println("Sum: " + sum); // Output: Sum: 55

        // groupBy
        Map> oddEven = groupBy(numbers, n -> n % 2 == 0 ? "even" : "odd");
        System.out.println("Grouped: " + oddEven);
        // Output: Grouped: {odd=[1, 3, 5, 7, 9], even=[2, 4, 6, 8, 10]}

        // partition
        Map> partitioned = partition(numbers, n -> n > 5);
        System.out.println("Greater than 5: " + partitioned.get(true));  // Output: [6, 7, 8, 9, 10]
        System.out.println("Not greater than 5: " + partitioned.get(false)); // Output: [1, 2, 3, 4, 5]

        // zip
        List names = List.of("Alice", "Bob", "Charlie");
        List ages = List.of(30, 25, 35);
        System.out.println("Zipped: " + zip(names, ages));
        // Output: Zipped: [Alice=30, Bob=25, Charlie=35]

        // frequency count
        List words = List.of("java", "python", "java", "go", "java", "python");
        System.out.println("Frequency: " + frequencyCount(words));
        // Output: Frequency: {java=3, python=2, go=1}
    }
}

Collections Utility Methods Using Generics

The java.util.Collections class is a masterclass in generic utility methods. Here are some of the most useful ones and how they leverage generics:

import java.util.*;

public class CollectionsUtilityDemo {
    public static void main(String[] args) {
        // -------------------------------------------------------
        // Collections.sort() -- >
        // -------------------------------------------------------
        List names = new ArrayList<>(List.of("Charlie", "Alice", "Bob"));
        Collections.sort(names);
        System.out.println("Sorted: " + names); // Output: Sorted: [Alice, Bob, Charlie]

        // -------------------------------------------------------
        // Collections.unmodifiableList() --  List unmodifiableList(List)
        // -------------------------------------------------------
        List readOnly = Collections.unmodifiableList(names);
        // readOnly.add("Diana"); // Throws UnsupportedOperationException
        System.out.println("Unmodifiable: " + readOnly); // Output: Unmodifiable: [Alice, Bob, Charlie]

        // -------------------------------------------------------
        // Collections.singletonList() --  List singletonList(T o)
        // -------------------------------------------------------
        List single = Collections.singletonList("OnlyOne");
        System.out.println("Singleton: " + single); // Output: Singleton: [OnlyOne]

        // -------------------------------------------------------
        // Collections.emptyList/Map/Set --  List emptyList()
        // -------------------------------------------------------
        List empty = Collections.emptyList();
        System.out.println("Empty: " + empty); // Output: Empty: []

        // -------------------------------------------------------
        // Collections.synchronizedList() --  List synchronizedList(List)
        // -------------------------------------------------------
        List threadSafe = Collections.synchronizedList(new ArrayList<>(names));
        System.out.println("Thread-safe: " + threadSafe);

        // -------------------------------------------------------
        // Collections.frequency() -- int frequency(Collection, Object)
        // -------------------------------------------------------
        List items = List.of("a", "b", "a", "c", "a");
        System.out.println("Frequency of 'a': " + Collections.frequency(items, "a"));
        // Output: Frequency of 'a': 3

        // -------------------------------------------------------
        // Collections.min/max with Comparator
        // -------------------------------------------------------
        String longest = Collections.max(names, Comparator.comparingInt(String::length));
        System.out.println("Longest name: " + longest); // Output: Longest name: Charlie
    }
}

11. Common Mistakes

Even experienced developers fall into these traps. Understanding these mistakes will save you hours of debugging and help you write cleaner generic code.

Mistake 1: Using Raw Types

A raw type is a generic type used without any type arguments. Raw types exist only for backward compatibility with pre-Java 5 code. There is never a valid reason to use them in new code.

public class RawTypeMistake {
    public static void main(String[] args) {
        // BAD -- raw type. The compiler cannot help you.
        List names = new ArrayList();
        names.add("Alice");
        names.add(42);          // No error -- but this is a bug
        String first = (String) names.get(0); // Manual cast -- tedious
        // String second = (String) names.get(1); // ClassCastException at runtime!

        // GOOD -- parameterized type. The compiler has your back.
        List safeNames = new ArrayList<>();
        safeNames.add("Alice");
        // safeNames.add(42); // COMPILE ERROR -- bug caught immediately
        String safeFirst = safeNames.get(0); // No cast needed
    }
}

Mistake 2: Trying to Instantiate Type Parameters

public class InstantiationMistake {

    // BAD -- cannot instantiate T directly
    // public static  T createBad() {
    //     return new T(); // COMPILE ERROR
    // }

    // GOOD -- use a Supplier
    public static  T create(java.util.function.Supplier supplier) {
        return supplier.get();
    }

    // GOOD -- use Class with reflection
    public static  T create(Class clazz) throws Exception {
        return clazz.getDeclaredConstructor().newInstance();
    }

    public static void main(String[] args) throws Exception {
        // Using Supplier (preferred -- no reflection, no exceptions)
        String s = create(String::new);
        ArrayList list = create(ArrayList::new);

        // Using Class
        String s2 = create(String.class);
        System.out.println("Created: '" + s2 + "'"); // Output: Created: ''
    }
}

Mistake 3: Generic Array Creation

public class GenericArrayMistake {
    public static void main(String[] args) {
        // BAD -- cannot create generic arrays directly
        // List[] arrayOfLists = new List[10]; // COMPILE ERROR

        // Why? Because arrays are "reified" -- they know their component type at runtime.
        // But generics are erased -- List becomes just List at runtime.
        // If Java allowed this, you could break type safety:

        // Hypothetically, if it were allowed:
        // Object[] objArray = arrayOfLists; // Arrays are covariant, so this would compile
        // objArray[0] = List.of(42);        // This is a List, stored in a List[] slot
        // String s = arrayOfLists[0].get(0); // ClassCastException! Integer is not String

        // GOOD -- use a List of Lists instead
        List> listOfLists = new ArrayList<>();
        listOfLists.add(List.of("Alice", "Bob"));
        listOfLists.add(List.of("Charlie"));

        // GOOD -- if you truly need an array, use a workaround with unchecked cast
        @SuppressWarnings("unchecked")
        List[] array = (List[]) new List[10];
        array[0] = List.of("Hello");
        System.out.println(array[0]); // Output: [Hello]
    }
}

Mistake 4: Confusing extends in Generics vs Class Hierarchy

public class ExtendsConfusion {
    public static void main(String[] args) {
        // In class hierarchy: Integer IS-A Number (subtype relationship)
        Number n = Integer.valueOf(42); // This works -- Integer extends Number

        // But in generics: List IS NOT a List
        // List numbers = new ArrayList(); // COMPILE ERROR!

        // Why? Because if this were allowed:
        // numbers.add(3.14); // This would be legal (Double is a Number)
        // But the actual list is ArrayList -- a Double would corrupt it!

        // This is called INVARIANCE: Generic types are invariant.
        // List and List are NOT related, even though Integer extends Number.

        // Solution 1: Use wildcards
        List readOnly = new ArrayList(); // OK for reading

        // Solution 2: Use the exact type
        List numbers = new ArrayList<>(); // Both Integer and Double can go in
        numbers.add(42);
        numbers.add(3.14);
    }
}

Mistake 5: Overloading with Type Erasure Conflicts

public class ErasureConflict {

    // These two methods look different at the source level...
    // public void process(List strings) { }
    // public void process(List integers) { }

    // ...but after erasure, both become:
    // public void process(List strings) { }
    // public void process(List integers) { }
    // COMPILE ERROR: both methods have same erasure

    // Solution: use different method names
    public void processStrings(List strings) {
        strings.forEach(s -> System.out.println("String: " + s));
    }

    public void processIntegers(List integers) {
        integers.forEach(i -> System.out.println("Integer: " + i));
    }

    // Or use a single generic method
    public  void process(List items, String label) {
        items.forEach(item -> System.out.println(label + ": " + item));
    }
}

Mistake 6: Using Generics with Primitives

public class PrimitiveMistake {
    public static void main(String[] args) {
        // BAD -- generics do not support primitive types
        // List numbers = new ArrayList<>(); // COMPILE ERROR

        // GOOD -- use wrapper classes (autoboxing handles conversion)
        List numbers = new ArrayList<>();
        numbers.add(42);  // autoboxing: int -> Integer
        int value = numbers.get(0); // auto-unboxing: Integer -> int

        // Be aware of boxing overhead in performance-critical code
        // For large datasets, consider specialized collections:
        //   - IntStream, LongStream, DoubleStream (java.util.stream)
        //   - int[], double[] (plain arrays)
        //   - Third-party: Eclipse Collections IntList, Trove TIntArrayList

        // Null danger with auto-unboxing
        List data = new ArrayList<>();
        data.add(null); // Allowed for Integer (it's an object)
        // int bad = data.get(0); // NullPointerException! null cannot unbox to int
    }
}

12. Best Practices

These best practices come from years of experience in the Java ecosystem and are distilled from Effective Java by Joshua Bloch, Java language specifications, and real-world codebase reviews. Following them will make your generic code safer, cleaner, and more maintainable.

Practice 1: Always Use Parameterized Types

Never use raw types. If you do not know the type, use <?> (unbounded wildcard) rather than leaving the type argument off entirely.

// BAD
List names = new ArrayList();
Map config = new HashMap();

// GOOD
List names = new ArrayList<>();
Map config = new HashMap<>();

// If you truly don't know the type, use unbounded wildcard
List unknownList = getListFromSomewhere();
// You can read from it (as Object), check size, iterate, etc.

Practice 2: Use Bounded Wildcards for API Flexibility

Public API methods should use wildcards in their parameters to be as flexible as possible for callers. This follows the PECS principle.

public class WildcardAPIBestPractice {

    // LESS FLEXIBLE -- only accepts List, not List or List
    public static double sumRigid(List numbers) {
        return numbers.stream().mapToDouble(Number::doubleValue).sum();
    }

    // MORE FLEXIBLE -- accepts List, List, List, etc.
    public static double sumFlexible(List numbers) {
        return numbers.stream().mapToDouble(Number::doubleValue).sum();
    }

    // LESS FLEXIBLE -- only accepts Comparator
    public static String maxRigid(List list, Comparator comparator) {
        return list.stream().max(comparator).orElseThrow();
    }

    // MORE FLEXIBLE -- accepts Comparator, Comparator, etc.
    public static  T maxFlexible(List list, Comparator comparator) {
        return list.stream().max(comparator).orElseThrow();
    }

    public static void main(String[] args) {
        List integers = List.of(1, 2, 3);

        // sumRigid(integers); // COMPILE ERROR -- List is not List
        System.out.println(sumFlexible(integers)); // Output: 6.0 -- works!
    }
}

Practice 3: Prefer Generic Methods Over Wildcard Types in Return Values

Use wildcards in parameters (inputs) but avoid them in return types. A method that returns List<?> forces the caller to deal with an unknown type. Instead, use a type parameter so the caller gets a concrete type.

public class ReturnTypeBestPractice {

    // BAD -- caller gets List, which is almost useless
    public static List filterBad(List list) {
        return list; // Caller can't do much with List
    }

    // GOOD -- caller gets List, preserving the type information
    public static  List filterGood(List list, Predicate predicate) {
        return list.stream().filter(predicate).collect(Collectors.toList());
    }

    public static void main(String[] args) {
        List names = List.of("Alice", "Bob", "Charlie", "Anna");

        // filterGood returns List -- full type safety preserved
        List aNames = filterGood(names, n -> n.startsWith("A"));
        String first = aNames.get(0); // No cast needed
        System.out.println(first); // Output: Alice
    }
}

Practice 4: Use @SuppressWarnings("unchecked") Sparingly

Unchecked warnings exist for a reason -- they indicate a potential type safety hole. Only suppress them when you have carefully verified that the cast is safe, and always document why it is safe.

public class SuppressWarningsBestPractice {

    // BAD -- suppressing at class or method level hides all warnings
    // @SuppressWarnings("unchecked")
    // public  T[] toArray(List list) { ... }

    // GOOD -- suppress at the narrowest possible scope with a comment
    public static  T[] toArray(List list, Class componentType) {
        // Safe because we create the array with the correct component type
        @SuppressWarnings("unchecked")
        T[] array = (T[]) java.lang.reflect.Array.newInstance(componentType, list.size());
        return list.toArray(array);
    }

    public static void main(String[] args) {
        List names = List.of("Alice", "Bob", "Charlie");
        String[] nameArray = toArray(names, String.class);
        System.out.println(java.util.Arrays.toString(nameArray));
        // Output: [Alice, Bob, Charlie]
    }
}

Practice 5: Favor Generic Methods Over Raw Types for Interoperability

When you need to work with legacy code that uses raw types, wrap the interaction in a generic method that performs a checked cast, rather than spreading raw type usage throughout your code.

public class LegacyInteropBestPractice {

    // Legacy code returns raw List
    @SuppressWarnings("rawtypes")
    public static List getLegacyData() {
        List data = new java.util.ArrayList();
        data.add("Alice");
        data.add("Bob");
        return data;
    }

    // Wrapper method: isolates the raw type and unchecked cast
    @SuppressWarnings("unchecked") // Safe: we know getLegacyData() returns strings
    public static List getLegacyNames() {
        return (List) getLegacyData();
    }

    // Safer alternative: validate each element
    public static  List checkedCast(List raw, Class type) {
        List result = new ArrayList<>();
        for (Object item : raw) {
            result.add(type.cast(item)); // Throws ClassCastException if wrong type
        }
        return result;
    }

    public static void main(String[] args) {
        // All downstream code uses proper generics
        List names = getLegacyNames();
        names.forEach(name -> System.out.println(name.toUpperCase()));
        // Output: ALICE
        //         BOB

        // Extra-safe approach
        List verified = checkedCast(getLegacyData(), String.class);
        System.out.println("Verified: " + verified); // Output: Verified: [Alice, Bob]
    }
}

Best Practices Summary

# Practice Rationale
1 Always use parameterized types -- never raw types Raw types bypass compile-time type checking entirely
2 Use bounded wildcards in method parameters (PECS) Makes APIs more flexible for callers without sacrificing type safety
3 Avoid wildcards in return types Callers should receive concrete types, not unknowns
4 Narrow @SuppressWarnings scope and document why Prevents hiding legitimate warnings; maintains auditability
5 Isolate legacy raw-type interactions in wrapper methods Keeps raw types out of your main codebase
6 Prefer List over arrays for generic data Arrays are covariant and reified; generics are invariant and erased -- they do not mix well
7 Use diamond operator (<>) to reduce verbosity The compiler infers type arguments; no need to repeat them
8 Favor interfaces in type parameters (T extends Comparable<T>) Enables maximum implementation flexibility

13. Complete Practical Example: Type-Safe Generic Repository

Let us bring everything together with a real-world example that demonstrates generic classes, generic methods, bounded type parameters, wildcards, and generic interfaces working in concert. We will build a type-safe in-memory repository -- a pattern you will encounter in Spring Boot, JPA, and virtually every data-driven Java application.

This example is designed to show how generics enable you to write a single piece of infrastructure code that works with any entity type, while the compiler ensures that you never accidentally mix up your User operations with your Product operations.

import java.util.*;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;

// ============================================================
// Step 1: Define a generic Identifiable interface
// ============================================================
// Any entity stored in our repository must have an ID
public interface Identifiable {
    ID getId();
    void setId(ID id);
}

// ============================================================
// Step 2: Define a generic Repository interface
// ============================================================
// T is the entity type, ID is the primary key type
// T must be Identifiable -- bounded type parameter
public interface CrudRepository, ID> {
    T save(T entity);
    Optional findById(ID id);
    List findAll();
    List findAll(Predicate filter);  // Wildcard: consumer of T
     List findAllMapped(Function mapper); // Generic method with wildcards
    boolean existsById(ID id);
    long count();
    void deleteById(ID id);
    void deleteAll();
}

// ============================================================
// Step 3: Implement a generic in-memory repository
// ============================================================
public class InMemoryCrudRepository>
        implements CrudRepository {

    private final Map store = new LinkedHashMap<>();
    private final AtomicLong sequence = new AtomicLong(1);

    @Override
    public T save(T entity) {
        if (entity.getId() == null) {
            entity.setId(sequence.getAndIncrement());
        }
        store.put(entity.getId(), entity);
        return entity;
    }

    @Override
    public Optional findById(Long id) {
        return Optional.ofNullable(store.get(id));
    }

    @Override
    public List findAll() {
        return new ArrayList<>(store.values());
    }

    // Wildcard in Predicate: accepts Predicate, Predicate, etc.
    @Override
    public List findAll(Predicate filter) {
        return store.values().stream()
            .filter(filter)
            .collect(Collectors.toList());
    }

    // Generic method: caller decides the return type R
    @Override
    public  List findAllMapped(Function mapper) {
        return store.values().stream()
            .map(mapper)
            .collect(Collectors.toList());
    }

    @Override
    public boolean existsById(Long id) {
        return store.containsKey(id);
    }

    @Override
    public long count() {
        return store.size();
    }

    @Override
    public void deleteById(Long id) {
        store.remove(id);
    }

    @Override
    public void deleteAll() {
        store.clear();
    }
}

// ============================================================
// Step 4: Define concrete entity classes
// ============================================================
public class User implements Identifiable {
    private Long id;
    private String name;
    private String email;
    private int age;

    public User(String name, String email, int age) {
        this.name = name;
        this.email = email;
        this.age = age;
    }

    @Override
    public Long getId() { return id; }

    @Override
    public void setId(Long id) { this.id = id; }

    public String getName() { return name; }
    public String getEmail() { return email; }
    public int getAge() { return age; }

    @Override
    public String toString() {
        return "User{id=" + id + ", name='" + name + "', email='" + email + "', age=" + age + "}";
    }
}

public class Product implements Identifiable {
    private Long id;
    private String name;
    private double price;
    private String category;

    public Product(String name, double price, String category) {
        this.name = name;
        this.price = price;
        this.category = category;
    }

    @Override
    public Long getId() { return id; }

    @Override
    public void setId(Long id) { this.id = id; }

    public String getName() { return name; }
    public double getPrice() { return price; }
    public String getCategory() { return category; }

    @Override
    public String toString() {
        return "Product{id=" + id + ", name='" + name + "', price=$" + price
            + ", category='" + category + "'}";
    }
}

// ============================================================
// Step 5: Build a generic service layer
// ============================================================
public class EntityService> {
    private final CrudRepository repository;

    public EntityService(CrudRepository repository) {
        this.repository = repository;
    }

    public T create(T entity) {
        return repository.save(entity);
    }

    public Optional getById(Long id) {
        return repository.findById(id);
    }

    public List getAll() {
        return repository.findAll();
    }

    // Uses bounded wildcard for maximum flexibility
    public List search(Predicate criteria) {
        return repository.findAll(criteria);
    }

    // Generic method with wildcard
    public  List extractField(Function extractor) {
        return repository.findAllMapped(extractor);
    }

    public boolean exists(Long id) {
        return repository.existsById(id);
    }

    public void delete(Long id) {
        repository.deleteById(id);
    }
}

// ============================================================
// Step 6: Putting it all together
// ============================================================
public class RepositoryDemo {
    public static void main(String[] args) {
        // Create typed repositories -- the compiler ensures type safety
        InMemoryCrudRepository userRepo = new InMemoryCrudRepository<>();
        InMemoryCrudRepository productRepo = new InMemoryCrudRepository<>();

        // Create typed services
        EntityService userService = new EntityService<>(userRepo);
        EntityService productService = new EntityService<>(productRepo);

        // --- User operations (type safe: only User methods available) ---
        userService.create(new User("Alice", "alice@example.com", 30));
        userService.create(new User("Bob", "bob@example.com", 25));
        userService.create(new User("Charlie", "charlie@example.com", 35));
        userService.create(new User("Diana", "diana@example.com", 28));

        System.out.println("=== All Users ===");
        userService.getAll().forEach(System.out::println);
        // Output:
        // User{id=1, name='Alice', email='alice@example.com', age=30}
        // User{id=2, name='Bob', email='bob@example.com', age=25}
        // User{id=3, name='Charlie', email='charlie@example.com', age=35}
        // User{id=4, name='Diana', email='diana@example.com', age=28}

        // Search with type-safe predicate
        System.out.println("\n=== Users over 28 ===");
        List seniorUsers = userService.search(u -> u.getAge() > 28);
        seniorUsers.forEach(System.out::println);
        // Output:
        // User{id=1, name='Alice', email='alice@example.com', age=30}
        // User{id=3, name='Charlie', email='charlie@example.com', age=35}

        // Extract specific fields -- generic method in action
        List emails = userService.extractField(User::getEmail);
        System.out.println("\n=== Email addresses ===");
        System.out.println(emails);
        // Output: [alice@example.com, bob@example.com, charlie@example.com, diana@example.com]

        // --- Product operations (completely independent, type safe) ---
        productService.create(new Product("Laptop", 999.99, "Electronics"));
        productService.create(new Product("Coffee Mug", 12.99, "Kitchen"));
        productService.create(new Product("Headphones", 149.99, "Electronics"));

        System.out.println("\n=== Electronics ===");
        List electronics = productService.search(p -> "Electronics".equals(p.getCategory()));
        electronics.forEach(System.out::println);
        // Output:
        // Product{id=1, name='Laptop', price=$999.99, category='Electronics'}
        // Product{id=3, name='Headphones', price=$149.99, category='Electronics'}

        // Extract product names
        List productNames = productService.extractField(Product::getName);
        System.out.println("\n=== Product names ===");
        System.out.println(productNames);
        // Output: [Laptop, Coffee Mug, Headphones]

        // The compiler prevents mixing types:
        // userRepo.save(new Product(...));   // COMPILE ERROR: Product is not User
        // productRepo.save(new User(...));   // COMPILE ERROR: User is not Product

        // Find by ID -- returns Optional, not Optional
        Optional found = userService.getById(1L);
        found.ifPresent(u -> System.out.println("\nFound: " + u.getName()));
        // Output: Found: Alice

        // Delete
        userService.delete(2L);
        System.out.println("\n=== After deleting Bob ===");
        System.out.println("User count: " + userRepo.count()); // Output: User count: 3
        System.out.println("Bob exists: " + userService.exists(2L)); // Output: Bob exists: false
    }
}

What This Example Demonstrates

Generic Feature Where It Appears
Generic interface Identifiable<ID>, CrudRepository<T, ID>
Bounded type parameter T extends Identifiable<ID> in CrudRepository
Generic class InMemoryCrudRepository<T>, EntityService<T>
Generic method <R> List<R> findAllMapped(...)
Upper bounded wildcard Function<? super T, ? extends R>
Lower bounded wildcard Predicate<? super T>
Diamond operator new InMemoryCrudRepository<>()
PECS principle Predicate consumes T (super), Function produces R (extends)
Type safety Cannot mix User and Product repositories

This is the power of generics: you write the repository logic once, and it works correctly and type-safely with User, Product, Order, or any entity you create in the future. The compiler guarantees that you cannot store a Product in a User repository, retrieve a User when you expected a Product, or pass the wrong type to a service method. All of these checks happen at compile time, with zero runtime overhead.

14. Quick Reference Cheat Sheet

Syntax Name Example
class Box<T> Generic class Box<String> box = new Box<>("hello");
<T> T method(T arg) Generic method String s = identity("hello");
<T extends Number> Upper bounded type parameter <T extends Comparable<T>> T max(T a, T b)
<T extends A & B> Multiple bounds <T extends Number & Comparable<T>>
? extends T Upper bounded wildcard List<? extends Number> -- read-only
? super T Lower bounded wildcard List<? super Integer> -- write-only
? Unbounded wildcard List<?> -- any type
<> Diamond operator new ArrayList<>() -- type inferred
PECS Producer Extends, Consumer Super Read = extends, Write = super
March 8, 2019