Why Should the Service Layer in Java Not Return a Result Object Directly?

The article explains that returning a Result wrapper from the Service layer mixes business logic with presentation concerns, leading to tighter coupling, reduced reusability, cumbersome error handling, harder testing, poorer DDD alignment, limited interface flexibility, and ambiguous transaction boundaries.

Java Companion
Java Companion
Java Companion
Why Should the Service Layer in Java Not Return a Result Object Directly?

During a code review the author noticed a teammate returning a Result<User> directly from the Service layer and started a technical discussion about why this design is problematic.

Separation of Responsibilities

In a traditional MVC architecture, the Service layer handles business logic while the Controller deals with HTTP request handling and response formatting. When a Service method returns a Result, it starts to handle response formatting, coupling business and presentation logic and reducing code clarity and maintainability.

@Service
public class UserService {
    public Result<User> getUserById(Long id) {
        User user = userMapper.selectById(id);
        if (user == null) {
            return Result.error(404, "用户不存在");
        }
        return Result.success(user);
    }
}

@RestController
public class UserController {
    @Autowired
    private UserService userService;

    @GetMapping("/user/{id}")
    public Result<User> getUser(@PathVariable Long id) {
        return userService.getUserById(id);
    }
}

In contrast, letting the Controller wrap the response keeps the Service pure:

@Service
public class UserService {
    public User getUserById(Long id) {
        User user = userMapper.selectById(id);
        if (user == null) {
            throw new BusinessException("用户不存在");
        }
        return user;
    }
}

@RestController
public class UserController {
    @Autowired
    private UserService userService;

    @GetMapping("/user/{id}")
    public Result<User> getUser(@PathVariable Long id) {
        User user = userService.getUserById(id);
        return Result.success(user);
    }
}

Reusability Issues

When Service methods return Result, other services that call them must unpack the wrapper, adding boilerplate and obscuring the actual data:

@Service
public class OrderService {
    @Autowired
    private UserService userService;

    public void createOrder(Long userId, OrderDTO orderDTO) {
        // Not recommended: need to unwrap Result
        Result<User> userResult = userService.getUserById(userId);
        if (!userResult.isSuccess()) {
            throw new BusinessException(userResult.getMessage());
        }
        User user = userResult.getData();
        // business logic
        validateUserStatus(user);
    }
}

Returning the pure business object simplifies the call:

@Service
public class OrderService {
    @Autowired
    private UserService userService;

    public void createOrder(Long userId, OrderDTO orderDTO) {
        User user = userService.getUserById(userId);
        // business logic
        validateUserStatus(user);
    }
}

Exception Handling Mechanism

Embedding error handling inside Service methods by returning Result.fail(...) leads to duplicated error‑handling code and scattered logic. Using exceptions combined with a global exception handler centralizes error processing:

public void createOrder(Long userId, OrderDTO orderDTO) {
    if (userId == null) {
        throw new BusinessException("用户ID不能为空");
    }
    // business logic
}

@RestControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(BusinessException.class)
    public Result<Void> handleBusinessException(BusinessException e) {
        return Result.error(400, e.getMessage());
    }

    @ExceptionHandler(Exception.class)
    public Result<Void> handleException(Exception e) {
        log.error("系统异常", e);
        return Result.error(500, "系统繁忙");
    }
}

Testing Convenience

When Services return plain objects, unit tests can directly assert on the business data, making tests concise:

@SpringBootTest
public class UserServiceTest {
    @Autowired
    private UserService userService;

    @Test
    public void testGetUserById() {
        User user = userService.getUserById(1L);
        assertNotNull(user);
        assertEquals("张三", user.getName());
    }

    @Test
    public void testGetUserById_NotFound() {
        assertThrows(BusinessException.class, () -> userService.getUserById(999L));
    }
}

If the Service returns Result, tests must unwrap the wrapper and check status flags, adding unnecessary verbosity.

Domain‑Driven Design Perspective

From a DDD viewpoint, the Service belongs to the application or domain layer and should expose domain concepts. Result is an infrastructure concern tied to HTTP response format and should not pollute the domain model.

@Service
public class TransferService {
    public TransferResult transfer(Long fromAccountId, Long toAccountId, BigDecimal amount) {
        Account from = accountRepository.findById(fromAccountId);
        Account to = accountRepository.findById(toAccountId);
        from.deduct(amount);
        to.deposit(amount);
        accountRepository.save(from);
        accountRepository.save(to);
        return new TransferResult(from, to, amount);
    }
}

Interface Adaptation Flexibility

Returning pure domain objects allows different controllers (REST, GraphQL, RPC) to wrap responses according to their protocol without changing the Service implementation.

@RestController
public class UserController {
    @Autowired
    private UserService userService;

    @GetMapping("/user/{id}")
    public Result<User> getUser(@PathVariable Long id) {
        User user = userService.getUserById(id);
        return Result.success(user);
    }

    @QueryMapping
    public User user(@Argument Long id) {
        return userService.getUserById(id);
    }
}

Clear Transaction Boundaries

Service methods are typical transaction boundaries. Returning a business object makes it clear that a successful return commits the transaction, while throwing an exception triggers rollback. A Result return obscures this semantics and can lead to data inconsistency if errors are signaled only via the wrapper.

@Service
public class OrderService {
    @Transactional
    public Order createOrder(OrderDTO orderDTO) {
        Order order = new Order();
        orderMapper.insert(order);
        inventoryService.deduct(orderDTO.getProductId(), orderDTO.getQuantity());
        return order; // transaction commits on normal return
    }
}

public Result<Order> createOrder(OrderDTO orderDTO) {
    Order order = new Order();
    orderMapper.insert(order);
    Result<Void> inventoryResult = inventoryService.deduct(...);
    if (!inventoryResult.isSuccess()) {
        return Result.fail("库存不足"); // transaction may not roll back
    }
    return Result.success(order);
}

Overall, keeping the Service layer focused on pure business logic improves separation of concerns, reusability, testability, DDD compliance, interface flexibility, and transaction clarity.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

JavaTestingException HandlingDomain-Driven DesignTransaction ManagementService LayerResult WrapperSeparation of Concerns
Java Companion
Written by

Java Companion

A highly professional Java public account

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.