Spring Boot 4 - Full Microservices Course - Home Energy Tracker - Device Service (Part 4)
This chapter covers the development and integration of the Device Service, the second microservice in the Home Energy Tracker architecture. It details building out a Spring Boot 4 application connected to a shared MySQL database, handling database migrations via Flyway managed in the User Service, implementing complete CRUD endpoints, building robust global exception handling using @ControllerAdvice, and using integration/unit test hooks to seed test data.
1. Why Do We Need a Separate Device Service?
- Separation of Concerns: While the User Service manages user profiles and handles core database structural migrations (Flyway), the Device Service isolates the business logic, endpoints, and data interactions specifically associated with home automation devices.
- Shared Database Pattern: Both the User Service and the Device Service connect to the exact same underlying MySQL database instance, reducing unnecessary data duplication while keeping service codebases modular.
- Independent Scalability & Deployment: Separating devices into their own microservice allows device-heavy operations (like IoT telemetry ingestion and state tracking) to scale independently from user management.
2. Core Mental Model & Architecture
Real-Life Analogy: The Office Building and Department Rooms
Think of the MySQL database as a secure central filing cabinet in an office building. The User Service acts as the HR department that holds the master key and manages the folder structure (Flyway schema migrations). The Device Service acts as the Facilities department; they don't alter the filing cabinet's foundational dividers, but they constantly read, add, and update room and device assignments inside that cabinet.
3. Project Scaffolding & Spring Initializr Configuration
To bootstrap the Device Service, a new Maven module was created using Spring Initializr with the following specifications:
Module Import Workflow
- Download the generated archive from Spring Initializr.
- Extract and move the folder into the root
home-energy-trackerworkspace directory. - Import the folder as a module via IDE Project Structure settings until it displays the blue module icon.
- Create a dedicated Git branch (
device-service) to track all related changes cleanly.
4. Database Schema Migration (V2__device.sql)
Although the Device Service queries and modifies the device table, structural table migrations are centrally managed inside the User Service to maintain a single source of truth for Flyway scripts.
Migration Script (src/main/resources/db/migration/V2__device.sql)
CREATE TABLE device (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
type VARCHAR(50) NOT NULL,
location VARCHAR(255) NOT NULL,
user_id BIGINT NOT NULL,
CONSTRAINT fk_device_user FOREIGN KEY (user_id)
REFERENCES user(id)
ON DELETE CASCADE
);
- Cascade Deletion: The foreign key constraint
ON DELETE CASCADEensures that if a user is deleted from theusertable, all associated smart devices owned by that user are automatically purged from the database.
5. Entity Modeling & Enums
Device Type Enum (model/DeviceType.java)
To enforce strict category types rather than loose string values, a Java enum was established:
package com.example.deviceservice.model;
public enum DeviceType {
SPEAKER,
CAMERA,
THERMOSTAT,
LIGHT,
LOCK,
DOORBELL
}
Device Entity (entity/Device.java)
package com.example.deviceservice.entity;
import com.example.deviceservice.model.DeviceType;
import jakarta.persistence.*;
import lombok.*;
@Entity
@Table(name = "device")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Device {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Enumerated(EnumType.STRING)
@Column(name = "type", nullable = false)
private DeviceType deviceType;
@Column(nullable = false)
private String location;
@Column(name = "user_id", nullable = false)
private Long userId;
}
6. Repository Layer (repository/DeviceRepository.java)
Extending Spring Data JPA's JpaRepository provides out-of-the-box CRUD operations without requiring boilerplate SQL implementation.
package com.example.deviceservice.repository;
import com.example.deviceservice.entity.Device;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface DeviceRepository extends JpaRepository<Device, Long> {
}
7. Data Transfer Object (DTO) & Mapping Layer
To decouple database entities from external API contracts, a DeviceDTO is utilized alongside mapper methods.
Device DTO (dto/DeviceDTO.java)
package com.example.deviceservice.dto;
import com.example.deviceservice.model.DeviceType;
import lombok.*;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DeviceDTO {
private Long id;
private String name;
private DeviceType deviceType;
private String location;
private Long userId;
}
8. Service Layer Implementation (service/DeviceService.java)
The service layer coordinates repository interactions, entity-to-DTO transformations, and throws custom domain exceptions when records are missing.
package com.example.deviceservice.service;
import com.example.deviceservice.dto.DeviceDTO;
import com.example.deviceservice.entity.Device;
import com.example.deviceservice.exception.DeviceNotFoundException;
import com.example.deviceservice.repository.DeviceRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class DeviceService {
private final DeviceRepository deviceRepository;
public DeviceDTO getDeviceById(Long id) {
Device device = deviceRepository.findById(id)
.orElseThrow(() -> new DeviceNotFoundException("Device with id " + id + " not found"));
return mapToDTO(device);
}
public DeviceDTO createDevice(DeviceDTO input) {
Device device = Device.builder()
.name(input.getName())
.deviceType(input.getDeviceType())
.location(input.getLocation())
.userId(input.getUserId())
.build();
Device savedDevice = deviceRepository.save(device);
return mapToDTO(savedDevice);
}
public DeviceDTO updateDevice(Long id, DeviceDTO input) {
Device device = deviceRepository.findById(id)
.orElseThrow(() -> new DeviceNotFoundException("Device with id " + id + " not found"));
device.setName(input.getName());
device.setDeviceType(input.getDeviceType());
device.setLocation(input.getLocation());
device.setUserId(input.getUserId());
Device updatedDevice = deviceRepository.save(device);
return mapToDTO(updatedDevice);
}
public void deleteDevice(Long id) {
if (!deviceRepository.existsById(id)) {
throw new DeviceNotFoundException("Device with id " + id + " not found");
}
deviceRepository.deleteById(id);
}
private DeviceDTO mapToDTO(Device device) {
return DeviceDTO.builder()
.id(device.getId())
.name(device.getName())
.deviceType(device.getDeviceType())
.location(device.getLocation())
.userId(device.getUserId())
.build();
}
}
9. Controller Layer (controller/DeviceController.java)
Exposes RESTful endpoints mapped under /api/v1/device.
package com.example.deviceservice.controller;
import com.example.deviceservice.dto.DeviceDTO;
import com.example.deviceservice.service.DeviceService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/v1/device")
@RequiredArgsConstructor
public class DeviceController {
private final DeviceService deviceService;
@GetMapping("/{id}")
public ResponseEntity<DeviceDTO> getDeviceById(@PathVariable Long id) {
return ResponseEntity.ok(deviceService.getDeviceById(id));
}
@PostMapping
public ResponseEntity<DeviceDTO> createDevice(@RequestBody DeviceDTO deviceDTO) {
DeviceDTO created = deviceService.createDevice(deviceDTO);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
@PutMapping("/{id}")
public ResponseEntity<DeviceDTO> updateDevice(@PathVariable Long id, @RequestBody DeviceDTO deviceDTO) {
DeviceDTO updated = deviceService.updateDevice(id, deviceDTO);
return ResponseEntity.ok(updated);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteDevice(@PathVariable Long id) {
deviceService.deleteDevice(id);
return ResponseEntity.noContent().build();
}
}
10. Global Exception Handling Architecture
To prevent raw internal stack traces from leaking to external clients when a resource is missing, a custom exception and a @ControllerAdvice global handler were introduced.
Custom Exception (exception/DeviceNotFoundException.java)
package com.example.deviceservice.exception;
public class DeviceNotFoundException extends RuntimeException {
public DeviceNotFoundException(String message) {
super(message);
}
}
Global Rest Exception Handler (exception/RestExceptionHandler.java)
package com.example.deviceservice.exception;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class RestExceptionHandler {
@ExceptionHandler(DeviceNotFoundException.class)
public ResponseEntity<String> handleDeviceNotFound(DeviceNotFoundException ex) {
return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(ex.getMessage());
}
}
11. Application Properties Configuration & Port Separation
Because both the User Service and Device Service run on the same local machine and connect to the shared MySQL container, port conflicts must be avoided by configuring distinct server ports.
src/main/resources/application.properties (Device Service)
server.port=8081
spring.datasource.url=jdbc:mysql://localhost:3306/home_energy_tracker?useSSL=false&serverTimezone=UTC
spring.datasource.username=root
spring.datasource.password=root
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.show-sql=true
12. Test Seeding Hooks & Data Generation
To efficiently populate the database with dummy test users and smart devices without manual API calls, test classes were leveraged as temporary command-line runners.
Seeding Logic Pattern
- User Service Test Hook: Iterates using a loop to generate 10 users with alternating alerting properties and threshold configurations.
- Device Service Test Hook: Automatically populates dozens of devices linked to existing valid user IDs (
userIdranging across seeded records).
@Test
void createDevices() {
int numberOfDevices = 20;
for (int i = 0; i < numberOfDevices; i++) {
Device device = Device.builder()
.name("Smart Device " + i)
.deviceType(DeviceType.SPEAKER)
.location("Room " + (i % 5))
.userId(1L)
.build();
deviceRepository.save(device);
}
log.info("Device repository has been populated successfully.");
}
Note: Tests are disabled post-execution to prevent accidental data overwrites during clean Maven builds.
Summary and Key Takeaways
- Shared Database Strategy: Microservices can successfully share a single MySQL instance while separating migration ownership (User Service executing Flyway scripts).
- Port Isolation: Running multiple Spring Boot instances locally requires assigning unique ports (e.g.,
8080for User Service,8081for Device Service). - Strong Typing with Enums: Using
@Enumerated(EnumType.STRING)guarantees clean serialization and database integrity for fixed domain options like device categories. - Global Error Management:
@RestControllerAdvicecleanly intercepts custom runtime exceptions (DeviceNotFoundException) and formats them into standardized HTTP404 Not Foundresponses. - Cascade Deletion Rules: Relational foreign keys configured with
ON DELETE CASCADEensure data consistency when parent records are purged. - Testing as a Utility: Unit test classes can be repurposed as quick on-demand seed generators to populate development databases with test records.