Available for work
Ankit Ranjan
Back to Deep Dives

The Decorator Pattern in Dart — Wrapping Behaviour Instead of Subclassing It

Want to add one thing to an object without breeding a subclass for every combination? Wrap it. The decorator pattern hands you the same interface back, plus a little extra — and in Flutter, you've been stacking decorators since your first widget tree.

June 4, 2026 7 topics 7 quiz questions
Share:
1

The problem — adding one thing without a subclass for every mix

You have a piece of text on screen. Now you want some padding around it. Fine — but then you want a background behind it too. And on one screen, a bit of transparency. The text never changes. You're only stacking on extras, one at a time.

The first instinct is a subclass per look.

class PaddedText extends Text { ... }
class BorderedText extends Text { ... }
class PaddedBorderedText extends Text { ... }
// and one more class every time you mix two features
It works for the first two. Then someone wants padded-and-transparent, and bordered-and-transparent, and all three at once. Each feature you add doesn't append a class — it doubles the table, because every existing combination now needs a transparent twin.

That's the trap: features that should combine freely are welded into a class name. Three independent extras shouldn't cost you eight classes. They should cost you three.

The thing you want to vary — the decoration — is tangled into the thing that doesn't, the text. Let's pull them apart, the same way Strategy did for behaviour.

2

The pattern — wrap an object in one that shares its interface

Here's the move. Instead of subclassing the component, you wrap it in another object that has the same interface, adds its one thing, then hands the call down to the wrapped child.

That sentence carries the whole pattern. Same interface, so the wrapper is a drop-in for the thing it wraps. Adds its one thing. Then delegates — calls the child to do the rest.

Same interface in, same interface out — plus one thingDecoratorhas a childadd my piecechild.build()Componentbuild()delegatesbuild()one interfaceboth expose itThe decorator IS a component too — so it can wrap, or be wrapped, without anyone noticing.

In code, the component and the decorator both implement one interface. The decorator holds a child of that same type and adds a step around the delegated call.

abstract interface class Greeter {
  String greet();
}

class PlainGreeter implements Greeter {
  @override
  String greet() => 'hello';
}

// A decorator: same interface, holds a Greeter, adds one thing
class Shout implements Greeter {
  final Greeter inner;        // the wrapped child
  Shout(this.inner);

  @override
  String greet() => '\${inner.greet()}!';   // delegate, then add
}

class Loud implements Greeter {
  final Greeter inner;
  Loud(this.inner);

  @override
  String greet() => inner.greet().toUpperCase();
}
Because every piece is a Greeter, you can nest them — and because each only adds one thing, the order is yours to choose.

Greeter g = Loud(Shout(PlainGreeter()));
print(g.greet());        // HELLO!
No subclass of PlainGreeter was written. The base object never knew it was being dressed up.

3

Flutter is decorators all the way down

Here's the part that lands. You already use this pattern constantly — every time you wrap a widget in another widget that takes a single child.

Look at the cast. Each of these takes exactly one child and adds exactly one thing.
Padding — adds empty space around its child.
Center — positions its child in the middle.
DecoratedBox — paints a background, border, or gradient behind its child.
Opacity — makes its child see-through.
SizedBox — pins its child to a fixed width and height.

Each layer wraps one child and adds one thingPaddingadds spacingDecoratedBoxadds a backgroundTextthe actual contentoutermostinnermostPadding(child: DecoratedBox(child: Text(...))) — wraps read outside-in, render inside-out.

None of them subclass Text to gain a feature. They each hold a child and add their one job around it. That's the decorator pattern, named in widget form.

const Padding(
  padding: EdgeInsets.all(16),
  child: DecoratedBox(
    decoration: BoxDecoration(color: Colors.amber),
    child: Text('hello'),
  ),
)
A wrapping widget is a decorator with a Flutter accent: it shares the Widget interface, takes a single child, adds one visual or layout concern, and leaves the child untouched. Once you see it, you can't unsee it.

4

Composing wrappers — the onion, and why order matters

Decorators nest. Wrap a wrapper in a wrapper and you get an onion of widgets, each layer adding its slice. You read it outside-in; it renders inside-out.

Padding(                          // outer: space around everything
  padding: const EdgeInsets.all(16),
  child: DecoratedBox(            // middle: background fill
    decoration: const BoxDecoration(color: Colors.amber),
    child: const Text('hello'),  // inner: the content
  ),
)
And here's the catch that bites people: order changes the result. These two are not the same picture.

// padding OUTSIDE the colour → amber box, gap around the box
Padding(
  padding: const EdgeInsets.all(16),
  child: ColoredBox(color: Colors.amber, child: text),
)

// padding INSIDE the colour → amber fills the padding too
ColoredBox(
  color: Colors.amber,
  child: Padding(
    padding: const EdgeInsets.all(16),
    child: text,
  ),
)
In the first, the padding sits outside the colour, so the amber stops at the box edge and there's a transparent gap around it. In the second, the colour wraps the padding, so the amber bleeds right out to fill that 16-pixel border too.

Same two decorators, swapped order, different screen. That's not a Flutter quirk — it's the nature of wrapping. Each layer acts on whatever the layer beneath it produced, so the sequence is part of the meaning.

Rule of thumb: read a wrapped tree from the inside out. The innermost child is your content; each layer outward is one transformation applied to everything so far.

5

Decorator versus inheritance — composition wins again

Back in the Strategy episode we met the slogan favour composition over inheritance. Decorator is that same instinct, pointed at appearance and behaviour you bolt onto an object rather than behaviour you swap inside it.

Inheritance bakes the extras into the type. A padded, bordered, transparent text needs a class that is all three at once — and the next reader who wants padded-and-bordered-but-opaque needs yet another. Features that ought to combine freely become a fixed menu of pre-mixed classes.

A class per combination, or a wrapper per featureA subclass for every mixPaddedTextBorderedTextOpaqueTextPaddedBorderedBorderedOpaquePaddedOpaqueEvery feature you adddoubles the tableThree wrappers you stackPaddingDecoratedBoxOpacitystack any subset, any orderNew feature?One more wrapper, not a row

Decorator asks the composition question instead: what small parts is this dressed in? Three reusable wrappers — Padding, DecoratedBox, Opacity — stack in any combination you like. Three features cost three classes, not eight, and a fourth feature adds one more wrapper, not a fresh row of pre-mixed types.

The trade Strategy and Decorator share: Strategy swaps the behaviour inside an object; Decorator stacks behaviour around one. Both refuse to model variation with a subclass per case, because that count always multiplies and composition only ever adds.

6

Decorators beyond widgets — streams, iterables, and a logging wrapper

Decorator isn't a layout trick. The moment you see "same interface, wraps one of itself, adds a step," you spot it across the language.

Streams. Every transformation on a stream returns a new stream that wraps the old one. .map and .where don't mutate the source — they hand back a fresh Stream with the same interface, adding one step.

final result = source
    .where((x) => x.isOdd)   // wraps source, filters
    .map((x) => x * 10);     // wraps that, transforms
Each stage wraps the stream before it, adds one stepsource[1,2,3,4]wraps source.where(odd)[1,3]wraps where.map(x10)[10,30]result10, then 30source.where(isOdd).map((x) => x * 10)Same Stream interface at every stage. map and where each return a new stream that wraps the last —lazy, so nothing flows until something listens. The widget onion, laid out sideways.

Iterables. The same story, lazily. list.map(...) and list.where(...) return wrapping iterables that compute nothing until you walk them. Each is a thin Iterable wrapping the one before — a decorator chain you build with dots.

A logging wrapper. The trick works on your own interfaces too. Say you have a repository and want to log every call without touching the real one. Wrap it.

abstract interface class UserRepo {
  Future<User> fetch(int id);
}

class LoggingRepo implements UserRepo {
  final UserRepo inner;        // same interface, wrapped
  LoggingRepo(this.inner);

  @override
  Future<User> fetch(int id) async {
    print('fetch(\$id) called');   // add a step
    final user = await inner.fetch(id);  // delegate
    print('fetch(\$id) returned');
    return user;
  }
}
The rest of your app still depends on UserRepo. It can't tell whether it's holding the real repo or one dressed in logging — which is exactly the point. You can stack a caching wrapper on top of that, and a retry wrapper on top of that, each unaware of the others.

7

When to decorate, when to just configure

Decorator is sharp, but it isn't free, and reaching for it reflexively buries you in tree depth.

The honest question is: does the framework already give you a property for this? A great deal of what looks like decoration is just configuration. You don't wrap a Text in a colour widget to recolour it — you pass a TextStyle.

// configure: one widget, a property
Text('hi', style: TextStyle(color: Colors.red))

// decorate: a wrapper that adds a separate concern
Padding(padding: ..., child: Text('hi'))
Rule of thumb: if the extra is a setting on the object you already have, configure it. If it's a genuinely separate concern that should stack with others and apply to any child, decorate it.

Where decoration earns its keep:
• The concern is independent and reusable — spacing, a background, opacity, logging, caching — and applies to any child, not just this one.
• You want to combine concerns freely without a class per combination.
• You want to add the concern without modifying the thing you're wrapping.

Where it hurts:
Deep trees. Six nested wrappers for one button is hard to read, and in Flutter every layer is a real element with a build cost. Reach for a property, a single richer widget like Container (which bundles common decorators for you), or pull the stack into a named widget of your own.
Order confusion. Because wrapping order changes the result, a tall onion can hide bugs that a flat configuration wouldn't.

So you've now met two faces of composition. Strategy swaps the behaviour inside an object; Decorator stacks behaviour around it. Both beat a subclass for every case — and both are already in your hands the moment you wrap one widget in another.

Test your understanding

7 questions

Seven questions on the decorator pattern — the problem it solves, how wrapping shares an interface, why order matters, and where it shows up across Flutter and Dart.

Search

Loading search...