Why My Object Changes Don't Stick: In-Memory vs Database Persistence
"I changed the object's failedLoginAttempts field, but when I query the database again, it's back to the old value! In high school, changing object properties just worked - why do I need to call save() in Spring Boot?" This is the fundamental difference between in-memory objects (what you learned in school) and database-backed objects (what you use in production). Let's understand why object changes don't automatically persist to the database.
The Discoveryβ
You wrote this code:
if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
user.increaseFailedLoginAttempts(); // Change the object
if (user.getFailedLoginAttempts() >= 5) {
user.setAccountLocked(true);
userRepository.save(user); // Save to database
}
// β If < 5 attempts, you DON'T save!
throw new InvalidCredentialsException("Invalid credentials");
}
The problem:
Login attempt 1: failedLoginAttempts = 1 (in memory, not saved)
Login attempt 2: Database still has 0, code reads 0, increments to 1 (in memory, not saved)
Login attempt 3: Database still has 0, code reads 0, increments to 1 (in memory, not saved)
...
Never reaches 5 because database never gets updated! π¨
Your question:
"I discovered why this didn't work - because I don't save it to database. But during high school I was writing code and there was no DB, and this logic was working. I was creating objects and altering their values and I could reach their values. But in business world we have databases. I get it, but still I change the
failedLoginAttemptsproperty of the object. How comegetFailedLoginAttempts()gets database value but not the values I assigned?"
Great question! Let's understand the two worlds: in-memory vs database.
High School Programming: In-Memory Objectsβ
How It Worked Without a Databaseβ
public class Main {
public static void main(String[] args) {
// Create object in memory
User user = new User();
user.setUsername("testuser");
user.setFailedLoginAttempts(0);
System.out.println(user.getFailedLoginAttempts()); // 0
// Change the object
user.increaseFailedLoginAttempts();
System.out.println(user.getFailedLoginAttempts()); // β
1
// Change again
user.increaseFailedLoginAttempts();
System.out.println(user.getFailedLoginAttempts()); // β
2
}
}
Why it worked:
- β Object lives in RAM (Random Access Memory)
- β All changes stay in memory
- β Same object reference throughout the program
- β No database involved
Visual: In-Memory Onlyβ
βββββββββββββββββββββββββββββββββββββββββββ
β RAM (Computer Memory) β
β β
β βββββββββββββββββββββββββββββββββββββ β
β β User object β β
β β username: "testuser" β β
β β failedLoginAttempts: 0 β β
β βββββββββββββββββββββββββββββββββββββ β
β β user.increaseFailedLoginAttempts()
β βββββββββββββββββββββββββββββββββββββ β
β β User object β β
β β username: "testuser" β β
β β failedLoginAttempts: 1 β β β
Changed in memory
β βββββββββββββββββββββββββββββββββββββ β
β β
βββββββββββββββββββββββββββββββββββββββββββ
When program ends β Memory cleared β Object gone!
Limitation: When the program stops, all data is lost.
Business World: Database-Backed Objectsβ
The Two Worlds Problemβ
@Service
public class AuthService {
@Autowired
private UserRepository userRepository;
public void login(LoginRequest request) {
// Step 1: Get from database
User user = userRepository.findByEmail("test@example.com");
System.out.println(user.getFailedLoginAttempts()); // 0 (from database)
// Step 2: Change in memory
user.increaseFailedLoginAttempts();
System.out.println(user.getFailedLoginAttempts()); // β
1 (in memory)
// Step 3: DON'T save to database (missing userRepository.save(user))
// Step 4: Method ends, object discarded
}
public void loginAgain(LoginRequest request) {
// Step 5: Query database again (new request)
User freshUser = userRepository.findByEmail("test@example.com");
System.out.println(freshUser.getFailedLoginAttempts()); // β 0 (database still has old value!)
}
}
Visual: Two Separate Worldsβ
Request 1:
βββββββββββββββββββββββββββββββββββββββββββ
β RAM (Temporary) β
β β
β User object (in memory) β
β failedLoginAttempts: 0 β 1 β β
Changed
β β
βββββββββββββββββββββββββββββββββββββββββββ
β NOT SAVED
β
βββββββββββββββββββββββββββββββββββββββββββ
β Database (Persistent) β
β β
β users table β
β failedLoginAttempts: 0 β β Still old value
β β
βββββββββββββββββββββββββββββββββββββββββββ
Request 1 ends β RAM cleared β Object discarded
Request 2:
βββββββββββββββββββββββββββββββββββββββββββ
β RAM (Fresh start) β
β β
β User object (freshly loaded from DB) β
β failedLoginAttempts: 0 β β Back to 0!
β β
βββββββββββββββββββββββββββββββββββββββββββ
β
Loaded from database
βββββββββββββββββββββββββββββββββββββββββββ
β Database (Persistent) β
β β
β users table β
β failedLoginAttempts: 0 β β Still 0
β β
βββββββββββββββββββββββββββββββββββββββββββ
Key insight: Database is the source of truth. In-memory changes are temporary.
Why They Don't Sync Automaticallyβ
The Lifecycle of a Database-Backed Objectβ
// Step 1: Load from database
User user = userRepository.findByEmail("test@example.com");
// JPA executes: SELECT * FROM users WHERE email = 'test@example.com'
// Creates Java object from database row
// Step 2: Object exists in memory
// user.failedLoginAttempts = 0 (copied from database)
// Step 3: Change in memory
user.increaseFailedLoginAttempts();
// user.failedLoginAttempts = 1 (only in RAM!)
// Database STILL has: failedLoginAttempts = 0
// Step 4: Save to database
userRepository.save(user);
// JPA executes: UPDATE users SET failed_login_attempts = 1 WHERE email = 'test@example.com'
// NOW database is updated!
// Step 5: Both in sync
// Memory: failedLoginAttempts = 1 β
// Database: failed_login_attempts = 1 β
Timeline Diagramβ
Time β
T1: Load from DB
βββββββββββββββ
β Database β failedLoginAttempts = 0
βββββββββββββββ
β SELECT
βββββββββββββββ
β RAM β failedLoginAttempts = 0
βββββββββββββββ
T2: Modify in memory
βββββββββββββββ
β Database β failedLoginAttempts = 0 (unchanged)
βββββββββββββββ
βββββββββββββββ
β RAM β failedLoginAttempts = 1 (changed)
βββββββββββββββ
βοΈ OUT OF SYNC!
T3: Save to database
βββββββββββββββ
β RAM β failedLoginAttempts = 1
βββββββββββββββ
β UPDATE
βββββββββββββββ
β Database β failedLoginAttempts = 1 (now updated)
βββββββββββββββ
β
IN SYNC!
Your Specific Problem: Failed Login Attemptsβ
The Broken Codeβ
@PostMapping("/auth/login")
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest request) {
User user = userRepository.findByEmail(request.getEmail())
.orElseThrow(() -> new UserNotFoundException("User not found"));
if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
// Change in memory
user.increaseFailedLoginAttempts();
// Only save if >= 5
if (user.getFailedLoginAttempts() >= 5) {
user.setAccountLocked(true);
userRepository.save(user); // β
Saved
}
// β If < 5, NOT saved!
throw new InvalidCredentialsException("Invalid credentials");
}
// Successful login
user.setFailedLoginAttempts(0);
userRepository.save(user); // β
Saved
return ResponseEntity.ok(new LoginResponse(...));
}
What Actually Happensβ
Attempt 1:
1. Load user from DB: failedLoginAttempts = 0
2. Password wrong: increaseFailedLoginAttempts() β now 1 (in memory)
3. Check: 1 >= 5? No
4. DON'T save β
5. Request ends β memory cleared
6. Database still has: failedLoginAttempts = 0
Attempt 2:
1. Load user from DB: failedLoginAttempts = 0 (database never changed!)
2. Password wrong: increaseFailedLoginAttempts() β now 1 (in memory)
3. Check: 1 >= 5? No
4. DON'T save β
5. Request ends β memory cleared
6. Database still has: failedLoginAttempts = 0
Attempt 3, 4, 5, 6, 7...
Same thing! Database NEVER gets updated because < 5.
Account NEVER locks because database always has 0!
The Fixβ
@PostMapping("/auth/login")
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest request) {
User user = userRepository.findByEmail(request.getEmail())
.orElseThrow(() -> new UserNotFoundException("User not found"));
if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
// Change in memory
user.increaseFailedLoginAttempts();
// Lock if >= 5
if (user.getFailedLoginAttempts() >= 5) {
user.setAccountLocked(true);
}
// β
ALWAYS save, regardless of count
userRepository.save(user);
throw new InvalidCredentialsException("Invalid credentials");
}
// Successful login
user.setFailedLoginAttempts(0);
userRepository.save(user); // β
Saved
return ResponseEntity.ok(new LoginResponse(...));
}
Now it works:
Attempt 1:
1. Load: failedLoginAttempts = 0
2. Increase: failedLoginAttempts = 1 (memory)
3. Save: Database updated to 1 β
Attempt 2:
1. Load: failedLoginAttempts = 1 (from database)
2. Increase: failedLoginAttempts = 2 (memory)
3. Save: Database updated to 2 β
Attempt 5:
1. Load: failedLoginAttempts = 4 (from database)
2. Increase: failedLoginAttempts = 5 (memory)
3. Lock: accountLocked = true
4. Save: Database updated: failedLoginAttempts = 5, accountLocked = true β
Mental Model: Two Separate Worldsβ
High School: One World (Memory Only)β
βββββββββββββββββββββββββββββββββββββββββββ
β Your Program β
β β
β User user = new User(); β
β user.setFailedLoginAttempts(0); β
β β
β user.increaseFailedLoginAttempts(); β
β // failedLoginAttempts = 1 β
β
β β
β System.out.println( β
β user.getFailedLoginAttempts() β
β ); // Prints 1 β
β
β β
βββββββββββββββββββββββββββββββββββββββββββ
Everything happens in memory.
Changes are immediate and visible.
No persistence needed.
Business World: Two Worlds (Memory + Database)β
βββββββββββββββββββββββββββββββββββββββββββ
β World 1: RAM (Temporary) β
β β
β User user = repo.find(...); β
β user.setFailedLoginAttempts(0); β
β user.increaseFailedLoginAttempts(); β
β // failedLoginAttempts = 1 in memory β
β
β β
βββββββββββββββββββββββββββββββββββββββββββ
βοΈ
Must explicitly sync
βοΈ
βββββββββββββββββββββββββββββββββββββββββββ
β World 2: Database (Persistent) β
β β
β users table: β
β failed_login_attempts = 0 β β
β β
β (Database doesn't change automatically) β
β β
βββββββββββββββββββββββββββββββββββββββββββ
β userRepository.save(user)
βββββββββββββββββββββββββββββββββββββββββββ
β World 2: Database (Persistent) β
β β
β users table: β
β failed_login_attempts = 1 β
β
β β
β (NOW updated) β
β β
βββββββββββββββββββββββββββββββββββββββββββ
Why Database is the Source of Truthβ
Scenario: Multiple Requestsβ
// Request 1 (from browser)
User user1 = userRepository.findByEmail("test@example.com");
user1.setFailedLoginAttempts(3);
// Don't save
// Request 2 (from mobile app, same user)
User user2 = userRepository.findByEmail("test@example.com");
System.out.println(user2.getFailedLoginAttempts());
// β Prints 0, not 3!
// Because database still has 0
Key insight: Each request creates a new Java object from the database.
Request 1:
Database (failedLoginAttempts = 0)
β SELECT
Object 1 (failedLoginAttempts = 0)
β user1.setFailedLoginAttempts(3)
Object 1 (failedLoginAttempts = 3)
β NOT SAVED
Request ends β Object 1 discarded
Request 2:
Database (failedLoginAttempts = 0) β Still 0!
β SELECT
Object 2 (failedLoginAttempts = 0) β Fresh from DB
Objects are independent copies! Changing one doesn't affect the other.
App Restart: In-Memory Changes Lostβ
Without Saveβ
// App running
User user = userRepository.findByEmail("test@example.com");
user.setFailedLoginAttempts(3);
// Don't save
// App restarts
// All RAM cleared!
// Object gone!
// App starts again
User freshUser = userRepository.findByEmail("test@example.com");
System.out.println(freshUser.getFailedLoginAttempts());
// β Prints 0 (database still has 0)
With Saveβ
// App running
User user = userRepository.findByEmail("test@example.com");
user.setFailedLoginAttempts(3);
userRepository.save(user); // β
Persisted to database
// App restarts
// RAM cleared, but database survives!
// App starts again
User freshUser = userRepository.findByEmail("test@example.com");
System.out.println(freshUser.getFailedLoginAttempts());
// β
Prints 3 (loaded from database)
Complete Comparisonβ
High School (In-Memory Only)β
public class InMemoryExample {
public static void main(String[] args) {
User user = new User();
user.setUsername("testuser");
user.setFailedLoginAttempts(0);
// Changes are immediate
user.increaseFailedLoginAttempts();
System.out.println(user.getFailedLoginAttempts()); // β
1
user.increaseFailedLoginAttempts();
System.out.println(user.getFailedLoginAttempts()); // β
2
// But when program ends...
// ALL DATA LOST! π¨
}
}
Characteristics:
- β Changes are immediate
- β Same object throughout execution
- β Data lost when program ends
- β Can't share data between program runs
- β Can't handle concurrent requests
Business World (Database-Backed)β
@Service
public class DatabaseExample {
@Autowired
private UserRepository userRepository;
public void example() {
// Load from database
User user = userRepository.findByEmail("test@example.com");
user.setFailedLoginAttempts(0);
// Change in memory
user.increaseFailedLoginAttempts();
System.out.println(user.getFailedLoginAttempts()); // β
1 (in memory)
// Save to database
userRepository.save(user); // β
Persists to database
// Even if app restarts, data survives!
// Even if another user logs in simultaneously, they see correct data!
}
}
Characteristics:
- β Data survives app restarts
- β Multiple users can access same data
- β Concurrent requests handled correctly
- β οΈ Must explicitly save changes
- β οΈ Each request gets fresh copy from database
Common Mistakesβ
Mistake 1: Forgetting to Saveβ
// β WRONG
User user = userRepository.findByEmail(email);
user.setFailedLoginAttempts(3);
// Forgot userRepository.save(user)
// Database NEVER updated!
Mistake 2: Saving Conditionallyβ
// β WRONG
User user = userRepository.findByEmail(email);
user.increaseFailedLoginAttempts();
if (user.getFailedLoginAttempts() >= 5) {
userRepository.save(user); // Only saves when >= 5
}
// Database never updated for attempts 1-4!
Mistake 3: Assuming Changes Persistβ
// β WRONG
public void loginAttempt1() {
User user = userRepository.findByEmail(email);
user.increaseFailedLoginAttempts();
// Don't save
}
public void loginAttempt2() {
User user = userRepository.findByEmail(email);
// Assuming failedLoginAttempts = 1 from previous attempt
// β WRONG! It's 0 again because we never saved!
}
Best Practicesβ
β DO: Always Save After Modifyingβ
User user = userRepository.findByEmail(email);
user.increaseFailedLoginAttempts();
userRepository.save(user); // β
Always save
β DO: Save After Any State Changeβ
if (!passwordEncoder.matches(password, user.getPassword())) {
user.increaseFailedLoginAttempts();
if (user.getFailedLoginAttempts() >= 5) {
user.setAccountLocked(true);
}
userRepository.save(user); // β
Save ALL changes
}
β DO: Reset and Save on Successβ
if (passwordEncoder.matches(password, user.getPassword())) {
user.setFailedLoginAttempts(0);
user.setLastLoginAt(LocalDateTime.now());
userRepository.save(user); // β
Save successful login
}
β DON'T: Assume In-Memory Changes Persistβ
// β WRONG
User user = userRepository.findByEmail(email);
user.setFailedLoginAttempts(3);
// Assumption: "It's changed, so database should have it"
// Reality: Database still has old value!
Summaryβ
High School vs Business Worldβ
| Aspect | High School (In-Memory) | Business World (Database) |
|---|---|---|
| Object lifetime | Until program ends | Until method ends |
| Data persistence | Lost when program ends | Survives app restarts |
| Changes | Immediate and visible | Temporary unless saved |
| Multiple requests | N/A (single execution) | Each request gets fresh copy |
| Synchronization | Not needed | Must call save() |
| Source of truth | The object itself | The database |
Key Takeawaysβ
- In-memory changes are temporary - they only exist in RAM
- Database is the source of truth - each query loads fresh data from database
- Must explicitly save -
userRepository.save(user)to persist changes - Each request is independent - objects from different requests don't share memory
- App restart clears memory - only database data survives
The Mental Modelβ
High School:
Object in RAM β Change it β β
Done!
Business World:
Object in RAM β Change it β Save to DB β β
Done!
β
Must call save()!
Your Specific Problem: The Fixβ
// β BEFORE (broken)
user.increaseFailedLoginAttempts();
if (user.getFailedLoginAttempts() >= 5) {
userRepository.save(user); // Only saves at 5
}
// β
AFTER (fixed)
user.increaseFailedLoginAttempts();
if (user.getFailedLoginAttempts() >= 5) {
user.setAccountLocked(true);
}
userRepository.save(user); // ALWAYS save
Now you understand why database-backed objects need explicit save() calls - because memory and database are two separate worlds that don't automatically synchronize! π―
