OAuth 2.0 & OpenID Connect (OIDC)
Security · Interview Essential
Authorization vs Authentication
OAuth 2.0 is a delegated authorization framework — it lets an app access a user's data in another service without sharing passwords. OIDC is a thin authentication (identity) layer built on top of OAuth 2.0.
The 4 Key Roles
- Resource Owner — the user who owns the data
- Client — the app requesting access (your Spring Boot app)
- Authorization Server — validates identity and issues tokens (Google, Okta, Keycloak)
- Resource Server — the API being accessed (e.g., Google Calendar API)
Authorization Code Flow (8 Steps)
// Most secure OAuth flow — used by server-side apps
1. User clicks "Login with Google" in your app
2. App redirects to Google with: clientId, redirectUri, responseType=code, scope
3. Google authenticates the user (login page)
4. User sees consent screen: "Allow App X to read your contacts?"
5. User approves
6. Google redirects back to app with short-lived Authorization Code
7. App server exchanges Code + clientSecret → Access Token (server-to-server, never exposed to browser)
8. App uses Access Token to call Google APIs on user's behalf
Token Types
| Token | Purpose | Lifetime |
| Authorization Code | Exchanged for Access Token (one-time use) | ~60 seconds |
| Access Token | Credential to call Resource Server APIs | Short (minutes–hours) |
| Refresh Token | Get new Access Tokens without re-login | Long (days–months) |
| ID Token (OIDC) | JWT with identity claims (name, email, sub) | Typically same as Access Token |
OAuth 2.0 vs OIDC
| Aspect | OAuth 2.0 | OIDC |
| Answers | "Can this app access X?" | "Who is this user?" |
| Purpose | Authorization (access control) | Authentication (identity) |
| Token issued | Access Token only | Access Token + ID Token (JWT) |
| Trigger | Any scope | scope includes openid |
| Use case | "Let app post to Twitter" | "Single Sign-On across apps" |
💡 Interview tip: OAuth solves authorization ("what can you do"), OIDC solves authentication ("who are you"). They're not the same thing. OIDC = OAuth 2.0 + identity.
Spring Security & Spring Boot
Security · Interview Essential
Spring Security is a powerful, customisable authentication and access control framework for Java applications. It integrates seamlessly with Spring Boot via auto-configuration.
How Spring Security Works (Filter Chain)
Every HTTP request passes through a chain of security filters before reaching your controller. The most important filter is UsernamePasswordAuthenticationFilter.
// Request flow:
HTTP Request
→ DelegatingFilterProxy
→ SecurityFilterChain (ordered filters)
→ UsernamePasswordAuthenticationFilter (handles /login)
→ BasicAuthenticationFilter
→ ExceptionTranslationFilter
→ FilterSecurityInterceptor (checks @PreAuthorize, URL rules)
→ Your Controller
Minimal Spring Boot Security Setup
// pom.xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
// Just adding the dependency auto-protects ALL endpoints.
// Default behaviour: basic auth with generated password printed in console.
Custom Security Configuration
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll() // no auth needed
.requestMatchers("/api/admin/**").hasRole("ADMIN") // ADMIN only
.anyRequest().authenticated() // everything else: login required
)
.httpBasic(Customizer.withDefaults()) // or .formLogin() for HTML login page
.csrf(csrf -> csrf.disable()); // disable for REST APIs
return http.build();
}
@Bean
public UserDetailsService userDetailsService() {
UserDetails user = User.builder()
.username("aftab")
.password(passwordEncoder().encode("password"))
.roles("ADMIN")
.build();
return new InMemoryUserDetailsManager(user);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); // NEVER store plain-text passwords
}
}
JWT Authentication with Spring Boot
// Flow for stateless REST APIs (most common in interviews):
1. POST /api/auth/login { username, password }
→ AuthController validates credentials via AuthenticationManager
→ On success: generate JWT (signed with secret key)
→ Return: { "token": "eyJhbG..." }
2. Client sends JWT in header on every request:
Authorization: Bearer eyJhbG...
3. JwtAuthFilter (OncePerRequestFilter):
→ Extract token from header
→ Validate signature + expiry
→ Load UserDetails and set Authentication in SecurityContext
→ Request proceeds to controller
// JWT Filter skeleton
@Component
public class JwtAuthFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
FilterChain chain) throws Exception {
String header = req.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7);
if (jwtService.isValid(token)) {
String username = jwtService.extractUsername(token);
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(username, null, authorities);
SecurityContextHolder.getContext().setAuthentication(auth);
}
}
chain.doFilter(req, res);
}
}
Method-Level Security
@EnableMethodSecurity // add to @Configuration class
@PreAuthorize("hasRole('ADMIN')")
public void deleteUser(long id) { ... }
@PreAuthorize("hasAuthority('user:read') or #id == authentication.principal.id")
public User getUser(long id) { ... }
💡 Key interview points: Spring Security uses a Filter Chain. JWT = stateless (no server-side session). BCrypt for password hashing. @PreAuthorize for method-level access control. CSRF disabled for REST APIs (re-enabled for form-based web apps).
equals() & hashCode() in Java
Java Core · Interview Essential
Every Java class inherits equals() and hashCode() from Object. The defaults use reference equality (this == obj). When you store objects in HashMap or HashSet, you must override both correctly.
The Contract (Never Break This)
// THE RULE: if a.equals(b) == true, then a.hashCode() == b.hashCode() MUST be true.
// The reverse is NOT required — same hash does NOT mean equal objects (hash collision is OK).
equals() — 5 Rules
- Reflexive:
x.equals(x) → always true
- Symmetric: if
x.equals(y) → then y.equals(x)
- Transitive: if
x.equals(y) and y.equals(z) → then x.equals(z)
- Consistent: repeated calls return same result if fields unchanged
- Null-safe:
x.equals(null) → always false, never throws NPE
Correct Implementation
public class Employee {
private int id;
private String name;
@Override
public boolean equals(Object o) {
if (this == o) return true; // same reference
if (!(o instanceof Employee)) return false; // null or wrong type
Employee e = (Employee) o;
return this.id == e.id && Objects.equals(this.name, e.name);
}
@Override
public int hashCode() {
return Objects.hash(id, name); // use SAME fields as equals()
}
}
What Breaks Without Proper Override
// Only equals() overridden, hashCode() NOT overridden → BROKEN
Employee e1 = new Employee(1, "Aftab");
Employee e2 = new Employee(1, "Aftab");
e1.equals(e2); // true (our equals says yes)
e1.hashCode() == e2.hashCode() // false (different buckets!) → CONTRACT BROKEN
Set<Employee> set = new HashSet<>();
set.add(e1);
set.contains(e2); // FALSE — e2 lands in different bucket, never found!
Map<Employee, String> map = new HashMap<>();
map.put(e1, "Developer");
map.get(e2); // null — key not found despite "equal" object!
⚠️ Always use the same set of fields in both methods. In Java 14+, use record — it auto-generates correct equals() and hashCode().
How HashMap Works Internally in Java
Java Core · Most Asked Interview Question
A HashMap<K,V> is backed by an array of buckets. Each bucket holds a linked list (Java 8+: a balanced tree when the list gets long).
Step-by-Step: How put(key, value) Works
map.put("name", "Aftab");
Step 1: Compute hash of key
hash = "name".hashCode() // e.g. 3373752
Step 2: Find bucket index
index = hash & (capacity - 1) // fast modulo, default capacity = 16
// e.g. index = 8
Step 3: If bucket[8] is empty → store node there directly
Step 4: If bucket[8] has node(s) (collision):
→ Walk the chain, compare using equals()
→ If key found: UPDATE the value
→ If not found: APPEND new node at end of chain
Internal Node Structure
// Simplified — what Java stores in each bucket slot
static class Node<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next; // pointer to next node in this bucket (for chaining)
}
Load Factor & Rehashing
- Default capacity: 16 buckets (always a power of 2)
- Default load factor: 0.75
- Threshold = 16 × 0.75 = 12 — when 12 entries are added, the map resizes
- Resize: doubles capacity to 32, rehashes all existing entries into new positions
Java 8+ Treeification
// If a single bucket chain grows beyond 8 nodes:
// LinkedList → Red-Black Tree (for that bucket only)
// Lookup improves from O(n) → O(log n) for that bucket
// This handles pathological hashing (e.g., all keys hash to same bucket)
Key Characteristics
| Property | HashMap | LinkedHashMap | TreeMap |
| Order | None | Insertion order | Sorted (natural) |
| get/put | O(1) avg | O(1) avg | O(log n) |
| Null key | 1 allowed | 1 allowed | Not allowed |
| Thread-safe? | No | No | No |
| Thread-safe alt | ConcurrentHashMap | — | ConcurrentSkipListMap |
💡 Interview answer checklist: Array of buckets → hash → index → linked list (tree if >8) → equals() for collision → load factor 0.75 → rehash when threshold exceeded.
Comparable vs Comparator in Java
Java Core · Interview Essential
Key Difference at a Glance
| Comparable | Comparator |
| Package | java.lang | java.util |
| Method | compareTo(T obj) | compare(T o1, T o2) |
| Sort strategies | One — natural ordering | Many — multiple orderings |
| Modifies the class? | Yes — class implements it | No — external class |
| Used by | Collections.sort(list) | Collections.sort(list, comparator) |
| Use when | One obvious default ordering | Multiple orderings or can't modify class |
Comparable — Natural Ordering
public class Employee implements Comparable<Employee> {
int id;
String name;
double salary;
@Override
public int compareTo(Employee other) {
return Integer.compare(this.id, other.id); // sort by ID — natural order
}
}
List<Employee> list = ...;
Collections.sort(list); // uses compareTo() automatically → sorted by id
Comparator — Custom Ordering (Multiple Ways)
// 1. Separate class
class BySalary implements Comparator<Employee> {
public int compare(Employee a, Employee b) {
return Double.compare(a.salary, b.salary);
}
}
Collections.sort(list, new BySalary());
// 2. Lambda (most common in modern Java)
list.sort((a, b) -> Double.compare(a.salary, b.salary));
// 3. Method reference — cleanest
list.sort(Comparator.comparingDouble(e -> e.salary));
// 4. Chained comparators (sort by name, then by salary if names are equal)
list.sort(Comparator.comparing(Employee::getName)
.thenComparingDouble(Employee::getSalary));
// 5. Reverse order
list.sort(Comparator.comparingInt(Employee::getId).reversed());
Return Value Convention (Both Methods)
- Negative — first object comes before second
- Zero — objects are considered equal in ordering
- Positive — first object comes after second
⚠️ Never use subtraction (a.id - b.id) for numeric comparison — it can overflow. Always use Integer.compare(a.id, b.id) or Double.compare(a.salary, b.salary).
Sort a List of Employee Objects in Java
Java Core · Coding Question
This is one of the most common Java coding interview questions. There are multiple ways — know all of them.
Employee Class
public class Employee {
private int id;
private String name;
private double salary;
private int age;
// constructor, getters, setters, toString
}
List<Employee> employees = Arrays.asList(
new Employee(3, "Aftab", 75000, 28),
new Employee(1, "Zara", 90000, 25),
new Employee(2, "Ahmed", 60000, 32)
);
1. Sort by Single Field
// By name (alphabetical)
employees.sort(Comparator.comparing(Employee::getName));
// By salary (ascending)
employees.sort(Comparator.comparingDouble(Employee::getSalary));
// By salary (descending)
employees.sort(Comparator.comparingDouble(Employee::getSalary).reversed());
// By id
employees.sort(Comparator.comparingInt(Employee::getId));
2. Sort by Multiple Fields
// Sort by department, then by salary descending within each department
employees.sort(
Comparator.comparing(Employee::getDepartment)
.thenComparingDouble(Comparator.comparingDouble(Employee::getSalary).reversed()::compare)
);
// Cleaner: sort by name, then age if names are same
employees.sort(
Comparator.comparing(Employee::getName)
.thenComparingInt(Employee::getAge)
);
3. Using Stream (returns new sorted list)
// Sort and collect into new list
List<Employee> sortedByName = employees.stream()
.sorted(Comparator.comparing(Employee::getName))
.collect(Collectors.toList());
// Sort by salary descending, get top 3
List<Employee> top3 = employees.stream()
.sorted(Comparator.comparingDouble(Employee::getSalary).reversed())
.limit(3)
.collect(Collectors.toList());
4. Using Comparable (natural order in the class)
public class Employee implements Comparable<Employee> {
@Override
public int compareTo(Employee o) {
return Integer.compare(this.id, o.id); // default: sort by id
}
}
Collections.sort(employees); // uses compareTo
5. Sort with null-safety
// nullsFirst / nullsLast handle null values without NullPointerException
employees.sort(Comparator.comparing(
Employee::getName,
Comparator.nullsLast(Comparator.naturalOrder())
));
💡 Interview summary: Use list.sort(Comparator.comparing(...)) for single field. Chain with .thenComparing() for multiple fields. Use .reversed() for descending. Stream .sorted() returns a new list — original unchanged.
SOLID Principles in Java
Design · Interview Essential
SOLID is a set of 5 object-oriented design principles that make code more maintainable, scalable, and testable.
S — Single Responsibility Principle
A class should have one, and only one, reason to change.
// ❌ Bad: one class doing too many things
class Employee {
void calculateSalary() { ... }
void saveToDatabase() { ... } // ← persistence: different responsibility
void generatePayslipPDF() { ... } // ← reporting: different responsibility
}
// ✅ Good: each class has one job
class Employee { void calculateSalary() {} }
class EmployeeRepository { void save(Employee e) {} }
class PayslipService { void generate(Employee e) {} }
O — Open/Closed Principle
Open for extension, closed for modification. Add new behaviour by adding new code, not by editing existing code.
// ❌ Bad: adding a new vehicle type requires modifying this method
double calculateTax(Vehicle v) {
if (v instanceof Car) return v.getValue() * 0.10;
if (v instanceof Truck) return v.getValue() * 0.20;
return 0;
}
// ✅ Good: each subclass handles its own calculation
abstract class Vehicle { abstract double calculateTax(); }
class Car extends Vehicle { double calculateTax() { return getValue() * 0.10; } }
class Truck extends Vehicle { double calculateTax() { return getValue() * 0.20; } }
// Add Bike? Just add new class — don't touch existing code
L — Liskov Substitution Principle
Subclasses must be substitutable for their superclass without breaking the program.
// ❌ Bad: Square extends Rectangle but breaks its contract
class Rectangle { setWidth(w); setHeight(h); getArea() = w*h; }
class Square extends Rectangle {
setWidth(w) { this.width = this.height = w; } // also sets height! breaks Rectangle's contract
}
// Using Square as Rectangle → unexpected area calculations
// ✅ Good: both extend Shape independently
abstract class Shape { abstract double getArea(); }
class Rectangle extends Shape { double getArea() { return width * height; } }
class Square extends Shape { double getArea() { return side * side; } }
I — Interface Segregation Principle
No class should be forced to implement methods it doesn't use. Prefer small, focused interfaces.
// ❌ Bad: one fat interface forces Bike to implement openDoors()
interface Vehicle { startEngine(); openDoors(); fly(); }
class Bike implements Vehicle { void openDoors() { throw new UnsupportedOperationException(); } }
// ✅ Good: split into focused interfaces
interface Motorized { void startEngine(); }
interface HasDoors { void openDoors(); }
class Car implements Motorized, HasDoors { ... }
class Bike implements Motorized { ... } // no forced openDoors()
D — Dependency Inversion Principle
High-level modules should not depend on low-level modules. Both should depend on abstractions.
// ❌ Bad: Car is tightly coupled to PetrolEngine
class Car {
private PetrolEngine engine = new PetrolEngine(); // hard dependency
}
// ✅ Good: Car depends on abstraction (interface), not concrete class
interface Engine { void start(); }
class PetrolEngine implements Engine { public void start() { ... } }
class ElectricEngine implements Engine { public void start() { ... } }
class Car {
private final Engine engine;
public Car(Engine engine) { this.engine = engine; } // injected — swap engine freely
}
// Spring's @Autowired implements DIP — inject any implementation of an interface
Java Design Patterns
Design · Interview Essential
Design patterns are reusable solutions to common software design problems. Categorised into three groups: Creational, Structural, and Behavioural.
Creational Patterns
Singleton — One instance, globally accessible
public class DatabaseConnection {
private static volatile DatabaseConnection instance;
private DatabaseConnection() {} // private constructor
public static DatabaseConnection getInstance() {
if (instance == null) {
synchronized (DatabaseConnection.class) {
if (instance == null) // double-checked locking (thread-safe)
instance = new DatabaseConnection();
}
}
return instance;
}
}
// Use: DatabaseConnection.getInstance()
// Used by: Spring Beans (default scope), Logger, Config managers
Factory Method — Let subclasses decide what to create
interface Notification { void send(String msg); }
class EmailNotification implements Notification { public void send(String m) { ... } }
class SMSNotification implements Notification { public void send(String m) { ... } }
class NotificationFactory {
public static Notification create(String type) {
return switch (type) {
case "EMAIL" -> new EmailNotification();
case "SMS" -> new SMSNotification();
default -> throw new IllegalArgumentException("Unknown type");
};
}
}
Notification n = NotificationFactory.create("EMAIL");
n.send("Hello!");
Builder — Construct complex objects step by step
public class User {
private final String name;
private final String email;
private final int age;
private User(Builder b) { this.name=b.name; this.email=b.email; this.age=b.age; }
public static class Builder {
private String name;
private String email;
private int age;
public Builder name(String v) { this.name=v; return this; }
public Builder email(String v) { this.email=v; return this; }
public Builder age(int v) { this.age=v; return this; }
public User build() { return new User(this); }
}
}
User u = new User.Builder().name("Aftab").email("a@b.com").age(28).build();
// Used by: StringBuilder, Lombok @Builder, Spring's UriComponentsBuilder
Structural Patterns
Decorator — Add behaviour without modifying the class
interface Coffee { double cost(); }
class SimpleCoffee implements Coffee { public double cost() { return 50; } }
class MilkDecorator implements Coffee {
private Coffee coffee;
MilkDecorator(Coffee c) { this.coffee = c; }
public double cost() { return coffee.cost() + 10; } // wraps and adds
}
Coffee c = new MilkDecorator(new SimpleCoffee()); // cost = 60
// Used by: Java I/O (BufferedReader wraps FileReader), Spring Security filter chain
Proxy — Control access to an object
interface Service { String getData(); }
class RealService implements Service { public String getData() { return "data"; } }
class CachingProxy implements Service {
private RealService real = new RealService();
private String cache = null;
public String getData() {
if (cache == null) cache = real.getData(); // call real only once
return cache;
}
}
// Used by: Spring AOP (@Transactional, @Cacheable create proxies), Hibernate lazy loading
Behavioural Patterns
Observer — Notify multiple objects when state changes
interface Observer { void update(String event); }
class EventBus {
private List<Observer> observers = new ArrayList<>();
void subscribe(Observer o) { observers.add(o); }
void publish(String event) { observers.forEach(o -> o.update(event)); }
}
// Used by: Spring's ApplicationEvent, Java's java.util.Observer, RxJava
Strategy — Swap algorithms at runtime
interface SortStrategy { void sort(int[] arr); }
class BubbleSort implements SortStrategy { public void sort(int[] a) { ... } }
class QuickSort implements SortStrategy { public void sort(int[] a) { ... } }
class Sorter {
private SortStrategy strategy;
void setStrategy(SortStrategy s) { this.strategy = s; }
void sort(int[] arr) { strategy.sort(arr); }
}
// Used by: Comparator (it IS a strategy), Spring Security authentication strategies
💡 Spring Boot uses patterns everywhere: Singleton (@Bean default scope) · Factory (BeanFactory) · Proxy (@Transactional, @Cacheable) · Observer (ApplicationEvent) · Template Method (JdbcTemplate) · Decorator (Filter chain)
Microservices Design Patterns
Architecture · Interview Essential
Microservices break a monolith into independently deployable services. These patterns solve the common problems that arise.
Decomposition Patterns
1. Decomposition by Business Capability
Split the monolith along business capabilities: Order Service, Payment Service, Inventory Service, User Service — each maps to one team's ownership.
2. Strangler Fig Pattern
Gradually replace a monolith — new microservices intercept requests for specific features, wrapping the legacy system. The monolith "dies" as each feature is migrated.
Communication Patterns
3. API Gateway
Single entry point for all clients. Handles routing, authentication, rate limiting, SSL termination, and response aggregation. Prevents clients from calling multiple services directly.
Client → API Gateway (Spring Cloud Gateway / Netflix Zuul)
↓ ↓ ↓
Order Service User Service Payment Service
4. Aggregator Pattern
A composite service calls multiple downstream services, combines their responses, and returns one consolidated response to the client.
5. Circuit Breaker (Resilience4j)
// States:
CLOSED → requests pass through normally
↓ (failures exceed threshold, e.g. 50% in 10 seconds)
OPEN → all requests fail-fast (no call to downstream service)
↓ (after timeout, e.g. 30 seconds)
HALF-OPEN → test requests allowed through
↓ (if test succeeds) → back to CLOSED
↓ (if test fails) → back to OPEN
// Prevents cascading failure: if Payment Service is down,
// Order Service doesn't hang waiting — it fails fast immediately
@CircuitBreaker(name = "paymentService", fallbackMethod = "fallback")
public Order processPayment(Order order) { ... }
public Order fallback(Order order, Exception e) { return "Payment queued for retry"; }
Data Patterns
6. Database Per Service
Each microservice has its own database. No shared DB between services. Services communicate via APIs or events — never direct DB joins across services.
7. CQRS (Command Query Responsibility Segregation)
Separate the write model (Commands: create/update/delete) from the read model (Queries: optimised views). Read replicas can be highly denormalized for performance.
// Command side: handles writes, emits events
POST /orders → OrderCommandService → saves to write DB → publishes OrderCreatedEvent
// Query side: subscribes to events, builds read-optimised views
OrderCreatedEvent → OrderProjectionService → updates read DB (denormalised view)
GET /orders/{id} → queries read DB directly → fast response
8. Event Sourcing
Instead of storing current state, store all events that led to that state. The current state is derived by replaying events. Enables full audit trail and time-travel debugging.
Async Communication
9. Asynchronous Messaging (Kafka / RabbitMQ)
Services communicate via a message broker instead of synchronous HTTP. The publisher doesn't wait for the consumer. Decouples services and enables retry/dead-letter queuing.
💡 Interview tip — know these for sure: API Gateway, Circuit Breaker, Database-per-Service, CQRS, Saga pattern (for distributed transactions across services).
12-Factor App with Spring Boot
Architecture · Modern Best Practice
The 12-Factor methodology is a set of best practices for building scalable, maintainable, cloud-native applications. Spring Boot is built to align with these principles.
| # | Factor | Principle | Spring Boot Implementation |
| 1 | Codebase | One repo, many deploys | Git, one repo per service |
| 2 | Dependencies | Declare all deps explicitly | pom.xml with spring-boot-starter-* |
| 3 | Config | Config in environment, not code | ${DB_URL} in application.properties; Spring Cloud Config |
| 4 | Backing Services | DB/queue = attached resource | Swap DB via config — no code change (@Repository) |
| 5 | Build/Release/Run | Strict stage separation | mvn package → release JAR → java -jar |
| 6 | Processes | Stateless, share-nothing | REST endpoints hold no session state; sessions in Redis |
| 7 | Port Binding | Self-contained, export via port | Embedded Tomcat: java -jar app.jar → runs on port 8080 |
| 8 | Concurrency | Scale out via process model | Run multiple instances behind Kubernetes / load balancer |
| 9 | Disposability | Fast startup, graceful shutdown | Idempotent endpoints; server.shutdown=graceful |
| 10 | Dev/Prod Parity | Keep environments similar | Docker containers ensure identical runtime |
| 11 | Logs | Treat logs as event streams | SLF4J → stdout → ELK (Elasticsearch, Logstash, Kibana) |
| 12 | Admin Processes | One-off tasks in same environment | Flyway/Liquibase DB migrations; Spring Batch jobs |
Factor 3 (Config) — Most Important in Practice
# application.properties — never hardcode env-specific values
spring.datasource.url = jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_NAME}
spring.datasource.username = ${DB_USER}
spring.datasource.password = ${DB_PASS}
jwt.secret = ${JWT_SECRET}
# Set via environment variables, Docker secrets, or Kubernetes ConfigMaps
# This same code runs in dev, staging, and production unchanged
💡 Why it matters: Following 12-Factor makes your app cloud-native — easy to containerise, scale horizontally, deploy to Kubernetes, and operate in CI/CD pipelines. It's the foundation of modern DevOps.