Skip to content

Commit 1dd3bb4

Browse files
committed
Repurpose older [Service<T>] to allow specifying a service type
We add an analyzer that will flag the previous usage as an error (requiring the removal of the T or setting it to the actual service to register). This should prevent bumps and rebuilds without notice. Since the attributes don't have run-time impact but are rather purely compile-time, bumping but not building (i.e. via a transitive dependency, say or direct copying), would not cause runtime failures because the registrations in the previously compile assembly would remain as they were. This unlocks a very useful scenario to trim down the amount of registered interfaces. Fixes #281
1 parent b895271 commit 1dd3bb4

File tree

10 files changed

+212
-50
lines changed

10 files changed

+212
-50
lines changed

readme.md

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@ public interface IMyService
5252

5353
The `ServiceLifetime` argument is optional and defaults to [ServiceLifetime.Singleton](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.dependencyinjection.servicelifetime?#fields).
5454

55-
> NOTE: The attribute is matched by simple name, so you can define your own attribute
55+
> [!NOTE]
56+
> The attribute is matched by simple name, so you can define your own attribute
5657
> in your own assembly. It only has to provide a constructor receiving a
5758
> [ServiceLifetime](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.dependencyinjection.servicelifetime) argument,
5859
> and optionally an overload receiving an `object key` for keyed services.
@@ -77,13 +78,27 @@ app.MapGet("/", (IMyService service) => service.Message);
7778
app.Run();
7879
```
7980

80-
> NOTE: the service is available automatically for the scoped request, because
81+
> [!NOTE]
82+
> The service is available automatically for the scoped request, because
8183
> we called the generated `AddServices` that registers the discovered services.
8284
8385
And that's it. The source generator will discover annotated types in the current
8486
project and all its references too. Since the registration code is generated at
8587
compile-time, there is no run-time reflection (or dependencies) whatsoever.
8688

89+
If the service implements many interfaces and you want to register it only for
90+
a specific one, you can specify that as the generic argument:
91+
92+
```csharp
93+
[Service<IMyService>(ServiceLifetime.Scoped)]
94+
public class MyService : IMyService, IDisposable
95+
```
96+
97+
> [!TIP]
98+
> If no specific interface is provided, all implemented interfaces are registered
99+
> for the same service implementation (and they all resolve to the same instance,
100+
> except for transient lifetime).
101+
87102
### Convention-based
88103

89104
You can also avoid attributes entirely by using a convention-based approach, which
@@ -156,6 +171,7 @@ right `INotificationService` will be injected, based on the key provided.
156171
Note you can also register the same service using multiple keys, as shown in the
157172
`EmailNotificationService` above.
158173

174+
> [!IMPORTANT]
159175
> Keyed services are a feature of version 8.0+ of Microsoft.Extensions.DependencyInjection
160176
161177
## How It Works
@@ -180,7 +196,8 @@ other two registrations just retrieve the same service (according to its defined
180196
lifetime). This means the instance is reused and properly registered under
181197
all implemented interfaces automatically.
182198

183-
> NOTE: you can inspect the generated code by setting `EmitCompilerGeneratedFiles=true`
199+
> [!TIP]
200+
> You can inspect the generated code by setting `EmitCompilerGeneratedFiles=true`
184201
> in your project file and browsing the `generated` subfolder under `obj`.
185202

186203
If the service type has dependencies, they will be resolved from the service
@@ -262,9 +279,11 @@ public class ServiceAttribute : Attribute
262279
}
263280
```
264281

265-
> NOTE: since the constructor arguments are only used by the source generation to
282+
283+
> [!TIP]
284+
> Since the constructor arguments are only used by the source generation to
266285
> detemine the registration style (and key), but never at run-time, you don't even need
267-
> to keep it around in a field or property!
286+
> to keep them around in a field or property!
268287

269288
With this in place, you only need to add this package to the top-level project
270289
that is adding the services to the collection!

src/DependencyInjection.Tests/GenerationTests.cs

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,17 @@ public void RegisterWithCustomServiceAttribute()
272272
Assert.Same(instance, services.GetRequiredService<IAsyncDisposable>());
273273
}
274274

275+
[Fact]
276+
public void RegisterWithSpecificServiceType()
277+
{
278+
var collection = new ServiceCollection();
279+
collection.AddServices();
280+
var services = collection.BuildServiceProvider();
281+
282+
Assert.NotNull(services.GetRequiredService<ISpecificService>());
283+
Assert.Null(services.GetService<INonSpecificService>());
284+
}
285+
275286
[GenerationTests.Service(ServiceLifetime.Singleton)]
276287
public class MyAttributedService : IAsyncDisposable
277288
{
@@ -380,11 +391,19 @@ public class SmsNotificationService : INotificationService
380391
}
381392

382393
// Showcases that legacy generic Service<TKey> attribute still works
394+
// but now with new semantics enforced by an analyzer.
383395
[Service("email")]
384-
#pragma warning disable CS0618 // Type or member is obsolete
385-
[Service<string>("default")]
386-
#pragma warning restore CS0618 // Type or member is obsolete
396+
[Service<INotificationService>("default")]
387397
public class EmailNotificationService : INotificationService
388398
{
389399
public string Notify(string message) => $"[Email] {message}";
400+
}
401+
402+
public interface ISpecificService;
403+
public interface INonSpecificService;
404+
405+
[Service<ISpecificService>]
406+
public class SpecificServiceType : ISpecificService, INonSpecificService
407+
{
408+
public void Dispose() => throw new NotImplementedException();
390409
}

src/DependencyInjection/AddServicesAnalyzer.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,7 @@ namespace Devlooped.Extensions.DependencyInjection;
1010
[DiagnosticAnalyzer(LanguageNames.CSharp, LanguageNames.VisualBasic)]
1111
public class AddServicesAnalyzer : DiagnosticAnalyzer
1212
{
13-
public static DiagnosticDescriptor NoAddServicesCall { get; } =
14-
new DiagnosticDescriptor(
13+
public static DiagnosticDescriptor NoAddServicesCall { get; } = new DiagnosticDescriptor(
1514
"DDI001",
1615
"No call to IServiceCollection.AddServices found.",
1716
"The AddServices extension method must be invoked in order for discovered services to be properly registered.",
Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using System;
1+
using System.Diagnostics;
22
using Microsoft.CodeAnalysis.Diagnostics;
33

44
static class CodeAnalysisExtensions
@@ -7,13 +7,21 @@ static class CodeAnalysisExtensions
77
/// Gets whether the current build is a design-time build.
88
/// </summary>
99
public static bool IsDesignTimeBuild(this AnalyzerConfigOptionsProvider options) =>
10+
#if DEBUG
11+
// Assume if we have a debugger attached to a debug build, we want to debug the generator
12+
!Debugger.IsAttached &&
13+
#endif
1014
options.GlobalOptions.TryGetValue("build_property.DesignTimeBuild", out var value) &&
1115
bool.TryParse(value, out var isDesignTime) && isDesignTime;
1216

1317
/// <summary>
1418
/// Gets whether the current build is a design-time build.
1519
/// </summary>
1620
public static bool IsDesignTimeBuild(this AnalyzerConfigOptions options) =>
21+
#if DEBUG
22+
// Assume if we have a debugger attached to a debug build, we want to debug the generator
23+
!Debugger.IsAttached &&
24+
#endif
1725
options.TryGetValue("build_property.DesignTimeBuild", out var value) &&
1826
bool.TryParse(value, out var isDesignTime) && isDesignTime;
1927
}

src/DependencyInjection/ConventionsAnalyzer.cs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,15 @@ namespace Devlooped.Extensions.DependencyInjection;
1010
[DiagnosticAnalyzer(LanguageNames.CSharp, LanguageNames.VisualBasic)]
1111
public class ConventionsAnalyzer : DiagnosticAnalyzer
1212
{
13-
public static DiagnosticDescriptor AssignableTypeOfRequired { get; } =
14-
new DiagnosticDescriptor(
13+
public static DiagnosticDescriptor AssignableTypeOfRequired { get; } = new DiagnosticDescriptor(
1514
"DDI002",
1615
"The convention-based registration requires a typeof() expression.",
1716
"When registering services by type, typeof() must be used exclusively to avoid run-time reflection.",
1817
"Build",
1918
DiagnosticSeverity.Error,
2019
isEnabledByDefault: true);
2120

22-
public static DiagnosticDescriptor OpenGenericType { get; } =
23-
new DiagnosticDescriptor(
21+
public static DiagnosticDescriptor OpenGenericType { get; } = new DiagnosticDescriptor(
2422
"DDI003",
2523
"Open generic service implementations are not supported for convention-based registration.",
2624
"Only the concrete (closed) implementations of the open generic interface will be registered. Register open generic services explicitly using the built-in service collection methods.",

0 commit comments

Comments
 (0)