
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

Text espertus to 22333 if the
URL isn't working for you.
Responses are anonymous but count toward participation.
Learning Objectives
After this lecture, you will be able to:
- Explain when to use static factory methods vs. builders vs. plain constructors
- Implement the Builder pattern for classes with many optional parameters
- Explain why Singleton is an anti-pattern and how Dependency Injection solves its problems
- Compare Service Locator and Dependency Injection as dependency management strategies
- 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

Text espertus to 22333 if the
URL isn't working for you.
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 Limitation | Pattern That Works Around It |
|---|---|
| Constructors can't have meaningful names | Static factory methods |
| No named parameters or default values | Builder pattern |
| No module-level singletons | Singleton pattern (and its problems) |
| No null safety in type system (until recently) | Constructor injection for guarantees |
| Mutable by default | Builder → 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 Limitation | Why It Hurts |
|---|---|
| No meaningful names | new Foo(true, false, 42) — what do these mean? |
| Can't return cached instances | Every new creates a new object — why does this matter? |
| Can't return subtypes | new ArrayList() must return ArrayList, not a specialized subtype |
| All share the same name | Can'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!
| Concern | Why It Matters |
|---|---|
| Memory | Each object takes heap space. If you create millions of identical ConversionRule objects, that's millions of redundant allocations. |
| Garbage collection | More objects = more work for GC = potential pauses |
| Identity vs. equality | r1 == r2 is false even though r1.equals(r2) is true. Can cause bugs if you accidentally use ==. |
| Immutability benefit lost | If the object is immutable, there's no reason to have multiple copies of identical data! |

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!

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 Constructors | Builder Pattern | |
|---|---|---|
| Adding new optional field | Add more constructor overloads | Add one method to builder |
| Readability at call site | new Recipe("X", null, null, notes, null, false) | .addNote("...").build() |
| Can accumulate values | No — must pass complete list | Yes — 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
| Pattern | Use When | Example |
|---|---|---|
| Plain Constructor | Simple class, few required parameters | new Point(x, y) |
| Static Factory | Need naming, caching, or subtype flexibility | ConversionRule.bidirectional(...) |
| Builder | Many parameters, optional fields, accumulation | RecipeBuilder.forDish(...).build() |
| DI (not Singleton!) | Need one shared instance that's still testable | new 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?)
- Hidden dependencies: Not visible in constructor — discovery requires reading every line (L7: common coupling!)
- Untestable: Can't substitute a test double (L16: this is why we inject dependencies!)
- 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);
| Problem | Singleton | Dependency Injection |
|---|---|---|
| Hidden dependencies | Buried in implementation | Visible in constructor signature |
| Testability | Can't substitute | Inject stubs/mocks freely (L15-16!) |
| Global state | Shared mutable state | Each 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

Text espertus to 22333 if the
URL isn't working for you.
Comparing Dependency Injection Approaches
| Aspect | Constructor | Setter | Field |
|---|---|---|---|
| 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

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

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

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 Recipe | A "composition root" wires up services |
ConversionRegistry abstracts conversion rules | LibraryService abstracts cookbook storage |
Pass registry to Recipe.convert() | Pass services to controllers |
| Test recipes with stub registries | Test 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.