microsoft / microsoft/aspire

Annotation-Based Open Discriminated Union for Aspire Resources

Open
#8,984 5 comments 3 reactions 0 assignees View on GitHub
area-app-model breaking-change
Dominant language
C#
Stars
6.3k
Forks
991
Avg merge
2d 15h
Merged PRs (30d)
196

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

1. **remove** the original resource instance
2. **create** another concrete type
3. **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**.

```mermaid
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)

```csharp
// 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()`, `TryGet()` (initial impl = `is/as`).
* Replace direct `is`, `as`, `OfType()` in the codebase.
* Roslyn analyzer forbids new violations.
* _Behaviour identical to today._

**Phase B — tag & identity**
* Introduce `ResourceTypeAnnotation` and `Resource.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

```csharp
public static bool IsKind(this IResource r)
=> r.ResourceKind == typeof(T);

public static bool TryGet(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_: cache `typeof(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 through `WithAnnotation`.
* **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

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.