Spring Framework & the IoC Container

Spring vs Plain Java

18 min Lesson 9 of 13

Spring vs Plain Java

After eight lessons building up Spring's mental model, a legitimate question emerges: what does the container actually buy us? Plain Java can instantiate objects, call setters, and pass dependencies through constructors — everything Spring does mechanically. This lesson answers that question concretely, by walking through the same application wired by hand and then wired by Spring, then naming every advantage and every cost that comes with the container.

The Hand-Wired Baseline

Imagine a small order-processing service. Three collaborators: a ProductRepository that reads from a database, an InventoryService that checks stock, and an OrderService that orchestrates everything.

// Plain Java — you own every line of wiring public class Main { public static void main(String[] args) { // 1. Build the dependency graph manually, bottom-up DataSource ds = buildDataSource(); ProductRepository repo = new JdbcProductRepository(ds); InventoryService inv = new InventoryService(repo); OrderService orders = new OrderService(repo, inv); // 2. Use the root object orders.placeOrder(42L, 3); } private static DataSource buildDataSource() { HikariConfig cfg = new HikariConfig(); cfg.setJdbcUrl(System.getenv("DB_URL")); cfg.setUsername(System.getenv("DB_USER")); cfg.setPassword(System.getenv("DB_PASS")); return new HikariDataSource(cfg); } }

This compiles, runs, and is fully testable. But notice what you are responsible for:

  • Building the dependency graph in the correct order.
  • Creating every collaborator exactly once (or tracking which ones are singletons yourself).
  • Closing resources (HikariDataSource is AutoCloseable) at shutdown.
  • Re-wiring by hand every time a dependency changes or a new scope is needed.

The Spring-Wired Version

// Spring 6 — the container owns the wiring @Configuration @ComponentScan("com.example.shop") public class AppConfig {} @Repository public class JdbcProductRepository implements ProductRepository { private final JdbcTemplate jdbc; public JdbcProductRepository(JdbcTemplate jdbc) { this.jdbc = jdbc; // injected by Spring } // ... } @Service public class InventoryService { private final ProductRepository repo; public InventoryService(ProductRepository repo) { this.repo = repo; // injected by Spring } // ... } @Service public class OrderService { private final ProductRepository repo; private final InventoryService inv; public OrderService(ProductRepository repo, InventoryService inv) { this.repo = repo; this.inv = inv; } // ... } // Entry point public class Main { public static void main(String[] args) { try (var ctx = new AnnotationConfigApplicationContext(AppConfig.class)) { OrderService orders = ctx.getBean(OrderService.class); orders.placeOrder(42L, 3); } // ctx.close() triggers @PreDestroy on all beans } }

The application logic is identical. What changed is who is responsible for the wiring — and that shift carries a concrete list of benefits.

Benefit 1: Automatic Dependency Resolution

Spring reads the constructor parameter types, finds beans that satisfy them, and wires everything. Add a new dependency to OrderService and you update one constructor — not a cascade of manual calls in Main. In a system with dozens of services this matters enormously.

Constructor injection is the gold standard. It makes dependencies explicit, keeps beans immutable (final fields), and removes the need for @Autowired on single-constructor beans (Spring injects automatically since 4.3). Field injection hides dependencies and breaks testability — avoid it.

Benefit 2: Singleton Scope Without Global State

By default every Spring bean is a singleton: one instance per container, shared across all callers. In plain Java you either use a static field (true global state, hard to test) or coordinate object creation manually. Spring gives you singletons without the anti-pattern: the container owns the instance; you inject it.

// Plain Java singleton — the classic anti-pattern public class ProductRepository { private static final ProductRepository INSTANCE = new ProductRepository(); private ProductRepository() {} public static ProductRepository getInstance() { return INSTANCE; } } // Spring singleton — clean, injectable, replaceable in tests @Repository public class JdbcProductRepository implements ProductRepository { ... } // Spring creates exactly one. Any test can substitute a mock.

Benefit 3: Lifecycle Management

Resources that need to be closed (database pools, thread pools, open files) are error-prone in hand-wired code. Spring's lifecycle hooks handle it automatically:

@Component public class ReportScheduler { private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); @PostConstruct public void start() { scheduler.scheduleAtFixedRate(this::runReport, 0, 1, TimeUnit.HOURS); } @PreDestroy public void stop() { scheduler.shutdown(); // called automatically when the container closes } }

Without the container you must hook into JVM shutdown yourself (Runtime.getRuntime().addShutdownHook(...)) and remember to do it for every resource-owning class.

Benefit 4: Scoped Beans

Web applications often need objects that live for exactly one HTTP request. Hand-wiring that requires ThreadLocal variables, careful cleanup, and a lot of discipline. Spring's request scope encapsulates all of it:

@Component @RequestScope // one instance per HTTP request, destroyed after response public class RequestContext { private String currentUserId; // getters / setters } @Service public class OrderService { private final RequestContext ctx; // Spring injects a scoped proxy public OrderService(RequestContext ctx) { this.ctx = ctx; } public void placeOrder(long productId, int qty) { String userId = ctx.getCurrentUserId(); // correct per-request value // ... } }

The container creates the instance when the request arrives and destroys it when the response is committed. Zero ThreadLocal code in your service layer.

Benefit 5: AOP — Cross-Cutting Concerns Without Boilerplate

Logging, transaction management, and security checks appear in dozens of methods. In plain Java you copy-paste that boilerplate or write a wrapper for every class. Spring's AOP proxy model applies aspects transparently:

// Plain Java — transaction boilerplate repeated everywhere public void placeOrder(long productId, int quantity) { Connection conn = dataSource.getConnection(); conn.setAutoCommit(false); try { repo.deductStock(conn, productId, quantity); paymentService.charge(conn, customerId, total); conn.commit(); } catch (Exception e) { conn.rollback(); throw e; } finally { conn.close(); } } // Spring — declare intent, Spring handles the mechanics @Transactional public void placeOrder(long productId, int quantity) { repo.deductStock(productId, quantity); paymentService.charge(customerId, total); // if either throws RuntimeException, Spring rolls back automatically }

Without Spring you write the try/commit/catch/rollback block everywhere transactions are needed — and forget it once, and you have a data-consistency bug in production.

The Honest Trade-offs

Spring is not free. Know the costs before you commit:

  • Startup time: The container scans classes, proxies beans, and resolves the graph at launch. A large Spring Boot application can take 10–30 seconds to start, which matters for CLI tools and serverless functions. Plain Java starts in milliseconds.
  • Implicit magic: When wiring is invisible, debugging a missing bean or a circular dependency requires understanding the container's internals. Plain Java wiring is transparent — errors point directly at the constructor call that fails.
  • Classpath weight: The Spring ecosystem adds several megabytes of JARs. Trivial for a backend service, noticeable for embedded or constrained environments.
  • Learning curve: Developers must understand scopes, proxy modes, and the application context before they can diagnose problems confidently.
The right heuristic: If your application has more than a handful of collaborating services, or it needs transaction management, security integration, or a web layer, Spring pays for itself immediately. For a single-purpose script or a library meant to be embedded, plain Java (or a micro-framework) is the better choice.

When Plain Java Is Enough

  • Command-line utilities with a single entry point and two or three dependencies.
  • Library JAR code — never force a container on your library's users.
  • Performance-sensitive startup paths where cold-start latency matters (AWS Lambda functions, CLI tools called frequently).
  • Code where complete transparency matters more than convenience (security-critical modules, low-level drivers).
Do not add Spring to escape poor object-oriented design. If your classes are tangled and hard to wire by hand, the container hides the pain but does not fix it. Refactor the design first; then adding Spring becomes a multiplier on already-clean code, not a band-aid over messy code.

Summary

The Spring IoC container earns its place by automating four things you would otherwise own manually: dependency graph resolution, singleton lifecycle, resource cleanup, and cross-cutting concerns via AOP. Plain Java remains the right tool for small, self-contained programs where startup speed, transparency, or zero-dependency deployment matters more than those benefits. For professional server-side applications — anything with a database, a transaction boundary, or more than a handful of collaborating services — the container's benefits far outweigh its costs. With that trade-off fully understood, the final lesson puts it all together in a complete wiring project.