Over the last few months of 2020, I have dedicated myself to writing MandarineTS Framework. It has been an amazing journey where I have met really knowledgeable people & have participated in the Deno community more actively.
I never thought I would be participating in such a large open source community but more than participating, I never thought I was going to collaborate in some sort of way.
Participating in Open-Source projects have been one of the most honorable experiences I have had: I have interacted with really important people in the computer science world (theoretically speaking), and this gives you not only a new way to look at your code & your reasons to code but it also opens your mind to where you want to go & offers you a better sense of how important your work can get to be.
On this post, I will be discussing the details of creating a framework: The concept & the reason, the basis of it, the core, what I have learned, what I regret, and how to maintain it.
This first part will only be about main concepts we need to know in order to create a framework. I will be posting a second and probably a third part where we will creating our own dependency injection framework.
Most of the things you think you can create are already created. Under that scenario, you have to think what is your added value, for example: You may pretty well offer the same things with better performance & syntax, or you may pretty well offer new things with the same performance & same syntax. In most cases, this is not worth it. The amount of time it requires and the very little usability you may get will not make it worth it.
But let's say you have either decided to continue no matter whether it's already created, or you have an awesome idea. The first thing you need to consider and have a pretty clear perspective about is an answer for the following question: What problem are you trying to solve?. Such question will be the one to lead you to structure your framework. You need to have a pretty solid idea of what your code will be solving because before starting to code, you already need to know how your framework will be distributed, you need to consider the performance aspects, whether you will use third party libraries, the problems of your current runtime, the limitations of the language you will use, and more fundamental questions we will be reviewing.
I created Mandarine because there was nothing alike in Deno. In the case of NodeJS, Mandarine is pretty similar to NestJS, but in Deno, this was an unique opportunity to bring the MVC pattern & a decorator-driven development style as the Deno community is growing.
When creating a framework, you need to have a deep understanding of both coding skills and fundamental skills (knowing how to code, why to code it, and the impact it has on the computer your code is running). You also need to have a deep understanding of the different environments that we have in software such as Develop environment & Production environment, this is because, if you are creating a framework, its objective will be to be used in a production environment where multiple instances (servers) and concurrent requests are constantly ongoing, this is something you need to consider how to handle when writing the first part of your code. If you leave these details out, you will regret.
Building a framework requires an architecture, this means, you will need to think what design patterns you will use, you will need to come up with your own standards in order to have the same kind of code when collaborators join, or even for yourself. You need to establish a line between the language you are using, the runtime and your framework. You will not use all the features of the language and/or runtime you are using, and this is important because you should not use all the features, you should only use what you will be needing. Do not get fancy or hacky to do things, that really does not make your code better.
You should maintain the same naming conventions across the framework. I personally like camel case, but this depends on the developer. Make sure you follow this, it will help when your code grows.
For example, Mandarine does not use a deps.ts file where all the exports of the different dependencies are located for the modules. I did it this way because every file should import the dependencies it will use, it should not use a "index" file for dependencies. if an "index" file for dependencies such as deps.ts is used, the risk of a code breaking all your modules grow. Instead, when importing manually the dependencies your file will use, you gain more control over that, and if a module breaks your application, it is easier to find the root of the cause.
Every framework has a core, needs a core. Your framework will need a core too. The core is just that, a core. It will connect the basis of your framework to all the different modules, it will be responsible for loading & initializing the different components that are needed for it to work out. For example, if you are writing a dependency injection framework (DI), then your core will be responsible for creating & injecting all the instances.
The core should stick to core functionalities, for example: If your framework is to create an HTTP Server, the HTTP Dispatcher (Creation of routes, initialization of the HTTP Server, etc) should not go inside the core, instead, your framework would need a module called "http-dispatcher" that will handle this kind of things & the core will provide it with the necessary functionalities as the core will work as a bridge for your framework.
The core is the most important part of a framework as it handles everything that is needed. It will be possible the module that has more lines of code (LOC). In Mandarine, the core contains the decorators that the developer will be likely to use, the interfaces, the DI factory and the DI container, it also contains all the exceptions that are originally thrown from Mandarine & utility classes that are used across the other modules.
Your framework will probably have more modules than just the core, this is where things will start getting messier. The more modules you have the more code you have, this affects performance at both compile (more time to compile your code) & runtime (it can take many resources if your code is not well-written, along with other aspects).
With that said, when having other modules than just the core, you always need to think how to make everything re-usable across your framework: You need to make everything you can a utility method or class, do not over code, do not repeat code. Try to centralize things such as Similar interfaces or similar classes, make use of generics, make use of class-extension. The cleaner your code is, the easier it will be to maintain and the better it will be for performance.
This is something you must be really careful of. Do not use your framework's functionalities inside the core or modules of your framework. Your framework must only provide functionality to the developer who is using it, but it should not provide functionality to itself. Your framework should be written as natively as possible, meaning, as self-dependent as possible. This practice of a self-dependent framework increases testability, readability, and decrease the boilerplate and risk of breaking.
When creating a framework, after you release the first version, you have to start thinking & assuming many people are using your framework by now. It's important to have this kind of thinking because it will make you carefully consider every single change: You do not want your framework to break other's people applications.
Avoid breaking changes as much as you can. Breaking changes can have a huge impact on other people's code. The new features you add & the bugs you fix should not alter the behavior of what is out there that people are using.
Yes, you can have breaking changes. Any project has breaking changes, in fact, breaking changes are something really common during the early versions of any program, but... avoid them.
Always your framework is being used in production, thus, you need to be thoughtful when it comes to changing the structure of something, the syntax of something, when removing a method from the code, etc.
Creating Mandarine has given me a lot of stories to tell, a lot of experience to be shared. One of the main things I have struggled while creating Mandarine is being consistent with design patterns & self-dependency for modules. Mandarine lacked design patterns during its first days, after several versions being released, this has improved. Mandarine's code is more consistent & has more design patterns, yet, it's still a process where I find myself always refactoring and thinking carefully what is the best approach to add a feature or solve a bug, since every single line of code can affect the consistency of the design patterns mandarine makes use of.
Another thing I would say I have learned is the importance of self-dependency modules. During the very very very early versions of Mandarine, Mandarine was hard to unit-test because all the modules had some sort of relation with the other modules, but as of right now, things have gotten easier as Mandarine is now using the proxy design pattern which wraps all core methods and functionalities in classes called Proxy, so the calls to a method are always made to the proxy and the proxy validates and then calls the requested method. This has made not only performance get better, but it has also made the code more readable and testable.
This is an interesting questions because I don't really have regrets that can be used as a useful experience when writing a framework. But I do have to say, not everything has been pretty with Mandarine. If I could go back to when I started writing it, I would tell myself to establish design patterns because the amount of code refactoring that is coming on my way is incredible (I have refactored the whole core of Mandarine 4 times, only because I didn't consider design patterns in the beginning).
I do think I could have made a better job writing unit tests, at the very beginning I didn't write unit tests for Mandarine. It is now that Mandarine is starting to have unit tests for every of its modules, it's been a long process writing these unit tests as Mandarine has grown significantly.
When I started with Mandarine, I felt really uncertain about how to maintain an open-source framework, and sometimes I still do because there's a lot to learn everyday for everyone, for me too, the community is always evolving & you need to adapt faster every time.
Your Master branch is the branch you have to protect the most. The master branch must always, always be stable. For this, I always recommend the following:
Create two branches: Master & Develop. Your main code/last release code will be always located under Master. The code for feature releases (features, bug fixes, etc) will be always be worked on Develop. When the release is ready and develop is stable, you merge Develop into master and then you create the release from master. From there, you continue to work on develop for anything that you want following the same process. Develop can get to be unstable, master must never be unstable.
Your versioning needs to be meaningful (MAJOR.MINOR.PATCH). You can't just release whatever number looks good, it is important to keep consistency & meaning of your versions. If something is a patch then it should affect the patch numbering, if an update includes many patches and features that affect many modules then it can be considered a minor, if there is a list of features & fixes that have been constantly released, and there is a big change that affect your framework at all levels then it can be considered a major update.
You also need to have a release plan, although this is a complex subject, the basic of it is, you need to know what each version will have, you can't just add anything to any version. You need to plan releases ahead, this way you can know what you have to code per release, you organize your framework more, it's less time consuming than just coding and releasing.
You should open issues to any feature/bug fix/other you are going to work on. This is because, the more documented a framework is, the better. Issues represent documentation to a specific problem at a specific period of time. These issues should have a context of why it is being done, and if it is a bug, these issues should have information about that bug. Don't code without an issue, open an issue if necessary.
Your framework should be extremely documented as mentioned before, don't expect people to know how to use it, be as explicit as you can, and document every single feature your framework has and how to use it in different scenarios. Also, don't only document code, document concepts. The concept of why something is made that way, why something exists inside your framework, what it does, when it can fail, everything about it is as important as the code. You don't really do anything with just documenting the code.
When you want to report a bug to yourself, or someone is reporting a bug, you must always have the basic information of that bug:
Operative System, version of the runtime, version of your framework, etc. This will help you find the cause quicker.
This is the end of "Writing a framework" part number 1. As I mentioned before, I will be creating a second and probably third part to use the concepts we went over this post programmatically. We will be creating our own DI Framework (a simple one) but that will give you a better understanding of what a core is, etc.
Question/Feedback? Please comment below :)