Modern Spring Boot Architecture: Beyond the Three-Layer Application

The three-layer architecture — controllers, services, repositories — is how most Spring Boot tutorials present application structure. It’s a reasonable starting point. It’s also a pattern that breaks down as applications grow.

The problems are predictable: services become God classes with hundreds of methods, repository calls leak into controllers, domain logic spreads across multiple layers, and changing anything requires understanding the entire application at once. The layered architecture doesn’t enforce boundaries — it suggests them. Suggestions decay under deadline pressure.

This article describes how to structure a Spring Boot application that remains maintainable as it grows.

What’s Wrong With Layers

The three-layer pattern organizes code by technical role:

com.example.myapp
├── controller/    ← HTTP handling
├── service/       ← "Business logic"
├── repository/    ← Data access
└── model/         ← Domain objects (often)

The fundamental problem: technical layers cut across business concerns. Everything related to order management is spread across OrderController, OrderService, OrderRepository, Order, OrderStatus, and any other classes that touch orders. Understanding order management requires understanding all of these.

The service layer becomes a coordination layer that contains almost no real domain logic — it calls repositories, performs basic transformations, and delegates decisions to whoever calls it.

// Typical service class — coordination, not domain logic
@Service
public class OrderService {
    
    // 40+ methods that do various things with orders
    public OrderDto createOrder(CreateOrderRequest request) { ... }
    public void cancelOrder(Long id) { ... }
    public void updateOrderStatus(Long id, String status) { ... }
    public Page<OrderDto> findOrdersByUser(Long userId, Pageable pageable) { ... }
    public BigDecimal calculateOrderTotal(Long id) { ... }
    public void applyDiscount(Long id, String couponCode) { ... }
    // ... 35 more methods
}

This class is a maintenance problem. Any change to order behavior requires understanding and modifying this class. It has too many responsibilities and too many dependencies.

Organizing by Domain, Not by Layer

The alternative: organize by business capability, then by layer within each capability.

com.example.myapp
├── orders/
│   ├── OrderController.java
│   ├── OrderService.java        ← Focused on orders only
│   ├── OrderRepository.java
│   ├── Order.java               ← Rich domain model
│   ├── OrderStatus.java
│   ├── PlaceOrderCommand.java
│   └── OrderSummary.java
├── inventory/
│   ├── InventoryController.java
│   ├── InventoryService.java
│   └── ...
├── payments/
│   └── ...
└── shared/
    ├── validation/
    └── pagination/

Everything related to orders lives in orders/. You can understand order behavior by looking at one package. The mental overhead of navigation disappears.

The Domain Model as the Core

In the classic three-layer pattern, the “model” classes are usually thin DTOs or JPA entities with getters and setters. Business logic lives in service classes.

This is backwards. The domain model should contain the business rules. Services should coordinate domain objects, not own logic.

// Anemic model — logic in service
@Entity
public class Order {
    @Id private Long id;
    private OrderStatus status;
    private List<OrderLine> lines;
    // getters, setters, nothing else
}

// Service owns all logic
public void cancelOrder(Long orderId, CancellationReason reason) {
    Order order = orderRepository.findById(orderId).orElseThrow();
    if (order.getStatus() == OrderStatus.DELIVERED) {
        throw new OrderCannotBeCancelledException("Already delivered");
    }
    if (order.getStatus() == OrderStatus.CANCELLED) {
        throw new OrderCannotBeCancelledException("Already cancelled");
    }
    order.setStatus(OrderStatus.CANCELLED);
    order.setCancellationReason(reason.name());
    order.setCancelledAt(Instant.now());
    orderRepository.save(order);
}
// Rich model — logic on the domain object
public class Order {
    private final OrderId id;
    private OrderStatus status;
    private Instant cancelledAt;
    private String cancellationReason;

    public void cancel(CancellationReason reason) {
        if (this.status == OrderStatus.DELIVERED) {
            throw new OrderCannotBeCancelledException("Cannot cancel a delivered order");
        }
        if (this.status == OrderStatus.CANCELLED) {
            throw new OrderCannotBeCancelledException("Order is already cancelled");
        }
        this.status = OrderStatus.CANCELLED;
        this.cancelledAt = Instant.now();
        this.cancellationReason = reason.name();
    }
}

// Service coordinates — doesn't own the logic
public void cancelOrder(OrderId orderId, CancellationReason reason) {
    Order order = orderRepository.findById(orderId).orElseThrow();
    order.cancel(reason);  // Business rule on the domain object
    orderRepository.save(order);
    eventPublisher.publish(new OrderCancelled(orderId, reason));
}

The rich model approach has a specific benefit: business rules are testable without Spring context. You can unit-test order.cancel() directly with no mocks or database.

Ports and Adapters (Hexagonal Architecture)

For applications with complex business logic, the ports and adapters pattern provides stronger boundaries.

The core idea: the domain doesn’t depend on infrastructure. Databases, HTTP clients, message queues are infrastructure. The domain defines what it needs (ports) and infrastructure provides implementations (adapters).

orders/
├── domain/
│   ├── Order.java                  ← Domain object
│   ├── OrderRepository.java        ← Port (interface)
│   ├── InventoryPort.java          ← Port (interface for inventory)
│   └── OrderService.java           ← Domain service
├── application/
│   └── PlaceOrderUseCase.java      ← Use case orchestration
└── adapters/
    ├── persistence/
    │   └── JpaOrderRepository.java  ← Adapter: implements OrderRepository
    ├── web/
    │   └── OrderController.java     ← Adapter: HTTP input
    └── messaging/
        └── OrderEventPublisher.java ← Adapter: messaging output
// Domain defines what it needs — not how it's implemented
public interface OrderRepository {
    void save(Order order);
    Optional<Order> findById(OrderId id);
    List<Order> findByCustomer(CustomerId customerId);
}

// Infrastructure provides the implementation
@Repository
public class JpaOrderRepository implements OrderRepository {

    private final OrderJpaRepository jpaRepo;
    private final OrderMapper mapper;

    @Override
    public void save(Order order) {
        jpaRepo.save(mapper.toJpaEntity(order));
    }

    @Override
    public Optional<Order> findById(OrderId id) {
        return jpaRepo.findById(id.value()).map(mapper::toDomain);
    }
}

The domain service depends only on interfaces. You can swap the JPA implementation for a MongoDB implementation without changing any domain code.

More importantly: you can test the domain service without a database, using an in-memory implementation of OrderRepository:

class OrderServiceTest {
    private final InMemoryOrderRepository repository = new InMemoryOrderRepository();
    private final FakeInventoryPort inventory = new FakeInventoryPort();
    private final OrderService service = new OrderService(repository, inventory);

    @Test
    void placingOrder_decreasesInventory() {
        inventory.addStock(ProductId.of("PROD-1"), 10);

        service.placeOrder(new PlaceOrderCommand(
            CustomerId.of("CUST-1"),
            List.of(new OrderLine(ProductId.of("PROD-1"), 2))
        ));

        assertThat(inventory.getStock(ProductId.of("PROD-1"))).isEqualTo(8);
    }
}

No Spring context. No database. Tests run in milliseconds.

Configuration Organization

Spring Boot applications accumulate configuration. Without structure, you end up with one enormous application.properties and a scattered set of @Configuration classes.

Organize configuration by module:

config/
├── SecurityConfiguration.java
├── CachingConfiguration.java
└── ObservabilityConfiguration.java

orders/
└── OrdersConfiguration.java  ← Beans specific to the orders module

Use typed configuration properties rather than @Value:

@ConfigurationProperties(prefix = "orders")
@Validated
public record OrdersProperties(
    @NotNull Duration paymentTimeout,
    @Min(1) @Max(100) int maxItemsPerOrder,
    boolean requireInventoryCheck
) {}

// application.yml
orders:
  payment-timeout: PT30S
  max-items-per-order: 50
  require-inventory-check: true

Typed properties are validated at startup, IDE-assisted, and clearly documented. @Value("${orders.payment.timeout}") scattered across 20 classes is not.

The Dependency Direction Rule

One rule that prevents most architectural degradation: dependencies point inward, toward the domain.

Web/Controllers → Application/UseCases → Domain ← Adapters/Infrastructure
  • Domain never imports from infrastructure
  • Domain never imports from application layer
  • Application layer imports from domain, not infrastructure (except through ports)
  • Infrastructure imports from domain (to implement ports) and application

Enforce this with ArchUnit:

@ArchTest
static final ArchRule domainHasNoDependencyOnAdapters = noClasses()
    .that().resideInAPackage("..domain..")
    .should().dependOnClassesThat()
    .resideInAPackage("..adapters..");

When this rule fails, it surfaces an architectural violation before it becomes a pattern.

What This Looks Like at Scale

A well-structured Spring Boot application serving a moderately complex domain:

src/main/java/com/example/myapp/
├── MyAppApplication.java
├── orders/
│   ├── domain/
│   │   ├── Order.java
│   │   ├── OrderLine.java
│   │   ├── OrderStatus.java
│   │   ├── OrderId.java
│   │   ├── OrderRepository.java     ← Port
│   │   └── InventoryPort.java       ← Port
│   ├── application/
│   │   ├── PlaceOrderUseCase.java
│   │   ├── CancelOrderUseCase.java
│   │   └── OrderQueryService.java
│   └── adapters/
│       ├── web/
│       │   ├── OrderController.java
│       │   ├── PlaceOrderRequest.java
│       │   └── OrderResponse.java
│       └── persistence/
│           ├── JpaOrderRepository.java
│           ├── OrderJpaEntity.java
│           └── OrderMapper.java
├── inventory/
│   └── ... (same structure)
└── shared/
    ├── validation/
    └── web/
        └── ErrorHandling.java

Each module is navigable, testable in isolation, and comprehensible without understanding the entire application.

Pragmatism

Not every Spring Boot application needs the full hexagonal architecture. A simple CRUD service with five entities probably doesn’t need ports and adapters. The overhead isn’t worth it.

The question is not “what’s the ideal architecture” but “what’s the simplest structure that will remain maintainable as this application grows?” For small applications, the layered pattern is fine. For applications with real business logic and a team of more than two people, the patterns above are worth the investment.

The rule of thumb: if you find yourself asking “where does this code go?”, your current structure is failing you. That’s when to invest in better boundaries.