Why Your Java Controllers Misuse Service Layers—and How to Fix It
This article examines common misuse of base Controller and Service classes in Java Spring projects, explains why they violate the Liskov Substitution Principle, and provides practical steps to split, refactor, and properly structure code across the three‑tier architecture.
Introduction
Charles Dickens wrote in *A Tale of Two Cities*: “It was the best of times, it was the worst of times.” The rapid growth of mobile internet creates many opportunities, but intensified competition makes many startups struggle. The author, after years of experience in early‑stage companies, shares observations on chaotic Java service‑side patterns and offers tentative advice.
1. Using Controller and Service Base Classes
1.1 Phenomenon Description
1.1.1 Controller Base Class
/** Base controller class */
public class BaseController {
/** Injected services */
@Autowired
protected UserService userService;
/** Phone number pattern */
protected static final String PHONE_PATTERN = "/^[1]([3-9])[0-9]{9}$/";
/** Validate phone */
protected static boolean vaildPhone(String phone) { ... }
}The typical base controller contains injected services, static constants, and static helper methods so that all controllers can inherit them and use the resources directly.
1.1.2 Service Base Class
/** Base service class */
public class BaseService {
/** Injected DAO */
@Autowired
protected UserDAO userDAO;
/** Injected services */
@Autowired
protected SmsService smsService;
/** System name */
@Value("${example.systemName}")
protected String systemName;
/** Static constants */
protected static final long SUPPER_USER_ID = 0L;
/** Service methods */
protected UserDO getUser(Long userId) { ... }
/** Static helper */
protected static String getUserName(UserDO user) { ... }
}The base service similarly aggregates DAO, other services, configuration parameters, constants, and utility methods for subclasses.
1.2 Why Base Classes Are Unnecessary
Liskov Substitution Principle (LSP): All places that use a base class must be able to use any subclass transparently.
Subclasses inherit all methods and fields, reducing the work needed to create them.
Code reuse is increased because subclasses get all functionality.
Extensibility improves as subclasses can add their own behavior.
However, in practice:
Neither the Controller base nor the Service base is actually substituted by subclasses, violating LSP.
They contain no abstract or virtual methods, so subclasses never replace the base.
The injected resources are not required by every subclass, causing unnecessary loading overhead.
Therefore, the base classes act as a catch‑all rather than a true abstraction and should be split.
1.3 How to Split the Base Classes
1.3.1 Move Injections to Concrete Implementations
/** User service class */
@Service
public class UserService {
@Autowired
private UserDAO userDAO;
@Autowired
private SmsService smsService;
@Value("${example.systemName}")
private String systemName;
...
}1.3.2 Move Static Constants to a Constant Class
/** Example constants */
public class ExampleConstants {
public static final long SUPPER_USER_ID = 0L;
...
}1.3.3 Move Service Methods to Service Classes
/** User service */
@Service
public class UserService {
public UserDO getUser(Long userId) { ... }
...
}
/** Company service */
@Service
public class CompanyService {
@Autowired
private UserService userService;
public UserDO getManager(Long companyId) {
CompanyDO company = ...;
return userService.getUser(company.getManagerId());
}
...
}1.3.4 Move Static Helpers to a Utility Class
/** User helper */
public class UserHelper {
public static String getUserName(UserDO user) { ... }
...
}2. Writing Business Logic Directly in Controllers
2.1 Phenomenon Description
/** User controller */
@Controller
@RequestMapping("/user")
public class UserController {
@Autowired
private UserDAO userDAO;
@ResponseBody
@RequestMapping(path = "/getUser", method = RequestMethod.GET)
public Result<UserVO> getUser(@RequestParam(name = "userId", required = true) Long userId) {
UserDO userDO = userDAO.getUser(userId);
if (Objects.isNull(userDO)) {
return null;
}
UserVO userVO = new UserVO();
BeanUtils.copyProperties(userDO, userVO);
return Result.success(userVO);
}
...
}The developer argues that a simple endpoint does not need a separate service layer. In reality, the @Value annotation fails because BeanPostProcessor handling is scoped per container, and the WebApplicationContext cannot see parent‑container properties.
Note that actual processing of the @Value annotation is performed by a BeanPostProcessor. BeanPostProcessor interfaces are scoped per‑container…
Consequently, Controllers should not contain business logic.
2.3 Service‑Side Three‑Tier Architecture
Spring MVC follows a classic three‑tier structure: Presentation (Controller), Business (Service), and Persistence (Repository).
Presentation handles HTTP requests, Business contains core logic, and Persistence manages data access.
3. Writing Persistence Code in Services
3.1 Problems
Business and persistence layers become tangled, breaking the three‑tier rule.
Embedding SQL in business logic increases complexity.
Direct use of third‑party middleware hinders replacement.
Scattered persistence code violates object‑oriented principles.
Unit testing persistence becomes difficult.
3.2 Move Database Code to DAO
/** User DAO */
@Repository
public class UserDAO {
@Autowired
private SessionFactory sessionFactory;
public UserDO getUserByEmpId(String empId) {
String hql = "from t_user where emp_id = '" + empId + "'";
Query query = sessionFactory.getCurrentSession().createQuery(hql);
List<UserDO> list = query.list();
if (CollectionUtils.isEmpty(list)) return null;
return list.get(0);
}
}
/** User service */
@Service
public class UserService {
@Autowired
private UserDAO userDAO;
public UserVO getUserByEmpId(String empId) {
UserDO userDO = userDAO.getUserByEmpId(empId);
if (Objects.isNull(userDO)) return null;
UserVO vo = new UserVO();
BeanUtils.copyProperties(userDO, vo);
return vo;
}
}The author prefers hand‑written MyBatis XML over code‑generation plugins like AliGenerator because generated code often introduces unnecessary complexity and can hide custom logic.
3.3 Move Redis Code to a Dedicated DAO
/** User Redis DAO */
@Repository
public class UserRedis {
@Autowired
private RedisTemplate<String, String> redisTemplate;
private static final String KEY_PATTERN = "hash::user::%s";
public void save(UserDO user) {
String key = MessageFormat.format(KEY_PATTERN, user.getId());
Map<String, String> map = new HashMap<>(8);
map.put(UserDO.CONST_NAME, user.getName());
map.put(UserDO.CONST_SEX, String.valueOf(user.getSex()));
map.put(UserDO.CONST_AGE, String.valueOf(user.getAge()));
redisTemplate.opsForHash().putAll(key, map);
}
}
/** User service */
@Service
public class UserService {
@Autowired
private UserDAO userDAO;
@Autowired
private UserRedis userRedis;
public void saveUser(UserVO user) {
UserDO userDO = transUser(user);
userRedis.save(userDO);
userDAO.save(userDO);
}
}Encapsulating Redis operations in a DAO aligns with the three‑tier architecture and improves maintainability.
4. Exposing Database Model Classes to APIs
4.1 Phenomenon Description
/** User DAO */
@Repository
public class UserDAO {
public UserDO getUser(Long userId) { ... }
}
/** User service */
@Service
public class UserService {
@Autowired
private UserDAO userDAO;
public UserDO getUser(Long userId) { return userDAO.getUser(userId); }
}
/** User controller */
@Controller
@RequestMapping("/user")
public class UserController {
@Autowired
private UserService userService;
@RequestMapping(path = "/getUser", method = RequestMethod.GET)
public Result<UserDO> getUser(@RequestParam(name = "userId", required = true) Long userId) {
UserDO user = userService.getUser(userId);
return Result.success(user);
}
}Returning the persistence entity (UserDO) directly exposes the database schema, wastes bandwidth, risks leaking sensitive fields, and couples the API to the underlying table structure.
4.2 Problems and Solutions
Database schema becomes visible to competitors.
Unrestricted fields increase payload size and consume user traffic.
Sensitive data may be unintentionally exposed.
Schema‑entity mismatches arise when API needs differ from DB design.
Documentation suffers because code no longer clarifies which fields are API‑relevant.
Solutions:
Enforce a strict separation between DB models and API DTOs through policy.
Structure projects so that only API modules define the outward‑facing models.
4.3 Three Project‑Structure Approaches
1️⃣ **Shared‑Model Structure** – All modules depend on a common model project. This allows the presentation layer to call any service or DAO directly, increasing risk of bypassing business logic.
2️⃣ **Separated‑Model Structure** – An API module defines DTOs and service interfaces; the service module implements them. The presentation layer depends only on the API module, reducing accidental calls to internal services.
3️⃣ **Service‑Oriented Structure** – Business and persistence modules are packaged as a Dubbo service. Only the API interfaces are exposed, guaranteeing that DB models never leak to callers.
4.4 A Not‑Recommended Suggestion
Some argue that using the same VO class for both API and persistence speeds up early development. While it may work for rapid iteration, it still violates the three‑tier independence principle and can lead to hidden coupling.
Postscript
“Different people see different things; this article reflects only my perspective.” The author dedicates the piece to the startups that allowed him to clean up architectural chaos and grow technically.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
macrozheng
Dedicated to Java tech sharing and dissecting top open-source projects. Topics include Spring Boot, Spring Cloud, Docker, Kubernetes and more. Author’s GitHub project “mall” has 50K+ stars.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
