Architecture & Boundaries · Lesson 10

Strategy selection and explicit factories

Choose a pricing policy visibly rather than hiding global service lookup.

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

A strategy supplies interchangeable behavior through a meaningful contract, while a factory selects an implementation from explicit creation criteria. ShippingService depends on IShippingPolicy and does not decide how concrete policies are constructed. ShippingFactory translates the host's supported code into either a flat or per-unit policy. This is ordinary composition, not a service locator hidden inside the domain operation. The portable example makes the selection and arithmetic inspectable. Its external solution puts the policy contract at the center and concrete creation at the outside, with real project references checked independently of the runtime calls.

Walk through the portable program

The host asks for flat and unit policies and supplies each to a separate ShippingService. Both evaluate three positive units. FlatShipping returns the fixed fee three; UnitShipping multiplies three by two using checked arithmetic and returns six. The two output lines are flat:3 and per-unit:6. ShippingService validates positive units before delegation. The policy code strings select supported behavior only at composition, not on every internal method call. Keeping two service instances makes the selected strategy explicit rather than mutating one global registry between observations.

Build real physical dependencies

In the external Domain assembly, IShippingPolicy states the capability. Application uses it without naming concrete policy types. Infrastructure implements the policies and the factory, and Host performs selection. The actual reference checker confirms that this particular structure remains inward. A factory need not be a framework service container, and a strategy need not be an inheritance hierarchy full of shared mutable state. Small functions can also express strategies when their shape and ownership fit. Choose the representation that makes the real variation clear and the construction dependencies visible to the reader.

Pitfalls and unproved guarantees

Avoid allowing arbitrary strings to fall through silently to a default strategy when unsupported selection should fail. This factory throws an argument exception for an unknown code. Also avoid passing a universal resolver into ShippingService and letting it fetch whatever dependency it wants; that hides required collaborators and complicates tests. The sample does not implement real shipping tariffs, money rounding, or remote provider retries. Its integer fees are bounded teaching rules. Neither a factory name nor an injected interface establishes validity, performance, or resilience beyond the behavior actually implemented.

Practice one: isolate behavior

First evaluate one unit with both strategies and predict three for flat and two for per-unit. Then use zero through ShippingService and predict rejection before policy arithmetic. Hint: the service's input rule and a strategy's fee calculation are separate expectations. Add an unknown factory code and assert its specific failure rather than accepting any exception from the whole setup. Use independent valid constructions for later arithmetic cases so one failed selection cannot prevent the other strategy observations from running.

Practice two: test a boundary

For another exercise, add a capped policy with a documented threshold and maximum fee. Test just below, at, and above that threshold while leaving ShippingService unchanged. Hint: select cases that distinguish the cap from a simple multiplier. Then decide whether selection belongs in configuration parsing or in a use-case decision, and keep invalid configuration separate from a legitimate domain choice. Do not use reflection or a global mutable map merely to make a two-policy example appear extensible before an actual need for such a registry exists.

Review the evidence

Review strategy and factory design by naming the varying behavior, required contract, creation criteria, unsupported selections, and owner of each instance. The sample's two outputs establish arithmetic differences, while the external build establishes the chosen compile-time boundaries. These patterns are tools for visible composition, not obligations to add classes around every conditional. Keep the useful distinction: a strategy answers how this operation behaves, and a factory answers which supported implementation is created. If either abstraction conceals dependencies or makes a simple rule harder to inspect, reconsider its scope.

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

Strategy selection and explicit factories — 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 interface IShippingPolicy { int Fee(int units); }
Application/Model.csDownload file ↗
using System;
using System.Collections.Generic;
using System.Linq;
using BoundaryLab.Domain;

namespace BoundaryLab.Application;

public sealed class ShippingService(IShippingPolicy policy)
{
    public int Fee(int units) { if (units < 1) throw new ArgumentOutOfRangeException(nameof(units)); return policy.Fee(units); }
}
Infrastructure/Model.csDownload file ↗
using System;
using System.Collections.Generic;
using System.Linq;
using BoundaryLab.Domain;
using BoundaryLab.Application;

namespace BoundaryLab.Infrastructure;

public static class ShippingFactory
{
    public static IShippingPolicy Create(string code) => code switch
    {
        "flat" => new FlatShipping(), "unit" => new UnitShipping(),
        _ => throw new ArgumentException("Unknown policy", nameof(code))
    };
}
public sealed class FlatShipping : IShippingPolicy { public int Fee(int units) => 3; }
public sealed class UnitShipping : IShippingPolicy { public int Fee(int units) => checked(units * 2); }
Host/Program.csDownload file ↗
using System;
using System.Collections.Generic;
using System.Linq;
using BoundaryLab.Domain;
using BoundaryLab.Application;
using BoundaryLab.Infrastructure;

var flat = new ShippingService(ShippingFactory.Create("flat"));
var units = new ShippingService(ShippingFactory.Create("unit"));
Console.WriteLine($"flat:{flat.Fee(3)}");
Console.WriteLine($"per-unit:{units.Fee(3)}");
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: flat:3 per-unit:6 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 flat = new ShippingService(ShippingFactory.Create("flat"));
var units = new ShippingService(ShippingFactory.Create("unit"));
Console.WriteLine($"flat:{flat.Fee(3)}");
Console.WriteLine($"per-unit:{units.Fee(3)}");

public interface IShippingPolicy { int Fee(int units); }

public sealed class ShippingService(IShippingPolicy policy)
{
    public int Fee(int units) { if (units < 1) throw new ArgumentOutOfRangeException(nameof(units)); return policy.Fee(units); }
}

public static class ShippingFactory
{
    public static IShippingPolicy Create(string code) => code switch
    {
        "flat" => new FlatShipping(), "unit" => new UnitShipping(),
        _ => throw new ArgumentException("Unknown policy", nameof(code))
    };
}
public sealed class FlatShipping : IShippingPolicy { public int Fee(int units) => 3; }
public sealed class UnitShipping : IShippingPolicy { public int Fee(int units) => checked(units * 2); }
Open C# practice

C# practice output

flat:3
per-unit:6
Try this
Add a third policy through the same contract, and give unknown selection an explicit expected failure. A factory is not an excuse to inject a universal service locator.

Further reading