Our journey on Scrimmage was not without its challenges. We weathered two significant pivots, transitioning from a B2C analytic product to a WEB3 play-to-earn game and finally settling into our current iteration as a B2B SaaS product. These pivots were not easy. Our entire codebase was archived during the first, and it took us five months to be production-ready with a new product. However, we learned from this experience and could reuse 90% of our codebase during the second pivot, significantly reducing our time to production to just one month. As a startup, you believe in your vision, but pivots are inevitable. You need a product that can adapt to the market's demands. Here’s how we did it.
We build healthy abstractions.
Building healthy abstractions is a concept that took me four years of programming to truly understand. It's not just a theoretical idea, but a practical approach that can be applied in various scenarios. The key is to be as non-specific as possible when naming your variables and classes, allowing them to be used in different contexts.
For example, we built a play-to-earn game with a dragon character who can level up and complete quests. Instead of calling variables “dragons,” we call them “NFTs.” This slight shift in naming will make you think more abstractly. Instead of thinking, “What dragons can do,” you think, “What NFT can do.” Now, you can easily reuse this class across all your future projects utilizing NFTs.
Another typical example is trying to separate architectural abstraction from app-specific logic. Don't call things by their names if you build architecture as a code solution or a simple CI/CD. Call it by its purpose. If you have ten microservices, you may wanna create a Docker file and CI/CD file for every one of them; you can make an architectural abstraction called “deployable,” which will have a default implementation of a Docker file and CI/CD and will require from microservice just a couple of input variables as name, port, and image. K8S doesn’t have to know about your app logic.
Following healthy abstractions can open a new way of writing code–building mechanisms that can solve problems instead of directly addressing them.
We build mechanisms that can solve problems instead of solving problems directly.
The idea comes from a desire to support all future business requirements without changing the code. If you notice that a file is getting changed very often, it is time to build a mechanism for supporting further changes without changing the code.
An example is how we organized our reward program. The app's idea was that users could make bets, and we gave them tokens for every bet they made. Instead of calling “bet” a “bet,” we called it “rewardable.” Instead of hardcoding how many tokens to reward for a bet, we implemented a mechanism to configure reward per “rewardable” from the DB. Now, we can reward tokens for anything without changing a line of code.
Another example, we wanted to reward users more when they win the bet. Instead of writing code like “If rewardable is a bet and if this bet has status WON, multiply reward by 2”, we implemented a “modification” mechanic. Modifications allow us to modify rewards based on predefined filters. You specify filters in the database, which will automatically be applied to every reward. For example, we have a filter: “When rewardable type is bet and field OUTCOME has value WIN, multiply reward by 2”. Tomorrow, the client comes and asks to reward people more when they lose. We can do it from a database by changing 0 lines of code.
While it may take longer to write, the code you produce by following these principles is invaluable. It can be reused in countless ways to support a myriad of use cases. This approach may require an initial investment of time, but it will ultimately save you time and resources in the long run.
If you build healthy abstractions and use them to create generalized mechanisms, you could consider moving some of the mechanisms into separate microservices that know nothing about the rest of the system.
We build microservices that can be open-sourced.
Imagine that thousands of other developers will use your microservice in completely unrelated projects to yours. This idea is making you reimagine your microservices' dependency on each other. It prevents our code from dependency hell and makes it reusable through all future projects and pivots.
An example is the scheduling mechanism we have implemented. We started our app as a singleton with cron jobs scanning from code. We can not scale this service horizontally, so we had to separate scheduling logic elsewhere. Instead of setting up cron jobs using code, we have implemented and constantly added new values to them; we have implemented a K8S-Scheduler, which is a helm chart that is getting deployed in our cluster. This helm chart scans all our microservices for cronjob definitions and executes them using API calls. Now, we can describe cronjob while creating a new endpoint, and a scheduler will start scheduling it without us touching a code.
Another example is user authentication using JWT. Instead of making it part of one of our microservices, we made it a separate microservice that authenticates users and returns a JWT token that can be used across all microservices. Now, this microservice can be reused on any future pivots or projects by executing a single helm command to deploy it.
If you follow this principle, after two years, you will have a library of 10 microservices that can be used to build any product related to your industry in a matter of days. Now, a product constructor allows you to create products using just configurations.
We build product constructors instead of products.
You may call it over-configuration, but we call it architecture. When you realize your product doesn’t have a product market fit, you can quickly pivot by changing a couple of variables in your database and deploying some of your past microservices to a new cluster. However, managing all those microservices may be challenging.
Terraform serves us well on this point. It provides an architectural abstraction that allows us to look at the infrastructure as a code that can be easily modified looped over, and configured. If you need some new functionality you don’t have yet; you already have a straightforward interface to create that peace and add it to the terraform, combining it with all other microservices. It automatically adds auth, making it observable, deployable, and schedulable. It will also pass all required environment variables and host it under the public ingress.
Conclusion
We implemented a system that allows us to develop software using configurations. It has to be the end goal of every IT product that wants to sustain dozens of engineers under its roof and survive future pivots.
Not every software needs this approach. Sometimes, you want to build a quick prototype to test the market. However, it is essential to differentiate a dirty prototype from an MVP with a long life ahead and full of pivots and requirement changes.