Defining Dovetails
Defining Dovetail Joints
Section titled “Defining Dovetail Joints”As stated previously joints are nothing more than a class that implements xref:Joinery.Dovetail.Infrastructure.IDovetailJoint.
Declaring a new kind of joint
Section titled “Declaring a new kind of joint”Everything below is about writing joints for kinds that already exist, such as IServiceJoint or ISerilogJoint. A package or application
adds a new kind, a typed extension point other code can contribute to, with a single <DovetailJoint> item in its project file; the build
generates the interface, the delegates and the Apply and DovetailContextBuilder methods for it. The
DovetailJoint reference lists everything the item accepts. The entry points that run the joints come from a
DovetailHost item, which the SDK declares for most projects.
Implementing Multiple Dovetail Joints
Section titled “Implementing Multiple Dovetail Joints”A defined joint can implement as many joints as you want. Configuration, Services, Command Line, Custom Dovetail Joints can all be handled by one joint.
public class MultipleDovetail : IServiceJoint, IConfigurationJoint{ public void Register(IDovetailContext context, IServiceCollection services) { } public void Register(IDovetailContext context, IConfigurationBuilder builder) { }}What Register takes
Section titled “What Register takes”The build generates each joint interface from its <DovetailJoint> declaration, so what Register takes
depends on the kind: the context first, then whatever the kind hands over.
| Kind | Interface | Register |
|---|---|---|
| Setup | ISetupJoint | Register(IDovetailContext context) |
| Service | IServiceJoint | Register(IDovetailContext context, IServiceCollection services) |
| Configuration | IConfigurationJoint | Register(IDovetailContext context, IConfigurationBuilder builder) |
| Logging | ILoggingJoint | Register(IDovetailContext context, ILoggingBuilder builder) |
| Host created | IHostCreatedJoint<THost> | Register(IDovetailContext context, THost host) |
Every kind has an async twin, such as IServiceAsyncJoint, whose Register returns a ValueTask and takes a trailing
CancellationToken cancellationToken = default.
None of Dovetail’s own kinds hands over an IConfiguration. A joint that needs the configuration reads context.Configuration. A kind that
is declared with ProvideConfiguration="true" does get an IConfiguration configuration parameter, after the context; the
DovetailJoint reference lists every parameter a kind can have, in the order they come in.
Dovetail Ordering
Section titled “Dovetail Ordering”By default the order of joints is non-deterministic and not controlled. Dovetails are “left in place” such that once the list of joints is created it is not changed between runs.
Sometimes you want to ensure that one joint runs before another, that can be done using different attributes. Delegate joints cannot be ordered in this manner and must be sorted manually.
This attribute can be used to ensure that your joint is called before another joint. The order is still non-deterministic but Dovetail will ensure that the defined joint is run before the joint type defined by the attribute.
// A before B[BeforeDovetail(typeof(DovetailB))]public class DovetailA{}
public class DovetailB{}This attribute can be used to ensure that your joint is called after another joint. The order is still non-deterministic but Dovetail will ensure that the defined joint is run after the joint type defined by the attribute.
// B before A[AfterDovetail(typeof(DovetailB))]public class DovetailA{}
public class DovetailB{}Custom Attributes
Section titled “Custom Attributes”If you want you can define your own custom attribute for use with joint ordering. Your attribute must implement xref:Joinery.Dovetail.IDovetailDependency and specify the xref:Joinery.Dovetail.DependencyDirection.
Dovetail Host Type
Section titled “Dovetail Host Type”You can define a joint that only runs for a given xref:Joinery.Dovetail.HostType. The HostType can be defined on the
xref:Joinery.Dovetail.DovetailContextBuilder using Set<DovetailHostType>(DovetailHostType.UnitTest). Using the DovetailHostType an assembly
can define different behaviors given the context.
The generated Imports class sets the host type only when the build specifies one. The DovetailHostType MSBuild property
(Live or UnitTest) takes precedence; otherwise test projects (IsTestProject) get UnitTest. Anywhere else the host type
is left unset, and the hosting extensions set it to Live if nothing else has.
A good example of this is designing a system that uses NodaTime. You can have a [LiveJoint] that registers
IClock using the expected calendar system, and another [UnitTestJoint] that registers a FakeClock
with a predefined starting date and time.
Undefined- The default host type, means the joint runs everywhere.Live- This joint applies in a live running applicationUnitTest- This joint applies to unit tests.
This attribute ensures a joint that only runs when the DovetailHostType is Live.
[LiveJoint]public class DovetailA{}This attribute ensures a joint that only runs when the DovetailHostType is UnitTest.
[UnitTestJoint]public class DovetailA{}[DovetailHostType]
Section titled “[DovetailHostType]”The general form of the two attributes above. It takes the host type the joint applies to, for example
[DovetailHostType(DovetailHostType.Live)]. It is also the only way to declare Undefined explicitly.
Which attributes are honoured where
Section titled “Which attributes are honoured where”A joint reaches a context by one of two paths, and both honour all three attributes the same way:
| Path | [LiveJoint] / [UnitTestJoint] | [DovetailHostType] |
|---|---|---|
Runtime reflection: DovetailContextBuilder.AppendJoint(...) / PrependJoint(...) with a joint instance or AppendJoint<T>() | Honoured | Honoured |
Generated metadata: the joints marked [DovetailExport], which the source generator lists in the generated Exports class at build | Honoured | Honoured |
On both paths:
[LiveJoint]and[UnitTestJoint]are read before[DovetailHostType], so[LiveJoint]wins over a[DovetailHostType]on the same joint. Declaring both[LiveJoint]and[UnitTestJoint]on one joint isn’t meaningful, and which of them applies is not specified.- The attributes are inherited. A joint that derives from a
[LiveJoint]class is a live joint too, and the closest declaration wins. - A joint that declares nothing has the
Undefinedhost type and applies everywhere, apart from the test project default below. - A joint applies when its host type is
Undefinedor equals the host type of the context.
The generated path adds one default of its own. In a test project (IsTestProject), an exported joint that declares no host
type is exported as UnitTest. A joint’s own attribute always takes precedence over that default, so a [LiveJoint] in a test
project stays live-only and [DovetailHostType(DovetailHostType.Undefined)] opts a joint out of the default.
The runtime reflection path has no such default.
Dovetail Category
Section titled “Dovetail Category”Every joint belongs to a category, a DovetailCategory, which is part of the joint’s metadata. A joint declares its category with
[DovetailCategory]. The attribute takes a string: DovetailCategory.Application and DovetailCategory.Core are constants for the two
well-known categories, and any other string is a category of its own.
[DovetailExport][DovetailCategory(DovetailCategory.Core)]public class CoreTimeJoint : IServiceJoint{ public void Register(IDovetailContext context, IServiceCollection services) { //... }}A joint reaches a context by one of two paths, and both give it the same category:
- A joint that declares none is in the
Applicationcategory. - A joint that declares
[DovetailCategory(X)]is in categoryX, whether it is added at runtime withAppendJoint(...)/PrependJoint(...)or exported with[DovetailExport]. The generatedExportsclass writes the category into each joint’s metadata as plain C#, the constant forApplicationandCoreand a string literal for any other, so the exported joint above is described with categoryCore. Nothing is reflected on, and a joint that takes constructor parameters (created by a factory) gets its category the same way. - The attribute is inherited. A joint that derives from a
[DovetailCategory]class is in that category too, and the closest declaration wins. A[DovetailCategory]on an interface the joint implements doesn’t count, because looking up an attribute on a type reads its base classes and not its interfaces. - The category is independent of the host type above; a joint can declare both.
Injecting dependencies
Section titled “Injecting dependencies”By default joints are a simple class that can be created using Activator.CreateInstance however the goal of joints was to allow “bootstrap time”
configuration to be provided. The xref:Joinery.Dovetail.DovetailContextBuilder acts as a bag of properties using indexer or Properties.
Any type defined in the context is injectable into any defined joint. This can be used for any number of purposes such as…
- allow consumers to provide configuration to your joint
- allow joints to communicate or share common state
Under the covers the Properties implements IServiceProvider,
and a joint’s constructor parameters are resolved from it.
public class InjectableDovetail : IServiceJoint{ private readonly IInjectData _joint;
public InjectableDovetail(IInjectData joint) => _joint = joint;
public void Register(IDovetailContext context, IServiceCollection services) { //... }}
public class OptionalInjectableDovetail : IServiceJoint{ private readonly IInjectData? _joint;
public OptionalInjectableDovetail(IInjectData? joint = null) => _joint = joint;
public void Register(IDovetailContext context, IServiceCollection services) { if (_joint is { }) { //... } }}How constructor parameters are resolved
Section titled “How constructor parameters are resolved”A joint takes its constructor parameters from the properties of the xref:Joinery.Dovetail.DovetailContextBuilder that holds it, in the same way
whether the joint is added with AppendJoint<T>() or exported with [DovetailExport]:
- By type. Each parameter is looked up under its declared type, exactly: a value stored with
Set<IClock>(clock)satisfies anIClockparameter and not aSystemClockone, and the other way round.?on a reference type changes nothing, soIInjectData?andIInjectDatafind the same value. A nullable value type is looked up as itself: anint?parameter is found underint?, notint. - Optional parameters fall back to their default. A parameter with a default value (
IInjectData? joint = null,int retries = 3) gets that default when the properties hold nothing of its type. - Required parameters must be there. A parameter without a default whose type is missing makes creating the context throw an
InvalidOperationException. For an exported joint its message names the missing type, the parameter and the joint. When the context has two or more joints it orders them with a comparer, and .NET wraps whatever a comparer throws, so the exception you catch is anInvalidOperationExceptionthat says “Failed to compare two elements in the array.” and the one that names the missing type is itsInnerException. With a single joint the exception is not wrapped. - At the point the joint is created, not before. The joint is created the first time something needs it, which is while
xref:Joinery.Dovetail.IDovetailContext is built, so anything you
Seton the builder up to that point is seen, including after the joint was added. Values stored later, for example by another joint while it runs, are too late.ExceptJoint(Type)needs the joints too, so it creates them when it is called. With two or more joints they are created while the context orders them, before their host types are applied, so a missing required value can fail the context even for a joint that would not have applied. - One constructor. An exported joint is created by calling one constructor that is public or internal (
protected internalcounts, the generated code lives in the same assembly). A joint with more than one of those is theDT0001error. A joint that has constructors but none of those, for example a singleprivateorprotectedone, is theDT0017error: it is reported at build time and left out of the exports. Private and protected constructors, and a static constructor, are not counted forDT0001.
For an exported joint the generator writes this lookup into the Exports class as plain C#: the exported method itself takes no parameters, and
each joint with constructor parameters is described by a factory that the context builder runs against its properties. Nothing is reflected on, so it is
trimming and AOT safe. A joint without constructor parameters is created directly. The same factory is used if you read the Joint of that metadata
before any builder has it, with the difference that there are no properties then: optional parameters get their defaults and a required one throws.
(Joints you add with AppendJoint<T>() are created with ActivatorUtilities, which does use reflection.)
// LaunchPad: a joint that tunes itself from options the host may or may not provide.[DovetailExport]public class TimeJoint(PrimitiveOptions? options = null) : IServiceJoint{ private readonly PrimitiveOptions _options = options ?? new PrimitiveOptions();
public void Register(IDovetailContext context, IServiceCollection services) { //... }}With this set on the DovetailContextBuilder, TimeJoint gets this instance; without it, TimeJoint gets null and uses its own default:
builder.Set(new PrimitiveOptions { /* ... */ });