Complete example
This page assembles a full coffee shop from the pieces in the rest of the docs. It shows a realistic container: async equipment, per-order scopes, keyed milk, a decorated payment terminal, and a module. Read it top to bottom for an overview of how the features fit together, and follow the links for the details.
The services
using Awaiten;
// Shared equipment. The machine warms up asynchronously, through an interface of the domain.
public interface IWarmUp { Task WarmUpAsync(CancellationToken ct); }
public sealed class EspressoMachine : IWarmUp
{
public bool IsHot { get; private set; }
public async Task WarmUpAsync(CancellationToken ct)
{
await HeatBoilerAsync(ct);
IsHot = true;
}
public Shot Pull() => new();
}
public sealed class Grinder;
public sealed class Shot;
public sealed class MenuBoard { public static MenuBoard Load() => new(); }
// One order at a time.
public sealed class Order;
public sealed class Tab(Order order);
// Made fresh, sized at request time.
public sealed class Cup
{
public Cup(EspressoMachine machine, [Arg] string size) => Size = size;
public string Size { get; }
}
// Keyed milk.
public interface IMilk;
public sealed class OatMilk : IMilk;
public sealed class WholeMilk : IMilk;
// Several syrups, resolved as a collection.
public interface ISyrup;
public sealed class VanillaSyrup : ISyrup;
public sealed class CaramelSyrup : ISyrup;
// Payment, wrapped with logging.
public interface IPaymentTerminal { Task ChargeAsync(decimal amount); }
public sealed class CardTerminal : IPaymentTerminal
{
public Task ChargeAsync(decimal amount) => Task.CompletedTask;
}
public sealed class LoggingTerminal(IPaymentTerminal inner) : IPaymentTerminal
{
public Task ChargeAsync(decimal amount) => inner.ChargeAsync(amount);
}
The payment module
Group payment wiring so it can be reused and tested on its own.
[Module]
[Singleton<CardTerminal, IPaymentTerminal>]
[Decorate<LoggingTerminal, IPaymentTerminal>]
public static class PaymentModule;
The container
[Container(AsyncSafety = AsyncSafety.SyncAfterInit)]
[AsyncInitializer<IWarmUp>(nameof(IWarmUp.WarmUpAsync))]
[Import(typeof(PaymentModule))]
// equipment
[Singleton<EspressoMachine>]
[Singleton<Grinder>]
[Singleton<MenuBoard>(Instance = nameof(Menu))]
// per order
[Scoped<Order>]
[Scoped<Tab>]
// made to order
[Transient<Cup>]
// keyed milk
[Singleton<OatMilk, IMilk>(Key = "Oat")]
[Singleton<WholeMilk, IMilk>(Key = "Whole")]
// syrups as a set
[Singleton<VanillaSyrup, ISyrup>]
[Singleton<CaramelSyrup, ISyrup>]
public static partial class CoffeeShop
{
private static MenuBoard Menu { get; } = MenuBoard.Load();
}
The [AsyncInitializer<IWarmUp>] convention makes every IWarmUp of the shop async. A Cup needs the async EspressoMachine, so in the strict default it could only be resolved asynchronously. The order below takes its cup through a synchronous Func<string, Cup>, so the container opts into AsyncSafety.SyncAfterInit. Once the machine is warmed at startup, that synchronous resolve returns immediately.
Opening the shop
Create the root and warm the equipment once at startup.
await using CoffeeShop.Root shop = new();
await shop.InitializeAsync(); // the espresso machine heats its boiler
Serving an order
Each order runs in its own scope, so its Order and Tab are isolated and disposed when the order is done.
using (IAwaitenScope order = shop.CreateScope())
{
Tab tab = order.Resolve<Tab>();
Cup cup = order.Resolve<Func<string, Cup>>()("Large");
IEnumerable<ISyrup> syrups = order.Resolve<IEnumerable<ISyrup>>();
IMilk milk = order.Resolve<IReadOnlyDictionary<string, IMilk>>()["Oat"];
IPaymentTerminal terminal = order.Resolve<IPaymentTerminal>(); // LoggingTerminal(CardTerminal)
await terminal.ChargeAsync(4.50m);
} // the order, its tab, and the cup are disposed here
Closing up
Disposing the root disposes the singletons in reverse creation order. Because the root is await using, any async-disposable service is drained through DisposeAsync.
What the compiler checked
None of this wiring is trusted at runtime. Before the app ran, Awaiten verified that every dependency is registered, that no singleton captures a scoped service, and that the decorated terminal has something to decorate. A mistake in any of those would have failed the build. The one check this container relaxes keeps the async machine off synchronous paths: with SyncAfterInit, a synchronous resolve before warm-up blocks instead of failing the build, so warming the machine at startup is up to you.
Where to go next
- Lifetimes to start on the concepts behind it.
- Diagnostics for every check the compiler runs.
- MS.DI bridge to run this under ASP.NET Core.