LearnAI ToolsCareerPractice BuildsPlayContact
Spring FrameworkBeginner~1.5 hours

Layered Service Application

Build a controller → service → repository app fully wired with DI.

Dependency InjectionBeansConfiguration

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.

What You'll Build
  • 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.

pom.xml (dependency)
<!-- 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;
@Configuration
public 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
}
}
@Qualifier vs. Just Renaming the Method

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`.

Product.java
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);
}
}
ProductRepository.java, InMemoryProductRepository.java
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;
}
}
DiscountPolicy.java + two implementations
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;
}
}
ProductService.java, ProductController.java
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);
}
}
}
AppConfig.java, LayeredServiceApplication.java
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;
@Configuration
class 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();
}
}
One File Per Class in Real Projects

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

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.