Background
I designed and implemented Mangrove’s B2B store platform, delivered as a Spring Boot REST service. One core capability is order placement for medical staff teams, each with its own merchandise catalog and shipping policy. This guide walks through the order-placement flow for an authenticated user and shows how the controller, services, repositories, and global exception advice work together, with accompanying Kotlin snippets.
UML Sequence Diagram
Here's the overview of the implementation:
Controller Layer
HTTPS Request
Client makes POST request to the service. For convenience and consistency, the client code is autogenerated to interact with the service using OpenAPI Generator.
POST /api/orders
Authorization: Bearer <JWT>
Content-Type: application/json
{
"firstName": "Mary",
"lastName": "Lovelace",
"addressLine1": "42 Binary Way",
"addressLine2": null,
"city": "Seattle",
"state": "WA",
"zipCode": "98101",
"orderItems": [
{ "productVariationId": 101, "quantity": 2 },
{ "productVariationId": 102, "quantity": 1 },
{ "productVariationId": 103, "quantity": 3 }
]
}Controller Entry Point
Spring Security injects the authenticated CustomUserDetails. The controller is minimal; its responsibility is to delegate to the service. On success, it will generate a 201 Created with the new order's location and DTO. Bean Validation runs before the method, and any @Valid failures raise MethodArgumentNotValidException, which GlobalExceptionAdvice handles.
// src/main/kotlin/com/mangrove_store/uniform/controller/OrderController.kt
@PreAuthorize("isAuthenticated()")
@PostMapping
fun placeOrder(
@AuthenticationPrincipal currentUser: CustomUserDetails,
@RequestBody @Valid request: CreateOrderRequest,
): ResponseEntity<OrderResponse> {
val order = orderService.placeOrder(request, currentUser)
val location = URI.create("/api/orders/${order.id}")
return ResponseEntity.created(location).body(OrderResponse.from(order))
}2. Service Layer
The service validates the request, assembles the order, computes totals, deducts credits, persists the aggregate, and logs the credit transaction.
// src/main/kotlin/com/mangrove_store/uniform/service/OrderService.kt
@Transactional
fun placeOrder(request: CreateOrderRequest, currentUser: CustomUserDetails): Order {
val userId = currentUser.userId
val user = userService.getUserById(userId)
// User belonds to a team. Each has its own catalog and shipping fee
val team = user.team ?: throw UserWithoutTeamException(userId)
// Validate requested products against the database
val variationIds: Set<Long> = request.orderItems.map { it.productVariationId }.toSet()
require(variationIds.isNotEmpty()) { "orderItems cannot be empty" }
val productVariations: List<ProductVariation> = productRepository.findVariationsWithProductByIdIn(variationIds)
val productVariationsMap: Map<Long, ProductVariation> = productVariations.associateBy { requireNotNull(it.id) }
if (variationIds.size != productVariationsMap.size) {
// Some requested items are not in the db
val missing: Set<Long> = variationIds - productVariationsMap.keys
throw InvalidProductVariationException(missing.first())
}
// Prices are stored in cents
val itemTotal = request.orderItems.sumOf {
require(it.quantity > 0) {"Item quantity must be greater than zero"}
it.quantity.toLong() * requireNotNull(productVariationsMap[it.productVariationId]).price
}
val shippingFee = team.shippingFee
val totalAmount = itemTotal + shippingFee
// Attempt credit deduction before creating order, throws InsufficientCreditsException
userService.deductUserCreditsOrThrow(userId, totalAmount)
val now = Instant.now()
val order = Order(
user = user,
team = team,
shippingFee = shippingFee,
totalAmount = totalAmount,
status = OrderStatus.PENDING,
firstName = request.firstName,
lastName = request.lastName,
addressLine1 = request.addressLine1,
addressLine2 = request.addressLine2,
city = request.city,
state = request.state,
zipCode = request.zipCode,
createdAt = now,
updatedAt = now
)
// Order.orderItems uses CascadeType.ALL. And so, the items persist with the order.
val orderItems = request.orderItems.map { item ->
val productVariation = productVariationsMap.getValue(item.productVariationId)
OrderItem(
order = order,
product = productVariation.product,
productVariation = productVariation,
quantity = item.quantity,
unitPrice = productVariation.price,
createdAt = now,
updatedAt = now
)
}
order.orderItems.addAll(orderItems)
val savedOrder = orderRepository.save(order)
// By this point order exists in the db, now we can log the transaction
creditService.logOrderTransaction(userId, -totalAmount, "Order #${savedOrder.id}", savedOrder)
return savedOrder
}Deducting user credits
The service calls repository to perform credit deduction. The number of returned rows indicates success or failure.
// src/main/kotlin/com/mangrove_store/uniform/service/UserService.kt
@Transactional
fun deductUserCreditsOrThrow(userId: Long, amount: Long) {
val rowsUpdated = userRepository.deductUserCredits(userId, amount)
if (rowsUpdated == 0) {
throw InsufficientCreditsException("User $userId has insufficient store credits to deduct $amount")
}
}The project utilizes JPA, however in this case a custom query is needed. The underlying UserRepository.deductUserCredits updates credits atomically with a safeguard:
@Modifying
@Query("""
UPDATE User u
SET u.storeCredits = u.storeCredits - :amount
WHERE u.id = :userId AND u.storeCredits >= :amount
""")
fun deductUserCredits(@Param("userId") userId: Long, @Param("amount") amount: Long): IntCredit Ledger
After the order is saved, the CreditService records a matching transaction.
// src/main/kotlin/com/mangrove_store/uniform/service/CreditService.kt
fun logOrderTransaction(userId: Long, amount: Long, description: String, order: Order) {
val user = userService.getUserByReference(userId) // Avoids an extra SELECT
creditTransactionRepository.save(
CreditTransaction(
user = user,
order = order,
amount = amount, // negative totalAmount for a debit
description = description, // The source or notes
createdAt = Instant.now(),
updatedAt = Instant.now()
)
)
}3. Global Exception Handling
This advice centralizes exception handling across the domains. The following exceptions may be thrown during placing order flow:
// src/main/kotlin/com/mangrove_store/uniform/advice/GlobalExceptionAdvice.kt
@RestControllerAdvice
class GlobalExceptionAdvice {
// `require` failure will trigger IllegalArgumentException.
@ExceptionHandler(IllegalArgumentException::class)
fun illegalArgument(e: IllegalArgumentException): ProblemDetail =
ProblemDetail.forStatusAndDetail(HttpStatus.UNPROCESSABLE_ENTITY, e.message ?: "Invalid request")
@ExceptionHandler(InsufficientCreditsException::class)
fun insufficientCredits(e: InsufficientCreditsException): ProblemDetail =
ProblemDetail.forStatusAndDetail(HttpStatus.PAYMENT_REQUIRED, e.message ?: "Insufficient credits")
@ExceptionHandler(MethodArgumentNotValidException::class)
fun validation(e: MethodArgumentNotValidException): ProblemDetail =
ProblemDetail.forStatusAndDetail(HttpStatus.UNPROCESSABLE_ENTITY, "Validation failed").apply {
val errors = e.bindingResult.fieldErrors.associate { it.field to (it.defaultMessage ?: "invalid") }
setProperty("errors", errors)
}
...
}- Business exceptions such as
InsufficientCreditsException,InvalidProductVariationException, andUserWithoutTeamExceptionreceive corresponding HTTP status codes and metadata. - Validation errors bubble up as
ProblemDetaildocuments with a field-to-error map.
All custom exceptions extend the RuntimeException class:
package com.mangrove_store.uniform.exception
class InvalidProductVariationException(
val variationId: Long?,
message: String? = "Invalid product variation $variationId"
) : RuntimeException(message)
4. Test Coverage
src/test/kotlin/com/mangrove_store/uniform/service/OrderServiceTest.kt covers the critical scenarios for OrderService.placeOrder, including:
- successful persistence of an order, credit deduction and transaction logging
- failure cases for insufficient credits, missing team association, nonexistent product variations, and invalid quantities
Happy-path example:
private val orderRepository: OrderRepository = mock(OrderRepository::class.java)
private val userService: UserService = mock(UserService::class.java)
private val productRepository: ProductRepository = mock(ProductRepository::class.java)
private val creditService: CreditService = mock(CreditService::class.java)
@Test
fun `placeOrder persists order and logs credit transaction`() {
val team = Team(id = 7L, name = "Falcons", shippingFee = 500L)
val user = User(id = 12L, email = "ada@example.com", passwordHash = "hash", team = team)
val currentUser = CustomUserDetails(user)
val variation = ProductVariation(id = 101L, product = Product(id = 55L, name = "Warmup Jacket"), variationName = "Medium", price = 2500L)
val request = CreateOrderRequest(
firstName = "Ada",
lastName = "Lovelace",
addressLine1 = "42 Binary Way",
addressLine2 = null,
city = "Seattle",
state = "WA",
zipCode = "98101",
orderItems = listOf(OrderItemRequest(productVariationId = 101L, quantity = 2))
)
whenever(userService.getUserById(user.id!!)).thenReturn(user)
whenever(productRepository.findVariationsWithProductByIdIn(setOf(101L))).thenReturn(listOf(variation))
whenever(orderRepository.save(any())).thenAnswer { invocation -> invocation.getArgument<Order>(0).copy(id = 444L) }
val result = service.placeOrder(request, currentUser)
assertEquals(444L, result.id)
verify(creditService).logOrderTransaction(eq(user.id!!), eq(-5500L), eq("Order #444"), any())
}Conclusion
This layered design separates concerns: the controller handles HTTP, the service layer enforces business rules and transactions, repositories govern persistence, and a global handler centralizes error responses for consistent API behavior.