Layered architecture for REST APIs

Layered architecture for REST APIs

Introduction

Most web applications have a supporting backend that stores application data and verifies user permissions. The backend should be maintainable and secure to provide a solid foundation for the web application. Here, we describe a simple architectural model and a set of design patterns for implementing a REST-style web API. Some design details are specific to this API style, but many principles are broadly applicable.

API requirements

To make things concrete, the requirements of our fictional but realistic API are:

  • REST-style API with JSON serialization

  • The initial version recognizes one entity, Product, with the following fields:

    • id (automatically generated)

    • name (1 to 30 characters)

    • weight in grams (> 0)

  • Create, Read, Update and Delete (CRUD) operations for Product

  • Database persistence

  • Token-based authentication and authorization (via HTTP header)

  • Two authorization levels: read or read/write

  • API is expected to grow in complexity in later versions

Layered architecture

First, we consider the macroscale code organization. As discussed in a previous blog post, related code should be placed together (cohesion) and the dependencies between code should be purposeful and understandable (coupling). One way to achieve these is to place individual code modules into larger units called layers. A layer consists of a set of related modules which are at the same level of abstraction. Layers form a hierarchy so that a layer is only allowed to depend on lower layers. Layers can be easily visualized as follows:

The responsibilities of each layer are:

  • API handles HTTP requests and JSON serialization. Additionally, it creates instances of services in lower layers (dependency injection). It is a thin layer with no domain logic, and delegates functionality to lower layers.

  • Domain services is a business logic and orchestration layer that implements high-level CRUD functionality for the Product entity. It verifies that the user has the correct permissions (read or write), and then delegates I/O to the database layer.

  • Authorization is a component in the I/O layer that cryptographically verifies tokens and checks permissions using an internal database or an external service.

  • Database is another component in the I/O layer that implements all database functionality. This includes obtaining a database connection, the database schema, and the actual CRUD queries.

  • Entities is a domain layer that contains data models for the Product entity. It validates inputs but otherwise has no logic.

  • Exceptions is a vertical library layer that defines global exceptions thrown by other layers.

Layer principles

The layered architecture is a generic architecture model that can be adapted to various use cases, such as the OSI model for networking. However, there are principles that all layer implementations should aim for. Additionally, the model has design choices that allow the model to be tuned to specific use cases.

Principle: Top-down dependencies

The most important rule of layers is that code dependencies flow from the top layers to the bottom layers. At the code level, this implies that a higher layer is allowed to import modules from a lower level, but a lower layer is not allowed to import higher-level modules. This keeps the coupling between code modules organized and understandable. If any modules have cyclic dependencies, they should be in the same layer.

Principle: Logical cohesion

Each layer is responsible for certain functionality that is distinct from the other layers. All code in the layer should contribute to this responsibility. The level of abstraction of lower layers is generally lower than in higher layers.

In our REST API, the highest layer (HTTP handling) is visible to the user, and I/O is done in a lower layer. However, we chose to model domain entities as the lowest layer, which violates the principle that the level of abstraction should always decrease in lower layers.

Design choice: Open or closed layering

We can either allow a higher layer to depend on any lower layer (open, or loose, layering), or only on the one immediately below the higher layer (closed, or strict, layering). In our case, we chose open layering to reduce boilerplate code (proxies that merely forward function calls).

Principle: Encapsulation

A layer should provide an internal interface to the upper layer(s) and hide implementation details. The interface should remain as stable as possible even when the implementation changes. In addition to improving maintainability, interfaces are a natural target for black box (behavioral) testing.

Principle: Global libraries as vertical layers

The horizontal layers are the backbone of the architecture, but usually, there is a need for generic utility libraries that are difficult to model using horizontal layers. Such libraries are placed in vertical layers, which can be used (imported) by any layer. Vertical layers decrease the purity of the architecture, so they should be kept to a minimum. Utilities that are specific to a horizontal layer, such as database handling, should instead be encapsulated and hidden in that layer.

Mapping layers to code structure

Programming languages have limited support for macro-level architectural patterns such as layers. Thus, conventions can be used to convey the link between the architecture diagram and code layout.

For implementing a layer, we can choose between these layouts:

  1. For a layer composed of a single component, such as API and domain services, we can place all code of the layer in a folder (package). The folder may have subfolders (subpackages).

  2. For a layer that is divided into discrete components with their own interfaces, such as the I/O layer, we can place each component in a separate folder.

  3. For a small layer, or during the initial phases of development, we can place the layer in a single code file. Examples include entities and exceptions. When the code base grows, we may need to refactor the layer into a folder.

To implement the Encapsulation principle, each layer should have a clear interface and the implementation details should be hidden. These can be partially expressed using programming language visibility rules, but many languages have limited support for exporting specific code units from a package. For interfaces composed of several modules, the Facade pattern can be used to denote the interface. In its simplest form, one can create an interface file (such as index.ts in TypeScript or __init__.py in Python) that exports the interface.

Design patterns

So far, we have focused on macroscale architecture, but the architecture builds upon the design decisions of individual layers. These aspects are related so that if we implemented some layers differently, we might also choose a different layer decomposition. Here, we describe some implementation strategies of individual layers.

Splitting the domain model

The domain model consists of business logic and data entities that are relevant to a user or other non-technical stakeholder. We split the domain into two layers at different levels of hierarchy: services (logic) and entities (in-memory data).

Entities are coupled to the HTTP API schema instead of the database schema because the HTTP API changes less often. Coupling to the API implies that the entities can be (de)serialized as JSON with little or no transformations. This enables the database layer to use types from entities for its interface and allows hiding all database schema details.

Repository pattern

A Repository is an interface that encapsulates CRUD access to a specific entity. It gives the illusion of an in-memory collection, such as a hash map, but it is backed by a persistence mechanism internally. Repository combines all read-write operations of a single entity into one cohesive module.

Below is the internal interface for ProductDatabase in the database layer. It implements and encapsulates database access routines for the Product entity. It uses API entities as inputs and outputs as explained in the previous design pattern. This high-level interface allows hiding details such as the choice between a relational or a no-SQL database.

 

class ProductDatabase {
  createProduct(input: ProductInput): Product { ... }
  getProduct(id: number): Product { ... }
  updateProduct(id: number, update: ProductUpdate): Product { ... }
  deleteProduct(id: number): void { ... }
}

In addition to the database layer, Repository can also be used in the domain layer. Here, it has logic for authorization checks and delegates persistence to the database layer. Example:

class ProductAuthRepository {
  createProduct(input: ProductInput): Product {
     this.authorization.assertHasWritePermission();
     return this.productDatabase.createProduct(input);
  }
}
Global HTTP exceptions

When an error occurs in one of the layers, it is thrown as a globally defined HTTP exception that is handled by the API layer. For example, when a product is queried but not found, ProductDatabase throws a ProductNotFound exception, which includes the final HTTP status (in this case, 404). The API layer catches this exception and ensures that the HTTP status and the error message are included in the serialized output. These global exceptions can be thrown at the origin, and they propagate automatically through the layers.

Constructor-based entity validation

Entity validation verifies that user input corresponds to domain rules, such as “product name must have between 1 and 30 characters”. We implement validation as part of entity object construction so that it’s impossible to create an object that represents an invalid entity. Libraries such as zod (TypeScript) and Pydantic (Python) automate validation. The entity ProductInput, used for creating a new product, conceptually looks as follows:

class ProductInput {
  readonly #name: string;
  readonly #weight: number;
  constructor(...) { /* validate and throw if invalid */ }
}

Conclusions

We applied the layered architecture to design a REST-style JSON API with authorization and database persistence. We derived four horizontal layers and one vertical layer, but this layer decomposition is not unique. For example, we could model entities as a second vertical layer, and model authorization as a distinct layer between domain services and database. Both decompositions result in identical code; thus, it is not possible to infer the intended architecture purely from the code, and supplementary documentation is needed (further reading: Key aspects of software are not expressible in code).

In addition to the architectural model, we used design patterns to implement the layered API. Both macroscale and microscale decisions affect the overall style of the code, and developers should be familiar with both.

Simplicity is always a priority for developers, and only necessary layers should be included. For example, if our API did not require authorization logic, we would not need the authorization and domain services layers. Instead, our horizontal layers would be API, database, and entities.

The layered architecture is a flexible model that can be adapted to many situations. Although layered designs often have impurities, such as vertical layers, the benefit of the model is the simplicity of its basic principles.

References

Belle, A. B., Boussaidi, G. E., Lethbridge, T. C., Kpodjedo, S., Mili, H., & Paz, A. (2021). Systematically reviewing the layered architectural pattern principles and their use to reconstruct software architectures. arXiv:2112.01644.


This article is written by Senior Software Architect Kristian Ovaska.