Spring Boot Web App: From Starter to Production in 30 Minutes
Build a production-ready web application with Spring Boot.
20+ years shipping production Java in banking & fintech. Drawn from code that ran under real load.
- ✓Basic knowledge of Java (Java 17 or later recommended)
- ✓Familiarity with Maven or Gradle build tools
- ✓Understanding of RESTful API concepts
- ✓An IDE (IntelliJ IDEA or Eclipse) installed
- Use Spring Initializr to generate a starter project with spring-boot-starter-web.
- Structure your code: controllers, services, repositories, DTOs.
- Implement REST endpoints with proper HTTP methods and status codes.
- Add global exception handling with @ControllerAdvice.
- Write integration tests with @SpringBootTest and MockMvc.
- Deploy with confidence using embedded Tomcat.
Think of Spring Boot as a pre-furnished apartment for your web app. You don't need to build walls, plumbing, or electricity from scratch — Spring Boot provides the framework (like the apartment structure) so you can focus on decorating (your business logic). Just add your code and it's ready to live in (deploy).
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
I've been building Spring applications since 2008 — back when XML configuration was the norm and you'd spend days wiring beans. Spring Boot changed everything. It's not just a framework; it's a productivity revolution. But here's the hard truth: most tutorials show you how to create a "Hello World" app and call it a day. They don't tell you what happens when that app hits production and a NullPointerException brings down your entire service at 3 AM.
In this guide, I'll walk you through building a real web application from scratch — a payment processing service that handles customer transactions. You'll learn the starter setup, REST API design, error handling, testing, and deployment. Along the way, I'll share war stories from production outages I've debugged, so you avoid the same mistakes.
By the end, you'll have a production-ready Spring Boot application, not just a toy. You'll understand why @RestController is your friend, why global exception handling is non-negotiable, and why your tests should mock external services. Let's get our hands dirty.
1. Setting Up Your Spring Boot Starter
Let's start with the foundation. Use Spring Initializr (start.spring.io) — it's the only way to go. Select Maven or Gradle, Java 17 or 21 (I prefer 21 for virtual threads), and add dependencies: Spring Web, Spring Data JPA, H2 Database (for development), and Lombok.
Why these? Spring Web gives you the embedded Tomcat and REST support. Spring Data JPA handles database access. H2 is an in-memory database perfect for development and testing. Lombok reduces boilerplate — but be careful: Lombok can hide bugs if misused.
Here's a minimal pom.xml snippet:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.5</version> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>
Now, create your main application class:
@SpringBootApplication public class PaymentApplication { public static void main(String[] args) { SpringApplication.run(PaymentApplication.class, args); } }
That's it. You have a running web server on port 8080. But don't stop here — real apps need structure.
2. Structuring Your Code: The Right Way
Package structure matters. Don't dump everything in one package. Use a layered architecture: controller, service, repository, dto, exception, config.
Here's what I've learned from debugging production issues: keep your controllers thin. They should only handle HTTP concerns — parsing requests, validating input, returning responses. Business logic belongs in services. Data access belongs in repositories.
Example structure: com.example.payment ├── controller │ └── PaymentController.java ├── service │ └── PaymentService.java ├── repository │ └── PaymentRepository.java ├── dto │ ├── PaymentRequest.java │ └── PaymentResponse.java ├── exception │ └── GlobalExceptionHandler.java ├── model │ └── Payment.java └── PaymentApplication.java
Why this matters? When you're debugging a 500 error at 2 AM, you want to know exactly where to look. This separation also makes testing easier — you can mock services without starting the full web context.
One more thing: use DTOs for your API layer. Don't expose your JPA entities directly. I've seen serialization issues, lazy loading exceptions, and security leaks because someone returned an entity with a password field. Always map entities to DTOs.
3. Building RESTful Endpoints: The Right HTTP Methods and Status Codes
REST is not just about URLs — it's about using HTTP properly. I've seen countless APIs that return 200 for everything, or use POST for updates. Don't be that developer.
- POST /resources → Create (201 Created)
- GET /resources → List (200 OK)
- GET /resources/{id} → Read (200 OK)
- PUT /resources/{id} → Full update (200 OK)
- PATCH /resources/{id} → Partial update (200 OK)
- DELETE /resources/{id} → Delete (204 No Content)
Use @RestController and @RequestMapping at the class level. Each method should have a specific mapping annotation: @GetMapping, @PostMapping, etc. This makes your code readable and self-documenting.
Always return ResponseEntity<T> for full control over status code and headers. For example, when creating a resource, return 201 with a Location header pointing to the new resource.
Let me show you a common mistake: returning a list directly from @GetMapping without wrapping it in ResponseEntity. That's fine, but you lose the ability to set headers. Use ResponseEntity for flexibility.
Another mistake: using @RequestMapping without specifying the method. That accepts all HTTP methods — a security risk. Always specify the method.
For more on this, check out our article on [REST PUT vs POST](/java/spring-rest-put-vs-post/).
4. Handling Errors Globally: Don't Let Your API Return Stack Traces
Default Spring Boot error handling returns a Whitelabel Error Page with a stack trace in development. In production, that's a security nightmare and a poor user experience. You must implement global exception handling.
Use @ControllerAdvice with @ExceptionHandler methods to catch exceptions and return consistent JSON error responses. Here's a pattern I've used in production:
Create a custom ErrorResponse DTO:
public record ErrorResponse(int status, String message, long timestamp) {}
Then in your global handler:
@ControllerAdvice public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class) @ResponseStatus(HttpStatus.NOT_FOUND) public ErrorResponse handleNotFound(ResourceNotFoundException ex) { return new ErrorResponse(404, ex.getMessage(), System.currentTimeMillis()); }
@ExceptionHandler(MethodArgumentNotValidException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) public ErrorResponse handleValidation(MethodArgumentNotValidException ex) { String message = ex.getBindingResult().getFieldErrors().stream() .map(e -> e.getField() + ": " + e.getDefaultMessage()) .collect(Collectors.joining(", ")); return new ErrorResponse(400, message, System.currentTimeMillis()); }
@ExceptionHandler(Exception.class) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) public ErrorResponse handleGeneral(Exception ex) { // Log the exception log.error("Unexpected error", ex); return new ErrorResponse(500, "Internal server error", System.currentTimeMillis()); } }
Notice: we log the full stack trace for general exceptions but return a generic message to the client. Never expose internal details.
For a deep dive, see our guide on [Spring Boot exception handling](/java/spring-boot-exception-handling/).
5. Validation: Never Trust Client Input
Input validation is your first line of defense. Use Bean Validation annotations (@NotNull, @Size, @Email, etc.) on your DTOs. Then add @Valid in your controller method.
But here's what the docs don't tell you: validation annotations on entities are not enough. You need to validate at the API boundary. Also, custom validation logic often requires cross-field validation — use @ScriptAssert or implement a custom validator.
Example PaymentRequest DTO:
public record PaymentRequest( @NotNull @DecimalMin("0.01") BigDecimal amount, @NotNull Currency currency, @NotNull @Size(min = 3, max = 100) String description ) {}
Then in your controller:
public ResponseEntity<PaymentResponse> createPayment(@Valid @RequestBody PaymentRequest request)
If validation fails, Spring throws MethodArgumentNotValidException, which we handle in our global handler.
For more complex scenarios, consider groups or custom validators. But keep it simple: most validation can be done with built-in annotations.
Check out our article on [Spring Boot validation](/java/spring-boot-validation/) for advanced techniques.
6. Testing Your Web Layer: Beyond Unit Tests
Unit tests are great for services, but for controllers you need integration tests that actually start the web context. Use @WebMvcTest for slicing — it starts only the web layer, not the full application.
Here's a test for PaymentController:
@WebMvcTest(PaymentController.class) class PaymentControllerTest {
@Autowired private MockMvc mockMvc;
@MockBean private PaymentService paymentService;
@Test void createPayment_shouldReturn201() throws Exception { PaymentRequest request = new PaymentRequest(new BigDecimal("100.00"), Currency.getInstance("USD"), "Test payment"); PaymentResponse response = new PaymentResponse(1L, new BigDecimal("100.00"), "USD", "COMPLETED"); when(paymentService.processPayment(any())).thenReturn(response);
mockMvc.perform(post("/api/payments") .contentType(MediaType.APPLICATION_JSON) .content("{\"amount\":100.00,\"currency\":\"USD\",\"description\":\"Test payment\"}")) .andExpect(status().isCreated()) .andExpect(jsonPath("$.status").value("COMPLETED")); } }
Notice: we mock the service layer. This test verifies HTTP mapping, serialization, and validation — without hitting the database.
For full integration tests, use @SpringBootTest with TestContainers to spin up a real database. This catches issues like wrong column mappings or SQL syntax errors.
See our guides on [Spring Boot testing with JUnit & Mockito](/java/spring-boot-testing-junit-mockito/) and [advanced testing](/java/spring-boot-testing-advanced/).
What the Official Docs Won't Tell You
Spring Boot documentation is excellent, but it glosses over real-world pitfalls. Here are things I've learned the hard way:
- Classpath scanning can bite you: If you have multiple @SpringBootApplication classes (e.g., in tests), Spring Boot might scan the wrong one. Always specify the main class in @SpringBootTest(classes = PaymentApplication.class).
- Embedded Tomcat configuration: The docs show server.port=8080, but they don't mention that you should also configure server.tomcat.max-threads, server.tomcat.max-connections, and server.tomcat.accept-count for production. Defaults are too low.
- Graceful shutdown: By default, Spring Boot kills the application immediately on SIGTERM. Add server.shutdown=graceful and set a timeout. Otherwise, in-flight requests get interrupted.
- Logging configuration: The default logback configuration logs to console only. In production, you need file appenders with rotation. Use logback-spring.xml and set logging.file.name.
- Actuator endpoints: Exposing /actuator/health is fine, but /actuator/env or /actuator/beans can leak sensitive info. Secure them with Spring Security or restrict to internal networks.
- @Transactional pitfalls: Adding @Transactional on a controller method is a common mistake. It opens a transaction for the entire request, which can cause long database locks. Keep transactions at the service layer.
- Lazy initialization: Spring Boot 2.2+ supports spring.main.lazy-initialization=true. It speeds up startup but can mask bean creation errors until first use. Use it with caution.
These are the gotchas that cause production outages. Don't ignore them.
7. Deploying to Production: From JAR to Live
Building is easy: mvn clean package produces a fat JAR. But deployment is where many apps fail.
First, create an application-prod.yml with production settings: database connection, logging, server config. Use environment variables for secrets. Never hardcode passwords.
Example application-prod.yml:
server: port: ${PORT:8080} tomcat: max-threads: 200 max-connections: 10000 accept-count: 100 shutdown: graceful
spring: datasource: url: ${DATABASE_URL} username: ${DATABASE_USER} password: ${DATABASE_PASSWORD} jpa: hibernate: ddl-auto: validate # Never use update in production
logging: file: name: /var/log/app/app.log level: root: WARN com.example: INFO
Run with: java -jar app.jar --spring.profiles.active=prod
Consider using a process manager like systemd or a container orchestration platform like Kubernetes. Always set up health checks on /actuator/health.
For API documentation, integrate SpringDoc OpenAPI (see [Spring Boot Swagger/OpenAPI](/java/spring-boot-swagger-openapi/)). It generates interactive API docs from your code.
Finally, monitor with Spring Boot Actuator and Micrometer. Track request rates, error rates, and latency. Set up alerts for 5xx errors.
Deployment is not the end — it's the beginning of operations.
The Case of the Silent 500: When a Missing @ResponseBody Crashed a Payment Gateway
- Always use @RestController for REST APIs — it combines @Controller and @ResponseBody.
- Configure a global exception handler with @ControllerAdvice to catch and log all exceptions.
- Enable detailed error responses in development (server.error.include-stacktrace=always) and sanitize them in production.
- Test your endpoints with actual HTTP clients, not just unit tests.
- Log the full stack trace on 500 errors — don't rely on default error pages.
curl -v http://localhost:8080/your-pathgrep 'RequestMapping' /var/log/app.log| File | Command / Code | Purpose |
|---|---|---|
| PaymentApplication.java | @SpringBootApplication | 1. Setting Up Your Spring Boot Starter |
| PaymentController.java | @RestController | 2. Structuring Your Code |
| PaymentController.java (updated) | @DeleteMapping("/{id}") | 3. Building RESTful Endpoints |
| GlobalExceptionHandler.java | @ControllerAdvice | 4. Handling Errors Globally |
| PaymentRequest.java | public record PaymentRequest( | 5. Validation |
| PaymentControllerTest.java | @WebMvcTest(PaymentController.class) | 6. Testing Your Web Layer |
| application-prod.yml | server: | 7. Deploying to Production |
Key takeaways
Interview Questions on This Topic
Explain the Spring Boot auto-configuration mechanism. How does it decide which beans to configure?
Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Drawn from code that ran under real load.
That's Spring Boot. Mark it forged?
6 min read · try the examples if you haven't