Adding a Service Layer
By lesson five you had a working DAO that converts rows to objects. By lesson six you could wrap multiple DAO calls inside a single database transaction. What you do not yet have is a clean place to answer the question: what are the rules the application must enforce, and who is responsible for coordinating multiple DAOs when a business operation needs them? That place is the service layer.
Why a Service Layer Exists
A DAO is deliberately narrow in scope: it knows how to talk to one table (or a small cluster of related tables). It does not know — and must not care — that creating a new order also needs to deduct stock from inventory, record an audit entry, and trigger a confirmation email. Pushing that orchestration logic into a Servlet or a JSP creates controllers that are impossible to test and business rules that are scattered across the codebase.
The service layer sits between the web tier (Servlets, REST endpoints, MVC controllers) and the data tier (DAOs). Its responsibilities are:
- Business rules — validate that an email address is not already registered before creating a user.
- Cross-DAO coordination — call
UserDAO, then WalletDAO, then AuditDAO, all inside one transaction.
- Transaction boundaries — the service owns the
Connection (or the unit-of-work) and commits or rolls back based on overall success.
- Mapping between layers — convert a raw domain entity into a DTO (Data Transfer Object) the web tier can safely expose, or vice-versa.
The canonical rule of thumb: if a Servlet would need to know the word "AND" — create a user and send a welcome email and log the action — that "AND" belongs in a service, not in the controller.
Structuring the Service
A service is typically a plain Java class (or interface + implementation). You program to the interface so the web tier depends only on the abstraction, making unit tests trivial to write.
// UserService.java (interface)
public interface UserService {
User register(String email, String fullName, String rawPassword)
throws EmailAlreadyExistsException, ServiceException;
User findById(long id) throws ServiceException;
void changeEmail(long userId, String newEmail)
throws EmailAlreadyExistsException, ServiceException;
}
// UserServiceImpl.java (implementation)
import java.sql.Connection;
import java.sql.SQLException;
public class UserServiceImpl implements UserService {
private final UserDAO userDao;
private final AuditDAO auditDao;
// Dependencies are injected (constructor injection)
public UserServiceImpl(UserDAO userDao, AuditDAO auditDao) {
this.userDao = userDao;
this.auditDao = auditDao;
}
@Override
public User register(String email, String fullName, String rawPassword)
throws EmailAlreadyExistsException, ServiceException {
// --- Business rule ---
if (userDao.existsByEmail(email)) {
throw new EmailAlreadyExistsException(email);
}
String hashed = PasswordUtil.bcrypt(rawPassword);
User newUser = new User(email, fullName, hashed);
// --- Cross-DAO work inside ONE transaction ---
try (Connection conn = DataSourceFactory.get().getConnection()) {
conn.setAutoCommit(false);
try {
userDao.insert(conn, newUser); // returns newUser with generated id
auditDao.log(conn, "USER_CREATED", newUser.getId());
conn.commit();
return newUser;
} catch (SQLException ex) {
conn.rollback();
throw new ServiceException("Registration failed", ex);
}
} catch (SQLException ex) {
throw new ServiceException("Could not open connection", ex);
}
}
}
Notice that the DAOs receive the Connection as a parameter rather than fetching their own. This is the key to keeping multiple DAO calls inside a single transaction — the service holds the Connection and passes it down.
Passing the Connection to DAOs
DAOs designed to support this pattern expose two kinds of methods: autonomous ones that open their own connection (useful for read-only singleton calls) and collaborative ones that accept an externally managed connection:
public class UserDAO {
// Autonomous — fine for isolated reads
public Optional<User> findById(long id) throws SQLException {
try (Connection conn = DataSourceFactory.get().getConnection()) {
return findById(conn, id); // delegate to shared implementation
}
}
// Collaborative — called by the service when it owns the transaction
public Optional<User> findById(Connection conn, long id) throws SQLException {
String sql = "SELECT id, email, full_name FROM users WHERE id = ?";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setLong(1, id);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? Optional.of(mapRow(rs)) : Optional.empty();
}
}
}
public void insert(Connection conn, User user) throws SQLException {
String sql = "INSERT INTO users (email, full_name, password_hash) VALUES (?, ?, ?)";
try (PreparedStatement ps = conn.prepareStatement(sql,
PreparedStatement.RETURN_GENERATED_KEYS)) {
ps.setString(1, user.getEmail());
ps.setString(2, user.getFullName());
ps.setString(3, user.getPasswordHash());
ps.executeUpdate();
try (ResultSet keys = ps.getGeneratedKeys()) {
if (keys.next()) user.setId(keys.getLong(1));
}
}
}
private User mapRow(ResultSet rs) throws SQLException {
User u = new User();
u.setId(rs.getLong("id"));
u.setEmail(rs.getString("email"));
u.setFullName(rs.getString("full_name"));
return u;
}
}
Prefer overloading over separate method names. findById(long) and findById(Connection, long) is idiomatic Java. The single-argument form is a convenience wrapper that handles the common standalone case; the two-argument form is the one the service uses when it needs to share a connection across DAO calls.
The Service as the Transaction Boundary
A critical design decision: the service layer, not the DAO layer, owns the transaction boundary. If DAOs start their own transactions internally, two DAOs called by the same service method will run in different transactions — any exception thrown by the second DAO cannot roll back the work already committed by the first. Centralising the transaction in the service gives you an all-or-nothing guarantee across the entire operation.
// Anti-pattern: DAO that commits internally
public void insert(User user) throws SQLException {
try (Connection conn = DataSourceFactory.get().getConnection()) {
conn.setAutoCommit(false);
// ... insert ...
conn.commit(); // ❌ committed before the service can roll back!
}
}
// Correct: DAO accepts an externally managed Connection
public void insert(Connection conn, User user) throws SQLException {
// ... insert — no commit here ... ✅
}
Checked vs. Unchecked Service Exceptions
Services should not expose raw SQLException to the web tier — that is an implementation detail. Define a small exception hierarchy for your domain:
ServiceException (checked or unchecked) — wraps infrastructure failures.
ValidationException — business rule violations (field errors, duplicate records).
NotFoundException — the requested entity does not exist.
The Servlet catches service-layer exceptions and turns them into HTTP responses (400, 404, 500) without ever seeing a SQLException:
// In a Jakarta Servlet
try {
User user = userService.register(email, fullName, password);
resp.sendRedirect(req.getContextPath() + "/dashboard");
} catch (EmailAlreadyExistsException e) {
req.setAttribute("error", "That email is already registered.");
req.getRequestDispatcher("/WEB-INF/views/register.jsp").forward(req, resp);
} catch (ServiceException e) {
log.error("Registration failed", e);
resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
}
Wiring Services in a Servlet Application
Without a DI container, a common approach is to initialise services in a ServletContextListener and store them in the application scope, where any Servlet can retrieve them:
@WebListener
public class AppBootstrap implements ServletContextListener {
@Override
public void contextInitialized(ServletContextEvent sce) {
UserDAO userDao = new UserDAO();
AuditDAO auditDao = new AuditDAO();
UserService userService = new UserServiceImpl(userDao, auditDao);
ServletContext ctx = sce.getServletContext();
ctx.setAttribute("userService", userService);
}
}
// Inside any Servlet
UserService userService =
(UserService) getServletContext().getAttribute("userService");
Services must be stateless with respect to individual requests. A service stored in application scope is shared across all concurrent threads. Never store request-specific data (the current user, form values, a Connection) in instance fields of a service — this is a thread-safety bug that is very hard to reproduce in development but will corrupt data under load.
Summary
The service layer is where your application's actual rules live. It decouples the web tier from persistence details, coordinates multi-DAO operations within a single transaction boundary, and translates infrastructure exceptions into domain-meaningful ones. Once your Servlets delegate to services and services delegate to DAOs, each layer has exactly one job — and the entire stack becomes testable, maintainable, and easy to reason about.