Decorator Pattern: Wrapping Objects with Style

java java21 scala scala2 scala3 kotlin design-patterns structural-patterns decorator-pattern

Imagine your platform needs to export financial reports. Some reports are exported as plain text. Some need to be compressed because they are huge. Some contain sensitive data and must be encrypted. Some need an audit trail for compliance. Any given report might need one of these behaviors, all of them, or none at all - and that combination is often only known at runtime, based on user settings or report type.

If you try to solve this with subclassing, you quickly end up with CompressedReportExporter, EncryptedReportExporter, CompressedEncryptedReportExporter, AuditedCompressedEncryptedReportExporter, and so on. That is the classic subclass explosion the Decorator pattern exists to prevent: instead of baking every combination into the class hierarchy, you wrap a base object with small, focused decorators that can be composed in any order.

The Problem: One Behavior, Many Optional Add-ons

The base requirement is simple: export report content as a string. The complexity comes from the optional, independently toggleable behaviors layered on top:

  • Compression for large reports.
  • Encryption for reports containing sensitive data.
  • Audit logging for compliance-sensitive exports.

None of these should require touching the others, and none should require the base exporter to know they exist.

Key Concepts

Concept In this example Why it matters
Component interface ReportExporter The stable contract every exporter (base or decorated) implements
Concrete component PlainTextReportExporter The base object that does the real, minimal work
Base decorator ReportExporterDecorator Holds the wrapped component and forwards calls to it
Concrete decorators CompressionDecorator, EncryptionDecorator, AuditLoggingDecorator Each adds one independent behavior around the wrapped exporter
Client ReportPublishingService Builds a decorator chain from feature flags, unaware of how many decorators are stacked

Real Use Case: Publishing Reports With Configurable Compliance Rules

Picture a reporting platform used by finance and operations teams. Every export request carries a few independent requirements:

  1. Large reports (quarterly filings, transaction logs) should be compressed before they leave the service.
  2. Reports containing customer or financial data must be encrypted at rest and in transit.
  3. Regulated exports must be logged for audit purposes - who exported what, and how much data left the system.

Crucially, these requirements are orthogonal: a small internal report might need none of them, while a large, sensitive, regulated report needs all three at once. The Decorator pattern lets ReportPublishingService build exactly the right chain for each request, at runtime, using simple boolean flags - without ever creating a subclass like CompressedEncryptedAuditedReportExporter.

Component Walkthrough: What Each Part Is Doing

  1. ReportExporter (Component Interface) defines the single operation every exporter must support: exportReport(content). Both the base exporter and every decorator implement this same interface, which is what allows them to be swapped and stacked transparently.
  2. PlainTextReportExporter (Concrete Component) is the simplest possible implementation: it returns the content unchanged. This is the object every decorator chain eventually wraps.
  3. ReportExporterDecorator (Base Decorator) is an abstract class that stores a reference to the wrapped ReportExporter (the delegate). It exists purely to avoid repeating that plumbing in every concrete decorator.
  4. CompressionDecorator calls the delegate first, then wraps the result with size metadata - demonstrating a decorator that transforms output after delegating.
  5. EncryptionDecorator also transforms the output (via a reversible XOR cipher encoded as Base64) and additionally exposes a decrypt helper, showing that a decorator can carry extra capabilities beyond the shared interface.
  6. AuditLoggingDecorator calls the delegate and returns its result unchanged, but records a side-effect (an audit entry) - demonstrating that decorators do not have to transform data to be useful.
  7. ReportPublishingService (Client) builds the decorator chain dynamically from compress, encrypt, and audit flags, then calls exportReport once on the fully assembled chain.

Request Flow: Stacking Decorators at Runtime

For a report that needs compression, encryption, and an audit trail, the chain is assembled like this:

  1. Start with PlainTextReportExporter (the concrete component).
  2. Wrap it in CompressionDecorator if the report is large.
  3. Wrap that in EncryptionDecorator if the report is sensitive.
  4. Wrap that in AuditLoggingDecorator if the export must be logged.
  5. Call exportReport(content) once on the outermost decorator - each layer delegates inward, then applies its own behavior on the way back out.

Because every layer honors the same ReportExporter contract, the order of decorators can change the result (compress-then-encrypt differs from encrypt-then-compress), which is a deliberate part of the pattern: composition order is a design decision, not an accident.

The Solution: Decorator Across JVM Languages

Below is the target interface, the base decorator, and one concrete decorator (CompressionDecorator) in Java 21, Kotlin, Scala 2, and Scala 3. The full source for all three decorators is linked at the end of this post.

public sealed interface ReportExporter permits PlainTextReportExporter, ReportExporterDecorator {
    String exportReport(String content);
}

abstract sealed class ReportExporterDecorator implements ReportExporter
        permits CompressionDecorator, EncryptionDecorator, AuditLoggingDecorator {
    protected final ReportExporter delegate;

    protected ReportExporterDecorator(ReportExporter delegate) {
        this.delegate = delegate;
    }
}

final class CompressionDecorator extends ReportExporterDecorator {
    public CompressionDecorator(ReportExporter delegate) {
        super(delegate);
    }

    @Override
    public String exportReport(String content) {
        String exported = delegate.exportReport(content);
        return "COMPRESSED[" + exported.length() + "]:" + exported;
    }
}

View in repository

sealed interface ReportExporter {
    fun exportReport(content: String): String
}

sealed class ReportExporterDecorator(
    protected val delegate: ReportExporter,
) : ReportExporter

class CompressionDecorator(
    delegate: ReportExporter,
) : ReportExporterDecorator(delegate) {
    override fun exportReport(content: String): String {
        val exported = delegate.exportReport(content)
        return "COMPRESSED[${exported.length}]:$exported"
    }
}

View in repository

trait ReportExporter {
  def exportReport(content: String): String
}

abstract class ReportExporterDecorator(protected val delegate: ReportExporter)
    extends ReportExporter

class CompressionDecorator(delegate: ReportExporter) extends ReportExporterDecorator(delegate) {
  override def exportReport(content: String): String = {
    val exported = delegate.exportReport(content)
    s"COMPRESSED[${exported.length}]:$exported"
  }
}

View in repository

trait ReportExporter:
  def exportReport(content: String): String

abstract class ReportExporterDecorator(protected val delegate: ReportExporter)
    extends ReportExporter

class CompressionDecorator(delegate: ReportExporter) extends ReportExporterDecorator(delegate):
  override def exportReport(content: String): String =
    val exported = delegate.exportReport(content)
    s"COMPRESSED[${exported.length}]:$exported"

View in repository

Scala Developer Mental Model

  • In Java 21, sealed interfaces and classes with permits make the decorator hierarchy exhaustive and explicit: the compiler knows exactly which classes can implement ReportExporter or extend ReportExporterDecorator.
  • In Scala 2/3, traits and abstract classes give you the same shape with less ceremony. Because there is no built-in equivalent of permits, the hierarchy is closed by convention rather than by the compiler (unless you add sealed trait).
  • In Kotlin, sealed interface plus a sealed class base decorator mirrors Java’s exhaustiveness guarantees while keeping constructor boilerplate minimal thanks to primary constructor properties.

Comparison: Java 21 vs Scala 2 vs Scala 3 vs Kotlin

Language Component contract Base decorator Composability
Java 21 sealed interface + permits abstract sealed class Explicit constructor chaining via super(delegate)
Scala 2 trait abstract class with a protected val Constructor chaining via extends ReportExporterDecorator(delegate)
Scala 3 trait (colon syntax) abstract class with a protected val Same as Scala 2, less boilerplate
Kotlin sealed interface sealed class with primary constructor property Very concise; sealed is implicitly abstract

Testing the Decorator: Proving Stackability

The most important test for a decorator is not any single decorator in isolation - it is proving that decorators compose: the final result reflects every layer, in the order they were applied, and behaviors like audit logging can be verified independently of the data transformation.

@Test
void shouldStackMultipleDecoratorsInAnyOrder() {
    List<String> auditLog = new ArrayList<>();
    ReportExporter exporter = new AuditLoggingDecorator(
            new EncryptionDecorator(new CompressionDecorator(new PlainTextReportExporter()), 7),
            auditLog);

    String exported = exporter.exportReport(REPORT);

    assertTrue(exported.startsWith("ENCRYPTED:"));
    assertEquals(1, auditLog.size());
}

View full test file

@Test
fun shouldStackMultipleDecoratorsInAnyOrder() {
    val auditLog = mutableListOf<String>()
    val exporter: ReportExporter =
        AuditLoggingDecorator(
            EncryptionDecorator(CompressionDecorator(PlainTextReportExporter()), 7),
            auditLog,
        )

    val exported = exporter.exportReport(report)

    assertTrue(exported.startsWith("ENCRYPTED:"))
    assertEquals(1, auditLog.size)
}

View full test file

@Test
@DisplayName("Decorators should stack in any order without a subclass explosion")
def shouldStackMultipleDecoratorsInAnyOrder(): Unit = {
  val auditLog = ArrayBuffer.empty[String]
  val exporter: ReportExporter = new AuditLoggingDecorator(
    new EncryptionDecorator(new CompressionDecorator(new PlainTextReportExporter), 7),
    auditLog
  )

  val exported = exporter.exportReport(report)

  assertTrue(exported.startsWith("ENCRYPTED:"))
  assertEquals(1, auditLog.size)
}

View full test file

@Test
@DisplayName("Decorators should stack in any order without a subclass explosion")
def shouldStackMultipleDecoratorsInAnyOrder(): Unit =
  val auditLog = ArrayBuffer.empty[String]
  val exporter: ReportExporter = AuditLoggingDecorator(
    EncryptionDecorator(CompressionDecorator(PlainTextReportExporter()), 7),
    auditLog
  )

  val exported = exporter.exportReport(report)

  assertTrue(exported.startsWith("ENCRYPTED:"))
  assertEquals(1, auditLog.size)

View full test file

Each language’s test suite also verifies the simpler building blocks - a plain export leaves content untouched, compression adds size metadata, encryption is reversible with the correct key, and audit logging records an entry without altering the exported content - plus a realistic scenario where ReportPublishingService assembles the whole chain from compress / encrypt / audit flags, exactly like a production feature-flag-driven export pipeline would.

When to Use the Decorator Pattern

Use a decorator when:

  1. You need to add optional, independently combinable behaviors to an object.
  2. The number of possible combinations would otherwise force a subclass per combination.
  3. You want to add or remove a behavior without touching the component’s core logic.
  4. Behaviors should be composable and reorderable at runtime, not fixed at compile time.

Avoid it when:

  1. There is only one fixed combination of behaviors - a single class is simpler.
  2. The “behaviors” actually change the object’s core identity or contract, not just add to it (that is closer to Strategy or plain inheritance).
  3. Deep decorator chains become hard to debug; consider a pipeline or middleware abstraction instead once the chain grows very long.

Code Samples

All examples in this post are available in the repository:

Implementation files:

Test files:


This is part of our Design Patterns in JVM Languages series. Check out the full design patterns guide for more patterns and interview preparation.