Overview
Every Spring application is, underneath its annotations, a graph of plain Java objects — Spring just calls that graph the "IoC container" and the objects inside it "beans." This project builds the simplest possible version of a real Spring application's shape: a repository that owns data, a service that owns business rules, and a controller (here, a plain facade class rather than a web endpoint) that is the one thing calling code talks to. None of these classes will ever call new on one another directly — instead, every dependency arrives already built, through a constructor, because Spring's container assembled the whole graph before your code ever asked for it.
This is core Spring, not Spring Boot: there is no auto-configuration guessing what beans you probably want, and no @SpringBootApplication scanning your whole classpath by convention. Every bean in this project is declared explicitly in one @Configuration class using @Bean factory methods, which is the classic Java-config alternative to an older Spring application's XML <beans> file — same end result, but type-checked by the compiler and refactorable by your IDE. By the end, you will have seen constructor injection resolve an unambiguous dependency automatically, and @Qualifier step in the one time two beans of the exact same interface type would otherwise leave Spring unable to decide between them.
- A
Productmodel and aProductRepositoryinterface with oneInMemoryProductRepositoryimplementation. - A
DiscountPolicyinterface with two implementations —RegularDiscountPolicyandPremiumDiscountPolicy. - A
ProductServicethat receives both its repository and its discount policy through constructor injection. - A
ProductControllerfacade — a plain, non-web entry-point class standing in for what would be a@RestControllerin a full web app. - One
@Configurationclass wiring every bean explicitly with@Beanmethods. @Qualifierresolving the ambiguity created by having twoDiscountPolicybeans of the same type.
Prerequisites
- What Inversion of Control means — objects receiving their dependencies instead of constructing them.
- Java interfaces and multiple implementations of the same interface.
- Constructors and
finalfields. - Maven basics — a
pom.xmlwith dependencies andmvn compile/mvn exec:java. - No prior Spring experience assumed — this is the first project in the course.
Project Structure
Unlike the single-file console apps from earlier Java courses, a Spring project is naturally split into one class per file, organized into a package — here, com.programinds.catalog. Product and the two interfaces (ProductRepository, DiscountPolicy) carry no Spring annotations at all; they are plain Java. InMemoryProductRepository, RegularDiscountPolicy, and PremiumDiscountPolicy are also plain classes — in this project none of them are annotated @Component, because every bean is instead declared explicitly inside AppConfig, the one @Configuration class that owns every wiring decision.
The dependency direction only ever points one way: ProductController depends on ProductService, which depends on ProductRepository and DiscountPolicy — never the reverse. That one-way arrow is what makes each layer independently testable and replaceable, and it is the same repository → service → controller shape you will reuse in every larger Spring project from here on, including the MVC and JDBC projects later in this course.
<!-- spring-context pulls in the IoC container itself — ApplicationContext, @Configuration, @Bean, and everything this project actually uses. --><dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>6.1.13</version></dependency>Step 1: Define the Product Model
Product is deliberately the one class in this whole project with no Spring annotations and no Spring awareness whatsoever — it is a plain data object, created fresh every time a product is added, and it is never itself registered as a bean. Only things Spring needs to create ONCE and hand out to multiple dependents (repositories, services, policies) belong in the container; ordinary data objects created many times over the life of the program do not.
package com.programinds.catalog;
// Plain data class — no Spring annotations at all. Nothing about a Product// needs to live in the container, since a new one is created every time// addProduct() runs, rather than being injected as a shared dependency.public class Product { private int id; // Assigned by the repository, never chosen by the caller private String name; // Product display name private double price; // Base price before any discount is applied
public Product(int id, String name, double price) { this.id = id; this.name = name; this.price = price; }
public int getId() { return id; }
public String getName() { return name; }
public double getPrice() { return price; }
@Override public String toString() { return String.format("#%d %-20s $%.2f", id, name, price); }}Step 2: Build the Repository Layer
ProductRepository is an interface, and ProductService in Step 4 will depend only on this interface — never on InMemoryProductRepository directly. That distinction is what makes dependency injection actually useful rather than just a longer way to write new: if a JdbcProductRepository were added later, ProductService's source code would not change by a single line, only AppConfig's single @Bean method for productRepository() would.
package com.programinds.catalog;
import java.util.List;
// The contract every repository implementation must satisfy. ProductService// is written against THIS type, not against any concrete class — that is the// whole reason a second implementation could be swapped in later without// touching ProductService at all.public interface ProductRepository { Product save(String name, double price); Product findById(int id); List<Product> findAll();}package com.programinds.catalog;
import java.util.ArrayList;import java.util.List;
// The only ProductRepository implementation in this project. It is a plain// class with no @Repository annotation, because Step 6 wires it into the// container explicitly via a @Bean method rather than relying on classpath// scanning to discover it.public class InMemoryProductRepository implements ProductRepository { private final List<Product> products = new ArrayList<>(); // Backing store; never exposed directly private int nextId = 1; // Auto-incrementing id, assigned by the repository itself
@Override public Product save(String name, double price) { Product product = new Product(nextId, name, price); products.add(product); nextId++; // Guarantees the next saved product gets a fresh id return product; }
@Override public Product findById(int id) { for (Product p : products) { if (p.getId() == id) { return p; } } return null; }
@Override public List<Product> findAll() { return products; }}Step 3: Add a DiscountPolicy Abstraction With Two Implementations
This abstraction is independent of the repository layer — it exists specifically so this project has two beans of the exact same interface type. Spring can autowire a dependency by type only when exactly one bean matches; the moment RegularDiscountPolicy and PremiumDiscountPolicy both implement DiscountPolicy, any injection point typed DiscountPolicy becomes ambiguous, which is precisely the situation @Qualifier exists to resolve in Step 6.
package com.programinds.catalog;
public interface DiscountPolicy { double applyDiscount(double price);}package com.programinds.catalog;
public class RegularDiscountPolicy implements DiscountPolicy { @Override public double applyDiscount(double price) { return price * 0.95; // Regular customers get a flat 5% off }}package com.programinds.catalog;
public class PremiumDiscountPolicy implements DiscountPolicy { @Override public double applyDiscount(double price) { return price * 0.85; // Premium customers get a steeper 15% off }}Step 4: Build the ProductService With Constructor Injection
Both dependencies arrive through the constructor and are stored in final fields, assigned exactly once. This is constructor injection, and it is the reason a ProductService can never exist in a half-wired state — there is no setter that could be called with null, and no way to construct one without supplying both a repository and a discount policy up front. Spring's container follows this same discipline when it builds the bean in Step 6: it will not create a ProductService bean until both constructor arguments are ready.
package com.programinds.catalog;
import java.util.List;
// Neither dependency is annotated @Autowired here, because this project wires// beans explicitly through @Bean factory methods in AppConfig (Step 6) rather// than through field/setter injection — the constructor itself is the only// injection point ProductService exposes.public class ProductService { private final ProductRepository productRepository; // Where products actually live private final DiscountPolicy discountPolicy; // Which discount rule to apply to every price
public ProductService(ProductRepository productRepository, DiscountPolicy discountPolicy) { this.productRepository = productRepository; // Assigned once, in the constructor, and never reassigned this.discountPolicy = discountPolicy; }
public Product addProduct(String name, double price) { return productRepository.save(name, price); }
public double getDiscountedPrice(int productId) { Product product = productRepository.findById(productId); if (product == null) { throw new IllegalArgumentException("No product with id " + productId); } return discountPolicy.applyDiscount(product.getPrice()); // Delegates the actual math to whichever policy was injected }
public List<Product> getAllProducts() { return productRepository.findAll(); }}Step 5: Build the ProductController Facade
This is the "controller" of the layered chain, but since this project has no web layer, it is a plain facade class rather than a @Controller with HTTP mappings — its only job is to be the single entry point main() talks to, the same way a real @RestController would be the entry point a browser talks to. Everything below it stays hidden behind this one class's two public methods.
package com.programinds.catalog;
public class ProductController { private final ProductService productService; // The only dependency this layer needs
public ProductController(ProductService productService) { this.productService = productService; // Constructor injection again, one layer up }
public void addProduct(String name, double price) { Product created = productService.addProduct(name, price); System.out.println("Added: " + created); }
public void printCatalogWithDiscount() { System.out.println("\n--- Product Catalog (Discounted Prices) ---"); for (Product p : productService.getAllProducts()) { double discounted = productService.getDiscountedPrice(p.getId()); System.out.printf("#%d %-20s $%.2f -> $%.2f%n", p.getId(), p.getName(), p.getPrice(), discounted); } }}Step 6: Wire Everything With @Configuration, @Bean, and @Qualifier
@Configuration marks this class itself as a source of bean definitions — Spring instantiates it and calls every @Bean-annotated method exactly once, caching each return value as a singleton by default. Nothing here is discovered through classpath scanning; every bean and every wiring decision is spelled out explicitly, which is the entire meaning of "Java-based configuration" as opposed to an older XML <beans> file achieving the same result with no compile-time checking.
@Bean factory method PARAMETERS are autowired by Spring exactly like a constructor's parameters are. productRepository resolves without any ambiguity, since only one bean matches that type — but two beans match DiscountPolicy, so without @Qualifier here Spring would refuse to start at all, throwing a NoUniqueBeanDefinitionException. @Qualifier("premiumDiscountPolicy") tells the container exactly which of the two matching beans to hand to this particular parameter, using the bean name Spring derives by default from each @Bean method's own name.
package com.programinds.catalog;
import org.springframework.beans.factory.annotation.Qualifier;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;
@Configurationpublic class AppConfig {
@Bean public ProductRepository productRepository() { return new InMemoryProductRepository(); // Only one bean of this type, so no @Qualifier is ever needed for it }
// Two @Bean methods returning the SAME interface type. Spring names a bean // after its factory method by default, so these become the beans // "regularDiscountPolicy" and "premiumDiscountPolicy" — exactly the names // @Qualifier below refers to. @Bean public DiscountPolicy regularDiscountPolicy() { return new RegularDiscountPolicy(); }
@Bean public DiscountPolicy premiumDiscountPolicy() { return new PremiumDiscountPolicy(); }
// Without @Qualifier, Spring cannot decide which of the two DiscountPolicy // beans belongs here — @Qualifier("premiumDiscountPolicy") removes the // ambiguity by naming the exact bean to inject into this parameter. @Bean public ProductService productService(ProductRepository productRepository, @Qualifier("premiumDiscountPolicy") DiscountPolicy discountPolicy) { return new ProductService(productRepository, discountPolicy); }
@Bean public ProductController productController(ProductService productService) { return new ProductController(productService); // Only one ProductService bean exists, so this resolves unambiguously }}You might wonder why not simply write one @Bean method instead of two and skip the ambiguity entirely. The point of this step is what happens in a LARGER app: once a second implementation of an interface legitimately needs to coexist with the first — different customers, different environments, different strategies — @Qualifier is the mechanism that lets both stay registered as beans while every injection point still says exactly which one it means.
Step 7: Run the Application With AnnotationConfigApplicationContext
AnnotationConfigApplicationContext is Spring's Java-config entry point: pass it one or more @Configuration classes, and by the time its constructor returns, every @Bean method has run and the entire dependency graph from Step 6 has been assembled. getBean(ProductController.class) then simply asks the already-built container for that one bean — no wiring happens at this point, it happened when the context was constructed.
package com.programinds.catalog;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class LayeredServiceApplication { public static void main(String[] args) { // Building the context is what triggers every @Bean method in AppConfig to run. AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
// By the time this returns, ProductController's ProductService, its // ProductRepository, and its (qualified) DiscountPolicy are all already injected. ProductController controller = context.getBean(ProductController.class);
controller.addProduct("Mechanical Keyboard", 4999.00); controller.addProduct("Wireless Mouse", 1299.00); controller.addProduct("USB-C Hub", 1899.00);
controller.printCatalogWithDiscount();
context.close(); // Releases the container and any resources its beans hold }}Complete Code
Save each class below as its own file under src/main/java/com/programinds/catalog/, matching each file's class name, then run with mvn compile exec:java -Dexec.mainClass=com.programinds.catalog.LayeredServiceApplication.
package com.programinds.catalog;
public class Product { private int id; private String name; private double price;
public Product(int id, String name, double price) { this.id = id; this.name = name; this.price = price; }
public int getId() { return id; } public String getName() { return name; } public double getPrice() { return price; }
@Override public String toString() { return String.format("#%d %-20s $%.2f", id, name, price); }}package com.programinds.catalog;
import java.util.List;
public interface ProductRepository { Product save(String name, double price); Product findById(int id); List<Product> findAll();}
// (separate file: InMemoryProductRepository.java)import java.util.ArrayList;
class InMemoryProductRepositoryImpl implements ProductRepository { private final List<Product> products = new ArrayList<>(); private int nextId = 1;
@Override public Product save(String name, double price) { Product product = new Product(nextId, name, price); products.add(product); nextId++; return product; }
@Override public Product findById(int id) { for (Product p : products) { if (p.getId() == id) { return p; } } return null; }
@Override public List<Product> findAll() { return products; }}package com.programinds.catalog;
public interface DiscountPolicy { double applyDiscount(double price);}
class RegularDiscountPolicy implements DiscountPolicy { @Override public double applyDiscount(double price) { return price * 0.95; }}
class PremiumDiscountPolicy implements DiscountPolicy { @Override public double applyDiscount(double price) { return price * 0.85; }}package com.programinds.catalog;
import java.util.List;
public class ProductService { private final ProductRepository productRepository; private final DiscountPolicy discountPolicy;
public ProductService(ProductRepository productRepository, DiscountPolicy discountPolicy) { this.productRepository = productRepository; this.discountPolicy = discountPolicy; }
public Product addProduct(String name, double price) { return productRepository.save(name, price); }
public double getDiscountedPrice(int productId) { Product product = productRepository.findById(productId); if (product == null) { throw new IllegalArgumentException("No product with id " + productId); } return discountPolicy.applyDiscount(product.getPrice()); }
public List<Product> getAllProducts() { return productRepository.findAll(); }}
class ProductControllerImpl { private final ProductService productService;
public ProductControllerImpl(ProductService productService) { this.productService = productService; }
public void addProduct(String name, double price) { Product created = productService.addProduct(name, price); System.out.println("Added: " + created); }
public void printCatalogWithDiscount() { System.out.println("\n--- Product Catalog (Discounted Prices) ---"); for (Product p : productService.getAllProducts()) { double discounted = productService.getDiscountedPrice(p.getId()); System.out.printf("#%d %-20s $%.2f -> $%.2f%n", p.getId(), p.getName(), p.getPrice(), discounted); } }}package com.programinds.catalog;
import org.springframework.beans.factory.annotation.Qualifier;import org.springframework.context.annotation.AnnotationConfigApplicationContext;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;
@Configurationclass AppConfigImpl { @Bean public ProductRepository productRepository() { return new InMemoryProductRepositoryImpl(); }
@Bean public DiscountPolicy regularDiscountPolicy() { return new RegularDiscountPolicy(); }
@Bean public DiscountPolicy premiumDiscountPolicy() { return new PremiumDiscountPolicy(); }
@Bean public ProductService productService(ProductRepository productRepository, @Qualifier("premiumDiscountPolicy") DiscountPolicy discountPolicy) { return new ProductService(productRepository, discountPolicy); }
@Bean public ProductControllerImpl productController(ProductService productService) { return new ProductControllerImpl(productService); }}
public class LayeredServiceApplication { public static void main(String[] args) { AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(AppConfigImpl.class);
ProductControllerImpl controller = context.getBean(ProductControllerImpl.class);
controller.addProduct("Mechanical Keyboard", 4999.00); controller.addProduct("Wireless Mouse", 1299.00); controller.addProduct("USB-C Hub", 1899.00);
controller.printCatalogWithDiscount();
context.close(); }}The "Complete Code" listing above merges classes into shared code blocks purely for readability on this page, with a couple of classes suffixed "Impl" to avoid duplicate top-level names within one block. In your actual project, delete those suffixes and give every class its own file exactly as shown in Steps 1-6 — Java requires a public top-level class to live in a file matching its own name.
Sample Run
Click Run to see what this code prints.
Every price is discounted by 15%, confirming that @Qualifier("premiumDiscountPolicy") in AppConfig won out over RegularDiscountPolicy — change that qualifier value to "regularDiscountPolicy" and rerun the program to see every price shift to a 5% discount instead, with no other line of code touched.
Extend This Project
- Add a third
DiscountPolicyimplementation,NoDiscountPolicy, and a constructor argument toProductControllerthat lets the caller choose which qualifiedProductServicebean to use at runtime. - Replace field-style
@Qualifierusage with a custom qualifier annotation (e.g.@Premium) to see the more advanced, type-safe alternative Spring offers for the same problem. - Add a second
ProductRepositoryimplementation and switch which oneAppConfigwires in with a one-line change, confirmingProductServicenever needs to change. - Introduce
@PrimaryonregularDiscountPolicy()instead of@Qualifieron the injection point, and observe how it changes which bean wins when no qualifier is specified at all. - Add unit tests for
ProductServiceusing a hand-written stubProductRepositoryand stubDiscountPolicy, taking advantage of the fact that both are interfaces.
Summary
You built the smallest complete example of a layered Spring application: a repository, a service, and a facade acting as a controller, every one of them wired together by an explicit @Configuration class instead of manual new calls. ProductService and ProductController both used constructor injection to guarantee they could never exist half-wired, and @Qualifier resolved the one genuine ambiguity in the whole graph — two beans sharing the DiscountPolicy type. This repository → service → controller shape, and the discipline of depending on interfaces rather than concrete classes, is the foundation every later project in this course builds on.