Introduction: Moving Beyond In-Memory Storage

Welcome back! In the last lesson, you learned how to use the Repository Pattern to keep your data logic separate from your business logic. Up until now, you have been storing your data in memory, which means all your tasks disappear every time you restart your app. This is fine for learning, but in real-world applications, you need your data to persist even after the server restarts.

In this lesson, you will learn how to save your data to a file using JSON. This is called file-based persistence. By the end of this lesson, you will know how to read and write your tasks to a JSON file, making your app more like a real backend service.


Recap: Task Data and Project Structure

Let’s quickly remind ourselves of the Task data structure and where our data lives. You have a Task type that looks like this:

export type Task = {
  id: number;
  title: string;
  content: string;
  completed: boolean;
  dueDate?: string;
};

Previously, you stored tasks in memory using an array. Now, you will use a file called data/tasks.json to store your tasks. This file will look like this when empty:

[]

This file will grow as you add, update, or delete tasks. The rest of your project structure stays the same, but now your data will be saved in a file instead of just in memory.


Reading and Writing JSON Files in Node.js

To work with files in Node.js, you use the built-in fs/promises module. This module lets you read and write files using promises, which means you can use async and await for cleaner code.

Here’s a simple example of reading and writing a JSON file:

import fs from 'fs/promises';

const FILE_PATH = './data/tasks.json';

// Reading tasks from the file
async function readTasks() {
  const raw = await fs.readFile(FILE_PATH, 'utf8');
  return JSON.parse(raw);
}

// Writing tasks to the file
async function writeTasks(tasks) {
  await fs.writeFile(FILE_PATH, JSON.stringify(tasks, null, 2));
}

Explanation:

  • fs.readFile reads the file as a string.
  • JSON.parse turns the string into a JavaScript array.
  • fs.writeFile saves the array back to the file as a JSON string.
  • JSON.stringify(tasks, null, 2) makes the JSON file easy to read by adding indentation. The second and third arguments in JSON.stringify(tasks, null, 2) ensure the JSON file is pretty-printed with 2-space indentation, making it easier to inspect manually.

Output Example: If you call writeTasks([{ id: 1, title: "Learn Next.js", completed: false }]), your tasks.json file will look like:

[
  {
    "id": 1,
    "title": "Learn Next.js",
    "completed": false
  }
]

Building the File-Based Task Repository

Now, let’s build a repository that uses the JSON file for storage. Here’s the main code for your file-based repository:

import fs from 'fs/promises';
import path from 'path';
import type { Task } from '@/lib/tasks';
import type { TaskRepository } from './taskRepository';

const FILE_PATH = path.join(process.cwd(), 'data/tasks.json');
let nextId = 1;

async function read(): Promise<Task[]> {
  try {
    const raw = await fs.readFile(FILE_PATH, 'utf8');
    const tasks = JSON.parse(raw);
    nextId = tasks.length > 0 ? Math.max(...tasks.map(t => t.id)) + 1 : 1;
    return tasks;
  } catch (e) {
    console.error('Error reading tasks file:', e);
    return [];
  }
}

async function write(tasks: Task[]): Promise<void> {
  await fs.writeFile(FILE_PATH, JSON.stringify(tasks, null, 2));
}

export const fileTaskRepository: TaskRepository = {
  async getAll() {
    return await read();
  },

  async getById(id) {
    const tasks = await read();
    return tasks.find(t => t.id === id) ?? null;
  },

  async create(data) {
    const tasks = await read();
    const task = { id: nextId++, ...data };
    tasks.push(task);
    await write(tasks);
    return task;
  },

  async update(id, updates) {
    const tasks = await read();
    const index = tasks.findIndex(t => t.id === id);
    if (index === -1) return null;
    const updated = { ...tasks[index], ...updates };
    tasks[index] = updated;
    await write(tasks);
    return updated;
  },

  async delete(id) {
    const tasks = await read();
    const filtered = tasks.filter(t => t.id !== id);
    if (filtered.length === tasks.length) return false;
    await write(filtered);
    return true;
  },

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

Explanation:

  • read() loads all tasks from the JSON file. It also updates nextId so new tasks get unique IDs.
  • write(tasks) saves the current list of tasks to the file.
  • Each method (getAll, getById, create, update, delete, filterByCompletion) reads the file, makes changes, and writes back if needed.
  • If the file doesn’t exist or is empty, read() returns an empty array.

This approach means your data will survive server restarts, making your app more reliable.

⚠️ Note: File-based persistence is great for learning and prototyping, but it's not suitable for large-scale production systems. It's slow, non-transactional, and lacks concurrency support. You'll later learn how to move to a proper database.


Swapping the Repository Implementation

One of the main benefits of using the Repository Pattern is how easily you can swap between data sources. In the previous lesson, your taskService.ts used an in-memory repository:

import { mapTaskRepository } from './mapTaskRepository';
const repo = mapTaskRepository;

To persist tasks to disk, you simply switch to:

import { fileTaskRepository } from './fileTaskRepository';
const repo = fileTaskRepository;

The rest of your service logic — and your entire API — doesn’t change at all. That’s because both mapTaskRepository and fileTaskRepository conform to the same TaskRepository interface. Since the interface defines the expected contract, the service functions can remain the same regardless of the underlying implementation.


Connecting the Repository to the Service Layer

Now, let’s connect your new file-based repository to the service layer. This is where your business logic lives. Here’s how you update your service to use the new repository:

import type { Task } from '@/lib/tasks';
import { fileTaskRepository } from '@/lib/repositories/fileTaskRepository';

const repo = fileTaskRepository;

export const getAllTasks = () => repo.getAll();
export const getTaskById = (id: number) => repo.getById(id);
export const createTask = (data: Omit<Task, 'id'>) => repo.create(data);
export const updateTask = (id: number, updates: Partial<Task>) => repo.update(id, updates);
export const deleteTask = (id: number) => repo.delete(id);
export const filterTasksByStatus = (completed: boolean) => repo.filterByCompletion(completed);

Explanation:

  • The service now uses fileTaskRepository instead of the in-memory one.
  • All your service functions (getAllTasks, createTask, etc.) now work with data stored in the JSON file.
  • This change is invisible to the rest of your app, thanks to the Repository Pattern.

Summary and Practice Preview

In this lesson, you learned how to move from in-memory storage to file-based persistence using JSON files. You saw how to:

  • Read and write JSON files in Node.js using fs/promises
  • Build a repository that saves tasks to a file
  • Connect your service layer to use the new file-based repository

This makes your app more production-ready, as your data will now survive server restarts. In the next practice exercises, you’ll get hands-on experience working with file-based persistence. Great job making it this far — let’s keep going!

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