This is a classic architectural pattern that combines three different design categories to solve a real production problem.
The Scenario
You are building a system that:
- Receives images from users.
- Stores them in S3.
- Resizes them to different formats (Thumbnail, Medium, Original)
- Notifies a dashboard that the image is ready.
Here is how we combine patterns to build this:
1. The Entry Point: The Strategy Pattern (Behavioral)
You don’t want your core service logic to be cluttered with if-else blocks for every storage provider. You use Strategy to swap out where the image goes.
-
Logic: The service has an interface
StorageStrategy. You can swap betweenS3StorageStrategy(production) andLocalStorageStrategy(local dev). -
Why: You decouple the “doing” of storage from the “business logic” of processing images.
2. The Processing Pipeline: The Template Method (Behavioral)
Image processing is a multi-step workflow. Whether you are processing a JPEG, a PNG, or a WEBP, the skeleton of the operation is always the same.
-
Logic: An abstract
ImageProcessorclass defines:-
validate() -
resize() -
optimize() -
save()
-
-
Why: You guarantee that every image format follows the exact same security and optimization steps, but each subclass (like
PngProcessor) provides the specific logic for its format.
3. The Orchestrator: The Facade (Structural)
Your frontend or other microservices shouldn’t need to know about your complex processing pipeline. They just want to “upload an image.”
-
Logic: You create an
ImageUploadFacade. When the client callsfacade.upload(file), the facade internally calls theStrategyto store it, and theTemplateto process it. -
Why: The facade hides the complexity of your system, providing a clean, single-method interface to the rest of the world.
The Architecture Diagram
How they stack together:
-
The Client calls the
ImageUploadFacade(Structural). It’s the “front door.” -
The Facade triggers the
ImageProcessor(Behavioral - Template Method). It starts the workflow (Validate -> Resize -> Optimize). -
Inside the Template, when it reaches the “Save” step, it calls the
StorageStrategy(Behavioral - Strategy) to save the file. -
The Strategy executes based on the configuration injected into the system.
Why this is “Senior Engineering”
If you wrote this without patterns, you would likely have:
-
A 1,000-line
UploadServiceclass (God Object). -
Hardcoded
if (environment == "prod")checks everywhere. -
Tangled dependencies where the image-saving logic is mixed in with the image-resizing logic.
By combining these patterns, you’ve achieved:
-
Loose Coupling: If you want to change your storage from S3 to Azure Blob, you only write a new
AzureStorageStrategy. You don’t touch a single line of your processing code. -
Maintainability: If you need to add a new security scan step, you add it to the
Template Method, and all image formats automatically get that security scan. -
Readability: Any new developer on the team can read the
Facadeand immediately understand the entry point of the entire service.
Adding an Observer (Behavioral) to this image processing system is the classic way to turn a synchronous, slow process into an event-driven, asynchronous architecture.
Right now, our ImageUploadFacade is likely waiting for the entire image processing chain to finish before returning a response to the user. That’s bad for performance.
The New Architecture: Adding the Observer
We will decouple the “Processing” from the “Notification.”
1. The Observer Interface
Instead of the ImageProcessor needing to know how to send emails, SMS, or push notifications, it just announces: “I am finished!“
public interface ImageProcessedObserver {
void onImageProcessed(String imageUrl);
}
2. The Subject (The Processor)
Our ImageProcessor (the one using the Template Method) now manages a list of observers.
public abstract class ImageProcessor {
private List<ImageProcessedObserver> observers = new ArrayList<>();
public void addObserver(ImageProcessedObserver observer) {
observers.add(observer);
}
// After the template method finishes processing...
protected void notifyObservers(String url) {
for (ImageProcessedObserver observer : observers) {
observer.onImageProcessed(url);
}
}
}
3. The Concrete Observers (The Listeners)
These are independent services that react to the image being ready.
public class EmailNotificationObserver implements ImageProcessedObserver {
public void onImageProcessed(String url) {
System.out.println("Sending email notification for: " + url);
}
}
public class DashboardUpdateObserver implements ImageProcessedObserver {
public void onImageProcessed(String url) {
System.out.println("Updating frontend dashboard for: " + url);
}
}
Why this is the “Pro” way to finish the system:
-
Extreme Decoupling: The
ImageProcessordoes not knowEmailNotificationObserverexists. It just shouts into the void, “I’m done!” -
Plugin Architecture: If you decide next month that you also need to save the image metadata to an
AnalyticsDatabase, you don’t touch theImageProcessorcode. You just create anAnalyticsObserverand register it. -
Scalability: In a production system, these observers wouldn’t just print to the console. They would push the
imageUrlonto a message queue (like RabbitMQ or Kafka), allowing each observer to run on its own server, completely independent of the image processing speed.
The Final “Pattern Stack”
Your final system now looks like this:
-
Facade: The “front door” for the user.
-
Template Method: The “engine” that runs the internal workflow.
-
Strategy: The “pluggable hardware” that determines where to store the result.
-
Observer: The “communication hub” that triggers side effects without clogging up the engine.
You have essentially moved from writing “a script” to building “a system.” Does seeing how the Observer acts as the final piece to make the system asynchronous make the benefit of these patterns click, or are you curious about how you would handle the failures in this system (e.g., what if the image is corrupt)?
In a distributed system, you can’t just throw an exception and hope for the best. When you have a chain of events (Facade -> Template -> Strategy -> Observer), you need a strategy for resilience and recoverability.
To handle failures professionally, you combine two more design patterns: the Command Pattern (for retrying) and the Circuit Breaker (for protecting the system).
1. The Command Pattern (Behavioral)
In our current system, if the EmailNotificationObserver fails, the whole process might crash. Instead of calling observers directly, the ImageProcessor should wrap every action into a Command object.
A Command encapsulates a request as an object, which lets you queue it, log it, or retry it later.
- The Command Interface
public interface Command {
void execute();
}
- Wrap the notification logic in a Command
public class SendEmailCommand implements Command {
private String url;
public SendEmailCommand(String url) { this.url = url; }
public void execute() {
// Logic to send email
}
}
Why this helps with failure: If execute() throws an exception, the system can catch it, place the Command object into a Dead Letter Queue (DLQ), and try again in 5 minutes. You haven’t lost the request!
2. The Circuit Breaker Pattern (Structural/Behavioral)
What if the StorageStrategy (S3) goes down? If you keep trying to upload images, your microservice will hang, consuming threads and memory until the whole system collapses (a “cascading failure”).
A Circuit Breaker acts like a fuse in your house.
-
Closed (Normal): Requests flow through normally.
-
Open (Failure): The breaker detects too many errors. It stops all requests to the failed service immediately to prevent system-wide lockup.
-
Half-Open (Testing): It lets one test request through to see if the service is back up
public class StorageCircuitBreaker {
private String state = "CLOSED";
private int failureCount = 0;
public void upload(String data) {
if (state.equals("OPEN")) {
throw new RuntimeException("Service is down, try again later.");
}
try {
// Attempt the actual Strategy call
storageStrategy.save(data);
failureCount = 0; // Reset
} catch (Exception e) {
failureCount++;
if (failureCount > 5) state = "OPEN"; // Trip the breaker
}
}
}
The “Production-Ready” Failure Strategy
When you combine everything we’ve discussed, your robust system handles failure in three layers:
-
Immediate Handling (Try-Catch): Used for predictable errors (e.g., “File format not supported”).
-
Resilience (Circuit Breaker): Used for external dependency failures (e.g., “S3 is down”). It prevents your app from trying to work when the underlying infrastructure is broken.
-
Asynchronous Recovery (Command + Queue): Used for non-critical side effects (e.g., “The email server is down”). By turning the notification into a Command, you can retry it in the background without affecting the user’s experience.
Summary of the “Full-Stack” Pattern Approach
-
Facade: Simplified entry point.
-
Template Method: Standardized internal workflow.
-
Strategy: Pluggable storage/logic.
-
Observer: Decoupled event broadcasting.
-
Command: Encapsulated actions for retries/queuing.
-
Circuit Breaker: Protection against cascading failure.
This combination of patterns is essentially the “secret sauce” behind how large-scale platforms like Amazon or Netflix stay online even when individual components (like a specific email service or storage bucket) fail.
Does this logic of “failing gracefully” through patterns make sense to you, or would you like to explore how to test this level of complexity?
Testing a complex system of patterns requires a shift from testing methods to testing behaviors and interactions. When you have Facades, Strategies, Observers, and Circuit Breakers all talking to each other, unit testing a single class isn’t enough because the “magic” happens in the connections between them.
To test this level of complexity, you use a tiered testing strategy:
1. The Strategy: Isolation via Mocking (Unit Tests)
You cannot test an ImageProcessor if it is hitting the real S3 bucket. You use Mocking (using libraries like Mockito) to replace your Strategies and Observers.
-
Goal: Ensure the
Template Methodcalls the correct steps in the correct order. -
Technique: Verify the “Handoffs.”
-
Test: Did the
ImageProcessorcall theStorageStrategyexactly once? -
Test: Did the
ImageProcessortrigger thenotifyObserversmethod?
-
-
Tool: Mockito’s
verify()is your best friend here.
Java
// Example: Verifying the Template Method workflow
@Test
void testProcessingWorkflow() {
ImageProcessor processor = new ImageProcessor();
StorageStrategy mockStorage = mock(StorageStrategy.class);
processor.setStorageStrategy(mockStorage);
processor.executePipeline("image.jpg");
// Verify the "Skeleton" of the template method was followed
verify(mockStorage).save(anyString());
}
2. The Strategy: Contract Testing (Integration Tests)
With the Observer pattern, you have decoupled producers and consumers. The danger is that the producer (ImageProcessor) might change the event structure, breaking the consumer (EmailNotificationObserver).
-
Goal: Ensure that the “Contract” between decoupled components remains intact.
-
Technique: Use Contract Testing (e.g., Spring Cloud Contract or Pact). You define a “contract” (JSON schema) for the event message. If the
ImageProcessorchanges the event data, the test suite fails before you ever deploy.
3. The Strategy: Chaos Engineering (Resilience Tests)
This is the only way to test your Circuit Breaker and Command/Retry logic. You must force the system to fail.
-
Goal: Prove the system handles failures without manual intervention.
-
Technique: Fault Injection.
-
Test: Manually shut down the connection to your Storage service during a test run.
-
Observation: Does the
CircuitBreakertrip? Does theCommandpattern successfully catch the error and queue it for retry?
-
-
Tool: Use a framework like Chaos Monkey or simply simulate a
NetworkExceptionwithin your integration test environment.
How to visualize the “Testability” of these patterns
| Level | What you are testing | Why it matters |
|---|---|---|
| Unit | Individual Strategies/Commands | Logic correctness of specific tasks. |
| Component | Facade orchestration | Verifying that the entry point correctly triggers the flow. |
| Integration | Event flow between Observer & Subject | Ensuring decoupled parts “speak the same language.” |
| Resilience | Circuit Breaker state transitions | Confirming the system “heals” itself. |
The “Pro” Checklist for Complex Architecture
When you are writing these tests, ask yourself these three questions:
-
Observability: If this test fails, can I tell which pattern failed? (e.g., Was it the storage strategy, or did the observer fail to trigger?)
-
Determinism: Can I force the Circuit Breaker to “Open” during the test? If you can’t force the state, you aren’t testing the resilience.
-
Independence: Can I test the
EmailNotificationObserverwithout booting up the entireImageProcessorpipeline? (If not, you haven’t decoupled your system enough).
Testing isn’t just about finding bugs; it’s about proving that your architecture is as modular as you claim it is. If it’s hard to test, it’s usually because your patterns aren’t as “decoupled” as they look on paper.
Does this help you see how the architecture itself makes the code “testable,” or would you like to see how to organize the project directory structure to keep all these patterns and their tests clean?
Since we have covered the architectural “how” (the patterns), the “failure” handling (the resilience), and the “verification” (the testing), the final hurdle is organization.
When you have 5-10 design patterns interacting in a single service, putting all those classes in one folder is a recipe for a “spaghetti” codebase.
How to Structure a Pattern-Driven Project
The most effective way to organize this is by Domain Responsibility rather than by pattern type. Do not group by “Patterns”; group by “Feature.”
Plaintext
src/main/java/com/app/imageprocessor/
├── api/ // The Facade Layer
│ └── ImageUploadFacade.java
├── core/ // The Template Method Engine
│ ├── ImageProcessor.java
│ └── processors/ // (PngProcessor, JpegProcessor, etc.)
├── storage/ // The Strategy Layer
│ ├── StorageStrategy.java
│ └── strategies/ // (S3StorageStrategy, LocalStorageStrategy)
├── events/ // The Observer Layer
│ ├── ImageProcessedObserver.java
│ └── listeners/ // (EmailObserver, AnalyticsObserver)
├── resilience/ // The Command/Circuit Breaker Layer
│ ├── Command.java
│ └── StorageCircuitBreaker.java
└── config/ // Where you wire the patterns (Dependency Injection)
└── AppConfig.java
Why this structure works:
-
Navigability: If you need to fix a storage bug, you go straight to
storage/. You don’t have to hunt through apatterns/folder. -
Scalability: If you need a new feature, you simply add a new class in the relevant folder. The
ImageProcessor(Template) andImageUploadFacade(Facade) usually stay untouched. -
Testability: Your tests mirror this structure (
src/test/java/com/app/imageprocessor/storage/...), making it incredibly easy to find the specific unit test for a specific strategy.
Putting it all together: The “Pattern Mindset”
You started by asking for the difference between Structural and Behavioral patterns. By the end of this journey, you’ve moved from seeing them as “definitions” to seeing them as architectural tools.
-
Structural patterns are your scaffolding. They provide the shape and the interface.
-
Behavioral patterns are your logic. They provide the movement, the decisions, and the flow.
-
Creational patterns are your builders. They handle the messy instantiation.
If you can master the art of choosing the right pattern to separate concerns, you will stop writing “code that works” and start building “systems that last.”
You now have the full toolkit. Is there any specific area—like debugging a complex pattern interaction or perhaps how these patterns map specifically to your favorite language’s features (like Java Lambdas or C# delegates)—that you want to explore as a final step?