Three-layer architecture (also known as clean) assumes the division of application code into "layers" with clearly defined responsibilities.
Three-layer architecture resembles onion architecture but differs slightly in details. For small applications, these differences are insignificant, and we can consider these concepts synonymous. However, for a better understanding of the difference, we recommend reading the article "Onion Architecture" 🧅
Application Layers
Three-layer architecture implies the division of code into 3 layers:
- domain;
- application layer;
- ports and adapters layer.
In diagrams, layers are usually nested:

Let's consider each layer separately and determine the criteria for classifying code as belonging to one of the layers.
Domain Layer
The domain layer contains the code and data from the subject area of the application. The code in the domain layer is the most important part that distinguishes one application from another. Sometimes the domain layer (or simply the domain) is also referred to as business logic.
For example, in an online store, entities such as user, product, cart, and order can fall into the domain. Code for creating a new user or order, functions for calculating the total price of the cart and discounts on products, and functions for adding and removing products from the cart — this is all part of the domain.
However, code for communicating with the database or rendering the interface, for example, does not belong to the domain. It does not fall into the subject area of the application; rather, it "services" it so that data transformed by the domain can be saved in the database or displayed to users.

In the diagram, the domain is located in the center because it determines how the rest of the application's code will be organized.
The domain defines:
- In what format it is necessary to transmit data from the outside world.
- Through which methods and functions it can be accessed.
- In what format it will provide the result.
In JavaScript, domain data is usually represented as structures: objects, arrays, built-in primitives.
const user = { name: "Alex", email: "say-hi@alex.com",};
const user = { name: "Alex", email: "say-hi@alex.com", };
A schematic description of the data structure is called a type. Types specify which fields and values the structure should contain. Domain data types are called domain types.
It is most convenient to describe data types using TypeScript. With it, a data schema for an entity can be described as follows:
type User = { name: string; email: string;};
type User = { name: string; email: string; };
The above code can be read as: "Data of type User
must contain 2 fields: name
and email
. The values of both fields must be strings."
JavaScript, however, can only offer classes for type description.
class User { constructor(name, email) { this.name = name this.email = email }}
class User { constructor(name, email) { this.name = name this.email = email } }
Application Layer
Surrounding the domain is the application layer. It contains the code for scenarios and use cases of the application.
Typically, these are command handlers that execute user scenarios. The layer also contains the interfaces of the ports and adapters.
In our store example, the application layer would describe how the purchasing and payment processes occur, what orders a user can see, what a seller or store administrator can see. It would also house the interfaces of modules that receive data from external services (but not the modules themselves).

Typically, the application layer code mostly consists of:
- obtaining the necessary data;
- calling domain code to transform data according to scenarios;
- saving or sending results.
The application layer implements functional architecture, where data retrieval and sending are handled by functions with side effects, and data transformation is handled by pure functions.
Layer separation encourages leaving functions in the domain that are solely responsible for data transformation. They do not store anything and do not make requests themselves — they depend only on arguments. Because of this, they are easier to test and modify.
The application layer can act as an "impure" context that ensures data is sent to the domain layer in the format it requires and processes results.
import { source } from 'ports/input'import { target } from 'ports/output'import { updateProduct } from 'domain/product'function renameProductHandler(command) { // Get product from external service through port (side effect): const product = source.getProductById(command.productId) // Call the domain update function (pure function): const updated = updateProduct(product, { name }) // Save the product to the external service through port (side effect): target.saveProduct(updated)}
import { source } from 'ports/input' import { target } from 'ports/output' import { updateProduct } from 'domain/product' function renameProductHandler(command) { // Get product from external service through port (side effect): const product = source.getProductById(command.productId) // Call the domain update function (pure function): const updated = updateProduct(product, { name }) // Save the product to the external service through port (side effect): target.saveProduct(updated) }
It is important to note that the application layer never directly calls external services. It also does not contain code for ports and adapters, but only their interfaces — that is, the application layer dictates the specifications and contracts under which external services should interact with our application.
Implementation of these interfaces resides in the outer layer — the ports and adapters layer.
Ports and Adapters Layer
This layer contains the code for connecting the application with the outside world.
A port is a specification of how an external service can interact with our application, or how our application wants external services to interact with it.
For example, we can specify how we want to communicate with a data storage system. Let our application be comfortable with the storage providing a method to save a product save
and a method to retrieve a product by its ID get
.
interface ProductsStorage { saveProduct(product: Product): void; getProductById(id: UniqueId): Product;}
interface ProductsStorage { saveProduct(product: Product): void; getProductById(id: UniqueId): Product; }
Then an external service that will implement the data storage will be obliged to contain these two methods. If the external service's API does not align with our preferences, we will write an adapter.
An adapter is a connector that makes an incompatible interface of an external service compatible with the one that our application requires. For instance, if the API returns data in snake
, while we want camel
, adapters will handle the conversion.
// Adapter for fetching data (browser fetch):function fetchUser(id) { return fetch(`/users/${id}`)}// User data adapter, converts field names to the required format:function fromResponse(serverUser) { const { Name, Email } = serverUser return { name: Name, email: Email }}
// Adapter for fetching data (browser fetch): function fetchUser(id) { return fetch(`/users/${id}`) } // User data adapter, converts field names to the required format: function fromResponse(serverUser) { const { Name, Email } = serverUser return { name: Name, email: Email } }
In the frontend, the incoming port is most often the user interface. Handling user events and re-rendering the screen are tasks specifically for the ports and adapters layer. The outgoing ports can be called those interfaces that communicate with, for example, the backend server.

If we are writing some console service or API server, then the incoming ports in these cases will be the console and API endpoints.
In addition to layers, the concept of three-layer architecture also encompasses the terms infrastructure and "shared kernel."
Infrastructure refers to the code that connects the application to the database, external services such as sending SMS, mailing, and more. In the frontend, infrastructure often refers to the backend server and third-party APIs like payment systems.
Shared Kernel refers to code that is available to any module, but from which dependency does not increase their coupling. In the frontend, shared kernel can include globally available objects like window
. In some cases, the programming language itself is also considered part of it.
Sometimes the shared kernel and layers may overlap — this depends on the depth of design. If the code, for instance, runs both in the browser and on a server, then it cannot directly depend on global objects, and we need to use adapters.
Advantages of Layered Separation
Highlighting the domain, application layer, and adapters with ports allows us to:
- Separate responsibilities among modules according to their purpose.
- Organize code so modules are easier to replace and test.
- Assemble and deliver an application not only as a whole but also as separate features.
- Abstract code and reuse modules in different projects.
In general, the degree of elaboration and design depends on the application requirements. It is one thing when we have a small online store that operates only in the browser, and another when we have a React application that should work in the browser, render on the server, and share code with a project in React Native.
A well-designed application for different environments is easier to modify and maintain, but the implementation will be more expensive.
Interaction of Layers
Three-layer architecture not only defines the layers themselves but also regulates their interaction. For example, it strictly defines the direction of dependencies.
Dependency Direction
In three-layer architecture, all dependencies point toward the domain. This means that outer layers can depend on inner ones, but not vice versa.
In classical implementations, the domain does not depend on anything — it is "bare" specifications of business logic, its data, and the code for processing them.
The application layer depends only on the domain. It can use code and entities from the domain layer along with its own entities and data.
The outer layer can depend on anything.
This direction of dependencies compels us to program "to the domain" and keep code in each layer clean. We are, in a way, reminding ourselves that the specification of behavior is dictated by our application, and we adapt external services to its needs.
It may seem strange and inconvenient that the application layer includes interfaces for ports and adapters, but this contradiction is resolved through dependency injection.
Unfortunately, JavaScript does not have a native way to implement dependency injection, but there are libraries like inversify or inject-js.
Driving and Driven Adapters
In our diagrams, there are two "zones": green on the left and red on the right. They indicate the direction of action of the adapters and ports that fall into this zone.
On the left are the driving adapters. These are the adapters that receive signals from users and inform our application what to do.
Driving adapters can include adapters above the user interface (UI). For instance, a framework for handling user events and rendering interfaces can indeed be included in this category.
On the right are the driven adapters. These adapters receive signals from our application; it tells them what to do. Driven adapters implement the interfaces of the application's ports, wrapping around infrastructure — external services — adapting them to the needs of the application.
Reducing Costs
Architecture is primarily a tool, and when choosing a tool, it is necessary to consider not only its benefits but also the costs associated with it.
We have already considered the benefits and advantages of three-layer architecture; now let's look at the costs:
- Three-layer architecture requires more time for design and implementation.
- It raises the entry barrier to the project.
- It may increase the amount of code that the browser has to parse.
Let’s consider ways we can reduce these costs.
Start with the Domain
In practice, not every project needs a precise and complete implementation of three-layer architecture. Often, we can get by with just distinguishing the domain layer, while the other layers are defined as necessary.
It is worth highlighting the domain because it is the most critical part of the application, the core. The domain layer code contains the most important rules for data transformation that the rest of the code uses.
The cleaner, clearer, and simpler the domain, the easier it is to understand what is happening in the application. A distinguished domain is easier to test, which means the chances of writing error-free code are higher. It will also remind us of the direction of dependencies in the project and help maintain a cleaner codebase.
Meeting the Application's Needs
When writing adapters, it is essential to remember that we are not "adapting our application to the outside world," but rather — adapting the outside world to the needs of our application.
If an external service provides an inconvenient API, we should consider how to write an adapter that makes the interface convenient.
An adapter should allow us to replace an external service with another without changing the application layer code. Therefore, we should first design the interface for the port or adapter, and only then write the implementation, the "glue" between the interface and the outside world.
Example Application
As an example of an application, we can mention the tree canvas image generator. This application draws on Canvas images with fractals resembling trees.

- In the domain layer, it has modules responsible for building the fractal and working with 2D geometry.
- In the application layer, there is a module that "translates" the fractal into drawing commands for Canvas.
- In the outer layer, there are adapters for working with the DOM and Canvas.
The details of the project can be explored on GitHub.