I use a simple way to structure the server-side code of my web applications. It divides modules into different sets and uses strict import rules to make code easy to maintain. While I mainly work with Node.js and the Express framework, it also works for other languages and lightweight frameworks that do not impose their own preferred way of structuring the applications. For example, I have used this for writing web application backends in Prolog and Java.
Sets of modules:
- Object graphs.
A handler module will set up routes considering one thing. For example,
auth module will set up routes for user registration, authentication and password reminder. A handler module will call specific functions on service modules. HTTP-specific objects (like
res in Express) must not be passed to services. All HTTP-specific functionality like cookie parsing or decoding a file upload must be handled in handlers or in middleware. Handlers render HTML using the data from the service modules.
Handlers can import service modules or middleware (to apply per-route) and must not import other handlers and repositories.
Middleware will apply a function to a set of routes. This includes common functionality like cookie parsing. Middleware is a central concept in the Express framework but can be found in some form in most modern server-side web frameworks.
Middleware might import services and call service functions, for example, to load currently logged-in user's data per request.
Middleware modules must not import handlers, services or repositories.
A service will pull data from repositories, will combine it and calculate results, save data and coordinate database transations between repositories. This is where the main "business logic" will go. This is essentially the transaction script design pattern. This works extremely well for simple applications with near-trivial business logic.
A service is a good place to put input data validation code. This could be as simple as assert calls to check a service function arguments.
Service modules are a good place to call external HTTP or RPC-based APIs.
Service modules can import repositories and must not import other services, handlers and middleware.
A repository is a place for database queries. An example function from the
save(userData). This will execute the actual query to save the user. The main point for having repository modules is to free service modules from database-specific code. Database-specific code can be very verbose if it is generating complex SQL queries or assembling data objects from a key-value store. This would clutter other modules like services.
Repositories must not import modules of any other role mentioned in this article. They will likely import database drivers.
Make complex object graphs separate so they can be reused from multiple services but do not let any object use service or repository functions. Make complex object graphs buildable from simple data you obtain through repositories. This makes complex business logic easily testable with mock data.
Object graph modules can import other object graph modules but not import modules of any other role mentioned in this article.
This is the table with module import rules. Read from left to right: a handler module may import middleware modules or service modules but not others.
Each module can always import any external module from external libraries whenever it makes sense. For example, a repository might import a database driver, an authentication handler module might import crypto libraries to sign or verify signatures and so on.
Main entrypoint of the application will load middleware and handlers. Through the transitive import graph, whole other application code gets loaded through them.
A module is a collection of related functionality. It depends on the application size (as domain size, a number of different entities: blog post, user, order, taxi ride etc.). In a very small application, only one module of each might be sufficient. As the application grows, modules can be split up (like having different handler modules for different user registration methods).
The code can be trivially divided into the directories:
- Handlers in
- Middleware in
- Services in
- Repositories in
- Object graphs directly in
libor in a subdirectory named after the object graph functionality.
Relative to the project root or the
src or the
lib directory. This makes it easy to find them in a text editor.
This design gives very easy to follow data flow. If you are debugging existing functionality, log or observe data at handler, service, or repository boundaries and see if you get everything those functions expect. If you are adding new functionality, start with a new handler and then see if you could reuse service functions, or if you add new service, then check if you can reuse existing repository functions.