Final Project
Implementing File-Based Persistence
আপনি একটি free preview lesson দেখছেন।
Lesson Overview
এখন পর্যন্ত Final Project in-memory repositories ব্যবহার করছে।
Example:
CourseRepository repository =
new InMemoryCourseRepository();
এবার আমরা একই repository contracts-এর file-based implementations তৈরি করব।
Goal:
Application restart হওয়ার পরও data থাকবে।
সবচেয়ে গুরুত্বপূর্ণ বিষয়:
CourseService
LearnerService
EnrollmentService
একটিও পরিবর্তন করতে হবে না।
আমরা implement করব:
StorageExceptionStoredDataCorruptionException- UTF-8
Properties - Safe paths
- Atomic file replacement
FileCourseRepositoryFileLearnerRepositoryFileEnrollmentRepository- Serialization
- Deserialization
restore()- Missing vs corrupted data handling
Storage Layout
আমাদের data directory:
data/
├── courses/
├── learners/
└── enrollments/
Example:
data/
├── courses/
│ └── java-oop.properties
├── learners/
│ └── 1.properties
└── enrollments/
└── 1001.properties
Why Properties?
Java standard library provides:
java.util.Properties
Simple structured file storage-এর জন্য এটি যথেষ্ট।
Example:
code=JAVA-OOP
title=Java and OOP Foundation
priceInPaisa=499000
status=PUBLISHED
এই project-এর goal database design নয়।
আমরা practice করছি:
File I/O
Serialization
Repository abstraction
Resource management
Failure handling
Storage Exceptions
File repositories raw:
IOException
application layer-এ leak করবে না।
StorageException.java
package io.liveklass.storage;
public class StorageException
extends RuntimeException {
public StorageException(
String message,
Throwable cause
) {
super(
message,
cause
);
}
}
StoredDataCorruptionException.java
package io.liveklass.storage;
public final class StoredDataCorruptionException
extends StorageException {
public StoredDataCorruptionException(
String message,
Throwable cause
) {
super(
message,
cause
);
}
}
Missing and Corrupted Are Different
Suppose:
java-oop.properties
does not exist।
Repository may return:
null
meaning:
Course not found
But if file exists and contains:
status=NOT-A-STATUS
that is not "not found"।
That is:
corrupted stored data
and should fail visibly।
Safe File Persistence Strategy
Every file repository should:
Normalize base directory
Create directory if missing
Use controlled filenames
Use UTF-8
Use try-with-resources
Write to temporary file
Replace target after successful write
Translate IOException
Validate deserialized data
Atomic Replacement
Instead of overwriting target directly:
Serialize
↓
Write temporary file
↓
Move temporary file over target
When supported:
StandardCopyOption.ATOMIC_MOVE
Fallback:
StandardCopyOption.REPLACE_EXISTING
Shared Move Helper
private void replaceFile(
Path temporary,
Path target
) throws IOException {
try {
Files.move(
temporary,
target,
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING
);
} catch (
AtomicMoveNotSupportedException exception
) {
Files.move(
temporary,
target,
StandardCopyOption.REPLACE_EXISTING
);
}
}
ATOMIC_MOVE সব filesystem-এ supported নয়।
1. File Course Repository
Course storage needs:
CourseCode
Title
Price
Status
Lessons
Example file:
code=JAVA-OOP
title=Java and OOP Foundation
priceInPaisa=499000
status=PUBLISHED
lesson.count=2
lesson.0.id=1
lesson.0.title=Introduction to Java
lesson.0.content=Java basics
lesson.1.id=2
lesson.1.title=Classes and Objects
lesson.1.content=OOP basics
FileCourseRepository.java
package io.liveklass.storage;
import io.liveklass.course.Course;
import io.liveklass.course.CourseCode;
import io.liveklass.course.CourseRepository;
import io.liveklass.course.CourseStatus;
import io.liveklass.course.Lesson;
import io.liveklass.course.LessonId;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Properties;
public final class FileCourseRepository
implements CourseRepository {
private final Path baseDirectory;
public FileCourseRepository(
Path baseDirectory
) {
if (baseDirectory == null) {
throw new IllegalArgumentException(
"Course storage directory is required."
);
}
this.baseDirectory =
baseDirectory.toAbsolutePath()
.normalize();
initializeDirectory();
}
@Override
public void save(
Course course
) {
if (course == null) {
throw new IllegalArgumentException(
"Course is required."
);
}
Path target =
pathFor(
course.getCode()
);
Path temporary =
createTemporaryFile();
try {
Properties properties =
serialize(
course
);
try (
var writer =
Files.newBufferedWriter(
temporary,
StandardCharsets.UTF_8
)
) {
properties.store(
writer,
null
);
}
replaceFile(
temporary,
target
);
} catch (
IOException exception
) {
deleteQuietly(
temporary
);
throw new StorageException(
"Failed to save course "
+ course.getCode()
+ ".",
exception
);
}
}
@Override
public Course findByCode(
CourseCode courseCode
) {
if (courseCode == null) {
throw new IllegalArgumentException(
"Course code is required."
);
}
try {
return readCourse(
pathFor(
courseCode
)
);
} catch (
NoSuchFileException exception
) {
return null;
} catch (
IOException exception
) {
throw new StorageException(
"Failed to read course "
+ courseCode
+ ".",
exception
);
}
}
@Override
public List<Course> findAll() {
try (
var paths =
Files.list(
baseDirectory
)
) {
return paths
.filter(
Files::isRegularFile
)
.filter(
this::isPropertiesFile
)
.sorted()
.map(
this::readUnchecked
)
.toList();
} catch (
IOException exception
) {
throw new StorageException(
"Failed to list courses.",
exception
);
}
}
private Properties serialize(
Course course
) {
Properties properties =
new Properties();
properties.setProperty(
"code",
course.getCode()
.value()
);
properties.setProperty(
"title",
course.getTitle()
);
properties.setProperty(
"priceInPaisa",
Long.toString(
course.getPriceInPaisa()
)
);
properties.setProperty(
"status",
course.getStatus()
.name()
);
List<Lesson> lessons =
course.getLessons();
properties.setProperty(
"lesson.count",
Integer.toString(
lessons.size()
)
);
for (
int index = 0;
index < lessons.size();
index++
) {
Lesson lesson =
lessons.get(
index
);
String prefix =
"lesson."
+ index
+ ".";
properties.setProperty(
prefix + "id",
Long.toString(
lesson.id()
.value()
)
);
properties.setProperty(
prefix + "title",
lesson.title()
);
properties.setProperty(
prefix + "content",
lesson.content()
);
}
return properties;
}
private Course readCourse(
Path path
) throws IOException {
Properties properties =
load(
path
);
try {
CourseCode code =
new CourseCode(
required(
properties,
"code"
)
);
String title =
required(
properties,
"title"
);
long price =
Long.parseLong(
required(
properties,
"priceInPaisa"
)
);
CourseStatus status =
CourseStatus.valueOf(
required(
properties,
"status"
)
);
int lessonCount =
Integer.parseInt(
required(
properties,
"lesson.count"
)
);
if (lessonCount < 0) {
throw new IllegalArgumentException(
"Lesson count cannot be negative."
);
}
List<Lesson> lessons =
new ArrayList<>();
for (
int index = 0;
index < lessonCount;
index++
) {
String prefix =
"lesson."
+ index
+ ".";
lessons.add(
new Lesson(
new LessonId(
Long.parseLong(
required(
properties,
prefix + "id"
)
)
),
required(
properties,
prefix + "title"
),
required(
properties,
prefix + "content"
)
)
);
}
return Course.restore(
code,
title,
price,
status,
lessons
);
} catch (
IllegalArgumentException exception
) {
throw new StoredDataCorruptionException(
"Invalid course data in "
+ path.getFileName()
+ ".",
exception
);
}
}
private Course readUnchecked(
Path path
) {
try {
return readCourse(
path
);
} catch (
IOException exception
) {
throw new StorageException(
"Failed to read course file "
+ path.getFileName()
+ ".",
exception
);
}
}
private Properties load(
Path path
) throws IOException {
Properties properties =
new Properties();
try (
var reader =
Files.newBufferedReader(
path,
StandardCharsets.UTF_8
)
) {
properties.load(
reader
);
}
return properties;
}
private String required(
Properties properties,
String key
) {
String value =
properties.getProperty(
key
);
if (
value == null
|| value.isBlank()
) {
throw new IllegalArgumentException(
"Missing property: "
+ key
+ "."
);
}
return value;
}
private Path pathFor(
CourseCode courseCode
) {
String fileName =
courseCode.value()
.toLowerCase(
Locale.ROOT
)
+ ".properties";
Path path =
baseDirectory.resolve(
fileName
)
.normalize();
if (
!path.startsWith(
baseDirectory
)
) {
throw new IllegalArgumentException(
"Invalid course storage path."
);
}
return path;
}
private Path createTemporaryFile() {
try {
return Files.createTempFile(
baseDirectory,
"course-",
".tmp"
);
} catch (
IOException exception
) {
throw new StorageException(
"Failed to create temporary course file.",
exception
);
}
}
private void replaceFile(
Path temporary,
Path target
) throws IOException {
try {
Files.move(
temporary,
target,
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING
);
} catch (
AtomicMoveNotSupportedException exception
) {
Files.move(
temporary,
target,
StandardCopyOption.REPLACE_EXISTING
);
}
}
private void initializeDirectory() {
try {
Files.createDirectories(
baseDirectory
);
} catch (
IOException exception
) {
throw new StorageException(
"Failed to initialize course storage.",
exception
);
}
}
private boolean isPropertiesFile(
Path path
) {
return path.getFileName()
.toString()
.endsWith(
".properties"
);
}
private void deleteQuietly(
Path path
) {
try {
Files.deleteIfExists(
path
);
} catch (
IOException ignored
) {
}
}
}
Why Course.restore()?
A stored course may already be:
PUBLISHED
ARCHIVED
Repository should reconstruct that state directly।
It should not replay:
course.publish();
course.archive();
during loading।
But restore must still enforce invariants।
Corrupted state must remain invalid।
2. File Learner Repository
Learner file:
id=1
name=Sakib
email=sakib@example.com
The complete logic is simpler because Learner has no nested collection।
Core Implementation
package io.liveklass.storage;
import io.liveklass.learner.EmailAddress;
import io.liveklass.learner.Learner;
import io.liveklass.learner.LearnerId;
import io.liveklass.learner.LearnerRepository;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.util.List;
import java.util.Properties;
public final class FileLearnerRepository
implements LearnerRepository {
private final Path baseDirectory;
public FileLearnerRepository(
Path baseDirectory
) {
if (baseDirectory == null) {
throw new IllegalArgumentException(
"Learner storage directory is required."
);
}
this.baseDirectory =
baseDirectory.toAbsolutePath()
.normalize();
try {
Files.createDirectories(
this.baseDirectory
);
} catch (
IOException exception
) {
throw new StorageException(
"Failed to initialize learner storage.",
exception
);
}
}
@Override
public void save(
Learner learner
) {
Properties properties =
new Properties();
properties.setProperty(
"id",
Long.toString(
learner.id()
.value()
)
);
properties.setProperty(
"name",
learner.name()
);
properties.setProperty(
"email",
learner.email()
.value()
);
Path target =
pathFor(
learner.id()
);
Path temporary;
try {
temporary =
Files.createTempFile(
baseDirectory,
"learner-",
".tmp"
);
try (
var writer =
Files.newBufferedWriter(
temporary,
StandardCharsets.UTF_8
)
) {
properties.store(
writer,
null
);
}
replaceFile(
temporary,
target
);
} catch (
IOException exception
) {
throw new StorageException(
"Failed to save learner "
+ learner.id()
+ ".",
exception
);
}
}
@Override
public Learner findById(
LearnerId learnerId
) {
try {
return read(
pathFor(
learnerId
)
);
} catch (
NoSuchFileException exception
) {
return null;
} catch (
IOException exception
) {
throw new StorageException(
"Failed to read learner "
+ learnerId
+ ".",
exception
);
}
}
@Override
public Learner findByEmail(
EmailAddress email
) {
return findAll()
.stream()
.filter(
learner ->
learner.email()
.equals(
email
)
)
.findFirst()
.orElse(
null
);
}
@Override
public List<Learner> findAll() {
try (
var paths =
Files.list(
baseDirectory
)
) {
return paths
.filter(
path ->
Files.isRegularFile(
path
)
&& path.getFileName()
.toString()
.endsWith(
".properties"
)
)
.sorted()
.map(
this::readUnchecked
)
.toList();
} catch (
IOException exception
) {
throw new StorageException(
"Failed to list learners.",
exception
);
}
}
private Learner read(
Path path
) throws IOException {
Properties properties =
new Properties();
try (
var reader =
Files.newBufferedReader(
path,
StandardCharsets.UTF_8
)
) {
properties.load(
reader
);
}
try {
return new Learner(
new LearnerId(
Long.parseLong(
required(
properties,
"id"
)
)
),
required(
properties,
"name"
),
new EmailAddress(
required(
properties,
"email"
)
)
);
} catch (
IllegalArgumentException exception
) {
throw new StoredDataCorruptionException(
"Invalid learner data in "
+ path.getFileName()
+ ".",
exception
);
}
}
private Learner readUnchecked(
Path path
) {
try {
return read(
path
);
} catch (
IOException exception
) {
throw new StorageException(
"Failed to read learner file.",
exception
);
}
}
private Path pathFor(
LearnerId learnerId
) {
return baseDirectory.resolve(
learnerId.value()
+ ".properties"
)
.normalize();
}
private String required(
Properties properties,
String key
) {
String value =
properties.getProperty(
key
);
if (
value == null
|| value.isBlank()
) {
throw new IllegalArgumentException(
"Missing property: "
+ key
+ "."
);
}
return value;
}
private void replaceFile(
Path temporary,
Path target
) throws IOException {
try {
Files.move(
temporary,
target,
java.nio.file.StandardCopyOption.ATOMIC_MOVE,
java.nio.file.StandardCopyOption.REPLACE_EXISTING
);
} catch (
java.nio.file.AtomicMoveNotSupportedException exception
) {
Files.move(
temporary,
target,
java.nio.file.StandardCopyOption.REPLACE_EXISTING
);
}
}
}
Email Lookup Cost
Current:
findByEmail()
loads and scans all learner files।
For this learning project, acceptable।
At scale, database index would be more appropriate।
The important thing is that application code still calls:
findByEmail(...)
regardless of implementation।
3. File Enrollment Repository
Enrollment file:
id=1001
learnerId=1
courseCode=JAVA-OOP
status=ACTIVE
Core deserialization:
return Enrollment.restore(
new EnrollmentId(
Long.parseLong(
required(
properties,
"id"
)
)
),
new LearnerId(
Long.parseLong(
required(
properties,
"learnerId"
)
)
),
new CourseCode(
required(
properties,
"courseCode"
)
),
EnrollmentStatus.valueOf(
required(
properties,
"status"
)
)
);
Serialization stores:
properties.setProperty(
"id",
Long.toString(
enrollment.getId()
.value()
)
);
properties.setProperty(
"learnerId",
Long.toString(
enrollment.getLearnerId()
.value()
)
);
properties.setProperty(
"courseCode",
enrollment.getCourseCode()
.value()
);
properties.setProperty(
"status",
enrollment.getStatus()
.name()
);
The rest follows the same pattern as learner storage:
Safe path
Temporary file
UTF-8 writer
Atomic replacement
Missing file → null
Invalid stored data → StoredDataCorruptionException
findAll() → list .properties files
existsByLearnerAndCourse()
For our small file repository:
@Override
public boolean existsByLearnerAndCourse(
LearnerId learnerId,
CourseCode courseCode
) {
return findAll()
.stream()
.anyMatch(
enrollment ->
enrollment.getLearnerId()
.equals(
learnerId
)
&& enrollment.getCourseCode()
.equals(
courseCode
)
);
}
This is simple but not highly scalable।
That is acceptable for this project।
Wiring File Repositories
Before:
CourseRepository courseRepository =
new InMemoryCourseRepository();
LearnerRepository learnerRepository =
new InMemoryLearnerRepository();
EnrollmentRepository enrollmentRepository =
new InMemoryEnrollmentRepository();
After:
Path dataDirectory =
Path.of(
"data"
);
CourseRepository courseRepository =
new FileCourseRepository(
dataDirectory.resolve(
"courses"
)
);
LearnerRepository learnerRepository =
new FileLearnerRepository(
dataDirectory.resolve(
"learners"
)
);
EnrollmentRepository enrollmentRepository =
new FileEnrollmentRepository(
dataDirectory.resolve(
"enrollments"
)
);
Services remain:
CourseService courseService =
new CourseService(
courseRepository
);
LearnerService learnerService =
new LearnerService(
learnerRepository
);
EnrollmentService enrollmentService =
new EnrollmentService(
courseRepository,
learnerRepository,
enrollmentRepository
);
No service changes।
Why This Is Important
CourseService depends on:
CourseRepository
not:
FileCourseRepository
Therefore storage mechanism can change independently।
This is the practical benefit of:
Dependency inversion through a useful boundary
without needing a framework।
Application Restart
First run:
Create course
Register learner
Create enrollment
Files are saved।
Application stops।
Second run:
courseService.findCourse(
new CourseCode(
"JAVA-OOP"
)
);
repository reads the stored file and reconstructs the domain object।
Corrupted Data Example
Suppose:
status=UNKNOWN
Then:
CourseStatus.valueOf(
"UNKNOWN"
);
fails।
Repository translates that failure into:
StoredDataCorruptionException
Correct behavior is not:
return null;
because the course file exists।
UTF-8
Always use:
StandardCharsets.UTF_8
Example:
Files.newBufferedWriter(
path,
StandardCharsets.UTF_8
);
This allows Bengali and other Unicode content to behave consistently across environments।
Resource Ownership
Repository opens:
Reader
Writer
Files.list() stream
Therefore repository closes them।
Use:
try (
var reader =
Files.newBufferedReader(...)
) {
}
This protects both success and failure paths।
File Persistence Limitations
Our implementation is suitable for learning and small local applications।
It does not provide:
Database transactions
Indexed queries
Concurrent write protection
Multi-process locking
Optimistic versioning
Atomic Save Does Not Solve Lost Updates
Imagine:
Process A reads Course v1
Process B reads Course v1
A changes and saves v2
B changes and saves its own v2
B may overwrite A।
Atomic replacement only helps ensure:
A single file replacement is not partially visible.
It does not solve application-level concurrency।
Do We Need an Abstract Base Repository?
We repeated helpers such as:
required()
replaceFile()
load properties
We could extract them later।
But do not immediately create:
AbstractFileRepository
just to remove a few repeated lines।
A shared abstraction should represent a real concept, not only reduce line count।
Practice Exercises
Exercise 1
Course file contains:
priceInPaisa=hello
What should happen?
Answer
Parsing fails and repository should surface:
StoredDataCorruptionException
Exercise 2
Requested learner file does not exist।
Answer
Repository returns:
null
and service can convert that to:
LearnerNotFoundException
Exercise 3
Why not return null for every storage error?
Answer
Because these are different:
Not found
Permission failure
Disk failure
Corrupted data
Hiding them behind null destroys useful failure information।
Exercise 4
Why create temp files inside the same base directory?
Answer
It increases the chance that temp and target are on the same filesystem, which helps atomic move support।
Exercise 5
Why does Course.restore() still validate?
Answer
Stored data is external input and can be corrupted।
Persistence must not create invalid domain objects।
Knowledge Check
Question 1
Why do file repositories implement existing repository interfaces?
Question 2
Why translate IOException?
Question 3
What is the difference between missing data and corrupted data?
Question 4
Why use explicit UTF-8?
Question 5
Why use try-with-resources?
Question 6
Why write to a temporary file before replacement?
Question 7
Is ATOMIC_MOVE always supported?
Question 8
Why use Course.restore() and Enrollment.restore()?
Question 9
Does atomic move solve concurrent lost updates?
Question 10
Why can services remain unchanged when storage changes?
Knowledge Check Answers
Answer 1
They provide the same persistence capabilities while changing only the underlying storage mechanism।
Answer 2
So low-level file-system details do not leak into application and domain code।
Answer 3
Missing means no stored entity exists; corrupted means stored data exists but cannot represent a valid entity।
Answer 4
To ensure deterministic text encoding across operating systems and environments।
Answer 5
To reliably close resources on both successful and failing execution paths।
Answer 6
To reduce the chance of leaving the target partially written if serialization fails।
Answer 7
No. A fallback move is required।
Answer 8
To reconstruct persisted lifecycle state without replaying business operations।
Answer 9
No. It only protects individual file replacement।
Answer 10
They depend on repository contracts rather than concrete in-memory or file implementations।
Lesson Summary
এই lesson-এ আমরা file-based persistence introduce করেছি।
Main concepts:
StorageExceptionlow-level I/O failures translate করেStoredDataCorruptionExceptioninvalid stored data represent করে.propertiessimple explicit serialization provide করে- UTF-8 explicitly use করা উচিত
- Safe paths repository boundary protect করে
- Try-with-resources file resources reliably close করে
Course.restore()এবংEnrollment.restore()stored lifecycle reconstruct করে- Restore validation bypass করে না
- Missing file এবং corrupted file আলাদা failure
- Temporary file + move partial overwrite risk reduce করে
ATOMIC_MOVEuseful but not guaranteed- File persistence indexed database-এর replacement নয়
- Atomic replacement concurrency control নয়
- Most importantly:
Application services remain unchanged
when repository implementation changes.
That proves our repository abstraction is useful.
Next Lesson
পরবর্তী lesson:
Building the Console Application
আমরা implement করব:
- Interactive menu
Scanner- Input parsing
- Course operations
- Learner operations
- Enrollment operations
- User-friendly error handling
- Console boundary
- Complete dependency wiring in
Main