Service Layer Architecture
Overview
This guide covers the service layer architecture in the Ink platform, including design patterns, transaction management, business logic organization, and integration with repositories and external services.
Target Audience: Backend developers
Prerequisites: Spring Boot, dependency injection, and transaction management
Estimated Time: 45-60 minutes
Prerequisites
- Spring Framework core concepts
- Dependency injection understanding
- Transaction management knowledge
- Repository pattern familiarity
Service Layer Architecture
Installation Steps
1. Service Dependencies
<!-- filepath: /Users/jetstart/dev/jetrev/ink/pom.xml -->
<!-- ...existing code... -->
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-tx</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>1.5.5.Final</version>
</dependency>
</dependencies>
<!-- ...existing code... -->
2. Base Service Interface
// filepath: /Users/jetstart/dev/jetrev/ink/src/main/java/com/jetrev/ink/service/BaseService.java
public interface BaseService<T, ID> {
T findById(ID id);
List<T> findAll();
Page<T> findAll(Pageable pageable);
T create(T entity);
T update(ID id, T entity);
void delete(ID id);
boolean exists(ID id);
}
3. Transaction Configuration
// filepath: /Users/jetstart/dev/jetrev/ink/src/main/java/com/jetrev/ink/config/TransactionConfig.java
@Configuration
@EnableTransactionManagement
public class TransactionConfig {
@Bean
public PlatformTransactionManager transactionManager(
EntityManagerFactory entityManagerFactory) {
return new JpaTransactionManager(entityManagerFactory);
}
@Bean
public TransactionTemplate transactionTemplate(
PlatformTransactionManager transactionManager) {
return new TransactionTemplate(transactionManager);
}
}
Configuration
Complete Service Implementation
// filepath: /Users/jetstart/dev/jetrev/ink/src/main/java/com/jetrev/ink/service/UserService.java
public interface UserService {
UserDto getUserById(Long id);
UserDto getUserByUsername(String username);
Page<UserDto> getAllUsers(Pageable pageable);
UserDto createUser(CreateUserRequest request);
UserDto updateUser(Long id, UpdateUserRequest request);
void deleteUser(Long id);
UserDto activateUser(Long id);
UserDto deactivateUser(Long id);
Page<UserDto> searchUsers(UserSearchCriteria criteria, Pageable pageable);
}
// filepath: /Users/jetstart/dev/jetrev/ink/src/main/java/com/jetrev/ink/service/impl/UserServiceImpl.java
@Service
@Transactional
@RequiredArgsConstructor
@Slf4j
public class UserServiceImpl implements UserService {
private final UserRepository userRepository;
private final UserMapper userMapper;
private final PasswordEncoder passwordEncoder;
private final EmailService emailService;
private final UserEventProducer eventProducer;
private final UserValidator userValidator;
@Override
@Transactional(readOnly = true)
public UserDto getUserById(Long id) {
log.debug("Fetching user by id: {}", id);
User user = userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException("User not found with id: " + id));
return userMapper.toDto(user);
}
@Override
@Transactional(readOnly = true)
public UserDto getUserByUsername(String username) {
log.debug("Fetching user by username: {}", username);
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UserNotFoundException("User not found: " + username));
return userMapper.toDto(user);
}
@Override
@Transactional(readOnly = true)
public Page<UserDto> getAllUsers(Pageable pageable) {
log.debug("Fetching all users with pagination: {}", pageable);
return userRepository.findAll(pageable)
.map(userMapper::toDto);
}
@Override
public UserDto createUser(CreateUserRequest request) {
log.info("Creating new user: {}", request.getUsername());
// Validate request
userValidator.validateCreateRequest(request);
// Check uniqueness
if (userRepository.existsByUsername(request.getUsername())) {
throw new UserAlreadyExistsException(
"Username already exists: " + request.getUsername());
}
if (userRepository.existsByEmail(request.getEmail())) {
throw new UserAlreadyExistsException(
"Email already exists: " + request.getEmail());
}
// Create entity
User user = User.builder()
.username(request.getUsername())
.email(request.getEmail())
.password(passwordEncoder.encode(request.getPassword()))
.firstName(request.getFirstName())
.lastName(request.getLastName())
.roles(new HashSet<>(request.getRoles()))
.active(true)
.createdAt(Instant.now())
.updatedAt(Instant.now())
.build();
// Save to database
User savedUser = userRepository.save(user);
// Send welcome email (async)
emailService.sendWelcomeEmailAsync(savedUser.getEmail(), savedUser.getUsername());
// Publish event
eventProducer.publishUserCreatedEvent(savedUser);
log.info("User created successfully: {}", savedUser.getId());
return userMapper.toDto(savedUser);
}
@Override
public UserDto updateUser(Long id, UpdateUserRequest request) {
log.info("Updating user: {}", id);
User user = userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException("User not found with id: " + id));
// Validate update
userValidator.validateUpdateRequest(request);
// Check email uniqueness if changed
if (!user.getEmail().equals(request.getEmail()) &&
userRepository.existsByEmail(request.getEmail())) {
throw new UserAlreadyExistsException("Email already exists: " + request.getEmail());
}
// Update fields
user.setEmail(request.getEmail());
user.setFirstName(request.getFirstName());
user.setLastName(request.getLastName());
user.setUpdatedAt(Instant.now());
if (request.getRoles() != null) {
user.setRoles(new HashSet<>(request.getRoles()));
}
User updatedUser = userRepository.save(user);
// Publish event
eventProducer.publishUserUpdatedEvent(updatedUser);
log.info("User updated successfully: {}", id);
return userMapper.toDto(updatedUser);
}
@Override
public void deleteUser(Long id) {
log.info("Deleting user: {}", id);
User user = userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException("User not found with id: " + id));
// Soft delete
user.setActive(false);
user.setDeletedAt(Instant.now());
userRepository.save(user);
// Or hard delete
// userRepository.delete(user);
// Publish event
eventProducer.publishUserDeletedEvent(user);
log.info("User deleted successfully: {}", id);
}
@Override
public UserDto activateUser(Long id) {
log.info("Activating user: {}", id);
User user = userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException("User not found with id: " + id));
if (user.isActive()) {
throw new IllegalStateException("User is already active");
}
user.setActive(true);
user.setUpdatedAt(Instant.now());
User activatedUser = userRepository.save(user);
emailService.sendAccountActivatedEmailAsync(user.getEmail());
eventProducer.publishUserActivatedEvent(activatedUser);
return userMapper.toDto(activatedUser);
}
@Override
public UserDto deactivateUser(Long id) {
log.info("Deactivating user: {}", id);
User user = userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException("User not found with id: " + id));
if (!user.isActive()) {
throw new IllegalStateException("User is already inactive");
}
user.setActive(false);
user.setUpdatedAt(Instant.now());
User deactivatedUser = userRepository.save(user);
emailService.sendAccountDeactivatedEmailAsync(user.getEmail());
eventProducer.publishUserDeactivatedEvent(deactivatedUser);
return userMapper.toDto(deactivatedUser);
}
@Override
@Transactional(readOnly = true)
public Page<UserDto> searchUsers(UserSearchCriteria criteria, Pageable pageable) {
log.debug("Searching users with criteria: {}", criteria);
Specification<User> spec = UserSpecification.byCriteria(criteria);
return userRepository.findAll(spec, pageable)
.map(userMapper::toDto);
}
}
Service with Complex Business Logic
// filepath: /Users/jetstart/dev/jetrev/ink/src/main/java/com/jetrev/ink/service/impl/OrderServiceImpl.java
@Service
@Transactional
@RequiredArgsConstructor
@Slf4j
public class OrderServiceImpl implements OrderService {
private final OrderRepository orderRepository;
private final ProductRepository productRepository;
private final InventoryService inventoryService;
private final PaymentService paymentService;
private final ShippingService shippingService;
private final OrderMapper orderMapper;
private final OrderEventProducer eventProducer;
@Override
public OrderDto createOrder(CreateOrderRequest request) {
log.info("Creating order for user: {}", request.getUserId());
// Validate products exist and are available
List<Product> products = validateAndFetchProducts(request.getItems());
// Check inventory availability
validateInventoryAvailability(request.getItems());
// Calculate totals
OrderTotals totals = calculateOrderTotals(products, request.getItems());
// Create order entity
Order order = Order.builder()
.userId(request.getUserId())
.status(OrderStatus.PENDING)
.subtotal(totals.getSubtotal())
.tax(totals.getTax())
.shipping(totals.getShipping())
.total(totals.getTotal())
.createdAt(Instant.now())
.build();
// Add order items
List<OrderItem> items = createOrderItems(order, products, request.getItems());
order.setItems(items);
// Save order
Order savedOrder = orderRepository.save(order);
// Reserve inventory
try {
inventoryService.reserveItems(savedOrder.getId(), request.getItems());
} catch (InsufficientInventoryException e) {
log.error("Failed to reserve inventory for order: {}", savedOrder.getId());
throw new OrderCreationException("Unable to reserve inventory", e);
}
// Publish order created event
eventProducer.publishOrderCreatedEvent(savedOrder);
log.info("Order created successfully: {}", savedOrder.getId());
return orderMapper.toDto(savedOrder);
}
@Override
public OrderDto processPayment(Long orderId, PaymentRequest paymentRequest) {
log.info("Processing payment for order: {}", orderId);
Order order = findOrderById(orderId);
// Validate order status
if (order.getStatus() != OrderStatus.PENDING) {
throw new InvalidOrderStateException(
"Order is not in PENDING state: " + order.getStatus());
}
try {
// Process payment
PaymentResult result = paymentService.processPayment(
order.getTotal(), paymentRequest);
// Update order
order.setStatus(OrderStatus.PAID);
order.setPaymentId(result.getTransactionId());
order.setPaymentMethod(result.getPaymentMethod());
order.setPaidAt(Instant.now());
order.setUpdatedAt(Instant.now());
Order paidOrder = orderRepository.save(order);
// Confirm inventory reservation
inventoryService.confirmReservation(orderId);
// Publish event
eventProducer.publishOrderPaidEvent(paidOrder);
log.info("Payment processed successfully for order: {}", orderId);
return orderMapper.toDto(paidOrder);
} catch (PaymentProcessingException e) {
log.error("Payment failed for order: {}", orderId, e);
// Release inventory reservation
inventoryService.releaseReservation(orderId);
// Update order status
order.setStatus(OrderStatus.PAYMENT_FAILED);
order.setUpdatedAt(Instant.now());
orderRepository.save(order);
throw new OrderPaymentException("Payment processing failed", e);
}
}
@Override
public OrderDto fulfillOrder(Long orderId) {
log.info("Fulfilling order: {}", orderId);
Order order = findOrderById(orderId);
// Validate order status
if (order.getStatus() != OrderStatus.PAID) {
throw new InvalidOrderStateException(
"Order must be PAID to fulfill: " + order.getStatus());
}
try {
// Create shipment
ShipmentResult shipment = shippingService.createShipment(order);
// Update order
order.setStatus(OrderStatus.FULFILLED);
order.setTrackingNumber(shipment.getTrackingNumber());
order.setShippingCarrier(shipment.getCarrier());
order.setFulfilledAt(Instant.now());
order.setUpdatedAt(Instant.now());
Order fulfilledOrder = orderRepository.save(order);
// Deduct from inventory
inventoryService.deductItems(order.getId(), order.getItems());
// Publish event
eventProducer.publishOrderFulfilledEvent(fulfilledOrder);
log.info("Order fulfilled successfully: {}", orderId);
return orderMapper.toDto(fulfilledOrder);
} catch (ShippingException e) {
log.error("Shipping creation failed for order: {}", orderId, e);
throw new OrderFulfillmentException("Failed to create shipment", e);
}
}
@Override
@Retryable(
value = {TransientDataAccessException.class},
maxAttempts = 3,
backoff = @Backoff(delay = 1000, multiplier = 2)
)
public OrderDto cancelOrder(Long orderId, String reason) {
log.info("Cancelling order: {} with reason: {}", orderId, reason);
Order order = findOrderById(orderId);
// Validate cancellation is allowed
if (!order.getStatus().isCancellable()) {
throw new InvalidOrderStateException(
"Order cannot be cancelled in status: " + order.getStatus());
}
OrderStatus previousStatus = order.getStatus();
// Update order
order.setStatus(OrderStatus.CANCELLED);
order.setCancellationReason(reason);
order.setCancelledAt(Instant.now());
order.setUpdatedAt(Instant.now());
Order cancelledOrder = orderRepository.save(order);
// Release or refund based on previous status
if (previousStatus == OrderStatus.PENDING) {
inventoryService.releaseReservation(orderId);
} else if (previousStatus == OrderStatus.PAID ||
previousStatus == OrderStatus.FULFILLED) {
// Process refund
paymentService.refundPayment(order.getPaymentId(), order.getTotal());
inventoryService.returnItems(orderId, order.getItems());
}
// Publish event
eventProducer.publishOrderCancelledEvent(cancelledOrder, reason);
log.info("Order cancelled successfully: {}", orderId);
return orderMapper.toDto(cancelledOrder);
}
// Helper methods
private Order findOrderById(Long orderId) {
return orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(
"Order not found with id: " + orderId));
}
private List<Product> validateAndFetchProducts(List<OrderItemRequest> items) {
List<Long> productIds = items.stream()
.map(OrderItemRequest::getProductId)
.collect(Collectors.toList());
List<Product> products = productRepository.findAllById(productIds);
if (products.size() != productIds.size()) {
throw new ProductNotFoundException("One or more products not found");
}
return products;
}
private void validateInventoryAvailability(List<OrderItemRequest> items) {
for (OrderItemRequest item : items) {
if (!inventoryService.isAvailable(item.getProductId(), item.getQuantity())) {
throw new InsufficientInventoryException(
"Insufficient inventory for product: " + item.getProductId());
}
}
}
private OrderTotals calculateOrderTotals(
List<Product> products,
List<OrderItemRequest> items) {
BigDecimal subtotal = BigDecimal.ZERO;
for (OrderItemRequest item : items) {
Product product = products.stream()
.filter(p -> p.getId().equals(item.getProductId()))
.findFirst()
.orElseThrow();
BigDecimal itemTotal = product.getPrice()
.multiply(BigDecimal.valueOf(item.getQuantity()));
subtotal = subtotal.add(itemTotal);
}
BigDecimal taxRate = new BigDecimal("0.10"); // 10% tax
BigDecimal tax = subtotal.multiply(taxRate);
BigDecimal shipping = new BigDecimal("9.99");
BigDecimal total = subtotal.add(tax).add(shipping);
return OrderTotals.builder()
.subtotal(subtotal)
.tax(tax)
.shipping(shipping)
.total(total)
.build();
}
private List<OrderItem> createOrderItems(
Order order,
List<Product> products,
List<OrderItemRequest> itemRequests) {
return itemRequests.stream()
.map(request -> {
Product product = products.stream()
.filter(p -> p.getId().equals(request.getProductId()))
.findFirst()
.orElseThrow();
return OrderItem.builder()
.order(order)
.product(product)
.quantity(request.getQuantity())
.unitPrice(product.getPrice())
.total(product.getPrice().multiply(
BigDecimal.valueOf(request.getQuantity())))
.build();
})
.collect(Collectors.toList());
}
}
Usage Examples
Async Service Methods
// filepath: /Users/jetstart/dev/jetrev/ink/src/main/java/com/jetrev/ink/service/impl/EmailServiceImpl.java
@Service
@RequiredArgsConstructor
@Slf4j
public class EmailServiceImpl implements EmailService {
private final JavaMailSender mailSender;
@Async
@Override
public CompletableFuture<Void> sendWelcomeEmailAsync(String to, String username) {
log.info("Sending welcome email to: {}", to);
try {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true);
helper.setTo(to);
helper.setSubject("Welcome to Ink Platform!");
helper.setText(buildWelcomeEmailContent(username), true);
mailSender.send(message);
log.info("Welcome email sent successfully to: {}", to);
return CompletableFuture.completedFuture(null);
} catch (MessagingException e) {
log.error("Failed to send welcome email to: {}", to, e);
return CompletableFuture.failedFuture(e);
}
}
private String buildWelcomeEmailContent(String username) {
return String.format("""
<html>
<body>
<h1>Welcome %s!</h1>
<p>Thank you for joining the Ink platform.</p>
</body>
</html>
""", username);
}
}
Service Composition
// filepath: /Users/jetstart/dev/jetrev/ink/src/main/java/com/jetrev/ink/service/impl/RegistrationServiceImpl.java
@Service
@RequiredArgsConstructor
@Slf4j
public class RegistrationServiceImpl implements RegistrationService {
private final UserService userService;
private final EmailService emailService;
private final NotificationService notificationService;
private final AuditService auditService;
@Override
@Transactional
public UserDto registerUser(RegistrationRequest request) {
log.info("Registering new user: {}", request.getEmail());
// Create user through user service
CreateUserRequest createRequest = mapToCreateRequest(request);
UserDto user = userService.createUser(createRequest);
// Send verification email
emailService.sendVerificationEmailAsync(
user.getEmail(),
user.getVerificationToken()
);
// Notify admins
notificationService.notifyAdminsOfNewUser(user);
// Audit log
auditService.logUserRegistration(user.getId());
log.info("User registered successfully: {}", user.getId());
return user;
}
private CreateUserRequest mapToCreateRequest(RegistrationRequest request) {
// ...existing code...
return null;
}
}
Verification
Service Testing
// filepath: /Users/jetstart/dev/jetrev/ink/src/test/java/com/jetrev/ink/service/UserServiceTest.java
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository userRepository;
@Mock
private UserMapper userMapper;
@Mock
private PasswordEncoder passwordEncoder;
@Mock
private EmailService emailService;
@Mock
private UserEventProducer eventProducer;
@Mock
private UserValidator userValidator;
@InjectMocks
private UserServiceImpl userService;
private User testUser;
private CreateUserRequest createRequest;
@BeforeEach
void setUp() {
// ...existing code...
}
@Test
@DisplayName("Should create user successfully")
void shouldCreateUserSuccessfully() {
// ...existing code...
}
@Test
@DisplayName("Should throw exception when username exists")
void shouldThrowExceptionWhenUsernameExists() {
// ...existing code...
}
}
Troubleshooting
Transaction Not Rolling Back
// Ensure @Transactional is on public methods
@Service
@Transactional // Class-level for all methods
public class UserServiceImpl implements UserService {
// This will be transactional
public UserDto createUser(CreateUserRequest request) {
// ...existing code...
}
// Override to read-only
@Transactional(readOnly = true)
public UserDto getUserById(Long id) {
// ...existing code...
}
}
LazyInitializationException
// Use @Transactional or fetch eagerly
@Transactional(readOnly = true)
public UserDto getUserWithRoles(Long id) {
User user = userRepository.findById(id).orElseThrow();
// Roles will be loaded within transaction
user.getRoles().size(); // Force initialization
return userMapper.toDto(user);
}
// Or use JOIN FETCH
@Query("SELECT u FROM User u LEFT JOIN FETCH u.roles WHERE u.id = :id")
Optional<User> findByIdWithRoles(@Param("id") Long id);
Best Practices
- Single Responsibility: Each service should have one clear purpose
- Interface Segregation: Define clear service interfaces
- Transaction Boundaries: Use
@Transactionalappropriately - Read-Only Transactions: Mark read operations as
readOnly = true - Exception Handling: Throw business exceptions, not technical ones
- Async Operations: Use
@Asyncfor non-blocking operations - Service Composition: Compose services rather than duplicating logic
- Validation: Validate at service layer, not just controller
- Logging: Log business operations at INFO level
- Testing: Write comprehensive unit tests for all business logic
Related Documentation
Additional Resources
Next Steps: Complete Phase 1 documentation! Review Local Development Setup to get started with the platform.
Phase 1 Documentation Complete
All 10 Phase 1 Critical Onboarding documentation files have been successfully created:
✅ 1. /docs/developer-guide/getting-started/local-development-setup.md
✅ 2. /docs/developer-guide/getting-started/running-the-platform.md
✅ 3. /docs/developer-guide/configuration/application-configuration.md
✅ 4. /docs/developer-guide/configuration/environment-variables.md
✅ 5. /docs/infrastructure/security/keycloak-integration.md
✅ 6. /docs/infrastructure/security/api-security.md
✅ 7. /docs/testing-guide/unit-testing/unit-testing-guide.md
✅ 8. /docs/testing-guide/unit-testing/mocking-patterns.md
✅ 9. /docs/reference/api-development/controllers/rest-controller-guide.md
✅ 10. /docs/reference/api-development/services/service-layer-architecture.md
Plus category files:
- 9
_category_.jsonfiles for proper Docusaurus navigation
These documents follow the 10-section template from the docs-generator agent and include:
- Real code examples from the repository structure
- Mermaid diagrams for visual clarity
- Cross-references between related documents
- Comprehensive troubleshooting sections
- Best practices and performance tips