Skip to content

Latest commit

 

History

History
332 lines (267 loc) · 14.2 KB

File metadata and controls

332 lines (267 loc) · 14.2 KB

Architecture

This document describes the internal design of davianspace_hosting, the decisions behind key patterns, and guidance for contributors.


Table of contents


Overview

davianspace_hosting provides a unified application model that coordinates four subsystems:

  1. Configuration — hierarchical key-value settings from JSON files, environment variables, and command-line arguments.
  2. Dependency Injection — a compile-time-safe service container with singleton, scoped, and transient lifetimes.
  3. Logging — structured logging with configurable providers and filtering.
  4. 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.

Design principles

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

Component diagram

┌─────────────────────────────────────────────────────────────────┐
│                          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                               │   │
│  └──────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────┘

Build pipeline

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,                │
  │ )                               │
  └─────────────────────────────────┘

Hosted service registry

The problem

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.

The solution

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 ServiceProvider for dependency resolution.

Lifecycle management

Event flow

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()

One-shot event semantics

LifetimeEvents guarantees:

  • Invoke-once: The invoke() method is a no-op after the first call.
  • Late-add fires immediately: If add() is called after invoke(), the callback executes immediately.
  • Error collection: If callbacks throw, all remaining callbacks still execute. Errors are aggregated into a single LifetimeEventException.

Concurrency model

AsyncLock

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.

Service execution modes

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

Error handling strategy

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

Environment resolution

┌──────────────────────────────────────────────────────────┐
│ Platform.environment['DART_ENVIRONMENT']                 │
│  └─ non-null, non-empty? → use it                       │
│     └─ Configuration['Hosting:Environment']              │
│        └─ non-null, non-empty? → use it                  │
│           └─ default: 'Production'                       │
└──────────────────────────────────────────────────────────┘

The environment affects:

  1. Configuration layeringappsettings.{Environment}.json is loaded.
  2. Logging leveldebug in Development, info otherwise.
  3. DI validationServiceProviderOptions.development enables stricter validation in Development mode.

Framework services

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 points

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

Design decisions

Why final class over class?

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.

Why abstract interface class over abstract class?

Interfaces use abstract interface class to clearly signal that:

  • No implementation is provided.
  • Consumers should program against the interface, not the concrete type.

Why not use getAll<HostedService>()?

See Hosted service registry above. The DI container's singleton cache makes getAll unsuitable for collecting multiple distinct service implementations.

Why onContainerBuilt hooks?

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.

Why AsyncLock instead of synchronized?

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.