Spring Tutorial (2026): Building Maintainable, Testable Spring Apps

A few years ago I got pulled into an incident that looked like a “database outage” but wasn’t. The database was fine. The real issue was that a service class created its own JDBC connections, hand-built SQL strings, swallowed exceptions, and retried in a tight loop. Under load, it turned into a CPU heater and a log spammer. The team couldn’t unit test it because everything was new’d up inline, and every fix required a risky deploy.\n\nIf you’ve ever lived through that kind of tight coupling, Spring will feel less like “framework magic” and more like a set of practical agreements: you describe what your app is made of (components), how they relate (dependencies), and where cross-cutting rules belong (transactions, security, logging). The container does the repetitive wiring so you can focus on your domain.\n\nI’m going to show you how I approach Spring in 2026: how the container thinks, how I structure a small but real API, how I handle data access and transactions without surprises, how I add AOP responsibly, and how I test in layers so your feedback loop stays fast.\n\n## Spring’s Mental Model: Container, Beans, and Wiring\nWhen I teach Spring to new teammates, I start with one analogy: treat the container like a stage manager in a theater.\n\n- You write actors (plain Java classes).\n- You declare what props they need (constructor parameters).\n- You tell the stage manager which actors exist (component scanning or configuration).\n- The stage manager creates them in the right order, hands them their props, and keeps them alive for the right amount of time.\n\nIn Spring language:\n\n- A bean is an object managed by Spring.\n- The IoC container is the component that builds and wires beans.\n- Dependency Injection (DI) is the act of providing a bean’s dependencies from the outside.\n\n### BeanFactory vs ApplicationContext (what I actually care about)\nYou’ll see both names. In practice:\n\n- BeanFactory is the minimal container API.\n- ApplicationContext is the richer container used by most apps (events, internationalization, resource loading, automatic integration features).\n\nIf you’re building normal server apps (web APIs, batch jobs, messaging consumers), you’re almost always working with an ApplicationContext under the hood.\n\n### The lifecycle in plain terms\nA bean typically goes through:\n\n1. Instantiation (constructor runs)\n2. Dependency injection (Spring sets constructor args or fields)\n3. Post-processing (Spring applies features like proxies for AOP/transactions)\n4. Initialization callbacks (optional)\n5. Usage (your app calls it)\n6. Destruction callbacks (optional, on shutdown)\n\nI don’t memorize lifecycle hook names; I focus on one rule: keep constructors side-effect free. If the constructor does network calls or reads files, tests and startup become fragile.\n\nA practical mental note: when people say “Spring is doing weird stuff,” it’s usually step 3 (post-processing). That’s where transaction proxies, security proxies, and custom aspects show up. If you understand that Spring sometimes wraps your bean in another object (a proxy) to intercept method calls, a lot of “mystery behavior” becomes explainable.\n\n### Scope is a design decision\nMost business apps should default to:\n\n- singleton scope (one instance per application context)\n\nOther scopes exist (prototype, web request, session, custom scopes). I only reach for them when I can explain why a single instance is incorrect.\n\nA small rule of thumb: if your class is stateful, ask “is that state really per-request?” If it is, don’t make it a singleton holding request state; model that state as a parameter or a value object.\n\n### Auto-configuration (what Boot adds to the story)\nIf Spring is the stage manager, Spring Boot is the stage manager who also sets up the lights, checks the mic levels, and puts chairs on stage based on the script it sees.\n\nBoot’s core trick is conditional configuration:\n\n- If a class is on your classpath (say, JdbcTemplate), Boot can add sensible defaults.\n- If you provide your own bean of a type, Boot usually backs off.\n- If a property is set, Boot can switch behaviors.\n\nThis is why I’m careful about two things in real projects:\n\n- I keep dependencies intentional. Adding a starter can pull in behavior you didn’t expect.\n- I keep configuration typed and explicit for anything that matters (timeouts, URLs, pool sizes, security).\n\n## Building a Boot App the 2026 Way: Setup, Dev Loop, and Observability\nIn 2026, I treat a new Spring project like infrastructure you’ll live with for years. A few up-front choices prevent recurring pain.\n\n### Practical defaults I recommend\n- Java: Use an LTS release (commonly Java 21 in many orgs; newer LTS if your org has moved).\n- Spring Boot: Use a current Spring Boot 3.x line (Jakarta namespace, modern baseline).\n- Build: Maven or Gradle; pick the one your team already maintains well.\n- Runtime: Container-first mindset even if you deploy to VMs. It makes dev/prod parity easier.\n\nIf you’re container-first, I also recommend being explicit about:\n\n- JVM memory settings (containers and cgroups matter)\n- health checks\n- startup time budget\n- graceful shutdown\n\n### IDE choice in real teams\nI’ve used STS, Eclipse, and IntelliJ IDEA. In 2026 the main differentiator isn’t “can it run Spring” (they all can), it’s:\n\n- refactoring quality\n- navigation through annotations and bean graphs\n- debugging with conditional breakpoints\n- test and coverage tooling\n\nPick what makes your team faster and more consistent.\n\n### Dev loop: fast feedback beats hero debugging\nA healthy loop looks like:\n\n- run the app locally\n- hit an endpoint\n- change a class\n- rerun a focused test\n\nI also add basic observability on day one:\n\n- structured logs\n- request correlation IDs\n- metrics for latency and error rates\n\nYou can do a lot with Spring Boot’s built-in actuator support and a standard log format. The goal isn’t “perfect dashboards”; it’s “I can answer why this is slow or failing in minutes, not hours.”\n\n### Observability in practice: what I enable early\nIf I had to pick a minimal “production sanity” set, it’s:\n\n- Actuator health/info + readiness/liveness\n- Micrometer metrics for HTTP server requests\n- tracing (OpenTelemetry or your platform’s choice)\n- structured logging with consistent fields\n\nI’m not trying to over-instrument. I’m trying to make these questions easy to answer:\n\n- Which endpoint is slow?\n- Is the slowdown app CPU, DB latency, or downstream calls?\n- Are errors correlated to a specific input, customer, or deployment?\n- Did we just introduce a regression?\n\n## Dependency Injection That Stays Maintainable\nDI is easy to get working and easy to get wrong. The difference is whether you treat your beans as a stable dependency graph or as a pile of annotations.\n\n### My strongest rule: constructor injection only\nConstructor injection makes dependencies explicit and keeps classes honest.\n\nBad smell: you can create a service with new and it “kind of works” but fails later because something wasn’t injected.\n\nGood smell: the class won’t compile unless you provide its dependencies.\n\nHere’s a small runnable example: a payment authorization API with a controller, service, and repository.\n\njava\npackage com.acme.billing;\n\nimport org.springframework.boot.SpringApplication;\nimport org.springframework.boot.autoconfigure.SpringBootApplication;\n\n@SpringBootApplication\npublic class BillingApplication {\n public static void main(String[] args) {\n SpringApplication.run(BillingApplication.class, args);\n }\n}\n\n\njava\npackage com.acme.billing.web;\n\nimport com.acme.billing.domain.PaymentAuthorization;\nimport com.acme.billing.domain.PaymentAuthorizationService;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.web.bind.annotation.;\n\nimport java.net.URI;\n\n@RestController\n@RequestMapping(\"/api/payment-authorizations\")\npublic class PaymentAuthorizationController {\n private final PaymentAuthorizationService service;\n\n public PaymentAuthorizationController(PaymentAuthorizationService service) {\n this.service = service;\n }\n\n @PostMapping\n public ResponseEntity authorize(@RequestBody AuthorizePaymentRequest request) {\n PaymentAuthorization created = service.authorize(request.customerId(), request.amountCents(), request.currency());\n return ResponseEntity.created(URI.create(\"/api/payment-authorizations/\" + created.id())).body(created);\n }\n\n public record AuthorizePaymentRequest(String customerId, long amountCents, String currency) {}\n}\n\n\njava\npackage com.acme.billing.domain;\n\nimport org.springframework.stereotype.Service;\n\nimport java.time.Instant;\nimport java.util.UUID;\n\n@Service\npublic class PaymentAuthorizationService {\n private final PaymentAuthorizationRepository repository;\n\n public PaymentAuthorizationService(PaymentAuthorizationRepository repository) {\n this.repository = repository;\n }\n\n public PaymentAuthorization authorize(String customerId, long amountCents, String currency) {\n if (customerId == null |

customerId.isBlank()) {\n throw new IllegalArgumentException(\"customerId is required\");\n }\n if (amountCents <= 0) {\n throw new IllegalArgumentException(\"amountCents must be positive\");\n }\n if (currency == null currency.isBlank()) {\n throw new IllegalArgumentException(\"currency is required\");\n }\n\n PaymentAuthorization authorization = new PaymentAuthorization(\n UUID.randomUUID().toString(),\n customerId,\n amountCents,\n currency,\n \"AUTHORIZED\",\n Instant.now()\n );\n\n repository.save(authorization);\n return authorization;\n }\n}\n\n\njava\npackage com.acme.billing.domain;\n\nimport java.time.Instant;\n\npublic record PaymentAuthorization(\n String id,\n String customerId,\n long amountCents,\n String currency,\n String status,\n Instant createdAt\n) {}\n\n\njava\npackage com.acme.billing.domain;\n\npublic interface PaymentAuthorizationRepository {\n void save(PaymentAuthorization authorization);\n}\n\n\njava\npackage com.acme.billing.persistence;\n\nimport com.acme.billing.domain.PaymentAuthorization;\nimport com.acme.billing.domain.PaymentAuthorizationRepository;\nimport org.springframework.jdbc.core.JdbcTemplate;\nimport org.springframework.stereotype.Repository;\n\n@Repository\npublic class JdbcPaymentAuthorizationRepository implements PaymentAuthorizationRepository {\n private final JdbcTemplate jdbcTemplate;\n\n public JdbcPaymentAuthorizationRepository(JdbcTemplate jdbcTemplate) {\n this.jdbcTemplate = jdbcTemplate;\n }\n\n @Override\n public void save(PaymentAuthorization authorization) {\n jdbcTemplate.update(\n \"insert into paymentauthorization (id, customerid, amountcents, currency, status, createdat) values (?, ?, ?, ?, ?, ?)\",\n authorization.id(),\n authorization.customerId(),\n authorization.amountCents(),\n authorization.currency(),\n authorization.status(),\n authorization.createdAt()\n );\n }\n}\n\n\nA few important points:\n\n- These are plain classes and records. No heavyweight container requirements.\n- The annotations (@RestController, @Service, @Repository) are metadata for wiring, not business logic.\n- PaymentAuthorizationService depends on an interface, not a concrete database class.\n\n### Annotation wiring vs configuration classes\nSpring gives you multiple ways to register beans:\n\n- component scanning (@Service, @Repository, etc.)\n- explicit configuration (@Configuration + @Bean)\n\nI often prefer scanning for “obvious” app components and explicit @Bean configuration for third-party clients and cross-cutting concerns.\n\nExample: a configuration class that creates a clock (easy to stub in tests) and a feature flag.\n\njava\npackage com.acme.billing.config;\n\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Configuration;\n\nimport java.time.Clock;\n\n@Configuration\npublic class BillingConfig {\n @Bean\n public Clock clock() {\n return Clock.systemUTC();\n }\n\n @Bean\n public FraudChecks fraudChecks() {\n return new FraudChecks(true);\n }\n\n public record FraudChecks(boolean enabled) {}\n}\n\n\nIn practice, I like using Clock because it kills an entire category of flaky time-based tests. If a service needs “now,” I inject a clock.\n\n### @Qualifier and the “two beans” problem\nWhen you have multiple beans of the same type, Spring needs help choosing.\n\nI prefer using @Qualifier with clear names rather than relying on field names.\n\njava\npackage com.acme.billing.config;\n\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Configuration;\nimport org.springframework.beans.factory.annotation.Qualifier;\n\nimport java.time.Clock;\n\n@Configuration\npublic class ClockConfig {\n @Bean\n @Qualifier(\"systemClock\")\n public Clock systemClock() {\n return Clock.systemUTC();\n }\n\n @Bean\n @Qualifier(\"fixedClock\")\n public Clock fixedClock() {\n return Clock.fixed(java.time.Instant.parse(\"2026-01-01T00:00:00Z\"), java.time.ZoneOffset.UTC);\n }\n}\n\n\nThen inject with:\n\njava\npublic PaymentAuthorizationService(@Qualifier(\"systemClock\") Clock clock, PaymentAuthorizationRepository repository) {\n this.clock = clock;\n this.repository = repository;\n}\n\n\nA close alternative is @Primary (pick a default bean) plus qualifiers only when you need a non-default. I tend to prefer qualifiers for anything that could be ambiguous later.\n\n### Traditional vs modern Spring configuration\nThis comes up when you maintain older services.\n\n

Area

Traditional approach

Modern approach I recommend

\n

\n

Bean config

XML files

Java config (@Configuration) + annotations

\n

Injection

field injection

constructor injection

\n

Properties

ad-hoc @Value strings

typed @ConfigurationProperties

\n

Integration tests

full app for every test

slice tests + targeted integration\n\nYou don’t need to rewrite everything overnight. I usually start by converting field injection to constructor injection in touched classes.\n\n### Typed configuration that fails fast\nIf you’ve ever deployed something and only then discovered a typo in a property name, you understand why I’m allergic to “stringly typed” config.\n\nI prefer @ConfigurationProperties with validation. That gives you:\n\n- autocompletion in IDEs\n- a single place to document configuration\n- startup-time failure when config is missing or invalid\n\nA quick example of a payment provider config:\n\njava\npackage com.acme.billing.config;\n\nimport jakarta.validation.constraints.Min;\nimport jakarta.validation.constraints.NotBlank;\nimport org.springframework.boot.context.properties.ConfigurationProperties;\nimport org.springframework.validation.annotation.Validated;\n\n@Validated\n@ConfigurationProperties(prefix = \"billing.payments\")\npublic record PaymentsProperties(\n @NotBlank String providerBaseUrl,\n @NotBlank String apiKey,\n @Min(50) int connectTimeoutMs,\n @Min(50) int readTimeoutMs\n) {}\n\n\nNow your app can fail on startup instead of failing on the first request in production. That’s a trade I’ll take every time.\n\n### Common DI mistakes I see (and how I avoid them)\n- Circular dependencies: often means your responsibilities are tangled. Split the service or introduce a smaller interface.\n- Overusing @Value: stringly-typed config breaks at runtime. Prefer typed configuration properties.\n- Treating the container as a global variable: avoid calling ApplicationContext.getBean(...) from business logic.\n- Hiding heavy work in bean creation: a bean that calls a remote service in a constructor (or @PostConstruct) makes startup fragile. Prefer lazy initialization patterns or explicit warmup steps with timeouts and visibility.\n\n## Web Layer: Spring MVC for Real APIs\nSpring MVC is a pragmatic web framework: good defaults, strong composition, and clear extension points.\n\n### Controller design that scales\nMy rule: controllers translate HTTP into domain calls and domain results back into HTTP. They should not:\n\n- open transactions\n- construct SQL\n- contain business rules\n\nIn the earlier example, the controller takes a request record and hands it to a service.\n\nWhen controllers stay thin, you get three wins:\n\n1. tests stay easy (controller tests check HTTP behavior, service tests check business rules)\n2. error handling stays consistent\n3. refactoring is safer (domain logic doesn’t depend on HTTP quirks)\n\n### Input validation that doesn’t turn into soup\nFor JSON APIs, I prefer to validate request DTOs at the boundary using Bean Validation, then validate business invariants inside the domain/service layer.\n\nBoundary validation catches things like “missing field” or “not a valid format.” Domain validation catches things like “customer is not allowed to authorize payments” or “amount exceeds risk limits.”\n\nExample request DTO validation:\n\njava\npackage com.acme.billing.web;\n\nimport jakarta.validation.constraints.Min;\nimport jakarta.validation.constraints.NotBlank;\n\npublic record AuthorizePaymentRequest(\n @NotBlank String customerId,\n @Min(1) long amountCents,\n @NotBlank String currency\n) {}\n\n\nThen in a controller:\n\njava\n@PostMapping\npublic ResponseEntity authorize(@Valid @RequestBody AuthorizePaymentRequest request) {\n PaymentAuthorization created = service.authorize(request.customerId(), request.amountCents(), request.currency());\n return ResponseEntity.created(URI.create(\"/api/payment-authorizations/\" + created.id())).body(created);\n}\n\n\nThis is one of those “boring but valuable” patterns: it reduces manual checks and makes error responses consistent.\n\n### Centralized exception handling you’ll actually thank yourself for\nOne of Spring’s underrated features is consistent exception translation and centralized handling.\n\nI like a global handler that maps known failures to predictable responses.\n\njava\npackage com.acme.billing.web;\n\nimport org.springframework.http.HttpStatus;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.web.bind.annotation.ControllerAdvice;\nimport org.springframework.web.bind.annotation.ExceptionHandler;\n\n@ControllerAdvice\npublic class ApiExceptionHandler {\n\n @ExceptionHandler(IllegalArgumentException.class)\n public ResponseEntity handleIllegalArgument(IllegalArgumentException ex) {\n ApiError error = new ApiError(\"INVALIDREQUEST\", ex.getMessage());\n return ResponseEntity.status(HttpStatus.BADREQUEST).body(error);\n }\n\n @ExceptionHandler(Exception.class)\n public ResponseEntity handleUnexpected(Exception ex) {\n ApiError error = new ApiError(\"UNEXPECTEDERROR\", \"Request failed\");\n return ResponseEntity.status(HttpStatus.INTERNALSERVERERROR).body(error);\n }\n\n public record ApiError(String code, String message) {}\n}\n\n\nTwo practical upgrades I often make in real services:\n\n- return stable error shapes (sometimes aligned to RFC 7807 Problem Details)\n- include a traceId/requestId so a client can report an error and you can find it fast\n\n### CORS, content negotiation, and the “frontend meets backend” reality\nIn most modern orgs, your API is consumed by browsers, mobile apps, and other services. That means you should be intentional about:\n\n- CORS rules (don’t leave it wide open by accident)\n- JSON versioning strategy (don’t break clients)\n- error response stability\n\nI don’t try to make versioning perfect. I try to make breaking changes hard to do accidentally.\n\n### When NOT to use MVC\nSpring MVC is great for standard HTTP APIs. I avoid it when:\n\n- you need ultra-low-latency, specialized networking where a dedicated stack is a better fit\n- you’re building a tiny one-off CLI tool where the framework footprint isn’t worth it\n\nFor most business APIs, MVC is a strong default.\n\n## Data Access and Transactions: JDBC/JPA Without Surprises\nData access is where many Spring apps become “mysterious” because people treat transactions like magic.\n\n### The promise Spring makes\nSpring aims to:\n\n- give you consistent data access APIs\n- translate lower-level exceptions into a consistent unchecked hierarchy\n- manage transactions declaratively\n\nThat “exception translation” is why @Repository matters: it marks the class for conversion of vendor exceptions into Spring’s consistent set.\n\n### JdbcTemplate, RowMapper, and ResultSetExtractor\nIf you’re working close to SQL, JdbcTemplate is still a solid choice.\n\n- RowMapper maps one row to one object.\n- ResultSetExtractor is for custom mapping across multiple rows.\n\nA realistic query example:\n\njava\npackage com.acme.billing.persistence;\n\nimport com.acme.billing.domain.PaymentAuthorization;\nimport org.springframework.jdbc.core.JdbcTemplate;\nimport org.springframework.jdbc.core.RowMapper;\nimport org.springframework.stereotype.Repository;\n\nimport java.sql.ResultSet;\nimport java.sql.SQLException;\nimport java.time.Instant;\nimport java.util.List;\n\n@Repository\npublic class PaymentAuthorizationQueries {\n private final JdbcTemplate jdbcTemplate;\n\n public PaymentAuthorizationQueries(JdbcTemplate jdbcTemplate) {\n this.jdbcTemplate = jdbcTemplate;\n }\n\n public List findRecentForCustomer(String customerId, int limit) {\n return jdbcTemplate.query(\n \"select id, customerid, amountcents, currency, status, createdat \" +\n \"from paymentauthorization \" +\n \"where customerid = ? \" +\n \"order by createdat desc \" +\n \"limit ?\",\n new PaymentAuthorizationRowMapper(),\n customerId,\n limit\n );\n }\n\n static class PaymentAuthorizationRowMapper implements RowMapper {\n @Override\n public PaymentAuthorization mapRow(ResultSet rs, int rowNum) throws SQLException {\n return new PaymentAuthorization(\n rs.getString(\"id\"),\n rs.getString(\"customerid\"),\n rs.getLong(\"amountcents\"),\n rs.getString(\"currency\"),\n rs.getString(\"status\"),\n rs.getObject(\"createdat\", Instant.class)\n );\n }\n }\n}\n\n\nA few practical notes from production life:\n\n- Keep SQL readable. I’d rather have a multi-line string than a clever builder.\n- Prefer named parameters when queries get complex (Spring has NamedParameterJdbcTemplate).\n- Be explicit about limits and ordering for “recent” endpoints; otherwise you build accidental table scans.\n\n### Transactions: what I assume until proven otherwise\nHere are the assumptions I make when I look at a service method that writes data:\n\n- it should usually be transactional\n- it should usually define the boundary at the service layer (not controller, not repository)\n- it should be short enough that I can reason about it\n\nA basic pattern looks like this:\n\njava\npackage com.acme.billing.domain;\n\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\n\n@Service\npublic class PaymentAuthorizationService {\n private final PaymentAuthorizationRepository repository;\n\n public PaymentAuthorizationService(PaymentAuthorizationRepository repository) {\n this.repository = repository;\n }\n\n @Transactional\n public PaymentAuthorization authorize(String customerId, long amountCents, String currency) {\n // validate, compute, persist\n PaymentAuthorization authorization = / build object / null;\n repository.save(authorization);\n return authorization;\n }\n}\n\n\nWhy do I put @Transactional here? Because this is where I want the business operation boundary. If authorization involves multiple writes (authorization row + audit row + outbox event), I want them to succeed or fail together.\n\n### The biggest transaction pitfall: proxies and self-invocation\nSpring’s transactional behavior is typically applied via proxies. That means a transaction starts when a call goes through the proxy.\n\nA common surprise looks like this:\n\n- authorize() is @Transactional\n- authorize() calls saveAudit()\n- you add @Transactional(propagation = REQUIRESNEW) to saveAudit()\n- nothing changes\n\nWhy? Because calling saveAudit() from inside the same class is a direct call, not a proxy call. No interceptor, no new transaction.\n\nMy rule: if I need different transactional behavior, I put that method on another bean (often an internal helper service) so the call goes through the proxy.\n\n### Propagation, isolation, and readOnly (how I use them)\nMost of the time, I don’t touch these knobs. When I do, it’s for a reason I can state in one sentence.\n\n- readOnly = true: for heavy read endpoints or batch reads, to communicate intent and let the underlying stack optimize where possible.\n- REQUIRESNEW: for “always write audit/log/outbox even if the main transaction fails” (but I use it sparingly; it can create surprising behavior).\n- isolation levels: only when I have a documented concurrency requirement and I’ve verified behavior against my database.\n\nIf you’re not sure, default to @Transactional and focus on correctness, indexes, and timeouts first.\n\n### Timeouts and retries: the “CPU heater” prevention kit\nRemember the incident story at the beginning? That kind of failure happens when systems retry aggressively without bounds.\n\nIn Spring apps, I like to be explicit about:\n\n- database connection pool sizing (don’t let it default blindly)\n- query timeouts for known expensive operations\n- HTTP client timeouts for downstream calls\n- retry policies that include backoff and a max attempt count\n\nIf you do retries, log the attempt count and a stable request id. Otherwise you get a wall of logs with no storyline.\n\n### When I choose JDBC vs JPA\nThis is not a religion for me. I use both. Here’s how I decide:\n\n- JDBC when queries are complex, performance-sensitive, or tightly tuned, and I want SQL to be the source of truth.\n- JPA when the domain is naturally object-centric, CRUD-heavy, and I benefit from entity mapping, optimistic locking, and repository patterns.\n\nA team can absolutely mix them: JPA for most operations, JDBC for a few hot paths or reporting queries.\n\n### JPA without surprises: the small set of rules I repeat\nIf you’re using Spring Data JPA, the surprises usually come from hidden queries and persistence context behavior. These rules reduce that:\n\n1. Watch out for N+1 queries (use fetch joins or entity graphs deliberately).\n2. Keep transactions short; don’t hold DB connections while doing network calls.\n3. Disable “open session in view” for APIs unless you have a strong reason not to.\n4. Model concurrency intentionally (optimistic locking for business-critical updates).\n\nA minimal optimistic locking example:\n\njava\npackage com.acme.billing.persistence;\n\nimport jakarta.persistence.;\n\n@Entity\n@Table(name = \"customerbalance\")\npublic class CustomerBalanceEntity {\n @Id\n private String customerId;\n\n private long balanceCents;\n\n @Version\n private long version;\n\n protected CustomerBalanceEntity() {}\n\n public CustomerBalanceEntity(String customerId, long balanceCents) {\n this.customerId = customerId;\n this.balanceCents = balanceCents;\n }\n\n public void debit(long amountCents) {\n this.balanceCents -= amountCents;\n }\n}\n\n\nThat @Version field turns “last write wins silently” into “concurrent update detected,” which you can handle explicitly.\n\n### Schema migrations: treat the database like code\nA practical production habit: use a migration tool (Flyway or Liquibase) and version your schema changes.\n\nThis pays off because:\n\n- new devs can bootstrap reliably\n- CI can run with a clean schema\n- rollouts are safer (you can plan backward-compatible changes)\n\nIf you take only one thing from this section, take this: “manual SQL in production consoles does not scale as a process.”\n\n## AOP: Powerful, Dangerous, and Worth It (When Used Carefully)\nI like AOP the way I like hot sauce: a little makes things better; too much ruins the meal.\n\n### What AOP is actually doing\nAOP in Spring is typically implemented by creating proxies around your beans. When you call a method through the proxy, Spring can run code before/after the method, or even replace the call.\n\nSpring uses this for:\n\n- transactions\n- security\n- caching\n- some observability hooks\n\n### Where I use custom AOP\nI use it for cross-cutting concerns that are truly cross-cutting and boring:\n\n- consistent logging around a boundary\n- metrics timing around a boundary\n- enforcement of a rule like “this method must run with a tenant context”\n\nI avoid using AOP to implement business logic. If the logic matters to the domain, I want it visible in the domain code, not hidden in an aspect.\n\n### A practical example: timing a service boundary\nIn many cases, you can use built-in metrics annotations, but a custom annotation is a good learning example: \n\njava\npackage com.acme.billing.observability;\n\nimport java.lang.annotation.*;\n\n@Target(ElementType.METHOD)\n@Retention(RetentionPolicy.RUNTIME)\npublic @interface TimedOperation {\n String value();\n}\n\n\njava\npackage com.acme.billing.observability;\n\nimport org.aspectj.lang.ProceedingJoinPoint;\nimport org.aspectj.lang.annotation.Around;\nimport org.aspectj.lang.annotation.Aspect;\nimport org.springframework.stereotype.Component;\n\n@Aspect\n@Component\npublic class TimingAspect {\n\n @Around(\"@annotation(timed)\")\n public Object time(ProceedingJoinPoint pjp, TimedOperation timed) throws Throwable {\n long startNs = System.nanoTime();\n try {\n return pjp.proceed();\n } finally {\n long elapsedMs = (System.nanoTime() - startNs) / 1000000;\n // Replace this with a real metrics system in production\n System.out.println(timed.value() + \" took \" + elapsedMs + \"ms\");\n }\n }\n}\n\n\nAnd then on a method:\n\njava\n@TimedOperation(\"paymentauthorize\")\npublic PaymentAuthorization authorize(...) {\n ...\n}\n\n\nI’m showing System.out.println only as a concept; in real services I’d emit a metric or structured log with the request id.\n\n### AOP edge cases to keep in mind\n- proxies intercept public method calls by default (common configuration)\n- internal method calls within the same class can bypass aspects\n- final classes/methods can change proxying options\n\nIf an aspect is critical, I add a test that proves it runs. Otherwise you can ship a “working” configuration that never actually intercepts anything.\n\n## Security: The Minimum I Do So I Don’t Regret It Later\nSecurity is a huge topic, but there’s a small subset I try to get right in every Spring API.\n\n### Threat model by default\nEven for internal services, I assume:\n\n- requests can be malformed\n- clients can retry aggressively\n- tokens can expire or be missing\n- logs can leak sensitive values if I’m careless\n\nSo I want:\n\n- authentication (who are you?)\n- authorization (what can you do?)\n- safe defaults (deny by default, explicit allow)\n\n### Practical patterns that age well\n- Keep authentication at the edge (filters)\n- Keep authorization close to the business operation (method security can be great)\n- Don’t log secrets (API keys, tokens, raw card data, etc.)\n\nFor browser-facing APIs, I also decide early on:\n\n- CSRF strategy\n- session vs token\n- CORS configuration\n\nThe actual implementation details vary by org (JWT, OAuth2, mTLS), but the design principle stays consistent: make the security posture obvious, testable, and hard to accidentally weaken.\n\n## Testing in Layers: Fast Feedback Without Fake Confidence\nTesting is where Spring apps either become a joy to evolve or a nightmare of slow, flaky builds.\n\nI aim for three layers:\n\n1. Pure unit tests (no Spring) for domain/service logic\n2. Slice tests (minimal Spring) for web and persistence boundaries\n3. Targeted integration tests (real DB via containers) for the risky parts\n\n### Unit tests: no Spring, no network\nBecause we used constructor injection and interfaces, we can test a service with a fake repository. That’s the payoff.\n\nA simple fake repository can be an in-memory map. It doesn’t need to be clever; it needs to be reliable and fast.\n\nThe key is: business logic tests shouldn’t depend on Spring context startup. If a unit test needs Boot, it’s usually not a unit test anymore.\n\n### Web slice tests: verify HTTP behavior\nFor controllers, I like tests that prove:\n\n- request validation works\n- exception mapping works\n- HTTP status codes are stable\n- the JSON shape is what clients expect\n\nThese tests don’t need a real database. They often use mocks for the service layer.\n\n### Persistence tests: verify SQL and mapping\nIf you’re using JDBC, I want at least one test that proves:\n\n- the schema exists\n- the SQL compiles\n- the mapping works for real rows\n\nIf you’re using JPA, I want tests that catch:\n\n- incorrect mappings\n- missing indexes (where possible)\n- query method behavior\n\n### Integration tests with real dependencies (but only where it matters)\nI don’t run every test against a real database; that’s slow. But I do like a handful of tests using a real DB (often via containers) for the highest risk flows.\n\nA common pattern is:\n\n- use an ephemeral Postgres container\n- run migrations\n- execute a real repository call\n- assert behavior\n\nThis catches “it compiles but doesn’t run” issues that unit tests can’t.\n\n### The testing trap I avoid\nThe trap is a suite where every test starts the full Spring context. It feels “real” at first, but it gets slower as the app grows, and then developers stop running tests locally, and then quality drops.\n\nI’d rather have:\n\n- 200 fast unit tests\n- 30 slice tests\n- 10 real integration tests\n\nthan 240 “everything” tests that take 20 minutes.\n\n## Production Considerations: The Stuff That Breaks at 2 AM\nThis is the part people skip in tutorials, but it’s where Spring earns its keep.\n\n### Connection pools and thread pools are not afterthoughts\nIf your app handles HTTP requests and DB calls, you have at least two resource pools:\n\n- request threads (Tomcat/Jetty/Undertow)\n- DB connections (often HikariCP)\n\nIf you size one and not the other, you get weird symptoms:\n\n- too many request threads + too few DB connections → threads pile up waiting for connections\n- too few request threads → CPU underutilized, latency spikes under load\n\nI don’t aim for perfect numbers on day one. I aim to make them explicit, measurable, and adjustable.\n\n### Timeouts everywhere\nI try to ensure every outbound dependency has a timeout:\n\n- DB query timeouts (where applicable)\n- HTTP client connect/read timeouts\n- message broker consumer timeouts\n\nNo timeout means “infinite wait,” and infinite wait means thread starvation. Thread starvation looks like an outage even when everything is technically “up.”\n\n### Idempotency and retries\nRetries are reality. Clients retry. Networks retry. Humans retry.\n\nSo for write endpoints, I think early about:\n\n- idempotency keys\n- deduplication strategy\n- safe retry semantics\n\nThis is how you avoid “the customer clicked pay twice and got charged twice” classes of incidents.\n\n### Events without spaghetti: transactional events and the outbox idea\nWhen business operations should trigger side effects (email, analytics, downstream messages), I want that logic to be reliable and not tightly coupled.\n\nA pattern I like is:\n\n- write domain changes\n- write an outbox event in the same DB transaction\n- publish the outbox event asynchronously\n\nSpring has useful tools here (like transactional event listeners), but the architectural point is bigger than the annotation: keep side effects reliable and observable, and avoid “fire and forget” inside the main transaction unless you can tolerate loss.\n\n## Expansion Strategy\nAdd new sections or deepen existing ones with:\n- Deeper code examples: More complete, real-world implementations\n- Edge cases: What breaks and how to handle it\n- Practical scenarios: When to use vs when NOT to use\n- Performance considerations: Before/after comparisons (use ranges, not exact numbers)\n- Common pitfalls: Mistakes developers make and how to avoid them\n- Alternative approaches: Different ways to solve the same problem\n\n## If Relevant to Topic\n- Modern tooling and AI-assisted workflows (for infrastructure/framework topics)\n- Comparison tables for Traditional vs Modern approaches\n- Production considerations: deployment, monitoring, scaling\n\n## A closing checklist I actually use\nWhen I’m reviewing a Spring API PR or standing up a new service, I mentally walk through this list:\n\n- DI: constructor injection, no hidden dependencies, minimal qualifiers\n- Config: typed properties, validated, safe defaults, secrets not logged\n- Web: thin controllers, stable errors, request validation at the boundary\n- Data: explicit transaction boundaries, no long transactions with network calls\n- Observability: request ids, structured logs, metrics for latency/errors\n- Testing: unit tests for domain logic, slice tests for boundaries, a few real integration tests\n- Resilience: timeouts everywhere, bounded retries with backoff, clear failure modes\n\nSpring isn’t perfect, and you can absolutely abuse it. But when you treat it as a set of agreements—about wiring, boundaries, and cross-cutting concerns—it becomes one of the most practical tools for building software you can change safely under pressure.

Scroll to Top