File Based Persistence
Moving Beyond In-Memory Storage with a File Repository
Up to this point, your Task Manager API has used an in-memory repository for persistence. You’ve already built strong abstractions—validation with Zod, structured routes, and consistent logging—but all task data disappears when the server restarts.
In this lesson, you’ll replace the in-memory store with a file-backed repository that writes tasks to data/tasks.json. This gives you durability across restarts while keeping your routes and service layer completely unchanged, demonstrating the value of clean layering.
You’ll practice:
- Persisting data with Node’s file system APIs. Tasks will be serialized to JSON and written to disk using
fs/promises, giving you real persistence without introducing a database. - Encapsulating storage concerns in a repository. All file paths, reads, writes, IDs, and timestamps live in one place, isolated from business logic.
- Preserving architectural boundaries. Routes remain focused on HTTP, services on intent and normalization, and storage can evolve independently.
The File Repository: What It Does and Why It’s Structured This Way
The file repository lives at
app/lib/repositories/fileTaskRepository.server.ts.
It implements the same TaskRepository interface your service layer already depends on, but persists tasks to disk instead of memory. This means the rest of your application doesn’t need to change at all when storage changes.
process.cwd()ensures the file path resolves relative to the running application, not the source file. This keeps behavior consistent across environments.- Storing data under a dedicated
data/directory keeps persistence explicit and discoverable. - Neither routes nor services need to know this path exists; only the repository is aware of storage details.
File Repository Helpers: Reading and Writing Tasks Safely
- Reads are intentionally fault-tolerant for this demo: if the file doesn’t exist, the API falls back to an empty list instead of crashing.
- Demo simplification: the provided helper also treats malformed JSON as an empty list. In a production system, catch missing-file errors separately and log or rethrow malformed JSON and permission errors to avoid silently overwriting data.
- Writes ensure the target directory exists before saving, which avoids filesystem errors on first run.
- JSON is formatted with indentation so you can easily inspect and debug stored tasks during development.
Repository Methods: Creating Tasks, IDs, and Timestamps
- ID generation is centralized in the repository, ensuring consistency regardless of how tasks are created.
- Timestamps like
createdAtare applied automatically, keeping this concern out of the service layer. - Services simply describe what should be created; the repository decides how it’s persisted.
Updating Tasks: Merging and Field Removal
- Updates merge existing data with incoming changes, preserving fields the client didn’t touch.
- An explicit
undefinedsignals intentional removal, allowing optional fields to be deleted cleanly. - The repository owns the final shape of persisted data, not the service or route.
Filtering and Deleting
- Filtering logic is centralized so routes don’t need to understand task structure.
- Deletion explicitly reports success or failure, enabling clear
404handling upstream. - Persistence behavior remains invisible to higher layers.
The Service Layer: Business Rules, Not Storage Details
The service layer coordinates business intent, not storage mechanics. It decides what an update means and delegates persistence to the repository.
Repository Seam (Swap-Friendly by Design):
- This seam allows you to swap implementations for tests or future databases with a single line.
- Neither routes nor services need to change when persistence evolves.
Normalization Helpers (Why They Exist)
hasOwn()distinguishes between fields that were omitted and fields that were intentionally sent, which is critical for PATCH semantics.normalizeText()ensures empty or whitespace-only strings don’t pollute persisted data.normalizeBoolean()prevents accidental overwrites from invalid or missing values.
These helpers became necessary once partial updates and persistent storage made intent meaningful.
Creating Tasks: Defaults and Cleanup
- Defaults like
completed: falseare applied consistently in one place. - Normalization ensures only meaningful data reaches persistence.
- Validation, normalization, and persistence form a clear, linear pipeline.
Replacing vs Patching Tasks
- PUT represents a full logical replacement, so required fields must always be supplied.
- PATCH updates only what the client explicitly sent, leaving the rest untouched.
hasOwn()preserves intent and prevents accidental data loss.
The Route Layer: Same API, Now With Disk Persistence
Your routes remain focused purely on HTTP, validation, and responses.
- Validation happens at the edge, before any business logic runs.
- Services receive trusted, well-formed input.
- Routes remain unaware of files, paths, IDs, or timestamps.
What You’ll Observe at Runtime
- Empty strings remove optional fields instead of being stored.
- PATCH requests update only the fields the client touched.
- Restarting the server preserves tasks on disk.
- Logs and response shapes remain unchanged.
Practical Notes (File Persistence):
- Durability: This JSON-file repository is a learning/demo persistence layer, suitable for local exploration and small prototypes. It is not a production replacement for a database because it lacks write locking, transactions, concurrency safety, corruption recovery, and database-level durability guarantees.
- Visibility: Persisted JSON is easy to inspect and debug.
- Isolation: Moving to a database later won’t require rewriting routes.
Summary
You replaced in-memory storage with file-backed persistence while preserving clean layering.
- The repository owns disk I/O and data shape.
- The service layer enforces business intent and normalization.
- The routes stay thin, predictable, and storage-agnostic.
This is the core payoff of good backend design: storage evolves, behavior stays stable.
