Back to main page Traveling Coderman

Node.js Microservice Architecture Organizing Express.js endpoints

981 views

We start the Node.js Microservice Architecture with the organization of endpoints. Learn how to organize the files and folders, why a REST resource should have its own DAO and why two layers are sufficient.

When developing a service, you want to start with the user-facing code. This way, you are fast to get something running. For a backend service, the user-facing code is the API. In our example, we provide a REST-like HTTP API. We want to provide five endpoints.

  • POST /todos: Creates a todo
  • PUT /todos/:id: Changes the todo with the ID
  • DELETE /todos/:id: Deletes the todo with the ID
  • GET /todos: Returns a list of all todos
  • GET /todos/:id: Returns the todo with the ID

Note how these endpoints are all related.

  • They define a resource /todos.
  • They work with the same entity type of a Todo.
  • They act against the same storage.

If any of these aspects resource name, entity type or storage changes, then it affects all endpoints. Things that change together should be close to each other. Following that guideline, changes are rather local to folders and not distributed across the system. This makes it easier to examine what needs to be changed. And it makes it easier to review the code.

Remember: Things that change together should be close to each other.

File System Organization ๐Ÿ”—

With this guideline, the organization of the endpoints within this architecture looks like this in the src folder.

src
|-- main.ts
|-- app.ts
`-- controllers
`-- todos
|-- todo.router.ts
|-- todo.type.ts
|-- todo.dao.ts
|-- delete-todo.controller.ts
|-- get-todo.controller.ts
|-- get-todos.controller.ts
|-- post-todo.controller.ts
`-- put-todo.controller.ts

The folder controllers/todos is a self-contained module. It does not reference other parts of the application and its single entrypoint is the router todo.router.ts. In this regard, the self-contained module controllers/todos follows the advice of deep modules by Ousterhout [^1]: A module should have a simple interface (in this case an object of type Router) and should encapsulate complexity.

Todo Router ๐Ÿ”—

From the outside, app.ts refers to todo.router.ts to mount the /todos resource into the application.

import express from "express";
import { todosRoute } from "./controllers/todos/todo.router";

const app = express();

// Enable JSON body parsing
app.use(express.json({ limit: "1mb" }));

// Mount the `/todos` resource
app.use("/todos", todosRoute);

export default app;

The todo router itself refers to the different controllers.

import express from "express";
import { deleteTodoController } from "./delete-todo.controller";
import { getTodoController } from "./get-todo.controller";
import { getTodosController } from "./get-todos.controller";
import { postTodoController } from "./post-todo.controller";
import { putTodoController } from "./put-todo.controller";

export const todosRoute = express.Router();

todosRoute.get("/:id", getTodoController);
todosRoute.get("", getTodosController);
todosRoute.put("/:id", putTodoController);
todosRoute.delete("/:id", deleteTodoController);
todosRoute.post("", postTodoController);

Todo Type ๐Ÿ”—

The type todo.type.ts defines the todo type including the ID.

export type TodoId = string;

export interface Todo {
id: TodoId;
name: string;
assignee: string;
dueDate: string;
}

If a todo is sent as a body to the endpoint POST /todos, then the ID field is expected to be missing. In these cases, it's possible to refer to the todo with the type Omit<"id", Todo>.

The same type for the representation in the API and in the database? Doesn't that expose implementation details? Nope. There is no need to have a TypeScript type to map to a different structure externally in the API or internally in the database. In later chapters we will see how we can define a format in the OpenAPI specification, how we can map the todo type in the data access layer and how we can use TypeScripts type system to express differences in representation in different layers without duplicating identical fields of todos. We will also discuss how architecture can evolve over time with its requirements.

Data Access Layer ๐Ÿ”—

The data access object bundles all the database communication of all the todo controllers. We cover the implementation of the data access layer in a later post.

For more information on this, checkout 'Why TypeScript is awesome for SQL queries'

import { v4 as uuid } from "uuid";
import { Todo, TodoId } from "./todo.type";

export async function getTodo(id: TodoId): Promise<Todo | undefined> {
/* ... */
}

export async function getTodos(): Promise<Todo[]> {
/* ... */
}

export async function createTodo(todo: Todo): Promise<Todo> {
/* ... */
}

export async function updateTodo(id: TodoId, todo: Todo): Promise<Todo> {
/* ... */
}

export async function deleteTodo(id: TodoId): Promise<void> {
/* ... */
}

Why are there separate files for controllers but not for each DAO function? It's all about testing. In TypeScript, each file is a module. With jest, a module can be mocked. Each controller *.controller.ts has different requirements on the mock. Therefore, bundling all controllers into one file todo.controller.ts and having one test file todo.controller.spec.ts would make the mocking of the DAO more complicated. On the other hand, the module todo.dao.ts does not require jest mocks since it does not depend on internal modules. We will cover these aspects in more detail in following chapters.

Dedicated data access object ๐Ÿ”—

A common architecture is the layered architecture that separates between endpoints, services and data access layers. In this layered architecture, I often see that endpoints, services and data access are in different folders. This is necessary, if a function in the lower layers is called from multiple places in an upper layer.

As an example, let's take the endpoint GET /todos. Consider additionally a job that sends notifications for todos that reached their due date. Both the endpoint and the job need to access the database to receive todos. In a layered architecture, there would be one data access function selectTodos(): Todo[] independent of the call side. However, such a function is build with the assumption that both the endpoint and the job access the todos in the same way.

There are several scenarios where this is not the case:

  • The job might want to filter directly with the due date.
  • The endpoint GET /todos might want to expose additional filters.
  • The job might want to select each todo with the information if it already sent a notification.

Implementing these requirements all in the same selectTodos function leads to an unnecessarily complex generic function. Such a function is hard to reason about and complex to optimize. Therefore, I recommend to implement two separate functions selectTodos(assignee: string): Todo[] and selectTodos(): Todo[] in two different dedicated data access objects controllers/todos/todo.dao.ts and jobs/notifications/todo.dao.ts.

Remember: Ideally, each data access function is only called from one place.

These functions are optimized for their one specific usage. This means that they are simpler, that their bugs can only affect one endpoint, and that the blast radius of future changes is reduced.

For more information on this, checkout 'Identifying bad code with function usage patterns'

However, endpoints like GET /todos and POST /todos are tightly related. After a call to POST /todos, the user expects the same todo to be returned including a generated id. It would be unexpected if the endpoint GET /todos returns new fields or hides fields. Hence, in this architecture, a resource like /todos uses one type todo.type.ts and one local data access object todo.dao.ts.

Controllers ๐Ÿ”—

Each controller is a plain function that uses the data access layer. The controller is concerned about HTTP requests, responses and status codes.

As an example, the endpoint GET /todos/:id can result in a 404. The data access function getTodo returns undefined if the todo does not exist. It is the role of the controller to map this to a 404 response.

import { NextFunction, Request, Response } from "express";
import { getTodo } from "./todo.dao";

export async function getTodoController(
request: Request,
response: Response,
next: NextFunction
): Promise<void> {
try {
const todo = await getTodo(request.params.id);
if (todo) {
response.send(todo);
} else {
response.sendStatus(404);
}
} catch (error) {
next(error);
}
}

Elimination of service layer ๐Ÿ”—

The classical layered architecture separates between endpoints, services and data access layers. In my experience, the service layer is an unnecessary indirection between the endpoints and the data access layers. I acknowledge though that there is complicated logic that should neither be in the endpoints nor in the data access layers. In this architecture, this complicated logic is accessed beside the data.

  • Endpoints
    • Data Access
    • Complicated Logic

In code, this looks like this.

import { NextFunction, Request, Response } from "express";
import { sendNotification } from "../../jobs/notifications/send-notification";
import { updateTodo } from "./todo.dao";

export async function putTodoController(
request: Request,
response: Response,
next: NextFunction
): Promise<void> {
try {
const updatedTodo = await updateTodo(request.params.id, request.body);
await sendNotification(updatedTodo);
response.send(updatedTodo);
} catch (error) {
next(error);
}
}

The controller accesses the database via the data access function updateTodo and then calls a function sendNotification to execute complicated logic in a separate module. We will talk about this aspect of the architecture in a later post about modules for complicated logic.

Conclusion ๐Ÿ”—

We explored how controllers are organized in the file system with data access functions, router and types. There is no service layer and there are no separate types within the layers. The next blog post is about testing these controllers.

Become a GitHub sponsor to access the code of this post as a GitHub repo.