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.
Decorators and adapters solve different problems — physical dependency lab
Set up the SDK project
- Save every file at its exact relative path under a fresh directory.
- Use a .NET 10 SDK. Run dotnet build ArchitectureLab.slnx.
- Run dotnet run --project Host/Host.csproj --no-build.
- From the solution directory run dotnet run --project ArchitectureCheck/ArchitectureCheck.csproj --no-build -- .
Download complete SDK project ↗
Project files
ArchitectureLab.slnx
Download 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.csproj
Download 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.csproj
Download 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.csproj
Download 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.csproj
Download 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.csproj
Download 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.cs
Download 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.cs
Download 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.cs
Download 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.cs
Download 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.cs
Download 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; }
}
C# practice output
cost:6 calls:1
Call a second valid quote, then a rejected one. Decide whether the counter means attempts or successful inner calls before asserting it.