Week 2 | Lesson 10

Service Layer & Business Logic

Service Layer, Dependency Injection, Data Transformation, Exception Handling, Testing Layers, Service Testing, Mocking, Controller Testing



© 2026 by Monika Protivová

Service Layer

Service Layer

The Service Layer is a logical layer that sits between the controller and the data access layer. It encapsulates business logic and orchestrates various operations.

The Service Layer acts as a bridge between the presentation layer (controllers) and the data access layer (repositories). It's where the core business logic resides and where complex operations are coordinated.

Key characteristics of the Service Layer:

  1. Contains business logic and rules
  2. Orchestrates multiple data access operations
  3. Provides transaction boundaries
  4. Handles data transformation and validation
  5. Implements authorization and security checks

Service Layer Responsibilities

The Service Layer is responsible for several key functions that ensure the proper operation of the application.
  1. Business Logic Handling - Implements complex business rules and workflows
  2. Domain Model Mapping - Converts between DTOs and domain models
  3. Transaction Management - Ensures data consistency across multiple operations
  4. Authorization - Enforces access control and permissions
  5. Data Validation - Validates business rules beyond basic input validation
  6. Error Handling - Manages exceptions and provides meaningful error messages

Business Logic Handling

The Service Layer is responsible for implementing complex business rules and workflows that span multiple entities or operations.

Business logic includes:

  • Validation of business rules
  • Complex calculations and computations
  • Workflow orchestration
  • Integration with external services
  • State management and transitions

Example: An order processing service might:

  • Validate inventory availability
  • Calculate discounts and taxes
  • Process payment through external gateway
  • Update inventory levels
  • Send confirmation emails
  • Log audit information

Domain Model Mapping

The Service Layer handles the conversion between Data Transfer Objects (DTOs) and domain models, ensuring proper separation of concerns between layers.

Mapping responsibilities include:

  • DTO to Domain Model - Converting incoming requests to internal domain objects
  • Domain Model to DTO - Converting internal objects to response formats
  • Data Enrichment - Adding computed fields or related data
  • Data Filtering - Removing sensitive or unnecessary information

This mapping ensures that:

  • Internal domain models remain clean and focused
  • External interfaces can evolve independently
  • Security is maintained by controlling data exposure
  • Performance is optimized by transferring only necessary data

Transaction Management

The Service Layer defines transaction boundaries to ensure data consistency across multiple operations.

Transaction management ensures:

  • ACID Properties - Atomicity, Consistency, Isolation, and Durability
  • Data Consistency - All related operations succeed or fail together
  • Rollback Capability - Ability to undo changes when errors occur
  • Isolation Levels - Control over concurrent access to data

Common transaction patterns in services:

  • Single service method = Single transaction
  • Nested transactions for complex workflows
  • Distributed transactions for microservices
  • Compensating actions for saga patterns

Authorization in Service Layer

The Service Layer enforces access control and permissions, ensuring users can only perform actions they're authorized for.

Authorization responsibilities in the service layer:

  • Resource-level authorization - Check if user can access specific resources (e.g., only view their own tasks)
  • Operation-level authorization - Check if user can perform specific actions (e.g., only managers can delete tasks)
  • Data filtering - Return only data the user is authorized to see
  • Business-rule authorization - Complex permission logic beyond simple roles (e.g., task creator or assigned user can edit)

Why in the service layer?

  • Controllers handle authentication (who you are)
  • Services handle authorization (what you can do)
  • Business rules often require data from the database
  • Authorization logic can be complex and reusable across endpoints
@Service class TaskService( private val taskRepository: TaskRepository, private val authenticationContext: AuthenticationContext ) { fun updateTask(taskId: Long, request: UpdateTaskRequest): Task { val task = taskRepository.findById(taskId) ?: throw TaskNotFoundException(taskId) val currentUser = authenticationContext.getCurrentUser() // Authorization check in service layer if (!canUserModifyTask(currentUser, task)) { throw UnauthorizedException("You are not authorized to modify this task") } // Update task... return task.toDomain() } private fun canUserModifyTask(user: User, task: Task): Boolean { return user.isAdmin() || user.id == task.createdBy || user.id == task.assignedTo } }

Data Validation in Service Layer

The Service Layer validates business rules that go beyond basic input validation performed at the controller level.

Service layer validation includes:

  • Business rule validation - Complex rules that involve multiple fields or entities
  • Cross-entity validation - Rules that require checking related data
  • State validation - Ensuring operations are valid for the current state
  • Uniqueness constraints - Checking database for existing records

Controller vs Service validation:

  • Controller validation: Format, required fields, basic constraints (using @Valid)
  • Service validation: Business rules, database constraints, state transitions
@Service class TaskService( private val taskRepository: TaskRepository, private val userRepository: UserRepository ) { fun assignTask(taskId: Long, userId: Long): Task { val task = taskRepository.findById(taskId) ?: throw TaskNotFoundException(taskId) // Business rule validation if (task.status == TaskStatus.COMPLETED) throw ValidationException("Cannot reassign a completed task") if (task.status == TaskStatus.ARCHIVED) throw ValidationException("Cannot reassign an archived task") // Cross-entity validation val user = userRepository.findById(userId) ?: throw UserNotFoundException(userId) if (!user.isActive) throw ValidationException("Cannot assign task to inactive user") if (user.currentTaskCount >= user.maxTaskLimit) throw ValidationException("User has reached maximum task limit") task.assignedTo = userId return taskRepository.save(task).toDomain() } }

Error Handling in Service Layer

The Service Layer manages exceptions and translates technical errors into meaningful business errors.

Service layer error handling responsibilities:

  • Exception translation - Convert technical errors (SQL, network) to business exceptions
  • Meaningful error messages - Provide context-specific error messages for the user
  • Resource cleanup - Ensure proper cleanup of resources on errors
  • Logging and monitoring - Log errors with appropriate context for debugging

Common exception types:

  • NotFoundException - Resource not found (404)
  • ValidationException - Business rule violation (400)
  • UnauthorizedException - Permission denied (403)
  • ConflictException - Resource state conflict (409)
@Service class TaskService( private val taskRepository: TaskRepository, private val logger: Logger ) { fun deleteTask(taskId: Long) { try { val task = taskRepository.findById(taskId) ?: throw TaskNotFoundException("Task with ID $taskId not found") if (task.status == TaskStatus.IN_PROGRESS) { throw ConflictException( "Cannot delete task that is currently in progress. " + "Please mark it as completed or cancelled first." ) } taskRepository.deleteById(taskId) logger.info("Task $taskId deleted successfully") } catch (ex: DataAccessException) { logger.error("Database error while deleting task $taskId", ex) throw ServiceException("Failed to delete task due to a database error") } } } class TaskNotFoundException(message: String) : RuntimeException(message) class ConflictException(message: String) : RuntimeException(message) class ServiceException(message: String) : RuntimeException(message)

Dependency Injection in Spring Boot

Limitations of Manual Dependency Injection

While dependency injection using constructor is a good start, it is not very practical for larger applications.

Problems with manual dependency injection:

  • Boilerplate Code - Lots of manual wiring in the main function
  • Dependency Order - Must create dependencies in the correct order
  • Complexity Growth - Becomes unmanageable as the application grows
  • Testing Difficulty - Hard to swap implementations for testing
  • Singleton Management - Manual management of object lifecycles
  • No Lifecycle Hooks - Can't easily initialize/cleanup resources

For larger applications, we need a dependency injection framework. Spring Boot provides a powerful built-in IoC container for this purpose.

Spring IoC Container

Spring Boot's IoC container automatically manages object creation and dependency injection.

Spring Boot provides an Inversion of Control (IoC) container that:

  • Automatically creates and manages objects (called "beans")
  • Automatically injects dependencies where needed
  • Manages bean lifecycles (creation, initialization, destruction)
  • Provides different scopes (singleton, prototype, request, session)
  • Enables easy testing with mock dependencies

Spring discovers beans through component scanning - it scans packages for classes annotated with stereotype annotations.

The @SpringBootApplication annotation on your main class enables:

@SpringBootApplication class FantasySpaceApplication fun main(args: Array<String>) { runApplication<FantasySpaceApplication>(*args) }
  • @Configuration - marks this as a configuration class
  • @EnableAutoConfiguration - enables Spring Boot's auto-configuration
  • @ComponentScan - enables component scanning in this package and subpackages

Constructor Injection in Spring Boot

Spring Boot automatically injects dependencies through constructors - the recommended approach.

Spring Boot supports multiple injection methods:

  • Constructor Injection (recommended) - Dependencies injected via constructor
  • Field Injection - Dependencies injected directly into fields with @Autowired
  • Setter Injection - Dependencies injected via setter methods

Constructor injection is preferred because:

  • Makes dependencies explicit and required
  • Enables immutability (val instead of var)
  • Easier to test (can pass mocks without Spring)
  • Prevents circular dependencies at compile time

Spring Stereotype Annotations

Spring uses stereotype annotations to identify and register beans in the IoC container.

These annotations enable component scanning - Spring automatically discovers and registers these classes as beans.

Main stereotype annotations:

  • @Component - Generic stereotype for any Spring-managed component
  • @Service - Indicates that a class provides business logic (service layer)
  • @Repository - Indicates that a class provides data access (data layer)
  • @Controller or @RestController - Indicates that a class handles HTTP requests (presentation layer)
  • @Configuration - Indicates that a class provides bean definitions
@RestController @RequestMapping("/api/tasks") class TaskController( private val taskService: TaskService, ) { @GetMapping fun getTasks(): List<TaskResponse> { return taskService.getTasks().map { it.toResponse() } } }
@Service class TaskService( private val taskRepository: TaskRepository ) { fun getTasks(): List<Task> { return taskRepository.findAll().map { it.toDomain() } } }
@Repository interface TaskRepository : JpaRepository<TaskEntity, Long> { // Additional query methods can be defined here if needed }

Data Transformation

Data Transformation Between Layers

In a well-architected Spring Boot application, each layer uses its own data representation. The service layer is responsible for transforming data between these representations.

Different layers have different concerns:

  • Database Layer (Entity) - Optimized for persistence (JPA annotations, nullable fields, auto-generated IDs)
  • Business Logic Layer (Domain) - Clean, type-safe models focused on business rules
  • API Layer (DTO) - Optimized for data transfer (serialization, versioning, documentation)

Why separate models?

  • Each layer can evolve independently
  • Security - control what data is exposed
  • Flexibility - API changes don't affect database schema
  • Clean code - each model serves a single purpose
  • Testability - business logic works with clean domain models

The Three Layer Models

Each layer has its own data representation with different responsibilities and characteristics.

1. DTOs (API Layer)

  • Request DTOs for incoming data
  • Response DTOs for outgoing data
  • Optimized for serialization
  • Can include computed fields
// Request DTO data class NewTaskRequest( val description: String ) // Response DTO data class TaskResponse( val id: Int, val description: String, val status: TaskStatus )

2. Domain Model (Business Logic)

  • Clean, immutable data classes
  • Non-null types where appropriate
  • Focused on business concepts
  • No framework dependencies
data class Task( val id: Int, val description: String, val status: TaskStatus )

3. Entity (Database)

  • JPA annotations (@Entity, @Id)
  • Nullable fields for DB compatibility
  • Auto-generated IDs
  • Mutable (var) for JPA
@Entity @Table(name = "tasks") data class TaskEntity( @Id @GeneratedValue(strategy = GenerationType.IDENTITY) val id: Long? = null, @Column(length = 1000) var description: String? = null, @Column(nullable = false) var status: TaskStatus = TaskStatus.NEW )

Mapping with Extension Functions

Use extension functions to convert between layer models. This keeps transformation logic clean and reusable.

Extension functions provide a clean, reusable way to transform data between layers:

  • Entity.toDomain() - Database to business logic
  • Domain.toEntity() - Business logic to database
  • Domain.toResponse() - Business logic to API

Benefits:

  • Centralized transformation logic
  • Easy to test
  • Readable and discoverable
  • Can chain transformations

TaskMappers.kt

// Entity -> Domain fun TaskEntity.toDomain(): Task { return Task( id = this.id?.toInt() ?: 0, description = this.description ?: "", status = this.status ) } // Domain -> Entity fun Task.toEntity(): TaskEntity { return TaskEntity( id = if (this.id == 0) null else this.id.toLong(), description = this.description, status = this.status ) } // Domain -> Response DTO fun Task.toResponse(): TaskResponse { return TaskResponse( id = this.id, description = this.description, status = this.status ) }

Layered Architecture (Common Approach)

The approach we've shown (extension functions with shared models) is common in Spring Boot applications.

The extension function approach is common in Spring Boot applications and works well for most projects. However, it creates some coupling between layers.In this architecture, the layers know a little bit about modules it depends on. This is OK, as long as it is not the other way around: the lower layers should not know about the upper layers.

Controller works with DTOs Service works with Domain Repository works with Entities knows about Domain models knows about Entity models

Characteristics:

  • Coupling between layers - Each layer imports and uses models from other layers
  • Simple and pragmatic - Easy to understand and implement
  • Extension functions for mapping - Clean syntax for transformations
  • Changes ripple - Modifying a model may require changes in multiple layers

Pros: Simple, pragmatic, less boilerplate, suitable for most applications. Cons: Layers are coupled - changes ripple across layers, harder to test in isolation.

Best for: Most Spring Boot applications, especially when rapid development is needed and the domain is relatively stable.

Hexagonal Architecture (True Separation)

For true separation, hexagonal architecture (Ports & Adapters) keeps the domain completely independent.

In Hexagonal Architecture (also called Ports & Adapters), each layer knows nothing about the others. The domain is at the center, and adapters depend on the domain - never the reverse.

Domain Core Domain Model Input Port (Interface) Output Port (Interface) REST Controller (Adapter) JPA Repository (Adapter) depends on implements Adapters

Key characteristics:

  • Domain is independent - No knowledge of REST, DB, or any external concerns
  • Ports define contracts - Domain defines interfaces (ports) that adapters must implement
  • Adapters handle transformation - REST and DB adapters convert their models to/from domain models
  • Dependency inversion - Adapters depend on the domain, not the other way around

Pros: True independence, easy to swap implementations, excellent testability, clear boundaries. Cons: More boilerplate, steeper learning curve, can be overkill for simple applications.

Best for: Complex domains, applications with multiple adapters (REST + GraphQL + CLI), microservices with strict boundaries.

For this course: We use the common layered approach. It's pragmatic and suitable for most Spring Boot applications. Consider hexagonal architecture as your applications grow in complexity.

Exception Handling in Spring Boot

Application Errors - General Concepts

Applications can encounter various types of errors during their execution. These errors can be due to user input, system state, or business logic violations.

So far, we have not paid great attention to the errors that can occur in our applications.

We know how to respond with different HTTP status codes based on expected service returned values, but we didn't deal with error states that can occur in our applications.

Under normal operation, most application calls will not result in an exception. But there are valid reasons why an exception might be thrown:

  • Input validation
    For example, we may want to validate user inputs, such as valid JSON request body, or valid query parameter values. Example: non-empty task description, valid status values, valid priority levels, etc.
  • State validation
    For example, we may want to validate the state of the application, such as whether a user is authenticated, or whether a task exists before updating it.
  • Business logic validation
    For example, we may want to validate the business logic, such as whether a task can be assigned to a user, or whether a user has permission to delete another user's task.

Defining Custom Exceptions

It is often a good idea to define custom exceptions for specific error conditions in your application.

Defining custom exceptions allows you to provide more meaningful error messages and handle specific error conditions in a more granular way. It also allows you to treat the same class of errors consistently across your application.

Here are a few examples of custom exceptions for a task management application:

  • TaskNotFoundException
    Thrown when a requested task is not found.
  • InvalidTaskDataException
    Thrown when the input provided for creating/updating a task is invalid.
  • TaskAlreadyExistsException
    Thrown when trying to create a task that would result in a duplication.
  • UnauthorizedAccessException
    Thrown when a user is not authorized to perform an action.
  • InvalidCredentialsException
    Thrown when a user fails to authenticate.
// Base exception for all application exceptions open class ApplicationException( val applicationMessage: String, val traceId: UUID = UUID.randomUUID(), val timestamp: LocalDateTime = LocalDateTime.now() ) : RuntimeException("[$traceId] $applicationMessage") // Specific exception types class TaskNotFoundException(taskId: Long) : ApplicationException("Task with id $taskId not found") class InvalidTaskDataException(message: String) : ApplicationException(message) class TaskAlreadyExistsException(taskName: String) : ApplicationException("Task with name $taskName already exists") class TaskStateException(message: String) : ApplicationException(message) class UnauthorizedAccessException(message: String) : ApplicationException(message)

@ControllerAdvice

@ControllerAdvice allows you to handle exceptions globally across all controllers.

Handling exceptions individually in each controller method is possible but impractical. Spring Boot provides @ControllerAdvice to handle exceptions globally.

Benefits of using @ControllerAdvice:

  • Centralized exception handling logic
  • Consistent error responses across all endpoints
  • Clean controller code without try-catch blocks
  • Easy to maintain and update error handling
  • Can log all exceptions in one place

A @ControllerAdvice class acts as a global exception handler that intercepts exceptions thrown by any controller in your application.

import org.springframework.web.bind.annotation.ControllerAdvice import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity @ControllerAdvice class GlobalExceptionHandler { // Handle specific exception types with @ExceptionHandler }

@ExceptionHandler

@ExceptionHandler methods define how specific exception types should be handled.

Inside your @ControllerAdvice class, you use @ExceptionHandler to define methods that handle specific exception types.

Each handler method:

  • Is annotated with @ExceptionHandler(ExceptionType::class)
  • Takes the exception as a parameter
  • Returns a ResponseEntity<T> with appropriate status and body
  • Can access request information if needed
@ExceptionHandler(TaskNotFoundException::class) fun handleTaskNotFound(ex: TaskNotFoundException): ResponseEntity<ErrorResponse> { val errorResponse = ErrorResponse(traceId = ex.traceId, message = ex.applicationMessage, timestamp = ex.timestamp) return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse) } @ExceptionHandler(InvalidTaskDataException::class) fun handleInvalidTaskData(ex: InvalidTaskDataException): ResponseEntity<ErrorResponse> { val errorResponse = ErrorResponse(traceId = ex.traceId, message = ex.applicationMessage, timestamp = ex.timestamp) return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse) } @ExceptionHandler(UnauthorizedAccessException::class) fun handleUnauthorizedAccess(ex: UnauthorizedAccessException): ResponseEntity<ErrorResponse> { val errorResponse = ErrorResponse(traceId = ex.traceId, message = ex.applicationMessage, timestamp = ex.timestamp) return ResponseEntity.status(HttpStatus.FORBIDDEN).body(errorResponse) } // Catch-all handler for unexpected exceptions @ExceptionHandler(Exception::class) fun handleGenericException(ex: Exception): ResponseEntity<ErrorResponse> { val errorResponse = ErrorResponse(traceId = ex.traceId, message = ex.applicationMessage, timestamp = ex.timestamp) return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse) }

Consistent Error Response Format

Define a standard error response format for all API errors.

It's a good practice to provide error responses in a standard format with meaningful error messages and traceId to help debug issues.

Define a common ErrorResponse data class:

data class ErrorResponse( val traceId: UUID, val message: String, val timestamp: LocalDateTime )

Extension function to convert exceptions to error responses:

fun ApplicationException.toErrorResponse() = ErrorResponse( traceId = this.traceId, message = this.applicationMessage, timestamp = this.timestamp )

This provides consistent error handling across your entire API with proper HTTP status codes and traceable error messages.

Consistent Error Response Format

Complete @ControllerAdvice with consistent error responses:

@RestControllerAdvice class GlobalExceptionHandler { private val logger = LoggerFactory.getLogger(this::class.java) @ExceptionHandler(ApplicationException::class) fun handleApplicationException(ex: ApplicationException): ResponseEntity<ErrorResponse> { logger.error("Application exception: ${ex.message}", ex) val status = when (ex) { is TaskNotFoundException -> HttpStatus.NOT_FOUND is InvalidTaskDataException -> HttpStatus.BAD_REQUEST is UnauthorizedAccessException -> HttpStatus.FORBIDDEN is TaskAlreadyExistsException -> HttpStatus.CONFLICT is TaskStateException -> HttpStatus.CONFLICT else -> HttpStatus.INTERNAL_SERVER_ERROR } return ResponseEntity.status(status).body(ex.toErrorResponse()) } @ExceptionHandler(Exception::class) fun handleGenericException(ex: Exception): ResponseEntity<ErrorResponse> { logger.error("Unexpected exception: ${ex.message}", ex) val errorResponse = ErrorResponse( traceId = UUID.randomUUID(), message = "An unexpected error occurred", timestamp = LocalDateTime.now() ) return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse) } }

Testing Application Layers

Testing Application Layers - General Idea

  • Testing units in isolation
  • Testing layers in isolation
  • Testing integrations

This approach allows us in detail test each part of the application, verifying it's core functionality, without the overhead of testing the entire application at once.

Interaction between layers and components can be tested using mocks and stubs.

Integration tests can be used to verify real interaction between different layers and components of the application, but because we have tested each part in isolation, we don't need to cover all functionality so deeply.

The aim is to achieve maximum coverage with minimum resources (and time) but still give us a high level of confidence that the application is working correctly.

Testing Units in Isolation

Unit tests verify individual units of code (functions, methods, classes) in complete isolation.

What is tested:

  • Individual functions and methods
  • Business logic calculations
  • Data transformations
  • Validation rules
  • Pure domain logic without dependencies

How it is tested:

  • Test the unit directly with known inputs
  • No mocking required - pure functions with no dependencies
  • Verify expected outputs for various inputs
  • Test edge cases and boundary conditions
  • Fast execution - milliseconds per test

Why we test this way:

  • Fastest tests to write and execute
  • Easiest to understand and maintain
  • Precise error localization - failures point exactly to the problem
  • No external dependencies means no flakiness
  • Forms the base of the testing pyramid - should have the most tests here

Example: Testing an Order price calculation that applies customer discounts to order items. Test with different item counts, prices, and discount percentages.

Testing Routing Layer (Controller)

Controller tests verify HTTP routing, request handling, and response formatting without testing business logic.

What is tested:

  • Route definitions (correct URLs and HTTP methods)
  • Request parameter extraction (path variables, query params, request body)
  • Request validation (@Valid annotations)
  • Response status codes (200, 201, 404, 400, etc.)
  • Response serialization (DTOs to JSON)
  • Authentication and authorization checks

How it is tested:

  • Mock the service layer dependencies
  • Make HTTP requests to controller endpoints
  • Verify service methods are called with correct parameters
  • Assert response status codes and body content
  • Test both successful and error scenarios

Why we test this way:

  • Isolates routing concerns from business logic
  • Fast execution - no database or complex business logic
  • Verifies the API contract (endpoints, request/response formats)
  • Catches routing mistakes early (wrong paths, methods, parameter names)
  • Ensures proper error handling and status codes

Example: Testing GET /api/tasks/{id} extracts the ID correctly, calls taskService.getTaskById(id), and returns 200 with task JSON or 404 if not found.

Testing Service Layer

Service tests verify business logic, data transformations, and orchestration without database dependencies.

What is tested:

  • Business logic and rules
  • Data validation (business rules, not just format)
  • Authorization logic (who can do what)
  • Data transformation (Entity ↔ Domain ↔ DTO)
  • Orchestration of multiple repository calls
  • Exception handling and error messages

How it is tested:

  • Mock repository dependencies with MockK
  • Define expected repository behavior with every { ... } returns ...
  • Call service methods with various inputs
  • Assert returned values and business logic outcomes
  • Verify repository methods called with correct parameters
  • Test both happy path and error scenarios

Why we test this way:

  • Isolates business logic from database operations
  • Fast execution - no database queries
  • Full control over data scenarios (including edge cases)
  • Tests the core value of the application (business rules)
  • Easy to test error conditions (not found, conflicts, etc.)

Example: Testing taskService.assignTask(taskId, userId) verifies: task exists, user exists, user not over task limit, task not completed/archived, calls repository to update assignment.

Testing Repository Layer

Repository tests verify database operations using a real database (in-memory or containerized).

What is tested:

  • CRUD operations (Create, Read, Update, Delete)
  • Query methods (find by ID, find by criteria, search)
  • Database constraints (unique, not null, foreign keys)
  • Transactions and rollback behavior
  • Data mapping (Entity to/from database tables)
  • Complex queries and joins

How it is tested:

  • Never mock the database - use a real database instance
  • Use in-memory database (H2) or containerized database (Testcontainers with PostgreSQL)
  • Set up schema before each test beforeEach
  • Seed test data as needed
  • Execute repository operations
  • Query database to verify changes
  • Clean up after each test afterEach

Why we test this way:

  • Verifies SQL queries are correct
  • Catches database-specific issues (constraints, types, indexes)
  • Tests real database behavior, not mocked behavior
  • Isolated from business logic - focuses only on data access
  • In-memory databases keep tests reasonably fast

Example: Testing taskRepository.findByStatus(TaskStatus.TODO) creates tasks with various statuses, calls the method, verifies only TODO tasks returned.

Integration Testing

Integration tests verify that all layers of the application work together correctly in a production-like environment.

What is tested:

  • End-to-end functionality through the API
  • Real dependency injection (Spring application context)
  • Complete request flow: Controller → Service → Repository → Database
  • Data persistence and retrieval
  • Transaction management
  • Authentication and authorization
  • Exception handling across all layers

How it is tested:

  • Start the entire application in a test environment
  • Use a real database (containerized with Testcontainers)
  • Make HTTP requests as a client would (using test HTTP client)
  • Seed database with test data before each test
  • Verify responses and database state
  • Clean up database after each test

Why we test this way:

  • Catches integration issues between layers
  • Verifies dependency injection configuration
  • Tests the application as users will use it
  • Finds issues that unit tests cannot (configuration, wiring)
  • Provides confidence in deployment

Trade-offs:

  • Slower - seconds per test instead of milliseconds
  • Complex setup - database, test data, application startup
  • Harder to debug - failures can be in any layer
  • Fewer tests needed - complement unit tests, don't replace them

Example: Test POST /api/tasks with task data, verify 201 Created response, then GET the task by ID to confirm it was saved to database correctly.

Mocking

Mocking

Mocking is a technique used in unit testing to create a fake implementation of a class or interface, which can be used to simulate the behavior of the real implementation.

This allows us to isolate the unit being tested from its dependencies, so we can focus on testing the unit's behavior without worrying about the behavior of its dependencies.

In Kotlin, we can use libraries like MockK or Mockito to create mocks and stubs for our tests.

Key benefits of mocking:

  • Isolation - Test units in complete isolation from dependencies
  • Control - Full control over dependency behavior
  • Speed - Eliminate slow operations (database, network calls)
  • Reliability - Remove external factors that could cause test failures

Using MockK

MockK is a powerful mocking library for Kotlin that provides a simple and intuitive API for creating mocks and verifying their behavior.

MockK is a powerful mocking library for Kotlin that allows you to create mocks, stubs, and spies for your tests. It provides a simple and intuitive API for creating mocks and verifying their behavior.

We can mock any class or interface, and define the behavior of the mocked object.

  • The mocked object is defined using mockk() function.
  • We can define the behavior of the mocked object using every { ... } returns ... syntax.
  • We can verify that the mocked object was called with the expected arguments using verify { ... } syntax.
// create the mocked object val myService: MyService = mockk() // or val myService = mockk<MyService>() // define the behavior of the mocked object every { myService.doSomething(any()) } returns "Mocked Result" // use the mocked object in your test val result = myService.doSomething("Test Input") // verify that the mocked object was called with the expected arguments verify(exactly = 1) { myService.doSomething("Test Input") }
  • We can also spy on an existing object, which allows us to verify its behavior without modifying the original object.
val myRealObject = spyk(MyRealObject()) verify { myRealObject.someMethod("Was called with an argument") }

Mocking Best Practices

Guidelines for effective mocking in your tests.

Best practices for mocking:

  • Mock External Dependencies Only - Don't mock the class under test
  • Use Interfaces - Mock interfaces rather than concrete classes when possible
  • Verify Interactions - Use verify() to ensure expected method calls
  • Clear Mock State - Reset mocks between tests using clearAllMocks()
  • Meaningful Assertions - Verify both return values and method invocations

What NOT to mock:

  • Simple data classes or value objects
  • The class being tested
  • Standard library classes
  • Complex objects that are easy to create

When to use spies vs mocks:

  • Mocks - When you want complete control over behavior
  • Spies - When you want to partially mock an existing object

Testing Services

Testing Services

Service layer tests verify business logic without database dependencies by mocking the repository layer.

Testing the service layer focuses on verifying business logic while isolating it from database concerns.

What we test:

  • Business logic and rules
  • Data transformations (DTO → Domain → Entity)
  • Authorization checks
  • Validation logic
  • Exception handling

Two testing approaches:

  • Integration Testing - Uses Spring Boot context with real database (slower but tests DI)
  • Unit Testing - Manually injects mocked dependencies (faster, more isolated)

Recommendation: Use unit tests with mocks for most service tests (fast feedback), and complement with a few integration tests to verify dependency injection and database interaction.

Integration Testing with Spring Boot

Integration tests use Spring Boot's dependency injection and a real database to verify service behavior.

Use @SpringBootTest to load the full Spring application context with all beans and dependencies.

Key annotations:

  • @SpringBootTest - Loads full Spring context
  • @Transactional - Auto-rollback after each test
  • @Autowired - Inject real beans

What we test:

  • Business logic with real database
  • Dependency injection wiring
  • Data persistence verification
  • Full integration flow

Pros: Tests DI, uses real database, tests full integration.

Cons: Slower startup, more resource intensive, harder to isolate failures.

import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import org.springframework.boot.test.context.SpringBootTest import org.springframework.beans.factory.annotation.Autowired import org.springframework.transaction.annotation.Transactional @SpringBootTest @Transactional class TaskServiceIT : FunSpec() { @Autowired private lateinit var taskRepository: TaskRepository @Autowired private lateinit var taskService: TaskService init { test("createTask should save and return task") { // Arrange val request = NewTaskRequest( description = "Write integration tests" ) val userId = 1L // Act val result = taskService.createTask(request, userId) // Assert result.description shouldBe "Write integration tests" result.status shouldBe TaskStatus.TODO // Verify saved in database val saved = taskRepository.findById(result.id.toLong()) saved?.description shouldBe "Write integration tests" } test("assignTask should validate business rules") { // Arrange: Create a task val task = taskRepository.save(TaskEntity( description = "Task to assign", status = TaskStatus.TODO, createdBy = 1L )) // Act & Assert: Verify assignment logic val result = taskService.assignTask(task.id!!, userId = 2L) result.assignedTo shouldBe 2L } } }

Unit Testing with Mocks

Unit tests manually inject mocked dependencies for fast, isolated testing of business logic.

Manually create the service with mocked repository dependencies using MockK.

Key setup:

  • Create mocks with mockk()
  • Define behavior with every { ... } returns ...
  • Verify calls with verify { ... }
  • No Spring context needed

What we test:

  • Business logic in isolation
  • Error handling (exceptions)
  • Authorization checks
  • Repository interaction

Pros: Fast execution, fully isolated, easy to test error conditions.

Cons: Doesn't test dependency injection wiring or database integration.

import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.mockk.every import io.mockk.mockk import io.mockk.verify class TaskServiceTest : FunSpec() { private val taskRepository: TaskRepository = mockk() private val taskService: TaskService = TaskService( taskRepository = taskRepository ) init { test("createTask should save and return task") { // Arrange val request = NewTaskRequest(description = "Write unit tests") val userId = 1L val taskEntity = TaskEntity( id = 1L, description = "Write unit tests", status = TaskStatus.TODO, createdBy = userId ) every { taskRepository.save(any()) } returns taskEntity // Act val result = taskService.createTask(request, userId) // Assert result.description shouldBe "Write unit tests" result.status shouldBe TaskStatus.TODO verify(exactly = 1) { taskRepository.save(any()) } } test("deleteTask should throw exception when task not found") { // Arrange every { taskRepository.findById(999L) } returns null // Act & Assert shouldThrow<TaskNotFoundException> { taskService.deleteTask(999L, userId = 1L) } } test("deleteTask should throw exception for unauthorized user") { // Arrange val task = TaskEntity(id = 1L, description = "Test", status = TaskStatus.TODO, createdBy = 1L) every { taskRepository.findById(1L) } returns task // Act & Assert shouldThrow<UnauthorizedException> { taskService.deleteTask(1L, userId = 999L) } } } }

Testing Controllers

Testing Controllers

Controller tests verify HTTP routing, request handling, and response formatting without testing business logic.

What we test in controllers:

  • Route definitions (correct URLs and HTTP methods)
  • Request parameter extraction (path variables, query params, body)
  • Request validation (@Valid annotations)
  • Response status codes (200, 201, 404, 400, etc.)
  • Response body serialization (objects to JSON)
  • Service layer is called with correct parameters

Why we test controllers separately:

  • Isolates routing concerns from business logic
  • Fast execution - no database or complex business logic
  • Verifies the API contract (endpoints, request/response formats)
  • Catches routing mistakes early (wrong paths, methods, parameter names)

Key tools:

  • @WebMvcTest - Loads only the web layer (controllers)
  • MockMvc - Simulates HTTP requests without starting a server
  • MockK - Mocking library for Kotlin

@WebMvcTest with MockK Configuration

@WebMvcTest loads only the web layer. Use @TestConfiguration to provide MockK beans instead of Mockito's @MockBean.

The @WebMvcTest annotation tells Spring Boot to:

  • Load only the controller layer (not services or repositories)
  • Configure MockMvc for testing
  • Auto-configure Spring MVC infrastructure
  • Not load the full application context (fast startup)

MockK setup with @TestConfiguration:

  • Create nested @TestConfiguration class
  • Define @Bean methods that return mockk()
  • Import config with @Import(TestConfig::class)
  • Use beforeEach to clear mocks between tests
@WebMvcTest(TaskController::class) @Import(TaskControllerTest.TaskControllerTestConfig::class) class TaskControllerTest : FunSpec() { @Autowired private lateinit var mockMvc: MockMvc @Autowired private lateinit var taskService: TaskService @TestConfiguration class TaskControllerTestConfig { @Bean fun taskService(): TaskService = mockk(relaxed = false) } init { beforeEach { clearMocks(taskService) } test("controller is loaded") { // MockMvc and controller are auto-configured } } }

MockMvc Basics

Common HTTP methods:

  • get("/api/tasks") - GET request
  • post("/api/tasks") - POST request
  • put("/api/tasks/{id}", id) - PUT request
  • delete("/api/tasks/{id}", id) - DELETE request

Common assertions:

  • status().isOk - 200
  • status().isCreated - 201
  • status().isNotFound - 404
  • status().isBadRequest - 400
  • jsonPath("$.field").value(expected) - Assert JSON field

Basic MockMvc request structure:

import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* mockMvc.perform( get("/api/tasks/{id}", taskId) // HTTP method and URL .contentType(MediaType.APPLICATION_JSON) // Request content type .accept(MediaType.APPLICATION_JSON) // Expected response type ) .andExpect(status().isOk) // Assert status code .andExpect(content().contentType(MediaType.APPLICATION_JSON)) // Assert content type .andExpect(jsonPath("$.id").value(taskId)) // Assert JSON fields

Testing GET Endpoint

Test structure:

  • Arrange: Mock service to return test data
  • Act: Perform GET request with MockMvc
  • Assert: Verify response status and body
  • Verify: Confirm service method was called

What we verify:

  • HTTP status code (200 OK)
  • Content type (application/json)
  • JSON response fields match expected values
  • Service method called exactly once with correct ID
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* import io.mockk.every import io.mockk.verify @WebMvcTest(TaskController::class) @Import(TaskControllerTest.TaskControllerTestConfig::class) class TaskControllerTest : FunSpec() { @Autowired private lateinit var mockMvc: MockMvc @Autowired private lateinit var taskService: TaskService init { beforeEach { clearMocks(taskService) } test("should return task when it exists") { // Arrange: Mock service to return a task val task = Task(id = 1, description = "Write tests", status = TaskStatus.NEW) every { taskService.getTask(1L) } returns task // Act & Assert: Perform request and verify response mockMvc.perform(get("/api/tasks/1")) .andExpect(status().isOk) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$.id").value(1)) .andExpect(jsonPath("$.description").value("Write tests")) .andExpect(jsonPath("$.status").value("NEW")) // Verify service was called verify(exactly = 1) { taskService.getTask(1L) } } } }

Testing POST Endpoint

Key differences from GET:

  • Send request body as JSON
  • Use ObjectMapper to serialize DTO
  • Set content type to application/json
  • Expect 201 Created status

What we verify:

  • 201 Created status code
  • Response contains created resource with ID
  • Service called with correct request DTO
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* import com.fasterxml.jackson.databind.ObjectMapper import io.mockk.every import io.mockk.verify @WebMvcTest(TaskController::class) @Import(TaskControllerTest.TaskControllerTestConfig::class) class TaskControllerTest : FunSpec() { @Autowired private lateinit var mockMvc: MockMvc @Autowired private lateinit var objectMapper: ObjectMapper @Autowired private lateinit var taskService: TaskService init { beforeEach { clearMocks(taskService) } test("should create task and return 201 with task response") { // Arrange: Prepare request and mock response val request = NewTaskRequest(description = "New Task") val createdTask = Task(id = 1, description = "New Task", status = TaskStatus.NEW) every { taskService.addTask("New Task") } returns createdTask // Act & Assert mockMvc.perform( post("/api/tasks") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)) ) .andExpect(status().isCreated) .andExpect(jsonPath("$.id").value(1)) .andExpect(jsonPath("$.description").value("New Task")) .andExpect(jsonPath("$.status").value("NEW")) // Verify service was called verify(exactly = 1) { taskService.addTask("New Task") } } } }

Testing Error Responses

Testing strategy:

  • Mock service to throw exceptions
  • Verify correct HTTP status codes
  • Check error response structure
  • Confirm error messages are clear

Common error scenarios:

  • 404 Not Found - resource doesn't exist
  • 400 Bad Request - validation failure
  • 403 Forbidden - unauthorized access
  • 409 Conflict - state violation

What this verifies:

  • @ControllerAdvice handles exceptions
  • Error responses match API contract
  • Proper HTTP status codes returned
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.* import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* import io.mockk.every @WebMvcTest(TaskController::class) @Import(TaskControllerTest.TaskControllerTestConfig::class) class TaskControllerTest : FunSpec() { @Autowired private lateinit var mockMvc: MockMvc @Autowired private lateinit var objectMapper: ObjectMapper @Autowired private lateinit var taskService: TaskService init { beforeEach { clearMocks(taskService) } test("should return 404 when task does not exist") { // Arrange: Mock service to throw exception every { taskService.getTask(1L) } throws TaskNotFoundException(1L) // Act & Assert: Verify 404 response mockMvc.perform(get("/api/tasks/1")) .andExpect(status().isNotFound) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$.status").value(404)) .andExpect(jsonPath("$.message").value("Task with id 1 not found")) } test("should return 400 when description is blank") { // Arrange: Mock service to throw validation exception val request = NewTaskRequest(description = "") every { taskService.addTask("") } throws InvalidTaskException("Task description cannot be blank") // Act & Assert: Verify 400 response mockMvc.perform( post("/api/tasks") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)) ) .andExpect(status().isBadRequest) .andExpect(jsonPath("$.status").value(400)) .andExpect(jsonPath("$.message").value("Task description cannot be blank")) } } }

Practice