Description
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
ResourceTypeAnnotation
andResource.ResourceKind
. Equals
/GetHashCode
now rely on the annotation-collection reference.- Helpers switch to the tag internally.
- Identity stable across view switches.
Phase C — move data to annotations
- Create
*Annotation
classes (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 #7251 for an initial prototype