Build it: the developer view

Starting a feature = asking questions. The questions are general (same in every project). The answers are SecurePay's. Your answers become your list of classes.

You writea class You configureyml, @Bean or DSL line Spring / no code

Any new feature: ask these first

Go through the list, write one-line answers, skip what doesn't apply. Each line opens the card with the detailed questions.

  1. Security — Who calls it and what are they allowed to do? Whose data is it?
  2. REST API — Which URL, method, request, response and status codes?
  3. Errors — What can go wrong and what does the caller see?
  4. Database — What is stored or changed? Which queries?
  5. Transactions — What must succeed or fail together?
  6. Other services — Which services do I call? What if they are down?
  7. Events — Who needs to know afterwards?
  8. Observability — How will I see it working (or failing) in production?
  9. Beans & config — Which classes, which settings, what changes per environment?

Then write in this order: DTOs → entity + migration → repository → service (+ transaction, rules) → controller → error mapping → security rule → events → tests.

1

Beans & dependency injection

Spring Core
  1. 1Which classes hold business logic or talk to other systems?
    SecurePayPayment rules, refunds, the Service B client, the event publisher.
    You write@Service / @Component classes with constructor injection (final fields, one constructor, no @Autowired needed).
  2. 2Which objects come from a library, so I can't put an annotation on them?
    SecurePayClock (for testable time), a configured WebClient.
    You write@Configuration class (e.g. AppConfig) with @Bean methods.
  3. 3Is there more than one implementation of the same interface?
    SecurePayFraudCheck: real rules in prod, a no-op locally.
    You configure@Profile("local") or @ConditionalOnProperty to choose one; @Primary / @Qualifier if both must exist.
  4. 4Does any bean keep data that changes per request or per user?
    SecurePayNo. Request data travels as method parameters; beans are singletons shared by all threads.
    Spring / no codeKeep beans stateless (no mutable fields). Only if really needed: request scope or ObjectProvider.
  5. 5Do two services need each other?
    SecurePayPaymentService ↔ RefundService would be circular.
    You writeMove the shared logic into a third class, or publish an event. Don't hide it with @Lazy.
  6. 6Can I unit-test the class without starting Spring?
    SecurePayYes, because of constructor injection.
    You writenew PaymentService(mockRepo, Clock.fixed(...)) in a plain JUnit + Mockito test.

You write

  • service/PaymentService, RefundService
  • config/AppConfig (@Bean Clock, clients)
  • one implementation per profile where needed
  • unit tests with constructor injection

Spring gives you

  • ApplicationContext / DefaultListableBeanFactory
  • component scanning from the @SpringBootApplication package
  • AutowiredAnnotationBeanPostProcessor (injection)
2

Bean lifecycle (startup & shutdown)

Spring Core
  1. 1Does something need to be loaded or warmed up once at startup?
    SecurePayFee table / currency list cached in memory.
    You write@PostConstruct for simple setup inside the bean. If it needs transactions or other fully-ready beans: @EventListener(ApplicationReadyEvent.class) or an ApplicationRunner (@PostConstruct runs before the proxy exists).
  2. 2Should the app refuse to start if a setting is missing or wrong?
    SecurePayYes: no Service B URL or no issuer-uri = broken service.
    You configure@Validated @ConfigurationProperties with @NotNull / @NotBlank → startup fails fast.
  3. 3Is there something to close or finish on shutdown?
    SecurePayAn executor, in-flight requests.
    You configure@PreDestroy or @Bean(destroyMethod = ...). Graceful shutdown for the web server is on by default since Boot 3.4 (server.shutdown).
  4. 4Do I need a new object each time instead of one shared bean?
    SecurePayA stateful report/batch builder.
    You writeUsually just new it. If it must be a bean: prototype scope + ObjectProvider<T>.getObject().
  5. 5Do I need to change or wrap every bean as it is created?
    SecurePayNo, not in SecurePay.
    Spring / no codeThat is a BeanPostProcessor — framework territory; application code almost never needs one.

You write

  • @PostConstruct / ApplicationReadyEvent listener for warm-up
  • @PreDestroy where you own a resource
  • validated @ConfigurationProperties

Spring gives you

  • instantiate → inject → Aware → BPP before-init (@PostConstruct) → init → BPP after-init (proxies)
  • context close hook, graceful shutdown
3

AOP (cross-cutting logic)

Spring Core
  1. 1Is the same extra logic repeated around many methods?
    SecurePayTiming + audit log around every money-moving method.
    You writeAn @Aspect @Component class (e.g. AuditAspect) with @Around.
  2. 2Is it about HTTP requests or about Java methods?
    SecurePayCorrelation id per request = HTTP. Audit per business method = method.
    You writeHTTP → OncePerRequestFilter / HandlerInterceptor. Method → aspect.
  3. 3How do I choose exactly which methods?
    SecurePayOnly methods marked as auditable.
    You writeOwn annotation @Audited + pointcut @Around("@annotation(audited)").
  4. 4Is it already solved by Spring?
    SecurePayTiming/metrics: yes.
    You configureMicrometer @Observed / @Timed (enable with management.observations.annotations.enabled=true) instead of a hand-written timing aspect.
  5. 5Must it run before or after @Transactional / security?
    SecurePayAudit should wrap the transaction.
    You configure@Order on the aspect: lower value = outer (runs first).
  6. 6Is the method public and called from another bean?
    SecurePayA call via this.method() inside the same class is not advised.
    Spring / no codeCall it through another bean. Keep advised methods public.

You write

  • aop/AuditAspect (@Aspect @Component)
  • annotation/Audited
  • web/CorrelationIdFilter (HTTP-level)

Spring gives you

  • AnnotationAwareAspectJAutoProxyCreator → CGLIB proxy
  • spring-boot-starter-aop (aspectjweaver) turns it on
4

@Transactional

Spring Core
  1. 1Which database writes must all succeed or all fail?
    SecurePayRefund: insert the Refund row + change Payment status.
    You write@Transactional on the public service method (RefundService.refund), not on the controller or repository.
  2. 2Is this method only reading?
    SecurePayGET payment, list payments.
    You configure@Transactional(readOnly = true) (Hibernate skips dirty checking; lazy fields load inside).
  3. 3Which exceptions should roll back?
    SecurePayBusiness failures like "refund exceeds amount".
    You writeMake them RuntimeExceptions, or rollbackFor = Exception.class — checked exceptions commit by default.
  4. 4Must something be saved even if the main work rolls back?
    SecurePayAudit row "refund attempt failed".
    You writeSeparate bean AuditService with @Transactional(propagation = REQUIRES_NEW).
  5. 5Does the transaction call a slow external system?
    SecurePayCalling Service B / a gateway would hold the DB connection.
    You writeCall it outside the transaction, or split: tx1 save PENDING → call → tx2 update.
  6. 6Can two requests change the same row at once?
    SecurePayTwo refunds of the same payment.
    You write@Version on the entity (optimistic lock) → 409 on conflict. See JPA.
  7. 7Does something happen only after the commit?
    SecurePayPublish PaymentRefunded to Kafka.
    You write@TransactionalEventListener(phase = AFTER_COMMIT), or an outbox table for guaranteed delivery.

You write

  • service/RefundService (@Transactional)
  • service/AuditService (REQUIRES_NEW)
  • exception classes extending RuntimeException

Spring gives you

  • TransactionInterceptor (proxy)
  • JpaTransactionManager (auto-configured)
  • TransactionSynchronizationManager (connection bound to the thread)
5

Spring Boot: starters & configuration

Spring Boot
  1. 1Which capabilities does this service need?
    SecurePayREST API, JPA, JWT security, validation, health.
    You configureStarters in pom.xml: web, data-jpa, oauth2-resource-server, validation, actuator + DB driver.
  2. 2Which values change per environment?
    SecurePayDB URL, issuer-uri, Service B URL, timeouts.
    You configureapplication.yml + application-local.yml / profiles; secrets from environment variables, never committed.
  3. 3Do I have my own settings?
    SecurePaysecurepay.service-b.base-url, securepay.service-b.timeout.
    You write@ConfigurationProperties("securepay") record (e.g. SecurePayProperties) + @ConfigurationPropertiesScan.
  4. 4Is Boot's default bean good enough?
    SecurePayJSON should skip nulls.
    You configureChange it with a property (spring.jackson.default-property-inclusion=non_null) or a customizer bean. Defining your own ObjectMapper makes Boot back off completely.
  5. 5Why is (or isn't) something auto-configured?
    SecurePayDataSource not created in a test.
    Spring / no codeRun with --debug (conditions report) or /actuator/conditions.

You write

  • config/SecurePayProperties (record)
  • application.yml + profile files
  • customizer beans only where a property is not enough

Spring gives you

  • SpringApplication.run, embedded Tomcat
  • auto-configurations from AutoConfiguration.imports
  • @ConditionalOnMissingBean back-off
6

REST API (Spring MVC)

Spring MVC
  1. 1What URL, HTTP method and status codes?
    SecurePayPOST /payments → 201 + Location; GET /payments/{id} → 200 / 404.
    You writePaymentController: @RestController, @RequestMapping("/payments"), ResponseEntity.created(uri).
  2. 2What does the client send, and what is valid?
    SecurePayamount > 0, 3-letter currency, merchantReference.
    You writeCreatePaymentRequest record with @NotNull, @Positive, @Pattern; @Valid @RequestBody.
  3. 3What do I send back?
    SecurePayid, status, amount, createdAt — never the entity.
    You writePaymentResponse record + a small PaymentMapper.
  4. 4Any path variables, query params, headers?
    SecurePayid, page/size/status filter, Idempotency-Key.
    You write@PathVariable, @RequestParam / Pageable, @RequestHeader("Idempotency-Key").
  5. 5Does it return a list that can grow?
    SecurePayPayments of a merchant: page it.
    You configurePageable in, a DTO page out (or @EnableSpringDataWebSupport(pageSerializationMode = VIA_DTO)) — returning Page<T> directly logs a warning.
  6. 6Where does the logic go?
    SecurePayController only translates HTTP ↔ Java.
    You writeController calls one service method; rules live in PaymentService.
  7. 7Is anything needed for every request?
    SecurePayCorrelation id header, request logging.
    You writeOncePerRequestFilter (before Spring MVC) or HandlerInterceptor (around the handler).
  8. 8How do I test only the web layer?
    SecurePayStatus codes, JSON shape, validation errors.
    You write@WebMvcTest(PaymentController.class) + MockMvc + @MockitoBean PaymentService.

You write

  • web/PaymentController
  • web/dto/CreatePaymentRequest, PaymentResponse
  • web/PaymentMapper
  • web/CorrelationIdFilter (optional)
  • PaymentControllerTest

Spring gives you

  • DispatcherServlet, RequestMappingHandlerMapping / Adapter
  • Jackson message converter (JSON ↔ records)
  • Bean Validation (Hibernate Validator)
7

Error handling

Spring MVC
  1. 1What business failures can happen?
    SecurePayPayment not found, already refunded, refund too large.
    You writeOwn exceptions: PaymentNotFoundException, RefundNotAllowedException (extend RuntimeException).
  2. 2Which status code for each?
    SecurePay404, 409, 422.
    You writeGlobalErrorHandler: @RestControllerAdvice with @ExceptionHandler methods returning ProblemDetail.
  3. 3What JSON shape do clients get?
    SecurePayRFC 9457 ProblemDetail for everything.
    You configureExtend ResponseEntityExceptionHandler (or spring.mvc.problemdetails.enabled=true) so Spring's own errors use the same shape.
  4. 4What about invalid input?
    SecurePay@Valid fails → 400 with field errors.
    You writeOverride handleMethodArgumentNotValid to add the field list.
  5. 5What about unexpected errors?
    SecurePay500, no stack trace to the client, full log with traceId.
    You writeA catch-all @ExceptionHandler(Exception.class) that logs and returns a generic ProblemDetail — plus an @ExceptionHandler(AccessDeniedException.class) that returns 403, because @PreAuthorize denials pass through the advice.
  6. 6What about 401/403?
    SecurePayURL rules fail in the security filter chain, before the controller.
    You configureNot the advice — Security's AuthenticationEntryPoint / AccessDeniedHandler. Keep /error permitted.

You write

  • exception/PaymentNotFoundException, RefundNotAllowedException
  • web/GlobalErrorHandler (@RestControllerAdvice)

Spring gives you

  • ExceptionHandlerExceptionResolver
  • DefaultHandlerExceptionResolver (415, 405…)
  • BasicErrorController (/error)
8

Database (Spring Data JPA)

Spring Data JPA
  1. 1What do I store and what identifies it?
    SecurePayPayment: id, amount, currency, status, merchantId, createdAt.
    You write@Entity Payment: BigDecimal amount, @Enumerated(EnumType.STRING) status, generated id.
  2. 2How are the tables related?
    SecurePayOne payment has many refunds.
    You writeRefund with @ManyToOne(fetch = LAZY); Payment with @OneToMany(mappedBy = "payment").
  3. 3Which queries do I need?
    SecurePayBy id, by merchant + status with paging.
    You writePaymentRepository extends JpaRepository<Payment, Long>; findByMerchantIdAndStatus(..., Pageable); @Query for complex ones.
  4. 4What must be loaded together?
    SecurePayPayment detail shows its refunds.
    You write@EntityGraph(attributePaths = "refunds") or JOIN FETCH — avoid N+1.
  5. 5Can two users change the same row?
    SecurePayTwo refunds at once.
    You write@Version Long version → OptimisticLockingFailureException → map to 409.
  6. 6What must be unique?
    SecurePayIdempotency key per merchant.
    You writeA unique constraint in the database (not only a check in Java).
  7. 7Who creates the tables?
    SecurePayVersioned scripts, same in every environment.
    You configureFlyway: V1__create_payment.sql; spring.jpa.hibernate.ddl-auto=validate (Postgres also needs flyway-database-postgresql).
  8. 8Should lazy loading happen while writing the JSON response?
    SecurePayNo — load what you need in the service.
    You configurespring.jpa.open-in-view=false (default true, logs a warning, hides N+1).
  9. 9How do I test queries?
    SecurePayAgainst a real Postgres.
    You write@DataJpaTest + Testcontainers (@ServiceConnection).

You write

  • domain/Payment, Refund (@Entity)
  • repository/PaymentRepository, RefundRepository
  • db/migration/V1__…sql
  • PaymentRepositoryTest

Spring gives you

  • SimpleJpaRepository (the implementation of your interface)
  • EntityManager / persistence context, dirty checking
  • HikariCP DataSource
9

Security (JWT resource server)

Spring Security
  1. 1Who calls this service, and how do they prove who they are?
    SecurePayClients and Service A, with a JWT from the Auth Server :9000.
    You configurespring-boot-starter-oauth2-resource-server + spring.security.oauth2.resourceserver.jwt.issuer-uri.
  2. 2Which endpoints are open without a token?
    SecurePay/actuator/health/**, /error.
    You writeSecurityConfig with a SecurityFilterChain bean, permitAll() for those.
  3. 3Who can do what?
    SecurePayPOST needs payments.write, GET needs payments.read.
    You configurehasAuthority("SCOPE_payments.write") rules, or @EnableMethodSecurity + @PreAuthorize.
  4. 4Do I need the caller's identity in my code?
    SecurePaymerchantId / sub from the token.
    You write@AuthenticationPrincipal Jwt jwt in the controller; a JwtAuthenticationConverter bean if roles come from a custom claim.
  5. 5Stateful or stateless?
    SecurePayStateless API.
    You configureSessionCreationPolicy.STATELESS, CSRF off (no cookies).
  6. 6What does the client see on 401 / 403?
    SecurePayDefault is fine; JSON body optional.
    You configureCustom AuthenticationEntryPoint / AccessDeniedHandler only if you want ProblemDetail.
  7. 7Can user A see user B's payment?
    SecurePayMust not — most people forget this.
    You writeOwnership check in PaymentService: token merchantId vs payment.merchantId → 404/403. The filter chain can't do this.
  8. 8How do I prove it works?
    SecurePayNo token 401, wrong scope 403, correct 201.
    You write@WebMvcTest + @Import(SecurityConfig.class) + jwt() request post-processor (the slice does not load your SecurityConfig by itself).

You write

  • config/SecurityConfig (SecurityFilterChain)
  • ownership check in PaymentService
  • @AuthenticationPrincipal Jwt in controllers
  • PaymentControllerSecurityTest

Spring gives you

  • BearerTokenAuthenticationFilter
  • JwtDecoder (from issuer-uri, keys from JWKS)
  • JwtAuthenticationProvider, ExceptionTranslationFilter, AuthorizationFilter
10

Calling another service (A → B)

Spring Cloud / WebClient
  1. 1Which remote calls does the feature make?
    SecurePayA calls B POST /payments.
    You writeOne client class per remote service: ServiceBClient (or an @HttpExchange interface).
  2. 2Where do the URL and timeouts come from?
    SecurePayConfig, different per environment.
    You configureProperties + a client @Bean built from Boot's WebClient.Builder / RestClient.Builder (keeps tracing and metrics).
  3. 3Whose identity goes to B?
    SecurePayThe user's JWT (relay).
    You configureRelay: ServletBearerExchangeFilterFunction. Service's own identity: OAuth2 client + client_credentials.
  4. 4What if B is slow or down?
    SecurePayFail fast with 503, don't hang threads.
    You configureTimeout + @CircuitBreaker(name = "serviceB", fallbackMethod = ...) (Resilience4j) with a narrow fallback. Needs resilience4j-spring-boot3 + spring-boot-starter-aop.
  5. 5Is it safe to retry?
    SecurePayPOST could create a duplicate payment.
    You writeSend an Idempotency-Key; B stores it with a unique constraint; retry only safe failures.
  6. 6How do B's errors become A's response?
    SecurePayB 404 → A 404, B down → A 503.
    You writeonStatus(...) in the client → your exceptions → GlobalErrorHandler.
  7. 7How do I test without B running?
    SecurePayFake B.
    You writeWireMock or OkHttp MockWebServer.

You write

  • client/ServiceBClient
  • config/ClientConfig (@Bean WebClient)
  • client exceptions + mapping
  • ServiceBClientTest (WireMock)

Spring gives you

  • WebClient / RestClient + Boot builder (observation, codecs)
  • Resilience4j auto-config (resilience4j-spring-boot3)
  • LoadBalancer if you use Eureka service names
11

Events (Kafka)

Spring for Apache Kafka
  1. 1Who else needs to know when this happens?
    SecurePayNotifications and ledger need "payment created/refunded".
    You writeEvent record PaymentCreatedEvent + PaymentEventPublisher using KafkaTemplate.
  2. 2Which topic and which key?
    SecurePaypayments.events, key = paymentId (order per payment).
    You configureConstant for the name; NewTopic bean (TopicBuilder) for local dev.
  3. 3When do I send, relative to the DB commit?
    SecurePayOnly after the payment is really saved.
    You write@TransactionalEventListener (phase defaults to AFTER_COMMIT); if losing an event is not OK → outbox table + relay.
  4. 4What if a consumer gets the same event twice?
    SecurePayAt-least-once delivery: it will happen.
    You writeIdempotent consumer: processed-event id table with a unique key.
  5. 5What if processing keeps failing?
    SecurePayRetry a few times, then park it.
    You configureDefaultErrorHandler + DeadLetterPublishingRecoverer + FixedBackOff → payments.events-dlt.
  6. 6How are events serialized?
    SecurePayJSON.
    You configureJsonSerializer / JsonDeserializer (trusted packages) wrapped in ErrorHandlingDeserializer.
  7. 7How do I test it?
    SecurePayReal broker in the test.
    You writeTestcontainers Kafka (@ServiceConnection) or @EmbeddedKafka.

You write

  • event/PaymentCreatedEvent (record)
  • event/PaymentEventPublisher
  • consumer/NotificationListener (@KafkaListener)
  • config/KafkaErrorConfig (DefaultErrorHandler bean)

Spring gives you

  • KafkaTemplate, ProducerFactory / ConsumerFactory (auto-config from spring.kafka.*)
  • listener container, offset commits
  • DLT publishing
12

Observability (health, metrics, traces, logs)

Micrometer / Actuator
  1. 1How do we know the service is alive and ready?
    SecurePayKubernetes / load balancer probes.
    You configureActuator health: /actuator/health/liveness and /readiness (automatic on Kubernetes; elsewhere management.endpoint.health.probes.enabled=true). DB is in overall health; add it to readiness only on purpose (management.endpoint.health.group.readiness.include=readinessState,db), never to liveness.
  2. 2Which endpoints are exposed?
    SecurePayhealth, info, prometheus — not everything.
    You configuremanagement.endpoints.web.exposure.include=health,info,prometheus.
  3. 3Who can reach actuator?
    SecurePayHealth for probes; the rest only internally.
    You configureExpose only what you need; consider management.server.port; show-details stays never (default) on the public side.
  4. 4Which business numbers matter?
    SecurePayPayments created / failed, refund latency.
    You writeMeterRegistry counter / timer, or @Observed (set management.observations.annotations.enabled=true + spring-boot-starter-aop).
  5. 5Can I follow one request across A → B → Kafka?
    SecurePayYes, one traceId everywhere.
    You configuremicrometer-tracing-bridge-otel + an exporter; clients built from Boot's builders propagate headers. For Kafka: spring.kafka.template.observation-enabled=true and spring.kafka.listener.observation-enabled=true.
  6. 6What goes into the logs?
    SecurePayBusiness events with paymentId, never card data or tokens.
    You writeSLF4J logger; traceId/spanId added by Boot's log correlation.
  7. 7How much tracing in prod?
    SecurePayA sample, not every request.
    You configuremanagement.tracing.sampling.probability (default 0.1).

You write

  • metrics in services (MeterRegistry / @Observed)
  • meaningful log lines
  • management.* properties

Spring gives you

  • Actuator endpoints, health indicators
  • ObservationRegistry, http.server.requests metrics
  • trace context propagation, log correlation

Worked example: refund a payment (Service B)

The same questions answered for one feature. The last column is the class list you start from.

QuestionAnswerBuild
SecurityNeeds scope payments.refund; merchant can refund only its own payments.SecurityConfig rule + ownership check in RefundService
REST APIPOST /payments/{id}/refunds {amount, reason} → 201 RefundResponse.RefundController, RefundRequest, RefundResponse
ErrorsNot found 404, already fully refunded 409, amount too big 422.RefundNotAllowedException + handlers in GlobalErrorHandler
DatabaseNew refund table, payment gets refundedAmount + version.Refund entity, RefundRepository, V3__refunds.sql, @Version on Payment
TransactionsInsert refund + update payment together.@Transactional RefundService.refund
Other servicesNone for this feature.—
EventsLedger must know → PaymentRefunded after commit.PaymentRefundedEvent + publisher (AFTER_COMMIT)
ObservabilityCount refunds, log paymentId + amount.Counter "securepay.refunds" + log line
TestsRules, HTTP, security, DB.RefundServiceTest, RefundControllerTest, RefundRepositoryTest