This document describes the internal design of davianspace_hosting, the
decisions behind key patterns, and guidance for contributors.
- Overview
- Design principles
- Component diagram
- Build pipeline
- Hosted service registry
- Lifecycle management
- Concurrency model
- Error handling strategy
- Environment resolution
- Framework services
- Extension points
- Design decisions
davianspace_hosting provides a unified application model that coordinates
four subsystems:
- Configuration — hierarchical key-value settings from JSON files, environment variables, and command-line arguments.
- Dependency Injection — a compile-time-safe service container with singleton, scoped, and transient lifetimes.
- Logging — structured logging with configurable providers and filtering.
- Lifecycle — deterministic startup/shutdown with event hooks and signal handling.
The design is conceptually equivalent to .NET's
Microsoft.Extensions.Hosting but expressed idiomatically in Dart with
zero reflection, no code generation, and full tree-shaking support.
| Principle | Application |
|---|---|
| Deterministic lifecycle | Strict build → start → run → stop → dispose ordering |
| Fail-fast on startup | Configuration and DI errors surface during build(), not at runtime |
| Graceful degradation on shutdown | Errors during stop are collected; all services get a chance to clean up |
| Zero reflection | No dart:mirrors — compatible with AOT compilation and tree-shaking |
| Separation of abstractions | Interfaces in abstractions/, implementations in core/ |
| Ecosystem composability | Each DavianSpace package works standalone; hosting orchestrates them |
┌─────────────────────────────────────────────────────────────────┐
│ Host (HostImpl) │
│ │
│ ┌──────────────┐ ┌─────────────────────┐ ┌───────────────┐ │
│ │ AsyncLock │ │ ApplicationLifetime │ │ Logger │ │
│ │ (mutex) │ │ (events) │ │ ("Host") │ │
│ └──────────────┘ └─────────────────────┘ └───────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ HostedServiceExecutor │ │
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │
│ │ │Service A│ │Service B│ │Service C│ ... (ordered) │ │
│ │ └─────────┘ └─────────┘ └─────────┘ │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ ServiceProvider (DI Container) │ │
│ │ │ │
│ │ Configuration LoggerFactory ApplicationLifetime │ │
│ │ HostedServiceCollection HostedServiceExecutor │ │
│ │ + user-registered services │ │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
HostBuilderImpl.build() executes a deterministic multi-phase pipeline:
Phase 1: Configuration
┌─────────────────────────────────┐
│ ConfigurationBuilder │
│ + appsettings.json │
│ + appsettings.{Env}.json │
│ + environment variables │
│ + command-line arguments │
│ + user configuration callbacks │
└─────────┬───────────────────────┘
▼
Phase 2: Environment resolution
┌─────────────────────────────────┐
│ DART_ENVIRONMENT env var │
│ → Hosting:Environment config │
│ → default: Production │
└─────────┬───────────────────────┘
▼
Phase 3: Logging
┌─────────────────────────────────┐
│ LoggingBuilder │
│ + user logging callbacks │
│ → LoggerFactory built │
└─────────┬───────────────────────┘
▼
Phase 4: Services
┌─────────────────────────────────┐
│ ServiceCollection │
│ + framework services │
│ + user service callbacks │
│ → ServiceProvider built │
│ → onContainerBuilt hooks fired │
└─────────┬───────────────────────┘
▼
Phase 5: Host assembly
┌─────────────────────────────────┐
│ HostImpl( │
│ configuration, │
│ serviceProvider, │
│ loggerFactory, │
│ ) │
└─────────────────────────────────┘
The DI container's getAll<T>() method uses a singleton cache keyed by
Type. When multiple factories are registered for the same abstract type
(HostedService), they all resolve to the same cached singleton instance.
This makes getAll<HostedService>() unsuitable for collecting multiple
distinct service implementations.
HostedServiceCollection is a dedicated registry of factory functions:
final class HostedServiceCollection {
final List<HostedService Function(ServiceProviderBase)> _factories = [];
void add(HostedService Function(ServiceProviderBase) factory) { ... }
List<HostedService> createAll(ServiceProviderBase provider) { ... }
}The addHostedService() extension method on ServiceCollection uses
onContainerBuilt hooks to populate the collection after the container
is built:
ServiceCollection addHostedService(
HostedService Function(ServiceProviderBase) factory,
) {
// Ensure collection is registered
if (!isRegistered<HostedServiceCollection>()) {
addSingletonFactory<HostedServiceCollection>(...);
}
// Hook into container build event
onContainerBuilt((sp) {
sp.getRequired<HostedServiceCollection>().add(factory);
});
return this;
}This pattern:
- Preserves registration order (important for startup/shutdown sequencing).
- Avoids the singleton cache problem entirely.
- Allows factories to access the full
ServiceProviderfor dependency resolution.
Host.start()
→ HostedServiceExecutor.startAll(services)
→ service1.start()
→ service2.start()
→ ...
→ ApplicationLifetime.notifyStarted()
→ onStarted callbacks fire
Host.run()
→ await shutdownRequested (SIGINT/SIGTERM/programmatic)
Host.stop()
→ ApplicationLifetime.notifyStopping()
→ onStopping callbacks fire
→ HostedServiceExecutor.stopAll()
→ ...serviceN.stop()
→ ...service1.stop() (reverse order)
→ ApplicationLifetime.notifyStopped()
→ onStopped callbacks fire
Host.dispose()
→ ServiceProvider.disposeAsync()
→ LoggerFactory.dispose()
LifetimeEvents guarantees:
- Invoke-once: The
invoke()method is a no-op after the first call. - Late-add fires immediately: If
add()is called afterinvoke(), the callback executes immediately. - Error collection: If callbacks throw, all remaining callbacks still
execute. Errors are aggregated into a single
LifetimeEventException.
HostImpl uses an AsyncLock to serialise start() and stop() calls:
Future<void> start() => _lock.acquire(() async {
if (_started) return; // idempotent
// ... startup logic
});This prevents race conditions when:
start()is called concurrently from multiple isolates.- A shutdown signal arrives while startup is still in progress.
stop()is called multiple times.
HostedServiceExecutor supports two modes:
| Mode | Start | Stop | Use case |
|---|---|---|---|
| Sequential (default) | In registration order | Reverse order | Services with dependencies |
| Concurrent | Future.wait |
Future.wait |
Independent services |
| Phase | Strategy |
|---|---|
| Build | Fail fast — exceptions propagate immediately |
| Service start | Rollback — stop already-started services, then rethrow |
| Service stop | Collect — all services get a chance to stop; errors aggregated |
| Lifetime events | Collect — all callbacks execute; errors aggregated |
| Dispose | Best-effort — errors logged but not thrown |
┌──────────────────────────────────────────────────────────┐
│ Platform.environment['DART_ENVIRONMENT'] │
│ └─ non-null, non-empty? → use it │
│ └─ Configuration['Hosting:Environment'] │
│ └─ non-null, non-empty? → use it │
│ └─ default: 'Production' │
└──────────────────────────────────────────────────────────┘
The environment affects:
- Configuration layering —
appsettings.{Environment}.jsonis loaded. - Logging level —
debugin Development,infootherwise. - DI validation —
ServiceProviderOptions.developmentenables stricter validation in Development mode.
Services automatically registered by HostBuilderImpl:
| Service | Type | Lifetime | Description |
|---|---|---|---|
Configuration |
Configuration |
Singleton | Via addConfiguration() |
LoggerFactory |
LoggerFactory |
Singleton | Built from logging callbacks |
Logger |
Logger |
Transient | Default logger ("Default") |
ApplicationLifetime |
ApplicationLifetime |
Singleton | Lifecycle event manager |
HostedServiceCollection |
HostedServiceCollection |
Singleton | Service factory registry |
HostedServiceExecutor |
HostedServiceExecutor |
Singleton | Start/stop orchestrator |
| Extension point | Mechanism |
|---|---|
| Custom configuration sources | configureConfiguration() callback |
| Service registration | configureServices() callback |
| Logging providers | configureLogging() callback |
| Cross-callback data | useProperty() / HostContext.properties |
| Background work | Implement HostedService |
| Custom host builder | Extend or replace HostBuilderImpl |
| Concurrent execution | Provide custom HostedServiceExecutor |
All concrete types use final class to prevent inheritance. This:
- Makes the API surface explicit and predictable.
- Enables compiler optimisations.
- Follows Dart 3's sealed/final class best practices.
Interfaces use abstract interface class to clearly signal that:
- No implementation is provided.
- Consumers should program against the interface, not the concrete type.
See Hosted service registry above. The DI
container's singleton cache makes getAll unsuitable for collecting
multiple distinct service implementations.
Hosted service factories need access to the built ServiceProvider to
resolve dependencies. The onContainerBuilt hook fires after the
container is fully constructed, making the provider available without
circular dependency issues.
Dart's single-threaded event loop doesn't have traditional mutexes.
AsyncLock coordinates async operations that span multiple event loop
ticks, preventing interleaving of start() and stop() sequences.