Design: Layers
You always build on top of something
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.
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 not-less-famous Model-View-Controller (MVC) pattern where the controller layer acts as a mediator:
- Also, AI can use DNN which help to recognize abstractions.
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:
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:
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:
- the controller receives receiving HTTP interactions (instead of UI client interactions like clicks, text inputs, etc).
- It translates them to calls to the service API of the “model” layer, which applies business rules.
- 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).
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:
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.