What is the idea?
Through my development career I've seen many codebases written in various ways. I remember struggling with code architecture and slowly learning what belongs where and why. Once I reached the full object oriented programming way of doing web development I thought that this is it, this is the way forward now.
But after some time I started having ideas for improvements, noticing things that are very rarely done but are enginnered to make possible. Turns out a lot of the OOP weight can be cut down, and it can go as far as removing all OOP altogether.
Let's see what we can trim down, but first, some background.
TLDR
Here's the simplified architecture without DI:
https://github.com/adrian-afl/oop-to-fn-experiment-blog-result
How it was before Nest.js (or Spring, Symfony) OOP web dev
Before OOP, my approach to architecture was very messy. I think code like that can be found everywhere, basically things like executing domain logic or persistence actions at the controller level, or scattering the code in various utility files with no general idea what should go where.
Code in this stage is very hard to adjust. If one day a requirement appear to add CLI interface to the app, or freeze the api for some external app, can make the team very frustrated about the architecture.
Main issues here are tightly coupled code layers, inflexibility and no idea about the code layout structure, where files are and what should be contained inside.
How it was like with the full OOP
After some time I discovered frameworks that at least try to guide their users about how to lay down the files and what are the general thing to do in most scenarios.
There is an essential tools in most of those framework, and that also makes them pretty similar, and it is dependency injection.
Let's analyze how dependency injection, for short, DI, is so awesome and why it looks like such a good idea.
The idea is that all code that do things is saved in form of classes, then classes are automatically instantiated and used. DI framework will take care of instantiating all classes in the correct order.
But the crucial thing is that if you want to use code from another class, for example, using user data access in some user logic class, you put it in the constructor as a parameter. You never call this constructor yourself, this is the DI job to read the content of the constructor and then provide correct dependencies based on this, all automatic.
Another crucial thing is that, in most cases, injecting via an interface is possible as well. This works well for alternate implementations, for example, various email sending services. If there's only one class implementing the interface in the app, it's usually automatically injected, if there are more you need to specify which will be used.
Looks awesome, but we will see later how it is in real life.
Usually, code in this stage is structured in a way, that there is a separate api layer, and separate logic layer. For example, a typical feature module would look like this:
userModule:
controllers:
UserController class:
constructor(UserService)
getUser(...)
createUser(...)
updateUser(...)
...
dtos:
UserDto interface
CreateUserDto interface
services:
UserService class:
getUser(...)
createUser(...)
updateUser(...)
In this layout there is a slight problem that the user controller and user service actually does multiple things at once. This interestingly causes another problem, confusion what should be where when the user logic grows more.
For example, you can end up with a method in user service that takes 150 lines to do the thing needed, and should you extract it? Based on what metric, where is the threshold when doing many things is too many already, and move to what? Another service? This is problematic because developer needs to think about it, rather than just know where to put it in the first place.
Going very strict with SRP
Multiple things done by services problem can be mitigated by strictly following the Single Responsibility Principle.
This will change the code a bit, because now a class is allowed to do only one main thing and handle all other related stuff.
So now we have separate classes for each endpoints, separate classes for each logical action, and dtos are gone because those are part of controllers now:
userModule:
controllers:
GetUserController class
CreateUserController class
UpdateUserController class
...
logic:
GetUser class
CreateUser class
UpdateUser class
This also means that most of the times, almost always, the classes will have only 1 public method, and you might want to call it the same always, for example execute
.
This code also puts the related data interfaces for each class inside the class file, so you get the input UpdateUser data interface inside the file.
Now the developer knows where to put the code at first glance, everything is structured in a way that there is no doubt where something should go.
This is mostly very nice, is such a pleasure to test and generally code like that looks good and is easy to follow.
But I had some ideas...
Domain classes simplified
Now this part will be a bit more related to languages that use duck typing and imports, like TypeScript, but otherwise no worries, it applies to other languages too.
To understand what will happen we need to talk about some observations with DI first:
- in DI, most of the times all of the instantiated classes are singletons, there exist only one instance always
- swapping implementations around with DI is very rarely done in real like, it's often implemented and handled correctly but then never used, so the effort is wasted
- adds a lot of verbosity in the multiple classes approach
- injecting by interfaces, apart from swapping implementations is almost never done too especially for domain code, and if done, would involve immense amount of verbosity by requiring interfaces for every single class
So, now we have a lot of classes that have dependencies and only 1 public method.
And here is the main idea.
What if the dependencies were imports and the public method was the only one exported method, and the class would be gone?
This way a class turns into a method, and imports do the injecting. And suddenly if all classes are rewritten this way, no more need for DI for domain code.
Tearing down the api code
After this transformation was done for logic code, I tackled the controllers as well, and this went a bit technical, but thanks for Fastify and Zod I managed to create a very simple api layer with automatic validation that doesn't require DI as well.
And at this point, my backend code works like it is using a framework, but there is no framework involved, there is not even something like "our own framework", rather the code is just gone completely, not needed anymore.
Final minimalistic result
To test this hypothetical architecture approach, I decided to adjust my bootstrap-like user management project backend, this is a simple app that manages users, sends emails, verifies, etc.
This is the result:
https://github.com/adrian-afl/oop-to-fn-experiment-blog-result
It's similar to the last one but the classes became files with functions
userModule:
controllers:
getUserController fn
createUserController fn
updateUserController fn
...
logic:
getUser fn
createUser fn
updateUser fn
Pros of the final result
- Removal of several big framework dependencies
- Code greatly simplified
- Architecture is very clear to follow and use
- More control over what is happening
- Less abstraction layers on api level - no framework involved in request handling adding unnecessary complexity or abstractions
- Can apply layer boundaries with linter restricting imports
Cons of the final result
- Sometimes import order will matter if the code inside executes immediately, for example, config files that read env variables immediately, but this is easily resolved anyway
- Swapping implementations is no longer that easy and requires more work, but as I mentioned, this rarely happens anyway, and ever if needed would be very simple to add
- There is some code needed to instantiate the api or other entrypoints that needs to be maintaned, but its mostly simple and gives much more control over handling anyway
- Mocking can be problematic depending on language, but inside Node.js and using Jest this is not a problem at all, just mock the function and done
Final Remarks
I think this is good way to structure the code and to remove unnecessary dependencies. Give it a try in your next prototype and tell me what you think!
Top comments (0)