Architect
Standards
ReferenceNormative

Spring Boot

A modular monolith with Spring Modulith, hexagonal inside every module, one deployable — and architecture tests as the load-bearing part.

Verified 2026-07-31 · Against Spring Boot 4.1, Spring Modulith, springdoc-openapi, Flyway, ShedLock, Testcontainers.

Modular monolith with Spring Modulith, hexagonal inside every module, one deployable. Start here for any non-trivial Spring service: module boundaries become compiler- and test-enforced without paying the distributed-systems tax up front. Extract a service later only when a module needs its own scaling or release cadence — the boundary is already drawn, so extraction is mechanical.

Never start from the layered controller/service/repository/dto/mapper default. It scales in file count, not comprehension, and it puts every business rule behind a database.

Module layout

One Maven module per business capability; the root package annotated @ApplicationModule, with no classes in the module root package.

<module>/
  core/  model/ usecase/ port/     ← three packages, no others
  inbound/   web|messaging|scheduling|mcp
  outbound/  persistence|external
  api/  api/event/                 ← the cross-module contract, @NamedInterface
  config/
  • A module depends on another only through that module's api package. Enforce with Modulith verify() and spring.modulith.detection-strategy=explicitly-annotated; it rejects cycles in the same test.
  • Integration events belong in api/event, outside core. An event in core is module-internal and therefore unreachable by the consumer it exists for. Guard it with an ArchUnit rule on the event marker interface — verify() alone stays green while a contract is merely unreachable, because no module has yet committed the violation.
  • No service, mapper, repository, query or dto package. Those name mechanisms, not roles, and they drain logic out of the model into procedure bags.

Web layer

  • A handler receives its caller; it never looks it up. A RequestContext record resolved by a HandlerMethodArgumentResolver, declared as the handler's first argument, always. New request facts — tenant, locale, channel — then join the record and the resolver, and no controller signature changes. Enforce the position with ArchUnit.
  • Errors as RFC-7807 ProblemDetail from a single @RestControllerAdvice.
  • A command holds strings; a port holds types. The request record is the only place valueOf runs, after validation — otherwise a bad filter value returns 500 instead of 400.

springdoc

  • Annotate only what is true of that endpoint: @Operation(summary) and the business 409s it can actually produce — and a 409 must name what conflicts. Do not annotate the success status; springdoc derives it from @ResponseStatus and the return type.
  • Never write 401/403/404/422/500 on a handler. They are the platform's error contract. Add them from one GlobalOperationCustomizer that reads the code the runtime uses — 401/403 only where @PreAuthorize is present, 404 only where a @PathVariable is (a collection endpoint cannot fail to find one resource). The document then cannot claim behaviour the code does not have.
  • Declare accepted values once — a custom annotation on the record component, carried into request schemas and query parameters by property and parameter customizers. @Schema(allowableValues=…) never gets written, so it cannot drift from the validator.
  • Bind query parameters as one @ParameterObject record, not a list of @RequestParams.
  • Do not nest Input/Output inside the use-case class. Every nested command then shares the simple name Input, springdoc collides the schemas, and each needs a @Schema(name=…) workaround. Use top-level <UseCase>Input / <UseCase>Output files instead.
  • Assert the generated document in an integration test. A controller annotation is only safe to delete if something demonstrably replaces it.

Leak trap: springdoc documents any handler argument it does not recognise as a query parameter. Register argument-resolver-supplied types with SpringDocUtils.addRequestWrapperToIgnore — otherwise the request context is published as a required query parameter and drags the current-user schema, roles and permissions included, into the public document.

Persistence and migrations

  • Flyway, per-module ownership. Migrations under db/migration/<domain>; the application lists the locations. Filename vYYMMddHHmm_description.sql.
  • Migrations are immutable once shared. No exceptions.
  • A conditional UPDATE is a concurrency guard, never the rule. The write port takes the state the decision was made from, and returns false when another transaction won the race.
  • Reads never round-trip through the domain model. Project rows straight to a DTO — the model exists to decide, and a response is not a decision.
  • JPA @Entity is the one standing exception to records-everywhere.

Messaging

Never publish ad hoc from a business module. One EventPublisher wraps events in a versioned envelope — event id, correlation and causation ids, producer — and hands delivery to the Spring Modulith event publication registry: an event_publication table, externalised to Kafka after commit, republished on restart. Topic routing lives in one EventExternalizationConfiguration.

This buys transactional-outbox semantics without writing an outbox. Delivery is at-least-once — consumer idempotency remains the consumer's job.

Scheduled jobs

The application scales horizontally, so a bare @Scheduled fires once per instance. Every scheduled method also carries @SchedulerLock(name="<module>.<job>", lockAtMostFor, lockAtLeastFor) — ShedLock, DB-timed so pod clock skew cannot double-fire.

lockAtMostFor must outlive the job's slowest run; a run that exceeds it executes twice. Enforce with ArchUnit.

Observability

  • Do not hand-write logger fields and entry/exit messages. One @Traced annotation on a type or method logs arguments, result, duration and failure. Meta-annotate the @UseCase stereotype with it, so an operation is traced by being one.
  • Tracing is silent by default, and that is the point. A value contributes only its type name; it prints in full only if it is an identifier, a scalar that cannot carry personal data, or it implements a LogSafe opt-in. Never make a DTO print itself to "improve" a trace — adding a field must never be able to leak it. Opt in with a summary (Page[count=3, total=57]), not contents. See secrets.
  • What survives per class is LOG.warn on rejection paths: they name why something was refused, which no generic aspect can know.

Architecture tests are the load-bearing part

Every convention above is worth an ArchUnit or Modulith rule — package set, core purity, model-framework purity, port shape, the execute entry point, the object-return rule, command and result placement, handler argument order, entity placement, scheduler locks, the event contract.

A convention in a document decays; a convention in a test cannot. Add the rule in the same commit as the convention.

Repository hygiene that paid off

Per-module living documents — shipped.md as the canonical inventory (an absent capability is not shipped, even if code exists), backlog.md, domain.md — plus a hand-curated module changelog, enforced by a pre-commit hook the build installs itself (git-build-hook-maven-plugin at mvn initialize), so there is no manual setup step.

Conventional Commits and semantic-release for versions; never hand-edit the generated root changelog.

Trap: when CI pushes chore(release) commits to main, a local main goes stale within minutes. Fetch and rebase before every push.

On this page