In the previous article, we introduced Creational patterns. Creational patterns focus on object creation: where objects are created, which concrete implementation should be used, and how to avoid coupling callers to construction details.

This article focuses on Structural patterns.

According to Refactoring.Guru, structural patterns explain how to assemble objects and classes into larger structures while keeping those structures flexible and efficient.

In other words, structural patterns help us answer this question:

How should we compose objects and modules without making the system too rigid?

Why structural patterns matter

In a real system, a single object rarely works alone. Objects usually depend on other objects, wrap other objects, expose simplified APIs, or adapt incompatible interfaces.

For example:·

1
2
3
4
5
6
Application
-> Service
-> ProviderClient
-> Cache
-> Mapper
-> MetricsReporter

This structure can become hard to change if every layer knows too much about the others.

Structural patterns help us organize these relationships. Their goal is not just to connect objects together. Their goal is to keep the connection flexible, understandable, and replaceable.

Structural patterns catalog

Refactoring.Guru lists seven common structural patterns:

Pattern Main idea Common use case
Adapter Convert one interface into another interface expected by the caller. Integrating external APIs or legacy code.
Bridge Separate abstraction from implementation so both can change independently. Avoiding large inheritance trees.
Composite Treat individual objects and groups of objects through the same interface. Tree structures, nested components, grouped operations.
Decorator Add behavior by wrapping an object. Adding logging, caching, metrics, validation, or retry logic.
Facade Provide a simple interface over a complex subsystem. Hiding module internals behind a clean API.
Flyweight Share common state between many similar objects. Reducing memory usage when creating many small objects.
Proxy Control access to another object through a placeholder. Lazy loading, access control, caching, remote calls.

Adapter

Adapter is useful when two interfaces do not match.

For example, our application may expect this interface:

1
2
3
FeedProvider
-> connect()
-> next_event()

But an external provider client may expose a different API:

1
2
3
SportradarClient
-> open_stream()
-> read_message()

Instead of changing the application to understand every provider API, we can write an adapter:

1
2
3
4
5
6
7
SportradarAdapter
-> implements FeedProvider
-> wraps SportradarClient

ESPNAdapter
-> implements FeedProvider
-> wraps ESPNClient

The application depends on FeedProvider, and provider-specific details stay inside the adapter.

Pros

  • Isolates external API differences.
  • Keeps application code independent from provider-specific interfaces.
  • Makes integration testing easier.
  • Makes replacing a provider less risky.

Cons

  • Adds one more layer.
  • If the common interface is badly designed, every adapter becomes awkward.
  • Debugging sometimes requires jumping between adapter and wrapped object.

Bridge

Bridge separates an abstraction from its implementation.

This is useful when two dimensions can change independently.

For example:

1
2
3
4
Notification
-> EmailSender
-> SmsSender
-> SlackSender

If we also have different notification types:

1
2
3
AlertNotification
ReportNotification
ReminderNotification

Using inheritance for every combination can become messy:

1
2
3
4
5
6
EmailAlertNotification
SmsAlertNotification
SlackAlertNotification
EmailReportNotification
SmsReportNotification
SlackReportNotification

Bridge avoids this by separating the high-level abstraction from the implementation:

1
2
Notification
-> sender: NotificationSender

Now notification type and sender implementation can change separately.

Another backend example is push feed.

Assume we have two dimensions:

1
2
3
4
5
6
7
Provider:
-> Sportradar
-> ESPN

Sport:
-> NBA
-> MLB

If we combine them directly, the design may become:

1
2
3
4
SportradarNbaFeed
SportradarMlbFeed
EspnNbaFeed
EspnMlbFeed

This works at first, but every new provider or sport creates more combinations.

A bridge-like structure separates the two dimensions:

1
2
3
FeedSource
-> ProviderImplementation
-> SportEndpoint

For example:

1
2
3
4
FeedSource(provider: SportradarProvider, sport: NBA endpoint)
FeedSource(provider: SportradarProvider, sport: MLB endpoint)
FeedSource(provider: ESPNProvider, sport: NBA endpoint)
FeedSource(provider: ESPNProvider, sport: MLB endpoint)

Now provider and sport can change independently. We can add a new provider without creating a new class for every sport, and we can add a new sport without changing every provider-specific feed class.

A third example is report exporting.

We may have different report types:

1
2
3
4
Report:
-> SalesReport
-> AuditReport
-> UsageReport

And different export targets:

1
2
3
4
Exporter:
-> CsvExporter
-> JsonExporter
-> S3Exporter

Without Bridge, we may create many combinations:

1
2
3
4
5
6
CsvSalesReport
JsonSalesReport
S3SalesReport
CsvAuditReport
JsonAuditReport
S3AuditReport

With Bridge, report logic and export logic are separated:

1
2
Report
-> exporter: ReportExporter

The report decides what data should be exported. The exporter decides how and where the data is written.

Pros

  • Avoids class explosion.
  • Separates high-level logic from implementation details.
  • Makes both sides easier to extend independently.

Cons

  • Adds abstraction even when the system may not need it yet.
  • Can be harder to understand than direct composition.
  • Not useful if there is only one dimension of change.

Composite

Composite is useful for tree-like structures.

It lets the caller treat a single object and a group of objects through the same interface.

For example, a file system has two kinds of nodes:

1
2
3
FileSystemNode
-> File
-> Folder

File is a leaf. It does not contain other nodes.

1
2
File: README.md
File: app.rs

Folder is the group object. It can contain both files and other folders.

1
2
3
4
5
Folder: project
-> File: README.md
-> Folder: src
-> File: main.rs
-> File: config.rs

The important point is that both File and Folder share the same interface:

1
2
3
FileSystemNode
-> size()
-> render()

The caller can call the same operation on both a single file and a folder:

1
2
3
4
5
size(file)
size(folder)

render(file)
render(folder)

When size(folder) is called, the folder can calculate its size by asking every child node for its size. It does not need to care whether the child is a file or another folder.

In this example, FileSystemNode is the shared interface.

File is the simple object. It only knows how to calculate or render itself.

Folder is the object that groups other nodes. It can contain many FileSystemNode interface, and each child can be either a File or another Folder.

That is the key idea of Composite: a group object can be used like a single object because both share the same interface.

Composite Pattern is useful when the structure can be nested and the caller wants to handle one item and many items in the same way.

Pros

  • Works well for tree structures.
  • Lets callers handle single objects and groups uniformly.
  • Makes recursive operations easier.

Cons

  • Can make the model too general.
  • Some operations may not make sense for both leaf and group objects.
  • Incorrect abstraction can hide important differences.

Decorator

Decorator adds behavior by wrapping an object instead of modifying the original object.

In backend development, Decorator often appears as middleware.

For example, an Http request may pass through several middleware layers before reaching the real handler:

1
2
3
4
5
6
Request
-> LoggingMiddleware
-> AuthenticationMiddleware
-> AuthorizationMiddleware
-> MetricsMiddleware
-> Handler

Each middleware wraps the next step. It can do something before calling the next middleware,
and it can also do something after the next middleware returns.

For example:

1
2
3
4
LoggingMiddleware
-> record request start
-> call next
-> record request result

The handler does not need to know whether logging, authentication, authorization, or metrics are enabled. These behaviors are added around the handler without modifying the handler itself.

The order matters. Authentication must run before Authorization because Authorization needs to know who the user is before it can check permissions.

Pros

  • Adds behavior without modifying the original object.
  • Keeps each behavior small and focused.
  • Can combine behaviors flexibly.
  • Commonly appears as middleware, interceptors, handlers, or filters.

Cons

  • Too many layers can make the call path harder to trace.
  • Decorator order matters, and the wrong order can cause bugs.
  • Some behavior depends on an earlier decorator. For example, Authorization depends on Authentication.

Facade

Facade provides a simple interface over a complex subsystem.

For example, a checkout flow may need several internal services:

1
2
3
4
5
InventoryService
PaymentGateway
CouponService
InvoiceService
NotificationService

Without a facade, the controller may need to call every service directly:

1
2
3
4
5
6
CheckoutController
-> check inventory
-> apply coupon
-> charge payment
-> create invoice
-> send notification

This makes the controller know too much about the checkout process.

With a facade, the controller can depend on one simpler entry point:

1
2
CheckoutFacade
-> checkout(order)

Internally, CheckoutFacade coordinates the subsystem:

1
2
3
4
5
6
CheckoutFacade.checkout(order)
-> InventoryService.reserve(order)
-> CouponService.apply(order)
-> PaymentGateway.charge(order)
-> InvoiceService.create(order)
-> NotificationService.send(order)

Facade is useful when we want to protect the rest of the system from module complexity.

The important point is that Facade does not remove the internal services. It only gives outside code a smaller and clearer API. Internal services should still keep their own responsibilities.

Pros

  • Gives a module a clear entry point.
  • Reduces coupling between modules.
  • Keeps AppState or bootstrap code cleaner.
  • Makes the subsystem easier to understand from outside.

Cons

  • Can become too large if it starts owning too much logic.
  • May hide important behavior if the API is too simple.
  • Can become a new god object if responsibilities are not controlled.
  • Can make debugging harder if errors are swallowed or hidden behind a generic API.

Flyweight

Flyweight reduces memory usage by sharing common state between many similar objects.

For example, if many objects repeat the same static data:

1
2
3
4
5
6
7
8
// If many events repeat the same sport metadata,
// such as sport_name, league_name, and provider_mapping,
// we can use Flyweight to share that metadata
// and reduce memory usage.
SportMetadata
-> sport_name
-> league_name
-> provider_mapping

We may share that common state instead of duplicating it in every object.

Flyweight is usually more relevant when there are many small objects and memory usage matters.

Pros

  • Reduces memory usage.
  • Avoids duplicated shared data.
  • Useful for large numbers of similar objects.

Cons

  • Adds complexity.
  • Shared state must be immutable or carefully controlled.
  • Usually unnecessary unless memory pressure is real.

Proxy

Proxy controls access to another object.

The proxy has the same interface as the real object. The caller thinks it is using the real object, but the request goes through the proxy first.

The proxy can decide whether to call the real object immediately, delay the call, return cached data, check permission, or forward the request to a remote service.

For example:

1
2
StoreRepository
-> find_latest()

The real repository may query SQL directly:

1
2
3
SqlStoreRepository
-> find_latest()
-> query SQL

But we can put a caching proxy in front of it:

1
2
3
4
5
6
7
CachedStoreRepositoryProxy
-> find_latest()
-> check Redis
-> if cache hit, return cached data
-> if cache miss, call SqlStoreRepository.find_latest()
-> write result back to Redis
-> return SQL data

The caller still uses the same interface:

1
repository.find_latest()

It does not need to know whether the data came from Redis or SQL.

Another example from Refactoring.Guru is a YouTube integration.

The real service can download video information from YouTube:

1
2
3
4
ThirdPartyYouTubeService
-> list_videos()
-> get_video_info(id)
-> download_video(id)

If the application asks for the same video many times, calling the real service every time is wasteful.

A proxy can implement the same interface and cache results:

1
2
3
4
5
6
7
8
9
10
11
CachedYouTubeProxy
-> list_videos()
-> return cached list if available
-> otherwise call ThirdPartyYouTubeService

-> get_video_info(id)
-> return cached info if available
-> otherwise call ThirdPartyYouTubeService

-> download_video(id)
-> download only if it is not already cached

The application still depends on the YouTube service interface. It can use either the real service or the cached proxy without changing its own code.

Pros

  • Controls access to expensive or sensitive objects.
  • Can support lazy initialization.
  • Can add caching, permission checks, or remote access handling.
  • Keeps the caller using the same interface.

Cons

  • Can hide network, cache, or permission behavior.
  • Adds another layer to debug.
  • If overused, it becomes hard to know when the real object is called.

How to choose

Structural patterns should be chosen based on the relationship problem we actually have.

Situation Better pattern
External API does not match our interface Adapter
Abstraction and implementation need to vary independently Bridge
Need to treat single objects and groups uniformly Composite
Need to add behavior without modifying the original object Decorator
Need a simple API over a complex subsystem Facade
Need to share repeated state across many objects Flyweight
Need to control access to another object Proxy

The main idea is that structure matters. If objects know too much about each other, the system becomes hard to change. Structural patterns give us different ways to compose objects while keeping boundaries clear.

The next article will introduce Behavioral patterns, which focus on how objects communicate and share responsibilities.

Reference