Layered Architecture — Controller → Service → Repository
We have been using the layered architecture since Lecture 2, but we have never formally defined the rules that govern each layer. Let us make them explicit.
The Three Layers
┌─────────────────────────────────────────────┐
│ Controller Layer │
│ - Receives HTTP requests │
│ - Validates input (with @Valid) │
│ - Calls service methods │
│ - Returns HTTP responses │
│ - Knows about DTOs, NOT about entities │
├─────────────────────────────────────────────┤
│ Service Layer │
│ - Contains ALL business logic │
│ - Manages transactions │
│ - Orchestrates multiple repositories │
│ - Converts between entities and DTOs │
│ - Throws business exceptions │
│ - Knows about entities AND DTOs │
├─────────────────────────────────────────────┤
│ Repository Layer │
│ - Data access ONLY │
│ - No business logic │
│ - No DTO knowledge │
│ - Knows about entities ONLY │
└─────────────────────────────────────────────┘
The Rules
Rule 1: Dependencies flow downward. A controller depends on services. A service depends on repositories. A controller should never call a repository directly. A repository should never call a service.
Rule 2: Each layer has a single responsibility. The controller handles HTTP. The service handles business logic. The repository handles data access. If you find business logic in a controller or HTTP logic in a service, you have a design problem.
Rule 3: The service layer is the transaction boundary. All database operations within a single business operation should be wrapped in one transaction. The controller should never manage transactions.
Rule 4: DTOs stop at the service layer. The controller receives and returns DTOs. The service converts between DTOs and entities. The repository works only with entities.
Why Not Skip the Service Layer?
For simple CRUD operations, the service layer often feels like unnecessary boilerplate — it just delegates to the repository:
// This service feels pointless — it just passes through to the repository
public User getUserById(Long id) {
return userRepository.findById(id).orElseThrow();
}
But as your application grows, the service layer earns its place:
- A “create post” operation needs to validate the author exists, generate a unique slug, set default status, assign tags, and save the post — all in one transaction.
- A “publish post” operation needs to check the post status, verify the author has publishing permission, update the status, set the published timestamp, and send a notification.
- A “delete user” operation needs to reassign their posts to an admin, delete their comments, revoke their tokens, and deactivate the account.
These multi-step operations cannot live in a controller or a repository. They belong in the service layer.
The Role of the Service Layer
The service layer has five core responsibilities:
Business Rule Enforcement
@Service
public class PostService {
public PostResponse publishPost(Long id) {
Post post = postRepository.findByIdWithDetails(id)
.orElseThrow(() -> new ResourceNotFoundException("Post", "id", id));
// Business rule: only drafts can be published
if (!"DRAFT".equals(post.getStatus())) {
throw new BadRequestException(
"Only draft posts can be published. Current status: " + post.getStatus());
}
// Business rule: posts must have a category to be published
if (post.getCategory() == null) {
throw new BadRequestException("A category is required before publishing");
}
// Business rule: content must be at least 100 characters
if (post.getContent().length() < 100) {
throw new BadRequestException(
"Content must be at least 100 characters to publish");
}
post.setStatus("PUBLISHED");
post.setPublishedAt(LocalDateTime.now());
return postMapper.toResponse(postRepository.save(post));
}
}
The controller does not know about these rules. It simply calls postService.publishPost(id) and the service either succeeds or throws an appropriate exception.
Transaction Management
@Transactional
public PostResponse createPost(CreatePostRequest request) {
// All of these operations happen in ONE transaction.
// If any step fails, ALL steps are rolled back.
User author = findAuthor(request.getAuthorId());
Category category = findCategory(request.getCategoryId());
String slug = generateUniqueSlug(request.getTitle());
Post post = new Post(request.getTitle(), slug, request.getContent());
post.setAuthor(author);
post.setCategory(category);
assignTags(post, request.getTagIds());
Post saved = postRepository.save(post);
return postMapper.toResponse(saved);
}
Orchestration of Multiple Repositories
@Service
public class PostService {
// A single service can use multiple repositories
private final PostRepository postRepository;
private final UserRepository userRepository;
private final CategoryRepository categoryRepository;
private final TagRepository tagRepository;
private final CommentRepository commentRepository;
// One business operation may touch multiple tables
@Transactional
public void deletePost(Long id) {
Post post = postRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Post", "id", id));
// Delete all comments (cascade should handle this, but being explicit)
commentRepository.deleteByPostId(id);
// Remove tag associations (junction table cleanup)
post.getTags().clear();
// Delete the post itself
postRepository.delete(post);
}
}
DTO ↔ Entity Conversion
@Service
public class PostService {
private final PostMapper postMapper;
@Transactional(readOnly = true)
public PostResponse getPostById(Long id) {
Post post = postRepository.findByIdWithDetails(id)
.orElseThrow(() -> new ResourceNotFoundException("Post", "id", id));
long commentCount = commentRepository.countByPostId(id);
// Convert entity → DTO while the Hibernate session is open
return postMapper.toResponse(post, commentCount);
}
}
Cross-Cutting Concerns
Logging, auditing, caching, and other cross-cutting concerns naturally fit in the service layer:
@Service
public class PostService {
private static final Logger log = LoggerFactory.getLogger(PostService.class);
@Transactional
public PostResponse publishPost(Long id) {
Post post = findPostOrThrow(id);
log.info("Publishing post id={}, title='{}', author={}",
post.getId(), post.getTitle(), post.getAuthor().getUsername());
// ... business logic ...
log.info("Post published successfully: id={}", post.getId());
return postMapper.toResponse(post);
}
}
@Service Annotation and Interface-Based Design
Interface + Implementation Pattern
A common pattern in enterprise Java is to define a service interface and a separate implementation:
// Interface — defines WHAT the service does
public interface PostService {
PostResponse createPost(CreatePostRequest request);
PostResponse getPostById(Long id);
Page<PostResponse> getPosts(String status, Pageable pageable);
PostResponse updatePost(Long id, UpdatePostRequest request);
PostResponse publishPost(Long id);
void deletePost(Long id);
}
// Implementation — defines HOW it does it
@Service
public class PostServiceImpl implements PostService {
@Override
public PostResponse createPost(CreatePostRequest request) {
// actual implementation
}
// ... other method implementations
}
Should You Use Interfaces?
This is a debated topic in the Spring community. Here are the arguments:
Arguments for interfaces:
- Easy to create mock implementations for testing
- Supports multiple implementations (e.g.,
StandardPostServiceandPremiumPostService) - Spring AOP proxying works more predictably with interfaces
- Follows the Dependency Inversion Principle
Arguments against interfaces:
- Extra file to maintain for every service
- Modern mocking frameworks (Mockito) can mock concrete classes just as easily
- You rarely need multiple implementations of a service
- Adds complexity without clear benefit in most projects
Our recommendation for this series: Skip interfaces and use concrete @Service classes directly. The simplicity outweighs the theoretical benefits. If you later need an interface (for multiple implementations or advanced proxying), you can add it then.
// Simple and sufficient for most projects
@Service
public class PostService {
// No interface needed — Mockito can mock this class directly
}
Service Naming Conventions
Name your service classes after the business domain they handle:
PostService // Everything related to posts
UserService // Everything related to users
CommentService // Everything related to comments
AuthenticationService // Login, register, token management
NotificationService // Emails, push notifications
Avoid names like PostManager, PostHelper, or PostUtils. These do not communicate that the class is a Spring-managed service with transactions and business logic.
Transaction Management with @Transactional
What is a Transaction?
A transaction is a group of database operations that must all succeed or all fail. There is no partial success. The classic example is a bank transfer:
1. Withdraw $100 from Account A → SUCCESS
2. Deposit $100 to Account B → FAILURE (network error)
Without a transaction, Account A loses $100 but Account B never receives it. With a transaction, the withdrawal is rolled back when the deposit fails — both accounts remain unchanged.
@Transactional in Spring
Spring manages transactions declaratively with the @Transactional annotation. You do not need to manually open connections, commit, or rollback.
@Service
public class PostService {
// @Transactional wraps the entire method in a database transaction.
// If ANY exception is thrown, ALL database changes are rolled back.
@Transactional
public PostResponse createPost(CreatePostRequest request) {
User author = userRepository.findById(request.getAuthorId())
.orElseThrow(() -> new ResourceNotFoundException("User", "id", request.getAuthorId()));
Post post = new Post(request.getTitle(), generateSlug(request.getTitle()), request.getContent());
post.setAuthor(author);
Post saved = postRepository.save(post); // INSERT
assignTags(saved, request.getTagIds()); // Multiple INSERTs into post_tags
// If assignTags() throws an exception here,
// the post INSERT is also rolled back!
// The database is never left in an inconsistent state.
return postMapper.toResponse(saved);
}
}
How @Transactional Works Behind the Scenes
When Spring sees @Transactional, it creates a proxy around your service class:
Controller calls postService.createPost(request)
↓
Spring Proxy:
1. BEGIN TRANSACTION
2. Call the actual PostService.createPost() method
3a. If method succeeds → COMMIT TRANSACTION
3b. If method throws exception → ROLLBACK TRANSACTION
This is why @Transactional only works on public methods called from outside the class. If a method calls another method in the same class, the proxy is bypassed:
@Service
public class PostService {
@Transactional
public void methodA() {
// Transaction is active here
methodB(); // ⚠️ This calls methodB() directly, NOT through the proxy!
// methodB()'s @Transactional annotation is IGNORED.
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void methodB() {
// If called from methodA(), this runs in methodA's transaction,
// NOT in a new transaction as the annotation requests.
}
}
This is a common gotcha. If you need separate transaction behavior for an internal method call, extract the method into a separate service class.
Rollback Behavior
By default, @Transactional rolls back on:
- Unchecked exceptions (RuntimeException and its subclasses) → ROLLBACK
- Checked exceptions (Exception subclasses that are NOT RuntimeException) → NO ROLLBACK
- Errors → ROLLBACK
You can customize this:
// Roll back on ALL exceptions (including checked exceptions)
@Transactional(rollbackFor = Exception.class)
// Roll back on a specific checked exception
@Transactional(rollbackFor = {IOException.class, ParseException.class})
// Do NOT roll back on a specific runtime exception
@Transactional(noRollbackFor = EmailSendingException.class)
Best practice: Since our custom exceptions (ResourceNotFoundException, BadRequestException) all extend RuntimeException, the default rollback behavior is correct. No customization needed.
Read-Only Transactions and Performance
@Transactional(readOnly = true)
For methods that only read data (no inserts, updates, or deletes), use readOnly = true:
@Service
public class PostService {
@Transactional(readOnly = true)
public PostResponse getPostById(Long id) {
Post post = postRepository.findByIdWithDetails(id)
.orElseThrow(() -> new ResourceNotFoundException("Post", "id", id));
return postMapper.toResponse(post);
}
@Transactional(readOnly = true)
public Page<PostResponse> getPosts(String status, Pageable pageable) {
// ... read-only operation
}
}
Performance Benefits of readOnly = true
The readOnly = true flag provides several optimizations:
1. Hibernate skips dirty checking. In a normal transaction, Hibernate checks every loaded entity at commit time to see if any fields changed (dirty checking). With readOnly = true, Hibernate skips this entirely because it knows no changes will be flushed.
2. Hibernate uses a read-only flush mode. No SQL UPDATE or INSERT statements are generated, reducing overhead.
3. Database-level optimizations. Some databases (PostgreSQL, MySQL/MariaDB with certain storage engines) can route read-only transactions to read replicas or use faster execution paths.
4. Prevents accidental writes. If you accidentally call entity.setStatus("PUBLISHED") inside a read-only transaction, the change is NOT persisted. This is a safety net.
Pattern: Class-Level + Method-Level Annotations
A common and clean pattern is to set readOnly = true at the class level and override it for write methods:
@Service
@Transactional(readOnly = true) // Default for ALL methods: read-only
public class PostService {
// This method inherits @Transactional(readOnly = true) — no annotation needed
public PostResponse getPostById(Long id) {
// read-only operation
}
// This method inherits readOnly = true — no annotation needed
public Page<PostResponse> getPosts(String status, Pageable pageable) {
// read-only operation
}
// OVERRIDE: this method needs write access
@Transactional // Overrides class-level readOnly = true → readOnly = false (default)
public PostResponse createPost(CreatePostRequest request) {
// write operation
}
@Transactional
public PostResponse publishPost(Long id) {
// write operation
}
@Transactional
public void deletePost(Long id) {
// write operation
}
}
This pattern is excellent because:
- Most service methods are reads (getById, getAll, search)
- Reads get automatic optimization without repeating the annotation
- Write methods are explicitly marked — easy to identify at a glance
Propagation and Isolation Levels
Transaction Propagation
Propagation defines what happens when a transactional method calls another transactional method. The most common scenario: ServiceA.methodA() (transactional) calls ServiceB.methodB() (transactional). Should they share one transaction or use separate ones?
// Propagation.REQUIRED (DEFAULT)
// "Join the existing transaction, or create a new one if none exists."
// This is what you want 99% of the time.
@Transactional(propagation = Propagation.REQUIRED)
public void createPost(CreatePostRequest request) {
// If called from another @Transactional method → joins that transaction
// If called directly from controller → creates a new transaction
}
Here are all propagation types, but in practice you only need REQUIRED and occasionally REQUIRES_NEW:
| Propagation | Behavior |
|---|---|
REQUIRED (default) |
Join existing transaction, or create new one |
REQUIRES_NEW |
Always create a new, independent transaction |
SUPPORTS |
Join existing transaction if present, run without one otherwise |
NOT_SUPPORTED |
Run without a transaction, suspending any existing one |
MANDATORY |
Join existing transaction, throw exception if none exists |
NEVER |
Run without a transaction, throw exception if one exists |
NESTED |
Create a savepoint within the existing transaction |
When to Use REQUIRES_NEW
Use REQUIRES_NEW when you need an operation to succeed even if the outer transaction fails. The classic example is audit logging:
@Service
public class PostService {
private final AuditService auditService;
@Transactional
public void deletePost(Long id) {
Post post = findPostOrThrow(id);
// Log the deletion BEFORE actually deleting
// This runs in its OWN transaction — even if the delete fails,
// the audit log entry is preserved.
auditService.logAction("DELETE_POST", "Post deleted: " + post.getTitle());
postRepository.delete(post);
// If this throws an exception, the post delete is rolled back,
// but the audit log entry remains because it committed separately.
}
}
@Service
public class AuditService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logAction(String action, String description) {
AuditLog log = new AuditLog(action, description, LocalDateTime.now());
auditLogRepository.save(log);
// This commits independently of the calling method's transaction
}
}
Transaction Isolation Levels
Isolation levels control how transactions interact with each other when running concurrently. This becomes important when multiple users access the same data simultaneously.
@Transactional(isolation = Isolation.READ_COMMITTED)
public PostResponse getPostById(Long id) { ... }
| Isolation Level | Description | Risk |
|---|---|---|
READ_UNCOMMITTED |
Can read data from uncommitted transactions | Dirty reads |
READ_COMMITTED |
Only reads committed data | Non-repeatable reads |
REPEATABLE_READ |
Same query returns same results within a transaction | Phantom reads |
SERIALIZABLE |
Full isolation — transactions run sequentially | Lowest performance |
DEFAULT |
Use the database’s default level | Depends on database |
MariaDB’s default is REPEATABLE_READ, which is appropriate for most applications. You rarely need to change this. Stick with the default unless you have a specific concurrency problem.
Practical Advice
For most Spring Boot applications:
- Use
Propagation.REQUIRED(default) everywhere - Use
Propagation.REQUIRES_NEWonly for audit logging or operations that must commit independently - Use
Isolation.DEFAULTand let MariaDB handle it - Focus on proper
@Transactionalplacement rather than propagation/isolation tuning
Business Logic Patterns — When to Throw, When to Return
Pattern 1: Throw on Not Found (Recommended for Most Cases)
// The service throws an exception — the controller never sees a null
public PostResponse getPostById(Long id) {
Post post = postRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Post", "id", id));
return postMapper.toResponse(post);
}
Use this when the caller expects the resource to exist. A missing resource is an error, not a normal case.
Pattern 2: Return Optional (When Absence is Normal)
// The service returns Optional — the caller decides what to do
public Optional<PostResponse> findPostBySlug(String slug) {
return postRepository.findBySlug(slug)
.map(postMapper::toResponse);
}
Use this when the resource might legitimately not exist and the caller needs to handle both cases (for example, checking if a username is available).
Pattern 3: Guard Clause Early Returns
Structure your methods with validations first, happy path last:
@Transactional
public PostResponse createPost(CreatePostRequest request) {
// Guard clauses — validate everything before doing any work
User author = userRepository.findById(request.getAuthorId())
.orElseThrow(() -> new ResourceNotFoundException("User", "id", request.getAuthorId()));
if (!author.isActive()) {
throw new BadRequestException("Cannot create posts for inactive users");
}
String slug = generateSlug(request.getTitle());
if (postRepository.existsBySlug(slug)) {
throw new DuplicateResourceException("Post", "slug", slug);
}
// Happy path — all validations passed, do the actual work
Post post = new Post(request.getTitle(), slug, request.getContent());
post.setExcerpt(request.getExcerpt());
post.setAuthor(author);
if (request.getCategoryId() != null) {
Category category = categoryRepository.findById(request.getCategoryId())
.orElseThrow(() -> new ResourceNotFoundException("Category", "id", request.getCategoryId()));
post.setCategory(category);
}
assignTags(post, request.getTagIds());
Post saved = postRepository.save(post);
return postMapper.toResponse(saved);
}
Pattern 4: Service Method Returns DTO, Never Entity
// ✅ GOOD — returns DTO, controller never touches entities
public PostResponse getPostById(Long id) {
Post post = postRepository.findByIdWithDetails(id).orElseThrow(...);
return postMapper.toResponse(post);
}
// ❌ BAD — returns entity, controller must deal with lazy loading and mapping
public Post getPostById(Long id) {
return postRepository.findById(id).orElseThrow(...);
}
Pattern 5: Private Helper Methods for Reusable Logic
@Service
@Transactional(readOnly = true)
public class PostService {
// Public business methods call private helpers
@Transactional
public PostResponse createPost(CreatePostRequest request) {
User author = findActiveUser(request.getAuthorId());
String slug = generateUniqueSlug(request.getTitle());
// ...
}
@Transactional
public PostResponse updatePost(Long id, UpdatePostRequest request) {
Post post = findPostOrThrow(id);
// ...
}
// Private helpers — reduce duplication across public methods
private Post findPostOrThrow(Long id) {
return postRepository.findByIdWithDetails(id)
.orElseThrow(() -> new ResourceNotFoundException("Post", "id", id));
}
private User findActiveUser(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new ResourceNotFoundException("User", "id", userId));
if (!user.isActive()) {
throw new BadRequestException("User is inactive: " + userId);
}
return user;
}
private String generateUniqueSlug(String title) {
String slug = title.toLowerCase()
.replaceAll("[^a-z0-9\\s-]", "")
.replaceAll("\\s+", "-")
.replaceAll("-+", "-")
.replaceAll("^-|-$", "");
String uniqueSlug = slug;
int counter = 1;
while (postRepository.existsBySlug(uniqueSlug)) {
uniqueSlug = slug + "-" + counter++;
}
return uniqueSlug;
}
private void assignTags(Post post, List<Long> tagIds) {
if (tagIds == null || tagIds.isEmpty()) return;
List<Tag> tags = tagRepository.findAllById(tagIds);
if (tags.size() != tagIds.size()) {
throw new BadRequestException("One or more tag IDs are invalid");
}
tags.forEach(post::addTag);
}
}
Logging with SLF4J & Logback
Why Logging Matters
Throughout the series, we have been using System.out.println() for debugging. In production, this is inadequate because:
- You cannot filter messages by severity (info vs warning vs error)
- You cannot control where logs go (console, file, monitoring system)
- You cannot include structured metadata (timestamps, class names, thread names)
- You cannot turn logging on/off for specific packages
SLF4J (Simple Logging Facade for Java) is the standard logging API. Logback is the default logging implementation in Spring Boot. Together, they give you full control over application logging.
Basic Logging Setup
SLF4J and Logback are already included with spring-boot-starter-web. No additional dependencies needed.
package com.example.blogapi.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
@Service
public class PostService {
// Create a logger for this class.
// The logger name equals the fully qualified class name.
// Convention: declare as a private static final field.
private static final Logger log = LoggerFactory.getLogger(PostService.class);
@Transactional
public PostResponse createPost(CreatePostRequest request) {
log.info("Creating post: title='{}', authorId={}",
request.getTitle(), request.getAuthorId());
// ... business logic ...
log.info("Post created successfully: id={}, slug='{}'",
saved.getId(), saved.getSlug());
return postMapper.toResponse(saved);
}
@Transactional
public void deletePost(Long id) {
log.info("Deleting post: id={}", id);
Post post = findPostOrThrow(id);
log.warn("Post '{}' by user '{}' is being permanently deleted",
post.getTitle(), post.getAuthor().getUsername());
postRepository.delete(post);
log.info("Post deleted: id={}", id);
}
public PostResponse getPostById(Long id) {
log.debug("Fetching post: id={}", id);
// debug-level messages are not shown in production (only in dev)
Post post = findPostOrThrow(id);
return postMapper.toResponse(post);
}
}
Log Levels
SLF4J defines five log levels, from most verbose to most critical:
| Level | Purpose | Visible by Default |
|---|---|---|
TRACE |
Very detailed diagnostic info (method entry/exit, loop iterations) | No |
DEBUG |
Diagnostic info useful during development (variable values, query results) | No |
INFO |
Normal operational events (startup, request processing, configuration) | Yes |
WARN |
Potentially harmful situations (deprecated API usage, retry attempts) | Yes |
ERROR |
Error events (exceptions, failed operations, data corruption) | Yes |
When you set a log level, you see that level and all levels above it. Setting DEBUG shows DEBUG + INFO + WARN + ERROR. Setting WARN shows only WARN + ERROR.
Configuring Log Levels
In application.properties:
# Set root level (affects all packages)
logging.level.root=INFO
# Set specific package levels
logging.level.com.example.blogapi=DEBUG
logging.level.com.example.blogapi.repository=WARN
# Show Hibernate SQL (equivalent to spring.jpa.show-sql=true, but via logging)
logging.level.org.hibernate.SQL=DEBUG
# Show Hibernate parameter values (very useful for debugging)
logging.level.org.hibernate.orm.jdbc.bind=TRACE
# Spring framework internals (usually keep at WARN)
logging.level.org.springframework=WARN
Logging Best Practices
Use parameterized messages (curly braces), not string concatenation:
// ✅ GOOD — the string is only constructed if the log level is enabled
log.debug("Processing post id={}, title='{}'", post.getId(), post.getTitle());
// ❌ BAD — string concatenation happens even if DEBUG is disabled
log.debug("Processing post id=" + post.getId() + ", title='" + post.getTitle() + "'");
Log at the right level:
// INFO — normal business events
log.info("User '{}' logged in successfully", username);
log.info("Post published: id={}", postId);
log.info("Application started on port {}", port);
// WARN — unusual situations that might need attention
log.warn("Post slug collision, generating alternative: '{}'", slug);
log.warn("Database connection pool is 80% full");
log.warn("Deprecated API endpoint accessed: {}", request.getRequestURI());
// ERROR — things that went wrong
log.error("Failed to send notification email to '{}'", email, exception);
log.error("Database connection failed after {} retries", maxRetries);
// DEBUG — development-time diagnostics
log.debug("Cache hit for post id={}", postId);
log.debug("Query returned {} results", results.size());
Always include the exception object as the last parameter in error logs:
try {
// ... some operation
} catch (Exception ex) {
// ✅ GOOD — the exception is the last parameter, SLF4J prints the full stack trace
log.error("Failed to process post id={}", postId, ex);
// ❌ BAD — the exception is converted to a string, losing the stack trace
log.error("Failed to process post: " + ex.getMessage());
}
Writing Logs to a File
# Log to a file
logging.file.name=logs/blog-api.log
# Maximum file size before rotation
logging.logback.rollingpolicy.max-file-size=10MB
# Keep 30 days of log history
logging.logback.rollingpolicy.max-history=30
# Log pattern — customize the format
logging.pattern.console=%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n
logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n
Service Layer Unit Testing Preview
We will cover testing in depth in Lecture 15, but let us preview how the service layer design enables clean unit tests.
Why Service Layer Tests Matter
The service layer contains your business logic — the most important code in your application. Testing it ensures your business rules work correctly.
Testing with Mockito
Because we use constructor injection, we can mock all dependencies:
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class) // Enable Mockito annotations
class PostServiceTest {
// @Mock creates a mock (fake) implementation of each dependency.
// These mocks return default values (null, 0, empty) unless configured.
@Mock
private PostRepository postRepository;
@Mock
private UserRepository userRepository;
@Mock
private CategoryRepository categoryRepository;
@Mock
private TagRepository tagRepository;
@Mock
private PostMapper postMapper;
// @InjectMocks creates a real PostService and injects the mocks above.
@InjectMocks
private PostService postService;
@Test
void getPostById_whenPostExists_returnsPostResponse() {
// ARRANGE — set up test data and mock behavior
Long postId = 1L;
Post mockPost = new Post("Test Title", "test-title", "Test content");
mockPost.setId(postId);
PostResponse expectedResponse = new PostResponse();
expectedResponse.setId(postId);
expectedResponse.setTitle("Test Title");
// Configure mocks: when findByIdWithDetails is called, return our mock post
when(postRepository.findByIdWithDetails(postId))
.thenReturn(Optional.of(mockPost));
when(postMapper.toResponse(any(Post.class), anyLong()))
.thenReturn(expectedResponse);
// ACT — call the method under test
PostResponse result = postService.getPostById(postId);
// ASSERT — verify the result
assertNotNull(result);
assertEquals(postId, result.getId());
assertEquals("Test Title", result.getTitle());
// Verify that the repository was called exactly once
verify(postRepository, times(1)).findByIdWithDetails(postId);
}
@Test
void getPostById_whenPostDoesNotExist_throwsResourceNotFoundException() {
// ARRANGE
Long postId = 999L;
when(postRepository.findByIdWithDetails(postId))
.thenReturn(Optional.empty());
// ACT & ASSERT — verify that the correct exception is thrown
ResourceNotFoundException exception = assertThrows(
ResourceNotFoundException.class,
() -> postService.getPostById(postId)
);
assertTrue(exception.getMessage().contains("999"));
}
@Test
void publishPost_whenPostIsDraft_publishesSuccessfully() {
// ARRANGE
Long postId = 1L;
Post post = new Post("Draft Post", "draft-post", "Content here for testing publish");
post.setId(postId);
post.setStatus("DRAFT");
post.setCategory(new Category("Tutorial", "tutorial"));
User author = new User("alice", "alice@example.com", "hash");
post.setAuthor(author);
when(postRepository.findByIdWithDetails(postId))
.thenReturn(Optional.of(post));
when(postMapper.toResponse(any(Post.class)))
.thenAnswer(invocation -> {
Post p = invocation.getArgument(0);
PostResponse resp = new PostResponse();
resp.setId(p.getId());
resp.setStatus(p.getStatus());
return resp;
});
// ACT
PostResponse result = postService.publishPost(postId);
// ASSERT
assertEquals("PUBLISHED", result.getStatus());
}
@Test
void publishPost_whenPostIsAlreadyPublished_throwsBadRequestException() {
// ARRANGE
Post post = new Post("Published Post", "published-post", "Content");
post.setId(1L);
post.setStatus("PUBLISHED");
when(postRepository.findByIdWithDetails(1L))
.thenReturn(Optional.of(post));
// ACT & ASSERT
assertThrows(BadRequestException.class, () -> postService.publishPost(1L));
}
}
Notice how the tests follow the Arrange-Act-Assert pattern:
- Arrange: Set up test data and configure mock behavior
- Act: Call the method under test
- Assert: Verify the result and mock interactions
The key insight: because our service uses constructor injection and depends on interfaces/classes (not concrete database connections), we can test it without any database, Spring context, or network calls. Tests run in milliseconds.
Hands-on: Restructure Blog API with Proper Service Layer & Transactions
Let us refactor the PostService to apply every concept from this lecture.
The Complete PostService
File: src/main/java/com/example/blogapi/service/PostService.java
package com.example.blogapi.service;
import com.example.blogapi.dto.*;
import com.example.blogapi.exception.*;
import com.example.blogapi.mapper.CommentMapper;
import com.example.blogapi.mapper.PostMapper;
import com.example.blogapi.model.*;
import com.example.blogapi.repository.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
@Service
@Transactional(readOnly = true) // Default: all methods are read-only
public class PostService {
private static final Logger log = LoggerFactory.getLogger(PostService.class);
private final PostRepository postRepository;
private final UserRepository userRepository;
private final CategoryRepository categoryRepository;
private final TagRepository tagRepository;
private final CommentRepository commentRepository;
private final PostMapper postMapper;
private final CommentMapper commentMapper;
public PostService(PostRepository postRepository,
UserRepository userRepository,
CategoryRepository categoryRepository,
TagRepository tagRepository,
CommentRepository commentRepository,
PostMapper postMapper,
CommentMapper commentMapper) {
this.postRepository = postRepository;
this.userRepository = userRepository;
this.categoryRepository = categoryRepository;
this.tagRepository = tagRepository;
this.commentRepository = commentRepository;
this.postMapper = postMapper;
this.commentMapper = commentMapper;
}
// ==================== CREATE ====================
@Transactional // Override: write operation
public PostResponse createPost(CreatePostRequest request) {
log.info("Creating post: title='{}', authorId={}", request.getTitle(), request.getAuthorId());
User author = findActiveUser(request.getAuthorId());
String slug = generateUniqueSlug(request.getTitle());
Post post = new Post(request.getTitle(), slug, request.getContent());
post.setExcerpt(request.getExcerpt());
post.setAuthor(author);
if (request.getCategoryId() != null) {
Category category = findCategoryOrThrow(request.getCategoryId());
post.setCategory(category);
}
assignTags(post, request.getTagIds());
Post saved = postRepository.save(post);
log.info("Post created: id={}, slug='{}'", saved.getId(), saved.getSlug());
return postMapper.toResponse(saved);
}
// ==================== READ ====================
public PostResponse getPostById(Long id) {
log.debug("Fetching post: id={}", id);
Post post = postRepository.findByIdWithAllDetails(id)
.orElseThrow(() -> new ResourceNotFoundException("Post", "id", id));
long commentCount = commentRepository.countByPostId(id);
return postMapper.toResponse(post, commentCount);
}
public PostResponse getPostBySlug(String slug) {
log.debug("Fetching post: slug='{}'", slug);
Post post = postRepository.findBySlug(slug)
.orElseThrow(() -> new ResourceNotFoundException("Post", "slug", slug));
long commentCount = commentRepository.countByPostId(post.getId());
return postMapper.toResponse(post, commentCount);
}
public Page<PostResponse> getPosts(String status, Pageable pageable) {
log.debug("Listing posts: status={}, page={}, size={}",
status, pageable.getPageNumber(), pageable.getPageSize());
Page<Post> posts;
if (status != null && !status.isBlank()) {
posts = postRepository.findByStatus(status, pageable);
} else {
posts = postRepository.findAll(pageable);
}
return posts.map(post -> {
long commentCount = commentRepository.countByPostId(post.getId());
return postMapper.toResponse(post, commentCount);
});
}
public Page<PostResponse> searchPosts(String keyword, Pageable pageable) {
log.debug("Searching posts: keyword='{}'", keyword);
if (keyword == null || keyword.trim().isEmpty()) {
throw new BadRequestException("Search keyword cannot be empty");
}
Page<Post> posts = postRepository.searchByKeyword(keyword.trim(), pageable);
return posts.map(postMapper::toResponse);
}
// ==================== UPDATE ====================
@Transactional
public PostResponse updatePost(Long id, UpdatePostRequest request) {
log.info("Updating post: id={}", id);
Post post = findPostOrThrow(id);
if (request.getTitle() != null && !request.getTitle().isBlank()) {
post.setTitle(request.getTitle().trim());
post.setSlug(generateUniqueSlug(request.getTitle()));
}
if (request.getContent() != null) {
post.setContent(request.getContent().trim());
}
if (request.getExcerpt() != null) {
post.setExcerpt(request.getExcerpt().trim());
}
if (request.getCategoryId() != null) {
Category category = findCategoryOrThrow(request.getCategoryId());
post.setCategory(category);
}
if (request.getTagIds() != null) {
post.getTags().clear();
assignTags(post, request.getTagIds());
}
// Dirty checking: Hibernate auto-detects changes and generates UPDATE
log.info("Post updated: id={}", id);
return postMapper.toResponse(post);
}
@Transactional
public PostResponse publishPost(Long id) {
log.info("Publishing post: id={}", id);
Post post = findPostOrThrow(id);
if (!"DRAFT".equals(post.getStatus())) {
throw new BadRequestException(
"Only draft posts can be published. Current status: " + post.getStatus());
}
if (post.getCategory() == null) {
throw new BadRequestException("A category is required before publishing");
}
post.setStatus("PUBLISHED");
post.setPublishedAt(LocalDateTime.now());
log.info("Post published: id={}, title='{}'", id, post.getTitle());
return postMapper.toResponse(post);
}
// ==================== DELETE ====================
@Transactional
public void deletePost(Long id) {
log.info("Deleting post: id={}", id);
Post post = findPostOrThrow(id);
log.warn("Permanently deleting post: id={}, title='{}', author='{}'",
post.getId(), post.getTitle(), post.getAuthor().getUsername());
postRepository.delete(post);
log.info("Post deleted: id={}", id);
}
// ==================== COMMENTS ====================
@Transactional
public CommentResponse addComment(Long postId, CreateCommentRequest request) {
log.info("Adding comment to post: postId={}, authorId={}", postId, request.getAuthorId());
Post post = findPostOrThrow(postId);
User author = findActiveUser(request.getAuthorId());
Comment comment = new Comment(request.getContent(), post, author);
if (request.getParentId() != null) {
Comment parent = commentRepository.findById(request.getParentId())
.orElseThrow(() -> new ResourceNotFoundException("Comment", "id", request.getParentId()));
if (!parent.getPost().getId().equals(postId)) {
throw new BadRequestException("Parent comment does not belong to this post");
}
comment.setParent(parent);
}
Comment saved = commentRepository.save(comment);
log.info("Comment added: id={}, postId={}", saved.getId(), postId);
return commentMapper.toResponse(saved);
}
public Page<CommentResponse> getComments(Long postId, Pageable pageable) {
if (!postRepository.existsById(postId)) {
throw new ResourceNotFoundException("Post", "id", postId);
}
Page<Comment> comments = commentRepository.findTopLevelByPostId(postId, pageable);
return comments.map(commentMapper::toResponseWithReplies);
}
// ==================== PRIVATE HELPERS ====================
private Post findPostOrThrow(Long id) {
return postRepository.findByIdWithDetails(id)
.orElseThrow(() -> new ResourceNotFoundException("Post", "id", id));
}
private User findActiveUser(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new ResourceNotFoundException("User", "id", userId));
if (!user.isActive()) {
throw new BadRequestException("User is inactive: " + userId);
}
return user;
}
private Category findCategoryOrThrow(Long categoryId) {
return categoryRepository.findById(categoryId)
.orElseThrow(() -> new ResourceNotFoundException("Category", "id", categoryId));
}
private String generateUniqueSlug(String title) {
String slug = title.toLowerCase()
.replaceAll("[^a-z0-9\\s-]", "")
.replaceAll("\\s+", "-")
.replaceAll("-+", "-")
.replaceAll("^-|-$", "");
String uniqueSlug = slug;
int counter = 1;
while (postRepository.existsBySlug(uniqueSlug)) {
uniqueSlug = slug + "-" + counter++;
}
return uniqueSlug;
}
private void assignTags(Post post, List<Long> tagIds) {
if (tagIds == null || tagIds.isEmpty()) return;
List<Tag> tags = tagRepository.findAllById(tagIds);
if (tags.size() != tagIds.size()) {
log.warn("Some tag IDs not found. Requested: {}, found: {}", tagIds.size(), tags.size());
throw new BadRequestException("One or more tag IDs are invalid");
}
tags.forEach(post::addTag);
}
}
What Makes This Service Production-Quality
- Class-level
@Transactional(readOnly = true)— all reads are optimized by default. - Method-level
@Transactional— only write methods open write transactions. - Structured logging at every important point — creation, update, deletion, errors.
- Guard clauses validate inputs before doing work.
- Private helpers eliminate duplication (
findPostOrThrow,findActiveUser,generateUniqueSlug,assignTags). - DTO conversion happens in the service layer while the session is open.
- Custom exceptions provide clear error messages.
- No try-catch — exceptions propagate to
GlobalExceptionHandler.
Exercises
- Create a UserService: Following the same patterns, build a
UserServicewith create, getById, getAll (paginated), update, and deactivate (soft delete) operations. Use@Transactional(readOnly = true)at the class level. - Add a statistics endpoint: Create a
StatsServicethat aggregates data from multiple repositories — total users, total posts (by status), total comments, most active authors. All in read-only transactions. - Add logging to GlobalExceptionHandler: Log every exception at the appropriate level —
log.warnfor 4xx errors,log.errorfor 5xx errors. Include the request URI and exception message. - Configure environment-specific logging: In
application-dev.properties, setlogging.level.com.example.blogapi=DEBUGandorg.hibernate.SQL=DEBUG. Inapplication-prod.properties, set everything toWARNand enable file logging. - Test transaction rollback: In
createPost, add a temporary exception afterpostRepository.save()but beforeassignTags(). Verify that the post is NOT saved in the database (the transaction rolled back). Then remove the temporary exception.
Summary
This lecture elevated your service layer from basic delegation to production-quality business logic:
- Layered architecture rules: Controllers handle HTTP, services handle business logic, repositories handle data access. Dependencies flow downward. DTOs stop at the service layer.
- @Service design: Use concrete classes (no interfaces) for simplicity. Name services after business domains. Extract reusable logic into private helper methods.
- @Transactional: Wraps methods in database transactions. Rolls back on unchecked exceptions. Use on the service layer, never on controllers or repositories.
- Read-only transactions:
@Transactional(readOnly = true)optimizes reads by skipping dirty checking. Set at class level, override for write methods. - Propagation:
REQUIRED(default) handles 99% of cases. UseREQUIRES_NEWfor audit logging or operations that must commit independently. - Business logic patterns: Throw on not-found (most cases), return Optional (when absence is normal), guard clauses first, return DTOs (never entities), extract private helpers.
- Logging: Use SLF4J with parameterized messages. INFO for business events, WARN for unusual situations, ERROR for failures, DEBUG for development diagnostics. Always pass exceptions as the last parameter.
- Testing preview: Constructor injection enables clean Mockito-based unit tests. Arrange-Act-Assert pattern. No database or Spring context needed.
What is Next
In Lecture 10, we will tackle Database Migration with Flyway — replacing ddl-auto=update with proper version-controlled migrations. You will learn how to manage schema changes safely across environments, write migration scripts, handle team collaboration conflicts, and prepare your database for production deployment.
Quick Reference
| Concept | Description |
|---|---|
| Layered Architecture | Controller → Service → Repository, each with clear responsibility |
@Transactional |
Wraps method in a database transaction, rolls back on unchecked exceptions |
readOnly = true |
Optimizes read operations, skips dirty checking |
Propagation.REQUIRED |
Join existing transaction or create new (default) |
Propagation.REQUIRES_NEW |
Always create a new independent transaction |
Isolation.DEFAULT |
Use database default (REPEATABLE_READ in MariaDB) |
| Rollback behavior | Unchecked exceptions → rollback, checked exceptions → no rollback |
rollbackFor |
Specify additional exceptions that trigger rollback |
| Guard clauses | Validate at the top of the method, happy path at the bottom |
| SLF4J | Standard Java logging API |
| Logback | Default logging implementation in Spring Boot |
Logger |
LoggerFactory.getLogger(MyClass.class) |
| Log levels | TRACE < DEBUG < INFO < WARN < ERROR |
| Parameterized messages | log.info("id={}", id) — no string concatenation |
logging.level.* |
Configure log levels per package in application.properties |
| Mockito | Mocking framework for unit testing |
@Mock |
Creates a mock implementation of a dependency |
@InjectMocks |
Creates real object with mocks injected |
| Arrange-Act-Assert | Test pattern: setup → execute → verify |
