MarkKoshkin
Back Home
Case Study

Mangrove: Spring Order Placement Flow

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): Int

Credit 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, and UserWithoutTeamException receive corresponding HTTP status codes and metadata.
  • Validation errors bubble up as ProblemDetail documents 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.

© 2025 Mark Koshkin. Have a great day!