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.

import fs from "fs/promises";
import path from "node:path";

const FILE_PATH = path.join(process.cwd(), "data", "tasks.json");
  • 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

async function readTasks() {
  try {
    const raw = await fs.readFile(FILE_PATH, "utf8");
    const parsed = JSON.parse(raw);
    return Array.isArray(parsed) ? parsed : [];
  } catch {
    return [];
  }
}

async function writeTasks(tasks: unknown[]) {
  await fs.mkdir(path.dirname(FILE_PATH), { recursive: true });
  await fs.writeFile(FILE_PATH, JSON.stringify(tasks, null, 2), "utf8");
}
  • 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

async create(data) {
  const tasks = await readTasks();
  const nextId = tasks.length
    ? Math.max(...tasks.map(t => t.id)) + 1
    : 1;

  const task = {
    id: nextId,
    createdAt: new Date().toISOString(),
    ...data
  };

  tasks.push(task);
  await writeTasks(tasks);
  return task;
}
  • ID generation is centralized in the repository, ensuring consistency regardless of how tasks are created.
  • Timestamps like createdAt are 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

async update(id, updates) {
  const tasks = await readTasks();
  const index = tasks.findIndex(t => t.id === id);
  if (index === -1) return null;

  const updated = {
    ...tasks[index],
    ...updates,
    id,
    updatedAt: new Date().toISOString()
  };

  if ("description" in updates && updates.description === undefined) {
    delete updated.description;
  }

  tasks[index] = updated;
  await writeTasks(tasks);
  return updated;
}
  • Updates merge existing data with incoming changes, preserving fields the client didn’t touch.
  • An explicit undefined signals 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

async filterByCompletion(completed) {
  const tasks = await readTasks();
  return tasks.filter(t => t.completed === completed);
}

async delete(id) {
  const tasks = await readTasks();
  const filtered = tasks.filter(t => t.id !== id);
  if (filtered.length === tasks.length) return false;
  await writeTasks(filtered);
  return true;
}
  • Filtering logic is centralized so routes don’t need to understand task structure.
  • Deletion explicitly reports success or failure, enabling clear 404 handling 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):

let repository: TaskRepository = fileTaskRepository;

export function useTaskRepository(repo: TaskRepository) {
  repository = repo;
}
  • 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)

function hasOwn(obj: object, key: string) {
  return Object.prototype.hasOwnProperty.call(obj, key);
}

function normalizeText(value?: string) {
  if (typeof value !== "string") return undefined;
  const trimmed = value.trim();
  return trimmed === "" ? undefined : trimmed;
}

function normalizeBoolean(value?: boolean) {
  return typeof value === "boolean" ? value : undefined;
}
  • 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

export async function createTask(payload) {
  const base = {
    title: payload.title.trim(),
    completed: payload.completed ?? false
  };

  const description = normalizeText(payload.description);
  if (description !== undefined) base.description = description;

  return repository.create(base);
}
  • Defaults like completed: false are 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

export async function replaceTask(id, payload) {
  const updates = {
    title: payload.title.trim(),
    completed: payload.completed
  };

  if (hasOwn(payload, "description")) {
    updates.description = normalizeText(payload.description);
  }

  return repository.update(id, updates);
}
export async function patchTask(id, payload) {
  const updates: any = {};

  if (hasOwn(payload, "title")) {
    const title = normalizeText(payload.title);
    if (title !== undefined) updates.title = title;
  }

  if (hasOwn(payload, "completed")) {
    const completed = normalizeBoolean(payload.completed);
    if (completed !== undefined) updates.completed = completed;
  }

  return repository.update(id, updates);
}
  • 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.

const parsed = taskCreateSchema.parse(body);

const created = await createTask({
  title: parsed.title,
  description: parsed.description,
  completed: parsed.completed
});

return ok(created, 201);
  • 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.

Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal