Dependency Direction Between Layers

18 min read

Object-Oriented Design and SOLID Review — review Java applications for reversed layer dependencies and enforce the correct inward-pointing dependency rule.

1. Introduction

In a layered Java application, classes are usually organized into responsibilities such as:

  • Controller or API layer
  • Application or service layer
  • Domain layer
  • Persistence or repository layer
  • External integration layer

The important design question is not only which layers exist, but also which layer is allowed to depend on which other layer.

A common Spring Boot flow is:

JAVA
HTTP Request
    ↓
Controller
    ↓
Service
    ↓
Repository
    ↓
Database

The request flows from the controller toward the database.

However, dependency direction requires more careful thinking than simply following request flow.

Business logic should not become tightly coupled to:

  • HTTP-specific classes
  • Spring MVC controllers
  • Database entities
  • JPA implementation details
  • Third-party SDK classes
  • External API clients
  • UI-specific request or response objects

When dependencies point in the wrong direction, a small change in one technical layer can force changes throughout the application.

During Pull Request review, senior developers should therefore ask:

  • Is the business layer depending on the web layer?
  • Is domain logic tied directly to JPA?
  • Is infrastructure deciding business behavior?
  • Are lower-level reusable components importing higher-level application classes?
  • Can this business logic be tested without starting Spring, a database, or an HTTP server?
  • Is an abstraction needed at a layer boundary?
  • Is the design becoming more complicated than the actual application requires?

Good dependency direction keeps business rules stable while allowing technical details to change around them.

2. What This Topic Means

Dependency direction describes which module, package, or layer knows about another layer at compile time.

For example:

JAVA
@RestController
public class PaymentController {
    private final PaymentService paymentService;
}

PaymentController depends on PaymentService.

That is normally appropriate because the controller is an entry point that delegates business work to the application layer.

Consider the opposite:

JAVA
@Service
public class PaymentService {
    private final PaymentController paymentController;
}

This is usually a design problem.

The service layer should not require a controller in order to perform payment processing.

Likewise, business code such as:

JAVA
public PaymentResult processPayment(PaymentRequest request)

should ideally operate on business-oriented data rather than depending unnecessarily on:

JAVA
HttpServletRequest
ResponseEntity
ServletContext
ModelAndView

Those belong to the web boundary.

A practical dependency structure may look like:

JAVA
Controller
    ↓
Application Service
    ↓
Domain Logic
    ↓
Repository/External-Service Abstractions
    ↑
Infrastructure Implementations

The most stable business concepts should not unnecessarily depend on volatile technical details.

3. Why It Matters in Real Projects

Readability

Correct dependency direction makes application flow easier to understand.

A developer opening a Spring Boot project should be able to recognize:

JAVA
Controller → Service → Repository

instead of finding dependencies such as:

JAVA
Repository → Controller
Domain → REST DTO
Utility → Service
Entity → Controller
Service → Servlet API

Predictable architecture reduces cognitive load.

Maintainability

Suppose a payment service directly uses a vendor-specific SDK.

JAVA
StripeClient stripeClient;

If the company later moves to another provider, the payment business logic may require major changes.

A better boundary can keep payment rules independent from the provider implementation.

Testability

Business services should ideally be testable without requiring:

  • HTTP requests
  • A running web server
  • A real database
  • Vendor SDK initialization
  • Spring context startup

Wrong dependency direction often makes simple unit tests unnecessarily expensive.

Reliability

Clear boundaries reduce accidental side effects.

For example, validation performed only in a controller can be bypassed when the service is called by:

  • A scheduled job
  • Kafka consumer
  • Batch process
  • Another service
  • Integration test

Business rules should normally remain inside the appropriate business layer.

Team Development

Different teams often own different layers.

For example:

  • API team
  • Payments team
  • Data team
  • Integration team

Clear dependency boundaries allow each team to modify implementation details with less impact on others.

Scalability of the Codebase

As the number of modules increases, uncontrolled dependencies can create a dependency graph where everything knows about everything else.

This makes the project increasingly expensive to modify.

4. Core Concept

A useful practical principle is:

Higher-level business decisions should not depend directly on lower-level technical implementation details when those details are expected to change independently.

This is closely related to the Dependency Inversion Principle.

However, applying it does not mean creating an interface for every class.

The goal is useful boundaries, not abstraction for its own sake.

Typical Layer Responsibilities

Controller Layer

Responsible for:

  • HTTP request mapping
  • Request parsing
  • HTTP-level validation
  • Authentication context extraction
  • Mapping request DTOs to application input
  • Mapping application results to HTTP responses

Typical dependencies:

JAVA
Controller → Service

It should normally not contain:

  • SQL logic
  • Large business workflows
  • JPA manipulation
  • External API orchestration

Service or Application Layer

Responsible for:

  • Use-case orchestration
  • Business workflow
  • Transaction boundaries
  • Calling domain logic
  • Calling repositories
  • Coordinating external services

Typical dependencies:

JAVA
Service → Domain
Service → Repository abstraction
Service → External-service abstraction

It should normally not depend on:

  • Controllers
  • HTTP response objects
  • View models
  • Servlet classes

Domain Layer

Responsible for:

  • Core business rules
  • Domain validation
  • Business state transitions
  • Business calculations

Ideally, domain logic should contain minimal infrastructure knowledge.

Repository/Infrastructure Layer

Responsible for:

  • Database access
  • JPA
  • JDBC
  • External APIs
  • Messaging
  • File systems
  • Vendor SDKs

Infrastructure provides technical capabilities required by higher-level application logic.

5. Important Rules

  • Controllers may depend on application services; services should not depend on controllers.
  • Business logic should not be implemented inside repository classes.
  • Repository classes should focus on persistence concerns.
  • Do not pass HttpServletRequest deep into the service or domain layers unless there is a strong technical reason.
  • Avoid returning ResponseEntity from business services.
  • Avoid letting domain objects depend on controller DTOs.
  • Keep vendor-specific SDK objects near integration boundaries.
  • Do not introduce interfaces mechanically for every class.
  • Introduce abstractions where they create a useful boundary.
  • Keep business validation usable from non-HTTP entry points.
  • Prevent circular dependencies between layers.
  • Avoid repositories calling services to determine business behavior.
  • Keep database-specific query logic out of controllers.
  • Map between API, domain, and persistence models where separation provides real value.
  • Prefer constructor injection so dependencies are explicit.
  • Use package structure and module boundaries to reinforce architectural direction.
  • Review imports during PR review; imports frequently reveal architectural violations.

6. Bad Code Example

Consider a Spring Boot order-processing application.

The following service has dependencies on the web layer, persistence details, and a concrete external API client.

JAVA
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;

@Service
public class OrderService {
    private final OrderJpaRepository orderRepository;
    private final InventoryRestClient inventoryRestClient;

    public OrderService(
            OrderJpaRepository orderRepository,
            InventoryRestClient inventoryRestClient) {
        this.orderRepository = orderRepository;
        this.inventoryRestClient = inventoryRestClient;
    }

    public ResponseEntity<OrderResponse> createOrder(
            CreateOrderRequest request,
            HttpServletRequest httpRequest) {

        String userId = httpRequest.getHeader("X-User-Id");

        OrderEntity order = new OrderEntity();
        order.setUserId(userId);
        order.setProductId(request.getProductId());
        order.setQuantity(request.getQuantity());

        InventoryApiResponse inventory =
                inventoryRestClient.checkInventory(
                        request.getProductId()
                );

        if (inventory.getAvailableQuantity()
                < request.getQuantity()) {
            return ResponseEntity
                    .badRequest()
                    .body(
                            new OrderResponse(
                                    null,
                                    "INSUFFICIENT_INVENTORY"
                            )
                    );
        }

        OrderEntity savedOrder =
                orderRepository.save(order);

        return ResponseEntity.ok(
                new OrderResponse(
                        savedOrder.getId(),
                        "CREATED"
                )
        );
    }
}

At first glance, this code may work.

However, the service now understands:

  • Servlet API
  • HTTP headers
  • REST response semantics
  • Controller request DTOs
  • Controller response DTOs
  • JPA entities
  • Concrete inventory API implementation

The application's core order workflow is tightly coupled to several technical layers.

7. Problems in the Bad Code

Service Depends on HTTP Infrastructure

The method accepts:

JAVA
HttpServletRequest

The order use case now requires a servlet request.

This creates problems when the same business logic must be called from:

  • Kafka consumer
  • Scheduled job
  • CLI process
  • Batch job
  • Unit test
  • Another internal service

Service Returns HTTP Response

The service returns:

JAVA
ResponseEntity<OrderResponse>

HTTP status decisions belong primarily at the web boundary.

The service should communicate application outcomes, not build HTTP protocol responses.

Service Depends on API DTO

The service accepts:

JAVA
CreateOrderRequest

This may be acceptable in small applications, but if CreateOrderRequest is specifically owned by the controller/API package, the application layer becomes dependent on the presentation contract.

That creates coupling between business logic and REST API evolution.

Business Logic Depends on JPA Entity

The service directly creates:

JAVA
OrderEntity

If the persistence model changes because of schema or ORM requirements, business workflow code may also require modification.

This is not automatically incorrect in every application, but it should be an intentional trade-off.

Concrete External Integration Dependency

The service depends directly on:

JAVA
InventoryRestClient

If another inventory implementation is introduced, the service may need modification.

Infrastructure Details Mixed with Business Workflow

The method currently handles:

  • HTTP identity extraction
  • Order construction
  • Inventory integration
  • Business validation
  • Persistence
  • HTTP response construction

Several architectural concerns are combined.

Unit Testing Becomes More Complicated

Testing createOrder() requires knowledge of:

  • Servlet requests
  • REST response objects
  • JPA entities
  • Inventory client behavior

The business rule itself is simple:

JAVA
requested quantity <= available quantity

but the test setup becomes unnecessarily infrastructure-heavy.

8. Code Review Findings

A senior reviewer should identify the following points.

Finding 1: Web Concern Leaks Into Service Layer

JAVA
HttpServletRequest httpRequest

The service should preferably receive the user ID or application-level caller context rather than reading an HTTP header itself.

Finding 2: Service Produces ResponseEntity

JAVA
ResponseEntity<OrderResponse>

This makes the application use case dependent on Spring MVC semantics.

The controller should translate application outcomes to HTTP responses.

Finding 3: Integration Boundary Is Concrete

The service currently depends directly on an inventory REST client.

If inventory is a replaceable integration, an application-facing abstraction may provide a cleaner boundary.

Finding 4: Persistence Entity Is Used as Business Model

The reviewer should determine whether this project intentionally uses JPA entities inside the application layer.

For a small CRUD application, this may be acceptable.

For complex business logic, separating the domain model can reduce persistence coupling.

Finding 5: Layer Responsibilities Are Blurred

The service knows too much about multiple technical layers.

Finding 6: Future Entry Points Will Be Difficult

If order creation is later triggered by Kafka, the developer may either duplicate business logic or create fake web-layer objects.

That is a strong sign that dependency direction is incorrect.

9. Reviewer Comment Example

  • OrderService currently depends on HttpServletRequest. Could the controller extract the authenticated user ID and pass it as application input instead?
  • Can we keep ResponseEntity in the controller layer and let the service return an application-level result?
  • The order use case is coupled directly to InventoryRestClient. Consider depending on an inventory abstraction if the REST implementation is an infrastructure detail.
  • This service currently mixes HTTP mapping, business rules, persistence, and external integration concerns. Can we keep the service focused on the order use case?
  • Please confirm whether OrderEntity is intentionally used as the domain model. If business rules are expected to grow, separating persistence details may keep this layer easier to maintain.

10. Improved Code

A practical design can separate the HTTP boundary from the application workflow without introducing unnecessary architecture.

Controller

JAVA
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/orders")
public class OrderController {
    private final CreateOrderService createOrderService;

    public OrderController(
            CreateOrderService createOrderService) {
        this.createOrderService = createOrderService;
    }

    @PostMapping
    public ResponseEntity<OrderResponse> createOrder(
            @RequestHeader("X-User-Id") String userId,
            @RequestBody CreateOrderRequest request) {

        CreateOrderCommand command =
                new CreateOrderCommand(
                        userId,
                        request.getProductId(),
                        request.getQuantity()
                );

        CreateOrderResult result =
                createOrderService.createOrder(command);

        if (!result.created()) {
            return ResponseEntity
                    .status(HttpStatus.CONFLICT)
                    .body(
                            new OrderResponse(
                                    null,
                                    result.status()
                            )
                    );
        }

        return ResponseEntity
                .status(HttpStatus.CREATED)
                .body(
                        new OrderResponse(
                                result.orderId(),
                                result.status()
                        )
                );
    }
}

Application Input

JAVA
public record CreateOrderCommand(
        String userId,
        String productId,
        int quantity) {
}

Application Result

JAVA
public record CreateOrderResult(
        Long orderId,
        boolean created,
        String status) {

    public static CreateOrderResult created(
            Long orderId) {
        return new CreateOrderResult(
                orderId,
                true,
                "CREATED"
        );
    }

    public static CreateOrderResult insufficientInventory() {
        return new CreateOrderResult(
                null,
                false,
                "INSUFFICIENT_INVENTORY"
        );
    }
}

Inventory Boundary

JAVA
public interface InventoryGateway {
    int getAvailableQuantity(String productId);
}

Repository Boundary

JAVA
public interface OrderRepository {
    Order save(Order order);
}

Domain Object

JAVA
import java.util.Objects;

public class Order {
    private Long id;
    private final String userId;
    private final String productId;
    private final int quantity;

    public Order(
            String userId,
            String productId,
            int quantity) {

        this.userId =
                Objects.requireNonNull(
                        userId,
                        "userId must not be null"
                );

        this.productId =
                Objects.requireNonNull(
                        productId,
                        "productId must not be null"
                );

        if (quantity <= 0) {
            throw new IllegalArgumentException(
                    "quantity must be greater than zero"
            );
        }

        this.quantity = quantity;
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getUserId() {
        return userId;
    }

    public String getProductId() {
        return productId;
    }

    public int getQuantity() {
        return quantity;
    }
}

Application Service

JAVA
import org.springframework.stereotype.Service;

@Service
public class CreateOrderService {
    private final OrderRepository orderRepository;
    private final InventoryGateway inventoryGateway;

    public CreateOrderService(
            OrderRepository orderRepository,
            InventoryGateway inventoryGateway) {
        this.orderRepository = orderRepository;
        this.inventoryGateway = inventoryGateway;
    }

    public CreateOrderResult createOrder(
            CreateOrderCommand command) {

        int availableQuantity =
                inventoryGateway.getAvailableQuantity(
                        command.productId()
                );

        if (availableQuantity < command.quantity()) {
            return CreateOrderResult
                    .insufficientInventory();
        }

        Order order =
                new Order(
                        command.userId(),
                        command.productId(),
                        command.quantity()
                );

        Order savedOrder =
                orderRepository.save(order);

        return CreateOrderResult.created(
                savedOrder.getId()
        );
    }
}

REST Inventory Implementation

JAVA
import org.springframework.stereotype.Component;

@Component
public class InventoryRestGateway
        implements InventoryGateway {

    private final InventoryRestClient inventoryRestClient;

    public InventoryRestGateway(
            InventoryRestClient inventoryRestClient) {
        this.inventoryRestClient =
                inventoryRestClient;
    }

    @Override
    public int getAvailableQuantity(
            String productId) {

        InventoryApiResponse response =
                inventoryRestClient
                        .checkInventory(productId);

        return response.getAvailableQuantity();
    }
}

The application service depends on:

JAVA
InventoryGateway

not directly on:

JAVA
InventoryRestClient

The REST client becomes an infrastructure implementation.

11. Improved Code Explanation

HTTP Responsibility Stays in the Controller

The controller handles:

  • HTTP headers
  • Request DTO
  • HTTP status
  • Response DTO
  • ResponseEntity

The service no longer knows that the request came through HTTP.

Application Service Receives Application Input

Instead of:

JAVA
HttpServletRequest
CreateOrderRequest

the service receives:

JAVA
CreateOrderCommand

This represents what the use case actually requires.

Application Service Returns Application Result

Instead of:

JAVA
ResponseEntity<OrderResponse>

the service returns:

JAVA
CreateOrderResult

This makes the use case reusable from other entry points.

External Integration Is Behind a Boundary

The service depends on:

JAVA
InventoryGateway

The REST implementation depends on:

JAVA
InventoryRestClient

The business workflow therefore knows about inventory capability, not transport details.

Persistence Is Behind a Repository Boundary

The application service depends on:

JAVA
OrderRepository

The actual JPA implementation can remain in infrastructure.

Testing Becomes Easier

A unit test can now provide:

  • Fake OrderRepository
  • Fake InventoryGateway

No servlet request is required.

No HTTP response object is required.

No real REST API is required.

12. Bad Code vs Improved Code

AreaBad CodeImproved Code
Web dependencyService uses servlet classesWeb dependency remains in controller
HTTP responseService returns ResponseEntityController builds HTTP response
External APIService uses REST client directlyService depends on integration abstraction
PersistenceBusiness flow tied to JPA implementationRepository boundary hides persistence
ReusabilityMainly usable from RESTUsable from REST, batch, messaging, tests
TestabilityRequires technical mocksTests focus on use-case behavior
MaintainabilityMultiple layers mixedResponsibilities are clearer
ReliabilityRules may be duplicated across entry pointsUse case remains centralized
Change impactTechnical changes can reach serviceTechnical details are isolated

13. Real Project Scenario

Consider a healthcare platform that originally exposes patient-notification functionality only through a REST API.

The initial architecture is:

JAVA
NotificationController
    ↓
NotificationService
    ↓
NotificationRepository

The service receives:

JAVA
HttpServletRequest

and extracts:

  • User ID
  • Hospital ID
  • Correlation ID

Later, the business introduces:

  • Scheduled medication reminders
  • Kafka-driven appointment reminders
  • Batch notification processing

The team now wants to reuse NotificationService.

However, the method requires an HTTP request.

Developers begin creating special methods:

JAVA
sendFromRest(...)
sendFromKafka(...)
sendFromScheduler(...)

The same notification validation is duplicated.

A better design is for the business workflow to accept:

JAVA
NotificationCommand

containing the actual information required by the use case.

Each entry point translates its own transport-specific input into that command.

The dependency structure becomes:

JAVA
REST Controller ─────┐
                     │
Kafka Consumer ──────┼──→ NotificationService
                     │
Scheduled Job ───────┘

The service no longer depends on how the use case was triggered.

14. Production Impact

Incorrect dependency direction can create several production problems.

Duplicate Business Logic

REST, Kafka, and batch flows may implement the same validation differently.

This can produce inconsistent business results.

Difficult Change Management

Changing an API DTO can unexpectedly require modifications to internal business services.

Integration Lock-In

Direct dependency on vendor APIs may make provider replacement expensive.

Hard-to-Test Business Logic

Teams may avoid testing business rules because each test requires large infrastructure setup.

Inconsistent Error Handling

If service classes construct HTTP responses directly, the same service may be difficult to reuse in non-HTTP execution paths.

Circular Dependencies

Poor layer separation may eventually create:

JAVA
ServiceA → ServiceB
ServiceB → RepositoryA
RepositoryA → ServiceA

Such architecture becomes difficult to initialize, test, and modify.

Increased Maintenance Cost

Developers must understand unrelated technical layers before changing simple business logic.

15. Common Developer Mistakes

Mistake 1: Service Depends on Controller

Avoid:

JAVA
@Service
public class UserService {
    private final UserController controller;
}

A service should not call its controller.

Mistake 2: Passing HttpServletRequest Everywhere

Avoid:

JAVA
service.process(request, httpServletRequest);

Extract required information at the boundary.

Mistake 3: Returning ResponseEntity From Service

Avoid using HTTP protocol types as the normal return contract of business services.

Mistake 4: Repository Contains Business Workflow

For example:

JAVA
repository.approveLoanAndSendNotification();

A repository should normally focus on persistence.

Mistake 5: Controller Calls Repository Directly for Complex Use Cases

Example:

JAVA
orderRepository.save(...);

inside a controller can bypass application-level business logic.

Simple CRUD applications may tolerate direct structures, but reviewers should consider future complexity.

Mistake 6: Domain Imports REST DTO

Avoid:

JAVA
import com.company.api.request.CreatePaymentRequest;

inside domain classes.

Mistake 7: Creating an Interface for Every Class

This is the opposite extreme.

For example:

JAVA
OrderService
OrderServiceImpl

when there is no boundary, alternate implementation, or testing value.

An interface should communicate architectural purpose rather than satisfy a naming convention.

Mistake 8: Infrastructure Calls Controller

An external integration adapter should not invoke a controller simply to reuse logic.

Call the application service instead.

Mistake 9: Shared Utility Layer Depends on Feature Services

A supposedly common utility package should not depend on higher-level feature modules.

This can create dependency cycles.

Mistake 10: Using Package Names Without Enforcing Boundaries

A project may contain:

JAVA
controller
service
repository

but still allow every package to import every other package.

Folder structure alone does not guarantee architecture.

16. Edge Cases

Small CRUD Applications

Not every application needs domain interfaces, ports, adapters, or multiple model layers.

For a simple internal CRUD tool:

JAVA
Controller → Service → JpaRepository

may be completely reasonable.

Avoid over-engineering.

JPA Entities as Domain Models

Using JPA entities directly in services is common.

It is not automatically wrong.

Review factors such as:

  • Complexity of business logic
  • Expected lifetime of the system
  • Persistence coupling
  • Lazy loading behavior
  • API exposure risk
  • Testing requirements

Shared DTOs

A DTO may intentionally be shared between modules.

The question is whether it represents a stable application contract or a transport-specific representation.

Cross-Cutting Concerns

Logging, tracing, metrics, security, and transactions do not always fit neatly into normal feature dependency direction.

Prefer established mechanisms such as:

  • Filters
  • Interceptors
  • AOP
  • Security context
  • Observability libraries

rather than making business services depend directly on controllers.

Exception Translation

Application exceptions should not necessarily contain HTTP status information.

The web layer can translate:

JAVA
InsufficientInventoryException

into:

JAVA
HTTP 409 Conflict

using an exception handler.

Async Processing

If a synchronous REST use case later becomes asynchronous, transport-independent services are easier to reuse.

Multiple Database Technologies

A business service should not need major redesign merely because persistence changes from JPA to another storage implementation.

17. Performance Considerations

Dependency direction itself has little direct runtime performance cost.

Introducing a Java interface does not create a meaningful performance concern in normal Spring applications.

However, architectural choices can indirectly affect performance.

Accidental Database Calls

Poor separation may allow controllers or serializers to navigate lazy JPA relationships.

This can produce unexpected database queries.

N+1 Problems

Exposing persistence entities directly through REST endpoints may trigger lazy-loading behavior during serialization.

Proper boundaries can make database access more explicit.

External API Calls

If business logic directly invokes external APIs throughout multiple classes, developers may accidentally perform repeated calls.

Centralized integration boundaries make these calls easier to identify and optimize.

Excessive Mapping

Do not introduce five model transformations for a simple CRUD request without justification.

For example:

JAVA
RequestDto
    ↓
ApplicationDto
    ↓
DomainDto
    ↓
PersistenceDto
    ↓
Entity

This can add complexity and allocation without useful separation.

Use the minimum number of boundaries required by the system.

Spring Context Size

Large dependency graphs and excessive beans can increase startup complexity, but this is usually secondary to maintainability concerns.

18. Security Considerations

Dependency direction can affect security when authentication and authorization responsibilities are placed incorrectly.

Authentication Context

The controller or security infrastructure may extract the authenticated identity.

The service should receive the identity or use an appropriate application security abstraction when authorization is part of business behavior.

Do Not Trust Controller Validation Alone

Suppose the controller checks:

JAVA
user has PAYMENT_APPROVER role

but the service can also be called by a scheduled job or internal component.

If authorization is a business-critical invariant, the appropriate enforcement must not be bypassable through another entry point.

Sensitive Transport Data

Do not pass complete HTTP request objects deep into the application simply because one header is required.

Pass only the required information.

External Service Credentials

Business services should not directly manage:

  • API keys
  • OAuth client secrets
  • Vendor credentials

Keep these concerns inside infrastructure configuration and clients.

Error Exposure

If domain or repository exceptions are returned directly through the API, sensitive implementation details may leak.

Translate internal errors at the application boundary.

19. Testing Considerations

Correct dependency direction usually improves testing significantly.

Service Unit Test

Consider the improved application service.

A test can use simple fake implementations.

JAVA
import static org.junit.jupiter.api.Assertions.assertEquals;

import org.junit.jupiter.api.Test;

class CreateOrderServiceTest {

    @Test
    void shouldCreateOrderWhenInventoryIsAvailable() {
        OrderRepository repository =
                order -> {
                    order.setId(101L);
                    return order;
                };

        InventoryGateway inventoryGateway =
                productId -> 10;

        CreateOrderService service =
                new CreateOrderService(
                        repository,
                        inventoryGateway
                );

        CreateOrderResult result =
                service.createOrder(
                        new CreateOrderCommand(
                                "USER-1",
                                "PRODUCT-1",
                                2
                        )
                );

        assertEquals(true, result.created());
        assertEquals(101L, result.orderId());
    }
}

No Spring MVC infrastructure is required.

Negative Test

Test insufficient inventory:

JAVA
@Test
void shouldRejectOrderWhenInventoryIsInsufficient() {
    OrderRepository repository =
            order -> order;

    InventoryGateway inventoryGateway =
            productId -> 1;

    CreateOrderService service =
            new CreateOrderService(
                    repository,
                    inventoryGateway
            );

    CreateOrderResult result =
            service.createOrder(
                    new CreateOrderCommand(
                            "USER-1",
                            "PRODUCT-1",
                            5
                    )
            );

    assertEquals(false, result.created());
    assertEquals(
            "INSUFFICIENT_INVENTORY",
            result.status()
    );
}

Controller Tests

Test separately that the controller maps application results to:

  • HTTP 201
  • HTTP 409
  • Appropriate response body

Integration Tests

Test infrastructure implementations separately:

  • JPA repository implementation
  • Inventory REST adapter
  • Database mapping
  • API error handling

Architecture Tests

For larger projects, architectural rules can be enforced using tools such as ArchUnit.

Example rule concept:

JAVA
service package must not depend on controller package

This prevents architectural drift over time.

20. Refactoring Guidelines

Correcting dependency direction in a large project should be done incrementally.

Step 1: Identify the Current Dependency

Find problematic imports such as:

JAVA
service → controller
service → servlet
domain → api.dto
repository → controller
domain → vendor.sdk

Step 2: Identify the Real Data Requirement

If a service receives:

JAVA
HttpServletRequest

determine what it actually needs.

Perhaps only:

JAVA
userId
tenantId
correlationId

Pass those values instead.

Step 3: Move Transport Mapping to Boundary

Convert:

JAVA
CreateOrderRequest

into:

JAVA
CreateOrderCommand

inside the controller.

Step 4: Move HTTP Result Mapping Out

Replace:

JAVA
ResponseEntity

with application-specific results or exceptions.

Step 5: Introduce Boundary Abstraction Only Where Useful

If the application needs inventory availability, define:

JAVA
InventoryGateway

rather than exposing:

JAVA
InventoryRestClient

throughout business code.

Step 6: Adapt Existing Infrastructure

Create:

JAVA
InventoryRestGateway implements InventoryGateway

without changing business behavior.

Step 7: Protect With Tests

Before major refactoring, add tests for existing business behavior.

Step 8: Migrate Callers Gradually

Update:

  • Controllers
  • Scheduled jobs
  • Consumers
  • Batch processes

to use the same application service.

Step 9: Remove Old Cross-Layer Dependencies

Once no longer required, remove:

  • Servlet dependencies
  • Controller imports
  • Direct vendor dependencies
  • Duplicate workflow methods

21. Best Practices

Keep Controllers Thin but Useful

A controller should handle transport concerns and delegate the use case.

Thin does not mean zero logic.

HTTP mapping belongs there.

Keep Application Services Transport-Neutral

Application services should generally describe use cases, not HTTP endpoints.

Good:

JAVA
createOrder(command)

Less reusable:

JAVA
handlePostRequest(request, servletRequest)

Keep Persistence Behind a Clear Boundary

The application should interact with persistence through a clearly understood contract.

Name Interfaces by Capability

Prefer:

JAVA
PaymentGateway
InventoryGateway
CustomerRepository

over mechanical names such as:

JAVA
IPaymentService
PaymentServiceInterface

Keep Infrastructure Replaceable Where Change Is Realistic

External providers are common candidates for abstraction.

Keep Business Rules Close to Business State

Do not place domain rules in controllers merely because input originates there.

Use Constructor Injection

Constructor injection makes dependency direction visible.

Keep Package Dependencies Predictable

A developer should be able to understand expected dependencies from the project structure.

Add Architecture Tests for Large Systems

Automated architectural checks are valuable when dozens of developers contribute to the same codebase.

22. Practices to Avoid

Controller → Repository for Complex Business Workflows

This often bypasses orchestration and validation.

Service → Controller

This reverses the normal application dependency.

Domain → HTTP Classes

Domain logic should not normally require servlet or Spring MVC types.

Domain → Vendor SDK

This makes core business rules depend on external technology.

Repository → Service

Repositories should not usually call higher-level services to decide business behavior.

Circular Dependencies

Avoid:

JAVA
A → B → C → A

They make responsibilities unclear.

Artificial Interface Explosion

Do not create interfaces simply because a class is injected.

Generic "Common" Module With Everything

A common package that depends on all feature layers usually becomes an architectural dumping ground.

Passing Framework Objects Through Every Layer

Avoid passing:

JAVA
HttpServletRequest
HttpSession
ResponseEntity
EntityManager

through layers that do not need them.

23. Code Review Checklist

  • Does the controller depend on the service rather than the service depending on the controller?
  • Does any service import controller classes?
  • Does any service require HttpServletRequest without a strong reason?
  • Does business logic return ResponseEntity?
  • Are API-specific DTOs leaking deep into the domain unnecessarily?
  • Does domain logic depend directly on JPA or vendor SDK classes?
  • Is repository code responsible only for persistence concerns?
  • Does a repository call back into a business service?
  • Is an external REST client being used directly throughout business code?
  • Would a stable integration abstraction reduce coupling?
  • Is an interface being introduced for an actual architectural reason?
  • Can this business service be called from a batch job or message consumer?
  • Can the core business logic be tested without starting Spring MVC?
  • Are business rules duplicated across REST, Kafka, and scheduled flows?
  • Are there circular dependencies between packages or beans?
  • Does changing a controller request object force changes in domain logic?
  • Does changing a persistence implementation require changes in core business rules?
  • Are technical exceptions leaking through multiple layers?
  • Are security rules enforced at the correct boundary?
  • Is the proposed architecture simpler than the problem, or are we over-engineering it?

24. Common Pull Request Review Comments

  1. This service currently imports the controller request type. Could we map the HTTP DTO to an application command at the controller boundary?
  1. Can we avoid passing HttpServletRequest into the service? It looks like only the user ID is required here.
  1. The service returns ResponseEntity, which couples this use case to Spring MVC. Please consider returning an application result and mapping it in the controller.
  1. This repository is making a business approval decision. That rule belongs in the service/domain layer rather than the persistence layer.
  1. The application service depends directly on the vendor SDK. Would a small gateway abstraction keep the business workflow independent of the provider?
  1. I don't see an alternate implementation or architectural boundary for this interface. Can we keep the concrete service unless the abstraction provides real value?
  1. This controller is calling the repository directly and duplicating validation already present in the service. Please route this workflow through the application service.
  1. This creates a dependency from the domain package back to the REST package. Can we move the mapping to the API boundary instead?
  1. Please check whether this new dependency introduces a cycle between order and payment modules.
  1. Could we add an architecture test to prevent service classes from depending on controller packages in future changes?

25. Code Review Exercise

Review the following Spring Boot code.

Identify:

  • Dependency-direction problems
  • Layering problems
  • Code smells
  • Reusability problems
  • Testing problems
  • Production risks
  • Appropriate improvements

import jakarta.servlet.http.HttpServletRequest; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Repository; import org.springframework.stereotype.Service;

@Repository public class PaymentRepository { private final PaymentService paymentService;

``` public PaymentRepository( PaymentService paymentService) { this.paymentService = paymentService; }

public boolean canSavePayment( PaymentEntity payment) { return paymentService .isPaymentAllowed(payment); }

public PaymentEntity save( PaymentEntity payment) { return payment; } ```

}

@Service public class PaymentService { private final FraudVendorClient fraudVendorClient; private final PaymentRepository paymentRepository;

``` public PaymentService( FraudVendorClient fraudVendorClient, PaymentRepository paymentRepository) { this.fraudVendorClient = fraudVendorClient; this.paymentRepository = paymentRepository; }

public ResponseEntity<PaymentResponse> pay( PaymentRequest request, HttpServletRequest servletRequest) {

String customerId = servletRequest.getHeader( "X-Customer-Id" );

PaymentEntity payment = new PaymentEntity( customerId, request.getAmount() );

if (!fraudVendorClient .isApproved(payment)) { return ResponseEntity .badRequest() .body( new PaymentResponse( "REJECTED" ) ); }

if (!paymentRepository .canSavePayment(payment)) { return ResponseEntity .badRequest() .body( new PaymentResponse( "INVALID" ) ); }

paymentRepository.save(payment);

return ResponseEntity.ok( new PaymentResponse( "SUCCESS" ) ); }

public boolean isPaymentAllowed( PaymentEntity payment) { return payment.getAmount() > 0; } ```

}

Do not assume that the code is wrong only because it does not use a complex architecture.

Identify specifically which dependencies cause the design problems.

26. Exercise Solution

Several important problems exist.

Issue 1: Circular Dependency

The repository depends on the service:

JAVA
PaymentRepository → PaymentService

and the service depends on the repository:

JAVA
PaymentService → PaymentRepository

This creates:

JAVA
PaymentService
    ↓
PaymentRepository
    ↓
PaymentService

The architectural responsibilities are unclear.

Issue 2: Repository Performs Business Validation

The repository calls:

JAVA
paymentService.isPaymentAllowed(payment);

A repository should not need a higher-level service to determine whether persistence is allowed.

The service or domain should make the business decision before calling the repository.

Issue 3: Service Depends on HttpServletRequest

The payment workflow is tied to HTTP.

Issue 4: Service Returns ResponseEntity

The service decides HTTP response behavior.

Issue 5: Service Depends on Vendor Client

The payment workflow directly uses:

JAVA
FraudVendorClient

If fraud providers change, business workflow code becomes coupled to the vendor API.

Issue 6: PaymentRequest and PaymentResponse Leak Into Application Logic

If these are REST-specific DTOs, the service is coupled to the API contract.

Improved Design

Payment Command

JAVA
import java.math.BigDecimal;

public record ProcessPaymentCommand(
        String customerId,
        BigDecimal amount) {
}

Payment Result

JAVA
public enum PaymentStatus {
    SUCCESS,
    INVALID_AMOUNT,
    FRAUD_REJECTED
}

public record ProcessPaymentResult(
        PaymentStatus status) {
}

Fraud Boundary

JAVA
public interface FraudChecker {
    boolean isApproved(Payment payment);
}

Repository Boundary

JAVA
public interface PaymentRepository {
    Payment save(Payment payment);
}

Domain Object

JAVA
import java.math.BigDecimal;
import java.util.Objects;

public class Payment {
    private Long id;
    private final String customerId;
    private final BigDecimal amount;

    public Payment(
            String customerId,
            BigDecimal amount) {
        this.customerId =
                Objects.requireNonNull(
                        customerId,
                        "customerId must not be null"
                );

        this.amount =
                Objects.requireNonNull(
                        amount,
                        "amount must not be null"
                );
    }

    public boolean hasValidAmount() {
        return amount.signum() > 0;
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getCustomerId() {
        return customerId;
    }

    public BigDecimal getAmount() {
        return amount;
    }
}

Application Service

JAVA
import org.springframework.stereotype.Service;

@Service
public class ProcessPaymentService {
    private final PaymentRepository paymentRepository;
    private final FraudChecker fraudChecker;

    public ProcessPaymentService(
            PaymentRepository paymentRepository,
            FraudChecker fraudChecker) {
        this.paymentRepository = paymentRepository;
        this.fraudChecker = fraudChecker;
    }

    public ProcessPaymentResult process(
            ProcessPaymentCommand command) {

        Payment payment =
                new Payment(
                        command.customerId(),
                        command.amount()
                );

        if (!payment.hasValidAmount()) {
            return new ProcessPaymentResult(
                    PaymentStatus.INVALID_AMOUNT
            );
        }

        if (!fraudChecker.isApproved(payment)) {
            return new ProcessPaymentResult(
                    PaymentStatus.FRAUD_REJECTED
            );
        }

        paymentRepository.save(payment);

        return new ProcessPaymentResult(
                PaymentStatus.SUCCESS
        );
    }
}

Controller

JAVA
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/payments")
public class PaymentController {
    private final ProcessPaymentService processPaymentService;

    public PaymentController(
            ProcessPaymentService processPaymentService) {
        this.processPaymentService =
                processPaymentService;
    }

    @PostMapping
    public ResponseEntity<PaymentResponse> pay(
            @RequestHeader("X-Customer-Id")
            String customerId,
            @RequestBody PaymentRequest request) {

        ProcessPaymentResult result =
                processPaymentService.process(
                        new ProcessPaymentCommand(
                                customerId,
                                request.getAmount()
                        )
                );

        return switch (result.status()) {
            case SUCCESS ->
                    ResponseEntity.ok(
                            new PaymentResponse(
                                    "SUCCESS"
                            )
                    );

            case INVALID_AMOUNT ->
                    ResponseEntity
                            .badRequest()
                            .body(
                                    new PaymentResponse(
                                            "INVALID_AMOUNT"
                                    )
                            );

            case FRAUD_REJECTED ->
                    ResponseEntity
                            .status(
                                    HttpStatus.CONFLICT
                            )
                            .body(
                                    new PaymentResponse(
                                            "FRAUD_REJECTED"
                                    )
                            );
        };
    }
}

Fraud Adapter

JAVA
import org.springframework.stereotype.Component;

@Component
public class FraudVendorAdapter
        implements FraudChecker {

    private final FraudVendorClient client;

    public FraudVendorAdapter(
            FraudVendorClient client) {
        this.client = client;
    }

    @Override
    public boolean isApproved(
            Payment payment) {
        return client.isApproved(payment);
    }
}

Why This Is Better

The new dependency structure is:

JAVA
PaymentController
    ↓
ProcessPaymentService
    ↓
PaymentRepository
    ↑
Persistence Implementation

and:

JAVA
ProcessPaymentService
    ↓
FraudChecker
    ↑
FraudVendorAdapter
    ↓
FraudVendorClient

The business workflow no longer depends directly on HTTP or vendor-specific infrastructure.

The circular dependency is removed.

Business validation is no longer delegated from the repository back into the service.

27. Interview Perspective

Dependency direction frequently appears in:

  • Senior Java interviews
  • Spring Boot interviews
  • System-design interviews
  • Code-review rounds
  • Architecture discussions

Interviewers may show code where:

JAVA
Controller → Repository

or:

JAVA
Service → Controller

and ask whether the design is acceptable.

A strong answer should avoid blindly saying:

"Controller must always call service."

Instead, explain the trade-off.

For a trivial CRUD endpoint, an additional service with no meaningful responsibility may add little value.

For business workflows containing:

  • Validation
  • Transactions
  • Multiple repositories
  • External APIs
  • Security rules
  • Events
  • Business calculations

an application service provides a valuable boundary.

Interviewers may also ask:

"Should the service depend on an interface or implementation?"

The best answer is not:

"Always interface."

The better answer is:

Use an abstraction where it separates a stable business requirement from a replaceable or technical implementation.

Examples include:

  • Payment provider
  • Email provider
  • Storage provider
  • Fraud service
  • Repository
  • Messaging gateway

28. Interview Questions and Answers

Basic Question

Question: What is the normal dependency direction in a Spring Boot layered application?

Answer:

A common structure is:

JAVA
Controller
    ↓
Service
    ↓
Repository

The controller handles transport concerns, the service handles application/business workflow, and the repository handles persistence.

The exact architecture can vary, but dependencies should have clear direction and responsibilities.

Intermediate Question

Question: Why should a service usually not depend on a controller?

Answer:

A controller is an entry-point adapter for HTTP or another presentation mechanism.

If the service depends on it, the business workflow becomes tied to the presentation layer.

This also creates reuse and testing problems.

The controller should normally depend on the service instead.

Advanced Question

Question: Does dependency inversion mean every service should have an interface?

Answer:

No.

Dependency inversion is about keeping high-level policy from depending unnecessarily on volatile implementation details.

Creating:

JAVA
OrderService
OrderServiceImpl

provides little architectural value if there is only one service implementation and no meaningful boundary.

Interfaces are especially useful around boundaries such as:

  • Persistence
  • External APIs
  • Messaging
  • Storage
  • Vendor integrations

Scenario-Based Question

Question: Your Spring Boot service currently depends directly on StripeClient. The company plans to support multiple payment providers. What would you change?

Answer:

Create an application-facing capability such as:

JAVA
PaymentGateway

The business service depends on PaymentGateway.

Then create infrastructure implementations such as:

JAVA
StripePaymentGateway
AdyenPaymentGateway

This keeps provider-specific code outside the core payment workflow.

Code-Review Question

Question: What is wrong with this service?

JAVA
public ResponseEntity<UserResponse> create(
        UserRequest request,
        HttpServletRequest servletRequest) {
}

Answer:

The method appears to mix application logic with HTTP transport concerns.

The reviewer should determine whether:

  • HttpServletRequest can be replaced with required application data.
  • UserRequest is an API-specific DTO.
  • ResponseEntity can be created by the controller instead.
  • The underlying use case should be transport-neutral.

Real-Project Question

Question: Is Controller → Service → Repository always mandatory?

Answer:

No.

Architecture should match system complexity.

For a simple CRUD endpoint with no meaningful business behavior, an extra service may add little value.

However, once the use case requires:

  • Business validation
  • Transactions
  • Multiple data sources
  • External integrations
  • Reuse from multiple entry points

the application service becomes valuable.

Architecture Question

Question: What is wrong with business services depending directly on REST client classes?

Answer:

The service becomes coupled to the transport and vendor implementation.

If the integration changes from REST to messaging or to another provider, business code may need modification.

An application-facing gateway can isolate this change when the boundary is important.

Spring Question

Question: How can circular dependencies reveal poor dependency direction?

Answer:

A cycle such as:

JAVA
ServiceA → RepositoryB → ServiceA

usually indicates that responsibilities are placed in the wrong layers.

Spring may also fail to create beans or developers may attempt workarounds such as @Lazy, but the architectural cycle should generally be investigated rather than hidden.

29. Quick Rule to Remember

Business logic should depend on what the application needs, not on how HTTP, databases, or external vendors happen to provide it.

30. Final Takeaway

Dependency direction between layers determines how easily a Java application can evolve.

Developers should remember:

  • Controllers are entry points, not business dependencies.
  • Services should focus on use cases and business orchestration.
  • Repositories should focus on persistence.
  • Domain logic should avoid unnecessary framework and transport dependencies.
  • External integrations should stay near infrastructure boundaries.
  • Interfaces should represent useful architectural boundaries, not coding rituals.
  • HttpServletRequest, ResponseEntity, vendor SDKs, and JPA details should not spread through every layer.
  • Circular dependencies are usually a strong architectural warning.
  • Business rules should remain reusable across REST, Kafka, scheduled jobs, batch processing, and tests when the project requires those entry points.

During Pull Request review, inspect not only method implementation but also imports and constructor dependencies.

Ask:

  • Which layer owns this responsibility?
  • Why does this class know about that class?
  • Is the dependency pointing toward a more stable application concept or toward a technical detail?
  • Will changing the database, HTTP contract, or external provider force unnecessary business-code changes?
  • Can the same use case be executed without the current transport mechanism?
  • Does introducing another abstraction genuinely simplify change, or merely add boilerplate?

The goal is not to build the most sophisticated architecture.

The goal is to maintain a clear dependency structure where business logic remains understandable, testable, reusable, and protected from unnecessary technical coupling.