Prepare for Spring Boot developer interviews with questions grouped by experience level, from core concepts to microservices architecture.
Junior (0-2 years)
Spring Boot is a framework built on top of the Spring Framework that removes most of the manual setup Spring normally requires. It was created because plain Spring needed heavy XML configuration and manual dependency wiring just to get a basic application running. Spring Boot adds auto-configuration, an embedded server, and opinionated starter dependencies so a working REST service can run with almost no boilerplate.
Spring is the underlying framework providing dependency injection, AOP, and other core capabilities, but it requires you to configure almost everything yourself. Spring Boot is built on top of Spring and auto-configures most of that setup, bundles an embedded server like Tomcat, and provides starter dependencies, so you write far less configuration for the same result.
An embedded server (Tomcat, Jetty, or Undertow) is packaged directly inside the application's JAR file instead of being installed separately on the machine. Spring Boot uses this so an application can run standalone with `java -jar`, with no separate server installation or deployment step, which is a big part of why it fits containerized deployment so well.
Starters are curated dependency bundles, like spring-boot-starter-web or spring-boot-starter-data-jpa, that pull in everything commonly needed for a given purpose in one line instead of you hunting down and version-matching a dozen individual libraries yourself.
It's the central place to configure a Spring Boot application, database connection details, server port, logging levels, custom application values, without touching code. YAML (application.yml) is often preferred for nested configuration, since flat properties files get hard to scan once you have several related settings grouped together.
Set server.port in application.properties (or application.yml) to the desired port number, for example server.port=8081. Without it, Spring Boot defaults to port 8080.
It declares the project's dependencies (which starters and libraries the project needs), the build plugins, and metadata like the Java version and packaging type. Spring Boot projects usually use Maven's pom.xml or Gradle's build.gradle, and the Spring Boot Maven/Gradle plugin is what packages the application into an executable JAR with the embedded server included.
It's a web-based (and IDE-integrated) tool for scaffolding a new Spring Boot project, letting you pick the build tool, Java version, and starter dependencies upfront, generating a ready-to-run project skeleton instead of assembling the folder structure and configuration by hand.
IoC means the framework, not your code, controls the creation and wiring of objects. Instead of a class creating its own dependencies with `new`, the Spring container creates them and hands them to the class, which inverts the usual control flow and is what makes dependency injection possible.
Dependency injection means an object's dependencies are supplied from outside rather than created internally. Spring supports constructor injection (passing dependencies through the constructor), setter injection (through setter methods), and field injection (directly annotating a field with @Autowired). Constructor injection is generally preferred since it makes dependencies explicit and required, and works well with immutable, final fields.
It's the core of the framework, responsible for creating, configuring, and managing the lifecycle of beans based on configuration metadata (annotations or XML). ApplicationContext is the most commonly used implementation of this container in modern Spring Boot applications.
A bean is simply an object that's created, configured, and managed by the Spring IoC container instead of being instantiated directly by your code with `new`. Beans are typically declared using annotations like @Component, @Service, @Repository, or @Bean methods inside a @Configuration class.
All three register a class as a Spring bean and are functionally interchangeable at the container level. The distinction is semantic: @Service marks business logic classes, @Repository marks data-access classes and additionally enables automatic translation of persistence exceptions into Spring's unified exception hierarchy, and @Component is the generic, general-purpose annotation the other two build on.
@Autowired tells Spring to automatically inject a matching bean into a field, constructor, or setter, instead of you writing the wiring code yourself. If more than one bean of the same type exists, Spring needs additional help, usually @Qualifier, to know which one to inject.
It's a convenience annotation that combines three others: @Configuration (marks the class as a source of bean definitions), @EnableAutoConfiguration (turns on Spring Boot's auto-configuration), and @ComponentScan (tells Spring where to look for components). This is why a single annotation on the main class is enough to bootstrap an entire application.
It marks a class as a source of bean definitions for the Spring container, typically containing one or more @Bean-annotated methods. It's the annotation-based replacement for the old XML configuration files Spring used before Java-based configuration became standard.
@Component is a class-level annotation used for classes you write and control yourself, auto-detected through component scanning. @Bean is a method-level annotation used inside a @Configuration class, typically for registering beans from third-party classes you don't own and can't annotate directly, like configuring a library's client object.
@Value injects a value from application.properties (or an environment variable, or a hardcoded default) directly into a field, for example @Value("${server.port}") to read the configured port into a variable, rather than manually looking up the property yourself.
Component scanning is how Spring automatically discovers classes annotated with @Component (and its specializations) without you manually registering each one. By default, @SpringBootApplication scans the package it's in and all sub-packages, which is why Spring Boot projects conventionally put the main class at the root of the package structure.
Annotate a class with @RestController, then map an HTTP method to a handler method using @GetMapping, @PostMapping, @PutMapping, or @DeleteMapping, with a path like @GetMapping("/users/{id}"). @RestController combines @Controller and @ResponseBody, so return values are automatically serialized to JSON without extra annotation.
@Controller is used for traditional web applications that return view names to be rendered as HTML (like Thymeleaf templates). @RestController is used for APIs, since it automatically serializes return values directly into the HTTP response body, usually as JSON, without needing a separate @ResponseBody annotation on every method.
@RequestMapping is the general-purpose mapping annotation that can be restricted to a specific HTTP method via its `method` attribute. @GetMapping, @PostMapping, @PutMapping, and @DeleteMapping are shorthand versions of @RequestMapping pre-configured for their respective HTTP verb, and are what most developers use directly for readability.
@PathVariable extracts a value from the URL path itself, like the {id} in /users/{id}. @RequestParam extracts a value from the query string, like ?page=2 in /users?page=2. Which one you use depends on whether the value identifies a specific resource (path variable) or filters/modifies a request (query parameter).
@RequestBody tells Spring to deserialize the incoming HTTP request body (typically JSON) directly into a Java object, so you can accept structured data in a POST or PUT request without manually parsing it.
Return a ResponseEntity instead of a plain object, for example `return ResponseEntity.status(HttpStatus.CREATED).body(user);`, which lets you control both the status code and the response body explicitly rather than always returning the default 200 OK.
Spring Data JPA sits on top of JPA/Hibernate and eliminates most boilerplate data-access code. Instead of writing implementation classes for common CRUD operations, you define an interface extending JpaRepository, and Spring generates the implementation for you at runtime.
CrudRepository provides basic CRUD operations (save, findById, delete, etc.). JpaRepository extends CrudRepository and adds JPA-specific features like batch operations and pagination support, which is why most Spring Boot projects use JpaRepository as the default choice even when they only need basic CRUD.
By following Spring Data's method-naming convention, for example findByEmailAndStatus(String email, String status) automatically generates the correct query based on the method name alone, no SQL or annotation needed, as long as the field names match the entity.
It marks a Java class as a JPA entity, meaning it maps to a database table, with each field typically corresponding to a column. It's usually paired with @Id to mark the primary key field and @GeneratedValue to control how that ID is generated.
Derived query methods are generated automatically from the method name and only work for reasonably simple conditions. @Query lets you write an explicit JPQL or native SQL query directly on the repository method, which you need once the logic is too complex for naming conventions to express clearly, like a multi-table join with custom filtering.
JPA is a specification, a set of interfaces and rules for how Java objects map to relational database tables. Hibernate is the most widely used implementation of that specification. Spring Data JPA works against the JPA interfaces, so in theory the underlying implementation could be swapped, though in practice almost every Spring Boot project uses Hibernate underneath.
It tells Spring to wrap the annotated method in a database transaction automatically, committing if the method completes successfully and rolling back if an unchecked exception is thrown, so you don't have to manually manage transaction boundaries with try-catch-finally blocks yourself.
They configure the same things, just in different formats. application.properties uses flat key-value pairs (server.port=8080). application.yml uses nested, indentation-based structure, which becomes noticeably more readable once you have several related settings grouped together, like a block of datasource configuration.
Use @ConfigurationProperties on a class annotated with a prefix, for example @ConfigurationProperties(prefix = "app.mail"), which binds all matching properties (app.mail.host, app.mail.port, etc.) onto the object's fields automatically, instead of injecting each value individually with separate @Value annotations.
It's an interface you implement to run code immediately after the application context has fully started, useful for one-off startup tasks like seeding a database with initial data or validating configuration before the application starts accepting traffic.
Mid-Level (3-6 years)
A bean goes through instantiation, then dependency injection, then any @PostConstruct-annotated initialization logic runs, after which the bean is ready for use. When the container shuts down, @PreDestroy-annotated methods run before the bean is destroyed. Understanding this matters whenever you need to hook custom setup or cleanup logic, like opening or closing a connection pool.
Singleton (default) creates one shared instance for the entire application context. Prototype creates a new instance every time the bean is requested. Request and Session scopes, used in web applications, create one instance per HTTP request or per user session respectively.
A constructor runs before dependency injection is complete, so you can't reliably use injected dependencies inside it. @PostConstruct runs after all dependencies have been injected, so it's the right place for initialization logic that depends on those injected beans being fully available.
Constructor injection makes dependencies explicit and enforces that they're provided at object creation, which lets you mark fields as final and makes the class harder to construct into an invalid, half-wired state. Field injection hides dependencies, makes unit testing harder since you can't easily pass mocks through a constructor, and allows a bean to exist temporarily without its dependencies set.
AOP lets you separate cross-cutting concerns, logic that applies across many unrelated parts of an application like logging, security checks, or transaction management, from the core business logic itself. Instead of repeating that code in every method, you define it once as an aspect and apply it declaratively to the methods that need it.
Advice is the actual code that runs at a specific point during method execution. The main types are @Before (runs before the method), @After (runs after, regardless of outcome), @AfterReturning (runs after a successful return), @AfterThrowing (runs if an exception is thrown), and @Around (wraps the method entirely, giving full control over whether and how it executes).
A common use is measuring method execution time: @Around advice captures a timestamp before calling proceed() on the intercepted method, then captures another timestamp after, logging the difference, all without touching the business logic of the method itself.
Use @ControllerAdvice combined with @ExceptionHandler methods to catch specific exception types across every controller in one centralized place, returning a consistent error response format instead of duplicating try-catch logic in each controller.
@ExceptionHandler declares a method that handles a specific exception type whenever it's thrown from any request-handling method in that controller (or globally, when combined with @ControllerAdvice). It centralizes error handling logic rather than scattering try-catch blocks throughout every endpoint.
Define an @ExceptionHandler method that builds a custom error object (with fields like message, status, timestamp) and returns it wrapped in a ResponseEntity with the appropriate HTTP status code, rather than letting Spring Boot's default error page or generic error JSON leak through.
Checked exceptions must be declared or caught at compile time. Unchecked (runtime) exceptions don't. By default, @Transactional only rolls back a transaction on unchecked exceptions, not checked ones, which surprises people who throw a checked exception expecting an automatic rollback. You'd need to explicitly configure rollbackFor to include it.
400 (Bad Request) means the request itself is malformed or fails validation. 401 (Unauthorized) means the caller isn't authenticated at all. 403 (Forbidden) means they're authenticated but not allowed to perform this specific action. 404 (Not Found) means the resource doesn't exist. Returning the wrong one, like 404 for a permissions issue, actively misleads API consumers trying to debug their integration.
It loads the full application context for integration testing, letting you test how multiple beans interact together as they would in production. It's heavier than a unit test, since it boots the whole context, so it's typically reserved for integration tests rather than every test in the suite.
@WebMvcTest loads only the web layer (controllers, filters, and related MVC infrastructure), mocking out the service and repository layers, which makes it much faster than a full @SpringBootTest. Use it when you specifically want to test controller behavior in isolation without paying the cost of starting the entire application context.
Use @MockBean to replace a real Spring-managed bean with a Mockito mock inside the test's application context, useful for isolating the class under test from a real database call or external API call it depends on.
@DataJpaTest configures an in-memory database and loads only the JPA-related components (repositories, entity manager), rather than the full application context, making it a fast, focused way to test repository queries in isolation.
LAZY loads the related entity only when it's actually accessed, deferring the extra query until needed. EAGER loads it immediately along with the parent entity. LAZY is generally the safer default since EAGER can silently cause performance problems by loading data you never end up using, especially across nested relationships.
It happens when fetching a list of entities triggers one query for the list itself, plus one additional query per entity to lazily load a related field, N+1 queries total instead of one efficient query. It's fixed by using a JOIN FETCH in a custom JPQL query, or by using @EntityGraph to specify which related entities should be fetched eagerly for that specific query.
save() persists the entity but may not immediately synchronize with the database, Hibernate can batch and delay the actual SQL execution. saveAndFlush() forces that synchronization immediately, which matters when you need the database's generated values (like an auto-increment ID) or constraints checked right away, before continuing further logic in the same transaction.
Add a @Version field (typically an int or long) to the entity. Hibernate automatically checks that version on update and throws an OptimisticLockException if another transaction modified the row in between, letting you catch that and retry or surface a conflict to the user, without needing a database-level pessimistic lock.
Actuator is a set of production-monitoring endpoints, health, metrics, environment info, added by including the spring-boot-starter-actuator dependency. Once added, endpoints like /actuator/health become available with minimal extra configuration, though which ones are exposed publicly should be deliberately restricted in production.
Use Bean Validation annotations (@NotNull, @Size, @Email, etc.) on the DTO's fields, then annotate the controller parameter with @Valid so Spring automatically validates the incoming request and returns a 400 error with details if validation fails, before your method body even runs.
An Entity maps directly to a database table and often carries JPA-specific annotations and relationships. A DTO (Data Transfer Object) is a plain object shaped specifically for what the API needs to expose. Returning entities directly risks leaking internal database structure, unintentionally triggering lazy-loading exceptions, and tightly coupling your API contract to your database schema, so most teams map entities to DTOs before returning them.
Senior (6-8 years)
Spring wraps the annotated method in a proxy that begins a database transaction before the method executes and commits it if the method completes normally, or rolls it back if an unchecked exception is thrown. This is implemented via AOP, so calling a @Transactional method from within the same class (self-invocation) bypasses the proxy and the annotation silently has no effect, a common source of production bugs.
REQUIRED, the default, joins an existing transaction if the caller already has one active, or starts a new one if not. REQUIRES_NEW always suspends any existing transaction and starts a completely independent one, useful when a piece of logic, like writing an audit log, needs to commit regardless of whether the outer transaction later rolls back.
Isolation level controls how much one transaction can see of another transaction's uncommitted changes, ranging from READ_UNCOMMITTED (least strict) to SERIALIZABLE (most strict). You'd raise it above the database's default when you're seeing concurrency bugs like phantom reads or non-repeatable reads in a specific critical section, accepting the performance trade-off that comes with stricter isolation.
Check the database's own deadlock logs first (most databases log the two competing queries and the lock cycle directly) rather than guessing from application logs alone. The fix is usually ensuring transactions acquire locks on shared resources in a consistent order across the whole codebase, or shortening transaction scope so locks are held for less time.
Because @Transactional is implemented via a dynamic proxy wrapping the bean. The proxy only intercepts calls that come through it from outside the class. A call to `this.someTransactionalMethod()` from inside the same class bypasses the proxy entirely, so the transactional behavior never triggers, a frequent gotcha that shows up as a mysterious missing rollback.
@Cacheable checks a configured cache for an existing result matching the method's arguments before running the method. If found, it returns the cached value directly, skipping the method body entirely. It requires a caching provider (like Redis, Caffeine, or the simple in-memory default) to be enabled and configured.
@Cacheable skips the method if a cached value exists. @CachePut always runs the method and updates the cache with the new result, useful for keeping a cache fresh on writes. @CacheEvict removes an entry from the cache, typically called on delete or update operations to prevent serving stale data.
@Async runs the annotated method in a separate thread so the caller isn't blocked waiting for it to finish, useful for fire-and-forget work like sending a notification. A common mistake is calling an @Async method from within the same class, which, like @Transactional, bypasses the proxy and runs synchronously with no error, silently defeating the purpose.
Annotate a method with @Scheduled, specifying either a fixed rate/delay or a cron expression, and enable scheduling on the application with @EnableScheduling. This runs independently of any incoming HTTP request, useful for jobs like nightly data cleanup or periodic report generation.
fixedRate runs the task at a fixed interval measured from the start of each execution, so if a run takes longer than the interval, the next one can start immediately or even overlap depending on configuration. fixedDelay measures the interval from the end of the previous execution, guaranteeing a gap between runs regardless of how long each one takes, which is usually the safer default for tasks with variable duration.
It depends on the access pattern: TTL-based expiry works well for data that naturally goes stale (like a pricing quote), while LRU-style eviction fits a fixed-size cache holding frequently accessed data where you'd rather drop the least-recently-used entry than let the cache grow unbounded. I'd also make sure cache invalidation happens explicitly on writes rather than relying purely on TTL, to avoid serving stale data after an update.
Lead (8-10 years)
Singleton (default bean scope), Factory (BeanFactory creates beans without you calling `new`), Proxy (used for AOP and @Transactional), Template Method (JdbcTemplate abstracts boilerplate while letting you plug in the specific query logic), and Observer (Spring's ApplicationEvent/ApplicationListener mechanism) are the ones that show up most directly in how the framework is built.
Define an interface for the varying behavior, implement it with multiple @Component-annotated classes, then inject all implementations as a List<InterfaceType> or resolve the correct one by name/qualifier at runtime, letting Spring's DI mechanism handle picking the right strategy instead of writing a manual if-else or switch chain.
Auto-configuration classes are @Configuration classes conditionally applied based on what's on the classpath and what beans already exist, using annotations like @ConditionalOnClass and @ConditionalOnMissingBean. Writing your own typically means creating a @Configuration class with those conditional annotations and registering it via a file under META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports, most often done when building an internal shared library other teams' Spring Boot apps will depend on.
Actuator adds production-ready monitoring endpoints, like /actuator/health, /actuator/metrics, and /actuator/env, out of the box, giving visibility into application health, memory usage, and configuration without writing any of that instrumentation yourself. In production, these endpoints are usually secured or restricted to internal networks since they expose operational details.
Profiles let you define environment-specific beans and configuration, activated via spring.profiles.active, so you can have application-dev.properties, application-staging.properties, and application-prod.properties, each overriding different values like database URLs or logging levels, without maintaining separate codebases per environment.
By default, Spring Security intercepts every request through a chain of filters, checking credentials against an in-memory or database-backed user store. Customizing it typically means defining a SecurityFilterChain bean specifying which endpoints require authentication, which roles can access what, and configuring the authentication mechanism, form login, HTTP Basic, or JWT.
On login, issue a signed JWT containing the user's identity and roles. On subsequent requests, a custom filter intercepts the Authorization header, validates the token's signature and expiry, and sets the authenticated user in Spring Security's context, all before the request reaches the controller, so endpoints stay stateless with no server-side session.
Authentication verifies who the user is, validating credentials or a token. Authorization decides what that authenticated user is allowed to do, enforced through role or permission checks, typically via method-level annotations like @PreAuthorize or URL-pattern-based rules in the security filter chain configuration.
Common approaches are URI versioning (/api/v1/users, /api/v2/users), a custom request header, or content negotiation via the Accept header. URI versioning is the most common in practice since it's the most explicit and easiest for API consumers to understand, even though it's the least 'RESTfully pure' of the options.
Spring Data's Pageable and Page abstractions handle this natively, you accept a Pageable parameter in the controller method and pass it straight to the repository, which returns a Page object containing the requested slice plus metadata like total elements and total pages, without you writing manual LIMIT/OFFSET logic.
An idempotent operation produces the same result no matter how many times it's repeated, GET, PUT, and DELETE are expected to be idempotent. POST is not, calling it twice typically creates two resources. This matters for retry logic, since safely retrying a failed request without risking duplicate side effects depends on the operation actually being idempotent.
For a single instance, a token bucket or sliding-window counter kept in memory (via a library like Bucket4j) works. For a multi-instance deployment behind a load balancer, that counter needs to live in a shared store like Redis instead, so limits are enforced consistently across instances rather than each instance tracking its own, separate count.
OAuth2 is an authorization framework, a protocol for how a client obtains permission to access a resource on a user's behalf. JWT is a token format, a way of encoding claims into a signed, self-contained string. They're commonly used together: OAuth2 defines the flow for obtaining an access token, and that access token is often, though not always, formatted as a JWT.
Staff (10+ years)
Spring Boot handles building each individual service quickly with minimal setup. Spring Cloud adds the cross-cutting concerns a distributed system needs that a single service alone doesn't solve: service discovery, client-side load balancing, centralized configuration, and distributed tracing, so services can find and call each other reliably without hardcoding hostnames.
Synchronous REST calls (via RestTemplate or WebClient) are simple to reason about but couple the caller's availability to the callee's uptime and latency. Asynchronous, event-driven communication through a message broker like Kafka or RabbitMQ decouples services and improves resilience, at the cost of eventual consistency and more complex debugging across a chain of asynchronous events. I'd default to synchronous for request-response interactions where the caller genuinely needs an immediate answer, and asynchronous for anything that can tolerate eventual processing.
A circuit breaker, using Resilience4j (Spring Cloud's current standard, since Hystrix is deprecated), stops calling a failing downstream service after a threshold of failures, failing fast and optionally falling back to a default response instead of letting failures cascade and exhaust resources while repeatedly waiting on a service that's already down.
A retry re-attempts a failed call, useful for transient failures like a brief network blip. A circuit breaker stops attempting calls entirely once failures cross a threshold, protecting the caller and the struggling downstream service from being hammered further. Used together, a bounded retry handles brief transient failures while the circuit breaker protects against sustained outages, since retrying indefinitely against a genuinely down service just adds more load to an already failing system.
Spring Cloud Config Server centralizes configuration in one place, typically backed by a Git repository, so services pull their configuration from a single source rather than each maintaining its own scattered properties files, and configuration changes can be rolled out and audited consistently.
I'd weigh it by ownership and deployment independence rather than defaulting to 'microservices are more modern': if a separate team owns it, it needs to scale or deploy independently, or it has fundamentally different reliability requirements, that argues for a separate service. Otherwise, adding it as a well-modularized package within an existing service is usually faster to build and operate, and premature service-splitting mostly adds network overhead and operational cost without a matching benefit.
Run it incrementally: get dependencies and code compiling under both versions where possible, lean on automated tests to catch regressions early, and migrate module by module behind feature flags or in parallel branches that merge frequently, rather than a long-lived migration branch that drifts from main. A big-bang rewrite is rarely something the business will tolerate stopping feature work for.
I look at whether it's solving the actual problem or just the symptom in front of them, whether it's been thought through for failure cases as much as the happy path, and whether it's consistent with patterns already established elsewhere in the system. An inconsistent one-off pattern becomes a maintenance burden the whole team inherits later. I'd rather ask a few pointed questions that surface the team's own blind spots than hand them a prescribed solution.
Automate what can be automated, linting, static analysis, dependency version alignment, enforced in CI, so standards aren't a matter of opinion in code review. For architectural conventions that can't be automated, I'd document the handful of decisions that actually matter, with the reasoning behind them, rather than a long style guide nobody actually reads.
First rule out environmental differences, connection pool sizing (HikariCP defaults are often too small for production concurrency), data volume, and whether staging traffic patterns actually resemble production. Then I'd want Actuator metrics and distributed tracing to see where time is actually going, since 'intermittent' under real load is very often thread pool exhaustion, connection pool contention, or GC pauses, none of which reliably show up in low-traffic staging.
Actuator's /health endpoint as the baseline, but extended with custom health indicators for the things that actually matter, downstream dependency availability, connection pool saturation, queue depth for async processing, not just 'is the JVM up.' I'd alert on rate-of-change and leading indicators (a growing queue, a rising error rate) rather than only static thresholds, since those catch problems before they become outages.
Treat the public API as a contract: additive changes are generally safe, but changing or removing existing method signatures needs a deprecation period with clear warnings before removal, not a silent breaking change in a minor version. I'd also want to know who's actually calling the old interface before removing it, rather than assuming nobody depends on it.
Mitigation before root-causing, roll back a recent deploy, fail over, or shed load if that stops the bleeding, even before fully understanding why it broke. I'd also make sure one person is clearly driving the incident and communicating status, since incidents usually drag on longer because of diffuse ownership, not because of a lack of technical skill in the room.
Start from actual load testing at realistic traffic shapes rather than linear extrapolation, since the real bottleneck (a database, a downstream API's rate limit, a connection pool ceiling) rarely scales linearly and often shows up well before the target load. I'd identify the actual constraint first, then decide whether the fix is more instances, caching, offloading work asynchronously, or in some cases redesigning the hot path.
Weigh it against concrete benefit, security support lifetime, performance improvements, features teams are actively blocked without, against real migration risk and cost to velocity while it's in progress. I'd pilot it on a lower-risk, less-critical service first rather than upgrading the most important system first, and want a rollback plan defined before starting, sitting right alongside the forward migration plan.
This is a judgment question interviewers use to see how you reason under uncertainty, not to test a specific fact. A strong answer names the actual constraint that forced the decision, the realistic options on the table, why you picked one knowing it wasn't guaranteed to be right, and what you'd do differently with what you know now, rather than presenting the decision as obviously correct in hindsight.
I'd pair on a real piece of their code and walk through how a future reader, including them in six months, would have to reconstruct their reasoning, rather than giving generic 'write cleaner code' feedback. Concrete, example-driven feedback tied to their own code sticks far better than abstract principles about layering or separation of concerns.
Push for a shared logging format (structured JSON logs with consistent field names) and a shared correlation-ID convention early, since retrofitting that across services after they've already diverged costs real time nobody budgeted for, easy to avoid by establishing it as a starting template new services are built from instead. I'd rather provide a well-documented shared starter library than a style guide people have to remember to follow manually.




