Overview
Three chapters introduced three sibling DSLs:
- Part XV -- SDLC DSL: pipelines, stages, build targets, quality gates
- Part XVI -- ALM DSL: services, feature flags, SLOs, Aspire wiring
- Part XVII -- PLM DSL: products, releases, roadmap, lifecycle, EOL
This chapter shows them working together inside one realistic .NET solution, AcmeStore. The two design rules driving the solution layout are:
- No lifecycle DSL leaks into the runtime.
AcmeStore.Lib(the domain) does not know thatAcmeStore.Sdlc,AcmeStore.AlmorAcmeStore.Plmexist. ASP.NET Core hosts do not reference them either. The dependency graph is one-directional, enforced bySDLC100/ALM100/PLM100analyzers. - 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 inAcmeStore.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 exportAcmeStore.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 exportThe 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.*[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
Reading rules:
- No solid arrow ever crosses from
InfraorDomainintoLifecycle. 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.FeatureTogglesdepends only onAcmeStore.Abstractions. It implementsIFeatureFlagsagainst whatever flag store is configured (config file, LaunchDarkly, ConfigCat, ...). The ALM source generator emits the typed subclassIFeatureFlagsTypedinto this project via MSBuild target output, so business code can write_flags.NewCheckoutinstead of_flags.IsEnabled("NewCheckout")without ever touchingAcmeStore.Alm.AcmeStore.AppHostdepends only on the three runtime hosts. The generatedAspireAppHost.g.cslands in this project (also via MSBuild output target), exposing apartialextension methodWireServices(this IDistributedApplicationBuilder). The hand-writtenProgram.cscalls it. AppHost has zero reference toAcmeStore.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();
}[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);
}[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 { }[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; }
}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 { }[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.mdshowingNewCheckoutasIn progressin Q2-2026 - A
CHANGELOG.g.mdentry for2.4.0-preview.1listingFEATURE_NEW_CHECKOUTandbehind 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# 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.htmlThe 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();// 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 stringSLO_API_*slo-dashboard.json-- a new Grafana panelApiService.SloEvaluator.g.cs-- a runtime evaluator (emitted intoAcmeStore.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;fromAcmeStore.Libto "just check the flag value" -- domain now knows about feature flag names. - A senior dev injecting
IRoadmapfromAcmeStore.Plminto a controller for "version awareness" -- a deployed binary now contains the entire internal roadmap. - An ops engineer adding a reference from
AcmeStore.SdlcintoAcmeStore.Workerto 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.