Last active
March 31, 2026 04:20
-
-
Save kawasima/673944f21a824f805ec83e89d5ae680c to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import static net.unit8.raoh.json.JsonDecoders.*; | |
| JsonDecoder<Quantity> quantityDec = | |
| int_().min(1).map(Quantity::new); | |
| JsonDecoder<ShippingAddress> addressDec = | |
| field("shippingAddress", | |
| string().nonBlank().map(ShippingAddress::new)); | |
| // ProductRepositoryを受け取り、flatMapで存在チェックまで行う | |
| static JsonDecoder<Tuple2<List<OrderLine>, ShippingAddress>> | |
| createOrderDec(ProductRepository products) { | |
| var orderLineDec = combine( | |
| field("productId", string().nonBlank() | |
| .map(ProductId::new) | |
| .flatMap(products::findById)), | |
| field("quantity", quantityDec) | |
| ).map(OrderLine::new); | |
| return combine( | |
| field("items", list(orderLineDec).nonempty()), | |
| addressDec | |
| ); | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| record OrderId(String value) {} | |
| record ProductId(String value) {} | |
| record Quantity(int value) {} | |
| record Email(String value) {} | |
| record Money(BigDecimal value) { | |
| Money { | |
| if (value.compareTo(BigDecimal.ZERO) < 0) { | |
| throw new IllegalArgumentException( | |
| "Money cannot be negative"); | |
| } | |
| } | |
| Money multiply(int n) { | |
| return new Money( | |
| value.multiply(BigDecimal.valueOf(n))); | |
| } | |
| Money add(Money other) { | |
| return new Money(value.add(other.value)); | |
| } | |
| } | |
| record OrderItem(ProductId productId, Quantity quantity) {} | |
| record ShippingAddress(String value) {} | |
| record OrderLine(Product product, Quantity quantity) {} | |
| record Order( | |
| OrderId id, | |
| List<OrderItem> items, | |
| ShippingAddress address, | |
| Money total, | |
| OrderStatus status | |
| ) {} | |
| class CreateOrder | |
| implements Function<CreateOrder.Input, Order> { | |
| record Input( | |
| List<OrderLine> lines, | |
| ShippingAddress address, | |
| int activeOrderCount | |
| ) {} | |
| private static final int MAX_ACTIVE_ORDERS = 10; | |
| private static final BigDecimal DISCOUNT_THRESHOLD | |
| = new BigDecimal("10000"); | |
| private static final BigDecimal DISCOUNT_RATE | |
| = new BigDecimal("0.1"); | |
| @Override | |
| public Order apply(Input input) { | |
| if (input.activeOrderCount() >= MAX_ACTIVE_ORDERS) { | |
| throw new DomainException( | |
| "注文数の上限に達しています"); | |
| } | |
| var total = calculateTotal(input.lines()); | |
| var items = input.lines().stream() | |
| .map(l -> new OrderItem( | |
| l.product().id(), l.quantity())) | |
| .toList(); | |
| return new Order( | |
| new OrderId(UUID.randomUUID().toString()), | |
| items, | |
| input.address(), | |
| total, | |
| OrderStatus.CREATED | |
| ); | |
| } | |
| private Money calculateTotal(List<OrderLine> lines) { | |
| var subtotal = lines.stream().reduce( | |
| new Money(BigDecimal.ZERO), | |
| (acc, line) -> acc.add( | |
| line.product().price() | |
| .multiply(line.quantity().value()) | |
| ), | |
| Money::add | |
| ); | |
| if (subtotal.value() | |
| .compareTo(DISCOUNT_THRESHOLD) >= 0) { | |
| var discounted = subtotal.value().multiply( | |
| BigDecimal.ONE.subtract(DISCOUNT_RATE) | |
| ); | |
| return new Money(discounted); | |
| } | |
| return subtotal; | |
| } | |
| } | |
| class CancelOrder | |
| implements Function<Order, Order> { | |
| @Override | |
| public Order apply(Order order) { | |
| if (order.status() != OrderStatus.CREATED) { | |
| throw new DomainException( | |
| "作成済み以外の注文はキャンセルできません"); | |
| } | |
| return new Order( | |
| order.id(), order.items(), | |
| order.address(), order.total(), | |
| OrderStatus.CANCELLED | |
| ); | |
| } | |
| } | |
| class CalculateRefund | |
| implements Function<Order, Money> { | |
| @Override | |
| public Money apply(Order order) { | |
| return switch (order.status()) { | |
| case CANCELLED -> order.total(); | |
| case SHIPPED -> new Money( | |
| order.total().value() | |
| .multiply(new BigDecimal("0.8"))); | |
| default -> throw new DomainException( | |
| "返金対象外の状態です"); | |
| }; | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import static net.unit8.raoh.encode.ObjectEncoders.*; | |
| Encoder<Money, BigDecimal> moneyEnc = | |
| decimal().contramap(Money::value); | |
| Encoder<Order, Map<String, Object>> orderEnc = object( | |
| property("id", Order::id, string().contramap(OrderId::value)), | |
| property("total", Order::total, moneyEnc), | |
| property("status", Order::status, enumOf(OrderStatus.class)), | |
| property("items", Order::items, list(object( | |
| property("productId", OrderItem::productId, string().contramap(ProductId::value)), | |
| property("quantity", OrderItem::quantity, int_().contramap(Quantity::value)) | |
| ))) | |
| ); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| @RestController | |
| @RequestMapping("/orders") | |
| class OrderController { | |
| private final OrderRepository orders; | |
| private final ProductRepository products; | |
| private final PaymentGateway payment; | |
| private final CreateOrder createOrder = new CreateOrder(); | |
| private final CancelOrder cancelOrder = new CancelOrder(); | |
| private final CalculateRefund calcRefund | |
| = new CalculateRefund(); | |
| @PostMapping | |
| ResponseEntity<?> create(@RequestBody JsonNode body) { | |
| return switch (createOrderDec(products).decode(body)) { | |
| case Ok(var lines, var address) -> { | |
| var activeCount | |
| = orders.countActive(currentUserId()); | |
| var order = createOrder.apply( | |
| new CreateOrder.Input( | |
| lines, address, activeCount | |
| ) | |
| ); | |
| payment.authorize(order.total()); | |
| orders.save(order); | |
| yield ResponseEntity.status(201) | |
| .body(orderEnc.encode(order)); | |
| } | |
| case Err(var issues) -> | |
| ResponseEntity.badRequest() | |
| .body(issues.toJsonList()); | |
| }; | |
| } | |
| @PostMapping("/{id}/cancel") | |
| ResponseEntity<?> cancel(@PathVariable String id) { | |
| var order = orders.findById(new OrderId(id)); | |
| var cancelled = cancelOrder.apply(order); | |
| orders.save(cancelled); | |
| return ResponseEntity.ok( | |
| orderEnc.encode(cancelled)); | |
| } | |
| @PostMapping("/{id}/refund") | |
| ResponseEntity<?> refund(@PathVariable String id) { | |
| var order = orders.findById(new OrderId(id)); | |
| var amount = calcRefund.apply(order); | |
| payment.refund(order.id(), amount); | |
| return ResponseEntity.ok(Map.of( | |
| "refundAmount", amount.value())); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment