cratis-fundamentals-type-discovery · git:20261012.6b38fef · 2026-10-12 · sha256 1124b632c3cde4b3

cratis-fundamentals-type-discovery git:20261012.6b38fefA

Immutable. This exact content never changes and is served at /api/v1/blob/1124b632c3cde4b3.

---
name: cratis-fundamentals-type-discovery
description: Wire a type that must enumerate every implementation of an abstraction by injecting IInstancesOf from Cratis.Types and deleting the hand-maintained service registrations. Use when a handler set, filter chain, strategy set, or dispatcher fans out to all implementations. Do not use for ordinary single-service dependency injection.
license: MIT
---

# Cratis implementation discovery

`services.AddSingleton<IFoo, Foo>()` looks free at the registration line. The
cost arrives later: a new implementation in another folder does nothing until
someone remembers the line, a removed one leaves a registration that fails at
startup, and every specification setup has to mirror the composition root.

`IInstancesOf<T>` moves that knowledge into the implementation. Adding or
removing one becomes a single-file change.

## Verified product sources

| Package | Version | Purpose |
| --- | --- | --- |
| `Cratis.Fundamentals` | `7.23.0` | `IInstancesOf<T>`, `IImplementationsOf<T>`, `ITypes`, convention bindings and the bundled `Cratis.Fundamentals.TypeDiscovery.Generator` |

Reverify against the Cratis Fundamentals repository before claiming support for
another version.

## Route near misses

- The consumer needs **one** implementation chosen at composition time:
  constructor-inject the interface and let the `IFoo → Foo` convention resolve
  it. This skill does not apply.
- The method **returns** a sequence of values to a caller: `IEnumerable<T>` (or
  `IReadOnlyList<T>`) is still the right return type. The rule is about
  *injecting* implementations of an abstraction, not about returning values.
- You only need the **types**, never instances: inject `IImplementationsOf<T>`,
  which enumerates `Type` rather than `T`.

## When you need this

The consumer of an abstraction has to iterate, filter, or fan out to **every**
registered implementation:

- Anything plural that delegates to a set — `*Handlers`, `*Filters`,
  `*Validators`, `*Formatters`, `*Strategies`, `*Providers`, `*Resolvers`.
- A dispatcher that asks each implementation `CanHandle(...)` and forwards to
  the one that says yes.
- A composite that fans one input out to all implementations and aggregates.

## Step 1 — Confirm the implementations are discoverable

Discovery normally uses compile-time generated providers, not a walk of
`DefinedTypes`. Reflection is the fallback when no generated providers exist.

- Use concrete, non-generic implementations. Generated contract maps exclude
  open-generic implementors. An assembly's own provider can include its internal
  types; references emitted from another assembly require public types.
- `T` is an interface or abstract class (`where T : class`).
- Instances resolve by concrete type, so each implementation needs a self-binding.
  `AddSelfBindings()` honors `[Singleton]`, `[Scoped]`, and `[IgnoreConvention]`;
  it excludes static/abstract/interface/exception types, System/Microsoft
  namespaces, and classes with primitive or record-typed constructor parameters.
  `IFoo → Foo` bindings require matching names in the same namespace and exactly
  one implementation; they do not replace self-bindings for a discovered set.

The host must call `services.AddTypeDiscovery()` to register `ITypes`,
`IInstancesOf<>`, and `IImplementationsOf<>`, plus the convention binding setup.
Arc's `AddCratisArc` does this (verified at Arc v22.75.0); a plain host must do it.
No per-implementation registration is needed when self-binding applies.

The Fundamentals NuGet package carries the generator as an analyzer. A project
reference does **not** carry it: reference the generator explicitly, otherwise
only Fundamentals' own provider may contribute. A captured `ITypes` universe
never gains late-loaded assemblies. `AddTypeDiscovery()` reuses the default
universe while the provider set is unchanged; a later call rebuilds it if new
providers registered, without changing earlier consumers.

## Step 2 — Choose the lifetime with an attribute

```csharp
using Cratis.DependencyInjection;

[Singleton]
public class CsvReportFormatter(IClock clock) : IReportFormatter
{
    public bool CanHandle(ReportRequest request) => request.Format == "csv";
    public string Format(Report report) => /* … */;
}
```

⚠️ `[Singleton]` lives in **`Cratis.DependencyInjection`**, not `Cratis`. That
is the using directive to add when it does not resolve.

The convention reads exactly three attributes:

| Attribute | Lifetime |
| --- | --- |
| `[Singleton]` | Singleton |
| `[Scoped]` | Scoped |
| neither | **Transient** |

`[IgnoreConvention]` opts a type out of convention binding entirely.

Skip `[Singleton]` only when the implementation genuinely holds per-call state.
The `IFoo → Foo` convention still applies to transients — do not register them
explicitly either.

## Step 3 — Inject `IInstancesOf<T>` in the consumer

```csharp
using Cratis.DependencyInjection;
using Cratis.Types;

[Singleton]
public class ReportFormatters(IInstancesOf<IReportFormatter> formatters) : IReportFormatters
{
    public bool CanHandle(ReportRequest request) =>
        formatters.Any(formatter => formatter.CanHandle(request));

    public string Format(ReportRequest request, Report report) =>
        formatters.First(formatter => formatter.CanHandle(request)).Format(report);
}
```

`IInstancesOf<T>` implements `IEnumerable<T>`, so LINQ works on it directly.

**The type set is fixed at construction; instances resolve on every enumeration**
through the service provider captured by `InstancesOf<T>`. The consumer's scope
therefore matters: the singleton above is safe only when no implementation is
scoped. A singleton captures the root provider and would make a scoped
implementation captive or fail scope validation. Use a scoped or transient
consumer resolved inside a scope when any implementation is scoped; follow the
lifetime rule in `csharp.md`. Materialize with `.ToArray()` only when one operation
needs a stable instance snapshot.

## Step 4 — Delete the dead registrations

Find every line in a composition root or service-collection extension that
registered the implementations or the consumer, and remove it:

```csharp
// Delete — IInstancesOf<T> discovers them, [Singleton] gives them their lifetime
services.AddSingleton<IReportFormatter, CsvReportFormatter>();
services.AddSingleton<IReportFormatter, JsonReportFormatter>();
services.AddSingleton<IReportFormatters, ReportFormatters>();
```

Search for every remaining reference before deleting, so nothing else was
relying on a registration for a different reason.

## What breaks

- **`[Singleton]` does not resolve.** The using directive is
  `Cratis.DependencyInjection`, not `Cratis`.
- **An implementation is silently absent from the set.** Its assembly did not
  contribute a generated provider before the universe was built, or its type is
  abstract, generic, or inaccessible to the emitted provider. Inspect
  `ITypes.DiscoveryMode` (`Unknown`, `Generated`, `Reflected`, or `Explicit`) and
  `ITypes.Assemblies`; ask `TypeDiscoveryDiagnostics.FindMissingContributors(types)`
  for referenced assembly names that did not contribute (it loads nothing and
  may also report libraries with no discoverable types). The reflection fallback
  includes package assemblies by prefix: `Cratis` by default, configurable through
  `PackageReferencedAssemblies.Instance.AddAssemblyPrefixesToInclude(...)` before
  discovery.
- **Discovery dependencies cannot resolve.** The plain host omitted
  `services.AddTypeDiscovery()`.
- **Enumeration yields null.** The concrete type was discovered but not registered
  as itself, for example because `[IgnoreConvention]` or a self-binding exclusion
  skipped it. `InstancesOf<T>` uses `GetService(type)`, not `GetRequiredService`.
- **An implementation is a new instance every time it is touched.** It has no
  lifetime attribute, so the convention made it transient. That is correct
  behavior for the convention and usually the wrong intent — add `[Singleton]`.
- **A stale registration fails at startup.** A hand-written
  `AddSingleton<TInterface, Impl>()` survived a type being removed. Step 4 is
  what prevents it.
- **Injecting `IEnumerable<T>` seems to work and returns nothing.** That
  signature only sees hand-registered implementations. It is the failure mode
  this skill exists to remove — it fails silently and empty, not loudly.

## How it is proven

`dotnet build` — zero warnings, zero errors — then run the specifications for
the affected behavior. The non-vacuity check matters here: assert the **count**
of discovered implementations, not just that iterating them threw nothing. A
fan-out over an empty set passes every assertion that only says "nothing went
wrong".

Assert the expected `ITypes.DiscoveryMode` and contributing assembly names too.
For startup diagnostics, collect EventSource `Cratis.Fundamentals.TypeDiscovery`
with `dotnet-trace`: it reports universe construction and warns when only
Fundamentals contributed.