Layered Architecture
Learning objectives
- Knows that both the multitier architecture and the client-server model are used to represent how the functionality of an application is divided across logical entities such as servers.
- Knows the concept layered architecture and how it is used to represent how code within an application is structured.
- Knows what a controller is and how it can be used in a web application.
Both Client-server model and Multitier architecture refer to the way how the application is structured over a set of servers, but neither of these discuss how the code of a web application is structured. Layered architecture refers to organizing code into layers that each have their own responsibility within the application.
Note that the concepts tier and layer are often used interchangeably, leading to confusion between multitier architecture and layered architecture. In this material, the term tier refers to separate logical entities such as servers, while the term layer refers to the division of code within an application.
Layers in layered architecture are organized horizontally, where each layer abstracts away implementation details of the layer below. Layers only communicate with the layers below them (i.e. call the functions exposed by the layers below them). Communication with the above layers is not allowed, i.e. a lower layer must not call a function of an above layer.
As an example, the image below shows a layered architecture that consists of three layers: a presentation layer, a business logic layer, and a database access layer.

When contrasting the way how, when learning to work with databases, our code was divided into three folders (database
, services
, and views
) with the layered architecture with three layers, we see some connections. The folder views
could be seen as the presentation layer, the folder services
could be seen as the business logic layer, and the folder database
could be seen as the database access layer. As with all architectures, some detail is omitted from the architectural description -- in our case, e.g. the code in app.js
is not discussed at all.
A part of the code in our app.js
has actually been responsible for controlling which functionality is called for which request. In practice, web applications often have an additional layer for controllers, which handle specific incoming requests. Using the terms view and service instead of presentation and business logic, and adding controllers into our architecture, an architecture with four layers would look as follows.

Let's briefly look into creating a separate controller layer into the addresses application that we worked on previously when learning to work with databases.
The addresses application has the following folder structure and the following app.js
file.
tree --dirsfirst
.
├── database
│ └── database.js
├── services
│ └── addressService.js
├── views
│ └── index.eta
└── app.js
3 directories, 4 files
import { serve } from "https://deno.land/std@0.222.1/http/server.ts";
import { configure, renderFile } from "https://deno.land/x/eta@v2.2.0/mod.ts";
import * as addressService from "./services/addressService.js";
configure({
views: `${Deno.cwd()}/views/`,
});
const responseDetails = {
headers: { "Content-Type": "text/html;charset=UTF-8" },
};
const redirectTo = (path) => {
return new Response(`Redirecting to ${path}.`, {
status: 303,
headers: {
"Location": path,
},
});
};
const deleteAddress = async (request) => {
const url = new URL(request.url);
const parts = url.pathname.split("/");
const id = parts[2];
await addressService.deleteById(id);
return redirectTo("/");
};
const addAddress = async (request) => {
const formData = await request.formData();
const name = formData.get("name");
const address = formData.get("address");
await addressService.create(name, address);
return redirectTo("/");
};
const listAddresses = async (request) => {
const data = {
addresses: await addressService.findAll(),
};
return new Response(await renderFile("index.eta", data), responseDetails);
};
const handleRequest = async (request) => {
const url = new URL(request.url);
if (request.method === "POST" && url.pathname.startsWith("/delete/")) {
return await deleteAddress(request);
} else if (request.method === "POST") {
return await addAddress(request);
} else {
return await listAddresses(request);
}
};
serve(handleRequest, { port: 7777 });
We can see clear functionality related to handling incoming requests. The functions addAddress
, deleteAddress
, and listAddresses
each represent a specific task in the application. Let's restructure the code a bit and move them to a separate file.
Let's create a file called addressController.js
and place it into a new folder called controllers
. Now, the folder structure is as follows.
tree --dirsfirst
.
├── controllers
│ └── addressController.js
├── database
│ └── database.js
├── services
│ └── addressService.js
├── views
│ └── index.eta
└── app.js
4 directories, 5 files
Let's then move the functions addAddress
, deleteAddress
, and listAddresses
that define how specific requests should be handled to the addressController.js
file. In addition to moving the functions, we also need to import the functions from the addressService.js
, as well as the renderFile
function from Eta.
Now, the file addressController.js
looks as follows.
import { renderFile } from "https://deno.land/x/eta@v2.2.0/mod.ts";
import * as addressService from "../services/addressService.js";
const responseDetails = {
headers: { "Content-Type": "text/html;charset=UTF-8" },
};
const redirectTo = (path) => {
return new Response(`Redirecting to ${path}.`, {
status: 303,
headers: {
"Location": path,
},
});
};
const deleteAddress = async (request) => {
const url = new URL(request.url);
const parts = url.pathname.split("/");
const id = parts[2];
await addressService.deleteById(id);
return redirectTo("/");
};
const addAddress = async (request) => {
const formData = await request.formData();
const name = formData.get("name");
const address = formData.get("address");
await addressService.create(name, address);
return redirectTo("/");
};
const listAddresses = async (request) => {
const data = {
addresses: await addressService.findAll(),
};
return new Response(await renderFile("index.eta", data), responseDetails);
};
export { addAddress, deleteAddress, listAddresses };
In app.js
, we no longer use the functions in the file, and can move them. Instead, we need to import the functionality provided by the addressController.js
, and call them for the incoming requests. After the changes, the file app.js
looks as follows.
import { serve } from "https://deno.land/std@0.222.1/http/server.ts";
import { configure } from "https://deno.land/x/eta@v2.2.0/mod.ts";
import * as addressController from "./controllers/addressController.js";
configure({
views: `${Deno.cwd()}/views/`,
});
const handleRequest = async (request) => {
const url = new URL(request.url);
if (request.method === "POST" && url.pathname.startsWith("/delete/")) {
return await addressController.deleteAddress(request);
} else if (request.method === "POST") {
return await addressController.addAddress(request);
} else {
return await addressController.listAddresses(request);
}
};
serve(handleRequest, { port: 7777 });
Now, the addresses application would have four layers. Each incoming request is first handled by the handleRequest
function that directs the request to a function of a controller. The controller then uses functions from services, which then use functions from a database abstraction.
To summarize the concept of a controller, a controller defines how a request is be handled. Note that there exists also a concept front controller, which is a controller that is responsible for handling all requests to the web application and directing them to specific controllers. In our case, the if
-statements in the handleRequest
function could be seen as a front controller, although it is very rudimentary.
When considering the layered architecture, web applications often have common functionality such as utility functions, configurations, and so on. These are typically omitted from the layered architecture representation -- if these would be represented in the layered architecture, they would typically be placed to either side of the architecture as cross-cutting functionality.
Common functionality could also be extracted to separate utility file. As an example, the redirectTo
function and the responseDetails
object would both likely be used in multiple controller files and as such it would be meaningful to extract them to a separate file.