Skip to main content

Command Palette

Search for a command to run...

Building Large-Scale Applications with Spring Boot: A System Design Perspective

Published
5 min read
F

hey i am java backend developer and i have 3 years of experience working as java developer.

Introduction

Spring Boot has become the framework of choice for building enterprise-grade Java applications due to its convention-over-configuration approach, embedded server support, and extensive ecosystem. However, as applications grow in size and complexity, proper system design becomes crucial to maintain performance, scalability, and maintainability.

This article explores architectural patterns, design considerations, and implementation strategies for building large-scale applications with Spring Boot while addressing common challenges in distributed systems.

1. Architectural Foundations

1.1 Layered Architecture

For large applications, a well-defined layered architecture is essential:

┌─────────────────────┐
│      Presentation   │ (REST API, Web UI, Mobile Backend)
├─────────────────────┤
│       Business      │ (Service Layer, Domain Logic)
├─────────────────────┤
│      Persistence    │ (Repositories, Data Access)
├─────────────────────┤
│   Infrastructure    │ (External Services, Messaging)
└─────────────────────┘

Implementation in Spring Boot:

@RestController // Presentation
public class UserController {

    private final UserService userService; // Business

    @GetMapping("/users/{id}")
    public ResponseEntity<UserDto> getUser(@PathVariable Long id) {
        return ResponseEntity.ok(userService.getUserById(id));
    }
}

@Service // Business
public class UserService {

    private final UserRepository userRepository; // Persistence

    public UserDto getUserById(Long id) {
        User user = userRepository.findById(id)
            .orElseThrow(() -> new ResourceNotFoundException("User not found"));
        return mapToDto(user);
    }
}

@Repository // Persistence
public interface UserRepository extends JpaRepository<User, Long> {
}

1.2 Modular Design

For large applications, break the system into modules (bounded contexts):

my-large-app/
├── user-module/
│   ├── src/main/java/com/myapp/user
│   ├── build.gradle
├── order-module/
│   ├── src/main/java/com/myapp/order
│   ├── build.gradle
├── inventory-module/
│   ├── src/main/java/com/myapp/inventory
│   ├── build.gradle
└── core-module/
    ├── src/main/java/com/myapp/core
    ├── build.gradle

Gradle multi-project setup:

// settings.gradle
include 'user-module'
include 'order-module'
include 'inventory-module'
include 'core-module'

2. Scaling Strategies

2.1 Vertical vs Horizontal Scaling

Vertical Scaling:

server.tomcat.max-threads=200
spring.datasource.hikari.maximum-pool-size=20

Horizontal Scaling:

  • Add more application instances

  • Requires stateless design:

@RestController
public class StatelessController {

    @GetMapping("/data")
    public String getData() {
        // No session state
        return "scalable-response";
    }
}

2.2 Caching Strategies

Application-level caching:

@Service
@CacheConfig(cacheNames = "products")
public class ProductService {

    @Cacheable
    public Product getProduct(Long id) {
        // DB call
    }

    @CacheEvict(allEntries = true)
    public void refreshProducts() {
        // Clear cache
    }
}

Distributed caching with Redis:

# application.properties
spring.cache.type=redis
spring.redis.host=redis-cluster.example.com
spring.redis.port=6379

3. Database Design for Scale

3.1 Database Sharding

@Configuration
public class ShardingConfig {

    @Bean
    public DataSource dataSource() {
        Map<Object, Object> dataSources = new HashMap<>();
        dataSources.put("shard1", shard1DataSource());
        dataSources.put("shard2", shard2DataSource());

        ShardingKeyDeterminer determiner = new CustomerRegionShardKeyDeterminer();

        return new ShardingDataSource(dataSources, determiner);
    }

    private DataSource shard1DataSource() {
        // Configure first shard
    }

    private DataSource shard2DataSource() {
        // Configure second shard
    }
}

3.2 Read Replicas

@Configuration
@EnableTransactionManagement
public class DatabaseConfig {

    @Bean
    @Primary
    @ConfigurationProperties("spring.datasource.write")
    public DataSource writeDataSource() {
        return DataSourceBuilder.create().build();
    }

    @Bean
    @ConfigurationProperties("spring.datasource.read")
    public DataSource readDataSource() {
        return DataSourceBuilder.create().build();
    }

    @Bean
    public DataSource routingDataSource() {
        RoutingDataSource routingDataSource = new RoutingDataSource();

        Map<Object, Object> dataSourceMap = new HashMap<>();
        dataSourceMap.put("write", writeDataSource());
        dataSourceMap.put("read", readDataSource());

        routingDataSource.setTargetDataSources(dataSourceMap);
        routingDataSource.setDefaultTargetDataSource(writeDataSource());

        return routingDataSource;
    }
}

4. Microservices Communication

4.1 Synchronous Communication (REST)

@FeignClient(name = "inventory-service", url = "${inventory.service.url}")
public interface InventoryClient {

    @GetMapping("/api/inventory/{productId}")
    InventoryResponse getInventory(@PathVariable String productId);
}

@RestController
public class OrderController {

    private final InventoryClient inventoryClient;

    @PostMapping("/orders")
    public ResponseEntity<Order> createOrder(@RequestBody OrderRequest request) {
        InventoryResponse inventory = inventoryClient.getInventory(request.getProductId());

        if (inventory.getStock() < request.getQuantity()) {
            throw new InsufficientStockException();
        }

        // Process order
    }
}

4.2 Asynchronous Communication (Event-Driven)

Spring Cloud Stream with Kafka:

@SpringBootApplication
@EnableBinding(OrderChannels.class)
public class OrderApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderApplication.class, args);
    }
}

public interface OrderChannels {
    String ORDER_EVENTS = "orderEvents";

    @Output(ORDER_EVENTS)
    MessageChannel orderEvents();
}

@Service
public class OrderService {

    private final OrderChannels channels;

    public void processOrder(Order order) {
        // Process order

        OrderEvent event = new OrderEvent(order.getId(), "CREATED");
        channels.orderEvents().send(MessageBuilder.withPayload(event).build());
    }
}

5. Performance Optimization

5.1 Connection Pooling

# HikariCP configuration
spring.datasource.hikari.maximum-pool-size=20
spring.datasource.hikari.minimum-idle=10
spring.datasource.hikari.idle-timeout=30000
spring.datasource.hikari.connection-timeout=20000

5.2 JVM Tuning

# Application startup parameters
java -Xms512m -Xmx2g -XX:+UseG1GC -XX:MaxGCPauseMillis=200 \
     -jar your-application.jar

5.3 Monitoring and Metrics

Spring Boot Actuator with Prometheus:

# application.properties
management.endpoints.web.exposure.include=health,metrics,prometheus
management.metrics.export.prometheus.enabled=true

6. Security Considerations

6.1 OAuth2 Resource Server

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests()
                .antMatchers("/public/**").permitAll()
                .anyRequest().authenticated()
            .and()
            .oauth2ResourceServer()
                .jwt()
                .decoder(jwtDecoder());
    }

    @Bean
    public JwtDecoder jwtDecoder() {
        return NimbusJwtDecoder.withJwkSetUri("https://auth-server/.well-known/jwks.json").build();
    }
}

6.2 Rate Limiting

@Configuration
public class RateLimitConfig {

    @Bean
    public RateLimiter rateLimiter() {
        return RateLimiter.create(100); // 100 requests per second
    }
}

@RestController
public class ApiController {

    private final RateLimiter rateLimiter;

    @GetMapping("/api/data")
    public ResponseEntity<String> getData() {
        if (!rateLimiter.tryAcquire()) {
            throw new TooManyRequestsException();
        }

        return ResponseEntity.ok("data");
    }
}

7. Deployment Strategies

7.1 Containerization with Docker

FROM eclipse-temurin:17-jdk-jammy

WORKDIR /app

COPY build/libs/myapp-*.jar app.jar

EXPOSE 8080

ENTRYPOINT ["java", "-jar", "app.jar"]

7.2 Kubernetes Deployment

apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
spec:
  replicas: 3
  selector:
    matchLabels:
      app: myapp
  template:
    metadata:
      labels:
        app: myapp
    spec:
      containers:
      - name: myapp
        image: myregistry/myapp:1.0.0
        ports:
        - containerPort: 8080
        resources:
          requests:
            cpu: "500m"
            memory: "1Gi"
          limits:
            cpu: "1"
            memory: "2Gi"
        readinessProbe:
          httpGet:
            path: /actuator/health
            port: 8080
          initialDelaySeconds: 30
          periodSeconds: 10

8. Observability

8.1 Distributed Tracing

# application.properties
spring.sleuth.sampler.probability=1.0
management.zipkin.tracing.endpoint=http://zipkin:9411/api/v2/spans

8.2 Centralized Logging

@Configuration
public class LoggingConfig {

    @Bean
    public CorrelationIdFilter correlationIdFilter() {
        return new CorrelationIdFilter();
    }
}

public class CorrelationIdFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, 
                                  HttpServletResponse response, 
                                  FilterChain filterChain) {
        MDC.put("correlationId", UUID.randomUUID().toString());
        try {
            filterChain.doFilter(request, response);
        } finally {
            MDC.remove("correlationId");
        }
    }
}

Conclusion

Building large-scale applications with Spring Boot requires careful consideration of architectural patterns, modular design, scaling strategies, and operational concerns. By implementing proper system design principles from the beginning, teams can create maintainable, scalable, and performant applications that evolve with business needs.

Key takeaways:

  • Adopt modular architecture to manage complexity

  • Design for horizontal scaling from day one

  • Implement proper observability from the start

  • Choose communication patterns based on use cases

  • Automate deployment and scaling processes

  • Continuously monitor and optimize performance

Remember that system design is an iterative process. As your application grows, regularly reassess your architectural decisions and be prepared to evolve your design to meet new challenges.

More from this blog