Scalable Spring Boot Architecture and Deployment
Architecting Scalable Services
Structuring for Growth
Getting a web service running with @RestController is a great first step, but production-grade applications demand a more robust structure. As systems grow, mixing business logic, data access, and web request handling in one place becomes unmanageable. To keep things clean, scalable, and maintainable, we separate concerns into distinct layers.
A layered architecture is like having specialised departments in a company. The web team handles customer requests, the operations team executes the business tasks, and the records team manages the data. Each layer has a clear job and only talks to the layers directly adjacent to it.
This separation makes the system easier to reason about. A change to the database schema should only affect the repository layer, not the controller. This principle of loose coupling is key to building software that can evolve without breaking.
Connecting the Layers
To make these layers work together, we need a way to connect them. Instead of having a service class create its own repository instance (e.g., OrderRepository repo = new OrderRepositoryImpl();), we let the Spring framework provide the necessary dependencies. This is called Dependency Injection (DI).
The preferred method is constructor injection. By declaring dependencies as arguments in a class's constructor, you make the class's requirements explicit and easy to see. It also allows for creating immutable fields, which is a good practice.
// In the Service Layer
import org.springframework.stereotype.Service;
@Service
public class ProductService {
private final ProductRepository productRepository;
// Spring injects the ProductRepository bean via the constructor
public ProductService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
public Product getProduct(Long id) {
// Business logic would go here
return productRepository.findById(id).orElse(null);
}
}
Using constructor injection makes testing much simpler. In your tests, you can easily create a mock
ProductRepositoryand pass it to theProductServiceconstructor, giving you full control over its behaviour.
Configuration for Different Worlds
Your application won't run in a single environment. It will move from your local development machine to a staging server for testing, and finally to production. Each environment has unique configuration, like database URLs, API keys, and feature flags. Managing this manually is error-prone.
Spring solves this with Profiles a powerful way to segregate parts of your application configuration and make them active only in certain environments. You can define beans that only get created when a specific profile is active.
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
@Configuration
public class DataSourceConfig {
@Bean(name = "dataSource")
@Profile("development")
DataSource developmentDataSource() {
// Configuration for an in-memory H2 database
// ...
return new H2DataSource();
}
@Bean(name = "dataSource")
@Profile("production")
DataSource productionDataSource() {
// Configuration for a pooled production PostgreSQL database
// ...
return new ProductionDataSource();
}
}
In this example, Spring will create the H2DataSource bean if the development profile is active, or the ProductionDataSource if the production profile is active. The active profile is typically set via an environment variable or a command-line argument when starting the application.
Externalise and Customise
Hardcoding configuration values, even in profile-specific beans, is a bad practice. Configuration should live outside the application code. Spring Boot makes this easy with properties files (application.properties or application.yml) and type-safe binding.
First, you create a dedicated configuration file for each profile, like application-development.properties and application-production.properties. Spring automatically loads the correct file based on the active profile.
Then, you can use @ConfigurationProperties to map these properties to a Plain Old Java Object (POJO). This approach is clean, type-safe, and keeps all related properties grouped together.
# In application-production.properties
app.database.url=jdbc:postgresql://prod-db.example.com:5432/orders
app.database.username=prod_user
app.feature.new-checkout.enabled=true
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "app")
public record AppConfig(Database database, Feature feature) {
public record Database(String url, String username) { }
public record Feature(NewCheckout newCheckout) { }
public record NewCheckout(boolean enabled) { }
}
With this setup, Spring populates an AppConfig bean with values from your properties file. You can then inject this bean anywhere you need it.
Sometimes, you need to create a bean only if a certain property is set. The @ConditionalOnProperty annotation is perfect for this. It lets you enable or disable entire features with a simple configuration toggle, without changing any code.
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class FeatureConfiguration {
@Bean
@ConditionalOnProperty(
value="app.feature.new-checkout.enabled",
havingValue = "true"
)
public NewCheckoutService newCheckoutService() {
return new NewCheckoutService();
}
}
Here, the NewCheckoutService bean will only be created and added to the application context if the property app.feature.new-checkout.enabled is set to true. This is incredibly useful for rolling out new features gradually or enabling specific functionality in certain environments.
Let's check your understanding of these architectural patterns.
What is the primary reason for separating a Spring application into distinct layers like Controller, Service, and Repository?
Which annotation allows you to create a bean only when a specific property in application.properties is set to a certain value?
By moving beyond basic annotations and adopting these structured patterns, you create services that are not only functional but also decoupled, testable, and ready to scale with changing requirements.