You have likely heard the term “architecture” associated with software. If you already know what architecture means in the context of software, feel free to skip to Environment Setup, otherwise, keep reading.
Webapps are like onions
If you have been playing with code since before frameworks were the main driving force behind the web, you may remember early times of dreadful beginner-PHP websites, where all the code was just put in one huge file. That was a pain to write, a pain to maintain, and an absolute nightmare to refactor.
This is one of the reasons why people much smarter than me pushed on with the idea to split code into separate files. Suddenly, things got easier. After a while, the concept of layers emerged. Implementations may differ in the number and specificity of these layers, but one tried and tested approach is to separate your app into at least the following layers:
Application Logic Layer
Data(base) Logic Layer
API Layer (controllers)
Webapps usually need a way to communicate with the client. This can be through a RESTful API, GraphQL, WebSockets, or anything else. Whatever method you use, the API Layer is the one where you will implement the methods that enable communication between the client and your your back-end. But beware, this includes only and exclusively the code that deals with getting the request from and the response to the client. Everything else will be handled by the…
Application Logic Layer (services)
If the API Layer is the receptionist who calls the person you have a meeting with, the ALL is this person.
When the app receives a request from a client, there are many things that have to be done. The request has to pass through:
- authentication, verification that whoever sent is who they say they are,
- authorization, verification that whoever sent it is allowed to do what they are trying to do,
- data sanitizing, removing data that should not get into the app or be returned from it,
and that is just the beginning. Once the user and their rights are verified, the data the user provided has to be converted to a uniform format, all parts of our app understand. I will explain this in more detail in the chapter on Models.
Once converted to a model, the business logic, which is inherent in the model, is executed on it, and it is then passed further to the…
Data(base) Logic Layer (repositories)
This one is quite self-explanatory. It handles database operations, maybe with raw SQL, maybe with an ORM, but the point is that it handles data(base) CRUD and nothing else.
Models do not really fit in as one of the layers, as they are used by both the ALL and DLL to ensure a more predictable communication, and they also hold the business logic. At its core, a model is an object with a constructor function, which guarantees that the object will have certain properties, i.e. it makes sure it will either look exactly the way we expect it to look or throw an error.
Now, in more advanced implementations, one could auto-generate all the basic CRUD logic for the whole app just by parsing model definitions, but for the sake of simplicity, we will code it manually. However, knowing this detail should be a good hint at how powerful good modelling can be.
This was all highly theoretical, so here is a simple example:
The client (mobile app, front-end…) sends a POST request with new user data to the route /users.
Before hitting the route POST /users callback in the user controller (API Layer), the authentication middleware or service triggers, which verifies (e.g. by checking request headers for authentication data) that the request is indeed coming from a client we trust.
If it is not, the authentication service responds negatively and the route returns a 401 — not authenticated error, which the middleware or user controller, depending on where authentication is triggered, sends back to the user.
If it is, the authentication service returns a positive, potentially adds authentication data to the request object for later use, and the process continues.
The next middleware or the user controller itself now calls the authorization service, to verify that this specific client actually is allowed to create new users.
If it is not, the authorization service responds negatively and returns a 403 — forbidden error, which the middleware or controller, depending on where authorization is triggered, sends back to the user.
If it is, the authorization service returns a positive, and the process continues.
Finally, the next middleware or service called by the user controller triggers — the data sanitizing service, which makes sure that all the fields have expected type, length, perhaps even content (e.g. with enum fields), depending on the situation.
If the rules are set to be very strict, the data sanitizing service can return a 400 — bad request error, if there are any unexpected fields, or it can silently remove them (never just ignore and pass on unrecognized and unchecked user input!).
Once data sanitizing is done, the user controller passes the “safe” data down to the user service, specifically to the create user method that the service provides. The create user method then:
packages the received data into a user model,
calls any required methods (business logic) on the user model, to change it further to the desired state (e.g. makeAdmin, givePermissions, etc.),
passes the user model down to the user repository, specifically to the create user method that the repository provides.
The user repository saves the model to the database by whichever method we implemented and returns the database response to the user service.
In this guide, we will use repositories only to save data to a DB, which is the most common scenario, but repositories could also "save data to a piece of paper" via printing, handle data by controlling a robot arm that moves "physical representations of data" around, or make a request of their own to an external service on another server and return the response of that.
When the user service receives the result of the database operation, it can do one of several things:
If we would like to return the user that was just created to the client, and the repository did not return all the data that were saved, we do another call to the user repository, this time to the get user method, with the data by which we can find the new user (usually some sort of a unique id).
When the user service receives the new user data from the repository, because, as a rule, services and repositories communicate by passing only models and metadata (e.g. how many results we want for a general get request), it will already be packed in a nice model, which we simply pass back to the user controller.
If we just want the client to know that the operation succeeded, we can simply pass back to the user controller a positive answer (true, the entry id, or something similar).
When the controller receives the data from the service, it calls the data sanitizing service, to make sure that any sensitive data (e.g. passwords, private keys etc.) get cleaned out of the response, if the database response happened to include them.
Once the model is clean of anything sensitive, the controller responds to the request by e.g. encoding the clean model into JSON and sending that to the client.
Y u no keep it simple???
Yes, this may look complicated, and if you are just building a tiny microservice with only a couple of endpoints, which will only be used internally and not be exposed to the web, and which is not mission-critical, you can get away with a much less complex setup. However, if you are building something even a little bigger that has to be maintainable and “safe”, this is the bare minimum of what you need.
And there is another upside — even the most complex application I have ever seen, an enterprise-level behemoth with a team of 20+ people working on hundreds and hundres of files, it was only a variation of these general principles. Admittedly, it was way, WAY more complex, with several sub-layers within each of these layers, and a lot of things were autogenerated from other autogenerated things, but the general idea was the same.
Apart from general maintainability and safety, there is also a third reason that arguably fits under the umbrella of maintainability but is important enough for me to single it out.
If you have everything in a single file, and all types of logic mixed up, what will you do, if you have to change your database to a different flavor of SQL? Or, even worse, if you change from SQL to NoSQL? Or if you decide you need Oauth, not just basic auth? Or any other "breaking change"?
You will commit ritual suicide with a spatula, that is what you will do.
If all your logic is neatly separated without any overflow from one layer to the other, things are very simple. Need to go from SQL to NoSQL? Easy, just write the new set of repositories and re-bind the calls in services. Since the data that comes in and goes out is always packaged in a model, as long as you “teach” your repository to receive and output these, everything else is guaranteed to keep happily chugging along.
The same goes for any other major changes.
If you do things right, most of the time, you will only have to do major changes to a single layer, without having to worry about others. And this is not even getting into how much less premature-baldness-causing it is to test nicely layered and modularized code.
So… you also mentioned business logic…
Yes, yes, I have. Business logic is the part of the app, which determines what happens with the data. If we take an insurance app as an example:
- The request is you sending your info in an envelope to the insurance agency.
- The controller is the agency front office making sure your info is relevant and passing it to an insurance agent.
- The service is the insurance agent, who enters your info into his form (a model), potentially gets supplementary data on you from their database and other sources (repositories), and then runs whatever maths he has to run, to get you your quote.
This maths, he runs, is the business logic. Put simply, business logic is the part of your app which cannot be written without the programmer knowing how your business does “its thing”. The controllers, services, and repositories can be generic, auto-generated, and still handle CRUD well enough.
Models (with some exceptions, and even then only partially) and their associated business logic absolutely cannot be generic. You can write a create method, which will be able to save anything you throw at it, be it a medical file or a recipe for pumpkin soup. You cannot, however, create a generic model, which will provide all the required functionalities for both these things without seriously breaking just about any good design principle in existence..
So, when you hear application logic, think “the logic that passes data around the app, and tells repositories and models what to do”.
When you hear data(base) logic, think “the logic that takes some data and/or metadata and does database magic based on that.”
When you hear model and/or business logic, think “the incarnation of the data with methods that define everything and anything that the app can do with the data”.
Time to get codin’!
This was the (not-so-)short intro to app architecture. I hope that you now have at least a vague idea about the hows and whys. Anyway, without further ado, let us dive into the real deal.
RESTful: A set of principles for building APIs;
GraphQL: A new(er), powerful alternative for REST;
WebSockets: A protocol for two-way (full duplex) server-client communication — unlike REST and GraphQL, sockets support pushing data;
request: The data and metadata the client sends to the server
response: The data the server returns to the client after handling the request
ORM: Object Relational Mapper; through use of various techniques greatly simplifies working with (often several) types of databases;
CRUD: Create, Read, Update, Delete; the basic set of operations you do on a data source;