Annotation-Based Open Discriminated Union for Aspire Resources
Today every concrete resource class—ProjectResource, ContainerResource, AzureServiceBusResource, etc.—owns state in its own fields.
When real workflows demand the same logical resource appear differently in run-mode and publish-mode, our model currently must
- remove the original resource instance
- create another concrete type
- copy annotations by hand
That breaks identity (two resource IDs), scatters logs, confuses diff tooling, and forces hacks inside helpers such as RunAsContainer(), RunAsEmulator(), and PublishAsDockerFile().
We already tie container identity to the annotation-collection reference; this proposal finishes that idea for all resources:
- All observable state lives in the annotation collection.
- A discriminator annotation —
ResourceTypeAnnotation.ResourceKind : Type— declares the current shape. - Wrapper classes (views) expose ergonomic APIs but share the same annotation spine.
| Dev inner-loop | Publish output |
|---|---|
.NET Project |
OCI container |
| Emulator container | Azure PaaS service |
| Local Redis container | Connection-string parameter pointing at a shared cache |
Identity never changes; we simply switch the tag.
graph TD
subgraph "Annotations (identity)"
A["{ annotations … ; tag = ResourceKind }"]
end
A -- viewed-as --> B[ProjectResource]
A -- viewed-as --> C[ContainerResource]
A -- viewed-as --> D[AzureBicepResource]
A -- viewed-as --> E[ConnectionStringParam]
Code sketches (same helpers, new engine)
// Project ⇒ container on publish
builder.AddProject("web")
.PublishAsDockerFile(); // internally retags to ContainerResource
// Azure PaaS ⇒ emulator container on local run
builder.AddAzureServiceBus("events")
.RunAsEmulator(); // retags to ContainerResource
// Container ⇒ external hosted cache for prod
builder.AddRedis("cache")
.PublishAsConnectionString(); // retags to ConnectionStringParameter
Execution plan (high-level)
Phase A — helper façade
- Add
IsKind<T>(),TryGet<T>()(initial impl =is/as). - Replace direct
is,as,OfType<T>()in the codebase. - Roslyn analyzer forbids new violations.
- Behaviour identical to today.
Phase B — tag & identity
- Introduce
ResourceTypeAnnotationandResource.ResourceKind. -
Equals/GetHashCodenow rely on the annotation-collection reference. - Helpers switch to the tag internally.
- Identity stable across view switches.
Phase C — move data to annotations
- Create
*Annotationclasses (e.g.ContainerEntryPointAnnotation). - Properties wrap annotations; helpers retag instead of delete + clone.
Annotation collections become read-only after model-build; cloning must be explicit.
Helper API details
public static bool IsKind<T>(this IResource r)
=> r.ResourceKind == typeof(T);
public static bool TryGet<T>(this IResource r,
[NotNullWhen(true)] out T? view)
where T : class, IResource
{
if (r.ResourceKind == typeof(T))
{
view = r as T ??
(T)Activator.CreateInstance(typeof(T), r.Name, r.Annotations)!;
return true;
}
view = null;
return false;
}
Every resource kind must expose a (string name, ResourceAnnotationCollection ann) constructor; an analyzer will enforce this.
Trade-offs & potential issues
-
Exhaustiveness – compiler no longer warns if a new kind is unhandled.
Mitigation: default branches plus analyzer checks. -
Performance – reflection in the helpers.
Mitigation: cachetypeof(T)comparisons and profile. -
External extensions using
is/as– will break when the tag diverges from CLR type.
Mitigation: analyzer package + migration docs; optional runtime guard. -
Annotation mutability – copy-on-write or concurrent edits could corrupt identity.
Mitigation: freeze collection reference after build; mutations throughWithAnnotation. -
Constructor convention – new kinds must add the two-arg ctor.
Mitigation: analyzer + project template. -
Versioning – equality semantics change.
Mitigation: land in next major release; debug shim to detect old behaviour.
Unsolved / open design gaps
View-specific members remain callable after a view switch.
Example: you create an AzureBicepResource, call RunAsEmulator(), so it is now viewed as a container, yet bicepResource.Outputs["primaryKey"] is still accessible—even though those outputs are meaningless in run-mode.
Unanswered questions:
- Do we introduce publish-only / run-only capabilities so annotations can self-describe validity?
- Should runtime guards throw (or assert) when a publish-only member is accessed under a run-mode tag?
- Can a Roslyn analyzer warn when publish-only members are used in run-time code paths?
- At minimum we need docs that state: after a view switch certain members are undefined and accessing them is user error.
These remain open and must be tracked as follow-up work once the union mechanics are in place.
See https://github.com/dotnet/aspire/pull/7251 for an initial prototype