Home Energy Tracker: User Service Implementation (Part 2)
1. Why Do We Need a Centralized User Service?
In a microservices architecture, breaking down a monolithic application into smaller, autonomous services requires careful ordering of dependencies. We must identify which service forms the foundational bedrock that all other services rely upon.
- The Dependency Foundation: Services like the Device Service or Billing Service cannot function without knowing who owns a device or who is consuming energy. Therefore, the User Service is implemented first.
- Database Strategy: In an ideal cloud-native production environment, each microservice possesses its own dedicated database for isolated migration control. However, to keep our initial learning curve manageable without over-engineering infrastructure, we host all project tables inside a single shared MySQL Docker container managed primarily by the User Service.
2. Core Mental Model & Real-Life Analogy
Real-Life Analogy: Imagine building an apartment complex where utilities, security gates, and mailrooms all depend on tenant registration records. You cannot assign an apartment key or set up utility tracking until the tenant is officially registered at the leasing office.
Similarly, the User Service acts as the central leasing office of our microservices ecosystem. It holds the definitive registry of identities (
users), while other services (like energy trackers) simply reference user identifiers when recording incoming telemetry.
3. Project Initialization & Architecture Setup
We start by generating a clean, standard Spring Boot project using Spring Initializr, adhering strictly to a multi-module Maven structure inside IntelliJ IDEA.
A. Initializr Configuration Specs
- Project Type: Maven
- Language: Java (Version 21)
- Spring Boot Version: 3.5.7 (with subsequent migration path to Spring Boot 4)
- Packaging: Jar
- Configuration Format:
application.properties(avoiding YAML for simplicity) - Group Name:
com.jour - Artifact Name:
user-service
B. Core Dependencies
C. IntelliJ Multi-Module Integration Workflow
Instead of managing multiple chaotic IntelliJ windows, we group all services under a single master directory (home energy tracker).
- Download the zipped initializer project and unpack it directly inside the master folder.
- Inside IntelliJ, navigate to File -> Project Structure -> Modules.
- Click (+) -> Import Module, select the newly extracted
user-servicefolder. - Choose Import module from external model and select Maven.
- Click Create to bind the service as an integrated IntelliJ module marked by the blue Maven icon.
4. Database Infrastructure via Docker Compose
To avoid complex local system installations (like Homebrew), we containerize our relational database using Docker Compose at the top-level project root.
A. docker-compose.yml Configuration
Create a docker-compose.yml file in the master directory:
services:
mysql:
image: mysql:8.3.0
container_name: mysql
restart: always
environment:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: home_energy_tracker
ports:
- "3306:3306"
volumes:
- mysql-data:/var/lib/mysql
- ./docker/mysql/init.sql:/docker-entrypoint-initdb.d/init.sql
volumes:
mysql-data:
B. Database Initialization Script (init.sql)
To prevent connection errors on first boot, we auto-create the schema via an initialization mount script located at ./docker/mysql/init.sql:
CREATE DATABASE IF NOT EXISTS home_energy_tracker;
USE home_energy_tracker;
C. Running Docker in Detached Mode
Open a terminal at the root master directory and spin up the container:
docker compose up -d
D. Verifying with MySQL Workbench
- Open MySQL Workbench and create a new connection (
home energy tracker DB). - Set Hostname to
localhost, Port3306, and Usernameroot. - Store password (
password) in the keychain, test connection, and launch the dashboard. Notice that the database exists, but tables are empty.
5. Step-by-Step Implementation: DTOs, Entities, and Repositories
We organize our code into distinct packages to maintain high modularity: controller, service, dto, entity, and repository.
A. User Data Transfer Object (UserDTO)
The DTO isolates internal database structures from client payloads, preventing sensitive leakage.
package com.jour.userservice.dto;
import lombok.Data;
import lombok.Builder;
@Data
@Builder
public class UserDTO {
private Long id;
private String name;
private String surname;
private String email;
private String address;
private boolean alerting;
private double energyAlertingThreshold;
}
B. Database Entity (User)
Maps directly to the relational database table.
package com.jour.userservice.entity;
import jakarta.persistence.*;
import lombok.*;
@Entity
@Table(name = "user")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String surname;
@Column(unique = true, nullable = false)
private String email;
private String address;
private boolean alerting;
private double energyAlertingThreshold;
}
C. JPA Repository (UserRepository)
Extends JpaRepository to inherit robust out-of-the-box CRUD and query execution methods without writing boilerplate SQL.
package com.jour.userservice.repository;
import com.jour.userservice.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
}
6. Database Migration via Flyway
Rather than letting Hibernate auto-generate unpredictable schemas, we use Flyway for deterministic, version-controlled database migrations.
A. Flyway Migration Naming Convention
Flyway scans src/main/resources/db/migration for files matching the strict pattern: V{version}__{description}.sql.
B. Version 1 Migration Script (V1__user_table.sql)
CREATE TABLE `user` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`name` VARCHAR(255) NOT NULL,
`surname` VARCHAR(255) DEFAULT NULL,
`email` VARCHAR(255) NOT NULL UNIQUE,
`address` VARCHAR(255) DEFAULT NULL,
`alerting` TINYINT NOT NULL DEFAULT 0,
`energy_alerting_threshold` DOUBLE NOT NULL DEFAULT 0,
PRIMARY KEY (`id`)
);
C. Configuring application.properties
Connect the Spring Boot application to our Docker MySQL instance and disable auto-DDL generation so Flyway takes complete control:
server.port=8080
spring.datasource.url=jdbc:mysql://localhost:3306/home_energy_tracker
spring.datasource.username=root
spring.datasource.password=password
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.jpa.hibernate.ddl-auto=none
spring.jpa.show-sql=true
7. Building the CRUD Controller & Business Service
A. User Service Implementation (UserService)
Encapsulates business logic, DTO-to-Entity mapping, and repository calls.
package com.jour.userservice.service;
import com.jour.userservice.dto.UserDTO;
import com.jour.userservice.entity.User;
import com.jour.userservice.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
public UserDTO createUser(UserDTO input) {
User user = User.builder()
.name(input.getName())
.surname(input.getSurname())
.email(input.getEmail())
.address(input.getAddress())
.alerting(input.isAlerting())
.energyAlertingThreshold(input.getEnergyAlertingThreshold())
.build();
User savedUser = userRepository.save(user);
return mapToDTO(savedUser);
}
public UserDTO getUserById(Long id) {
return userRepository.findById(id)
.map(this::mapToDTO)
.orElse(null);
}
public void updateUser(Long id, UserDTO input) {
User existingUser = userRepository.findById(id)
.orElseThrow(() -> new RuntimeException("User not found with id: " + id));
existingUser.setName(input.getName());
existingUser.setSurname(input.getSurname());
existingUser.setEmail(input.getEmail());
existingUser.setAddress(input.getAddress());
existingUser.setAlerting(input.isAlerting());
existingUser.setEnergyAlertingThreshold(input.getEnergyAlertingThreshold());
userRepository.save(existingUser);
}
public void deleteUser(Long id) {
User existingUser = userRepository.findById(id)
.orElseThrow(() -> new RuntimeException("User not found with id: " + id));
userRepository.delete(existingUser);
}
private UserDTO mapToDTO(User user) {
return UserDTO.builder()
.id(user.getId())
.name(user.getName())
.surname(user.getSurname())
.email(user.getEmail())
.address(user.getAddress())
.alerting(user.isAlerting())
.energyAlertingThreshold(user.getEnergyAlertingThreshold())
.build();
}
}
B. REST Controller (UserController)
Exposes HTTP endpoints conforming to standard RESTful design principles.
package com.jour.userservice.controller;
import com.jour.userservice.dto.UserDTO;
import com.jour.userservice.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/v1/user")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@PostMapping
public ResponseEntity<UserDTO> createUser(@RequestBody UserDTO userDTO) {
UserDTO createdUser = userService.createUser(userDTO);
return new ResponseEntity<>(createdUser, HttpStatus.CREATED);
}
@GetMapping("/{id}")
public ResponseEntity<UserDTO> getUserById(@PathVariable Long id) {
UserDTO userDTO = userService.getUserById(id);
if (userDTO == unll) {
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
return new ResponseEntity<>(userDTO, HttpStatus.OK);
}
@PutMapping("/{id}")
public ResponseEntity<String> updateUser(@PathVariable Long id, @RequestBody UserDTO userDTO) {
try {
userService.updateUser(id, userDTO);
return new ResponseEntity<>("User updated successfully", HttpStatus.OK);
} catch (RuntimeException e) {
return new ResponseEntity<>(e.getMessage(), HttpStatus.NOT_FOUND);
}
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
try {
userService.deleteUser(id);
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
} catch (RuntimeException e) {
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
}
}
8. Advanced Interception: Aspect-Oriented Programming (AOP)
As projects expand, logging and telemetry code clutters core business logic. We leverage AOP to separate cross-cutting concerns (like method entry logging and performance monitoring) from our core business rules.
A. Adding the AOP Dependency
Add the Spring Boot Starter AOP module to pom.xml and sync Maven:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
B. Creating the Logging Aspect (LoggingAspect)
Automatically intercepts service methods to log inputs and return results without modifying service classes.
package com.jour.userservice.aspect;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;
@Aspect
@Component
@Slf4j
public class LoggingAspect {
@Pointcut("execution(* com.jour.userservice.service.*.*(..))")
public void serviceMethods() {}
@Before("serviceMethods()")
public void logBefore(JoinPoint joinPoint) {
log.info("Calling service method: {} with arguments: {}",
joinPoint.getSignature().getName(), joinPoint.getArgs());
}
@AfterReturning(pointcut = "serviceMethods()", returning = "result")
public void logAfterReturning(JoinPoint joinPoint, Object result) {
log.info("Service method returned: {} with result: {}",
joinPoint.getSignature().getName(), result);
}
}
Security Warning Note: Be mindful when logging raw arguments and returned objects via AOP; avoid outputting sensitive payloads (such as passwords, tokens, or PII) to production logs.
C. Creating the Execution Time Aspect (ExecutionTimeAspect)
Measures controller performance using an @Around advice block.
package com.jour.userservice.aspect;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;
@Aspect
@Component
@Slf4j
public class ExecutionTimeAspect {
@Pointcut("execution(* com.jour.userservice.controller.*.*(..))")
public void controllerMethods() {}
@Around("controllerMethods()")
public Object measureExecutionTime(ProceedingJoinPoint pjp) throws Throwable {
long startTime = System.currentTimeMillis();
Object proceed = pjp.proceed();
long executionTime = System.currentTimeMillis() - startTime;
log.info("Controller method {} executed in {} ms",
pjp.getSignature(), executionTime);
return proceed;
}
}
9. Summary and Key Takeaways
- Foundational Ordering: The User Service is built first in our microservices topology because all other domain modules rely on user identifiers.
- Dockerized Persistence: MySQL runs reliably inside a Docker container orchestrated via a root
docker-compose.ymlfile, eliminating host system variances. - Database Migrations via Flyway: Relational DDL schemas are managed declaratively using version-controlled Flyway migration scripts (
V1__user_table.sql) rather than relying on Hibernate auto-generation. - Data Transfer Objects (DTOs): Decouple internal database entities from external API contracts to protect internal database structures and enforce security boundaries.
- Spring Data JPA Productivity: Extending
JpaRepositoryeliminates boilerplate code by providing built-in methods for saving, updating, finding, and deleting records. - Aspect-Oriented Programming (AOP): Separates cross-cutting concerns such as logging (
@Before,@AfterReturning) and performance monitoring (@Around) cleanly away from core business logic. - Modular Multi-Module Architecture: Managing multiple services inside a single master IntelliJ project workspace streamlines development and debugging across microservices.