Layered Architecture
Learning Objectives
- You know of layered architecture.
As applications grow, a disorganized codebase will become difficult to manage, making it easier to introduce bugs and slowing down development. One common way to organize code is to use the layered architecture.
Application as layers
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.
The repository pattern is an example of a layered architecture, where a repository layer is used to separate data access logic from business logic.
When we think of our applications with the client-side functionality (“presentation”), the server-side functionality, and the repository layer for abstracting the database from the rest of the server-side functionality, we can view the application as a layered architecture with three layers and the database, as shown in Figure 1.
Bigger server-side applications often have a layer for controllers, which handle specific incoming requests, and services that provide the functionality for the controllers. When adding controllers and services to the architecture, a layered architecture with four layers and the database would look as follows in Figure 2.
When working on server-side functionality, we have recently focused on building APIs. The term controller is often used interchangeably with API — in web software development, a controller is a part of the server-side functionality that handles specific incoming requests, and is responsible for calling the functions (e.g. from a service or from a repository) to fulfill the request.
Application with layered architecture
To demonstrate the use of the layered architecture concretely, let’s create an application that allows retrieving and changing an object with the property “information”. For simplicity, we omit the database and the client-side functionality, and focus on the layers.
The application would consist of four files, which are as follows:
app.js
would be the entrypoint to the application that would map the routes to the controller.informationController.js
would provide the functionality for the routes.informationService.js
would provide the functionality for the controller.informationRepository.js
would provide the functionality for the service.
Let’s start with the informationRepository.js
file, which would look as follows:
const data = {
information: "Hello, World!",
};
const getInformation = () => {
return data;
};
const setInformation = (newInformation) => {
data.information = newInformation;
};
export { getInformation, setInformation };
In the case of our example that just retrieves and changes data, the informationService.js
could be omitted. Let’s however add it — it just passes information to the repository:
import * as informationRepository from "./informationRepository.js";
const getInformation = () => {
return informationRepository.getInformation();
};
const setInformation = (newInformation) => {
informationRepository.setInformation(newInformation);
};
export { getInformation, setInformation };
The informationController.js
would provide the functionality for the routes:
import * as informationService from "./informationService.js";
const getInformation = (c) => {
return c.json(informationService.getInformation());
};
const setInformation = async (c) => {
const json = await c.req.json();
informationService.setInformation(json.information);
return c.json({ message: "Information updated." });
};
export { getInformation, setInformation };
Finally, the app.js
would be the entrypoint to the application that would map the routes to the controller:
import { Hono } from "jsr:@hono/hono@4.6.5";
import * as informationController from "./informationController.js";
const app = new Hono();
app.get("/information", informationController.getInformation);
app.post("/information", informationController.setInformation);
Deno.serve(app.fetch);
Now, GET requests to /information
would return the information, as shown below.
curl localhost:8000/information
{"information":"Hello, World!"}%
Similarly, POST requests to /information
could be used to update the information.
curl -X POST -H "Content-Type: application/json" -d '{"information": "hello"}' localhost:8000/information
{"message":"Information updated."}%
After changing the information, GET requests to /information
would return the updated information.
curl localhost:8000/information
{"information":"hello"}%
Validation in controller
Validation of incoming data would be handled in the controllers. To add validation to our application, let’s create a file validators.js
that has basic validation functionality. Create the file and add the following code to it.
import { z } from "zod";
const informationValidator = z.object({
information: z.string().min(1),
});
export { informationValidator };
Now, if we would wish to use the validator in the informationController.js
file, we would import the validator, and use it with the Zod validator middleware. The key change would be in the setInformation
function of the informationController.js
file, which would return an array consisting of the validator and the function that sets the information. After the change, the informationController.js
file would look as follows.
import { zValidator } from "zValidator";
import * as informationService from "./informationService.js";
import { informationValidator } from "./validators.js";
const getInformation = async (c) => {
return c.json(informationService.getInformation());
};
const setInformation = [
zValidator(informationValidator, "json"),
async (c) => {
const json = await c.req.valid("json");
informationService.setInformation(json.information);
return c.json({ message: "Information updated." });
},
];
export { getInformation, setInformation };
Now, we would modify the app.js
so that it destructures the array from the setInformation
variable exposed by the controller, passing the destructured array as the parts for the route. The app.js
file would look as follows.
import { Hono } from "jsr:@hono/hono@4.6.5";
import * as informationController from "./informationController.js";
const app = new Hono();
app.get("/information", informationController.getInformation);
app.post("/information", ...informationController.setInformation);
export default app;
With these changes, the application would validate the information sent to the server, returning an error if the data is not valid.
curl -X POST -H "Content-Type: application/json" -d '{"info": "hello"}' localhost:8000/information
{"success":false,"error":{"issues":[{"code":"invalid_type","expected":"string","received":"undefined","path":["information"],"message":"Required"}],"name":"ZodError"}}%
On the other hand, if the information is valid, the information would be updated.
curl -X POST -H "Content-Type: application/json" -d '{"information": "hello"}' localhost:8000/information
{"message":"Information updated."}%
Returning a list with the Zod validator middleware and the function used to set the information is a trick that can make the code more readable, even though it might seem confusing at first. In the end, much like the controller, the app.js
does not need to know about the validation middleware, and the validation middleware does not need to know about the controller.