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 & Ellen Spertus, CC-BY-SA

HW3 Survey from Prof. Bell

Poll Everywhere QR Code or Logo

Text espertus to 22333 if the
URL isn't working for you.

https://pollev.com/espertus

Responses are anonymous but count toward participation.

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

Poll: Which of the following statements about constructors are true?

A. There can be only one constructor in a class

B. Constructors must have the same name as the class

C. Constructors must always create new objects -- no recycling

D. Constructors must always return an instance of the enclosing class, not a subclass

Poll Everywhere QR Code or Logo

Text espertus to 22333 if the
URL isn't working for you.

https://pollev.com/espertus

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.

Integer.valueOf() Implementation

    /**
* Returns an {@code Integer} instance representing the specified
* {@code int} value. If a new {@code Integer} instance is not
* required, this method should generally be used in preference to
* the constructor {@link #Integer(int)}, as this method is likely
* to yield significantly better space and time performance by
* caching frequently requested values.
*
* This method will always cache values in the range -128 to 127,
* inclusive, and may cache other values outside of this range.
*
* @param i an {@code int} value.
* @return an {@code Integer} instance representing {@code i}.
* @since 1.5
*/
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}

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), DayOfWeek.from(temporal)
  • valueOf() — parsing/conversion: Integer.valueOf("42"), Boolean.valueOf(true)
  • getInstance() / newInstance() — may cache or create: Calendar.getInstance()
  • getTypeName() / newTypeName() — factory in different class: Files.newBufferedReader(path)

Example: Bidirectional Conversion Rules

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);
}
}
// old (4 arguments)
ConversionRule cupsToMilliliters = new ConversionRule("cups", "mL", 236.588, true);

// new (clearer, with fewer arguments)
ConversionRule cupsToMilliliters = ConversionRule.bidirectional("cups", "mL", 236.588);

Example: Cached Conversion Rules

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

// Private constructor — forces use of factory methods
// For simplicity, we are not supporting bidirectional rules.
private ConversionRule(String from, String to, double factor) {
this.fromUnit = from;
this.toUnit = to;
this.factor = factor;
}

// Cache conversion rules for reuse.
private static final Map<String, ConversionRule> cachedRules = new ConcurrentHashMap<>();

public static ConversionRule cached(String from, String to, double factor) {
String key = from + "->" + to + "@" + factor;
// If a cached rule is present, return it; otherwise, create and cache one.
return cachedRules.computeIfAbsent(key,
k -> new ConversionRule(from, to, factor));
}
}

The Telescoping Constructor Problem

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

public class Recipe {
// Most general constructor
Recipe(String name, List<Ingredient> ingredients, List<String> instructions, List<String> notes,
String source, boolean isVegan) { ... }

Recipe(String name) { this(name, List.of(), List.of(), List.of(), null, false); }

Recipe(String name, List<Ingredient> ingredients) {
this(name, ingredients, List.of(), List.of(), null, false);
}

Recipe(String name, List<Ingredient> ingredients, List<String> instructions) { ... }
Recipe(String name, List<Ingredient> ingredients, List<String> instructions, List<String> notes) {...}
Recipe(String name, List<Ingredient> ingredients, List<String> instructions, List<String> notes,
String source) { ... }

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

✓ Each method both mutates and returns the RecipeBuilder.

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;
}

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

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

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.
►These languages specify nullable types with question marks.

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

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 this depends on ConversionRegistry,
// which is hardcoded in the convert() method.





public Recipe convert(Recipe recipe, String toUnit) {
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
// using reflection
}

Convenient. But is it good?

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

Poll: Which injection types make it hard to enforce invariants?

A. Constructor injection

B. Setter injection

C. Field injection

Poll Everywhere QR Code or Logo

Text espertus to 22333 if the
URL isn't working for you.

https://pollev.com/espertus

Comparing Dependency Injection Approaches

AspectConstructorSetterField
Immutability✅ Dependencies are final❌ Mutable after construction❌ Mutable after construction
Required dependencies✅ Clear at construction⚠️ May be optional⚠️ Unclear if required
Invariants✅ Enforced at construction❌ Hard to enforce❌ Hard to enforce
Testability✅ Easy to substitute✅ Easy to substitute⚠️ Requires reflection/framework
Object validity✅ Always valid after construction❌ May be invalid temporarily❌ May be invalid temporarily

Winner for enforcing invariants: Constructor injection — object is guaranteed valid from the moment it's created.

Field Injection Violates Encapsulation

Two-panel jousting meme. Top panel: knight with lance labeled 'CLASS WITH PRIVATE FIELDS'. Bottom panel: same knight with lance piercing through helmet visor, labeled 'FIELD INJECTION'

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

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?

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);
}
}
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.

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!

Configuring the Service Locator for Tests

// You must configure the registry for every test
@BeforeEach
void setUp() {
ServiceLocator.clear();
ServiceLocator.register(RecipeImporter.class, mockJsonImporter);
ServiceLocator.register(RecipeImporter.class, mockPaprikaImporter);
}

@AfterEach
void tearDown() {
ServiceLocator.clear();
// Clean up to avoid test pollution
}

⚠️ Manual setup required for each test
⚠️ Global state must be managed carefully

Review of Types of Coupling (L7)

  • Data coupling shares common types, such as String
  • Stamp coupling shares user-defined types that might change
  • Control Coupling happens when a parameter to a method controls the flow of execution
  • Common Coupling involves multiple modules sharing a common data structure without proper encapsulation
  • Content Coupling violates encapsulation (using reflection)

Service Locator Through the Coupling Lens

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

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

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

Which is Better? It Depends...

Book cover in the style of O'Reilly.
Title: 'It Depends'
Subtitle: 'The Definitive Guide'
Heading: 'The answer to every programming question ever conceived'
Bottom: 'O RLY?'
Credit: @ThePracticleDev

Assignment 5 Preview

public class ImportService {
public void importRecipe(Path file) {
Recipe recipe = parseRecipe(file);
LibraryService.getInstance().addRecipe(recipe); // Hidden dependency!
}
}
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.