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

Overview

Three chapters introduced three sibling DSLs:

This chapter shows them working together inside one realistic .NET solution, AcmeStore. The two design rules driving the solution layout are:

  1. No lifecycle DSL leaks into the runtime. AcmeStore.Lib (the domain) does not know that AcmeStore.Sdlc, AcmeStore.Alm or AcmeStore.Plm exist. ASP.NET Core hosts do not reference them either. The dependency graph is one-directional, enforced by SDLC100 / ALM100 / PLM100 analyzers.
  2. Generators emit into target projects, not into the DSL projects themselves. The Aspire AppHost wiring lands in AcmeStore.AppHost. The typed feature-flag interface lands in AcmeStore.FeatureToggles. The DSL projects (*.Sdlc, *.Alm, *.Plm) hold only declarations and a const-string registry. They are build-time metadata.

The combination is what makes the lifecycle DSLs safe in a production codebase: PMs can declare a vision in *.Plm, devs can flip a flag in *.Alm, ops can tighten a quality gate in *.Sdlc, and none of those changes can possibly drag accidental code into a deployed binary.


Solution layout

AcmeStore.sln
├── kernel/
│   ├── AcmeStore.SharedKernel/        # value types, primitives
│   └── AcmeStore.Abstractions/        # IFeatureFlags, IReleaseInfo, ILayer
│
├── domain/
│   ├── AcmeStore.Requirements/        # Requirements DSL -- Features, ACs
│   ├── AcmeStore.Specifications/      # ISpec interfaces per feature
│   └── AcmeStore.Lib/                 # DDD DSL -- aggregates, invariants
│
├── lifecycle/                         ← never referenced by runtime code
│   ├── AcmeStore.Sdlc/                # ★ SDLC DSL declarations
│   ├── AcmeStore.Alm/                 # ★ ALM DSL declarations
│   └── AcmeStore.Plm/                 # ★ PLM DSL declarations
│
├── infra/
│   ├── AcmeStore.FeatureToggles/      # impl of IFeatureFlags + generated typed wrapper
│   ├── AcmeStore.Telemetry/           # generated OTel meters/sources
│   ├── AcmeStore.Api/                 # ASP.NET Core REST/GraphQL host
│   ├── AcmeStore.Worker/              # background queue worker
│   ├── AcmeStore.Web/                 # Blazor WASM
│   └── AcmeStore.AppHost/             # Aspire host (consumes generated wiring)
│       └── AcmeStore.ServiceDefaults/
│
└── tools/                             ← only legal consumers of *.Alm and *.Plm
    ├── AcmeStore.Sdlc.Runner/         # local CLI: ./build.sh test
    ├── AcmeStore.Tools.Alm/           # rollout, flag flip, incident sim
    └── AcmeStore.Tools.Plm/           # release cut, EOL calendar, roadmap export

The four-folder split (kernel/, domain/, lifecycle/, infra/, tools/) is the visible expression of the layering rule. Every project carries an assembly-level marker:

[assembly: Layer(Layer.Kernel)]    // AcmeStore.Abstractions
[assembly: Layer(Layer.Domain)]    // AcmeStore.Lib, .Requirements, .Specifications
[assembly: Layer(Layer.Lifecycle)] // AcmeStore.Sdlc, .Alm, .Plm
[assembly: Layer(Layer.Infra)]     // AcmeStore.Api, .Worker, .Web, .AppHost, .Telemetry, .FeatureToggles
[assembly: Layer(Layer.Tools)]     // AcmeStore.Tools.*

The analyzers SDLC100 / ALM100 / PLM100 read this attribute on each consuming compilation. Any reference from Domain or Infra into Lifecycle is a build-time error.


Dependency-inversion diagram

Diagram
AcmeStore's five-layer stack: Lifecycle DSLs (Sdlc, Alm, Plm) are build-time metadata reachable only from tools, never from Domain or Infra.

Reading rules:

  • No solid arrow ever crosses from Infra or Domain into Lifecycle. The lifecycle column is an island for runtime purposes.
  • Dotted arrows between lifecycle DSLs (PLM ⇢ ALM ⇢ SDLC) are build-time-only references between metadata projects. They never get linked into a runtime assembly because no runtime project references the top of the chain.
  • AcmeStore.FeatureToggles depends only on AcmeStore.Abstractions. It implements IFeatureFlags against whatever flag store is configured (config file, LaunchDarkly, ConfigCat, ...). The ALM source generator emits the typed subclass IFeatureFlagsTyped into this project via MSBuild target output, so business code can write _flags.NewCheckout instead of _flags.IsEnabled("NewCheckout") without ever touching AcmeStore.Alm.
  • AcmeStore.AppHost depends only on the three runtime hosts. The generated AspireAppHost.g.cs lands in this project (also via MSBuild output target), exposing a partial extension method WireServices(this IDistributedApplicationBuilder). The hand-written Program.cs calls it. AppHost has zero reference to AcmeStore.Alm.
  • Tools are the only legitimate runtime consumers of the lifecycle DSLs, and they are never deployed -- they live in tools/ and are excluded from the published artifact graph.

End-to-end story: shipping NewCheckout

The interesting part of the walkthrough is following one feature through every layer. Here is "NewCheckout" from idea to GA.

1. Requirement (in AcmeStore.Requirements)

[Feature(
    Id = "FEATURE_NEW_CHECKOUT",
    Title = "Single-page checkout flow",
    AcceptanceCriteria = new[]
    {
        nameof(GuestCanCheckoutWithoutAccount),
        nameof(LoggedInUserPrefilledFromProfile),
        nameof(PaymentFailureShowsRecoverableError)
    })]
public abstract record NewCheckoutFeature : RequirementBase
{
    public abstract void GuestCanCheckoutWithoutAccount();
    public abstract void LoggedInUserPrefilledFromProfile();
    public abstract void PaymentFailureShowsRecoverableError();
}

2. Domain implementation (in AcmeStore.Lib)

[AggregateRoot("Cart", BoundedContext = "Checkout")]
public partial class Cart
{
    [Implements(typeof(NewCheckoutFeature),
        nameof(NewCheckoutFeature.GuestCanCheckoutWithoutAccount))]
    public Result Checkout(GuestId? guest, IFeatureFlagsTyped flags)
        => flags.NewCheckout
            ? RunNewCheckout(guest)
            : RunLegacyCheckout(guest);
}

IFeatureFlagsTyped is injected from AcmeStore.FeatureToggles. The aggregate has no idea that the flag was declared in AcmeStore.Alm.

3. Pipeline + quality gate (in AcmeStore.Sdlc)

The existing MainBuildPipeline from Part XV already enforces Coverage >= 0.85. Adding NewCheckout does not require any pipeline change -- the gate applies uniformly.

4. Service, flag and rollout (in AcmeStore.Alm)

[Service("Api", build: Sdlc.BUILDTARGET_API)]
[Slo("p99-latency", m => m.LatencyP99Ms <= 250)]
public partial class ApiService { }

[FeatureFlag("NewCheckout", Default = false, Owner = Plm.PRODUCT_STORE,
    ExpiresOn = "2026-09-30")]
[Rollout(RolloutStrategy.Canary, Percent = 10)]
public partial class NewCheckoutFlag { }

The ALM generator now emits, into AcmeStore.FeatureToggles:

public interface IFeatureFlagsTyped : IFeatureFlags
{
    bool NewCheckout { get; }
    bool DarkMode { get; }
    bool ExperimentalSearch { get; }
}

The Cart.Checkout aggregate compiles. The flag is wired. No magic strings.

5. Roadmap and release (in AcmeStore.Plm)

[RoadmapItem(Requirements.FEATURE_NEW_CHECKOUT,
    Milestone = "Q2-2026",
    Status = RoadmapStatus.InProgress)]
public partial class RM_NewCheckout { }

[Release("Store",
    Version = "2.4.0-preview.1",
    Date = "2026-04-01",
    Pipeline = Sdlc.PIPELINE_MAIN,
    Channel = ReleaseChannel.Preview)]
public partial class Store_2_4_0_preview { }

The PLM generator emits:

  • A roadmap.md showing NewCheckout as In progress in Q2-2026
  • A CHANGELOG.g.md entry for 2.4.0-preview.1 listing FEATURE_NEW_CHECKOUT and behind feature flag NewCheckout (canary 10%)

6. Tooling (in tools/AcmeStore.Tools.Alm and tools/AcmeStore.Tools.Plm)

# Promote canary from 10% to 50%
dotnet run --project tools/AcmeStore.Tools.Alm -- \
    flag set NewCheckout --rollout canary --percent 50

# Cut the GA release once the AC is satisfied and SLOs hold
dotnet run --project tools/AcmeStore.Tools.Plm -- \
    release cut Store --version 2.4.0 --channel stable

# Generate the customer-facing changelog
dotnet run --project tools/AcmeStore.Tools.Plm -- \
    changelog --product Store --output public/changelog.html

The two tools are the only projects that reference AcmeStore.Alm and AcmeStore.Plm. They live in tools/. They are excluded from the deploy graph by <IsPackable>false</IsPackable> and dotnet publish filters.

7. Aspire boots everything (AcmeStore.AppHost)

// AcmeStore.AppHost/Program.cs (hand-written)
var builder = DistributedApplication.CreateBuilder(args);
builder.WireServices();   // partial method emitted from AcmeStore.Alm declarations
builder.Build().Run();

The hand-written Program.cs is two lines. Everything else -- replicas, health checks, resource references -- is generated from the [Service] declarations in AcmeStore.Alm. AppHost stays clean of lifecycle references.


What changes when you remove a flag

Removing NewCheckoutFlag from AcmeStore.Alm cascades through the build:

Step What happens
1 IFeatureFlagsTyped.g.cs is regenerated without the NewCheckout property
2 Cart.Checkout fails to compile -- it still reads flags.NewCheckout
3 Developer either deletes the legacy branch (flags.NewCheckout ? ... : ...) or restores the flag
4 roadmap.md and CHANGELOG.g.md regenerate; the retired flag appears under "Retired flags"
5 Analyzer ALM001 reports any orphaned references; build is green

There is no scenario where the flag exists in production but the code reads false because the const string was misspelled. There is no scenario where the flag is removed from code but a stale entry survives in a config file. The compiler is the source of truth.


What changes when a service gets a new SLO

Adding a new [Slo] to ApiService regenerates:

  • AlmRegistry.g.cs -- the new const string SLO_API_*
  • slo-dashboard.json -- a new Grafana panel
  • ApiService.SloEvaluator.g.cs -- a runtime evaluator (emitted into AcmeStore.Telemetry)
  • CHANGELOG.g.md -- a "SLO added" line on the next release

Nothing in AcmeStore.Lib recompiles. Nothing in AcmeStore.Api changes. The runtime impact is bounded to the telemetry and dashboard projects that deliberately opt in.


Why this layering matters

Without strict dependency inversion the lifecycle DSLs become a footgun:

  • A junior dev using AcmeStore.Alm; from AcmeStore.Lib to "just check the flag value" -- domain now knows about feature flag names.
  • A senior dev injecting IRoadmap from AcmeStore.Plm into a controller for "version awareness" -- a deployed binary now contains the entire internal roadmap.
  • An ops engineer adding a reference from AcmeStore.Sdlc into AcmeStore.Worker to read pipeline metadata at runtime -- production now depends on a build-time metadata project that may be missing or stale in the deployed image.

Each of these is a small mistake. Each is invisible at code review until something breaks in production. The Layer attribute and the *100 analyzers turn each of them into a build error that fires the moment the bad reference is added.

The lifecycle DSLs give product, ops and engineering a shared vocabulary inside the same compiler. Strict layering keeps that vocabulary from contaminating the code that customers run. Together, they are the missing pieces between "the spec is in Jira" and "the spec is in the type system" -- the same direction the rest of the CMF has been pulling all along.

⬇ Download