Server-Side Functionality with a Database

CRUD Pattern, Repository Pattern, and Layered Architecture


Learning Objectives

  • You understand the CRUD pattern, the repository pattern, and the layered architecture.
  • You can apply the three patterns in a server-side application.

As we are adding more functionality to the application, it is important to learn about practices that help us structure the code in a way that makes it easier to maintain and extend. In this part, we will look at the repository pattern, the CRUD pattern, and the layered architecture. These patterns help us structure code for easier maintenance and extension — they are also commonly used in software development, so learning about them is useful in itself.

CRUD Pattern

The acronym “CRUD” refers to Create, Read, Update, and Delete, which are the four basic operations for managing data. The operations are present in most web applications in one form or another, often in multiple locations. As an example, a blog application could consist of the following four main operations:

  • Adding a new blog post (create)
  • Viewing a list of blog posts (read)
  • Editing an existing blog post (update)
  • Removing an existing blog post (delete)

Similarly, a book application could consist of the following four main operations:

  • Adding a new book item (create)
  • Viewing a list of book items (read)
  • Editing an existing book item (update)
  • Removing an existing book item (delete)

And so on. The pattern is often present in both the server-side and client-side of the application. As an example, on the server-side, the operations for a book management application could be implemented through the following routes (as we already have done):

// Create
app.post("/api/books", (c) => {
  // implementation
});

// Read all
app.get("/api/books", (c) => {
  // implementation
});

// Read one
app.get("/api/books/:bookId", (c) => {
  // implementation
});

// Update
app.put("/api/books/:bookId", (c) => {
  // implementation
});

// Delete
app.delete("/api/books/:bookId", (c) => {
  // implementation
});

Similarly, on the client-side, we might see the same operations. Think of, for example, how in the chapter state is shared between components we created a module-level singleton that provides functionality for managing the state.

The functionality included retrieving all books, retrieving a single book, adding a book, and deleting a book — this looked as follows.

let bookState = $state([
  { id: 1, name: "HTML for Hamsters" },
  { id: 2, name: "CSS: Cannot Style Sandwiches" },
  { id: 3, name: "JavaScript and the Fifty Shades of Errors" },
]);

const useBookState = () => {
  return {
    get books() {
      return bookState;
    },
    getOne: (id) => {
      return bookState.find((b) => b.id === id);
    },
    addBook: (name) => {
      bookState.push({ id: bookState.length + 1, name });
    },
    deleteBook: (id) => {
      bookState = bookState.filter((b) => b.id !== id);
    },
  };
};

export { useBookState };

Although the update operation is missing in the above example, the other three operations are present.

CRUD Pattern

CRUD stands for Create, Read, Update, and Delete, which are the four basic operations for managing data in a storage system, such as a database. It’s not limited to web applications; CRUD applies to any system that interacts with persistent data.


Loading Exercise...

Repository Pattern

The repository pattern is a design pattern that provides an abstraction between the data (e.g., the database) and the application logic. The pattern helps encapsulate the logic required to retrieve the data, and to transform it if needed, allowing the rest of the application to interact with the data in a consistent way without worrying about specific data access mechanisms.

When applying the repository pattern in a server-side web application, an additional “layer” is added to the application. The layer is responsible for handling the data access logic, and the rest of the application interacts with the data through the layer. The flow is visualized in Figure 1.

Fig 1. — Repository pattern creates an additional layer on top of the database, providing an abstraction through which the database is used.
Loading Exercise...

Concretely, this would mean creating a new file for accessing the database, and exposing functions from the file that allow the rest of the application to interact with the data.

It is up to the programmer to ensure that the database is accessed only through the repository file, and not through other parts of the application.

Let’s look at this first by creating a bookRepository.js that abstracts away the functionality for interacting with a database that contains book items. The repository acts as a “CRUD” and contains functions for creating, reading, updating, and deleting book items. The rest of the application would use these functions to interact with the data.

Concretely, a bookRepository.js that acts as a CRUD could look as follows:

import postgres from "postgres";

const sql = postgres();

const create = async (book) => {
  const result = await sql`INSERT INTO books
    (title, description, published_at, page_count)
    VALUES (${book.title}, ${book.description}, ${book.published_at}, ${book.page_count})
    RETURNING *;`;

  return result[0];
};

const readAll = async () => {
  return await sql`SELECT * FROM books`;
};

const readOne = async (id) => {
  const result = await sql`SELECT * FROM books WHERE id = ${id}`;
  return result[0];
};

const update = async (id, book) => {
  const result = await sql`UPDATE books SET
      title = ${book.title},
      description = ${book.description},
      published_at = ${book.published_at},
      page_count = ${book.page_count}
    WHERE id = ${id}
    RETURNING *;`;
  return result[0];
};

const deleteOne = async (id) => {
  const result = await sql`DELETE FROM books
    WHERE id = ${id} RETURNING *`;
  return result[0];
};

export { create, deleteOne, readAll, readOne, update };

With the above bookRepository.js in place, we could change the app.js file to use the functions from bookRepository.js, allowing the application to interact with the data through the repository. After modification, the app.js would look as follows.

import { Hono } from "@hono/hono";
import { cors } from "@hono/hono/cors";
import * as bookRepository from "./bookRepository.js";

const app = new Hono();
app.use("/*", cors());

app.post("/api/books", async (c) => {
  const book = await c.req.json();
  if (!book.title ||
    !book.description ||
    !book.published_at ||
    !book.page_count) {
    return c.json({ error: "Missing required fields" }, 400);
  }

  const newBook = await bookRepository.create(book);
  return c.json(newBook, 201);
});

app.get("/api/books", async (c) => {
  const books = await bookRepository.readAll();
  return c.json(books);
});

app.get("/api/books/:bookId", async (c) => {
  const id = Number(c.req.param("bookId"));
  if (!Number.isInteger(id)) {
    return c.json({ error: "Invalid book id" }, 400);
  }

  const book = await bookRepository.readOne(id);

  if (!book) {
    return c.json({ error: "Book not found" }, 404);
  }

  return c.json(book);
});

app.put("/api/books/:bookId", async (c) => {
  const id = Number(c.req.param("bookId"));
  if (!Number.isInteger(id)) {
    return c.json({ error: "Invalid book id" }, 400);
  }

  const book = await c.req.json();
  if (!book.title ||
    !book.description ||
    !book.published_at ||
    !book.page_count) {
    return c.json({ error: "Missing required fields" }, 400);
  }

  const updatedBook = await bookRepository.update(id, book);

  if (!updatedBook) {
    return c.json({ error: "Book not found" }, 404);
  }

  return c.json(updatedBook);
});

app.delete("/api/books/:bookId", async (c) => {
  const id = Number(c.req.param("bookId"));
  if (!Number.isInteger(id)) {
    return c.json({ error: "Invalid book id" }, 400);
  }

  const deletedBook = await bookRepository.deleteOne(id);

  if (!deletedBook) {
    return c.json({ error: "Book not found" }, 404);
  }

  return c.json(deletedBook);
});

export default app;

Now, the app.js no longer contains the logic for interacting with the database, but instead uses the functions from the bookRepository.js file to interact with the data. This allows us to change how the data is stored without changing the rest of the application.

Repository pattern

To summarize, the repository pattern is a design pattern that provides an abstraction between the data (e.g., the database) and the application logic. The pattern helps encapsulate the logic required to retrieve the data, and to transform it if needed, allowing the rest of the application to interact with the data in a consistent way without worrying about specific data access mechanisms.

The repository pattern is often combined with the CRUD pattern, but it can also expose other types of functions such as intent-specific functionality (e.g. findByTitle, findByPublicationYear, etc.).


Loading Exercise...

Layered architecture

Layered architecture is a way to organize code into layers that each have their own responsibility. The layers abstract away implementation details, and only communicate within the layer and the layers below them. Communication with above layers is not allowed.

Bigger server-side applications often have a layer for controllers, which handle specific incoming requests, services that provide functionality for the controllers, and repositories that abstract away the database. Such an architecture could look like the one in Figure 1.

Fig 1. — Layered architecture with controllers, services, and repository.

Smaller applications may not need all the layers, and can be implemented e.g. just with the controllers and the repository. In such a case, we would have a route mapping from the API endpoints to controller functions, which would then call the functions from the repository to interact with the data. The repository would then handle the data access logic, as we have seen above.

Loading Exercise...

As a concrete example, let’s decompose our book application further by creating a bookController.js that handles the incoming requests. The bookController.js could look as follows (note that the CRUD pattern is present here as well).

import * as bookRepository from "./bookRepository.js";

const create = async (c) => {
  const book = await c.req.json();
  if (!book.title ||
    !book.description ||
    !book.published_at ||
    !book.page_count) {
    return c.json({ error: "Missing required fields" }, 400);
  }

  const newBook = await bookRepository.create(book);
  return c.json(newBook);
};

const readAll = async (c) => {
  const books = await bookRepository.readAll();
  return c.json(books);
};

const readOne = async (c) => {
  const id = Number(c.req.param("bookId"));
  if (!Number.isInteger(id)) {
    return c.json({ error: "Invalid book id" }, 400);
  }

  const book = await bookRepository.readOne(id);

  if (!book) {
    return c.json({ error: "Book not found" }, 404);
  }

  return c.json(book);
};

const update = async (c) => {
  const id = Number(c.req.param("bookId"));
  if (!Number.isInteger(id)) {
    return c.json({ error: "Invalid book id" }, 400);
  }

  const book = await c.req.json();
  if (!book.title ||
    !book.description ||
    !book.published_at ||
    !book.page_count) {
    return c.json({ error: "Missing required fields" }, 400);
  }

  const updatedBook = await bookRepository.update(id, book);

  if (!updatedBook) {
    return c.json({ error: "Book not found" }, 404);
  }

  return c.json(updatedBook);
};

const deleteOne = async (c) => {
  const id = Number(c.req.param("bookId"));
  if (!Number.isInteger(id)) {
    return c.json({ error: "Invalid book id" }, 400);
  }

  const deletedBook = await bookRepository.deleteOne(id);

  if (!deletedBook) {
    return c.json({ error: "Book not found" }, 404);
  }

  return c.json(deletedBook);
};

export { create, deleteOne, readAll, readOne, update };

Now, with the above file in place, our app.js could be modified to use the functions from bookController.js, allowing the application to interact with the data through the controller. After modification, the app.js would look as follows.

import { Hono } from "@hono/hono";
import { cors } from "@hono/hono/cors";
import * as bookController from "./bookController.js";

const app = new Hono();

app.use("/*", cors());

app.post("/api/books", bookController.create);
app.get("/api/books", bookController.readAll);
app.get("/api/books/:bookId", bookController.readOne);
app.put("/api/books/:bookId", bookController.update);
app.delete("/api/books/:bookId", bookController.deleteOne);

export default app;

After the modifications, we can test the application to ensure that it still works as expected. The application should behave the same way as before, but now the code is organized into layers, which makes it easier to maintain and extend. As the application is relatively small, we have omitted the service layer, but in larger applications, the service layer could provide additional functionality for the controllers.

Layered architecture

Layered architecture is a way to organize code into layers that each have their own responsibility. The layers abstract away implementation details, and only communicate within the layer and the layers below them. Communication with above layers is not allowed.

As a whole, the book application would now be composed of three files, app.js, bookController.js, and bookRepository.js, which together implement the CRUD pattern, the repository pattern, and the layered architecture. The application is now structured in a way that makes it easier to maintain and extend, as the different parts of the application are separated into their own files and layers. As a figure, the application would look as follows.

Fig 3. — The book application is now structured in a way that makes it easier to maintain and extend.

In practice, we would also implement additional functionality, such as validation and error handling, but the above example illustrates the main concepts of the CRUD pattern, the repository pattern, and the layered architecture.

Loading Exercise...

Summary

In summary:

  • The CRUD pattern describes the four basic operations for managing data: Create, Read, Update, and Delete.
  • The repository pattern provides an abstraction between application logic and the database. It centralizes data access in one place, so other parts of the application interact with data through a consistent interface.
  • Layered architecture organizes code into separate layers, each with its own responsibility. Common layers include controllers (handling requests), services (providing business logic), and repositories (managing data access).
  • Together, the CRUD pattern, repository pattern, and layered architecture provide a structured way for building applications.
Loading Exercise...