A Guide For Practical Modularity and Architecture with Spring
02 Apr 2026Modern 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:
- Changes stay localized
- Dependencies become intentional
- Refactoring costs drop significantly
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
- Domain and application layers know nothing about Spring
- Spring is an infrastructure detail
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:
- Business logic is testable without Spring
- Infrastructure can be swapped without refactoring core logic
- Framework upgrades become far less risky
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
- Thousands of beans loaded for every test
- Unrelated modules referencing each other βjust because it worksβ
- Refactoring causes cascading failures
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.
Recommended Module Layout
root
βββ domain
βββ application
βββ infrastructure
βββ web
βββ boot
domain: entities, value objects, domain servicesapplication: use casesinfrastructure: JPA, messaging, external APIsweb: REST, GraphQL, MVCboot: Spring Boot entry point
Boot Module Example
@SpringBootApplication
@Import({
OrdersConfiguration.class,
PaymentsConfiguration.class
})
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
Benefits:
- Clear dependency direction
- Faster builds
- Easier parallel development
- Enforced architectural boundaries via Maven/Gradle
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:
- No direct dependencies
- Easy extensibility
- Clean separation of concerns
Asynchronous Events
@Async
@EventListener
public void handle(OrderPlacedEvent event) { }
When to Move to Messaging
Use brokers (Kafka, RabbitMQ) when:
- You need durability
- You need cross-service communication
- You need replayability
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:
- Designing feature-oriented packages
- Keeping Spring out of the domain
- Enforcing module boundaries
- Limiting application context size
- Using events to decouple behavior
Modularity is not a one-time decision. It is a habit reinforced by structure, tooling, and refusal to accept βjust one shortcut.β