Building Better Spring Boot Apps: A Practical Guide -- POV of a senior engineer.

October 28, 2025 (1 month ago)

Building Better Spring Boot Apps: A Practical Guide

A Note on This Guide

Over the past few weeks, I took some time away to invest in deepening my technical expertise in Spring Boot application architecture. This period allowed me to not only explore advanced architectural patterns and best practices but also to mentor junior developers, sharing practical insights and real-world implementation strategies.

This guide is a synthesis of that learning journey—a comprehensive yet approachable exploration of modern Spring Boot architecture. Whether you're building your first production application or refining an existing system, these patterns and practices will help you create applications that are robust, maintainable, and ready to scale.


If you're building Spring Boot applications, you've probably wondered: "What's the best way to organize all this code?" Trust me, you're not alone. Let's walk through some battle-tested patterns that'll make your life (and your teammates' lives) much easier.

Part 1: Getting Your Project Structure Right

Think of your project structure like organizing your house. You could put all your kitchen stuff in one room, all your bedroom stuff in another, and all your bathroom items somewhere else. But wouldn't it make more sense to organize by function instead? That's exactly what we're talking about here.

The Old Way: Organizing by Technical Layers

Most of us start by creating packages like controller, service, repository, and model. It feels natural—everything has its place based on what it does technically. All your controllers live together, all your services hang out in their own package, and so on.

The problem? When you need to add a new feature (let's say, updating a user's profile), you're jumping between four different packages. Change the User entity here, update UserRepository there, modify UserService over here, and finally tweak UserController way over there. It's like having your coffee maker in the kitchen, coffee beans in the garage, filters in the bedroom, and mugs in the basement!

This creates what we call low cohesion (unrelated things stuck together) and high coupling (everything depends on everything else). One small change can ripple through your entire codebase.

The Better Way: Organizing by Features

Here's a game-changer: put everything related to one feature in the same package. All your user-related stuff—controller, service, repository, DTOs—goes in a user package. Order-related stuff? That's in the order package.

Why this rocks:

  • Finding stuff is easy: Need to work on user features? Everything's right there in one place
  • Changes stay contained: Updating user logic rarely touches your order code
  • Better encapsulation: Many classes can be package-private instead of public, hiding implementation details
  • Team-friendly: Multiple developers can work on different features without stepping on each other's toes

Taking It Further: Vertical Slice Architecture

Vertical Slice Architecture (VSA) takes this idea and runs with it. Each "slice" is a complete feature from top to bottom—from the API endpoint all the way down to the database. Think of it like a self-contained mini-application for each feature.

The beauty here? Each slice is independent. You can change how one feature works without worrying about breaking another. Testing becomes easier. And if you ever need to split your app into microservices, each slice is already a natural candidate!

Making It Bulletproof: Spring Modulith

Here's the thing—even with the best intentions, projects can drift. Someone's in a hurry, takes a shortcut, and suddenly your clean architecture has holes in it. That's where Spring Modulith comes in.

Spring Modulith lets you:

  • Define clear module boundaries based on your package structure
  • Verify your architecture automatically with simple unit tests—if someone tries to access internal classes from another module, the build fails
  • Test modules independently by loading only what you need, making tests faster
  • Document your structure with automatically generated diagrams

Think of it as having an architectural guard that never sleeps, never gets tired, and catches mistakes before they reach production.

Part 2: Crafting Clean Domain Logic

Now that we've got our structure sorted, let's talk about the heart of your application—the domain layer where your business logic lives.

Understanding Your Data Objects

Let's clear up some confusion about Entities, DTOs, and Value Objects. These terms get thrown around a lot, but understanding them is crucial.

Entities are your core business objects with a unique identity. A User with ID 123 is a specific person. Entities can (and should!) contain business logic. Your Order entity should know how to calculate its total price or cancel itself. Don't just make them dumb data bags!

DTOs (Data Transfer Objects) are messengers—they carry data between layers or across network boundaries. They're simple, with no business logic. Think of them as envelopes: they hold information but don't do anything with it.

Why bother with DTOs? Three big reasons:

  1. Flexibility: Your API contract stays stable even when your database changes
  2. Security: You never accidentally expose sensitive fields (like passwords)
  3. Performance: You can shape DTOs exactly for what each endpoint needs

Value Objects represent concepts without identity. Two Money objects both representing "$10 USD" are the same—they don't need unique IDs. They make your code more expressive. Instead of BigDecimal price and String currency, you have Money price. Much clearer!

Automate the Boring Stuff: MapStruct

Converting between entities and DTOs is tedious and error-prone. MapStruct solves this beautifully—it generates the mapping code at compile time, so it's fast and type-safe.

You just create a simple interface:

@Mapper(componentModel = "spring")
public interface UserMapper {
    UserDTO toDTO(User user);
    User toEntity(UserDTO dto);
}

MapStruct handles the rest. No boilerplate, no runtime overhead, and if you mess up a mapping, you'll know at build time, not in production.

Database Best Practices

A few golden rules that'll save you from performance nightmares:

Default to lazy loading: Eager loading causes the dreaded N+1 query problem. Load relationships explicitly when you need them with JOIN FETCH in your queries.

Use projections for read operations: Don't load entire entities when you only need three fields. Query for exactly what you need—your database and memory will thank you.

Choose the right tool: Use find() for single records by ID, JPQL for most queries, and native queries only when you absolutely need database-specific features. Always use bind parameters to prevent SQL injection!

Designing Services That Stand the Test of Time

Should services have interfaces? Here's the deal:

For simple, internal services that'll only ever have one implementation, interfaces might be overkill. But as your app grows, interfaces become invaluable:

  • Abstraction: Consumers depend on what the service does, not how it does it
  • Testability: Mocking interfaces is straightforward and clean
  • Flexibility: Swap implementations without changing consumers

Constructor injection is your friend: Always inject dependencies through the constructor. Make them private final. This gives you:

  • Immutable, thread-safe components
  • Impossible-to-create invalid objects (no NullPointerException surprises)
  • Clear signals when a class has too many dependencies

Avoid field injection (@Autowired on fields)—it hides dependencies and makes testing harder.

Part 3: Communication Patterns That Scale

Your app doesn't live in isolation. Let's talk about how it should communicate with the world.

REST APIs Done Right

Your API is your application's public face. Make it professional.

Standardize your responses: Every response should follow the same structure:

{
  "status": "success",
  "message": "User created successfully",
  "data": { ...actual data... },
  "metadata": { "page": 1, "totalPages": 5 }
}

This consistency makes life easier for everyone consuming your API.

Version your API properly: Your API will change. Have a plan for it. The most common approaches:

  • URL versioning (/api/v1/users): Super clear, works everywhere
  • Header versioning (X-API-Version: 1): Keeps URLs clean
  • Query parameters (/api/users?v=1): Easy to test
  • Media type (Accept: application/vnd.app-v1+json): Most RESTful

Pick one and stick with it consistently.

Going Async: Event-Driven Architecture

Sometimes you don't need an immediate response. When a user places an order, you might need to:

  • Send a confirmation email
  • Update inventory
  • Notify the warehouse
  • Log analytics

These don't need to happen synchronously. Enter events!

Within your app: Use Spring's built-in events. Your OrderService publishes an OrderCreatedEvent, and other parts of your app listen for it. They're decoupled—the order service doesn't even know who's listening.

Between services: Use Spring Cloud Stream with RabbitMQ or Kafka. You get durable messaging, retry logic, and the ability to scale consumers independently.

The rule of thumb: use synchronous calls when you need an immediate answer for the user. Use events to notify other parts of the system about what happened.

Part 4: Handling the Cross-Cutting Stuff

Some concerns span your entire application—logging, security, error handling. Don't scatter them everywhere; centralize them elegantly.

Global Exception Handling

Stop writing try-catch blocks in every controller method! Use @RestControllerAdvice to handle exceptions in one place:

@RestControllerAdvice
public class GlobalExceptionHandler {
    
    @ExceptionHandler(ResourceNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public ApiResponse<Object> handleNotFound(ResourceNotFoundException ex) {
        return new ApiResponse<>("error", ex.getMessage(), null, null);
    }
    
    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ApiResponse<Object> handleValidation(MethodArgumentNotValidException ex) {
        // Extract validation errors and return them nicely formatted
    }
}

Every exception now returns a consistent, professional error response. No more scattered error handling logic!

The Magic of AOP (Aspect-Oriented Programming)

Want to log method execution times without cluttering your business logic? AOP to the rescue!

Create a custom annotation like @LogExecutionTime, then write an aspect that intercepts methods with this annotation and logs how long they took. Your business code stays clean, and you can toggle performance monitoring on and off by just adding or removing the annotation.

Other great uses:

  • Automatic audit logging
  • Custom security checks
  • Transaction management
  • Caching

Managing Shared Code

Every app has utility classes. Keep them organized:

  • Make them final with a private constructor
  • Use only static methods
  • Put them in a dedicated util package
  • Use Lombok's @UtilityClass to enforce these rules automatically

For shared code across modules, create a dedicated common module. But be careful! Only put truly generic, cross-cutting code there. Domain-specific logic in shared modules creates hidden coupling—resist the temptation!

In larger systems, treat shared libraries as separate projects with their own versioning. Publish them to a repository manager and let consuming apps choose when to upgrade. This prevents breaking changes from sneaking in and keeps teams independent.

Part 5: Keeping Your Architecture Honest

Designing a clean architecture is step one. Keeping it that way over time? That's the real challenge.

Test Your Architecture

Don't rely on documentation or developer discipline alone. Write tests that verify your architectural rules!

Unit testing with mocks: Test services in isolation using Mockito. Mock the dependencies (like repositories) so you're testing only the service logic.

Know the difference:

  • @Mock (pure Mockito): For standard unit tests
  • @MockBean (Spring Boot): For integration tests where you need to replace a real Spring bean

Verify module boundaries: With Spring Modulith, write a simple test:

@Test
void verifyModularStructure() {
    ApplicationModules.of(Application.class).verify();
}

This test fails if anyone creates illegal dependencies between modules. It's like having a vigilant architect reviewing every code change.

Module-scoped integration tests: Use @ApplicationModuleTest to load only one module and its dependencies. These tests run fast and prove your modules are truly independent.

The Bottom Line

Architecture isn't something you do once and forget. It's a living practice. By:

  • Organizing code around features, not layers
  • Using Spring Modulith to enforce boundaries
  • Separating entities from DTOs
  • Choosing the right communication patterns
  • Centralizing cross-cutting concerns
  • Testing architectural rules automatically

...you create a system that's not just good today, but stays good as it grows.

Your future self (and your teammates) will thank you. Happy coding! 🚀