A Guide For Practical Modularity and Architecture with Spring

Modern Spring projects rarely fail because of missing features. They fail because of architecture erosion: tangled dependencies, oversized contexts, and codebases that become hostile to change. Spring’s flexibility is a double-edged sword: without discipline, it enables exactly the kind of accidental complexity developers later regret.

In this article I focus on concrete, developer-controlled practices to improve modularity and architecture in Spring-based systems. The goal is not theoretical purity, but sustainable projects that scale in features, teams, and time.


1. Modular Package Design: Structure Is Architecture

Package structure is not cosmetic. It defines boundaries, controls coupling, and communicates intent.

Avoid Technical Layer Packages

This structure is commonβ€”and problematic:

com.example.app
β”œβ”€β”€ controller
β”œβ”€β”€ service
β”œβ”€β”€ repository
└── model

It encourages cross-cutting dependencies and turns services into dumping grounds.

Prefer Feature-Oriented Modules

A better approach is vertical slicing by domain or feature:

com.example.orders
β”œβ”€β”€ api
β”‚   └── OrderController.java
β”œβ”€β”€ application
β”‚   └── PlaceOrderService.java
β”œβ”€β”€ domain
β”‚   β”œβ”€β”€ Order.java
β”‚   └── OrderRepository.java
└── infrastructure
└── JpaOrderRepository.java

Benefits:

Rule of thumb: If a package cannot be deleted without breaking unrelated features, it is not a real module.


2. Hexagonal / Clean Architecture with Spring (Without the Ceremony)

Hexagonal (Ports & Adapters) and Clean Architecture work extremely well with Spring, if you let Spring adapt to the architecture, not the other way around.

Core Principle

Example: Application Port

public interface PaymentGateway {
  PaymentResult charge(PaymentRequest request);
}

Application Service

public class ProcessPaymentService {
  private final PaymentGateway gateway;

  public ProcessPaymentService(PaymentGateway gateway) {
    this.gateway = gateway;
  }

  public void process(PaymentRequest request) {
    gateway.charge(request);
  }
}

Spring Adapter

@Component
class StripePaymentGateway implements PaymentGateway {
  @Override
  public PaymentResult charge(PaymentRequest request) {
    // Stripe API integration
  }
}

Spring Configuration

@Configuration
class PaymentConfig {
  @Bean
  ProcessPaymentService processPaymentService(PaymentGateway gateway) {
    return new ProcessPaymentService(gateway);
  }
}

Why this matters:

My strong opinion: if your domain layer has @Component, you already lost.


3. Avoiding God-Contexts: Stop Wiring Everything Everywhere

Spring’s auto-scanning makes it dangerously easy to create a single, massive application context.

Symptoms of a God-Context

Practical Countermeasures

1. Explicit Configuration per Module

@Configuration
@ComponentScan(basePackageClasses = OrdersModule.class)
public class OrdersConfiguration {}

Each module owns its configuration. No global scanning.

2. Use spring-context-indexer

This avoids classpath scanning entirely and forces discipline.

<dependency>
  <groupId>org.springframework</groupId>
  <artifactId>spring-context-indexer</artifactId>
  <optional>true</optional>
</dependency>

3. Context Boundaries in Tests

@SpringBootTest(classes = OrdersConfiguration.class)
class OrderServiceTest {}

If a test requires half the system to start, the architecture is already broken.


4. Multi-Module Spring Boot Projects: Real Modularity at Build Time

Single-module projects scale poorly once multiple teams are involved.

root
β”œβ”€β”€ domain
β”œβ”€β”€ application
β”œβ”€β”€ infrastructure
β”œβ”€β”€ web
└── boot

Boot Module Example

@SpringBootApplication
@Import({
  OrdersConfiguration.class,
  PaymentsConfiguration.class
})
public class Application {
  public static void main(String[] args) {
    SpringApplication.run(Application.class, args);
  }
}

Benefits:

My recommendation: if your IDE can’t prevent illegal dependencies, your build should.


5. Event-Driven Design: Decoupling Without Chaos

Events are one of the most effective ways to reduce coupling, when used deliberately.

Domain Events with Spring Events

public record OrderPlacedEvent(UUID orderId) {}

@Component
class OrderService {
  private final ApplicationEventPublisher publisher;

  public void placeOrder(Order order) {
    // persist order
    publisher.publishEvent(new OrderPlacedEvent(order.getId()));
  }
}

@Component
class InventoryListener {
  @EventListener
  public void on(OrderPlacedEvent event) {
    // reserve inventory
  }
}

Advantages:

Asynchronous Events

@Async
@EventListener
public void handle(OrderPlacedEvent event) { }

When to Move to Messaging

Use brokers (Kafka, RabbitMQ) when:

Spring makes the transition smooth, but do not start with messaging if in-process events suffice. Complexity compounds quickly.


Architecture Is a Daily Practice

Spring does not enforce good architecture, it amplifies whatever discipline you already have.

Developers can dramatically improve project quality by:

Modularity is not a one-time decision. It is a habit reinforced by structure, tooling, and refusal to accept β€œjust one shortcut.”