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:
Contains business logic and rules
Orchestrates multiple data access operations
Provides transaction boundaries
Handles data transformation and validation
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.
Business Logic Handling - Implements complex business rules and workflows
Domain Model Mapping - Converts between DTOs and domain models
Transaction Management - Ensures data consistency across multiple operations
Authorization - Enforces access control and permissions
Data Validation - Validates business rules beyond basic input validation
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")
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.
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.
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.
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:
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.
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)
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
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.