Architecture & Boundaries · Lesson 11

Decorators and adapters solve different problems

Translate a legacy API then add an observable boundary concern by composition.

External .NET 10 SDK; five real projects with explicit ProjectReference items. Not executed by the single-file .NET 9 playground.

Read or download the complete SDK project, then follow its setup on your computer. The separate C# companion practice below runs in SharpRune’s .NET 9 console environment.

The architectural boundary

An adapter translates between an existing API and the contract an application wants to consume. A decorator implements that same consumer contract while adding behavior around another implementation. Here LegacyCostAdapter translates LegacyPricer's Compute method into ICost. CountingCost wraps any ICost and records successful completed calls. The portable example composes both directly, and the external solution gives Application ownership of the consumer port while Infrastructure contains the legacy-specific translation and wrapper. These are real BCL-only classes, not fake namespaces standing in for a payment SDK or a remote service.

Walk through the portable program

The host constructs a legacy pricer, wraps it in the adapter, then wraps that adapter in CountingCost. Quote of three reaches the adapter, passes the positive-unit rule, and calls legacy multiplication by two. Six returns through the wrapper, which increments Calls after the inner operation succeeds. The host prints cost:6 and calls:1. The counter therefore means successful inner completions, not every attempted invocation. That placement is part of the observable contract, and an exception before the increment would leave the count unchanged.

Build real physical dependencies

The external Application assembly contains only ICost, the shape its consumer expects. Infrastructure knows about the legacy method and implements the adapter and decorator. Domain supplies the unit-validity rule, and Host composes the chain. A runtime call can pass through several wrappers while all compile-time references still obey the checked direction. The separate project tool verifies that graph, not the semantic compatibility of arbitrary wrappers. Each adapter still needs tests for its actual translation, and each decorator needs a clear statement of what extra effects it introduces or preserves.

Pitfalls and unproved guarantees

Do not confuse adding a concern with changing the underlying domain meaning accidentally. A retrying decorator can duplicate an effect, a caching decorator can alter freshness, and a metrics decorator can count attempts or successes differently. This example has no network call or externally visible charging effect, so it makes none of those guarantees. It also uses synchronous sequential state and is not a thread-safe counter. Ordering matters when wrappers have effects. Keep the concern and its failure policy explicit instead of assuming every class implementing the same interface is behaviorally interchangeable in all contexts.

Practice one: isolate behavior

First call Quote twice with valid quantities and predict Calls two. Then call zero through the same chain and predict the validation exception with the successful-call count still two. Hint: the increment happens after the inner call returns. Observe both the exception and the retained count so a broad failure handler cannot conceal a misplaced increment. Add a separate fake ICost that deliberately throws, using a fresh CountingCost instance, and predict zero successful calls without invoking any real external provider.

Practice two: test a boundary

For another exercise, write a decorator that records attempted quantities before delegation and name that metric differently from successful calls. Test success and failure independently. Hint: distinguish recording an attempt from promising that work completed. Then adapt a second legacy API with a differently named method to the same ICost port, preserving the consumer's units and failure meaning. Keep translation tests separate from wrapper tests; combining everything into one large green output can hide whether the adapter or the added concern is responsible for a disagreement.

Review the evidence

Review adapters by checking representation, units, failures, and the consumer contract they satisfy. Review decorators by checking preserved behavior, added effects, ordering, and what happens when the inner operation fails. The sample demonstrates both patterns with one deterministic chain, while the external assemblies verify where their dependencies live. It does not demonstrate payment delivery, retry safety, or production observability infrastructure. Thoughtful composition makes those boundaries clearer; merely nesting interface implementations does not guarantee their combined semantics are correct or appropriate for an effectful application.

External .NET 10 SDK; five real projects with explicit ProjectReference items. Not executed by the single-file .NET 9 playground.

Decorators and adapters solve different problems — physical dependency lab

Set up the SDK project

  1. Save every file at its exact relative path under a fresh directory.
  2. Use a .NET 10 SDK. Run dotnet build ArchitectureLab.slnx.
  3. Run dotnet run --project Host/Host.csproj --no-build.
  4. From the solution directory run dotnet run --project ArchitectureCheck/ArchitectureCheck.csproj --no-build -- .

Download complete SDK project ↗

Project files

ArchitectureLab.slnxDownload file ↗
<Solution>
  <Project Path="Domain/Domain.csproj" />
  <Project Path="Application/Application.csproj" />
  <Project Path="Infrastructure/Infrastructure.csproj" />
  <Project Path="Host/Host.csproj" />
  <Project Path="ArchitectureCheck/ArchitectureCheck.csproj" />
</Solution>
Domain/Domain.csprojDownload file ↗
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <LangVersion>14.0</LangVersion>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>
</Project>
Application/Application.csprojDownload file ↗
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <LangVersion>14.0</LangVersion>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>
  <ItemGroup>
    <ProjectReference Include="../Domain/Domain.csproj" />
  </ItemGroup>
</Project>
Infrastructure/Infrastructure.csprojDownload file ↗
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <LangVersion>14.0</LangVersion>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>
  <ItemGroup>
    <ProjectReference Include="../Domain/Domain.csproj" />
    <ProjectReference Include="../Application/Application.csproj" />
  </ItemGroup>
</Project>
Host/Host.csprojDownload file ↗
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <LangVersion>14.0</LangVersion>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>
  <ItemGroup>
    <ProjectReference Include="../Domain/Domain.csproj" />
    <ProjectReference Include="../Application/Application.csproj" />
    <ProjectReference Include="../Infrastructure/Infrastructure.csproj" />
  </ItemGroup>
</Project>
ArchitectureCheck/ArchitectureCheck.csprojDownload file ↗
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <LangVersion>14.0</LangVersion>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>
</Project>
Domain/Model.csDownload file ↗
using System;
using System.Collections.Generic;
using System.Linq;

namespace BoundaryLab.Domain;

public static class UnitRule { public static bool Valid(int units) => units > 0; }
Application/Model.csDownload file ↗
using System;
using System.Collections.Generic;
using System.Linq;
using BoundaryLab.Domain;

namespace BoundaryLab.Application;

public interface ICost { int Quote(int units); }
Infrastructure/Model.csDownload file ↗
using System;
using System.Collections.Generic;
using System.Linq;
using BoundaryLab.Domain;
using BoundaryLab.Application;

namespace BoundaryLab.Infrastructure;

public sealed class LegacyPricer { public int Compute(int quantity) => checked(quantity * 2); }
public sealed class LegacyCostAdapter(LegacyPricer legacy) : ICost
{
    public int Quote(int units) { if (!UnitRule.Valid(units)) throw new ArgumentOutOfRangeException(nameof(units)); return legacy.Compute(units); }
}
public sealed class CountingCost(ICost inner) : ICost
{
    public int Calls { get; private set; }
    public int Quote(int units) { int result = inner.Quote(units); Calls++; return result; }
}
Host/Program.csDownload file ↗
using System;
using System.Collections.Generic;
using System.Linq;
using BoundaryLab.Domain;
using BoundaryLab.Application;
using BoundaryLab.Infrastructure;

var counted = new CountingCost(new LegacyCostAdapter(new LegacyPricer()));
Console.WriteLine($"cost:{counted.Quote(3)}");
Console.WriteLine($"calls:{counted.Calls}");
ArchitectureCheck/Program.csDownload file ↗
using System.Xml.Linq;
string root = Path.GetFullPath(args.Length == 0 ? "." : args[0]);
var allowed = new Dictionary<string, HashSet<string>>(StringComparer.Ordinal)
{
    ["Domain"] = new(),
    ["Application"] = new() { "Domain" },
    ["Infrastructure"] = new() { "Domain", "Application" },
    ["Host"] = new() { "Domain", "Application", "Infrastructure" }
};
int violations = 0;
foreach (var item in allowed)
{
    string path = Path.Combine(root, item.Key, item.Key + ".csproj");
    var document = XDocument.Load(path);
    foreach (var reference in document.Descendants().Where(element => element.Name.LocalName == "ProjectReference"))
    {
        string include = (string?)reference.Attribute("Include") ?? throw new InvalidOperationException("Missing Include");
        string targetPath = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(path)!, include));
        string target = Path.GetFileNameWithoutExtension(targetPath);
        string expectedPath = Path.Combine(root, target, target + ".csproj");
        if (!item.Value.Contains(target) || targetPath != expectedPath)
        {
            violations++;
            Console.WriteLine($"violation:{item.Key}->{target}");
        }
    }
}
Console.WriteLine($"projects:{allowed.Count}");
Console.WriteLine($"violations:{violations}");
return violations == 0 ? 0 : 1;

Expected result

Host output: cost:6 calls:1 Physical ProjectReference policy check output: projects:4 violations:0 Both commands exit zero. The checker inspects the four business-project files; its own tooling project is outside that policy. It checks declared project-reference direction, not all runtime behavior or package dependencies.

Use the listed SDK and dependencies for this project. Open C# practice runs the separate portable exercise.

C# companion practice

using System;
using System.Collections.Generic;
using System.Linq;

var counted = new CountingCost(new LegacyCostAdapter(new LegacyPricer()));
Console.WriteLine($"cost:{counted.Quote(3)}");
Console.WriteLine($"calls:{counted.Calls}");

public static class UnitRule { public static bool Valid(int units) => units > 0; }

public interface ICost { int Quote(int units); }

public sealed class LegacyPricer { public int Compute(int quantity) => checked(quantity * 2); }
public sealed class LegacyCostAdapter(LegacyPricer legacy) : ICost
{
    public int Quote(int units) { if (!UnitRule.Valid(units)) throw new ArgumentOutOfRangeException(nameof(units)); return legacy.Compute(units); }
}
public sealed class CountingCost(ICost inner) : ICost
{
    public int Calls { get; private set; }
    public int Quote(int units) { int result = inner.Quote(units); Calls++; return result; }
}
Open C# practice

C# practice output

cost:6
calls:1
Try this
Call a second valid quote, then a rejected one. Decide whether the counter means attempts or successful inner calls before asserting it.

Further reading