- Published on
🏗️ 12-Factor App methodology
The 12-Factor App methodology was created by developers at Heroku to standardize best practices in cloud-native application development. As a platform-as-a-service (PaaS) provider, Heroku saw a need for applications to be optimized for the cloud with portability, scalability, and maintainability in mind. The 12-Factor principles ensure that applications are ready for the challenges of cloud environments, where quick deployment, horizontal scaling, and frequent code changes are common.
1. Codebase - One Codebase Tracked in Version Control, Many Deploys
Every 12-factor app should have one codebase per app, tracked in version control, with the potential for multiple deployments (staging, production).
Why It Matters:
The single codebase approach prevents code fragmentation and ensures that all updates, bug fixes, and features are traceable and versioned across environments. By keeping a single source of truth, it becomes easier to manage the app's lifecycle, enabling consistent deployments and reducing the risk of discrepancies between environments.
Example:
Git is an excellent example of a version control system that embodies the principle of having a single codebase per application, tracked in a centralized manner, while supporting multiple deployments.
Its powerful branching and merging capabilities allow teams to create separate branches for features, bug fixes, or experiments without affecting the stable main branch, facilitating parallel development. This separation ensures that all changes are traceable and can be integrated back into the main codebase in a controlled manner, minimizing conflicts and maintaining stability across environments like staging and production.
Additionally, Git's robust history tracking allows teams to roll back to previous versions easily, ensuring that deployments are both reliable and consistent. Overall, Git's design and features align perfectly with the 12-factor app methodology, promoting efficient collaboration and code management.
2. Dependencies - Explicitly Declare and Isolate Dependencies
Declare and isolate dependencies within the code to prevent conflicts between dependencies across environments.
Why It Matters:
When dependencies are explicit and isolated, the app’s portability and deployability increase. Externalizing dependencies means that you avoid version conflicts, making deployments reproducible and minimizing “it works on my machine” issues.
Example:
- Java(Spring Boot): Maven or Gradle handles dependency management in
pom.xml
orbuild.gradle
. To avoid dependency conflicts, declare versions directly and use dependency management to control version resolution, ensuring consistency across environments. - Node(NestJS): Define dependencies explicitly in
package.json
with versions pinned to ensure compatibility. Usenpm ci
(which installs dependencies as defined inpackage-lock.json
) in production environments for a stable and predictable setup.
3. Config - Store Configuration in the Environment
Configuration, such as database credentials or API keys, should be stored outside the codebase in environment variables.
Why It Matters:
Externalizing configuration separates configuration from code, enhancing security and making it easy to change settings without modifying the codebase. Environment variables ensure that applications can be promoted across environments without the risk of exposing sensitive data.
Example:
- Java(Spring Boot): Inject configuration using
@Value("${variable}")
to load values from the environment, allowing them to be set in a configuration management tool or through the system’s environment variables. - Node(NestJS): Use the
dotenv
library to load variables from.env
files and theConfigService
to inject them into modules. This keeps sensitive information out of the codebase and makes environment-specific changes easier.
4. Backing Services - Treat Backing Services as Attached Resources
Treat databases, message queues, and third-party services as attachable resources, making them interchangeable and decoupled from the core app.
Why It Matters:
This principle ensures that external services (like databases or caches) can be swapped or scaled independently, increasing fault tolerance and flexibility. For example, if a caching service goes down, you can swap it without requiring application redeployment.
Example:
- Java(Spring Boot): Use Spring’s
@ConfigurationProperties
to define externalized configurations, making database changes easy without code updates. For example, you can switch databases by updating the JDBC URL in the configuration. - Node(NestJS): Dependency injection and modular structure make it easy to swap resources. Use
ConfigService
to load connection details, keeping the app abstracted from specific providers.
5. Build, Release, Run - Strictly Separate Build and Run Stages
Each deployment should have a defined build, release, and run stage.
Why It Matters:
Separating these stages helps maintain reliable, consistent deployments. The build stage compiles the code, the release stage combines the build with configuration, and the run stage executes the application. This allows fast rollbacks and minimizes human error.
Example:
- Java(Spring Boot): Use CI/CD pipelines (like GitHub Actions) to create separate steps for build, release, and run. For instance, define a
Dockerfile
to package the application, then use Kubernetes or Docker Swarm to manage releases. - Node(NestJS): Dockerize with
docker-compose
to separate stages and manage dependencies, improving reliability and enabling easier rollbacks and fast deployment adjustments.
6. Processes - Execute the App as One or More Stateless Processes
The application should run as one or more stateless processes, offloading state to external services.
Why It Matters:
Running as stateless processes ensures that any instance of an app can serve any request without the need for session consistency. This approach allows for fast scaling and improved resilience, as instances can start and stop at will.
Example:
- Java(Spring Boot): Use Spring Session to offload session data to Redis or a database, ensuring that all instances of the application remain stateless.
- Node(NestJS): Use session storage solutions like
@nestjs/redis
to decouple application logic from session management. This setup allows for flexible scaling, where each process is isolated and stateless.
7. Port Binding - Export Services via Port Binding
The application should self-host its services, making it accessible through a defined port.
Why It Matters:
By binding to a specific port, the app becomes a fully self-contained unit, simplifying service deployment and eliminating dependency on specific external servers.
Example:
- Java(Spring Boot): With its embedded Tomcat server, Spring Boot allows setting
server.port
inapplication.properties
to bind to a specified port, making it easy to configure within Docker or Kubernetes. - Node(NestJS): Use
app.listen(process.env.PORT)
to bind dynamically based on the environment. This setup is especially useful when deploying containerized applications where ports might vary.
8. Concurrency - Scale Out via the Process Model
Scale horizontally by adding more instances of stateless processes rather than relying solely on multithreading within a single instance.
Why It Matters:
Concurrency maximizes system utilization and fault tolerance. By scaling through multiple instances, apps handle high traffic and failover gracefully.
Example:
- Java(Spring Boot): Deploy the application on a Kubernetes cluster with Horizontal Pod Autoscaler (HPA) to add or remove instances based on traffic, ensuring seamless scaling.
- Node(NestJS): Deploy with PM2 (Process Manager 2) for clustered mode, enabling each instance to process requests independently. This setup helps balance load across instances and improves availability.
9. Disposability - Maximize Robustness with Fast Startup and Graceful Shutdown
Ensure that applications can start up and shut down quickly to support fast scaling and failover.
Why It Matters:
Quick start and shutdown times enhance reliability and make it easier to perform rolling updates or scale under load. Graceful shutdown ensures that in-progress requests are completed before shutdown, minimizing downtime.
Example:
- Java(Spring Boot): Use built-in Spring Boot features like graceful shutdown and avoid tasks with long timeouts. This ensures minimal disruption when scaling down instances or during restarts.
- Node(NestJS): With
@nestjs/terminus
, you can add health checks and graceful shutdown hooks to handle current requests before termination, improving overall uptime and reliability.
10. Dev/Prod Parity - Keep Development, Staging, and Production as Similar as Possible
Reduce the gap between development and production environments to minimize potential issues and promote smoother transitions.
Why It Matters:
By maintaining consistency across environments, you decrease the likelihood of environment-specific bugs and improve developer productivity. This principle encourages the use of similar tooling and environments from dev through production.
Example:
- Java(Spring Boot): Use Docker to standardize environments across development and production. Docker images ensure the same configurations and dependencies are used in both stages.
- Node(NestJS): Use
docker-compose
for local development to replicate the production setup. This ensures developers work with an environment similar to production, reducing inconsistencies.
11. Logs - Treat Logs as Event Streams
Logs should be treated as event streams, allowing them to be collected and analyzed centrally.
Why It Matters:
Centralized logging improves observability, making it easier to track application behavior and troubleshoot issues. By decoupling logs from the app, you can leverage powerful logging and monitoring tools.
Example:
- Java(Spring Boot): Use Spring Boot’s support for SLF4J and log aggregation tools like ELK (Elasticsearch, Logstash, Kibana) to centralize log management. The application can output logs to stdout, enabling easy capture by logging frameworks.
- Node(NestJS): Use
@nestjs/serve-static
to integrate logging solutions. Log data can be sent to external systems like Loggly or Papertrail for aggregation and monitoring, simplifying issue tracking.
12. Admin Processes - Run Administrative/Management Tasks as One-off Processes
Administrative tasks (like database migrations) should be run as one-off processes in the same environment as the app.
Why It Matters:
This principle ensures that all tasks executed in the application environment are consistent, enabling smoother migrations and management operations without additional setup.
Example:
- Java(Spring Boot): Use
Spring Boot Migrations
to handle database migrations, running them as part of the application’s lifecycle. - Node(NestJS): Create migration scripts as part of the application with TypeORM or Sequelize. Running migrations as one-off scripts ensures they are executed in the correct environment.
Conclusion
The 12-Factor App methodology provides a powerful framework for building cloud-native applications. By following these principles, you can create software that is portable, scalable, and maintainable, ultimately improving deployment practices and operational reliability. Adopting these practices ensures that your applications can thrive in a dynamic cloud environment, ready to meet the demands of modern users.