← All posts

How I Structure a Spring Boot Microservice

11 Dec 20258 min read
Spring BootArchitectureMicroservices

Every Spring tutorial ships the same three packages: controller, service, repository. It is fine for a demo and it ages badly. At forty classes you have three folders holding pieces of eight unrelated features, and adding one feature means touching all three while reading none of them fully.

The layout below is what I have converged on. It is not clever — the value is entirely in the boundaries it makes hard to cross by accident.

Package by feature, not by layer

src/main/java/com/example/orders
orders/
├─ OrderApplication.java
├─ config/                 # security, kafka, jackson, openapi
│   ├─ SecurityConfig.java
│   └─ KafkaConfig.java
├─ shared/                 # cross-feature only. resist filling this.
│   ├─ error/
│   │   ├─ ApiError.java
│   │   └─ GlobalExceptionHandler.java
│   └─ domain/Money.java
├─ order/                  # <- a feature owns its whole vertical
│   ├─ api/
│   │   ├─ OrderController.java
│   │   ├─ PlaceOrderRequest.java
│   │   └─ OrderView.java
│   ├─ domain/
│   │   ├─ Order.java             # no Spring imports below this line
│   │   ├─ OrderStatus.java
│   │   └─ OrderRepository.java   # interface, defined by the domain
│   ├─ application/
│   │   └─ PlaceOrderService.java
│   └─ infrastructure/
│       ├─ JpaOrderRepository.java
│       └─ OrderEventPublisher.java
└─ catalog/
    └─ ...

Everything about orders is in one place. Deleting the feature is deleting a directory. More usefully, a new engineer reading order/ sees the whole story without opening anything else.

The one rule that makes it work

Dependencies point inwards. api may call application, which may call domain. Nothing points back out. infrastructure implements interfaces that domain declares.

This is hexagonal architecture with less ceremony. The payoff is that domain logic is testable without a Spring context, a database, or a broker — which in practice means it actually gets tested.

order/domain/Order.java
/**
 * No @Entity, no @Component, no Spring on the classpath here.
 * Just the rules about what an order is allowed to do.
 */
public final class Order {

  private final OrderId id;
  private final CustomerId customer;
  private final List<OrderLine> lines;
  private OrderStatus status;

  public void cancel(Clock clock) {
    if (status == OrderStatus.SHIPPED) {
      throw new OrderAlreadyShipped(id);
    }
    if (status == OrderStatus.CANCELLED) {
      return;                       // cancelling twice is not an error
    }
    this.status = OrderStatus.CANCELLED;
    this.cancelledAt = clock.instant();
  }

  public Money total() {
    return lines.stream().map(OrderLine::subtotal).reduce(Money.ZERO, Money::plus);
  }
}

Clock is injected rather than calling Instant.now() inside. Time is an input like any other, and a domain that hides it is a domain you cannot test around midnight or across a month boundary.

On separate JPA entitiesKeeping the domain model and the JPA entity as separate classes with a mapper is the purest form of this, and it costs a mapping layer. On small services I let the entity be the domain model and keep the annotations confined to it. Pick deliberately — the failure mode is drifting into the mapping layer without deciding to.

Controllers stay thin

A controller translates HTTP into a call and back. If there is a conditional in it about business rules, that logic is in the wrong place.

order/api/OrderController.java
@RestController
@RequestMapping("/api/v1/orders")
@RequiredArgsConstructor
class OrderController {

  private final PlaceOrderService placeOrder;

  @PostMapping
  @PreAuthorize("hasAuthority('SCOPE_order:write')")
  ResponseEntity<OrderView> place(@Valid @RequestBody PlaceOrderRequest request,
                                  @AuthenticationPrincipal Jwt principal) {

    Order order = placeOrder.handle(request.toCommand(principal.getSubject()));

    return ResponseEntity
        .created(URI.create("/api/v1/orders/" + order.id()))
        .body(OrderView.from(order));
  }
}

Note the class is package-private. Nothing outside order.api should be calling a controller directly, and the compiler can enforce that for free. Spring does not need public classes.

Never expose entities

Returning a JPA entity from a controller couples your public API to your schema — a column rename becomes a breaking API change, and lazy associations serialise into either extra queries or a LazyInitializationException at the worst moment. Records make the boundary cheap:

order/api/OrderView.java
public record OrderView(
    String id,
    String status,
    BigDecimal total,
    String currency,
    Instant placedAt,
    List<LineView> lines) {

  public static OrderView from(Order order) {
    return new OrderView(
        order.id().value(),
        order.status().name(),
        order.total().amount(),
        order.total().currency().getCurrencyCode(),
        order.placedAt(),
        order.lines().stream().map(LineView::from).toList());
  }
}

One place for errors

Scattered try/catch produces inconsistent error shapes, and clients end up parsing three different formats from one service. Handle it once, centrally, in RFC 7807 form.

shared/error/GlobalExceptionHandler.java
@RestControllerAdvice
class GlobalExceptionHandler {

  @ExceptionHandler(MethodArgumentNotValidException.class)
  ProblemDetail onValidation(MethodArgumentNotValidException ex) {
    ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
    problem.setTitle("Validation failed");
    problem.setProperty("errors", ex.getBindingResult().getFieldErrors().stream()
        .collect(toMap(FieldError::getField, FieldError::getDefaultMessage, (a, b) -> a)));
    return problem;
  }

  @ExceptionHandler(OrderNotFound.class)
  ProblemDetail onNotFound(OrderNotFound ex) {
    ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.NOT_FOUND);
    problem.setTitle("Order not found");
    problem.setDetail(ex.getMessage());
    return problem;
  }

  /** Catch-all: log the cause, tell the client nothing about it. */
  @ExceptionHandler(Exception.class)
  ProblemDetail onUnexpected(Exception ex) {
    String ref = UUID.randomUUID().toString();
    log.error("unhandled failure ref={}", ref, ex);

    ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR);
    problem.setTitle("Unexpected error");
    problem.setDetail("Something went wrong. Reference: " + ref);
    return problem;
  }
}

The reference ID is the part that pays for itself. A user reports eight characters, you grep the logs, and you have the stack trace — without ever leaking one to the client.

Configuration as typed objects

@Value scattered across classes means configuration errors surface at 3am on the code path that first needs them. Bind and validate at startup instead, so a bad environment fails the deploy rather than the request.

config/OrderProperties.java
@ConfigurationProperties(prefix = "app.orders")
@Validated
public record OrderProperties(
    @NotNull Duration paymentTimeout,
    @Positive int maxLinesPerOrder,
    @NotBlank String fulfilmentTopic) {}

Tests that match the layout

  • Domain — plain JUnit, no Spring. Milliseconds. This is where the rules get tested, so it should be where most of the tests are.
  • Application — mock the repository interfaces the domain declares.
  • Web@WebMvcTest, one slice, serialisation and status codes.
  • Integration — Testcontainers against the real PostgreSQL and Kafka. Slow, few, and the only ones that prove the wiring works.
Not H2Testing against H2 while running PostgreSQL in production tests a database you do not ship. Dialect differences in JSON columns, upserts, sequences and locking are exactly where the interesting bugs live. Testcontainers costs a few seconds of startup and removes a whole category of "works in tests, fails in prod".
The short versionGroup by feature so changes are local. Point dependencies inwards so the domain stays testable. Keep controllers dumb, never return entities, handle errors once, bind config as validated types, and test against the database you actually deploy.