Spring Boot – Structuring a Project

June 17, 20264 min readUpdated 8/18/2026

Spring Boot has no opinion about your package layout, which means the decision is yours and it compounds. This lesson describes the structure the pizza API uses and the reasoning behind each choice — including the one mistake that produces a genuinely baffling error.

The one rule you cannot break

@SpringBootApplication includes @ComponentScan, which scans the annotated class's package and everything below it. Nothing else.

com.pizza.api
├── PizzaSpringbootBackendApplication.java   <- scanning starts here
├── config/                                   scanned
├── entity/                                   scanned
└── security/                                 scanned

com.pizza.util                                NOT scanned - a sibling, not a child

Put a @Service in com.pizza.util and Spring never sees it. The failure is not "package not scanned" — it is:

Parameter 0 of constructor in com.pizza.api.entity.order.CustomerOrderServiceImpl
required a bean of type 'com.pizza.util.TaxCalculator' that could not be found.

Which sends you looking for a missing @Service annotation that is right there. Keep the application class in the root package of your application and this problem never occurs.

Package by feature, not by layer

The layout most tutorials show groups by technical role:

com.example.app
├── controller/    ProductController, OrderController, UserController, CartController...
├── service/       ProductService, OrderService, UserService, CartService...
├── repository/    ProductRepository, OrderRepository, UserRepository...
└── model/         Product, Order, User, Cart...

It looks tidy and it works badly at scale. Every change to one feature touches four directories, and no directory tells you what the application does. Open controller/ in a mature project and you are looking at forty files with nothing in common.

The pizza API groups by domain instead:

com.pizza.api
├── config/      SecurityConfig · OpenApiConfig · RestMVCConfig · ThreadPoolConfig
│                CacheConfig · PizzaProperties
├── aspect/      ServiceTimingAspect
├── dto/         every DTO + ONE central EntityDTOMapper (MapStruct)
├── entity/      DatabaseTableNames + one package per domain:
│   ├── cart/    Cart, CartItem, CartItemTopping + DAO/Service/Controller
│   ├── crust/
│   ├── order/   CustomerOrder, OrderItem, PricingService, OrderPlacedEvent
│   ├── product/ Product, ProductSize
│   ├── topping/
│   └── user/    User, UserAddress, UserPaymentMethod, auth + profile + admin
├── exception/   ApiError · ApiSubError · ApiException · RestExceptionHandler
├── mail/        MailService + Impl
├── mapper/      JdbcTemplate RowMapper classes
├── messaging/   JMS publisher, listener and config
├── payment/     StripeService · StripeWebhookController
├── report/      ReportDAO/Imp · ReportService/Impl · ReportRestController
├── search/      Elasticsearch document, repository, service
├── security/    JwtService · JwtAuthenticationFilter · oauth2/
└── storage/     ProductImageStorageService + Impl

Everything about products lives in entity/product/: the entity, its DAO, its service, its controller. Deleting the feature means deleting a directory. Understanding it means reading one.

Interface plus implementation, always

Every service and every DAO in the pizza API is an interface and a separate class:

entity/product/
├── Product.java                 the JPA entity
├── ProductRepository.java       Spring Data - derived queries
├── ProductDAO.java              the data-access interface
├── ProductDAOImp.java           repository + JdbcTemplate
├── ProductService.java          the business interface
├── ProductServiceImpl.java      the implementation
├── ProductRestController.java   HTTP
├── ProductType.java             enums
└── SizeName.java
public interface ProductService {

    List<ProductDTO> getMenu();

    List<ProductDTO> getByType(ProductType type);

    ProductDTO getProductByPublicId(UUID id);

    ProductDTO createProduct(ProductCreateDTO dto);

    ProductDTO updateProduct(UUID id, ProductCreateDTO dto);

    ProductDTO setProductImage(UUID id, MultipartFile file);

    void deactivateProduct(UUID id);

    void deleteProduct(UUID id);
}

Is the interface worth it when there is exactly one implementation? Reasonable people disagree. The argument for it here: the interface is the readable summary of what the feature does — eight lines against three hundred — and it is what the controller and the tests depend on. The argument against is that it is ceremony. Pick one and be consistent; the cost of mixing both styles is higher than the cost of either.

The DAO layer

This is the pizza API's most opinionated choice. Each DAO implementation wires in both a Spring Data repository and a JdbcTemplate, and uses whichever fits the method:

  • Repository for simple things — save, single-row lookups, existence checks. It returns managed entities that dirty checking can track, and it honours the @SQLRestriction that hides soft-deleted rows.
  • JdbcTemplate for anything that aggregates or whose result is not an entity. JPA has nothing to offer those.

Lessons 16 and 17 cover each in depth. The structural point is that the choice is made inside the DAO, so the service above it never knows or cares which was used.

Where the cross-cutting pieces go

PackageHoldsWhy not in the feature package
config/Security, OpenAPI, thread pools, caching, typed propertiesThey configure the application, not one domain
exception/The error shape and one @RestControllerAdviceOne error contract for every endpoint — lesson 12
dto/Every DTO and one central MapStruct mapperDTOs cross feature boundaries; one mapper avoids twelve
security/JWT service and filterApplies to every request

A reasonable alternative is to keep each feature's DTOs in its own package. The pizza API chose one dto/ package because a single EntityDTOMapper then has one obvious home; with per-feature DTOs you get per-feature mappers, which is more files and more places for the mapping conventions to drift.

Naming

Consistency matters more than the specific convention, but two are worth calling out:

  • CustomerOrder, not Order. ORDER is a SQL reserved word, and an entity called Order generates SQL that needs quoting in every query. Renaming the class is cheaper than fighting it.
  • Pick Impl or Imp and never mix them. The pizza API uses DAOImp and ServiceImpl — inherited from the codebase it was patterned on, and inconsistent. It is consistent within each layer, which is enough to be predictable, and changing it now would touch every file for no functional gain.

What to take from this

  • Application class in the root package. Everything else is preference; this one produces a misleading error.
  • Group by feature. A directory should tell you what the app does, not what layer it is.
  • Be consistent about interfaces and naming. Half-applied conventions are worse than either extreme.
  • Cross-cutting code goes in its own package, feature code stays with its feature.

Next: beans, scopes and lifecycle — what the container is actually holding, and why @Primary and @Qualifier exist.