Skip to main content

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​

  1. Single Responsibility: Each service should have one clear purpose
  2. Interface Segregation: Define clear service interfaces
  3. Transaction Boundaries: Use @Transactional appropriately
  4. Read-Only Transactions: Mark read operations as readOnly = true
  5. Exception Handling: Throw business exceptions, not technical ones
  6. Async Operations: Use @Async for non-blocking operations
  7. Service Composition: Compose services rather than duplicating logic
  8. Validation: Validate at service layer, not just controller
  9. Logging: Log business operations at INFO level
  10. Testing: Write comprehensive unit tests for all business logic

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_.json files 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