Back to Blog

How to Audit Your Spring Boot Architecture Before It's Too Late

J

JOptimize Team

April 21, 2026· 5 min read

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.


1. Layer violations — when controllers talk to repositories directly

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); } }

2. Complex classes — the Complex Class problem

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:

  • More than 20 methods
  • More than 15 fields
  • Over 500 lines of code
  • More than 10 dependencies injected via constructor

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 }

3. Circular dependencies — when packages depend on each other

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.


4. Anemic Domain Model — entities with no behavior

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(); } }

How to enforce this automatically with ArchUnit

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.


Detect all of this in 30 seconds with JOptimize

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:

  • Layer violations — controllers importing repositories, services importing controllers
  • Complex classes — Complex Classes with their complexity score, method count, and field count
  • Circular dependencies — package-level cycles with severity (critical vs warning)
  • Architecture pattern — Layered, Hexagonal, Clean Architecture, Anemic Domain Model, automatically detected from your code
  • AI recommendation — concrete migration steps based on your actual classes and packages

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.


Summary

| 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.

Want to go deeper?

Master Spring Boot, security, and Java performance with hands-on courses.

Detect issues in your project

JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.