Architecture & Boundaries · Lesson 15

Local singleton state is not cluster coordination

Recognize a state-scope anti-pattern without pretending two objects are a deployed cluster.

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

Local object lifetime is not distributed coordination. This example creates two independently owned object graphs and labels them node-a and node-b to discuss state scope. They are ordinary objects in one portable process, not a deployed cluster or a load test. Each HitCounter owns a separate CounterState through its construction. Even a singleton registered in one dependency container is not automatically a unique object across multiple processes or deployments. The architecture question is where authoritative state lives and which failures or competing writers its contract must handle, not whether a class has a reassuring lifetime name.

Walk through the portable program

NodeFactory creates a new CounterState for every requested HitCounter. The host calls Hit twice only on nodeA, so its count becomes two. nodeB's independent state remains zero. Output is node-a:2 and node-b:0. This demonstrates ownership separation without launching any server, measuring traffic, or assuming a routing algorithm. The checked increment is a simple sequential operation and does not make concurrent calls safe. The same reference could be intentionally shared within one process, but that would be a different composition from the two fresh graphs shown here.

Build real physical dependencies

The external solution places the state in Domain, the application-facing counter in Application, and its construction helper outside. All five projects are real, and the reference checker verifies their declared directions. The host still runs as one console application; the project split does not create a cluster. In a real deployment, per-instance caches, counters, and gates need an explicit scope. Some data may be intentionally local and disposable, while quotas or globally unique decisions may require a shared authoritative store with appropriate atomic operations. That storage decision is separate from dependency direction.

Pitfalls and unproved guarantees

Do not add together imagined per-node limits and call them a global guarantee without specifying routing and coordination. Restarts can reset local state, multiple containers can each create their own singleton, and a shared cache can introduce its own consistency and availability tradeoffs. Thread safety inside one object also differs from correctness across processes. The sample provides no synchronization and no durability. Moving every value into a remote cache is not an automatic solution either; define authority, invalidation, failure behavior, and performance requirements before choosing a local or shared mechanism.

Practice one: isolate behavior

First give both HitCounter objects the same explicitly constructed CounterState and predict that both read two after two hits through one reference. Hint: the changed observation comes from shared ownership inside this process, not from new cluster coordination. Restore separate construction and hit each once, predicting one for each. Use these cases to distinguish object lifetime from traffic distribution, and avoid claiming that labels or multiple local variables establish real multi-instance deployment behavior.

Practice two: test a boundary

For another exercise, write a contract for a global quota that must not exceed a limit across real instances. Identify the required atomic storage operation and the outcome when that store is unavailable. Hint: a separate get followed by set is not automatically a safe shared increment. Plan an integration test using actual competing clients and a real storage adapter, keeping its evidence separate from the portable counters. Then identify a metric that may remain approximate and local, explaining why that weaker requirement can justify a different architecture.

Review the evidence

Review stateful service design by naming the lifetime owner, sharing scope, authoritative source, synchronization boundary, and restart behavior. The example proves only the deterministic relationship between two local graphs and their counters. Its external reference checks prove a source dependency policy, not capacity or cluster resilience. For production load, collect measurements on the actual single instance and actual cluster while testing correctness under the expected failure model. Architecture should make those scopes explicit so a convenient local singleton is never mistaken for a distributed consistency mechanism.

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

Local singleton state is not cluster coordination — 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 sealed class CounterState { public int Count { get; private set; } public void Hit() => Count = checked(Count + 1); }
Application/Model.csDownload file ↗
using System;
using System.Collections.Generic;
using System.Linq;
using BoundaryLab.Domain;

namespace BoundaryLab.Application;

public sealed class HitCounter(CounterState state) { public int Count => state.Count; public void Hit() => state.Hit(); }
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 NodeFactory { public static HitCounter Create() => new(new CounterState()); }
Host/Program.csDownload file ↗
using System;
using System.Collections.Generic;
using System.Linq;
using BoundaryLab.Domain;
using BoundaryLab.Application;
using BoundaryLab.Infrastructure;

var nodeA = NodeFactory.Create();
var nodeB = NodeFactory.Create();
nodeA.Hit(); nodeA.Hit();
Console.WriteLine($"node-a:{nodeA.Count}");
Console.WriteLine($"node-b:{nodeB.Count}");
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: node-a:2 node-b:0 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 nodeA = NodeFactory.Create();
var nodeB = NodeFactory.Create();
nodeA.Hit(); nodeA.Hit();
Console.WriteLine($"node-a:{nodeA.Count}");
Console.WriteLine($"node-b:{nodeB.Count}");

public sealed class CounterState { public int Count { get; private set; } public void Hit() => Count = checked(Count + 1); }

public sealed class HitCounter(CounterState state) { public int Count => state.Count; public void Hit() => state.Hit(); }

public static class NodeFactory { public static HitCounter Create() => new(new CounterState()); }
Open C# practice

C# practice output

node-a:2
node-b:0
Try this
Treat this as two independently owned object graphs, not a real cluster test. Explain what happens to a per-process counter across restarts and multiple deployed instances.

Further reading