Abstracting Data Persistence

Abstracting Persistence with a Repository

You’ve already implemented Zod validation, consistent logging, and structured routes. The next architectural milestone is to decouple persistence logic from business logic. When persistence concerns (like IDs, timestamps, or array manipulation) live inside your service layer, changing where data lives—say, moving from an in-memory array to a file or database—forces you to modify many files and risks introducing bugs.

The repository pattern solves this by introducing a dedicated abstraction for persistence.
Routes and services no longer deal directly with arrays or storage mechanisms—they talk to a single, well-defined interface.

Benefits include:

  • Flexibility: Swap implementations without rewriting logic.
  • Testability: Inject mock repositories for testing.
  • Clarity: Business logic reads cleanly, without storage details.

What We’re Abstracting (and Why It’s Useful)

Your Task Manager project already implements task logic in a structured way.
Now, you’ll introduce three core components for persistence abstraction:

  • A repository interface that defines available persistence operations.
  • An in-memory implementation that fulfills those operations for now.
  • A service layer that depends only on the repository interface, not on a concrete storage implementation.

The Contract: `TaskRepository` (What Every Repository Must Provide)

app/lib/repositories/taskRepository.server.ts

export interface TaskRepository {
  getAll(): Task[];
  getById(id: number): Task | null;
  create(data: Omit<Task, "id" | "createdAt" | "updatedAt">): Task;
  update(id: number, updates: Partial<Omit<Task, "id" | "createdAt" | "updatedAt">>): Task | null;
  delete(id: number): boolean;
  filterByCompletion(completed: boolean): Task[];
}

What You Should Notice

  • Return types communicate intent:
    • getById and update return Task | null when nothing is found.
    • delete returns a boolean for success or failure.
    • getAll and filterByCompletion return plain arrays.
  • Server-managed fields:
    The repository—not the route or service—handles id, createdAt, and updatedAt.
  • Async-compatible interface:
    The MaybePromise<T> return type lets the in-memory repository return values immediately while file or database repositories can return promises. Services should await repository calls so the same contract works across storage implementations.

This interface serves as the contract between your application’s domain and its persistence layer. Anything implementing it becomes plug-compatible.

The In-Memory Implementation: `mapTaskRepository`

app/lib/repositories/mapTaskRepository.server.ts

import { tasks } from "~/lib/tasks";
let nextId = tasks.length > 0 ? Math.max(...tasks.map((t) => t.id)) + 1 : 1;

export const mapTaskRepository: TaskRepository = {
  getAll: () => [...tasks],
  getById: (id) => tasks.find((t) => t.id === id) ?? null,
  create: (data) => {
    const now = new Date().toISOString();
    const task: Task = { id: nextId++, createdAt: now, ...data };
    tasks.push(task);
    return task;
  },
  update: (id, updates) => {
    const idx = tasks.findIndex((t) => t.id === id);
    if (idx === -1) return null;
    const updated: Task = {
      ...tasks[idx],
      ...updates,
      updatedAt: new Date().toISOString()
    };
    tasks[idx] = updated;
    return updated;
  },
  delete: (id) => {
    const before = tasks.length;
    const filtered = tasks.filter((t) => t.id !== id);
    if (filtered.length === before) return false;
    tasks.length = 0;
    tasks.push(...filtered);
    return true;
  },
  filterByCompletion: (completed) => tasks.filter((t) => t.completed === completed)
};
  • ID lifecycle:
    nextId is derived from existing tasks and auto-increments with each creation.
    Callers never need to handle ID generation.

  • Timestamps:

    • create sets createdAt once.
    • update refreshes updatedAt on each modification.
      This keeps time-related fields consistent across the app.
  • Copy-on-read:
    getAll() returns a shallow copy ([...tasks]) to prevent accidental mutation of the in-memory store.

  • Deletion semantics:
    delete() rebuilds the array without the target task and returns a boolean.
    Routes interpret this boolean into proper HTTP responses (204 or 404).

  • Filtering:
    filterByCompletion() centralizes filtering logic.
    Services and routes no longer need to repeat common query logic.

This implementation is simple, deterministic, and ideal for development and tests before adding real persistence.

The Service Layer: Depending on the Repository, Not Storage Details

app/lib/services/taskService.ts

let repo: TaskRepository = mapTaskRepository;

export function useTaskRepository(r: TaskRepository) {
  repo = r;
}

Why This Matters

  • Swap-in replacement:
    useTaskRepository() provides a single seam for injecting another repository (e.g., a file-based or database-backed implementation). The rest of the service layer stays unchanged.

  • Business-friendly API:
    The service exposes functions like createTask, replaceTask, and patchTask that align with your Zod schemas and route semantics:

    • CreateTaskPayload (POST): Optional completed, optional dueDate.
    • ReplaceTaskPayload (PUT): Requires completed for full replacement.
    • PatchTaskPayload (PATCH): Uses Partial<…> for selective updates.

    The service translates these payloads into repository calls, ensuring clean, validated data while keeping route code concise.

    Subtle but Important Conventions in taskService.ts
  • undefined vs. null:
    The repository returns null when a task isn’t found.
    The service converts this to undefined (return updated ?? undefined;), which routes interpret as a 404.
    Each layer communicates in its own terms but shares meaning.

  • Defaults live in the service:
    In createTask, completed defaults to false when omitted.
    This is a business rule, not a storage concern—so it belongs in the service.

  • No validation here:
    The service assumes routes have already validated inputs via Zod.
    This keeps boundaries clear:

    • Routes → Validate
    • Services → Apply business logic
    • Repositories → Persist data

How the Layers Collaborate (End-to-End Story)

  1. Route parses JSON, authenticates via API key, validates input with Zod, and logs with withLogging.
  2. Service receives clean, validated data and applies business logic such as defaults.
  3. Repository assigns id, manages timestamps, and performs persistence operations (read/write).
  4. Route returns a standardized ok or err response, displayed in the preview UI and logged to the console.

Because each layer has a single responsibility, the entire flow is predictable, testable, and easy to evolve.

Compact architecture flow: Route → Zod schema → Service → Repository → Storage.

Why This Abstraction Pays Off Immediately

Even before adopting a database, the repository pattern provides major benefits:

  • Consistency: IDs and timestamps follow uniform rules across all operations.
  • Lower cognitive load: Developers don’t need to handle array or time logic in routes.
  • Testability: Inject a mock or test repository using useTaskRepository() for unit tests.
  • Risk containment: Changes to persistence logic happen in one place—the repository—without touching routes or services.

Practical Guidance

When adjusting or adding task behavior, ask:

  • Is this a business rule? → Implement it in the service.
  • Is this a storage detail (IDs, timestamps, lookups)? → Implement it in the repository.

If you need new functionality—like “overdue tasks”—define it once in the repository, expose it through the service, and reuse it across routes.

Keep route handlers thin:
Validate → Authorize → Call the service → Respond.
This separation ensures clean boundaries and easier long-term maintenance.

Summary

You’ve implemented a clean persistence abstraction using the repository pattern:

  • TaskRepository defines a stable storage contract.
  • mapTaskRepository implements that contract in-memory, handling IDs and timestamps centrally.
  • taskService depends on the repository interface, not the underlying storage.

As a result:

  • Routes stay small and predictable.
  • Validation remains at the edge with Zod.
  • Logging and standardized responses make the system observable and debuggable.

This design keeps your Remix backend maintainable and adaptable as it grows — each layer has one clear responsibility, and the repository can be swapped anytime without rewriting your services or routes.

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