Java Web Basic – Tag 10 von 10
Von Elyndra Valen, Senior Developer bei Java Fleet Systems Consulting


Connection Pools

🗺️ Deine Position im Kurs

TagThemaStatus
1Java EE Überblick & HTTP ✅ Abgeschlossen
2HTTP-Protokoll Vertiefung & Zustandslosigkeit✅ Abgeschlossen
3Servlets & Servlet API✅ Abgeschlossen
4Deployment Descriptor & MVC vs Model 2✅ Abgeschlossen
5JSP & Expression Languages✅ Abgeschlossen
6Java Beans, Actions, Scopes & Direktiven✅ Abgeschlossen
7Include-Action vs Include-Direktive✅ Abgeschlossen
8JSTL – Java Standard Tag Libraries✅ Abgeschlossen
9Java Web und Datenbanken – Datasource✅ Abgeschlossen
→ 10Connection Pools & JDBC Performance👉 DU BIST HIER! 🎉

Modul: Java Web Basic
Gesamt-Dauer: 10 Arbeitstage (je 8 Stunden)
Dein Ziel: JDBC Performance meistern und Production-Ready sein!


📋 Voraussetzungen für diesen Tag

Du brauchst:

  • ✅ JDK 21 LTS installiert
  • ✅ Apache NetBeans 22 (oder neuer)
  • ✅ Payara Server 6.x konfiguriert
  • ✅ MariaDB (oder MySQL) installiert und lauffähig
  • ✅ Tag 1-9 abgeschlossen
  • ✅ Datasource & Connection Pools verstanden
  • ✅ DAO-Pattern beherrschen
  • ✅ JSTL & JSP sitzen

Tag verpasst?
Spring zurück zu Tag 9, um Datasources zu verstehen. Heute bauen wir darauf auf!

Setup-Probleme?
Schreib uns: support@java-developer.online


⚡ Das Wichtigste in 30 Sekunden

Heute lernst du:

  • ✅ Prepared Statements vs. Statement – Performance & Security
  • ✅ Batch Operations für Massen-Inserts
  • ✅ Transaction Management im Detail
  • ✅ Connection Pool Tuning für Production
  • ✅ Stored Procedures aufrufen
  • ✅ JDBC Best Practices & Anti-Patterns
  • ✅ Performance-Optimierung & Monitoring

Am Ende des Tages kannst du:

  • Production-Ready JDBC-Code schreiben
  • Performance-Probleme identifizieren & lösen
  • Transaktionen korrekt handhaben
  • Batch-Operations nutzen
  • Connection Pools optimal konfigurieren
  • SQL-Injection verhindern
  • Du bist ein Java Web Developer! 🎓

Zeit-Investment: ~6-8 Stunden
Schwierigkeitsgrad: Fortgeschritten (aber du schaffst das – es ist der letzte Tag!)


👋 Willkommen zu Tag 10 – Der finale Tag!

Hi! 👋

Elyndra hier. Das ist es – der letzte Tag des Java Web Basic Kurses!

Real talk: Wenn du bis hierher gekommen bist, bin ich wirklich stolz auf dich! 💪

Kurzwiederholung: Challenge von Tag 9

Gestern hast du Datasources und Connection Pools konfiguriert. Heute machen wir die Anwendung Production-Ready!

Was dich heute erwartet:

  • Performance-Optimierung
  • Security Best Practices
  • Advanced JDBC Features
  • Production-Ready Code schreiben

Und am Ende: Du bist ein vollwertiger Java Web Developer! 🎓

Franz-Martin (unser Tech Lead) sagt immer: „Die Grundlagen sind das Fundament. Aber Performance und Security machen den Unterschied zwischen einem Junior und einem Professional.“

Let’s finish this strong! 🚀


🟢 GRUNDLAGEN: Statement vs. PreparedStatement

Das Problem mit Statement

Erinnerst du dich an Java SE?

// ❌ Statement: String Concatenation
String name = request.getParameter("name");
String sql = "SELECT * FROM products WHERE name = '" + name + "'";

try (Connection conn = dataSource.getConnection();
     Statement stmt = conn.createStatement();
     ResultSet rs = stmt.executeQuery(sql)) {
    
    // Daten verarbeiten...
}

Was ist das Problem?

Problem 1: SQL-Injection! 🔴

// User gibt ein: ' OR '1'='1
String name = "' OR '1'='1";

// Resultierende Query:
SELECT * FROM products WHERE name = '' OR '1'='1'
                                          ↑
                        Dies ist IMMER true!

Ergebnis: Alle Produkte werden zurückgegeben, nicht nur das gesuchte!

Noch schlimmer:

// User gibt ein: '; DROP TABLE products; --
String name = "'; DROP TABLE products; --";

// Resultierende Query:
SELECT * FROM products WHERE name = ''; DROP TABLE products; --'
                                         ↑
                            Löscht die Tabelle!

Deine Datenbank ist weg! 💥


Problem 2: Performance!

Jedes Mal wenn du eine Query ausführst:

1. SQL-String wird geparst
2. Execution Plan wird erstellt
3. Query wird optimiert
4. Query wird ausgeführt

Bei gleichen Queries mit unterschiedlichen Parametern wiederholt sich jedes Mal Schritt 1-3!

Das ist langsam und ineffizient!


Die Lösung: PreparedStatement

// ✅ PreparedStatement: Parameter-Binding
String sql = "SELECT * FROM products WHERE name = ?";
                                               ↑
                                    Placeholder!

try (Connection conn = dataSource.getConnection();
     PreparedStatement pstmt = conn.prepareStatement(sql)) {
    
    pstmt.setString(1, name);  // Parameter wird escaped!
    
    try (ResultSet rs = pstmt.executeQuery()) {
        // Daten verarbeiten...
    }
}

Was macht PreparedStatement?

1. Compilation & Caching:

PreparedStatement pstmt = conn.prepareStatement(sql);
// → Query wird einmal kompiliert und gecacht

2. Parameter-Binding:

pstmt.setString(1, name);
// → Wert wird escaped und sicher eingesetzt

3. Wiederverwendung:

// Gleiche Query, andere Parameter:
pstmt.setString(1, "Laptop");
pstmt.executeQuery();

pstmt.setString(1, "Mouse");
pstmt.executeQuery();

// Query-Plan wird wiederverwendet! (schneller!)

PreparedStatement Syntax im Detail

Basis-Syntax:

String sql = "SELECT * FROM products WHERE category = ? AND price < ?";
PreparedStatement pstmt = conn.prepareStatement(sql);

// Parameter setzen (1-basiert!)
pstmt.setString(1, "Electronics");   // Erster ?
pstmt.setDouble(2, 100.0);           // Zweiter ?

ResultSet rs = pstmt.executeQuery();

Wichtig: Parameter-Indizes starten bei 1, nicht bei 0!


Alle setXXX() Methoden:

// Strings
pstmt.setString(1, "text");

// Zahlen
pstmt.setInt(1, 42);
pstmt.setLong(1, 123456789L);
pstmt.setDouble(1, 99.99);
pstmt.setBigDecimal(1, new BigDecimal("19.99"));

// Boolean
pstmt.setBoolean(1, true);

// Datum & Zeit
pstmt.setDate(1, java.sql.Date.valueOf("2025-01-15"));
pstmt.setTime(1, java.sql.Time.valueOf("14:30:00"));
pstmt.setTimestamp(1, java.sql.Timestamp.valueOf("2025-01-15 14:30:00"));

// NULL-Werte
pstmt.setNull(1, java.sql.Types.VARCHAR);

// Binary Data
pstmt.setBytes(1, byteArray);
pstmt.setBlob(1, inputStream);

Beispiel: Komplexe Query mit mehreren Parametern

String sql = "SELECT * FROM products " +
             "WHERE category = ? " +
             "AND price BETWEEN ? AND ? " +
             "AND in_stock = ? " +
             "ORDER BY name";

try (PreparedStatement pstmt = conn.prepareStatement(sql)) {
    
    pstmt.setString(1, "Electronics");  // category
    pstmt.setDouble(2, 50.0);           // min price
    pstmt.setDouble(3, 200.0);          // max price
    pstmt.setBoolean(4, true);          // in_stock
    
    try (ResultSet rs = pstmt.executeQuery()) {
        while (rs.next()) {
            // Daten verarbeiten...
        }
    }
}

Das Prinzip: Jeder ? bekommt einen Wert über setXXX(index, value)!


Sicherheit: SQL-Injection Prevention

Wie PreparedStatement SQL-Injection verhindert:

// User-Eingabe:
String maliciousInput = "'; DROP TABLE products; --";

// Mit Statement (UNSICHER!):
String sql = "SELECT * FROM products WHERE name = '" + maliciousInput + "'";
// Resultat: SELECT * FROM products WHERE name = ''; DROP TABLE products; --'
// → Tabelle wird gelöscht! 💥

// Mit PreparedStatement (SICHER!):
String sql = "SELECT * FROM products WHERE name = ?";
pstmt.setString(1, maliciousInput);
// Resultat: SELECT * FROM products WHERE name = '\'; DROP TABLE products; --'
//                                                    ↑
//                                        Escaped! Wird als String behandelt!

Der Unterschied:

  • Statement: String wird direkt in Query eingefügt
  • PreparedStatement: String wird escaped und als Literal behandelt

Best Practice: NIEMALS String-Concatenation für SQL-Queries verwenden!


Performance-Vergleich

Benchmark: 1000 Queries ausführen

// ❌ Statement: 1000ms
long start = System.currentTimeMillis();
for (int i = 0; i < 1000; i++) {
    String sql = "SELECT * FROM products WHERE id = " + i;
    Statement stmt = conn.createStatement();
    stmt.executeQuery(sql);
    stmt.close();
}
long duration = System.currentTimeMillis() - start;
// Dauer: ~1000ms (1 Sekunde)

// ✅ PreparedStatement: 400ms
start = System.currentTimeMillis();
String sql = "SELECT * FROM products WHERE id = ?";
PreparedStatement pstmt = conn.prepareStatement(sql);
for (int i = 0; i < 1000; i++) {
    pstmt.setInt(1, i);
    pstmt.executeQuery();
}
pstmt.close();
duration = System.currentTimeMillis() - start;
// Dauer: ~400ms (0.4 Sekunden)

PreparedStatement ist 2.5x schneller! 🚀

Warum?

  • Query wird nur einmal kompiliert
  • Execution Plan wird gecacht
  • Datenbank kann optimieren

🟢 GRUNDLAGEN: Batch Operations

Das Problem: Viele Inserts

Szenario: 1000 Produkte in Datenbank einfügen

// ❌ LANGSAM: Einzelne Inserts
String sql = "INSERT INTO products (name, price, stock) VALUES (?, ?, ?)";

try (PreparedStatement pstmt = conn.prepareStatement(sql)) {
    
    for (int i = 0; i < 1000; i++) {
        pstmt.setString(1, "Product " + i);
        pstmt.setDouble(2, 99.99);
        pstmt.setInt(3, 10);
        
        pstmt.executeUpdate();  // ← Jedes Mal ein DB-Roundtrip!
    }
}
// Dauer: ~5000ms (5 Sekunden!)

Problem: 1000 Roundtrips zur Datenbank!

App → DB: INSERT Product 1
DB → App: OK
App → DB: INSERT Product 2
DB → App: OK
...
(1000x wiederholt)

Das ist extrem langsam!


Die Lösung: Batch Operations

// ✅ SCHNELL: Batch-Insert
String sql = "INSERT INTO products (name, price, stock) VALUES (?, ?, ?)";

try (PreparedStatement pstmt = conn.prepareStatement(sql)) {
    
    for (int i = 0; i < 1000; i++) {
        pstmt.setString(1, "Product " + i);
        pstmt.setDouble(2, 99.99);
        pstmt.setInt(3, 10);
        
        pstmt.addBatch();  // ← Fügt zur Batch hinzu (kein DB-Zugriff!)
        
        // Alle 100 Statements ausführen
        if (i % 100 == 0) {
            pstmt.executeBatch();  // ← 100 Inserts auf einmal!
            pstmt.clearBatch();
        }
    }
    
    // Rest ausführen
    pstmt.executeBatch();
}
// Dauer: ~500ms (0.5 Sekunden!)

10x schneller! 🚀

Was macht addBatch()?

App sammelt 100 Statements:
- INSERT Product 1
- INSERT Product 2
- INSERT Product 3
- ...
- INSERT Product 100

executeBatch() → Schickt alle 100 auf einmal zur DB!

DB verarbeitet alle 100 zusammen
DB → App: OK (alle 100 erfolgreich)

Nur 10 Roundtrips statt 1000!


Batch Operations im Detail

Basic Pattern:

PreparedStatement pstmt = conn.prepareStatement(sql);

for (Item item : items) {
    pstmt.setXXX(1, item.getValue1());
    pstmt.setXXX(2, item.getValue2());
    pstmt.addBatch();  // Fügt zur Batch hinzu
}

int[] results = pstmt.executeBatch();  // Führt alle aus

executeBatch() Rückgabewert:

int[] results = pstmt.executeBatch();

// results[i] enthält:
// - Anzahl betroffener Rows für Statement i
// - Statement.SUCCESS_NO_INFO (-2) wenn unbekannt
// - Statement.EXECUTE_FAILED (-3) bei Fehler

Beispiel:

int[] results = pstmt.executeBatch();
// results = [1, 1, 1, -2, 1, 1, ...]
//           ↑  ↑  ↑   ↑
//           OK OK OK  Info nicht verfügbar

Best Practice: Batch Size

// ✅ Empfohlene Batch-Größe: 50-100
final int BATCH_SIZE = 100;

for (int i = 0; i < items.size(); i++) {
    Item item = items.get(i);
    
    pstmt.setString(1, item.getName());
    pstmt.setDouble(2, item.getPrice());
    pstmt.addBatch();
    
    // Batch nach jedem 100. Element ausführen
    if ((i + 1) % BATCH_SIZE == 0) {
        pstmt.executeBatch();
        pstmt.clearBatch();  // ✅ Wichtig: Batch leeren!
    }
}

// Rest ausführen (falls items.size() nicht durch BATCH_SIZE teilbar)
pstmt.executeBatch();

Warum 50-100?

  • Zu klein (10): Viele Roundtrips, langsam
  • Zu groß (10000): Großer Memory-Verbrauch, Timeout-Risiko
  • Sweet Spot (100): Balance zwischen Performance und Ressourcen

Batch mit Transaktionen kombinieren

Connection conn = null;

try {
    conn = dataSource.getConnection();
    conn.setAutoCommit(false);  // Transaction starten
    
    String sql = "INSERT INTO products (name, price, stock) VALUES (?, ?, ?)";
    PreparedStatement pstmt = conn.prepareStatement(sql);
    
    for (int i = 0; i < 1000; i++) {
        pstmt.setString(1, "Product " + i);
        pstmt.setDouble(2, 99.99);
        pstmt.setInt(3, 10);
        pstmt.addBatch();
        
        if (i % 100 == 0) {
            pstmt.executeBatch();
            pstmt.clearBatch();
        }
    }
    
    pstmt.executeBatch();
    pstmt.close();
    
    conn.commit();  // ✅ Alle Inserts erfolgreich
    
} catch (SQLException e) {
    if (conn != null) {
        conn.rollback();  // ❌ Fehler: Alle Inserts rückgängig
    }
    throw new RuntimeException("Batch insert failed", e);
    
} finally {
    if (conn != null) {
        conn.setAutoCommit(true);
        conn.close();
    }
}

Das Prinzip: Batch + Transaction = Atomare Massen-Operation!


🟡 PROFESSIONALS: Transaction Management

ACID-Eigenschaften verstehen

Was sind Transaktionen?

Eine Transaction ist eine Gruppe von Operationen, die entweder komplett ausgeführt werden oder gar nicht.

ACID:

A – Atomicity (Atomarität):

  • Alles oder nichts
  • Keine Teil-Ausführung

C – Consistency (Konsistenz):

  • Daten bleiben valide
  • Business Rules werden eingehalten

I – Isolation (Isolation):

  • Transaktionen beeinflussen sich nicht gegenseitig
  • Keine Race Conditions

D – Durability (Dauerhaftigkeit):

  • Nach Commit sind Daten persistent
  • Überleben System-Crash

Transaction Isolation Levels

Das Problem: Concurrent Access

User A startet Transaction: Überweist 100€ von Konto 1 → Konto 2
User B startet Transaction: Liest Kontostand von Konto 1

Was soll User B sehen?

  • Alten Stand (vor Überweisung)?
  • Neuen Stand (nach Überweisung)?
  • Zwischenstand (während Überweisung)?

Das regeln Isolation Levels!


Die 4 Isolation Levels:

LevelDirty ReadNon-Repeatable ReadPhantom ReadPerformance
READ_UNCOMMITTED✅ Möglich✅ Möglich✅ Möglich🚀 Schnellst
READ_COMMITTED❌ Verhindert✅ Möglich✅ Möglich⚡ Schnell
REPEATABLE_READ❌ Verhindert❌ Verhindert✅ Möglich🐢 Langsam
SERIALIZABLE❌ Verhindert❌ Verhindert❌ Verhindert🐌 Langsamst

Was bedeuten die Begriffe?

Dirty Read:

  • Transaction A ändert Daten, committed aber noch nicht
  • Transaction B liest die nicht-committeten Daten
  • Transaction A macht Rollback
  • Problem: B hat „schmutzige“ Daten gelesen!

Non-Repeatable Read:

  • Transaction A liest Daten (z.B. Kontostand = 100€)
  • Transaction B ändert die Daten (Kontostand = 50€) und committed
  • Transaction A liest nochmal
  • Problem: Unterschiedliche Werte innerhalb einer Transaction!

Phantom Read:

  • Transaction A liest alle Produkte mit price < 100 (findet 5 Produkte)
  • Transaction B fügt neues Produkt mit price = 50 ein und committed
  • Transaction A liest nochmal
  • Problem: Plötzlich 6 Produkte statt 5! („Phantom“)

Isolation Level setzen:

Connection conn = dataSource.getConnection();

// Isolation Level setzen
conn.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED);

// Mögliche Werte:
// Connection.TRANSACTION_READ_UNCOMMITTED
// Connection.TRANSACTION_READ_COMMITTED    ← Standard in meisten DBs
// Connection.TRANSACTION_REPEATABLE_READ
// Connection.TRANSACTION_SERIALIZABLE

Best Practice:

// ✅ Standard: READ_COMMITTED
// - Verhindert Dirty Reads
// - Gute Performance
// - Ausreichend für die meisten Anwendungen

conn.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED);

Transaction Best Practices

1. Kurze Transaktionen

// ❌ SCHLECHT: Lange Transaction
conn.setAutoCommit(false);

// Schritt 1: DB-Operation (5ms)
updateAccount(accountId, amount);

// Schritt 2: External API Call (2000ms!)
boolean approved = paymentGateway.charge(amount);

// Schritt 3: DB-Operation (5ms)
if (approved) {
    conn.commit();
} else {
    conn.rollback();
}

// Transaction dauert 2010ms!
// → Verbindung ist 2010ms geblockt! 😱

// ✅ BESSER: Kurze Transaction
// Schritt 1: External API Call (OHNE Transaction)
boolean approved = paymentGateway.charge(amount);

// Schritt 2: Kurze Transaction
conn.setAutoCommit(false);
try {
    updateAccount(accountId, amount);
    logPayment(transactionId, approved);
    conn.commit();  // Transaction dauert nur ~10ms!
} catch (SQLException e) {
    conn.rollback();
}
conn.setAutoCommit(true);

Das Prinzip: Transaction nur für DB-Operationen, nicht für externe Calls!


2. Immer Finally-Block nutzen

Connection conn = null;

try {
    conn = dataSource.getConnection();
    conn.setAutoCommit(false);
    
    // Operationen...
    
    conn.commit();
    
} catch (SQLException e) {
    if (conn != null) {
        try {
            conn.rollback();
        } catch (SQLException ex) {
            // Log, aber nicht re-throw
        }
    }
    throw new RuntimeException("Transaction failed", e);
    
} finally {
    if (conn != null) {
        try {
            conn.setAutoCommit(true);  // ✅ Wichtig!
            conn.close();
        } catch (SQLException e) {
            // Log
        }
    }
}

Warum setAutoCommit(true) in finally?

  • Connection geht zurück in Pool
  • Nächster User bekommt Connection mit korrektem Auto-Commit!

3. Savepoints für Teil-Rollbacks

Connection conn = dataSource.getConnection();
conn.setAutoCommit(false);

Savepoint savepoint1 = null;

try {
    // Operation 1: Produkt erstellen
    insertProduct(conn, product);
    
    savepoint1 = conn.setSavepoint("AfterProductInsert");
    
    // Operation 2: Bilder hochladen (könnte fehlschlagen)
    uploadProductImages(conn, productId, images);
    
    // Alles OK
    conn.commit();
    
} catch (ImageUploadException e) {
    // ✅ Nur Bilder-Upload rückgängig machen
    if (savepoint1 != null) {
        conn.rollback(savepoint1);
    }
    // Produkt bleibt bestehen!
    conn.commit();
    
} catch (SQLException e) {
    // ❌ Alles rückgängig machen
    conn.rollback();
    throw new RuntimeException(e);
    
} finally {
    conn.setAutoCommit(true);
    conn.close();
}

Use Case: Teil-Operationen können fehlschlagen, ohne ganze Transaction zu verwerfen!


🟡 PROFESSIONALS: Connection Pool Tuning

Connection Pool Parameter verstehen

Die wichtigsten Parameter:

Wichtig: Die Parameter-Namen sind unterschiedlich zwischen Tomcat und Payara!

Tomcat (context.xml):

<Resource 
    name="jdbc/ShopDB"
    
    <!-- Pool-Größe (Tomcat-Syntax) -->
    initialSize="10"        <!-- Verbindungen beim Start -->
    minIdle="10"            <!-- Minimum immer verfügbar -->
    maxTotal="100"          <!-- Maximum gleichzeitig -->
    maxIdle="50"            <!-- Maximum im Leerlauf -->
    
    <!-- Timeouts -->
    maxWaitMillis="30000"   <!-- Max Wartezeit: 30 Sek -->
    
    <!-- Validation -->
    testOnBorrow="true"     <!-- Teste vor Benutzung -->
    testWhileIdle="true"    <!-- Teste Idle-Connections -->
    validationQuery="SELECT 1"
    
    <!-- Eviction (Aufräumen) -->
    timeBetweenEvictionRunsMillis="30000"    <!-- Alle 30 Sek -->
    minEvictableIdleTimeMillis="60000"       <!-- Nach 1 Min Idle -->
    
    <!-- Connection Leak Detection -->
    removeAbandonedOnBorrow="true"
    removeAbandonedTimeout="300"             <!-- Nach 5 Min -->
    logAbandoned="true"
/>

Payara (Admin Console):

JDBC Connection Pools → [Pool Name] → General Tab:

Pool Settings:
- Initial Pool Size: 10
- Minimum Pool Size: 10  
- Maximum Pool Size: 100
- Pool Resize Quantity: 2
- Idle Timeout: 300 seconds
- Max Wait Time: 30000 milliseconds

Advanced Tab:
- Connection Validation: Required
- Validation Method: table
- Validation Table Name: dual (oder eigene Tabelle)
- Validate Atmost Once: 30 seconds

Wichtig zu verstehen:

KonzeptTomcat (XML)Payara (Admin Console)
KonfigurationMETA-INF/context.xmlWeb-basierte GUI
FormatXML-AttributeFormular-Felder
Beispiel „Max Size“maxTotal="100"„Maximum Pool Size: 100“

Was bedeuten die Parameter im Detail?

Wichtiger Hinweis: Die folgenden Erklärungen verwenden Tomcat-Syntax (XML), da das konzeptionell einfacher zu zeigen ist. In Payara konfigurierst du diese Werte über die Admin Console (GUI), nicht über XML-Dateien!

Pool-Größe:

initialSize: 10
          ↓
Beim Start: ██████████ (10 Connections erstellt)

minIdle: 10
       ↓
Minimum: ██████████ (immer mindestens 10 verfügbar)

maxTotal: 100
        ↓
Maximum: ████████████████████████████ (max 100 gleichzeitig)

maxIdle: 50
       ↓
Wenn mehr als 50 idle: älteste werden geschlossen

Timeouts:

maxWaitMillis="30000"

User Request → Pool: "Gib mir Connection!"
               Pool: "Alle busy, warte..."
               
Nach 30 Sekunden:
               Pool: "Timeout! Keine Connection verfügbar!"
               
→ SQLException: Cannot get connection, wait time exceeded

Validation:

testOnBorrow="true"
validationQuery="SELECT 1"

User Request → Pool: "Gib mir Connection!"
               Pool: "OK, aber erst testen..."
               Pool → DB: SELECT 1
               DB → Pool: OK
               Pool → User: "Hier, Connection ist OK!"

Wenn Test fehlschlägt:
               Pool → DB: SELECT 1
               DB: (keine Antwort - Connection tot)
               Pool: "Connection kaputt, erstelle neue..."
               Pool → User: "Hier, neue Connection!"

Verhindert „Stale Connection“ Fehler!


Eviction (Aufräumen):

timeBetweenEvictionRunsMillis="30000"
minEvictableIdleTimeMillis="60000"

Alle 30 Sekunden läuft "Eviction Thread":
  
  1. Prüft alle Idle-Connections
  2. Schließt Connections, die > 60 Sek idle sind
  3. Hält Pool sauber

Warum wichtig?

  • Spart DB-Ressourcen
  • Verhindert „Connection Timeout“ in DB
  • Pool passt sich an Last an

Production-Ready Pool-Konfiguration

Empfohlene Settings für verschiedene Szenarien:

Szenario 1: Kleine App (< 100 User gleichzeitig)

<Resource 
    name="jdbc/ShopDB"
    
    initialSize="5"
    minIdle="5"
    maxTotal="20"
    maxIdle="10"
    
    maxWaitMillis="10000"
    
    testOnBorrow="true"
    testWhileIdle="true"
    validationQuery="SELECT 1"
    
    timeBetweenEvictionRunsMillis="60000"
    minEvictableIdleTimeMillis="300000"
/>

Szenario 2: Mittlere App (100-1000 User)

<Resource 
    name="jdbc/ShopDB"
    
    initialSize="20"
    minIdle="20"
    maxTotal="100"
    maxIdle="50"
    
    maxWaitMillis="30000"
    
    testOnBorrow="true"
    testWhileIdle="true"
    validationQuery="SELECT 1"
    validationQueryTimeout="3"
    
    timeBetweenEvictionRunsMillis="30000"
    minEvictableIdleTimeMillis="180000"
    
    removeAbandonedOnBorrow="true"
    removeAbandonedTimeout="300"
    logAbandoned="true"
/>

Szenario 3: Große App (> 1000 User)

<Resource 
    name="jdbc/ShopDB"
    
    initialSize="50"
    minIdle="50"
    maxTotal="200"
    maxIdle="100"
    
    maxWaitMillis="60000"
    
    testOnBorrow="true"
    testWhileIdle="true"
    validationQuery="SELECT 1"
    validationQueryTimeout="5"
    
    timeBetweenEvictionRunsMillis="30000"
    minEvictableIdleTimeMillis="120000"
    
    removeAbandonedOnBorrow="true"
    removeAbandonedOnMaintenance="true"
    removeAbandonedTimeout="180"
    logAbandoned="true"
    
    <!-- Performance-Tuning -->
    fairQueue="false"
    jmxEnabled="true"
/>

Monitoring & Diagnostics

Payara Monitoring aktivieren:

Admin Console → Monitoring → Configure Monitoring
→ JDBC Connection Pool: HIGH

Metrics anzeigen:

Admin Console → Monitoring → Server → Resources → JDBC Connection Pools → ShopDBPool

Was du siehst:

numConnUsed:     15    ← Aktuell in Benutzung
numConnFree:     35    ← Verfügbar
numConnCreated:  50    ← Gesamt erstellt
waitTime:        2ms   ← Durchschnittliche Wartezeit
numConnTimedOut: 0     ← Timeouts

JMX Monitoring (für Production):

// JConsole / VisualVM nutzen
// → MBeans → java.sql → ShopDBPool

// Oder programmatisch:
MBeanServer mbs = ManagementFactory.getPlatformMBeanServer();
ObjectName poolName = new ObjectName(
    "Catalina:type=DataSource,class=javax.sql.DataSource,name=\"jdbc/ShopDB\""
);

int numActive = (Integer) mbs.getAttribute(poolName, "numActive");
int numIdle = (Integer) mbs.getAttribute(poolName, "numIdle");

System.out.println("Active: " + numActive + ", Idle: " + numIdle);

Logging aktivieren:

Payara Admin Console → Configurations → server-config → Logger Settings

Log Levels:
jakarta.enterprise.resource.resourceadapter: FINE
org.apache.tomcat.jdbc.pool: FINE

Log-Ausgabe:

INFO: Created connection pool 'ShopDBPool'
FINE: Borrowing connection from pool (active: 5, idle: 15)
FINE: Returning connection to pool (active: 4, idle: 16)
WARNING: Connection leak detected! Stack trace:
    at com.shop.dao.ProductDAOImpl.findAll()
    ...

🟡 PROFESSIONALS: Stored Procedures

Was sind Stored Procedures?

Stored Procedures sind vorkompilierte SQL-Funktionen, die in der Datenbank gespeichert sind.

Beispiel: Stored Procedure in MariaDB

-- Stored Procedure erstellen
DELIMITER $$

CREATE PROCEDURE getProductsByPriceRange(
    IN minPrice DECIMAL(10,2),
    IN maxPrice DECIMAL(10,2)
)
BEGIN
    SELECT * FROM products
    WHERE price BETWEEN minPrice AND maxPrice
    ORDER BY price;
END$$

DELIMITER ;

Aufrufen aus JDBC:

String sql = "{CALL getProductsByPriceRange(?, ?)}";

try (CallableStatement cstmt = conn.prepareCall(sql)) {
    
    cstmt.setDouble(1, 50.0);   // minPrice
    cstmt.setDouble(2, 200.0);  // maxPrice
    
    try (ResultSet rs = cstmt.executeQuery()) {
        while (rs.next()) {
            System.out.println(rs.getString("name") + ": " + rs.getDouble("price"));
        }
    }
}

Stored Procedures mit OUT-Parametern

Beispiel: Berechnung in Datenbank

DELIMITER $$

CREATE PROCEDURE calculateOrderTotal(
    IN orderId INT,
    OUT totalAmount DECIMAL(10,2),
    OUT itemCount INT
)
BEGIN
    SELECT SUM(price * quantity), COUNT(*)
    INTO totalAmount, itemCount
    FROM order_items
    WHERE order_id = orderId;
END$$

DELIMITER ;

Aufrufen aus JDBC:

String sql = "{CALL calculateOrderTotal(?, ?, ?)}";

try (CallableStatement cstmt = conn.prepareCall(sql)) {
    
    // IN-Parameter
    cstmt.setInt(1, orderId);
    
    // OUT-Parameter registrieren
    cstmt.registerOutParameter(2, java.sql.Types.DECIMAL);
    cstmt.registerOutParameter(3, java.sql.Types.INTEGER);
    
    // Ausführen
    cstmt.execute();
    
    // OUT-Parameter lesen
    BigDecimal totalAmount = cstmt.getBigDecimal(2);
    int itemCount = cstmt.getInt(3);
    
    System.out.println("Total: " + totalAmount);
    System.out.println("Items: " + itemCount);
}

Wann Stored Procedures nutzen?

✅ Vorteile:

  • Performance (vorkompiliert, DB-seitig optimiert)
  • Weniger Netzwerk-Traffic
  • Wiederverwendbar
  • Zentralisierte Business-Logik

❌ Nachteile:

  • Schwer zu testen
  • Nicht portabel (DB-spezifisch)
  • Versionierung kompliziert
  • Debugging schwierig

Best Practice:

  • Nutze Stored Procedures für komplexe Berechnungen
  • Nutze Stored Procedures für Performance-kritische Operationen
  • Aber: Halte Business-Logik in Java, nicht in DB!

🔵 BONUS: Performance-Optimierung

1. Connection Pooling Tuning

Problem identifizieren:

// Monitoring zeigt:
waitTime:        500ms    ← User warten lange!
numConnTimedOut: 15       ← Viele Timeouts!
numConnUsed:     100      ← Pool immer voll!

Lösung 1: Pool vergrößern

<!-- Vorher -->
maxTotal="100"

<!-- Nachher -->
maxTotal="200"

Problem: Connection Leaks

// Monitoring zeigt:
numConnUsed:     95       ← Fast alle used
numConnFree:     5        ← Kaum noch frei
Aber: Niedrige Last!      ← Widerspruch!

Ursache: Connections werden nicht geschlossen!

Lösung:

// ❌ FALSCH: Leak!
Connection conn = dataSource.getConnection();
// ... vergessen zu schließen

// ✅ RICHTIG: Try-with-resources
try (Connection conn = dataSource.getConnection()) {
    // ...
}  // Automatisch geschlossen

2. Query-Optimierung

Problem: Langsame Queries

// Slow Query Log zeigt:
SELECT * FROM products WHERE name LIKE '%laptop%'
Time: 5000ms  ← 5 Sekunden! 😱

Lösung 1: Index erstellen

-- Index auf name-Spalte
CREATE INDEX idx_products_name ON products(name);

-- Query jetzt:
Time: 50ms  ← 100x schneller!

Lösung 2: Query umschreiben

// ❌ LANGSAM: Wildcard am Anfang
SELECT * FROM products WHERE name LIKE '%laptop%'
// Index kann nicht genutzt werden!

// ✅ SCHNELLER: Wildcard am Ende
SELECT * FROM products WHERE name LIKE 'laptop%'
// Index kann genutzt werden!

// ✅ ODER: Full-Text-Search nutzen
SELECT * FROM products WHERE MATCH(name) AGAINST('laptop')

Problem: N+1 Queries

// ❌ SCHLECHT: N+1 Query Problem
List<Order> orders = orderDAO.findAll();  // 1 Query

for (Order order : orders) {
    List<Item> items = orderDAO.getItems(order.getId());  // N Queries!
    order.setItems(items);
}
// Gesamt: 1 + N Queries (N = Anzahl Orders)
// ✅ BESSER: JOIN verwenden
String sql = """
    SELECT o.*, i.*
    FROM orders o
    LEFT JOIN order_items i ON o.id = i.order_id
    """;
// Nur 1 Query!

3. Caching-Strategien

Problem: Gleiche Daten immer wieder geladen

// Jeder Request lädt Kategorien neu (ineffizient!)
@WebServlet("/products")
public class ProductServlet extends HttpServlet {
    
    protected void doGet(...) {
        List<Category> categories = categoryDAO.findAll();  // DB-Zugriff!
        // ...
    }
}

Lösung 1: Application-Scope Cache

@WebServlet("/products")
public class ProductServlet extends HttpServlet {
    
    @Override
    public void init() throws ServletException {
        // Beim Start laden
        List<Category> categories = categoryDAO.findAll();
        getServletContext().setAttribute("categories", categories);
    }
    
    protected void doGet(...) {
        // Aus Cache holen (kein DB-Zugriff!)
        List<Category> categories = 
            (List<Category>) getServletContext().getAttribute("categories");
        // ...
    }
}

Lösung 2: LRU-Cache mit Guava

<!-- pom.xml -->
<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>33.0.0-jre</version>
</dependency>
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;

public class ProductDAOImpl implements ProductDAO {
    
    private LoadingCache<Integer, Product> cache;
    
    public ProductDAOImpl() {
        cache = CacheBuilder.newBuilder()
            .maximumSize(1000)              // Max 1000 Einträge
            .expireAfterWrite(10, TimeUnit.MINUTES)  // Nach 10 Min invalidieren
            .build(new CacheLoader<Integer, Product>() {
                @Override
                public Product load(Integer id) throws Exception {
                    return loadFromDatabase(id);  // Bei Cache-Miss: DB laden
                }
            });
    }
    
    @Override
    public Optional<Product> findById(int id) {
        try {
            return Optional.of(cache.get(id));  // Aus Cache (oder lädt automatisch)
        } catch (Exception e) {
            return Optional.empty();
        }
    }
    
    private Product loadFromDatabase(int id) {
        // Echter DB-Zugriff
        try (Connection conn = dataSource.getConnection()) {
            // ...
        }
    }
}

🔵 BONUS: Security Best Practices

1. SQL-Injection Prevention (nochmal betont!)

// ❌ NIEMALS String-Concatenation!
String sql = "SELECT * FROM users WHERE username = '" + username + "'";
// → SQL-Injection möglich!

// ✅ IMMER PreparedStatement!
String sql = "SELECT * FROM users WHERE username = ?";
pstmt.setString(1, username);
// → Sicher!

2. Credentials Management

// ❌ NIEMALS Passwörter im Code!
String dbPassword = "secret123";

// ❌ NIEMALS Passwörter in Konfigurationsdateien (Git!)
<Resource password="secret123" />

// ✅ Environment Variables
String dbPassword = System.getenv("DB_PASSWORD");

// ✅ Payara Password Aliases
asadmin create-password-alias db_password
<Resource password="${ALIAS=db_password}" />

3. Least Privilege Principle

-- ❌ FALSCH: Application-User mit Admin-Rechten
GRANT ALL PRIVILEGES ON *.* TO 'app_user'@'%';

-- ✅ RICHTIG: Nur nötige Rechte
GRANT SELECT, INSERT, UPDATE, DELETE ON shopdb.* TO 'app_user'@'%';

-- Kein DROP, CREATE, ALTER!

4. Connection Strings absichern

// ❌ UNSICHER: Credentials in URL
String url = "jdbc:mariadb://localhost:3306/shopdb?user=root&password=secret";

// ✅ SICHER: Credentials separat
Properties props = new Properties();
props.setProperty("user", System.getenv("DB_USER"));
props.setProperty("password", System.getenv("DB_PASSWORD"));
props.setProperty("useSSL", "true");

Connection conn = DriverManager.getConnection(url, props);

Weiterführende Ressourcen

JavaBeans Spec:

JSP Actions:

Bean Validation:


🎯 Häufig gestellte Fragen (FAQ)

Frage 1: Wann PreparedStatement vs. CallableStatement?

Antwort:

PreparedStatement:

  • ✅ Für normale SQL-Queries (SELECT, INSERT, UPDATE, DELETE)
  • ✅ Performance durch Query-Caching
  • ✅ SQL-Injection Prevention
  • ✅ 99% der Anwendungsfälle
String sql = "SELECT * FROM products WHERE category = ?";
PreparedStatement pstmt = conn.prepareStatement(sql);

CallableStatement:

  • ✅ Nur für Stored Procedures
  • ✅ Wenn DB-seitige Logik gewünscht
  • ✅ OUT-Parameter benötigt
String sql = "{CALL getProductsByCategory(?)}";
CallableStatement cstmt = conn.prepareCall(sql);

Best Practice: Nutze PreparedStatement, außer du hast explizite Stored Procedures!


Frage 2: Wie groß soll mein Connection Pool sein?

Antwort:

Faustregel:

Pool Size = (Anzahl CPU-Kerne * 2) + Anzahl Festplatten

Beispiel:
4 CPU-Kerne, 1 HDD
Pool Size = (4 * 2) + 1 = 9

Aber: Das ist nur ein Startpunkt!

In der Praxis:

Min Pool Size:  10-20
Max Pool Size:  50-100 (für mittlere Apps)
                100-200 (für große Apps)

Wichtig: Mit Monitoring optimieren!

Wenn: waitTime > 100ms
Dann: Pool vergrößern

Wenn: numConnUsed < 50% von maxTotal
Dann: Pool verkleinern

Frage 3: Wie vermeide ich Connection Leaks?

Antwort:

1. IMMER Try-with-resources:

// ✅ RICHTIG
try (Connection conn = dataSource.getConnection();
     PreparedStatement pstmt = conn.prepareStatement(sql);
     ResultSet rs = pstmt.executeQuery()) {
    // ...
}  // Automatisch geschlossen

2. Leak Detection aktivieren:

<Resource 
    removeAbandonedOnBorrow="true"
    removeAbandonedTimeout="300"
    logAbandoned="true"
/>

3. Code-Review:

  • Suche nach getConnection() ohne Try-with-resources
  • Suche nach close() in catch-Block (kann übersprungen werden!)

Frage 4: Batch vs. einzelne Statements – wann was?

Antwort:

Einzelne Statements:

  • ✅ 1-10 Operationen
  • ✅ Sofortiges Feedback gewünscht
  • ✅ Operationen sind unabhängig
for (Product p : products) {
    pstmt.setString(1, p.getName());
    pstmt.executeUpdate();
}

Batch Operations:

  • ✅ 100+ Operationen
  • ✅ Performance kritisch
  • ✅ Operationen sind ähnlich
for (Product p : products) {
    pstmt.setString(1, p.getName());
    pstmt.addBatch();
}
pstmt.executeBatch();

Breaking Point: Ab ~50 Operationen lohnt sich Batch meist!


Frage 5: Wie teste ich DAO-Code mit echter DB?

Antwort:

Option 1: Testcontainers (empfohlen!)

<!-- pom.xml -->
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>mariadb</artifactId>
    <version>1.19.3</version>
    <scope>test</scope>
</dependency>
import org.testcontainers.containers.MariaDBContainer;

public class ProductDAOTest {
    
    static MariaDBContainer<?> mariadb = new MariaDBContainer<>("mariadb:11.2");
    
    @BeforeAll
    static void setUp() {
        mariadb.start();
        
        // DataSource mit Testcontainer-URL erstellen
        DataSource dataSource = createDataSource(
            mariadb.getJdbcUrl(),
            mariadb.getUsername(),
            mariadb.getPassword()
        );
        
        // Test-Daten einfügen
        initTestData(dataSource);
    }
    
    @Test
    void testFindAll() {
        ProductDAO dao = new ProductDAOImpl(dataSource);
        List<Product> products = dao.findAll();
        
        assertNotNull(products);
        assertTrue(products.size() > 0);
    }
    
    @AfterAll
    static void tearDown() {
        mariadb.stop();
    }
}

Vorteile:

  • ✅ Echter DB-Test (keine Mocks)
  • ✅ Isoliert (jeder Test eigene DB)
  • ✅ Automatisch gestartet/gestoppt

Option 2: H2 In-Memory-DB

<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <version>2.2.224</version>
    <scope>test</scope>
</dependency>
@BeforeEach
void setUp() {
    DataSource dataSource = new JdbcDataSource();
    dataSource.setURL("jdbc:h2:mem:testdb");
    
    // Schema erstellen
    try (Connection conn = dataSource.getConnection()) {
        conn.createStatement().execute(
            "CREATE TABLE products (...)"
        );
    }
}

Vorteile:

  • ✅ Sehr schnell
  • ✅ Kein Setup nötig

Nachteile:

  • ⚠️ H2 ≠ MariaDB (SQL-Dialekt unterschiedlich)

🎉 Tag 10 geschafft – Du bist ein Java Web Developer! 🎓

LEGENDARY! Du hast es geschafft! 🔥🎉

Real talk: 10 Tage Java Web Basic sind durch. Du hast jeden einzelnen Tag gemeistert. Das ist HUGE!

Das hast du in diesem Kurs gelernt:

Woche 1: Grundlagen

  • ✅ Java EE Architektur & HTTP-Protokoll
  • ✅ Servlets & Servlet API meistern
  • ✅ MVC & Model 2 Architektur
  • ✅ JSP & Expression Language
  • ✅ Java Beans, Actions, Scopes & Direktiven

Woche 2: Production-Ready

  • ✅ Include-Action vs Include-Direktive
  • ✅ JSTL – Komplett scriptlet-freie JSPs
  • ✅ Datasources & Connection Pools
  • ✅ Connection Pool Tuning
  • ✅ JDBC Performance & Best Practices

Du kannst jetzt:

  • 🚀 Vollständige Web-Anwendungen von Grund auf bauen
  • 🚀 Datasources & Connection Pools konfigurieren
  • 🚀 Production-Ready JDBC-Code schreiben
  • 🚀 SQL-Injection verhindern
  • 🚀 Performance-Probleme identifizieren & lösen
  • 🚀 Transaktionen korrekt handhaben
  • 🚀 Batch-Operations nutzen
  • 🚀 Code schreiben, der in Production läuft!

Honestly? Das ist ein riesiger Meilenstein! 💪

Du bist jetzt ein Java Web Developer. Nicht Junior. Nicht „in Ausbildung“. Ein echter Developer, der Production-Code schreiben kann.

Was jetzt?

  1. Projekt bauen: Bau eine echte Anwendung (Shop, Blog, Task-Manager)
  2. Aufbau-Kurs: Lerne Filter, Listeners, Sessions im Detail
  3. Frameworks: Spring Boot, Hibernate, REST APIs
  4. Bewirb dich: Du hast die Skills für Junior-Positionen!

Ein letzter Gedanke:

Als ich bei Java Fleet anfing, hab ich auch mit diesen Grundlagen gestartet. Legacy-Code refactoren, Performance-Probleme lösen, Connection Leaks fixen – das sind die Daily Tasks eines Developers.

Du hast jetzt die Skills dafür. 🎓

Stolz sein ist angesagt! Du hast 80 Stunden in diesen Kurs investiert. Das zeigt Commitment und Durchhaltevermögen. Genau das, was in der IT zählt.

Lowkey: Die meisten brechen bei Tag 3 ab. Du bist bis zum Ende geblieben. Das macht dich besonders! ✨


🔮 Wie geht’s weiter?

Java Web Aufbau-Kurs (10 weitere Tage)

Was dich erwartet:

  • Filter & Filter-Chains im Detail
  • Listeners (ServletContext, Session, Request)
  • Session Management & Session-Tracking
  • Cookies & Session Security
  • File Upload & Download
  • WebSockets für Real-Time Apps
  • RESTful Services mit JAX-RS
  • JSON Processing
  • Authentication & Authorization
  • Deployment & Production-Setup

Und dann:

  • Spring Boot Grundlagen
  • Hibernate/JPA
  • Microservices
  • Docker & Kubernetes
  • CI/CD Pipelines

Der Weg ist klar – du bist bereit dafür! 🚀


📧 Abschluss-Troubleshooting

Problem: PreparedStatement mit ORDER BY funktioniert nicht

// ❌ FALSCH: ORDER BY mit ?
String sql = "SELECT * FROM products ORDER BY ?";
pstmt.setString(1, "price");  // Funktioniert NICHT!

Ursache: Parameter können nur für Werte, nicht für Spaltennamen!

Lösung 1: Whitelist

String column = request.getParameter("sort");

// Validierung gegen Whitelist
if (!List.of("name", "price", "stock").contains(column)) {
    column = "name";  // Fallback
}

String sql = "SELECT * FROM products ORDER BY " + column;
Statement stmt = conn.createStatement();  // Kein PreparedStatement nötig

Lösung 2: Switch/Case

String sort = request.getParameter("sort");
String sql = switch (sort) {
    case "price" -> "SELECT * FROM products ORDER BY price";
    case "stock" -> "SELECT * FROM products ORDER BY stock";
    default -> "SELECT * FROM products ORDER BY name";
};

Problem: ResultSet already closed

// ❌ FEHLER
try (Connection conn = dataSource.getConnection();
     Statement stmt = conn.createStatement();
     ResultSet rs = stmt.executeQuery(sql)) {
    // ...
}

// ResultSet hier geschlossen!
rs.next();  // SQLException: ResultSet closed

Lösung: Daten INNERHALB des Try-Blocks verarbeiten!

List<Product> products = new ArrayList<>();

try (Connection conn = dataSource.getConnection();
     Statement stmt = conn.createStatement();
     ResultSet rs = stmt.executeQuery(sql)) {
    
    while (rs.next()) {
        products.add(mapRowToProduct(rs));  // ✅ Innerhalb Try
    }
}

// ✅ Liste nutzen (außerhalb Try-Block)
return products;

Problem: Auto-increment ID nach Insert holen

String sql = "INSERT INTO products (name, price) VALUES (?, ?)";

// ✅ Mit RETURN_GENERATED_KEYS
try (PreparedStatement pstmt = conn.prepareStatement(sql, 
                                Statement.RETURN_GENERATED_KEYS)) {
    
    pstmt.setString(1, "Laptop");
    pstmt.setDouble(2, 999.99);
    
    int affectedRows = pstmt.executeUpdate();
    
    if (affectedRows > 0) {
        try (ResultSet generatedKeys = pstmt.getGeneratedKeys()) {
            if (generatedKeys.next()) {
                int id = generatedKeys.getInt(1);
                System.out.println("Neue ID: " + id);
            }
        }
    }
}

Viel Erfolg auf deinem weiteren Weg! 🚀

Wenn du Fragen hast oder Unterstützung brauchst, schreib uns: support@java-developer.online


🎖️ Dein Zertifikat

Du hast den Java Web Basic Kurs abgeschlossen!

Hier ist dein (imaginäres, aber wohlverdientes) Zertifikat:

╔══════════════════════════════════════════════════════════╗
║                                                          ║
║           🎓 JAVA WEB BASIC ZERTIFIKAT 🎓               ║
║                                                          ║
║  Dieses Zertifikat bestätigt, dass                     ║
║                                                          ║
║              [DEIN NAME]                                ║
║                                                          ║
║  erfolgreich den Java Web Basic Kurs                    ║
║  (10 Tage / 80 Stunden) abgeschlossen hat.             ║
║                                                          ║
║  Skills erworben:                                       ║
║  ✅ Servlets & Servlet API                              ║
║  ✅ JSP & JSTL                                          ║
║  ✅ MVC/Model 2 Architektur                             ║
║  ✅ Datasources & Connection Pools                      ║
║  ✅ JDBC Best Practices                                 ║
║  ✅ Production-Ready Code                               ║
║                                                          ║
║  Du bist jetzt ein Java Web Developer! 🚀              ║
║                                                          ║
║  Ausgestellt am: [DATUM]                                ║
║  Von: Java Fleet Systems Consulting                     ║
║  Instruktor: Elyndra Valen                              ║
║                                                          ║
╚══════════════════════════════════════════════════════════╝

„Der Weg zum Meister beginnt mit dem ersten Schritt. Du hast 10 Tage lang jeden Tag einen neuen Schritt gemacht. Sei stolz auf dich!“ – Elyndra Valen

Jetzt geh raus und bau etwas Großartiges! 🌟

Autor

  • Elyndra Valen

    28 Jahre alt, wurde kürzlich zur Senior Entwicklerin befördert nach 4 Jahren intensiver Java-Entwicklung. Elyndra kennt die wichtigsten Frameworks und Patterns, beginnt aber gerade erst, die tieferen Zusammenhänge und Architektur-Entscheidungen zu verstehen. Sie ist die Brücke zwischen Junior- und Senior-Welt im Team.