A hands-on tutorial on Spring Framework (without Spring Boot), organized into Git branches, whose goal is to cover almost every notion of the Spring Core foundation (IoC container, dependency injection, bean lifecycle, AOP, SpEL, validation, resources, i18n, application events...) through a real REST API structured as a Maven multi-module project (see Approach for how the two combine).
The data model follows the same schema as spring-boot-tutorial: categories → products → orders ← customers.
All API routes are prefixed with /api/v1.
This document is the complete specification of the project: it is meant to be followed step by step to implement each branch.
- Approach
- Spring Core fundamentals
- Tech stack
- Data model
- Module structure
- Branching strategy
- Standard response format
- Spring AOP
- Testing strategy
- feature/core-architecture
- feature/dao
- feature/service
- feature/web
- Order of work
- Code conventions
- Spring Core concepts covered
- How to follow this tutorial
The project is built on Spring Framework 6.2.x alone, without Spring Boot, so the mechanisms of the "raw" Spring container can be exercised and taught explicitly:
- Manual context assembly (Java Config and XML, in two different modules, to compare both styles)
- Bean lifecycle, scopes, and container extension points (
BeanPostProcessor,FactoryBean) wired by hand - AOP, SpEL, validation, i18n, application events implemented explicitly
- A classic WAR deployment on an external Tomcat, via
WebApplicationInitializer - Manually configured declarative transaction management (explicit
PlatformTransactionManager)
The Maven multi-module architecture (a parent POM + 5 modules with real compilation dependencies between them, breaking dao breaks compilation of service) is a valid destination on its own if that is what you are looking for. It is also the vehicle chosen here to teach Spring Core: each module is organized so that the Spring Core mechanisms it demonstrates are easy to point to and follow.
The project only produces a REST API (module web), to stay focused on API development.
Explaining how the Spring container actually works, not just how to annotate a class, is the actual goal of this tutorial.
- Inversion of Control (IoC): instead of an object constructing or looking up its own dependencies, it declares what it needs (through a constructor, in this project) and something external supplies them. That "something external" is the container: it builds the entire object graph of the application (DAOs, services, controllers, infrastructure beans) and wires it together, so no class ever calls
newon a collaborator it depends on. BeanFactoryvsApplicationContext:BeanFactoryis the minimal container contract (register bean definitions, get beans, wire dependencies).ApplicationContextis the interface actually used everywhere in this project: it is aBeanFactoryplus extra services a real application needs - publishing/consuming application events, resolving messages throughMessageSource(seefeature/web), loadingResources uniformly regardless of where they live (classpath, filesystem, URL - seefeature/dao), and reading configuration through theEnvironmentabstraction.- Configuration metadata is just data, not code: whether a bean is declared in XML (
service-context.xmlinfeature/service), in Java (@Configuration/@BeaninPersistenceConfig,feature/dao), or discovered via@Componentand classpath scanning, the container turns all three into the same internal representation (aBeanDefinitiongraph) before wiring anything. This project deliberately mixes all three styles across modules specifically to make that equivalence visible, instead of picking one and hiding the others. - Bean lifecycle, step by step: for each singleton bean the container (1) instantiates it, (2) injects its declared dependencies, (3) runs any
BeanPostProcessor.postProcessBeforeInitialization, (4) runs initialization callbacks (@PostConstruct,InitializingBean.afterPropertiesSet()), (5) runsBeanPostProcessor.postProcessAfterInitialization(this is the step that lets Spring AOP wrap a bean in a proxy transparently, see Spring AOP), and finally (6) on container shutdown, runs destruction callbacks (@PreDestroy,DisposableBean.destroy()).feature/daomakes steps 2-4 and theBeanPostProcessorextension point observable on purpose (see its task list), since Spring Boot normally runs this whole sequence silently. - Bean scopes control identity, not just configuration:
singleton(the default) means the container hands out the exact same instance to every injection point;prototypemeans a brand-new instance is created every time one is requested, so it cannot safely hold container-managed dependencies the way a singleton does without care;request(used infeature/web) means one instance per HTTP request, made possible by a scoped proxy that forwards each call to the instance for the currently active request. - The
Environmentabstraction and profiles: application code should ask the container "what is the value of this property," not care whether the value came from a.propertiesfile, an environment variable, or a system property -Environment/@Valueunify all of those sources.@Profile("dev")/@Profile("prod")(infeature/dao) let the same codebase register a different bean (e.g. a differentDataSource) depending on which profiles are active, without anifstatement anywhere in application code. - Aspect-oriented programming as a container extension, not a language feature: cross-cutting concerns (logging, timing) are woven in by having the container substitute a bean with a dynamically generated proxy around it, at the
BeanPostProcessorstep above; see Spring AOP for how this project makes that proxying visible instead of leaving it implicit.
| Component | Choice |
|---|---|
| Framework | Spring Framework 6.2.x (classic, without Spring Boot) |
| Language | Java 17 |
| Build | Maven multi-module (parent pom.xml + module pom.xml files) |
| Database | PostgreSQL 16 |
| ORM | Hibernate / JPA (manual configuration, LocalContainerEntityManagerFactoryBean) |
| Migrations | Flyway (run at startup, configured in the dao module) |
| API | Annotated Spring MVC (@RestController), JSON via Jackson |
| AOP | Spring AOP (proxy) + AspectJ (aspectjweaver), aspects annotated @Aspect |
| Validation | Bean Validation 3.0 (Hibernate Validator), @Valid on DTOs |
| I18n | MessageSource (ReloadableResourceBundleMessageSource), Locale resolved from Accept-Language |
| Server | External Tomcat 10 (WAR packaging) |
| Tests | JUnit 5, Mockito, spring-test, MockMvc |
| CI/CD | GitHub Actions (multi-module build, mvn -pl ... -am) |
| Containerization | Docker (multi-stage build), docker-compose for local runs |
categories (id, category_name)
│ 1
│
│ N
products (id, category_id, product_name, unit_price)
│ 1
│
│ N
orders (id, customer_id, product_id, quantity, total)
│ N
│
│ 1
customers (id, first_name, last_name, telephone, email, address)
Columns and constraints are identical to spring-boot-tutorial (see its README for details).
spring-tutorial/ (parent pom, packaging=pom)
├── pom.xml (dependencyManagement, modules, common plugins)
│
├── spring-tutorial-common/ (jar - shared exceptions)
│ └── src/main/java/edgareldy/springtutorial/common/
│ └── exception/
│ ├── ResourceNotFoundException.java
│ └── BusinessRuleException.java
│
├── spring-tutorial-domain/ (jar - JPA entities, no Spring dependency)
│ └── src/main/java/edgareldy/springtutorial/domain/
│ ├── Category.java
│ ├── Product.java
│ ├── Customer.java
│ └── Order.java
│
├── spring-tutorial-dao/ (jar - data access, depends on domain + common)
│ └── src/main/java/edgareldy/springtutorial/dao/
│ ├── config/
│ │ └── PersistenceConfig.java (JavaConfig: DataSource, EntityManagerFactory, TransactionManager)
│ ├── CategoryDao.java / CategoryDaoImpl.java
│ ├── ProductDao.java / ProductDaoImpl.java
│ ├── CustomerDao.java / CustomerDaoImpl.java
│ └── OrderDao.java / OrderDaoImpl.java
│
├── spring-tutorial-service/ (jar - business logic, depends on dao)
│ └── src/main/
│ ├── java/edgareldy/springtutorial/service/
│ │ ├── CategoryService.java / impl/CategoryServiceImpl.java
│ │ ├── ProductService.java / impl/ProductServiceImpl.java
│ │ ├── CustomerService.java / impl/CustomerServiceImpl.java
│ │ ├── OrderService.java / impl/OrderServiceImpl.java
│ │ ├── event/
│ │ │ ├── OrderCreatedEvent.java
│ │ │ └── OrderCreatedEventListener.java
│ │ └── aspect/
│ │ └── LoggingAspect.java
│ └── resources/
│ └── spring/service-context.xml (XML config: <tx:annotation-driven/>, <aop:aspectj-autoproxy/>, <context:component-scan/>)
│
└── spring-tutorial-web/ (war - JSON REST API, depends on service)
└── src/main/java/edgareldy/springtutorial/web/
├── config/
│ ├── WebAppInitializer.java (WebApplicationInitializer, replaces web.xml)
│ └── WebMvcConfig.java (@EnableWebMvc, JSON message converters, imports service-context.xml and PersistenceConfig)
├── dto/
│ ├── common/ (ApiResponse.java, PageResponse.java)
│ ├── category/ (CategoryRequest, CategoryResponse)
│ ├── product/ (ProductRequest, ProductResponse)
│ ├── customer/ (CustomerRequest, CustomerResponse)
│ └── order/ (OrderRequest, OrderResponse)
├── mapper/ (CategoryMapper, ProductMapper, CustomerMapper, OrderMapper)
├── controller/
│ ├── CategoryController.java
│ ├── ProductController.java
│ ├── CustomerController.java
│ └── OrderController.java
└── exception/
└── WebExceptionHandler.java (@ControllerAdvice, ApiResponse<Void> responses)
| Branch | Role |
|---|---|
master |
Stable code. No direct commits, only merges from develop. |
develop |
Integration branch. |
feature/core-architecture |
Parent POM + common and domain modules. |
feature/dao |
spring-tutorial-dao module (data access, Java Config). |
feature/service |
spring-tutorial-service module (business logic, XML config). |
feature/web |
spring-tutorial-web module (JSON API, @RestController, /api/v1 prefix, WAR). |
Each branch corresponds almost one-to-one with a Maven module: this is deliberate, so the Git history illustrates the progressive construction of the module dependency tree.
Every API response (success and error alike) is wrapped in a generic ApiResponse<T> DTO, defined in dto/common/ApiResponse.java, to keep a consistent contract across all endpoints.
public record ApiResponse<T>(
boolean success,
String message,
T data,
Instant timestamp
) {
public static <T> ApiResponse<T> success(T data, String message) {
return new ApiResponse<>(true, message, data, Instant.now());
}
public static <T> ApiResponse<T> error(String message) {
return new ApiResponse<>(false, message, null, Instant.now());
}
}- On list endpoints,
dataholds aPageResponse<T>(paginated content) instead of a plainList<T>. - On error,
WebExceptionHandler(@ControllerAdvice, inweb/exception/) returns anApiResponse<Void>withsuccess=falseand an explicitmessage, mapping each exception to an HTTP status:ResourceNotFoundException(fromspring-tutorial-common) → 404MethodArgumentNotValidException(Bean Validation failure on a@Validrequest body) → 400, message resolved throughMessageSourceBusinessRuleException(fromspring-tutorial-common) → 422- Any other uncaught
Exception→ 500
- Error response example:
{
"success": false,
"message": "Category not found",
"data": null,
"timestamp": "2026-07-05T10:16:05Z"
}The project uses Spring AOP to illustrate aspect-oriented programming, kept out of any business logic in services/controllers.
- Dependency:
aspectjweaver, enabled via<aop:aspectj-autoproxy/>inservice-context.xml LoggingAspect(service/aspect/LoggingAspect.java):@Aroundadvice on all public methods ofservice.impl(pointcutexecution(* edgareldy.springtutorial.service.impl..*(..))), logs method entry/exit, arguments, execution time, and thrown exceptions- Also serves as a teaching base for the other advice types (
@Before,@After,@AfterReturning,@AfterThrowing) alongside@Around - Illustrates the proxy-based nature of Spring AOP: since services are Spring-managed beans injected by interface (
CategoryService,ProductService, ...), a JDK dynamic proxy is used rather than a CGLIB subclass proxy
Every module ships its tests before its Pull Request is opened, with the tool that fits the module's place in the architecture.
| Module | Tool | What it verifies |
|---|---|---|
dao |
JUnit 5 + SpringExtension + H2 |
DAO methods against a real in-memory database, with the Spring context loaded from the module's configuration |
service |
JUnit 5 + Mockito, and SpringExtension for the context tests |
Business rules with the DAO layer mocked, and the wiring of the service context |
web |
JUnit 5 + MockMvc |
HTTP status codes, payload shape (ApiResponse<T>) and error mapping, with the service layer mocked |
Every test method, in every module, is named _NN_Should<Outcome>_When<Condition>: a two-digit, zero-padded sequence number (the order of the methods within the class, restarting at _01_ in each class; JUnit does not enforce it, it is kept consistent by convention), followed by what is expected, followed by the condition that produces it.
@Test
void _01_ShouldReturnCategory_WhenCategoryExists() { ... }
@Test
void _02_ShouldReturnEmpty_WhenCategoryDoesNotExist() { ... }No other naming style (shouldX(), testX(), givenX_whenY_thenZ(), savePersistsANewCategory()) is used anywhere in this project's test suite. This applies to test methods only, not to @BeforeEach/@AfterEach helpers.
Technical foundation shared by the whole project, to be merged first into develop.
Originally named feature/config; renamed to better reflect that it lays out the parent POM
and the two dependency-free foundation modules (common, domain), not just configuration
files.
- Parent
pom.xml:packaging=pom,<modules>section listing the 5 modules,<dependencyManagement>centralizing versions (Spring Framework, Hibernate, PostgreSQL driver, JUnit, etc.),maven-compiler-plugin(Java 17) declared once -
spring-tutorial-commonmodule: shared exceptions (ResourceNotFoundException,BusinessRuleException), no Spring dependency (deliberately a "pure Java" module) -
spring-tutorial-domainmodule: JPA entitiesCategory,Product,Customer,Order(jakarta.persistence.*annotations only, no Spring dependency either) - Flyway script
V1__init_schema.sqlplaced underspring-tutorial-dao/src/main/resources/db/migration -
.github/workflows/ci.yml: multi-module build (mvn -T 1C clean verifyat the root, compiling all modules in the correct order thanks to the Maven dependency graph) - Multi-stage
Dockerfile(Maven build stage + Tomcat 10 image, deploys the WAR towebapps/) -
docker-compose.yml:webservice built from theDockerfile, joining an externally managed PostgreSQL container instead of declaring its owndbservice (see Configuration notes) - Branch README explaining the role of the parent POM
- No Spring Boot BOM here: unlike the Boot tutorials in this series, there is no
spring-boot-dependenciesimport to guarantee that Spring Framework, Hibernate, and the Jakarta Persistence API versions are mutually compatible. Verify the version matrix manually before locking versions in the parent<dependencyManagement>(Spring Framework 6.2.x requires Hibernate 6.x built againstjakarta.persistence3.1, and a Jakarta EE 9+ Tomcat, i.e. Tomcat 10, not 9). - H2 vs. Testcontainers for
daointegration tests: H2 was chosen, oncefeature/daoneeded to decide. It is not only a test convenience: the@Profile("dev")DataSourceinPersistenceConfigembeds H2 directly, so the dev profile and the DAO integration tests share the exact same database engine instead of introducing a second one just for tests.feature/serviceandfeature/webtests should stay consistent with this choice. docker-compose.ymldoes not declare its own PostgreSQL service. In this development environment, a single long-livedpostgres_maincontainer (PostgreSQL 16) is already shared across several local projects, on an external Docker network (pg_net); thewebservice here joins that network and connects to a dedicatedspring_tutorialdatabase created once inside it (docker exec postgres_main psql -U admin -d maindb -c "CREATE DATABASE spring_tutorial;"), instead of starting a redundant, conflicting PostgreSQL container on the same host port. A reader without an equivalent shared container should add their owndb: image: postgres:16service (with a matchingJDBC_URL) rather than rely onpg_netexisting.
Depends on common and domain.
-
PersistenceConfig(Java Config):DataSource(connection pool),LocalContainerEntityManagerFactoryBean(illustrates theFactoryBeanpattern),JpaTransactionManager, exposed as Spring beans - Spring profiles (
@Profile("dev")/@Profile("prod")) for two differentDataSourcebeans (embedded H2 in dev, PostgreSQL in prod), activated viaspring.profiles.active, to illustrate theEnvironmentabstraction -
@PropertySource("classpath:jdbc.properties")+Environment/@Valueto inject the connection URL and credentials, instead of hard-coding them inPersistenceConfig - Bean lifecycle made explicit:
@PostConstruct/@PreDestroymethods (e.g. logging connection pool startup/shutdown) - A teaching
BeanPostProcessor(e.g.BeanCreationLoggerPostProcessor) that logs the name of every bean instantiated in thedaocontext, to make a normally invisible container extension point visible -
CategoryDao,ProductDao,CustomerDao,OrderDaointerfaces (contracts) +*DaoImplimplementations usingEntityManagerdirectly (no Spring Data here, manual DAO to properly show the JPA mechanics) - Explicit JPQL queries for relations (
Product → Category,Order → Customer/Product) - Loading the Flyway script through Spring's
Resourceabstraction (ClassPathResource), to illustrate the resource abstraction independently of the file system - Integration tests for the
daomodule with an H2 database, Spring context loaded via theSpringExtension/@ContextConfigurationJUnit 5 integration (the sameAnnotationConfigApplicationContext-based mechanism, just not instantiated by hand)
Depends on dao. Uses XML configuration (deliberately different from the Java Config used in the dao module, to compare both approaches within the same project).
-
service-context.xml:<context:component-scan base-package="edgareldy.springtutorial.service"/>,<tx:annotation-driven/>,<aop:aspectj-autoproxy/> -
CategoryService,ProductService,CustomerService,OrderServiceinterfaces at the root ofservice/, implementations inservice/impl/, annotated@Transactional - Business rules shared with the other tutorials: deleting a non-empty category is forbidden,
totalcomputed on orders - Constructor injection with
@Qualifier: at least two beans of the same type (e.g. twoClockbeans,Clock.systemDefaultZone()in production and a fixedClockinjectable in tests) to illustrate disambiguation by qualifier - A
prototype-scoped bean (e.g. an order reference generator), contrasted with thesingletonservices used by default -
OrderCreatedEventapplication event published viaApplicationEventPublisherfromOrderServiceImpl, consumed by an@EventListener(e.g. audit logging) in another bean, to illustrate producer/consumer decoupling within the same context -
LoggingAspect(service/aspect/) implementing the mechanism described in Spring AOP - Use of SpEL in a
@Valueannotation (e.g. a computed default page size) and/or in the XML (#{...}) - Business validation with Bean Validation (
@Valid,jakarta.validation.constraints.*) on objects passed to services, and/or a customorg.springframework.validation.Validatorfor a rule that cannot be expressed with annotations - Unit tests (Mockito) and integration tests loading
service-context.xmlviaClassPathXmlApplicationContext(in practice, aPersistenceConfig/service-context.xmlcontext hierarchy loaded throughSpringExtension, the same mechanism a hand-instantiatedClassPathXmlApplicationContextwould use)
Depends on service. The only entry point of the project, exclusively a JSON API.
All routes are prefixed with /api/v1.
| Method | URL | Description |
|---|---|---|
| GET | /api/v1/categories |
Paginated list |
| GET | /api/v1/categories/{id} |
Detail |
| POST | /api/v1/categories |
Create |
| PUT | /api/v1/categories/{id} |
Update |
| DELETE | /api/v1/categories/{id} |
Delete |
| GET | /api/v1/products |
Paginated list, filterable by categoryId |
| GET | /api/v1/products/{id} |
Detail |
| POST | /api/v1/products |
Create |
| PUT | /api/v1/products/{id} |
Update |
| DELETE | /api/v1/products/{id} |
Delete |
| GET | /api/v1/customers |
Paginated list, search by name |
| GET | /api/v1/customers/{id} |
Detail |
| POST | /api/v1/customers |
Create |
| PUT | /api/v1/customers/{id} |
Update |
| DELETE | /api/v1/customers/{id} |
Delete |
| GET | /api/v1/orders |
Paginated list, filterable by customerId/productId |
| GET | /api/v1/orders/{id} |
Detail |
| POST | /api/v1/orders |
Create (computes total) |
| PUT | /api/v1/orders/{id} |
Update |
| DELETE | /api/v1/orders/{id} |
Delete |
-
WebAppInitializer(WebApplicationInitializer): registers theDispatcherServlet, replacesweb.xml -
WebMvcConfig(@EnableWebMvc): maps theDispatcherServletto/api/v1/*, configures JacksonHttpMessageConverters, imports theservice-context.xmlcontext andPersistenceConfig - Request/Response DTOs + mappers (same conventions as
spring-boot-tutorial:recordDTOs, never a JPA entity exposed directly), annotated@Valid/@NotNull/@Sizeto validate incoming requests - Generic
ApiResponse<T>andPageResponse<T>DTOs (dto/common/), wrapping every response -
WebExceptionHandler(@ControllerAdvice, inweb/exception/) implementing the exception-to-status mapping described in Standard response format (404/400/422/500) -
MessageSource(ReloadableResourceBundleMessageSource) +messages_fr.properties/messages_en.propertiesfiles, locale resolved from theAccept-Languageheader, to illustrate internationalization - A
HandlerInterceptor(e.g.RequestLoggingInterceptor) registered viaWebMvcConfigurer#addInterceptors, to illustrate an MVC extension point outside of Servlet filters - A
request-scoped bean (scoped proxy, e.g. a request correlation context) injected into a controller, to illustrate web-related scopes -
@RestControllercontrollers per resource (@RequestMapping("/api/v1/...")), delegating only to theservicelayer -
MockMvctests for thewebmodule
feature/core-architecture→ Pull Request todevelopfeature/dao(depends onconfig) → Pull Request todevelopfeature/service(depends ondao) → Pull Request todevelopfeature/web(depends onservice) → Pull Request todevelopdevelop→master
- Root package:
edgareldy.springtutorial, with a sub-package per module (.common,.domain,.dao,.service,.web) - Contract/implementation services (like the other tutorials in the series): interface at the root of
service/, implementation inservice/impl/ - Every exposed route is prefixed with
/api/v1 - The
webmodule always returns a genericApiResponse<T>DTO (orApiResponse<PageResponse<T>>for lists) - No
domainentity is ever exposed directly in an HTTP response: DTOs only - Each module has its own
pom.xmland declares only the dependencies it actually needs (no abusive inheritance of unused transitive dependencies)
The goal is to cover almost the entire "Core Technologies" chapter of the Spring Framework reference documentation. Non-exhaustive list, with the branch where each notion is demonstrated.
BeanFactoryvsApplicationContext(feature/dao,feature/web)- Annotation-based configuration (
@Component,@Service,@Repository,@Autowired,@Qualifier,@Value) (feature/service,feature/web) - Java configuration (
@Configuration,@Bean) (feature/dao) - XML configuration (
<bean>,<context:component-scan>,<tx:annotation-driven/>,<aop:aspectj-autoproxy/>) (feature/service) - Bean scopes:
singleton,prototype,request(feature/service,feature/web) - Bean lifecycle:
@PostConstruct/@PreDestroy,InitializingBean/DisposableBean(feature/dao) - Container extension points:
BeanPostProcessor,FactoryBean(feature/dao) EnvironmentandPropertySourceabstraction, profiles (@Profile) (feature/dao)- Application events (
ApplicationEventPublisher,@EventListener) (feature/service) - Internationalization via
MessageSource(feature/web) Resourceabstraction (ClassPathResource) (feature/dao)
@Aspect, pointcuts,@Aroundadvice (feature/service)- JDK dynamic proxy vs CGLIB proxy, explained in Spring AOP
- Bean Validation (
@Valid,jakarta.validation.constraints.*) (feature/service,feature/web) - Custom
org.springframework.validation.Validator(feature/service)
- Used in
@Valueand/or in XML (feature/service)
DispatcherServlet,WebApplicationInitializer, WAR packaging (feature/web)@RestController,@RequestMapping, JacksonHttpMessageConverter(feature/web)HandlerInterceptor(feature/web)@ControllerAdvice/ centralized exception handling (feature/web)
- Manual
EntityManager(no Spring Data) (feature/dao) - Declarative transaction management (
@Transactional,PlatformTransactionManager,<tx:annotation-driven/>) (feature/dao,feature/service)
- Maven multi-module (parent POM,
dependencyManagement, module dependency graph) - Contract/implementation pattern for services and DAOs
- Generic
ApiResponse<T>DTO - Multi-module tests (
AnnotationConfigApplicationContext,ClassPathXmlApplicationContext, Mockito,MockMvc) - Continuous integration on a multi-module Maven build
- Clone the repository and check out
develop - Follow the branches in order:
feature/core-architecture→feature/dao→feature/service→feature/web - Build all modules from the root:
mvn clean install - Either run
docker-compose up(builds the WAR and deploys it on Tomcat 10, see Configuration notes for the PostgreSQL connection), or deployspring-tutorial-web/target/spring-tutorial-web.warmanually on a local Tomcat 10 - Call the API at
http://localhost:8080/spring-tutorial-web/api/v1/...