Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

Part VI: Create Bounded Context Libraries

"You can't put things in the right place if the right place doesn't exist yet."

In Part V, we built the safety net: characterization tests that capture SubscriptionHub's actual behavior. Golden master snapshots of billing outputs. Integration tests that prove the subscription lifecycle works end-to-end. The tests are green. They will stay green through every change in this part.

Now we make the foundational structural move. Not the most exciting move -- no Value Objects, no Aggregates, no Domain Events. Just empty .csproj files arranged in the right shape. But this is the move that makes every subsequent phase possible. Every Value Object extracted in Part VIII needs a home. Every Aggregate built in Part IX needs a compilation unit with enforced dependency rules. Every ACL formalized in Part VII needs an Infrastructure project to live in. If the libraries do not exist, all of that improved code lands back in SubscriptionHub.Services, and you have better code with the same architecture. Improved abstractions, identical coupling.

A bounded context is not an idea. It is a .csproj.

It is a compilation unit with a dependency graph that the compiler enforces on every build. When Subscriptions.Domain does not reference Billing.Domain, the compiler makes it physically impossible for a subscription aggregate to import a billing concept. No code review required. No architectural fitness function. No runtime check. The compiler says no, and the developer cannot override it.

This is not metaphorical. This is dotnet build returning error CS0246 when you try to use Invoice inside the Subscriptions domain. That error is the architecture.

Creating libraries first inverts the usual approach. Most teams try to extract clean code first and worry about project structure later. That approach produces beautiful Value Objects sitting inside the monolithic Services project, perfectly clean in isolation, still coupled to everything through the project graph. Structure first, code second. The structure is the constraint that prevents the code from drifting back.


Anatomy of a Context

Each bounded context from Part III becomes a set of projects with strict dependency rules. The projects follow the Onion Architecture pattern: dependencies point inward, the domain is at the center, and infrastructure is on the outside.

The Three Layers

Domain is the innermost layer. It contains aggregates, Value Objects, domain events, specifications, repository interfaces, and domain services. It references only the SharedKernel. It has zero NuGet packages. Zero. Not even Microsoft.Extensions.DependencyInjection.Abstractions. The domain is pure C#. This is testability expressed as a project file -- you can test every domain class with nothing but xunit and new.

Application is the middle layer. It contains command handlers, query handlers, DTOs, validators, and application services that orchestrate domain operations. It references Domain. It can reference NuGet packages for things like FluentValidation or MediatR if you use them. It does not reference EF Core, HTTP clients, or any infrastructure concern.

Infrastructure is the outermost layer. It contains the EF Core DbContext, repository implementations, external service adapters (the ACL implementations from Part VII), and configuration. It references Domain and Application. It is the only layer that has infrastructure NuGet packages: Microsoft.EntityFrameworkCore, Npgsql, Stripe.net, etc.

The dependency rule is absolute: arrows point inward only. Infrastructure depends on Application and Domain. Application depends on Domain. Domain depends on nothing (except SharedKernel). No layer may reference a layer outside of it.

Diagram

Not every context needs all three layers on day one. Subscriptions and Billing are complex enough to justify the full stack immediately. Notifications and Analytics are simpler -- they start as single projects and split later if complexity warrants it. Over-structuring a simple context adds ceremony without benefit. Under-structuring a complex one creates the mud you are trying to escape. Use judgment.

The Domain Project File

Here is the .csproj for Subscriptions.Domain. Read it carefully -- what is absent matters more than what is present.

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <RootNamespace>SubscriptionHub.Subscriptions.Domain</RootNamespace>
  </PropertyGroup>

  <ItemGroup>
    <ProjectReference Include="..\..\SharedKernel\SharedKernel.csproj" />
  </ItemGroup>

  <!-- No PackageReference elements. None. -->

</Project>

No PackageReference elements. The domain project references only SharedKernel, and SharedKernel itself has zero NuGet packages. This means Subscriptions.Domain can be built with nothing but the .NET SDK. No EF Core. No JSON serializer. No HTTP client. No logging framework. Nothing that ties the domain to a technology choice.

This is not accidental minimalism. It is a design constraint. The moment you add a NuGet package to the domain, you create a coupling that propagates to every project that references it. Add Microsoft.EntityFrameworkCore to the domain "just for the [Key] attribute," and now the domain assembly loads EF Core at runtime, EF Core updates become domain project updates, and the domain's test suite needs EF Core on the test runner. One package reference. Cascading consequences.

The Infrastructure Project File

Contrast this with Subscriptions.Infrastructure:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <RootNamespace>SubscriptionHub.Subscriptions.Infrastructure</RootNamespace>
  </PropertyGroup>

  <ItemGroup>
    <ProjectReference Include="..\Subscriptions.Domain\Subscriptions.Domain.csproj" />
    <ProjectReference Include="..\Subscriptions.Application\Subscriptions.Application.csproj" />
  </ItemGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.11" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.11" />
    <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" />
  </ItemGroup>

</Project>

All the infrastructure lives here. EF Core, the SQL Server provider, DI abstractions. This is the only project that knows about the database. The domain does not know. The application does not know. When you switch from SQL Server to PostgreSQL, you change one project. When you upgrade EF Core, you change one project. The blast radius of infrastructure changes is contained by the project graph.


Creating the Structure

Before and After

Here is the solution before and after the restructuring. The left column is what we inherited. The right column is what we are building.

BEFORE:                                  AFTER:
─────────────────────────────            ─────────────────────────────
SubscriptionHub.Web/                     src/
SubscriptionHub.Services/                ├── Subscriptions/
SubscriptionHub.Data/                    │   ├── Subscriptions.Domain/
SubscriptionHub.PaymentGateway/          │   ├── Subscriptions.Application/
SubscriptionHub.Notifications/           │   └── Subscriptions.Infrastructure/
SubscriptionHub.Analytics.ETL/           ├── Billing/
SubscriptionHub.Common/                  │   ├── Billing.Domain/
SubscriptionHub.Tests/                   │   ├── Billing.Application/
                                         │   └── Billing.Infrastructure/
                                         ├── Notifications/
                                         │   └── Notifications/
                                         ├── Analytics/
                                         │   └── Analytics/
                                         ├── SharedKernel/
                                         │   └── SharedKernel/
                                         └── Host/
                                             └── SubscriptionHub.Api/
                                         tests/
                                         ├── Subscriptions.Domain.Tests/
                                         ├── Subscriptions.Application.Tests/
                                         ├── Subscriptions.Infrastructure.Tests/
                                         ├── Billing.Domain.Tests/
                                         └── ... (mirrors src/ structure)

Eight projects on the left. Fourteen on the right (and growing). The project count increased. That is the point. More projects means more boundaries means more compiler enforcement. The number of projects is not a liability -- it is a feature of the architecture.

Step by Step

Creating the structure is mechanical. It is dotnet new classlib repeated with the right names in the right folders.

# Create root structure
mkdir -p src/Subscriptions src/Billing src/Notifications src/Analytics src/SharedKernel src/Host

# SharedKernel — the deliberate, tiny shared core
dotnet new classlib -n SharedKernel -o src/SharedKernel/SharedKernel

# Subscriptions context — full 3-layer stack
dotnet new classlib -n Subscriptions.Domain -o src/Subscriptions/Subscriptions.Domain
dotnet new classlib -n Subscriptions.Application -o src/Subscriptions/Subscriptions.Application
dotnet new classlib -n Subscriptions.Infrastructure -o src/Subscriptions/Subscriptions.Infrastructure

# Billing context — full 3-layer stack
dotnet new classlib -n Billing.Domain -o src/Billing/Billing.Domain
dotnet new classlib -n Billing.Application -o src/Billing/Billing.Application
dotnet new classlib -n Billing.Infrastructure -o src/Billing/Billing.Infrastructure

# Notifications — single project for now (simple context)
dotnet new classlib -n Notifications -o src/Notifications/Notifications

# Analytics — single project for now (simple context)
dotnet new classlib -n Analytics -o src/Analytics/Analytics

# Add all projects to the solution
dotnet sln add src/SharedKernel/SharedKernel/SharedKernel.csproj
dotnet sln add src/Subscriptions/Subscriptions.Domain/Subscriptions.Domain.csproj
dotnet sln add src/Subscriptions/Subscriptions.Application/Subscriptions.Application.csproj
dotnet sln add src/Subscriptions/Subscriptions.Infrastructure/Subscriptions.Infrastructure.csproj
# ... repeat for all projects

# Wire up project references — direction matters
cd src/Subscriptions/Subscriptions.Domain
dotnet add reference ../../SharedKernel/SharedKernel/SharedKernel.csproj

cd ../Subscriptions.Application
dotnet add reference ../Subscriptions.Domain/Subscriptions.Domain.csproj

cd ../Subscriptions.Infrastructure
dotnet add reference ../Subscriptions.Domain/Subscriptions.Domain.csproj
dotnet add reference ../Subscriptions.Application/Subscriptions.Application.csproj

# Same pattern for Billing
# Notifications and Analytics reference SharedKernel only (for now)

After running these commands, dotnet build should produce green. Every project compiles. Every project is empty (just the default Class1.cs placeholder). The structure exists. The dependency rules are enforced. No code has moved yet.

The Full Project Reference Graph

The following diagram shows every project and every reference arrow. Domain projects are islands with only inward arrows. The API is the only project that sees all contexts. The compiler enforces every single one of these relationships.

Diagram

Look at the Domain projects: Subscriptions.Domain and Billing.Domain. They have zero outward arrows except to SharedKernel. No domain project references another domain project. No domain project references any infrastructure project. This is the Dependency Inversion Principle expressed as a project graph. The domain depends on nothing. Everything depends on the domain.

Now look at what is missing. There is no arrow from Subscriptions.Infrastructure to Billing.Infrastructure. There is no arrow from Billing.Domain to Subscriptions.Domain. These absences are the architecture. If a developer on the Billing team writes using SubscriptionHub.Subscriptions.Domain; inside a Billing project, the build fails. No discussion needed. The compiler already had the conversation.


Moving Code Into the New Structure

The Mechanical Move

The initial code migration is deliberately unambitious. We are not refactoring. We are not improving. We are relocating. Take a class from the monolithic project, determine which context and layer it belongs to, and move the file. The class stays exactly as it is -- same name, same logic, same dependencies. The only change is the namespace and the project it lives in.

This is important enough to repeat: do not refactor the code yet. Just move it to the right home. Refactoring comes in later phases.

Why? Because refactoring during a structural move creates two sources of risk that compound. If you move Subscription.cs to Subscriptions.Domain and simultaneously extract Value Objects from it, and a characterization test fails, which change broke it? The move or the extraction? You cannot tell. Keep the variables isolated. Move first. Refactor later. Each step produces a green test suite.

What Goes Where

Here is the mapping from old location to new location for the Subscriptions context. The same pattern applies to Billing.

Old Location Old Project New Location New Layer Why
Entities/Subscription.cs Data Subscriptions.Domain/ Domain Core entity (becomes aggregate later)
Entities/Plan.cs Data Subscriptions.Domain/ Domain Core entity
Entities/PlanTierPrice.cs Data Subscriptions.Domain/ Domain Value-like entity
Entities/UsageRecord.cs Data Subscriptions.Domain/ Domain Entity within Subscription boundary
SubscriptionService.cs Services Subscriptions.Application/ Application Orchestration logic (refactored later)
PlanService.cs Services Subscriptions.Application/ Application Orchestration logic
AppDbContext.cs (partial) Data Subscriptions.Infrastructure/ Infrastructure Becomes SubscriptionsDbContext
Configurations/Subscription*.cs Data Subscriptions.Infrastructure/ Infrastructure EF configuration

And for Billing:

Old Location Old Project New Location New Layer Why
Entities/Invoice.cs Data Billing.Domain/ Domain Core entity (becomes aggregate later)
Entities/InvoiceLineItem.cs Data Billing.Domain/ Domain Entity within Invoice boundary
Entities/PaymentMethod.cs Data Billing.Domain/ Domain Entity
BillingService.cs Services Billing.Application/ Application The God Service -- stays intact for now
InvoiceService.cs Services Billing.Application/ Application Orchestration logic
DunningService.cs Services Billing.Application/ Application Orchestration logic
PricingCalculator.cs Services Billing.Application/ Application Calculation logic
TaxCalculationService.cs Services Billing.Application/ Application Will become ACL adapter later

Notice that BillingService.cs -- the 2,400-line God Service -- moves to Billing.Application/ completely intact. We do not split it yet. We do not extract Value Objects from it. We move it. It compiles in its new home. The characterization tests still pass. We have won something: the God Service now lives inside a bounded context with explicit dependency constraints. It can no longer silently reach into the Subscriptions domain through a shared AppDbContext. Every cross-context dependency it has will surface as a compilation error that we fix with explicit interfaces.

Handling Compilation Errors

When you move Subscription.cs from SubscriptionHub.Data to Subscriptions.Domain, every file that had using SubscriptionHub.Data.Entities; and referenced Subscription will fail to compile. These compilation errors are not bugs -- they are boundary violations being surfaced. Each error is a coupling point that was invisible in the monolith and is now visible because the project graph no longer allows it.

There are three strategies for handling these errors during the transition:

Strategy 1: Add a temporary project reference. If Billing.Application needs Subscription (a type that now lives in Subscriptions.Domain), add a project reference from Billing.Infrastructure to Subscriptions.Domain. This is a temporary coupling. Mark it with a comment:

// TODO: Remove cross-context reference. Replace with ID-only reference.
// See: Part VII (ACLs) and Part IX (Aggregates)

This keeps the build green while documenting the debt.

Strategy 2: Use ID-only references immediately. Instead of referencing the Subscription type from the Billing context, replace it with a Guid subscriptionId. This is the correct long-term solution (see the DbContext section below), and for simple cases it is easy to apply during the move.

Strategy 3: Leave the class in the old project temporarily. If a class has too many cross-context dependencies to move cleanly, leave it in SubscriptionHub.Services for now. Mark it:

// TODO: Move to Billing.Application once cross-context dependencies are resolved.
// Blocked by: references to Subscription, Customer, NotificationService

The old projects (Services, Data, Common) become thin delegation wrappers during the migration. They still exist. They still compile. They delegate to the new context libraries for anything that has been moved. Old callers -- controllers, existing tests, background jobs -- keep working because the old projects forward to the new ones.

The Migration Flow

Diagram

The star (★) on BillingService is a reminder: it moved intact. The God Service is now inside the Billing context, but it is still a God Service. We will decompose it in Parts VIII and IX. For now, it has a home, and that home has walls.

Run the Tests

After every batch of moves, run the characterization tests from Part V. Every single one must pass. If a test fails, the move introduced a regression -- usually a namespace change that broke a reflection-based test or a configuration that depended on assembly scanning.

dotnet test --filter "Category=Characterization"
# Expected: all green

This is non-negotiable. The tests are the contract. If they are green, the system's behavior has not changed. If they are red, you broke something during the move, and you need to fix it before continuing.


Separate DbContexts

Why Split the DbContext

SubscriptionHub has one AppDbContext with 31 DbSet<T> properties. Any code with a reference to the Data project can query any table. The Notifications team queries Subscriptions and Invoices directly. The Analytics team runs raw SQL against the same connection string. The Billing team loads Customer navigation properties to get Stripe IDs.

A single DbContext is a single boundary: none. Every entity can reach every other entity through navigation properties and Include() chains. There is no ownership. When the Billing team changes the Invoice schema, the migration runs against the same context that the Subscriptions team depends on. Migration conflicts are a weekly occurrence.

Splitting the DbContext is the database equivalent of creating separate projects. Each context owns its tables. Each context has its own migrations. Each context can evolve its schema independently.

Same Database, Separate Contexts

This is a critical point that often confuses teams new to DDD: separate DbContexts do not mean separate databases. All four contexts still read from and write to the same physical SQL Server instance. The same appsettings.json connection string. The same deployment target. The only thing that changes is which C# code can see which tables.

SubscriptionsDbContext can query Subscriptions, Plans, and UsageRecords. It cannot see Invoices. Not because of row-level security. Not because of database permissions. Because Invoice is not registered as a DbSet<T> in SubscriptionsDbContext, so the compiler will not let you write a LINQ query against it.

SubscriptionsDbContext

public class SubscriptionsDbContext : DbContext
{
    public SubscriptionsDbContext(DbContextOptions<SubscriptionsDbContext> options)
        : base(options)
    {
    }

    public DbSet<Subscription> Subscriptions => Set<Subscription>();
    public DbSet<Plan> Plans => Set<Plan>();
    public DbSet<PlanTierPrice> PlanTierPrices => Set<PlanTierPrice>();
    public DbSet<UsageRecord> UsageRecords => Set<UsageRecord>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Apply only Subscriptions-context configurations
        modelBuilder.ApplyConfigurationsFromAssembly(
            typeof(SubscriptionsDbContext).Assembly,
            type => type.Namespace?.Contains("Subscriptions") == true);

        // Explicit schema to avoid table name collisions
        modelBuilder.HasDefaultSchema("subscriptions");
    }
}

Four DbSet properties. Not 31. The Subscriptions context sees exactly the tables it owns. The HasDefaultSchema("subscriptions") call is optional but recommended -- it documents ownership at the database level and prevents migration conflicts when two contexts have tables with similar names.

BillingDbContext

public class BillingDbContext : DbContext
{
    public BillingDbContext(DbContextOptions<BillingDbContext> options)
        : base(options)
    {
    }

    public DbSet<Invoice> Invoices => Set<Invoice>();
    public DbSet<InvoiceLineItem> InvoiceLineItems => Set<InvoiceLineItem>();
    public DbSet<Payment> Payments => Set<Payment>();
    public DbSet<TaxCalculation> TaxCalculations => Set<TaxCalculation>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.ApplyConfigurationsFromAssembly(
            typeof(BillingDbContext).Assembly,
            type => type.Namespace?.Contains("Billing") == true);

        modelBuilder.HasDefaultSchema("billing");
    }
}

Cross-Context References: ID Only

In the old AppDbContext, Invoice had a navigation property to Subscription:

// OLD — navigation property crosses context boundary
public class Invoice
{
    public int Id { get; set; }
    public int SubscriptionId { get; set; }
    public Subscription Subscription { get; set; } = null!; // ← crosses into Subscriptions context
    public int CustomerId { get; set; }
    public Customer Customer { get; set; } = null!;          // ← crosses into Subscriptions context
    // ...
}

In the new structure, Invoice lives in Billing.Domain and cannot reference Subscription (which lives in Subscriptions.Domain). The cross-context reference becomes an ID:

// NEW — ID-only reference respects context boundary
public class Invoice
{
    public int Id { get; set; }
    public int SubscriptionId { get; set; }  // Just an int. No navigation property.
    public int CustomerId { get; set; }       // Just an int. No navigation property.
    // ...
}

No navigation property. No Include() possible. No FK constraint across context boundaries. If the Billing context needs subscription data, it must ask for it through an explicit interface -- an ACL (Part VII) or a domain event (Part X). The coupling is no longer hidden in a navigation property. It is visible, explicit, and testable.

This feels like a loss. Navigation properties are convenient. invoice.Subscription.Plan.Name is easy to write and easy to read. But that convenience is exactly the coupling that created the Big Ball of Mud. The ease of reaching across boundaries is what made every team's code entangled with every other team's data. Removing the navigation property makes the coupling inconvenient, and inconvenient coupling is coupling that gets questioned, designed, and eventually replaced with a proper boundary.

EF Core Migrations Per Context

Each DbContext gets its own migration folder. Migrations run independently. The Billing team can add a column to Invoice without coordinating with the Subscriptions team.

# Subscriptions migrations
dotnet ef migrations add InitialSubscriptions \
    --context SubscriptionsDbContext \
    --output-dir Migrations/Subscriptions \
    --project src/Subscriptions/Subscriptions.Infrastructure

# Billing migrations
dotnet ef migrations add InitialBilling \
    --context BillingDbContext \
    --output-dir Migrations/Billing \
    --project src/Billing/Billing.Infrastructure

At startup, each context applies its own migrations:

// In Program.cs or a hosted service
using var scope = app.Services.CreateScope();

var subscriptionsDb = scope.ServiceProvider
    .GetRequiredService<SubscriptionsDbContext>();
await subscriptionsDb.Database.MigrateAsync();

var billingDb = scope.ServiceProvider
    .GetRequiredService<BillingDbContext>();
await billingDb.Database.MigrateAsync();

What About Joins?

"But I need to join Invoices and Subscriptions for a report!"

Yes. Read models can span context boundaries. Write models must not.

The distinction is critical. When you write data -- creating an invoice, changing a subscription, processing a payment -- you operate within a single context, on a single aggregate, through a single DbContext. The invariants of that context are enforced by that context's domain model. No cross-context writes.

When you read data for display, reporting, or analytics, you can query across contexts. The simplest approach is a dedicated read-model DbContext that maps to a SQL view joining the relevant tables:

// Read-only context for cross-context queries — no migrations, no writes
public class ReportingReadContext : DbContext
{
    public ReportingReadContext(DbContextOptions<ReportingReadContext> options)
        : base(options)
    {
    }

    public IQueryable<InvoiceWithSubscriptionView> InvoiceReports
        => Set<InvoiceWithSubscriptionView>().AsNoTracking();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<InvoiceWithSubscriptionView>(e =>
        {
            e.HasNoKey();
            e.ToView("vw_InvoiceWithSubscription"); // SQL view
        });
    }
}

The read context has no migrations (it maps to views, not tables). It uses AsNoTracking() because it never writes. It lives in the API project or a dedicated Reporting project, not inside any bounded context. This is CQRS at the infrastructure level -- separate models for reads and writes, with different rules for each.

Database Ownership Diagram

Diagram

The dashed lines between Invoices and Subscriptions are ID-only references. No navigation property. No FK constraint enforced by EF Core. The relationship exists at the data level (an int column) but not at the code level (no Subscription type visible from Billing). If you need to join them, use the reporting read context with a SQL view.


Kill the Common Project

The Problem with Common

SubscriptionHub.Common has 47 classes. Every project in the solution references it. When anyone adds a type to Common, every project transitively depends on it. As we established in Part I, Common is not a Shared Kernel. It is an accidental coupling magnet masquerading as shared infrastructure.

Here is what is actually in Common:

SubscriptionHub.Common/
├── DTOs/
│   ├── SubscriptionDto.cs          → belongs to Subscriptions.Application
│   ├── InvoiceDto.cs               → belongs to Billing.Application
│   ├── CustomerDto.cs              → belongs to Subscriptions.Application
│   ├── PlanDto.cs                  → belongs to Subscriptions.Application
│   ├── PaymentMethodDto.cs         → belongs to Billing.Application
│   ├── NotificationDto.cs          → belongs to Notifications
│   ├── AnalyticsSummaryDto.cs      → belongs to Analytics
│   ├── AnalyticsFilterDto.cs       → belongs to Analytics
│   ├── UsageReportDto.cs           → belongs to Subscriptions.Application
│   ├── WebhookPayloadDto.cs        → belongs to Billing.Infrastructure
│   ├── InvoiceLineItemDto.cs       → belongs to Billing.Application
│   └── RevenueRecognitionDto.cs    → belongs to Billing.Application
├── Enums/
│   ├── SubscriptionStatus.cs       → belongs to Subscriptions.Domain
│   ├── InvoiceStatus.cs            → belongs to Billing.Domain
│   ├── PaymentStatus.cs            → belongs to Billing.Domain
│   ├── NotificationType.cs         → belongs to Notifications
│   └── PlanTier.cs                 → belongs to Subscriptions.Domain
├── Constants/
│   ├── ErrorCodes.cs               → split per context
│   ├── CurrencyCodes.cs            → SharedKernel (Money)
│   └── EmailTemplateNames.cs       → belongs to Notifications
├── Extensions/
│   ├── StringExtensions.cs         → SharedKernel (if genuinely shared)
│   ├── DateTimeExtensions.cs       → SharedKernel (if genuinely shared)
│   ├── DecimalExtensions.cs        → SharedKernel (Money-related)
│   └── EnumExtensions.cs           → SharedKernel
├── Exceptions/
│   ├── SubscriptionNotFoundException.cs  → Subscriptions.Domain
│   ├── PaymentFailedException.cs         → Billing.Domain
│   ├── InvoiceAlreadyPaidException.cs    → Billing.Domain
│   └── ... 10 more                       → distributed to owning context
├── Helpers/
│   ├── PricingHelper.cs            → Billing.Application (the third proration impl)
│   ├── CurrencyHelper.cs          → SharedKernel (Money)
│   └── TaxHelper.cs                → Billing.Application
└── Validators/
    ├── EmailValidator.cs           → SharedKernel (EmailAddress VO)
    └── CurrencyCodeValidator.cs    → SharedKernel (Money VO)

Every annotation on the right says where the type belongs. Out of 47 classes, exactly 6 are genuinely shared. The other 41 are context-specific types that ended up in Common because someone needed them in two places and Common was the path of least resistance.

SharedKernel: The Deliberate Core

The SharedKernel replaces Common. It contains exactly the types that multiple bounded contexts legitimately need to share -- the foundational abstractions of the domain model.

// SharedKernel/Entity.cs
public abstract class Entity<TId> where TId : notnull
{
    public TId Id { get; protected set; } = default!;

    public override bool Equals(object? obj)
        => obj is Entity<TId> other && Id.Equals(other.Id);

    public override int GetHashCode() => Id.GetHashCode();
}
// SharedKernel/ValueObject.cs
public abstract class ValueObject
{
    protected abstract IEnumerable<object?> GetEqualityComponents();

    public override bool Equals(object? obj)
    {
        if (obj is not ValueObject other) return false;
        return GetEqualityComponents()
            .SequenceEqual(other.GetEqualityComponents());
    }

    public override int GetHashCode()
        => GetEqualityComponents()
            .Aggregate(0, (hash, component)
                => HashCode.Combine(hash, component));
}
// SharedKernel/Result.cs
public sealed class Result<TValue, TError>
{
    private readonly TValue? _value;
    private readonly TError? _error;

    public bool IsSuccess { get; }
    public TValue Value => IsSuccess
        ? _value! : throw new InvalidOperationException("No value on failure.");
    public TError Error => !IsSuccess
        ? _error! : throw new InvalidOperationException("No error on success.");

    private Result(TValue value) { _value = value; IsSuccess = true; }
    private Result(TError error) { _error = error; IsSuccess = false; }

    public static Result<TValue, TError> Success(TValue value) => new(value);
    public static Result<TValue, TError> Failure(TError error) => new(error);
}
// SharedKernel/DomainEvent.cs
public abstract record DomainEvent
{
    public Guid EventId { get; } = Guid.NewGuid();
    public DateTime OccurredAt { get; } = DateTime.UtcNow;
}
// SharedKernel/Money.cs
public sealed class Money : ValueObject
{
    public decimal Amount { get; }
    public string Currency { get; }

    public Money(decimal amount, string currency)
    {
        if (string.IsNullOrWhiteSpace(currency) || currency.Length != 3)
            throw new ArgumentException("Currency must be a 3-letter ISO code.");

        Amount = amount;
        Currency = currency.ToUpperInvariant();
    }

    public Money Add(Money other)
    {
        if (Currency != other.Currency)
            throw new InvalidOperationException(
                $"Cannot add {Currency} and {other.Currency}.");
        return new Money(Amount + other.Amount, Currency);
    }

    protected override IEnumerable<object?> GetEqualityComponents()
    {
        yield return Amount;
        yield return Currency;
    }
}
// SharedKernel/EmailAddress.cs
public sealed class EmailAddress : ValueObject
{
    public string Value { get; }

    public EmailAddress(string value)
    {
        if (string.IsNullOrWhiteSpace(value) || !value.Contains('@'))
            throw new ArgumentException($"Invalid email: {value}");
        Value = value.Trim().ToLowerInvariant();
    }

    protected override IEnumerable<object?> GetEqualityComponents()
    {
        yield return Value;
    }
}

Six types. Entity<TId>, ValueObject, Result<TValue, TError>, DomainEvent, Money, EmailAddress. That is the entire SharedKernel. Compare that to Common's 47 classes.

The SharedKernel .csproj has zero NuGet packages:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <RootNamespace>SubscriptionHub.SharedKernel</RootNamespace>
  </PropertyGroup>

  <!-- No ProjectReference. No PackageReference. Pure C#. -->

</Project>

Zero project references. Zero package references. The SharedKernel depends on nothing. It is the bottom of the dependency graph. Every domain project references it, and it references nobody. This is the foundation.

When the Common Project Disappears

The Common project does not disappear on day one. It shrinks over multiple sessions. Each time you move a class to its owning context or to SharedKernel, Common loses a file. Eventually, it has zero files and you remove it from the solution.

The moment Common.csproj is deleted, every project that referenced it loses the transitive coupling. Subscriptions.Domain no longer transitively depends on AnalyticsSummaryDto. Billing.Application no longer transitively depends on NotificationType. The implicit coupling that made every team's build depend on every other team's types is gone.

When the shared project disappears, coupling disappears with it.


The Composition Root

API References Everything

The SubscriptionHub.Api project is the composition root -- the single place where all bounded contexts are wired together. It is the only project that references all Infrastructure projects. It is the only project that knows about every context.

Diagram

The API project does not contain business logic. It contains three things: DI registration, controller routing, and middleware configuration.

DI Registration Per Context

Each bounded context exposes a single extension method for DI registration. This keeps the Program.cs clean and gives each context control over its internal wiring.

// In Subscriptions.Infrastructure/DependencyInjection.cs
public static class DependencyInjection
{
    public static IServiceCollection AddSubscriptionsContext(
        this IServiceCollection services,
        IConfiguration configuration)
    {
        services.AddDbContext<SubscriptionsDbContext>(options =>
            options.UseSqlServer(
                configuration.GetConnectionString("SubscriptionHub"),
                sql => sql.MigrationsHistoryTable(
                    "__EFMigrationsHistory", "subscriptions")));

        services.AddScoped<ISubscriptionRepository, SubscriptionRepository>();
        services.AddScoped<IPlanRepository, PlanRepository>();

        return services;
    }
}
// In Billing.Infrastructure/DependencyInjection.cs
public static class DependencyInjection
{
    public static IServiceCollection AddBillingContext(
        this IServiceCollection services,
        IConfiguration configuration)
    {
        services.AddDbContext<BillingDbContext>(options =>
            options.UseSqlServer(
                configuration.GetConnectionString("SubscriptionHub"),
                sql => sql.MigrationsHistoryTable(
                    "__EFMigrationsHistory", "billing")));

        services.AddScoped<IInvoiceRepository, InvoiceRepository>();
        services.AddScoped<IPaymentRepository, PaymentRepository>();

        return services;
    }
}

And in the API's Program.cs:

var builder = WebApplication.CreateBuilder(args);

// Each context wires itself up — the API just calls the extension method
builder.Services.AddSubscriptionsContext(builder.Configuration);
builder.Services.AddBillingContext(builder.Configuration);
builder.Services.AddNotifications(builder.Configuration);
builder.Services.AddAnalytics(builder.Configuration);

var app = builder.Build();

// Middleware, routing, etc.
app.MapControllers();

app.Run();

Four lines of DI registration. Each context is a black box that the API activates. The API does not know about SubscriptionsDbContext directly. It does not know about repository implementations. It does not know which NuGet packages the Billing context uses. Each context manages its own internals.

Thin Controllers

Controllers live in the API project. They receive an HTTP request, dispatch a command or query to the appropriate context, and return the result. No business logic. No DbContext queries. No cross-context orchestration.

[ApiController]
[Route("api/[controller]")]
public class SubscriptionsController : ControllerBase
{
    private readonly ISubscriptionRepository _subscriptions;

    public SubscriptionsController(ISubscriptionRepository subscriptions)
    {
        _subscriptions = subscriptions;
    }

    [HttpGet("{id:int}")]
    public async Task<ActionResult<SubscriptionDto>> Get(int id)
    {
        var subscription = await _subscriptions.GetByIdAsync(id);
        if (subscription is null) return NotFound();
        return Ok(subscription.ToDto());
    }

    [HttpPost("{id:int}/change-plan")]
    public async Task<ActionResult> ChangePlan(
        int id, [FromBody] ChangePlanRequest request)
    {
        var result = await _subscriptions.ChangePlanAsync(id, request.NewPlanId);
        return result.IsSuccess ? Ok() : BadRequest(result.Error);
    }
}

The controller does not know about EF Core. It does not know about SQL Server. It does not know about the Billing context. It receives a request, dispatches it to the Subscriptions context through an interface, and returns the response. If you replace SQL Server with PostgreSQL, this controller does not change. If you replace the repository implementation with an in-memory fake for testing, this controller does not change. The API is a thin shell over the domain.


What We Built

Let us step back and look at what changed.

Dimension Before After
Projects 8 (flat) 14+ (layered per context)
DbContexts 1 (31 entities) 4 (4-12 entities each)
Shared project Common (47 classes) SharedKernel (6 types)
Cross-context coupling Navigation properties, shared DbContext ID-only references, explicit interfaces
Boundary enforcement Code review (human) Compiler (automated)
Migration coordination All teams, one migration folder Per-context, independent
Business logic location SubscriptionHub.Services (47 classes) Distributed to owning contexts
Dependency direction Arbitrary (circular in places) Inward only (domain has zero outward deps)

The code itself has barely changed. BillingService is still 2,400 lines. Subscription still has public setters. There are no Value Objects, no Aggregates, no Domain Events. Those come in Parts VIII, IX, and X.

What changed is the structure. The boundaries exist. The compiler enforces them. Every subsequent refactoring has a home -- the right project, in the right layer, in the right context. When we extract Money as a Value Object in Part VIII, it goes into SharedKernel and every domain project gets it through a single project reference. When we build the Subscription Aggregate in Part IX, it goes into Subscriptions.Domain and no Billing code can bypass its invariants because Billing.Domain does not reference Subscriptions.Domain.

Run the characterization tests one final time:

dotnet test --filter "Category=Characterization"
# All green. Behavior unchanged. Structure transformed.

The boundaries exist in code. The compiler enforces them. Now we can fix the connections between them.

Next: Part VII: Fix & Formalize ACLs -- stop the bleeding at the boundaries. The PaymentGateway that leaks Stripe types. The NotificationService that queries the main database. The Tax API with no abstraction. Anti-Corruption Layers, formalized and contract-tested.


Summary

What We Covered Key Takeaway
Libraries before code Create the project structure first -- every subsequent phase needs a home
Three-layer anatomy Domain (zero deps) → Application → Infrastructure. Arrows point inward only
Zero-package domain Domain .csproj has no NuGet packages. Testability in a project file
Project creation Mechanical: dotnet new classlib + dotnet add reference. Structure is cheap
Code migration Move files, not refactor. Namespace changes only. Characterization tests stay green
Compilation errors as signals Every CS0246 is a boundary violation being surfaced. Fix with temp refs or ID-only
Separate DbContexts Same database, separate EF contexts. Each context owns its tables
ID-only cross-context refs No navigation properties across contexts. Guid or int, not entity types
Per-context migrations Independent migration folders. Independent schema evolution. No coordination
Read vs. write models Write models stay within context. Read models can span contexts via SQL views
Kill Common 47-class coupling magnet → 6-type SharedKernel. Coupling disappears with the project
Composition root API references all Infrastructure projects. The only place all contexts meet
DI per context services.AddSubscriptionsContext() -- each context wires itself
Thin controllers Receive HTTP → dispatch command → return result. No business logic