diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 31210aa..0e8fa52 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -3,4 +3,4 @@ # git config blame.ignoreRevsFile .git-blame-ignore-revs # Reformat every C# file with CSharpier -79a2881a3fd722dd95f8aa589567003acb896678 +d7a1a8256cc826e23715ab1a1e1a834ffa4a89be diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6784ad7..8f9a9b4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,46 +1,106 @@ # Contributing -## Setup +## Before you start -```sh +Install these tools: + +- The .NET SDK 10.0.302 or a subsequent 10.0 SDK. The `global.json` file selects the SDK. +- The .NET 8 runtime. The tests also run on `net8.0`. +- Node.js 22 and npm, if you change the documentation site. + +## Build the solution and run the tests + +```shell +dotnet restore DependencyModules.sln +dotnet build DependencyModules.sln --configuration Release +dotnet test DependencyModules.sln --configuration Release +``` + +To run all tests with code coverage, use the coverage script. The script writes the report to `artifacts/coverage`. If you give a percentage, the script fails when the line coverage is less than that value. + +```shell +./scripts/coverage.sh 85 +``` + +To do a test of the packages that a user gets, use the package script. The script packs the nine packages. Then it builds and runs a test application for each target framework. The test application references these packages. + +```shell +./scripts/verify-packages.sh +``` + +## Code format + +CSharpier formats the C# code. The tool manifest in `.config/dotnet-tools.json` sets the version. + +```shell +dotnet tool restore +dotnet csharpier format . +dotnet csharpier check . +``` + +The `.editorconfig` file gives IDEs the same C# format as CSharpier. This format puts braces on new lines (Allman style). + +To make sure that the format is correct before each commit, enable the hook in `.githooks`: + +```shell git config core.hooksPath .githooks +``` + +The hook examines each C# file with staged changes. It examines the files in your folder, not the staged copies. If `dotnet` is not on the `PATH`, the hook does no check. + +CSharpier does not format the project files (`.csproj`, `.props`, and `.targets`). When you change the project files, keep their format. + +The `.git-blame-ignore-revs` file contains the commit that changed the format of all C# files with CSharpier. GitHub uses this file. To use this file with `git blame`, run this command: + +```shell git config blame.ignoreRevsFile .git-blame-ignore-revs -dotnet tool restore ``` -The first line turns on the pre-commit hook, which rejects a commit whose C# is not formatted. Git -does not carry hooks across a clone, so this is the one step that cannot be automated for you. +## Pull requests -The second keeps the CSharpier reformat out of `git blame`, which otherwise reports it as the last -change to nearly every line in the repo. GitHub already reads that file without being asked. +The `build-package` workflow runs for each pull request to `main`. It does these checks: -## Formatting +1. It does a check of the format with CSharpier. +2. It builds the solution. +3. It runs all tests with code coverage. The line coverage must be 85 percent or more. +4. It runs `scripts/verify-packages.sh`. -C# layout is [CSharpier](https://csharpier.com)'s, and the version is pinned in -`.config/dotnet-tools.json` so every clone and CI agree on what formatted means. Braces are Allman. -Nothing about the style is up for discussion in review โ€” run the formatter: +## Documentation -```sh -dotnet csharpier format . +The documentation site is in the `website` folder. It uses VitePress. + +```shell +cd website +npm ci +npm run dev +npm run build ``` -`.editorconfig` describes the same layout for your IDE, so typing and formatting do not disagree. -Project files are excluded (see `.csharpierignore`); CSharpier reindents MSBuild XML but leaves the -interior of multi-line comments where it was, which this repo has a lot of. +`npm run build` fails when an internal link has no target page. When you merge a change to `website` into `main`, the `docs` workflow publishes the site to GitHub Pages. + +`README.md` is also the NuGet page of each package. Thus the links and images in `README.md` must be absolute URLs. -`build-package.yaml` runs `dotnet csharpier check .` on every pull request. The hook is the fast -answer, that check is the guarantee. +Write the documentation in [ASD-STE100 Simplified Technical English](https://www.asd-ste100.org/). Use the names from the code for types, members, and attributes. -## Build and test +## Releases -```sh -dotnet build DependencyModules.sln -dotnet test DependencyModules.sln +Each push to `main` publishes prerelease packages to GitHub Packages. Their version has the suffix `ci.` and the run number. + +To publish a release, push a version tag: + +```shell +git tag v1.5.0 +git push origin v1.5.0 ``` -Both target frameworks are built, so running the tests needs the .NET 8 runtime alongside the .NET -10 SDK that `global.json` selects. +The `release` workflow then does these steps: + +1. It builds the code. +2. It runs the tests. +3. It packs the nine packages. +4. It publishes the packages to nuget.org and to GitHub Packages. +5. It makes a GitHub release with generated release notes. + +A version with a hyphen, for example `1.6.0-preview.1`, is a prerelease. -`./scripts/coverage.sh 85` runs every suite with coverage and fails under the threshold, the same -way CI does. `./scripts/verify-packages.sh` packs the libraries and consumes them from a real -package reference, which is the only thing that catches a packaging fault. +The tag sets the package version. `Directory.Build.props` sets the version for local builds and for the prerelease packages. The assembly version stays `1.0.0.0` for all 1.x versions. diff --git a/README.md b/README.md index ef76acf..2d0c7c3 100644 --- a/README.md +++ b/README.md @@ -5,39 +5,27 @@ [![coverage](https://raw.githubusercontent.com/ipjohnson/DependencyModules/badges/coverage.svg)](https://github.com/ipjohnson/DependencyModules/actions/workflows/build-package.yaml) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/ipjohnson/DependencyModules/blob/main/LICENSE.txt) -**Your DI registrations, written as attributes and compiled into your assembly.** -No reflection, no assembly scanning, no startup cost โ€” and Native AOT works, because -there is nothing left to trim away. +DependencyModules is a source generator for `Microsoft.Extensions.DependencyInjection`. You put attributes on your classes. When you compile the project, the generator writes the code that registers these classes in an `IServiceCollection`. The generated code does not use reflection to find services at run time. -๐Ÿ“– **[Documentation](https://ipjohnson.github.io/DependencyModules/)** ยท -[Getting started](https://ipjohnson.github.io/DependencyModules/guide/getting-started) ยท -[Conventions](https://ipjohnson.github.io/DependencyModules/guide/conventions) ยท -[Decorators](https://ipjohnson.github.io/DependencyModules/guide/decorators) ยท -[Testing](https://ipjohnson.github.io/DependencyModules/guide/testing) ยท -[AOT](https://ipjohnson.github.io/DependencyModules/guide/aot) +A module is a partial class that registers the services of a project. A module can use other modules. The test packages build a service provider from your modules for each xUnit or NUnit test. -## The whole trick +Documentation: [ipjohnson.github.io/DependencyModules](https://ipjohnson.github.io/DependencyModules/) -You mark a class: +## Packages -```csharp -[SingletonService] -public class SmtpEmailSender : IEmailSender; -``` - -At build time the generator writes the registration into your assembly: - -```csharp -// ApplicationModule.Dependencies.g.cs -services.AddSingleton( - typeof(global::MyApp.IEmailSender), - typeof(global::MyApp.SmtpEmailSender) -); -``` +| Package | Contents | +| --- | --- | +| [DependencyModules.Runtime](https://www.nuget.org/packages/DependencyModules.Runtime/) | The attributes, the interfaces, and the `AddModule` methods. | +| [DependencyModules.SourceGenerator](https://www.nuget.org/packages/DependencyModules.SourceGenerator/) | The source generator. It operates only when you compile. | +| [DependencyModules.xUnit](https://www.nuget.org/packages/DependencyModules.xUnit/) | `[ModuleTest]` for xUnit v3. | +| [DependencyModules.NUnit](https://www.nuget.org/packages/DependencyModules.NUnit/) | `[ModuleTest]` and `[ModuleTestCase]` for NUnit 4. | +| [DependencyModules.Testing](https://www.nuget.org/packages/DependencyModules.Testing/) | The test attributes and interfaces that the xUnit and NUnit packages use. | +| [DependencyModules.NSubstitute](https://www.nuget.org/packages/DependencyModules.NSubstitute/) | `[Mock]` parameters with NSubstitute. | +| [DependencyModules.Moq](https://www.nuget.org/packages/DependencyModules.Moq/) | `[Mock]` and `Mock` parameters with Moq. | +| [DependencyModules.FakeItEasy](https://www.nuget.org/packages/DependencyModules.FakeItEasy/) | `[Mock]` parameters with FakeItEasy. | +| [DependencyModules.SourceGenerator.Impl](https://www.nuget.org/packages/DependencyModules.SourceGenerator.Impl/) | The source code of the generator. A framework can compile this code into a different source generator. | -That is the entire mechanism. The output is ordinary C# that you can read, grep, set a -breakpoint in, and check into a review. Nothing inspects your assembly at run time, so -there is no startup scan to pay for and nothing for the trimmer to guess about. +The target frameworks of the runtime package, `DependencyModules.Testing`, the test packages, and the mock packages are `net8.0` and `net10.0`. The generator is compatible with Roslyn 4.10 and all subsequent versions. ## Install @@ -46,242 +34,89 @@ dotnet add package DependencyModules.Runtime dotnet add package DependencyModules.SourceGenerator ``` -Requires .NET 8.0 or later. The packages ship `net8.0` and `net10.0` assemblies, so a -project on either LTS gets one built against its own framework. Console applications also -want `Microsoft.Extensions.DependencyInjection`. +## Register services -## Quick start - -Mark the services, declare a module, load it once: +Put a service attribute on each class. The attribute sets the lifetime. ```csharp -// Services.cs using DependencyModules.Runtime.Attributes; -namespace MyApp; +namespace Shop; + +public interface IPriceCalculator +{ + decimal Total(decimal price, int quantity); +} [SingletonService] -public class SmtpEmailSender : IEmailSender; +public class PriceCalculator : IPriceCalculator +{ + public decimal Total(decimal price, int quantity) => price * quantity; +} -[ScopedService] -public class OrderRepository : IOrderRepository; +[DependencyModule] +public partial class ShopModule; ``` +The generator registers `PriceCalculator` as `IPriceCalculator`. `ShopModule` registers all services of the project. + +## Load the module + ```csharp -// Program.cs -using MyApp; // the generated module lives in your root namespace using DependencyModules.Runtime; using Microsoft.Extensions.DependencyInjection; +using Shop; var services = new ServiceCollection(); -services.AddModule(); -var provider = services.BuildServiceProvider(); -``` +services.AddModule(); -`ApplicationModule` is generated for you in a project whose entry point is a top-level -`Program.cs`. Anywhere else โ€” a class library, or a project that wants more than one module โ€” -declare your own: +var provider = services.BuildServiceProvider(); -```csharp -[DependencyModule] -public partial class ApplicationModule; +var calculator = provider.GetRequiredService(); ``` -Declaring one in a project that already gets a generated `ApplicationModule` merges with it rather -than colliding โ€” and to add a `ConfigureServices` to the generated one, declare the partial -*without* `[DependencyModule]` and implement `IServiceCollectionConfiguration`. +## Tests with modules -A module must be `partial`, and must be declared directly in a namespace rather than nested -inside another type. Services marked with `[SingletonService]` and friends may be nested freely. - -> The generated module takes the project's `RootNamespace`, and top-level statements sit -> in the global namespace โ€” so a top-level `Program.cs` needs `using YourRootNamespace;` -> before it can name `ApplicationModule`. - -## Registering forty things without writing forty attributes +```csharp +using DependencyModules.xUnit.Attributes; +using Shop; +using Xunit; -Declare the rule once. It is matched by the compiler, against the types that exist at build -time: +namespace Shop.Tests; -```csharp -[DependencyModule] -public partial class HandlerModule : IConventionModule +public class PriceCalculatorTests { - void IConventionModule.Conventions(IConventionDefinitions conventions) + [ModuleTest(typeof(ShopModule))] + public void Multiplies(IPriceCalculator calculator) { - conventions.RegisterAll(typeof(IRequestHandler<,>)).AsScoped(); - - conventions.RegisterAll(typeof(IValidator<>)) - .IncludeBaseClasses() - .AlsoAsSelf() - .AsScoped(); + Assert.Equal(10m, calculator.Total(2.5m, 4)); } } ``` -Every handler in the project is registered against the closed interface it implements. Add a -handler tomorrow and it joins; delete one and the registration goes with it. A convention that -stops matching anything is a build warning rather than a runtime surprise. +The test gets its parameters from a new service provider. After the test, the test package disposes the service provider. -The body of `Conventions` is never executed โ€” it is read from source at compile time, which is -why only the documented calls can appear in it. See the -[conventions guide](https://ipjohnson.github.io/DependencyModules/guide/conventions). +## Features -## Composing modules +- [Services](https://ipjohnson.github.io/DependencyModules/guide/services): lifetimes, keyed services, registration types, cross-wired services, and factory methods. +- [Modules](https://ipjohnson.github.io/DependencyModules/guide/modules): module dependencies, realms, module parameters, and the generated `ApplicationModule`. +- [Conventions](https://ipjohnson.github.io/DependencyModules/guide/conventions): registrations for all classes that implement an interface. +- [Decorators](https://ipjohnson.github.io/DependencyModules/guide/decorators) and [interception](https://ipjohnson.github.io/DependencyModules/guide/interception): code around services. +- [Environments](https://ipjohnson.github.io/DependencyModules/guide/environments): registrations that occur only in some environments. +- [Testing](https://ipjohnson.github.io/DependencyModules/guide/testing): xUnit and NUnit tests with modules, mocks, and more service providers in a test. +- [Native AOT](https://ipjohnson.github.io/DependencyModules/guide/aot): generated factories and trimming. -A module generates an attribute of the same name, so modules compose by attribute: +## Sample projects -```csharp -[DependencyModule] -[DomainModule] -[InfrastructureModule(useInMemory: true, ConnectionName = "primary")] -public partial class ApiModule; -``` - -Constructor parameters and settable properties on a module are mirrored onto its generated -attribute, so a module can be configured by whoever composes it. For anything the attributes -cannot express, implement `IServiceCollectionConfiguration` and write the registrations by hand. - -## Decorators and interception - -Wrap a service without touching it or its callers. The first constructor parameter is the -wrapped instance; the rest resolve normally: - -```csharp -[Decorator(Order = 2000)] -public class CachingRepository(IRepository inner, IMemoryCache cache) : IRepository; - -[Decorator(Order = 1000)] -public class TracingRepository(IRepository inner, ILogger log) : IRepository; - -// resolves as CachingRepository(TracingRepository(SqlRepository)) -``` - -Lower orders sit closer to the implementation. Ordering is global across every module in an -`AddModule(s)` call, so an application's decorators can wrap those a library contributed โ€” -by convention framework code uses 0โ€“999 and application code starts at 1000. - -For cross-cutting behaviour across every member of a service, `[Intercept]` generates a typed -wrapper rather than a dynamic proxy. See -[decorators and interception](https://ipjohnson.github.io/DependencyModules/guide/decorators). - -## Testing - -Tests receive their dependencies as method parameters, against the real registration graph: - -```csharp -[assembly: ApplicationModule] -[assembly: NSubstituteSupport] - -public class OrderTests -{ - [ModuleTest] - public async Task PlaceOrder_PricesThroughTheChannel( - IRequestHandler handler, - [Mock] IBookRepository books) - { - books.Find("isbn-1", Arg.Any()) - .Returns(new Book("isbn-1", 20m)); - - var order = await handler.Handle(new PlaceOrder("isbn-1", 10), default); - - Assert.Equal(140m, order.Total); - } -} -``` - -```shell -dotnet add package DependencyModules.xUnit # or DependencyModules.NUnit -dotnet add package DependencyModules.NSubstitute # or .Moq, or .FakeItEasy -``` +The `integ-tests` folder contains projects that reference the source projects of the solution. The solution builds them, and the build workflow runs their tests: -Each test gets its own provider, so singletons cannot leak between them. See the -[testing guide](https://ipjohnson.github.io/DependencyModules/guide/testing). - -## Native AOT - -Verified end to end: a console application using conventions, keyed registrations, decorators, -a static factory and an intercepted open generic publishes to a **2.2 MB** self-contained -binary with **zero IL trim or AOT warnings**, behaving identically to the JIT build. - -The one limitation is not this library's to fix: the container cannot close an open generic -over a value type without dynamic code, so `IRepository` resolves and `IRepository` -throws. Setting `PublishAot` makes that fail in an ordinary `dotnet run` rather than only after -publishing. See the [AOT guide](https://ipjohnson.github.io/DependencyModules/guide/aot). - -## Compared with runtime scanning - -The registration work that Scrutor, container modules, or a hand-written `AddScoped` list -do when the application starts happens here at `dotnet build`: - -| | Runtime scanning | DependencyModules | -|---|---|---| -| When registration is decided | First request to the container | `dotnet build` | -| A convention that matches nothing | Silent | [`DM0005`](https://ipjohnson.github.io/DependencyModules/reference/diagnostics) at build | -| A service that cannot be constructed | `InvalidOperationException`, eventually | [`DM0002`](https://ipjohnson.github.io/DependencyModules/reference/diagnostics) at build | -| Trimming / Native AOT | Types disappear; scanner finds nothing | Literal `typeof()`, so the trimmer keeps them | -| Startup cost | Proportional to assembly size | None | -| What actually got registered | Debugger, at run time | A file you can open | - -The third row is the mechanism behind the AOT results above: a trimmer keeps what is -statically referenced, a type found only by reflection is not referenced, and an emitted -`typeof(CreateOrderHandler)` is. - -## Feature reference - -| | | -|---|---| -| `[SingletonService]` `[ScopedService]` `[TransientService]` | Register with the matching lifetime | -| `[CrossWireService]` | One instance shared across the implementation and its interfaces | -| `As = typeof(IFoo)` | Choose the service type explicitly | -| `Key = "primary"` | Keyed registration | -| `Using = RegistrationType.Try` | `Add`, `Try`, `TryEnumerable` or `Replace` | -| `Realm = typeof(SomeModule)` | Restrict a registration to one module | -| `Order = 10` | Where a registration sits in `IEnumerable` | -| `[IfEnvironment("Development")]` | Register only in named environments | -| `[Decorator]` `[Decorate]` `[Intercept]` | Wrap a service, or one you do not own | -| A `static` method carrying a service attribute | Factory, for types the container cannot build | - -Full details for each, with the rules and the edge cases, are in the -[documentation](https://ipjohnson.github.io/DependencyModules/). - -## Samples - -The [`integ-tests/`](https://github.com/ipjohnson/DependencyModules/tree/main/integ-tests) -directory is a working sample gallery, built and tested on every commit: - -| Sample | Shows | -|---|---| -| [`SutProject`](https://github.com/ipjohnson/DependencyModules/tree/main/integ-tests/SutProject) | Every registration shape, in one project | -| [`SutProject.Tests`](https://github.com/ipjohnson/DependencyModules/tree/main/integ-tests/SutProject.Tests) | Conventions, realms, keyed services, cross-wiring, factories, features, and all three mocking libraries | -| [`ConsoleTestProject`](https://github.com/ipjohnson/DependencyModules/tree/main/integ-tests/ConsoleTestProject) | Top-level statements and the generated `ApplicationModule` | -| [`web/WebApiApp`](https://github.com/ipjohnson/DependencyModules/tree/main/integ-tests/web/WebApiApp) | An ASP.NET Core host, with its own test project | - -## Reporting a problem - -When a registration is missing or wrong, three steps produce almost everything needed to -diagnose it: - -1. **Read the generated code.** Set `true` - and look under `obj/`. The registrations the generator produced are the ground truth. - (Point `CompilerGeneratedFilesOutputPath` inside `obj/` โ€” a folder in the project directory - gets compiled as ordinary source on the next build.) -2. **Turn on the generator log**, which records the configuration in effect, every module and - service discovered, and anything skipped along with the reason: - ```xml - - $(MSBuildProjectDirectory)/dmlogs - - ``` -3. **Check for `DM####` warnings** in the build output. The generator reports these for mistakes - it can detect โ€” see the - [diagnostics reference](https://ipjohnson.github.io/DependencyModules/reference/diagnostics). - -Please include the log and the generated file in any -[issue](https://github.com/ipjohnson/DependencyModules/issues). +- `SutProject` and `SecondarySutProject`: modules and services. +- `SutProject.Tests`: xUnit tests of these modules. +- `SutProject.NUnitTests`: NUnit tests of these modules. +- `ConsoleTestProject`: a console application. +- `web/WebApiApp` and `web/WebApiApp.Tests`: an ASP.NET Core application and its tests. ## License -MIT. See [LICENSE.txt](https://github.com/ipjohnson/DependencyModules/blob/main/LICENSE.txt) -and [CHANGELOG.md](https://github.com/ipjohnson/DependencyModules/blob/main/CHANGELOG.md). +DependencyModules has the [MIT license](https://github.com/ipjohnson/DependencyModules/blob/main/LICENSE.txt). diff --git a/src/DependencyModules.FakeItEasy/DependencyModules.FakeItEasy.csproj b/src/DependencyModules.FakeItEasy/DependencyModules.FakeItEasy.csproj index 80b4d1b..c5f051a 100644 --- a/src/DependencyModules.FakeItEasy/DependencyModules.FakeItEasy.csproj +++ b/src/DependencyModules.FakeItEasy/DependencyModules.FakeItEasy.csproj @@ -6,7 +6,7 @@ enable True DependencyModules.FakeItEasy - FakeItEasy mocking support for DependencyModules test integrations. Add [FakeItEasySupport] to resolve any unregistered dependency as a FakeItEasy fake, and use [Mock] on a test parameter to inject and configure it. Pair with a test framework integration such as DependencyModules.xUnit. + FakeItEasy fakes for DependencyModules tests. With [FakeItEasySupport], a test parameter with [Mock] gets a fake. The service provider of the test gives the same fake to the other services. Use it with DependencyModules.xUnit or DependencyModules.NUnit. true diff --git a/src/DependencyModules.Moq/DependencyModules.Moq.csproj b/src/DependencyModules.Moq/DependencyModules.Moq.csproj index dd3b116..2e8453c 100644 --- a/src/DependencyModules.Moq/DependencyModules.Moq.csproj +++ b/src/DependencyModules.Moq/DependencyModules.Moq.csproj @@ -6,7 +6,7 @@ enable True DependencyModules.Moq - Moq mocking support for DependencyModules test integrations. Add [MoqSupport], then take a Mock<T> test parameter to get the mock itself, or mark a parameter [Mock] to get the mocked instance. Either way the service under test is built against the same mock. Pair with a test framework integration such as DependencyModules.xUnit. + Moq mocks for DependencyModules tests. With [MoqSupport], a Mock<T> parameter gets the mock, and a [Mock] parameter gets the mock object. The service provider of the test gives the same mock object to the other services. Use it with DependencyModules.xUnit or DependencyModules.NUnit. true diff --git a/src/DependencyModules.NSubstitute/DependencyModules.NSubstitute.csproj b/src/DependencyModules.NSubstitute/DependencyModules.NSubstitute.csproj index b26a745..a58ac7c 100644 --- a/src/DependencyModules.NSubstitute/DependencyModules.NSubstitute.csproj +++ b/src/DependencyModules.NSubstitute/DependencyModules.NSubstitute.csproj @@ -6,7 +6,7 @@ enable True DependencyModules.NSubstitute - NSubstitute mocking support for DependencyModules test integrations. Add [NSubstituteSupport] to resolve any unregistered dependency as an NSubstitute substitute, and use [Mock] on a test parameter to inject and configure it. Pair with a test framework integration such as DependencyModules.xUnit. + NSubstitute mocks for DependencyModules tests. With [NSubstituteSupport], a test parameter with [Mock] gets a substitute. The service provider of the test gives the same substitute to the other services. Use it with DependencyModules.xUnit or DependencyModules.NUnit. true diff --git a/src/DependencyModules.NUnit/DependencyModules.NUnit.csproj b/src/DependencyModules.NUnit/DependencyModules.NUnit.csproj index a14853e..0ad6f38 100644 --- a/src/DependencyModules.NUnit/DependencyModules.NUnit.csproj +++ b/src/DependencyModules.NUnit/DependencyModules.NUnit.csproj @@ -6,7 +6,7 @@ enable True DependencyModules.NUnit - NUnit integration for DependencyModules. Provides the [ModuleTest] attribute, which builds a service provider from your modules for every test iteration and injects the services a test method asks for, plus [ModuleTestCase] for data-driven tests and attributes for per-test service overrides and value injection. + NUnit support for DependencyModules. [ModuleTest] builds a service provider from your modules for each run of a test. The test parameters get their values from this service provider. [ModuleTestCase] gives data rows. true diff --git a/src/DependencyModules.Runtime/DependencyModules.Runtime.csproj b/src/DependencyModules.Runtime/DependencyModules.Runtime.csproj index b7d4a60..bf65583 100644 --- a/src/DependencyModules.Runtime/DependencyModules.Runtime.csproj +++ b/src/DependencyModules.Runtime/DependencyModules.Runtime.csproj @@ -6,7 +6,7 @@ enable True DependencyModules.Runtime - Runtime support for DependencyModules: attributes, module interfaces, and IServiceCollection extensions used to compose attribute-driven dependency injection modules. Pair with the DependencyModules.SourceGenerator package, which generates the registration code. + The runtime package of DependencyModules. It contains the service attributes, the module interfaces, and the AddModule methods for IServiceCollection. Use it with the DependencyModules.SourceGenerator package, which writes the registration code. true diff --git a/src/DependencyModules.SourceGenerator/DependencyModules.SourceGenerator.csproj b/src/DependencyModules.SourceGenerator/DependencyModules.SourceGenerator.csproj index a4456dd..b9e8ae7 100644 --- a/src/DependencyModules.SourceGenerator/DependencyModules.SourceGenerator.csproj +++ b/src/DependencyModules.SourceGenerator/DependencyModules.SourceGenerator.csproj @@ -9,7 +9,7 @@ enable True DependencyModules.SourceGenerator - Roslyn source generator for DependencyModules. Turns [DependencyModule], [SingletonService], [ScopedService], [TransientService], and [CrossWireService] attributes into IServiceCollection registration code at compile time โ€” no reflection or assembly scanning at run time. Pair with the DependencyModules.Runtime package. + The source generator of DependencyModules. When you compile, it writes the IServiceCollection registration code for each [DependencyModule] and for the classes with [SingletonService], [ScopedService], [TransientService], or [CrossWireService]. The generated code does not use reflection to find services. Use it with the DependencyModules.Runtime package. true false diff --git a/src/DependencyModules.Testing/DependencyModules.Testing.csproj b/src/DependencyModules.Testing/DependencyModules.Testing.csproj index ce40de8..3ff2aa8 100644 --- a/src/DependencyModules.Testing/DependencyModules.Testing.csproj +++ b/src/DependencyModules.Testing/DependencyModules.Testing.csproj @@ -6,7 +6,7 @@ enable True DependencyModules.Testing - Test-framework-neutral building blocks for DependencyModules test integrations. Contains the [Mock] and [InjectValues] parameter attributes, the mocking seam (IMockSupportAttribute) that the DependencyModules.NSubstitute, DependencyModules.Moq and DependencyModules.FakeItEasy packages implement, the hooks an integration uses to build a test's container, and attribute discovery helpers. Reference a test framework integration such as DependencyModules.xUnit rather than this package directly. + The test attributes and interfaces of DependencyModules: [Mock], [InjectValues], [Shared], [TestExport], and the interfaces for mock libraries and test attributes. It also contains the helpers that find the attributes of a test on the method, the class, and the assembly. It does not depend on a test framework. The DependencyModules.xUnit and DependencyModules.NUnit packages use this package. Add one of those packages to a test project. true diff --git a/src/DependencyModules.xUnit/DependencyModules.xUnit.csproj b/src/DependencyModules.xUnit/DependencyModules.xUnit.csproj index 4ec76af..c54c3b0 100644 --- a/src/DependencyModules.xUnit/DependencyModules.xUnit.csproj +++ b/src/DependencyModules.xUnit/DependencyModules.xUnit.csproj @@ -6,7 +6,7 @@ enable True DependencyModules.xUnit - xUnit v3 integration for DependencyModules. Provides the [ModuleTest] attribute, which builds a service provider from your modules and injects the services a test method asks for, plus attributes for per-test service overrides and value injection. + xUnit v3 support for DependencyModules. [ModuleTest] builds a service provider from your modules for each test and each data row. The test parameters get their values from this service provider. true diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts index de71a00..38e0c78 100644 --- a/website/.vitepress/config.ts +++ b/website/.vitepress/config.ts @@ -1,140 +1,101 @@ -import { defineConfig } from 'vitepress'; +import { defineConfig } from 'vitepress' -// Published to https://ipjohnson.github.io/DependencyModules/, so every absolute path needs the -// repository name as a base. Getting this wrong is the classic Pages failure: the site builds, the -// landing page loads, and every asset and internal link 404s. -const base = '/DependencyModules/'; +const description = 'A source generator for Microsoft.Extensions.DependencyInjection registrations.' + +const packages = [ + 'Runtime', + 'SourceGenerator', + 'Testing', + 'xUnit', + 'NUnit', + 'NSubstitute', + 'Moq', + 'FakeItEasy', + 'SourceGenerator.Impl', +] export default defineConfig({ + lang: 'en-US', title: 'DependencyModules', - description: - 'Compile-time dependency injection for .NET. Attributes and conventions become registration ' + - 'code at build time โ€” no reflection, no assembly scanning, trimming and Native AOT safe.', - base, - lang: 'en-GB', + description, + base: '/DependencyModules/', cleanUrls: true, - - // A broken internal link should fail the build rather than ship. The docs cross-reference heavily - // and a rename would otherwise rot links silently. + lastUpdated: true, ignoreDeadLinks: false, - head: [ - ['link', { rel: 'icon', href: `${base}favicon.svg`, type: 'image/svg+xml' }], + ['link', { rel: 'icon', type: 'image/svg+xml', href: '/DependencyModules/favicon.svg' }], ['meta', { name: 'theme-color', content: '#6d5bd5' }], ['meta', { property: 'og:type', content: 'website' }], ['meta', { property: 'og:title', content: 'DependencyModules' }], - [ - 'meta', - { - property: 'og:description', - content: 'Compile-time dependency injection for .NET. No reflection, AOT safe.', - }, - ], + ['meta', { property: 'og:description', content: description }], ], - themeConfig: { - siteTitle: 'DependencyModules', logo: { light: '/logo.svg', dark: '/logo-dark.svg' }, - + outline: [2, 3], nav: [ { text: 'Guide', link: '/guide/getting-started', activeMatch: '/guide/' }, - { text: 'Reference', link: '/reference/diagnostics', activeMatch: '/reference/' }, + { text: 'Reference', link: '/reference/attributes', activeMatch: '/reference/' }, { text: 'NuGet', + items: packages.map((name) => ({ + text: `DependencyModules.${name}`, + link: `https://www.nuget.org/packages/DependencyModules.${name}/`, + })), + }, + ], + sidebar: [ + { + text: 'Introduction', + items: [{ text: 'Getting started', link: '/guide/getting-started' }], + }, + { + text: 'Registration', + items: [ + { text: 'Services', link: '/guide/services' }, + { text: 'Modules', link: '/guide/modules' }, + { text: 'Conventions', link: '/guide/conventions' }, + { text: 'Decorators', link: '/guide/decorators' }, + { text: 'Interception', link: '/guide/interception' }, + { text: 'Environments', link: '/guide/environments' }, + ], + }, + { + text: 'Testing', + items: [ + { text: 'Write tests', link: '/guide/testing' }, + { text: 'xUnit', link: '/guide/testing-xunit' }, + { text: 'NUnit', link: '/guide/testing-nunit' }, + { text: 'Mocks', link: '/guide/testing-mocking' }, + { text: 'More service providers', link: '/guide/testing-container-source' }, + ], + }, + { + text: 'Advanced', + items: [ + { text: 'Native AOT and trimming', link: '/guide/aot' }, + { text: 'Extending', link: '/guide/extending' }, + { text: 'Troubleshooting', link: '/guide/troubleshooting' }, + ], + }, + { + text: 'Reference', items: [ - { text: 'Runtime', link: 'https://www.nuget.org/packages/DependencyModules.Runtime/' }, - { - text: 'SourceGenerator', - link: 'https://www.nuget.org/packages/DependencyModules.SourceGenerator/', - }, - { text: 'Testing', link: 'https://www.nuget.org/packages/DependencyModules.Testing/' }, - { text: 'xUnit', link: 'https://www.nuget.org/packages/DependencyModules.xUnit/' }, - { text: 'NUnit', link: 'https://www.nuget.org/packages/DependencyModules.NUnit/' }, - { - text: 'NSubstitute', - link: 'https://www.nuget.org/packages/DependencyModules.NSubstitute/', - }, - { text: 'Moq', link: 'https://www.nuget.org/packages/DependencyModules.Moq/' }, - { - text: 'FakeItEasy', - link: 'https://www.nuget.org/packages/DependencyModules.FakeItEasy/', - }, + { text: 'Attributes', link: '/reference/attributes' }, + { text: 'MSBuild properties', link: '/reference/msbuild' }, + { text: 'Diagnostics', link: '/reference/diagnostics' }, + { text: 'API', link: '/reference/api' }, ], }, ], - - sidebar: { - '/guide/': [ - { - text: 'Getting started', - items: [ - { text: 'Installation', link: '/guide/getting-started' }, - { text: 'Modules', link: '/guide/modules' }, - { text: 'Registering services', link: '/guide/services' }, - ], - }, - { - text: 'Testing', - items: [ - { text: 'Testing modules', link: '/guide/testing' }, - { text: 'xUnit', link: '/guide/testing-xunit' }, - { text: 'NUnit', link: '/guide/testing-nunit' }, - { text: 'Mocking frameworks', link: '/guide/testing-mocking' }, - { text: 'Testing registrations', link: '/guide/testing-registrations' }, - ], - }, - { - text: 'Registering in bulk', - items: [ - { text: 'Conventions', link: '/guide/conventions' }, - { text: 'Scanning a package', link: '/guide/scanning' }, - ], - }, - { - text: 'Changing behaviour', - items: [ - { text: 'Decorators', link: '/guide/decorators' }, - { text: 'Interception', link: '/guide/interception' }, - { text: 'Environments', link: '/guide/environments' }, - ], - }, - { - text: 'Everything else', - items: [ - { text: 'Trimming and AOT', link: '/guide/aot' }, - { text: 'Troubleshooting', link: '/guide/troubleshooting' }, - { text: 'Writing your own generator', link: '/guide/extending' }, - ], - }, - ], - '/reference/': [ - { - text: 'Reference', - items: [ - { text: 'Diagnostics', link: '/reference/diagnostics' }, - { text: 'Attributes', link: '/reference/attributes' }, - { text: 'Runtime interfaces', link: '/reference/interfaces' }, - { text: 'Convention API', link: '/reference/conventions-api' }, - { text: 'MSBuild properties', link: '/reference/msbuild' }, - ], - }, - ], - }, - socialLinks: [{ icon: 'github', link: 'https://github.com/ipjohnson/DependencyModules' }], - search: { provider: 'local' }, - editLink: { pattern: 'https://github.com/ipjohnson/DependencyModules/edit/main/website/:path', - text: 'Edit this page on GitHub', + text: 'Change this page on GitHub', }, - footer: { - message: 'Released under the MIT License.', - copyright: 'Copyright ยฉ Ian Johnson', + message: 'MIT license', + copyright: 'Copyright (c) Ian Johnson', }, - - outline: [2, 3], }, -}); +}) diff --git a/website/guide/aot.md b/website/guide/aot.md index 6a6d801..e3525fb 100644 --- a/website/guide/aot.md +++ b/website/guide/aot.md @@ -1,88 +1,65 @@ -# Trimming and Native AOT +# Native AOT and trimming -## The problem +You can use DependencyModules in applications that you publish with Native AOT or with trimming. The generator finds the services when you compile. The generated code references each service type directly. -You publish trimmed, or as Native AOT, and the application dies at startup: +## The runtime package -``` -System.InvalidOperationException: Unable to resolve service for type 'MyApp.IHandler' -``` - -Nothing changed in your code, and it works perfectly in development. This is the classic failure of -runtime assembly scanning, and it is worth understanding why it happens rather than which flag -suppresses it. +`DependencyModules.Runtime` sets `IsAotCompatible` to `true`. Its build makes the trimming and AOT warnings IL2026, IL2055, IL2067, IL2072, IL2075, IL2087, and IL3050 into errors. The runtime does not examine assemblies to find services. -A reflection-based scanner enumerates an assembly's types when the application starts. The trimmer -runs long before that, and its job is to remove any type nothing references. It has no way to know -your scanner will go looking for `CreateOrderHandler`, because nothing in your code mentions -`CreateOrderHandler` โ€” that is the whole appeal of scanning. So the trimmer removes it, the scan -finds nothing, and the container has no registration. +## Generated registrations -The failure only appears in a published build, which is the worst place to discover it. +The generated code registers each class with its type, for example `services.AddSingleton(typeof(IClock), typeof(SystemClock))`. The service provider then calls the constructor of the class. The registration methods of `Microsoft.Extensions.DependencyInjection` tell the trimmer to keep the public constructors of the class. -## How DependencyModules helps +Each generated method with registrations has a static field. The initializer of the field gives the method to the module. The field has a `[DynamicDependency]` attribute that identifies the method. Thus the trimmer keeps these methods. -The same work happens during the build instead, and each match is emitted as a literal `typeof()` -into your assembly: - -```csharp -services.AddScoped(typeof(IHandler), typeof(CreateOrderHandler)); -``` +The generated code examines the environment conditions at run time with `if` statements. Thus the generated code references all classes that have conditions, and the trimmer keeps these classes. -Two things follow from that one line, and together they are the whole story. +Decorators and interceptor wrappers use constructor calls in the generated code. They do not use reflection to make instances. The interceptor wrapper for a generic class is different. The service provider makes that wrapper from its type. -**The trimmer roots the type.** A `typeof()` in your code is an ordinary static reference โ€” exactly -the thing the trimmer is looking for. There is nothing dynamic to see through. +## Generated factories -**The constructor survives too.** `ServiceDescriptor`'s implementation-type parameter carries -`[DynamicallyAccessedMembers(PublicConstructors)]`, and that annotation can only flow to a type the -compiler knows about. Because the type is named literally, it does. +The generator can also write a factory for each registration. The factory calls the constructor of the class in the generated code: -Both hold for [types in a referenced package](/guide/scanning) as well, which is the case runtime -scanners handle worst. +```csharp +services.AddSingleton( + typeof(global::Shop.IPriceCalculator), + provider => new global::Shop.PriceCalculator() +); +``` -## What this covers +To use generated factories for all modules of a project, set the `DependencyModules_GenerateFactories` MSBuild property: -- Attribute registration -- Conventions, including open generics and referenced-assembly scanning -- Decorators and interception โ€” the wrapper is generated code in your own assembly +```xml + + true + +``` -## What it does not cover +To use generated factories for one module, set `GenerateFactories = true` on `[DependencyModule]`. If a module sets `GenerateFactories`, the generator uses the value of the module and not the MSBuild property. -**Environment conditions decide behaviour, not size.** The test runs at run time, so both branches -compile and every conditionally registered type stays referenced. Removing a service from a build is -a compile-time decision, and belongs to `#if`. See -[what conditions cost](/guide/environments#what-conditions-cost). +For a factory, the generator selects the constructor in this sequence: -**Open generic registration is the least AOT-friendly part of the container itself**, independent of -this library โ€” the container has to construct a closed type at run time, and Native AOT only has code -for the instantiations the compiler could see. +1. A constructor with `[ActivatorUtilitiesConstructor]`. +2. The primary constructor, if it has parameters. +3. The constructor with the most parameters. -In practice the line falls between reference and value type arguments. Measured on a published -`osx-arm64` binary, with `[SingletonService]` on `Bin : IBin`: +Without generated factories, the service provider selects the constructor. -``` -GetRequiredService>() works โ€” reference types share one instantiation -GetRequiredService>() InvalidOperationException: Unable to create a generic service - for type 'IBin`1[System.Int32]' because 'System.Int32' is a - ValueType. Native code to support creating generic services - might not be available with native AOT. -``` +The generator does not use `private` constructors. It gets each constructor parameter from the service provider: -This is the container, not the generator: an [intercepted](/guide/interception) open generic behaves -exactly the same way, because it is registered the same way. If you are targeting Native AOT, register -closed constructions โ€” a [convention](/guide/conventions) over the open generic does that for you, -emitting one registration per implementation. +| Parameter | Generated call | +| --- | --- | +| `IServiceProvider` | The service provider. | +| A nullable type, for example `IClock?` | `GetService`. The value is `null` if there is no registration. | +| A parameter with `[FromKeyedServices("key")]` | `GetRequiredKeyedService`, or `GetKeyedService` for a nullable type. | +| All other parameters | `GetRequiredService`. | -**Runtime assembly discovery is not supported**, because there would be nothing to resolve at build -time. See [Scanning a package](/guide/scanning). +The generator does not write factories for generic classes. It also does not write factories for the service types that a class with `[Intercept]` registers. -## The generator never ships +Each factory that the generator writes returns its class. Thus a decorator that sets `Implementation` finds the implementation of the registration. For more information, refer to [Decorate one implementation](./decorators.md#decorate-one-implementation). -Worth stating plainly, since "source generator" sometimes reads as "extra thing in my output". +## Test packages -The analyzer packages contain no `lib/` folder, so they cannot reach your build output at all, and -`DevelopmentDependency=true` stops them flowing transitively to anything referencing your library. +`DependencyModules.Testing`, the test packages, and the mock packages use reflection to make modules, mocks, and test parameters. -Only `DependencyModules.Runtime` is a run-time dependency, and it holds interfaces, attributes and a -small registry โ€” no Roslyn, and no reflection over your types. +Use these packages only in test projects. Do not publish them with Native AOT. diff --git a/website/guide/conventions.md b/website/guide/conventions.md index ca0701d..da87233 100644 --- a/website/guide/conventions.md +++ b/website/guide/conventions.md @@ -1,334 +1,328 @@ # Conventions -## The problem +A convention registers all classes that agree with a set of conditions. You do not put an attribute on each class. When you compile, the generator reads the convention. It then writes one registration for each class that the convention selects. -Attributes are explicit, which is a virtue right up until you have forty of them saying the same -thing: +You do not add a package for conventions. The convention types are in `DependencyModules.Runtime`, and `DependencyModules.SourceGenerator` reads the conventions. + +## Declare a convention + +Implement `IConventionModule` on a module. Write the conventions in the `Conventions` method. ```csharp -[TransientService] public class CreateOrderHandler : IRequestHandler { } -[TransientService] public class RenameOrderHandler : IRequestHandler { } -[TransientService] public class ShipOrderHandler : IRequestHandler { } -// โ€ฆ thirty-seven more -``` +using DependencyModules.Runtime.Attributes; +using DependencyModules.Runtime.Conventions; -Nothing here is a decision. Every handler is transient because every handler is transient, and the -only real event is the day someone writes the forty-first and forgets the attribute. You are back to -the hand-maintained list, just spread across forty files instead of gathered in one. +namespace Billing; -## How DependencyModules helps +public interface IInvoiceRule +{ + bool Accepts(decimal total); +} -State the rule once, and let the generator find the types that fit **while it builds**: +public class MinimumTotalRule : IInvoiceRule +{ + public bool Accepts(decimal total) => total > 0; +} -```csharp -using DependencyModules.Runtime.Conventions; +public class MaximumTotalRule : IInvoiceRule +{ + public bool Accepts(decimal total) => total < 10_000; +} [DependencyModule] -public partial class DataModule : IConventionModule +public partial class BillingModule : IConventionModule { - void IConventionModule.Conventions(IConventionDefinitions conventions) + public void Conventions(IConventionDefinitions conventions) { - conventions.RegisterAll(typeof(IRequestHandler<,>)).AsTransient(); + conventions.RegisterAll().AsSingleton(); } } ``` -Forty registrations, one declaration, and the forty-first handler registers itself by existing. +This convention registers `MinimumTotalRule` and `MaximumTotalRule` as `IInvoiceRule`. -Nothing extra to install: the contracts are part of `DependencyModules.Runtime` and the generator -that reads them is part of `DependencyModules.SourceGenerator`, both of which you already have. +The generator reads the `Conventions` method when you compile. The method does not run. Thus these conditions are applicable to the method: -::: tip Explicit or implicit, either compiles -`void IConventionModule.Conventions(โ€ฆ)` as above, or an ordinary -`public void Conventions(IConventionDefinitions conventions)` โ€” both are matched. The explicit form -is used when a type somehow carries both, since that is the one satisfying the interface. -::: +- The method must have a body with statements. +- Each statement starts with a `RegisterAll` call on the parameter of the method. +- Each statement continues with a chain of calls on the result of `RegisterAll`. +- The arguments must be values that the compiler knows, for example string literals, constants, and `nameof` expressions. -## The body never runs +If the generator cannot read a statement, it gives the error DM0009 and does not use the statement. -This is the one genuinely surprising thing on this page, and everything else follows from it. +If one argument of a call is not a value that the compiler knows, the generator gives DM0009 for the statement. For example, `IfEnvironmentValue(Keys.Feature, "on")` gives DM0009 if `Keys.Feature` is a `static readonly` field. Use string literals or `const` fields for these arguments. -`Conventions` is **read at compile time, not executed**. The generator parses that method as source -and works out what you asked for. It is a declaration that happens to be written in C# syntax. +You can implement the method as a public method or as an explicit interface implementation: `void IConventionModule.Conventions(IConventionDefinitions conventions)`. If a type has the two methods, the generator reads the explicit interface implementation. -Two consequences: +The type that implements `IConventionModule` must have `[DependencyModule]`. If it does not have this attribute, the generator gives the error DM0009. The generator does this only if the project has one or more modules. -**Only the calls documented on this page may appear in it.** A loop, an `if`, a local variable or a -call to your own helper method cannot be read, and is reported as -[DM0009](/reference/diagnostics#dm0009) rather than silently ignored. +## Select classes by service type -**What comes out is ordinary registration code** โ€” one `services.AddTransient(โ€ฆ)` per match, sitting -in your assembly. Turn on `EmitCompilerGeneratedFiles` and read it: +`RegisterAll` sets the service type for the convention: -```csharp -// generated -services.AddTransient(typeof(IRequestHandler), typeof(CreateOrderHandler)); -services.AddTransient(typeof(IRequestHandler), typeof(RenameOrderHandler)); -``` +| Call | Selected classes | +| --- | --- | +| `RegisterAll()` | Classes that implement `IService`. | +| `RegisterAll(typeof(IService))` | The same as `RegisterAll()`. | +| `RegisterAll(typeof(IHandler<>))` | Classes that implement a type of `IHandler<>`. | +| `RegisterAll()` | Classes that agree with the filters. A service type is not necessary. | -## What matches +For an open generic service type, the convention registers each class as each closed type that the class implements. A generic class can also implement the open generic type, for example `Handler : IHandler`. The convention then writes an open generic registration. The class must give its type parameters to the interface without changes and in the same sequence. The convention does not register a class such as `Handler : IHandler`. -A type matches when it **declares** the service type, or declares an interface that extends it: +A class implements the service type for a convention when one of these conditions is true: -```csharp -public interface IAuditedRepository : IRepository { } +- The class declaration contains the service type. +- The class declaration contains an interface that derives from the service type. -public class OrderRepository : IRepository { } // matches -public class AuditedOrders : IAuditedRepository { } // matches โ€” IAuditedRepository extends IRepository -``` +A convention does not select a class if only its base class implements the service type. `IncludeBaseClasses()` also selects these classes. -An interface declaring that it extends another is a deliberate statement that it is substitutable for -it, so it counts. +The service type must be an interface. A convention for a class type selects no classes. The generator then gives the warning DM0005. -Reaching the service type through a **base class** does not count, unless you ask for it: +### Candidate classes -```csharp -public abstract class RepositoryBase : IRepository { } -public class ProductRepository : RepositoryBase { } // no match by default +A convention examines only the classes of the project that contains the module. A class is a candidate when all these conditions are true: -conventions.RegisterAll().IncludeBaseClasses().AsScoped(); // now it matches -``` +- It is a class, a record class, or a record struct. +- It is not `static` and not `abstract`. +- The generated code can use it. Thus it is not `private`, `protected`, or `file`, and it is not in a `private` or `protected` class. An `internal` class and a `protected internal` class are candidates. +- It does not have a service attribute or `[Decorator]`. -Turn it on for the common `CreateOrderValidator : AbstractValidator` shape, where the -interface only ever arrives through a framework base class. Bear in mind that every future subclass -of that base joins the convention too. +A class with a service attribute keeps the registration from its attribute. The convention does not register this class again. -::: info Attributes always win -A type carrying `[SingletonService]`, `[ScopedService]`, `[TransientService]` or `[CrossWireService]` -is never a convention candidate, so an attribute is how you exempt one type from a rule that would -otherwise catch it. +A nested class can be a candidate. A nested class without an access modifier is `private`. Thus the convention does not select it. -Neither is a `[Decorator]` โ€” a decorator implements the interface it decorates, and it is not a -service in its own right. -::: +A selected class must have a `public` constructor, or no declared constructor. The service provider uses only `public` constructors. If the module uses [generated factories](./aot.md#generated-factories), the generated code calls the constructor. Then an `internal` or `protected internal` constructor is also correct. This is not true for a class with `[Intercept]`, because the generator does not write a factory for it. If the class has no constructor that its registration can use, the generator gives the warning DM0006 and does not register the class. -## Open generics +## Lifetime -An open generic cannot be written as a type argument, so use the `Type` overload. Each match is -registered against the **closed** construction it actually implements: +Each convention must call one lifetime method: -```csharp -public class CreateOrderHandler : IRequestHandler { } -public class RenameOrderHandler : IRequestHandler { } - -conventions.RegisterAll(typeof(IRequestHandler<,>)).AsTransient(); -``` +- `AsSingleton()` +- `AsScoped()` +- `AsTransient()` -```csharp -// generated -services.AddTransient(typeof(IRequestHandler), typeof(CreateOrderHandler)); -services.AddTransient(typeof(IRequestHandler), typeof(RenameOrderHandler)); -``` +If a convention has no lifetime or more than one lifetime, the generator gives the error DM0009. -A type implementing **several** closings is registered against all of them: +## Registration shape -```csharp -public class OrderEvents - : INotificationHandler, INotificationHandler { } -``` +By default, a convention registers each class as the service type that it selected. These calls change the registrations: -Both are registered. They are different service types, so this is not one implementation registered -twice. +| Call | Registrations | +| --- | --- | +| No call | Each selected interface. For a service type that is an open generic type, each closed type that the class implements. | +| `AsSelf()` | The class type only. | +| `AsSelfWithInterfaces()` | The class type and each interface of the class. The convention does not include the interfaces in `System` namespaces. | +| `AlsoAsSelf()` | Each selected interface and the class type. | +| `As()` | The type `TService` only. | +| `AsMatchingInterface()` | The interface with the name `I` and the class name. If the class has no such interface, the convention does not register the class. | -A generic implementation that closes nothing registers as the open generic, and the container closes -it per request: +For `AsSelfWithInterfaces()`, the interfaces of the class are the interfaces in the class declaration and the interfaces that they derive from. If the convention calls `IncludeBaseClasses()`, the convention also includes the interfaces of the base classes. For `AsSelfWithInterfaces()`, the convention removes the interfaces in `System` namespaces. This is also true for the service type of the convention. If the convention uses a different registration shape, it registers a service type in a `System` namespace. -```csharp -public class PassThroughCache : ICache { } // registers ICache<> itself -``` +If a convention calls `AsSelfWithInterfaces()` or `AlsoAsSelf()`, the interface registrations get the instance from the registration of the class type. Thus, for the `Singleton` and `Scoped` lifetimes, all these registrations give the same instance in a scope. If `AsSelfWithInterfaces()` finds no interface, it registers only the class type. -## Narrowing what matches +The generator cannot cross-wire a generic class. If a convention with `AlsoAsSelf()` or `AsSelfWithInterfaces()` selects a generic class, the generator gives the warning DM0014 and does not register that class. Select the generic classes with a different convention that does not use these calls. -A service type is often too broad on its own. Filters chain, and combine with **and**; alternatives -go inside a single call: +Use only one of `AsSelf()`, `AsSelfWithInterfaces()`, and `AlsoAsSelf()` in a convention. If you use more than one, the generator gives the error DM0009. ```csharp -conventions.RegisterAll() - .InNamespaceOf() // and: in this namespace or below it - .WithoutName("*Legacy") // and: not named like this - .WithAttribute() // and: carrying this attribute - .AsScoped(); -``` - -| Filter | Matches | -|---|---| -| `InNamespaceOf()` | the marker's namespace **and those beneath it** | -| `InNamespaces(params string[])` | the given namespaces and those beneath them | -| `InExactNamespaces(params string[])` | only those namespaces, not nested ones | -| `NotInNamespaceOf()`, `NotInNamespaces(โ€ฆ)` | excludes; applied after inclusions | -| `WithAttribute()`, `WithoutAttribute()` | the attribute type, resolved rather than name-matched | -| `WithName(params string[])`, `WithoutName(โ€ฆ)` | name globs โ€” see below | - -Namespace and name inclusions of the same kind combine with **or**. Exclusions are applied afterwards, -and any one of them removes a match. - -### Name globs +using DependencyModules.Runtime.Attributes; +using DependencyModules.Runtime.Conventions; -Two wildcards, and no regular expressions: +namespace Billing; -| Token | Matches | -|---|---| -| `*` | zero or more characters | -| `?` | exactly one character | +public interface IExporter +{ + string Export(decimal total); +} -A pattern containing a dot is matched against the full `Namespace.TypeName`; otherwise against the -bare type name. Matching is ordinal and case-sensitive, like C# identifiers. +public class CsvExporter : IExporter +{ + public string Export(decimal total) => total.ToString(); +} -```csharp -conventions.RegisterAll().WithName("*Repository", "*Store").AsScoped(); +[DependencyModule] +public partial class ExportModule : IConventionModule +{ + public void Conventions(IConventionDefinitions conventions) + { + conventions.RegisterAll().AlsoAsSelf().AsSingleton(); + } +} ``` -Prefer a service type, an attribute or a namespace wherever you can. A name pattern will cheerfully -match a class somebody adds next year โ€” and `*Handler` matches `LoggingHandler` too. - -## Registering types that implement nothing - -Some things worth registering implement no interface at all. `RegisterAll()` with no service type -selects by filter alone: +In this example, the service provider gives the same instance for `IExporter` and `CsvExporter`. + +## Filters + +Filters decrease the number of selected classes. You can use more than one filter in a convention. + +| Call | Result | +| --- | --- | +| `InNamespaceOf()` | Selects classes in the namespace of `TMarker` and in its nested namespaces. | +| `InNamespaces("A", "B")` | Selects classes in the given namespaces and in their nested namespaces. | +| `InExactNamespaces("A", "B")` | Selects classes in the given namespaces only. | +| `NotInNamespaceOf()` | Removes classes in the namespace of `TMarker` and in its nested namespaces. | +| `NotInNamespaces("A", "B")` | Removes classes in the given namespaces and in their nested namespaces. | +| `WithName("*Repository")` | Selects classes with a name that agrees with one of the patterns. | +| `WithoutName("*Fake")` | Removes classes with a name that agrees with one of the patterns. | +| `WithAttribute()` | Selects classes that have the attribute. | +| `WithoutAttribute()` | Removes classes that have the attribute. | + +These conditions are applicable to filters: + +- A nested namespace starts with the namespace and a period. The filter for `Shop.Orders` includes `Shop.Orders.Import`. It does not include `Shop.OrdersArchive`. +- In a name pattern, `*` agrees with zero or more characters. `?` agrees with one character. Name patterns are case-sensitive. +- If a name pattern contains a period, the filter compares the pattern with the full name of the class (the namespace and the class name). If the name pattern does not contain a period, the filter compares the pattern with the class name only. +- The name of a nested class contains the names of the classes around it, for example `Outer.Inner`. +- An attribute filter compares the attribute type. Thus the filter also finds an attribute that you write with its full name or with an alias. +- A class must agree with each type of filter that the convention has: namespace, name, and attribute. +- If a convention has more than one namespace filter that selects, the class must be in one of these namespaces. +- If a convention has more than one name pattern that selects, the class name must agree with one of these patterns. +- If a convention has more than one `WithAttribute` filter, the class must have all these attributes. +- The convention does not select a class that agrees with a filter that removes. ```csharp -conventions.RegisterAll() - .InNamespaceOf() - .WithName("*Calculator") - .AsSelf() - .AsScoped(); -``` +using DependencyModules.Runtime.Attributes; +using DependencyModules.Runtime.Conventions; -Because there is no interface to constrain it, this form **requires** a shape and at least one -filter. Missing either is [DM0009](/reference/diagnostics#dm0009). +namespace Billing.Storage; -## What each match is registered as +public interface IStore +{ + string Name { get; } +} -| Call | Registers | -|---|---| -| *(default)* | the service type the convention matched | -| `AsSelf()` | the match's own concrete type, instead of the interface | -| `AlsoAsSelf()` | the matched service type **and** the concrete type, sharing one instance | -| `AsSelfWithInterfaces()` | the concrete type and **every** interface it implements, sharing one instance | -| `AsMatchingInterface()` | the interface named after the type โ€” `Foo` as `IFoo` | -| `As()` | one named service type, whatever the match matched through | +public class InvoiceRepository : IStore +{ + public string Name => "invoices"; +} -### One instance or several +public class CustomerRepository : IStore +{ + public string Name => "customers"; +} -This is the distinction that catches people out with every scanning library, so it is worth being -explicit about. +public class RepositoryFake : IStore +{ + public string Name => "fake"; +} -```csharp -conventions.RegisterAll().AsSingleton(); // one registration -conventions.RegisterAll().AsSingleton(); // another, same class +[DependencyModule] +public partial class StorageModule : IConventionModule +{ + public void Conventions(IConventionDefinitions conventions) + { + conventions + .RegisterAll() + .InNamespaceOf() + .WithName("*Repository") + .AsScoped(); + } +} ``` -A class matched through two different interfaces gets **two registrations and two instances**. That -is what Scrutor and MediatR both produce, and for handlers it is usually what you want. - -When you want one instance reachable through several service types, say so: +### Selection by filters only -```csharp -conventions.RegisterAll(typeof(IValidator<>)).IncludeBaseClasses().AlsoAsSelf().AsScoped(); -``` +`RegisterAll()` without a service type selects classes only with filters. Two more conditions are applicable to this type of convention: -`AlsoAsSelf()` and `AsSelfWithInterfaces()` both cross-wire โ€” resolving any of the registered service -types gives the same instance. The difference is reach: `AlsoAsSelf()` registers only the interfaces -the convention matched, while `AsSelfWithInterfaces()` registers everything the type implements. +- The convention must have a filter that selects: a namespace filter, a name filter, or an attribute filter. Without such a filter, the convention could select all classes of the project. Thus the generator gives the error DM0009. +- The convention must set a registration shape, for example `AsSelf()`. Without a shape, the generator does not know the service type. Thus it gives the error DM0009. -::: warning AsSelfWithInterfaces skips System interfaces -Interfaces in `System` or a namespace beginning `System.` are not expanded into, so a type whose base -implements `IDisposable` does not become resolvable as `IDisposable`. +```csharp +using DependencyModules.Runtime.Attributes; +using DependencyModules.Runtime.Conventions; -This applies only to the automatic expansion. A service type you name yourself is always honoured, so -`RegisterAll()` still registers `IDisposable`. -::: +namespace Billing.Calculators; -## Lifetime, keys and registration strategy +public class TaxCalculator +{ + public decimal Tax(decimal total) => total * 0.2m; +} -A lifetime is **required**; there is no default. Omitting one is -[DM0009](/reference/diagnostics#dm0009) rather than a silent transient. +public class FeeCalculator +{ + public decimal Fee(decimal total) => 1.5m; +} -```csharp -conventions.RegisterAll() - .AsScoped() - .Using(RegistrationType.Try) // Add, Try, TryEnumerable or Replace - .WithKey("primary"); // literal, const or enum member +[DependencyModule] +public partial class CalculatorModule : IConventionModule +{ + public void Conventions(IConventionDefinitions conventions) + { + conventions + .RegisterAll() + .InNamespaceOf() + .WithName("*Calculator") + .AsSelf() + .AsTransient(); + } +} ``` -## Registering only in some environments +## Classes from a referenced assembly -A convention can carry an [environment condition](/guide/environments), so a whole rule applies only -where you want it rather than needing the attribute repeated on every class it matches: +`InAssemblyOf()` makes the convention examine the assembly that contains `TMarker`. The convention then does not examine the project. You can use `InAssemblyOf()` for a package or for a project that has no module. ```csharp -conventions.RegisterAll().IfEnvironment("Development").AsScoped(); -conventions.RegisterAll().IfEnvironmentValue("AUDIT", "on").AsSingleton(); +conventions.RegisterAll().InAssemblyOf().AsSingleton(); ``` -| Call | Registers when | -|---|---| -| `IfEnvironment(params string[])` | the environment name matches any of them | -| `IfNotEnvironment(params string[])` | it matches none of them | -| `IfEnvironmentValue(key)` ยท `IfEnvironmentValue(key, value)` | the key is present, or equals exactly | -| `IfNotEnvironmentValue(โ€ฆ)` | the inverse of either form | +A convention examines one assembly. If a convention calls `InAssemblyOf` two times, the last call replaces the first call. -The test runs when the modules are applied, not while the build runs โ€” so this changes what gets -registered, not what the convention matched. Every match is still emitted, behind the same guard. +In a referenced assembly, a class is a candidate when all these conditions are true: -A class carrying its own condition combines with the convention's using **and**, so neither -declaration can quietly override the other: +- It is a `public` class. +- It is not a nested class. +- It is not `abstract` and not `static`. +- It does not have a service attribute or `[Decorator]`. -```csharp -conventions.RegisterAll().IfEnvironment("Development").AsSingleton(); +A selected class must have a `public` constructor. -[IfEnvironmentValue("REGION", "eu")] -public class EuFoo : IFoo { } // Development AND REGION=eu -``` +The generator reads the environment attributes of a class from a referenced assembly. The conditions of the class and the conditions of the convention are applicable. If a condition of the class has no name or no key, the generator gives the warning DM0012 at the convention statement. -## When two conventions collide +A class from a referenced assembly has no location in your source code. Thus the generator shows its diagnostics, for example DM0010, at the convention statement. -Two conventions in one module registering the same implementation under the **same service type** is -[DM0004](/reference/diagnostics#dm0004), an error โ€” the lifetime would be ambiguous: +## Keys and registration type -```csharp -conventions.RegisterAll().AsScoped(); -conventions.RegisterAll().AsSingleton(); // DM0004 -``` +`WithKey(key)` registers each class as a keyed service. The generator writes the key into the generated code without changes. With `AlsoAsSelf()` or `AsSelfWithInterfaces()`, all registrations of a class use the key. -A type filling two *different* roles is not a collision, and registers as both: +`Using(RegistrationType.Try)` sets the registration type. For the values, refer to [Registration type](./services.md#registration-type). The generator reads the value of the argument. Thus you can also write the full name of the enum member or use a constant. If the argument is not a value that the compiler knows, the generator gives the error DM0009. ```csharp -public class OrderEvents : INotificationHandler, IRequestPreProcessor { } - -conventions.RegisterAll(typeof(INotificationHandler<>)).AsTransient(); -conventions.RegisterAll(typeof(IRequestPreProcessor<>)).AsTransient(); // fine +conventions.RegisterAll().WithKey("archive").Using(RegistrationType.Try).AsScoped(); ``` -Conventions in *different* modules never collide, because each registers into its own -[realm](/guide/modules#realms-keeping-a-registration-out-of-the-default-module). +## Environment conditions -## What conventions will not do +A convention can register its classes only in some environments. Use these calls: -Anything needing a lambda over the matched types โ€” a predicate, or a lifetime chosen per type โ€” -cannot be expressed, because the declaration is read rather than run. There is no way to evaluate -your code at compile time. +| Call | The convention registers when | +| --- | --- | +| `IfEnvironment("Development", "Staging")` | The environment name is one of the names. | +| `IfNotEnvironment("Production")` | The environment name is not one of the names. | +| `IfEnvironmentValue("FEATURE_X")` | The environment has a value for the key. | +| `IfEnvironmentValue("FEATURE_X", "on")` | The value for the key is equal to the given value. | +| `IfNotEnvironmentValue("FEATURE_X")` | The environment has no value for the key. | +| `IfNotEnvironmentValue("FEATURE_X", "on")` | The value for the key is not equal to the given value. | -Use `IServiceCollectionConfiguration` for those, alongside your conventions: +If a selected class has environment attributes, for example `[IfEnvironment("Development")]`, the conditions of the class are also applicable. This is also true for a class from a referenced assembly. All conditions must be true. For more information, refer to [Environments](./environments.md). -```csharp -[DependencyModule] -public partial class DataModule : IConventionModule, IServiceCollectionConfiguration -{ - void IConventionModule.Conventions(IConventionDefinitions conventions) - { - conventions.RegisterAll().AsScoped(); - } +## Diagnostics - public void ConfigureServices(IServiceCollection services) - { - // unrestricted access to IServiceCollection, at run time - } -} -``` +| ID | Severity | Cause | +| --- | --- | --- | +| DM0004 | Error | Two conventions in one module register the same class as the same service type. The generator does not write these two registrations. | +| DM0005 | Warning | A convention selects no classes. | +| DM0006 | Warning | A selected class has no constructor that its registration can use. | +| DM0009 | Error | The generator cannot read a convention statement. | +| DM0010 | Info | The generator registered a class from a convention. The message shows the service type and the module. | +| DM0012 | Warning | An environment condition of a selected class has no name or no key. | +| DM0014 | Warning | A convention with `AlsoAsSelf()` or `AsSelfWithInterfaces()` selects a generic class. | + +If two conventions select one class for two different service types, this is not an error. The class then gets two registrations. For example, a class that implements `IFirstRole` and `ISecondRole` can have a singleton registration as `IFirstRole` and a scoped registration as `ISecondRole`. + +## Decorators and interception -## Next +[Decorators](./decorators.md) are applicable to convention registrations. A generic decorator is applicable to each closed registration that a convention makes. -- [Scanning a package](/guide/scanning) โ€” matching types in an assembly you do not own -- [Convention API reference](/reference/conventions-api) โ€” every call in one table -- [Diagnostics](/reference/diagnostics) โ€” what each DM code means +`[Intercept]` is not a service attribute. Thus a class with `[Intercept]` stays a candidate for a convention. The interceptors are then applicable to the convention registration. For more information, refer to [Interception](./interception.md). diff --git a/website/guide/decorators.md b/website/guide/decorators.md index 8e41b50..f77a09b 100644 --- a/website/guide/decorators.md +++ b/website/guide/decorators.md @@ -1,204 +1,244 @@ # Decorators -## The problem +A decorator is a wrapper class for a service. The decorator implements the service type and gets the service in its constructor. When you get the service type from the service provider, you get the decorator. The decorator then calls the service. -You want to cache the results of a repository: +## Declare a decorator + +Put `[Decorator]` on the class: ```csharp +using DependencyModules.Runtime.Attributes; + +namespace Orders; + +public interface IOrderService +{ + string Place(string item); +} + [SingletonService] -public class SqlRepository : IRepository +public class OrderService : IOrderService { - public Item Get(int id) => /* a database round trip */; + public string Place(string item) => $"placed {item}"; +} + +public interface IAuditLog +{ + void Write(string line); +} + +[SingletonService] +public class ConsoleAuditLog : IAuditLog +{ + public void Write(string line) => Console.WriteLine(line); +} + +[Decorator] +public class AuditedOrderService(IOrderService inner, IAuditLog log) : IOrderService +{ + public string Place(string item) + { + log.Write($"order for {item}"); + return inner.Place(item); + } } ``` -Putting the cache inside `SqlRepository` gives that class a second job and makes it harder to test. -Putting it in every caller is worse. What you want is something that sits **between** the callers and -the repository, without either side knowing. +When you get `IOrderService`, the service provider gives `AuditedOrderService`. `AuditedOrderService` gets `OrderService` in the `inner` parameter. + +The generator finds the service type from the constructor. The service type is the first constructor parameter with a type that the decorator implements. The generator examines the base classes of the decorator and all its interfaces, also the interfaces that an interface derives from. If the generator finds no service type, it gives the warning DM0025 and does not apply the decorator. The `Service` property can also set the service type, for example `[Decorator(Service = typeof(IOrderService))]`. + +The generator does not register the decorator class as a service. The service provider gives the other constructor parameters, for example `IAuditLog` in the example. -Microsoft's container has no built-in way to express that. +The generated code calls the constructor of the decorator. Thus the constructor must be `public`, `internal`, or `protected internal`. If the decorator has no such constructor, the generator gives the warning DM0025 and does not apply the decorator. -## How DependencyModules helps +## Which registrations a decorator changes -Write the wrapper as an ordinary class, mark it `[Decorator]`, and it takes over the registration: +The decorators change the registrations after all modules of the load operation add their services. A decorator changes each registration of its service type in the service collection. All loaded modules can add these registrations. Your code can also add them before the call to `AddModules`. + +A decorator changes each registration only one time. When two modules contain the same decorator, the decorator also changes each registration only one time. + +The decorated registration keeps its lifetime. A decorator can change keyed registrations, instance registrations, and factory registrations. A keyed registration keeps its key. + +A decorator does not change registrations that you add after the modules load. + +For a registration with an implementation type, the decorator moves the registration to a private service key. The decorator then gets the service with `GetRequiredKeyedService`. Thus the service provider must support keyed services. The service provider continues to make and dispose the decorated service. + +## Decorator sequence + +The `Order` property sets the sequence of decorators for one service type. If the `Order` value of decorator A is less than the `Order` value of decorator B, B is the outer decorator. Thus B gets the call before A. ```csharp -public interface IRepository { Item Get(int id); } +using DependencyModules.Runtime.Attributes; + +namespace Orders; + +public interface IPriceService +{ + string Describe(); +} [SingletonService] -public class SqlRepository : IRepository +public class PriceService : IPriceService { - public Item Get(int id) => /* โ€ฆ */; + public string Describe() => "price"; } -[Decorator] -public class CachingRepository(IRepository inner, IMemoryCache cache) : IRepository +[Decorator(Order = 10)] +public class InnerPriceDecorator(IPriceService inner) : IPriceService { - public Item Get(int id) => cache.GetOrCreate(id, _ => inner.Get(id))!; + public string Describe() => $"inner({inner.Describe()})"; +} + +[Decorator(Order = 20)] +public class OuterPriceDecorator(IPriceService inner) : IPriceService +{ + public string Describe() => $"outer({inner.Describe()})"; } ``` -Resolving `IRepository` now gives you `CachingRepository` wrapping `SqlRepository`. Neither the -callers nor `SqlRepository` changed. +In this example, `Describe()` gives `outer(inner(price))`. -## How it is wired +The decorators of all modules and the interceptors are in one sequence. The default `Order` value is 0. The source code recommends values from 0 to 999 for packages and values of 1000 and more for application code. Then the decorators of the application are the outer decorators. The generator does not examine these ranges. -The **first constructor parameter is the wrapped instance**; every other parameter is resolved from -the container normally. That is the whole convention. +The generator reads the value of `Order`, not its text. Thus you can also use a constant or digit separators, for example `Order = 1_000`. -You never register the decorator yourself โ€” `[Decorator]` is enough, and the decorator is not -registered as a service in its own right. This also keeps it out of -[convention](/guide/conventions) matching, which matters because a decorator implements the very -interface a convention over that interface would be looking for. +If two decorators in one module have the same service type and the same `Order` value, the generator gives the error DM0007. -## Ordering +## Generic decorators -With more than one decorator, `Order` decides the nesting. **Lower orders sit closer to the -implementation**; higher ones wrap them: +A generic decorator decorates a generic service type: ```csharp -[Decorator(Order = 10)] public class Retrying(IRepository inner) : IRepository { } -[Decorator(Order = 20)] public class Logging(IRepository inner) : IRepository { } +using DependencyModules.Runtime.Attributes; +using DependencyModules.Runtime.Conventions; -// resolves as Logging(Retrying(SqlRepository)) -``` +namespace Orders.Handlers; -So a logged call reports the whole retry sequence as one operation, which is usually what you want. +public interface IHandler +{ + TResponse Handle(TRequest request); +} -Ordering is global โ€” decorators are sorted across **every module** in an `AddModule(s)` call, not -just within the module that declared them. By convention framework packages use 0โ€“999 and application -code 1000 and above, so an application's decorators wrap the ones contributed by libraries it -consumes. +public record CreateOrder(string Item); -Two decorators of one service sharing an order is [DM0007](/reference/diagnostics#dm0007), since -their nesting would be ambiguous. +public record CancelOrder(string OrderId); -## One decorator over every closed generic +public class CreateOrderHandler : IHandler +{ + public string Handle(CreateOrder request) => "created"; +} -This is where decorators earn their keep. A single declaration can wrap **every** closed registration -of an open generic โ€” cross-cutting behaviour over all your MediatR handlers or FluentValidation -validators, written once: +public class CancelOrderHandler : IHandler +{ + public string Handle(CancelOrder request) => "cancelled"; +} -```csharp [Decorator] -public class LoggingHandler( - IRequestHandler inner, ILogger log) - : IRequestHandler +public class LoggingHandler(IHandler inner) + : IHandler { public TResponse Handle(TRequest request) { - log.LogInformation("handling {Request}", typeof(TRequest).Name); + Console.WriteLine($"handle {typeof(TRequest).Name}"); return inner.Handle(request); } } -``` - -Combined with a convention, that is the entire setup: - -```csharp -conventions.RegisterAll(typeof(IRequestHandler<,>)).AsScoped(); -``` -Every handler registered, every handler wrapped, and a new handler joins both by existing. - -## Decorating only in some environments - -A decorator carries [environment conditions](/guide/environments) the same way a service does, which -is how you get behaviour that exists only where you want it โ€” request logging in development, a -circuit breaker only in production: - -```csharp -[Decorator] -[IfEnvironment("Development")] -public class LoggingRepository(IRepository inner, ILogger log) : IRepository +[DependencyModule] +public partial class HandlerModule : IConventionModule { - public Item Get(int id) + public void Conventions(IConventionDefinitions conventions) { - log.LogInformation("getting {Id}", id); - return inner.Get(id); + conventions.RegisterAll(typeof(IHandler<,>)).AsScoped(); } } ``` -Outside Development the decorator is **never applied**, so `IRepository` resolves as the undecorated -implementation. Nothing wraps it and nothing tests the environment per call โ€” the decision is made -once, while the modules are being applied. - -All four condition attributes work, and they combine with **and** exactly as they do on a service: +The generator makes a closed decorator for each closed registration of the service type in the project. In this example, `LoggingHandler` decorates `IHandler` and `IHandler`. The registrations can be from service attributes or from conventions. -```csharp -[Decorator] -[IfNotEnvironment("Production")] -[IfEnvironmentValue("TRACE_SQL", "on")] -public class TracingRepository(IRepository inner) : IRepository { โ€ฆ } -``` +These conditions are applicable to a generic decorator: -A condition changes **whether** a decorator applies, never **where it sits**. Ordering is unaffected, -so a conditional decorator dropping out leaves the rest of the chain nesting exactly as before: +- The decorator must have the same type parameters as the service type, in the same sequence. If it does not, the generator gives the warning DM0025. +- The generator uses only the closed service types that the same project registers. +- If a closed service type does not agree with the constraints of the decorator, the generator does not use the decorator for that type. -```csharp -[Decorator(Order = 10)] [IfEnvironment("Development")] public class Inner(IRepository r) : IRepository { } -[Decorator(Order = 20)] public class Outer(IRepository r) : IRepository { } +The generator cannot decorate an open generic registration, for example a registration of `IRepository<>` to `Repository<>`. If the project has only an open generic registration of the service type, the generator gives the warning DM0013. If the project also contains closed registrations, the generator decorates only the closed registrations. It gives no diagnostic for the open generic registration. -// Development: Outer(Inner(SqlRepository)) -// Production: Outer(SqlRepository) -``` +To decorate each closed type, register closed types. For example, declare classes that are not generic, such as `OrderRepository : IRepository`. A convention registers such a class as each closed type that it implements. -## Decorating a type you do not own +## Decorate from a module -When the service, the decorator, or both come from an assembly you do not control, there is nowhere -to put `[Decorator]`. Declare it on the module instead: +When you cannot put `[Decorator]` on the decorator class, use `[Decorate]` on a module. For example, the decorator can be in a package. ```csharp +using DependencyModules.Runtime.Attributes; + +namespace Orders; + +public class TimedOrderService(IOrderService inner) : IOrderService +{ + public string Place(string item) => inner.Place(item); +} + [DependencyModule] -[Decorate(typeof(IRepository), typeof(CachingRepository), Order = 100)] -public partial class DataModule; +[Decorate(typeof(IOrderService), typeof(TimedOrderService), Order = 30)] +public partial class OrdersModule; ``` -## When decoration happens - -Decoration runs as a distinct phase **after** every module's registrations, so a decorator sees -everything registered by every module in the call, regardless of the order they were added in. You do -not have to sequence anything. +The first argument is the service type. The next argument is the decorator type. The decorator must have a `public` constructor with a parameter of the service type. If it does not have such a constructor, the generator ignores the decorator and gives no diagnostic. The [generator log](./troubleshooting.md#write-a-generator-log) shows the cause. Only the module that has the attribute uses this decorator. -The boundary is the `AddModule(s)` call: anything you register afterwards is outside that scope and -will not be decorated. +If the service type is an open generic type, the decorator type must also be an open generic type. If the decorator type is not generic, the generator gives the warning DM0013. -## One limitation +## Decorate one implementation -A service **registered as an open generic** โ€” one generic implementation serving every closing โ€” -cannot be decorated: +When a service type has more than one implementation, set `Implementation` to decorate only one of them: ```csharp +using DependencyModules.Runtime.Attributes; + +namespace Orders.Payments; + +public interface IPaymentMethod +{ + string Pay(decimal amount); +} + +[SingletonService] +public class CardPayment : IPaymentMethod +{ + public string Pay(decimal amount) => "card"; +} + [SingletonService] -public class Repository : IRepository { } // registers IRepository<> itself +public class InvoicePayment : IPaymentMethod +{ + public string Pay(decimal amount) => "invoice"; +} -[Decorator] -public class CachingRepository(IRepository inner) : IRepository { } +[Decorator(Implementation = typeof(CardPayment))] +public class CardPaymentCheck(IPaymentMethod inner) : IPaymentMethod +{ + public string Pay(decimal amount) => amount > 0 ? inner.Pay(amount) : "rejected"; +} ``` -This is [DM0013](/reference/diagnostics#dm0013) at build time, whichever way the decorator was -declared โ€” on the class, or on the module with `[Decorate]`. +`CardPaymentCheck` decorates only the registration of `CardPayment`. + +The decorator finds the implementation of a registration from its type, from its instance, or from the return type of its factory. If a factory returns `object` or the service type, the registration does not show its implementation. The decorator does not change that registration. + +The factories that the generator writes return their class. Thus `Implementation` also operates in a module that uses [generated factories](./aot.md#generated-factories). + +## Environment conditions -Register closed constructions instead. A [convention](/guide/conventions) over the open generic -registers one per implementation, and an open generic decorator is then expanded across them. +A `[Decorator]` class can have environment attributes, for example `[IfEnvironment("Development")]`. The decorator then changes the registrations only when the conditions are true. If the conditions are false, the other decorators keep their positions in the sequence. For the attributes, refer to [Environments](./environments.md). -Note that this is about the **registration**, not the decorator. An open generic decorator over -closed registrations โ€” the example further up โ€” works, and is the common case. +The generator also reads the environment attributes of a decorator that `[Decorate]` adds. If a condition has no name or no key, the generator gives the warning DM0012. -## Decorator or interceptor? +## Realms -| | Decorator | [Interception](/guide/interception) | -|---|---|---| -| Who writes the wrapper | you | the generator | -| Declared on | the decorator, against an interface | the implementation being wrapped | -| Covers by default | every registration of that service | the one class it is written on | -| Member access | real signatures and parameter names | uniform, `TResult` and `IArguments` | -| Reach for it when | caching *this* method, validating *that* one | logging, timing, retry, tracing | +A decorator without `Realm` is applicable in each module that is not realm-only. A decorator with `Realm` is applicable only in the module that `Realm` identifies. For realms, refer to [Realms](./modules.md#realms). -Either can be narrowed to the other's default. `[Decorator(Implementation = typeof(X))]` decorates one -implementation instead of all of them; `[Intercept(Realm = โ€ฆ)]` and `[Intercept(Members = โ€ฆ)]` narrow -an interception further still. +## Decorators in code -If you need to do something specific to one member, write a decorator. If you need to do the same -thing to every member of thirty services, read on. +To decorate registrations with your code, implement `IServiceCollectionConfiguration.ConfigureDecorators` on a module. This method runs after all generated decorators. For more information, refer to [Registration code in the module](./modules.md#registration-code-in-the-module). diff --git a/website/guide/environments.md b/website/guide/environments.md index fe6c5be..eac9f1b 100644 --- a/website/guide/environments.md +++ b/website/guide/environments.md @@ -1,275 +1,182 @@ # Environments -## The problem - -You do not want your development machine sending real email. So the registration becomes conditional: +Registrations that have conditions occur only when the environment agrees with the conditions. For example, a service can register only in the `Development` environment. The environment is an `IModuleEnvironment` from the `DependencyModules.Runtime.Interfaces` namespace: ```csharp -// Program.cs -if (builder.Environment.IsDevelopment()) -{ - services.AddSingleton(); -} -else +public interface IModuleEnvironment { - services.AddSingleton(); + string EnvironmentName { get; } + + string? Value(string name); } ``` -This works, and it has a habit of multiplying. The decision lives in `Program.cs`, a long way from -either class, so reading `FakeEmailSender` tells you nothing about when it is used. After a few of -these the composition root is a pile of branches, and the only way to know what runs in staging is to -trace all of them. - -## How DependencyModules helps +`EnvironmentName` is the name of the environment. `Value` gives the value for a key. If the environment has no value for the key, `Value` gives `null`. -Put the condition on the class, next to the registration it qualifies: - -```csharp -[SingletonService] -[IfEnvironment("Development", "Staging")] -public class FakeEmailSender : IEmailSender { } +## The default environment -[SingletonService] -[IfNotEnvironment("Development")] -public class SmtpEmailSender : IEmailSender { } -``` +If you do not give an environment, the modules use the environment of the process: -Resolve `IEmailSender` and you get whichever one the environment selected. `Program.cs` has no branch -in it, and each class states its own applicability where you will actually read it. +- The name is the value of the `ASPNETCORE_ENVIRONMENT` environment variable. +- If that variable is not set, the name is the value of the `DOTNET_ENVIRONMENT` environment variable. +- If the two variables are not set, the name is `Production`. +- `Value(name)` gives the value of the environment variable `name`. -## The conditions +`ModuleEnvironment.CreateDefault()` gives a new instance of this environment each time that you call it. -| Attribute | Registers when | -|---|---| -| `[IfEnvironment(params string[])]` | the environment name matches any of them | -| `[IfNotEnvironment(params string[])]` | it matches none of them | -| `[IfEnvironmentValue(key)]` | the environment has any value for the key | -| `[IfEnvironmentValue(key, value)]` | the value equals exactly | -| `[IfNotEnvironmentValue(โ€ฆ)]` | the inverse of either form | +## Give an environment -Conditions of **different kinds** combine with **and**; alternatives go inside one attribute as -`params`. So this registers only outside production, and only when the feature is switched on: +To give an environment, use the `AddModules` method with an environment parameter: ```csharp -[SingletonService] -[IfNotEnvironment("Production")] -[IfEnvironmentValue("FEATURE_PROFILING", "on")] -public class RequestProfiler : IProfiler { } -``` - -Environment **names** compare case-insensitively, matching `IHostEnvironment.IsDevelopment()`. -**Values** compare ordinally. +using DependencyModules.Runtime; +using Microsoft.Extensions.DependencyInjection; +using Mail; -## Where the environment comes from +var environment = new ModuleEnvironment( + "Staging", + new Dictionary { ["FEATURE_BILLING"] = "on" } +); -There is always one, and it is never null. +var services = new ServiceCollection(); -The simplest case needs no wiring at all. Supply nothing and you get `ModuleEnvironment.CreateDefault()`, -which reads the process โ€” `ASPNETCORE_ENVIRONMENT`, then `DOTNET_ENVIRONMENT`, then falling back to -`"Production"`. Values come from environment variables. - -So `[IfEnvironment("Development")]` already works against the variable your tooling sets for you. - -To decide explicitly โ€” and a test should โ€” pass one to `AddModules`: - -```csharp -services.AddModules(new ModuleEnvironment("Development"), new ApplicationModule()); +services.AddModules(environment, new MailModule()); ``` -Values go inline, since a `ModuleEnvironment` is a collection of them: +You can also register an `IModuleEnvironment` instance as a singleton before you load the modules. `AddModules` then uses this instance. -```csharp -services.AddModules( - new ModuleEnvironment("Development") - { - { "FEATURE_PROFILING", "on" }, - { "REGION", "eu" } - }, - new ApplicationModule()); -``` +`AddModules` uses one environment for all modules of the call. The service collection then contains this environment as `IModuleEnvironment`: -A dictionary still works, and the two combine โ€” an entry written inline replaces one of the same key -that came from the dictionary. +- If you give an environment, `AddModules` removes all `IModuleEnvironment` registrations and registers your environment. +- If you do not give an environment and the service collection has no `IModuleEnvironment`, `AddModules` registers the default environment. -### What happens to keys you did not write +Thus your services can get `IModuleEnvironment` in their constructors. To use values from two environments, get the environment from the service collection first. Then add its values to the new environment. -Anything **not** written there falls back to an environment variable of that name, so supplying a -couple of values does not mean giving up the rest. +If you do not give an environment, `AddModules` uses the last `IModuleEnvironment` registration in the service collection. If this registration is not a singleton instance, `AddModules` throws an `InvalidOperationException`. The environment must be available before the service provider is available, because the registrations that have conditions use the environment. -A key you did write wins โ€” including one written as `null`, which is how you hide a variable of the -same name: +## The `ModuleEnvironment` class -```csharp -new ModuleEnvironment("Development") -{ - { "REGION", "eu" }, // wins over any REGION variable - { "FEATURE_PROFILING", null } // hides a FEATURE_PROFILING variable -} -``` +`ModuleEnvironment` in the `DependencyModules.Runtime` namespace implements `IModuleEnvironment`: -To pin an environment to exactly what is at the call site and read nothing else, lead with `false`: +| Member | Function | +| --- | --- | +| `new ModuleEnvironment(name, values)` | Makes an environment with a name and optional values. If a key is not in the values, `Value` reads the environment variable. | +| `new ModuleEnvironment(false, name, values)` | Makes an environment that does not read environment variables. | +| `Add(key, value)` | Adds a value. If the environment has a value for the key, `Add` replaces it. | +| `ModuleEnvironment.CreateDefault()` | Gives the environment of the process. | +| `ModuleEnvironment.None` | An environment with an empty name and no values. | -```csharp -new ModuleEnvironment(false, "Development") { { "REGION", "eu" } } // reads nothing else -``` +This list gives more information about `ModuleEnvironment`: -A test asserting which services an environment registers wants this. Otherwise a variable set on the -machine running it can reach a key the test never mentioned, and the test passes or fails depending -on whose machine it runs on. +- If you give a `Dictionary` for the values, the environment makes a copy with the same `Comparer`. For example, `StringComparer.OrdinalIgnoreCase` makes the keys not case-sensitive. +- If a key has a `null` value, `Value` gives `null` for this key. It does not read the environment variable of that name. +- The environment keeps the value of an environment variable after the first read. If the variable changes, `Value` continues to give the first value. This is also true for the default environment. +- When you enumerate a `ModuleEnvironment`, you get only the values that you gave. You do not get environment variables. +- For `ModuleEnvironment.None`, the `[IfNotEnvironment]` and `[IfNotEnvironmentValue]` conditions are true. -The flag leads rather than trailing, so it is read before the values it governs. Both forms still -take a dictionary, so turning fallback off does not mean giving up the constructor you were using: +`ModuleEnvironment` is also an `IEnumerable>`. Thus you can use a collection initializer to give the values: ```csharp -new ModuleEnvironment(false, "Development", new Dictionary { ["A"] = "1" }) -``` - -A comparer you supplied on that dictionary is carried over rather than reset โ€” useful for -`OrdinalIgnoreCase`, matching how Windows treats variable names. - -### What gets cached - -An environment caches what it reads from the process, misses included, for its own lifetime. The -instance `AddModules` registers is held for the application's lifetime, so a service that injects -`IModuleEnvironment` and reads a value per request pays one process lookup rather than one per call โ€” -and an unset optional variable, which is the case a default exists for, is cached as absent rather -than re-read every time. - -The trade is that an instance does not see a variable changed mid-process. `CreateDefault()` builds a -fresh one on each call, so asking again is how you get a current view: +using DependencyModules.Runtime; -```csharp -var current = ModuleEnvironment.CreateDefault().Value("FEATURE_X"); +var environment = new ModuleEnvironment(false, "Test") +{ + { "Region", "eu-west-1" }, + { "FEATURE_BILLING", "on" }, +}; ``` -Values you supplied yourself are never affected โ€” they are answered directly, and the cache only ever -holds what came from the process. Enumerating a `ModuleEnvironment` still yields only what you -supplied. - -### Stating that there is no environment - -`ModuleEnvironment.None` has an empty name and no values, so every condition evaluates false. Prefer -it to leaving the environment unset, which silently picks up the process instead. +## Registrations with conditions -Whatever is used is **registered**, so `GetRequiredService()` afterwards returns -the same environment that decided the registrations. +Put one or more of these attributes on a service class. The attributes are in the `DependencyModules.Runtime.Attributes` namespace. -::: warning Register an instance, not a type -The environment is read while the collection is still being populated โ€” before any provider exists to -resolve anything โ€” so only a singleton **instance** can work. +| Attribute | The service registers when | +| --- | --- | +| `[IfEnvironment("Development", "Staging")]` | The environment name is one of the names. | +| `[IfNotEnvironment("Production")]` | The environment name is not one of the names. | +| `[IfEnvironmentValue("FEATURE_BILLING")]` | The environment has a value for the key. | +| `[IfEnvironmentValue("FEATURE_BILLING", "on")]` | The value for the key is equal to the given value. | +| `[IfNotEnvironmentValue("FEATURE_BILLING")]` | The environment has no value for the key. | +| `[IfNotEnvironmentValue("FEATURE_BILLING", "on")]` | The value for the key is not equal to the given value. | ```csharp -services.AddSingleton(new ModuleEnvironment("Staging")); // works -services.AddSingleton(); // throws -``` - -Registering by type or by factory throws, with a message naming the fix. -::: +using DependencyModules.Runtime.Attributes; -An environment passed to `AddModules` **replaces** one already in the collection. To layer one on -another, read the existing one and combine before you call: +namespace Mail; -```csharp -var existing = services.FirstOrDefault(d => d.ServiceType == typeof(IModuleEnvironment)) - ?.ImplementationInstance as IModuleEnvironment; - -services.AddModules(Combine(existing ?? ModuleEnvironment.CreateDefault(), overlay), modules); -``` +public interface IMailSender +{ + void Send(string to, string body); +} -## Overriding a default +[SingletonService] +public class SmtpMailSender : IMailSender +{ + public void Send(string to, string body) { } +} -A conditional registration is emitted **after** the unconditional ones in its module, which is what -makes the override pattern work โ€” register the normal implementation unconditionally and the special -one conditionally: +[SingletonService] +[IfEnvironment("Development", "Staging")] +public class FakeMailSender : IMailSender +{ + public void Send(string to, string body) => Console.WriteLine($"{to}: {body}"); +} -```csharp -[SingletonService] public class SmtpEmailSender : IEmailSender { } -[SingletonService] [IfEnvironment("Development")] public class FakeEmailSender : IEmailSender { } +[DependencyModule] +public partial class MailModule; ``` -In Development the fake wins, because the container resolves a single service from the **last** -matching descriptor. +In the `Development` and `Staging` environments, the module registers `SmtpMailSender` and then `FakeMailSender`. Thus `GetService()` gives `FakeMailSender`. In the other environments, it gives `SmtpMailSender`. The generator writes the registrations with conditions after the registrations without conditions. -Across modules, **module order decides** โ€” a referenced module's conditional registration does not -override the module that references it. +This list gives information about the conditions: -::: info Try is first-wins -A conditional `Using(RegistrationType.Try)` cannot override an unconditional registration, since -`Try` declines when the service type is already present. Use `Add`, the default, for this pattern. -::: +- Environment names are not case-sensitive. +- Values are case-sensitive. +- If a class has more than one condition, all conditions must be true. +- The arguments must be string literals or string constants. The generator ignores an argument that is not a string constant, and it uses the other arguments. For example, it reads `[IfEnvironmentValue("K", null)]` as `[IfEnvironmentValue("K")]`. If no string argument is left, the generator gives the warning DM0012 and ignores the condition. -## Conditions and conventions +You can put `[IfEnvironmentValue]` and `[IfNotEnvironmentValue]` on a class more than one time. You can put `[IfEnvironment]` and `[IfNotEnvironment]` on a class only one time. -A class matched by a [convention](/guide/conventions) honours its own conditions, so an attribute -behaves the same whether the class is registered by attribute or by rule. +The generator writes an `if` statement around the registrations of the class. The module then examines the environment when it loads. -A convention can also carry a condition itself, gating every match rather than making you repeat the -attribute on each class: +## Conditions on decorators and conventions -```csharp -conventions.RegisterAll().IfEnvironment("Development").AsScoped(); -``` +You can put the same attributes on a `[Decorator]` class. The decorator then changes the registrations only when the conditions are true. The generator also reads these attributes on a decorator that `[Decorate]` adds. For more information, refer to [Decorators](./decorators.md#environment-conditions). -The same four tests are available, named after the attributes: +A convention can also have conditions. The conditions of a class that a convention selects are also applicable, also for a class from a referenced assembly. For more information, refer to [Conventions](./conventions.md#environment-conditions). -| Call | Registers when | -|---|---| -| `IfEnvironment(params string[])` | the environment name matches any of them | -| `IfNotEnvironment(params string[])` | it matches none of them | -| `IfEnvironmentValue(key)` ยท `IfEnvironmentValue(key, value)` | the key is present, or equals exactly | -| `IfNotEnvironmentValue(โ€ฆ)` | the inverse of either form | +## Read the environment in a module -When a convention carries a condition **and** a matched class carries its own, the two combine with -**and** โ€” neither can silently discard the other: +A module can implement `IEnvironmentServiceCollectionConfiguration` to read the environment when it loads: ```csharp -conventions.RegisterAll().IfEnvironment("Development").AsSingleton(); +using DependencyModules.Runtime.Attributes; +using DependencyModules.Runtime.Interfaces; +using Microsoft.Extensions.DependencyInjection; -[IfEnvironmentValue("REGION", "eu")] -public class EuFoo : IFoo { } +namespace Mail; -// EuFoo registers only when the environment is Development AND REGION is eu -``` - -## Conditions and decorators - -A [decorator](/guide/decorators#decorating-only-in-some-environments) takes the same conditions. Where -it does not apply, the service resolves undecorated, and the ordering of everything else is -unchanged. - -## What conditions cost - -The test runs at **run time**, which means both branches are compiled and every conditionally -registered type stays referenced in the output. - -Conditions change what is *registered*, not what *ships*. To keep a service out of a build entirely, -you want `#if`. - -## Seeing it at build time - -[DM0011](/reference/diagnostics#dm0011) reports what each conditional registration depends on, inline -in the IDE at the class โ€” so the applicability is visible without running anything. - -A condition that names nothing to test โ€” `[IfEnvironment()]`, `[IfEnvironmentValue("")]` โ€” is -[DM0012](/reference/diagnostics#dm0012). Both compile, and both are almost certainly a mistake. +public record MailOptions(string Region); -## Programmatic access - -For registration that depends on the environment but is not a simple condition: - -```csharp -[DependencyModule] -public partial class ApplicationModule : IEnvironmentServiceCollectionConfiguration +[DependencyModule(OnlyRealm = true)] +public partial class MailOptionsModule : IEnvironmentServiceCollectionConfiguration { public void ConfigureServices(IServiceCollection services, IModuleEnvironment environment) { - if (environment.Value("REGION") == "eu") - { - services.AddSingleton(); - } + services.AddSingleton(new MailOptions(environment.Value("Region") ?? "default")); } } ``` -It receives the same non-null environment the attributes are evaluated against. +## Diagnostics + +| ID | Severity | Cause | +| --- | --- | --- | +| DM0011 | Info | A service has conditions. The message shows the conditions. | +| DM0012 | Warning | A condition has no environment name or no key. The generator ignores this condition. | + +The generator gives DM0011 only for classes with a service attribute. It gives DM0012 also for a `[Decorator]` class, for a decorator that `[Decorate]` adds, and for a class that a convention selects. For a decorator that `[Decorate]` adds, DM0012 is at the module. For a class from a referenced assembly, DM0012 is at the convention statement. On a convention statement, a condition call without a name or a key gives the error DM0009. + +## Environments in tests + +The test packages can give a different environment to each test. For more information, refer to [Environment for a test](./testing.md#environment-for-a-test). diff --git a/website/guide/extending.md b/website/guide/extending.md index 2506956..16d0424 100644 --- a/website/guide/extending.md +++ b/website/guide/extending.md @@ -1,232 +1,198 @@ -# Writing your own generator +# Extending -## The problem +This page tells you about the extension points of the runtime, of `DependencyModules.Testing`, and of the generator. -You want a registration mechanism this library does not have โ€” your own attribute, a DSL that suits -your domain, registrations derived from something only your codebase knows about. +## Module features -Writing that as a standalone source generator means rebuilding a lot of unglamorous machinery first: -finding the modules, parsing the MSBuild configuration, producing diagnostics, keeping the -incremental cache honest, and emitting registration code that composes with everything else. None of -that is the part you actually wanted to write. +A feature lets one module get the other loaded modules that implement an interface. Implement `IDependencyModuleFeature` from `DependencyModules.Runtime.Features` on a module: -## How DependencyModules helps - -All of it lives in a shared assembly you can compile into your own analyzer. Your mechanism produces -the same `ServiceModel`s the attribute path produces, so emission needs no special case and your -registrations compose with `[SingletonService]` and conventions as if they had always been there. - -The [convention](/guide/conventions) generator is exactly this โ€” a registration mechanism of its own, -plugged into the same pipeline โ€” and it is the worked example throughout this page. It ships inside -`DependencyModules.SourceGenerator` rather than beside it, but nothing about how it plugs in depends -on that; yours can live in its own analyzer package. - -::: warning Not a stable public API yet -These are the extension points the convention generator uses, and they are public. They are **not** -versioned as a stable API, so a minor release may move them. If you build on this, pin the generator -package version. -::: - -## What you get to reuse +```csharp +using DependencyModules.Runtime.Attributes; +using DependencyModules.Runtime.Features; +using Microsoft.Extensions.DependencyInjection; -| | | -|---|---| -| Module discovery | which `[DependencyModule]` classes exist, and their realms and features | -| Configuration | the `DependencyModules_*` MSBuild properties, already parsed | -| `DependencyFileWriter` | turns `ServiceModel`s into registration code | -| `FileLogger` | the diagnostic log users attach to issues | -| Diagnostics | the `DM####` descriptors and their release tracking | -| Model equality helpers | what keeps the incremental cache working | +namespace Plugins; -## The shape +public interface IPluginModule +{ + string PluginName { get; } +} -Two interfaces. `BaseSourceGenerator` is the Roslyn entry point, and it asks you for the generators -that want module models: +public record PluginInfo(string Name); -```csharp -[Generator] -public class MySourceGenerator : BaseSourceGenerator +[DependencyModule(OnlyRealm = true)] +public partial class PluginRegistryModule : IDependencyModuleFeature { - protected override IEnumerable AttributeSourceGenerators() + public void HandleFeature(IServiceCollection collection, IEnumerable feature) { - yield return new MyGenerator(); + foreach (var plugin in feature) + { + collection.AddSingleton(new PluginInfo(plugin.PluginName)); + } } +} - // SetupRootGenerator is deliberately not overridden. DependencyModules.SourceGenerator owns the - // module partial; emitting it from here too would declare every module twice. The base class - // knows that from the attribute you trigger on, so the default does the right thing here. +[DependencyModule(OnlyRealm = true)] +public partial class ReportsPluginModule : IPluginModule +{ + public string PluginName => "reports"; } ``` -A generator that declares its **own** module attribute is the other shape, and the default flips to -match: nothing else can write those modules, so the base class writes them for you. - ```csharp -[Generator] -public class MyFrameworkGenerator : BaseSourceGenerator -{ - protected override ITypeDefinition[] ModuleAttributeTypes() => - [TypeDefinition.Get("My.Framework", "MyModuleAttribute")]; +using DependencyModules.Runtime; +using Microsoft.Extensions.DependencyInjection; +using Plugins; - protected override IEnumerable AttributeSourceGenerators() - { - yield return new MyGenerator(); - } -} +var services = new ServiceCollection(); + +services.AddModules(new PluginRegistryModule(), new ReportsPluginModule()); + +var plugins = services.BuildServiceProvider().GetServices(); ``` -`[MyModule]` on a class now gets everything `[DependencyModule]` does โ€” `AddModule()`, services, -conventions, decorators, interception โ€” with no `[DependencyModule]` in the consuming project. Two -things follow from declaring your own attribute: +When the modules load, `HandleFeature` gets each loaded module that implements `TFeature`. The features are applicable before the modules add their services. If more than one module implements a feature, the `Order` property sets the sequence of the feature handlers. The default value is 0. + +Put `IDependencyModuleFeature` on the declaration of the module that has `[DependencyModule]`. The generator does not read the other partial declarations. -- **Override `SetupRootGenerator` with an empty body** if you want the attribute as a marker only, - and no module written for it. -- **`Program.cs` is not yours.** A file of top level statements carries no attribute to tell the two - generators apart, so the generated `ApplicationModule` belongs to whichever generator reads - `[DependencyModule]`. If your framework ships without this package's generator and you want that - module, override `ShouldAutoApproveCompilationUnit` to `true`. +## Attributes that load modules -`IDependencyModuleSourceGenerator` is one method. You receive the initialization context and a -provider of every discovered module paired with the configuration in effect: +An attribute that implements `IDependencyModuleProvider` from `DependencyModules.Runtime.Interfaces` can load a module. The generated module attributes implement this interface. You can also write such an attribute: ```csharp -public class MyGenerator : IDependencyModuleSourceGenerator -{ - public void SetupGenerator( - IncrementalGeneratorInitializationContext context, - IncrementalValuesProvider<(ModuleEntryPointModel Left, DependencyModuleConfigurationModel Right)> modules) - { - var candidates = context.SyntaxProvider - .CreateSyntaxProvider(IsCandidate, GetModel) - .Where(model => !model.IsIgnored) - .Collect(); +using DependencyModules.Runtime.Attributes; +using DependencyModules.Runtime.Interfaces; - context.RegisterSourceOutput(modules.Collect().Combine(candidates), Generate); - } -} -``` +namespace Plugins; -For an attribute-driven mechanism, `BaseAttributeSourceGenerator` does more of the work โ€” you -supply the attribute types, a transform, a comparer and an ignored sentinel: +[DependencyModule(OnlyRealm = true)] +public partial class NamedPluginModule(string name) : IPluginModule +{ + public string PluginName => name; +} -```csharp -public class MyGenerator : BaseAttributeSourceGenerator +public class PluginAttribute(string name) : Attribute, IDependencyModuleProvider { - protected override IEnumerable AttributeTypes() => [MyAttributeType]; - protected override MyModel GenerateAttributeModel(GeneratorAttributeSyntaxContext c, CancellationToken t) => โ€ฆ; - protected override IEqualityComparer GetComparer() => new MyModelComparer(); - protected override MyModel IgnoredModel => MyModel.Ignore; - protected override void GenerateSourceOutput(SourceProductionContext context, โ€ฆ) => โ€ฆ; + public IDependencyModule GetModule() => new NamedPluginModule(name); } + +[DependencyModule] +[Plugin("audit")] +public partial class HostModule; ``` -## Emitting registrations +When `HostModule` loads, `NamedPluginModule` also loads. The test packages also read these attributes on test methods, test classes, and the assembly. -Build `ServiceModel`s and hand them to `DependencyFileWriter`. The `uniqueId` becomes part of the -generated method and field names, so pick something that will not collide with another generator -contributing to the same module: +The generated module attribute is `partial`. To add an interface to it, write a partial declaration: ```csharp -var writer = new DependencyFileWriter(logger, coverageAttributeOnMethod: true); +namespace Plugins; -var output = writer.Write(entryPointModel, configurationModel, serviceModels, "MyMechanism"); +public interface IDocumentedModule; -context.AddSource( - entryPointModel.EntryPointType.GetFileNameHint(configuration.RootNamespace, "MyDependencies"), - output); +public partial class ReportsPluginModuleAttribute : IDocumentedModule; ``` -::: tip coverageAttributeOnMethod -`[ExcludeFromCodeCoverage]` is not `AllowMultiple`, and attributes on partial parts combine. Only one -writer can own the class-level attribute, so every other file contributing to the same partial has to -apply it per member. Pass `true` unless you are the first. -::: +## Test extension points + +`DependencyModules.Testing` has interfaces for attributes that change the steps of a test. For more information, refer to [Attributes that you write for tests](./testing.md#attributes-that-you-write-for-tests) and [Other mock libraries](./testing-mocking.md#other-mock-libraries). -## Packaging +## A source generator for different framework attributes -The project is an analyzer, and analyzer packaging is unforgiving in ways that only surface once -someone installs the package. Copy the conventions project's csproj rather than working it out again. +A framework can use a different attribute to identify a module. It can also use different service attributes. The `DependencyModules.SourceGenerator.Impl` package contains the source code of the generator. You compile this source code into your generator. + +Do these steps: + +1. Make a class library that has the target framework `netstandard2.0`. +2. Add the `DependencyModules.SourceGenerator.Impl` package. +3. Set `PackageDependencyModuleIncludeSource` to `true`. +4. Write a class that derives from `BaseSourceGenerator`. ```xml - - netstandard2.0 - true - true - true - false - - $(NoWarn);NU5128 - - - - - + + + netstandard2.0 + latest + enable + enable + true + true + true + + + + + ``` -Roslyn supplies the compiler assemblies at load time, so every compiler dependency must be -`PrivateAssets="all"` or it leaks into your consumers' dependency graphs. +The generator source code uses the `CSharpAuthor` package. The `DependencyModules.SourceGenerator.Impl` package contains the source code of `CSharpAuthor`, and the project compiles it with the generator source code. If the project sets `PackageCSharpAuthorIncludeSource` to `true`, the project compiles `CSharpAuthor` from its own `CSharpAuthor` package. The project then does not compile the copy. + +Version 1.5.0 and the versions before it do not contain the source code of `CSharpAuthor`. For these versions, also add the `CSharpAuthor` package, version 2.0.0, with `IncludeAssets="build"`. Then set `PackageCSharpAuthorIncludeSource` to `true`. -### Reusing the shared sources +```csharp +using CSharpAuthor; +using DependencyModules.Conventions; +using DependencyModules.SourceGenerator; +using DependencyModules.SourceGenerator.Impl; +using Microsoft.CodeAnalysis; -The shared code is compiled **into** your analyzer rather than referenced, because an analyzer -assembly cannot depend on another one at load time: +namespace MyFramework.Generator; -```xml - - - Impl\%(RecursiveDir)/%(FileName)%(Extension) - - +[Generator] +public class FrameworkGenerator : BaseSourceGenerator +{ + protected override ITypeDefinition[] ModuleAttributeTypes() => + new[] { TypeDefinition.Get("MyFramework", "FrameworkModuleAttribute") }; + + protected override IEnumerable AttributeSourceGenerators() + { + yield return new ServiceSourceGenerator(); + yield return new ConventionGenerator(); + } +} ``` -That works because **`Impl` declares no `[Generator]` of its own**. Compiling it into a second -analyzer assembly adds no second registration of the service, decorator or interceptor generators, so -a project referencing both packages does not generate everything twice. +The source code in the package declares no `[Generator]` class. Only the `DependencyModules.SourceGenerator` package declares one. Thus your generator does not contain a copy of the DependencyModules generator. -If you carry the `DM####` descriptors, you need their release tracking too, or the build fails -RS2008: +`BaseSourceGenerator` has these members to override: -```xml - - - - -``` +| Member | Function | +| --- | --- | +| `ModuleAttributeTypes()` | The attributes that identify a module. The default is `[DependencyModule]`. If your generator uses only `[DependencyModule]`, it does not write the module class. The `DependencyModules.SourceGenerator` package writes it. | +| `AttributeSourceGenerators()` | The parts that write registrations. | +| `SetupRootGenerator(...)` | Writes the module classes. An override can write no module classes. | +| `ShouldAutoApproveCompilationUnit` | If the value is `true`, the generator uses `Program.cs` as the entry of a generated `ApplicationModule`. The default value is `true` only if `ModuleAttributeTypes()` gives only `[DependencyModule]`. | +| `GenerateEntryPointModel(...)` | Makes the model of a module from a module declaration or from `Program.cs`. | + +A framework generator that does not use `DependencyModules.SourceGenerator` can set `ShouldAutoApproveCompilationUnit` to `true`. The framework generator then writes the `ApplicationModule`. + +The package contains these parts: + +- `ServiceSourceGenerator` writes the registrations for the service attributes. +- `ConventionGenerator` writes the convention registrations and the decorators. -## Three rules that will cost you a day each +The package does not contain the interception part. -**Never put a symbol in a model.** `ISymbol` is not equatable and holds its `SyntaxTree` alive. A -model containing one never compares equal across runs, so the incremental cache misses on every -keystroke and pins memory. Render what you need to strings or `ITypeDefinition` during the transform. +A part implements `IDependencyModuleSourceGenerator`. This interface has one method: `SetupGenerator(context, provider)`. The provider gives pairs of `ModuleEntryPointModel` and `DependencyModuleConfigurationModel` values. -**Give every model structural equality.** A positional record compares `IReadOnlyList` members by -reference, so two structurally identical models built on consecutive runs are unequal and everything -downstream recomputes. `ModelEquality.ListEquals` and `ListHashCode` exist for this. +To write registrations for attributes that you declare, derive a part from `BaseAttributeSourceGenerator`. Implement `AttributeTypes()`, `GenerateAttributeModel`, `GenerateSourceOutput`, `GetComparer()`, and `IgnoredModel`. -**Keep the predicate syntax-only and cheap.** It runs on a great many nodes. Reject on node type -first, and never touch the semantic model โ€” resolve in the transform, which runs only for what the -predicate accepted. +`DependencyFileWriter` writes the registration code for a list of `ServiceModel` values. Its `Write` method has a `uniqueId` parameter. The name of the generated method is `uniqueId` and the suffix `Dependencies`. Thus each part that adds registrations to a module must use a different `uniqueId`. -## Refuse rather than guess +`DependencyFileWriter` puts `[ExcludeFromCodeCoverage]` on the members that it writes, and not on the class. Use the constructor that takes only the logger. The constructor with the `coverageAttributeOnMethod` parameter is obsolete. It ignores the value of the parameter. -The house style is that an unsupported shape produces a `DM####` diagnostic and generates nothing. -The failure mode should be "this library does not support X", never a `CS` error inside generated -code, and never a silent absence. +The generator source code declares the DM diagnostics. The compiler then gives the warning RS2008 for each diagnostic, because your project has no analyzer release tracking. -Silent failure is the recurring bug class here. When you add something, ask what happens when it does -*not* work โ€” and if the answer is "nothing is registered and the build is green", add a diagnostic. +To suppress these warnings, add `RS2008` to `NoWarn`. You can also add release tracking files. -## Testing it +A project that uses your generator must reference `DependencyModules.Runtime`, because the generated code uses it. -Drive the generator in memory and then **execute what it produced**. Asserting on generated text -passes happily while the wrong service type is registered. +Your generator reads the MSBuild properties only if the project declares them as `CompilerVisibleProperty` items. The `DependencyModules.SourceGenerator` package declares them in its `build` folder. Declare them in your package too. -The pattern used throughout this repository is: compile the source with the generator, emit a real -assembly, load it, build a provider, and resolve. See [Testing modules](/guide/testing) for the -consumer-facing equivalent. +The `DependencyModules.SourceGenerator` package has these properties. You can use the same properties for your generator package: -One caveat if you drive two analyzers from one test project: both compile in the shared `Impl` -sources, so referencing both as libraries puts two copies of every `Impl` type in scope and every use -is `CS0433`. Reach the second through an `Alias` and let everything else resolve to the first. +- `IncludeBuildOutput` is `false`. +- `DevelopmentDependency` is `true`. +- The generator assembly is in the `analyzers/dotnet/cs` folder of the package. +- `NoWarn` contains `NU5128`. +- The Roslyn package references have `PrivateAssets="all"`. diff --git a/website/guide/getting-started.md b/website/guide/getting-started.md index 9e5ce82..7ca7790 100644 --- a/website/guide/getting-started.md +++ b/website/guide/getting-started.md @@ -1,188 +1,132 @@ # Getting started -## The problem +DependencyModules is a source generator for `Microsoft.Extensions.DependencyInjection`. You put attributes on your classes. When you compile the project, the generator writes the code that adds these classes to an `IServiceCollection`. -Every .NET application wires its services in one place, and that place grows: +The generated code does not use reflection to find services at run time. It contains one registration call for each service. -```csharp -// Program.cs, eventually -services.AddScoped(); -services.AddScoped(); -services.AddSingleton(); -services.AddScoped(); -// โ€ฆ and another two hundred lines -``` - -Nothing checks that this list is complete. Write a new class, forget to add its line, and the failure -shows up at run time: - -``` -System.InvalidOperationException: Unable to resolve service for type -'MyApp.IPricingRules' while attempting to activate 'MyApp.OrderService'. -``` - -Usually in the environment you deployed to, rather than the one you tested in. - -The common escape is a runtime scanner such as Scrutor: describe the types once, and let reflection -find them when the application starts. That does remove the list, but it costs you three things. You -can no longer read what was registered. The scan runs on every start. And the trimmer cannot see -through reflection, so a published, trimmed or Native AOT build registers nothing and fails at -startup โ€” a failure that never reproduces in development. +## Before you start -## How DependencyModules helps +- The project must have the target framework `net8.0` or a subsequent version. The packages contain assemblies for `net8.0` and `net10.0`. +- The C# compiler must contain Roslyn 4.10 or a subsequent version. The .NET SDK 8.0.300 and all subsequent SDKs contain this compiler. -You declare registration next to the class it belongs to, and a source generator writes the -`services.AddScoped(โ€ฆ)` calls into your assembly **while the project builds**. +## Install the packages -The hand-written list comes back, except you did not write it and cannot forget a line. Because it is -ordinary C# in your own assembly, there is nothing to reflect over at startup and nothing for the -trimmer to lose. - -## Install +Add the two packages to the project that contains your services: ```shell dotnet add package DependencyModules.Runtime dotnet add package DependencyModules.SourceGenerator ``` -Requires .NET 8.0 or later, and ships both `net8.0` and `net10.0` assemblies so a project on either -LTS release gets one built against its own framework. +`DependencyModules.Runtime` contains the attributes and the types that the generated code uses. It has one dependency: `Microsoft.Extensions.DependencyInjection.Abstractions`. The version of this dependency is 8.0.0 for `net8.0` and 10.0.0 for `net10.0`. -::: tip A console app or class library needs one more -`DependencyModules.Runtime` depends on `Microsoft.Extensions.DependencyInjection.Abstractions`, which -is the right dependency for a library โ€” but `ServiceCollection` and `BuildServiceProvider()` live in -the implementation package. A project using the Web or Worker SDK already has it through its framework -reference. Anything else needs: +`DependencyModules.SourceGenerator` contains the generator. The generator operates only when you compile. The build output does not contain the generator. The package sets `DevelopmentDependency` to `true`. If you pack your project as a NuGet package, your package does not get a dependency on the generator package. -```shell -dotnet add package Microsoft.Extensions.DependencyInjection -``` -::: - -Those two are everything the library itself needs โ€” [conventions](/guide/conventions) included. The -optional packages are for [testing](/guide/testing), and this guide will tell you when you want them: +To build a service provider, the application must also have the `Microsoft.Extensions.DependencyInjection` package. ASP.NET Core applications and applications that use the .NET generic host contain this package. -| Package | For | -|---|---| -| `DependencyModules.xUnit` / `DependencyModules.NUnit` | [building a provider in tests](/guide/testing) from your real modules | -| `DependencyModules.NSubstitute` / `.Moq` / `.FakeItEasy` | [mocking a service](/guide/testing-mocking) inside such a test | +## Register a service -## Your first module +Put a service attribute on each class that you want in the service collection. Each attribute sets one lifetime: -Two pieces. First, mark the class you want registered: +| Attribute | Lifetime | +| --- | --- | +| `[SingletonService]` | `ServiceLifetime.Singleton` | +| `[ScopedService]` | `ServiceLifetime.Scoped` | +| `[TransientService]` | `ServiceLifetime.Transient` | ```csharp using DependencyModules.Runtime.Attributes; -namespace MyApp; +namespace Shop; -public interface IEmailSender { void Send(string to); } +public interface IPriceCalculator +{ + decimal Total(decimal price, int quantity); +} [SingletonService] -public class SmtpEmailSender : IEmailSender +public class PriceCalculator : IPriceCalculator { - public void Send(string to) { } + public decimal Total(decimal price, int quantity) => price * quantity; } ``` -Second, declare a **module** โ€” a `partial` class the generator fills in. It collects every marked -class in the project: +The generator registers `PriceCalculator` as `IPriceCalculator`, because `PriceCalculator` implements this interface. For more information about the service type, refer to [Services](./services.md). -```csharp -[DependencyModule] -public partial class ApplicationModule; -``` +## Declare a module -`partial` is required. The generator completes the class you declared; without `partial` there is -nothing to complete, and you get [DM0003](/reference/diagnostics#dm0003). +A module is a partial class with the `[DependencyModule]` attribute. The generator writes the other part of the class. This part adds the services of the project to a service collection. -::: tip You may already have one -A project whose entry point is a top-level `Program.cs` gets an `ApplicationModule` generated for it, -in the project's `RootNamespace` โ€” so in that project the declaration above is redundant, and -declaring it merges with the generated one rather than fighting it. +```csharp +using DependencyModules.Runtime.Attributes; -If you want to add a `ConfigureServices` to that generated module, declare the partial **without** -`[DependencyModule]` and implement `IServiceCollectionConfiguration`: +namespace Shop; -```csharp -public partial class ApplicationModule : IServiceCollectionConfiguration -{ - public void ConfigureServices(IServiceCollection services) => - services.AddHttpClient(); -} +[DependencyModule] +public partial class ShopModule; ``` -See [Modules](/guide/modules#you-may-not-need-to-declare-one). -::: +The class must be `partial`. If the class is not partial, the generator gives the error DM0003 and writes no code for the module. + +## Load the module -Now load it at your composition root: +Call `AddModule()` on the service collection. Then build the service provider. ```csharp using DependencyModules.Runtime; +using Microsoft.Extensions.DependencyInjection; +using Shop; var services = new ServiceCollection(); -services.AddModule(); +services.AddModule(); var provider = services.BuildServiceProvider(); -var sender = provider.GetRequiredService(); // SmtpEmailSender + +var calculator = provider.GetRequiredService(); + +Console.WriteLine(calculator.Total(2.50m, 4)); ``` -That is the whole loop: mark the class, declare the module once, load the module once. +In an ASP.NET Core application, call `AddModule()` on `builder.Services`: + +```csharp +using DependencyModules.Runtime; +using Shop; -::: tip Call AddModule once -Modules pull in other modules through attributes rather than by calling `AddModule` inside each -other โ€” see [Modules](/guide/modules#composing-modules). Calling it once at the composition root -keeps the registration order predictable and avoids registering anything twice. -::: +var builder = WebApplication.CreateBuilder(args); -## Proving to yourself that nothing is hiding +builder.Services.AddModule(); -The generated code is the ground truth, and it is worth looking at once so the rest of this guide -reads as concrete rather than magic. Turn it on: +var app = builder.Build(); -```xml - - true - +app.Run(); ``` -Build, then open `obj/โ€ฆ/ApplicationModule.Dependencies.g.cs`. Inside it: +## Generated code + +For `ShopModule`, the generator writes two files. The names of the files contain the module name. The `ShopModule.Module.g.cs` file implements `IDependencyModule` on the module. The `ShopModule.Dependencies.g.cs` file contains the registrations: ```csharp -private static void ModuleDependencies(IServiceCollection services) +private static void ModuleDependencies(global::Microsoft.Extensions.DependencyInjection.IServiceCollection services) { - services.AddSingleton(typeof(MyApp.IEmailSender), typeof(MyApp.SmtpEmailSender)); + services.AddSingleton( + typeof(global::Shop.IPriceCalculator), + typeof(global::Shop.PriceCalculator) + ); } ``` -One line, and it is the line you would have written by hand. No reflection, no startup scan, and a -literal `typeof()` the trimmer can follow. - -::: warning If you redirect the output, exclude it from the build -`EmitCompilerGeneratedFiles` alone writes under `obj/`, which is already excluded and is fine. -`CompilerGeneratedFilesOutputPath` pointing at a folder **inside your project** is the trap: those -files are then compiled as ordinary source *as well as* being generated, so every type exists twice -and you get a wall of `CS0111`/`CS0579`. +To see the generated files, set `EmitCompilerGeneratedFiles` to `true` in the project file. The compiler then writes the files below the `obj` folder. ```xml true - generated - - - - ``` -Stale files are the other half: delete the folder when you rename a module, or the old name compiles -alongside the new one. -::: - -## Where to go next +## Next steps -- [Modules](/guide/modules) โ€” grouping registrations and composing them across projects -- [Registering services](/guide/services) โ€” lifetimes, keys, factories, `As`, `Try`/`Replace` -- [Conventions](/guide/conventions) โ€” when attributing each class stops scaling -- [Testing modules](/guide/testing) โ€” building a provider from your real modules in a test +- [Services](./services.md) tells you about lifetimes, service types, keys, and factory methods. +- [Modules](./modules.md) tells you how to use modules together and how to select the services that each module registers. +- [Testing](./testing.md) tells you how to write tests that get services from your modules. diff --git a/website/guide/interception.md b/website/guide/interception.md index c94798c..05b084f 100644 --- a/website/guide/interception.md +++ b/website/guide/interception.md @@ -1,297 +1,184 @@ # Interception -## The problem +An interceptor is a class that runs code before and after the calls to a service. You write the interceptor one time and use it for many services. For each intercepted service, the generator writes a wrapper class. The wrapper calls the interceptors. The last interceptor calls the service. -A [decorator](/guide/decorators) works well when you want to do something to one member. It scales -badly in two directions. +## Write an interceptor -**Wide interfaces.** To time one method on an interface with twenty members, you write a decorator -with twenty methods โ€” nineteen of which are pass-throughs that exist only to compile, and which -someone has to remember to update when a twenty-first member appears. +An interceptor implements one or more interfaces from the `DependencyModules.Runtime.Interception` namespace: -**Many services.** To time thirty unrelated services, you write thirty decorators. The behaviour is -identical in all of them; only the interface differs. +| Interface | Members that it intercepts | +| --- | --- | +| `IInterceptor` | Methods that have a return value, and `void` methods. Properties, indexers, and events. | +| `IAsyncInterceptor` | Methods that have the return type `Task`, `Task`, `ValueTask`, or `ValueTask`. | +| `IAsyncEnumerableInterceptor` | Methods that have the return type `IAsyncEnumerable`. | -In both cases you are writing forwarding code by hand, and the actual logic is four lines. - -## How DependencyModules helps +```csharp +using System.Diagnostics; +using DependencyModules.Runtime.Interception; -Write the behaviour once, as an interceptor. The generator emits a type implementing the service -interface and routes its members through it โ€” every member by default, and -[the kinds you name](#covering-some-members-and-not-others) when that is too much: +namespace Inventory; -```csharp -public class TimingInterceptor(ILogger log) : IInterceptor +public class TimingInterceptor : IInterceptor, IAsyncInterceptor { public TResult Intercept(InvocationContext context) { - var stopwatch = Stopwatch.StartNew(); - - try - { - return context.Proceed(); - } - finally - { - log.LogInformation("{Member} took {Elapsed}", context.Caller.MemberName, stopwatch.Elapsed); - } + var watch = Stopwatch.StartNew(); + var result = context.Proceed(); + Console.WriteLine($"{context.Caller}: {watch.ElapsedMilliseconds} ms"); + return result; + } + + public async ValueTask InterceptAsync( + AsyncInvocationContext context + ) + { + var watch = Stopwatch.StartNew(); + var result = await context.ProceedAsync(); + Console.WriteLine($"{context.Caller}: {watch.ElapsedMilliseconds} ms"); + return result; } } ``` -Apply it to any service, however many members it has: +Each context has these members: -```csharp -[SingletonService] -[Intercept(typeof(TimingInterceptor))] -public class Repository : IRepository { } -``` +| Member | Function | +| --- | --- | +| `Caller` | A `CallerInfo` value with the service type and the member name. Its `ToString()` gives `Service.Member`. | +| `Arguments` | The arguments of the call. You can read them with `Count`, the indexer, and `NameAt(index)`. You can change an argument before the call continues. | +| `Proceed()` | Calls the next interceptor, or the service after the last interceptor. `AsyncInvocationContext` has `ProceedAsync()`. | -The return type comes from the generated call site rather than from reflection, so nothing is boxed -and nothing is inspected at run time. +For a member that has no return value, `TResult` is the `NoResult` type. These members include `void` methods, `Task` methods, `ValueTask` methods, property `set` accessors, and event accessors. -::: info Only calls through the interface are intercepted -A call the implementation makes to *itself* does not pass through the wrapper โ€” it is an ordinary -method call inside one object. -::: +An interceptor must call `Proceed()` or `ProceedAsync()` to call the service. If the interceptor does not call `Proceed()` or `ProceedAsync()`, the service does not run. -## Three interfaces, chosen per member +An interceptor can call `Proceed()` or `ProceedAsync()` more than one time, for example to try a call again. Each call runs the subsequent interceptors and the service again. -A synchronous interceptor cannot serve a `Task`-returning member, because it has nowhere to await. -Implement whichever kinds your services actually have: +An async interceptor waits for `ProceedAsync()`. Thus the code after the `await` runs when the call is complete. An `IAsyncEnumerableInterceptor` gets the stream from `Proceed()` and enumerates it. Thus it gets each item of the stream. -| Interface | For members returning | -|---|---| -| `IInterceptor` | a value directly, or `void` | -| `IAsyncInterceptor` | `Task`, `Task`, `ValueTask`, `ValueTask` | -| `IAsyncEnumerableInterceptor` | `IAsyncEnumerable` | +## Use interceptors on a class -One type may implement any combination, and **the generator picks per member**: +Put `[Intercept]` on the implementation class. The class must also have a registration, from a service attribute or from a convention. ```csharp -public class TracingInterceptor : IInterceptor, IAsyncInterceptor -{ - public TResult Intercept(InvocationContext context) => context.Proceed(); - - public async ValueTask InterceptAsync(AsyncInvocationContext context) - { - using var span = tracer.StartSpan(context.Caller.MemberName); - - return await context.ProceedAsync(); - } -} -``` +using DependencyModules.Runtime.Attributes; -A member that no interceptor can serve is forwarded untouched, with no allocation. So an interceptor -implementing only `IAsyncInterceptor`, applied to a service with both synchronous and asynchronous -members, intercepts the asynchronous ones and leaves the rest alone. +namespace Inventory; -## Awaiting is yours - -The generated wrapper awaits nothing on your behalf. Your interceptor awaits `ProceedAsync()` itself, -which means anything after the await runs once the work has genuinely finished โ€” and because the -whole call sits in one method body, state that spans it is an ordinary local: - -```csharp -public async ValueTask InterceptAsync(AsyncInvocationContext context) +public interface IStockService { - using var scope = _tracer.StartSpan(context.Caller.MemberName); // spans the whole call + int Count(string sku); - return await context.ProceedAsync(); + Task ReserveAsync(string sku, int quantity); } -``` -That `using` disposes after the awaited work completes, not when the `Task` was handed back. - -## Streams - -An `IAsyncEnumerable` member returns its stream immediately, before any item exists. A stream -interceptor enumerates it, so it observes each item as it is produced: - -```csharp -public async IAsyncEnumerable InterceptStream(StreamInvocationContext context) +[SingletonService] +[Intercept(typeof(TimingInterceptor))] +public class StockService : IStockService { - var count = 0; - - await foreach (var item in context.Proceed()) - { - count++; - yield return item; - } + public int Count(string sku) => 10; - log.LogInformation("{Member} produced {Count}", context.Caller.MemberName, count); + public Task ReserveAsync(string sku, int quantity) => Task.CompletedTask; } ``` -## What the context gives you - -| Member | | -|---|---| -| `Proceed()` / `ProceedAsync()` | run the rest of the pipeline โ€” more than once to retry, or not at all to skip the implementation | -| `Caller.ServiceType`, `Caller.MemberName` | what is being called | -| `Arguments` | by index, and **writable** โ€” a write replaces what the implementation receives. `NameAt(index)` gives the declared parameter name | +You can give more than one interceptor, for example `[Intercept(typeof(AuditInterceptor), typeof(TimingInterceptor))]`. The first interceptor in the list gets the call first. -Arguments cost nothing until you read one. +You can also put more than one `[Intercept]` attribute on the class. The generator then adds the interceptors in the sequence of the attributes. For `Service`, `Order`, and `Realm`, the generator uses the value from the last attribute that sets the property. The wrapper intercepts only the members that all the attributes select. -## Several interceptors - -```csharp -[Intercept(typeof(TimingInterceptor), typeof(RetryInterceptor))] -public class Repository : IRepository { } -``` +The wrapper intercepts only the calls on the service type. If the class calls one of its members, the wrapper does not intercept this call. -They nest in declaration order. Each is resolved from the container, so an interceptor can take -dependencies of its own โ€” as `TimingInterceptor` does with its `ILogger`. +## Service type -## How an interceptor is registered +The generator intercepts one interface of the class. By default, it uses the interface in the class declaration. If the class declaration contains no interface, the generator examines the base classes in sequence. It uses the interfaces of the first base class that declares interfaces. -You do not register interceptors. The generated code registers each one **as its own type**, with -`TryAdd`, so it is resolvable by the wrapper without being visible as an `IInterceptor` to anything -else. +If the class declares more than one interface, set `Service`: -`TryAdd` means a registration you made yourself wins. An interceptor carrying its own -`[SingletonService]` or `[ScopedService]` keeps that lifetime, because services are applied before -decorators and yours is already there by the time this runs. +```csharp +[SingletonService] +[Intercept(typeof(TimingInterceptor), Service = typeof(IStockService))] +public class AuditedStockService : IStockService, IDisposable +{ + public int Count(string sku) => 10; -The default is singleton, and it is the wrong default for an interceptor that takes a scoped -dependency โ€” a singleton holding a scoped service is a captive dependency, and nothing says so unless -`ValidateScopes` is on. Name the lifetime instead: + public Task ReserveAsync(string sku, int quantity) => Task.CompletedTask; -```csharp -[Intercept(typeof(AuditInterceptor), Lifetime = ServiceLifetime.Scoped)] -public class Repository : IRepository { } + public void Dispose() { } +} ``` -## An interception belongs to one implementation +The generator gives the warning DM0008 in these conditions: -`[Intercept]` applies to **the class it is written on**, not to every implementation of the interface: +- The generator finds no interface. +- The generator finds more than one interface, and `[Intercept]` does not set `Service`. +- `Service` is an interface that the class does not implement. +- The service type has no members. -```csharp -[SingletonService] [Intercept(typeof(TimingInterceptor))] -public class SqlRepository : IRepository { } +The interception is applicable only to the registrations of the class. The wrapper changes a registration only if the implementation of the registration is the class. These registrations have the class as their implementation: -[SingletonService] -public class InMemoryRepository : IRepository { } // not wrapped -``` +- A registration with the class type +- An instance of the class +- A factory that has the class as its return type -This is the opposite of a [decorator](/guide/decorators), which is declared against the interface and -wraps everything behind it โ€” and the difference is the point. A decorator says "this behaviour -belongs to the interface"; an interceptor says "this behaviour belongs to this class". +The wrapper does not change a factory that has `object` or the service type as its return type, also if the factory makes the class. For example, the wrapper does not change `services.AddSingleton(_ => new StockService())`. -Decorators can name one implementation too, with `[Decorator(Implementation = typeof(X))]`, when that -turns out to be what you meant. +## Intercepted members -## Realms - -An interception with no `Realm` takes the one its own class's service attribute names, so these agree -without being told to: +By default, the wrapper intercepts all members of the service type: methods, properties, indexers, and events. It also intercepts the members of the interfaces that the service type derives from. The `Members` property selects the types of members to intercept: ```csharp -[SingletonService(Realm = typeof(DiagnosticsModule))] -[Intercept(typeof(AuditInterceptor))] -public class Profiler : IProfiler { } -``` - -The interception lands in `DiagnosticsModule`, where the registration is. Name a realm explicitly to -override that: +[SingletonService] +[Intercept(typeof(TimingInterceptor), Members = InterceptedMembers.Methods)] +public class MethodsOnlyStockService : IStockService +{ + public int Count(string sku) => 10; -```csharp -[Intercept(typeof(AuditInterceptor), Realm = typeof(DiagnosticsModule))] + public Task ReserveAsync(string sku, int quantity) => Task.CompletedTask; +} ``` -A class registered by a **convention** takes its realm at match time, which is too late for an -interception to inherit โ€” so a realm-only convention module needs the realm named on `[Intercept]`. -[DM0020](/reference/diagnostics#dm0020) reports an interception no module ends up applying. - -## Covering some members and not others - -Every member is covered by default, which is right for auditing or retry โ€” an interceptor has no way -to know which members matter, and leaving one out silently is worse than covering too much. - -It is the wrong default for an interface with properties, where a timing interceptor records a call -per read. Name the kinds instead: +The values of `InterceptedMembers` are `Methods`, `Properties`, `Indexers`, `Events`, and `All`. You can use more than one value with the `|` operator. The generator reads the value of `Members`, not its text. Thus you can also use a constant. The wrapper sends the calls to the other members directly to the service. -```csharp -[Intercept(typeof(TimingInterceptor), Members = InterceptedMembers.Methods)] -public class Repository : IRepository { } -``` +An interceptor intercepts only the members that its interfaces can intercept. For example, an interceptor that implements only `IInterceptor` does not intercept a method that has the return type `Task`. The generator then gives the warning DM0015 with the names of these members. -`InterceptedMembers` has `Methods`, `Properties`, `Indexers` and `Events`, combinable with `|`. A -member left out is still forwarded โ€” the wrapper implements the whole interface either way โ€” it just -does not run through the chain. +## Interceptor registration -## What cannot be intercepted +The generator registers each interceptor as its class type, with `TryAdd`. The `Lifetime` property of `[Intercept]` sets the lifetime of this registration. The default is `Singleton`. The interceptor gets its constructor parameters from the service provider. -The generator has to emit a real override, so some shapes are impossible. These are reported as -[DM0008](/reference/diagnostics#dm0008) rather than failing the build: +If the service collection has a registration for the interceptor class type, the generator does not add a registration. For example, `[SingletonService(As = typeof(TimingInterceptor))]` on the interceptor makes such a registration. A service attribute without `As` registers the interceptor as its first interface, for example `IInterceptor`. That registration does not prevent the `TryAdd` registration. -- `ref`, `in` and `out` parameters, and `ref struct` parameters -- by-reference returns -- `init`-only setters -- static members -- a generic *method* whose shape the wrapper cannot forward, by the same rules as above +## Sequence with decorators -::: warning One such member disables interception for the whole interface -There is no partial wrapper. A single `out` parameter anywhere on the interface means no wrapper is -generated at all, so every other member goes uninterceped too, and `GetRequiredService()` -returns the plain implementation. The diagnostic names the member it found first; fixing it may -uncover another. +The interception occurs at the same time as the decorators, after all modules add their services. The `Order` property of `[Intercept]` sets the position of the interception in the decorator sequence. The value can be a number or a constant. For more information, refer to [Decorator sequence](./decorators.md#decorator-sequence). -Move the member to an interface that is not intercepted, or write a -[decorator](/guide/decorators) for the service instead. -::: +## Realms -## Intercepting a generic service +By default, the interception is applicable in the same modules as the registration of the class. If the service attribute sets `Realm`, the interception uses the same realm. To set a different realm, set `Realm` on `[Intercept]`. -A generic implementation registers as an open generic, and a decorator cannot touch one โ€” decoration -rewrites a registration into a factory, and the container refuses a factory for an open generic -service type. Interception does not need a factory: the wrapper is a generated type, and an open -generic implementation type is what the container does accept. +If no module uses the interception, the generator gives the warning DM0020. This can occur when a realm-only module registers the class from a convention and the interception has no realm. -```csharp -[SingletonService] -[Intercept(typeof(TracingInterceptor))] -public class Repository : IRepository { โ€ฆ } -``` +## Generic services -The wrapper is generic over the same parameters โ€” `Repository_Intercepted : IRepository` โ€” and -takes `Repository` by its own type rather than the service, which would resolve back to the wrapper -and recurse. The container closes it per construction, so `IRepository` and -`IRepository` each get their own. +The generator can intercept a generic class. It writes a generic wrapper with the same type parameters and constraints. The generated code also registers the class as its open generic type, with the same lifetime. The wrapper gets the service from this registration. -::: warning Native AOT closes this over reference types only -An open generic registration is the container's least AOT-friendly shape, intercepted or not: a -published binary can construct `IRepository` and throws for `IRepository`. That is not -specific to interception โ€” a plain `[SingletonService]` on a generic class behaves identically. See -[Trimming and AOT](/guide/aot#what-it-does-not-cover). -::: +## Members that the generator cannot intercept -Constraints come along with the parameters. `Repository where T : class, IEntity, new()` is wrapped -by `Repository_Intercepted : IRepository where T : class, IEntity, new()`, because without them -the wrapper could not reference what it wraps. +The generator does not intercept a service type that has one of these members: -## When an interceptor covers only some members +- A static member +- A method or a property that has a `ref` return value +- A method that has a `ref struct` return type +- A parameter with `ref`, `out`, or `in` +- A parameter or a property of a `ref struct` type +- A property with an `init` accessor +- An event without `add` and `remove` accessors -Separate from the above, and quieter. Each interceptor is placed only around the members whose shape -it can serve โ€” `IInterceptor` for a direct return, `IAsyncInterceptor` for a task, -`IAsyncEnumerableInterceptor` for a stream โ€” and it is simply absent from the rest: +In these conditions, the generator gives the warning DM0008 and writes no wrapper. The diagnostic gives the name of one member. Other members can have the same problem. If you move these members to a different interface, the generator can intercept the service type. You can also use a [decorator](./decorators.md). -```csharp -public class AuditInterceptor : IInterceptor { โ€ฆ } // sync only +The generator cannot find a type parameter with the `allows ref struct` constraint. If a return type or a parameter type is such a type parameter, the generator gives no DM0008. The generated wrapper then does not compile. -[SingletonService] -[Intercept(typeof(AuditInterceptor))] -public class Orders : IOrders -{ - public int Count(string customer) { โ€ฆ } // audited - public Task CountAsync(string customer) { โ€ฆ } // not audited -} -``` +## Generated code -That is [DM0015](/reference/diagnostics#dm0015). It is worth taking seriously rather than silencing: -an interceptor that rewrites arguments stops rewriting them, and one that authorises or audits stops -doing that โ€” on the async members, which are usually the ones doing the work. Implement the missing -interface, or apply the interceptor to a service with no such member. +For each intercepted class, the generator writes an `internal` wrapper class in the namespace of the class. The name of the wrapper is the class name and the suffix `_Intercepted`, for example `StockService_Intercepted`. For a class that is not generic, the generated code makes the wrapper with a constructor call. Thus it does not use reflection. -One type may implement any combination of the three, which is how a single interceptor covers a mixed -interface. +When you get an intercepted service from the service provider, you get the wrapper. Thus a test that examines the type of the service finds the wrapper type, not the class type. diff --git a/website/guide/modules.md b/website/guide/modules.md index 30897a9..67e8f96 100644 --- a/website/guide/modules.md +++ b/website/guide/modules.md @@ -1,239 +1,359 @@ # Modules -## The problem +A module is a partial class or a partial record with the `[DependencyModule]` attribute. The generator writes the other part of the module. The module then adds its services to an `IServiceCollection`. -A single project registering everything is fine until it is not. Two things push back: +```csharp +using DependencyModules.Runtime.Attributes; -**Your own application grows areas.** Data access, messaging, and diagnostics each have their own -services, and you would like to reason about them โ€” and switch them out โ€” as units rather than as one -undifferentiated pile of registrations. +namespace Catalog; -**A library cannot register itself.** If you ship a package, its services have to end up in the -consumer's container somehow. The usual answer is to export an `AddMyLibrary(this IServiceCollection)` -extension method and hope everybody remembers to call it, in the right order, once. +[DependencyModule] +public partial class CatalogModule; +``` -## How DependencyModules helps +A module must be `partial`. If it is not partial, the generator gives the error DM0003. A module must be at the namespace level. If you declare a module in a different class, the generator gives the error DM0017. In the two conditions, the generator writes no code for the module. -A **module** is a unit of registration you can name, and modules pull each other in. A library -declares its own module; an application references it and gets everything the library registers -without knowing what any of it is. +The generator reads the attributes of a module only from the declaration that has `[DependencyModule]`. If the module has more than one partial declaration, put the module attributes and the `IDependencyModuleFeature` interfaces on that declaration. -## Declaring one +## Services in a module -A module is a `partial` class carrying `[DependencyModule]`. The generator completes the partial with -the code that applies its registrations: +A module registers these services: -```csharp -[DependencyModule] -public partial class ApplicationModule; -``` +- Each service in the same project that does not set `Realm`. +- Each service that sets `Realm` to the module. +- Each class that a convention of the module selects. For more information, refer to [Conventions](./conventions.md). -By default it collects every attributed service in its project. That is the whole declaration โ€” the -body stays empty unless you want something from the rest of this page. +A module can use the module of a different project to get the services of that project. For more information, refer to [Module dependencies](#module-dependencies). A convention can also register classes from a referenced assembly. -::: warning Two rules -A module **must** be `partial`, or the generator has nothing to complete โ€” -[DM0003](/reference/diagnostics#dm0003). +If a project has more than one module, each module registers all services that do not set `Realm`. If you load two of these modules, the services have two registrations. [Realms](#realms) can divide the services of a project between modules. -A module must be declared **directly in a namespace**, never nested inside another type โ€” -[DM0017](/reference/diagnostics#dm0017). A nested module quietly generates a separate, detached class -instead of completing your partial, so its registrations never run. Services can be nested freely; -the restriction is only on modules. -::: +## Load a module -## Composing modules +The `ServiceCollectionExtensions` class in the `DependencyModules.Runtime` namespace has these methods: -Every module generates **an attribute with the same name**. Applying that attribute to another module -makes it a dependency: +| Method | Result | +| --- | --- | +| `AddModule()` | Makes an instance of `T` and loads it. `T` must have a constructor without parameters. | +| `AddModule(module)` | Loads the module instance. | +| `AddModules(params modules)` | Loads the module instances in one operation. | +| `AddModules(environment, params modules)` | Loads the module instances with the environment that you give. For more information, refer to [Environments](./environments.md). | ```csharp -// MyApp.Data โ€” its own project -[DependencyModule] -public partial class DataModule; -``` +using Catalog; +using DependencyModules.Runtime; +using Microsoft.Extensions.DependencyInjection; -```csharp -// MyApp โ€” references MyApp.Data -[DependencyModule] -[DataModule] // everything DataModule registers comes along -public partial class ApplicationModule; -``` +var services = new ServiceCollection(); -Loading `ApplicationModule` now also applies `DataModule`. This is what replaces the -`AddMyLibrary(services)` extension method: a package ships a module, and consuming it is one -attribute rather than a call somebody has to remember. +services.AddModule(); -```csharp -services.AddModule(); // DataModule comes too +var provider = services.BuildServiceProvider(); ``` -::: warning The two modules are in two projects, and that matters -A module collects every attributed service **in its own project** โ€” so two modules declared in one -project each hold that project's whole registration list, and composing one into the other does not -change what either holds. Loading `ApplicationModule` would then apply the same registrations twice. +When a module loads, its dependencies also load. -```csharp -// one project, both modules โ€” every service registers twice -[DependencyModule] public partial class DataModule; -[DependencyModule] [DataModule] public partial class ApplicationModule; -``` +In one load operation, each module loads one time. The load operation uses the `Equals` method to compare two modules. For more information, refer to [Module equality](#module-equality). If you call `AddModule` two times with the same module, the module loads two times and its registrations occur two times. -Composing across projects is the shape above and is what this is for. Two modules that genuinely -belong in one project want [realms](#realms-keeping-a-registration-out-of-the-default-module) -instead: a realm is how you say which registrations belong to which module. -::: +## Module dependencies -Dependencies are expanded **before** the module that declares them, so a module's own registrations -are applied last and win wherever the container is last-wins. An application can therefore override -something a library registered simply by registering it itself. +The generator writes an attribute for each module. The name of the attribute is the name of the module and the suffix `Attribute`. For `CatalogModule`, the attribute is `[CatalogModule]`. -## Loading modules +To make a module use a different module, put the attribute of the other module on the module class: ```csharp -using DependencyModules.Runtime; +using DependencyModules.Runtime.Attributes; + +namespace Shop; -services.AddModule(); -services.AddModules(new ApplicationModule(), new DiagnosticsModule()); +[DependencyModule] +[Catalog.CatalogModule] +public partial class ShopModule; ``` -`AddModules` also accepts an [environment](/guide/environments), which is what conditional -registrations get evaluated against: +When `ShopModule` loads, `CatalogModule` also loads. The dependency can be in a different project or in a NuGet package. Circular dependencies are permitted. Each module loads one time. + +To make the generator write no attribute for a module, set `GenerateAttribute = false`: ```csharp -services.AddModules(new ModuleEnvironment("Development"), new ApplicationModule()); +using DependencyModules.Runtime.Attributes; + +namespace Shop; + +[DependencyModule(GenerateAttribute = false)] +public partial class InternalToolsModule; ``` -## You may not need to declare one +The generated attribute is `partial`. To add interfaces or members to the attribute, write a partial declaration of the attribute class. + +## Module load sequence + +The load sequence has an effect on which registration is the last for a service type. `GetService` gives the instance from the last registration. -For applications using [top-level statements](https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/program-structure/top-level-statements), -an `ApplicationModule` is generated for you from `Program.cs`: +Before the modules add their registrations, the load operation makes a list of modules. It examines the modules that you give, in the sequence of your list. After it examines a module, it examines the dependencies of that module, in the sequence of the module attributes. When the load operation examines a module for the first time, it puts the module at the start of the list. When it finds a module again, it does not change the list. + +Then the modules add their registrations, from the start of the list to the end. Thus the module that the load operation examines first adds its registrations last. + +This list shows the results: + +- If you load one module, the module adds its registrations after all its dependencies. Thus a module can replace a service of a dependency with an `Add` registration of the same service type. +- In one `AddModules` call, the first module in your list adds its registrations last. +- If you call `AddModule` more than one time, each call makes a different list. The modules of the last call add their registrations last. ```csharp -using MyApp; // the generated module takes your RootNamespace using DependencyModules.Runtime; - -[assembly: SomeOtherModule] // compose other modules at the assembly level +using Microsoft.Extensions.DependencyInjection; +using Shop; var services = new ServiceCollection(); -// SomeOtherModule, plus every attributed service in this project -services.AddModule(); +// PaymentModule adds its registrations last. +services.AddModules(new PaymentModule(), new ShippingModule()); ``` -::: warning The first `using` is not optional -The generated module takes the project's `RootNamespace`, and top-level statements sit in the -global namespace โ€” so `Program.cs` cannot see `ApplicationModule` until it imports that namespace. -Leave it out and the build fails with `CS0246: The type or namespace name 'ApplicationModule' could -not be found`, which does not hint at the cause. -::: +A dependency keeps the position where the load operation first finds it. For example, `ShopModule` has the dependency `CatalogModule`. In `AddModules(new CatalogModule(), new ShopModule())`, the load operation examines `CatalogModule` first. Thus `CatalogModule` adds its registrations after `ShopModule`. -This is why the ASP.NET sample in this repository never declares a module โ€” the web project's -`Program.cs` gets one automatically, and the test project composes it by name. +After the modules of one load operation add their services, the decorators of these modules change the registrations. A decorator does not change the registrations that a subsequent load operation adds. For more information, refer to [Decorators](./decorators.md). -## Realms: keeping a registration out of the default module +## Realms -By default an attributed service joins every module in its compilation. Occasionally that is wrong โ€” -a profiler you only want when the diagnostics module is loaded, say. A **realm** scopes a -registration to one named module: +If a service has a realm, only the module of that realm registers the service. Set `Realm` on the service attribute to the type of the module: ```csharp -[SingletonService(Realm = typeof(DiagnosticsModule))] -public class Profiler : IProfiler { } +using DependencyModules.Runtime.Attributes; + +namespace Shop; + +public interface IShippingRates +{ + decimal Rate(string country); +} + +[SingletonService(Realm = typeof(ShippingModule))] +public class ShippingRates : IShippingRates +{ + public decimal Rate(string country) => 5m; +} + +[DependencyModule] +public partial class ShippingModule; ``` -`Profiler` is now registered only by `DiagnosticsModule`, and an application that does not compose -that module never sees it. +Only `ShippingModule` registers `ShippingRates`. The other modules of the project do not register it. -The reverse restriction is on the module itself. `OnlyRealm = true` means the module takes **nothing** -that did not name it: +A module with `OnlyRealm = true` registers only the services that set `Realm` to that module. It also registers the classes that its conventions select. It does not register the other services without a realm. ```csharp +using DependencyModules.Runtime.Attributes; + +namespace Shop; + +public interface IPaymentProcessor +{ + bool Charge(decimal amount); +} + +[SingletonService(Realm = typeof(PaymentModule))] +public class PaymentProcessor : IPaymentProcessor +{ + public bool Charge(decimal amount) => true; +} + [DependencyModule(OnlyRealm = true)] -public partial class DiagnosticsModule; +public partial class PaymentModule; ``` -Convention registrations always name their declaring module as their realm, which is why two modules -running conventions over the same interface do not leak into each other. +Realms are also applicable to decorators and interceptors. A convention registers its services only in the module that declares the convention. + +## Module parameters -::: warning Two modules in one assembly, loaded together, register everything twice -"Joins every module in its compilation" is literal. An assembly declaring two modules that neither set -`OnlyRealm` puts the *whole* registration list in both โ€” decorators included โ€” so loading both in one -call runs it twice: +A module can have constructor parameters and properties with a `set` accessor. The generated attribute has the same constructor parameters and the same properties. Thus the module that uses the attribute can give the values. ```csharp -services.AddModules(new AppModule(), new DataModule()); // every service registered twice +using DependencyModules.Runtime.Attributes; +using DependencyModules.Runtime.Interfaces; +using Microsoft.Extensions.DependencyInjection; + +namespace Shop; + +public record MailSettings(string Host, int Port, string? Sender); + +[DependencyModule(OnlyRealm = true)] +public partial class MailModule : IServiceCollectionConfiguration +{ + private readonly string _host; + private readonly int _port; + + public MailModule(string host, int port) + { + _host = host; + _port = port; + } + + public string? Sender { get; set; } + + public void ConfigureServices(IServiceCollection services) + { + services.AddSingleton(new MailSettings(_host, _port, Sender)); + } + + public override bool Equals(object? obj) => + obj is MailModule other + && other._host == _host + && other._port == _port + && other.Sender == Sender; + + public override int GetHashCode() => HashCode.Combine(_host, _port, Sender); +} + +[DependencyModule] +[MailModule("smtp.example.com", 25, Sender = "shop@example.com")] +public partial class NotificationModule; ``` -Declaring two modules is fine; loading both is what doubles up. **Give one a realm** โ€” that is what -says which registrations belong to which module, and the only thing that removes the doubling. +The generator puts the `public`, `internal`, and `protected internal` properties that have a `set` accessor on the attribute. The `set` accessor must also be `public`, `internal`, or `protected internal`. Thus a property with a `private set` accessor is not on the attribute. The generator does not put `static` properties or the properties of nested classes on the attribute. + +When the attribute does not set a property, the result is as follows: + +- A reference-type property keeps the value from the module, for example the initial value of the property. +- A value-type property gets the default value of its type, for example 0. The property does not keep the initial value from the module. +- A nullable value-type property, for example `int?`, also gets the default value of the value type. The property of the attribute has the type `int`. Thus the module property gets 0, not `null`. + +### Module equality + +A module class that you declare gets a generated `Equals` method and a generated `GetHashCode` method. The generated `Equals` method compares only the module type. Thus, two instances of the same module type are the same module. + +If the module declares `Equals(object)`, the generator does not write these methods. The generator examines all partial declarations of the module. If the module declares `GetHashCode` but not `Equals(object)`, the generator writes only `Equals`. -Composing one into the other does *not*, however it reads: both still hold the whole list, so -loading the outer one applies it twice. Composition is for modules in [separate -projects](#composing-modules), where each holds only its own. -::: +The load operation calls `Equals(object)`. If the module declares only `Equals` for its own type, for example `IEquatable.Equals(T)`, the generated `Equals(object)` calls that method. Thus your method compares the modules. -## Parameters +If a module has properties that the generator puts on the attribute and declares no `Equals` method, the generator gives the warning DM0018. Two instances with different values are then one module. Only the first instance loads. If you declare `Equals` and `GetHashCode`, two instances with different values can load, as shown in the `MailModule` example. -A module can take values from whoever loads it โ€” a connection string, a base URL. Declare them as -properties, and the generated attribute mirrors them: +The generated `ApplicationModule` does not get these methods. + +## Registration code in the module + +A module can add registrations with code. Implement one or more of these interfaces from `DependencyModules.Runtime.Interfaces`: + +| Interface | Method | When it runs | +| --- | --- | --- | +| `IServiceCollectionConfiguration` | `ConfigureServices(IServiceCollection services)` | After the generated registrations of the module. | +| `IServiceCollectionConfiguration` | `ConfigureDecorators(IServiceCollection services)` | After all decorators of all modules. This method is optional. | +| `IEnvironmentServiceCollectionConfiguration` | `ConfigureServices(IServiceCollection services, IModuleEnvironment environment)` | After `IServiceCollectionConfiguration.ConfigureServices`. It gets the environment. | ```csharp -[DependencyModule] -public partial class ApplicationModule +using DependencyModules.Runtime.Attributes; +using DependencyModules.Runtime.Interfaces; +using Microsoft.Extensions.DependencyInjection; + +namespace Shop; + +public record ShopSettings(string Environment); + +[DependencyModule(OnlyRealm = true)] +public partial class SettingsModule : IEnvironmentServiceCollectionConfiguration { - public string? ConnectionString { get; set; } + public void ConfigureServices(IServiceCollection services, IModuleEnvironment environment) + { + services.AddSingleton(new ShopSettings(environment.EnvironmentName)); + } } ``` +## Extension method for a module + +Set `GenerateUseMethod` to make the generator write an extension method for `IServiceCollection`. The method has the name that you give. The parameters of the method are the constructor parameters of the module. The method calls `AddModules`. + ```csharp -[ApplicationModule(ConnectionString = "Server=โ€ฆ")] -public partial class TestModule; +using DependencyModules.Runtime.Attributes; + +namespace Shop; + +[DependencyModule(OnlyRealm = true, GenerateUseMethod = "AddReporting")] +public partial class ReportingModule(string connectionString) +{ + public string ConnectionString => connectionString; +} ``` -::: warning A module with parameters needs an identity -Modules de-duplicate **by type**, which is what stops a module reached twice from registering -everything twice. A module carrying parameters is the case that rule does not fit: two instances -holding different values are the same module by it, so the first one reached wins and the other is -discarded with nothing said. +The generator puts the method in the `ReportingModuleExtensions` class in the same namespace: ```csharp -[DependencyModule] [ApplicationModule(ConnectionString = "primary")] public partial class A; -[DependencyModule] [ApplicationModule(ConnectionString = "reporting")] public partial class B; +using Microsoft.Extensions.DependencyInjection; +using Shop; + +var services = new ServiceCollection(); + +services.AddReporting("Server=reports"); ``` -Load both and one connection string arrives. [DM0018](/reference/diagnostics#dm0018) reports it, and -declaring your own `Equals` and `GetHashCode` says which answer you meant โ€” identity by value, so -both survive, or identity by type, so one wins deliberately. -::: +## The generated application module + +The generator can write a module for an application project. The generated module has the name `ApplicationModule` and is in the root namespace of the project. The generator writes it when these conditions are true: -A **value-typed** parameter cannot carry a default. `public int Retries { get; set; } = 3;` is reset -to `0` by a composition that does not name it, because `0` and "not set" are the same value and the -generated attribute cannot tell them apart. A nullable or reference-typed parameter keeps its -default. Name value-typed parameters at every composition, or make them nullable. +- The project has a `Program.cs` file in the project folder. +- The `DependencyModules_AutoGenerateModule` MSBuild property is not `false`. + +```csharp +using DependencyModules.Runtime; +using WebShop; -## When attributes are not enough +var builder = WebApplication.CreateBuilder(args); -Some registration cannot be expressed as an attribute on a class โ€” `AddHttpClient()`, options -binding, anything from a third-party library with its own extension method. Implement -`IServiceCollectionConfiguration` on the module and you get the collection directly: +builder.Services.AddModule(); + +var app = builder.Build(); + +app.Run(); +``` + +The registrations of `ApplicationModule` are as follows: + +- If the project declares a module that is partial, is not realm-only, and has no constructor parameters, `ApplicationModule` loads that module. If the project declares more than one such module, the generator compares their full names. `ApplicationModule` then loads the first module. +- If the project declares no such module, `ApplicationModule` registers the services of the project. + +To add a module from a different project to `ApplicationModule`, put the module attribute on the assembly in `Program.cs`: ```csharp -[DependencyModule] -public partial class ApplicationModule : IServiceCollectionConfiguration -{ - public void ConfigureServices(IServiceCollection services) - { - services.AddHttpClient(); - } -} +using DependencyModules.Runtime; +using Catalog; +using WebShop; + +[assembly: CatalogModule] + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddModule(); + +var app = builder.Build(); + +app.Run(); ``` -It runs **after** the module's own registrations, with unrestricted access. There is a matching -`ConfigureDecorators` that runs after every module's decorators, and an -`IEnvironmentServiceCollectionConfiguration` that also hands you the -[environment](/guide/environments#programmatic-access). +The generator reads assembly-level module attributes only from `Program.cs`. If you put one in a different file of an application project, the generator gives the error DM0019 and ignores the attribute. An assembly-level attribute must also have a `using` directive for its namespace. If the directive is missing, the generator gives the warning DM0016. + +A `using` directive is not necessary for an attribute with its full name, for example `[assembly: Catalog.CatalogModule]`. Thus DM0016 is only for an attribute without its namespace. DM0019 is also for an attribute with its full name. + +The top-level statements of `Program.cs` can also call a static method of a module. `ApplicationModule` then also loads that module. These conditions are necessary: + +- The statement only calls the method. It does not give the result to a variable. +- The module has a constructor without parameters. +- The module is from a referenced project or package. The generator cannot see the `IDependencyModule` interface of a module from the same project, because this interface is in generated code. + +The generated `ApplicationModule` is a partial class. To add registration code to it, declare `partial class ApplicationModule` in the root namespace without `[DependencyModule]`. Then implement `IServiceCollectionConfiguration`. + +If you declare a module with the name `ApplicationModule` and `[DependencyModule]` in the root namespace, the generator uses your module. It does not write a different `ApplicationModule`. Your module also loads the modules that the assembly-level module attributes and the static calls in `Program.cs` identify. -## Next +## Records as modules + +A module can be a partial record: + +```csharp +using DependencyModules.Runtime.Attributes; + +namespace Shop; + +[DependencyModule] +public partial record AuditModule; +``` -- [Registering services](/guide/services) โ€” what each attribute emits -- [Conventions](/guide/conventions) โ€” registering by rule instead of per class +A record module uses the equality of the record. The generator does not write `Equals` or `GetHashCode` for it. diff --git a/website/guide/scanning.md b/website/guide/scanning.md deleted file mode 100644 index 3892e6b..0000000 --- a/website/guide/scanning.md +++ /dev/null @@ -1,74 +0,0 @@ -# Scanning a package - -## The problem - -A [convention](/guide/conventions) matches types in the project being built. That covers your own -code, but not this: - -You depend on a package that ships a dozen `IHandler<,>` implementations and no -`AddThePackage(services)` extension method. You cannot put `[TransientService]` on those classes โ€” -they are not yours โ€” and a convention declared in your project does not look inside them. - -## How DependencyModules helps - -`InAssemblyOf()` points a convention at a **referenced assembly**, using any type from it as the -marker: - -```csharp -conventions.RegisterAll(typeof(IHandler<,>)) - .InAssemblyOf() - .AsScoped(); -``` - -The package's types are read during **your** build, and each match is emitted as a literal `typeof()` -into **your** assembly: - -```csharp -// generated, in your project -services.AddScoped(typeof(IHandler), typeof(ThePackage.CreateOrderHandler)); -``` - -Nothing is loaded or reflected over at run time, so this survives trimming exactly as your own -registrations do โ€” see [Trimming and AOT](/guide/aot). - -## Filters and shapes work the same - -Everything from [Conventions](/guide/conventions#narrowing-what-matches) applies: - -```csharp -conventions.RegisterAll() - .InAssemblyOf() - .WithName("Retry*") - .AsSelf() - .AsSingleton(); -``` - -## What you can and cannot see - -**Only `public` types cross an assembly boundary.** A scan of your own project also sees `internal` -types; a scan of a package does not. Nothing warns about this โ€” the generator cannot report on what -it cannot see โ€” so a convention that matches less than you expected is usually this. - -**One assembly at a time.** There is no "scan everything I depend on". Point each convention at the -assembly you mean. - -**Attributed types are skipped**, just as they are in your own project. An assembly whose types carry -`[SingletonService]` and friends already has its own module โ€” compose that module instead of scanning -it, and you get the author's intended lifetimes rather than your guess at them. - -## When not to reach for it - -Scanning is for assemblies you **do not own**. - -For a project you do own, give it its own module with its own conventions and compose through -[module attributes](/guide/modules#composing-modules). That already works across assemblies, and it -keeps each project in charge of its own registrations rather than making the consumer guess at them. - -Discovering assemblies at run time is not supported at all, since there would be nothing to resolve -at build time. - -## Diagnostics - -A match from a referenced assembly has no source location to point at, so -[DM0010](/reference/diagnostics#dm0010) and friends report at the `RegisterAll` line instead of at -the class. diff --git a/website/guide/services.md b/website/guide/services.md index c570e92..b135225 100644 --- a/website/guide/services.md +++ b/website/guide/services.md @@ -1,238 +1,344 @@ -# Registering services +# Services -Each registration line you would have written by hand answers three questions: how long the instance -lives, what type callers ask for, and how the registration is added to the collection. This page -covers how to answer each one with an attribute. +A service attribute tells the generator to register a class or a static factory method. The attributes are in the `DependencyModules.Runtime.Attributes` namespace. -## Lifetime +## Lifetimes -The attribute name is the lifetime, and it maps directly onto the call it replaces: +Each service attribute sets one lifetime: -| Attribute | Emits | -|---|---| -| `[SingletonService]` | `AddSingleton` | -| `[ScopedService]` | `AddScoped` | -| `[TransientService]` | `AddTransient` | -| `[CrossWireService]` | the implementation **and** every interface it declares, sharing one instance | +| Attribute | Lifetime | Result | +| --- | --- | --- | +| `[SingletonService]` | `Singleton` | The service provider makes one instance and gives it for all requests. | +| `[ScopedService]` | `Scoped` | The service provider makes one instance for each scope. | +| `[TransientService]` | `Transient` | The service provider makes a new instance for each request. | ```csharp -[SingletonService] -public class SmtpEmailSender : IEmailSender { } +using DependencyModules.Runtime.Attributes; + +namespace Shop; + +public interface IOrderStore +{ + void Save(string orderId); +} + +[ScopedService] +public class OrderStore : IOrderStore +{ + public void Save(string orderId) { } +} ``` +## Service type + +The service type is the type that you use to get the service from the service provider. The generator selects the service type in this sequence: + +1. If the attribute sets `As`, the service type is the `As` type. +2. If the class declaration contains an interface, the service type is the first interface in the declaration. +3. If the class declaration contains no interface, the generator examines the interfaces of the base classes. +4. If the generator finds no interface, the service type is the class. + +The generator does not use a capability interface as the service type. These are the capability interfaces: + +- `IDisposable` and `IAsyncDisposable` +- `ICloneable`, `IComparable`, `IComparable`, `IEquatable`, and `IConvertible` +- `IFormattable`, `ISpanFormattable`, `IParsable`, and `ISpanParsable` +- `IEnumerable` and `IEnumerable` +- `ISerializable` +- `INotifyPropertyChanged`, `INotifyPropertyChanging`, and `INotifyCollectionChanged` + +If you set `As` to a capability interface, the generator uses it. `[CrossWireService]` does not use this list. It registers all interfaces that the class declares. + +Each attribute registers one service type. To register a class as two service types, put two attributes on the class: + ```csharp -// generated -services.AddSingleton(typeof(IEmailSender), typeof(SmtpEmailSender)); +using DependencyModules.Runtime.Attributes; + +namespace Shop; + +public interface IReadStore +{ + string Read(string key); +} + +public interface IWriteStore +{ + void Write(string key, string value); +} + +[SingletonService(As = typeof(IReadStore))] +[SingletonService(As = typeof(IWriteStore))] +public class FileStore : IReadStore, IWriteStore +{ + public string Read(string key) => ""; + + public void Write(string key, string value) { } +} ``` -## What callers ask for +In this example, the generator writes two registrations. Each registration makes a different instance of `FileStore`. [`[CrossWireService]`](#cross-wired-services) uses one instance for the two service types. -By default, a class with interfaces registers as **the first interface it declares**, and a class -with no interface registers as itself. +## Keyed services -That default is wrong as soon as a class implements two interfaces for different reasons: +Set `Key` to register a keyed service. The generator then calls the keyed registration methods, for example `AddKeyedSingleton`. ```csharp -[SingletonService] -public class SmtpEmailSender : IEmailSender, IDiagnosticSource { } +using DependencyModules.Runtime.Attributes; + +namespace Shop; + +public interface IPaymentGateway +{ + string Name { get; } +} + +[SingletonService(Key = "card")] +public class CardGateway : IPaymentGateway +{ + public string Name => "card"; +} + +[SingletonService(Key = "invoice")] +public class InvoiceGateway : IPaymentGateway +{ + public string Name => "invoice"; +} ``` -Here `IEmailSender` happens to be first, but nothing about the code says that was deliberate โ€” and -reordering the base list would silently change the registration. Say which one you meant: +To get a keyed service in a constructor, use `[FromKeyedServices]` from `Microsoft.Extensions.DependencyInjection`: ```csharp -[SingletonService(As = typeof(IEmailSender))] -public class SmtpEmailSender : IEmailSender, IDiagnosticSource { } +using DependencyModules.Runtime.Attributes; +using Microsoft.Extensions.DependencyInjection; + +namespace Shop; + +[TransientService] +public class Checkout([FromKeyedServices("card")] IPaymentGateway gateway) +{ + public string GatewayName => gateway.Name; +} ``` -Two details of the default worth knowing, since neither is guessable: +The generator writes the key into the generated code without changes. The key can be a string, a number, a constant, or an enum value. + +## Registration type + +The `Using` property sets the registration type. The values are in the `RegistrationType` enum: + +| Value | Generated call | Result | +| --- | --- | --- | +| `Add` | `AddSingleton`, `AddScoped`, `AddTransient` | Adds a registration. This is the default. | +| `Try` | `TryAddSingleton`, `TryAddScoped`, `TryAddTransient` | Adds the registration only if the service type has no registration. | +| `TryEnumerable` | `TryAddEnumerable` | Adds the registration only if no registration has the same service type and implementation type. | +| `Replace` | `Replace` | The call removes the first registration of the service type. Then it adds this registration. | -**Capability interfaces are passed over.** `IDisposable`, `IEquatable`, `IComparable`, -`INotifyPropertyChanged`, `IEnumerable` and their relatives describe something a class *can do*, -not what callers ask for. They are skipped when picking the default, so this registers as `IPool` -rather than `IDisposable`: +`TryEnumerable` cannot add a registration if its implementation type is the service type. A class that you register as itself makes such a registration. Each factory method also makes one. For these registrations, `AddModule` gives an `ArgumentException`. Use `Add` or `Try` for them. ```csharp -[SingletonService] -public class ConnectionPool : IDisposable, IPool { } +using DependencyModules.Runtime.Attributes; + +namespace Shop; + +public interface IClock +{ + DateTimeOffset Now { get; } +} + +[SingletonService(Using = RegistrationType.Try)] +public class SystemClock : IClock +{ + public DateTimeOffset Now => DateTimeOffset.UtcNow; +} ``` -If a capability interface is the only one, the class registers as itself. This is inference only โ€” -`[SingletonService(As = typeof(IDisposable))]` is always honoured. +You can set the registration type at three levels. The generator uses the first value that it finds in this sequence: -Note that framework interfaces which *are* genuine service roles stay eligible, so -`IEqualityComparer`, `IJsonTypeInfoResolver` and `IHttpClientFactory` all work as you would -expect. +1. The `Using` property of the service attribute. +2. The `Using` property of `[DependencyModule]`. This value is applicable to all services of the module. +3. The `DependencyModules_RegistrationType` MSBuild property. This value is applicable to all modules of the project. -**A class declaring no interface of its own inherits the search.** The generator walks up the base -classes looking for one, which is what makes `class OrderRepository : RepositoryBase` register as -`IRepository`, and `class Worker : BackgroundService` register as `IHostedService`. If it finds -nothing but capability interfaces, the class registers as itself. +If no level sets a value, the registration type is `Add`. -### One instance behind several interfaces +The generator reads the value of `Using`, not its text. Thus you can also write the full name of the enum member or use a constant. -Sometimes both interfaces are the point. A cache with a read side and a write side wants **one -instance** reachable through either: +## Registration sequence -```csharp -[CrossWireService] -public class Cache : IReadCache, IWriteCache { } -``` +In one module, the generator writes the registrations in this sequence: -```csharp -provider.GetRequiredService(); // same instance -provider.GetRequiredService(); // as this one -``` +1. Registrations without an environment condition are before registrations with an environment condition. +2. `Add` and `TryEnumerable` registrations are before `Try` and `Replace` registrations. For this step, the generator reads the `Using` property of the service attribute and the MSBuild property. It does not read the `Using` property of `[DependencyModule]`. +3. If the `Order` value of registration A is less than the `Order` value of registration B, A is first. +4. If two registrations have the same `Order` value, the generator compares the class names to set the sequence. -Registering the two interfaces separately would give you one instance per service type instead, which -for a cache means two caches and a bug that takes a while to find. +Thus a `Try` or `Replace` registration is after the `Add` registrations of the same module. It can change the result of these `Add` registrations. -## Several implementations of one interface +The registrations from [conventions](./conventions.md) are after all registrations from service attributes of the module. Thus a `Try` or `Replace` service attribute does not change the result of a convention registration of the same module. -When more than one implementation is registered, a key says which one you want: +When a service type has more than one registration, `GetService` gives the instance from the last registration. `GetServices` gives the instances from all registrations. The `Order` property sets the position of a registration in the module. The default value is 0. Values less than 0 are permitted. ```csharp -[SingletonService(Key = "primary")] -public class PrimaryConnection : IConnection { } +using DependencyModules.Runtime.Attributes; -[SingletonService(Key = "reporting")] -public class ReportingConnection : IConnection { } -``` +namespace Shop; -```csharp -provider.GetRequiredKeyedService("primary"); +public interface IDiscountRule +{ + decimal Apply(decimal price); +} + +[SingletonService(Order = 1)] +public class StandardDiscount : IDiscountRule +{ + public decimal Apply(decimal price) => price; +} + +[SingletonService(Order = 2)] +public class SaleDiscount : IDiscountRule +{ + public decimal Apply(decimal price) => price * 0.9m; +} ``` -The key is written into the registration exactly as you wrote it, so a string literal, a `const` or -an enum member all work. +In this example, `GetService()` gives `SaleDiscount`. -A constructor asks for one with `[FromKeyedServices]`, which is the container's own attribute: +The generator reads the value of `Order`, not its text. Thus you can also use a constant or digit separators, for example `Order = 1_000`. -```csharp -[ScopedService] -public class ReportBuilder( - [FromKeyedServices("reporting")] IConnection connection) { } -``` +For the sequence of registrations from more than one module, refer to [Module load sequence](./modules.md#module-load-sequence). -## Taking all of them +## Cross-wired services -Depend on `IEnumerable` and the container hands over every **unkeyed** registration of that -service โ€” which is how a pipeline of validators or handlers is written: +`[CrossWireService]` registers a class as each interface that the class declares. It also registers the class as the class type. The interface registrations get the instance from the registration of the class type. Thus, for the `Singleton` and `Scoped` lifetimes, all these registrations give the same instance in a scope. ```csharp -[SingletonService(As = typeof(IValidator), Order = 10)] -public class RequiredFields : IValidator { } +using DependencyModules.Runtime.Attributes; -[SingletonService(As = typeof(IValidator), Order = 20)] -public class BusinessRules : IValidator { } -``` +namespace Shop; -```csharp -[ScopedService] -public class OrderService(IEnumerable validators) +public interface IInventoryReader { - public void Place(Order order) - { - foreach (var validator in validators) { // RequiredFields, then BusinessRules - validator.Validate(order); - } - } + int Count(string sku); } -``` -`Order` decides the sequence, lowest first. Without it the sequence is the order the generator emits, -which is sorted by class name โ€” so renaming a class reorders the pipeline. Everything is `0` by -default and the sort is stable within one order, so naming an order for some services leaves the rest -where they are. +public interface IInventoryWriter +{ + void Set(string sku, int count); +} + +[CrossWireService] +public class Inventory : IInventoryReader, IInventoryWriter +{ + private readonly Dictionary _counts = new(); + + public int Count(string sku) => _counts.GetValueOrDefault(sku); -::: warning Keyed registrations are not in the enumeration -`GetServices()` and an `IEnumerable` dependency both return the unkeyed registrations only. A -set of handlers you want to both *route to by name* and *list* therefore cannot use keys for the -first and the enumeration for the second โ€” register them unkeyed and carry the name on each handler. -There is a [design note](https://github.com/ipjohnson/DependencyModules/blob/main/docs/design/dispatch-by-name.md) -on making this first-class. -::: + public void Set(string sku, int count) => _counts[sku] = count; +} +``` -## How the registration is added +The generator reads the interfaces from all partial declarations of the class. It does not cross-wire the interfaces of a base class. If the class declares no interface, the generator registers only the class type. If the class then gets interfaces from a base class, the generator also gives the warning DM0024. To cross-wire these interfaces, write them in the declaration of the class. -By default every attribute adds unconditionally, so registering the same service type twice leaves -two descriptors and the last one wins. `Using` changes that: +The default lifetime is `Singleton`. To set a different lifetime, set the `Lifetime` property, for example `[CrossWireService(Lifetime = ServiceLifetime.Scoped)]`. -| Value | Behaviour | -|---|---| -| `Add` *(default)* | always adds | -| `Try` | adds only if the service type is not already registered | -| `TryEnumerable` | adds unless this exact service/implementation pair is present | -| `Replace` | replaces an existing registration of the service type | +`[CrossWireService]` also has the `Realm`, `Using`, and `Key` properties. It has no `As` property and no `Order` property. `Realm` has the same function as on the other service attributes. If you set `Key`, all registrations of the class use the key. -`Try` is the one a library wants for a default the application should be able to override: +The registration of the class type and the interface registrations use the same registration type. The generator selects it in the sequence of [Registration type](#registration-type). For `TryEnumerable`, the registration of the class type uses `TryAdd`. `TryAddEnumerable` cannot add a registration that has its service type as its implementation type. -```csharp -[SingletonService(Using = RegistrationType.Try)] -public class DefaultClock : IClock { } -``` +The generator cannot cross-wire a generic class. It gives the warning DM0014 and does not register the class. For a generic class, you can use one of the other service attributes. -The application registers its own `IClock` and wins; if it does not, `DefaultClock` is there. +You can also put `[CrossWireService]` on a static factory method. The generator then registers the return type of the method, and it cross-wires each interface that the return type declares. -## When the container cannot construct the type +## Factory methods -Some classes need something the container has no way to supply โ€” a timestamp, a value from -configuration, an object built by a factory somewhere else. Put the attribute on a **static factory -method** instead of on the class: +You can put a service attribute on a static method. The generator calls the method to make the instance. The return type of the method is the service type. ```csharp -public class SomeClass : ISomeInterface +using DependencyModules.Runtime.Attributes; + +namespace Shop; + +public interface ITaxTable { - public SomeClass(IDep one, IDepTwo two, DateTime timestamp) { } + decimal Rate(string region); +} + +public class FixedTaxTable(decimal rate) : ITaxTable +{ + public decimal Rate(string region) => rate; +} +public static class TaxFactories +{ [SingletonService] - public static ISomeInterface Factory(IDep one, IDepTwo two) => - new SomeClass(one, two, DateTime.UtcNow); + public static ITaxTable CreateTaxTable(IClock clock) => new FixedTaxTable(0.2m); } ``` -Every parameter of the factory method is resolved from the container; everything else is yours to -supply. +The generator gets each parameter of the method from the service provider. If the method has one parameter of type `IServiceProvider`, the method gets the service provider. + +The method must be `static`, and the generated code must be able to call it. Thus the method must be `public` or `internal`. A method without an access modifier is `private`. The classes that contain the method must not be `private` or `protected`. If the generated code cannot call the method, the generator gives the warning DM0023 and does not register the method. + +Do not set `Key` on the attribute of a factory method. The generated keyed registration does not call the method. The service provider then gives an exception when you get the service. -## Choosing a constructor +## Generic services -With several constructors, the greediest accessible one is used โ€” the same rule `ActivatorUtilities` -follows. To pin a specific one: +If a generic class implements a generic interface with the same type parameters, the generator writes an open generic registration. ```csharp -[ActivatorUtilitiesConstructor] -public SomeClass(IDep one) { } -``` +using DependencyModules.Runtime.Attributes; -## Removing the container's reflection +namespace Shop; -By default the generator emits `typeof(Implementation)` and lets the container construct it, which it -does by reflection. Turning on factory generation emits a `new` expression instead: +public interface IRepository +{ + List Items { get; } +} -```xml - - true - +[ScopedService] +public class Repository : IRepository +{ + public List Items { get; } = new(); +} ``` +The service provider can then give `IRepository` for each type `T`. + +A class that implements a closed interface, for example `IRepository`, gets a closed registration. You can also set `As` to an open generic type, for example `As = typeof(IRepository<>)`. + +## Classes that the generator cannot register + +The generator cannot make an instance of an abstract class or a static class. If you put a service attribute on such a class, the generator gives the warning DM0002 and does not register the class. An abstract type can have a registration from a class that is not abstract, or from a factory method. + +The service provider uses only a `public` constructor. If a class has only `internal` constructors, the generator gives no warning. The service provider then cannot make an instance of the class. Make a constructor `public`, or set `GenerateFactories = true` on the module. A generated factory can call an `internal` constructor. + +## Records, nested classes, and partial classes + +A service can be a record class. A service can also be a nested class. + +If a service class has more than one partial declaration, the generator reads only the declaration that has the service attribute. It reads the attributes, the base types, and the constructors from this declaration. Put the interfaces and the [environment attributes](./environments.md) on that declaration. `[CrossWireService]` is different. It reads the interfaces of all partial declarations. + +## JSON serializer contexts + +The generator can register `JsonSerializerContext` classes as `IJsonTypeInfoResolver`. To do this for one module, set `RegisterJsonSerializers = true` on `[DependencyModule]`. To do this for all modules of a project, set the `DependencyModules_RegisterGenerator` MSBuild property to `true`. + ```csharp -// generated, with the property set -services.AddSingleton( - typeof(ISummaryProvider), - provider => new SummaryProvider(provider.GetRequiredService()) -); -``` +using System.Text.Json.Serialization; +using DependencyModules.Runtime.Attributes; + +namespace Shop; -Every constructor dependency becomes an explicit `GetRequiredService` call, so the container never -reflects over the constructor. Worth it when you are chasing startup time or targeting Native AOT -aggressively. +public record OrderMessage(string OrderId, decimal Total); + +[JsonSourceGenerationOptions] +[JsonSerializable(typeof(OrderMessage))] +public partial class ShopJsonContext : JsonSerializerContext; + +[DependencyModule(RegisterJsonSerializers = true)] +public partial class MessagingModule; +``` -It has two costs worth reading before you turn it on project-wide: the container cannot see inside a -factory, so `ValidateScopes` and `ValidateOnBuild` stop catching anything; and a factory registration -cannot say what implementation it built, which is what per-implementation wrapping needs. Both are in -[MSBuild properties](/reference/msbuild#generatefactories-and-container-validation). +The generator registers each class that has `[JsonSourceGenerationOptions]` and no service attribute. The registration is transient and gives the `Default` property of the context. -## Next +## Realms -- [Conventions](/guide/conventions) โ€” when one attribute per class stops scaling -- [Environments](/guide/environments) โ€” registering a different implementation per environment +If a service has a realm, only the module of that realm registers the service. For more information, refer to [Realms](./modules.md#realms). diff --git a/website/guide/testing-container-source.md b/website/guide/testing-container-source.md new file mode 100644 index 0000000..754b209 --- /dev/null +++ b/website/guide/testing-container-source.md @@ -0,0 +1,114 @@ +# More service providers in a test + +A test can make more service providers from the same registrations. For example, a test can start two instances of an application that use one mock message bus. The `ITestContainerSource` service makes these service providers. + +## Make a service provider + +Add an `ITestContainerSource` parameter to the test. Call `CreateAsync()` to get a new service provider. + +```csharp +using DependencyModules.Runtime.Attributes; +using DependencyModules.Testing.Attributes.Interfaces; +using DependencyModules.xUnit.Attributes; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Cluster.Tests; + +public interface INodeCounter +{ + int Count { get; } + + void Add(); +} + +[SingletonService] +public class NodeCounter : INodeCounter +{ + public int Count { get; private set; } + + public void Add() => Count++; +} + +[DependencyModule] +public partial class ClusterModule; + +public class TwoNodeTests +{ + [ModuleTest(typeof(ClusterModule))] + public async Task EachNodeHasItsCounter(ITestContainerSource source) + { + var first = await source.CreateAsync(); + var second = await source.CreateAsync(); + + first.GetRequiredService().Add(); + + Assert.Equal(1, first.GetRequiredService().Count); + Assert.Equal(0, second.GetRequiredService().Count); + } +} +``` + +Each call to `CreateAsync()` builds a new service provider from the service collection of the test. The new service provider makes new instances of the singletons that are not shared. The `ITestStartupAttribute` attributes run for each new service provider. The test package disposes all these service providers at the end of the test. + +## Shared services + +The new service providers give the same instance of a shared service. These services are shared: + +- The type of each parameter of the test method. This includes the `[Mock]` parameters. +- Each `[TestExport]` with `Shared = true`. +- Each service type in the `SharedServices` property of an `ISharedTestRegistration` attribute. + +`IServiceProvider` is not shared. A type from `IsolatedServices` is not shared. For more information, refer to [Change which services are shared](#change-which-services-are-shared). + +```csharp +using DependencyModules.Testing.Attributes.Interfaces; +using DependencyModules.xUnit.Attributes; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Cluster.Tests; + +public class SharedCounterTests +{ + [ModuleTest(typeof(ClusterModule))] + public async Task TheParameterIsShared(ITestContainerSource source, INodeCounter counter) + { + var node = await source.CreateAsync(); + + node.GetRequiredService().Add(); + + Assert.Equal(1, counter.Count); + } +} +``` + +In this example, `counter` is a parameter. Thus the new service provider gives the same `INodeCounter` instance. + +## How the test package shares a service + +At the first `CreateAsync()` call, the test package gets the instances of all shared services from the service provider of the test. The new service providers then give these instances. The results are as follows: + +- For a singleton service, the service provider of the test and all new service providers give the same instance. +- For a transient service, all new service providers give one instance. This instance is not the instance of the test parameter. The service provider of the test continues to make new instances. +- The test package makes an instance of each shared service at the first `CreateAsync()` call, also if the test does not use the service. +- If the service provider of the test cannot make a service that an attribute shares, `CreateAsync()` throws an `InvalidOperationException`. An example is a `[TestExport]` with `Shared = true`. The message gives the service type, and the inner exception gives the cause. +- If the service provider of the test cannot make the type of a parameter, the test package does not share this type. It gives no message. +- A parameter type without a registration is not in the new service providers. For example, a class that the test package makes with `ActivatorUtilities` is not in the new service providers. +- The new service providers keep the registrations with a key of a shared type. Each new service provider makes its own instances of these registrations. Thus a `[FromKeyedServices]` parameter does not get the same instance as a new service provider. + +A `[TestExport]` with `Shared = true` is shared, also for a transient lifetime. A `[TestExport]` without `Shared = true` is also shared if the test has a parameter of that type. If the export is not shared, each new service provider makes new instances of the export. + +## Change which services are shared + +The `ISharedTestRegistration` interface in `DependencyModules.Testing.Attributes.Interfaces` selects the shared services. An attribute that implements it can have these members: + +| Member | Default | Function | +| --- | --- | --- | +| `Shared` | `true` | On a parameter attribute, `false` makes the parameter not shared. On a different attribute, `true` shares the types in `SharedServices`. | +| `SharedServices` | Empty | Types to share. | +| `IsolatedServices(MethodInfo testMethod)` | Empty | Types that are not shared, also when they are parameters. The test package reads this member only from attributes on the test method, the test class, and the assembly. | + +`[Mock]`, `[InjectValues]`, `[Shared]`, and `[TestExport]` implement this interface. `[Shared]` on a parameter does not change the result, because the test package shares all parameters by default. + +An attribute can use `IsolatedServices` to make a type not shared. Each service provider then makes a different instance of that type, also when the test has a parameter of that type. For example, a test harness can use `IsolatedServices` for a type that must have a different instance in each service provider. diff --git a/website/guide/testing-mocking.md b/website/guide/testing-mocking.md index e8f1269..3f1fbe0 100644 --- a/website/guide/testing-mocking.md +++ b/website/guide/testing-mocking.md @@ -1,289 +1,142 @@ -# Mocking frameworks +# Mocks -## The problem +A mock replaces a service in the service provider of a test. The test gets the mock as a parameter and sets the return values of its members. The other services get the mock in their constructors. -A provider built from your real modules gives you real services, which is usually the point โ€” and -occasionally the problem. One of the services behind `Weather` is non-deterministic: +## Packages -```csharp -[SingletonService] -public class TemperatureProvider : ITemperatureProvider -{ - public int GetTemperature() => Random.Shared.Next(-20, 55); -} -``` - -You cannot assert on a forecast built out of random numbers. But you do not want to abandon the -container either โ€” `Weather` and `SummaryProvider` should still be the real ones, wired the real way. -You want to replace exactly one leaf of the graph and leave the rest alone. - -## How DependencyModules helps +| Package | Attribute | Mock library | +| --- | --- | --- | +| `DependencyModules.NSubstitute` | `[NSubstituteSupport]` | NSubstitute | +| `DependencyModules.Moq` | `[MoqSupport]` | Moq | +| `DependencyModules.FakeItEasy` | `[FakeItEasySupport]` | FakeItEasy | -Mark the parameter `[Mock]` and that service is **replaced in the container** before anything is -resolved. Everything constructed afterwards gets the substitute: +Add one of these packages and a test package to the test project. Put the attribute of the mock package on the test method, on the test class, or on the assembly. An attribute on the assembly is applicable to all tests: ```csharp -[ModuleTest] -public void GetStaticForecast( - Weather weather, - [Mock] ITemperatureProvider temperatureProvider, - [Mock] IAiSummaryProvider aiSummaryProvider) -{ - temperatureProvider.GetTemperature().Returns(38); - aiSummaryProvider.GetSummary().Returns("Sunny"); - - var forecast = weather.GetWeatherForecast().ToArray(); +using DependencyModules.NSubstitute; - Assert.All(forecast, day => Assert.Equal(38, day.TemperatureC)); - Assert.All(forecast, day => Assert.Equal("Sunny", day.Summary)); -} +[assembly: NSubstituteSupport] ``` -`Weather` is still constructed by the container, and it receives the same substitutes the test is -holding. You wire nothing together yourself. +If more than one mock support attribute is applicable to a test, `[Mock]` uses only one attribute. It uses the attribute on the method. If the method has no such attribute, it uses the attribute on the class. If the class has no such attribute, it uses the attribute on the assembly. Each applicable `[MoqSupport]` attribute also registers the `Mock` parameters. -Note what stayed real: `SummaryProvider` was not mocked, so the call still travels -`Weather` โ†’ `SummaryProvider` โ†’ `IAiSummaryProvider`. Only the leaf was swapped. +## `[Mock]` -`[Mock]` comes from `DependencyModules.Testing`, which your [test framework -integration](/guide/testing#pick-an-integration) already brings in โ€” so it needs a -`using DependencyModules.Testing.Attributes;`. It carries no test framework dependency and no mocking -library dependency of its own. - -## Choosing a library - -`[Mock]` does not depend on a particular mocking library. It defines a seam, and a small package -fills it โ€” so use whichever library you already have: - -| Package | Attribute | Creates | -|---|---|---| -| `DependencyModules.NSubstitute` | `[NSubstituteSupport]` | `Substitute.For(type)` | -| `DependencyModules.Moq` | `[MoqSupport]` | `Mock` | -| `DependencyModules.FakeItEasy` | `[FakeItEasySupport]` | `Sdk.Create.Fake(type)` | - -Install one and apply its attribute. Like the module attributes it works at assembly, class or -method level, and assembly is usually right: - -```shell -dotnet add package DependencyModules.Moq -``` +Put `[Mock]` on a test parameter. `[Mock]` is in the `DependencyModules.Testing.Attributes` namespace. ```csharp -[assembly: MoqSupport] -``` - -Without one, `[Mock]` throws with a message telling you so, rather than quietly handing back the real -service. - -All three work under both [xUnit](/guide/testing-xunit) and [NUnit](/guide/testing-nunit) โ€” the -mocking package and the test framework package are independent choices. - -::: tip Pick one per project -The support attributes are found by walking method, class then assembly, and the first one found -supplies the test's mocks. Two in scope is not an error, but which one wins depends on where each is -declared, which is not a thing to rely on. -::: - -## The same test in each - -Only the configuration lines differ โ€” `[Mock]`, the injection and the assertions around them are -identical. The example above is NSubstitute; here are all three side by side: - -::: code-group - -```csharp [NSubstitute] -// arrange -temperatureProvider.GetTemperature().Returns(38); - -// assert on the interaction -temperatureProvider.Received().GetTemperature(); -temperatureProvider.Received(1).Record(Arg.Any()); -``` - -```csharp [Moq] -// arrange -Mock.Get(temperatureProvider).Setup(x => x.GetTemperature()).Returns(38); - -// assert on the interaction -Mock.Get(temperatureProvider).Verify(x => x.GetTemperature()); -Mock.Get(temperatureProvider).Verify(x => x.Record(It.IsAny()), Times.Once); -``` - -```csharp [FakeItEasy] -// arrange -A.CallTo(() => temperatureProvider.GetTemperature()).Returns(38); - -// assert on the interaction -A.CallTo(() => temperatureProvider.GetTemperature()).MustHaveHappened(); -A.CallTo(() => temperatureProvider.Record(A._)).MustHaveHappenedOnceExactly(); -``` - -::: - -Mocks are **loose** in all three: an unconfigured member returns `default` rather than throwing. That -is each library's own default, kept rather than overridden. - -## NSubstitute +using DependencyModules.NSubstitute; +using DependencyModules.Testing.Attributes; +using DependencyModules.xUnit.Attributes; +using NSubstitute; +using Xunit; -The substitute is both what gets injected and what you configure, so a `[Mock]` parameter can be set -up directly: +namespace Weather.Tests; -```csharp -using DependencyModules.NSubstitute; +public interface ITemperatureSource +{ + int Celsius(); +} -[assembly: NSubstituteSupport] -``` +public class Forecast(ITemperatureSource source) +{ + public string Describe() => source.Celsius() > 25 ? "hot" : "mild"; +} -```csharp -[ModuleTest] -public void SendsTheMail(IEmailSender sender, [Mock] IAuditLog log) +[NSubstituteSupport] +public class ForecastTests { - sender.Send("someone@example.com"); + [ModuleTest] + public void HotAbove25([Mock] ITemperatureSource source, Forecast forecast) + { + source.Celsius().Returns(30); - log.Received().Write(Arg.Any()); + Assert.Equal("hot", forecast.Describe()); + } } ``` -Nothing else to know โ€” the parameter is the substitute. +For each `[Mock]` parameter, the test package does these steps: -## FakeItEasy +1. Makes a mock of the parameter type with the mock library. +2. Registers the mock as a singleton, after the modules and after `[TestExport]`. +3. Gives the mock to the parameter. -Same shape. The fake is what gets injected and what you configure, through `A.CallTo`: +Because the mock registration is the last registration, the service provider gives the mock to all services that get the type. If the parameter has `[FromKeyedServices("key")]`, the registration is a keyed registration with that key. -```csharp -using DependencyModules.FakeItEasy; +The NSubstitute and FakeItEasy packages make the mocks with the default configuration of the library: `Substitute.For` and `FakeItEasy.Sdk.Create.Fake`. The parameter and the other services get the same object. Thus the configuration that the test makes on the parameter is applicable to the other services. -[assembly: FakeItEasySupport] -``` +Each test, each data row, and each iteration gets new mocks. -```csharp -[ModuleTest] -public void SendsTheMail(IEmailSender sender, [Mock] IAuditLog log) -{ - sender.Send("someone@example.com"); +If no mock support attribute is applicable to the test, the test fails with the message "Mock library not found". - A.CallTo(() => log.Write(A._)).MustHaveHappened(); -} -``` - -Fakes are built through `FakeItEasy.Sdk.Create.Fake(type)` rather than `A.Fake()`, because the -type is not known until the test asks for it. The result is the same object either would produce. +::: info NOTE +A mock support attribute does not make mocks for services that have no registration. Only the `[Mock]` parameters and, for Moq, the `Mock` parameters get mocks. A service with a dependency that has no registration and no mock parameter causes an error when the test gets the service. +::: ## Moq -Moq is the one that needs a paragraph, because it keeps the mock and the object it produces apart. -`[Mock] IFoo` gives you the **object**, so configuring it means going back through `Mock.Get`: +If `[MoqSupport]` is applicable to the test, the test can have a `Mock` parameter. The parameter gets the `Mock` object. The service provider gives `mock.Object` for `T`. ```csharp -[ModuleTest] -public void SendsTheMail(IEmailSender sender, [Mock] IAuditLog log) -{ - Mock.Get(log).Verify(x => x.Write(It.IsAny())); -} -``` +using DependencyModules.Moq; +using DependencyModules.xUnit.Attributes; +using Moq; +using Xunit; -### Ask for the `Mock` instead +namespace Weather.Tests; -You can skip that by naming the mock in the parameter type. No `[Mock]` needed โ€” the type already -says what it is: - -```csharp -[ModuleTest] -public void GetStaticForecast( - Weather weather, - Mock temperatureProvider, - Mock aiSummaryProvider) +[MoqSupport] +public class MoqForecastTests { - temperatureProvider.Setup(x => x.GetTemperature()).Returns(38); - aiSummaryProvider.Setup(x => x.GetSummary()).Returns("Sunny"); - - var forecast = weather.GetWeatherForecast().ToArray(); + [ModuleTest] + public void MildAt20(Mock source, Forecast forecast) + { + source.Setup(temperature => temperature.Celsius()).Returns(20); - Assert.All(forecast, day => Assert.Equal(38, day.TemperatureC)); + Assert.Equal("mild", forecast.Describe()); + } } ``` -This does the same thing `[Mock]` does โ€” `ITemperatureProvider` is replaced in the container before -anything is resolved, so `Weather` is built against the same mock. Both the `Mock` and its -`Object` are registered, which is what lines the two halves up: you hold the mock, and everything the -container builds gets its object. `mock.Object` reaches the object yourself when you want it. +This list gives information about the Moq package: -### The two spellings agree +- `Mock` and `[Mock] Mock` give the same result. +- `[Mock] T` gives `mock.Object`. `Mock.Get(value)` gives the `Mock` for the value. +- If a test has a `[Mock] T` parameter and a `Mock` parameter, the two parameters use one mock. +- Two `Mock` parameters for the same `T` get the same mock. +- The package makes each mock with `new Mock()`. If you do not set a member, the member gives the default value of Moq. For example, a member gives an empty array, an empty sequence, or a completed task. A member with a different reference type, for example `string`, gives `null`. -Ask for `[Mock] ITemperatureProvider` and `Mock` on one test and you get **one -mock seen two ways**, not two mocks. Two parameters naming the same `Mock` are likewise one mock. +If `[MoqSupport]` is not applicable to the test, a `Mock` parameter gets a new `Mock` from `ActivatorUtilities`. But the service provider does not give `mock.Object` for `T`. The other services then get the usual implementation of `T`. -`[Mock]` on a `Mock` parameter is allowed and does nothing โ€” the type is already enough. +## Mocks and `[TestExport]` -::: warning `Mock` without `[MoqSupport]` silently does nothing useful -A `Mock` parameter only means anything when `[MoqSupport]` is in scope. Without it the parameter -still resolves โ€” the container constructs a `Mock` like any other concrete type โ€” but nothing -registers it, so the service under test gets the real implementation and your setups apply to a mock -nobody can see. -::: +The test package registers a `[Mock]` parameter after `[TestExport]`. Thus the mock replaces a `[TestExport]` registration of the same service type. A `[Mock]` parameter with `[FromKeyedServices]` does not replace a `[TestExport]` registration without a key. -## What wins when two things register the same service {#precedence} +For Moq, the test package registers a `Mock` parameter before `[TestExport]`. Thus a `[TestExport]` for `T` replaces the registration of `mock.Object`. The `Mock` parameter gets its mock. But the other services get the `[TestExport]` service, not `mock.Object`. If the test also has a `[Mock] T` parameter, this parameter also gets the `[TestExport]` service. -**The narrowest declaration decides.** `[Mock]` sits on a parameter and names one argument; -`[TestExport]` applies to a method, a class or an assembly. So a `[Mock]` parameter overrides a -`[TestExport]` naming the same service, and that is the shape having both is for โ€” the class sets the -default and one test opts out: +If the test project references `DependencyModules.SourceGenerator`, the generator examines the test methods. If a `[Mock]` parameter replaces a `[TestExport]` registration on the same test method, the generator gives the warning DM0021. It gives no DM0021 for the two exceptions in this section, because the `[TestExport]` registration stays in use. -```csharp -[TestExport(typeof(IClock), Implementation = typeof(SystemClock))] // the fixture default -public class ExpiryTests -{ - [ModuleTest] - public void UsesTheRealClock(IClock clock) { } // SystemClock +To set a default for many tests, put `[TestExport]` on the class or on the assembly. A `[Mock]` parameter can then replace the default in one test. - [ModuleTest] - public void Expires([Mock] IClock clock) { } // the mock -} -``` +## Other mock libraries -Written on the **same method** the two are contradictory rather than useful โ€” the parameter wins and -the `[TestExport]` beside it does nothing โ€” so that is -[DM0021](/reference/diagnostics#dm0021). +To use a different mock library, write an attribute that implements `IMockSupportAttribute` from `DependencyModules.Testing.Attributes.Interfaces`: -`Mock` deliberately does not get this. Asking for the mock object is not the same as declaring the -service mocked, so a `[TestExport]` still beats it: +| Member | Function | +| --- | --- | +| `object ProvideMock(Type type)` | Gives a new mock of the type. | +| `bool RegistersService(ITestMethodContext testMethod, Type serviceType)` | Gives `true` if the attribute registers the type. `[Mock]` then does not register a mock for this type. The default implementation gives `false`. | ```csharp -[ModuleTest] -[TestExport(typeof(IClock), Implementation = typeof(SystemClock))] -public void RealClock(IClock clock, Mock mock) +using DependencyModules.Testing.Attributes.Interfaces; + +namespace Weather.Tests; + +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class | AttributeTargets.Assembly)] +public class StubSupportAttribute : Attribute, IMockSupportAttribute { - Assert.IsType(clock); // the export won + public object ProvideMock(Type type) => + throw new NotSupportedException($"No stub for {type.Name}"); } ``` - -Underneath, registrations are last-one-wins and the order is fixed: - -1. **Mock support** registers the `Mock` pairs. -2. **`[TestExport]`** and other setup attributes. -3. **`[Mock]` parameters**, last โ€” which is what makes them win. - -A `[Mock]` stands aside for a type the mock library registers itself, which is what keeps -`[Mock] IFoo` and `Mock` on one test resolving to a matched pair rather than two unrelated -mocks. - -::: warning This changed in 1.2.0 -Before 1.2.0 `[TestExport]` won everywhere, including for a `[Mock]`-decorated parameter โ€” so a test -declaring `[Mock] IFoo` alongside a `[TestExport]` for `IFoo` silently held the real implementation, -and the first arrange line threw a mocking-library error naming neither attribute. If you were -relying on that, drop the `[Mock]`. -::: - -## When not to mock - -A mock is right when you intend to **assert on the interaction** โ€” what was called, with which -arguments. When you want a working implementation that simply behaves differently, a mock makes you -stub out every member you touch, and -[`[TestExport]`](/guide/testing#when-you-want-a-real-object-not-a-mock) is the better tool. When the -parameter is data rather than a service, -[`[InjectValues]`](/guide/testing#when-the-parameter-is-not-a-service-at-all) is. - -## Next - -- [Testing modules](/guide/testing) โ€” the parts shared by both test frameworks -- [Testing registrations](/guide/testing-registrations) โ€” asserting on what a module registered diff --git a/website/guide/testing-nunit.md b/website/guide/testing-nunit.md index d59c594..d80c994 100644 --- a/website/guide/testing-nunit.md +++ b/website/guide/testing-nunit.md @@ -1,128 +1,85 @@ # NUnit -`DependencyModules.NUnit` is the NUnit integration. Read [Testing modules](/guide/testing) first โ€” -this page covers only what is specific to NUnit. +`DependencyModules.NUnit` adds `[ModuleTest]` and `[ModuleTestCase]` to NUnit test projects. The package is compatible with NUnit version 4.2.2 and all subsequent versions before 5.0.0. + +## Install ```shell dotnet add package DependencyModules.NUnit ``` +Also add references to `NUnit3TestAdapter` and to `Microsoft.NET.Test.Sdk`. + +## `[ModuleTest]` + +`[ModuleTest]` is in the `DependencyModules.NUnit.Attributes` namespace. Put it on a test method. Do not also put `[Test]` on the method. Give zero or more module types in the constructor. + ```csharp using DependencyModules.NUnit.Attributes; +using NUnit.Framework; +using Shop; -public class WeatherTests +namespace Shop.NUnitTests; + +public class CalculatorTests { - [ModuleTest] - [ApplicationModule] - public void GetForecast(Weather weather) + [ModuleTest(typeof(ShopModule))] + public void Multiplies(IPriceCalculator calculator) { - var forecast = weather.GetWeatherForecast().ToArray(); - - Assert.That(forecast, Has.Length.EqualTo(5)); + Assert.That(calculator.Total(2m, 3), Is.EqualTo(6m)); } } ``` -`[ModuleTest]` replaces `[Test]`. `[TestFixture]` on the class is optional โ€” a module test implies a -fixture the same way `[Test]` does. +A class with `[ModuleTest]` methods is a test fixture. `[TestFixture]` is not necessary on the class. -Everything shared applies unchanged: assembly-level module attributes, `[Mock]`, `[InjectValues]`, -`[TestExport]`, keyed services, and all three [mocking packages](/guide/testing-mocking). Those live -in `DependencyModules.Testing` and name no test framework, so they are the same types either -integration hands you โ€” not copies. +NUnit builds and runs the test with its usual test commands. Thus `[SetUp]`, `[TearDown]`, `[Timeout]`, `[Repeat]`, and `[Retry]` have their usual function. -## A container per iteration +## Data rows: `[ModuleTestCase]` -Every iteration of a test gets its own container, torn down when that iteration ends. That includes -each `[Repeat]` pass and each `[Retry]` attempt, not just each test case: +For data rows, use `[ModuleTestCase]`. Each attribute is one row. The test package gives the values of a row to the first parameters of the method. The service provider gives the values for the other parameters. ```csharp -[ModuleTest] -[ApplicationModule] -[Repeat(3)] -public void EachPassStartsClean(ICallCounter counter) -{ - counter.Record(); - - Assert.That(counter.Count, Is.EqualTo(1)); // never 2, never 3 -} -``` - -The container's lifetime brackets the whole iteration, so `[SetUp]` and `[TearDown]` both run while -it is alive: - -``` -container built โ†’ [SetUp] โ†’ test method โ†’ [TearDown] โ†’ container disposed -``` - -That ordering is worth knowing if a `[SetUp]` method needs a service. It cannot take one as a -parameter โ€” NUnit calls it, not this package โ€” but it can read one from `ITestCaseInfo`, or the test -method can do the work instead. - -## Data-driven tests +using DependencyModules.NUnit.Attributes; +using NUnit.Framework; +using Shop; -Use `[ModuleTestCase]` rather than NUnit's `[TestCase]`. Row arguments come first, injected ones -after: +namespace Shop.NUnitTests; -```csharp -[ModuleTest] -[ApplicationModule] -[ModuleTestCase("one")] -[ModuleTestCase("two")] -public void MultipleRows(string value, ITemperatureProvider provider) +public class RowTests { - Assert.That(value, Is.Not.Null); // from [ModuleTestCase] - Assert.That(provider, Is.Not.Null); // from the container + [ModuleTest(typeof(ShopModule))] + [ModuleTestCase(2, 4)] + [ModuleTestCase(3, 6, TestName = "Three items")] + public void RowAndService(int quantity, int expected, IPriceCalculator calculator) + { + Assert.That(calculator.Total(2m, quantity), Is.EqualTo((decimal)expected)); + } } ``` -A row may supply fewer arguments than the method takes โ€” that is the point of it โ€” but not more. +`TestName` sets the name of the row of its `[ModuleTestCase]`. Without `TestName`, the name is the method name and the values, for example `RowAndService(2, 4)`. The rows from a different row source also get this default name. -::: warning `[TestCase]` will not work here -NUnit's own `[TestCase]` requires a row to supply an argument for *every* parameter, and enforces -that when the test case is built, before this package sees it. A method whose trailing parameters -come from the container fails that check with -`Method requires 2 arguments but TestCaseAttribute only supplied 1`. `[TestCase]` also builds its own -test cases, so combining the two produces one case per row *plus* one more. +The number of values in a row can be less than the number of parameters. A row with more values than parameters does not run. NUnit then shows the row as `NotRunnable` and gives the cause. -`[ModuleTestCase]` is the same idea without the all-or-nothing rule. -::: +Do not use `[TestCase]`, `[TestCaseSource]`, `[Values]`, or `[Range]` with `[ModuleTest]`. NUnit makes more tests from these attributes, and the test package cannot give their values to the parameters. Thus the test package does not run these tests. NUnit shows each of them as `NotRunnable`, and the message gives the name of the attribute. This is also true for the other NUnit data attributes, for example `[Random]` and `[ValueSource]`. -Each row is a separate test case, so each gets its own container. - -To supply rows from somewhere other than an attribute literal, implement `IModuleTestDataAttribute`: - -```csharp -[AttributeUsage(AttributeTargets.Method)] -public class CsvRowsAttribute(string path) : Attribute, IModuleTestDataAttribute -{ - public IEnumerable GetRows(MethodInfo method) => - File.ReadLines(path).Select(line => line.Split(',').Cast().ToArray()); -} -``` +NUnit makes some of these tests `NotRunnable` before the test package can examine them. An example is a `[TestCaseSource]` row whose number of values is less than the number of parameters. For such a test, NUnit shows its own message. -## Differences from the xUnit integration +To get rows from a different source, write an attribute that implements `IModuleTestDataAttribute`. Its `GetRows(MethodInfo method)` method gives the rows. Put the attribute on the test method. -| | [xUnit](/guide/testing-xunit) | NUnit | -|---|---|---| -| Replaces | `[Fact]` and `[Theory]` | `[Test]` | -| Data rows | `[InlineData]`, `[MemberData]`, any `IDataAttribute` | `[ModuleTestCase]` | -| Class attribute | none needed | `[TestFixture]` optional | -| Fixture instance | one per test | one per fixture, per NUnit's own model | -| Test case metadata | `ITestCaseInfo` exposing `IXunitTestMethod` | `ITestCaseInfo` exposing `TestMethod` | +## Each iteration gets a new service provider -The fixture row is NUnit's behaviour, not this package's: NUnit constructs the fixture once and -reuses it, so fixture *fields* are shared across tests even though containers are not. Keep per-test -state in the test method, or in a service resolved from the container. +The test package builds a new service provider for each row and for each iteration from `[Repeat]` and `[Retry]`. -## Skipping, timeouts and categories +The `[SetUp]` and `[TearDown]` methods run after the test package builds the service provider of the iteration. After `[TearDown]`, the test package disposes the service provider. A `[SetUp]` method cannot get the service provider or `ITestCaseInfo`. Only the parameters of the test method get values from the service provider. -NUnit's own attributes work as they always do โ€” `[Ignore]`, `[Explicit]`, `[Category]`, -`[Timeout]`, `[Order]`, `[Parallelizable]`. `[ModuleTest]` only supplies arguments and the container; -it does not replace the rest of NUnit. +## Test information -## Next +The `ITestCaseInfo` interface in the `DependencyModules.NUnit.Impl` namespace has these properties: -- [Mocking frameworks](/guide/testing-mocking) โ€” `[Mock]` and the three libraries behind it -- [xUnit](/guide/testing-xunit) โ€” the same integration for xUnit -- [Testing registrations](/guide/testing-registrations) โ€” asserting on what a module registered +| Property | Value | +| --- | --- | +| `TestMethod` | The NUnit `TestMethod`. | +| `TestMethodArguments` | The values of the parameters. | +| `TestMethodAttributes` | The attributes of the test method, the test class, and the assembly. | diff --git a/website/guide/testing-registrations.md b/website/guide/testing-registrations.md deleted file mode 100644 index ff916e5..0000000 --- a/website/guide/testing-registrations.md +++ /dev/null @@ -1,140 +0,0 @@ -# Testing registrations - -## The problem - -`[ModuleTest]` answers questions about **behaviour**: resolve a service, call it, assert on what it -did. Some questions are not about behaviour at all. - -"Did my convention match exactly the three repositories I meant, and not the test double someone -added last week?" You cannot answer that by resolving one service โ€” resolving works fine whether the -convention matched three types or thirty. The thing you want to inspect is the registration list -itself. - -## How DependencyModules helps - -There is nothing to learn here, and that is the point. Modules apply to a plain `IServiceCollection`, -so you build one and read it: - -```csharp -using DependencyModules.Runtime; - -var services = new ServiceCollection(); - -services.AddModules(new DataModule()); - -var descriptor = Assert.Single(services, d => d.ServiceType == typeof(IRepository)); - -Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime); -Assert.Equal(typeof(SqlRepository), descriptor.ImplementationType); -``` - -No test-framework integration, no attributes โ€” an ordinary `[Fact]` works. - -## Pinning what a convention matched - -This is the shape that earns its keep, because the interesting question about a -[convention](/guide/conventions) is usually *which types matched*: - -```csharp -var registered = services - .Where(d => d.ServiceType == typeof(IRepository)) - .Select(d => d.ImplementationType!.Name) - .OrderBy(name => name) - .ToArray(); - -Assert.Equal(["OrderRepository", "ProductRepository"], registered); -``` - -Assert on the **whole set**, not with `Assert.Contains`. A containment check passes happily while -your convention quietly picks up a fourth type that someone adds next year โ€” which is precisely the -failure mode conventions have. - -## Testing conditional registrations - -Registrations gated on the [environment](/guide/environments) are decided **when the modules are -applied**, so the environment has to be supplied at that moment: - -```csharp -var services = new ServiceCollection(); - -services.AddModules(new ModuleEnvironment("Development"), new ApplicationModule()); - -Assert.IsType( - services.BuildServiceProvider().GetRequiredService()); -``` - -::: warning Always name the environment -Supply nothing and the **process** environment is used, which defaults to `"Production"`. A test for -a development-only service that forgets this quietly tests the other branch and passes for the wrong -reason. -::: - -Both sides fit in one theory: - -```csharp -[Theory] -[InlineData("Development", typeof(FakeEmailSender))] -[InlineData("Production", typeof(SmtpEmailSender))] -public void SelectsTheSenderByEnvironment(string environment, Type expected) -{ - var services = new ServiceCollection(); - - services.AddModules(new ModuleEnvironment(environment), new ApplicationModule()); - - Assert.IsType(expected, services.BuildServiceProvider().GetRequiredService()); -} -``` - -## Testing decorator order - -Reflecting over a decorator chain is painful and tells you little. Have each decorator contribute to -a string instead, and assert on the result: - -```csharp -Assert.Equal("outer(inner(core))", provider.GetRequiredService().Describe()); -``` - -That reads as the nesting it describes, and fails with a message you can act on. - -## Testing interceptors - -Easiest through something the interceptor writes to: - -```csharp -var provider = services.BuildServiceProvider(); -var log = provider.GetRequiredService(); - -provider.GetRequiredService().Count("acme"); - -Assert.Equal(["intercepted Count"], log.Lines); -``` - -::: danger Build the provider once -`BuildServiceProvider()` called twice gives you two providers with two independent sets of -singletons. Resolve the service from one and the log from the other and you are comparing two -different instances โ€” the assertion fails and the registration looks broken when it is not. - -```csharp -var provider = services.BuildServiceProvider(); // once - -var service = provider.GetRequiredService(); -var log = provider.GetRequiredService(); // same provider, same singleton -``` -::: - -## Testing what a package scan found - -A [referenced-assembly scan](/guide/scanning) is worth pinning, because a package upgrade can change -what matches without anything in your code changing: - -```csharp -var policies = provider.GetServices() - .Select(policy => policy.Name) - .OrderBy(name => name) - .ToArray(); - -Assert.Equal(["first", "second"], policies); -``` - -Remember that only `public` types cross an assembly boundary, so a scan finds strictly less than the -same convention would in your own project. diff --git a/website/guide/testing-xunit.md b/website/guide/testing-xunit.md index 5258524..d0e62e9 100644 --- a/website/guide/testing-xunit.md +++ b/website/guide/testing-xunit.md @@ -1,200 +1,135 @@ # xUnit -`DependencyModules.xUnit` is the xUnit integration. Read [Testing modules](/guide/testing) first โ€” -this page covers only what is specific to xUnit. +`DependencyModules.xUnit` adds `[ModuleTest]` to xUnit v3 test projects. The package is compatible with `xunit.v3` version 3.2.2 and all subsequent versions before 4.0.0. + +## Install ```shell dotnet add package DependencyModules.xUnit ``` -```csharp -using DependencyModules.xUnit.Attributes; - -public class WeatherTests -{ - [ModuleTest] - [ApplicationModule] - public void GetForecast(Weather weather) - { - var forecast = weather.GetWeatherForecast().ToArray(); - - Assert.Equal(5, forecast.Length); - } -} -``` - -Requires **xUnit v3**. `[ModuleTest]` derives from `FactAttribute` and is discovered through xUnit's -own test case discoverer, so it is a fact as far as the rest of xUnit is concerned. - -::: warning `dotnet new xunit` gives you v2 -The template still creates an xUnit **v2** project โ€” package `xunit`, not `xunit.v3` โ€” and this -integration cannot use it at all. Start from `dotnet new xunit3`, or replace the reference: +Also add references to `xunit.v3` and to a test runner, for example `xunit.runner.visualstudio`. -```xml - -``` +## `[ModuleTest]` -Supported range is `[3.2.2, 4.0.0)`. xUnit 4.0.0 changed a discovery API this package binds to, so -combining them fails at test discovery with a `MissingMethodException` naming an xUnit internal -rather than this package. From 1.2.0 the dependency is bounded, so NuGet says so at restore instead. -::: +`[ModuleTest]` is in the `DependencyModules.xUnit.Attributes` namespace. Put it on a test method. Do not also put `[Fact]` or `[Theory]` on the method. `[ModuleTest]` has these constructors: -## `[ModuleTest]` replaces `[Fact]` +| Constructor | Result | +| --- | --- | +| `[ModuleTest]` | Loads no module from the attribute. The test can get modules from module attributes. | +| `[ModuleTest(typeof(ShopModule))]` | Loads one module. | +| `[ModuleTest(typeof(ShopModule), typeof(MailModule))]` | Loads the modules in the sequence of the list. | -It replaces `[Theory]` as well. A module test with data attributes on it produces one test case per -row without your saying so โ€” there is no separate attribute for the parameterised case. +The constructor with two or more module types does not record the source file and the line of the test. Thus the test explorer of an IDE cannot open the source code of this test. The other two constructors record this information. -Because it derives from `FactAttribute`, everything `[Fact]` carries carries here too: +`[ModuleTest]` is an xUnit `FactAttribute`. Thus `Skip`, `SkipType`, `SkipUnless`, `SkipWhen`, `SkipExceptions`, `Explicit`, `Timeout`, `DisplayName`, and traits have the same function as on `[Fact]`. ```csharp -[ModuleTest(Skip = "flaky on CI", Explicit = true, Timeout = 5000, DisplayName = "Forecast")] -public void GetForecast(Weather weather) { } -``` - -`Skip`, `SkipUnless`, `SkipWhen`, `SkipExceptions`, `SkipType`, `Explicit`, `Timeout` and -`DisplayName` all behave as xUnit defines them, and `[Trait]` is carried onto the generated test -cases. +using DependencyModules.xUnit.Attributes; +using Shop; +using Xunit; -## Naming modules on the attribute +namespace Shop.Tests; -Beyond the module attributes described in [Testing modules](/guide/testing#stop-repeating-the-module-list), -`[ModuleTest]` takes module types directly: +public class CalculatorTests +{ + [ModuleTest(typeof(ShopModule))] + public void Multiplies(IPriceCalculator calculator) + { + Assert.Equal(6m, calculator.Total(2m, 3)); + } -```csharp -[ModuleTest(typeof(ApplicationModule))] -public void GetForecast(Weather weather) { } + [ModuleTest(typeof(ShopModule), Skip = "Not ready")] + public void SkippedTest(IPriceCalculator calculator) { } +} ``` -::: warning Two or more modules loses the source location -`[ModuleTest]` captures the file and line it sits on through `[CallerFilePath]`/`[CallerLineNumber]`, -which is how a test explorer navigates back to your test. C# will not accept caller-info parameters -after a `params` array, so the overload taking **several** module types cannot capture them. +The service provider gives values only to the parameters of the test method. xUnit gives the values for the constructor of the test class. -Such a test still runs and still reports correctly; only navigation from the explorer to the source -is unavailable. Naming one module, or none, takes an overload that keeps it โ€” so prefer the module -attributes for the multi-module case: +## Data rows -```csharp -[ModuleTest] // location captured -[ApplicationModule] -[DiagnosticsModule] -public void GetForecast(Weather weather) { } -``` -::: +You can use xUnit data attributes with `[ModuleTest]`, for example `[InlineData]`, `[MemberData]`, and `[ClassData]`. The test package also reads other attributes that implement the xUnit `IDataAttribute` interface. The test package gives the values of a row to the first parameters of the method. The service provider gives the values for the other parameters. -## Data-driven tests +```csharp +using DependencyModules.xUnit.Attributes; +using Shop; +using Xunit; -Any xUnit data attribute works โ€” `[InlineData]`, `[MemberData]`, `[ClassData]`, and anything else -implementing `IDataAttribute`. Row arguments come first, injected ones after: +namespace Shop.Tests; -```csharp -[ModuleTest] -[InlineData("one")] -[InlineData("two")] -public void MultipleRows(string value, ITemperatureProvider provider) +public class RowTests { - Assert.NotNull(value); // from [InlineData] - Assert.NotNull(provider); // from the container + [ModuleTest(typeof(ShopModule))] + [InlineData(2, 4)] + [InlineData(3, 6)] + public void RowAndService(int quantity, int expected, IPriceCalculator calculator) + { + Assert.Equal(expected, calculator.Total(2m, quantity)); + } } ``` -A row supplies the **leading** parameters and may supply fewer than the method takes โ€” that is the -point of it. The rest are resolved from the container. +Each row gets a new service provider. If the data attributes give no rows, the test fails. It does not pass when no row runs. -Each row is a separate test case with its own container, so state cannot carry from one row to the -next. +A row of the type `TheoryData` or `TheoryDataRow` has a type for each value. The xUnit analyzer compares the number of these types with the number of parameters. If the number of types is less, the analyzer gives the error xUnit1037. For a `[ModuleTest]` method, this number of values is correct. -::: warning xUnit's analyzer objects to the shape -`xUnit1037` counts a row's arguments against the method's parameters and finds them short, because to -xUnit a row is meant to supply all of them. Under `[ModuleTest]` the shortfall is the feature. Silence -it where you use it: +Disable xUnit1037 for these tests: ```csharp -#pragma warning disable xUnit1037 -``` - -Also worth knowing: before 1.2.0, `[MemberData]` under `[ModuleTest]` produced **zero** test cases and -the run reported a pass. `[MemberData(nameof(Cases), MemberType = typeof(MyTests))]` was the shape -that worked. Both work now, and a row source that yields nothing is a failure rather than a silent -green. -::: +using DependencyModules.xUnit.Attributes; +using Shop; +using Xunit; -`TheoryDataRow`'s own metadata is honoured per row, so a single row can skip or carry its own traits: +namespace Shop.Tests; -```csharp -public static TheoryData Cases => new() +public class TypedRowTests { - new TheoryDataRow("ok"), - new TheoryDataRow("broken") { Skip = "pending #412" }, -}; + public static TheoryData Quantities => new(2, 3); -[ModuleTest] -[MemberData(nameof(Cases))] -public void MultipleRows(string value, ITemperatureProvider provider) { } -``` - -## Reading the test case - -`ITestCaseInfo` is resolvable from the container and exposes xUnit's own metadata for the running -test: +#pragma warning disable xUnit1037 -```csharp -[ModuleTest] -public void KnowsWhatItIs(ITestCaseInfo testCase) -{ - IXunitTestMethod method = testCase.TestMethod; + [ModuleTest(typeof(ShopModule))] + [MemberData(nameof(Quantities))] + public void TypedRow(int quantity, IPriceCalculator calculator) + { + Assert.Equal(2m * quantity, calculator.Total(2m, quantity)); + } - Assert.Equal(nameof(KnowsWhatItIs), method.MethodName); +#pragma warning restore xUnit1037 } ``` -| Member | | -|---|---| -| `TestMethod` | the `IXunitTestMethod` xUnit built | -| `TestMethodArguments` | the arguments the test will be invoked with | -| `TestMethodAttributes` | every attribute on the method | +A `TheoryDataRow` can set its `Skip`, `SkipType`, `SkipUnless`, `SkipWhen`, `Timeout`, `Traits`, `TestDisplayName`, and `Label`. These values are applicable only to that row. + +The test of a data row gets the traits of the class, of the method, and of the row. The traits of the row are, for example, from the `Traits` property of `[InlineData]`. A `[Theory]` row gets its traits in the same way. -## Fixtures and lifetime +## Lifetime of the service provider -xUnit constructs the test class **once per test**, which is its own model and unchanged here. Combined -with a container per test, that means nothing survives between tests unless you deliberately make it โ€” -a class fixture, a collection fixture, or a static. +The test package builds the service providers when xUnit makes the tests of a test method. After all tests of the test method run, the test package disposes these service providers. -The container's lifetime brackets the test, so a constructor or `IAsyncLifetime` on the class runs -inside it. Anything the test class needs from the container has to come through a `[ModuleTest]` -parameter, though โ€” xUnit constructs the class, not this package, so a constructor parameter is -xUnit's to supply. +## Test information -## Customising how the provider is built +The `ITestCaseInfo` interface in the `DependencyModules.xUnit.Impl` namespace has these properties: -Implement `IServiceProviderBuilderAttribute` to take over the final step, if you want validation on or -a different container: +| Property | Value | +| --- | --- | +| `TestMethod` | The xUnit `IXunitTestMethod`. | +| `TestMethodArguments` | The values of the parameters. | +| `TestMethodAttributes` | The attributes of the test method, the test class, and the assembly. | ```csharp -[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class | AttributeTargets.Assembly)] -public class ValidatingProviderAttribute : Attribute, IServiceProviderBuilderAttribute +using DependencyModules.xUnit.Attributes; +using DependencyModules.xUnit.Impl; +using Xunit; + +namespace Shop.Tests; + +public class InfoTests { - public IServiceProvider BuildServiceProvider( - ITestMethodContext testMethod, IServiceCollection serviceCollection) => - serviceCollection.BuildServiceProvider(new ServiceProviderOptions - { - ValidateScopes = true, - ValidateOnBuild = true, - }); + [ModuleTest] + public void KnowsItsName(ITestCaseInfo info) + { + Assert.Equal(nameof(KnowsItsName), info.TestMethod.MethodName); + } } ``` - -It runs last, after every other hook has contributed, so it is also the final chance to amend the -collection. Without one, the collection is built with `BuildServiceProvider()` and its defaults. - -Unlike the other hooks, which all contribute, only **one** of these is used. Declare a single one โ€” -assembly level is the usual place, since replacing the container is a project-wide decision. - -This one is not xUnit-specific โ€” it lives in `DependencyModules.Testing` and works the same under -[NUnit](/guide/testing-nunit). - -## Next - -- [Mocking frameworks](/guide/testing-mocking) โ€” `[Mock]` and the three libraries behind it -- [NUnit](/guide/testing-nunit) โ€” the same integration for NUnit -- [Testing registrations](/guide/testing-registrations) โ€” asserting on what a module registered diff --git a/website/guide/testing.md b/website/guide/testing.md index b7194a6..af9ba16 100644 --- a/website/guide/testing.md +++ b/website/guide/testing.md @@ -1,307 +1,269 @@ -# Testing modules +# Testing -## The problem +The test packages build a service provider from your modules for each test. The test method gets services as parameters. Thus a test uses the same registrations as the application. -Here is a service with two dependencies, one of which has a dependency of its own: +## Packages -```csharp -[SingletonService] -public class Weather(ISummaryProvider summaryProvider, ITemperatureProvider temperatureProvider) -{ - public IEnumerable GetWeatherForecast() { /* โ€ฆ */ } -} -``` +| Package | Contents | +| --- | --- | +| `DependencyModules.xUnit` | `[ModuleTest]` for xUnit v3. | +| `DependencyModules.NUnit` | `[ModuleTest]` and `[ModuleTestCase]` for NUnit 4. | +| `DependencyModules.Testing` | The attributes and interfaces that the two test packages use. The test packages reference this package. | +| `DependencyModules.NSubstitute` | `[NSubstituteSupport]` for mocks. | +| `DependencyModules.Moq` | `[MoqSupport]` for mocks. | +| `DependencyModules.FakeItEasy` | `[FakeItEasySupport]` for mocks. | -To test it, you have two options and neither is good. +Add one test package to the test project. Also add a reference to the project that contains your modules. If the test project declares modules or services, also add `DependencyModules.SourceGenerator`. -**Construct it by hand.** You end up rebuilding the object graph in the test: +For more information about each framework, refer to [xUnit](./testing-xunit.md) and [NUnit](./testing-nunit.md). -```csharp -var weather = new Weather( - new SummaryProvider(new AiSummaryProvider()), - new TemperatureProvider()); -``` +## Write a test -Every constructor change breaks every test that touches the type, and the wiring you are testing is -the wiring you just wrote โ€” not the wiring your application actually uses. - -**Build a provider in each test.** Correct, but it is four lines of ceremony before you get to the -part you care about, repeated in every test, and now you have a provider to dispose: +Replace `[Fact]` or `[Test]` with `[ModuleTest]`. Give the module types to the attribute. Declare a parameter for each service that the test uses. ```csharp -var services = new ServiceCollection(); -services.AddModule(); -using var provider = services.BuildServiceProvider(); +using DependencyModules.xUnit.Attributes; +using Shop; +using Xunit; + +namespace Shop.Tests; -var weather = provider.GetRequiredService(); +public class PriceCalculatorTests +{ + [ModuleTest(typeof(ShopModule))] + public void TotalMultipliesPriceAndQuantity(IPriceCalculator calculator) + { + Assert.Equal(10m, calculator.Total(2.5m, 4)); + } +} ``` -## How DependencyModules helps +## Modules for a test -A test framework integration does the second thing for you. You say which modules to load, and the -services your test needs arrive as **method parameters**, resolved from a provider built out of your -real modules: +A test loads the modules from these locations: + +- The types in `[ModuleTest]`. Each type must have a constructor without parameters. +- The module attributes on the test method, for example `[ShopModule]`. +- The module attributes on the test class. +- The module attributes on the assembly, for example `[assembly: ShopModule]`. ```csharp -public class WeatherTests +using DependencyModules.xUnit.Attributes; +using Shop; +using Xunit; + +namespace Shop.Tests; + +[ShopModule] +public class OrderTests { [ModuleTest] - [ApplicationModule] - public void GetForecast(Weather weather) + public void CalculatorIsAvailable(IPriceCalculator calculator) { - var forecast = weather.GetWeatherForecast().ToArray(); - - // assert on forecast + Assert.NotNull(calculator); } } ``` -Three things are happening in that test: +A module attribute on the assembly is applicable to all tests of the assembly. A module attribute can also give module parameters, for example `[MailModule("localhost", 25)]`. -- **`[ModuleTest]`** replaces your framework's test attribute. It builds a service provider and runs - your method against it. -- **`[ApplicationModule]`** says which modules to load. It is the attribute the generator produced - for your module โ€” see [composing modules](/guide/modules#composing-modules). -- **`Weather weather`** is resolved from the resulting provider, along with its whole dependency - graph. +The modules load in this sequence: -Change `Weather`'s constructor and the test keeps compiling, because the test never mentioned the -constructor. +1. The modules of `[ModuleTest]`, in the sequence of the list. +2. The modules from the assembly. +3. The modules from the test class. +4. The modules from the test method. -## Pick an integration +Thus a registration from a method attribute is after a registration from a class attribute. When a parameter has more than one registration, it gets the instance from the last registration. Module dependencies can change this sequence. For more information, refer to [Module load sequence](./modules.md#module-load-sequence). -One package per test framework. Install the one matching the framework you already use: +If two locations give modules that are equal, the module loads one time. The test package keeps the instance from the first location in this list: the method, the class, the assembly, and `[ModuleTest]`. The load operation compares modules with `Equals`. For the `Equals` method of a module, refer to [Module equality](./modules.md#module-equality). -| Package | Framework | | -|---|---|---| -| `DependencyModules.xUnit` | xUnit v3 | [xUnit](/guide/testing-xunit) | -| `DependencyModules.NUnit` | NUnit | [NUnit](/guide/testing-nunit) | +## A new service provider for each test -```shell -dotnet add package DependencyModules.xUnit -``` - -This page is the part they share, and it is most of it. The two framework pages cover only what -differs โ€” how data rows are supplied, and what each framework's own attributes do around a module -test. +Each test gets a new service collection and a new service provider. A test does not use the service provider of a different test. -::: warning Reference one integration, not both -Each defines a `ModuleTestAttribute`. They share a name and nothing else, because each has to derive -from what its own framework requires. A project referencing both would need to disambiguate every -`[ModuleTest]`, which is not a configuration worth having. -::: +- A data row gets a new service provider. +- In NUnit, `[Repeat]` and `[Retry]` run a test more than one time. Each iteration gets a new service provider. +- The test package disposes the service provider. The service provider then disposes the services that it made. NUnit disposes the service provider after each iteration of the test. xUnit disposes the service providers of all data rows after the last row. -Everything else โ€” `[Mock]`, `[TestExport]`, `[InjectValues]`, keyed services โ€” lives in -`DependencyModules.Testing`, which your integration brings in. Those types name no test framework, so -both integrations hand you the *same* attribute rather than a copy of it. They need a -`using DependencyModules.Testing.Attributes;` alongside the one for `[ModuleTest]`. +## Test parameters -## Stop repeating the module list +The test package gets a value for each parameter in this sequence: -Module attributes apply at **assembly, class or method level**, and they accumulate. Put the ones -every test needs in one file at the assembly level: +1. The test package gives the values of the data row to the first parameters. +2. A parameter of type `IServiceProvider` gets the service provider of the test. +3. A parameter attribute that gives values, for example `[Mock]`, gives the value. If a parameter has more than one of these attributes, the first value that is not `null` is the value. +4. A parameter with `[FromKeyedServices("key")]` gets the keyed service from the service provider. If there is no keyed registration, the value is `null`. +5. The service provider gives the service. +6. If the service provider has no registration for the type, the test package makes an instance with `ActivatorUtilities.CreateInstance`. The service provider gives the constructor parameters. -```csharp -// Bootstrap.cs -using DependencyModules.NSubstitute; -using MyApp.Tests; // the namespace the module is declared in +Step 6 lets a test get a class that has no registration, for example the class that the test examines. Step 6 is not applicable to a parameter with `[FromKeyedServices]`. -[assembly: ApplicationModule] -[assembly: NSubstituteSupport] // or [MoqSupport] / [FakeItEasySupport] -``` +### Give constructor values: `[InjectValues]` -That second `using` is easy to miss. A module generates its attribute in the module's own -namespace, and an assembly-level attribute has no namespace context to inherit โ€” so without it the -build fails with `CS0246: The type or namespace name 'ApplicationModuleAttribute' could not be -found`, naming a type you never wrote. Importing the namespace or writing the attribute qualified, -`[assembly: MyApp.Tests.ApplicationModule]`, both work. -[DM0016](/reference/diagnostics#dm0016) reports it and names the namespace to import, for a module -declared here or one from a referenced package. +When the test package makes an instance of a class that has no registration, `[InjectValues]` gives more constructor arguments. The service provider gives the other arguments. -A test project has no entry point, so [DM0019](/reference/diagnostics#dm0019) โ€” which reports an -assembly-level module attribute in the wrong file โ€” stays quiet here. That is deliberate: assembly -attributes are read at run time by the test integration, and a file of their own is exactly where -they belong. +```csharp +using DependencyModules.Testing.Attributes; +using DependencyModules.xUnit.Attributes; +using Shop; +using Xunit; -Every test in the project now gets `ApplicationModule` without saying so: +namespace Shop.Tests; -```csharp -public class WeatherTests +public class CheckoutReport(IPriceCalculator calculator, string customer) { - [ModuleTest] - public void UsesTheAssemblyModules(Weather weather) { } + public string Line(decimal price, int quantity) => + $"{customer}: {calculator.Total(price, quantity)}"; +} - [ModuleTest] - [DiagnosticsModule] // this test gets DiagnosticsModule as well - public void AddsOneMore(Weather weather, IProfiler profiler) { } +public class CheckoutReportTests +{ + [ModuleTest(typeof(ShopModule))] + public void LineContainsTheCustomer([InjectValues("Ada")] CheckoutReport report) + { + Assert.StartsWith("Ada:", report.Line(1m, 1)); + } } ``` -`NSubstituteSupport` is what enables [`[Mock]`](/guide/testing-mocking), and it comes from a separate -package โ€” one per mocking library, so use whichever you already have. See -[Mocking frameworks](/guide/testing-mocking). +## Replace a service for a test: `[TestExport]` -## A container per test +`[TestExport]` registers a service for the tests that it is applicable to. Put it on a test method, on a test class, or on the assembly. -Each test gets **its own provider**, built before the test runs and disposed after it, so a singleton -mutated in one test cannot leak into another. That holds per *iteration*, not merely per method โ€” a -data row, a repeat and a retry each get a fresh container. +```csharp +using DependencyModules.Testing.Attributes; +using DependencyModules.xUnit.Attributes; +using Microsoft.Extensions.DependencyInjection; +using Shop; +using Xunit; -Within a test, ask for `IServiceProvider` and create scopes as usual: +namespace Shop.Tests; -```csharp -[ModuleTest] -public void ScopedServicesAreScoped(IServiceProvider provider) +public class FixedPriceCalculator : IPriceCalculator { - using var first = provider.CreateScope(); - using var second = provider.CreateScope(); - - var one = first.ServiceProvider.GetRequiredService(); + public decimal Total(decimal price, int quantity) => 1m; +} - // one is the same instance within first, and a different one in second +public class ExportTests +{ + [ModuleTest(typeof(ShopModule))] + [TestExport( + typeof(IPriceCalculator), + Implementation = typeof(FixedPriceCalculator), + Lifetime = ServiceLifetime.Singleton + )] + public void UsesTheExport(IPriceCalculator calculator) + { + Assert.Equal(1m, calculator.Total(100m, 3)); + } } ``` -`IServiceProvider` is special-cased: it is the test's container itself, since a container cannot -resolve itself out of itself. +| Property | Default | Function | +| --- | --- | --- | +| `Service` | Not applicable | The service type. You give it in the constructor. | +| `Implementation` | The service type | The class that the registration makes. | +| `Lifetime` | `Transient` | The lifetime of the registration. | +| `Shared` | `false` | If the value is `true`, the service providers from `ITestContainerSource` give the same instance of the service. Refer to [More service providers in a test](./testing-container-source.md). | -## How a parameter gets filled +The test package adds the `[TestExport]` registrations after the modules. Thus they are after the registrations of the modules. -Worth knowing when a parameter does not arrive as you expected. Each one is tried in this order, and -the first step that answers wins: +The decorators of the modules do not change a `[TestExport]` registration. The decorators change the registrations when the modules load, before the test package adds the `[TestExport]` registrations. This is also true for `[Mock]`. -1. **A data row**, if the test has one. Row arguments fill the leading parameters, so anything the - row supplies is never resolved from the container. -2. **Attributes on the parameter** โ€” `[Mock]`, `[InjectValues]` and anything else implementing - `ITestParameterValueProvider`. Several may sit on one parameter; one returning nothing stands - aside for the next. -3. **The container**, honouring `[FromKeyedServices]` when present. -4. **Direct construction.** A concrete type the container does not know is built anyway, through - `ActivatorUtilities`, with its dependencies resolved from the container. +## Test information: `ITestCaseInfo` -That last step is why a test can name the class under test directly without registering it: +The service provider of a test contains an `ITestCaseInfo` service. It has the test method, the argument values, and the attributes of the test. Each test package has an `ITestCaseInfo` interface in its `Impl` namespace. -```csharp -[ModuleTest] -public void ConstructsTheSubjectDirectly(OrderCalculator calculator) { } // never registered -``` +## Environment for a test -## Keyed services - -`[FromKeyedServices]` works on a test parameter the way it does on a constructor parameter: +To give an environment to a test, write an attribute that implements `IModuleEnvironmentProvider` from `DependencyModules.Runtime.Interfaces`: ```csharp -[ModuleTest] -public void ResolvesTheKeyedOne([FromKeyedServices("primary")] IRepository repository) { } -``` +using System.Reflection; +using DependencyModules.Runtime; +using DependencyModules.Runtime.Interfaces; -See [registering services](/guide/services) for how a registration acquires a key. +namespace Shop.Tests; -## When you want a real object, not a mock - -A [mock](/guide/testing-mocking) is right when you intend to **assert on the interaction** โ€” what was -called, with which arguments. When you instead want a working implementation that simply behaves -differently, a mock makes you stub out every member you touch. - -`[TestExport]` registers a real type into the test's container without touching the module: - -```csharp -public class FixedClock : IClock +[AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Class | AttributeTargets.Method)] +public class TestEnvironmentAttribute(string name) : Attribute, IModuleEnvironmentProvider { - public DateTime UtcNow => new(2026, 1, 1); + public IModuleEnvironment? ProvideEnvironment(MethodInfo testMethod) => + new ModuleEnvironment(false, name); } - -[ModuleTest] -[TestExport(typeof(IClock), Implementation = typeof(FixedClock), Lifetime = ServiceLifetime.Singleton)] -public void OrdersAreStampedWithTheCurrentTime(IOrderService service) { } ``` -`FixedClock` is constructed by the container, so it can have dependencies of its own. - -| Property | | -|---|---| -| *(constructor)* | the service type | -| `Implementation` | defaults to the service type when omitted | -| `Lifetime` | defaults to `Transient` | - -Like the module attributes it applies at assembly, class or method level, so a stub every test needs -can sit in your bootstrap file once. A `[TestExport]` also beats a mock for the same service, -whatever order the two are declared in โ€” see [ordering](/guide/testing-mocking#what-wins-when-two-things-register-the-same-service). - -## When the parameter is not a service at all - -Sometimes a test parameter is a type the container cannot build on its own, because part of it is -data rather than a service. `[InjectValues]` supplies the parts the container cannot: +Put the attribute on a test method, a test class, or the assembly. If attributes at more than one level give an environment, the test uses the environment from the method. If the method has no such attribute, the test uses the environment from the class. If the class has no such attribute, the test uses the environment from the assembly. If no attribute gives an environment, the modules use the default environment. ```csharp -public record InjectModel(IDependencyOne DependencyOne, string StringValue); - -[ModuleTest] -public void InjectTestValue([InjectValues("Hello World!")] InjectModel model) -{ - // model.DependencyOne came from the container - // model.StringValue came from the attribute -} -``` - -The values are matched against the constructor parameters the container **cannot** supply, so you -list only what it could not work out for itself. +using DependencyModules.xUnit.Attributes; +using Shop; +using Xunit; -They are the parameter type's *constructor arguments*, not the parameter's own value โ€” so a -parameter that should simply **be** a value wants a data row instead. `[InlineData]` and NUnit's -`[TestCase]` both compose with `[ModuleTest]`, and the container fills whatever the row does not: +namespace Shop.Tests; -```csharp -[ModuleTest] -[InlineData("978-0132350884")] -[InlineData("978-0201616224")] -public async Task GetBook_FindsEachIsbn(string isbn, IRequestHandler handler) +public class EnvironmentTests { - // isbn came from the row, handler from the container + [ModuleTest(typeof(ShopModule))] + [TestEnvironment("Development")] + public void RunsInDevelopment(DependencyModules.Runtime.Interfaces.IModuleEnvironment environment) + { + Assert.Equal("Development", environment.EnvironmentName); + } } ``` -Asking for a bare `string` through `[InjectValues]` fails with *"A suitable constructor for type -'System.String' could not be located"*, because that is exactly what it tried to do. +## Attributes that you write for tests -## Choosing between the three +The `DependencyModules.Testing.Attributes.Interfaces` namespace has interfaces for attributes that you write. Put an `ITestParameterValueProvider` attribute on a parameter. Put the other attributes on a test method, a test class, or the assembly. -| | Reach for it when | -|---|---| -| [`[Mock]`](/guide/testing-mocking) | you want to assert on the interaction โ€” what was called, with what | -| `[TestExport]` | you want a real object with different behaviour, constructed by the container | -| `[InjectValues]` | the parameter is a type the container cannot finish building, because part of it is data | -| `[InlineData]` / `[TestCase]` | the parameter simply **is** a value โ€” one test per row | +| Interface | Function | +| --- | --- | +| `ITestServiceSetupAttribute` | Adds registrations to the service collection of the test, after the modules. | +| `IServiceProviderBuilderAttribute` | Builds the service provider from the service collection. If there are attributes at more than one level, the test uses only one attribute. It uses the attribute on the method. If the method has no such attribute, it uses the attribute on the class. If the class has no such attribute, it uses the attribute on the assembly. | +| `ITestStartupAttribute` | Runs code after the test package builds the service provider, before the test. | +| `ITestParameterValueProvider` | A parameter attribute that registers services and gives the value of the parameter. `[Mock]` uses it. | -## What is worth testing +If no `IServiceProviderBuilderAttribute` is applicable, the test package calls `BuildServiceProvider()` without `ServiceProviderOptions`. Thus the service provider does not validate scopes. It also does not validate the registrations when the test package builds it. The example that follows enables these two checks. -Asserting that `[SingletonService]` produced an `AddSingleton` call is testing this library, and this -library has its own tests. Spend your assertions on the things the compiler cannot check: +The methods of these interfaces get an `ITestMethodContext` value. In xUnit, you can cast this value to `IXunitTestMethodContext`. In NUnit, you can cast it to `INUnitTestMethodContext`. -- a [convention](/guide/conventions) matched the types you meant โ€” and, more usefully, did **not** - match the ones you did not -- a [conditional registration](/guide/environments) picks the right implementation per environment -- [decorators](/guide/decorators) nest in the order you intended -- a service resolves at all, which catches a missing registration in a module you compose +```csharp +using DependencyModules.Testing.Attributes.Interfaces; +using Microsoft.Extensions.DependencyInjection; -The build already covers a good deal of the rest. A convention that matches nothing is -[DM0005](/reference/diagnostics#dm0005), and a service that cannot be constructed is -[DM0002](/reference/diagnostics#dm0002) โ€” both before a test runs. +namespace Shop.Tests; -## A trap worth knowing about +public class ValidatedProviderAttribute : Attribute, IServiceProviderBuilderAttribute +{ + public IServiceProvider BuildServiceProvider( + ITestMethodContext testMethod, + IServiceCollection serviceCollection + ) => + serviceCollection.BuildServiceProvider( + new ServiceProviderOptions { ValidateScopes = true, ValidateOnBuild = true } + ); +} +``` -An [intercepted](/guide/interception) service resolves as a **generated wrapper**, not as your class. -So this fails, confusingly: +## The steps of a test -```csharp -Assert.IsType(provider.GetRequiredService()); // it is Orders_Intercepted -``` +For each test, the test package does these steps: -Assert on the interface, or on behaviour. The same applies to a [decorated](/guide/decorators) -service, where what resolves is the outermost decorator. +1. Makes a service collection. +2. Registers `ITestCaseInfo` and `ITestContainerSource`. +3. Registers the environment from an `IModuleEnvironmentProvider` attribute, if there is one. +4. Loads the modules. +5. Uses the `ITestServiceSetupAttribute` attributes. It uses the mock support attributes first. Then it uses the other attributes, for example `[TestExport]`. +6. Uses the parameter attributes, for example `[Mock]`. +7. Builds the service provider. +8. Runs the `ITestStartupAttribute` attributes. +9. Gets the parameter values and runs the test. +10. Disposes the service provider. In xUnit, this step occurs after the last data row of the test method. -## Next +## More information -- [xUnit](/guide/testing-xunit) and [NUnit](/guide/testing-nunit) โ€” what differs per framework -- [Mocking frameworks](/guide/testing-mocking) โ€” faking one service while the rest stays real -- [Testing registrations](/guide/testing-registrations) โ€” asserting on what a module registered +- [xUnit](./testing-xunit.md) and [NUnit](./testing-nunit.md) tell you about the two test packages. +- [Mocks](./testing-mocking.md) tells you how to replace services with mocks. +- [More service providers in a test](./testing-container-source.md) tells you how to make more service providers in one test. diff --git a/website/guide/troubleshooting.md b/website/guide/troubleshooting.md index 02976eb..f41cef6 100644 --- a/website/guide/troubleshooting.md +++ b/website/guide/troubleshooting.md @@ -1,13 +1,8 @@ # Troubleshooting -Something is not registered the way you expected. Because every registration is generated code sitting -in your own assembly, you can go and look at it rather than guessing โ€” which makes this a short page. +## See the generated code -Three steps produce almost everything needed to diagnose a problem, in the order worth doing them. - -## 1. Read the generated code - -This answers "was it registered, and as what" definitively, and it is usually the only step you need. +Set `EmitCompilerGeneratedFiles` to `true` in the project file. The compiler then writes the generated files below the `obj` folder. ```xml @@ -15,98 +10,96 @@ This answers "was it registered, and as what" definitively, and it is usually th ``` -The files appear under `obj/`: - -| File | Holds | -|---|---| -| `YourModule.Dependencies.g.cs` | the registrations | -| `YourModule.Module.g.cs` | the module plumbing | -| *(separate files)* | decorators and interceptors | +Most IDEs also show the generated files below the analyzers of the project. -A service missing from `Dependencies.g.cs` was never discovered โ€” jump to the common causes below. A -service present but registered as the wrong service type is a question about `As` and matching, and -[Registering services](/guide/services#what-callers-ask-for) covers it. +The generator writes these files for each module. The name of each file starts with the namespace of the module and the module name. The generator removes the root namespace of the project from the start of the name. If the module is not in the root namespace, the name starts with `global-` and the full namespace, for example `global-Sub.FooModule.Module.g.cs`. -::: warning Stale files -If you redirect `CompilerGeneratedFilesOutputPath` into your project, delete the folder between runs. -Stale files compile alongside fresh ones and produce a wall of `CS0111`/`CS0579` that has nothing to -do with your actual problem. -::: +| File | Contents | +| --- | --- | +| `.Module.g.cs` | The other part of the module and the module attribute. | +| `.Dependencies.g.cs` | The registrations from service attributes. | +| `.ConventionDependencies.g.cs` | The registrations from conventions. | +| `.Decorators.g.cs` | The decorators. | +| `.Interceptors.g.cs` | The registrations of the interceptor wrappers. | +| `_Intercepted.g.cs` | The wrapper of one intercepted class. | -## 2. Turn on the generator log +## Write a generator log -When the generated file does not explain it, the log says what the generator saw and what it decided -โ€” the configuration in effect, every module and service discovered, and anything skipped **along with -the reason**. +Set `DependencyModules_LogOutputDirectory` to a folder. The generator then writes log files into this folder. ```xml - $(MSBuildProjectDirectory)/dmlogs + $(MSBuildProjectDirectory)/generator-logs ``` -::: warning No log appeared? -Every `DependencyModules_*` property reaches the generator through -`build/DependencyModules.SourceGenerator.targets`, which ships **inside the NuGet package**. A project -that references the analyzer as a `ProjectReference` โ€” building this library from source, or vendoring -it โ€” never imports that file, so the property is invisible and silently takes its default. +Each part of the generator writes a log file each time that the generator runs. The name of each file is the name of the part, the time in milliseconds, eight random characters, and the extension `.txt`. Thus no log file replaces a different log file. The service part, the interceptor part, and the convention part write two files. The name of the second file has the suffix `.Diagnostics` after the name of the part. A log file can be empty. -Declare them yourself in that project, or in a `Directory.Build.props` above it: +The log of `ServiceSourceGenerator` shows the configuration, the modules, and the services that the generator found. The logs also show the errors and the exceptions. If the folder has no new log files, build the project again with `dotnet build --no-incremental`. The usual build does not run the generator when no file changed. -```xml - - - - - - - - -``` -::: +## A service has no registration + +Examine these causes: + +1. The module is not partial. The generator gives the error DM0003. +2. The module is in a different class. The generator gives the error DM0017. +3. The class is abstract or static. The generator gives the warning DM0002. +4. The service is a static factory method that the generated code cannot call. The generator gives the warning DM0023. +5. The attributes must be from `DependencyModules.Runtime.Attributes`. +6. The service sets `Realm` to a different module. +7. The module has `OnlyRealm = true`, and the service does not set `Realm` to this module. +8. The service has environment conditions that are false. For each class with a service attribute and conditions, the generator gives DM0011, which shows the conditions. DM0011 has the severity Info. Thus the build output does not show it at the usual verbosity. +9. The service type of the registration is not the type that you use to get the service. For more information, refer to [Service type](./services.md#service-type). +10. The application must call `AddModule` or `AddModules`, or load a module that has the module attribute. +11. The service is in a different project. The application must load the module of that project. For more information, refer to [Module dependencies](./modules.md#module-dependencies). -## 3. Check for DM diagnostics +If the generator does not write the other part of a module, the module does not implement `IDependencyModule`. Then `AddModule()` gives the error CS0311. -The generator reports what it can detect at build time, and a good deal of what goes wrong here is -already a warning you have not read yet. See the [diagnostics reference](/reference/diagnostics). +## The service provider gives an incorrect implementation -## Common causes +When a service type has more than one registration, `GetService` gives the instance from the last registration. Examine these sequences: -**The module is not `partial`.** [DM0003](/reference/diagnostics#dm0003). The generator completes -your class; without `partial` there is nothing to complete. +- The sequence of the registrations in one module. Refer to [Registration sequence](./services.md#registration-sequence). +- The sequence of the modules. Refer to [Module load sequence](./modules.md#module-load-sequence). -**The module is nested inside another type.** A nested module generates a separate, detached class -instead of completing your partial, so its registrations never run. Declare modules directly in a -namespace. +To find all registrations, call `GetServices()`. The result contains one instance for each registration. -**A convention matched nothing.** [DM0005](/reference/diagnostics#dm0005) โ€” usually a renamed -interface or a typo in a filter. +For a decorated service, the service provider gives the outer decorator. For an intercepted service, it gives the wrapper class. Thus a check for the type of the implementation class fails. -**A convention picked up something unexpected.** Narrow it with a -[filter](/guide/conventions#narrowing-what-matches). Watch name patterns in particular: `*Handler` -matches `LoggingHandler` too. +## A service has two registrations -**The wrong implementation resolves.** The container takes the **last** matching descriptor for a -single resolve. Check the order in the generated file, remembering that conditional registrations are -emitted after unconditional ones โ€” see [overriding a default](/guide/environments#overriding-a-default). +Examine these causes: + +- Two modules of the same project register all services that do not set `Realm`. If you load two of these modules, each service registers two times. Realms can divide the services. +- You call `AddModule` two times with the same module. In one `AddModules` call, each module loads one time. + +## The MSBuild properties have no effect + +The `DependencyModules.SourceGenerator` package declares the MSBuild properties as `CompilerVisibleProperty` items. If you reference the generator project with a `ProjectReference`, and not the package, declare these items in your project: + +```xml + + + + + + + + + +``` -**A test resolves the wrong environment branch.** Supply nothing and the *process* environment is -used, defaulting to `"Production"`. See -[testing conditional registrations](/guide/testing-registrations#testing-conditional-registrations). +## The generator failed -**An assertion on the concrete type fails.** An [intercepted](/guide/interception) or -[decorated](/guide/decorators) service resolves as the wrapper, not your class. +If an exception occurs in the generator, the generator gives the error DM0001. Some registrations of the project can then be missing. If the exception occurs for one module, the generator writes the code of the other modules. -**`AddModule` called more than once.** Calling it inside a module, or several times at the root, -duplicates registrations. +Do these steps: -**Two modules declared in one project, both loaded.** Each holds that project's whole registration -list, so loading both applies it twice โ€” and composing one into the other does not help, since both -still hold everything. Give one a [realm](/guide/modules#realms-keeping-a-registration-out-of-the-default-module). +1. Set `DependencyModules_LogOutputDirectory` to a folder. +2. Build the project again. +3. Write an issue for the problem on the [issues page](https://github.com/ipjohnson/DependencyModules/issues) of the repository. +4. Attach the log files to the issue. -## Reporting a problem +## Diagnostics -Please include **the generator log and the generated file** in any -[issue](https://github.com/ipjohnson/DependencyModules/issues). Between them they show whether a -service was discovered, which realm it landed in, and what configuration was in effect โ€” which is -most of the way to a diagnosis before anyone has to reproduce it. +For each diagnostic, refer to [Diagnostics](../reference/diagnostics.md). diff --git a/website/index.md b/website/index.md index 677ee99..f5e2716 100644 --- a/website/index.md +++ b/website/index.md @@ -3,142 +3,66 @@ layout: home hero: name: DependencyModules - text: Dependency injection, decided at compile time - tagline: >- - Declare registration next to the class it belongs to, and a source generator writes the - IServiceCollection calls during the build. Nothing reflects, nothing scans at startup, and the - trimmer can follow every registration you declared. + text: Registration code that the generator writes + tagline: DependencyModules is a source generator for Microsoft.Extensions.DependencyInjection. You put attributes on your classes. The generator writes the registrations when you compile. image: src: /hero.svg - alt: Declarations on the left becoming generated registration code on the right + alt: DependencyModules actions: - theme: brand text: Get started link: /guide/getting-started - theme: alt - text: Conventions - link: /guide/conventions - - theme: alt - text: View on GitHub + text: GitHub link: https://github.com/ipjohnson/DependencyModules features: - - title: Registrations you can read - details: >- - Every registration is emitted into your assembly as plain C#. Set EmitCompilerGeneratedFiles - and the file under obj/ is the ground truth โ€” no container graph to reason about, no startup - cost to measure. - link: /guide/services - linkText: Registering services - - - title: Conventions without reflection - details: >- - Declare what to register once and the generator resolves the matches during the build. - Assignability, namespaces, attributes and name globs โ€” including types in a referenced package. - link: /guide/conventions - linkText: How conventions work - - - title: Trimming and Native AOT safe - details: >- - Each match is emitted as a literal typeof(), which the trimmer roots and which carries the - constructor along with it. The capability that breaks reflection-based scanners is the one that - works here. - link: /guide/aot - linkText: Why it survives trimming - - - title: Mistakes reported at build time - details: >- - A convention that matches nothing, a service that cannot be constructed, two conventions - claiming one service type โ€” each is a DM diagnostic in the IDE rather than an exception at - startup. - link: /reference/diagnostics - linkText: Diagnostics reference - - - title: Decorate and intercept - details: >- - Wrap a service with a decorator you write, or with a generated wrapper that routes every member - through an interceptor. Both compose with conventions and both are ordered globally. - link: /guide/decorators - linkText: Decorators and interception - - - title: Built for testing - details: >- - The xUnit package builds a provider from the modules a test names and injects the services the - test asks for, with mocks substituted where you want them. - link: /guide/testing - linkText: Testing modules + - title: Attributes for services + details: You put [SingletonService], [ScopedService], or [TransientService] on a class. The generator writes one registration call for each service. + - title: Modules + details: A module registers the services of a project. A module can use other modules. These modules can also be in packages. + - title: Conventions + details: A convention registers all classes that implement an interface. Filters select classes by name, namespace, and attribute. + - title: Decorators and interception + details: Decorators and interceptors put code around services. For classes that are not generic, the generated code makes them without reflection. + - title: Environments + details: A service can have a registration only in some environments, or only when the environment has a value. + - title: Tests with modules + details: xUnit and NUnit tests get services as parameters. Each test gets a new service provider. Mocks replace services. --- -
- -## The problem - -Every .NET application keeps a list like this, and nothing checks that it is complete: +## Example ```csharp -services.AddScoped(); -services.AddSingleton(); -// โ€ฆ another two hundred lines -``` - -Forget a line and you find out at run time, in the environment you deployed to. Reach for a runtime -scanner instead and you trade that for three new problems: you can no longer read what was -registered, the scan runs on every start, and the trimmer cannot see through reflection โ€” so a -published, trimmed build registers nothing at all. +using DependencyModules.Runtime.Attributes; -## What it looks like instead +namespace Shop; -Mark the class, and the registration is written for you during the build. +public interface IPriceCalculator +{ + decimal Total(decimal price, int quantity); +} -```csharp [SingletonService] -public class SmtpEmailSender : IEmailSender { } +public class PriceCalculator : IPriceCalculator +{ + public decimal Total(decimal price, int quantity) => price * quantity; +} [DependencyModule] -public partial class ApplicationModule; +public partial class ShopModule; ``` ```csharp -var services = new ServiceCollection(); - -services.AddModule(); -``` - -Or declare a rule once, and let it cover everything that fits โ€” including the handler somebody adds -next year. +using DependencyModules.Runtime; +using Microsoft.Extensions.DependencyInjection; +using Shop; -```csharp -[DependencyModule] -public partial class HandlerModule : IConventionModule -{ - void IConventionModule.Conventions(IConventionDefinitions conventions) - { - conventions.RegisterAll(typeof(IRequestHandler<,>)).AsScoped(); - conventions.RegisterAll().InNamespaceOf().AsScoped(); - } -} -``` +var services = new ServiceCollection(); -That body never runs. It is read during the build, and what comes out the other side is the same -registration code you would have written by hand: +services.AddModule(); -```csharp -services.AddScoped(typeof(IRequestHandler), typeof(CreateOrderHandler)); -services.AddScoped(typeof(IRequestHandler), typeof(RenameOrderHandler)); +var calculator = services.BuildServiceProvider().GetRequiredService(); ``` -
- - +To start, refer to [Getting started](./guide/getting-started.md). diff --git a/website/reference/api.md b/website/reference/api.md new file mode 100644 index 0000000..f72c6f0 --- /dev/null +++ b/website/reference/api.md @@ -0,0 +1,147 @@ +# API + +This page shows the public types that applications and tests use. The generated code also uses types from the `DependencyModules.Runtime.Helpers` namespace. Your code does not usually call these types. + +## `DependencyModules.Runtime` + +### `ServiceCollectionExtensions` + +| Method | Function | +| --- | --- | +| `AddModule(this IServiceCollection services)` | Makes an instance of `T` and loads it. `T` must implement `IDependencyModule` and have a constructor without parameters. | +| `AddModule(this IServiceCollection services, IDependencyModule module)` | Loads the module. | +| `AddModules(this IServiceCollection services, params IDependencyModule[] modules)` | Loads the modules in one operation. | +| `AddModules(this IServiceCollection services, IModuleEnvironment? environment, params IDependencyModule[] modules)` | Loads the modules with the environment. | + +The return value of all these methods is the service collection. + +To call the last method with a `null` environment, cast the argument: `(IModuleEnvironment?)null`. + +Guide: [Load a module](../guide/modules.md#load-a-module). + +### `ModuleEnvironment` + +`ModuleEnvironment` implements `IModuleEnvironment` and `IEnumerable>`. + +| Member | Function | +| --- | --- | +| `ModuleEnvironment(string environmentName, IReadOnlyDictionary? values = null)` | Makes an environment that reads environment variables for the keys that are not in `values`. | +| `ModuleEnvironment(bool fallBackToEnvironmentVariables, string environmentName, IReadOnlyDictionary? values = null)` | Makes an environment. If the first argument is `false`, the environment does not read environment variables. | +| `EnvironmentName` | The name of the environment. | +| `Value(string name)` | Gives the value for the key, or `null`. | +| `Add(string key, string? value)` | Adds a value, or replaces the value for the key. | +| `static CreateDefault()` | Gives a new instance of the environment of the process. | +| `static None` | An environment with an empty name and no values. | + +Guide: [Environments](../guide/environments.md). + +## `DependencyModules.Runtime.Attributes` + +For the attributes, refer to [Attributes](./attributes.md). + +`BaseServiceAttribute` and `CrossWireServiceAttribute` implement `IServiceRegistrationAttribute`. This interface has the `As`, `Key`, `Lifetime`, and `Using` properties. Your code can use the interface to read a service attribute at run time. The generator does not use this interface to find attributes. + +## `DependencyModules.Runtime.Interfaces` + +| Interface | Members | Function | +| --- | --- | --- | +| `IDependencyModule` | `LoadModule`, `PopulateServiceCollection(IServiceCollection)`, `GetModules()` | The interface of all modules. The generator implements it on each module. If `LoadModule` is `false`, the module does not load. `GetModules()` gives more modules to load. | +| `IDependencyModuleProvider` | `GetModule()` | Gives a module. The generated module attributes implement it. | +| `IServiceCollectionConfiguration` | `ConfigureServices(IServiceCollection)`, `ConfigureDecorators(IServiceCollection)` | Registration code in a module. | +| `IEnvironmentServiceCollectionConfiguration` | `ConfigureServices(IServiceCollection, IModuleEnvironment)` | Registration code in a module that reads the environment. | +| `IModuleEnvironment` | `EnvironmentName`, `Value(string)` | The environment. | +| `IModuleEnvironmentProvider` | `ProvideEnvironment(MethodInfo testMethod)` | Gives the environment for a test. | + +`IDependencyModule` also has members with the `Internal` prefix. The generated code uses them. Do not call these members from your code. They have `[EditorBrowsable(EditorBrowsableState.Never)]`. Thus IntelliSense does not show them in a project that references the package. + +## `DependencyModules.Runtime.Features` + +| Type | Function | +| --- | --- | +| `IDependencyModuleFeature` | A module that gets the loaded modules that implement `TFeature`. Members: `Order` (default 0) and `HandleFeature(IServiceCollection, IEnumerable)`. | +| `IDependencyModuleApplicatorProvider` | Gives the feature applicators of a module. The generator implements it. | +| `IFeatureApplicator` | Gives the loaded modules to a feature handler. Members: `Order` and `Apply(IServiceCollection, IReadOnlyList)`. | +| `FeatureApplicator` | The `IFeatureApplicator` that the generated code uses. | + +Guide: [Module features](../guide/extending.md#module-features). + +## `DependencyModules.Runtime.Conventions` + +| Interface | Function | +| --- | --- | +| `IConventionModule` | A module with conventions. Member: `Conventions(IConventionDefinitions conventions)`. | +| `IConventionDefinitions` | Starts a convention: `RegisterAll()`, `RegisterAll(Type serviceType)`, `RegisterAll()`. | +| `IConventionRegistration` | The calls of a convention chain. | + +The `IConventionRegistration` calls: + +| Group | Calls | +| --- | --- | +| Lifetime | `AsSingleton()`, `AsScoped()`, `AsTransient()` | +| Shape | `AsSelf()`, `AsSelfWithInterfaces()`, `AlsoAsSelf()`, `As()`, `AsMatchingInterface()` | +| Namespace filters | `InNamespaceOf()`, `InNamespaces(params string[])`, `InExactNamespaces(params string[])`, `NotInNamespaceOf()`, `NotInNamespaces(params string[])` | +| Name filters | `WithName(params string[])`, `WithoutName(params string[])` | +| Attribute filters | `WithAttribute()`, `WithoutAttribute()` | +| Selection | `IncludeBaseClasses()`, `InAssemblyOf()` | +| Registration | `WithKey(object)`, `Using(RegistrationType)` | +| Environment | `IfEnvironment(params string[])`, `IfNotEnvironment(params string[])`, `IfEnvironmentValue(string)`, `IfEnvironmentValue(string, string)`, `IfNotEnvironmentValue(string)`, `IfNotEnvironmentValue(string, string)` | + +The generator reads these calls when you compile. The methods do not run. + +Guide: [Conventions](../guide/conventions.md). + +## `DependencyModules.Runtime.Interception` + +| Type | Function | +| --- | --- | +| `IInterceptor` | `TResult Intercept(InvocationContext context)` | +| `IAsyncInterceptor` | `ValueTask InterceptAsync(AsyncInvocationContext context)` | +| `IAsyncEnumerableInterceptor` | `IAsyncEnumerable InterceptStream(StreamInvocationContext context)` | +| `InvocationContext` | `Caller`, `Arguments`, `Proceed()` | +| `AsyncInvocationContext` | `Caller`, `Arguments`, `ProceedAsync()` | +| `StreamInvocationContext` | `Caller`, `Arguments`, `Proceed()` | +| `CallerInfo` | `ServiceType`, `MemberName`. `ToString()` gives `Service.Member`. | +| `IArguments` | `Count`, the indexer `this[int]` with get and set, `NameAt(int)` | +| `NoResult` | The result type of a member that has no return value. | + +The `InvocationState` classes are for the generated wrappers. + +Guide: [Interception](../guide/interception.md). + +## `DependencyModules.Runtime.Helpers` + +The generated code uses these types: + +| Type | Function | +| --- | --- | +| `DependencyRegistry` | Keeps the registrations and decorators of module `T`, and loads modules. | +| `DecoratorHelper` | Adds decorators and interceptor wrappers to the service collection. | +| `DecoratorRegistration` | A decorator with its `Order`. | +| `EnvironmentConditions` | Examines the environment conditions. | + +## `DependencyModules.Testing.Attributes.Interfaces` + +| Interface | Function | +| --- | --- | +| `IModuleTestAttribute` | `ModuleTypes`. The `[ModuleTest]` attributes implement it. | +| `ITestMethodContext` | `Method` and `Attributes` of the test that runs. | +| `ITestServiceSetupAttribute` | Adds registrations for a test. | +| `IServiceProviderBuilderAttribute` | Builds the service provider of a test. | +| `ITestStartupAttribute` | Runs code after the service provider is available. | +| `ITestParameterValueProvider` | Registers services for a parameter and gives the value of the parameter. | +| `IInjectValueAttribute` | Gives constructor arguments for a parameter value. | +| `IMockSupportAttribute` | Makes mocks for `[Mock]`. | +| `ISharedTestRegistration` | Selects the shared services. | +| `ITestContainerSource` | `CreateAsync()` builds a new service provider for the test. | +| `IOrderedAttribute` | `Order`, with the default value 10. | + +Guide: [Testing](../guide/testing.md). + +## `DependencyModules.Testing.Impl` + +| Type | Function | +| --- | --- | +| `TestParameterResolver` | Gets the values of the test parameters. The test packages use it. | +| `TestContainerSource` | The `ITestContainerSource` implementation. | +| `SharedRegistrations` | Finds the shared services of a test. | +| `AttributeUtility` | Finds the attributes of a test on the method, the class, and the assembly. | diff --git a/website/reference/attributes.md b/website/reference/attributes.md index d4dbaf0..37a292e 100644 --- a/website/reference/attributes.md +++ b/website/reference/attributes.md @@ -1,119 +1,173 @@ # Attributes -Every attribute this library defines, with its properties โ€” for looking one up once you know what you -are after. If you are working out *which* attribute you want, the guide covers that: -[registering services](/guide/services), [modules](/guide/modules), -[decorators](/guide/decorators) and [environments](/guide/environments). +This page shows all attributes of the packages. For how to use each attribute, refer to the guide pages. -All of them live in `DependencyModules.Runtime.Attributes`. +## Runtime attributes -## Modules +Namespace: `DependencyModules.Runtime.Attributes`. Package: `DependencyModules.Runtime`. ### `[DependencyModule]` -Marks a `partial` class as a module. Generates an attribute of the same name for composition. +Identifies a partial class or a partial record as a module. Targets: class, assembly. -| Property | | -|---|---| -| `OnlyRealm` | the module takes only registrations that named it as their realm | -| `GenerateAttribute` | set `false` to suppress the generated composition attribute | -| `RegisterJsonSerializers` | register discovered `JsonSerializerContext` types | +| Property | Type | Default | Function | +| --- | --- | --- | --- | +| `OnlyRealm` | `bool` | `false` | If the value is `true`, the module registers only the services that set `Realm` to this module and the classes that its conventions select. | +| `Using` | `RegistrationType` | The MSBuild property or `Add` | The registration type for the services of the module. | +| `GenerateAttribute` | `bool` | `true` | If the value is `false`, the generator does not write a module attribute. | +| `GenerateUseMethod` | `string?` | `null` | The name of an `IServiceCollection` extension method that the generator writes for the module. | +| `GenerateFactories` | `bool` | The MSBuild property or `false` | If the value is `true`, the registrations of the module use generated factories. | +| `RegisterJsonSerializers` | `bool` | The MSBuild property or `false` | If the value is `true`, the module registers the classes that have `[JsonSourceGenerationOptions]` as `IJsonTypeInfoResolver`. | -### `[Decorate(service, decorator)]` +If the module does not set `Using`, `GenerateFactories`, or `RegisterJsonSerializers`, the generator uses the applicable MSBuild property. For the properties, refer to [MSBuild properties](./msbuild.md). -Declares a decorator on the module rather than on the decorator class โ€” for when the service, the -decorator or both come from an assembly you do not control. +Guide: [Modules](../guide/modules.md). -| Property | | -|---|---| -| `Order` | nesting; lower sits closer to the implementation | +### `[SingletonService]`, `[ScopedService]`, `[TransientService]` -## Services +Registers a class, or the return value of a static method, with the lifetime in the name. Targets: class, method. You can put more than one on a class. -### `[SingletonService]` ยท `[ScopedService]` ยท `[TransientService]` +| Property | Type | Default | Function | +| --- | --- | --- | --- | +| `As` | `Type?` | `null` | The service type. Without `As`, the generator selects the service type. | +| `Key` | `object?` | `null` | The key of a keyed registration. | +| `Using` | `RegistrationType` | Not set | The registration type. If it is not set, the generator uses the value of the module, then the MSBuild property, then `Add`. | +| `Realm` | `Type?` | `null` | The module that registers the service. | +| `Order` | `int` | `0` | The position of the registration in the module. | -Registers the class, or a static factory method, with that lifetime. - -| Property | | -|---|---| -| `As` | the service type to register as | -| `Key` | a service key | -| `Using` | `Add`, `Try`, `TryEnumerable` or `Replace` | -| `Realm` | scope the registration to one module | -| `Order` | where this registration sits among the others for the same service, lowest first | - -`Order` decides the sequence an `IEnumerable` dependency arrives in, which is what a pipeline of -validators or handlers reads. It decides a plain `GetService()` too, since the container returns -the last registration โ€” so the highest order wins that. Everything is `0` by default and the sort is -stable within one order, so naming an order for some services leaves the rest where they were. +Guide: [Services](../guide/services.md). ### `[CrossWireService]` -Registers the implementation **and** every interface it declares, sharing one instance. +Registers a class as each interface that it declares and as the class type. All the registrations use the instance of the class registration. If the class declares no interface, the generator registers only the class type. Targets: class, method. On a static method, the generator registers the return type and each interface that the return type declares. -Takes the same properties, plus `Lifetime`. +| Property | Type | Default | Function | +| --- | --- | --- | --- | +| `Lifetime` | `ServiceLifetime` | `Singleton` | The lifetime of the registrations. | +| `Key` | `object?` | `null` | The key of all the registrations. | +| `Using` | `RegistrationType` | Not set | The registration type of all the registrations. If it is not set, the generator uses the value of the module, then the MSBuild property, then `Add`. | +| `Realm` | `Type?` | `null` | The module that registers the service. | -## Decoration and interception +Guide: [Cross-wired services](../guide/services.md#cross-wired-services). ### `[Decorator]` -Marks a class as a decorator of the interface it implements. The first constructor parameter is the -wrapped instance; the rest are resolved from the container. +Identifies a class as a decorator. Targets: class. + +| Property | Type | Default | Function | +| --- | --- | --- | --- | +| `Service` | `Type?` | `null` | The service type to decorate. Without `Service`, the generator uses the first constructor parameter with a type that the class implements, also through a base class. | +| `Order` | `int` | `0` | The position of the decorator. If the value of decorator A is less than the value of decorator B, B is the outer decorator. | +| `Realm` | `Type?` | `null` | The module that uses the decorator. | +| `Implementation` | `Type?` | `null` | The implementation to decorate. Without `Implementation`, the decorator changes all registrations of the service type. | + +Guide: [Decorators](../guide/decorators.md). + +### `[Decorate]` + +Adds a decorator from a module. Targets: class (the module class). You can put more than one on a module. + +| Constructor parameter or property | Type | Function | +| --- | --- | --- | +| `service` | `Type` | The service type to decorate. | +| `decorator` | `Type` | The decorator type. | +| `Order` | `int` | The position of the decorator. The default is 0. | + +Guide: [Decorate from a module](../guide/decorators.md#decorate-from-a-module). + +### `[Intercept]` + +Adds interceptors to a class. Targets: class. You can put more than one on a class. + +| Constructor parameter or property | Type | Default | Function | +| --- | --- | --- | --- | +| `interceptors` | `params Type[]` | Not applicable | The interceptor types. The first type gets the call first. | +| `Service` | `Type?` | `null` | The interface to intercept. | +| `Order` | `int` | `0` | The position in the decorator sequence. | +| `Realm` | `Type?` | `null` | The module that uses the interception. | +| `Lifetime` | `ServiceLifetime` | `Singleton` | The lifetime of the interceptor registrations. | +| `Members` | `InterceptedMembers` | `All` | The members to intercept. | + +Guide: [Interception](../guide/interception.md). + +### Environment attributes + +Targets: class. Put them on a service class, on a `[Decorator]` class, or on a decorator that `[Decorate]` adds. You can also put them on a class that a convention selects, also in a referenced assembly. + +| Attribute | Constructor | More than one on a class | +| --- | --- | --- | +| `[IfEnvironment]` | `(params string[] environmentNames)` | No | +| `[IfNotEnvironment]` | `(params string[] environmentNames)` | No | +| `[IfEnvironmentValue]` | `(string key)` or `(string key, string value)` | Yes | +| `[IfNotEnvironmentValue]` | `(string key)` or `(string key, string value)` | Yes | + +Guide: [Environments](../guide/environments.md). + +### Enums + +`RegistrationType`: + +| Value | Generated call | +| --- | --- | +| `Add` | `AddSingleton`, `AddScoped`, `AddTransient`, or the keyed methods. | +| `Try` | `TryAddSingleton`, `TryAddScoped`, `TryAddTransient`, or the keyed methods. | +| `TryEnumerable` | `TryAddEnumerable` | +| `Replace` | `Replace` | + +`InterceptedMembers` is a flags enum: `Methods = 1`, `Properties = 2`, `Indexers = 4`, `Events = 8`, `All = 15`. -A `[Decorator]` is never a convention candidate โ€” it is not a service. +## The generated module attribute -| Property | | -|---|---| -| `Order` | nesting; lower sits closer to the implementation | -| `Service` | the decorated interface, when it cannot be inferred | -| `Realm` | restrict the decorator to one module, matching `Realm` on the service attributes | -| `Implementation` | decorate one implementation rather than every registration of the service | +For each module, the generator writes an attribute with the name of the module and the suffix `Attribute`. The attribute is in the namespace of the module. -An unrestricted decorator belongs to every module that is not `OnlyRealm`, exactly as an unrestricted -service registration does โ€” so a decorator with no `Realm` is not picked up by an `OnlyRealm` module. +- Targets: class, assembly, method, parameter. You can put more than one on an item. +- The constructor has the constructor parameters of the module. +- The properties are the `public`, `internal`, and `protected internal` properties of the module that have a `set` accessor. The `set` accessor must also be `public`, `internal`, or `protected internal`. The properties of a nested class are not included. +- The attribute implements `IDependencyModuleProvider`. -### `[Intercept(params Type[])]` +Guide: [Module dependencies](../guide/modules.md#module-dependencies). -Wraps a service so every call through its interface passes through the given interceptors, in order. +## Test attributes -| Property | | -|---|---| -| `Service` | the interface to intercept, when the service implements more than one | -| `Order` | nesting relative to decorators and other interceptors | -| `Realm` | restrict the interception to one module, matching `Realm` on the service attributes | -| `Lifetime` | how the interceptors are registered. `Singleton` by default | -| `Members` | which kinds of member to cover. Everything by default | +Namespace: `DependencyModules.Testing.Attributes`. Package: `DependencyModules.Testing`. -An interception applies to **the one implementation it was declared on**, not to every class behind -the interface โ€” a sibling implementation carrying no `[Intercept]` is left alone. Decorators are the -other way round, and deliberately so. +| Attribute | Targets | Function | +| --- | --- | --- | +| `[Mock]` | Parameter | Registers a mock for the parameter type and gives it to the parameter. | +| `[InjectValues(params object[] value)]` | Parameter | Gives constructor arguments when the test package makes the parameter value. | +| `[Shared]` | Parameter | Identifies the parameter as shared. The test package shares the parameters by default. It does not share `IServiceProvider` parameters or the types from `IsolatedServices`. | +| `[TestExport(Type service)]` | Method, class, assembly | Registers a service for the test. More than one is permitted. | -With no `Realm` the interception takes the one its own class's service attribute names, so -`[SingletonService(Realm = typeof(X))]` and a plain `[Intercept]` agree without being told to. Naming -a realm explicitly still wins. An interception no module ends up applying is -[DM0020](/reference/diagnostics#dm0020). +`[TestExport]` properties: -`Members` takes `InterceptedMembers.Methods`, `.Properties`, `.Indexers`, `.Events` or any -combination. A member left out is still forwarded โ€” the wrapper implements the whole interface either -way โ€” it just does not run through the chain. See [Interception](/guide/interception). +| Property | Type | Default | Function | +| --- | --- | --- | --- | +| `Implementation` | `Type?` | The service type | The class that the registration makes. | +| `Lifetime` | `ServiceLifetime` | `Transient` | The lifetime of the registration. | +| `Shared` | `bool` | `false` | If the value is `true`, the service providers from `ITestContainerSource` give the same instance. | -## Environment conditions +Guide: [Testing](../guide/testing.md). -All four take effect on a service registered by attribute or by convention, and on a -[`[Decorator]`](/guide/decorators#decorating-only-in-some-environments) โ€” where a condition that does -not hold means the decorator is never applied. Conditions of different kinds combine with **and**. +## Test framework attributes -The same tests are available on a [convention](/reference/conventions-api#environment-conditions) -itself, as `IfEnvironment(โ€ฆ)` and friends. +| Attribute | Namespace | Targets | Constructor | +| --- | --- | --- | --- | +| `[ModuleTest]` (xUnit) | `DependencyModules.xUnit.Attributes` | Method | `()`, `(Type module)`, `(params Type[] modules)` | +| `[ModuleTest]` (NUnit) | `DependencyModules.NUnit.Attributes` | Method | `(params Type[] modules)` | +| `[ModuleTestCase]` (NUnit) | `DependencyModules.NUnit.Attributes` | Method | `(params object?[] arguments)` | -### `[IfEnvironment(params string[])]` ยท `[IfNotEnvironment(params string[])]` +`[ModuleTestCase]` has the `TestName` property. You can put more than one `[ModuleTestCase]` on a method. -Compares the environment name, case-insensitively. +Guide: [xUnit](../guide/testing-xunit.md), [NUnit](../guide/testing-nunit.md). -### `[IfEnvironmentValue(key)]` ยท `[IfEnvironmentValue(key, value)]` +## Mock support attributes -Presence of the key, or an exact ordinal match on its value. `AllowMultiple`. +Targets: method, class, assembly. -### `[IfNotEnvironmentValue(โ€ฆ)]` +| Attribute | Namespace | +| --- | --- | +| `[NSubstituteSupport]` | `DependencyModules.NSubstitute` | +| `[MoqSupport]` | `DependencyModules.Moq` | +| `[FakeItEasySupport]` | `DependencyModules.FakeItEasy` | -The inverse of either form. +Guide: [Mocks](../guide/testing-mocking.md). diff --git a/website/reference/conventions-api.md b/website/reference/conventions-api.md deleted file mode 100644 index 4ea449f..0000000 --- a/website/reference/conventions-api.md +++ /dev/null @@ -1,81 +0,0 @@ -# Convention API - -Every call available inside a `Conventions` body. See [Conventions](/guide/conventions) for how they -fit together. - -```csharp -[DependencyModule] -public partial class DataModule : IConventionModule -{ - void IConventionModule.Conventions(IConventionDefinitions conventions) - { - conventions.RegisterAll().InNamespaceOf().AsScoped(); - } -} -``` - -## Starting a convention - -| Call | Selects | -|---|---| -| `RegisterAll()` | types assignable to `TService` | -| `RegisterAll(Type serviceType)` | the same, for an open generic โ€” `typeof(IHandler<,>)` | -| `RegisterAll()` | nothing by assignability; requires a filter and a shape | - -## Filters - -Chained calls, combined with **and**. Inclusions of the same kind combine with **or**; exclusions are -applied afterwards and any one removes a match. - -| Call | | -|---|---| -| `InNamespaceOf()` | the marker's namespace and those beneath it | -| `InNamespaces(params string[])` | those namespaces and those beneath them | -| `InExactNamespaces(params string[])` | only those namespaces | -| `NotInNamespaceOf()` | excludes that namespace and those beneath it | -| `NotInNamespaces(params string[])` | excludes those namespaces | -| `WithAttribute()` | types carrying the attribute | -| `WithoutAttribute()` | types not carrying it | -| `WithName(params string[])` | name globs โ€” `*` and `?` | -| `WithoutName(params string[])` | excludes matching names | -| `IncludeBaseClasses()` | also match types reaching the service through a base class | -| `InAssemblyOf()` | scan the marker's assembly instead of this project | - -## Shape - -| Call | Registers each match as | -|---|---| -| *(default)* | the service type the convention matched | -| `AsSelf()` | its own concrete type, instead of the interface | -| `AlsoAsSelf()` | the matched service type **and** the concrete type, sharing one instance | -| `AsSelfWithInterfaces()` | the concrete type and every interface it implements, sharing one instance | -| `AsMatchingInterface()` | the interface named after it โ€” `Foo` as `IFoo` | -| `As()` | one named service type | - -## Lifetime and strategy - -| Call | | -|---|---| -| `AsSingleton()` ยท `AsScoped()` ยท `AsTransient()` | required; there is no default | -| `Using(RegistrationType)` | `Add`, `Try`, `TryEnumerable` or `Replace` | -| `WithKey(object)` | a service key โ€” literal, `const` or enum member | - -## Environment conditions - -Gate every match on the [environment](/guide/environments). Named after the attributes, and combined -with **and** against any condition on a matched class. - -| Call | Registers when | -|---|---| -| `IfEnvironment(params string[])` | the environment name matches any of them | -| `IfNotEnvironment(params string[])` | it matches none of them | -| `IfEnvironmentValue(string key)` | the environment has any value for the key | -| `IfEnvironmentValue(string key, string value)` | the value equals exactly, compared ordinally | -| `IfNotEnvironmentValue(string key)` ยท `IfNotEnvironmentValue(string key, string value)` | the inverse of either form | - -## What is not offered - -Anything taking a lambda โ€” a predicate over types, or a lifetime chosen per type. The declaration is -read at compile time rather than run. - -Use `IServiceCollectionConfiguration.ConfigureServices` for those. diff --git a/website/reference/diagnostics.md b/website/reference/diagnostics.md index fb4e3cd..6838f5d 100644 --- a/website/reference/diagnostics.md +++ b/website/reference/diagnostics.md @@ -1,480 +1,267 @@ # Diagnostics -The generator reports what it can work out at build time as `DM####` codes, so a registration mistake -shows up in the IDE rather than as a resolution failure at startup. This page says what each one -means and what to do about it. - -These are reported by a source generator rather than by an analyzer, and every way of silencing one -works. Across a project: - -```xml - - $(NoWarn);DM0005 - $(WarningsAsErrors);DM0013 - -``` +The generator gives these diagnostics when you compile. Their category is `DependencyModules`. -```ini -# .editorconfig โ€” including the Error-severity codes -[*.cs] -dotnet_diagnostic.DM0019.severity = none -``` +| ID | Severity | Title | +| --- | --- | --- | +| [DM0001](#dm0001) | Error | DependencyModules generator failed | +| [DM0002](#dm0002) | Warning | Service type cannot be constructed | +| [DM0003](#dm0003) | Error | Dependency module must be partial | +| [DM0004](#dm0004) | Error | Convention match is ambiguous | +| [DM0005](#dm0005) | Warning | Convention matched no types | +| [DM0006](#dm0006) | Warning | Convention matched a type that cannot be constructed | +| [DM0007](#dm0007) | Error | Decorator order is ambiguous | +| [DM0008](#dm0008) | Warning | Service cannot be intercepted | +| [DM0009](#dm0009) | Error | Convention declaration cannot be read | +| [DM0010](#dm0010) | Info | Service is registered by convention | +| [DM0011](#dm0011) | Info | Service is registered conditionally | +| [DM0012](#dm0012) | Warning | Environment condition tests nothing | +| [DM0013](#dm0013) | Warning | Open generic registration cannot be decorated | +| [DM0014](#dm0014) | Warning | Generic type cannot be cross-wired | +| [DM0015](#dm0015) | Warning | Interceptor does not apply to every member | +| [DM0016](#dm0016) | Warning | Assembly-level module attribute needs its namespace imported | +| [DM0017](#dm0017) | Error | Dependency module cannot be nested inside another type | +| [DM0018](#dm0018) | Warning | Module with properties relies on generated equality | +| [DM0019](#dm0019) | Error | Assembly-level module attribute is not composed | +| [DM0020](#dm0020) | Warning | Interception is applied by no module | +| [DM0021](#dm0021) | Warning | [Mock] and [TestExport] name one service on the same method | +| [DM0022](#dm0022) | Removed | Decorator names an implementation while factories are generated | +| [DM0023](#dm0023) | Warning | Factory method cannot be called | +| [DM0024](#dm0024) | Warning | Cross-wired class declares no interface | +| [DM0025](#dm0025) | Warning | Decorator is not applied | -Or at one site, which is usually what you want: +The table shows the title of each diagnostic. The compiler shows a message with more information, for example the names of the types. -```csharp -#pragma warning disable DM0018 -[DependencyModule] -public partial class CacheModule { public int SizeLimit { get; set; } } -#pragma warning restore DM0018 -``` +You can change the severity of a diagnostic in an `.editorconfig` file. For example, you can change DM0010 from Info to Warning. You can also suppress a diagnostic with `#pragma warning disable` or with the `NoWarn` MSBuild property. -::: warning `.editorconfig` and `#pragma` did not work before 1.2.0 -For most codes, in 1.0.0 and 1.1.0, only `NoWarn` and `WarningsAsErrors` had any effect. A diagnostic -carried its file and line but was not attached to the syntax tree, and Roslyn decides both -`.editorconfig` severity and `#pragma` filtering from the tree. +Some diagnostics have no location in a source file. For these diagnostics, use `NoWarn` or a `.globalconfig` file. `#pragma` and the file sections of `.editorconfig` have no effect on them. These diagnostics have no location: -The 1.1.0 release notes said `.editorconfig` worked. It worked for `DM0016` and `DM0019` and nothing -else โ€” and that release is what broke it, by moving ten diagnostics from the project onto the -declaration they are about. Both mechanisms reach every code from 1.2.0. -::: +- DM0001 +- DM0008 for a service type without members +- DM0007 and DM0013 for a decorator from `[Decorate]` -**Raising a severity is the part `.editorconfig` cannot do.** A generator's diagnostics reach the -compilation with their severity already fixed, and Roslyn's severity *mapping* applies to analyzer -diagnostics โ€” so `dotnet_diagnostic.DM0010.severity = warning` will not promote an informational code -into the build. `WarningsAsErrors` promotes a warning to an error; nothing promotes an `Info`. +Info diagnostics show in the IDE and in a SARIF log. The `dotnet build` output does not show them at the usual verbosity. -`DM0010` and `DM0011` are informational and exist to make a registration visible at the class. They -appear in the IDE, and in `dotnet build` only at `-v detailed` or higher โ€” not at the default -verbosity. The rest are worth reading. +## DM0001 -| Code | Severity | Meaning | -|---|---|---| -| [DM0001](#dm0001) | Error | The generator failed | -| [DM0002](#dm0002) | Warning | A service type cannot be constructed | -| [DM0003](#dm0003) | Error | A module is not `partial` | -| [DM0004](#dm0004) | Error | Two conventions register a type as the same service type | -| [DM0005](#dm0005) | Warning | A convention matched nothing | -| [DM0006](#dm0006) | Warning | A convention matched a type with no accessible constructor | -| [DM0007](#dm0007) | Error | Two decorators of one service share an order | -| [DM0008](#dm0008) | Warning | A service marked for interception cannot be wrapped | -| [DM0009](#dm0009) | Error | A convention declaration could not be read | -| [DM0010](#dm0010) | Info | A service is registered by convention | -| [DM0011](#dm0011) | Info | A service is registered only when a condition holds | -| [DM0012](#dm0012) | Warning | An environment condition names nothing to test | -| [DM0013](#dm0013) | Warning | A service registered as an open generic cannot be decorated | -| [DM0014](#dm0014) | Warning | A generic type cannot be cross-wired | -| [DM0015](#dm0015) | Warning | An interceptor does not apply to every member | -| [DM0016](#dm0016) | Warning | An assembly-level module attribute's namespace is not imported | -| [DM0017](#dm0017) | Error | A module is declared inside another type | -| [DM0018](#dm0018) | Warning | A module with parameters relies on generated equality | -| [DM0019](#dm0019) | Error | An assembly-level module attribute is outside the entry point file | -| [DM0020](#dm0020) | Warning | An interception is applied by no module | -| [DM0021](#dm0021) | Warning | `[Mock]` and `[TestExport]` name one service on the same method | -| [DM0022](#dm0022) | Warning | A decorator names an implementation while factories are generated | +An exception occurred in the generator. Some registrations can be missing. The message gives the type and the message of the exception. -## DM0001 {#dm0001} +If the exception occurred for one module, the generator writes the code of the other modules. The log files also record the exception. -**The generator failed; registrations may be missing.** +To correct the problem: -Please [open an issue](https://github.com/ipjohnson/DependencyModules/issues) with the generator log -โ€” see [Troubleshooting](/guide/troubleshooting). +1. Set the `DependencyModules_LogOutputDirectory` MSBuild property to a folder. +2. Build the project again. +3. Write an issue for the problem on the [issues page](https://github.com/ipjohnson/DependencyModules/issues) of the repository. +4. Attach the log files to the issue. -## DM0002 {#dm0002} +## DM0002 -**A service type cannot be constructed and was not registered.** +A class with a service attribute is abstract or static. The generator cannot make an instance of it and does not register it. -The implementation is abstract or a static class, so the container could not construct it. +To correct the problem, put the attribute on a class that is not abstract. You can also register the service with a static factory method. -## DM0003 {#dm0003} +## DM0003 -**A module marked with `[DependencyModule]` is not partial.** +A class with `[DependencyModule]` is not partial. The generator writes no code for the module. -The generator completes the module's partial declaration. Without `partial` there is nothing to -complete. +To correct the problem, add the `partial` modifier to the class. -## DM0004 {#dm0004} +## DM0004 -**Two conventions in one module register a type as the same service type.** +Two conventions in one module register the same class as the same service type. The generator does not write these registrations. -The lifetime would be ambiguous. +To correct the problem, add a filter to one convention. You can also move one convention to a different module. -```csharp -conventions.RegisterAll().AsScoped(); -conventions.RegisterAll().AsSingleton(); // DM0004 -``` +## DM0005 -Equal lifetimes are an error too โ€” the declaration is redundant. +A convention selects no classes. The message gives a possible cause: -A type filling two *different* roles is not ambiguous and registers as both. Conventions in different -modules never collide; each registers into its own realm. +- The service type is a class. A convention selects only by interfaces. +- Only a base class of the classes implements the service type. +- The filters remove all classes. -## DM0005 {#dm0005} +To correct the problem: -**A convention matched no types.** +- If the service type is a class, use an interface as the service type. You can also register the classes with attributes. +- If only a base class implements the service type, call `IncludeBaseClasses()`. +- If the filters remove all classes, examine the name, namespace, and assembly filters. -Almost always a renamed interface or a typo in a filter. +## DM0006 -## DM0006 {#dm0006} +A convention selects a class that has no constructor that its registration can use. The generator does not register the class. -**A convention matched a type with no accessible constructor.** +The service provider uses only `public` constructors. Thus the generator gives DM0006 if the class has no `public` constructor. A class from a referenced assembly must also have a `public` constructor. -The container could not construct it. +If the module uses [generated factories](../guide/aot.md#generated-factories), the generated code calls the constructor. Then an `internal` or `protected internal` constructor is also correct. This is not true for a class with `[Intercept]`, because the generator does not write a factory for it. The message tells which constructors are correct. -## DM0007 {#dm0007} +To correct the problem, add a `public` constructor to the class. You can also add a filter that removes the class. -**Two decorators of one service share an order.** +## DM0007 -Their nesting would be ambiguous. See [Decorators](/guide/decorators#ordering). +Two decorators in one module have the same service type and the same `Order` value. The sequence of these decorators is not known. -## DM0008 {#dm0008} +To correct the problem, give the decorators different `Order` values. -**A service marked for interception cannot be wrapped.** +## DM0008 -The member uses `ref`, `in`, `out` or a `ref struct` parameter, returns by reference, has an -`init`-only setter, or is static. +The generator cannot intercept a service. It writes no wrapper. Thus, the interceptors do not intercept the members of the service. The message gives the cause: -One such member costs the whole interface: no wrapper is generated, so every other member goes -uninterceped too. The message names the first offender it found. See -[Interception](/guide/interception#what-cannot-be-intercepted). +- The service type has a member that a wrapper cannot send to the service. For the list, refer to [Members that the generator cannot intercept](../guide/interception.md#members-that-the-generator-cannot-intercept). The message gives the name of one such member. +- The class implements no interface. +- The class implements more than one interface, and `[Intercept]` does not set `Service`. +- The class does not implement the `Service` type. +- The service type has no members. -## DM0009 {#dm0009} +To correct the problem, set `Service`. You can also move the member to an interface that is not intercepted. You can also use a decorator. -**A convention declaration could not be read.** +## DM0009 -The `Conventions` body is read at compile time, so only the documented calls can appear in it โ€” a -loop, a conditional, a local or a call to your own helper cannot. +The generator cannot read a convention statement. The message gives the cause and the statement. These are the causes: -It also covers a convention with no lifetime, a `RegisterAll()` with no shape or no filter, and a -chain that could not be resolved. +- The `Conventions` method has no statement body, or a statement is not a chain of calls on the parameter. +- A chain does not start with `RegisterAll`. +- A call is not a convention call. +- An argument is not a value that the compiler knows. The message gives the argument. +- The convention has no lifetime or more than one lifetime. +- The convention has more than one of `AsSelf()`, `AsSelfWithInterfaces()`, and `AlsoAsSelf()`. +- `RegisterAll()` without a service type has no registration shape or no filter that selects. +- The type that implements `IConventionModule` does not have `[DependencyModule]`. The generator gives this cause only if the project has one or more modules. -## DM0010 {#dm0010} +To correct the problem, change the statement. For the conditions, refer to [Conventions](../guide/conventions.md). -**A service is registered by convention.** +## DM0010 -Informational, reported at the class, naming the service type it was registered as and the interface -the match came through when it was not direct. +The generator registered a class from a convention. The message shows the service type and the module. If the convention selected the class from a different interface or a base class, the message also shows that type. -A match from a [referenced assembly](/guide/scanning) has no class to point at, so it reports at the -`RegisterAll` line instead. +For a class from a referenced assembly, the diagnostic is at the convention statement. -## DM0011 {#dm0011} +This diagnostic gives information only. -**A service is registered only when an environment condition holds.** +## DM0011 -Informational, reported at the class. See [Environments](/guide/environments). +A service has environment conditions. The message shows the conditions. The generator gives DM0011 for each class with a service attribute and conditions, also when the conditions are true. Registrations from conventions do not get DM0011. -## DM0012 {#dm0012} +This diagnostic gives information only. -**An environment condition names nothing to test.** +## DM0012 -`[IfEnvironment()]` and `[IfEnvironmentValue("")]` both compile, and neither does anything. A -condition with nothing to test cannot be false, so the generated guard is `if (true)` and the service -registers unconditionally โ€” written plain or written as the `IfNot` form. The attribute reads as a -condition and is not one, which is what the diagnostic is for. +An environment condition has no environment name or no key. Thus it does not examine the environment. The generator ignores the condition. The generator ignores arguments that are not string constants. Thus a condition with only such arguments, for example an array, also gives DM0012. -## DM0013 {#dm0013} +The generator gives DM0012 for these classes: -**A service registered as an open generic cannot be decorated.** +- A class with a service attribute +- A `[Decorator]` class +- A decorator that `[Decorate]` adds. The diagnostic is at the module. +- A class that a convention selects. For a class from a referenced assembly, the diagnostic is at the convention statement. -Decoration replaces a registration with a factory, and the container does not allow one for an open -generic service type โ€” `Open generic service type 'IRepository`1[T]' requires registering an open -generic implementation type`. +On a convention statement, a condition call without a name or a key gives DM0009. -```csharp -[SingletonService] -public class Repository : IRepository { } // registers IRepository<> itself +To correct the problem, give a name or a key. You can also remove the attribute. -[Decorator] -public class CachingRepository(IRepository inner) : IRepository { } // DM0013 -``` +## DM0013 -Reported whichever way the decorator was declared โ€” on the class, or on the module with -`[Decorate]` โ€” and whether or not the decorator is itself generic. +The service type of a decorator is a type that the project registers only as an open generic type, for example `IRepository<>`. The service provider cannot decorate an open generic registration. If the project also contains closed registrations of the service type, the generator decorates them and gives no diagnostic. -Register closed constructions instead. A [convention](/guide/conventions) over the open generic -registers one per implementation, and an open generic decorator is expanded across them. See -[Decorators](/guide/decorators#one-limitation). +`[Decorate]` with an open generic service type and a decorator that is not generic also gives DM0013. -## DM0014 {#dm0014} +To correct the problem, register closed types of the service. For example, declare classes that are not generic, such as `OrderRepository : IRepository`. The generator then uses a generic decorator for each closed type. For `[Decorate]` with a decorator that is not generic, give a closed service type, for example `typeof(IRepository)`. -**A generic type cannot be cross-wired.** +## DM0014 -`[CrossWireService]` shares one instance across the implementation and every interface it declares, -which is emitted as a factory per interface โ€” and an open generic registration cannot carry one. +The generator cannot cross-wire a generic class. It gives DM0014 in these conditions, and it does not register the class: -```csharp -[CrossWireService] -public class Ledger : ILedger, IAudit { } // DM0014 -``` +- `[CrossWireService]` is on a generic class. +- A convention with `AlsoAsSelf()` or `AsSelfWithInterfaces()` selects a generic class. -Registering each interface to the same open generic implementation type would compile, and is a -different contract: the container builds one instance per service type, which is the opposite of what -the attribute promises. +To correct the problem for `[CrossWireService]`, use `[SingletonService]`, `[ScopedService]`, or `[TransientService]`. To register the class as more than one interface, use one attribute for each interface. -Use `[SingletonService]`, `[ScopedService]` or `[TransientService]` instead, applying one per -interface if the type needs to answer to more than one. +To correct the problem for a convention, remove `AlsoAsSelf()` or `AsSelfWithInterfaces()`. You can also select the generic classes with a different convention. -## DM0015 {#dm0015} +## DM0015 -**An interceptor does not apply to every member it was applied to.** +An interceptor does not implement the interface for some members of the service. These members run without the interceptor. The message gives the names of the members. -Three interfaces cover the member shapes, and the generator picks per member: +To correct the problem, implement the missing interface on the interceptor. For example, implement `IAsyncInterceptor` for methods that have the return type `Task`. You can also use the interceptor for a service without such members. -| Interface | Members | -|---|---| -| `IInterceptor` | returning a value directly, or `void` | -| `IAsyncInterceptor` | returning `Task`, `Task`, `ValueTask`, `ValueTask` | -| `IAsyncEnumerableInterceptor` | returning `IAsyncEnumerable` | +## DM0016 -An interceptor that implements none of the one a member needs is left out of that member's chain, and -those calls run without it: +An assembly-level module attribute is in a file that has no `using` directive for the namespace of the module. The code does not compile. A `global using` directive in a different file is also a `using` directive for this check. -```csharp -public class AuditInterceptor : IInterceptor { โ€ฆ } // sync only +The generator examines only attributes without their namespace. It examines them only if the project has a module or a generated `ApplicationModule`. -[SingletonService] -[Intercept(typeof(AuditInterceptor))] -public class Orders : IOrders -{ - public int Count(string customer) { โ€ฆ } // audited - public Task CountAsync(string customer) { โ€ฆ } // DM0015 โ€” not audited -} -``` +To correct the problem, add the `using` directive. You can also write the full name, for example `[assembly: Catalog.CatalogModule]`. -This matters more than it first reads. An interceptor that rewrites arguments stops rewriting them; -one that authorises or audits stops doing that, on exactly the members most likely to be the -interesting ones. In the sharpest case โ€” an `IInterceptor` applied to a service whose members are all -async โ€” it never runs at all. +## DM0017 -Implement the missing interface on the interceptor, or apply it to a service with no such member. +A class with `[DependencyModule]` is in a different class. The generator can write the other part of a module only at the namespace level. It does not write the other part of this module. -Reported once per interceptor and member shape, so a wide interface produces one line rather than -one per member. See [Interception](/guide/interception). +To correct the problem, move the module to the namespace level. -## DM0016 {#dm0016} +## DM0018 -**An assembly-level module attribute's namespace is not imported.** +A module has properties that the generator puts on the module attribute, but the module does not declare `Equals`. The generated `Equals` method compares only the module type. Thus, two instances with different values are one module. Only the first instance loads. -A module generates its attribute in the module's own namespace, and an assembly-level attribute has -no namespace context to inherit โ€” a `using` written inside a namespace declaration cannot apply to -it, because assembly attributes precede every namespace in the file. +The generator examines all partial declarations of the module. If the module declares `Equals` for its own type, for example `IEquatable.Equals(T)`, the generator gives no DM0018. The generated `Equals(object)` then calls that method. -```csharp -// Bootstrap.cs -using DependencyModules.NSubstitute; +To correct the problem, declare `Equals` and `GetHashCode` on the module. If each module of this type loads only one time, you can also suppress the warning with `NoWarn` or `.editorconfig`. -[assembly: ApplicationModule] // DM0016 โ€” nothing brings MyApp.Composition into scope -[assembly: NSubstituteSupport] -``` +## DM0019 -Left alone this is `CS0246: The type or namespace name 'ApplicationModuleAttribute' could not be -found` โ€” a type you never wrote, generated into a namespace the error does not name. Every part of -that message points away from the fix, which is one line: +An assembly-level module attribute is in a file that the generator does not use for `ApplicationModule`. The generator reads assembly-level module attributes only from `Program.cs`. Thus, it ignores this attribute. The module does not register its services. -```csharp -using MyApp.Composition; // or write it as [assembly: MyApp.Composition.ApplicationModule] -``` +The generator gives DM0019 for an attribute without its namespace and for an attribute with its full name, for example `[assembly: Catalog.CatalogModule]`. It examines the files only if the project has a module or a generated `ApplicationModule`. -A module in a referenced package is covered too: its attribute is already in metadata by the time -this runs, so it can be found there. What cannot be resolved is a module in *this* compilation โ€” -its attribute is written by the generator that is running, so nothing about it exists yet to look up. +To correct the problem, move the attribute to `Program.cs`. You can also load the module with `AddModule`. -Unlike the other diagnostics here this one is read from syntax rather than from the compiler's view -of your code, and it has to be: the attribute is written by the generator that is running, so it does -not exist in the compilation being examined and nothing about it can be resolved. The check is -therefore "is there a module by this name, and could this file see it" โ€” which is why it stays quiet -for an attribute matching no module in the compilation, a module in the global namespace, a usage -already written qualified, and a namespace supplied by a `global using` in any file. +## DM0020 -See [Testing](/guide/testing#stop-repeating-the-module-list) and [Modules](/guide/modules). +A class has `[Intercept]`, but no module in the project uses the interception. Thus the interceptors do not run. This can occur when a realm-only module registers the class from a convention, and `[Intercept]` has no realm. -## DM0017 {#dm0017} +To correct the problem, set `Realm` on `[Intercept]` to the module that registers the class. -**A dependency module cannot be nested inside another type.** +## DM0021 -A module must be declared directly in a namespace. The generator completes it with a second partial -declaration written at namespace level, so a nested one produced a *separate* type of the same name -while the nested declaration never implemented `IDependencyModule` โ€” a green build that registered -nothing. +A test method has `[TestExport]` for a service type and a `[Mock]` parameter of the same type. The mock replaces the `[TestExport]` registration. The generator gives DM0021 only if the test project references `DependencyModules.SourceGenerator`. -```csharp -public static class Outer -{ - [DependencyModule] - public partial class NestedModule; // DM0017 -} -``` +The generator gives no DM0021 for the two exceptions, because the `[TestExport]` registration stays in use: -`AddModule()` would not compile, but `[assembly: NestedModule]` bound to the -detached type's attribute and did, which is why this is reported rather than left to be discovered. -Move the module out to the namespace. Services may be nested freely; the restriction is only on -modules. +- The `[Mock]` parameter has `[FromKeyedServices]`. +- For Moq, the test also has a `Mock` parameter of the same type. -See [Modules](/guide/modules) and [Troubleshooting](/guide/troubleshooting). +For more information, refer to [Mocks and `[TestExport]`](../guide/testing-mocking.md#mocks-and-testexport). -## DM0018 {#dm0018} +To correct the problem, if the `[TestExport]` is a default, move it to the test class or to the assembly. You can also remove the attribute that you do not want. -**A module with parameters relies on generated equality.** +## DM0022 -Modules de-duplicate by type, which is what stops a module reached twice from registering everything -twice. A module carrying parameters is the case that rule does not fit: two instances holding -different values are the same module by it, so the first one reached wins and the other is discarded -silently. +The generator does not give DM0022 now. It gave DM0022 when a decorator set `Implementation` in a module with generated factories. -```csharp -[DependencyModule] -public partial class CacheModule : IServiceCollectionConfiguration -{ - public int SizeLimit { get; set; } // DM0018 - public void ConfigureServices(IServiceCollection services) => - services.AddSingleton(new CacheSettings(SizeLimit)); -} +Each generated factory now returns its class. Thus the decorator changes only the registration of the implementation that it names. For more information, refer to [Decorate one implementation](../guide/decorators.md#decorate-one-implementation). -[DependencyModule] [CacheModule(SizeLimit = 10)] public partial class SmallCacheFeature; -[DependencyModule] [CacheModule(SizeLimit = 999)] public partial class BigCacheFeature; -``` +## DM0023 -Load both features and one `CacheSettings` arrives, not two โ€” whichever was reached first, with no -error and no duplicate to notice. +A static factory method has a service attribute, but the generated code cannot call the method. The generator does not register the method. The message gives the cause: -The generator has to choose an identity for you, and type-only is the choice it makes. Declaring your -own `Equals` and `GetHashCode` suppresses the generated pair and says which you meant. Both answers -are legitimate: +- The method is not `static`. +- The method is `private`, `protected`, or `private protected`. A method without an access modifier is `private`. +- A class that contains the method is `private` or `protected`. -```csharp -// identity is the values: both configurations survive -public override bool Equals(object? obj) => - obj is CacheModule other && other.SizeLimit == SizeLimit; -public override int GetHashCode() => SizeLimit; +To correct the problem, make the method `static`, and `public` or `internal`. The classes that contain the method must not be `private` or `protected`. -// identity is the type: one wins, and that is intended -public override bool Equals(object? obj) => obj is CacheModule; -public override int GetHashCode() => typeof(CacheModule).GetHashCode(); -``` +## DM0024 -Only **settable, non-static** properties count. A read-only property is not a parameter โ€” a module -implementing an interface with `public string Value => "A";` has nothing to configure and is not -reported. +A class has `[CrossWireService]`, but it declares no interface. It gets one or more interfaces from a base class. The generator registers only the class type. It does not cross-wire the interfaces of a base class. -Silence it per project with `NoWarn` or `.editorconfig` if every parameterised module in the codebase -is composed once. +To correct the problem, write the interfaces in the declaration of the class. For example, write `class Store : ReaderBase, IReader`. -See [Modules](/guide/modules#parameters). +## DM0025 -## DM0019 {#dm0019} +A class has `[Decorator]`, but the generator cannot apply the decorator. The generator does not write code for the decorator. The message gives the cause: -**An assembly-level module attribute is outside the entry point file.** - -Assembly-level module attributes are composed into the generated `ApplicationModule`, and that module -is built from one compilation unit โ€” the entry point. Written in any other file the attribute was -read by nobody: a clean build, no diagnostic, and an `InvalidOperationException` at the first resolve. - -```csharp -// Bootstrap.cs โ€” DM0019 -using MyApp.Library; - -[assembly: LibraryModule] -``` - -Move it to the file holding the entry point, or load the module explicitly with -`services.AddModule()`. - -The module can live anywhere โ€” this compilation or a referenced package. Before 1.2.0 only a module -declared in the same project was checked, so the shape above, which is the one worth catching, -reported nothing. - -This stays quiet when nothing generated an `ApplicationModule`. A class library has no entry point, -and neither does a test project โ€” where assembly-level module attributes are read at *run time* by -the test integration and are perfectly at home in a file of their own, which is the shape -[Testing](/guide/testing#stop-repeating-the-module-list) shows. - -## DM0020 {#dm0020} - -**An interception is applied by no module, so it never runs.** - -Registrations and interceptions are placed by the same rule โ€” named a realm, it belongs to that -module; named none, it belongs to every module that is not `OnlyRealm`. The wrapper is generated -either way, so an interception no module applies is a class nothing ever routes through. - -The case that reaches here is a realm-only module registering the class *by convention*: - -```csharp -[Intercept(typeof(AuditInterceptor))] // DM0020 โ€” no realm named -public class Greeter : IGreeter { โ€ฆ } - -[DependencyModule(OnlyRealm = true)] -public partial class GreetingModule : IConventionModule -{ - void IConventionModule.Conventions(IConventionDefinitions conventions) => - conventions.RegisterAll().AsSingleton(); -} -``` - -A convention registration is stamped with its declaring module's realm when the convention is -matched, which is long after the interception was read. So the registration lands in -`GreetingModule` and the interception is offered only to modules that are *not* realm-only โ€” of -which there are none here. - -Name the module and the two meet: - -```csharp -[Intercept(typeof(AuditInterceptor), Realm = typeof(GreetingModule))] -``` - -An interception on a class registered by a *service attribute* needs none of this: it takes the realm -from that attribute automatically, so `[SingletonService(Realm = typeof(X))]` and a plain -`[Intercept]` agree without being told to. See [Interception](/guide/interception#realms). - -## DM0021 {#dm0021} - -**`[Mock]` and `[TestExport]` name one service on the same test method.** - -A parameter attribute names one argument, which is the narrowest thing a test can say, so `[Mock]` -wins and the `[TestExport]` beside it does nothing: - -```csharp -[ModuleTest] -[TestExport(typeof(IClock), Implementation = typeof(SystemClock))] -public void Expires([Mock] IClock clock) { } // DM0021 โ€” the export does nothing -``` - -Only on the same method. `[TestExport]` on the class or the assembly is a default for everything -under it, and one test overriding that for one argument is what having both scopes is for: - -```csharp -[TestExport(typeof(IClock), Implementation = typeof(SystemClock))] // the fixture default -public class ExpiryTests -{ - [ModuleTest] - public void UsesTheRealClock(IClock clock) { } // SystemClock - - [ModuleTest] - public void Expires([Mock] IClock clock) { } // the mock โ€” no diagnostic -} -``` - -See [Mocking frameworks](/guide/testing-mocking#precedence). - -## DM0022 {#dm0022} - -**A decorator names an implementation while factories are generated.** - -Reaching one registration of a service means asking each descriptor what implementation it was built -from, and a factory registration cannot say. `DependencyModules_GenerateFactories` makes every -registration factory-built, so the decorator would wrap *all* of them โ€” the opposite of what naming -one asked for. - -```csharp -[Decorator(Implementation = typeof(SmtpSender))] // DM0022 when factories are on -public class RetryingSender(IEmailSender inner) : IEmailSender { โ€ฆ } -``` - -Turn the property off for this project, or drop `Implementation` and decorate them all. - -An *intercepted* service escapes the property automatically โ€” the interception is declared on the -class being registered, so the code emitting that registration can see it and keeps a `typeof` -registration for it. A decorator is declared on the decorator, so the registration it targets is -written by a pass that never learns about it. See -[MSBuild properties](/reference/msbuild#generatefactories-and-container-validation). +- The class implements no type that one of its constructor parameters has. Thus the generator finds no service type. +- The class has no constructor that the generated code can call. The constructor must be `public`, `internal`, or `protected internal`. +- No constructor parameter has the service type. Thus the decorator cannot get the service. +- The type parameters of a generic decorator are not the type arguments of the service type in the same sequence. +To correct the problem, correct the cause that the message gives. For example, set `Service` on the attribute, or give the class a `public` constructor. diff --git a/website/reference/interfaces.md b/website/reference/interfaces.md deleted file mode 100644 index 4f94fe2..0000000 --- a/website/reference/interfaces.md +++ /dev/null @@ -1,93 +0,0 @@ -# Runtime interfaces - -`DependencyModules.Runtime.Interfaces` holds the contracts the generated code implements and calls. -Most projects never name one โ€” the attributes are the surface you write against โ€” but they turn up in -three places: a compiler error naming a type you did not write, a generated file you opened to see -what happened, and the seam you implement when an attribute cannot express something. - -They are covered by the [semantic versioning promise](https://semver.org/spec/v2.0.0.html): none of -them breaks within 1.x. - -## `IDependencyModule` - -What every module is. The generator implements it on the partial class you declared, so you never -write it yourself โ€” but it is the constraint on `AddModule()`, which is why it shows up in -an error when a module did not get generated: - -``` -error CS0311: The type 'ApplicationModule' cannot be used as type parameter 'T' โ€ฆ no implicit -reference conversion from 'ApplicationModule' to 'IDependencyModule'. -``` - -That message means the generator did not complete your class. The usual causes each have a -diagnostic beside them โ€” the module is not `partial` -([DM0003](/reference/diagnostics#dm0003)), or it is nested inside another type -([DM0017](/reference/diagnostics#dm0017)). - -## `IDependencyModuleProvider` - -What a module's **generated attribute** implements. `[DataModule]` on another module is an instance -of `DataModuleAttribute`, and this is how the runtime gets a module out of it. - -You do not implement this. It is worth knowing because it is what makes a module attribute -recognisable in a referenced package โ€” which is how [DM0016](/reference/diagnostics#dm0016) and -[DM0019](/reference/diagnostics#dm0019) find a module that came from a NuGet package rather than from -your own project. - -## `IServiceCollectionConfiguration` - -The escape hatch for registration an attribute cannot express โ€” `AddHttpClient()`, options binding, -anything that is a method call rather than a class you own. Implement it on a module and the generator -calls it alongside the registrations it wrote: - -```csharp -[DependencyModule] -public partial class ApplicationModule : IServiceCollectionConfiguration -{ - public void ConfigureServices(IServiceCollection services) - { - services.AddHttpClient(); - services.Configure(options => options.SizeLimit = 1024); - } -} -``` - -It is also how you extend a module the generator wrote for you. A project with top-level statements -gets an `ApplicationModule` it did not declare; adding a partial for it **without** -`[DependencyModule]` and implementing this interface merges into that module rather than colliding -with it: - -```csharp -public partial class ApplicationModule : IServiceCollectionConfiguration -{ - public void ConfigureServices(IServiceCollection services) => - services.AddHttpClient(); -} -``` - -See [Modules](/guide/modules#when-attributes-are-not-enough). - -## `IModuleEnvironment` - -What [`[IfEnvironment]`](/guide/environments) is evaluated against. `ModuleEnvironment` implements it, -and `AddModules` takes one: - -```csharp -services.AddModules(new ModuleEnvironment("Development"), new ApplicationModule()); -``` - -Implement it yourself when the environment is not a name from `DOTNET_ENVIRONMENT` โ€” a feature flag -service, or a configuration section. - -## Where they live - -All of them are in `DependencyModules.Runtime`, which is the package you already reference: - -```csharp -using DependencyModules.Runtime.Interfaces; -``` - -The interception contracts โ€” `IInterceptor`, `IAsyncInterceptor`, `IAsyncEnumerableInterceptor` โ€” are -in `DependencyModules.Runtime.Interception` instead, and are covered in -[Interception](/guide/interception). The convention contracts are in -`DependencyModules.Runtime.Conventions`; see the [Convention API](/reference/conventions-api). diff --git a/website/reference/msbuild.md b/website/reference/msbuild.md index ccdc8d0..c14cdef 100644 --- a/website/reference/msbuild.md +++ b/website/reference/msbuild.md @@ -1,77 +1,44 @@ # MSBuild properties -Project-wide settings that change what the generator emits. Set them in a `PropertyGroup` in the -consuming project; they reach the generator through the package's `build/*.targets`, so they work -when the packages are installed from NuGet. - -| Property | Default | | -|---|---|---| -| `DependencyModules_GenerateFactories` | `false` | emit a `new` expression instead of `typeof(T)`, so the container does not construct by reflection โ€” [see the trade-off](#generatefactories-and-container-validation) | -| `DependencyModules_RegistrationType` | `Add` | the default registration strategy for the project | -| `DependencyModules_AutoGenerateModule` | `true` | generate `ApplicationModule` for a top-level `Program.cs` | -| `DependencyModules_RegisterGenerator` | `false` | register discovered `JsonSerializerContext` types | -| `ExcludeGeneratedCodeFromCoverage` | `true` | apply `[ExcludeFromCodeCoverage]` to generated members โ€” note this one carries no `DependencyModules_` prefix | -| `GeneratedCodeStyle` | `Allman` | brace style for the generated files: `Allman` or `KAndR`. Unprefixed on purpose โ€” the name is shared with other source generators, so one line styles all of them. An unrecognized value falls back to `Allman` | -| `DependencyModules_LogOutputDirectory` | *(none)* | write a generator log here โ€” see [Troubleshooting](/guide/troubleshooting) | +Set these properties in the project file, in a `PropertyGroup`. They are applicable to all modules of the project. ```xml - true - $(MSBuildProjectDirectory)/dmlogs + Try + KandR ``` -## Seeing the generated files +| Property | Values | Default | Function | +| --- | --- | --- | --- | +| `DependencyModules_RegistrationType` | `Add`, `Try`, `TryEnumerable`, `Replace` | `Add` | The registration type for services that do not set `Using`. If a module sets `Using`, the generator uses the module value. | +| `DependencyModules_GenerateFactories` | `true`, `false` | `false` | If the value is `true`, the registrations use generated factories. If a module sets `GenerateFactories`, the generator uses the module value. | +| `DependencyModules_AutoGenerateModule` | `true`, `false` | `true` | If the value is `false`, the generator does not write `ApplicationModule`. | +| `DependencyModules_RegisterGenerator` | `true`, `false` | `false` | If the value is `true`, all modules register the classes that have `[JsonSourceGenerationOptions]`. If a module sets `RegisterJsonSerializers`, the generator uses the module value. | +| `DependencyModules_LogOutputDirectory` | A folder | Empty | The folder for the generator log files. If the value is empty, the generator writes no log. | +| `ExcludeGeneratedCodeFromCoverage` | `true`, `false` | `true` | If the value is `true`, the generator puts `[ExcludeFromCodeCoverage]` on the members that it writes. | +| `GeneratedCodeStyle` | `Allman`, `KandR` | `Allman` | The brace style of the generated code. The value `K&R` has the same effect as `KandR`. | -Not a DependencyModules property, but the one you will reach for most: +The values of the Boolean properties and of `GeneratedCodeStyle` are not case-sensitive. The values of `DependencyModules_RegistrationType` are case-sensitive. If the generator does not know the value of `DependencyModules_RegistrationType`, it uses `Add`. If the generator does not know the value of `GeneratedCodeStyle`, it uses `Allman`. -```xml - - true - -``` +If `ExcludeGeneratedCodeFromCoverage` is `true`, the generator puts `[ExcludeFromCodeCoverage]` on each method, constructor, and property that it writes. It does not put the attribute on the module class. Thus the coverage tools measure the members that you write in the module, for example `ConfigureServices`. The members of the generated module attribute and of the `GenerateUseMethod` class also get the attribute. -## `GenerateFactories` and container validation {#generatefactories-and-container-validation} - -Worth knowing before turning this on project-wide. - -What it emits is a **factory** per registration: - -```csharp -services.AddSingleton( - typeof(OrderService), - provider => new OrderService(provider.GetRequiredService())); -``` +An interceptor wrapper is a generated class. Thus it gets the attribute on the class. If the value is `false`, the generated code has no `[ExcludeFromCodeCoverage]`. -`Microsoft.Extensions.DependencyInjection` cannot see inside a factory, so every registration in the -project becomes opaque to its own graph validation. Measured on the same captive dependency โ€” a -singleton taking a scoped service โ€” with only this property differing: +`GeneratedCodeStyle` does not have the `DependencyModules_` prefix. Other source generators can read the same property. -| | `BuildServiceProvider(ValidateScopes + ValidateOnBuild)` | -|---|---| -| unset | throws โ€” `Cannot consume scoped service 'IUnitOfWork' from singleton 'OrderService'` | -| `true` | builds cleanly | +The generator also reads the `RootNamespace` and `ProjectDir` properties of the .NET SDK. `RootNamespace` sets the namespace of `ApplicationModule`. -A missing registration goes the same way: the `GetRequiredService` call inside a factory is not -checked at build either, so it throws on first resolve instead. +## The generator package declares the properties -The property exists for startup cost and for Native AOT, which is exactly the setting a team turns on -late and everywhere. If you rely on `ValidateScopes` and `ValidateOnBuild` in development โ€” and the -standard advice is to โ€” keep this off there and turn it on for the published build. +The compiler gives a property to the generator only if the project declares it as a `CompilerVisibleProperty` item. The `DependencyModules.SourceGenerator` package does this in `build/DependencyModules.SourceGenerator.targets`. If you reference the generator project with a `ProjectReference`, declare the items in your project. For the list, refer to [Troubleshooting](../guide/troubleshooting.md#the-msbuild-properties-have-no-effect). -## `GenerateFactories` and per-implementation wrapping +## The source package -A factory registration cannot say what implementation it built, and that is how -[interception](/guide/interception) finds the one registration to wrap. In 1.1.0 the filter therefore -matched nothing under this property and interception went back to wrapping every registration of the -service type โ€” an unmarked sibling came back inside another class's wrapper, and interceptors ran -once per registration. +The `DependencyModules.SourceGenerator.Impl` package reads one more property: -From 1.2.0 a service any implementation intercepts keeps its `typeof` registration whatever this -property says. It costs those services the property's benefit and nothing else โ€” the wrapper around -them is still emitted as a literal `new`, and `typeof` is the shape every registration has with the -property off, which is what Native AOT already runs. Nothing to configure. +| Property | Values | Default | Function | +| --- | --- | --- | --- | +| `PackageDependencyModuleIncludeSource` | `true`, `false` | Empty | If the value is `true`, the project compiles the source code of the package. The package also contains the source code of `CSharpAuthor`, which the project then also compiles. | -The same limit reaches `[Decorator(Implementation = โ€ฆ)]`, and there it cannot be worked around the -same way: the decorator is declared on the decorator, so the pass writing the registration it targets -never learns about it. That combination is [DM0022](/reference/diagnostics#dm0022). +For more information, refer to [Extending](../guide/extending.md#a-source-generator-for-different-framework-attributes).