File Handling and I/O

Designing a File-Based Repository

ReadingPreview

You are viewing a free preview lesson.

Lesson Overview

এখন পর্যন্ত আমরা file system-এর individual operations শিখেছি:

Path তৈরি
File read করা
File write করা
Streams ব্যবহার
Resources close করা
Copy, move, delete করা

এবার এগুলোকে application design-এর সঙ্গে connect করব।

ধরুন LiveKlass application-এর একটি Course save করতে হবে।

Business code ideally এমন হওয়া উচিত:

repository.save(
        course
);

Business service-এর জানা উচিত নয়:

File কোথায় আছে
File name কী
UTF-8 কীভাবে ব্যবহার হচ্ছে
Properties কীভাবে parse হচ্ছে
Temporary file কীভাবে replace হচ্ছে

এই implementation details repository-এর responsibility।

এই lesson-এ আমরা শিখব:

  • Repository abstraction
  • File-based repository
  • Domain এবং storage responsibility
  • File naming strategy
  • Serialization এবং deserialization
  • Save
  • Find
  • List
  • Delete
  • Missing entity handling
  • Corrupted file handling
  • Exception translation
  • Atomic replacement
  • File storage-এর limitations
  • কখন file repository useful এবং কখন database প্রয়োজন

Learning Objectives

এই lesson শেষে আপনি পারবেন:

  • Repository pattern-এর purpose explain করতে
  • File storage details business logic থেকে hide করতে
  • Domain objectকে text format-এ serialize করতে
  • Stored data থেকে domain object reconstruct করতে
  • Missing এবং corrupted data distinguish করতে
  • Repository-specific exception design করতে
  • Atomic replacement ব্যবহার করতে
  • File repository-এর concurrency limitations explain করতে

What Is a Repository?

Repository application codeকে storage implementation থেকে isolate করে।

Business layer দেখে:

CourseRepository

Implementation হতে পারে:

FileCourseRepository
PostgresCourseRepository
InMemoryCourseRepository

Service-এর perspective থেকে contract একই থাকতে পারে।

Example:

public interface CourseRepository {

    void save(
            Course course
    );

    Course findByCode(
            CourseCode courseCode
    );

    List<Course> findAll();

    boolean deleteByCode(
            CourseCode courseCode
    );
}

Why Use a Repository?

Without repository:

public void publishCourse(
        CourseCode code
) throws IOException {
    Path path =
            Path.of(
                    "courses",
                    code + ".properties"
            );

    Properties properties =
            new Properties();

    // Read file
    // Parse values
    // Change status
    // Write file
}

Business logic এখন file-system details-এর সঙ্গে tightly coupled।

Better:

Course course =
        repository.findByCode(
                code
        );

course.publish();

repository.save(
        course
);

The service focuses on:

Business behavior

Repository focuses on:

Persistence

Repository Does Not Mean Database

Repository একটি abstraction।

Storage হতে পারে:

Memory
File
SQL database
NoSQL database
Remote service

আজ আমরা implement করব:

FileCourseRepository

Domain Model

আমাদের simplified Course persist করবে:

code
title
priceInPaisa
status

CourseStatus.java

package io.liveklass.course;

public enum CourseStatus {

    DRAFT,
    REVIEW,
    PUBLISHED,
    ARCHIVED;

    public boolean canTransitionTo(
            CourseStatus target
    ) {
        if (target == null) {
            return false;
        }

        return switch (this) {
            case DRAFT ->
                    target == REVIEW;

            case REVIEW ->
                    target == DRAFT
                    || target == PUBLISHED;

            case PUBLISHED ->
                    target == ARCHIVED;

            case ARCHIVED ->
                    false;
        };
    }
}

CourseCode.java

package io.liveklass.course;

import java.util.Objects;

public final class CourseCode {

    private final String value;

    public CourseCode(
            String value
    ) {
        if (
                value == null
                || value.isBlank()
        ) {
            throw new IllegalArgumentException(
                    "Course code is required."
            );
        }

        String normalized =
                value.strip()
                        .toUpperCase();

        if (
                !normalized.matches(
                        "[A-Z0-9-]+"
                )
        ) {
            throw new IllegalArgumentException(
                    "Course code contains unsupported characters."
            );
        }

        this.value =
                normalized;
    }

    public String getValue() {
        return value;
    }

    @Override
    public boolean equals(
            Object other
    ) {
        if (this == other) {
            return true;
        }

        if (
                !(other
                instanceof CourseCode courseCode)
        ) {
            return false;
        }

        return value.equals(
                courseCode.value
        );
    }

    @Override
    public int hashCode() {
        return Objects.hash(
                value
        );
    }

    @Override
    public String toString() {
        return value;
    }
}

Restoring Persisted Domain State

Normally a new course starts as:

DRAFT

But repository may load:

PUBLISHED

We should not replay:

DRAFT → REVIEW → PUBLISHED

just to restore persisted state।

Instead provide a controlled restoration path।


Course.java

package io.liveklass.course;

public final class Course {

    private final CourseCode code;
    private final String title;
    private final long priceInPaisa;

    private CourseStatus status;

    public Course(
            CourseCode code,
            String title,
            long priceInPaisa
    ) {
        this(
                code,
                title,
                priceInPaisa,
                CourseStatus.DRAFT
        );
    }

    private Course(
            CourseCode code,
            String title,
            long priceInPaisa,
            CourseStatus status
    ) {
        if (code == null) {
            throw new IllegalArgumentException(
                    "Course code is required."
            );
        }

        if (
                title == null
                || title.isBlank()
        ) {
            throw new IllegalArgumentException(
                    "Course title is required."
            );
        }

        if (priceInPaisa < 0) {
            throw new IllegalArgumentException(
                    "Course price cannot be negative."
            );
        }

        if (status == null) {
            throw new IllegalArgumentException(
                    "Course status is required."
            );
        }

        this.code = code;
        this.title = title.strip();
        this.priceInPaisa = priceInPaisa;
        this.status = status;
    }

    public static Course restore(
            CourseCode code,
            String title,
            long priceInPaisa,
            CourseStatus status
    ) {
        return new Course(
                code,
                title,
                priceInPaisa,
                status
        );
    }

    public void changeStatus(
            CourseStatus targetStatus
    ) {
        if (
                !status.canTransitionTo(
                        targetStatus
                )
        ) {
            throw new IllegalStateException(
                    "Invalid course status transition from "
                    + status
                    + " to "
                    + targetStatus
                    + "."
            );
        }

        status =
                targetStatus;
    }

    public CourseCode getCode() {
        return code;
    }

    public String getTitle() {
        return title;
    }

    public long getPriceInPaisa() {
        return priceInPaisa;
    }

    public CourseStatus getStatus() {
        return status;
    }

    @Override
    public String toString() {
        return code
                + " — "
                + title
                + " — "
                + status;
    }
}

Why Use restore()?

A repository is reconstructing an existing object।

It is not performing a new business transition।

This:

Course.restore(...)

communicates that intent explicitly।

But notice:

Constructor invariants are still enforced

The repository cannot restore:

Negative price
Blank title
Null status

as a valid domain object।


Repository Contract

CourseRepository.java

package io.liveklass.repository;

import io.liveklass.course.Course;
import io.liveklass.course.CourseCode;

import java.util.List;

public interface CourseRepository {

    void save(
            Course course
    );

    Course findByCode(
            CourseCode courseCode
    );

    List<Course> findAll();

    boolean deleteByCode(
            CourseCode courseCode
    );
}

Missing Course Contract

Our contract says:

findByCode(...)

returns:

Course → when found
null   → when not found

This is a deliberate design।

A higher-level method may convert absence into:

CourseNotFoundException

Example:

Course course =
        repository.findByCode(
                courseCode
        );

if (course == null) {
    throw new CourseNotFoundException(
            courseCode
    );
}

Missing data and storage failure remain different।


Storage Format

We will use Java:

Properties

Each course file might look like:

code=JAVA-OOP
title=Java and OOP Foundation
priceInPaisa=499000
status=PUBLISHED

This is simple enough for learning file persistence।


Why Not JSON Yet?

JSON is common in real systems, but Java does not provide a rich general-purpose JSON mapper in the standard library।

Using JSON would require an external library such as:

Jackson
Gson

That would distract from the I/O concepts of this module।

Later, a real application may use JSON, database rows, or another serialization format।


File Naming Strategy

Course:

JAVA-OOP

can map to:

java-oop.properties

Storage:

data/
└── courses/
    ├── java-oop.properties
    ├── backend.properties
    └── system-design.properties

File Names Should Come from Controlled Values

Do not use arbitrary course title:

Java / Backend: Complete Course!

directly as a file name।

Use a controlled identifier such as:

CourseCode

Because it already allows only:

A-Z
0-9
-

This makes path construction safer।


Repository Exception

CourseRepositoryException.java

package io.liveklass.repository;

public final class CourseRepositoryException
        extends RuntimeException {

    public CourseRepositoryException(
            String message
    ) {
        super(
                message
        );
    }

    public CourseRepositoryException(
            String message,
            Throwable cause
    ) {
        super(
                message,
                cause
        );
    }
}

Repository clients do not need to understand raw:

IOException
NumberFormatException
IllegalArgumentException from malformed storage

The repository translates storage failures into its abstraction।


File Repository Fields

public final class FileCourseRepository
        implements CourseRepository {

    private final Path baseDirectory;
}

Constructor:

public FileCourseRepository(
        Path baseDirectory
) {
    if (baseDirectory == null) {
        throw new IllegalArgumentException(
                "Base directory is required."
        );
    }

    this.baseDirectory =
            baseDirectory
                    .toAbsolutePath()
                    .normalize();
}

Resolve Course File

private Path resolveFile(
        CourseCode courseCode
) {
    if (courseCode == null) {
        throw new IllegalArgumentException(
                "Course code is required."
        );
    }

    String fileName =
            courseCode.getValue()
                    .toLowerCase()
                    + ".properties";

    Path target =
            baseDirectory.resolve(
                    fileName
            )
            .normalize();

    if (
            !target.startsWith(
                    baseDirectory
            )
    ) {
        throw new IllegalArgumentException(
                "Course path escapes repository directory."
        );
    }

    return target;
}

Because CourseCode is already controlled, traversal risk is low, but repository still protects its storage boundary।


Serialization

Serialization means:

Convert object state
→ storable representation

For our repository:

Course

becomes:

Properties

Convert Course to Properties

private Properties serialize(
        Course course
) {
    Properties properties =
            new Properties();

    properties.setProperty(
            "code",
            course.getCode()
                    .getValue()
    );

    properties.setProperty(
            "title",
            course.getTitle()
    );

    properties.setProperty(
            "priceInPaisa",
            Long.toString(
                    course.getPriceInPaisa()
            )
    );

    properties.setProperty(
            "status",
            course.getStatus()
                    .name()
    );

    return properties;
}

Why Persist Enum name()?

We persist:

course.getStatus()
        .name()

Example:

PUBLISHED

Do not persist:

ordinal()

because enum ordering can change।


Deserialization

Deserialization means:

Stored representation
→ domain object

For example:

code=JAVA-OOP
title=Java Foundation
priceInPaisa=499000
status=DRAFT

becomes:

Course

Required Property Helper

private String requireProperty(
        Properties properties,
        String key
) {
    String value =
            properties.getProperty(
                    key
            );

    if (
            value == null
            || value.isBlank()
    ) {
        throw new CourseRepositoryException(
                "Stored course is missing property: "
                + key
                + "."
        );
    }

    return value;
}

Deserialize Course

private Course deserialize(
        Properties properties,
        Path source
) {
    try {
        CourseCode courseCode =
                new CourseCode(
                        requireProperty(
                                properties,
                                "code"
                        )
                );

        String title =
                requireProperty(
                        properties,
                        "title"
                );

        long priceInPaisa =
                Long.parseLong(
                        requireProperty(
                                properties,
                                "priceInPaisa"
                        )
                );

        CourseStatus status =
                CourseStatus.valueOf(
                        requireProperty(
                                properties,
                                "status"
                        )
                );

        return Course.restore(
                courseCode,
                title,
                priceInPaisa,
                status
        );
    } catch (
        CourseRepositoryException exception
    ) {
        throw exception;
    } catch (
        RuntimeException exception
    ) {
        throw new CourseRepositoryException(
                "Stored course data is invalid: "
                + source.getFileName()
                + ".",
                exception
        );
    }
}

Corrupted Storage Is Not Not-Found

Suppose file exists:

code=JAVA-OOP
priceInPaisa=hello
status=DRAFT

Parsing fails।

This is not:

Course not found

It is:

Repository data corrupted or invalid

Correct abstraction:

CourseRepositoryException

Loading a Properties File

private Properties loadProperties(
        Path path
) throws IOException {
    Properties properties =
            new Properties();

    try (
            BufferedReader reader =
                    Files.newBufferedReader(
                            path,
                            StandardCharsets.UTF_8
                    )
    ) {
        properties.load(
                reader
        );
    }

    return properties;
}

Resource ownership is clear:

Repository opens reader
Repository closes reader

Saving Properties

private void storeProperties(
        Path path,
        Properties properties
) throws IOException {
    try (
            BufferedWriter writer =
                    Files.newBufferedWriter(
                            path,
                            StandardCharsets.UTF_8
                    )
    ) {
        properties.store(
                writer,
                null
        );
    }
}

Direct Save Problem

We could simply:

storeProperties(
        target,
        properties
);

But overwriting an important file directly is less robust।

Instead:

Write temp file
↓
Move temp file over target

Atomic Replacement Strategy

private void replaceFile(
        Path target,
        Properties properties
) throws IOException {
    Files.createDirectories(
            baseDirectory
    );

    Path temp =
            Files.createTempFile(
                    baseDirectory,
                    "course-",
                    ".tmp"
            );

    try {
        storeProperties(
                temp,
                properties
        );

        try {
            Files.move(
                    temp,
                    target,
                    StandardCopyOption.REPLACE_EXISTING,
                    StandardCopyOption.ATOMIC_MOVE
            );
        } catch (
            AtomicMoveNotSupportedException exception
        ) {
            Files.move(
                    temp,
                    target,
                    StandardCopyOption.REPLACE_EXISTING
            );
        }
    } finally {
        Files.deleteIfExists(
                temp
        );
    }
}

Why Use a Temp File?

Direct overwrite can expose incomplete content if something goes badly during the write।

Temp strategy means:

Existing file remains untouched
until new file has been written

Then replacement happens afterward।


Why Same Directory?

Temp file is created under:

baseDirectory

This increases the chance source and target are on the same file system, which is important for atomic move support।


Implementing save()

@Override
public void save(
        Course course
) {
    if (course == null) {
        throw new IllegalArgumentException(
                "Course is required."
        );
    }

    Path target =
            resolveFile(
                    course.getCode()
            );

    Properties properties =
            serialize(
                    course
            );

    try {
        replaceFile(
                target,
                properties
        );
    } catch (
        IOException exception
    ) {
        throw new CourseRepositoryException(
                "Could not save course "
                + course.getCode()
                + ".",
                exception
        );
    }
}

Save Semantics

Our:

save(...)

means:

Create if missing
Replace if existing

This is effectively repository-level:

upsert

The interface should document this behavior।


Implementing findByCode()

@Override
public Course findByCode(
        CourseCode courseCode
) {
    Path path =
            resolveFile(
                    courseCode
            );

    try {
        Properties properties =
                loadProperties(
                        path
                );

        return deserialize(
                properties,
                path
        );
    } catch (
        NoSuchFileException exception
    ) {
        return null;
    } catch (
        IOException exception
    ) {
        throw new CourseRepositoryException(
                "Could not load course "
                + courseCode
                + ".",
                exception
        );
    }
}

Why Catch NoSuchFileException Separately?

Because our repository contract defines:

Missing file → Course absent → null

Other I/O failures mean:

Repository failed

Do not collapse them together।


Do We Need Files.exists() First?

We could write:

if (
        Files.notExists(
                path
        )
) {
    return null;
}

then read।

But this adds a check-then-read race।

Simpler:

Try reading
Catch NoSuchFileException

The actual operation remains authoritative।


Implementing deleteByCode()

@Override
public boolean deleteByCode(
        CourseCode courseCode
) {
    Path path =
            resolveFile(
                    courseCode
            );

    try {
        return Files.deleteIfExists(
                path
        );
    } catch (
        IOException exception
    ) {
        throw new CourseRepositoryException(
                "Could not delete course "
                + courseCode
                + ".",
                exception
        );
    }
}

Semantics:

true  → Existing course file deleted
false → Course file did not exist

Listing Stored Courses

Use:

Files.list(
        baseDirectory
)

Filter:

Regular files
.properties extension

Then load each course।


Implementing findAll()

@Override
public List<Course> findAll() {
    try {
        Files.createDirectories(
                baseDirectory
        );
    } catch (
        IOException exception
    ) {
        throw new CourseRepositoryException(
                "Could not initialize course repository.",
                exception
        );
    }

    try (
            Stream<Path> paths =
                    Files.list(
                            baseDirectory
                    )
    ) {
        return paths.filter(
                Files::isRegularFile
        )
        .filter(
                this::isCourseFile
        )
        .map(
                this::loadCourseFile
        )
        .toList();
    } catch (
        IOException exception
    ) {
        throw new CourseRepositoryException(
                "Could not list stored courses.",
                exception
        );
    }
}

Course File Filter

private boolean isCourseFile(
        Path path
) {
    return path.getFileName()
            .toString()
            .endsWith(
                    ".properties"
            );
}

Load One Listed File

private Course loadCourseFile(
        Path path
) {
    try {
        Properties properties =
                loadProperties(
                        path
                );

        return deserialize(
                properties,
                path
        );
    } catch (
        IOException exception
    ) {
        throw new CourseRepositoryException(
                "Could not load stored course "
                + path.getFileName()
                + ".",
                exception
        );
    }
}

Complete FileCourseRepository

package io.liveklass.repository;

import io.liveklass.course.Course;
import io.liveklass.course.CourseCode;
import io.liveklass.course.CourseStatus;

import java.io.BufferedReader;
import java.io.BufferedWriter;
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.List;
import java.util.Properties;
import java.util.stream.Stream;

public final class FileCourseRepository
        implements CourseRepository {

    private final Path baseDirectory;

    public FileCourseRepository(
            Path baseDirectory
    ) {
        if (baseDirectory == null) {
            throw new IllegalArgumentException(
                    "Base directory is required."
            );
        }

        this.baseDirectory =
                baseDirectory
                        .toAbsolutePath()
                        .normalize();
    }

    @Override
    public void save(
            Course course
    ) {
        if (course == null) {
            throw new IllegalArgumentException(
                    "Course is required."
            );
        }

        Path target =
                resolveFile(
                        course.getCode()
                );

        try {
            Files.createDirectories(
                    baseDirectory
            );

            replaceFile(
                    target,
                    serialize(
                            course
                    )
            );
        } catch (
            IOException exception
        ) {
            throw new CourseRepositoryException(
                    "Could not save course "
                    + course.getCode()
                    + ".",
                    exception
            );
        }
    }

    @Override
    public Course findByCode(
            CourseCode courseCode
    ) {
        Path path =
                resolveFile(
                        courseCode
                );

        try {
            return deserialize(
                    loadProperties(
                            path
                    ),
                    path
            );
        } catch (
            NoSuchFileException exception
        ) {
            return null;
        } catch (
            IOException exception
        ) {
            throw new CourseRepositoryException(
                    "Could not load course "
                    + courseCode
                    + ".",
                    exception
            );
        }
    }

    @Override
    public List<Course> findAll() {
        try {
            Files.createDirectories(
                    baseDirectory
            );
        } catch (
            IOException exception
        ) {
            throw new CourseRepositoryException(
                    "Could not initialize course repository.",
                    exception
            );
        }

        try (
                Stream<Path> paths =
                        Files.list(
                                baseDirectory
                        )
        ) {
            return paths.filter(
                    Files::isRegularFile
            )
            .filter(
                    this::isCourseFile
            )
            .map(
                    this::loadCourseFile
            )
            .toList();
        } catch (
            IOException exception
        ) {
            throw new CourseRepositoryException(
                    "Could not list courses.",
                    exception
            );
        }
    }

    @Override
    public boolean deleteByCode(
            CourseCode courseCode
    ) {
        Path path =
                resolveFile(
                        courseCode
                );

        try {
            return Files.deleteIfExists(
                    path
            );
        } catch (
            IOException exception
        ) {
            throw new CourseRepositoryException(
                    "Could not delete course "
                    + courseCode
                    + ".",
                    exception
            );
        }
    }

    private Path resolveFile(
            CourseCode courseCode
    ) {
        if (courseCode == null) {
            throw new IllegalArgumentException(
                    "Course code is required."
            );
        }

        String fileName =
                courseCode.getValue()
                        .toLowerCase()
                        + ".properties";

        Path target =
                baseDirectory.resolve(
                        fileName
                )
                .normalize();

        if (
                !target.startsWith(
                        baseDirectory
                )
        ) {
            throw new IllegalArgumentException(
                    "Course path escapes repository directory."
            );
        }

        return target;
    }

    private Properties serialize(
            Course course
    ) {
        Properties properties =
                new Properties();

        properties.setProperty(
                "code",
                course.getCode()
                        .getValue()
        );

        properties.setProperty(
                "title",
                course.getTitle()
        );

        properties.setProperty(
                "priceInPaisa",
                Long.toString(
                        course.getPriceInPaisa()
                )
        );

        properties.setProperty(
                "status",
                course.getStatus()
                        .name()
        );

        return properties;
    }

    private Course deserialize(
            Properties properties,
            Path source
    ) {
        try {
            CourseCode code =
                    new CourseCode(
                            requireProperty(
                                    properties,
                                    "code"
                            )
                    );

            String title =
                    requireProperty(
                            properties,
                            "title"
                    );

            long priceInPaisa =
                    Long.parseLong(
                            requireProperty(
                                    properties,
                                    "priceInPaisa"
                            )
                    );

            CourseStatus status =
                    CourseStatus.valueOf(
                            requireProperty(
                                    properties,
                                    "status"
                            )
                    );

            return Course.restore(
                    code,
                    title,
                    priceInPaisa,
                    status
            );
        } catch (
            CourseRepositoryException exception
        ) {
            throw exception;
        } catch (
            RuntimeException exception
        ) {
            throw new CourseRepositoryException(
                    "Stored course data is invalid: "
                    + source.getFileName()
                    + ".",
                    exception
            );
        }
    }

    private String requireProperty(
            Properties properties,
            String key
    ) {
        String value =
                properties.getProperty(
                        key
                );

        if (
                value == null
                || value.isBlank()
        ) {
            throw new CourseRepositoryException(
                    "Stored course is missing property: "
                    + key
                    + "."
            );
        }

        return value;
    }

    private Properties loadProperties(
            Path path
    ) throws IOException {
        Properties properties =
                new Properties();

        try (
                BufferedReader reader =
                        Files.newBufferedReader(
                                path,
                                StandardCharsets.UTF_8
                        )
        ) {
            properties.load(
                    reader
            );
        }

        return properties;
    }

    private void storeProperties(
            Path path,
            Properties properties
    ) throws IOException {
        try (
                BufferedWriter writer =
                        Files.newBufferedWriter(
                                path,
                                StandardCharsets.UTF_8
                        )
        ) {
            properties.store(
                    writer,
                    null
            );
        }
    }

    private void replaceFile(
            Path target,
            Properties properties
    ) throws IOException {
        Path temp =
                Files.createTempFile(
                        baseDirectory,
                        "course-",
                        ".tmp"
                );

        try {
            storeProperties(
                    temp,
                    properties
            );

            try {
                Files.move(
                        temp,
                        target,
                        StandardCopyOption.REPLACE_EXISTING,
                        StandardCopyOption.ATOMIC_MOVE
                );
            } catch (
                AtomicMoveNotSupportedException exception
            ) {
                Files.move(
                        temp,
                        target,
                        StandardCopyOption.REPLACE_EXISTING
                );
            }
        } finally {
            Files.deleteIfExists(
                    temp
            );
        }
    }

    private boolean isCourseFile(
            Path path
    ) {
        return path.getFileName()
                .toString()
                .endsWith(
                        ".properties"
                );
    }

    private Course loadCourseFile(
            Path path
    ) {
        try {
            return deserialize(
                    loadProperties(
                            path
                    ),
                    path
            );
        } catch (
            IOException exception
        ) {
            throw new CourseRepositoryException(
                    "Could not load stored course "
                    + path.getFileName()
                    + ".",
                    exception
            );
        }
    }
}

Using the Repository

package io.liveklass;

import io.liveklass.course.Course;
import io.liveklass.course.CourseCode;
import io.liveklass.course.CourseStatus;
import io.liveklass.repository.CourseRepository;
import io.liveklass.repository.FileCourseRepository;

import java.nio.file.Path;

public class Main {

    public static void main(
            String[] args
    ) {
        CourseRepository repository =
                new FileCourseRepository(
                        Path.of(
                                "data",
                                "courses"
                        )
                );

        Course course =
                new Course(
                        new CourseCode(
                                "JAVA-OOP"
                        ),
                        "Java and OOP Foundation",
                        499_000L
                );

        course.changeStatus(
                CourseStatus.REVIEW
        );

        repository.save(
                course
        );

        Course loaded =
                repository.findByCode(
                        new CourseCode(
                                "java-oop"
                        )
                );

        System.out.println(
                loaded
        );

        repository.findAll()
                .forEach(
                        System.out::println
                );
    }
}

Expected Stored File

Conceptually:

code=JAVA-OOP
title=Java and OOP Foundation
priceInPaisa=499000
status=REVIEW

Properties.store() may also write formatting or comment metadata depending on the JDK implementation।

Your application should rely on the properties, not exact visual formatting।


Repository Responsibility

The repository should own:

  • File location
  • File naming
  • Encoding
  • Serialization
  • Deserialization
  • Resource lifecycle
  • Atomic replacement
  • I/O exception translation

It should not own:

  • Whether learner can access a course
  • Whether instructor may publish
  • Course pricing business policy
  • Authentication
  • Authorization

Those belong to other layers।


Missing vs Broken Storage

These must remain different।

Missing

java-oop.properties does not exist

Result:

null

according to our repository contract।

Broken

priceInPaisa=banana

Result:

CourseRepositoryException

Never turn corrupted storage into:

Course not found

File Storage and Concurrency

Our repository has a serious limitation:

Multiple writers

Suppose:

Process A loads course
Process B loads same course
A changes title and saves
B changes status and saves

B may overwrite A's update।

This is a:

Lost update

Atomic File Replacement Does Not Solve Lost Updates

Atomic move helps with:

Partial file replacement

It does not solve:

Two clients updating stale versions

These are different problems।


Possible Version Field

You could persist:

version=7

Then update only if expected version matches।

Conceptually:

Loaded version = 7
Current stored version = 7
→ Save version 8

If current stored version is already:

8

then reject the stale update।

But implementing correct cross-process locking and compare-and-set semantics with plain files becomes increasingly complex।

This is where databases become much more attractive।


File Repository Limitations

A file repository usually lacks convenient support for:

  • Concurrent updates
  • Transactions
  • Complex queries
  • Indexes
  • Relationships
  • Unique constraints across multiple entities
  • Row-level locking
  • Efficient pagination
  • High write throughput
  • Multi-node coordination

When File Storage Is Appropriate

Good use cases:

Learning projects
Local developer tools
Static course content
Configuration
Small single-user applications
Exports
Imports
Caches
Simple prototypes

When a Database Is Better

Prefer a database when you need:

Many concurrent users
Frequent updates
Transactions
Relationships
Search and filtering
Pagination
Uniqueness guarantees
Large datasets
Multiple application instances
Reliable concurrent writes

For a real multi-user course platform, primary course metadata would normally belong in a database rather than one file per course।


Files Can Still Be Useful Beside a Database

Database:

Course metadata
Enrollment
Payments
User relationships

Object/file storage:

Lesson Markdown
Images
Videos
PDF attachments
Exports

Real systems often combine several storage technologies।


Repository Abstraction Makes Migration Easier

Today:

CourseRepository repository =
        new FileCourseRepository(
                path
        );

Later:

CourseRepository repository =
        new PostgresCourseRepository(
                dataSource
        );

Application service can continue using:

repository.save(...)
repository.findByCode(...)

provided the repository contract remains appropriate।


Do Not Abstract Everything Prematurely

Repository abstraction is useful when there is a meaningful persistence boundary।

Do not create interfaces for every tiny file operation such as:

FileNameProviderFactoryStrategy
PathResolverManager
FileReaderRepositoryAdapterFactory

Keep the design proportional to the problem।


Common Mistakes

Letting Services Build File Paths

Leaks storage implementation into business logic।


Using Course Titles as File Names

Titles may contain unsafe or unstable characters।


Persisting Enum ordinal()

Enum ordering changes can corrupt meaning।


Returning null for Every Repository Failure

Makes not-found indistinguishable from broken storage।


Exposing Raw IOException Everywhere

Leaks storage details beyond the repository abstraction।


Ignoring Corrupted Files

Bad stored data should produce a visible repository failure।


Writing Directly to Important Files Without Considering Partial Replacement

Temp file + move provides safer update semantics।


Assuming Atomic Move Solves Concurrent Updates

It does not prevent lost updates।


Treating File Storage as a Database Replacement

Files become difficult when querying, transactions, and concurrency grow।


Putting Business Rules Inside the Repository

Repository should persist state, not decide business policy।


Practice Exercises

Exercise 1: Create Repository Interface

Create:

LessonRepository

with:

save(...)
findById(...)
findAll()
deleteById(...)

Do not expose Path in the interface।


Exercise 2: Serialization

Convert:

Course

to:

Properties

including:

code
title
priceInPaisa
status

Persist enum using:

name()

Exercise 3: Missing vs Failure

Implement:

findByCode(...)

so that:

Missing file → null
Permission denied → CourseRepositoryException
Invalid stored price → CourseRepositoryException

Exercise 4: Atomic Save

Implement:

Write temporary file
Move with REPLACE_EXISTING + ATOMIC_MOVE
Fall back when atomic move is unsupported

Exercise 5: List Courses

Use:

Files.list()

and load only:

*.properties

files।


Exercise 6: Corrupted File

Create:

code=JAVA
title=Java
priceInPaisa=hello
status=DRAFT

Predict and verify the repository failure।


Exercise 7: Concurrency Analysis

Explain why atomic file replacement does not prevent this:

A loads version 1
B loads version 1
A saves changes
B saves different changes

What update is potentially lost?


Predict the Result

Question 1

Stored status:

status=PUBLISHED

Repository calls:

CourseStatus.valueOf(
        "PUBLISHED"
);

What is returned?

Answer

CourseStatus.PUBLISHED

Question 2

Stored status:

status=published

What happens with:

CourseStatus.valueOf(
        "published"
);

Answer

It throws:

IllegalArgumentException

because valueOf() is case-sensitive।

The repository should translate invalid persisted data into a repository failure।


Question 3

Course file does not exist।

Our findByCode() catches:

NoSuchFileException

What does it return?

Answer

null

because that is the repository contract we chose।


Question 4

The file exists but contains:

priceInPaisa=abc

Should repository return null?

Answer

No।

The stored entity exists but is invalid।

It should throw:

CourseRepositoryException

Question 5

Does atomic replacement prevent two callers from overwriting each other's logical changes?

Answer

No।

It protects replacement integrity, not application-level concurrent update coordination।


Knowledge Check

Question 1

What problem does a repository solve?

Question 2

Should business services know file names?

Question 3

What is serialization?

Question 4

What is deserialization?

Question 5

Why use a controlled identifier for file names?

Question 6

Why persist enum name() instead of ordinal()?

Question 7

Why distinguish a missing file from an unreadable file?

Question 8

Why translate IOException into CourseRepositoryException?

Question 9

Why use a temporary file during save?

Question 10

What does atomic replacement protect?

Question 11

Does it prevent lost updates?

Question 12

Why use Course.restore()?

Question 13

What responsibilities belong in the repository?

Question 14

What responsibilities should remain outside it?

Question 15

When is a database usually preferable to one-file-per-entity storage?


Knowledge Check Answers

Answer 1

It separates application/domain logic from persistence implementation details।

Answer 2

Usually no. File naming is a repository implementation concern।

Answer 3

Converting object state into a form that can be stored or transmitted।

Answer 4

Converting stored representation back into application/domain objects।

Answer 5

It provides stable, predictable, and safer storage paths।

Answer 6

ordinal() depends on enum declaration order; name() has clearer stable meaning।

Answer 7

Absence is a valid lookup result; unreadable storage means the repository failed।

Answer 8

Higher layers should depend on repository semantics rather than low-level file-system details।

Answer 9

It reduces the chance of exposing partially written target content।

Answer 10

It helps ensure target replacement occurs as one file-system operation when supported।

Answer 11

No. Concurrent stale writers can still overwrite each other।

Answer 12

To reconstruct persisted state without pretending that normal business transitions occurred again।

Answer 13

File paths, encoding, serialization, reading, writing, deletion, listing, and storage exception translation।

Answer 14

Authorization, publication policy, pricing rules, and other business decisions।

Answer 15

When concurrency, transactions, relationships, querying, pagination, or multi-instance writes become important।


Lesson Summary

এই lesson-এ আমরা শিখেছি:

  • Repository persistence implementationকে business logic থেকে isolate করে
  • Repository storage database হওয়া বাধ্যতামূলক নয়
  • File-based repository simple persistence শেখার জন্য useful
  • Stable domain identifiers file naming-এর জন্য ভালো
  • Domain objectsকে serialization-এর মাধ্যমে file format-এ convert করা যায়
  • Stored representationকে deserialization-এর মাধ্যমে domain object-এ restore করা যায়
  • Persisted enum values name() দিয়ে store করা safer than ordinal()
  • Missing entity এবং repository failure আলাদা concepts
  • Corrupted storage not-found হিসেবে return করা উচিত নয়
  • IOException repository-specific failure-এ translate করা যায়
  • Resource ownership repository-এর ভিতরে রাখা যায়
  • Temporary-file replacement direct overwrite-এর চেয়ে safer হতে পারে
  • ATOMIC_MOVE support platform-dependent
  • Atomic replacement partial write risk reduce করে
  • Atomic replacement lost update prevent করে না
  • File repositories concurrent write scenarios-এ limited
  • Databases transactions, querying, indexing, and concurrency better handle করে
  • Files এবং databases একই real system-এ different responsibilities পালন করতে পারে
  • A clean repository contract future storage implementation পরিবর্তন সহজ করতে পারে
  • Abstraction useful, but unnecessary architecture avoid করা উচিত

Next Lesson

পরবর্তী lesson:

Module Practice and Assessment

আমরা তৈরি করব একটি complete file-based course storage application যেখানে থাকবে:

  • Safe path handling
  • UTF-8 reading and writing
  • Repository abstraction
  • File serialization
  • Atomic replacement
  • Listing stored courses
  • Missing-file handling
  • Exception translation
  • Recursive export or backup
  • Final file I/O design assessment