JOptimize Team
Most Spring Boot projects start clean. A controller here, a service there, a few repositories. Six months later, you have a UserService with 40 methods, controllers calling repositories directly, and circular dependencies that nobody dares to touch.
This is not a skill problem. It's an architecture drift problem — and it happens to everyone.
In this article, we'll look at the four most common architecture issues in Spring Boot projects, how to detect them early, and how to fix them before they become expensive.
Spring Boot encourages a clean layered architecture:
Controller → Service → Repository → Model
Each layer should only talk to the one directly below it. When a controller imports a repository directly, it bypasses the service layer entirely — business logic leaks into the HTTP layer, and your code becomes hard to test and maintain.
What it looks like:
@RestController public class OrderController { // ❌ Controller directly depends on Repository private final OrderRepository orderRepository; @GetMapping("/orders/{id}") public Order getOrder(@PathVariable Long id) { return orderRepository.findById(id).orElseThrow(); } }
The fix: Every controller method should delegate to a service. The service owns the business logic, the controller owns the HTTP contract.
@RestController public class OrderController { private final OrderService orderService; // ✅ @GetMapping("/orders/{id}") public Order getOrder(@PathVariable Long id) { return orderService.getOrder(id); } }
A Complex Class is a class that does too much. It knows too much about the system, has too many methods, and accumulates responsibilities over time. In Spring Boot, this almost always happens to the main service class of a core domain.
Signs your class has become a Complex Class:
What it looks like:
@Service public class UserService { // 8 injected dependencies private final UserRepository userRepository; private final EmailService emailService; private final BillingService billingService; private final NotificationService notificationService; private final AuditService auditService; private final CacheService cacheService; private final ReportService reportService; private final MetricsService metricsService; // 35 methods covering authentication, billing, notifications, // reporting, caching, auditing... }
The fix: Apply the Single Responsibility Principle. Each class should have one reason to change. Extract focused services:
@Service public class UserRegistrationService { private final UserRepository userRepository; private final EmailService emailService; // Only handles registration } @Service public class UserBillingService { private final BillingService billingService; // Only handles billing }
A circular dependency happens when package A imports from package B, which imports from package A. The JVM handles this at class level, but at the architecture level it means your modules are tightly coupled and cannot be understood or changed independently.
com.app.order → com.app.payment com.app.payment → com.app.order ← circular
The fix: Introduce a shared contract package. Both packages depend on the interface, not on each other.
com.app.order → com.app.shared.contract com.app.payment → com.app.shared.contract
Alternatively, merge the two packages if they're genuinely inseparable, or use Spring events (ApplicationEventPublisher) to decouple them at runtime.
An Anemic Domain Model means your @Entity classes are just data bags — getters, setters, no logic. All the business rules live in services, making them bloated and hard to test in isolation.
// ❌ Anemic entity — no behavior @Entity public class Order { private OrderStatus status; private BigDecimal total; // Just getters and setters } // All logic in service @Service public class OrderService { public void confirmOrder(Order order) { if (order.getStatus() == PENDING && order.getTotal().compareTo(ZERO) > 0) { order.setStatus(CONFIRMED); order.setConfirmedAt(LocalDateTime.now()); } } }
The fix: Move business behavior into the entity itself. The entity owns its invariants.
// ✅ Rich domain entity @Entity public class Order { private OrderStatus status; private BigDecimal total; public void confirm() { if (status != PENDING) throw new IllegalStateException("Only pending orders can be confirmed"); if (total.compareTo(ZERO) <= 0) throw new IllegalStateException("Order total must be positive"); this.status = CONFIRMED; this.confirmedAt = LocalDateTime.now(); } }
The best way to prevent architecture drift is to write tests for it. ArchUnit is a Java library that lets you express architecture rules as unit tests.
@AnalyzeClasses(packages = "com.myapp") public class ArchitectureTest { @ArchTest static final ArchRule layerRule = layeredArchitecture() .consideringAllDependencies() .layer("Controller").definedBy("..controller..") .layer("Service").definedBy("..service..") .layer("Repository").definedBy("..repository..") .whereLayer("Controller").mayOnlyBeAccessedByLayers("Service") .whereLayer("Repository").mayOnlyBeAccessedByLayers("Service"); @ArchTest static final ArchRule noCircularDeps = slices() .matching("com.myapp.(*)..") .should().beFreeOfCycles(); }
Add this to your CI pipeline and architecture violations fail the build before they reach production.
Manually reviewing architecture takes hours. JOptimize automates it — upload your project ZIP or paste a GitHub URL and get a full architecture audit in under a minute.
What JOptimize detects:
The architecture audit costs 2 credits (from €1). No subscription, no setup, no CI integration required to get started.
# Or run it locally with the CLI — your code never leaves your machine npm install -g @joptimize/cli joptimize auth jp_live_your_key joptimize arch .
Your architecture report is saved to your dashboard with a shareable link — useful for sharing findings with your team before a refactoring sprint.
| Issue | Risk | Fix | |---|---|---| | Layer violations | Business logic in HTTP layer | Controllers → Services only | | Complex Classes | Unmaintainable, untestable | Single Responsibility Principle | | Circular deps | Tight coupling, can't change one without the other | Shared contracts or events | | Anemic Domain | Logic scattered across services | Behavior in entities |
Architecture doesn't rot overnight. It drifts — one shortcut at a time. The earlier you detect it, the cheaper it is to fix.
Master Spring Boot, security, and Java performance with hands-on courses.
JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.