Skip to main content
A pixel art illustration showing a scale shift from code to architecture. Left side: developer examines small objects through a magnifying glass (Builder, Factory). Right side: same developer views a city skyline of connected services with translucent buildings — internals hidden, connections highlighted. Tagline: Same Principles. Bigger Scale.

CS 3100: Program Design and Implementation II

Lecture 17: From Code Patterns to Architecture Patterns

©2026 Jonathan Bell, CC-BY-SA

Learning Objectives

After this lecture, you will be able to:

  1. Explain when to use static factory methods vs. builders vs. plain constructors
  2. Implement the Builder pattern for classes with many optional parameters
  3. Explain why Singleton is an anti-pattern and how Dependency Injection solves its problems
  4. Compare Service Locator and Dependency Injection as dependency management strategies
  5. Recognize how code-level creation patterns manifest at larger architectural scales

Creation Patterns Are Information Hiding for Construction

In Lecture 6, we learned to hide implementation details behind interfaces.

Creation patterns hide how objects are created — the constructors, the validation, the wiring of dependencies — behind factory methods and builders.

The client says "give me a Recipe" without knowing the 15 steps required to construct one correctly.

These Are Java Patterns (Mostly)

The patterns in this lecture exist because Java's language design creates specific problems:

Java LimitationPattern That Works Around It
Constructors can't have meaningful namesStatic factory methods
No named parameters or default valuesBuilder pattern
No module-level singletonsSingleton pattern (and its problems)
No null safety in type system (until recently)Constructor injection for guarantees
Mutable by defaultBuilder → immutable objects

Other languages solve these problems differently. We'll note alternatives as we go.

What's Wrong With Constructors?

Constructors have limitations that become painful as classes evolve:

// Constructor with many parameters — what do these values mean?
ConversionRule rule = new ConversionRule("cups", "mL", 236.588, true, false, "volume");
// ??? ??? ??? ??? ??? ???
Constructor LimitationWhy It Hurts
No meaningful namesnew Foo(true, false, 42) — what do these mean?
Can't return cached instancesEvery new creates a new object — why does this matter?
Can't return subtypesnew ArrayList() must return ArrayList, not a specialized subtype
All share the same nameCan't have two constructors with same parameter types

Why Does "Every new Creates a New Object" Matter?

Every time you call new, Java allocates space on the heap for a new object:

// Three separate objects in memory, even though they represent the SAME conversion
ConversionRule r1 = new ConversionRule("cups", "mL", 236.588);
ConversionRule r2 = new ConversionRule("cups", "mL", 236.588);
ConversionRule r3 = new ConversionRule("cups", "mL", 236.588);
// r1 != r2 != r3, but they hold identical data!
ConcernWhy It Matters
MemoryEach object takes heap space. If you create millions of identical ConversionRule objects, that's millions of redundant allocations.
Garbage collectionMore objects = more work for GC = potential pauses
Identity vs. equalityr1 == r2 is false even though r1.equals(r2) is true. Can cause bugs if you accidentally use ==.
Immutability benefit lostIf the object is immutable, there's no reason to have multiple copies of identical data!
2x2 grid: Top row shows different recipes as separate objects with happy GC robot (correct behavior). Bottom-left shows wasteful duplication of identical ConversionRules with GC robot shrugging 'why three copies?'. Bottom-right shows efficient caching with relaxed GC robot. Center: for identical immutable data, caching saves memory.

Factory Methods Enable Instance Caching

For immutable objects, a factory method can return the same instance for identical values:

Constructor: always allocates

// Each call = new heap allocation
Integer a = new Integer(42); // deprecated!
Integer b = new Integer(42);
Integer c = new Integer(42);

System.out.println(a == b); // false
System.out.println(b == c); // false
// Three objects in memory

Factory method: can cache

// Factory method returns cached instance
Integer a = Integer.valueOf(42);
Integer b = Integer.valueOf(42);
Integer c = Integer.valueOf(42);

System.out.println(a == b); // true!
System.out.println(b == c); // true!
// ONE object in memory, three references

Integer.valueOf() caches integers from -128 to 127. Same value = same object.

This is why new Integer() is deprecated since Java 9 — valueOf() is strictly better.

Static Factory Methods Add Names and Flexibility

Effective Java Item 1: "Consider static factory methods instead of constructors"

Constructor: caller must know details

// What does "true" mean? What's 236.588?
ConversionRule rule = new ConversionRule(
"cups", "mL", 236.588, true);

// Must create new instance every time
ConversionRule rule2 = new ConversionRule(
"cups", "mL", 236.588, true);
// rule != rule2 (different objects)

Factory method: intent is clear

// Named method explains what we're getting
ConversionRule rule = StandardConversions
.cupsToMilliliters();

// Or lookup with semantic meaning
ConversionRule rule2 = StandardConversions
.getRule("cups", "mL");
// Can return cached instance!

Common naming conventions:

  • of() — concise factory: List.of("a", "b", "c"), EnumSet.of(RED, BLUE)
  • from() — type conversion: Date.from(instant), Instant.from(temporal)
  • valueOf() — parsing/conversion: Integer.valueOf("42"), Boolean.valueOf(true)
  • getInstance() / newInstance() — may cache or create: Calendar.getInstance()
  • getType() / newType() — factory in different class: Files.newBufferedReader(path)

Complete Example: Constructor vs. Factory Methods

public class ConversionRule {
private final String fromUnit;
private final String toUnit;
private final double factor;
private final boolean bidirectional;

// Private constructor — forces use of factory methods
private ConversionRule(String from, String to, double factor, boolean bidir) {
this.fromUnit = from; this.toUnit = to;
this.factor = factor; this.bidirectional = bidir;
}

// Factory methods with meaningful names
public static ConversionRule oneWay(String from, String to, double factor) {
return new ConversionRule(from, to, factor, false);
}

public static ConversionRule bidirectional(String from, String to, double factor) {
return new ConversionRule(from, to, factor, true);
}

// Can return cached instances!
private static final Map<String, ConversionRule> CACHE = new ConcurrentHashMap<>();

public static ConversionRule cached(String from, String to, double factor) {
String key = from + "->" + to + "@" + factor;
return CACHE.computeIfAbsent(key,
k -> new ConversionRule(from, to, factor, true));
}
}

The Telescoping Constructor Problem

Effective Java Item 2: "Consider a builder when faced with many constructor parameters"

When a class has many optional parameters, constructors become unwieldy:

public class Recipe {
// Telescoping constructors — each adds one more parameter
public Recipe(String name) { this(name, List.of(), List.of(), List.of(), null, false); }
public Recipe(String name, List<Ingredient> ingredients) { this(name, ingredients, List.of(), List.of(), null, false); }
public Recipe(String name, List<Ingredient> ingredients, List<String> instructions) { ... }
public Recipe(String name, List<Ingredient> ingredients, List<String> instructions, List<String> notes) { ... }
public Recipe(String name, List<Ingredient> ingredients, List<String> instructions, List<String> notes, String source) { ... }
public Recipe(String name, List<Ingredient> ingredients, List<String> instructions, List<String> notes, String source, boolean isVegan) { ... }

// Which constructor do I call if I want name + notes but no ingredients?
}

❌ Combinatorial explosion: every combination of optional parameters needs its own constructor!

Two-panel contrast. Left: Telescoping constructors shown as exploding tree — 6 params create 64 constructor combinations, developer overwhelmed. Right: Builder pattern as simple linear chain of method calls, easily extensible. Center: Exponential vs Linear complexity.

The Builder Pattern: Fluent, Readable, Extensible

Recipe recipe = new RecipeBuilder("Pasta Carbonara")
.addIngredient(new Ingredient("spaghetti", 400, "g"))
.addIngredient(new Ingredient("guanciale", 200, "g"))
.addInstruction("Boil pasta in salted water")
.addInstruction("Crisp guanciale in a cold pan")
.addNote("Do NOT add cream. This is not alfredo.")
.source("Kenji López-Alt")
.build();

✓ Each method call is self-documenting. Set only what you need. Order doesn't matter.

Telescoping ConstructorsBuilder Pattern
Adding new optional fieldAdd more constructor overloadsAdd one method to builder
Readability at call sitenew Recipe("X", null, null, notes, null, false).addNote("...").build()
Can accumulate valuesNo — must pass complete listYes — addIngredient() multiple times

Inside the Builder: The Implementation

public class RecipeBuilder {
private final String name; // Required
private final List<Ingredient> ingredients = new ArrayList<>(); // Accumulated
private final List<String> instructions = new ArrayList<>();
private final List<String> notes = new ArrayList<>();
private String source = null; // Optional

public RecipeBuilder(String name) {
this.name = name; // Non-null by default; @Nullable if optional
}

public RecipeBuilder addIngredient(Ingredient ing) {
this.ingredients.add(ing);
return this; // Return 'this' enables method chaining
}

public RecipeBuilder addInstruction(String step) { this.instructions.add(step); return this; }
public RecipeBuilder addNote(String note) { this.notes.add(note); return this; }
public RecipeBuilder source(String source) { this.source = source; return this; }

public Recipe build() {
if (ingredients.isEmpty()) throw new IllegalStateException("Recipe needs ingredients");
// Create immutable Recipe with defensive copies
return new Recipe(name, List.copyOf(ingredients), List.copyOf(instructions),
List.copyOf(notes), source);
}
}

Language Comparison: Do You Need Builder? (Sidebar)

The telescoping constructor problem is a Java problem. Other languages have built-in solutions:

TypeScript: Object with optional properties

interface RecipeOptions {
name: string; // required
ingredients?: Ingredient[]; // optional
instructions?: string[];
notes?: string[];
source?: string;
}

// Call with named properties — no builder needed!
const recipe = createRecipe({
name: "Pasta Carbonara",
notes: ["No cream!"],
source: "Kenji López-Alt"
// ingredients, instructions use defaults
});

Kotlin: Default parameters + named arguments

data class Recipe(
val name: String, // required
val ingredients: List<Ingredient> = emptyList(),
val instructions: List<String> = emptyList(),
val notes: List<String> = emptyList(),
val source: String? = null
)

// Call with named arguments — no builder needed!
val recipe = Recipe(
name = "Pasta Carbonara",
notes = listOf("No cream!"),
source = "Kenji López-Alt"
)

⚠ In Java, we use Builder to simulate what these languages provide natively.

Choosing the Right Creation Pattern

PatternUse WhenExample
Plain ConstructorSimple class, few required parametersnew Point(x, y)
Static FactoryNeed naming, caching, or subtype flexibilityConversionRule.bidirectional(...)
BuilderMany parameters, optional fields, accumulationRecipeBuilder.forDish(...).build()
DI (not Singleton!)Need one shared instance that's still testablenew Service(injectedDep)

The Singleton Pattern: Convenient But Dangerous

Effective Java Items 3-4: The Singleton

Singleton ensures a class has exactly one instance with global access:

public class ConversionRegistry {
// Eager initialization — instance created at class load
private static final ConversionRegistry INSTANCE = new ConversionRegistry();

private ConversionRegistry() {
// Private constructor prevents external instantiation
loadStandardConversions();
}

public static ConversionRegistry getInstance() {
return INSTANCE; // Always returns the same instance
}

public ConversionRule getRule(String from, String to) { /* ... */ }
}

The appeal: Simple access. No need to pass the registry around. Just call getInstance() anywhere.

Singleton Seems Convenient...

// Any code anywhere can access the registry — no passing required!
public class RecipeParser {
public Recipe parse(Path file) {
Recipe recipe = parseFromFile(file);
// Just call getInstance() — easy!
ConversionRegistry.getInstance().validate(recipe);
return recipe;
}
}

public class RecipeExporter {
public void export(Recipe recipe, String format) {
// Same pattern — getInstance() whenever you need it
ConversionRegistry registry = ConversionRegistry.getInstance();
Recipe converted = recipe.convertAll(registry);
writeToFormat(converted, format);
}
}

No constructor parameters, no field declarations, no wiring. What's not to love?

Everything. This convenience hides serious problems.

Singleton Creates Three Problems (Sound Familiar?)

  1. Hidden dependencies: Not visible in constructor — discovery requires reading every line (L7: common coupling!)
  2. Untestable: Can't substitute a test double (L16: this is why we inject dependencies!)
  3. Global state: Every caller shares the same mutable instance — changes ripple unpredictably

L16 showed #2 from a testability lens. Today we see the full picture: Singleton is an anti-pattern for object creation.

Language Comparison: Do You Need Singleton?

The Singleton pattern exists because Java lacks module-level state. Other languages provide natural singletons:

TypeScript/JavaScript: Module is the singleton

// conversionRegistry.ts
// Module state IS the singleton — no pattern needed!
const rules = new Map<string, ConversionRule>();

export function addRule(rule: ConversionRule) {
rules.set(`${rule.from}->${rule.to}`, rule);
}

export function getRule(from: string, to: string) {
return rules.get(`${from}->${to}`);
}

// Usage: import { getRule } from './conversionRegistry';
// Every importer gets the SAME module instance

Kotlin: object declaration

// Built-in language support for singletons
object ConversionRegistry {
private val rules = mutableMapOf<String, ConversionRule>()

fun addRule(rule: ConversionRule) {
rules["${rule.from}->${rule.to}"] = rule
}

fun getRule(from: String, to: String): ConversionRule? {
return rules["$from->$to"]
}
}

// Usage: ConversionRegistry.getRule("cups", "mL")
// Kotlin guarantees single instance, thread-safe init

⚠ These are still global state with the same testability problems! The pattern is unnecessary, but the problem remains.

You Already Know the Fix: Dependency Injection

In L16, we learned that Dependency Injection improves testability by making dependencies substitutable.

Now we see it from a different angle: DI is also a creation pattern — it determines how objects get their collaborators.

Same technique, two perspectives: testability enabler AND creation pattern.

Singleton vs. DI: Who Controls Creation?

The fundamental difference is control over dependencies.

Singleton: object reaches out

public class RecipeConverter {
// No hint of what this depends on!

public Recipe convert(Recipe recipe,
String toUnit) {
// Object fetches its own dependency
ConversionRule rule =
ConversionRegistry.getInstance()
.getRule(recipe.getUnit(),
toUnit);
return recipe.scaleTo(rule);
}
}

❌ Caller has no control over which registry is used

DI: caller provides dependencies

public class RecipeConverter {
private final ConversionRegistry registry;

// Caller decides which registry
public RecipeConverter(
ConversionRegistry registry) {
this.registry = registry;
}

public Recipe convert(Recipe recipe,
String toUnit) {
ConversionRule rule =
registry.getRule(
recipe.getUnit(), toUnit);
return recipe.scaleTo(rule);
}
}

✓ Caller controls creation and wiring

DI Solves All Three Singleton Problems (L16 Recap)

// Production: wire up with real implementations
ConversionRegistry realRegistry = new StandardConversionRegistry();
RecipeConverter converter = new RecipeConverter(realRegistry);

// Test: swap in a test double — exactly what we did in L16!
ConversionRegistry stubRegistry = (from, to) -> new ConversionRule("cups", "mL", 236.588);
RecipeConverter testConverter = new RecipeConverter(stubRegistry);
ProblemSingletonDependency Injection
Hidden dependenciesBuried in implementationVisible in constructor signature
TestabilityCan't substituteInject stubs/mocks freely (L15-16!)
Global stateShared mutable stateEach context gets its own instance

You proved this works in your tests! Now we formalize it as a creation pattern.

Three Ways to Inject Dependencies

Constructor injection

public class ImportService {
private final LibraryService lib;

public ImportService(
LibraryService lib) {
this.lib = lib;
}
}

Dependencies clear, immutable, always valid.

Setter injection

public class ImportService {
private LibraryService lib;

public void setLibrary(
LibraryService lib) {
this.lib = lib;
}
}

For optional dependencies. Object might be in invalid state.

Field injection

public class ImportService {
@Inject
private LibraryService lib;

// No constructor needed!
// Framework sets field directly
}

Convenient. But is it good?

All three let you swap implementations. But they differ in what invariants they preserve.

Why Not Field Injection?

From Lecture 1: "Software Engineering is the Integral of Programming Over Time"

Field injection (using @Autowired annotations) optimizes for writing code. But code is written once and read/maintained many times:

ActivityField InjectionConstructor Injection
Writing (once)✓ Less boilerplateMore code to write
Reading (many times)❌ Dependencies hidden✓ Dependencies visible in constructor
Testing (many times)❌ Requires framework magic✓ Just call new with mocks
Refactoring (many times)❌ IDE can't trace dependencies✓ Compiler catches missing deps
Onboarding new devs❌ "Where do these come from?"✓ Constructor documents requirements

Technical sustainability means optimizing for the lifetime of the code, not just the moment of writing.

public class ImportService {
@Autowired private LibraryService library;
@Autowired private ConversionRegistry conversions;
@Autowired private ValidationService validator;

public void importRecipe(Path file) {
Recipe recipe = parse(file);
validator.validate(recipe); // What if validator is null?
conversions.normalize(recipe); // What if conversions is null?
library.save(recipe); // What if library is null?
}
}
ProblemWhy It Hurts
Nullable fieldsFields are null until framework injects them. Call a method too early? NPE.
No finalCan't mark fields final — framework sets them after construction
Hidden dependenciesConstructor has no parameters — dependencies invisible in API
Untestable without frameworkCan't do new ImportService(mock, mock, mock) — no constructor!
Reflection magicFramework bypasses access control to set private fields

With field injection, the object exists in an invalid state between construction and injection:

Field Injection: Invalid state window

Constructor Injection: Always valid

Constructor Injection: Explicit, Immutable, Always Valid

public class ImportService {
private final LibraryService library; // final = immutable
private final ConversionRegistry conversions;
private final ValidationService validator;

// Dependencies are VISIBLE, REQUIRED, and set ONCE
// Non-null by default in our codebase; use @Nullable to mark optional
public ImportService(LibraryService library,
ConversionRegistry conversions,
ValidationService validator) {
this.library = library;
this.conversions = conversions;
this.validator = validator;
}

public void importRecipe(Path file) {
Recipe recipe = parse(file);
validator.validate(recipe); // Can't be null — guaranteed!
conversions.normalize(recipe);
library.save(recipe);
}
}

✓ Object is always valid after construction. Dependencies are documented in the API. Testing is trivial.

The Industry Has Shifted: Constructor Injection Wins

Spring Framework's official documentation now states:

"The Spring team generally advocates constructor injection, as it lets you implement application components as immutable objects and ensures that required dependencies are not null."
— Spring Framework Documentation, 2024

EraDominant StyleOptimized ForSustainability
2003-2007XML configurationDecoupling from code❌ Fragile, hard to refactor
2007-2015Field injectionWriting speed❌ Technical debt over time
2015-presentConstructor injectionMaintainability✓ Sustainable long-term

The industry learned: optimize for the lifetime of the code, not the moment of creation.

The Problem: What If You Don't Know the Implementation?

With DI, the caller must know which implementation to inject:

// Someone has to decide: which LibraryService implementation?
LibraryService library = new InMemoryLibraryService(); // or DatabaseLibraryService?
ImportService importer = new ImportService(library, conversions);

But what if the caller shouldn't know? What if:

  • The implementation is chosen at runtime based on configuration?
  • The implementation is provided by a plugin loaded dynamically?
  • You want two modules to be completely independent — neither knows about the other?

Motivating Example: Recipe Import Plugins

Imagine your recipe app supports importing from different sources. Users can install plugins:

// The core application defines an interface
public interface RecipeImporter {
boolean canHandle(Path file);
Recipe importFrom(Path file);
}

// Plugins implement this interface — but the core app doesn't know they exist!
// In a JAR file: plugins/json-importer.jar
public class JsonRecipeImporter implements RecipeImporter { ... }

// In another JAR: plugins/paprika-importer.jar
public class PaprikaImporter implements RecipeImporter { ... }

// In yet another: plugins/gemini-importer.jar — uses an LLM!
public class GeminiImporter implements RecipeImporter { ... }

How does the app use these importers if it doesn't know they exist at compile time?

BONUS: Plugin Complexity Can Vary Wildly

Same interface, vastly different implementations — core app doesn't care:

JsonRecipeImporter: Simple parsing

public class JsonRecipeImporter 
implements RecipeImporter {

public boolean canHandle(Path file) {
return file.toString().endsWith(".json");
}

public Recipe importFrom(Path file) {
// Simple: read JSON, map to Recipe
String json = Files.readString(file);
return objectMapper.readValue(json,
Recipe.class);
}
}

GeminiImporter: Complex LLM workflow

public class GeminiImporter 
implements RecipeImporter {
private final GeminiClient gemini;

public boolean canHandle(Path file) {
// Can handle images, PDFs, screenshots!
return isImage(file) || isPdf(file);
}

public Recipe importFrom(Path file) {
// 1. Send image to Gemini Vision
String extracted = gemini.analyzeImage(file,
"Extract recipe: name, ingredients, steps");

// 2. Parse LLM response into structured data
Recipe draft = parseGeminiResponse(extracted);

// 3. Validate and normalize units
return normalizeUnits(draft);
}
}

✓ Core app just calls importer.importFrom(file) — doesn't know or care if it's parsing JSON or calling an LLM API!

Service Locator: Find Implementations at Runtime

A Service Locator is a registry that maps interfaces to implementations:

public class ServiceLocator {
private static final Map<Class<?>, List<Object>> services = new HashMap<>();

// Register an implementation (called at startup or by plugin loader)
public static <T> void register(Class<T> type, T instance) {
services.computeIfAbsent(type, k -> new ArrayList<>()).add(instance);
}

// Find all implementations of an interface
public static <T> List<T> getAll(Class<T> type) {
return (List<T>) services.getOrDefault(type, List.of());
}

// Find a single implementation
public static <T> T get(Class<T> type) {
List<T> impls = getAll(type);
if (impls.isEmpty()) throw new IllegalStateException("No " + type.getSimpleName());
return impls.get(0);
}
}

Using Service Locator for Plugin Discovery

// At application startup: plugins register themselves
public class JsonRecipeImporterPlugin {
static {
// Plugin registers itself when its JAR is loaded
ServiceLocator.register(RecipeImporter.class, new JsonRecipeImporter());
}
}

// Core application: discovers and uses plugins without knowing they exist
public class ImportService {
public Recipe importRecipe(Path file) {
// Ask Service Locator for all registered importers
List<RecipeImporter> importers = ServiceLocator.getAll(RecipeImporter.class);

for (RecipeImporter importer : importers) {
if (importer.canHandle(file)) {
return importer.importFrom(file);
}
}
throw new UnsupportedOperationException("No importer for: " + file);
}
}

✓ New plugins can be added by dropping a JAR file — no recompilation of core app!

Plugin architecture diagram. Center: Core App building with Service Locator desk. Left: Explanation that JARs are ZIP files of compiled Java code, with three example plugin JARs arriving from different sources. Right: Numbered flow showing how plugins register and get discovered. Key insight: core app never directly references plugin implementations.

Service Locator Through the Coupling Lens (L7)

Remember our coupling types from L7? DI and Service Locator make different coupling tradeoffs:

Dependency Injection: Data Coupling

// Caller KNOWS the implementation
new ImportService(libraryService);
  • Caller passes data (the dependency) to constructor
  • Visible: Constructor documents what's needed
  • Testable: Pass mocks directly
  • Limitation: Caller must have a reference to pass

Service Locator: Common Coupling

// Caller knows NOTHING about implementation
ServiceLocator.get(LibraryService.class);
  • All code shares the global registry
  • Hidden: Dependencies discovered at runtime
  • Test setup: Must configure global registry
  • Power: Modules never reference each other

Data coupling (DI) is usually preferable. Common coupling (Service Locator) is the price you pay for true runtime extensibility.

DI vs. Service Locator: The Dependencies Tell the Story

Service Locator

public class ImportService {
// What does this depend on?
// You must read every line to know!

public void importRecipe(Path file) {
Recipe recipe = parseRecipe(file);
ServiceLocator
.get(LibraryService.class)
.addRecipe(recipe);
ServiceLocator
.get(ConversionRegistry.class)
.validate(recipe);
}
}

Dependencies discovered at runtime

Dependency Injection

public class ImportService {
private final LibraryService library;
private final ConversionRegistry conv;

// Dependencies ARE the constructor
public ImportService(
LibraryService library,
ConversionRegistry conv) {
this.library = library;
this.conv = conv;
}

public void importRecipe(Path file) {
Recipe recipe = parseRecipe(file);
library.addRecipe(recipe);
conv.validate(recipe);
}
}

Dependencies explicit in signature

The Same Patterns Work at Every Scale

public class ImportService {
public void importRecipe(Path file) {
Recipe recipe = parseRecipe(file);
LibraryService.getInstance().addRecipe(recipe); // Hidden dependency!
}
}

✗ Testing is painful — you can't substitute a mock LibraryService.
✗ The dependency graph is invisible.
✗ Changing the library implementation affects everything that calls getInstance().

public class ImportService {
private final LibraryService library;
private final ConversionRegistry conversions;

public ImportService(LibraryService library, ConversionRegistry conversions) {
this.library = library;
this.conversions = conversions;
}

public void importRecipe(Path file) {
Recipe recipe = parseRecipe(file);
library.addRecipe(recipe); // Dependency is explicit
}
}

✓ Dependencies visible. Tests can inject mocks. Implementations swappable.

Same Principles, Bigger Scope

Object Level (A1-A4)Service Level (A5+)
RecipeBuilder creates a RecipeA "composition root" wires up services
ConversionRegistry abstracts conversion rulesLibraryService abstracts cookbook storage
Pass registry to Recipe.convert()Pass services to controllers
Test recipes with stub registriesTest controllers with mock services

The question shifts from "how do I create this object?" to "how do I wire up this whole system?" — but the answer is the same: depend on abstractions, inject implementations, keep coupling loose.

Services Connect Through Interfaces, Not Implementations

Dashed borders are interfaces. Every dependency points to an abstraction. No service knows how any other service is implemented.

Someone Has to Wire It All Together

public class Application {
public static void main(String[] args) {
// Create implementations
ConversionRegistry conversions = new StandardConversionRegistry();
LibraryService library = new InMemoryLibraryService();

// Wire services together
ImportService importService = new ImportService(library, conversions);
ExportService exportService = new ExportService(library);

// Wire the controller
CLIController controller = new CLIController(
importService, exportService, library
);

controller.run(args);
}
}

This is the "composition root" — the one place that knows about concrete implementations. Everything else depends on abstractions.

Preview: Where Do Service Boundaries Come From?

We said ImportService, ExportService, LibraryService — but how did we decide those were the right boundaries?

Next lecture, we step back to ask:

  • What distinguishes "architecture" from "design"?
  • How do you identify the natural seams in a problem domain?
  • How do you communicate and document architectural decisions?

The patterns you've learned — Builder, Factory, DI — don't disappear at larger scales. They're the building blocks. Architecture is about deciding which buildings to construct and how they relate.

Key Takeaways

  • These are Java patterns — they work around language limitations (no named params, no module singletons). Other languages have different idioms!
  • Creation patterns are information hiding applied to object construction — same principle from L6!
  • Static factories name intent and enable caching; Builders handle many optional parameters
  • Singleton is an anti-pattern: hides dependencies (L7), hurts testability (L16), creates global state
  • Dependency Injection is both a testability enabler (L16) AND a creation pattern — same technique!
  • Constructor injection makes dependencies explicit, immutable, and visible in the API
  • These patterns scale: the same principles that wire objects also wire entire systems (preview of A5)

Understand the problem, then use the language's idiomatic solution.