Building an app from scratch can be complicated and overwhelming. Since it's a process with multiple steps and details, it can soon become long-drawn and confusing if a specific pattern or process is not followed.
As a follow-up to our series on The Twelve-Factor App (Ops for Scalable Applications and How to Define the Architecture for Scalable Applications), this article explores some principles that can help build scalable apps from an implementation perspective. As mentioned, this can be rather difficult if you do not follow a specific plan, but we hope these patterns enlighten you.
Centralized Code Management
To begin with, having your code stored and tracked is important. So, The Twelve-Factor App, in its Codebase Principle, emphasizes the importance of maintaining a single codebase for an application. Each app should have a centralized code repository, or repo, as its main source. This relationship should be one-to-one, with one app corresponding to one codebase and each codebase connected to a single app.
Among the benefits of this approach is that it enables easier collaboration between project members and ensures consistency across different environments. It also facilitates efficient deployment and scaling of services since all instances stem from the same codebase. Additionally, it reduces the risk of issues such as configuration drift or version mismatches. So, the Codebase principle allows development teams to track changes easily, manage dependencies, and implement CI/CD practices effectively.
In practice, the Codebase factor encourages the use of version control systems, such as Git, to manage the application's source code. The repository should be treated as a standalone entity, separate from the specific deployments or environments. Once again, this allows the team to create new instances of the application from the same codebase, making it easier to scale horizontally or deploy across multiple servers. By maintaining a single, version-controlled codebase, the Twelve-Factor App promotes transparency, consistency, and efficient development practices.
Managing Dependencies Effectively
When scaling an app horizontally or vertically, it’s essential to maintain consistency in its environment and dependencies. By explicitly listing and isolating dependencies, the Dependencies principle ensures that all application instances have the same set of required libraries and frameworks to work properly. This should be done with the help of a package manager – usually, the language or runtime provides one – and a manifest file that is included in the codebase. This approach promotes flexibility and portability, which are crucial for scaling applications.
Dependencies in Node.js are declared in a JSON file called package.json.
This approach has some benefits, especially when scaling horizontally and deploying multiple instances of the app. Self-contained dependencies allow for easy replication and distribution of instances across servers or cloud platforms. This behavior can be related to another factor from the Twelve-Factor App, the 'Disposability' principle, which ensures easy and quick setup, execution, and termination of the app. Also, it reduces the risk of compatibility issues arising from differences in system-level libraries or configurations.
Overall, the Dependencies principle facilitates applications' scalability by ensuring consistent dependencies across instances and increasing portability. By explicitly and separately managing dependencies, we can quickly scale services horizontally or vertically without the hassle of package conflicts or environment variations. This principle plays a significant role in efficiently scaling applications in modern and agile development practices.
Config Principle: Increased Versatility
Ensuring portability is essential for efficiently scaling apps, and thus the Config principle emphasizes the importance of storing application configuration in external, secure, and easily modifiable sources. According to this principle, the app's config should be kept separate from the codebase to enable easy customization and maintainability across different environments. By Config, we mean anything that is likely to vary between deploys and environments.
Developers adhere to the Config principle by storing configuration variables, such as database credentials, API keys, or feature flags, in external sources like environment variables or configuration files, although the first option is recommended. This approach provides flexibility and allows for different settings in development, staging, and production environments without modifying the codebase. Additionally, it brings various security-related benefits, such as preventing sensitive information from being tracked by version control. It also simplifies the deployment process, as the same codebase can be deployed multiple times with specific and different configurations, thanks to the Build, Release, Run principle that we will soon explore.
Thus, we see that attaching certain configs to deployment is a model that scales up seamlessly as the app naturally grows into more deploys and team members. Env vars, or environment variables, streamline the process by enabling independent management for each deployment without grouping them into environments such as "development" or "production." As the project evolves and new variables are created, you can easily add them to your deployments without any complications.
The Three Phases of Application Lifecycle
The Build, Release, Run principle is a central concept within the Twelve-Factor App because it has direct relevance to other factors, like Config and Dependencies. It emphasizes a clear separation of concerns during the app lifecycle, split into three distinct stages: build, release, and run. Each stage serves a specific purpose and carries its own set of responsibilities.
The build stage focuses on transforming the app's source code into a deployable artifact. This typically involves compiling code, bundling dependencies, and performing any necessary pre-processing or asset generation. The goal here is to create a self-contained and versioned package representing the app's build. By maintaining a consistent and reproducible build process, developers can ensure that each deployment uses the same build artifacts, minimizing the risk of inconsistencies or unexpected behavior.
The release stage involves combining the build artifact with the appropriate configuration to create a runnable instance of the application. This is where env vars and settings, such as database connections and API keys, are introduced. Decoupling the release from the build makes it easier to manage and customize configs for different environments, just like the Config principle. Each release should have a unique identifier, like a timestamp or a version flag; this will make it easier to roll back to previous versions or test a specific release.
Finally, the run stage focuses on executing the application in its chosen environment. It involves running the previously generated binary and making it available to handle incoming requests or perform scheduled tasks. Keeping the runtime process simple and automated ensures the app's availability. This stage benefits from the clear separation of concerns between build and release, facilitating consistent deployment across multiple instances, i.e., horizontal scaling. It also enables monitoring, logging, and other runtime activities without affecting the underlying build or release processes.
As seen, the "build, release, run" principle ensures that each stage of the application lifecycle is clearly defined and separate, providing benefits in terms of scalability, reproducibility, and manageability. Nowadays, using CI/CD tools and technologies to help you manage the entire process is a good practice. To summarize this topic, by maintaining a well-defined build process, customizing releases for different deployments, and executing the app independently, developers can streamline their workflow and improve the software's overall reliability.
Achieving Consistency: Dev/Prod Parity
At this point, we see that Twelve-Factor Apps are meant for agile and scalable development, emphasizing continuous delivery with small gaps between the development and production stages. Developers edit a live, local version while end users access the running deployment. Historically, these gaps are substantial, and they can be classified as such:
- Time gap: the duration between when the code is written and when the code goes to production.
- Personnel gap: the separation between teams with developers writing code and ops engineers deploying it.
- Tools gap: the gap that arises with different teams using different stacks; for instance, developers may use a stack like Nginx and SQLite, while the production deployment uses Apache and PostgreSQL.
As said, the goal of a Twelve-Factor App is to keep these gaps small, so it's common for agile teams to have:
- Code being deployed hours or even just minutes after being written.
- Developers writing code being involved in deployment and instance monitoring.
To make this viable, it's crucial to have the development and production environments as similar as possible, especially when it comes to backing services. It's not uncommon to see developers using services that differ from the ones used in production. A classic example would be having programmers use SQLite for development due to its handiness and versatility, while the production deployment uses PostgreSQL, for instance. The twelve-factor developer should not do that because this is when tiny incompatibilities pop up. Code that worked and passed tests in the development or staging phases may fail production, making it harder to operate and maintain continuous delivery.
Ultimately, the Dev/prod parity principle promotes consistency and reliability in the app's lifecycle. By striving to minimize differences between development and production environments, developers can improve the predictability of the application's behavior, reduce deployment risks, and enhance the collaboration between development and operations teams.
In a nutshell, services will scale more efficiently if these principles from the Twelve-Factor App manifest are followed:
- Using a centralized, standalone, and version-controlled codebase.
- Separating declarations of dependencies explicitly with a package manager.
- Treating anything that may vary between deploys as config.
- Storing config as env vars and avoid their being grouped together as environments
- Dividing the app’s lifecycle into three stages: build, release, and run, and using CI/CD tools.
- Keeping development and production environments as similar as possible to make dev tests more reliable.
This piece was written by Vinícius Assis Lima, a Software Developer at Encora. Thanks to João Pedro São Gregório Silva and João Augusto Caleffi for reviews and insights.
Fast-growing tech companies partner with Encora to outsource product development and drive growth. Contact us to learn more about our software engineering capabilities.