Project: Wiring an App with the IoC Container
Throughout this tutorial you have explored Spring's configuration styles, component scanning, qualifiers, and the ApplicationContext lifecycle. Now you will apply all of it in a single, coherent mini-project: a small layered application whose every dependency is managed by the Spring container — no new keyword in sight beyond the entry point.
What You Are Building
A Library Management System with three layers:
- Repository layer —
BookRepository: persists and retrieves books (in-memory map for simplicity).
- Service layer —
BookService: business rules (add, find, list).
- Presentation layer —
LibraryApp: the entry point that drives the use-case.
You will wire everything with Java-based configuration (@Configuration + @Bean) in one class and annotation-driven injection (@Autowired / constructor injection) in the components. This mirrors the style used in real Spring applications before Spring Boot auto-configuration takes over.
Why no Spring Boot here? Spring Boot is a powerful starting point, but understanding how to wire an application without it is exactly what separates developers who can debug misconfigured contexts from those who cannot. Boot's magic is just this code, generated for you.
Project Structure
src/main/java/com/example/library/
├── AppConfig.java // @Configuration class
├── model/
│ └── Book.java
├── repository/
│ ├── BookRepository.java // interface
│ └── InMemoryBookRepository.java
├── service/
│ ├── BookService.java // interface
│ └── BookServiceImpl.java
└── LibraryApp.java // main()
Step 1 — The Domain Model
package com.example.library.model;
public class Book {
private final String isbn;
private final String title;
private final String author;
public Book(String isbn, String title, String author) {
this.isbn = isbn;
this.title = title;
this.author = author;
}
public String getIsbn() { return isbn; }
public String getTitle() { return title; }
public String getAuthor() { return author; }
@Override
public String toString() {
return "[" + isbn + "] " + title + " by " + author;
}
}
Step 2 — The Repository Layer
Define the contract as an interface, then provide one implementation. Spring will inject the implementation wherever the interface is declared as a dependency — the caller never knows or cares which concrete class it receives.
package com.example.library.repository;
import com.example.library.model.Book;
import java.util.List;
import java.util.Optional;
public interface BookRepository {
void save(Book book);
Optional<Book> findByIsbn(String isbn);
List<Book> findAll();
}
package com.example.library.repository;
import com.example.library.model.Book;
import java.util.*;
public class InMemoryBookRepository implements BookRepository {
private final Map<String, Book> store = new LinkedHashMap<>();
@Override
public void save(Book book) {
store.put(book.getIsbn(), book);
}
@Override
public Optional<Book> findByIsbn(String isbn) {
return Optional.ofNullable(store.get(isbn));
}
@Override
public List<Book> findAll() {
return List.copyOf(store.values());
}
}
Step 3 — The Service Layer
The service encapsulates business rules and depends on BookRepository through constructor injection — the recommended approach because it makes dependencies explicit and enables final fields.
package com.example.library.service;
import com.example.library.model.Book;
import java.util.List;
import java.util.Optional;
public interface BookService {
void addBook(String isbn, String title, String author);
Optional<Book> findBook(String isbn);
List<Book> listAllBooks();
}
package com.example.library.service;
import com.example.library.model.Book;
import com.example.library.repository.BookRepository;
import java.util.List;
import java.util.Optional;
public class BookServiceImpl implements BookService {
private final BookRepository repository;
// Spring injects the BookRepository bean here at construction time
public BookServiceImpl(BookRepository repository) {
this.repository = repository;
}
@Override
public void addBook(String isbn, String title, String author) {
if (isbn == null || isbn.isBlank()) {
throw new IllegalArgumentException("ISBN must not be blank");
}
repository.save(new Book(isbn, title, author));
}
@Override
public Optional<Book> findBook(String isbn) {
return repository.findByIsbn(isbn);
}
@Override
public List<Book> listAllBooks() {
return repository.findAll();
}
}
Program to interfaces, not implementations. BookServiceImpl depends on BookRepository (the interface), not on InMemoryBookRepository. Swapping in a JPA-backed implementation later requires changing exactly one line in the configuration class — the rest of the codebase is untouched.
Step 4 — The Configuration Class
One @Configuration class wires the entire object graph. Spring calls the @Bean methods and manages the returned objects as singleton beans by default.
package com.example.library;
import com.example.library.repository.BookRepository;
import com.example.library.repository.InMemoryBookRepository;
import com.example.library.service.BookService;
import com.example.library.service.BookServiceImpl;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class AppConfig {
@Bean
public BookRepository bookRepository() {
return new InMemoryBookRepository();
}
@Bean
public BookService bookService(BookRepository bookRepository) {
// Spring resolves the BookRepository bean and passes it here
return new BookServiceImpl(bookRepository);
}
}
Notice that bookService receives BookRepository as a method parameter — Spring matches it by type to the bookRepository() bean. This is factory-method injection, identical in effect to constructor injection but expressed in configuration code rather than in the component itself.
Step 5 — The Entry Point
package com.example.library;
import com.example.library.service.BookService;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class LibraryApp {
public static void main(String[] args) {
// Bootstrap the container with our configuration class
ApplicationContext ctx =
new AnnotationConfigApplicationContext(AppConfig.class);
// Retrieve the BookService bean by its interface type
BookService service = ctx.getBean(BookService.class);
// Exercise the application
service.addBook("978-0-13-468599-1", "Effective Java", "Joshua Bloch");
service.addBook("978-0-13-235088-4", "Clean Code", "Robert C. Martin");
service.addBook("978-0-13-110362-7", "The C Programming Language", "Kernighan & Ritchie");
System.out.println("All books:");
service.listAllBooks().forEach(System.out::println);
System.out.println("\nLookup by ISBN:");
service.findBook("978-0-13-468599-1")
.ifPresentOrElse(System.out::println,
() -> System.out.println("Not found"));
// Always close the context to trigger @PreDestroy / lifecycle callbacks
((AnnotationConfigApplicationContext) ctx).close();
}
}
Running the Project
Add the Spring Context dependency to your build file, then run LibraryApp:
<!-- Maven pom.xml -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>6.1.6</version>
</dependency>
Expected output:
All books:
[978-0-13-468599-1] Effective Java by Joshua Bloch
[978-0-13-235088-4] Clean Code by Robert C. Martin
[978-0-13-110362-7] The C Programming Language by Kernighan & Ritchie
Lookup by ISBN:
[978-0-13-468599-1] Effective Java by Joshua Bloch
What Spring Is Doing Behind the Scenes
AnnotationConfigApplicationContext reads AppConfig.class and builds a bean definition registry.
- It instantiates
InMemoryBookRepository first (no dependencies), registers it as the bookRepository singleton.
- It instantiates
BookServiceImpl, resolving the BookRepository parameter from the registry, and registers it as the bookService singleton.
- Your
main method retrieves the BookService bean — Spring returns the already-constructed singleton, injected and ready.
Always close the context in standalone applications. Calling ctx.close() triggers @PreDestroy callbacks and releases resources (thread pools, connections, files). In web applications the container manages this automatically, but in a plain main method you must do it yourself or resources will leak.
Extending the Project — Things to Try
- Add a second repository implementation — e.g. a
JdbcBookRepository — and switch between them by changing a single @Bean method. No other class changes.
- Use
@Profile to activate the in-memory repository in tests and the JDBC repository in production.
- Extract to component scanning — annotate the implementations with
@Repository and @Service, remove the explicit @Bean methods, and add @ComponentScan to AppConfig. The wiring works identically.
- Add
@PostConstruct to pre-seed the repository with fixture data and observe when it fires relative to injection.
Summary
You have built a fully Spring-wired, layered application without a single manual new in the business code. The container owns the object graph: it constructs, injects, and manages the lifecycle of every bean. Interfaces decouple layers so implementations can be swapped with minimal code change. This is exactly the architecture Spring Boot applications use under the hood — understanding it at this level means you can configure, debug, and extend any Spring project with confidence.