Applying the Twelve-Factor App methodology to Spring Boot microservices — what each factor means in practice, how Spring Boot supports it, and where developers commonly go wrong.
The Twelve-Factor App methodology was written in 2011 by the engineers at Heroku. Over fifteen years later it still reads as a near-perfect description of how a cloud-native service should be built. What’s changed is that Spring Boot now makes it significantly easier to comply — most factors are supported idiomatically out of the box. What hasn’t changed is the ways developers accidentally violate them. This post goes through all twelve, with the practical Spring Boot implementation and the failure modes I’ve seen in production, including patterns I’ve encountered at Mosaic Smart Data and DWP Digital.
One codebase tracked in version control. Multiple deploys (staging, production) from the same codebase, differentiated by configuration.
Spring Boot support: Natural. One Git repository, one pom.xml.
Common violation: Maintaining separate branches for each environment (staging, production) and manually cherry-picking changes. This creates divergence and makes it impossible to reason about what’s actually deployed. Use one main branch, tags for releases, and configuration profiles for environment differences.
All dependencies declared in pom.xml. Nothing assumed from the environment (no “I know the server has Oracle drivers installed”).
Spring Boot support: Maven and Gradle handle this. The uber-JAR (java -jar app.jar) is self-contained.
Common violation: Relying on a JNDI datasource provided by the application server, or assuming a JDBC driver is available on the classpath without declaring it. At one legacy DWP system I reviewed, the Oracle JDBC driver was dropped into the Tomcat lib/ folder on each server by hand and not declared anywhere in the build. The first containerisation attempt failed because the container had no Oracle driver and nobody knew why.
All configuration that varies between deploys (database URLs, credentials, API keys, feature flags) stored in environment variables, not in the codebase.
Spring Boot support: @Value, @ConfigurationProperties, and the Environment abstraction all map environment variables to properties cleanly. Spring Boot’s externalized configuration hierarchy (application.yml < environment variables < command-line arguments) gives you precise control.
@ConfigurationProperties(prefix = "betfair")
@Validated
public record BetfairProperties(
@NotBlank String appKey,
@NotBlank String username,
@NotBlank String sessionToken,
@Min(1) @Max(60) int streamingTimeoutSeconds
) {}
Common violation: Hard-coding environment-specific values in application-prod.yml and committing it to Git. If application-prod.yml contains passwords or API keys, your secrets are in version control history permanently, even if you later remove the file. Use AWS Parameter Store, Secrets Manager, or Kubernetes Secrets for production credentials — never YAML files in Git.
Databases, caches, message queues, and external APIs are all “attached resources” accessed via URL/credentials from config. Swap a backing service (swap RDS for Aurora, or point at a different database) by changing config, not code.
Spring Boot support: spring.datasource.url, spring.kafka.bootstrap-servers, spring.data.redis.host — all injectable from the environment.
Common violation: Hardwiring the notion that “production uses RDS” into application code. If your service builds a datasource programmatically using a flag like if (env.equals("prod")) { ... }, you’ve violated this factor. Configuration, not branching logic, should determine which resource you connect to.
Build: convert source to an executable. Release: combine executable with config. Run: execute in the environment.
Spring Boot support: Maven/Gradle produces the build artifact (JAR). GitHub Actions (or your CI pipeline) combines it with environment-specific config to produce a versioned release. The container runs it.
Common violation: Running mvn package on the production server during deployment. This conflates build and run, makes the build dependent on the production environment’s Maven installation, and means your deployment requires network access to Maven Central. Build once, deploy the artefact.
Application instances are stateless. Any state that needs to persist between requests lives in a backing service (database, Redis, etc.).
Spring Boot support: Default Spring Boot is stateless — unless you add HttpSession or in-memory state.
Common violation: Storing session state in HttpSession and deploying multiple instances behind a load balancer without sticky sessions. Sessions are tied to a specific instance. When that instance scales down, sessions are lost. Use Spring Session with Redis for distributed session state, or better, design APIs to be stateless using JWTs. At DWP Digital, migrating legacy session-based authentication to JWT-based stateless auth was a prerequisite for deploying multiple replicas on Kubernetes.
The service is self-contained and binds to a port itself. It doesn’t require an application server (Tomcat, JBoss) to be pre-installed.
Spring Boot support: Embedded Tomcat (or Undertow, Jetty) is included in the uber-JAR. The application binds to a configurable port via server.port. This is probably the factor Spring Boot implements most completely out of the box.
Common violation: Deploying to an external Tomcat installation as a WAR file. This is possible with Spring Boot but it bypasses the self-contained advantage. Containerisation of a WAR-based deployment is significantly more complex than containerising a JAR.
Scale by running more instances of the application process, not by making single instances larger.
Spring Boot support: Stateless processes (Factor VI) are a prerequisite. Spring Boot services designed for containerisation scale horizontally cleanly.
Common violation: Spawning background threads inside the application for work that should be handled by a queue consumer. If your service processes tasks by spinning up ExecutorService threads, you’ve created internal concurrency that’s invisible to the orchestrator. Move that work to a Kafka consumer or SQS listener — the orchestrator can now scale consumers independently of request-handling workers.
Instances should start quickly (minimising time to serve traffic) and shut down gracefully (finishing in-flight requests before exiting).
Spring Boot support: Spring Boot 2.3+ added graceful shutdown support:
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 30s
When the JVM receives SIGTERM, Spring Boot stops accepting new requests, waits up to 30 seconds for in-flight requests to complete, then shuts down cleanly. Without this, Fargate task replacements kill connections mid-request.
Common violation: Catching SIGTERM and ignoring it (common in legacy code), or using shutdown hooks that don’t coordinate with in-flight HTTP requests. Test your graceful shutdown explicitly — it’s the kind of thing that only breaks in production.
Spring Boot support: Testcontainers is the answer here. Start the same PostgreSQL version in tests that you run in production.
@SpringBootTest
@Testcontainers
class OrderRepositoryTest {
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
}
Common violation: Using H2 in tests and PostgreSQL in production. H2 SQL dialect differences (function names, type handling, constraint behaviour) mean tests pass but production fails. I’ve seen this cause silent data corruption in a DWP Digital integration — a query that H2 accepted silently and PostgreSQL rejected on a type mismatch. Use Testcontainers.
Write logs to stdout/stderr. Don’t manage log files in the application.
Spring Boot support: The default Logback configuration writes to stdout. Don’t change this in production. Let your container orchestrator (ECS, Kubernetes) capture stdout and route it to CloudWatch, Elasticsearch, or your chosen log aggregation platform.
Common violation: Configuring Logback to write to a file on the filesystem in production containers. The file exists inside the container, is not persisted (ephemeral container storage), and disappears when the container is replaced. You lose your logs on every deployment.
Database migrations, one-time data fixes, and admin scripts should run as one-off processes from the same codebase, not baked into application startup.
Spring Boot support: Flyway or Liquibase for migrations. Run as a one-off task (java -jar app.jar --spring.flyway.enabled=true --server.port=-1) or as an ECS run-task triggered before each deployment.
Common violation: Running migrations inside ApplicationListener<ApplicationReadyEvent> during service startup. If you’re running three instances and they all start simultaneously, each attempts the migration. Flyway handles this with a lock, but it’s still a bad pattern — migration failures cause service startup failures, and the migration log is mixed into the application log. Separate the concerns.
Twelve factors, twenty minutes to read, years to fully internalise. The value isn’t in achieving a perfect score — it’s in having a shared vocabulary for the tradeoffs and a systematic way to audit what your services are and aren’t doing.
If you’re building Spring Boot microservices for cloud deployment and want an engineer who takes architectural hygiene seriously, get in touch.