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 `Product` model and a `ProductRepository` interface with one `InMemoryProductRepository` implementation.
- A `DiscountPolicy` interface with two implementations — `RegularDiscountPolicy` and `PremiumDiscountPolicy`.
- A `ProductService` that receives both its repository and its discount policy through constructor injection.
- A `ProductController` facade — a plain, non-web entry-point class standing in for what would be a `@RestController` in a full web app.
- One `@Configuration` class wiring every bean explicitly with `@Bean` methods.
- `@Qualifier` resolving the ambiguity created by having two `DiscountPolicy` beans 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 `final` fields.
- Maven basics — a `pom.xml` with dependencies and `mvn 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 `DiscountPolicy` implementation, `NoDiscountPolicy`, and a constructor argument to `ProductController` that lets the caller choose which qualified `ProductService` bean to use at runtime.
- Replace field-style `@Qualifier` usage 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 `ProductRepository` implementation and switch which one `AppConfig` wires in with a one-line change, confirming `ProductService` never needs to change.
- Introduce `@Primary` on `regularDiscountPolicy()` instead of `@Qualifier` on the injection point, and observe how it changes which bean wins when no qualifier is specified at all.
- Add unit tests for `ProductService` using a hand-written stub `ProductRepository` and stub `DiscountPolicy`, 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.