Architecture & Boundaries · Lesson 4

Hexagonal inbound and outbound ports

Separate the driving operation from the capacity adapter it drives.

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

Hexagonal Architecture focuses on an application communicating through purposeful ports and interchangeable adapters. IReserve is the inbound operation driven by this host, while ICapacity is an outbound capability used during that operation. ReserveService implements the application behavior and MemoryCapacity is one adapter. The number of ports is driven by meaningful conversations, not a requirement to create exactly six. This example is a bounded sequential model. The portable source demonstrates real method calls, and the external solution places contracts and adapters in separate assemblies so the reference policy can also be verified.

Runtime calls travel from a driving adapter through the use case to a driven adapter. Code dependencies from both adapters point to interfaces owned by the application core.View full size ↗
Separate runtime call flow from source-code dependency direction. The application owns its inbound and outbound contracts.
Read the diagram

The upper panel follows runtime calls: a driving adapter invokes an inbound port, the use case calls an outbound port, and a driven adapter performs the external action. A port describes an interaction at the application boundary.

The lower panel describes compile-time dependencies. The driving adapter uses the inbound interface; the driven adapter implements the outbound interface. Both interfaces belong to the application core. The latter dependency points inward even though the runtime call travels outward.

Adapters translate protocols and data at a boundary. A console host or HTTP endpoint can drive the same application use case; an in-memory or persistent implementation can serve an outbound port. Substitution requires preserving behavior and failure semantics, not merely matching a method signature.

Illustration created with ImageGen for SharpRune.

Walk through the portable program

MemoryCapacity starts with three free units. The first request for two satisfies the domain's positive and available-capacity rule, so the adapter commits it and one unit remains. The second request for two exceeds that remaining unit and returns false without another commit. The lines are first:True, excess:False, and remaining:1. The host drives IReserve, while the service queries and commands its ICapacity collaborator. Those inbound and outbound roles describe who initiates each conversation; they do not imply that an adapter must involve HTTP, a database, or another operating-system process.

Build real physical dependencies

In the external project, Application defines both ports and ReserveService, Domain supplies the capacity rule, and Infrastructure implements MemoryCapacity. Host chooses the adapter and drives the inbound interface. This gives a real replaceable boundary without pretending that the in-memory adapter validates a production database. Build the complete solution and run the physical project checker to verify that Application does not acquire an Infrastructure reference. Runtime dispatch through an application-owned outbound interface is expected and does not contradict the checked direction of compile-time assembly dependencies.

Pitfalls and unproved guarantees

The read-check-commit shape is intentionally sequential and is not a concurrency guarantee. Two real workers could observe the same available value before either commits unless the adapter and port support a suitable atomic persistence contract. MemoryCapacity rechecks its own range but is not synchronized or durable. Replacing it with a database class is therefore not enough to claim cluster-safe reservation. Likewise, a fake adapter establishes the application's behavior against the supplied contract; separate integration evidence must establish mapping, failure, and consistency behavior of each real adapter technology. An adapter exception is also distinct from an ordinary rejected quantity and needs an explicit caller policy.

Practice one: isolate behavior

First supply a fresh capacity of two and request exactly two, expecting true and zero remaining. Then request one more from that exhausted adapter and expect false with zero unchanged. Hint: preserve a reference to the actual adapter so you can observe both the boolean and state after rejection. Add zero as a separate rejected request. These cases test the inbound rule and no-effect obligation, while leaving the external project structure and adapter-selection mechanism unchanged.

Practice two: test a boundary

For another exercise, drive the same IReserve operation from a small batch-style host that supplies prepared typed quantities. Hint: an inbound adapter translates its input into the port contract; it should not duplicate the capacity policy. Then outline a versioned or transactional outbound contract for genuinely competing reservations and identify what the storage adapter must guarantee atomically. Keep that proposed production guarantee separate from the current memory implementation. A diagram showing two adapters does not establish that simultaneous requests cannot oversell a shared resource.

Review the evidence

Review a ports-and-adapters design by naming each conversation, its driving actor, its declared contract, and its real implementation. Check the actual compile-time graph as well as the runtime operation. This lesson needs neither a web server nor a message bus to demonstrate those boundaries. Its deterministic state observations establish the bounded sequential example, while the complete external assemblies add enforceable reference direction. When new adapters arrive, preserve meaningful contracts and test their real technology-specific behavior rather than assuming every class behind an interface is automatically interchangeable under load or failure.

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

Hexagonal inbound and outbound ports — 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 CapacityRule { public static bool CanTake(int requested, int free) => requested > 0 && requested <= free; }
Application/Model.csDownload file ↗
using System;
using System.Collections.Generic;
using System.Linq;
using BoundaryLab.Domain;

namespace BoundaryLab.Application;

public interface IReserve { bool Reserve(int units); }
public interface ICapacity { int Free { get; } void Commit(int units); }
public sealed class ReserveService(ICapacity capacity) : IReserve
{
    public bool Reserve(int units)
    {
        if (!CapacityRule.CanTake(units, capacity.Free)) return false;
        capacity.Commit(units); return true;
    }
}
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 MemoryCapacity : ICapacity
{
    public int Free { get; private set; }
    public MemoryCapacity(int free) { if (free < 0) throw new ArgumentOutOfRangeException(nameof(free)); Free = free; }
    public void Commit(int units) { if (!CapacityRule.CanTake(units, Free)) throw new InvalidOperationException(); Free -= units; }
}
Host/Program.csDownload file ↗
using System;
using System.Collections.Generic;
using System.Linq;
using BoundaryLab.Domain;
using BoundaryLab.Application;
using BoundaryLab.Infrastructure;

var capacity = new MemoryCapacity(3);
IReserve service = new ReserveService(capacity);
Console.WriteLine($"first:{service.Reserve(2)}");
Console.WriteLine($"excess:{service.Reserve(2)}");
Console.WriteLine($"remaining:{capacity.Free}");
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: first:True excess:False remaining: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 capacity = new MemoryCapacity(3);
IReserve service = new ReserveService(capacity);
Console.WriteLine($"first:{service.Reserve(2)}");
Console.WriteLine($"excess:{service.Reserve(2)}");
Console.WriteLine($"remaining:{capacity.Free}");

public static class CapacityRule { public static bool CanTake(int requested, int free) => requested > 0 && requested <= free; }

public interface IReserve { bool Reserve(int units); }
public interface ICapacity { int Free { get; } void Commit(int units); }
public sealed class ReserveService(ICapacity capacity) : IReserve
{
    public bool Reserve(int units)
    {
        if (!CapacityRule.CanTake(units, capacity.Free)) return false;
        capacity.Commit(units); return true;
    }
}

public sealed class MemoryCapacity : ICapacity
{
    public int Free { get; private set; }
    public MemoryCapacity(int free) { if (free < 0) throw new ArgumentOutOfRangeException(nameof(free)); Free = free; }
    public void Commit(int units) { if (!CapacityRule.CanTake(units, Free)) throw new InvalidOperationException(); Free -= units; }
}
Open C# practice

C# practice output

first:True
excess:False
remaining:1
Try this
Drive IReserve through another host while keeping ICapacity unchanged. This sequential memory adapter does not establish concurrent inventory correctness.

Further reading