Service Layer Design & Business Logic

Table of Contents

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., StandardPostService and PremiumPostService)
  • 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_NEW only for audit logging or operations that must commit independently
  • Use Isolation.DEFAULT and let MariaDB handle it
  • Focus on proper @Transactional placement rather than propagation/isolation tuning

Business Logic Patterns — When to Throw, When to Return

// 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

  1. Class-level @Transactional(readOnly = true) — all reads are optimized by default.
  2. Method-level @Transactional — only write methods open write transactions.
  3. Structured logging at every important point — creation, update, deletion, errors.
  4. Guard clauses validate inputs before doing work.
  5. Private helpers eliminate duplication (findPostOrThrow, findActiveUser, generateUniqueSlug, assignTags).
  6. DTO conversion happens in the service layer while the session is open.
  7. Custom exceptions provide clear error messages.
  8. No try-catch — exceptions propagate to GlobalExceptionHandler.

Exercises

  1. Create a UserService: Following the same patterns, build a UserService with create, getById, getAll (paginated), update, and deactivate (soft delete) operations. Use @Transactional(readOnly = true) at the class level.
  2. Add a statistics endpoint: Create a StatsService that aggregates data from multiple repositories — total users, total posts (by status), total comments, most active authors. All in read-only transactions.
  3. Add logging to GlobalExceptionHandler: Log every exception at the appropriate level — log.warn for 4xx errors, log.error for 5xx errors. Include the request URI and exception message.
  4. Configure environment-specific logging: In application-dev.properties, set logging.level.com.example.blogapi=DEBUG and org.hibernate.SQL=DEBUG. In application-prod.properties, set everything to WARN and enable file logging.
  5. Test transaction rollback: In createPost, add a temporary exception after postRepository.save() but before assignTags(). 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. Use REQUIRES_NEW for 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

Leave a Reply

Your email address will not be published. Required fields are marked *