Design: Layers

You always build on top of something

Jérôme Beau
6 min readJun 19, 2024
Each layer of the skin have a responsibility: the cuticle (1) strengthen and protect the hair shaft, the soft layer (2), the network of nerves (4) vehicle sensations, the dermis (5) allows to sense heat, and three nerves (6) divide from the network to communicate information deeper.

Maybe you have been wondering once and a while: “Why splitting those instructions in multiple layers calls? It would be simpler to aggregate them!”

Why layering?

Layers allow to separate concerns in a software architecture. By assigning a family of objects (such as “controllers”, “services”, etc.) to a layer, you’re defining a common type between them, either explicitly or implicitly. All objects from such a layer are expected to handle the same responsibility (like handling interactions, performing business calls, fetching data, etc.).

Such a “normalization” of roles in your software have a number of benefits:

  • it makes easier to locate what software part is doing something, whether it is to fix, refactor or improve it. If there’s something to look about “user interaction” or “storage”, a “public” or a “private” API, you’ll know where to look, right away. Even if you need to create something new, you’ll know where to put it.
  • it allows to quickly spot a missing piece in the normalized architecture. If every business path has a controller and a service, why this new business item doesn’t have a service like the others?
  • it eases isomorphic design, since you can use the same layering roles on both front and back ends (with the same naming schemes). Those shared concepts can typically be provided to both ends from a “common” package.
  • it allows scaling and robustness by defining system parts which can be duplicated to handle more requests in parallel or to fail over.

How to layer?

You can layer in two directions:

  • Vertically: lower layers contain more basic, primary, sometimes atomic concepts, whereas higher layers contain most sophisticated and abstract ones, which usually encapsulate the lower layers.
  • Horizontally: At a given abstraction level, multiple concepts or processes can constitute a chain of responsibilities to produce some effect.
The two dimensions of layering

Examples of such layering can be found in many software architectures, such as:

  • the OSI model probably the most famous vertical layering of abstractions in computer science, from hardware cables to the HTTP protocol and above :
The OSI network layering defines 7 abstraction layers but only 1 responsibility (to transfer data)
On the other way, the MVC pattern describes only 1 abstraction level, with 3 responsibilities collaborating

Software architecture

As you can guess, layering is also an essential aspect of architecture. In a client-server one, the MVC pattern usually maps like below, where view, controller and model responsibilities span horizontally, while abstractions span vertically:

MVC applied to a client-server architecture: the client call goes down to the HTTP abstraction to issue a request, which is handled at the same abstraction level on the server side, then managed with the business rules of an higher abstraction level. Finally, that service abstraction asks a lower abstraction to perform storage operations.

Client

If you would zoom on the client side, you would also find a client-level controller (the one of the client UI system) and a model (a.k.a. the “view model”) built from an HTTP response. For instance:

An example of zooming on the client-side MVC, where the model is the a Data Transfer Object (DTO) exchanged between the client and the server. Because those common types definitions need to be synchronized between client and server, you would typically define them in a “common” package of a monorepo to be deployed with both client and server.

Note that the JSON DTO might not be the model used by the view(s) in the end, which may required adaptations of it, depending on the view requirements.

Server

On the server side, a typically sequence of request handling through layers would like below:

  1. the controller receives receiving HTTP interactions (instead of UI client interactions like clicks, text inputs, etc).
  2. It translates them to calls to the service API of the “model” layer, which applies business rules.
  3. the service layer calls a lower provider abstraction, which in turn translates business params into storage formats. That provider is typically stateless and “dumb”, i.e. it never implements business rules, since it can be switched for another. It typically calls ever lower abstractions such as one (or more) database(s), or a chain of (formatting, ciphering, logging, etc.) responsibilities and, as a low-level layer, returns null/undefined values instead of throwing errors (since those edge values might be interpreted differently by the different upper layers).
Request handling through server layers

Speaking about translating from some layer semantics to another, the above diagram gives you hints about what semantics should be used in each API:

  • the controller must translate to service semantics (i.e. translate HTTP requests to business objects parameters) because the call crosses a responsibility layer (horizontally).
class UserController {

constructor(private service: UserService) { }

handle (req: IncomingMessage) {
const user = JSON.parse(request.body) as User // Translates HTTP payload to User
switch (httpRequest.method) {
case "POST": return this.userService.create(user)
}
}
}
  • the service and provider use the same semantics, because they remain in the same responsibility layer (vertically). It is only about abstraction, so the lower layer role is to adapt upper semantics to his own lower semantics:
class UserService {

constructor(private provider: UserProvider) { }

async create (user: User) {
if (await this.provider.exists(user)) { // Same user semantics
throw new UserAlreadyExistsError(user.key)
} else {
await this.provider.insert(user) // Same user semantics
}
}
}

Why that? Because the provider can have multiple implementations, so you can’t use implementation specifics in such an encapsulating API. This will allow to switch your implementation to:

  • change your storage engine (happens but not so frequently) ;
  • easily unit test the service with a mock “memory” provider (instead of requiring the setup of a network or database provider);
  • use those layers in an isomorphic architecture (happens very often nowadays). Nothing in the service code above is specific to a client or server side.

Isomorphism

This latter isomorphic design pushes the normalization up to using the same APIs on the front and back ends, but this can even go beyond that: thanks to our separation of business code and the lower provider layer, the UserService implementation can shared between both ends, and so be moved into the common package as a set of business rules which don’t need to change depending on where they are executed. Each instantiation of it will just use use either a client or a server provider:

Decoupling the UserProvider abstraction from the UserService abstraction allows to setup an isomorphic software architecture where both client and server use the same UserService API, but with different ways of providing the user (through HTTP and through a database read, respectively).

or, in code terms:

clientUserService = new UserService(new HTTPUserProvider("https://myserver"))
serverUserService = new UserService(new JSONUserProvider("firestore:db1"))

This can save you code maintenance cost, since the same business code can be mutualized on client and server side. That means a smaller codebase, less bugs, and avoiding to implement the same business feature for both sides.

Conclusion

Layering helps to warrant a good maintainability of your software through normalization of a software architecture. Such a normalization, also helps to make such an architecture flexible to the point of implementing an isomorphic design.

--

--